- 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;
|
||||
}
|
||||
|
||||
// #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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue