From 16b1bc7b74f23d5c68abdd5ab065a39eb979de20 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 10 Nov 2026 22:28:08 +0100 Subject: [PATCH] feat(news): larger markers, and a country's own news as one badge with a count A report placed no closer than a country was a marker at the country's centre, so a country's news piled there. They are now one disc with the number of reports, named, and a click opens the Local list over the country. Markers are 24 px at least: the category icon was unreadable at 16. Co-Authored-By: Claude Fable 5.2 --- src/layers/news/index.js | 78 +++++++++++++++++++++++++++++++++- src/layers/news/layer.test.mjs | 43 +++++++++++++++++++ src/layers/news/model.js | 30 ++++++++++++- src/layers/news/model.test.mjs | 3 ++ src/layers/news/sprites.js | 43 +++++++++++++++++++ 5 files changed, 195 insertions(+), 2 deletions(-) diff --git a/src/layers/news/index.js b/src/layers/news/index.js index 65a653dd7..6749b3501 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -45,6 +45,8 @@ import { cardEvents, cardView, categoryLabel, + countryGroups, + countryOnly, disputedLabel, feedCardView, feedQueryFor, @@ -71,7 +73,7 @@ import { visibleOnMap, withinNewsWindow, } from './model.js'; -import { SPRITE_SCALE, markerSprite } from './sprites.js'; +import { SPRITE_SCALE, countSprite, markerSprite } from './sprites.js'; import { CARD_H, CARD_W, eventCard } from './cards.js'; export * from './model.js'; export { createNewsSource } from './source.js'; @@ -117,6 +119,9 @@ const REPORT_PREFIX = `${NEWS_PICK_PREFIX}report:`; const HOTSPOT_PREFIX = `${NEWS_PICK_PREFIX}hotspot:`; const DEVELOPING_PREFIX = `${NEWS_PICK_PREFIX}developing:`; const LOCAL_PREFIX = `${NEWS_PICK_PREFIX}local:`; +const COUNTRY_PREFIX = `${NEWS_PICK_PREFIX}country:`; +// A country badge carries a number: large enough to read two digits. +const COUNTRY_BADGE_PX = 34; // Outlines in GEV's own colours, apart from the severity ramp: hotspots in // the accent, developing places in its lighter tint, disputed areas dotted // in the primary text colour, the Local section's area a faint line of it. @@ -222,6 +227,8 @@ export function createNewsLayer({ selectedPin = 0; let shownDetail = null; let cullTargets = []; + // The country badges drawn, by key: a click opens that country's news. + let countryBadges = new Map(); const contextEntities = new Map(); // Credits are registered once what they credit first shows; the registry // keeps them for the session (as TomTom's) and has no removal. @@ -492,6 +499,8 @@ export function createNewsLayer({ for (const report of drawn) { const isSelected = report.id === selectedId; if (!report.pins.length || (!isSelected && !onMap(report))) continue; + // A country's own news has no spot: its badge stands for it. + if (!isSelected && countryOnly(report)) continue; // One marker per pin; the pin index rides on the pick id. Fill is // severity, the icon the category, the rim attestation; a marker grows // with the sources reporting its event and fades with age. A fresh, @@ -551,6 +560,59 @@ export function createNewsLayer({ } } + // News placed no closer than a country: one badge at its centre with the + // number of reports, in place of that many markers piled there. + function renderCountryBadges(entities, drawn, labels) { + countryBadges = new Map(); + const groups = countryGroups( + drawn.filter((r) => r.id !== selectedId && onMap(r)), + ); + for (const group of groups) { + countryBadges.set(group.key, group); + const position = cesium.Cartesian3.fromDegrees(group.lon, group.lat); + const size = COUNTRY_BADGE_PX; + const sprite = countSprite( + group.count, + size * (viewer?.scene?.pixelRatio || 1), + ); + const color = cesium.Color.fromCssColorString(DISPUTED_COLOR); + entities.add({ + id: `${COUNTRY_PREFIX}${group.key}`, + position, + ...(sprite + ? { + billboard: { + image: sprite, + width: Math.round(size * SPRITE_SCALE), + height: Math.round(size * SPRITE_SCALE), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + } + : { + point: { + pixelSize: size, + color: color.withAlpha(0.3), + outlineColor: color, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }), + }); + labels.push( + ambientLabel({ + id: `country:${group.key}`, + position, + // The badge carries the count; the label's line stops at its rim. + title: group.place, + leaderOffsetPx: Math.round((size * SPRITE_SCALE) / 2), + accent: DISPUTED_COLOR, + priority: 9_000 + group.count, + gapPx: size / 2 + 4, + }), + ); + } + } + // The big events stand as picture cards over their marker, never two on // top of each other. The clicked event shows its picture card under its // marker, with its text card above. @@ -693,6 +755,7 @@ export function createNewsLayer({ ? [...shown, current] : shown; renderMarkers(entities, drawn); + renderCountryBadges(entities, drawn, labels); if (enabled) creditReports(drawn.filter((r) => r.pins.length && onMap(r))); entities.resumeEvents(); showOutlines(detail); @@ -1209,6 +1272,19 @@ export function createNewsLayer({ } return; } + if (typeof id === 'string' && id.startsWith(COUNTRY_PREFIX)) { + // The country's news is the Local list with the view over it. + const group = countryBadges.get(id.slice(COUNTRY_PREFIX.length)); + if (!group) return; + layer.setParams({ filters: { section: 'local' } }); + focusRectangle([ + group.lon - 5, + Math.max(-85, group.lat - 3.5), + group.lon + 5, + Math.min(85, group.lat + 3.5), + ]); + return; + } if (typeof id === 'string' && id.startsWith(HOTSPOT_PREFIX)) { const hotspotId = id.slice(HOTSPOT_PREFIX.length).split('~')[0]; // Clicking the hotspot already in focus steps out of a report. diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index 0c0d60bf6..3a677d4db 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -841,6 +841,49 @@ test('a failed Conflicts read leaves Local reading with each refresh', async (t) assert.equal(view.error, 'News feed unavailable'); }); +test("a country's own news is one badge with a count, not markers piled on its centre", async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const anchor = { + lat: 49, + lon: 36, + place: 'Ukraine', + precision: 'country', + mappable: true, + }; + const { layer, viewer } = fixture(t, { + height: 1_000_000, + source: { + getFeed: async () => ({ + reports: [ + report('own-1', { relevance: 'minor', pins: [anchor] }), + report('own-2', { relevance: 'minor', pins: [anchor] }), + report('placed', { relevance: 'minor' }), + ], + nextBefore: null, + }), + }, + }); + const ids = () => + viewer.added[1].entities.values.map((entity) => String(entity.id)); + layer.enable(); + await layer.update(); + layer.setParams({ filters: { section: 'local' } }); + await flush(); + assert.deepEqual( + ids().filter((id) => /~\d+$/.test(id)), + ['news:report:placed~0'], + ); + assert.deepEqual( + ids().filter((id) => id.startsWith('news:country:')), + ['news:country:Ukraine'], + ); + // Both are still listed, and one picked there gets its own marker. + assert.equal(layer.getRowControls().feed.inView.items.length, 3); + layer.setParams({ reportId: 'own-1' }); + await flush(); + assert.ok(ids().includes('news:report:own-1~0')); +}); + test('Local reports are listed an hour at most while they cannot be read again', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); let at = NOW; diff --git a/src/layers/news/model.js b/src/layers/news/model.js index 22bcf41aa..3f4aafc94 100644 --- a/src/layers/news/model.js +++ b/src/layers/news/model.js @@ -404,7 +404,9 @@ export const beaconPixelSize = (last24h) => /** Marker size for an event: grows with the number of distinct sources. */ export function eventPixelSize(sources = 1) { return Math.round( - Math.min(30, 16 + 3.5 * Math.log2(Math.max(1, Number(sources) || 1))), + // No smaller than 24: the category icon is half the disc and unreadable + // below that. + Math.min(36, 24 + 3 * Math.log2(Math.max(1, Number(sources) || 1))), ); } @@ -593,6 +595,32 @@ export function feedCardView(report, now = Date.now()) { }; } +/** + * Whether a report is placed no closer than a country: it has no spot of its + * own on the map. + */ +export const countryOnly = (report) => + report.pins?.length > 0 && + report.pins.every((pin) => pin.precision === 'country'); + +/** + * Country-only reports as one group per country, in the order met: + * `{key, place, lat, lon, count}`. They are drawn as one badge with a count, + * not as markers piled on the country's centre. + */ +export function countryGroups(reports) { + const groups = new Map(); + for (const report of reports) { + if (!countryOnly(report)) continue; + const { lat, lon, place } = report.pins[0]; + const key = place || `${lat.toFixed(1)},${lon.toFixed(1)}`; + const group = groups.get(key); + if (group) group.count += 1; + else groups.set(key, { key, place: place || '', lat, lon, count: 1 }); + } + return [...groups.values()]; +} + /** * Marker position for a report. Many reports name the same city and would * stack on one centroid, so each is spread on a small deterministic ring diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs index 899c1f3c1..85d1aac76 100644 --- a/src/layers/news/model.test.mjs +++ b/src/layers/news/model.test.mjs @@ -519,6 +519,9 @@ test('marker style: severity fill, attestation rim, size by sources, fading with assert.equal(fresh.severity, 3); // the event's, when higher than the report's assert.equal(fresh.rim, 'claimed'); assert.equal(fresh.size, eventPixelSize(4)); + // The icon is half the disc: no marker is too small to read it. + assert.equal(eventPixelSize(1), 24); + assert.equal(eventPixelSize(1000), 36); assert.equal(fresh.alpha, 1); assert.equal(markerStyle(r, NOW + 48 * 3_600_000).alpha, 0.45); assert.equal(reportSeverity({ category: 'political' }), 0); diff --git a/src/layers/news/sprites.js b/src/layers/news/sprites.js index 7c7aa3a0d..6fb047999 100644 --- a/src/layers/news/sprites.js +++ b/src/layers/news/sprites.js @@ -9,6 +9,7 @@ const CANVAS = 64; const BODY = 52; // disc diameter inside the canvas; the rest is rim and ring const INK = 'rgba(10, 10, 15, 0.92)'; const SELECTED = '#00d4ff'; +const COUNT_INK = '#e8eaed'; /** Canvas pixels per marker pixel: the billboard is drawn at this ratio. */ export const SPRITE_SCALE = CANVAS / BODY; @@ -86,3 +87,45 @@ export function markerSprite({ category, severity, rim, selected = false }) { cache.set(key, url); return url; } + +/** + * Data URL of a country badge: the dark disc with a light rim and the number + * of reports it stands for, drawn at the pixels it is shown at so the digits + * stay sharp. Null where there is no canvas. + * @param {number} count Reports the badge stands for. + * @param {number} pixels The disc's diameter in drawn pixels. + */ +export function countSprite(count, pixels) { + const body = Math.max(1, Math.round(pixels)); + const text = count > 99 ? '99+' : String(count); + const key = `count|${text}|${body}`; + if (cache.has(key)) return cache.get(key); + const canvas = globalThis.document?.createElement?.('canvas'); + const ctx = canvas?.getContext?.('2d'); + if (!ctx) return null; + const k = body / BODY; + canvas.width = canvas.height = Math.round(CANVAS * k); + const c = canvas.width / 2; + const r = body / 2; + ctx.beginPath(); + ctx.arc(c, c, r + 1.5 * k, 0, 2 * Math.PI); + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(c, c, r, 0, 2 * Math.PI); + ctx.fillStyle = INK; + ctx.fill(); + ctx.beginPath(); + ctx.arc(c, c, r - 2 * k, 0, 2 * Math.PI); + ctx.lineWidth = 3 * k; + ctx.strokeStyle = COUNT_INK; + ctx.stroke(); + ctx.fillStyle = COUNT_INK; + ctx.font = `600 ${Math.round(body * (text.length > 2 ? 0.34 : 0.44))}px ui-monospace, Menlo, monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, c, c + k); + const url = canvas.toDataURL('image/png'); + cache.set(key, url); + return url; +}