- 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:
parent
1eaa2f0ac9
commit
e74bb448b0
|
|
@ -109,9 +109,50 @@ function smoothPolygon(points) {
|
||||||
return result;
|
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
|
// Create organic "lake" shape from cluster member posts
|
||||||
function clusterToPolygon(cluster) {
|
function clusterToPolygon(cluster) {
|
||||||
const { id, title, post_count, member_posts, bounding_box } = cluster;
|
const { id, title, post_count, member_posts, bounding_box } = cluster;
|
||||||
|
const color = clusterColor(cluster);
|
||||||
|
|
||||||
// Get valid points from member posts
|
// Get valid points from member posts
|
||||||
let points = [];
|
let points = [];
|
||||||
|
|
@ -183,6 +224,7 @@ function clusterToPolygon(cluster) {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
post_count,
|
post_count,
|
||||||
|
color,
|
||||||
},
|
},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "Polygon",
|
type: "Polygon",
|
||||||
|
|
@ -199,6 +241,7 @@ function clusterToPoint(cluster) {
|
||||||
id: cluster.id,
|
id: cluster.id,
|
||||||
title: cluster.title?.slice(0, 35) + (cluster.title?.length > 35 ? "..." : "") || "Cluster",
|
title: cluster.title?.slice(0, 35) + (cluster.title?.length > 35 ? "..." : "") || "Cluster",
|
||||||
post_count: cluster.post_count,
|
post_count: cluster.post_count,
|
||||||
|
color: clusterColor(cluster),
|
||||||
},
|
},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "Point",
|
type: "Point",
|
||||||
|
|
@ -333,13 +376,14 @@ export default function EventClustersLayer({ map, enabled = true, onClusterClick
|
||||||
"visibility": "none",
|
"visibility": "none",
|
||||||
},
|
},
|
||||||
paint: {
|
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"],
|
"interpolate", ["linear"], ["get", "post_count"],
|
||||||
2, "#60a5fa", // lighter blue
|
2, 0.22,
|
||||||
5, "#a78bfa", // softer purple
|
10, 0.34,
|
||||||
10, "#f472b6", // softer pink
|
40, 0.45,
|
||||||
],
|
],
|
||||||
"fill-opacity": 0.35,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -352,13 +396,13 @@ export default function EventClustersLayer({ map, enabled = true, onClusterClick
|
||||||
"visibility": "none",
|
"visibility": "none",
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": [
|
"line-color": ["coalesce", ["get", "color"], "#93c5fd"],
|
||||||
|
"line-width": [
|
||||||
"interpolate", ["linear"], ["get", "post_count"],
|
"interpolate", ["linear"], ["get", "post_count"],
|
||||||
2, "#93c5fd", // even lighter for border
|
2, 1.5,
|
||||||
5, "#c4b5fd",
|
40, 3,
|
||||||
10, "#f9a8d4",
|
|
||||||
],
|
],
|
||||||
"line-width": 2,
|
"line-opacity": 0.85,
|
||||||
"line-blur": 1,
|
"line-blur": 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 = (() => {
|
const debugEnabled = (() => {
|
||||||
try {
|
try {
|
||||||
return typeof window !== "undefined" &&
|
return typeof window !== "undefined" &&
|
||||||
|
|
@ -2122,7 +2138,7 @@ export default function MapView({
|
||||||
{viewParams.center && (
|
{viewParams.center && (
|
||||||
<EventClustersLayer
|
<EventClustersLayer
|
||||||
map={mapRef.current}
|
map={mapRef.current}
|
||||||
enabled={contentFilter === "cluster"}
|
enabled={clusterZonesEnabled || contentFilter === "cluster"}
|
||||||
onClusterPostIds={setClusterPostIds}
|
onClusterPostIds={setClusterPostIds}
|
||||||
onClusterClick={(cluster) => {
|
onClusterClick={(cluster) => {
|
||||||
// Pan to cluster and potentially show details
|
// Pan to cluster and potentially show details
|
||||||
|
|
@ -2228,6 +2244,15 @@ export default function MapView({
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-share-nodes"></i>
|
<i className="fa-solid fa-share-nodes"></i>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sw-search__iconBtn"
|
className="sw-search__iconBtn"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ function getBaseStyle(theme) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 8,
|
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: {
|
sources: {
|
||||||
"carto-base": {
|
"carto-base": {
|
||||||
type: "raster",
|
type: "raster",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue