mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
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:
1 parent
bd1b16e4ee
commit
16b1bc7b74
5 files changed
+195
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user