diff --git a/shared/map-watch.mjs b/shared/map-watch.mjs index e8799f78..0ab05d36 100644 --- a/shared/map-watch.mjs +++ b/shared/map-watch.mjs @@ -276,6 +276,49 @@ function clusterPopup(entry) { `; } +// ── 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) { @@ -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; @@ -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 }; diff --git a/styles.css b/styles.css index 9ecd9260..6b0bf1c5 100644 --- a/styles.css +++ b/styles.css @@ -1338,7 +1338,8 @@ 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; @@ -1346,8 +1347,10 @@ h1 { 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; } @@ -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); @@ -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); @@ -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); @@ -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); diff --git a/tests/map-coincident-spread.test.mjs b/tests/map-coincident-spread.test.mjs new file mode 100644 index 00000000..18ff4ad5 --- /dev/null +++ b/tests/map-coincident-spread.test.mjs @@ -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'); + }); +});