Add pictograms

This commit is contained in:
InfoX 2026-10-03 18:22:27 +02:00
parent 984ac0fd3f
commit 8bdda8ae02
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
15 changed files with 252 additions and 104 deletions

View file

@ -4,7 +4,7 @@ A PWA that tells wheelchair users and parents with strollers whether a place or
**their** limits, and shows how we know: every fact carries a source, a date and a reliability level. **their** limits, and shows how we know: every fact carries a source, a date and a reliability level.
Missing data is never shown as "accessible". Missing data is never shown as "accessible".
Prototype for the "Kraków bez barier" hackathon challenge. Demo area: Old Town, Kazimierz, Wawel. Prototype for the "Kraków bez barier" hackathon challenge. Demo area: Wawel, Old Town and Kazimierz out east to TAURON Arena (≈19 km²).
## Run ## Run

File diff suppressed because one or more lines are too long

View file

@ -6,20 +6,22 @@ import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { toPlace } from '../src/lib/osmPlace.ts'; import { toPlace } from '../src/lib/osmPlace.ts';
const BBOX = [50.046, 19.923, 50.064, 19.956]; // S,W,N,E: Old Town + Kazimierz + Wawel // S,W,N,E: Wawel + Old Town + Kazimierz out east to Tauron Arena / Czyżyny
const BBOX = [50.044, 19.920, 50.074, 20.000];
const bb = BBOX.join(','); const bb = BBOX.join(',');
const OVERPASS = 'https://overpass-api.de/api/interpreter'; const OVERPASS = 'https://overpass-api.de/api/interpreter';
const queries = { const queries = {
places: `[out:json][timeout:90]; places: `[out:json][timeout:240];
( (
nwr["name"]["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|pharmacy|bank|clinic|doctors|dentist|hospital|post_office|cinema|theatre|library|arts_centre|townhall|community_centre|place_of_worship|university|college|school|police|courthouse)$"](${bb}); nwr["name"]["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|pharmacy|bank|clinic|doctors|dentist|hospital|post_office|cinema|theatre|library|arts_centre|townhall|community_centre|place_of_worship|university|college|school|police|courthouse|events_venue|conference_centre|exhibition_centre|marketplace)$"](${bb});
nwr["name"]["leisure"~"^(stadium|sports_centre|swimming_pool|fitness_centre)$"](${bb});
nwr["name"]["tourism"~"^(museum|gallery|hotel|hostel|guest_house|apartment|attraction|information)$"](${bb}); nwr["name"]["tourism"~"^(museum|gallery|hotel|hostel|guest_house|apartment|attraction|information)$"](${bb});
nwr["name"]["shop"](${bb}); nwr["name"]["shop"](${bb});
nwr["name"]["historic"~"^(castle|monument|church|building|city_gate|fort)$"](${bb}); nwr["name"]["historic"~"^(castle|monument|church|building|city_gate|fort)$"](${bb});
); );
out meta center;`, out meta center;`,
support: `[out:json][timeout:90]; support: `[out:json][timeout:240];
( (
nwr["amenity"="toilets"](${bb}); nwr["amenity"="toilets"](${bb});
nwr["highway"="elevator"](${bb}); nwr["highway"="elevator"](${bb});
@ -28,12 +30,12 @@ out meta center;`,
node["railway"="tram_stop"]["name"](${bb}); node["railway"="tram_stop"]["name"](${bb});
); );
out meta center;`, out meta center;`,
net: `[out:json][timeout:120]; net: `[out:json][timeout:300];
way["highway"~"^(footway|pedestrian|path|steps|living_street|residential|service|tertiary|unclassified|track|cycleway)$"](${bb}); way["highway"~"^(footway|pedestrian|path|steps|living_street|residential|service|tertiary|unclassified|track|cycleway)$"](${bb});
out meta; out meta;
>; >;
out skel qt;`, out skel qt;`,
kerbs: `[out:json][timeout:90]; kerbs: `[out:json][timeout:240];
( (
node["kerb"](${bb}); node["kerb"](${bb});
node["highway"="crossing"](${bb}); node["highway"="crossing"](${bb});
@ -41,26 +43,35 @@ out skel qt;`,
out tags;`, out tags;`,
}; };
// Public Overpass instances rate-limit hard (HTTP 429/504): fetch one query at a time, back off, and rotate mirrors.
const MIRRORS = [OVERPASS, 'https://overpass.kumi.systems/api/interpreter', 'https://overpass.private.coffee/api/interpreter'];
async function fetchRaw(name) { async function fetchRaw(name) {
const file = `scripts/.cache/${name}.json`; const file = `scripts/.cache/${name}-${BBOX.join('_')}.json`; // keyed by bbox so a changed area never reuses stale data
if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8')); if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8'));
for (let attempt = 1; attempt <= 3; attempt++) { for (let attempt = 1; attempt <= 8; attempt++) {
const res = await fetch(OVERPASS, { const endpoint = MIRRORS[(attempt - 1) % MIRRORS.length];
method: 'POST', try {
headers: { const res = await fetch(endpoint, {
'User-Agent': 'krakow-bez-barier-hackathon/0.1', signal: AbortSignal.timeout(280_000), // a hung mirror must not stall the build forever
Accept: 'application/json', method: 'POST',
'Content-Type': 'application/x-www-form-urlencoded', headers: {
}, 'User-Agent': 'krakow-bez-barier-hackathon/0.1',
body: 'data=' + encodeURIComponent(queries[name]), Accept: 'application/json',
}); 'Content-Type': 'application/x-www-form-urlencoded',
if (res.ok) { },
const text = await res.text(); body: 'data=' + encodeURIComponent(queries[name]),
await writeFile(file, text); });
return JSON.parse(text); if (res.ok) {
const text = await res.text();
JSON.parse(text); // a mirror can answer 200 with an HTML error page
await writeFile(file, text);
return JSON.parse(text);
}
console.warn(`${name}: HTTP ${res.status} from ${endpoint}, retry ${attempt}`);
} catch (e) {
console.warn(`${name}: ${e.message} from ${endpoint}, retry ${attempt}`);
} }
console.warn(`${name}: HTTP ${res.status}, retry ${attempt}`); await new Promise((r) => setTimeout(r, 15000 * attempt));
await new Promise((r) => setTimeout(r, 8000 * attempt));
} }
throw new Error(`Overpass failed for ${name}`); throw new Error(`Overpass failed for ${name}`);
} }
@ -68,9 +79,9 @@ async function fetchRaw(name) {
const round = (n) => Math.round(n * 1e6) / 1e6; const round = (n) => Math.round(n * 1e6) / 1e6;
const day = (ts) => (ts ? ts.slice(0, 10) : null); const day = (ts) => (ts ? ts.slice(0, 10) : null);
const [placesRaw, supportRaw, netRaw, kerbRaw] = await Promise.all( const raw = {};
Object.keys(queries).map((q) => fetchRaw(q)), for (const q of Object.keys(queries)) raw[q] = await fetchRaw(q);
); const { places: placesRaw, support: supportRaw, net: netRaw, kerbs: kerbRaw } = raw;
const places = placesRaw.elements.map((e) => toPlace(e)).filter(Boolean); const places = placesRaw.elements.map((e) => toPlace(e)).filter(Boolean);
const toilets = [], elevators = [], benches = [], stops = []; const toilets = [], elevators = [], benches = [], stops = [];

View file

@ -15,7 +15,8 @@ ok(await page.locator('text=Zaloguj się').first().isVisible(), 'login screen sh
// resident flow: report -> appears in own place detail as unverified // resident flow: report -> appears in own place detail as unverified
await page.click('text=Zaloguj jako Anna Kowalska'); await page.click('text=Zaloguj jako Anna Kowalska');
await page.waitForSelector('#q'); await page.waitForSelector('#q');
ok((await page.locator('text=Wyniki:').first().innerText()).includes('27'), 'places list loaded (~2.7k)'); const total = Number(((await page.locator('text=Wyniki:').first().innerText()).match(/\d+/) ?? ['0'])[0]);
ok(total > 4000, `places list loaded (${total} places)`);
await page.fill('#q', 'Pracownia Przykładowa'); await page.fill('#q', 'Pracownia Przykładowa');
await page.click('button[id^=place-]'); await page.click('button[id^=place-]');
await page.waitForSelector('text=Szczegóły dostępności'); await page.waitForSelector('text=Szczegóły dostępności');
@ -45,7 +46,7 @@ ok((await page.locator('button:has-text("Zatwierdź")').count()) === pending, 'u
await page.locator('button:has-text("Zatwierdź")').first().click(); await page.locator('button:has-text("Zatwierdź")').first().click();
await page.click('[role=tab]:has-text("Jakość")'); await page.click('[role=tab]:has-text("Jakość")');
await page.waitForSelector('.kbb-kpis'); await page.waitForSelector('.kbb-kpis');
ok(await page.locator('text=0 / 82').first().isVisible(), 'data tab shows operator data gap (0 / 82 stops)'); ok(await page.getByText(/^0 \/ \d+$/).first().isVisible(), 'data tab shows operator data gap (0 / N stops)');
// map pins + route render without worker errors // map pins + route render without worker errors
await page.click('nav >> text=Trasa'); await page.click('nav >> text=Trasa');

View file

@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useI18n, type Key } from '../lib/i18n'; import { useI18n, type Key } from '../lib/i18n';
import { usePhoto } from '../lib/usePhoto';
import type { Place, UserReport } from '../lib/types'; import type { Place, UserReport } from '../lib/types';
import { Alert, VettingSummary } from './ui'; import { ReportCard } from './ReportCard';
import { Alert } from './ui';
type Lane = 'all' | 'fast-track' | 'review' | 'likely-reject'; type Lane = 'all' | 'fast-track' | 'review' | 'likely-reject';
type Decision = 'accepted' | 'rejected'; type Decision = 'accepted' | 'rejected';
@ -17,12 +17,6 @@ const rank = (r: UserReport): [number, number] => {
return v ? [TRIAGE_RANK[v.triage], SEVERITY_RANK[v.severity]] : [1, 3]; return v ? [TRIAGE_RANK[v.triage], SEVERITY_RANK[v.severity]] : [1, 3];
}; };
function Thumb({ id }: { id: string | undefined }) {
const { t } = useI18n();
const src = usePhoto(id);
return src ? <img className="kbb-thumb" src={src} alt={t('rep.photo.alt')} /> : null;
}
interface Props { interface Props {
reports: UserReport[]; reports: UserReport[];
placeById: Map<string, Place>; placeById: Map<string, Place>;
@ -97,26 +91,12 @@ export function DashQueue({ reports, placeById, onDecide, onUndo, onOpenPlace }:
const p = placeById.get(r.placeId); const p = placeById.get(r.placeId);
const name = p?.name ?? r.placeId; const name = p?.name ?? r.placeId;
return ( return (
<li key={r.id} className="kbb-q-item"> <li key={r.id} className="margin-bottom-2">
<Thumb id={r.photoId} /> <ReportCard report={r} placeName={name} headingLevel="h2" detailed>
<div className="kbb-q-item__body"> <button type="button" className="usa-button margin-0" onClick={() => decide([r.id], 'accepted')} aria-label={`${t('dash.queue.accept')}: ${name}`}>{t('dash.queue.accept')}</button>
<h2 className="margin-0 font-sans-md"> <button type="button" className="usa-button usa-button--secondary margin-0" onClick={() => decide([r.id], 'rejected')} aria-label={`${t('dash.queue.reject')}: ${name}`}>{t('dash.queue.reject')}</button>
{name}{' '} {p && <button type="button" className="usa-button usa-button--unstyled" onClick={() => onOpenPlace(p.id)}>{t('dash.q.openPlace')}</button>}
{r.demo && <span className="usa-tag bg-ink text-white">{t('detail.reports.demo')}</span>} </ReportCard>
</h2>
<p className="margin-y-05">
{t(`rep.attr.${r.attr}` as Key)}: <strong>{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)}</strong>
<span className="text-base-dark"> · {date(r.createdAt.slice(0, 10))}</span>
</p>
{r.measure && <p className="font-sans-xs margin-y-05">{t('dash.queue.measure')}: <strong>{r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'}</strong> ({t(`vet.method.${r.measure.method}` as Key)})</p>}
{r.note && <p className="font-sans-xs text-base-dark margin-y-05">{r.note}</p>}
<VettingSummary v={r.vetting} detailed />
<div className="kbb-q-actions">
<button type="button" className="usa-button margin-0" onClick={() => decide([r.id], 'accepted')} aria-label={`${t('dash.queue.accept')}: ${name}`}>{t('dash.queue.accept')}</button>
<button type="button" className="usa-button usa-button--secondary margin-0" onClick={() => decide([r.id], 'rejected')} aria-label={`${t('dash.queue.reject')}: ${name}`}>{t('dash.queue.reject')}</button>
{p && <button type="button" className="usa-button usa-button--unstyled" onClick={() => onOpenPlace(p.id)}>{t('dash.q.openPlace')}</button>}
</div>
</div>
</li> </li>
); );
})} })}

View file

@ -7,6 +7,7 @@ import type { LatLon } from '../lib/geo';
import type { Hotspot } from '../lib/civic'; import type { Hotspot } from '../lib/civic';
import { HARD_FLAGS, type RouteResult } from '../lib/route'; import { HARD_FLAGS, type RouteResult } from '../lib/route';
import type { Place, Verdict } from '../lib/types'; import type { Place, Verdict } from '../lib/types';
import { CATEGORY_ICON, glyphBitmap } from '../lib/categoryIcon';
import { VERDICT_STYLE } from './ui'; import { VERDICT_STYLE } from './ui';
maplibregl.setWorkerUrl(new URL(`${import.meta.env.BASE_URL}maplibre/maplibre-gl-worker.mjs`, document.baseURI).href); maplibregl.setWorkerUrl(new URL(`${import.meta.env.BASE_URL}maplibre/maplibre-gl-worker.mjs`, document.baseURI).href);
@ -37,6 +38,28 @@ function applyBasemap(map: MLMap, b: 'map' | 'satellite') {
map.setLayoutProperty('osm', 'visibility', b === 'map' ? 'visible' : 'none'); map.setLayoutProperty('osm', 'visibility', b === 'map' ? 'visible' : 'none');
map.setLayoutProperty('sat', 'visibility', b === 'satellite' ? 'visible' : 'none'); map.setLayoutProperty('sat', 'visibility', b === 'satellite' ? 'visible' : 'none');
} }
/** Category pictograms drawn on the place dots once zoomed in far enough to read them. */
async function addGlyphs(map: MLMap) {
try {
const text = await (await fetch(SPRITE)).text();
for (const [cat, icon] of Object.entries(CATEGORY_ICON)) {
for (const [tone, color] of [['l', '#ffffff'], ['d', '#1b1b1b']] as const) {
const bmp = await glyphBitmap(text, icon, color);
if (bmp && !map.hasImage(`ic-${cat}-${tone}`)) map.addImage(`ic-${cat}-${tone}`, bmp);
}
}
if (map.getLayer('place-glyphs')) return;
map.addLayer({
id: 'place-glyphs', type: 'symbol', source: 'places', minzoom: 15.5,
layout: {
'icon-image': ['concat', 'ic-', ['get', 'cat'], '-', ['match', ['get', 'verdict'], ['partial', 'incomplete'], 'd', 'l']],
'icon-size': ['interpolate', ['linear'], ['zoom'], 15.5, ['match', ['get', 'verdict'], 'incomplete', 0.14, 0.2], 17, ['match', ['get', 'verdict'], 'incomplete', 0.21, 0.3]],
'icon-allow-overlap': true, 'icon-ignore-placement': true,
},
}, 'places-selected');
} catch { /* glyphs are decoration: the coloured dots still work without them */ }
}
const lngLat = (p: LatLon): [number, number] => [p[1], p[0]]; const lngLat = (p: LatLon): [number, number] => [p[1], p[0]];
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`; const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
@ -126,13 +149,14 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
id: 'places', type: 'circle', source: 'places', id: 'places', type: 'circle', source: 'places',
layout: { 'circle-sort-key': ['match', ['get', 'verdict'], 'incomplete', 0, 1] }, layout: { 'circle-sort-key': ['match', ['get', 'verdict'], 'incomplete', 0, 1] },
paint: { paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, ['match', ['get', 'verdict'], 'incomplete', 2.5, 5.5], 17, ['match', ['get', 'verdict'], 'incomplete', 6, 11.5]], 'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, ['match', ['get', 'verdict'], 'incomplete', 2.5, 5.5], 17, ['match', ['get', 'verdict'], 'incomplete', 9, 11.5]],
'circle-opacity': ['match', ['get', 'verdict'], 'incomplete', 0.8, 1], 'circle-opacity': ['match', ['get', 'verdict'], 'incomplete', 0.8, 1],
'circle-color': ['match', ['get', 'verdict'], 'match', VERDICT_STYLE.match.hex, 'partial', VERDICT_STYLE.partial.hex, 'conflict', VERDICT_STYLE.conflict.hex, 'fail', VERDICT_STYLE.fail.hex, '#ffffff'], 'circle-color': ['match', ['get', 'verdict'], 'match', VERDICT_STYLE.match.hex, 'partial', VERDICT_STYLE.partial.hex, 'conflict', VERDICT_STYLE.conflict.hex, 'fail', VERDICT_STYLE.fail.hex, '#ffffff'],
'circle-stroke-color': ['match', ['get', 'verdict'], 'incomplete', '#565c65', '#ffffff'], 'circle-stroke-color': ['match', ['get', 'verdict'], 'incomplete', '#565c65', '#ffffff'],
'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 1.5, 2], 'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 1.5, 2],
}, },
}); });
void addGlyphs(map);
map.addLayer({ map.addLayer({
id: 'places-selected', type: 'circle', source: 'places', filter: ['==', ['get', 'id'], ''], id: 'places-selected', type: 'circle', source: 'places', filter: ['==', ['get', 'id'], ''],
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, 8, 17, 15], 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': '#005ea2', 'circle-stroke-width': 4 }, paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, 8, 17, 15], 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': '#005ea2', 'circle-stroke-width': 4 },
@ -162,7 +186,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
features: places.map(({ place, verdict }) => ({ features: places.map(({ place, verdict }) => ({
type: 'Feature' as const, type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [place.lon, place.lat] }, geometry: { type: 'Point' as const, coordinates: [place.lon, place.lat] },
properties: { id: place.id, verdict }, properties: { id: place.id, verdict, cat: place.cat },
})), })),
}), }),
[places], [places],

View file

@ -3,10 +3,10 @@ import { googleMapsPlaceUrl } from '../lib/links';
import { useI18n, type Key } from '../lib/i18n'; import { useI18n, type Key } from '../lib/i18n';
import { DEMO_PLACES } from '../lib/demo'; import { DEMO_PLACES } from '../lib/demo';
import type { Assessment, Place, ReportAttr, Status, UserReport } from '../lib/types'; import type { Assessment, Place, ReportAttr, Status, UserReport } from '../lib/types';
import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag, VettingSummary } from './ui'; import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag } from './ui';
import { ReportForm } from './ReportForm'; import { ReportForm } from './ReportForm';
import type { ProcessedPhoto } from '../lib/photo'; import type { ProcessedPhoto } from '../lib/photo';
import { usePhoto } from '../lib/usePhoto'; import { ReportCard } from './ReportCard';
export interface ReachState { minutes: number | null; summary: { n: number; fit: number } | 'stuck' | null } export interface ReachState { minutes: number | null; summary: { n: number; fit: number } | 'stuck' | null }
@ -173,8 +173,14 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
{mine.length === 0 ? ( {mine.length === 0 ? (
<div className="kbb-empty"><Icon name="edit" size={3} /><p className="margin-0">{t('detail.reports.none')}</p></div> <div className="kbb-empty"><Icon name="edit" size={3} /><p className="margin-0">{t('detail.reports.none')}</p></div>
) : ( ) : (
<ul className="usa-list"> <ul className="usa-list--unstyled margin-0 padding-0">
{mine.map((r) => <ReportItem key={r.id} r={r} onRemove={() => p.onRemoveReport(r.id)} />)} {mine.map((r) => (
<li key={r.id} className="margin-bottom-2">
<ReportCard report={r}>
{!r.demo && <button type="button" className="usa-button usa-button--unstyled" onClick={() => p.onRemoveReport(r.id)}>{t('detail.reports.remove')}</button>}
</ReportCard>
</li>
))}
</ul> </ul>
)} )}
</section> </section>
@ -223,27 +229,3 @@ function osmPath(id: string) {
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation'; const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
return `${kind}/${id.slice(1)}`; return `${kind}/${id.slice(1)}`;
} }
function ReportItem({ r, onRemove }: { r: UserReport; onRemove: () => void }) {
const { t, date } = useI18n();
const photo = usePhoto(r.photoId);
return (
<li>
<div className="display-flex">
{photo && <img className="kbb-thumb margin-right-1" src={photo} alt={t('rep.photo.alt')} />}
<div>
<strong>{t(`rep.attr.${r.attr}`)}:</strong>{' '}
{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)}
{r.demo && <span className="usa-tag margin-left-1 bg-ink text-white">{t('detail.reports.demo')}</span>}
<span className="text-base-dark"> — {date(r.createdAt.slice(0, 10))}</span>
{r.measure && <div className="font-sans-xs">{t(`vet.measure.${r.measure.kind}`)}: {r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${r.measure.method}`)})</div>}
{r.note && <div className="text-base-dark font-sans-xs">{r.note}</div>}
<VettingSummary v={r.vetting} />
{!r.demo && (
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={onRemove}>{t('detail.reports.remove')}</button>
)}
</div>
</div>
</li>
);
}

View file

@ -4,12 +4,12 @@ import { VERDICT_ORDER } from '../lib/assess';
import { DEMO_PLACES } from '../lib/demo'; import { DEMO_PLACES } from '../lib/demo';
import { fold } from '../lib/text'; import { fold } from '../lib/text';
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types'; import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
import { Icon, VerdictTag } from './ui'; import { CategoryIcon, Icon, VerdictTag } from './ui';
export interface Filters { query: string; cat: PlaceCategory | 'all'; onlyFit: boolean; onlySaved: boolean } export interface Filters { query: string; cat: PlaceCategory | 'all'; onlyFit: boolean; onlySaved: boolean }
export const DEFAULT_FILTERS: Filters = { query: '', cat: 'all', onlyFit: false, onlySaved: false }; export const DEFAULT_FILTERS: Filters = { query: '', cat: 'all', onlyFit: false, onlySaved: false };
const CATS: PlaceCategory[] = ['city', 'food', 'culture', 'shop', 'stay', 'health', 'services', 'transit']; const CATS: PlaceCategory[] = ['city', 'food', 'culture', 'sport', 'shop', 'stay', 'health', 'services', 'transit'];
/** Items shown per category in the overview before "show all". */ /** Items shown per category in the overview before "show all". */
const PER_GROUP = 5; const PER_GROUP = 5;
const PAGE = 40; const PAGE = 40;
@ -81,7 +81,7 @@ export function PlacesView({ profile, filters, onFilters, results, counts, asses
<div className="kbb-chips kbb-chips--scroll margin-top-2" role="group" aria-label={t('places.cats')}> <div className="kbb-chips kbb-chips--scroll margin-top-2" role="group" aria-label={t('places.cats')}>
{(['all', ...CATS] as const).filter((c) => c === 'all' || c === filters.cat || (counts[c] ?? 0) > 0).map((c) => ( {(['all', ...CATS] as const).filter((c) => c === 'all' || c === filters.cat || (counts[c] ?? 0) > 0).map((c) => (
<button key={c} type="button" aria-pressed={filters.cat === c} className={`kbb-chip ${filters.cat === c ? 'is-on' : ''}`} onClick={() => set({ cat: c })}> <button key={c} type="button" aria-pressed={filters.cat === c} className={`kbb-chip ${filters.cat === c ? 'is-on' : ''}`} onClick={() => set({ cat: c })}>
{t(`cat.${c}` as Key)}<span className="kbb-chip__n">{counts[c] ?? 0}</span> {c !== 'all' && <CategoryIcon cat={c} size="sm" />}{t(`cat.${c}` as Key)}<span className="kbb-chip__n">{counts[c] ?? 0}</span>
</button> </button>
))} ))}
</div> </div>
@ -94,7 +94,7 @@ export function PlacesView({ profile, filters, onFilters, results, counts, asses
// overview: a few places per category, then a jump into the full category // overview: a few places per category, then a jump into the full category
groups.map(([cat, list]) => ( groups.map(([cat, list]) => (
<section key={cat} aria-labelledby={`grp-${cat}`} className="kbb-group"> <section key={cat} aria-labelledby={`grp-${cat}`} className="kbb-group">
<h2 id={`grp-${cat}`} className="kbb-group__h">{t(`cat.${cat}` as Key)} <span className="text-base-dark text-normal">({list.length})</span></h2> <h2 id={`grp-${cat}`} className="kbb-group__h"><CategoryIcon cat={cat} />{t(`cat.${cat}` as Key)} <span className="text-base-dark text-normal">({list.length})</span></h2>
<ul className="usa-list--unstyled margin-0 padding-0"> <ul className="usa-list--unstyled margin-0 padding-0">
{list.slice(0, PER_GROUP).map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)} {list.slice(0, PER_GROUP).map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
</ul> </ul>
@ -126,9 +126,10 @@ function PlaceRow({ place: p, assessment: a, onSelect }: { place: Place; assessm
<li className="margin-bottom-1"> <li className="margin-bottom-1">
<button <button
type="button" id={`place-${p.id}`} onClick={() => onSelect(p.id)} type="button" id={`place-${p.id}`} onClick={() => onSelect(p.id)}
className="kbb-place-btn width-full text-left bg-white border-1px border-base-lighter radius-md padding-2 display-flex flex-justify flex-align-start" className="kbb-place-btn width-full text-left bg-white border-1px border-base-lighter radius-md padding-2 display-flex flex-align-start"
> >
<span className="display-block"> <CategoryIcon cat={p.cat} />
<span className="display-block flex-1">
<span className="display-block text-bold text-ink">{p.name}</span> <span className="display-block text-bold text-ink">{p.name}</span>
<span className="display-block font-sans-xs text-base-dark margin-top-05"> <span className="display-block font-sans-xs text-base-dark margin-top-05">
{t(`cat.${p.cat}` as Key)} {t(`cat.${p.cat}` as Key)}

View file

@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import { usePhoto } from '../lib/usePhoto';
import type { UserReport } from '../lib/types';
import { Icon, VettingSummary } from './ui';
interface Props {
report: UserReport;
/** Queue view: the place is the heading and the claim sits below it. Place view: the claim is the heading. */
placeName?: string | undefined;
headingLevel?: 'h2' | 'h3';
/** Show the full automatic assessment (city panel) instead of the short tags. */
detailed?: boolean;
children?: ReactNode;
}
const STATUS_TAG = {
pending: 'bg-base-lighter text-ink',
accepted: 'bg-success-dark text-white',
rejected: 'bg-error-dark text-white',
} as const;
/** One layout for every user report, with or without a photo, in the place view and in the city inbox. */
export function ReportCard({ report: r, placeName, headingLevel = 'h3', detailed = false, children }: Props) {
const { t, date } = useI18n();
const photo = usePhoto(r.photoId);
const Heading = headingLevel;
const claim = (
<>
{t(`rep.attr.${r.attr}` as Key)}: <strong>{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)}</strong>
</>
);
const status = r.status ?? 'pending';
return (
<article className="kbb-rcard">
<div className={`kbb-rcard__media ${photo ? '' : 'is-empty'}`}>
{photo ? (
<img src={photo} alt={t('rep.photo.alt')} />
) : (
<>
<Icon name="visibility_off" size={4} />
<span>{t('vet.flag.no-photo')}</span>
</>
)}
</div>
<div className="kbb-rcard__body">
<div className="kbb-rcard__top">
<Heading className="kbb-rcard__title">{placeName ?? claim}</Heading>
<span className={`usa-tag ${STATUS_TAG[status]}`}>{t(status === 'pending' ? 'detail.reports.pending' : (`dash.queue.status.${status}` as Key))}</span>
</div>
{placeName && <p className="kbb-rcard__claim">{claim}</p>}
<p className="kbb-rcard__meta">
{date(r.createdAt.slice(0, 10))}
{r.demo && <span className="usa-tag bg-ink text-white margin-left-1">{t('detail.reports.demo')}</span>}
</p>
{r.measure && (
<p className="kbb-rcard__line">
{t(`vet.measure.${r.measure.kind}` as Key)}: <strong>{r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'}</strong> ({t(`vet.method.${r.measure.method}` as Key)})
</p>
)}
{r.note && <p className="kbb-rcard__note">{r.note}</p>}
<VettingSummary v={r.vetting} detailed={detailed} />
{children && <div className="kbb-rcard__actions">{children}</div>}
</div>
</article>
);
}

View file

@ -1,6 +1,7 @@
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../lib/i18n'; import { useI18n } from '../lib/i18n';
import type { Confidence, Fact, SourceKind, Status, Verdict, VetState } from '../lib/types'; import { CATEGORY_ICON } from '../lib/categoryIcon';
import type { Confidence, Fact, PlaceCategory, SourceKind, Status, Verdict, VetState } from '../lib/types';
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`; const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
@ -12,6 +13,15 @@ export function Icon({ name, size = 3, className = '' }: { name: string; size?:
); );
} }
/** Category pictogram in a soft square; decorative, the category name is always written next to it. */
export function CategoryIcon({ cat, size = 'md' }: { cat: PlaceCategory; size?: 'sm' | 'md' }) {
return (
<span className={`kbb-cat kbb-cat--${size}`} aria-hidden="true">
<Icon name={CATEGORY_ICON[cat]} size={size === 'sm' ? 2 : 3} />
</span>
);
}
export const VERDICT_STYLE: Record<Verdict, { icon: string; tag: string; alert: string; hex: string }> = { export const VERDICT_STYLE: Record<Verdict, { icon: string; tag: string; alert: string; hex: string }> = {
match: { icon: 'check_circle', tag: 'bg-success-dark text-white', alert: 'usa-alert--success', hex: '#00a91c' }, match: { icon: 'check_circle', tag: 'bg-success-dark text-white', alert: 'usa-alert--success', hex: '#00a91c' },
partial: { icon: 'warning', tag: 'bg-warning text-ink', alert: 'usa-alert--warning', hex: '#ffbe2e' }, partial: { icon: 'warning', tag: 'bg-warning text-ink', alert: 'usa-alert--warning', hex: '#ffbe2e' },

36
src/lib/categoryIcon.ts Normal file
View file

@ -0,0 +1,36 @@
// Pictogram per place category: USWDS sprite icon names, shared by the list, the filter chips and the map.
import type { PlaceCategory } from './types';
export const CATEGORY_ICON: Record<PlaceCategory, string> = {
city: 'star',
food: 'restaurant',
culture: 'local_library',
sport: 'emoji_events',
shop: 'store',
stay: 'hotel',
health: 'medical_services',
services: 'account_balance',
toilets: 'accessibility_new',
elevator: 'unfold_more',
transit: 'directions_bus',
};
const GLYPH_PX = 48;
/** Pulls one `<symbol>` out of the sprite and draws it as a single-colour glyph on a canvas (MapLibre needs bitmaps). */
export async function glyphBitmap(spriteText: string, icon: string, color: string): Promise<ImageData | null> {
const doc = new DOMParser().parseFromString(spriteText, 'image/svg+xml');
const sym = doc.getElementById(icon) ?? doc.querySelector(`symbol[id="${icon}"]`);
if (!sym) return null;
const viewBox = sym.getAttribute('viewBox') ?? '0 0 24 24';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${GLYPH_PX}" height="${GLYPH_PX}" fill="${color}">${sym.innerHTML}</svg>`;
const img = new Image(GLYPH_PX, GLYPH_PX);
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
try { await img.decode(); } catch { return null; }
const canvas = document.createElement('canvas');
canvas.width = canvas.height = GLYPH_PX;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(img, 0, 0, GLYPH_PX, GLYPH_PX);
return ctx.getImageData(0, 0, GLYPH_PX, GLYPH_PX);
}

View file

@ -63,6 +63,7 @@ const pl = {
'view.label': 'Widok wyników', 'view.label': 'Widok wyników',
'cat.all': 'Wszystkie', 'cat.all': 'Wszystkie',
'cat.food': 'Jedzenie i picie', 'cat.food': 'Jedzenie i picie',
'cat.sport': 'Sport i rekreacja',
'cat.city': 'Oferta Miasta', 'cat.city': 'Oferta Miasta',
'cat.culture': 'Kultura i religia', 'cat.culture': 'Kultura i religia',
'cat.shop': 'Sklepy', 'cat.shop': 'Sklepy',
@ -229,7 +230,7 @@ const pl = {
'route.go': 'Wyznacz trasę', 'route.go': 'Wyznacz trasę',
'route.myLocation': 'Użyj mojej lokalizacji', 'route.myLocation': 'Użyj mojej lokalizacji',
'route.locating': 'Ustalanie lokalizacji…', 'route.locating': 'Ustalanie lokalizacji…',
'route.locOut': 'Jesteś poza obszarem demonstracyjnym (Stare Miasto, Kazimierz, Wawel).', 'route.locOut': 'Jesteś poza obszarem demonstracyjnym (od Wawelu i Kazimierza przez Stare Miasto po TAURON Arenę).',
'route.locFail': 'Nie udało się ustalić lokalizacji. Wpisz punkt startu.', 'route.locFail': 'Nie udało się ustalić lokalizacji. Wpisz punkt startu.',
'route.presets': 'Przykładowe trasy', 'route.presets': 'Przykładowe trasy',
'route.unknownPlace': 'Nie znaleziono miejsca „{text}”. Wybierz pozycję z podpowiedzi.', 'route.unknownPlace': 'Nie znaleziono miejsca „{text}”. Wybierz pozycję z podpowiedzi.',
@ -316,7 +317,7 @@ const pl = {
'about.biz': 'Model biznesowy i skalowanie', 'about.biz': 'Model biznesowy i skalowanie',
'about.biz.body': 'Bezpłatna aplikacja dla mieszkańców i turystów. Przychód z usług B2B: (1) widżet i raport „dostępność obiektu” dla hoteli, organizatorów wydarzeń i zarządców nieruchomości, (2) API dostępności dla systemów rezerwacyjnych i aplikacji turystycznych, (3) pakiet wdrożeniowy dla miast. Koszty to hosting statyczny, okresowe pobieranie z Overpass i moderacja zgłoszeń. Za produkt odpowiada podmiot prowadzący usługę, nie UMK.', 'about.biz.body': 'Bezpłatna aplikacja dla mieszkańców i turystów. Przychód z usług B2B: (1) widżet i raport „dostępność obiektu” dla hoteli, organizatorów wydarzeń i zarządców nieruchomości, (2) API dostępności dla systemów rezerwacyjnych i aplikacji turystycznych, (3) pakiet wdrożeniowy dla miast. Koszty to hosting statyczny, okresowe pobieranie z Overpass i moderacja zgłoszeń. Za produkt odpowiada podmiot prowadzący usługę, nie UMK.',
'about.limits': 'Znane ograniczenia prototypu', 'about.limits': 'Znane ograniczenia prototypu',
'about.limits.body': 'Obszar: Stare Miasto, Kazimierz, Wawel. Logowanie i zgłoszenia są demonstracyjne i lokalne (bez serwera). Dane miejskie obejmują na razie tylko przystanki. Wysokość krawężników znana tylko dla części przejść. Szerokość wejść prawie nigdy nie jest zmapowana. Czasy przejścia to szacunki. Kafelki mapy z serwera OSM nadają się do demonstracji — produkcja wymaga własnego dostawcy kafelków.', 'about.limits.body': 'Obszar: od Wawelu i Kazimierza przez Stare Miasto po TAURON Arenę i Czyżyny. Logowanie i zgłoszenia są demonstracyjne i lokalne (bez serwera). Dane miejskie obejmują na razie tylko przystanki. Wysokość krawężników znana tylko dla części przejść. Szerokość wejść prawie nigdy nie jest zmapowana. Czasy przejścia to szacunki. Kafelki mapy z serwera OSM nadają się do demonstracji — produkcja wymaga własnego dostawcy kafelków.',
'about.demo': 'Kontrolki demonstracyjne', 'about.demo': 'Kontrolki demonstracyjne',
'about.simulate': 'Symuluj niedostępność źródła danych (OSM)', 'about.simulate': 'Symuluj niedostępność źródła danych (OSM)',
'about.simulate.help': 'Odświeżenie miejsca zakończy się błędem — pokazuje, co zobaczy użytkownik.', 'about.simulate.help': 'Odświeżenie miejsca zakończy się błędem — pokazuje, co zobaczy użytkownik.',
@ -438,6 +439,7 @@ const pl = {
'detail.card': 'Karta dostępności (drukuj)', 'detail.card': 'Karta dostępności (drukuj)',
'detail.gmaps': '— otwiera Google Maps w nowej karcie', 'detail.gmaps': '— otwiera Google Maps w nowej karcie',
'detail.tools': 'Dane i narzędzia', 'detail.tools': 'Dane i narzędzia',
'detail.reports.pending': 'Oczekuje na urząd',
'detail.reach': 'Co jest w zasięgu bez barier?', 'detail.reach': 'Co jest w zasięgu bez barier?',
'cat.transit.long': 'Przystanki', 'cat.transit.long': 'Przystanki',
'card.title': 'Karta dostępności', 'card.title': 'Karta dostępności',
@ -759,6 +761,7 @@ const en: Dict = {
'view.label': 'Results view', 'view.label': 'Results view',
'cat.all': 'All', 'cat.all': 'All',
'cat.food': 'Food and drink', 'cat.food': 'Food and drink',
'cat.sport': 'Sport and recreation',
'cat.city': 'City attractions', 'cat.city': 'City attractions',
'cat.culture': 'Culture and worship', 'cat.culture': 'Culture and worship',
'cat.shop': 'Shops', 'cat.shop': 'Shops',
@ -925,7 +928,7 @@ const en: Dict = {
'route.go': 'Find route', 'route.go': 'Find route',
'route.myLocation': 'Use my location', 'route.myLocation': 'Use my location',
'route.locating': 'Finding your location…', 'route.locating': 'Finding your location…',
'route.locOut': 'You are outside the demo area (Old Town, Kazimierz, Wawel).', 'route.locOut': 'You are outside the demo area (from Wawel and Kazimierz through the Old Town to TAURON Arena).',
'route.locFail': 'Could not get your location. Type a starting point.', 'route.locFail': 'Could not get your location. Type a starting point.',
'route.presets': 'Example routes', 'route.presets': 'Example routes',
'route.unknownPlace': 'No place found for “{text}”. Pick one of the suggestions.', 'route.unknownPlace': 'No place found for “{text}”. Pick one of the suggestions.',
@ -1012,7 +1015,7 @@ const en: Dict = {
'about.biz': 'Business model and scaling', 'about.biz': 'Business model and scaling',
'about.biz.body': 'Free app for residents and tourists. Revenue from B2B: (1) an “venue accessibility” widget and report for hotels, event organisers and property managers, (2) an accessibility API for booking systems and travel apps, (3) a roll-out package for cities. Costs are static hosting, periodic Overpass pulls and report moderation. The operating entity owns the product, not the city office.', 'about.biz.body': 'Free app for residents and tourists. Revenue from B2B: (1) an “venue accessibility” widget and report for hotels, event organisers and property managers, (2) an accessibility API for booking systems and travel apps, (3) a roll-out package for cities. Costs are static hosting, periodic Overpass pulls and report moderation. The operating entity owns the product, not the city office.',
'about.limits': 'Known prototype limitations', 'about.limits': 'Known prototype limitations',
'about.limits.body': 'Area: Old Town, Kazimierz, Wawel. Sign-in and reports are demo-only and local (no server). City data covers stops only for now. Kerb height is known for only some crossings. Entrance width is almost never mapped. Walking times are estimates. OSM tile servers are fine for a demo — production needs its own tile provider.', 'about.limits.body': 'Area: from Wawel and Kazimierz through the Old Town to TAURON Arena and Czyżyny. Sign-in and reports are demo-only and local (no server). City data covers stops only for now. Kerb height is known for only some crossings. Entrance width is almost never mapped. Walking times are estimates. OSM tile servers are fine for a demo — production needs its own tile provider.',
'about.demo': 'Demo controls', 'about.demo': 'Demo controls',
'about.simulate': 'Simulate the data source being unavailable (OSM)', 'about.simulate': 'Simulate the data source being unavailable (OSM)',
'about.simulate.help': 'Refreshing a place will fail — shows what a user would see.', 'about.simulate.help': 'Refreshing a place will fail — shows what a user would see.',
@ -1134,6 +1137,7 @@ const en: Dict = {
'detail.card': 'Access card (print)', 'detail.card': 'Access card (print)',
'detail.gmaps': '— opens Google Maps in a new tab', 'detail.gmaps': '— opens Google Maps in a new tab',
'detail.tools': 'Data and tools', 'detail.tools': 'Data and tools',
'detail.reports.pending': 'Awaiting the city office',
'detail.reach': 'What is within barrier-free reach?', 'detail.reach': 'What is within barrier-free reach?',
'cat.transit.long': 'Stops', 'cat.transit.long': 'Stops',
'card.title': 'Access card', 'card.title': 'Access card',

View file

@ -19,10 +19,11 @@ export const PLACE_TAGS = [
]; ];
export function category(t: Record<string, string>): PlaceCategory { export function category(t: Record<string, string>): PlaceCategory {
const a = t.amenity, to = t.tourism, h = t.historic; const a = t.amenity, to = t.tourism, h = t.historic, l = t.leisure;
if (['restaurant', 'cafe', 'bar', 'pub', 'fast_food', 'ice_cream', 'food_court'].includes(a ?? '')) return 'food'; if (['restaurant', 'cafe', 'bar', 'pub', 'fast_food', 'ice_cream', 'food_court'].includes(a ?? '')) return 'food';
if (['pharmacy', 'clinic', 'doctors', 'dentist', 'hospital'].includes(a ?? '')) return 'health'; if (['pharmacy', 'clinic', 'doctors', 'dentist', 'hospital'].includes(a ?? '')) return 'health';
if (['museum', 'gallery', 'attraction', 'information'].includes(to ?? '') || h) return 'city'; if (['museum', 'gallery', 'attraction', 'information'].includes(to ?? '') || h || l === 'stadium' || ['events_venue', 'conference_centre', 'exhibition_centre'].includes(a ?? '')) return 'city';
if (['sports_centre', 'swimming_pool', 'fitness_centre'].includes(l ?? '')) return 'sport';
if (['cinema', 'theatre', 'library', 'arts_centre'].includes(a ?? '')) return 'culture'; if (['cinema', 'theatre', 'library', 'arts_centre'].includes(a ?? '')) return 'culture';
if (['hotel', 'hostel', 'guest_house', 'apartment'].includes(to ?? '')) return 'stay'; if (['hotel', 'hostel', 'guest_house', 'apartment'].includes(to ?? '')) return 'stay';
if (a === 'place_of_worship') return 'culture'; if (a === 'place_of_worship') return 'culture';
@ -31,7 +32,7 @@ export function category(t: Record<string, string>): PlaceCategory {
} }
/** Snapshots built before the "city offer" category still file sights under culture; this re-files them by `kind`. */ /** Snapshots built before the "city offer" category still file sights under culture; this re-files them by `kind`. */
const CITY_KINDS = new Set(['museum', 'gallery', 'attraction', 'information', 'castle', 'monument', 'church', 'city_gate', 'fort', 'building']); const CITY_KINDS = new Set(['museum', 'gallery', 'attraction', 'information', 'castle', 'monument', 'church', 'city_gate', 'fort', 'building', 'stadium']);
export const withCityOffer = (p: Place): Place => (p.cat === 'culture' && p.kind && CITY_KINDS.has(p.kind) ? { ...p, cat: 'city' } : p); export const withCityOffer = (p: Place): Place => (p.cat === 'culture' && p.kind && CITY_KINDS.has(p.kind) ? { ...p, cat: 'city' } : p);
const round = (n: number) => Math.round(n * 1e6) / 1e6; const round = (n: number) => Math.round(n * 1e6) / 1e6;
@ -53,7 +54,7 @@ export function toPlace(el: OsmElement, cat?: PlaceCategory): Place | null {
lon: round(lon), lon: round(lon),
name: t.name ?? null, name: t.name ?? null,
cat: cat ?? category(t), cat: cat ?? category(t),
kind: t.amenity ?? t.tourism ?? t.shop ?? t.historic ?? t.highway ?? t.railway ?? null, kind: t.amenity ?? t.tourism ?? t.shop ?? t.historic ?? t.leisure ?? t.highway ?? t.railway ?? null,
ts: day(el.timestamp), ts: day(el.timestamp),
tags, tags,
}; };

View file

@ -17,7 +17,7 @@ export interface Profile {
needChanging: boolean; needChanging: boolean;
} }
export type PlaceCategory = 'city' | 'food' | 'culture' | 'shop' | 'stay' | 'health' | 'services' | 'toilets' | 'elevator' | 'transit'; export type PlaceCategory = 'city' | 'food' | 'culture' | 'sport' | 'shop' | 'stay' | 'health' | 'services' | 'toilets' | 'elevator' | 'transit';
export interface Place { export interface Place {
id: string; id: string;

View file

@ -418,3 +418,33 @@ body { margin: 0; }
.kbb-doc__foot { font-size: 8pt; } .kbb-doc__foot { font-size: 8pt; }
} }
.kbb-doc__fact-head > span { white-space: nowrap; flex: none; } .kbb-doc__fact-head > span { white-space: nowrap; flex: none; }
/* ---- user report card (place view + city inbox) ---- */
.kbb-rcard { display: flex; gap: 0.9rem; padding: 0.9rem; border: 1px solid #dfe1e2; border-radius: 0.25rem; background: #fff; }
.kbb-rcard__media { flex: none; width: 5.5rem; height: 5.5rem; border-radius: 0.25rem; overflow: hidden; background: #1b1b1b; }
.kbb-rcard__media img { display: block; width: 100%; height: 100%; object-fit: cover; }
.kbb-rcard__media.is-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.2rem; padding: 0.25rem; border: 2px dashed #a9aeb1; background: #f0f0f0; color: #565c65; text-align: center; font-size: 0.7rem; line-height: 1.15; }
.kbb-rcard__media .usa-icon { fill: #71767a; }
.kbb-rcard__body { flex: 1; min-width: 0; }
.kbb-rcard__top { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 0.25rem 0.75rem; }
.kbb-rcard__title { margin: 0; font-size: 1.05rem; line-height: 1.3; font-weight: 400; }
.kbb-rcard__title strong, h2.kbb-rcard__title, h3.kbb-rcard__title { font-weight: 700; }
.kbb-rcard__claim { margin: 0.15rem 0 0; }
.kbb-rcard__meta { margin: 0.15rem 0 0; font-size: 0.85rem; color: #565c65; }
.kbb-rcard__line { margin: 0.4rem 0 0; font-size: 0.9rem; }
.kbb-rcard__note { margin: 0.4rem 0 0; padding-left: 0.6rem; border-left: 3px solid #dfe1e2; font-size: 0.9rem; color: #454545; }
.kbb-rcard__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 0.75rem; }
@media (max-width: 29.99em) {
.kbb-rcard { gap: 0.7rem; padding: 0.75rem; }
.kbb-rcard__media { width: 4.25rem; height: 4.25rem; }
.kbb-rcard__actions .usa-button:not(.usa-button--unstyled) { flex: 1 1 0; }
}
/* ---- category pictograms ---- */
.kbb-cat { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; margin-right: 0.75rem; border-radius: 0.5rem; background: #e7f6f8; color: #005ea2; }
.kbb-cat .usa-icon { fill: currentColor; }
.kbb-cat--sm { width: 1.5rem; height: 1.5rem; margin-right: 0; border-radius: 0.35rem; background: rgba(0, 94, 162, 0.12); }
.kbb-chip.is-on .kbb-cat--sm { background: rgba(255, 255, 255, 0.25); color: #fff; }
.kbb-group__h { display: flex; align-items: center; gap: 0.5rem; }
.kbb-group__h .kbb-cat { margin-right: 0; width: 2rem; height: 2rem; }
.kbb-place-btn .kbb-cat { margin-top: 0.1rem; }