import maplibregl from "maplibre-gl"; import React from "react"; import { createRoot } from "react-dom/client"; const SW_ANIM_MS = 840; import CardRenderer from "../Cards/CardRenderer"; import { cardTokens } from "../../theme/cardTokens"; import { getTemplateSpecForPost, getTemplateKeyForPost, adaptPostToTemplateData } from "./templateSpecs"; import { getMarkerColorForCategory } from "./postPriority"; import { loadAuth } from "../../auth/authStorage"; import { recordPostShare, recordPostView, togglePostLike, fetchPostById, fetchPostComments, createPostComment, fetchPostLikeState, fetchProfile, fetchPostTruth, votePostTruth, } from "../../api/client"; import { trackEvent } from "../../utils/analytics"; const avatarCache = new Map(); const avatarInflight = new Map(); const HEATMAP_ENABLED = false; function applyAvatar(el, username, url) { if (!el) return; const initial = (username || "U")[0]?.toUpperCase() || "U"; el.innerHTML = ""; if (url) { const img = document.createElement("img"); img.src = url; img.alt = username || "user"; el.appendChild(img); } else { el.textContent = initial; } } function resolvePostImage(post) { return normalizeImageUrl( post?.image_small || post?.image_medium || post?.image_large || post?.image || post?.media_url || post?.image_url || "" ); } function refreshMarkerFromPost(map, postId, fresh) { if (!map || !fresh) return; const ref = map.__swMarkersRef; const list = ref?.current || []; for (const item of list) { if (!item || item.id !== postId) continue; const nextPost = { ...(item.post || {}), ...(fresh || {}) }; item.post = nextPost; if (!item.el) continue; item.el.__post = nextPost; const nextKey = getTemplateKeyForPost(nextPost); const nextImg = resolvePostImage(nextPost); const nextTitle = (nextPost?.title || "").toString(); if ( item.el.__lastTemplateKey !== nextKey || item.el.__lastImage !== nextImg || item.el.__lastTitle !== nextTitle ) { if (item.type === "simple" && item.el.__renderSimple) { try { item.el.__renderSimple(); } catch {} } else if (item.el.__renderCompact) { try { item.el.__renderCompact(); } catch {} } } } } async function loadAvatar(username) { const key = (username || "").toLowerCase().trim(); if (!key) return ""; if (avatarCache.has(key)) return avatarCache.get(key); if (avatarInflight.has(key)) return avatarInflight.get(key); const p = (async () => { const profile = await fetchProfile(username); const url = (profile && profile.avatar_url) ? String(profile.avatar_url) : ""; avatarCache.set(key, url); avatarInflight.delete(key); return url; })().catch(() => { avatarCache.set(key, ""); avatarInflight.delete(key); return ""; }); avatarInflight.set(key, p); return p; } function hydrateAvatars(root) { if (!root) return; const nodes = root.querySelectorAll("[data-username]"); nodes.forEach((el) => { const username = el.getAttribute("data-username") || ""; if (!username) return; el.classList.add("sw-user-avatar"); applyAvatar(el, username, avatarCache.get(username.toLowerCase().trim()) || ""); loadAvatar(username).then((url) => { applyAvatar(el, username, url); }); }); } 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; } export function applyOcclusionForExpanded() {} 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 formatCount(value) { if (!Number.isFinite(value)) return "—"; if (value < 1000) return String(Math.max(0, Math.floor(value))); if (value < 1000000) return `${(value / 1000).toFixed(1)}k`; return `${(value / 1000000).toFixed(1)}m`; } function readCount(post, keys) { for (const key of keys) { const v = post?.[key]; if (Number.isFinite(v)) return v; const n = Number(v); if (!Number.isNaN(n) && Number.isFinite(n)) return n; } return null; } function updateStat(modalWrap, statKey, value) { const el = modalWrap.querySelector(`[data-stat="${statKey}"] .sw-stat-num`); if (el) el.textContent = formatCount(value); } function updateTruth(modalWrap, post) { const rail = modalWrap.querySelector(".sw-truth-rail"); if (!rail) return; const score = truthScore(post); const marker = rail.querySelector(".sw-truth-rail-marker"); const label = rail.querySelector(".sw-truth-rail-score"); if (marker) marker.style.top = `${100 - score}%`; if (label) label.textContent = `${score}`; } function updateTruthVoteUI(modalWrap, stats) { if (!modalWrap || !stats) return; const aiEl = modalWrap.querySelector("[data-truth-ai]"); const userEl = modalWrap.querySelector("[data-truth-user]"); const countEl = modalWrap.querySelector("[data-truth-count]"); const rail = modalWrap.querySelector(".sw-truth-rail"); const marker = rail ? rail.querySelector(".sw-truth-rail-marker") : null; const label = rail ? rail.querySelector(".sw-truth-rail-score") : null; const aiScore = Number.isFinite(stats.ai_score) ? Math.round(stats.ai_score) : stats.AIScore; const userScore = Number.isFinite(stats.user_score) ? Math.round(stats.user_score) : stats.UserScore; const truthScoreValue = Number.isFinite(stats.truth_score) ? Math.round(stats.truth_score) : stats.TruthScore; const totalVotes = Number.isFinite(stats.total_votes) ? stats.total_votes : stats.TotalVotes; if (aiEl) aiEl.textContent = Number.isFinite(aiScore) ? String(aiScore) : "—"; if (userEl) userEl.textContent = Number.isFinite(userScore) ? String(userScore) : "—"; if (countEl) countEl.textContent = `${totalVotes || 0} votes`; if (marker && Number.isFinite(truthScoreValue)) marker.style.top = `${100 - truthScoreValue}%`; if (label && Number.isFinite(truthScoreValue)) label.textContent = `${truthScoreValue}`; const vote = (stats.user_vote || stats.UserVote || "").toString(); const btns = modalWrap.querySelectorAll(".sw-truth-vote-btn"); btns.forEach((btn) => { const v = btn.getAttribute("data-truth-vote"); btn.classList.toggle("is-active", vote && v === vote); }); const railUp = modalWrap.querySelector(".sw-truth-rail-btn-up"); const railDown = modalWrap.querySelector(".sw-truth-rail-btn-down"); if (railUp) railUp.classList.toggle("is-active", vote === "true"); if (railDown) railDown.classList.toggle("is-active", vote === "false"); } function isAuthed() { try { const auth = loadAuth(); return !!(auth && auth.access_token); } catch { return false; } } function requestAuth(message, mode = "login") { try { window.dispatchEvent( new CustomEvent("sociowire:auth-required", { detail: { message, mode }, }) ); } catch {} trackEvent("auth_required", { context: "map", mode: mode || "login" }); } function applyPostDetails(modalWrap, post) { if (!modalWrap || !post) return; const title = (post?.title || post?.Title || "Untitled").toString(); const summary = (post?.snippet || post?.body || "").toString(); const img = heroImageForPost(post); const fullImg = fullImageForPost(post); const url = (post?.url || "").toString().trim(); const titleEl = modalWrap.querySelector(".sw-modal-title"); if (titleEl) titleEl.textContent = title; const summaryEl = modalWrap.querySelector(".sw-modal-summary"); if (summaryEl) summaryEl.textContent = summary; const heroGallery = collectGalleryImages(post); const heroImageSrc = heroGallery[0] || heroImageForPost(post); const heroFullSrc = heroGallery[0] || fullImageForPost(post); const heroHasGallery = heroGallery.length > 1; const hero = modalWrap.querySelector(".sw-modal-hero"); const clusterHero = modalWrap.querySelector(".sw-cluster-hero-media"); if (hero || clusterHero) { const target = hero || clusterHero; const heroImageHtml = heroImageSrc ? `` : `
${escapeHtml(normCatLabel(post))}
`; const arrowHtml = heroHasGallery ? `` : ""; target.innerHTML = `${heroImageHtml}${arrowHtml}`; let galleryRow = modalWrap.querySelector(".sw-modal-gallery-row"); if (heroHasGallery) { if (!galleryRow) { galleryRow = document.createElement("div"); galleryRow.className = "sw-modal-gallery-row"; const heroRow = target.closest(".sw-modal-hero-row"); const parent = heroRow?.parentNode || target.parentNode || modalWrap; if (parent) { parent.insertBefore(galleryRow, heroRow ? heroRow.nextSibling : target.nextSibling || null); } else { modalWrap.appendChild(galleryRow); } } galleryRow.innerHTML = heroGallery .map( (url, idx) => `` ) .join(""); } else if (galleryRow) { galleryRow.remove(); galleryRow = null; } bindHeroLightbox(modalWrap); if (heroHasGallery) { initModalGallery(modalWrap, heroGallery); } } const badge = modalWrap.querySelector(".sw-modal-badge"); if (badge) badge.textContent = normCatLabel(post); const meta = modalWrap.querySelector(".sw-modal-meta"); if (meta) { const author = (post?.author || post?.Author || "").toString().trim(); const sub = (post?.sub_category || post?.subCategory || "").toString().trim(); const relTime = formatRelativeTime(getPostTime(post)); const metaLeft = author ? `by ${author}` : ""; const metaRight = sub ? sub : ""; meta.innerHTML = ` ${author ? `
` : ""} ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} ${metaRight ? `• ${escapeHtml(metaRight)}` : ""} • ${escapeHtml(relTime)} `; hydrateAvatars(modalWrap); } setModalTags(modalWrap, post); const cta = modalWrap.querySelector(".sw-cta-primary"); if (cta) { if (url) { cta.removeAttribute("disabled"); cta.setAttribute("data-url", url); } else { cta.setAttribute("disabled", "true"); cta.setAttribute("data-url", ""); } } const viewCount = readCount(post, ["view_count", "views", "viewCount"]); const likeCount = readCount(post, ["like_count", "likes", "likeCount"]); const shareCount = readCount(post, ["share_count", "shares", "shareCount"]); const commentCount = readCount(post, ["comment_count", "comments", "commentCount"]); updateStat(modalWrap, "views", viewCount); updateStat(modalWrap, "likes", likeCount); updateStat(modalWrap, "shares", shareCount); updateStat(modalWrap, "comments", commentCount); updateTruth(modalWrap, post); } function renderComments(container, items) { if (!container) return; if (!Array.isArray(items) || items.length === 0) { container.innerHTML = `
— no comments yet
`; return; } container.innerHTML = items .map((c) => { const who = (c?.username || "anon").toString().trim(); const body = (c?.body || "").toString().trim(); const ts = (c?.created_at || "").toString().trim(); return `
${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}
${escapeHtml(body)}
`; }) .join(""); hydrateAvatars(container); } function mountCard(container, spec, data, theme, post) { let root = container.__swReactRoot; if (!root) { root = createRoot(container); container.__swReactRoot = root; } const tokens = cardTokens?.[theme] || cardTokens.blue; const score = truthScore(post || data || {}); root.render( React.createElement( "div", { className: "sw-card-wrap", style: { width: spec?.size?.w, height: spec?.size?.h }, }, React.createElement(CardRenderer, { spec, data, themeTokens: tokens, onAction: (action, value) => { if (action?.type === "open_url" && value) { try { window.open(value, "_blank"); } catch {} } }, className: "", }), React.createElement( "div", { className: "sw-truth-mini-bar" }, React.createElement("div", { className: "sw-truth-mini-marker", style: { top: `${100 - score}%` }, }) ), React.createElement( "div", { className: "sw-truth-mini-score", style: { background: truthColor(score) } }, `${score}` ), ) ); const rawScore = Number( post?.truth_score ?? post?.truthScore ?? post?.truth_ai ?? post?.truthAi ?? post?.ai_score ?? post?.AIScore ); const hasAI = Number.isFinite( Number(post?.truth_ai ?? post?.truthAi ?? post?.ai_score ?? post?.AIScore) ); const hasScore = Number.isFinite(rawScore) && !(rawScore === 50 && !hasAI); if (!hasScore && post?.id) { fetchPostTruth(post.id).then((stats) => { if (!stats) return; const nextScore = Number(stats.truth_score ?? stats.TruthScore); if (!Number.isFinite(nextScore)) return; post.truth_score = nextScore; const markerEl = container.querySelector(".sw-truth-mini-marker"); const scoreEl = container.querySelector(".sw-truth-mini-score"); if (markerEl) markerEl.style.top = `${100 - nextScore}%`; if (scoreEl) { scoreEl.style.background = truthColor(nextScore); scoreEl.textContent = `${Math.round(nextScore)}`; } }); } return () => { try { root.unmount(); } catch {} container.__swReactRoot = null; }; } function escapeHtml(str) { return String(str ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function renderTagPills(tags) { if (!Array.isArray(tags) || tags.length === 0) return ""; const uniq = []; const seen = new Set(); for (const raw of tags) { const t = (raw || "").toString().trim().toLowerCase(); if (!t || seen.has(t)) continue; seen.add(t); uniq.push(t); if (uniq.length >= 6) break; } if (uniq.length === 0) return ""; return `
${uniq.map((t) => `#${escapeHtml(t)}`).join("")}
`; } function setModalTags(modalWrap, post) { if (!modalWrap) return; const tagsHtml = renderTagPills(post?.event_tags || post?.tags); const existing = modalWrap.querySelector(".sw-modal-tags"); if (tagsHtml) { if (existing) { existing.outerHTML = tagsHtml; } else { const titleEl = modalWrap.querySelector(".sw-modal-title") || modalWrap.querySelector(".sw-cluster-title"); if (titleEl) titleEl.insertAdjacentHTML("afterend", tagsHtml); } } else if (existing) { existing.remove(); } } function bindHeroLightbox(modalWrap) { if (!modalWrap) return; const img = modalWrap.querySelector(".sw-modal-hero img, .sw-cluster-hero-media img"); if (!img || img.dataset.zoomBound === "1") return; const fullSrc = (img.getAttribute("data-fullsrc") || img.getAttribute("src") || "").trim(); if (!fullSrc) return; img.dataset.zoomBound = "1"; img.style.cursor = "zoom-in"; img.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openImageLightbox(fullSrc); }); } function initModalGallery(modalWrap, gallery) { if (!modalWrap || !Array.isArray(gallery) || gallery.length === 0) return; const heroImg = modalWrap.querySelector(".sw-modal-hero img, .sw-cluster-hero-media img"); if (!heroImg) return; let activeIndex = 0; const thumbButtons = modalWrap.querySelectorAll(".sw-modal-gallery-thumb"); const arrows = modalWrap.querySelectorAll(".sw-modal-hero-arrow"); const setActive = (idx) => { if (!Number.isFinite(idx)) return; const normalized = (idx + gallery.length) % gallery.length; activeIndex = normalized; heroImg.src = gallery[normalized]; heroImg.dataset.fullsrc = gallery[normalized]; heroImg.dataset.galleryIndex = String(normalized); thumbButtons.forEach((btn) => { const btnIndex = Number(btn.dataset.index); btn.classList.toggle("active", btnIndex === normalized); }); }; arrows.forEach((btn) => { btn.addEventListener("click", (event) => { event.stopPropagation(); const direction = btn.classList.contains("left") ? -1 : 1; const nextIndex = (activeIndex + direction + gallery.length) % gallery.length; setActive(nextIndex); }); }); thumbButtons.forEach((btn) => { btn.addEventListener("click", (event) => { event.stopPropagation(); const idx = Number(btn.dataset.index); if (Number.isNaN(idx)) return; setActive(idx); }); }); setActive(0); } function openImageLightbox(src) { if (!src) return; const overlay = document.createElement("div"); overlay.className = "sw-image-lightbox"; overlay.style.position = "fixed"; overlay.style.inset = "0"; overlay.style.background = "rgba(8,10,16,0.86)"; overlay.style.zIndex = "10000000"; overlay.style.display = "flex"; overlay.style.alignItems = "center"; overlay.style.justifyContent = "center"; overlay.style.padding = "16px"; const img = document.createElement("img"); img.src = src; img.alt = ""; img.style.maxWidth = "96vw"; img.style.maxHeight = "96vh"; img.style.objectFit = "contain"; img.style.borderRadius = "14px"; img.style.boxShadow = "0 20px 50px rgba(0,0,0,0.45)"; overlay.appendChild(img); const close = (opts = {}) => { try { document.removeEventListener("keydown", onKey); } catch {} try { window.removeEventListener("popstate", onPop); } catch {} if (overlay.parentNode) overlay.parentNode.removeChild(overlay); // When closing image, go back in history to return to post (not close post) if (!opts.skipHistory && overlay.__swPushed) { overlay.__swPushed = false; try { history.back(); } catch {} } }; const onKey = (e) => { // ESC closes image and returns to post (via history.back) if (e.key === "Escape") close(); }; const onPop = () => close({ skipHistory: true }); overlay.addEventListener("click", close); document.addEventListener("keydown", onKey); window.addEventListener("popstate", onPop); // Push history for image so back button closes image (returns to post) try { history.pushState({ sw: "image" }, ""); overlay.__swPushed = true; } catch {} document.body.appendChild(overlay); } function normCatLabel(post) { const c = (post?.category || "").toString().toUpperCase(); if (c === "NEWS") return "NEWS"; if (c === "EVENT" || c === "EVENTS") return "EVENTS"; if (c === "FRIENDS") return "FRIENDS"; if (c === "MARKET") return "MARKET"; return "NEWS"; } function categoryIconClass(post) { const c = (post?.category || "NEWS").toString().toUpperCase(); switch (c) { case "EVENT": case "EVENTS": return "fa-regular fa-calendar"; case "FRIENDS": return "fa-solid fa-user-group"; case "MARKET": return "fa-solid fa-store"; default: return "fa-regular fa-newspaper"; } } function isClusterMainPost(post) { const clusterPostId = Number(post?.cluster_post_id); const postId = Number(post?.post_id || post?.id); if (Number.isFinite(clusterPostId) && Number.isFinite(postId) && clusterPostId > 0 && postId > 0) { return clusterPostId === postId; } const key = (post?.cluster_key || post?.clusterKey || "").toString().trim(); const members = Array.isArray(post?.cluster_members) ? post.cluster_members : []; if (key && members.length > 0) return true; const url = (post?.url || "").toString(); return url.includes("/cluster/"); } function dispatchClusterHeatmap(post, opts = {}) { if (!post) return; const postId = Number(post?.id || post?.post_id || 0); const members = Array.isArray(post?.cluster_members) ? post.cluster_members : []; const items = Array.isArray(post?.cluster_items) ? post.cluster_items : []; const points = items .map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) })) .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon)); window.dispatchEvent( new CustomEvent("sociowire:cluster-heatmap", { detail: { clusterKey: post?.cluster_key || post?.clusterKey || "", postId, members, points, items, post, center: { lat: Number(post?.lat), lon: Number(post?.lon) }, mode: opts.mode || "click", skipPan: !!opts.skipPan, }, }) ); } function dispatchClusterHeatmapClear() { window.dispatchEvent(new CustomEvent("sociowire:cluster-heatmap-clear")); } function buildClusterItemsHtml(items) { if (!Array.isArray(items) || items.length === 0) { return `
No related reports yet.
`; } const rows = items.slice(0, 6).map((item) => { const title = escapeHtml(item?.title || "Untitled"); const source = escapeHtml(item?.source || ""); const url = escapeHtml(item?.url || ""); const meta = source ? `${source}` : ""; const href = url ? ` data-related-url="${url}"` : ""; return `
${title}
${meta}
`; }); return rows.join(""); } function heroImageForPost(post) { const raw = post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || ""; const v = normalizeImageUrl(raw); if (v) return v; const cat = normCatLabel(post); return `/og/category?cat=${encodeURIComponent(cat)}`; } function fullImageForPost(post) { const raw = post?.image_large || post?.image_medium || post?.image_small || post?.image || post?.media_url || ""; return normalizeImageUrl(raw); } function collectGalleryImages(post) { const result = []; const seen = new Set(); const add = (value) => { const normalized = normalizeImageUrl(value); if (!normalized) return; if (seen.has(normalized)) return; seen.add(normalized); result.push(normalized); }; if (Array.isArray(post?.media_urls)) { post.media_urls.forEach(add); } ["image_large", "image_medium", "image_small", "image", "media_url"].forEach((key) => { add(post?.[key]); }); return result; } function formatRelativeTime(createdAt) { try { if (!createdAt) return "now"; const d = createdAt instanceof Date ? createdAt : new Date(String(createdAt)); if (Number.isNaN(d.getTime())) return "now"; const s = Math.floor((Date.now() - d.getTime()) / 1000); if (s < 60) return "now"; const m = Math.floor(s / 60); if (m < 60) return `${m}m`; const h = Math.floor(m / 60); if (h < 48) return `${h}h`; const days = Math.floor(h / 24); return `${days}d`; } catch { return "now"; } } function getPostTime(post) { const published = post?.published_at || post?.publishedAt || post?.PublishedAt || ""; if (published) { const d = new Date(String(published)); if (!Number.isNaN(d.getTime())) { const now = Date.now(); if (d.getTime() <= now + 5 * 60 * 1000) { return published; } } } return ( post?.created_at || post?.CreatedAt || post?.createdAt || published ); } function truthScore(post) { const raw = Number( post?.truth_score ?? post?.truthScore ?? post?.truth_ai ?? post?.truthAi ?? post?.ai_score ?? post?.AIScore ); if (Number.isFinite(raw)) { return clamp(Math.round(raw), 0, 100); } return 65; } function truthColor(score) { const s = clamp(score, 0, 100); const hue = Math.round((s / 100) * 120); return `hsl(${hue} 80% 42%)`; } function clamp(v, min, max) { return Math.min(Math.max(v, min), max); } function normalizeImageUrl(raw) { const value = (raw || "").toString().trim(); if (!value) return ""; if (value.startsWith("//")) return `https:${value}`; if (value.startsWith("http://")) return `https://${value.slice(7)}`; return value; } function closeCenteredOverlay(map, opts = {}) { try { const ov = map?.__swCenteredOverlay; if (!ov) return; if (!opts.force && ov.openedAt && Date.now() - ov.openedAt < 700) return; if (ov.closing) return; ov.closing = true; const skipHistory = !!opts.skipHistory || opts.reason === "switch"; if (ov.onKey) window.removeEventListener("keydown", ov.onKey); if (ov.onAuth) window.removeEventListener("sociowire:auth-changed", ov.onAuth); if (ov.onPop) window.removeEventListener("popstate", ov.onPop); // animate out try { const mw = ov.modalWrapRef; if (mw) { mw.style.opacity = "0"; mw.style.transform = "scale(0.96)"; } if (ov.backdrop) ov.backdrop.style.opacity = "0"; } catch {} try { if (ov.unmountFull) ov.unmountFull(); } catch {} try { if (ov.unmountMini) ov.unmountMini(); } catch {} try { const b = ov.backdrop; if (b) setTimeout(() => { try { b.remove(); } catch {} }, 300); } catch {} map.__swCenteredOverlay = null; try { const st = ov.interactionState || {}; if (map) { if (st.dragPan) map.dragPan?.enable?.(); if (st.scrollZoom) map.scrollZoom?.enable?.(); if (st.doubleClickZoom) map.doubleClickZoom?.enable?.(); if (st.touchZoomRotate) map.touchZoomRotate?.enable?.(); } } catch {} if (!skipHistory && ov.historyPushed && !ov.historyClosed) { ov.historyClosed = true; try { window.history.back(); } catch {} } if (map) { try { map.__swForceFetchAt = Date.now(); map.fire("moveend"); } catch {} } } catch {} } export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { if (!map) return; closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true }); const interactionState = { dragPan: map.dragPan && map.dragPan.isEnabled ? map.dragPan.isEnabled() : null, scrollZoom: map.scrollZoom && map.scrollZoom.isEnabled ? map.scrollZoom.isEnabled() : null, doubleClickZoom: map.doubleClickZoom && map.doubleClickZoom.isEnabled ? map.doubleClickZoom.isEnabled() : null, touchZoomRotate: map.touchZoomRotate && map.touchZoomRotate.isEnabled ? map.touchZoomRotate.isEnabled() : null, }; try { if (map.dragPan) map.dragPan.disable(); } catch {} try { if (map.scrollZoom) map.scrollZoom.disable(); } catch {} try { if (map.doubleClickZoom) map.doubleClickZoom.disable(); } catch {} try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {} let postData = { ...(post || {}) }; const data = adaptPostToTemplateData(postData, { size: "full" }); const cat = normCatLabel(postData); const templateKey = getTemplateKeyForPost(postData); const relTime = formatRelativeTime(getPostTime(postData)); const author = (postData?.author || postData?.Author || "").toString().trim(); const sub = (postData?.sub_category || postData?.subCategory || "").toString().trim(); const metaLeft = author ? `by ${author}` : ""; const metaRight = sub ? sub : ""; const img = heroImageForPost(postData); const truth = truthScore(postData); const url = data?.url || ""; const postID = postData?.id || postData?.ID || 0; const viewCount = readCount(postData, ["view_count", "views", "viewCount"]); const likeCount = readCount(postData, ["like_count", "likes", "likeCount"]); const shareCount = readCount(postData, ["share_count", "shares", "shareCount"]); const commentCount = readCount(postData, ["comment_count", "comments", "commentCount"]); const isCluster = isClusterMainPost(postData); const clusterItems = Array.isArray(postData?.cluster_items) ? postData.cluster_items : []; const clusterCount = Array.isArray(postData?.cluster_members) ? postData.cluster_members.length : 0; const tagPills = renderTagPills(postData?.event_tags || postData?.tags); const heatmapPill = HEATMAP_ENABLED ? `
🔥 Heat map
` : ""; trackEvent("post_open", { post_id: postID, source: postData?.source || postData?.Source || "", category: postData?.category || postData?.Category || "", context: "map", fullscreen: !!fullScreen, }); // Backdrop catches outside click to close const backdrop = document.createElement("div"); try { backdrop.classList.add("sw-centered-backdrop","sw-enter"); } catch {} backdrop.classList.add("sw-centered-backdrop"); backdrop.className = "sw-centered-backdrop"; if (fullScreen) backdrop.classList.add("sw-modal-fullscreen"); backdrop.style.position = "fixed"; backdrop.style.inset = "0"; backdrop.style.zIndex = "9999999"; backdrop.style.background = "rgba(0,0,0,0.08)"; backdrop.style.backdropFilter = "blur(1px)"; backdrop.style.display = "flex"; backdrop.style.alignItems = "center"; backdrop.style.justifyContent = "center"; backdrop.style.padding = "12px"; backdrop.style.pointerEvents = "auto"; // Wrapper uses existing styling hooks const modalWrap = document.createElement("div"); modalWrap.classList.add("sw-centered-modal"); modalWrap.className = "post-pin--expanded sw-centered-modal"; if (fullScreen) modalWrap.classList.add("sw-modal-fullscreen"); modalWrap.style.pointerEvents = "auto"; // Keep size behavior from your CSS (max-width/min(92vw,...), max-height etc.) if (isCluster) { modalWrap.innerHTML = `
MAIN NEWS
${author ? `
` : ""} ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} ${metaRight ? `• ${escapeHtml(metaRight)}` : ""} • ${escapeHtml(relTime)}
${escapeHtml(postData?.cluster_title || postData?.title || "Untitled")}
${tagPills}
${truth}
${ img ? `` : `
${escapeHtml(normCatLabel(postData))}
` }
Generated news update
${escapeHtml(data?.summary || postData?.snippet || "")}
👁 ${formatCount(viewCount)}
❤️ ${formatCount(likeCount)}
🔁 ${formatCount(shareCount)}
💬 ${formatCount(commentCount)}
${heatmapPill}
Live chat / comments
— loading…
`; } else { modalWrap.innerHTML = `
${escapeHtml(cat)}
${author ? `
` : ""} ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} ${metaRight ? `• ${escapeHtml(metaRight)}` : ""} • ${escapeHtml(relTime)}
${truth}
${ img ? `` : `
${escapeHtml(normCatLabel(postData))}
` }
${escapeHtml(data?.headline || postData?.title || "Untitled")}
${tagPills}
${escapeHtml(data?.summary || postData?.snippet || "")}
👁 ${formatCount(viewCount)}
❤️ ${formatCount(likeCount)}
🔁 ${formatCount(shareCount)}
💬 ${formatCount(commentCount)}
${heatmapPill}
Live chat / comments
— loading…
`; } bindHeroLightbox(modalWrap); backdrop.appendChild(modalWrap); document.body.appendChild(backdrop); hydrateAvatars(modalWrap); const relatedLinks = modalWrap.querySelectorAll("[data-related-url]"); relatedLinks.forEach((el) => { el.addEventListener("click", () => { const target = el.getAttribute("data-related-url"); if (!target) return; try { window.open(target, "_blank", "noopener"); } catch { window.location.href = target; } }); }); requestAnimationFrame(() => { try { backdrop.classList.add("sw-enter-active"); } catch {} try { modalWrap.classList.add("sw-enter-active"); } catch {} }); // animate in backdrop.style.opacity = "0"; modalWrap.style.opacity = "0"; modalWrap.style.transform = "scale(0.96)"; requestAnimationFrame(() => { backdrop.style.transition = "opacity 260ms ease"; modalWrap.style.transition = "opacity 260ms ease, transform 320ms cubic-bezier(.2,.9,.2,1)"; backdrop.style.opacity = "1"; modalWrap.style.opacity = "1"; modalWrap.style.transform = "scale(1)"; }); // ✅ animate in (fade + scale) try { requestAnimationFrame(() => { backdrop.classList.add("sw-open"); modalWrap.classList.add("sw-open"); }); } catch {} // outside click closes (ignore drag/scroll on map) let backdropDown = null; const trackDown = (e) => { backdropDown = { x: e.clientX || 0, y: e.clientY || 0, t: Date.now(), }; }; const shouldCloseBackdrop = (e) => { if (map?.__swCenteredOverlay?.openedAt) { if (Date.now() - map.__swCenteredOverlay.openedAt < 350) return false; } if (!backdropDown) return true; const dx = (e.clientX || 0) - backdropDown.x; const dy = (e.clientY || 0) - backdropDown.y; const dist = Math.hypot(dx, dy); const dt = Date.now() - backdropDown.t; return dist < 8 && dt < 700; }; backdrop.addEventListener("pointerdown", trackDown); backdrop.addEventListener("mousedown", trackDown); backdrop.addEventListener("touchstart", (e) => { const touch = e.touches && e.touches[0]; if (!touch) return; trackDown({ clientX: touch.clientX, clientY: touch.clientY }); }, { passive: true }); backdrop.addEventListener("click", (e) => { if (!shouldCloseBackdrop(e)) return; closeCenteredOverlay(map, { force: true }); }); // inside click doesn't close const stopPropagation = (e) => e.stopPropagation(); modalWrap.addEventListener("click", stopPropagation); modalWrap.addEventListener("pointerdown", stopPropagation); modalWrap.addEventListener("mousedown", stopPropagation); modalWrap.addEventListener("touchstart", stopPropagation, { passive: true }); // close button const xbtn = modalWrap.querySelector(".sw-modal-x"); if (xbtn) xbtn.addEventListener("click", () => closeCenteredOverlay(map, { force: true })); // ESC closes const onKey = (e) => { if (e.key === "Escape") closeCenteredOverlay(map, { force: true }); }; window.addEventListener("keydown", onKey); // Back button support WITHOUT changing URL // We push a fake history state so back button closes the modal let onPop = null; let historyPushed = false; try { // Only push history state without changing URL window.history.pushState({ swModal: true, postId: postID }, ""); historyPushed = true; onPop = (e) => { // Check if we're returning to the modal state (from image) or actually closing const state = e.state || window.history.state; // If state still has swModal=true, we're just returning from image to post // Don't close the post modal if (state && state.swModal === true && state.postId === postID) { return; // Stay in post modal } // Otherwise, we're closing the modal const ov = map.__swCenteredOverlay; if (ov && ov.postId === postID) { ov.historyClosed = true; closeCenteredOverlay(map, { skipHistory: true, force: true }); } }; window.addEventListener("popstate", onPop); } catch {} // CTA open url const cta = modalWrap.querySelector(".sw-cta-primary"); if (cta) { cta.addEventListener("click", () => { const u = cta.getAttribute("data-url") || ""; if (!u) return; try { window.open(u, "_blank", "noopener,noreferrer"); } catch {} }); } if (postID > 0 && !modalWrap.__swViewed) { modalWrap.__swViewed = true; recordPostView(postID).then((res) => { const counts = res?.counts; if (counts) { updateStat(modalWrap, "views", counts.view_count); updateStat(modalWrap, "likes", counts.like_count); updateStat(modalWrap, "shares", counts.share_count); updateStat(modalWrap, "comments", counts.comment_count); } }); } if (postID > 0) { fetchPostTruth(postID).then((stats) => { if (stats) updateTruthVoteUI(modalWrap, stats); }); } let liked = false; const likeBtn = modalWrap.querySelector('[data-action="like"]'); const commentInput = modalWrap.querySelector(".sw-livechat-input"); const commentBtn = modalWrap.querySelector(".sw-livechat-post"); const applyAuthUI = () => { const authed = isAuthed(); if (likeBtn) { likeBtn.disabled = false; likeBtn.textContent = authed && liked ? "Liked" : "Like"; } if (commentInput) { commentInput.readOnly = !authed; commentInput.placeholder = authed ? "Write a comment…" : "Sign in to comment"; } }; if (likeBtn && postID > 0) { likeBtn.addEventListener("click", async () => { if (!isAuthed()) { requestAuth("Please sign in or create a free account to like this post."); return; } trackEvent("post_like_click", { post_id: postID, context: "map" }); likeBtn.disabled = true; const res = await togglePostLike(postID, !liked); if (res?.ok) { liked = !!res.liked; likeBtn.textContent = liked ? "Liked" : "Like"; const counts = res?.counts; if (counts) { updateStat(modalWrap, "likes", counts.like_count); updateStat(modalWrap, "shares", counts.share_count); updateStat(modalWrap, "comments", counts.comment_count); updateStat(modalWrap, "views", counts.view_count); } } likeBtn.disabled = false; }); } const shareBtn = modalWrap.querySelector('[data-action="share"]'); if (shareBtn && postID > 0) { shareBtn.addEventListener("click", async () => { const shareUrl = `${window.location.origin}/p/${postID}`; const shareTitle = postData?.title || postData?.Title || "SocioWire"; trackEvent("post_share_click", { post_id: postID, context: "map" }); try { if (navigator.share) { await navigator.share({ title: shareTitle, url: shareUrl }); } else if (navigator.clipboard) { await navigator.clipboard.writeText(shareUrl); } } catch {} const res = await recordPostShare(postID); const counts = res?.counts; if (counts) { updateStat(modalWrap, "views", counts.view_count); updateStat(modalWrap, "shares", counts.share_count); } }); } const heatBtn = modalWrap.querySelector('[data-action="cluster-heatmap"]'); if (HEATMAP_ENABLED && heatBtn && isCluster) { heatBtn.addEventListener("click", () => { closeCenteredOverlay(map, { force: true }); dispatchClusterHeatmap(postData, { mode: "click" }); }); } if (postID > 0) { fetchPostById(postID).then((fresh) => { if (!fresh) return; postData = { ...postData, ...fresh }; applyPostDetails(modalWrap, postData); refreshMarkerFromPost(map, postID, postData); }); } const commentsBody = modalWrap.querySelector(".sw-livechat-body"); if (commentsBody && postID > 0) { fetchPostComments(postID, 20).then((items) => renderComments(commentsBody, items)); } if (commentBtn && commentInput && postID > 0) { commentBtn.addEventListener("click", async () => { if (!isAuthed()) { requestAuth("Please sign in or create a free account to comment."); return; } const body = (commentInput.value || "").trim(); if (!body) return; trackEvent("post_comment_submit", { post_id: postID, context: "map" }); commentBtn.disabled = true; const res = await createPostComment(postID, body); if (res?.ok) { commentInput.value = ""; const existing = await fetchPostComments(postID, 20); renderComments(commentsBody, existing); const counts = res?.counts; if (counts) { updateStat(modalWrap, "comments", counts.comment_count); updateStat(modalWrap, "likes", counts.like_count); updateStat(modalWrap, "shares", counts.share_count); updateStat(modalWrap, "views", counts.view_count); } } commentBtn.disabled = false; }); commentInput.addEventListener("focus", () => { if (!isAuthed()) { requestAuth("Please sign in or create a free account to comment."); } }); } if (postID > 0 && isAuthed()) { fetchPostLikeState(postID).then((res) => { if (res?.ok && res.liked) { liked = true; if (likeBtn) likeBtn.textContent = "Liked"; } }); } const rail = modalWrap.querySelector(".sw-truth-rail"); if (rail && postID > 0) { const doVote = async (vote) => { if (!isAuthed()) { requestAuth("Please sign in or create a free account to vote on truth."); return; } try { rail.classList.add("sw-truth-vote-disabled"); const stats = await votePostTruth(postID, vote); if (stats) updateTruthVoteUI(modalWrap, stats); } catch {} rail.classList.remove("sw-truth-vote-disabled"); }; const voteUp = rail.querySelector(".sw-truth-rail-btn-up"); const voteDown = rail.querySelector(".sw-truth-rail-btn-down"); if (voteUp) voteUp.addEventListener("click", () => doVote("true")); if (voteDown) voteDown.addEventListener("click", () => doVote("false")); } applyAuthUI(); const onAuth = () => { if (postID > 0 && isAuthed()) { fetchPostLikeState(postID).then((res) => { if (res?.ok && res.liked) { liked = true; } else { liked = false; } applyAuthUI(); }); } else { liked = false; applyAuthUI(); } }; window.addEventListener("sociowire:auth-changed", onAuth); let unmountFull = null; map.__swCenteredOverlay = { postId: postData?.id, modalWrapRef: modalWrap, backdrop, interactionState, onKey, onPop, onAuth, unmountFull, unmountMini: null, historyPushed, historyClosed: false, openedAt: Date.now(), }; } function hashPostId(id) { // Simple hash function for stable offset calculation const str = String(id); let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash); } function calculateSmartOffset(post, markersRef, map) { if (!map || !markersRef.current) return { x: 0, y: 0 }; 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) return { x: 0, y: 0 }; // Si le post est très récent (< 10 secondes), offset vers le HAUT pour compenser l'anchor bottom const createdAt = post?.created_at || post?.CreatedAt || post?.createdAt; if (createdAt) { const ageSeconds = (Date.now() - new Date(createdAt).getTime()) / 1000; if (ageSeconds < 10) { return { x: 0, y: -110 }; // Offset vers le haut (négatif = monte) } } const currentPoint = map.project([lon, lat]); const PROXIMITY_THRESHOLD = 100; // pixels - larger to detect more overlaps // Check distance in geographic coordinates (more stable across zoom levels) const nearbyCount = markersRef.current.filter(marker => { if (!marker.post || marker.post.id === post.id) return false; const mLat = typeof marker.post.lat === "number" ? marker.post.lat : marker.post.latitude; const mLon = typeof marker.post.lon === "number" ? marker.post.lon : marker.post.lng; if (mLat == null || mLon == null) return false; // Geographic distance (rough approximation) const latDiff = Math.abs(lat - mLat); const lonDiff = Math.abs(lon - mLon); const geoDist = Math.sqrt(latDiff * latDiff + lonDiff * lonDiff); // Small threshold for geographic clustering (~100m at equator) return geoDist < 0.001; }).length; if (nearbyCount === 0) return { x: 0, y: 0 }; // Use deterministic hash-based offset so it's stable across zoom/pan const hash = hashPostId(post.id); const angle = (hash % 8) * 45 * (Math.PI / 180); // 8 positions, 45° apart const offsetMagnitude = 25 + ((hash % 3) * 15); // 25, 40, or 55 pixels return { x: Math.cos(angle) * offsetMagnitude, y: Math.sin(angle) * offsetMagnitude }; } 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) return; const lngLat = [lon, lat]; // Calculate smart offset for nearby markers // DISABLED: Using geographic coordinate variation instead of pixel offset // const offset = calculateSmartOffset(post, markersRef, map); const offset = { x: 0, y: 0 }; const root = document.createElement("div"); root.classList.add("sw-appear"); requestAnimationFrame(() => root.classList.add("sw-appear-in")); root.className = "post-pin post-pin--compact"; root.style.zIndex = "1"; root.__post = post; function unmountIfAny() { if (root.__swUnmount) { try { root.__swUnmount(); } catch {} root.__swUnmount = null; } } function renderCompact() { if (!root.__swCompactReady) { root.className = "post-pin post-pin--compact sw-appear sw-appear-in"; root.style.zIndex = "1"; // Add connecting line if offset is significant // DISABLED: No longer using pixel offsets const hasOffset = false; const lineHTML = ''; root.innerHTML = `
${lineHTML} `; root.__swCompactReady = true; } const wrap = root.querySelector(".sw-template-mini-wrap"); if (wrap) { const activePost = root.__post || post; const spec = getTemplateSpecForPost(activePost, "mini"); const data = adaptPostToTemplateData(activePost, { size: "mini" }); root.__lastTemplateKey = getTemplateKeyForPost(activePost); root.__lastImage = data?.image || ""; root.__lastTitle = data?.headline || ""; root.__swUnmount = mountCard(wrap, spec, data, theme, activePost); } clearOcclusion(markersRef); } renderCompact(); root.__renderCompact = renderCompact; if (HEATMAP_ENABLED && isClusterMainPost(post)) { const handleEnter = () => { dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true }); }; const handleLeave = () => { dispatchClusterHeatmapClear(); }; root.addEventListener("mouseenter", handleEnter); root.addEventListener("mouseleave", handleLeave); root.__swClusterHover = { handleEnter, handleLeave }; } const marker = new maplibregl.Marker({ element: root, anchor: "bottom", offset: [offset.x, offset.y] }) .setLngLat(lngLat) .addTo(map); markersRef.current.push({ id: post.id, marker, el: root, post, type: "full" }); root.addEventListener("click", (ev) => { ev.stopPropagation(); const existing = map.__swCenteredOverlay; if (existing && existing.postId === post?.id) { if (existing.openedAt && Date.now() - existing.openedAt < 800) return; closeCenteredOverlay(map, { force: true }); return; } openCenteredOverlay({ map, post, theme, fullScreen: false }); if (expandedElRef) expandedElRef.current = null; }); } /** * Crée un marqueur simple (juste icône circulaire) pour les posts secondaires * S'ouvre en modal au clic, affiche mini-carte au hover */ export function createSimpleMarkerForPost(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) return; const lngLat = [lon, lat]; // Pas d'offset pour les marqueurs simples (ils sont petits) const root = document.createElement("div"); root.classList.add("sw-appear"); requestAnimationFrame(() => root.classList.add("sw-appear-in")); root.className = "post-pin--simple sw-appear sw-appear-in"; root.style.cursor = "pointer"; root.style.width = "40px"; root.style.height = "52px"; root.style.display = "flex"; root.style.flexDirection = "column"; root.style.alignItems = "center"; root.__post = post; function renderSimple() { const activePost = root.__post || post; const color = getMarkerColorForCategory(activePost?.category); const img = normalizeImageUrl(activePost?.image_small || ""); root.__lastTemplateKey = getTemplateKeyForPost(activePost); root.__lastImage = img || ""; root.__lastTitle = (activePost?.title || "").toString(); // Marqueur pin avec image en haut et pointe en bas root.innerHTML = `
${img ? `` : `
`}
${escapeHtml(activePost?.title || "Untitled")}
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
${normCatLabel(activePost)} • ${formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
`; } renderSimple(); root.__renderSimple = renderSimple; const isCluster = HEATMAP_ENABLED && isClusterMainPost(post); const markerEl = root.querySelector(".sw-simple-marker"); const hoverCard = root.querySelector(".sw-simple-hover-card"); // Hover effects root.addEventListener("mouseenter", () => { if (isCluster) { dispatchClusterHeatmap(root.__post || post, { mode: "hover", skipPan: true }); } if (markerEl) { markerEl.style.transform = "scale(1.2)"; markerEl.style.boxShadow = "0 4px 16px rgba(0,0,0,0.5)"; } if (hoverCard) { hoverCard.style.opacity = "1"; hoverCard.style.transform = "translateX(-50%) translateY(-6px) scale(1)"; } }); root.addEventListener("mouseleave", () => { if (isCluster) { dispatchClusterHeatmapClear(); } if (markerEl) { markerEl.style.transform = "scale(1)"; markerEl.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)"; } if (hoverCard) { hoverCard.style.opacity = "0"; hoverCard.style.transform = "translateX(-50%) translateY(-6px) scale(0.9)"; } }); // Click ouvre le modal root.addEventListener("click", (ev) => { ev.stopPropagation(); const existing = map.__swCenteredOverlay; if (existing && existing.postId === post?.id) { if (existing.openedAt && Date.now() - existing.openedAt < 800) return; closeCenteredOverlay(map, { force: true }); return; } openCenteredOverlay({ map, post, theme, fullScreen: false }); if (expandedElRef) expandedElRef.current = null; }); const marker = new maplibregl.Marker({ element: root, anchor: "bottom", // La pointe du pin est à la position exacte }) .setLngLat(lngLat) .addTo(map); markersRef.current.push({ id: post.id, marker, el: root, post, type: "simple" }); } /** * ISLAND MARKER: Creates a circular avatar marker for user islands * Islands are displayed with avatar, username, and 🏝️ badge */ export function createIslandMarker(island, mapRef, markersRef, onIslandClick, theme = "blue") { const map = mapRef.current; if (!map) return; const lat = island.virtual_y; // Virtual coords const lon = island.virtual_x; if (lat == null || lon == null) { console.warn("[createIslandMarker] Missing virtual coordinates", island); return; } const root = document.createElement("div"); root.classList.add("island-marker", "sw-appear"); // Fade-in animation requestAnimationFrame(() => root.classList.add("sw-appear-in")); // Marker circulaire avec avatar root.innerHTML = `
${island.avatar_url ? `${island.username}` : `
${island.username[0].toUpperCase()}
` }
🏝️ ISLAND
@${island.username}
`; // Hover effect const avatar = root.querySelector(".island-avatar"); root.addEventListener("mouseenter", () => { if (avatar) { avatar.style.transform = "scale(1.1)"; avatar.style.boxShadow = "0 6px 20px rgba(102, 126, 234, 0.6)"; } }); root.addEventListener("mouseleave", () => { if (avatar) { avatar.style.transform = "scale(1)"; avatar.style.boxShadow = "0 4px 12px rgba(0,0,0,0.4)"; } }); // Click handler root.addEventListener("click", (ev) => { ev.stopPropagation(); console.log("[Island Marker] Clicked:", island.username, island.id); if (onIslandClick) { onIslandClick(island); } }); const marker = new maplibregl.Marker({ element: root, anchor: "bottom", }) .setLngLat([lon, lat]) .addTo(map); markersRef.current.push({ id: island.id, marker, el: root, island, type: "island" }); console.log("[Island Marker] Created for", island.username, "at", [lon, lat]); }