feat(news): an application may draw the News picture cards itself

createNewsLayer takes a pictureCards(cards, { selectedId }) function; createApplicationCatalog passes it through newsOptions. With it, the layer paints no picture cards of its own and hands the function one card per pictured event on the map, most severe then newest first, with the selected report's id; it hands no cards when the layer turns off.
This commit is contained in:
Sameh Khamis committed 2026-11-11 13:53:10 -08:00
1 parent c19495644d
commit 99a1e06b61
4 files changed
+139 -3

No files matched your search

+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 };