Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions shared/map-watch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,49 @@ function clusterPopup(entry) {
</div>`;
}

// ── Phyllotaxis spread for coincident alerts ──
// Many alerts fall back to regional coordinates (see shared/geo-fallback-coords.mjs:
// EU/international/default → 50,10; UK → 54.5,-2.5; London → 51.5074,-0.1278), which
// causes their dots to stack on a single pixel. We detect groups that share a
// lat/lng to within COINCIDENT_COORD_PRECISION and spread them on a golden-angle
// sunflower spiral so each marker gets its own pixel at any zoom level. The pattern
// is deterministic (sorted by alert id) so dots don't jump around between renders.
const GOLDEN_ANGLE_RAD = Math.PI * (3 - Math.sqrt(5));
const METERS_PER_DEGREE_LAT = 111320;
const SUNFLOWER_STEP_METERS = 220;
const COINCIDENT_COORD_PRECISION = 4;

export function spreadCoincidentAlerts(alerts, { precision = COINCIDENT_COORD_PRECISION, stepMeters = SUNFLOWER_STEP_METERS } = {}) {
if (!Array.isArray(alerts) || alerts.length <= 1) return alerts;
const groups = new Map();
alerts.forEach((alert, index) => {
const lat = Number(alert?.lat);
const lng = Number(alert?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
const key = `${lat.toFixed(precision)},${lng.toFixed(precision)}`;
let bucket = groups.get(key);
if (!bucket) { bucket = []; groups.set(key, bucket); }
bucket.push(index);
});
const result = alerts.slice();
groups.forEach((indices) => {
if (indices.length <= 1) return;
indices.sort((a, b) => String(alerts[a]?.id ?? a).localeCompare(String(alerts[b]?.id ?? b)));
const baseLat = Number(alerts[indices[0]].lat);
const baseLng = Number(alerts[indices[0]].lng);
const metersPerDegreeLng = METERS_PER_DEGREE_LAT * Math.max(Math.cos((baseLat * Math.PI) / 180), 0.01); // floor guards polar latitudes where cos→0
indices.forEach((idx, k) => {
if (k === 0) return; // first item stays at origin
const angle = k * GOLDEN_ANGLE_RAD;
const radius = stepMeters * Math.sqrt(k);
const dLat = (radius * Math.cos(angle)) / METERS_PER_DEGREE_LAT;
const dLng = (radius * Math.sin(angle)) / metersPerDegreeLng;
result[idx] = { ...alerts[idx], lat: baseLat + dLat, lng: baseLng + dLng };
});
});
return result;
}

const SEVERITY_RANK = Object.freeze({ critical: 3, high: 2, elevated: 1, moderate: 0 });

function clusterSeverity(items) {
Expand Down Expand Up @@ -575,7 +618,8 @@ export function createMapController(config) {
lastState = state;
lastView = view;
const mode = resolveMapMode(state.mapViewMode);
const items = view.filtered.filter((alert) => Number.isFinite(alert.lat) && Number.isFinite(alert.lng));
const rawItems = view.filtered.filter((alert) => Number.isFinite(alert.lat) && Number.isFinite(alert.lng));
const items = spreadCoincidentAlerts(rawItems);
const signature = `${mode}:${liveMap.getZoom()}:${items.map((item) => `${item.id}:${(item.lat ?? 0).toFixed(3)},${(item.lng ?? 0).toFixed(3)}`).join('|')}`;
if (!forceFit && signature === lastSignature) return;
lastSignature = signature;
Expand Down Expand Up @@ -708,4 +752,4 @@ export function createMapController(config) {
};
}

export { markerPopup as _markerPopup, clusterPopup as _clusterPopup, SEVERITY_LEGEND_ITEMS as _SEVERITY_LEGEND_ITEMS, TILE_LIGHT as _TILE_LIGHT, TILE_DARK as _TILE_DARK, CLUSTER_FLY_DURATION as _CLUSTER_FLY_DURATION, clusterSeverity as _clusterSeverity, statusLine as _statusLine, normaliseCountryName as _normaliseCountryName, vignetteLevel as _vignetteLevel };
export { markerPopup as _markerPopup, clusterPopup as _clusterPopup, SEVERITY_LEGEND_ITEMS as _SEVERITY_LEGEND_ITEMS, TILE_LIGHT as _TILE_LIGHT, TILE_DARK as _TILE_DARK, CLUSTER_FLY_DURATION as _CLUSTER_FLY_DURATION, clusterSeverity as _clusterSeverity, statusLine as _statusLine, normaliseCountryName as _normaliseCountryName, vignetteLevel as _vignetteLevel, spreadCoincidentAlerts as _spreadCoincidentAlerts };
13 changes: 8 additions & 5 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1338,16 +1338,19 @@ h1 {
/* ── Aesthetic: dot glow ──
Soft coloured halo lifts each severity dot off the desaturated tiles,
giving depth without clutter. The halo colour inherits from the dot's
background via a layered box-shadow. */
background via a layered box-shadow. A subtle inner radial highlight
("gloss") and a faint outer ring add depth without extra DOM. */
.map-dot {
position: relative;
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.92);
background-image: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0.12) 38%, rgba(255, 255, 255, 0) 62%);
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.28),
0 0 0 3px rgba(255, 255, 255, 0.10),
0 0 6px 2px rgba(255, 255, 255, 0.18);
transition: box-shadow 0.25s ease, transform 0.25s ease;
}
Expand Down Expand Up @@ -1377,7 +1380,7 @@ h1 {
}

.map-dot--critical {
background: #dc2626;
background-color: #dc2626;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.28),
0 0 8px 3px rgba(220, 38, 38, 0.45);
Expand All @@ -1389,7 +1392,7 @@ h1 {
}

.map-dot--high {
background: #ea580c;
background-color: #ea580c;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.28),
0 0 8px 3px rgba(234, 88, 12, 0.40);
Expand All @@ -1401,7 +1404,7 @@ h1 {
}

.map-dot--elevated {
background: #d97706;
background-color: #d97706;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.28),
0 0 8px 3px rgba(217, 119, 6, 0.35);
Expand All @@ -1413,7 +1416,7 @@ h1 {
}

.map-dot--moderate {
background: #0284c7;
background-color: #0284c7;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.28),
0 0 8px 3px rgba(2, 132, 199, 0.35);
Expand Down
103 changes: 103 additions & 0 deletions tests/map-coincident-spread.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { _spreadCoincidentAlerts as spreadCoincidentAlerts } from '../shared/map-watch.mjs';

describe('spreadCoincidentAlerts', () => {
it('returns input unchanged when alerts have distinct coordinates', () => {
const alerts = [
{ id: 'a', lat: 51.5, lng: -0.1 },
{ id: 'b', lat: 40.7, lng: -74.0 },
{ id: 'c', lat: 48.8, lng: 2.3 }
];
const result = spreadCoincidentAlerts(alerts);
assert.equal(result.length, alerts.length);
result.forEach((alert, i) => {
assert.equal(alert.lat, alerts[i].lat);
assert.equal(alert.lng, alerts[i].lng);
});
});

it('spreads coincident alerts so every marker has distinct coordinates', () => {
const alerts = Array.from({ length: 12 }, (_, i) => ({
id: `dup-${i}`,
title: `Alert ${i}`,
lat: 50,
lng: 10
}));
const result = spreadCoincidentAlerts(alerts);
const keys = new Set(result.map((a) => `${a.lat.toFixed(6)},${a.lng.toFixed(6)}`));
assert.equal(keys.size, alerts.length, 'Every spread alert should sit on a unique lat/lng');
});

it('keeps spread alerts close to the original location (< ~1.5km)', () => {
const alerts = Array.from({ length: 20 }, (_, i) => ({ id: `x${i}`, lat: 50, lng: 10 }));
const result = spreadCoincidentAlerts(alerts);
result.forEach((alert) => {
// Crude degrees->meters using lat degree length; good enough for bound assertion.
const dLat = (alert.lat - 50) * 111320;
const dLng = (alert.lng - 10) * 111320 * Math.cos((50 * Math.PI) / 180);
const distance = Math.hypot(dLat, dLng);
assert.ok(distance < 1500, `Expected offset under 1500m, got ${distance.toFixed(1)}m`);
});
});

it('is deterministic across repeated calls', () => {
const alerts = Array.from({ length: 8 }, (_, i) => ({ id: `z${i}`, lat: 54.5, lng: -2.5 }));
const a = spreadCoincidentAlerts(alerts);
const b = spreadCoincidentAlerts(alerts);
a.forEach((alert, i) => {
assert.equal(alert.lat, b[i].lat);
assert.equal(alert.lng, b[i].lng);
});
});

it('preserves non-coordinate fields on spread alerts', () => {
const alerts = [
{ id: '1', title: 'One', severity: 'high', lat: 50, lng: 10 },
{ id: '2', title: 'Two', severity: 'critical', lat: 50, lng: 10 }
];
const result = spreadCoincidentAlerts(alerts);
assert.equal(result[0].title, 'One');
assert.equal(result[0].severity, 'high');
assert.equal(result[1].title, 'Two');
assert.equal(result[1].severity, 'critical');
});

it('leaves alerts with different coordinates alone when mixed with coincident ones', () => {
const alerts = [
{ id: 'unique', lat: 40.7, lng: -74.0 },
{ id: 'a', lat: 50, lng: 10 },
{ id: 'b', lat: 50, lng: 10 },
{ id: 'c', lat: 50, lng: 10 }
];
const result = spreadCoincidentAlerts(alerts);
assert.equal(result[0].lat, 40.7);
assert.equal(result[0].lng, -74.0);
// The three coincident ones must end up on distinct coordinates.
const coincidentKeys = new Set(result.slice(1).map((a) => `${a.lat.toFixed(6)},${a.lng.toFixed(6)}`));
assert.equal(coincidentKeys.size, 3);
});

it('handles empty / single-item / invalid-coord inputs safely', () => {
assert.deepEqual(spreadCoincidentAlerts([]), []);
assert.deepEqual(spreadCoincidentAlerts([{ id: 'solo', lat: 1, lng: 2 }]), [{ id: 'solo', lat: 1, lng: 2 }]);
const withNaN = [
{ id: 'a', lat: NaN, lng: 10 },
{ id: 'b', lat: 50, lng: 10 },
{ id: 'c', lat: 50, lng: 10 }
];
const result = spreadCoincidentAlerts(withNaN);
// The NaN alert is skipped (not grouped); the two valid coincident ones get distinct lat/lng.
assert.ok(Number.isNaN(result[0].lat));
assert.notEqual(`${result[1].lat},${result[1].lng}`, `${result[2].lat},${result[2].lng}`);
});

it('returns the same array reference contents (does not mutate input alerts)', () => {
const original = { id: 'a', lat: 50, lng: 10, title: 'keep me' };
const alerts = [original, { id: 'b', lat: 50, lng: 10 }];
spreadCoincidentAlerts(alerts);
assert.equal(original.lat, 50);
assert.equal(original.lng, 10);
assert.equal(original.title, 'keep me');
});
});