Merge pull request #1680 from bilawalsidhu/sameh/news-host-cards

feat(news): an application may draw the News picture cards itself
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-11 14:01:22 -08:00
commit 632da1cebd
4 files changed
+139 -3

No files matched your search

+1 -1
View File
@@ -6698,7 +6698,7 @@ request viewbox and continues searching without that geographic preference.
### News (November 2026)
- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. A selected report's card reads "Source: host ↗" and opens the article.
- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. An app built on Core may draw the picture cards itself: `createApplicationCatalog({ newsOptions: { pictureCards } })` calls `pictureCards(cards, { selectedId })` on each redraw with one card per event on the map that has a picture, single source or not, most severe then newest first (`{ id, title, lat, lon, publishedAt, severity, color, image, video, credit }`, at the marker), and the selected report's id or null (its own card still stands at its marker); with no cards when the layer turns off; the layer then paints none of its own, and a click on one is `setParams({ reportId, focus: true })`. A selected report's card reads "Source: host ↗" and opens the article.
- **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report.
- **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working.
- **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN` when it is set.
+2 -1
View File
@@ -68,6 +68,7 @@ export function createApplicationCatalog({
signal,
metadata = APPLICATION_LAYER_METADATA,
vesselOptions,
newsOptions,
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
@@ -142,7 +143,7 @@ export function createApplicationCatalog({
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationNews({ source: sources.news }),
createApplicationNews({ ...newsOptions, source: sources.news }),
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
+55 -1
View File
@@ -48,6 +48,7 @@ import {
countryGroups,
countryOnly,
disputedLabel,
eventPicture,
feedCardView,
feedQueryFor,
hotspotLabel,
@@ -63,6 +64,7 @@ import {
normalizeFilters,
passesFilters,
reportMarkerPosition,
reportSeverity,
reportsInView,
severityStyle,
shortTitle,
@@ -167,6 +169,13 @@ const emptyInView = () => ({
* chosen: outlines (disputed areas dotted) and the geocoded reports as
* markers, the big events as picture cards. The News panel renders the feed
* this layer publishes through getRowControls().feed.
*
* `pictureCards(cards, { selectedId })`, when given, draws the picture cards
* instead of the layer: it is called on every render with one card per
* pictured event on the map, in rank order (`{ id, title, lat, lon,
* publishedAt, severity, color, image, video, credit }`), and the selected
* report's id or null, whose own card the layer still draws at its marker; it
* is called with no cards when the layer is turned off.
*/
export function createNewsLayer({
source,
@@ -178,6 +187,7 @@ export function createNewsLayer({
openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
getViewCentre = viewCentre,
registerCredit = registerDynamicCredit,
pictureCards: drawPictureCards = null,
} = {}) {
if (
typeof source?.getHotspots !== 'function' ||
@@ -185,6 +195,8 @@ export function createNewsLayer({
)
throw new TypeError('News requires a news source');
if (!overlayHost) throw new TypeError('News requires an overlay host');
if (drawPictureCards !== null && typeof drawPictureCards !== 'function')
throw new TypeError('News picture cards must be a function');
let viewer = null,
dataSource = null,
@@ -679,6 +691,43 @@ export function createNewsLayer({
}
}
// With a `pictureCards` function the host draws the picture cards: one per
// event on the map with a picture (its most severe, then newest report),
// most severe then newest first, at its marker. The host chooses which
// stand; a click on one calls setParams({ reportId, focus: true }).
function hostCards(drawn) {
const events = new Set();
return drawn
.filter((r) => r.pins.length && onMap(r) && eventPicture(r))
.sort(
(a, b) =>
reportSeverity(b) - reportSeverity(a) ||
(b.event?.lastAt || b.publishedAt || '').localeCompare(
a.event?.lastAt || a.publishedAt || '',
),
)
.filter((report) => {
const event = report.event?.id || report.id;
if (events.has(event)) return false;
events.add(event);
return true;
})
.map((report) => {
const picture = eventPicture(report);
return {
id: report.id,
title: report.titleEn || report.title,
...reportMarkerPosition(report, 0),
publishedAt: report.event?.lastAt || report.publishedAt || null,
severity: reportSeverity(report),
color: severityStyle(report).color,
image: picture.url,
video: picture.video || null,
credit: picture.credit,
};
});
}
// The selected report's card: what, where, how attested, the link to the
// original article ("Source: host ↗"), which a click on it opens, and the
// service it came through.
@@ -775,7 +824,11 @@ export function createNewsLayer({
.filter((target) => target.position);
horizon.update();
updatePulse();
pictureCards(drawn, current, labels);
if (drawPictureCards)
drawPictureCards(enabled ? hostCards(drawn) : [], {
selectedId: enabled ? selectedId : null,
});
else pictureCards(drawn, current, labels);
selectedCard(current, labels);
if (enabled)
overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false });
@@ -1442,6 +1495,7 @@ export function createNewsLayer({
}
overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false);
drawPictureCards?.([], { selectedId: null });
requestRender();
notify();
},
+81
View File
@@ -46,6 +46,7 @@ function fixture(
centre = () => ({ lat: 49, lon: 36 }),
credits = [],
now = () => NOW,
pictureCards,
} = {},
) {
const calls = { feed: [], hotspots: 0, outline: 0 };
@@ -118,6 +119,7 @@ function fixture(
credits.push(credit.key);
return true;
},
...(pictureCards ? { pictureCards } : {}),
});
// The context store announces selections on `window`, which Node lacks.
const originalWindow = globalThis.window;
@@ -227,6 +229,85 @@ test('the severity floor changes what is shown, not what is read', async (t) =>
);
});
test('a host that draws the picture cards is handed one card per pictured event on the map, most severe first', async (t) => {
const picture = (url, extra = {}) => ({
size: 1,
lastAt: '2026-10-28T09:00:00.000Z',
sides: [],
image: { url, publisher: 'Example', ...extra },
});
const reports = [
report('single', {
severity: 1,
event: picture('https://example.com/single.jpg', {
video: 'https://example.com/single.mp4',
}),
}),
report('major', {
severity: 3,
event: {
...picture('https://example.com/major.jpg'),
id: 'event-major',
size: 3,
},
}),
// Another report of the same event: its picture stands once.
report('repeat', {
severity: 2,
event: {
...picture('https://example.com/major.jpg'),
id: 'event-major',
size: 3,
},
}),
report('bare', { severity: 2 }),
];
const handed = [];
const { layer, entries } = fixture(t, {
height: 500_000,
source: { getFeed: async () => ({ reports, nextBefore: null }) },
pictureCards: (cards, state) => handed.push({ cards, ...state }),
});
layer.enable();
await layer.update();
layer.setParams({ filters: { minSeverity: 0 } });
const { cards, selectedId } = handed.at(-1);
assert.equal(selectedId, null);
// A single source is enough; a report without a picture has none.
assert.deepEqual(
cards.map((card) => card.id),
['major', 'single'],
);
assert.equal(cards[1].image, 'https://example.com/single.jpg');
assert.equal(cards[1].video, 'https://example.com/single.mp4');
assert.equal(cards[0].video, null);
assert.equal(cards[0].credit, 'Example');
assert.ok(
Math.abs(cards[0].lat - 50) < 0.5 && Math.abs(cards[0].lon - 36) < 0.5,
);
// The layer paints none of its own.
assert.ok(
entries.flat().every((entry) => !entry.id.startsWith('event-card:')),
);
// The selected report is named, pictured or not.
layer.setParams({ reportId: 'bare' });
assert.equal(handed.at(-1).selectedId, 'bare');
layer.disable();
assert.deepEqual(handed.at(-1), { cards: [], selectedId: null });
});
test('picture cards must be drawn by a function', () => {
assert.throws(
() =>
createNewsLayer({
source: { getHotspots() {}, getFeed() {} },
overlayHost: {},
pictureCards: 'yes',
}),
TypeError,
);
});
test('Local reads every report once, lists those near the view and takes the map over', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let centre = { lat: 49, lon: 36 };