Add pictograms
This commit is contained in:
parent
984ac0fd3f
commit
8bdda8ae02
15 changed files with 252 additions and 104 deletions
|
|
@ -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.
|
||||
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
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -6,20 +6,22 @@ import { existsSync } from 'node:fs';
|
|||
import { execFileSync } from 'node:child_process';
|
||||
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 OVERPASS = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
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"]["shop"](${bb});
|
||||
nwr["name"]["historic"~"^(castle|monument|church|building|city_gate|fort)$"](${bb});
|
||||
);
|
||||
out meta center;`,
|
||||
support: `[out:json][timeout:90];
|
||||
support: `[out:json][timeout:240];
|
||||
(
|
||||
nwr["amenity"="toilets"](${bb});
|
||||
nwr["highway"="elevator"](${bb});
|
||||
|
|
@ -28,12 +30,12 @@ out meta center;`,
|
|||
node["railway"="tram_stop"]["name"](${bb});
|
||||
);
|
||||
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});
|
||||
out meta;
|
||||
>;
|
||||
out skel qt;`,
|
||||
kerbs: `[out:json][timeout:90];
|
||||
kerbs: `[out:json][timeout:240];
|
||||
(
|
||||
node["kerb"](${bb});
|
||||
node["highway"="crossing"](${bb});
|
||||
|
|
@ -41,11 +43,16 @@ out skel qt;`,
|
|||
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) {
|
||||
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'));
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const res = await fetch(OVERPASS, {
|
||||
for (let attempt = 1; attempt <= 8; attempt++) {
|
||||
const endpoint = MIRRORS[(attempt - 1) % MIRRORS.length];
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
signal: AbortSignal.timeout(280_000), // a hung mirror must not stall the build forever
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'krakow-bez-barier-hackathon/0.1',
|
||||
|
|
@ -56,11 +63,15 @@ async function fetchRaw(name) {
|
|||
});
|
||||
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}, retry ${attempt}`);
|
||||
await new Promise((r) => setTimeout(r, 8000 * attempt));
|
||||
console.warn(`${name}: HTTP ${res.status} from ${endpoint}, retry ${attempt}`);
|
||||
} catch (e) {
|
||||
console.warn(`${name}: ${e.message} from ${endpoint}, retry ${attempt}`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 15000 * attempt));
|
||||
}
|
||||
throw new Error(`Overpass failed for ${name}`);
|
||||
}
|
||||
|
|
@ -68,9 +79,9 @@ async function fetchRaw(name) {
|
|||
const round = (n) => Math.round(n * 1e6) / 1e6;
|
||||
const day = (ts) => (ts ? ts.slice(0, 10) : null);
|
||||
|
||||
const [placesRaw, supportRaw, netRaw, kerbRaw] = await Promise.all(
|
||||
Object.keys(queries).map((q) => fetchRaw(q)),
|
||||
);
|
||||
const raw = {};
|
||||
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 toilets = [], elevators = [], benches = [], stops = [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
await page.click('text=Zaloguj jako Anna Kowalska');
|
||||
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.click('button[id^=place-]');
|
||||
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.click('[role=tab]:has-text("Jakość")');
|
||||
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
|
||||
await page.click('nav >> text=Trasa');
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { useI18n, type Key } from '../lib/i18n';
|
||||
import { usePhoto } from '../lib/usePhoto';
|
||||
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 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];
|
||||
};
|
||||
|
||||
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 {
|
||||
reports: UserReport[];
|
||||
placeById: Map<string, Place>;
|
||||
|
|
@ -97,26 +91,12 @@ export function DashQueue({ reports, placeById, onDecide, onUndo, onOpenPlace }:
|
|||
const p = placeById.get(r.placeId);
|
||||
const name = p?.name ?? r.placeId;
|
||||
return (
|
||||
<li key={r.id} className="kbb-q-item">
|
||||
<Thumb id={r.photoId} />
|
||||
<div className="kbb-q-item__body">
|
||||
<h2 className="margin-0 font-sans-md">
|
||||
{name}{' '}
|
||||
{r.demo && <span className="usa-tag bg-ink text-white">{t('detail.reports.demo')}</span>}
|
||||
</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">
|
||||
<li key={r.id} className="margin-bottom-2">
|
||||
<ReportCard report={r} placeName={name} headingLevel="h2" detailed>
|
||||
<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>
|
||||
</ReportCard>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { LatLon } from '../lib/geo';
|
|||
import type { Hotspot } from '../lib/civic';
|
||||
import { HARD_FLAGS, type RouteResult } from '../lib/route';
|
||||
import type { Place, Verdict } from '../lib/types';
|
||||
import { CATEGORY_ICON, glyphBitmap } from '../lib/categoryIcon';
|
||||
import { VERDICT_STYLE } from './ui';
|
||||
|
||||
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('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 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',
|
||||
layout: { 'circle-sort-key': ['match', ['get', 'verdict'], 'incomplete', 0, 1] },
|
||||
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-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-width': ['match', ['get', 'verdict'], 'incomplete', 1.5, 2],
|
||||
},
|
||||
});
|
||||
void addGlyphs(map);
|
||||
map.addLayer({
|
||||
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 },
|
||||
|
|
@ -162,7 +186,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
|
|||
features: places.map(({ place, verdict }) => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'Point' as const, coordinates: [place.lon, place.lat] },
|
||||
properties: { id: place.id, verdict },
|
||||
properties: { id: place.id, verdict, cat: place.cat },
|
||||
})),
|
||||
}),
|
||||
[places],
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { googleMapsPlaceUrl } from '../lib/links';
|
|||
import { useI18n, type Key } from '../lib/i18n';
|
||||
import { DEMO_PLACES } from '../lib/demo';
|
||||
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 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 }
|
||||
|
||||
|
|
@ -173,8 +173,14 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
|||
{mine.length === 0 ? (
|
||||
<div className="kbb-empty"><Icon name="edit" size={3} /><p className="margin-0">{t('detail.reports.none')}</p></div>
|
||||
) : (
|
||||
<ul className="usa-list">
|
||||
{mine.map((r) => <ReportItem key={r.id} r={r} onRemove={() => p.onRemoveReport(r.id)} />)}
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -223,27 +229,3 @@ function osmPath(id: string) {
|
|||
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { VERDICT_ORDER } from '../lib/assess';
|
|||
import { DEMO_PLACES } from '../lib/demo';
|
||||
import { fold } from '../lib/text';
|
||||
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 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". */
|
||||
const PER_GROUP = 5;
|
||||
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')}>
|
||||
{(['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 })}>
|
||||
{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>
|
||||
))}
|
||||
</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
|
||||
groups.map(([cat, list]) => (
|
||||
<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">
|
||||
{list.slice(0, PER_GROUP).map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
|
||||
</ul>
|
||||
|
|
@ -126,9 +126,10 @@ function PlaceRow({ place: p, assessment: a, onSelect }: { place: Place; assessm
|
|||
<li className="margin-bottom-1">
|
||||
<button
|
||||
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 font-sans-xs text-base-dark margin-top-05">
|
||||
{t(`cat.${p.cat}` as Key)}
|
||||
|
|
|
|||
68
src/components/ReportCard.tsx
Normal file
68
src/components/ReportCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
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`;
|
||||
|
||||
|
|
@ -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 }> = {
|
||||
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' },
|
||||
|
|
|
|||
36
src/lib/categoryIcon.ts
Normal file
36
src/lib/categoryIcon.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ const pl = {
|
|||
'view.label': 'Widok wyników',
|
||||
'cat.all': 'Wszystkie',
|
||||
'cat.food': 'Jedzenie i picie',
|
||||
'cat.sport': 'Sport i rekreacja',
|
||||
'cat.city': 'Oferta Miasta',
|
||||
'cat.culture': 'Kultura i religia',
|
||||
'cat.shop': 'Sklepy',
|
||||
|
|
@ -229,7 +230,7 @@ const pl = {
|
|||
'route.go': 'Wyznacz trasę',
|
||||
'route.myLocation': 'Użyj mojej 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.presets': 'Przykładowe trasy',
|
||||
'route.unknownPlace': 'Nie znaleziono miejsca „{text}”. Wybierz pozycję z podpowiedzi.',
|
||||
|
|
@ -316,7 +317,7 @@ const pl = {
|
|||
'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.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.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.',
|
||||
|
|
@ -438,6 +439,7 @@ const pl = {
|
|||
'detail.card': 'Karta dostępności (drukuj)',
|
||||
'detail.gmaps': '— otwiera Google Maps w nowej karcie',
|
||||
'detail.tools': 'Dane i narzędzia',
|
||||
'detail.reports.pending': 'Oczekuje na urząd',
|
||||
'detail.reach': 'Co jest w zasięgu bez barier?',
|
||||
'cat.transit.long': 'Przystanki',
|
||||
'card.title': 'Karta dostępności',
|
||||
|
|
@ -759,6 +761,7 @@ const en: Dict = {
|
|||
'view.label': 'Results view',
|
||||
'cat.all': 'All',
|
||||
'cat.food': 'Food and drink',
|
||||
'cat.sport': 'Sport and recreation',
|
||||
'cat.city': 'City attractions',
|
||||
'cat.culture': 'Culture and worship',
|
||||
'cat.shop': 'Shops',
|
||||
|
|
@ -925,7 +928,7 @@ const en: Dict = {
|
|||
'route.go': 'Find route',
|
||||
'route.myLocation': 'Use my 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.presets': 'Example routes',
|
||||
'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.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.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.simulate': 'Simulate the data source being unavailable (OSM)',
|
||||
'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.gmaps': '— opens Google Maps in a new tab',
|
||||
'detail.tools': 'Data and tools',
|
||||
'detail.reports.pending': 'Awaiting the city office',
|
||||
'detail.reach': 'What is within barrier-free reach?',
|
||||
'cat.transit.long': 'Stops',
|
||||
'card.title': 'Access card',
|
||||
|
|
|
|||
|
|
@ -19,10 +19,11 @@ export const PLACE_TAGS = [
|
|||
];
|
||||
|
||||
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 (['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 (['hotel', 'hostel', 'guest_house', 'apartment'].includes(to ?? '')) return 'stay';
|
||||
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`. */
|
||||
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);
|
||||
|
||||
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),
|
||||
name: t.name ?? null,
|
||||
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),
|
||||
tags,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface Profile {
|
|||
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 {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -418,3 +418,33 @@ body { margin: 0; }
|
|||
.kbb-doc__foot { font-size: 8pt; }
|
||||
}
|
||||
.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; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue