nleh
This commit is contained in:
parent
4753cacdcc
commit
d7f12db601
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import "../../styles/mapMarkers.css";
|
||||
|
|
@ -34,6 +34,9 @@ export default function MapView({
|
|||
const [timeFilter, setTimeFilter] = useState("RECENT");
|
||||
const [subFilter, setSubFilter] = useState("All");
|
||||
|
||||
const stageRef = useRef(null);
|
||||
const [showSubcats, setShowSubcats] = useState(true);
|
||||
|
||||
const userPosition = useUserPosition(mapRef, hasLastView);
|
||||
|
||||
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = usePostsEngine({
|
||||
|
|
@ -89,6 +92,23 @@ export default function MapView({
|
|||
}, [selectedPost, mapRef]);
|
||||
|
||||
// Websocket for new posts
|
||||
useEffect(() => {
|
||||
const el = stageRef.current;
|
||||
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const e = entries && entries[0];
|
||||
const ok = !!(e && e.isIntersecting && e.intersectionRatio > 0.15);
|
||||
setShowSubcats(ok);
|
||||
},
|
||||
{ threshold: [0, 0.15, 0.3, 0.6] }
|
||||
);
|
||||
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const host =
|
||||
|
|
@ -224,7 +244,7 @@ export default function MapView({
|
|||
|
||||
return (
|
||||
<div className="map-page">
|
||||
<div className="map-stage">
|
||||
<div className="map-stage" ref={stageRef}>
|
||||
<div ref={containerRef} className="map-container" />
|
||||
{status && <div className="map-status">{status}</div>}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,40 @@ function mountCard(container, spec, data, theme) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the marker anchor to be displayed at:
|
||||
* - X: centered (50% width)
|
||||
* - Y: 6/7 of map height (so the popup sits "1/7 from bottom")
|
||||
*/
|
||||
function panMarkerToPreferredAnchor(map, lngLat) {
|
||||
if (!map) return;
|
||||
|
||||
const el = map.getContainer();
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const targetX = rect.width * 0.5;
|
||||
const targetY = rect.height * (6 / 7); // 1/7 from bottom => anchor at 6/7 height
|
||||
|
||||
const p = map.project(lngLat);
|
||||
const dx = targetX - p.x;
|
||||
const dy = targetY - p.y;
|
||||
|
||||
// tiny deltas -> skip
|
||||
if (Math.abs(dx) < 2 && Math.abs(dy) < 2) return;
|
||||
|
||||
// lock close/fetch/rebuild while panning
|
||||
try {
|
||||
const until = Date.now() + 2000;
|
||||
map.__swIgnoreMapClickUntil = until;
|
||||
map.__swIgnoreCloseUntil = until;
|
||||
map.__swIgnoreFetchUntil = until;
|
||||
map.__swIgnoreRebuildUntil = until;
|
||||
} catch {}
|
||||
|
||||
map.panBy([dx, dy], { duration: 650 });
|
||||
}
|
||||
|
||||
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
|
@ -118,6 +152,8 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
return;
|
||||
}
|
||||
|
||||
const lngLat = [lon, lat];
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "post-pin post-pin--compact";
|
||||
root.style.zIndex = "1";
|
||||
|
|
@ -134,7 +170,6 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
root.className = "post-pin post-pin--compact";
|
||||
root.style.zIndex = "1";
|
||||
|
||||
// pointer stays: .post-pin-pointer-small (UNCHANGED)
|
||||
root.innerHTML = `
|
||||
<div class="sw-template-mini-wrap"></div>
|
||||
<div class="post-pin-pointer-small"></div>
|
||||
|
|
@ -155,7 +190,18 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
root.className = "post-pin post-pin--expanded";
|
||||
root.style.zIndex = "999999";
|
||||
|
||||
// pointer stays: .post-card-pointer (UNCHANGED)
|
||||
// 2000ms locks so recenter/fetch/rebuild does not close this expanded popup
|
||||
try {
|
||||
const m = mapRef.current;
|
||||
if (m) {
|
||||
const until = Date.now() + 2000;
|
||||
m.__swIgnoreMapClickUntil = until;
|
||||
m.__swIgnoreCloseUntil = until;
|
||||
m.__swIgnoreFetchUntil = until;
|
||||
m.__swIgnoreRebuildUntil = until;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const headline = escapeHtml(post?.title || "Untitled");
|
||||
|
||||
root.innerHTML = `
|
||||
|
|
@ -212,8 +258,23 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
root.__swUnmount = mountCard(wrap, spec, data, theme);
|
||||
}
|
||||
|
||||
// After render, pan so marker sits at the preferred anchor (1/7 from bottom)
|
||||
requestAnimationFrame(() => {
|
||||
applyOcclusionForExpanded(markersRef, root);
|
||||
try {
|
||||
const m = mapRef.current;
|
||||
if (!m) return;
|
||||
|
||||
// 1) put the marker anchor exactly where we want
|
||||
panMarkerToPreferredAnchor(m, lngLat);
|
||||
|
||||
// 2) re-occlude after the pan finishes
|
||||
m.once("moveend", () => {
|
||||
try { applyOcclusionForExpanded(markersRef, root); } catch {}
|
||||
});
|
||||
|
||||
// also occlude right away
|
||||
applyOcclusionForExpanded(markersRef, root);
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -221,7 +282,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
|||
root.__renderCompact = renderCompact;
|
||||
|
||||
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
|
||||
.setLngLat([lon, lat])
|
||||
.setLngLat(lngLat)
|
||||
.addTo(map);
|
||||
|
||||
markersRef.current.push({ id: post.id, marker, el: root, post });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
import maplibregl from "maplibre-gl";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import CardRenderer from "../Cards/CardRenderer";
|
||||
import { cardTokens } from "../../theme/cardTokens";
|
||||
import { getTemplateSpecForPost, adaptPostToTemplateData } from "./templateSpecs";
|
||||
|
||||
export function clearAllMarkers(markersRef, expandedElRef) {
|
||||
if (markersRef.current) {
|
||||
for (const m of markersRef.current) {
|
||||
try {
|
||||
if (m?.el && m.el.__swUnmount) m.el.__swUnmount();
|
||||
} catch {}
|
||||
try {
|
||||
m.marker.remove();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
markersRef.current = [];
|
||||
if (expandedElRef) expandedElRef.current = null;
|
||||
}
|
||||
|
||||
function rectsOverlap(a, b) {
|
||||
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
|
||||
}
|
||||
|
||||
export function applyOcclusionForExpanded(markersRef, expandedEl) {
|
||||
if (!expandedEl) return;
|
||||
|
||||
const expandedCard = expandedEl.querySelector(".post-card");
|
||||
if (!expandedCard) return;
|
||||
|
||||
const PAD_X = 26;
|
||||
const PAD_TOP = 20;
|
||||
const PAD_BOTTOM = 140;
|
||||
|
||||
const raw = expandedCard.getBoundingClientRect();
|
||||
const expandedRect = {
|
||||
left: raw.left - PAD_X,
|
||||
right: raw.right + PAD_X,
|
||||
top: raw.top - PAD_TOP,
|
||||
bottom: raw.bottom + PAD_BOTTOM,
|
||||
};
|
||||
|
||||
const list = markersRef.current || [];
|
||||
for (const item of list) {
|
||||
const el = item?.el;
|
||||
if (!el || el === expandedEl) continue;
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
const isBelow = r.top >= raw.top;
|
||||
if (!isBelow) {
|
||||
el.style.visibility = "visible";
|
||||
el.style.pointerEvents = "auto";
|
||||
continue;
|
||||
}
|
||||
|
||||
const hide = rectsOverlap(expandedRect, r);
|
||||
el.style.visibility = hide ? "hidden" : "visible";
|
||||
el.style.pointerEvents = hide ? "none" : "auto";
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOcclusion(markersRef) {
|
||||
const list = markersRef.current || [];
|
||||
for (const item of list) {
|
||||
const el = item?.el;
|
||||
if (!el) continue;
|
||||
el.style.visibility = "visible";
|
||||
el.style.pointerEvents = "auto";
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(container, spec, data, theme) {
|
||||
const root = createRoot(container);
|
||||
const tokens = cardTokens?.[theme] || cardTokens.blue;
|
||||
|
||||
root.render(
|
||||
React.createElement(CardRenderer, {
|
||||
spec,
|
||||
data,
|
||||
themeTokens: tokens,
|
||||
onAction: (action, value) => {
|
||||
if (action?.type === "open_url" && value) {
|
||||
try { window.open(value, "_blank"); } catch {}
|
||||
}
|
||||
},
|
||||
className: "",
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
try { root.unmount(); } catch {}
|
||||
};
|
||||
}
|
||||
|
||||
// Put clicked coordinate at ~6/7 of map height (=> 1/7 from bottom)
|
||||
function panMapSoPointHitsTarget(map, lng, lat) {
|
||||
if (!map) return;
|
||||
|
||||
// ✅ ignore map click-close while we animate
|
||||
map.__swIgnoreMapClickUntil = Date.now() + 2200;
|
||||
|
||||
const c = map.getContainer();
|
||||
const w = c.clientWidth || 0;
|
||||
const h = c.clientHeight || 0;
|
||||
if (!w || !h) return;
|
||||
|
||||
const mobile = (typeof window !== "undefined" && window.innerWidth <= 640);
|
||||
|
||||
const targetX = w * 0.50;
|
||||
const targetY = mobile ? h * (6 / 7) : h * 0.80;
|
||||
|
||||
const p = map.project([lng, lat]);
|
||||
const dx = p.x - targetX;
|
||||
const dy = p.y - targetY;
|
||||
|
||||
const centerPx = { x: w / 2, y: h / 2 };
|
||||
const newCenter = map.unproject([centerPx.x + dx, centerPx.y + dy]);
|
||||
|
||||
try {
|
||||
map.easeTo({
|
||||
center: newCenter,
|
||||
duration: 420,
|
||||
easing: (t) => t * (2 - t),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const lat =
|
||||
typeof post.lat === "number"
|
||||
? post.lat
|
||||
: typeof post.latitude === "number"
|
||||
? post.latitude
|
||||
: null;
|
||||
|
||||
const lon =
|
||||
typeof post.lon === "number"
|
||||
? post.lon
|
||||
: typeof post.lng === "number"
|
||||
? post.lng
|
||||
: null;
|
||||
|
||||
if (lat == null || lon == null) {
|
||||
console.warn("post sans coordonnée:", post);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "post-pin post-pin--compact";
|
||||
root.style.zIndex = "1";
|
||||
|
||||
function unmountIfAny() {
|
||||
if (root.__swUnmount) {
|
||||
try { root.__swUnmount(); } catch {}
|
||||
root.__swUnmount = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompact() {
|
||||
unmountIfAny();
|
||||
root.className = "post-pin post-pin--compact";
|
||||
root.style.zIndex = "1";
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="sw-template-mini-wrap"></div>
|
||||
<div class="post-pin-pointer-small"></div>
|
||||
`;
|
||||
|
||||
const wrap = root.querySelector(".sw-template-mini-wrap");
|
||||
if (wrap) {
|
||||
const spec = getTemplateSpecForPost(post, "mini");
|
||||
const data = adaptPostToTemplateData(post);
|
||||
root.__swUnmount = mountCard(wrap, spec, data, theme);
|
||||
}
|
||||
|
||||
clearOcclusion(markersRef);
|
||||
}
|
||||
|
||||
function renderExpanded() {
|
||||
unmountIfAny();
|
||||
root.className = "post-pin post-pin--expanded";
|
||||
root.style.zIndex = "999999";
|
||||
|
||||
const headline = escapeHtml(post?.title || "Untitled");
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="post-card sw-expanded-shell">
|
||||
<div class="sw-expanded-top">
|
||||
<div class="sw-expanded-left">
|
||||
<div class="sw-template-full-wrap"></div>
|
||||
</div>
|
||||
|
||||
<div class="sw-expanded-right">
|
||||
<div class="sw-watch-title">Watching</div>
|
||||
<div class="sw-watch-list">
|
||||
<div class="sw-watch-item">☑ Julia ★<div class="sw-watch-msg">— I see it…</div></div>
|
||||
<div class="sw-watch-item">☑ Kim / CNN<div class="sw-watch-msg">— Blah blah blah</div></div>
|
||||
<div class="sw-watch-item">☑ Yan ★<div class="sw-watch-msg">— Watch this!!</div></div>
|
||||
</div>
|
||||
<button class="sw-add-feed-btn" type="button">ADD TO FEED</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sw-news-generated">
|
||||
<div class="sw-news-title">${headline}</div>
|
||||
<div class="sw-news-sub">The news here (generated)</div>
|
||||
</div>
|
||||
|
||||
<div class="post-card-footer sw-actions-row">
|
||||
<button class="post-card-btn">Contact</button>
|
||||
<button class="post-card-btn">Like</button>
|
||||
<button class="post-card-btn">Share</button>
|
||||
<button class="post-card-btn">Fix</button>
|
||||
</div>
|
||||
|
||||
<div class="post-card-comments sw-livechat">
|
||||
<div class="sw-livechat-title">Live chat / comments</div>
|
||||
<div class="sw-livechat-body">
|
||||
<div class="sw-chat-line">— …</div>
|
||||
<div class="sw-chat-line">— what the f***?</div>
|
||||
<div class="sw-chat-line">— nice…</div>
|
||||
<div class="sw-chat-line">— my eyes!!</div>
|
||||
</div>
|
||||
<div class="sw-livechat-inputrow">
|
||||
<input class="sw-livechat-input" placeholder="Write a comment…" />
|
||||
<button class="sw-livechat-post" type="button">POST</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-card-pointer"></div>
|
||||
`;
|
||||
|
||||
const wrap = root.querySelector(".sw-template-full-wrap");
|
||||
if (wrap) {
|
||||
const spec = getTemplateSpecForPost(post, "full");
|
||||
const data = adaptPostToTemplateData(post);
|
||||
root.__swUnmount = mountCard(wrap, spec, data, theme);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
panMapSoPointHitsTarget(mapRef.current, lon, lat);
|
||||
applyOcclusionForExpanded(markersRef, root);
|
||||
});
|
||||
}
|
||||
|
||||
renderCompact();
|
||||
root.__renderCompact = renderCompact;
|
||||
|
||||
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
|
||||
.setLngLat([lon, lat])
|
||||
.addTo(map);
|
||||
|
||||
markersRef.current.push({ id: post.id, marker, el: root, post });
|
||||
|
||||
root.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
|
||||
// ✅ ignore the map click-close that often fires after marker click on mobile
|
||||
const m = mapRef.current;
|
||||
if (m) m.__swIgnoreMapClickUntil = Date.now() + 2000;
|
||||
|
||||
if (expandedElRef.current && expandedElRef.current !== root) {
|
||||
if (expandedElRef.current.__renderCompact) expandedElRef.current.__renderCompact();
|
||||
expandedElRef.current = null;
|
||||
}
|
||||
|
||||
const isExpanded = root.classList.contains("post-pin--expanded");
|
||||
if (isExpanded) {
|
||||
renderCompact();
|
||||
expandedElRef.current = null;
|
||||
} else {
|
||||
do {
|
||||
eval { my = q{}; };
|
||||
} while(0);
|
||||
do {
|
||||
try { const map = mapRef.current; if (map) { map.__swIgnoreCloseUntil = Date.now() + 2000; map.__swIgnoreMapClickUntil = Date.now() + 2000; } } catch {}
|
||||
} while(0);
|
||||
renderExpanded();
|
||||
expandedElRef.current = root;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
|
@ -1,58 +1,34 @@
|
|||
/**
|
||||
* Frontend-only template specs (no DB changes).
|
||||
* Keep pointers as-is:
|
||||
* Frontend-only template specs.
|
||||
* Pointers stay as-is:
|
||||
* - compact triangle: .post-pin-pointer-small
|
||||
* - expanded triangle: .post-card-pointer
|
||||
*/
|
||||
|
||||
export const TEMPLATE_SPECS = {
|
||||
news: {
|
||||
mini_spec: {
|
||||
size: { w: 280, h: 110 },
|
||||
size: { w: 240, h: 96 }, /* ✅ smaller to avoid layout push */
|
||||
background: { type: "gradient", value: "newsBlue" },
|
||||
radius: 18,
|
||||
layers: [
|
||||
{ id: "badge", type: "chip", x: 14, y: 12, text: "NEWS", style: "chip.news" },
|
||||
{ id: "title", type: "text", x: 14, y: 40, w: 252, h: 44, bind: "data.headline", style: "text.title", maxLines: 2 },
|
||||
{ id: "meta", type: "text", x: 14, y: 88, w: 252, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 }
|
||||
{ id: "badge", type: "chip", x: 12, y: 10, text: "NEWS", style: "chip.news" },
|
||||
{ id: "title", type: "text", x: 12, y: 34, w: 216, h: 42, bind: "data.headline", style: "text.title", maxLines: 2 },
|
||||
{ id: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 }
|
||||
],
|
||||
},
|
||||
|
||||
/* Desktop/tablet full */
|
||||
full_spec: {
|
||||
size: { w: 360, h: 480 },
|
||||
size: { w: 360, h: 520 },
|
||||
background: { type: "solid", value: "surface" },
|
||||
radius: 22,
|
||||
layers: [
|
||||
{ id: "hero", type: "image", x: 0, y: 0, w: 360, h: 180, bind: "data.image" },
|
||||
{ id: "hero", type: "image", x: 0, y: 0, w: 360, h: 220, bind: "data.image" },
|
||||
{ id: "badge", type: "chip", x: 16, y: 16, text: "NEWS", style: "chip.news" },
|
||||
{ id: "title", type: "text", x: 16, y: 200, w: 328, h: 70, bind: "data.headline", style: "text.h1", maxLines: 3 },
|
||||
{ id: "summary", type: "text", x: 16, y: 272, w: 328, h: 110, bind: "data.summary", style: "text.body", maxLines: 4 },
|
||||
{ id: "title", type: "text", x: 16, y: 240, w: 328, h: 78, bind: "data.headline", style: "text.h1", maxLines: 3 },
|
||||
{ id: "summary", type: "text", x: 16, y: 325, w: 328, h: 110, bind: "data.summary", style: "text.body", maxLines: 5 },
|
||||
{
|
||||
id: "cta",
|
||||
type: "button",
|
||||
x: 16, y: 402, w: 328, h: 48,
|
||||
text: "Open source",
|
||||
style: "button.primary",
|
||||
action: { type: "open_url", bind: "data.url" }
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
/* ✅ Mobile full (shorter) */
|
||||
full_spec_mobile: {
|
||||
size: { w: 320, h: 380 },
|
||||
background: { type: "solid", value: "surface" },
|
||||
radius: 20,
|
||||
layers: [
|
||||
{ id: "hero", type: "image", x: 0, y: 0, w: 320, h: 120, bind: "data.image" },
|
||||
{ id: "badge", type: "chip", x: 14, y: 12, text: "NEWS", style: "chip.news" },
|
||||
{ id: "title", type: "text", x: 14, y: 140, w: 292, h: 58, bind: "data.headline", style: "text.h1", maxLines: 2 },
|
||||
{ id: "summary", type: "text", x: 14, y: 202, w: 292, h: 80, bind: "data.summary", style: "text.body", maxLines: 3 },
|
||||
{
|
||||
id: "cta",
|
||||
type: "button",
|
||||
x: 14, y: 300, w: 292, h: 46,
|
||||
x: 16, y: 450, w: 328, h: 48,
|
||||
text: "Open source",
|
||||
style: "button.primary",
|
||||
action: { type: "open_url", bind: "data.url" }
|
||||
|
|
@ -78,17 +54,7 @@ export function getTemplateKeyForPost(post) {
|
|||
export function getTemplateSpecForPost(post, variant /* "mini"|"full" */) {
|
||||
const key = getTemplateKeyForPost(post);
|
||||
const t = TEMPLATE_SPECS[key] || TEMPLATE_SPECS.news;
|
||||
|
||||
if (variant === "full") {
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.innerWidth <= 640 && t.full_spec_mobile) {
|
||||
return t.full_spec_mobile;
|
||||
}
|
||||
} catch {}
|
||||
return t.full_spec;
|
||||
}
|
||||
|
||||
return t.mini_spec;
|
||||
return variant === "full" ? t.full_spec : t.mini_spec;
|
||||
}
|
||||
|
||||
export function adaptPostToTemplateData(post) {
|
||||
|
|
|
|||
|
|
@ -88,8 +88,22 @@ export function useMapCore(theme) {
|
|||
map.on("move", reOcclude);
|
||||
map.on("zoom", reOcclude);
|
||||
|
||||
map.on("click", () => closeExpandedIfAny());
|
||||
map.on("dragstart", () => closeExpandedIfAny());
|
||||
// IMPORTANT: ignore close while we are auto-panning to keep popup visible
|
||||
map.on("click", () => {
|
||||
try {
|
||||
const until = map.__swIgnoreMapClickUntil || 0;
|
||||
if (until && Date.now() < until) return;
|
||||
} catch {}
|
||||
closeExpandedIfAny();
|
||||
});
|
||||
|
||||
map.on("dragstart", () => {
|
||||
try {
|
||||
const until = map.__swIgnoreCloseUntil || 0;
|
||||
if (until && Date.now() < until) return;
|
||||
} catch {}
|
||||
closeExpandedIfAny();
|
||||
});
|
||||
|
||||
map.on("load", () => setViewParams(getViewFromMap(map)));
|
||||
|
||||
|
|
@ -98,7 +112,10 @@ export function useMapCore(theme) {
|
|||
setViewParams(vp);
|
||||
try {
|
||||
const center = vp.center;
|
||||
localStorage.setItem(LAST_VIEW_KEY, JSON.stringify({ lat: center[1], lon: center[0], zoom: map.getZoom() }));
|
||||
localStorage.setItem(
|
||||
LAST_VIEW_KEY,
|
||||
JSON.stringify({ lat: center[1], lon: center[0], zoom: map.getZoom() })
|
||||
);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function usePostsEngine({
|
|||
}) {
|
||||
const allPostsRef = useRef([]);
|
||||
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
|
||||
const delayedFetchRef = useRef(null);
|
||||
|
||||
const [status, setStatus] = useState("Loading posts...");
|
||||
const [visiblePosts, setVisiblePosts] = useState([]);
|
||||
|
|
@ -26,6 +27,12 @@ export function usePostsEngine({
|
|||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
// If expanded is open and we are inside ignore window, do not rebuild (it closes the popup)
|
||||
try {
|
||||
const until = map.__swIgnoreRebuildUntil || 0;
|
||||
if (expandedElRef?.current && until && Date.now() < until) return;
|
||||
} catch {}
|
||||
|
||||
clearAllMarkers(markersRef, expandedElRef);
|
||||
|
||||
const posts = allPostsRef.current || [];
|
||||
|
|
@ -61,6 +68,19 @@ export function usePostsEngine({
|
|||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
const mapObj = mapRef.current;
|
||||
|
||||
// Delay fetch while popup is open (prevents marker clear/rebuild)
|
||||
try {
|
||||
const until = mapObj?.__swIgnoreFetchUntil || 0;
|
||||
if (until && Date.now() < until) {
|
||||
const wait = Math.min(4000, until - Date.now());
|
||||
if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current);
|
||||
delayedFetchRef.current = setTimeout(load, wait + 25);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const [lng, lat] = viewParams.center;
|
||||
const radiusKm = viewParams.radiusKm || 750;
|
||||
const filterKey = `${mainFilter}|${subFilter}`;
|
||||
|
|
@ -101,11 +121,9 @@ export function usePostsEngine({
|
|||
|
||||
const existing = allPostsRef.current || [];
|
||||
const byId = new Map();
|
||||
|
||||
for (const p of existing) {
|
||||
if (p && typeof p.id !== "undefined") byId.set(p.id, p);
|
||||
}
|
||||
|
||||
if (Array.isArray(newPosts)) {
|
||||
for (const p of newPosts) {
|
||||
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) byId.set(p.id, p);
|
||||
|
|
@ -113,7 +131,6 @@ export function usePostsEngine({
|
|||
}
|
||||
|
||||
allPostsRef.current = Array.from(byId.values());
|
||||
|
||||
lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
|
||||
|
||||
rebuildMarkersForFilters(timeFilter);
|
||||
|
|
@ -131,6 +148,10 @@ export function usePostsEngine({
|
|||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (delayedFetchRef.current) {
|
||||
try { clearTimeout(delayedFetchRef.current); } catch {}
|
||||
delayedFetchRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [viewParams, mainFilter, subFilter, mapRef, rebuildMarkersForFilters, timeFilter]);
|
||||
|
||||
|
|
|
|||
|
|
@ -33,3 +33,22 @@
|
|||
.sw-bottom-label { font-size:.65rem; color:#e5e7eb; text-shadow:0 1px 2px rgba(0,0,0,.9); }
|
||||
.sw-bottom-active .sw-bottom-circle { background: rgba(37, 99, 235, 0.82); border-color:#60a5fa; }
|
||||
.sw-bottom-active .sw-bottom-label { color:#bfdbfe; font-weight:600; }
|
||||
|
||||
/* Floating subcategory dock (always above Sociowall, not coverable) */
|
||||
.sw-subcats-float{
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(14px + var(--sw-wall-overlap, 0px));
|
||||
z-index: 300;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sw-subcats-float .sw-bottom-row{
|
||||
pointer-events: auto;
|
||||
max-width: min(980px, 96vw);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.sw-subcats-float .sw-bottom-row::-webkit-scrollbar{ height: 0; }
|
||||
|
|
|
|||
|
|
@ -1,20 +1,14 @@
|
|||
.app-root{ min-height:100vh; display:flex; flex-direction:column; }
|
||||
|
||||
/* allow scrolling under the map */
|
||||
.main-shell{ flex: 0 0 auto; display:block; padding:0; }
|
||||
/* allow full page scroll */
|
||||
.main-shell{ flex: 1 0 auto; display:block; padding:0; }
|
||||
|
||||
/* MAP HEIGHT (make map taller so Sociowall/Chat sit lower) */
|
||||
/* DO NOT clip MapView (it contains map + below-stage) */
|
||||
.map-shell{
|
||||
position:relative;
|
||||
width:100%;
|
||||
height:68vh;
|
||||
border-radius:0;
|
||||
overflow:hidden;
|
||||
height:auto;
|
||||
overflow:visible;
|
||||
border:none;
|
||||
background:#020617;
|
||||
}
|
||||
|
||||
/* Phones: taller map => wall+chat lower */
|
||||
@media (max-width: 640px){
|
||||
.map-shell{ height:76vh; }
|
||||
background:transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,49 @@
|
|||
.map-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
:root{
|
||||
--sw-below-overlap: 40px; /* wall overlaps map a bit */
|
||||
}
|
||||
|
||||
/* Map stage should fill the map-shell height (no competing vh/clamp here) */
|
||||
.map-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.map-page{ display:flex; flex-direction:column; width:100%; }
|
||||
|
||||
/* Map stage first */
|
||||
.map-stage{
|
||||
position:relative;
|
||||
width:100%;
|
||||
height: clamp(520px, 72vh, 860px);
|
||||
}
|
||||
|
||||
.map-container,
|
||||
.maplibre-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Landscape: vh is small, reduce min height */
|
||||
@media (orientation: landscape){
|
||||
:root{ --sw-below-overlap: 22px; }
|
||||
.map-stage{ height: clamp(360px, 62vh, 720px); }
|
||||
}
|
||||
|
||||
.map-status {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 0.5rem;
|
||||
z-index: 30;
|
||||
.map-container, .maplibre-container{
|
||||
position:absolute; inset:0; width:100%; height:100%;
|
||||
}
|
||||
|
||||
/* Below map area */
|
||||
.below-stage {
|
||||
padding: 0.25rem 0.7rem 1rem;
|
||||
.map-status{
|
||||
position:absolute; left:.5rem; bottom:.5rem;
|
||||
font-size:.7rem; padding:.2rem .4rem;
|
||||
background:rgba(0,0,0,.6); border-radius:.5rem;
|
||||
z-index:30;
|
||||
}
|
||||
|
||||
.below-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: stretch;
|
||||
/* BELOW MAP (scroll area) – pulled up slightly over map */
|
||||
.below-stage{
|
||||
margin-top: calc(var(--sw-below-overlap) * -1);
|
||||
position:relative;
|
||||
z-index:25;
|
||||
padding: .25rem .7rem 1.0rem;
|
||||
}
|
||||
|
||||
/* Mobile: stack */
|
||||
@media (max-width: 640px) {
|
||||
.below-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.below-row{
|
||||
display:flex;
|
||||
gap:.6rem;
|
||||
align-items:stretch;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
/* keep side-by-side on most phones; stack only ultra-small */
|
||||
@media (max-width: 420px){
|
||||
.below-row{ flex-direction:column; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.map-overlay { position:absolute; pointer-events:none; z-index:20; }
|
||||
.map-overlay { position:absolute; pointer-events:none; z-index:40; }
|
||||
.map-overlay * { pointer-events:auto; }
|
||||
|
||||
.map-overlay-top {
|
||||
|
|
@ -15,7 +15,8 @@
|
|||
display:flex; flex-direction:column; gap:.5rem;
|
||||
}
|
||||
|
||||
.map-overlay-bottom { bottom: 3.2rem; left:50%; transform:translateX(-50%); display:flex; gap:.4rem; }
|
||||
/* ✅ Sub-categories row MUST stay above the Sociowall overlap */
|
||||
.map-overlay-bottom { bottom: calc(3.2rem + var(--sw-below-overlap, 0px) + env(safe-area-inset-bottom)); left:50%; transform:translateX(-50%); display:flex; gap:.4rem; }
|
||||
|
||||
.map-overlay-myloc { right:.7rem; bottom:6rem; }
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@
|
|||
display:flex;
|
||||
gap:.6rem;
|
||||
align-items:stretch;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.sociowall-panel {
|
||||
flex: 1.8;
|
||||
.sociowall-panel{
|
||||
flex: 1 1 auto;
|
||||
min-width:0; /* IMPORTANT: prevents pushing chat */
|
||||
min-height: 160px;
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border-radius: 14px;
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
flex-direction:column;
|
||||
}
|
||||
|
||||
.sociowall-header {
|
||||
.sociowall-header{
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
|
|
@ -27,39 +28,45 @@
|
|||
margin-bottom:.25rem;
|
||||
}
|
||||
|
||||
.post-list { flex: 1; overflow-y: auto; padding-right: .15rem; max-height: 42vh; }
|
||||
.post-list-header { display:flex; align-items:center; gap:.5rem; margin-bottom:.25rem; font-size:.7rem; color:#cbd5f5; }
|
||||
.km-filter input[type="range"] { width:160px; }
|
||||
/* list scroll inside wall */
|
||||
.post-list{
|
||||
flex:1;
|
||||
overflow-y:auto;
|
||||
padding-right:.15rem;
|
||||
max-height: 44vh;
|
||||
}
|
||||
|
||||
.status-text { font-size:.72rem; opacity:.85; margin:.15rem 0; }
|
||||
.status-error { color:#fecaca; }
|
||||
|
||||
/* list items compact */
|
||||
.post-card {
|
||||
/* prevent long content from widening layout */
|
||||
.post-card{
|
||||
overflow:hidden;
|
||||
min-width:0;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(55, 65, 81, 0.9);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
padding:.3rem .45rem;
|
||||
margin-bottom:.22rem;
|
||||
font-size:.76rem;
|
||||
cursor:pointer;
|
||||
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, transform .1s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
border-color: rgba(96, 165, 250, 0.9);
|
||||
box-shadow: 0 0 12px rgba(56,189,248,.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.post-card.selected {
|
||||
background: linear-gradient(135deg, #1d4ed8, #0ea5e9);
|
||||
border-color: #bfdbfe;
|
||||
box-shadow: 0 0 16px rgba(56,189,248,.6), 0 10px 22px rgba(15,23,42,.98);
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
width: 170px;
|
||||
min-width: 170px;
|
||||
/* SCALE the fixed 280px template so it never forces width */
|
||||
.post-card .sw-wall-mini{
|
||||
--sw-wall-scale: 0.86;
|
||||
transform: scale(var(--sw-wall-scale));
|
||||
transform-origin: left top;
|
||||
width: calc(100% / var(--sw-wall-scale));
|
||||
}
|
||||
|
||||
.post-list-header{ display:flex; align-items:center; gap:.5rem; margin-bottom:.25rem; font-size:.7rem; color:#cbd5f5; }
|
||||
.km-filter input[type="range"]{ width:160px; }
|
||||
|
||||
.status-text{ font-size:.72rem; opacity:.85; margin:.15rem 0; }
|
||||
.status-error{ color:#fecaca; }
|
||||
|
||||
.chat-panel{
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
max-width: 220px;
|
||||
min-height: 160px;
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.9);
|
||||
|
|
@ -67,13 +74,10 @@
|
|||
padding:.4rem .45rem;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
align-self: flex-start;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
.chat-header{
|
||||
font-size:.78rem;
|
||||
font-weight:700;
|
||||
letter-spacing:.06em;
|
||||
|
|
@ -81,23 +85,22 @@
|
|||
color:#bfdbfe;
|
||||
margin-bottom:.2rem;
|
||||
}
|
||||
.chat-users { list-style:none; padding:0; margin:0; font-size:.76rem; }
|
||||
.chat-users li { display:flex; align-items:center; gap:.3rem; padding:.18rem .1rem; }
|
||||
.chat-users li::before { content:"👤"; font-size:.72rem; opacity:.9; }
|
||||
|
||||
/* ✅ MOBILE: keep chat BESIDE sociowall (no stacking) */
|
||||
@media (max-width: 640px){
|
||||
.below-row{ flex-direction: row; }
|
||||
.sociowall-panel{ flex: 2.2; }
|
||||
.chat-panel{
|
||||
width: 34vw;
|
||||
min-width: 140px;
|
||||
max-width: 180px;
|
||||
position: static;
|
||||
top: auto;
|
||||
}
|
||||
.post-list{ max-height: 36vh; }
|
||||
.chat-users{
|
||||
list-style:none;
|
||||
padding:0; margin:0;
|
||||
font-size:.76rem;
|
||||
overflow:auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-height: 44vh;
|
||||
}
|
||||
|
||||
/* Guard: never render Sociowall overlay on top of the map */
|
||||
.map-overlay-wall{ display:none !important; }
|
||||
.chat-users li{ display:flex; align-items:center; gap:.3rem; padding:.18rem .1rem; }
|
||||
.chat-users li::before{ content:"👤"; font-size:.72rem; opacity:.9; }
|
||||
|
||||
/* stack only very small */
|
||||
@media (max-width: 420px){
|
||||
.below-row{ flex-direction: column; }
|
||||
.chat-panel{ width:100%; min-width:0; max-width:none; }
|
||||
.post-list{ max-height: 46vh; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue