This commit is contained in:
Your Name 2025-12-18 22:19:15 -05:00
parent 4753cacdcc
commit d7f12db601
11 changed files with 556 additions and 158 deletions

View File

@ -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 "maplibre-gl/dist/maplibre-gl.css";
import "../../styles/mapMarkers.css"; import "../../styles/mapMarkers.css";
@ -34,6 +34,9 @@ export default function MapView({
const [timeFilter, setTimeFilter] = useState("RECENT"); const [timeFilter, setTimeFilter] = useState("RECENT");
const [subFilter, setSubFilter] = useState("All"); const [subFilter, setSubFilter] = useState("All");
const stageRef = useRef(null);
const [showSubcats, setShowSubcats] = useState(true);
const userPosition = useUserPosition(mapRef, hasLastView); const userPosition = useUserPosition(mapRef, hasLastView);
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = usePostsEngine({ const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = usePostsEngine({
@ -89,6 +92,23 @@ export default function MapView({
}, [selectedPost, mapRef]); }, [selectedPost, mapRef]);
// Websocket for new posts // 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(() => { useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws"; const proto = window.location.protocol === "https:" ? "wss" : "ws";
const host = const host =
@ -224,7 +244,7 @@ export default function MapView({
return ( return (
<div className="map-page"> <div className="map-page">
<div className="map-stage"> <div className="map-stage" ref={stageRef}>
<div ref={containerRef} className="map-container" /> <div ref={containerRef} className="map-container" />
{status && <div className="map-status">{status}</div>} {status && <div className="map-status">{status}</div>}

View File

@ -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") { export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme = "blue") {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
@ -118,6 +152,8 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
return; return;
} }
const lngLat = [lon, lat];
const root = document.createElement("div"); const root = document.createElement("div");
root.className = "post-pin post-pin--compact"; root.className = "post-pin post-pin--compact";
root.style.zIndex = "1"; root.style.zIndex = "1";
@ -134,7 +170,6 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
root.className = "post-pin post-pin--compact"; root.className = "post-pin post-pin--compact";
root.style.zIndex = "1"; root.style.zIndex = "1";
// pointer stays: .post-pin-pointer-small (UNCHANGED)
root.innerHTML = ` root.innerHTML = `
<div class="sw-template-mini-wrap"></div> <div class="sw-template-mini-wrap"></div>
<div class="post-pin-pointer-small"></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.className = "post-pin post-pin--expanded";
root.style.zIndex = "999999"; 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"); const headline = escapeHtml(post?.title || "Untitled");
root.innerHTML = ` root.innerHTML = `
@ -212,8 +258,23 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
root.__swUnmount = mountCard(wrap, spec, data, theme); root.__swUnmount = mountCard(wrap, spec, data, theme);
} }
// After render, pan so marker sits at the preferred anchor (1/7 from bottom)
requestAnimationFrame(() => { requestAnimationFrame(() => {
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); applyOcclusionForExpanded(markersRef, root);
} catch {}
}); });
} }
@ -221,7 +282,7 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
root.__renderCompact = renderCompact; root.__renderCompact = renderCompact;
const marker = new maplibregl.Marker({ element: root, anchor: "bottom" }) const marker = new maplibregl.Marker({ element: root, anchor: "bottom" })
.setLngLat([lon, lat]) .setLngLat(lngLat)
.addTo(map); .addTo(map);
markersRef.current.push({ id: post.id, marker, el: root, post }); markersRef.current.push({ id: post.id, marker, el: root, post });

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@ -1,58 +1,34 @@
/** /**
* Frontend-only template specs (no DB changes). * Frontend-only template specs.
* Keep pointers as-is: * Pointers stay as-is:
* - compact triangle: .post-pin-pointer-small * - compact triangle: .post-pin-pointer-small
* - expanded triangle: .post-card-pointer * - expanded triangle: .post-card-pointer
*/ */
export const TEMPLATE_SPECS = { export const TEMPLATE_SPECS = {
news: { news: {
mini_spec: { mini_spec: {
size: { w: 280, h: 110 }, size: { w: 240, h: 96 }, /* ✅ smaller to avoid layout push */
background: { type: "gradient", value: "newsBlue" }, background: { type: "gradient", value: "newsBlue" },
radius: 18, radius: 18,
layers: [ layers: [
{ id: "badge", type: "chip", x: 14, y: 12, text: "NEWS", style: "chip.news" }, { id: "badge", type: "chip", x: 12, y: 10, 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: "title", type: "text", x: 12, y: 34, w: 216, h: 42, 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: "meta", type: "text", x: 12, y: 76, w: 216, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 }
], ],
}, },
/* Desktop/tablet full */
full_spec: { full_spec: {
size: { w: 360, h: 480 }, size: { w: 360, h: 520 },
background: { type: "solid", value: "surface" }, background: { type: "solid", value: "surface" },
radius: 22, radius: 22,
layers: [ 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: "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: "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: 272, w: 328, h: 110, bind: "data.summary", style: "text.body", maxLines: 4 }, { id: "summary", type: "text", x: 16, y: 325, w: 328, h: 110, bind: "data.summary", style: "text.body", maxLines: 5 },
{ {
id: "cta", id: "cta",
type: "button", type: "button",
x: 16, y: 402, w: 328, h: 48, x: 16, y: 450, 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,
text: "Open source", text: "Open source",
style: "button.primary", style: "button.primary",
action: { type: "open_url", bind: "data.url" } action: { type: "open_url", bind: "data.url" }
@ -78,17 +54,7 @@ export function getTemplateKeyForPost(post) {
export function getTemplateSpecForPost(post, variant /* "mini"|"full" */) { export function getTemplateSpecForPost(post, variant /* "mini"|"full" */) {
const key = getTemplateKeyForPost(post); const key = getTemplateKeyForPost(post);
const t = TEMPLATE_SPECS[key] || TEMPLATE_SPECS.news; const t = TEMPLATE_SPECS[key] || TEMPLATE_SPECS.news;
return variant === "full" ? t.full_spec : t.mini_spec;
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;
} }
export function adaptPostToTemplateData(post) { export function adaptPostToTemplateData(post) {

View File

@ -88,8 +88,22 @@ export function useMapCore(theme) {
map.on("move", reOcclude); map.on("move", reOcclude);
map.on("zoom", reOcclude); map.on("zoom", reOcclude);
map.on("click", () => closeExpandedIfAny()); // IMPORTANT: ignore close while we are auto-panning to keep popup visible
map.on("dragstart", () => closeExpandedIfAny()); 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))); map.on("load", () => setViewParams(getViewFromMap(map)));
@ -98,7 +112,10 @@ export function useMapCore(theme) {
setViewParams(vp); setViewParams(vp);
try { try {
const center = vp.center; 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 {} } catch {}
}); });

View File

@ -15,6 +15,7 @@ export function usePostsEngine({
}) { }) {
const allPostsRef = useRef([]); const allPostsRef = useRef([]);
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" }); const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
const delayedFetchRef = useRef(null);
const [status, setStatus] = useState("Loading posts..."); const [status, setStatus] = useState("Loading posts...");
const [visiblePosts, setVisiblePosts] = useState([]); const [visiblePosts, setVisiblePosts] = useState([]);
@ -26,6 +27,12 @@ export function usePostsEngine({
const map = mapRef.current; const map = mapRef.current;
if (!map) return; 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); clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || []; const posts = allPostsRef.current || [];
@ -61,6 +68,19 @@ export function usePostsEngine({
let cancelled = false; let cancelled = false;
async function load() { 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 [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 750; const radiusKm = viewParams.radiusKm || 750;
const filterKey = `${mainFilter}|${subFilter}`; const filterKey = `${mainFilter}|${subFilter}`;
@ -101,11 +121,9 @@ export function usePostsEngine({
const existing = allPostsRef.current || []; const existing = allPostsRef.current || [];
const byId = new Map(); const byId = new Map();
for (const p of existing) { for (const p of existing) {
if (p && typeof p.id !== "undefined") byId.set(p.id, p); if (p && typeof p.id !== "undefined") byId.set(p.id, p);
} }
if (Array.isArray(newPosts)) { if (Array.isArray(newPosts)) {
for (const p of newPosts) { for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) byId.set(p.id, p); 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()); allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey }; lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
rebuildMarkersForFilters(timeFilter); rebuildMarkersForFilters(timeFilter);
@ -131,6 +148,10 @@ export function usePostsEngine({
load(); load();
return () => { return () => {
cancelled = true; cancelled = true;
if (delayedFetchRef.current) {
try { clearTimeout(delayedFetchRef.current); } catch {}
delayedFetchRef.current = null;
}
}; };
}, [viewParams, mainFilter, subFilter, mapRef, rebuildMarkersForFilters, timeFilter]); }, [viewParams, mainFilter, subFilter, mapRef, rebuildMarkersForFilters, timeFilter]);

View File

@ -33,3 +33,22 @@
.sw-bottom-label { font-size:.65rem; color:#e5e7eb; text-shadow:0 1px 2px rgba(0,0,0,.9); } .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-circle { background: rgba(37, 99, 235, 0.82); border-color:#60a5fa; }
.sw-bottom-active .sw-bottom-label { color:#bfdbfe; font-weight:600; } .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; }

View File

@ -1,20 +1,14 @@
.app-root{ min-height:100vh; display:flex; flex-direction:column; } .app-root{ min-height:100vh; display:flex; flex-direction:column; }
/* allow scrolling under the map */ /* allow full page scroll */
.main-shell{ flex: 0 0 auto; display:block; padding:0; } .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{ .map-shell{
position:relative; position:relative;
width:100%; width:100%;
height:68vh; height:auto;
border-radius:0; overflow:visible;
overflow:hidden;
border:none; border:none;
background:#020617; background:transparent;
}
/* Phones: taller map => wall+chat lower */
@media (max-width: 640px){
.map-shell{ height:76vh; }
} }

View File

@ -1,49 +1,49 @@
.map-page { :root{
display: flex; --sw-below-overlap: 40px; /* wall overlaps map a bit */
flex-direction: column;
height: 100%;
} }
/* Map stage should fill the map-shell height (no competing vh/clamp here) */ .map-page{ display:flex; flex-direction:column; width:100%; }
.map-stage {
position: relative; /* Map stage first */
width: 100%; .map-stage{
height: 100%; position:relative;
width:100%;
height: clamp(520px, 72vh, 860px);
} }
.map-container, /* Landscape: vh is small, reduce min height */
.maplibre-container { @media (orientation: landscape){
position: absolute; :root{ --sw-below-overlap: 22px; }
inset: 0; .map-stage{ height: clamp(360px, 62vh, 720px); }
width: 100%;
height: 100%;
} }
.map-status { .map-container, .maplibre-container{
position: absolute; position:absolute; inset:0; width:100%; height:100%;
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;
} }
/* Below map area */ .map-status{
.below-stage { position:absolute; left:.5rem; bottom:.5rem;
padding: 0.25rem 0.7rem 1rem; font-size:.7rem; padding:.2rem .4rem;
background:rgba(0,0,0,.6); border-radius:.5rem;
z-index:30;
} }
.below-row { /* BELOW MAP (scroll area) pulled up slightly over map */
display: flex; .below-stage{
gap: 0.6rem; margin-top: calc(var(--sw-below-overlap) * -1);
align-items: stretch; position:relative;
z-index:25;
padding: .25rem .7rem 1.0rem;
} }
/* Mobile: stack */ .below-row{
@media (max-width: 640px) { display:flex;
.below-row { gap:.6rem;
flex-direction: column; 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; }
} }

View File

@ -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 * { pointer-events:auto; }
.map-overlay-top { .map-overlay-top {
@ -15,7 +15,8 @@
display:flex; flex-direction:column; gap:.5rem; 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; } .map-overlay-myloc { right:.7rem; bottom:6rem; }

View File

@ -3,11 +3,12 @@
display:flex; display:flex;
gap:.6rem; gap:.6rem;
align-items:stretch; align-items:stretch;
min-width:0;
} }
/* Panels */ .sociowall-panel{
.sociowall-panel { flex: 1 1 auto;
flex: 1.8; min-width:0; /* IMPORTANT: prevents pushing chat */
min-height: 160px; min-height: 160px;
background: rgba(15, 23, 42, 0.96); background: rgba(15, 23, 42, 0.96);
border-radius: 14px; border-radius: 14px;
@ -18,7 +19,7 @@
flex-direction:column; flex-direction:column;
} }
.sociowall-header { .sociowall-header{
font-size: .78rem; font-size: .78rem;
font-weight: 700; font-weight: 700;
letter-spacing: .06em; letter-spacing: .06em;
@ -27,39 +28,45 @@
margin-bottom:.25rem; margin-bottom:.25rem;
} }
.post-list { flex: 1; overflow-y: auto; padding-right: .15rem; max-height: 42vh; } /* list scroll inside wall */
.post-list-header { display:flex; align-items:center; gap:.5rem; margin-bottom:.25rem; font-size:.7rem; color:#cbd5f5; } .post-list{
.km-filter input[type="range"] { width:160px; } flex:1;
overflow-y:auto;
padding-right:.15rem;
max-height: 44vh;
}
.status-text { font-size:.72rem; opacity:.85; margin:.15rem 0; } /* prevent long content from widening layout */
.status-error { color:#fecaca; } .post-card{
overflow:hidden;
/* list items compact */ min-width:0;
.post-card {
border-radius:10px; border-radius:10px;
border:1px solid rgba(55, 65, 81, 0.9); border:1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.92); background: rgba(15, 23, 42, 0.92);
padding:.3rem .45rem; padding:.3rem .45rem;
margin-bottom:.22rem; margin-bottom:.22rem;
font-size:.76rem;
cursor:pointer; 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 { /* SCALE the fixed 280px template so it never forces width */
width: 170px; .post-card .sw-wall-mini{
min-width: 170px; --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); background: rgba(15, 23, 42, 0.96);
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.9); border: 1px solid rgba(148, 163, 184, 0.9);
@ -67,13 +74,10 @@
padding:.4rem .45rem; padding:.4rem .45rem;
display:flex; display:flex;
flex-direction:column; flex-direction:column;
overflow:hidden;
position: sticky;
top: 10px;
align-self: flex-start;
} }
.chat-header { .chat-header{
font-size:.78rem; font-size:.78rem;
font-weight:700; font-weight:700;
letter-spacing:.06em; letter-spacing:.06em;
@ -81,23 +85,22 @@
color:#bfdbfe; color:#bfdbfe;
margin-bottom:.2rem; 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) */ .chat-users{
@media (max-width: 640px){ list-style:none;
.below-row{ flex-direction: row; } padding:0; margin:0;
.sociowall-panel{ flex: 2.2; } font-size:.76rem;
.chat-panel{ overflow:auto;
width: 34vw; -webkit-overflow-scrolling: touch;
min-width: 140px; max-height: 44vh;
max-width: 180px;
position: static;
top: auto;
}
.post-list{ max-height: 36vh; }
} }
/* Guard: never render Sociowall overlay on top of the map */ .chat-users li{ display:flex; align-items:center; gap:.3rem; padding:.18rem .1rem; }
.map-overlay-wall{ display:none !important; } .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; }
}