mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(maps): show a chain's own logo on its map pin
A corridor full of petrol stations was a row of identical coloured dots, and the one thing that tells them apart is the brand. OSM carries brand:wikidata on most of them — 88% of the fuel stations in a Berlin box — Wikidata carries the logo as property P154, and Commons serves it. The bytes are proxied and cached rather than linked: one self-hosted instance asking Wikimedia for a handful of logos is a very different egress profile from every visitor's browser announcing which petrol stations they are looking at. The cache is in memory because a GET must not write to the database, and there are only so many fuel brands; a brand with no logo is remembered too, or every pan would ask again. A logo-less or unreachable brand answers 204 and the pin keeps its category icon, so nothing depends on Wikidata being reachable. Both renderers show it, since both read the same category table.
This commit is contained in:
@@ -159,20 +159,30 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string) {
|
||||
const cached = poiIconCache.get(category)
|
||||
function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// A chain shows its logo instead of the category icon — on a corridor full of petrol
|
||||
// stations the brand is what the eye looks for. The bytes come from TREK, which
|
||||
// proxies them, so the browser never tells Wikimedia what is on screen.
|
||||
const brand = brandWikidata && /^Q[1-9][0-9]{0,11}$/.test(brandWikidata) ? brandWikidata : null
|
||||
const cacheKey = brand ? `${category}:${brand}` : category
|
||||
const cached = poiIconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
// onerror empties the wrapper: the route answers 204 for a brand Wikidata has no
|
||||
// logo for, and an empty circle in the category colour is the right fallback.
|
||||
const inner = brand
|
||||
? `<img src="/api/maps/brand-logo/${brand}" alt="" width="18" height="18" style="display:block;object-fit:contain;" onerror="this.remove()" />`
|
||||
: svg
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${brand ? '#fff' : color};border:2px solid ${brand ? color : 'white'};box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${inner}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
})
|
||||
poiIconCache.set(category, icon)
|
||||
poiIconCache.set(cacheKey, icon)
|
||||
return icon
|
||||
}
|
||||
|
||||
@@ -547,7 +557,7 @@ export const MapView = memo(function MapView({
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category)}
|
||||
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
>
|
||||
|
||||
@@ -395,16 +395,33 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement {
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
||||
function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
// A chain shows its logo instead of the category icon: on a corridor full of petrol
|
||||
// stations the brand is what the eye is looking for, and the server proxies it so the
|
||||
// browser never asks Wikimedia which ones are on screen.
|
||||
function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement {
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;'
|
||||
const inner = brandLogoMarkup(brandWikidata) || svg
|
||||
const background = brandWikidata ? '#fff' : color
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${background};border:2px solid ${color};box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;overflow:hidden;">${inner}</div>`
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* The <img> for a brand logo, or '' when there is no brand.
|
||||
*
|
||||
* `onerror` empties the wrapper rather than leaving a broken-image glyph: the route
|
||||
* answers 204 for a brand Wikidata has no logo for, and an empty circle in the
|
||||
* category colour is the right fallback.
|
||||
*/
|
||||
function brandLogoMarkup(brandWikidata?: string | null): string {
|
||||
if (!brandWikidata || !/^Q[1-9][0-9]{0,11}$/.test(brandWikidata)) return ''
|
||||
return `<img src="/api/maps/brand-logo/${brandWikidata}" alt="" width="18" height="18" style="display:block;object-fit:contain;" onerror="this.remove()" />`
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
@@ -1234,7 +1251,7 @@ export function MapViewGL({
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface Poi {
|
||||
lng: number
|
||||
category: string
|
||||
poi_type: string
|
||||
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
|
||||
brand?: string | null
|
||||
brand_wikidata?: string | null
|
||||
address: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
|
||||
@@ -246,6 +246,28 @@ export class MapsController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A brand's logo, by Wikidata id — what makes a corridor full of petrol stations
|
||||
* readable at a glance.
|
||||
*
|
||||
* Proxied rather than linked: the browser never announces to Wikimedia which brands
|
||||
* a user is looking at, and one instance asking for a handful of logos is a very
|
||||
* different egress profile from every visitor doing it. An unknown or logo-less
|
||||
* brand answers 204, like the photo route above and for the same reason — a marker
|
||||
* per petrol station would otherwise be a burst of 404s from one address.
|
||||
*/
|
||||
@Get('brand-logo/:wikidataId')
|
||||
async brandLogo(@Param('wikidataId') wikidataId: string, @Res() res: Response): Promise<void> {
|
||||
const logo = await this.maps.brandLogo(wikidataId);
|
||||
if (!logo) {
|
||||
this.emptyPhoto(res);
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
res.type(logo.contentType);
|
||||
res.send(logo.bytes);
|
||||
}
|
||||
|
||||
// 204 for "no bytes to serve". Overrides the immutable Cache-Control the hit
|
||||
// path already set — a photo that reappears in the cache must not stay hidden
|
||||
// behind a month-old empty response.
|
||||
|
||||
@@ -261,6 +261,9 @@ export interface OverpassPoi {
|
||||
phone: string | null;
|
||||
opening_hours: string | null;
|
||||
cuisine: string | null;
|
||||
/** Brand name and its Wikidata id, when OSM carries them — the logo is looked up from the id. */
|
||||
brand: string | null;
|
||||
brand_wikidata: string | null;
|
||||
/**
|
||||
* Which index the row came from. Overture is not OpenStreetMap: it carries
|
||||
* OSM among other sources under other licences, so a row from the TREK index
|
||||
|
||||
@@ -379,6 +379,29 @@ function isGoogleMapsHost(hostname: string): boolean {
|
||||
|
||||
const WIKI_TIMEOUT_MS = 6000;
|
||||
|
||||
// ── Brand logos ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A road trip corridor is mostly chains — Shell, Aral, JET — and the brand is the
|
||||
// fastest thing to recognise on a map. OSM carries `brand:wikidata` on most of them,
|
||||
// Wikidata carries the logo (P154), and Commons serves it.
|
||||
//
|
||||
// The bytes are proxied rather than linked so the browser never talks to Wikimedia:
|
||||
// one self-hosted instance asking for a handful of logos is a very different egress
|
||||
// profile from every visitor's browser announcing which petrol stations they are
|
||||
// looking at. The cache is in memory on purpose — a GET must not write to the DB, and
|
||||
// there are only so many fuel brands.
|
||||
const BRAND_LOGO_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const BRAND_LOGO_CACHE_MAX = 300;
|
||||
/** Well past any logo; a Commons original can be a multi-megabyte SVG or print-res PNG. */
|
||||
const BRAND_LOGO_MAX_BYTES = 512 * 1024;
|
||||
const BRAND_LOGO_WIDTH = 96;
|
||||
const WIKIDATA_ID_RE = /^Q[1-9][0-9]{0,11}$/;
|
||||
|
||||
export interface BrandLogo {
|
||||
bytes: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
// Tighter than the wiki calls, because this one sits at the FRONT of a chain:
|
||||
// identity, then sitelinks, then the extract. Nominatim answers a bounded
|
||||
// search in 0.2-0.7s in practice, so anything past a couple of seconds is a bad
|
||||
@@ -547,6 +570,10 @@ export class MapsService {
|
||||
private readonly photoCache: PlacePhotoCacheService,
|
||||
) {}
|
||||
|
||||
/** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the
|
||||
* oldest entry is the one evicted when it fills up. */
|
||||
private readonly brandLogoCache = new Map<string, { at: number; logo: BrandLogo | null }>();
|
||||
|
||||
private isSettingDisabled(key: string): boolean {
|
||||
const row = this.database.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
@@ -641,6 +668,70 @@ export class MapsService {
|
||||
return this.resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo of a brand, by its Wikidata id, as bytes.
|
||||
*
|
||||
* Two hops: Wikidata says which Commons file is the logo (property P154), Commons
|
||||
* serves a thumbnail of it. Both answers are cached, including "this brand has no
|
||||
* logo" — otherwise every map pan would ask Wikidata about the same supermarket
|
||||
* chain again. Returns null whenever anything is missing or unreadable; a marker
|
||||
* without a logo falls back to its category icon, which is a fine outcome.
|
||||
*/
|
||||
async brandLogo(wikidataId: string): Promise<BrandLogo | null> {
|
||||
if (!WIKIDATA_ID_RE.test(wikidataId)) return null;
|
||||
|
||||
const cached = this.brandLogoCache.get(wikidataId);
|
||||
if (cached && Date.now() - cached.at < BRAND_LOGO_CACHE_TTL_MS) return cached.logo;
|
||||
|
||||
const remember = (logo: BrandLogo | null): BrandLogo | null => {
|
||||
if (this.brandLogoCache.size >= BRAND_LOGO_CACHE_MAX) {
|
||||
const oldest = this.brandLogoCache.keys().next().value;
|
||||
if (oldest !== undefined) this.brandLogoCache.delete(oldest);
|
||||
}
|
||||
this.brandLogoCache.set(wikidataId, { at: Date.now(), logo });
|
||||
return logo;
|
||||
};
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'wbgetclaims',
|
||||
entity: wikidataId,
|
||||
property: 'P154',
|
||||
format: 'json',
|
||||
});
|
||||
const claimRes = await fetch(`https://www.wikidata.org/w/api.php?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
|
||||
});
|
||||
if (!claimRes.ok) return remember(null);
|
||||
const claims = (await claimRes.json()) as {
|
||||
claims?: { P154?: { mainsnak?: { datavalue?: { value?: unknown } } }[] };
|
||||
};
|
||||
const file = claims.claims?.P154?.[0]?.mainsnak?.datavalue?.value;
|
||||
if (typeof file !== 'string' || !file.trim()) return remember(null);
|
||||
|
||||
// Special:FilePath renders a thumbnail at the width asked for and redirects to
|
||||
// the CDN, so each hop is re-checked by the guard rather than trusted.
|
||||
const url = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=${BRAND_LOGO_WIDTH}`;
|
||||
const imgRes = await safeFetchFollow(url, undefined, { bypassInternalIpAllowed: true });
|
||||
if (!imgRes.ok) return remember(null);
|
||||
|
||||
const declared = Number(imgRes.headers.get('content-length') ?? '0');
|
||||
if (declared > BRAND_LOGO_MAX_BYTES) return remember(null);
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
// Checked again after reading: a chunked response has no length to check first.
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > BRAND_LOGO_MAX_BYTES) return remember(null);
|
||||
|
||||
const contentType = imgRes.headers.get('content-type') ?? '';
|
||||
if (!contentType.startsWith('image/')) return remember(null);
|
||||
|
||||
return remember({ bytes, contentType });
|
||||
} catch (err) {
|
||||
if (err instanceof SsrfBlockedError) return remember(null);
|
||||
return remember(null);
|
||||
}
|
||||
}
|
||||
|
||||
// POI search by category within a viewport. Never calls Google.
|
||||
//
|
||||
// The index answers first: it holds the same places, indexed in an R-Tree, and
|
||||
@@ -695,6 +786,11 @@ export class MapsService {
|
||||
website: p.contact?.website ?? null,
|
||||
phone: p.contact?.phone ?? null,
|
||||
opening_hours: p.hours?.osm ?? null,
|
||||
// The index carries the chain and its Wikidata item, which is what
|
||||
// the logo on the pin is looked up from — so a branch of a chain
|
||||
// gets its own mark here exactly as it does on the Overpass path.
|
||||
brand: p.brand?.name ?? null,
|
||||
brand_wikidata: p.brand?.wikidata ?? null,
|
||||
// The index has no cuisine field, so this null is the truth
|
||||
// rather than a field being dropped on the way through.
|
||||
cuisine: null,
|
||||
@@ -1083,6 +1179,10 @@ export class MapsService {
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
brand: tags.brand || tags.operator || null,
|
||||
// Only the plain Q-id form is passed on; anything else would be a lookup we
|
||||
// would have to guess at.
|
||||
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3253,3 +3253,92 @@ describe('readWikiIdentity', () => {
|
||||
expect(identity).toEqual({ wikidata: null, wikipedia: null, wikimedia_commons: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('brandLogo', () => {
|
||||
// A fresh service per case: the logo cache lives on the instance, and a hit from
|
||||
// one case would answer the next one's question before its fetch stub ran.
|
||||
const service = (): MapsService => new MapsService(new DatabaseService(db as never), photoCacheStub);
|
||||
|
||||
const claimResponse = (file: string | null) => ({
|
||||
ok: true,
|
||||
json: async () => (file === null ? { claims: {} } : { claims: { P154: [{ mainsnak: { datavalue: { value: file } } }] } }),
|
||||
});
|
||||
|
||||
const imageResponse = (bytes: number, contentType = 'image/png') => ({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': contentType, 'content-length': String(bytes) }),
|
||||
arrayBuffer: async () => new ArrayBuffer(bytes),
|
||||
});
|
||||
|
||||
it('MAPS-147: reads the logo claim and serves the bytes Commons returns', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(claimResponse('Aral Logo.svg'))
|
||||
.mockResolvedValueOnce(imageResponse(2278));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const logo = await service().brandLogo('Q565734');
|
||||
|
||||
expect(logo?.bytes.byteLength).toBe(2278);
|
||||
expect(logo?.contentType).toBe('image/png');
|
||||
const [claimUrl] = fetchMock.mock.calls[0];
|
||||
expect(claimUrl).toContain('wbgetclaims');
|
||||
expect(claimUrl).toContain('property=P154');
|
||||
const [fileUrl] = fetchMock.mock.calls[1];
|
||||
// Special:FilePath renders the thumbnail and redirects, so the width rides along.
|
||||
expect(fileUrl).toContain('Special:FilePath/Aral%20Logo.svg');
|
||||
expect(fileUrl).toContain('width=96');
|
||||
});
|
||||
|
||||
it('MAPS-148: asks Wikidata nothing when the id is not one', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
for (const bad of ['', 'Q', 'P154', 'Q0', '../secrets', 'Q12; DROP TABLE places']) {
|
||||
expect(await service().brandLogo(bad)).toBeNull();
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MAPS-149: answers a repeat from memory instead of asking again', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(claimResponse('Esso.png'))
|
||||
.mockResolvedValueOnce(imageResponse(1024));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const svcOne = service();
|
||||
await svcOne.brandLogo('Q867662');
|
||||
await svcOne.brandLogo('Q867662');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('MAPS-150: remembers that a brand has no logo, so a map pan does not ask again', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(claimResponse(null));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const svcOne = service();
|
||||
expect(await svcOne.brandLogo('Q89432390')).toBeNull();
|
||||
expect(await svcOne.brandLogo('Q89432390')).toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('MAPS-151: refuses an image that is too big, and one that is not an image', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(claimResponse('Huge.png'))
|
||||
.mockResolvedValueOnce(imageResponse(4 * 1024 * 1024)));
|
||||
expect(await service().brandLogo('Q1')).toBeNull();
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(claimResponse('NotAnImage.svg'))
|
||||
.mockResolvedValueOnce(imageResponse(500, 'text/html')));
|
||||
expect(await service().brandLogo('Q2')).toBeNull();
|
||||
});
|
||||
|
||||
it('MAPS-152: a refused hop leaves the marker without a logo rather than failing', async () => {
|
||||
mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'blocked' } as SsrfResult);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(claimResponse('Blocked.png')));
|
||||
|
||||
expect(await service().brandLogo('Q3')).toBeNull();
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user