v2: 3D globe on zoom-out (#223) + colored news-cluster zones (#224)

- useMapCore: MapLibre v5 globe projection in style spec (auto flat on zoom-in)
- EventClustersLayer: per-cluster color by dominant impact (green/red) or stable
  per-event hue; opacity/width scale with post_count; enabled by default
- MapView: clusterZonesEnabled state + search-bar toggle
(#222's 24h default + filter-follows-map-move already present in source)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
canastack-platform 2026-07-05 15:45:49 +00:00
parent 1eaa2f0ac9
commit e74bb448b0
3 changed files with 84 additions and 11 deletions

View File

@ -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,
},
});

View File

@ -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 && (
<EventClustersLayer
map={mapRef.current}
enabled={contentFilter === "cluster"}
enabled={clusterZonesEnabled || contentFilter === "cluster"}
onClusterPostIds={setClusterPostIds}
onClusterClick={(cluster) => {
// Pan to cluster and potentially show details
@ -2228,6 +2244,15 @@ export default function MapView({
>
<i className="fa-solid fa-share-nodes"></i>
</button>
<button
type="button"
className={"sw-search__iconBtn" + (clusterZonesEnabled ? " is-active" : "")}
title={clusterZonesEnabled ? "Hide news zones" : "Show news zones"}
aria-pressed={clusterZonesEnabled}
onClick={() => setClusterZonesEnabled((v) => !v)}
>
<i className="fa-solid fa-layer-group"></i>
</button>
<button
type="button"
className="sw-search__iconBtn"

View File

@ -23,6 +23,10 @@ function getBaseStyle(theme) {
return {
version: 8,
// #223: real 3D globe when zoomed out (MapLibre v5 globe projection
// auto-transitions to a flat mercator map as you zoom in). Declaring it in
// the style spec keeps it applied across theme changes (setStyle).
projection: { type: "globe" },
sources: {
"carto-base": {
type: "raster",