diff --git a/src/components/Map/EventClustersLayer.jsx b/src/components/Map/EventClustersLayer.jsx index 937cb2c..6c28f5c 100644 --- a/src/components/Map/EventClustersLayer.jsx +++ b/src/components/Map/EventClustersLayer.jsx @@ -109,9 +109,50 @@ function smoothPolygon(points) { return result; } +// #224: derive a stable, distinct color per cluster from its topical identity +// (event_key / title / dominant subject). Two unrelated events (e.g. a war vs a +// natural disaster) therefore render as two clearly different colored regions. +// If the cluster carries an explicit market impact (green/red), honor it. +function hashString(str) { + let h = 0; + const s = String(str || ""); + for (let i = 0; i < s.length; i++) { + h = (h << 5) - h + s.charCodeAt(i); + h |= 0; + } + return Math.abs(h); +} + +function dominantImpact(cluster) { + const posts = Array.isArray(cluster?.member_posts) ? cluster.member_posts : []; + let green = 0; + let red = 0; + for (const p of posts) { + const imp = (p?.impact || p?.asset_impact || "").toString().toLowerCase(); + if (imp === "green" || imp === "positive" || imp === "bull") green++; + else if (imp === "red" || imp === "negative" || imp === "bear") red++; + } + const top = (cluster?.impact || "").toString().toLowerCase(); + if (top === "green" || top === "positive") return "green"; + if (top === "red" || top === "negative") return "red"; + if (green === 0 && red === 0) return ""; + return green >= red ? "green" : "red"; +} + +function clusterColor(cluster) { + const impact = dominantImpact(cluster); + if (impact === "green") return "#22c55e"; + if (impact === "red") return "#ef4444"; + // Distinct hue per topic/event so related news share one color region. + const seed = cluster?.event_key || cluster?.subject || cluster?.title || cluster?.id; + const hue = hashString(seed) % 360; + return `hsl(${hue}, 72%, 58%)`; +} + // Create organic "lake" shape from cluster member posts function clusterToPolygon(cluster) { const { id, title, post_count, member_posts, bounding_box } = cluster; + const color = clusterColor(cluster); // Get valid points from member posts let points = []; @@ -183,6 +224,7 @@ function clusterToPolygon(cluster) { id, title, post_count, + color, }, geometry: { type: "Polygon", @@ -199,6 +241,7 @@ function clusterToPoint(cluster) { id: cluster.id, title: cluster.title?.slice(0, 35) + (cluster.title?.length > 35 ? "..." : "") || "Cluster", post_count: cluster.post_count, + color: clusterColor(cluster), }, geometry: { type: "Point", @@ -333,13 +376,14 @@ export default function EventClustersLayer({ map, enabled = true, onClusterClick "visibility": "none", }, paint: { - "fill-color": [ + // #224: color each region by its dominant subject/impact (per-cluster) + "fill-color": ["coalesce", ["get", "color"], "#60a5fa"], + "fill-opacity": [ "interpolate", ["linear"], ["get", "post_count"], - 2, "#60a5fa", // lighter blue - 5, "#a78bfa", // softer purple - 10, "#f472b6", // softer pink + 2, 0.22, + 10, 0.34, + 40, 0.45, ], - "fill-opacity": 0.35, }, }); @@ -352,13 +396,13 @@ export default function EventClustersLayer({ map, enabled = true, onClusterClick "visibility": "none", }, paint: { - "line-color": [ + "line-color": ["coalesce", ["get", "color"], "#93c5fd"], + "line-width": [ "interpolate", ["linear"], ["get", "post_count"], - 2, "#93c5fd", // even lighter for border - 5, "#c4b5fd", - 10, "#f9a8d4", + 2, 1.5, + 40, 3, ], - "line-width": 2, + "line-opacity": 0.85, "line-blur": 1, }, }); diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index bdb5234..5067c0b 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -244,6 +244,22 @@ export default function MapView({ } }); + // #224: colored news-cluster zones on the map (default ON). + const CLUSTER_ZONES_KEY = "sociowire:clusterZones"; + const [clusterZonesEnabled, setClusterZonesEnabled] = useState(() => { + try { + const saved = localStorage.getItem(CLUSTER_ZONES_KEY); + return saved === null ? true : saved === "true"; + } catch { + return true; + } + }); + useEffect(() => { + try { + localStorage.setItem(CLUSTER_ZONES_KEY, clusterZonesEnabled ? "true" : "false"); + } catch {} + }, [clusterZonesEnabled]); + const debugEnabled = (() => { try { return typeof window !== "undefined" && @@ -2122,7 +2138,7 @@ export default function MapView({ {viewParams.center && ( { // Pan to cluster and potentially show details @@ -2228,6 +2244,15 @@ export default function MapView({ > +