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 <[email protected]>
This commit is contained in:
Mazeyar Moeini FeizabadiandClaude Fable 5.2 committed 2026-11-10 22:28:08 +01:00
1 parent bd1b16e4ee
commit 16b1bc7b74
5 files changed
+195 -2

No files matched your search

+77 -1
View File
@@ -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.
+43
View File
@@ -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;
+29 -1
View File
@@ -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
+3
View File
@@ -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);
+43
View File
@@ -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;
}