sw-fe/src/components/Map/markerManager.js

1867 lines
64 KiB
JavaScript

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
? `<img src="${escapeHtml(heroImageSrc)}" data-fullsrc="${escapeHtml(heroFullSrc)}" alt="" loading="lazy" data-gallery-index="0" />`
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(post)}"></i><span>${escapeHtml(normCatLabel(post))}</span></div></div>`;
const arrowHtml = heroHasGallery
? `<button class="sw-modal-hero-arrow left" type="button" aria-label="Previous image"><i class="fa-solid fa-chevron-left" /></button><button class="sw-modal-hero-arrow right" type="button" aria-label="Next image"><i class="fa-solid fa-chevron-right" /></button>`
: "";
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) =>
`<button type="button" class="sw-modal-gallery-thumb${idx === 0 ? " active" : ""}" data-index="${idx}"><img src="${escapeHtml(
url
)}" alt="" loading="lazy" /></button>`
)
.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 ? `<div class="sw-inline-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span>• ${escapeHtml(relTime)}</span>
`;
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 = `<div class="sw-chat-line">— no comments yet</div>`;
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 `
<div class="sw-chat-bubble">
<div class="sw-chat-head">
<div class="sw-chat-avatar" data-username="${escapeHtml(who)}"></div>
<div class="sw-chat-meta">${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}</div>
</div>
<div class="sw-chat-text">${escapeHtml(body)}</div>
</div>
`;
})
.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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 `
<div class="sw-modal-tags">
${uniq.map((t) => `<span class="sw-modal-tag">#${escapeHtml(t)}</span>`).join("")}
</div>
`;
}
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 `<div class="sw-cluster-source-empty">No related reports yet.</div>`;
}
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 ? `<span class="sw-cluster-source">${source}</span>` : "";
const href = url ? ` data-related-url="${url}"` : "";
return `
<div class="sw-cluster-source-item"${href}>
<div class="sw-cluster-source-title">${title}</div>
${meta}
</div>
`;
});
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
? `<div class="sw-stat-pill sw-heatmap-pill" data-action="cluster-heatmap">🔥 Heat map</div>`
: "";
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 = `
<div class="post-card sw-expanded-shell sw-modal sw-cluster-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
<div class="sw-modal-toprow">
<div class="sw-modal-left">
<div class="sw-modal-badge">MAIN NEWS</div>
<div class="sw-modal-meta">
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span>• ${escapeHtml(relTime)}</span>
</div>
</div>
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
</div>
<div class="sw-cluster-title">${escapeHtml(postData?.cluster_title || postData?.title || "Untitled")}</div>
${tagPills}
<div class="sw-cluster-body">
<div class="sw-cluster-left">
<div class="sw-cluster-hero">
<div class="sw-truth-rail" data-score="${truth}">
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
<div class="sw-truth-rail-bar">
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
</div>
<div class="sw-truth-rail-score">${truth}</div>
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
</div>
<div class="sw-cluster-hero-media">
${
img
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
}
</div>
</div>
<div class="sw-cluster-actions">
<button class="post-card-btn" type="button" data-action="like">Like</button>
<button class="post-card-btn" type="button" data-action="share">Share</button>
<button class="post-card-btn" type="button">Fix</button>
</div>
</div>
<div class="sw-cluster-right">
<div class="sw-cluster-related-title">Related reports ${clusterCount ? `(${clusterCount})` : ""}</div>
<div class="sw-cluster-related-list">
${buildClusterItemsHtml(clusterItems)}
</div>
<div class="sw-cluster-generated">Generated news update</div>
<div class="sw-cluster-summary">
${escapeHtml(data?.summary || postData?.snippet || "")}
</div>
</div>
</div>
<div class="sw-stat-row">
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
${heatmapPill}
</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">— loading…</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>
`;
} else {
modalWrap.innerHTML = `
<div class="post-card sw-expanded-shell sw-modal" data-template="${escapeHtml(templateKey)}" data-category="${escapeHtml((postData?.category || "").toString().toUpperCase())}">
<div class="sw-modal-toprow">
<div class="sw-modal-left">
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
<div class="sw-modal-meta">
${author ? `<div class="sw-modal-author-avatar" data-username="${escapeHtml(author)}"></div>` : ""}
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span>• ${escapeHtml(relTime)}</span>
</div>
</div>
<button class="sw-modal-x" type="button" aria-label="Close">✕</button>
</div>
<div class="sw-modal-hero-row">
<div class="sw-truth-rail" data-score="${truth}">
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
<div class="sw-truth-rail-bar">
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
</div>
<div class="sw-truth-rail-score">${truth}</div>
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
</div>
<div class="sw-modal-hero">
${
img
? `<img src="${escapeHtml(img)}" data-fullsrc="${escapeHtml(fullImageForPost(postData))}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
}
</div>
</div>
<div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
${tagPills}
<div class="sw-modal-summary">
${escapeHtml(data?.summary || postData?.snippet || "")}
</div>
<div class="sw-stat-row">
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
<div class="sw-stat-pill" data-stat="shares">🔁 <span class="sw-stat-num">${formatCount(shareCount)}</span></div>
<div class="sw-stat-pill" data-stat="comments">💬 <span class="sw-stat-num">${formatCount(commentCount)}</span></div>
${heatmapPill}
</div>
<div class="sw-modal-cta-row">
<button class="sw-cta-primary" type="button" ${url ? "" : "disabled"} data-url="${escapeHtml(url)}">
Open source
</button>
<div class="sw-cta-actions">
<button class="post-card-btn" type="button">Contact</button>
<button class="post-card-btn" type="button" data-action="like">Like</button>
<button class="post-card-btn" type="button" data-action="share">Share</button>
<button class="post-card-btn" type="button">Fix</button>
</div>
</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">— loading…</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>
`;
}
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 = `
<div class="sw-template-mini-wrap"></div>
<div class="post-pin-pointer-small"></div>
${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 = `
<div class="sw-simple-marker" style="
width: 32px;
height: 32px;
border-radius: 50%;
background: ${color};
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
z-index: 2;
">
${img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
<div style="
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
opacity: 0.9;
"></div>
`}
</div>
<div class="sw-simple-pin-point" style="
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 16px solid ${color};
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
margin-top: -2px;
z-index: 1;
"></div>
<div class="sw-simple-hover-card" style="
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-6px) scale(0.9);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 1000;
min-width: 200px;
max-width: 280px;
background: rgba(8, 20, 40, 0.97);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 10px 12px;
border: 1px solid rgba(90, 190, 255, 0.7);
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
white-space: normal;
">
<div style="font-weight: 900; font-size: 12px; color: #e8f5ff; margin-bottom: 4px;">
${escapeHtml(activePost?.title || "Untitled")}
</div>
<div style="font-size: 10px; color: rgba(232, 245, 255, 0.8);">
${escapeHtml((activePost?.snippet || "").substring(0, 80))}${(activePost?.snippet || "").length > 80 ? "..." : ""}
</div>
<div style="margin-top: 6px; font-size: 9px; color: rgba(232, 245, 255, 0.6);">
${normCatLabel(activePost)}${formatRelativeTime(activePost?.created_at || activePost?.CreatedAt)}
</div>
</div>
`;
}
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 = `
<div class="island-marker-container" style="
display: flex;
flex-direction: column;
align-items: center;
position: relative;
cursor: pointer;
">
<div class="island-avatar" style="
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: 3px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
">
${island.avatar_url
? `<img src="${island.avatar_url}" style="width:100%;height:100%;object-fit:cover;" alt="${island.username}" />`
: `<div style="color:white;font-size:32px;font-weight:bold;">${island.username[0].toUpperCase()}</div>`
}
</div>
<div class="island-badge" style="
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
background: rgba(102, 126, 234, 0.95);
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: bold;
letter-spacing: 0.5px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
">🏝️ ISLAND</div>
<div class="island-username" style="
margin-top: 8px;
text-align: center;
font-weight: bold;
color: white;
font-size: 12px;
text-shadow: 0 2px 4px rgba(0,0,0,0.6);
">@${island.username}</div>
<div class="island-pin-point" style="
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 20px solid rgba(102, 126, 234, 0.8);
margin: 0 auto;
margin-top: 4px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
"></div>
</div>
`;
// 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]);
}