Fix bus stops and rebrand (TBD).

This commit is contained in:
InfoX 2026-10-03 19:14:41 +02:00
parent 8bdda8ae02
commit 7b32a47fc0
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
31 changed files with 2235 additions and 171 deletions

View file

@ -1,10 +1,10 @@
# Kraków bez barier
# GdzieWjade
A PWA that tells wheelchair users and parents with strollers whether a place or route in Kraków fits
**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: Wawel, Old Town and Kazimierz out east to TAURON Arena (≈19 km²).
Prototype for the "Kraków bez barier" hackathon challenge (the app name is one constant: `APP_NAME` in `src/config.ts`). Demo area: Wawel, Old Town and Kazimierz out east to TAURON Arena (≈19 km²).
## Run
@ -49,6 +49,24 @@ official, unlocks the City panel). Everything is stored in the browser, per acco
It only orders the city queue. If the model cannot load the app says "No Credits left". Hosted free vision APIs
need a key (Pollinations anonymous is text-only), so none are wired in.
## Public transport
The route form has a **walking / transit** switch. In transit mode the app plans up to one transfer from the ZTP Kraków
timetable (static GTFS, `public/data/kk-transit.json`, built by `pnpm transit:build`):
- every walking leg (to the first stop, between stops when changing, from the last stop) is routed with the user's limits, with
barriers counted per leg; stops that cannot be reached for the profile are dropped;
- each connection shows departure and arrival times, delays, the next departures of the line and stop accessibility from OSM;
- a stop's detail page lists its next departures; other places list the stops within 400 m.
**Live delays** come from ZTP's GTFS-Realtime feeds (`TripUpdates_A/T.pb`, `ServiceAlerts_A/T.pb`, decoded in `src/lib/gtfsrt.ts`, no
dependency). ZTP sends no CORS headers, so the app requests `rt/*`, which `pnpm dev` and `pnpm preview` proxy to
`gtfs.ztp.krakow.pl`. A static production host needs the same reverse-proxy rule (nginx: `location /rt/ { proxy_pass https://gtfs.ztp.krakow.pl/; }`).
Without it the app says "timetable only" and shows no delays; it never pretends a trip is on time when no prediction exists.
The ZTP timetable has **no low-floor flag** (`wheelchair_accessible` is 0 for all 57k trips in the area) and the stops' `wheelchair_boarding`
is empty, so the app cannot promise an accessible vehicle and says so.
## Data and honesty rules
- **OpenStreetMap** (ODbL) via Overpass: places, steps, ramps, kerbs, surfaces, benches, toilets.

View file

@ -7,7 +7,7 @@
<meta name="description" content="Sprawdź dostępność miejsc i tras w Krakowie dla osób na wózkach i rodziców z wózkami dziecięcymi — ze źródłem, datą i wiarygodnością każdej informacji." />
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
<title>Kraków bez barier</title>
<title>%APP_NAME%</title>
</head>
<body>
<div id="root"></div>

View file

@ -9,7 +9,8 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"data:build": "node scripts/build-data.mjs",
"data:build": "node scripts/build-data.mjs && node scripts/build-transit.mjs",
"transit:build": "node scripts/build-transit.mjs",
"predev": "node scripts/copy-worker.mjs",
"prebuild": "node scripts/copy-worker.mjs",
"smoke": "node scripts/smoke.mjs",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -30,8 +30,12 @@ await page.click('button[id^=place-]');
await page.waitForSelector('text=Szczegóły dostępności');
await scan('place detail');
await page.click('nav >> text=Trasa');
await page.click('text=Sukiennice → Zamek Królewski na Wawelu');
await page.fill('#route-from', 'Sukiennice');
await page.fill('#route-to', 'Zamek Królewski na Wawelu');
await page.click('button[type=submit]:has-text("Wyznacz")');
await page.waitForSelector('text=Opis trasy tekstem');
await page.waitForTimeout(1500); // let the map settle: pins that are still moving overlap and trip the target-size rule
await page.evaluate(() => document.querySelector('.kbb-panel')?.scrollTo(0, 0)); // the result scrolls under the phone sheet's sticky handle; scan from the top
await scan('route');
await page.click('nav >> text=Profil');
await page.waitForSelector('#st1');

View file

@ -35,6 +35,15 @@ way["highway"~"^(footway|pedestrian|path|steps|living_street|residential|service
out meta;
>;
out skel qt;`,
// named green areas (parks, gardens, cemeteries...) as outlines: unnamed paths inside them get the area's name in route text
parks: `[out:json][timeout:240];
(
way["name"]["leisure"~"^(park|garden|playground)$"](${bb});
relation["name"]["leisure"~"^(park|garden)$"](${bb});
way["name"]["landuse"~"^(recreation_ground|cemetery)$"](${bb});
way["name"]["natural"="wood"](${bb});
);
out geom;`,
kerbs: `[out:json][timeout:240];
(
node["kerb"](${bb});
@ -81,7 +90,45 @@ const day = (ts) => (ts ? ts.slice(0, 10) : null);
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, support: supportRaw, net: netRaw, kerbs: kerbRaw, parks: parksRaw } = raw;
// ---- green areas: closed rings, relations stitched from their outer member ways ----
const r5 = (n) => Math.round(n * 1e5) / 1e5;
const pt = (g) => [r5(g.lat), r5(g.lon)];
const same = (a, b) => a[0] === b[0] && a[1] === b[1];
function stitch(segs) {
const rings = [];
const left = segs.map((s) => s.slice());
while (left.length) {
let ring = left.pop();
for (let progress = true; progress && !same(ring[0], ring[ring.length - 1]);) {
progress = false;
for (let i = 0; i < left.length; i++) {
const s = left[i], end = ring[ring.length - 1];
if (same(s[0], end)) ring = ring.concat(s.slice(1));
else if (same(s[s.length - 1], end)) ring = ring.concat(s.slice(0, -1).reverse());
else continue;
left.splice(i, 1);
progress = true;
break;
}
}
if (ring.length >= 4 && same(ring[0], ring[ring.length - 1])) rings.push(ring);
}
return rings;
}
const parks = [];
for (const el of parksRaw.elements) {
const n = el.tags?.name;
if (!n) continue;
if (el.type === 'way' && el.geometry) {
const ring = el.geometry.map(pt);
if (ring.length >= 4 && same(ring[0], ring[ring.length - 1])) parks.push({ n, p: ring });
} else if (el.type === 'relation') {
const outer = (el.members ?? []).filter((m) => m.type === 'way' && m.role !== 'inner' && m.geometry).map((m) => m.geometry.map(pt));
for (const ring of stitch(outer)) parks.push({ n, p: ring });
}
}
const places = placesRaw.elements.map((e) => toPlace(e)).filter(Boolean);
const toilets = [], elevators = [], benches = [], stops = [];
@ -242,7 +289,7 @@ const out = {
cityData: { name: 'ZTP Kraków GTFS (stops.txt: wheelchair_boarding)', url: 'https://gtfs.ztp.krakow.pl/', feedDates: feeds.map((f) => ({ mode: f.mode, version: f.feedVersion })), license: 'verify at otwartedane.um.krakow.pl before reuse' },
counts: { places: places.length + transit.length, transit: transit.length, toilets: toilets.length, elevators: elevators.length, benches: benches.length, ways: ways.length, nodes: nodes.length },
},
places: [...places, ...transit], toilets, elevators, benches, nodes, ways, kerbs,
places: [...places, ...transit], toilets, elevators, benches, nodes, ways, kerbs, parks,
};
await mkdir('public/data', { recursive: true });
await writeFile('public/data/kk-snapshot.json', JSON.stringify(out));

135
scripts/build-transit.mjs Normal file
View file

@ -0,0 +1,135 @@
// Builds public/data/kk-transit.json: the part of the ZTP Kraków timetable (GTFS) that touches the snapshot area.
// Run after `pnpm data:build` (it reads the stops in public/data/kk-snapshot.json). Uses the GTFS zips cached in scripts/.cache.
// Data © ZTP Kraków / MPK. Licence of the feed: confirm at otwartedane.um.krakow.pl before reuse.
import { readFile, writeFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
const snap = JSON.parse(await readFile('public/data/kk-snapshot.json', 'utf8'));
const FEEDS = [{ mode: 'bus', zip: 'scripts/.cache/gtfs-bus.zip' }, { mode: 'tram', zip: 'scripts/.cache/gtfs-tram.zip' }];
function parseCsv(text) {
const rows = [];
let row = [], cur = '', q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"' && text[i + 1] === '"') { cur += '"'; i++; }
else if (c === '"') q = false;
else cur += c;
} else if (c === '"') q = true;
else if (c === ',') { row.push(cur); cur = ''; }
else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++;
row.push(cur); cur = '';
if (row.length > 1 || row[0] !== '') rows.push(row);
row = [];
} else cur += c;
}
if (cur || row.length) { row.push(cur); rows.push(row); }
const [head, ...body] = rows;
return body.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h.replace(/^/, ''), r[i]])));
}
const read = (zip, f) => execFileSync('unzip', ['-p', zip, f], { maxBuffer: 512 * 1024 * 1024 }).toString('utf8');
const minutes = (hms) => { const [h, m, s] = hms.split(':').map(Number); return h * 60 + m + Math.round((s || 0) / 60); };
const dateInt = (s) => Number(s);
const routes = []; // [{ id, short, mode, color }]
const routeIdx = new Map();
const services = []; // sorted yyyymmdd ints
const patterns = []; // see format below
const patternIdx = new Map();
const stopsUsed = new Set();
let tripCount = 0;
for (const feed of FEEDS) {
if (!existsSync(feed.zip)) throw new Error(`missing ${feed.zip}: run pnpm data:build first`);
const prefix = `z${feed.mode[0]}`;
const areaStops = new Set(snap.places.filter((p) => p.cat === 'transit' && p.id.startsWith(prefix)).map((p) => p.id.slice(2)));
const routeRows = parseCsv(read(feed.zip, 'routes.txt'));
const routeInfo = new Map(routeRows.map((r) => [r.route_id, { short: r.route_short_name, color: r.route_color && r.route_color !== 'FFFFFF' ? r.route_color : '' }]));
// service_id -> dates
const cal = parseCsv(read(feed.zip, 'calendar.txt'));
const exc = parseCsv(read(feed.zip, 'calendar_dates.txt'));
const svcDates = new Map();
const add = (id, d) => { (svcDates.get(id) ?? svcDates.set(id, new Set()).get(id)).add(d); };
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
for (const c of cal) {
const start = dateInt(c.start_date), end = dateInt(c.end_date);
if (!dayNames.some((d) => c[d] === '1')) { svcDates.get(c.service_id) ?? svcDates.set(c.service_id, new Set()); continue; }
for (let t = Date.UTC(+String(start).slice(0, 4), +String(start).slice(4, 6) - 1, +String(start).slice(6, 8)); ; t += 864e5) {
const dt = new Date(t); const d = dt.getUTCFullYear() * 10000 + (dt.getUTCMonth() + 1) * 100 + dt.getUTCDate();
if (d > end) break;
if (c[dayNames[dt.getUTCDay()]] === '1') add(c.service_id, d);
}
}
for (const e of exc) {
if (e.exception_type === '1') add(e.service_id, dateInt(e.date));
else if (e.exception_type === '2') svcDates.get(e.service_id)?.delete(dateInt(e.date));
}
const svcIdx = new Map();
for (const [id, set] of svcDates) svcIdx.set(id, services.push([...set].sort((a, b) => a - b)) - 1);
// trips
const trips = new Map();
for (const t of parseCsv(read(feed.zip, 'trips.txt'))) trips.set(t.trip_id, t);
// stop_times: keep only rows at area stops
const byTrip = new Map();
const lines = read(feed.zip, 'stop_times.txt').split(/\r\n|\r|\n/);
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const c0 = line.indexOf(','), c1 = line.indexOf(',', c0 + 1), c2 = line.indexOf(',', c1 + 1), c3 = line.indexOf(',', c2 + 1), c4 = line.indexOf(',', c3 + 1);
const stop = line.slice(c2 + 1, c3).replace(/"/g, '');
if (!areaStops.has(stop)) continue;
const tripId = line.slice(0, c0).replace(/"/g, '');
const seq = Number(line.slice(c3 + 1, c4));
const arr = minutes(line.slice(c0 + 1, c1)), dep = minutes(line.slice(c1 + 1, c2));
(byTrip.get(tripId) ?? byTrip.set(tripId, []).get(tripId)).push({ stop, seq, arr, dep });
}
for (const [tripId, rows] of byTrip) {
const trip = trips.get(tripId);
if (!trip || svcIdx.get(trip.service_id) === undefined) continue;
rows.sort((a, b) => a.seq - b.seq);
const info = routeInfo.get(trip.route_id);
if (!info) continue;
const rk = `${feed.mode}:${info.short}`;
let r = routeIdx.get(rk);
if (r === undefined) { r = routes.push({ short: info.short, mode: feed.mode, color: info.color }) - 1; routeIdx.set(rk, r); }
const stops = rows.map((x) => `${prefix}${x.stop}`);
stops.forEach((s) => stopsUsed.add(s));
const t0 = rows[0].dep;
const offs = rows.map((x) => x.arr - t0).concat(rows.map((x) => x.dep - t0)); // [arrivals..., departures...]
const key = `${r}|${trip.trip_headsign}|${stops.join(',')}`;
let pi = patternIdx.get(key);
if (pi === undefined) {
pi = patterns.push({ r, h: trip.trip_headsign, s: stops, o: [], t: [], _ov: new Map() }) - 1;
patternIdx.set(key, pi);
}
const p = patterns[pi];
const ok = offs.join(',');
let vi = p._ov.get(ok);
if (vi === undefined) { vi = p.o.push(offs) - 1; p._ov.set(ok, vi); }
// wheelchair_accessible: 1 = low-floor vehicle, 2 = not accessible, 0 = unknown
p.t.push([svcIdx.get(trip.service_id), t0, vi, Number(trip.wheelchair_accessible) || 0, tripId]);
tripCount++;
}
}
for (const p of patterns) { p.t.sort((a, b) => a[1] - b[1]); delete p._ov; }
const out = {
meta: {
builtAt: new Date().toISOString(),
source: 'ZTP Kraków GTFS',
feeds: FEEDS.map((f) => ({ mode: f.mode, version: snap.meta.cityData.feedDates.find((d) => d.mode === f.mode)?.version ?? null })),
license: 'verify at otwartedane.um.krakow.pl before reuse',
note: 'Pattern t: [serviceIdx, firstStopDepartureMin, offsetVariantIdx, wheelchair(0 unknown/1 low-floor/2 no), tripId]; o: arrivals then departures, minutes relative to t0; services: yyyymmdd lists.',
},
routes, services, patterns,
};
await writeFile('public/data/kk-transit.json', JSON.stringify(out));
console.log({ routes: routes.length, services: services.length, patterns: patterns.length, trips: tripCount, stops: stopsUsed.size });

View file

@ -48,15 +48,34 @@ await page.click('[role=tab]:has-text("Jakość")');
await page.waitForSelector('.kbb-kpis');
ok(await page.getByText(/^0 \/ \d+$/).first().isVisible(), 'data tab shows operator data gap (0 / N stops)');
// public transport: toggle in the route form, then either connections or an honest "none" (depends on the time of day)
await page.click('nav >> text=Trasa');
await page.fill('#route-from', 'Sukiennice');
await page.fill('#route-to', 'Tauron Arena Kraków');
await page.click('button[type=submit]:has-text("Wyznacz")');
await page.waitForSelector('.kbb-modes');
await page.click('.kbb-mode:has-text("Komunikacja")');
await page.locator('.kbb-jhead').first().or(page.getByText('Brak połączenia')).first().waitFor({ timeout: 20000 }).catch(async () => {
console.error('transit result text:', (await page.locator('.kbb-result').innerText()).slice(0, 300), '| errors:', errors.slice(0, 3));
});
const connections = await page.locator('.kbb-jhead').count();
ok(connections > 0 || (await page.getByText('Brak połączenia').count()) > 0, `transit planner answered (${connections} connections)`);
await page.click('.kbb-mode:has-text("Pieszo")');
// map pins + route render without worker errors
await page.click('nav >> text=Trasa');
await page.click('text=Sukiennice → Zamek Królewski na Wawelu');
await page.fill('#route-from', 'Sukiennice');
await page.fill('#route-to', 'Zamek Królewski na Wawelu');
await page.click('button[type=submit]:has-text("Wyznacz")');
await page.waitForSelector('text=Opis trasy tekstem');
await page.waitForTimeout(1500);
const canvas = await page.locator('canvas.maplibregl-canvas').count();
ok(canvas === 1, 'map canvas present');
ok(await page.locator('text=Bariery i uwagi na trasie').isVisible(), 'route summary + barriers listed');
ok(await page.locator('ol.usa-list li').count() > 3, 'text route steps listed');
const steps = page.locator('details:has(summary:has-text("Opis trasy tekstem"))');
ok(!(await steps.evaluate((d) => d.open)), 'text route is collapsed by default');
await steps.locator('summary').click();
ok((await steps.locator('li').count()) > 3 && (await steps.locator('li').first().isVisible()), 'text route steps listed once opened');
await page.click('text=Trasa >> nth=0').catch(() => {});
ok(errors.filter((e) => !/GL Driver|favicon/.test(e)).length === 0, `no console errors ${JSON.stringify(errors.slice(0, 3))}`);

View file

@ -3,7 +3,7 @@ import { AboutView } from './components/AboutView';
import { AccessCard } from './components/AccessCard';
import { DashboardView } from './components/DashboardView';
import { LoginView } from './components/LoginView';
import { MapView } from './components/MapView';
import { MapView, type JourneyOverlay } from './components/MapView';
import { PlaceDetail, type ReachState, type RefreshState } from './components/PlaceDetail';
import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './components/PlacesView';
import { ProfileView } from './components/ProfileView';
@ -29,6 +29,9 @@ import { NoCreditsError, vetReport } from './lib/vet';
import { GuideBanner } from './components/GuideBanner';
import { findAlternatives } from './lib/route';
import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings';
import { APP_SLUG } from './config';
import { useJourneys } from './lib/useTransit';
import { loadTransitIndex } from './lib/transit';
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types';
@ -41,15 +44,6 @@ const isStrOrNull = (v: unknown): v is string | null => v === null || typeof v =
const isStrArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === 'string');
const isObj = <T extends object>(v: unknown): v is T => typeof v === 'object' && v !== null && !Array.isArray(v);
/** Real place pairs from the snapshot; the result changes with the profile. Resolved by name at runtime. */
const PRESETS: { id: string; from: string; to: string }[] = [
{ id: 'wawel', from: 'Sukiennice', to: 'Zamek Królewski na Wawelu' },
{ id: 'kazimierz', from: 'Cinema City Kraków - Galeria Kazimierz', to: 'Sukiennice' },
{ id: 'remuh', from: 'Synagoga Remuh', to: 'Sukiennice' },
{ id: 'manggha', from: 'Narodowy Stary Teatr imienia Heleny Modrzejewskiej', to: 'Muzeum Sztuki i Techniki Japońskiej Manggha' },
{ id: 'schindler', from: 'Sukiennice', to: 'Schody z filmu Lista Schindlera' },
];
export default function App() {
// ---- identity (demo) ----
const [loginLang, setLoginLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
@ -236,9 +230,23 @@ export default function App() {
const altRoutes = useMemo(() => (routeOptions ?? []).filter((_, i) => i !== routeSel).map((o) => o.result.coords), [routeOptions, routeSel]);
const [routeEnds, setRouteEnds] = useState<{ from: LatLon; to: LatLon } | null>(null);
const [focus, setFocus] = useState<{ at: LatLon; n: number } | null>(null);
const [routeTab, setRouteTab] = useState<'walk' | 'transit'>('walk');
const transitStops = useMemo(() => realPlaces.filter((p) => p.cat === 'transit'), [realPlaces]);
// warm the timetable as soon as a route exists, so the "Komunikacja" tab is usually instant (skipped on metered links)
useEffect(() => { if (routeEnds && !isMetered()) void loadTransitIndex().catch(() => undefined); }, [routeEnds]);
const planner = useJourneys({ enabled: view === 'route' && routeTab === 'transit', ends: routeEnds, graph, profile, stops: transitStops });
const journey = useMemo<JourneyOverlay | null>(() => {
const j = view === 'route' && routeTab === 'transit' && planner.status === 'ready' ? planner.journeys[planner.selected] : undefined;
if (!j) return null;
return {
walks: [j.walkTo.coords, ...(j.transfer ? [j.transfer.walk.coords] : []), j.walkFrom.coords],
rides: j.rides.map((r) => ({ path: r.path, mode: r.dep.route.mode })),
stops: j.rides.flatMap((r) => [{ at: [r.from.lat, r.from.lon] as LatLon, label: `${r.dep.line} · ${r.from.name ?? ''}` }, { at: [r.to.lat, r.to.lon] as LatLon, label: `${r.dep.line} · ${r.to.name ?? ''}` }]),
};
}, [view, routeTab, planner]);
const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => {
setRouteOptions(o);
setRouteSel(0);
setRouteSel(Math.max(0, o?.findIndex((x) => x.recommended) ?? 0)); // the recommended variant is the default
setRouteEnds(from && to ? { from, to } : null);
if (o?.[0] && o[0].result.status !== 'none') setSheet('half');
};
@ -259,11 +267,6 @@ export default function App() {
setView('route');
setPane('list');
};
const presets = useMemo(() => {
const byName = (n: string) => realPlaces.find((p) => p.name === n)?.id;
return PRESETS.flatMap((p) => { const a = byName(p.from), b = byName(p.to); return a && b ? [{ id: p.id, fromId: a, toId: b }] : []; });
}, [realPlaces]);
// ---- city panel ----
const stats = useMemo(() => computeStats(places, assessments), [places, assessments]);
const hotspots = useMemo(() => (snap && index ? computeHotspots(snap, index, realPlaces) : []), [snap, index, realPlaces]);
@ -285,7 +288,7 @@ export default function App() {
const pendingCount = useMemo(() => reports.filter((r) => !r.status || r.status === 'pending').length, [reports]);
const doExport = (kind: 'csv' | 'geojson') => {
const stamp = new Date().toISOString().slice(0, 10);
const name = `krakow-bez-barier-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`;
const name = `${APP_SLUG}-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`;
if (kind === 'csv') download(name, 'text/csv', toCsv(realPlaces, assessments));
else download(name, 'application/geo+json', JSON.stringify(toGeoJson(realPlaces, assessments)));
setExportMsg(t('dash.export.done', { name }));
@ -481,6 +484,8 @@ export default function App() {
onClearReach={clearReach}
onBack={back}
onRouteTo={() => routeTo(selected)}
stops={transitStops}
onOpenStop={(id) => select(id)}
onRefresh={() => void doRefresh(selected)}
onAddReport={(r, photo) => void addReport(r, photo)}
onRemoveReport={(id) => { void deletePhoto(id); setUserReports((cur) => cur.filter((r) => r.id !== id)); }}
@ -494,8 +499,9 @@ export default function App() {
{view === 'route' && (
<RouteView
graph={graph} labels={labels} profile={profile} bbox={snap.meta.bbox}
tab={routeTab} onTab={setRouteTab} planner={planner} onShowTransit={() => setSheet((s) => (s === 'full' ? 'half' : s))}
form={routeForm} onForm={setRouteForm} options={routeOptions} ends={routeEnds} guiding={guiding} onGuide={() => { setGuiding(true); setPane('map'); }} selected={routeSel} onSelect={setRouteSel} onResult={onResult}
presets={presets} onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
/>
)}
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
@ -507,9 +513,10 @@ export default function App() {
showPlaces={view === 'places'}
selectedId={view === 'places' ? selectedId : null}
onSelect={select}
route={view === 'route' ? route : null}
route={view === 'route' && routeTab === 'walk' ? route : null}
journey={journey}
routeEnds={view === 'route' ? routeEnds : null}
altRoutes={view === 'route' ? altRoutes : []}
altRoutes={view === 'route' && routeTab === 'walk' ? altRoutes : []}
focus={focus}
visible={sheet}
bbox={snap.meta.bbox}

View file

@ -25,7 +25,7 @@ export function GuideBanner({ result, onStop, onPos, onReroute }: Props) {
const [pos, setPos] = useState<LatLon | null>(null);
const [sim, setSim] = useState(false);
const [voice, setVoice] = useState<'off' | 'all' | 'warn'>('off');
const [err, setErr] = useState<'denied' | 'none' | null>(null);
const [err, setErr] = useState<'denied' | 'none' | 'insecure' | null>(null);
const simAlong = useRef(0);
// ---- position source ----
@ -41,10 +41,11 @@ export function GuideBanner({ result, onStop, onPos, onReroute }: Props) {
return () => window.clearInterval(id);
}
if (!('geolocation' in navigator)) { setErr('none'); return; }
if (!window.isSecureContext) { setErr('insecure'); return; } // browsers hand out location on HTTPS only
setErr(null);
const id = navigator.geolocation.watchPosition(
(p) => { setErr(null); setPos([p.coords.latitude, p.coords.longitude]); },
(e) => setErr(e.code === 1 ? 'denied' : 'none'),
(e) => { if (e.code !== 3) setErr(e.code === 1 ? 'denied' : 'none'); }, // a timeout just means "no fix yet": keep waiting
{ enableHighAccuracy: true, maximumAge: 1000, timeout: 15_000 },
);
return () => navigator.geolocation.clearWatch(id);
@ -126,7 +127,7 @@ export function GuideBanner({ result, onStop, onPos, onReroute }: Props) {
{st && !st.arrived && (
<p className="kbb-guide__meta">{t('guide.remaining', { km: (st.remaining / 1000).toFixed(2), min: etaMin ?? 0 })}</p>
)}
{err && <p className="kbb-guide__meta" role="alert">{t(err === 'denied' ? 'guide.denied' : 'guide.noGps')}</p>}
{err && <p className="kbb-guide__meta" role="alert">{t(err === 'denied' ? 'guide.denied' : err === 'insecure' ? 'loc.insecure' : 'guide.noGps')}</p>}
{sim && <p className="kbb-guide__meta">{t('guide.simOn')}</p>}
</div>
<div className="kbb-guide__actions">

View file

@ -85,9 +85,17 @@ interface Props {
hotspots: Hotspot[];
hotspotFocus: Hotspot | null;
reduceMotion: boolean;
/** Public-transport journey to draw: walking legs, rides and the stops where you board / get off. */
journey: JourneyOverlay | null;
}
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion }: Props) {
export interface JourneyOverlay {
walks: LatLon[][];
rides: { path: LatLon[]; mode: 'bus' | 'tram' }[];
stops: { at: LatLon; label: string }[];
}
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey }: Props) {
const { t, lang } = useI18n();
const ref = useRef<HTMLDivElement>(null);
const mapRef = useRef<MLMap | null>(null);
@ -95,6 +103,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const hotMarkers = useRef<maplibregl.Marker[]>([]);
const journeyMarkers = useRef<maplibregl.Marker[]>([]);
const meMarker = useRef<maplibregl.Marker | null>(null);
const basemapRef = useRef(basemap);
basemapRef.current = basemap;
@ -127,6 +136,10 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
map.addSource('route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('routeAlt', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'route-alt', type: 'line', source: 'routeAlt', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#565c65', 'line-width': 4, 'line-opacity': 0.55 } });
map.addSource('journey', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'journey-casing', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'ride'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 10 } });
map.addLayer({ id: 'journey-ride', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'ride'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': ['match', ['get', 'mode'], 'tram', '#a2461c', '#54278f'], 'line-width': 6 } });
map.addLayer({ id: 'journey-walk', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'walk'], layout: { 'line-cap': 'butt', 'line-join': 'round' }, paint: { 'line-color': '#005ea2', 'line-width': 4.5, 'line-dasharray': [1.5, 1.5] } });
map.addSource('reach', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'reach', type: 'line', source: 'reach',
@ -145,8 +158,10 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
line('route-ok', 'ok', '#005ea2', 5);
line('route-warn', 'warn', '#a2461c', 5, [2, 1.5]);
line('route-blocked', 'blocked', '#b50909', 6, [0.4, 1.6]);
// Dots with a verdict are always shown; grey "no data" dots only once zoomed in (they are noise at city scale).
const dots = (id: string, filter: maplibregl.FilterSpecification, minzoom: number) =>
map.addLayer({
id: 'places', type: 'circle', source: 'places',
id, type: 'circle', source: 'places', filter, minzoom,
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', 9, 11.5]],
@ -156,17 +171,21 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 1.5, 2],
},
});
dots('places', ['!=', ['get', 'verdict'], 'incomplete'], 0);
dots('places-nodata', ['==', ['get', 'verdict'], 'incomplete'], 15);
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 },
});
map.on('click', 'places', (ev: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
for (const layer of ['places', 'places-nodata']) {
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; });
}
map.on('click', ['places', 'places-nodata'], (ev: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
const id = ev.features?.[0]?.properties?.id as string | undefined;
if (id) onSelectRef.current(id);
});
map.on('mouseenter', 'places', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'places', () => { map.getCanvas().style.cursor = ''; });
applyBasemap(map, basemapRef.current);
setReady(true);
});
@ -265,6 +284,53 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, route, routeEnds, lang]);
// ---- public-transport journey ----
useEffect(() => {
const map = mapRef.current;
if (!ready || !map) return;
journeyMarkers.current.forEach((m) => m.remove());
journeyMarkers.current = [];
const src = map.getSource('journey') as GeoJSONSource;
if (!journey) { src.setData({ type: 'FeatureCollection', features: [] }); return; }
src.setData({
type: 'FeatureCollection',
features: [
...journey.walks.map((w) => ({ type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: w.map(lngLat) }, properties: { kind: 'walk', mode: '' } })),
...journey.rides.map((r) => ({ type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: r.path.map(lngLat) }, properties: { kind: 'ride', mode: r.mode } })),
],
});
const mark = (el: HTMLElement, at: LatLon, popup?: string) => {
const m = new maplibregl.Marker({ element: el }).setLngLat(lngLat(at));
if (popup) m.setPopup(new maplibregl.Popup({ offset: 16 }).setText(popup));
m.addTo(map);
journeyMarkers.current.push(m);
};
const pin = (label: string, aria: string, cls: string) => {
const b = document.createElement('div');
b.className = `kbb-pin ${cls}`;
b.textContent = label;
b.setAttribute('role', 'img');
b.setAttribute('aria-label', aria);
return b;
};
if (routeEnds) {
mark(pin('A', t('route.start'), 'kbb-pin--start'), routeEnds.from);
mark(pin('B', t('route.end'), 'kbb-pin--end'), routeEnds.to);
}
for (const s of journey.stops) {
const el = document.createElement('div');
el.className = 'kbb-pin kbb-pin--stop';
el.setAttribute('role', 'img');
el.setAttribute('aria-label', s.label);
el.innerHTML = `<svg class="usa-icon" aria-hidden="true" focusable="false"><use href="${SPRITE}#directions_bus"></use></svg>`;
mark(el, s.at, s.label);
}
const bounds = new maplibregl.LngLatBounds();
[...journey.walks.flat(), ...journey.rides.flatMap((r) => r.path)].forEach((c) => bounds.extend(lngLat(c)));
if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 16 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, journey, routeEnds, lang]);
useEffect(() => {
const map = mapRef.current;
if (!ready || !map || !focus) return;

View file

@ -3,10 +3,11 @@ 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 } from './ui';
import { Alert, FactCard, Icon, VERDICT_STYLE } from './ui';
import { ReportForm } from './ReportForm';
import type { ProcessedPhoto } from '../lib/photo';
import { ReportCard } from './ReportCard';
import { DeparturesBoard, NearbyTransit } from './TransitUi';
export interface ReachState { minutes: number | null; summary: { n: number; fit: number } | 'stuck' | null }
@ -30,6 +31,9 @@ interface Props {
onClearReach: () => void;
onBack: () => void;
onRouteTo: () => void;
/** All public-transport stops (for "transit nearby"), and a way to open one of them. */
stops: Place[];
onOpenStop: (id: string) => void;
onRefresh: () => void;
onAddReport: (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => void;
onRemoveReport: (id: string) => void;
@ -83,12 +87,13 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
</div>
)}
<div className={`usa-alert ${VERDICT_STYLE[assessment.verdict].alert} margin-top-2`} role="status">
<div className="usa-alert__body">
<h2 className="usa-alert__heading"><VerdictTag verdict={assessment.verdict} /></h2>
<p className="usa-alert__text">{t(`verdict.${assessment.verdict}.long`)}</p>
{missing.length > 0 && <p className="usa-alert__text">{t('verdict.missing', { list: missing.join(', ') })}</p>}
<div className={`kbb-verdict kbb-verdict--${assessment.verdict} margin-top-2`} role="status">
<div className="kbb-verdict__head">
<Icon name={VERDICT_STYLE[assessment.verdict].icon} size={5} />
<h2 className="kbb-verdict__title">{t(`verdict.${assessment.verdict}`)}</h2>
</div>
<p className="kbb-verdict__text">{t(`verdict.${assessment.verdict}.long`)}</p>
{missing.length > 0 && <p className="kbb-verdict__text">{t('verdict.missing', { list: missing.join(', ') })}</p>}
</div>
<div className="kbb-actions margin-top-2">
@ -138,6 +143,15 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
</div>
</details>
{place.cat === 'transit' ? (
<section aria-labelledby="board-h" className="margin-top-3">
<h2 id="board-h" className="margin-top-0 margin-bottom-1 font-sans-lg">{t('tr.board.title')}</h2>
<DeparturesBoard stop={place} />
</section>
) : (
<NearbyTransit place={place} stops={p.stops} onOpenStop={p.onOpenStop} />
)}
<h2 className="margin-top-3 font-sans-lg">{t('detail.facts')}</h2>
{FACT_GROUPS.map((status) => {
const facts = assessment.facts.filter((f) => f.status === status);

View file

@ -0,0 +1,116 @@
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useI18n } from '../lib/i18n';
import type { Labels } from '../lib/labels';
import { fold } from '../lib/text';
import type { Place } from '../lib/types';
import { CategoryIcon, Icon } from './ui';
const MAX = 8;
interface Props {
id: string;
value: string;
onChange: (text: string) => void;
labels: Labels;
placeholder: string;
/** Extra first option (e.g. "use my location"), shown when the field is focused and still short. */
extra?: { label: string; onPick: () => void } | undefined;
}
/**
* Place field with our own suggestion list. The native <datalist> is unreliable on phones (iOS ignores long lists, Android shows
* a bare list), so suggestions are rendered here: prefix matches first, then word starts, then anywhere, places before transit stops.
*/
export function PlaceInput({ id, value, onChange, labels, placeholder, extra }: Props) {
const { t } = useI18n();
const listId = useId();
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const box = useRef<HTMLDivElement>(null);
const closeTimer = useRef<number>(0);
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const index = useMemo(() => [...labels.byLabel.entries()].map(([label, place]) => ({ label, place, f: fold(label), n: fold(place.name ?? '') })), [labels]);
const q = fold(value.trim());
const hits = useMemo(() => {
if (q.length < 2) return [];
const scored: { label: string; place: Place; s: number }[] = [];
for (const x of index) {
if (!x.f.includes(q)) continue;
const s = (x.n === q ? 0 : x.n.startsWith(q) ? 1 : x.f.includes(` ${q}`) ? 2 : 3) + (x.place.cat === 'transit' ? 4 : 0);
scored.push({ label: x.label, place: x.place, s });
}
return scored.sort((a, b) => a.s - b.s || a.label.localeCompare(b.label, 'pl')).slice(0, MAX);
}, [index, q]);
const showExtra = !!extra && q.length < 2;
const options: ({ kind: 'extra'; label: string } | { kind: 'place'; label: string; place: Place })[] = [
...(showExtra ? [{ kind: 'extra' as const, label: extra.label }] : []),
...hits.map((h) => ({ kind: 'place' as const, label: h.label, place: h.place })),
];
const expanded = open && options.length > 0;
const pick = (i: number) => {
const o = options[i];
if (!o) return;
setOpen(false);
setActive(-1);
if (o.kind === 'extra') extra?.onPick();
else onChange(o.label);
};
const key = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive((a) => Math.min(options.length - 1, a + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); }
else if (e.key === 'Enter' && expanded && active >= 0) { e.preventDefault(); pick(active); }
else if (e.key === 'Escape' && expanded) { e.preventDefault(); setOpen(false); }
};
return (
<div
ref={box} className="kbb-combo"
// The list is in the page flow, so closing it moves the buttons below. Closing on blur straight away would shift the "Wyznacz"
// button between pointer-down and pointer-up and swallow the tap; a short delay lets the click land first.
onBlur={(e) => {
if (box.current?.contains(e.relatedTarget as Node | null)) return;
closeTimer.current = window.setTimeout(() => { setOpen(false); setActive(-1); }, 250);
}}
onFocus={() => window.clearTimeout(closeTimer.current)}
>
<input
id={id} className="usa-input maxw-full" autoComplete="off" autoCorrect="off" spellCheck={false} placeholder={placeholder}
role="combobox" aria-expanded={expanded} aria-controls={listId} aria-autocomplete="list"
{...(active >= 0 ? { 'aria-activedescendant': `${listId}-${active}` } : {})}
value={value}
onChange={(e) => { onChange(e.target.value); setOpen(true); setActive(-1); }}
onFocus={() => setOpen(true)}
onKeyDown={key}
/>
{expanded && (
<ul id={listId} role="listbox" aria-label={t('route.suggestions')} className="kbb-combo__list usa-list--unstyled">
{options.map((o, i) => (
<li
key={`${o.kind}-${o.label}`} id={`${listId}-${i}`} role="option" aria-selected={i === active}
className={`kbb-combo__opt ${i === active ? 'is-active' : ''}`}
// keep focus in the input while tapping, so the list is not closed by the blur before the click lands
onMouseDown={(e) => e.preventDefault()} onClick={() => pick(i)}
>
{o.kind === 'extra'
? <><span className="kbb-cat"><Icon name="my_location" size={3} /></span><span className="kbb-combo__txt"><strong>{o.label}</strong></span></>
: (
<>
<CategoryIcon cat={o.place.cat} />
<span className="kbb-combo__txt">
<strong>{o.place.name}</strong>
<span className="text-base-dark font-sans-xs">
{[o.place.tags['addr:street'] && `${o.place.tags['addr:street']} ${o.place.tags['addr:housenumber'] ?? ''}`.trim(), o.place.cat === 'transit' ? t(o.place.kind === 'tram_stop' ? 'tr.mode.tram' : 'tr.mode.bus') : null].filter(Boolean).join(' · ')}
</span>
</span>
</>
)}
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -2,10 +2,18 @@ import { useEffect, useRef, useState, type FormEvent } from 'react';
import { hasKey, useI18n, type Key } from '../lib/i18n';
import type { LatLon } from '../lib/geo';
import { resolveLabel, type Labels } from '../lib/labels';
import type { AreaLabel } from '../lib/placeNames';
import { findAlternatives, HARD_FLAGS, type RouteGraph, type RouteOption, type RouteResult, type RouteSegment } from '../lib/route';
import type { Profile } from '../lib/types';
import { appleMapsUrl, googleMapsUrl } from '../lib/links';
import { Alert, Icon } from './ui';
import { PlaceInput } from './PlaceInput';
import { getPosition, kmOutside, LocateFailure } from '../lib/locate';
import { TransitPlan } from './TransitUi';
import type { PlannerState } from '../lib/useTransit';
/** Walking longer than this (minutes) gets public transport recommended. */
const LONG_WALK_MIN = 30;
export interface RouteForm {
fromText: string;
@ -28,16 +36,34 @@ interface Props {
selected: number;
onSelect: (i: number) => void;
onResult: (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => void;
presets: { id: string; fromId: string; toId: string }[];
onFocus: (p: LatLon) => void;
tab: 'walk' | 'transit';
onTab: (t: 'walk' | 'transit') => void;
planner: PlannerState;
onShowTransit: () => void;
}
export function RouteView({ graph, labels, profile, bbox, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, presets, onFocus }: Props) {
export function RouteView({ graph, labels, profile, bbox, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, onFocus, tab, onTab, planner, onShowTransit }: Props) {
const result = options?.[selected]?.result ?? null;
const { t, tm } = useI18n();
const [error, setError] = useState<string | null>(null);
const [locating, setLocating] = useState(false);
// location problems appear as a short-lived bubble on the locate button instead of a page-level error
const [locMsg, setLocMsg] = useState<{ text: string; leaving: boolean } | null>(null);
const locTimers = useRef<number[]>([]);
useEffect(() => () => locTimers.current.forEach(clearTimeout), []);
const showLocMsg = (text: string) => {
locTimers.current.forEach(clearTimeout);
setLocMsg({ text, leaving: false });
locTimers.current = [
window.setTimeout(() => setLocMsg((m) => (m ? { ...m, leaving: true } : m)), 4500),
window.setTimeout(() => setLocMsg(null), 5000),
];
};
const resultRef = useRef<HTMLDivElement>(null);
// a long walk, or no barrier-free walk at all, is where public transport is the better answer
const recommendTransit: 'long' | 'blocked' | null =
!result ? null : result.status === 'none' || result.status === 'blocked' ? 'blocked' : result.durationMin > LONG_WALK_MIN ? 'long' : null;
useEffect(() => {
if (result) { resultRef.current?.scrollIntoView({ block: 'start' }); resultRef.current?.focus({ preventScroll: true }); }
}, [options]);
@ -55,17 +81,18 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, options,
const locate = () => {
setLocating(true);
setError(null);
navigator.geolocation.getCurrentPosition(
setLocMsg(null);
getPosition().then(
(pos) => {
setLocating(false);
const { latitude: lat, longitude: lon } = pos.coords;
const [s, w, n, e] = bbox;
if (lat < s || lat > n || lon < w || lon > e) { setError(t('route.locOut')); return; }
onForm({ ...form, fromText: t('route.myLocation'), fromPoint: [lat, lon] });
const km = kmOutside(pos, bbox);
if (km > 0.05) { showLocMsg(t('route.locOut', { km: km < 10 ? km.toFixed(1) : String(Math.round(km)) })); return; }
onForm({ ...form, fromText: t('route.myLocation'), fromPoint: pos });
},
(e: unknown) => {
setLocating(false);
showLocMsg(t(`loc.${e instanceof LocateFailure ? e.reason : 'unavailable'}` as Key));
},
() => { setLocating(false); setError(t('route.locFail')); },
{ enableHighAccuracy: true, timeout: 10_000 },
);
};
@ -79,87 +106,92 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, options,
<h1 id="route-title" className="margin-top-0 font-sans-xl">{t('route.title')}</h1>
<p className="font-sans-xs text-base-dark margin-top-0">{t('route.intro')}</p>
<form className="usa-form maxw-full" onSubmit={submit}>
<datalist id="place-labels">{labels.list.map((l) => <option key={l} value={l} />)}</datalist>
<label className="usa-label margin-top-0" htmlFor="route-from">{t('route.from')}</label>
<input
id="route-from" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
value={form.fromText} onChange={(e) => onForm({ ...form, fromText: e.target.value, fromPoint: null })}
<form className="usa-form maxw-full kbb-ft" onSubmit={submit}>
<div className="kbb-ft__row">
<span className="kbb-ft__dot kbb-ft__dot--a" aria-hidden="true">A</span>
<div className="kbb-ft__field">
<label className="usa-label" htmlFor="route-from">{t('route.from')}</label>
<PlaceInput
id="route-from" labels={labels} placeholder={t('route.placeholder')} value={form.fromText}
onChange={(text) => onForm({ ...form, fromText: text, fromPoint: null })}
extra={'geolocation' in navigator ? { label: t('route.myLocation'), onPick: locate } : undefined}
/>
</div>
{'geolocation' in navigator && (
<button type="button" className="usa-button usa-button--unstyled margin-top-1" onClick={locate} disabled={locating}>
<Icon name="my_location" size={3} className="margin-right-05" />
{locating ? t('route.locating') : t('route.myLocation')}
<span className="kbb-ft__loc">
<button type="button" className="kbb-iconbtn kbb-ft__btn" onClick={locate} disabled={locating} aria-label={locating ? t('route.locating') : t('route.myLocation')} title={t('route.myLocation')}>
<Icon name="my_location" size={3} />
</button>
{locMsg && <span role="alert" className={`kbb-pop ${locMsg.leaving ? 'is-leaving' : ''}`}>{locMsg.text}</span>}
</span>
)}
<div className="margin-top-1">
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={swap} disabled={!!form.fromPoint}>
<Icon name="arrow_downward" size={3} className="margin-right-05" />{t('route.swap')}
</div>
<div className="kbb-ft__row">
<span className="kbb-ft__dot kbb-ft__dot--b" aria-hidden="true">B</span>
<div className="kbb-ft__field">
<label className="usa-label" htmlFor="route-to">{t('route.to')}</label>
<PlaceInput id="route-to" labels={labels} placeholder={t('route.placeholder')} value={form.toText} onChange={(text) => onForm({ ...form, toText: text })} />
</div>
<button type="button" className="kbb-iconbtn kbb-ft__btn" onClick={swap} disabled={!!form.fromPoint} aria-label={t('route.swap')} title={t('route.swap')}>
<Icon name="arrow_upward" size={2} /><Icon name="arrow_downward" size={2} />
</button>
</div>
<label className="usa-label" htmlFor="route-to">{t('route.to')}</label>
<input
id="route-to" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
value={form.toText} onChange={(e) => onForm({ ...form, toText: e.target.value })}
/>
<button type="submit" className="usa-button margin-top-3">
<button type="submit" className="usa-button kbb-ft__go">
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('route.go')}
</button>
</form>
{presets.length > 0 && (
<div className="margin-top-2">
<p className="text-bold margin-bottom-1">{t('route.presets')}</p>
<ul className="usa-list--unstyled margin-0 padding-0">
{presets.map((p) => {
const a = labels.byLabel.size ? [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.fromId) : undefined;
const b = [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.toId);
if (!a || !b) return null;
return (
<li key={p.id} className="margin-bottom-1">
<button
type="button" className="usa-button usa-button--outline usa-button--small margin-0 text-left"
onClick={() => { const f = { fromText: a[0], toText: b[0], fromPoint: null }; onForm(f); run(f); }}
>
{a[1].name} → {b[1].name}
</button>
</li>
);
})}
</ul>
</div>
)}
<div aria-live="polite" ref={resultRef} tabIndex={-1} className="margin-top-3 kbb-result">
{error && <Alert kind="error" role="alert">{error}</Alert>}
{options && options.length > 0 && result && result.status !== 'none' && (
{ends && (
<div className="kbb-modes" role="group" aria-label={t('route.tabs')}>
{(['walk', 'transit'] as const).map((m) => (
<button key={m} type="button" aria-pressed={tab === m} className={`kbb-mode ${tab === m ? 'is-on' : ''}`} onClick={() => onTab(m)}>
<Icon name={m === 'walk' ? 'directions_walk' : 'directions_bus'} size={3} />{t(m === 'walk' ? 'route.tab.walk' : 'route.tab.transit')}
{m === 'transit' && recommendTransit && <span className="kbb-mode__tag">{t('tr.recommended')}</span>}
</button>
))}
</div>
)}
{ends && tab === 'walk' && recommendTransit && (
<Alert kind="info" slim>
{recommendTransit === 'blocked' ? t('route.recommend.blocked') : t('route.recommend.long', { n: Math.round(result?.durationMin ?? 0) })}{' '}
<button type="button" className="usa-button usa-button--unstyled" onClick={() => onTab('transit')}>{t('route.recommend.cta')}</button>
</Alert>
)}
{ends && tab === 'transit' && <TransitPlan planner={planner} onShow={onShowTransit} />}
{result && tab === 'walk' && <Summary result={result} t={t} />}
{options && options.length > 0 && result && result.status !== 'none' && tab === 'walk' && (
<Variants options={options} selected={selected} onSelect={onSelect} />
)}
{result && result.status !== 'none' && ends && (
<section aria-labelledby="guide-title" className="border-1px border-base-lighter radius-md padding-2 margin-bottom-3 bg-base-lightest">
<h2 id="guide-title" className="font-sans-md margin-top-0">{t('guide.start')}</h2>
{result && result.status !== 'none' && ends && tab === 'walk' && (
<section aria-labelledby="guide-title" className="kbb-guidecard">
<h2 id="guide-title" className="font-sans-md margin-top-0 margin-bottom-05">{t('guide.start')}</h2>
<p className="font-sans-xs margin-top-0">{t('guide.start.help')}</p>
<button type="button" className="usa-button" onClick={onGuide} disabled={guiding}>
<button type="button" className="usa-button kbb-guidecard__go" onClick={onGuide} disabled={guiding}>
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('guide.start')}
</button>
<div className="kbb-guidecard__alts">
<a className="usa-button usa-button--outline" href={googleMapsUrl(result, ends.from, ends.to)} target="_blank" rel="noopener noreferrer">
{t('guide.gmaps')}<Icon name="launch" size={2} className="margin-left-05" />
<span>{t('guide.gmaps')}</span><Icon name="launch" size={2} />
</a>
<a className="usa-button usa-button--outline" href={appleMapsUrl(ends.from, ends.to)} target="_blank" rel="noopener noreferrer">
{t('guide.apple')}<Icon name="launch" size={2} className="margin-left-05" />
<span>{t('guide.apple')}</span><Icon name="launch" size={2} />
</a>
</div>
<p className="font-sans-3xs text-base-dark margin-bottom-0">{t('guide.mapsNote')}</p>
</section>
)}
{result && <Result result={result} onFocus={onFocus} t={t} tm={tm} />}
{result && result.status !== 'none' && tab === 'walk' && <Details result={result} onFocus={onFocus} t={t} />}
</div>
</section>
);
}
function Result({ result, onFocus, t }: {
result: RouteResult; onFocus: (p: LatLon) => void; t: ReturnType<typeof useI18n>['t']; tm: ReturnType<typeof useI18n>['tm'];
}) {
type T = ReturnType<typeof useI18n>['t'];
/** The verdict on the selected route: the first thing under the walking / transit switch. */
function Summary({ result, t }: { result: RouteResult; t: T }) {
if (result.status === 'none') {
return <Alert kind="warning" heading={t('route.none')}>{t(`route.none.${result.reason ?? 'disconnected'}` as Key)}</Alert>;
}
@ -168,32 +200,39 @@ function Result({ result, onFocus, t }: {
const min = Math.max(1, Math.round(result.durationMin));
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
return (
<>
<div className={`usa-alert ${blocked ? 'usa-alert--error' : notes.length ? 'usa-alert--warning' : 'usa-alert--success'}`}>
<div className={`usa-alert ${blocked ? 'usa-alert--error' : notes.length ? 'usa-alert--warning' : 'usa-alert--success'} margin-bottom-3`}>
<div className="usa-alert__body">
<h2 className="usa-alert__heading">{blocked ? t('route.blocked') : notes.length ? t('route.okCheck', { n: notes.length }) : t('route.ok')}</h2>
<p className="usa-alert__text">
{blocked ? t('route.blocked.long') : t('route.summary', { km, min })}
</p>
<p className="usa-alert__text">{blocked ? t('route.blocked.long') : t('route.summary', { km, min })}</p>
{blocked && <p className="usa-alert__text">{t('route.summary', { km, min })}</p>}
</div>
</div>
<p className="font-sans-xs margin-bottom-0">{t('route.quality', { pct: result.stats.knownSurfacePct })}</p>
{result.stats.unknownKerbs > 0 && <p className="font-sans-xs margin-y-05">{t('route.unknownKerbs', { n: result.stats.unknownKerbs })}</p>}
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.estimate')}</p>
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.legend')}</p>
);
}
/** Where a segment or barrier is: its own name, else the park it crosses / the street it runs along. */
function placeName(x: { name: string | null; near?: AreaLabel | undefined }, t: T): string | null {
if (x.name) return x.name;
return x.near ? t(`seg.near.${x.near.kind}` as Key, { name: x.near.name }) : null;
}
function Details({ result, onFocus, t }: { result: RouteResult; onFocus: (p: LatLon) => void; t: T }) {
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
return (
<>
<h2 className="font-sans-lg">{t('route.barriers')}</h2>
{notes.length === 0 ? (
<p>{t('route.barriers.none')}</p>
) : (
<ul className="usa-list--unstyled margin-0 padding-0">
{notes.map((b, i) => (
<li key={`${b.wayId}-${b.flag}-${i}`} className="border-1px border-base-lighter radius-md padding-1 margin-bottom-1 display-flex flex-justify flex-align-center">
{notes.map((b, i) => {
const where = placeName(b, t);
return (
<li key={`${b.wayId}-${b.flag}-${i}`} className="kbb-barrier">
<span>
<Icon name={HARD_FLAGS.has(b.flag) ? 'error' : 'warning'} size={3} className={`margin-right-05 ${HARD_FLAGS.has(b.flag) ? 'text-error-dark' : 'text-warning-darker'}`} />
<strong>{t(`flag.${b.flag}` as Key)}</strong>
{b.name ? ` · ${b.name}` : ''}
{where ? ` · ${where}` : ''}
{b.detail && b.flag === 'steps' ? ` · ${b.detail}` : ''}
{b.lengthM >= 10 ? ` · ${Math.round(b.lengthM / 5) * 5} m` : ''}
</span>
@ -201,16 +240,28 @@ function Result({ result, onFocus, t }: {
{t('route.focus')}
</button>
</li>
))}
);
})}
</ul>
)}
<h2 className="font-sans-lg">{t('route.steps')}</h2>
<ol className="usa-list">
<details className="kbb-reach border-1px border-base-lighter radius-md margin-top-2 bg-base-lightest">
<summary>
<Icon name="list" size={3} className="margin-right-05 text-primary" />
<span className="flex-1">{t('route.steps')}</span>
<Icon name="expand_more" size={3} className="kbb-chevron" />
</summary>
<ol className="usa-list margin-top-0 padding-right-2">
{result.segments.map((s, i) => (
<li key={i}>{segmentText(s, i === 0, t)}</li>
))}
</ol>
</details>
<p className="font-sans-xs margin-bottom-0">{t('route.quality', { pct: result.stats.knownSurfacePct })}</p>
{result.stats.unknownKerbs > 0 && <p className="font-sans-xs margin-y-05">{t('route.unknownKerbs', { n: result.stats.unknownKerbs })}</p>}
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.estimate')}</p>
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.legend')}</p>
</>
);
}
@ -222,7 +273,7 @@ function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI1
const flags = s.flags.filter((f) => f !== 'surface-unknown' && f !== 'ramp-steps').map((f) => t(`flag.${f}` as Key));
const line = t('seg.line', {
turn: t(`turn.${first ? 'start' : s.turn}` as Key),
name: s.name ?? t('seg.unnamed'),
name: placeName(s, t) ?? t('seg.unnamed'),
kind: kindLabel,
m: Math.max(5, Math.round(s.distanceM / 5) * 5),
surface,

View file

@ -0,0 +1,274 @@
import { useMemo, useState } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import { bearing, haversine } from '../lib/geo';
import { HARD_FLAGS, type RouteResult } from '../lib/route';
import { alertsFor, clock, departuresFrom, minutesUntil, type Departure, type Journey, type Ride, type TransitIndex, type TransitRoute } from '../lib/transit';
import { useNow, useRealtime, useTransitData, type PlannerState } from '../lib/useTransit';
import type { Place } from '../lib/types';
import { Alert, Icon } from './ui';
/** Line number in a coloured tag; tram and bus differ by colour and by the spoken label. */
export function LineBadge({ route }: { route: TransitRoute }) {
const { t } = useI18n();
return (
<span className={`kbb-line kbb-line--${route.mode}`} role="img" aria-label={`${t(`tr.mode.${route.mode}` as Key)} ${route.short}`}>
{route.short}
</span>
);
}
/** Delay as text + colour; no real-time update says "per timetable" rather than pretending to be on time. */
export function DelayTag({ dep }: { dep: Pick<Departure, 'delaySec' | 'canceled'> }) {
const { t } = useI18n();
if (dep.canceled) return <span className="usa-tag bg-error-dark text-white">{t('tr.canceled')}</span>;
if (dep.delaySec === null) return <span className="kbb-delay kbb-delay--none">{t('tr.delay.none')}</span>;
const min = Math.round(dep.delaySec / 60);
if (min >= 1) return <span className="kbb-delay kbb-delay--late">{t('tr.delay.late', { n: min })}</span>;
if (min <= -1) return <span className="kbb-delay kbb-delay--early">{t('tr.delay.early', { n: -min })}</span>;
return <span className="kbb-delay kbb-delay--ok">{t('tr.delay.ontime')}</span>;
}
function When({ ms, now }: { ms: number; now: number }) {
const { t } = useI18n();
const m = minutesUntil(ms, now);
return <><strong>{clock(ms)}</strong> <span className="text-base-dark">({m < 1 ? t('tr.now') : t('tr.inMin', { n: m })})</span></>;
}
function DepartureRow({ d, now }: { d: Departure; now: number }) {
const { t } = useI18n();
const m = minutesUntil(d.expected, now);
return (
<li className={`kbb-dep ${d.canceled ? 'is-canceled' : ''}`}>
<LineBadge route={d.route} />
<span className="kbb-dep__dir">{d.headsign}</span>
<span className="kbb-dep__time">{clock(d.expected)}</span>
<span className="kbb-dep__meta">
<span>{m < 1 ? t('tr.now') : t('tr.inMin', { n: m })}</span>
<DelayTag dep={d} />
</span>
</li>
);
}
/** "Next departures" of one stop, with live delays when the operator provides them. */
export function DeparturesBoard({ stop, limit = 8, compact = false }: { stop: Place; limit?: number; compact?: boolean }) {
const { t } = useI18n();
const { ix, error } = useTransitData(true);
const rt = useRealtime(true);
const now = useNow();
const deps = useMemo(() => (ix ? departuresFrom(ix, stop.id, now - 60_000, 120, rt).slice(0, limit) : []), [ix, stop.id, now, rt, limit]);
if (error) return <Alert kind="warning" slim>{t('tr.error')}</Alert>;
if (!ix) return <p role="status" className="font-sans-xs text-base-dark">{t('tr.board.loading')}</p>;
return (
<div>
{deps.length === 0 ? <p className="margin-y-05">{t('tr.board.none')}</p> : (
<ul className={`kbb-deps usa-list--unstyled margin-0 padding-0 ${compact ? 'is-compact' : ''}`}>
{deps.map((d) => <DepartureRow key={d.tripId} d={d} now={now} />)}
</ul>
)}
{!compact && <p className="font-sans-3xs text-base-dark margin-bottom-0">{rt.ok.bus || rt.ok.tram ? t('tr.src.rt', { time: clock(rt.fetchedAt) }) : t('tr.src.static')}</p>}
</div>
);
}
/** Stops within 400 m of a place with their next departures; collapsed until opened (the timetable loads only then). */
export function NearbyTransit({ place, stops, onOpenStop }: { place: Place; stops: Place[]; onOpenStop: (id: string) => void }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const near = useMemo(
() => stops
.map((s) => ({ s, d: haversine([place.lat, place.lon], [s.lat, s.lon]), from: compass(bearing([place.lat, place.lon], [s.lat, s.lon])) }))
.filter((x) => x.d <= 400).sort((a, b) => a.d - b.d).slice(0, 8),
[place, stops],
);
return (
<details key={place.id} className="kbb-reach border-1px border-base-lighter radius-md margin-top-2 bg-base-lightest" onToggle={(e) => setOpen(e.currentTarget.open)}>
<summary>
<Icon name="directions_bus" size={3} className="margin-right-05 text-primary" />
<span className="flex-1">{t('tr.nearby')}</span>
<Icon name="expand_more" size={3} className="kbb-chevron" />
</summary>
<div className="padding-x-2 padding-bottom-2">
{open && <NearbyList near={near} onOpenStop={onOpenStop} />}
</div>
</details>
);
}
function NearbyList({ near, onOpenStop }: { near: { s: Place; d: number; from: string }[]; onOpenStop: (id: string) => void }) {
const { t } = useI18n();
const { ix, error } = useTransitData(true);
const rt = useRealtime(true);
const now = useNow();
// only stops that have something to show, nearest first
const rows = useMemo(
() => (ix ? near.map(({ s, d, from }) => ({ s, d, from, towards: towards(ix, s.id), deps: departuresFrom(ix, s.id, now - 60_000, 120, rt).slice(0, 3) })).filter((r) => r.deps.length > 0).slice(0, 3) : []),
[ix, near, now, rt],
);
if (near.length === 0) return <p className="margin-top-0">{t('tr.nearby.none')}</p>;
if (error) return <Alert kind="warning" slim>{t('tr.error')}</Alert>;
if (!ix) return <p role="status" className="font-sans-xs text-base-dark margin-top-0">{t('tr.board.loading')}</p>;
if (rows.length === 0) return <p className="margin-top-0">{t('tr.board.none')}</p>;
return (
<ul className="usa-list--unstyled margin-0 padding-0">
{rows.map(({ s, d, from, towards: dirs, deps }) => (
<li key={s.id} className={`kbb-stopcard kbb-stopcard--${s.kind === 'tram_stop' ? 'tram' : 'bus'}`}>
<div className="kbb-stopcard__head">
<Icon name="directions_bus" size={3} />
<div>
<h3 className="kbb-stopcard__name">
<button type="button" className="usa-button usa-button--unstyled" onClick={() => onOpenStop(s.id)}>{s.name}</button>
</h3>
<p className="kbb-stopcard__where">
{t(s.kind === 'tram_stop' ? 'tr.mode.tram' : 'tr.mode.bus')} · {t('tr.nearby.dist', { m: Math.round(d / 10) * 10 })} {t(`dir.${from}` as Key)}
</p>
{dirs.length > 0 && <p className="kbb-stopcard__where"><strong>{t('tr.towards', { list: dirs.join(' · ') })}</strong></p>}
</div>
</div>
<ul className="kbb-deps usa-list--unstyled margin-0 padding-0">
{deps.map((dep) => <DepartureRow key={dep.tripId} d={dep} now={now} />)}
</ul>
</li>
))}
<li className="font-sans-xs text-base-dark">{rt.ok.bus || rt.ok.tram ? t('tr.src.rt', { time: clock(rt.fetchedAt) }) : t('tr.src.static')}</li>
</ul>
);
}
/** Where vehicles stopping here go (final stops of all lines), to tell opposite platforms of the same stop apart. */
function towards(ix: TransitIndex, stopId: string): string[] {
const heads = new Map<string, number>();
for (const [pi, pos] of ix.byStop.get(stopId) ?? []) {
const p = ix.data.patterns[pi]!;
if (pos < p.s.length - 1) heads.set(p.h, (heads.get(p.h) ?? 0) + p.t.length);
}
return [...heads.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([h]) => h);
}
/** 8-point compass key for a bearing in degrees: tells apart same-named stops on different sides of the street. */
const compass = (deg: number): string => ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'][Math.round((((deg % 360) + 360) % 360) / 45) % 8]!;
const stopAccess = (p: Place): Key => {
const w = p.tags.wheelchair;
return (w === 'yes' ? 'tr.stop.yes' : w === 'limited' ? 'tr.stop.limited' : w === 'no' ? 'tr.stop.no' : 'tr.stop.unknown');
};
/** Distance and barriers of one walking leg; every leg is routed with the user's limits. */
function WalkMeta({ walk }: { walk: RouteResult }) {
const { t } = useI18n();
const notes = walk.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
const hard = notes.filter((b) => HARD_FLAGS.has(b.flag)).length;
const caution = notes.length - hard;
return (
<span className="font-sans-xs">
{' · '}{t('tr.leg.dist', { m: Math.max(10, Math.round(walk.distanceM / 10) * 10) })}{' · '}
{notes.length === 0
? <span className="text-success-dark text-bold">{t('tr.leg.clean')}</span>
: <span className={`${hard ? 'text-error-dark' : 'text-warning-darker'} text-bold`}>{t('tr.leg.some', { hard, caution })}</span>}
</span>
);
}
function RideLine({ ride, now }: { ride: Ride; now: number }) {
const { t } = useI18n();
return (
<li className="kbb-leg kbb-leg--ride">
<LineBadge route={ride.dep.route} />
<div className="kbb-leg__body">
<p className="margin-0"><strong>{t('tr.ride', { line: ride.dep.line, dir: ride.dep.headsign })}</strong></p>
<p className="margin-y-05 font-sans-xs">
{t('tr.ride.detail', { time: clock(ride.boardMs), from: ride.from.name ?? '', end: clock(ride.alightMs), stops: ride.stops })}
{' '}<span className="text-base-dark">({minutesUntil(ride.boardMs, now) < 1 ? t('tr.now') : t('tr.inMin', { n: minutesUntil(ride.boardMs, now) })})</span>{' '}
<DelayTag dep={ride.dep} />
</p>
<p className="margin-0 font-sans-3xs text-base-dark">{t(stopAccess(ride.from))}</p>
</div>
</li>
);
}
/** The list of connections for the current route ends. Selecting one draws it on the map. */
export function TransitPlan({ planner, onShow }: { planner: PlannerState; onShow: () => void }) {
const { t } = useI18n();
const now = useNow();
const { status, journeys, rt, selected } = planner;
const lines = useMemo(() => new Set(journeys.flatMap((j) => j.rides.map((r) => r.dep.line))), [journeys]);
const alerts = useMemo(() => alertsFor(rt, lines), [rt, lines]);
if (status === 'error') return <Alert kind="error" role="alert">{t('tr.error')}</Alert>;
if (status !== 'ready') {
const { stage, pct } = planner.progress;
return (
<section aria-labelledby="tr-title" className="kbb-loader">
<h2 id="tr-title" className="font-sans-lg margin-top-0">{t('tr.title')}</h2>
<p role="status" className="margin-bottom-1"><strong>{t(`tr.stage.${stage}` as Key)}</strong></p>
<progress className="kbb-progress" value={pct} max={100} aria-label={t('tr.progress')}>{pct}%</progress>
<p className="font-sans-3xs text-base-dark">{t('tr.loadingNote')}</p>
</section>
);
}
return (
<section aria-labelledby="tr-title">
<h2 id="tr-title" className="font-sans-lg margin-top-0">{t('tr.title')}</h2>
<p className="font-sans-3xs text-base-dark margin-top-0">{rt.ok.bus || rt.ok.tram ? t('tr.src.rt', { time: clock(rt.fetchedAt) }) : t('tr.src.static')}</p>
{journeys.length === 0 ? <Alert kind="warning">{t('tr.none')}</Alert> : (
<fieldset className="usa-fieldset">
<legend className="usa-sr-only">{t('tr.pick')}</legend>
<ul className="usa-list--unstyled margin-0 padding-0">
{journeys.map((j, i) => <JourneyCard key={j.id} j={j} i={i} selected={selected === i} now={now} onSelect={() => { planner.select(i); onShow(); }} />)}
</ul>
</fieldset>
)}
{alerts.length > 0 && (
<div className="margin-top-2">
<h3 className="font-sans-md margin-bottom-1">{t('tr.alerts')}</h3>
<ul className="usa-list--unstyled margin-0 padding-0">
{alerts.map((a, i) => <li key={i} className="margin-bottom-1"><Alert kind="warning" slim><strong>{a.header}</strong>{a.description ? ` — ${a.description}` : ''}</Alert></li>)}
</ul>
</div>
)}
<p className="font-sans-3xs text-base-dark">{t('tr.lowfloor')}</p>
</section>
);
}
function JourneyCard({ j, i, selected, now, onSelect }: { j: Journey; i: number; selected: boolean; now: number; onSelect: () => void }) {
const { t } = useI18n();
const first = j.rides[0]!, last = j.rides[j.rides.length - 1]!;
const leave = first.boardMs - (j.walkTo.durationMin + 1) * 60000;
const walkTo = Math.max(1, Math.round(j.walkTo.durationMin)), walkEnd = Math.max(1, Math.round(j.walkFrom.durationMin));
return (
<li className="margin-bottom-1">
<div className="usa-radio">
<input className="usa-radio__input usa-radio__input--tile" id={`journey-${i}`} type="radio" name="journey" checked={selected} onChange={onSelect} />
<label className="usa-radio__label margin-top-0" htmlFor={`journey-${i}`}>
<span className="kbb-jhead">
{j.rides.map((r, k) => <span key={k} className="kbb-jhead__line">{k > 0 && <Icon name="navigate_next" size={2} />}<LineBadge route={r.dep.route} /></span>)}
<span className="kbb-jhead__time">{clock(leave)} → {clock(j.arriveMs)}</span>
<span className="text-base-dark">{t('tr.total', { n: Math.max(1, Math.round((j.arriveMs - leave) / 60000)) })}</span>
<span className={`usa-tag ${j.transfer ? 'bg-base-lighter text-ink' : 'bg-success-dark text-white'}`}>{j.transfer ? t('tr.transferTag') : t('tr.direct')}</span>
</span>
<span className="usa-checkbox__label-description">
{j.hard === 0 && j.caution === 0
? <span className="text-success-dark text-bold">{t('tr.access.clean')}</span>
: <span className={j.hard ? 'text-error-dark text-bold' : 'text-warning-darker text-bold'}>{t('tr.access.some', { hard: j.hard, caution: j.caution })}</span>}
</span>
</label>
</div>
{selected && (
<ol className="kbb-legs usa-list--unstyled margin-0 padding-0" aria-label={t('tr.title')}>
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.leave', { time: clock(leave) })} · {t('tr.walkTo', { n: walkTo, stop: first.from.name ?? '' })}<WalkMeta walk={j.walkTo} /></div></li>
<RideLine ride={first} now={now} />
{j.transfer && (
<>
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.transfer', { m: j.transfer.distM, n: j.transfer.walkMin, w: j.transfer.waitMin })}<WalkMeta walk={j.transfer.walk} /></div></li>
<RideLine ride={j.rides[1]!} now={now} />
</>
)}
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.walkEnd', { n: walkEnd, stop: last.to.name ?? '' })}<WalkMeta walk={j.walkFrom} /> · {t('tr.arrive', { time: clock(j.arriveMs) })}</div></li>
{j.next.length > 0 && <li className="font-sans-xs text-base-dark margin-top-1">{t('tr.next', { list: j.next.map((d) => clock(d.expected)).join(', ') })}</li>}
</ol>
)}
</li>
);
}

5
src/config.ts Normal file
View file

@ -0,0 +1,5 @@
// The one place that names the app. Change APP_NAME and every screen, the page title, the PWA manifest and exported file names follow
// (see vite.config.ts for the HTML title / manifest, `app.name` in i18n.ts for the UI).
export const APP_NAME = 'GdzieWjade';
export const APP_SLUG = APP_NAME.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');

170
src/lib/gtfsrt.ts Normal file
View file

@ -0,0 +1,170 @@
// Minimal GTFS-Realtime (protobuf) reader: just the fields the app uses, no dependency.
// Spec: https://gtfs.org/documentation/realtime/proposal/ (field numbers below are from gtfs-realtime.proto).
export interface StopEvent { delay?: number; time?: number }
export interface StopUpdate { stopId: string; seq?: number; arrival?: StopEvent; departure?: StopEvent; skipped?: boolean }
export interface TripUpdate { tripId: string; routeId?: string; startDate?: string; delay?: number; canceled?: boolean; stops: StopUpdate[] }
export interface Alert { routeIds: string[]; stopIds: string[]; header: string; description: string; start?: number; end?: number }
export interface RealtimeFeed { timestamp: number; trips: TripUpdate[]; alerts: Alert[] }
class Reader {
private pos = 0;
constructor(private readonly buf: Uint8Array, private readonly end = buf.length) {}
get done() { return this.pos >= this.end; }
varint(): number {
let result = 0, mul = 1, byte: number;
// up to 10 bytes; values past 2^53 lose precision, which never matters for the fields we read
for (let i = 0; i < 10; i++) {
if (this.pos >= this.end) throw new Error('truncated varint');
byte = this.buf[this.pos++]!;
result += (byte & 0x7f) * mul;
mul *= 128;
if (byte < 0x80) return result;
}
throw new Error('varint too long');
}
/** int32/int64 as two's complement (negative delays arrive as 10-byte varints). */
signed(): number {
const start = this.pos;
let big = 0n, shift = 0n, byte: number;
do {
if (this.pos >= this.end) throw new Error('truncated varint');
byte = this.buf[this.pos++]!;
big |= BigInt(byte & 0x7f) << shift;
shift += 7n;
} while (byte >= 0x80 && this.pos - start < 10);
return Number(BigInt.asIntN(64, big));
}
bytes(): Reader {
const len = this.varint();
const r = new Reader(this.buf, this.pos + len);
r.pos = this.pos;
if (this.pos + len > this.end) throw new Error('truncated message');
this.pos += len;
return r;
}
string(): string {
const r = this.bytes();
return new TextDecoder().decode(this.buf.subarray(r.pos, r.end));
}
skip(wire: number) {
if (wire === 0) this.varint();
else if (wire === 1) this.pos += 8;
else if (wire === 2) this.bytes();
else if (wire === 5) this.pos += 4;
else throw new Error(`unsupported wire type ${wire}`);
}
/** Iterate (fieldNumber, wireType) pairs; the caller must consume or skip each value. */
fields(fn: (field: number, wire: number) => void) {
while (!this.done) {
const tag = this.varint();
fn(tag >>> 3, tag & 7);
}
}
}
function event(r: Reader): StopEvent {
const e: StopEvent = {};
r.fields((f, w) => {
if (f === 1 && w === 0) e.delay = r.signed();
else if (f === 2 && w === 0) e.time = r.signed();
else r.skip(w);
});
return e;
}
function stopUpdate(r: Reader): StopUpdate {
const u: StopUpdate = { stopId: '' };
r.fields((f, w) => {
if (f === 1 && w === 0) u.seq = r.varint();
else if (f === 2 && w === 2) u.arrival = event(r.bytes());
else if (f === 3 && w === 2) u.departure = event(r.bytes());
else if (f === 4 && w === 2) u.stopId = r.string();
else if (f === 5 && w === 0) { if (r.varint() === 1) u.skipped = true; } // SKIPPED
else r.skip(w);
});
return u;
}
function tripUpdate(r: Reader): TripUpdate {
const t: TripUpdate = { tripId: '', stops: [] };
r.fields((f, w) => {
if (f === 1 && w === 2) {
const d = r.bytes();
d.fields((g, x) => {
if (g === 1 && x === 2) t.tripId = d.string();
else if (g === 3 && x === 2) t.startDate = d.string();
else if (g === 4 && x === 0) { if (d.varint() === 3) t.canceled = true; } // CANCELED
else if (g === 5 && x === 2) t.routeId = d.string();
else d.skip(x);
});
} else if (f === 2 && w === 2) t.stops.push(stopUpdate(r.bytes()));
else if (f === 5 && w === 0) t.delay = r.signed();
else r.skip(w);
});
return t;
}
function translated(r: Reader): string {
let text = '';
r.fields((f, w) => {
if (f === 1 && w === 2) {
const tr = r.bytes();
tr.fields((g, x) => {
if (g === 1 && x === 2) { const s = tr.string(); if (!text) text = s; } else tr.skip(x);
});
} else r.skip(w);
});
return text;
}
function alert(r: Reader): Alert {
const a: Alert = { routeIds: [], stopIds: [], header: '', description: '' };
r.fields((f, w) => {
if (f === 1 && w === 2) {
const p = r.bytes();
p.fields((g, x) => {
if (g === 1 && x === 0) a.start = p.varint();
else if (g === 2 && x === 0) a.end = p.varint();
else p.skip(x);
});
} else if (f === 5 && w === 2) {
const e = r.bytes();
e.fields((g, x) => {
if (g === 2 && x === 2) a.routeIds.push(e.string());
else if (g === 5 && x === 2) a.stopIds.push(e.string());
else e.skip(x);
});
} else if (f === 10 && w === 2) a.header = translated(r.bytes());
else if (f === 11 && w === 2) a.description = translated(r.bytes());
else r.skip(w);
});
return a;
}
/** Decodes a TripUpdates / ServiceAlerts feed. Throws when the bytes are not a GTFS-RT FeedMessage (e.g. an HTML error page). */
export function decodeFeed(bytes: Uint8Array): RealtimeFeed {
const out: RealtimeFeed = { timestamp: 0, trips: [], alerts: [] };
const r = new Reader(bytes);
let sawHeader = false;
r.fields((f, w) => {
if (f === 1 && w === 2) {
const h = r.bytes();
h.fields((g, x) => {
if (g === 1 && x === 2) { h.string(); sawHeader = true; }
else if (g === 3 && x === 0) out.timestamp = h.varint();
else h.skip(x);
});
} else if (f === 2 && w === 2) {
const e = r.bytes();
e.fields((g, x) => {
if (g === 3 && x === 2) out.trips.push(tripUpdate(e.bytes()));
else if (g === 5 && x === 2) out.alerts.push(alert(e.bytes()));
else e.skip(x);
});
} else r.skip(w);
});
if (!sawHeader) throw new Error('not a GTFS-realtime feed');
return out;
}

View file

@ -1,8 +1,9 @@
import { createContext, useContext } from 'react';
import { APP_NAME } from '../config';
import type { Lang, Msg } from './types';
const pl = {
'app.name': 'Kraków bez barier',
'app.name': APP_NAME,
'app.tagline': 'Sprawdź, czy miejsce i trasa pasują do Twoich potrzeb — z informacją, skąd to wiemy.',
'banner.proto': 'Prototyp na hackathon.',
'banner.data': 'Dane: zapis OpenStreetMap z {date}. Miejsca oznaczone DEMO są fikcyjne.',
@ -13,6 +14,74 @@ const pl = {
'nav.profile': 'Profil',
'lang.label': 'Język',
'lang.switchTo': 'Przełącz język na angielski',
'route.tabs': 'Sposób podróży',
'route.tab.walk': 'Pieszo',
'route.tab.transit': 'Komunikacja',
'tr.title': 'Komunikacja miejska',
'tr.loading': 'Wczytuję rozkład jazdy…',
'tr.error': 'Nie udało się wczytać rozkładu jazdy. Spróbuj ponownie później.',
'tr.none': 'Brak połączenia w najbliższych 1,5 godziny (do jednej przesiadki) między przystankami w pobliżu startu i celu.',
'tr.src.rt': 'Rozkład ZTP Kraków i dane na żywo (stan z {time}). Opóźnienia dotyczą kursów, dla których przewoźnik je podaje.',
'tr.src.static': 'Według rozkładu ZTP Kraków. Dane o opóźnieniach są teraz niedostępne.',
'tr.pick': 'Wybierz połączenie',
'tr.leave': 'Wyjdź o {time}',
'tr.arrive': 'Jesteś o {time}',
'tr.total': '{n} min',
'tr.walkTo': 'Pieszo {n} min do przystanku {stop}',
'tr.walkEnd': 'Pieszo {n} min do celu od przystanku {stop}',
'tr.ride': 'Linia {line} w stronę {dir}',
'tr.ride.detail': 'odjazd {time} z {from}, wysiadasz o {end} ({stops} przyst.)',
'tr.transfer': 'Przesiadka: {m} m pieszo (ok. {n} min), czekanie ok. {w} min',
'tr.transferTag': '1 przesiadka',
'tr.direct': 'Bez przesiadek',
'tr.inMin': 'za {n} min',
'tr.now': 'teraz',
'tr.delay.none': 'wg rozkładu',
'tr.delay.ontime': 'o czasie',
'tr.delay.late': 'opóźnienie {n} min',
'tr.delay.early': '{n} min przed czasem',
'tr.canceled': 'odwołany',
'tr.next': 'Kolejne: {list}',
'tr.access.clean': 'Dojścia bez barier według Twojego profilu',
'tr.access.some': 'Dojścia: bariery {hard}, uwagi {caution}',
'tr.stop.yes': 'przystanek oznaczony jako dostępny',
'tr.stop.limited': 'przystanek częściowo dostępny',
'tr.stop.no': 'przystanek oznaczony jako niedostępny',
'tr.stop.unknown': 'brak danych o dostępności przystanku',
'tr.lowfloor': 'Rozkład ZTP nie podaje, czy pojazd jest niskopodłogowy. Sprawdź przy wsiadaniu.',
'tr.alerts': 'Komunikaty o utrudnieniach',
'tr.showMap': 'Pokaż na mapie',
'tr.mode.bus': 'autobus',
'tr.mode.tram': 'tramwaj',
'tr.board.title': 'Najbliższe odjazdy',
'tr.board.none': 'Brak odjazdów w najbliższych 2 godzinach.',
'tr.board.loading': 'Wczytuję rozkład…',
'tr.nearby': 'Komunikacja w pobliżu',
'tr.nearby.none': 'Brak przystanków w promieniu 400 m.',
'tr.nearby.dist': '{m} m',
'tr.stopLabel': 'Przystanek {name}',
'tr.towards': 'w stronę: {list}',
'dir.n': 'na północ od miejsca',
'dir.ne': 'na północny wschód od miejsca',
'dir.e': 'na wschód od miejsca',
'dir.se': 'na południowy wschód od miejsca',
'dir.s': 'na południe od miejsca',
'dir.sw': 'na południowy zachód od miejsca',
'dir.w': 'na zachód od miejsca',
'dir.nw': 'na północny zachód od miejsca',
'tr.progress': 'Wczytywanie komunikacji miejskiej',
'tr.stage.download': 'Pobieram rozkład jazdy…',
'tr.stage.index': 'Przygotowuję rozkład…',
'tr.stage.live': 'Sprawdzam opóźnienia na żywo…',
'tr.stage.plan': 'Szukam połączeń…',
'tr.loadingNote': 'Pierwsze wczytanie rozkładu (ok. 0,4 MB) trwa chwilę, potem połączenia pojawiają się od razu.',
'tr.recommended': 'Zalecana',
'route.recommend.long': 'Spacer zajmie ok. {n} min. Dla tak długiej trasy zalecamy komunikację miejską.',
'route.recommend.blocked': 'Pieszo nie ma trasy bez barier. Sprawdź dojazd komunikacją miejską.',
'route.recommend.cta': 'Pokaż komunikację',
'tr.leg.clean': 'bez barier według Twojego profilu',
'tr.leg.some': 'bariery: {hard}, uwagi: {caution}',
'tr.leg.dist': '{m} m',
'dash.sec.state': 'Stan danych ({n} miejsc)',
'dash.sec.stops': 'Przystanki ({total}) — kto wie o dostępności',
'skip': 'Przejdź do treści',
@ -230,13 +299,19 @@ const pl = {
'route.go': 'Wyznacz trasę',
'route.myLocation': 'Użyj mojej lokalizacji',
'route.locating': 'Ustalanie lokalizacji…',
'route.locOut': 'Jesteś poza obszarem demonstracyjnym (od Wawelu i Kazimierza przez Stare Miasto po TAURON Arenę).',
'route.locOut': 'Jesteś ok. {km} km 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.suggestions': 'Podpowiedzi miejsc',
'loc.insecure': 'Przeglądarka udostępnia lokalizację tylko przez HTTPS. Otwórz aplikację pod adresem https://.',
'loc.unsupported': 'Ta przeglądarka nie udostępnia lokalizacji. Wpisz punkt startu.',
'loc.denied': 'Brak zgody na lokalizację. Zezwól w ustawieniach przeglądarki i telefonu (Lokalizacja), potem spróbuj ponownie.',
'loc.timeout': 'Ustalanie lokalizacji trwa za długo. Wyjdź na otwartą przestrzeń albo wpisz punkt startu.',
'loc.unavailable': 'Nie udało się ustalić lokalizacji. Włącz lokalizację w telefonie albo wpisz punkt startu.',
'route.presets': 'Przykładowe trasy',
'route.unknownPlace': 'Nie znaleziono miejsca „{text}”. Wybierz pozycję z podpowiedzi.',
'route.same': 'Start i cel są takie same.',
'route.ok': 'Trasa bez zmapowanych barier',
'route.okCheck': 'Trasa bez znanych barier — sprawdź {n} miejsc na trasie',
'route.okCheck': 'Trasa bez znanych barier — uwagi do sprawdzenia: {n}',
'route.blocked': 'Nie znaleziono trasy bez barier',
'route.blocked.long': 'W zmapowanych danych każda droga prowadzi przez barierę niezgodną z Twoimi granicami. Pokazujemy najlepszą próbę z zaznaczonymi barierami — nie traktuj jej jako dostępnej.',
'route.none': 'Brak trasy w dostępnych danych',
@ -273,6 +348,8 @@ const pl = {
'turn.sharp-right': 'Skręć ostro w prawo',
'turn.back': 'Zawróć',
'seg.unnamed': 'bez nazwy',
'seg.near.park': 'teren: {name}',
'seg.near.street': 'wzdłuż: {name}',
'seg.hw.footway': 'chodnik',
'seg.hw.pedestrian': 'ulica piesza',
'seg.hw.path': 'ścieżka',
@ -700,7 +777,7 @@ export const hasKey = (k: string): k is Key => k in pl;
type Dict = Record<Key, string>;
const en: Dict = {
'app.name': 'Kraków without barriers',
'app.name': APP_NAME,
'app.tagline': 'Check whether a place or route fits your needs — and see how we know.',
'banner.proto': 'Hackathon prototype.',
'banner.data': 'Data: OpenStreetMap snapshot from {date}. Places marked DEMO are fictional.',
@ -711,6 +788,74 @@ const en: Dict = {
'nav.profile': 'Profile',
'lang.label': 'Language',
'lang.switchTo': 'Switch language to Polish',
'route.tabs': 'How to travel',
'route.tab.walk': 'On foot',
'route.tab.transit': 'Transit',
'tr.title': 'Public transport',
'tr.loading': 'Loading the timetable…',
'tr.error': 'Could not load the timetable. Try again later.',
'tr.none': 'No connection in the next 1.5 hours (up to one transfer) between stops near the start and the destination.',
'tr.src.rt': 'ZTP Kraków timetable plus live data (as of {time}). Delays are shown for trips the operator reports.',
'tr.src.static': 'Based on the ZTP Kraków timetable. Delay data is unavailable right now.',
'tr.pick': 'Choose a connection',
'tr.leave': 'Leave at {time}',
'tr.arrive': 'Arrive at {time}',
'tr.total': '{n} min',
'tr.walkTo': 'Walk {n} min to the stop {stop}',
'tr.walkEnd': 'Walk {n} min to the destination from {stop}',
'tr.ride': 'Line {line} towards {dir}',
'tr.ride.detail': 'departs {time} from {from}, get off at {end} ({stops} stops)',
'tr.transfer': 'Transfer: {m} m on foot (about {n} min), wait about {w} min',
'tr.transferTag': '1 transfer',
'tr.direct': 'No transfer',
'tr.inMin': 'in {n} min',
'tr.now': 'now',
'tr.delay.none': 'per timetable',
'tr.delay.ontime': 'on time',
'tr.delay.late': '{n} min late',
'tr.delay.early': '{n} min early',
'tr.canceled': 'cancelled',
'tr.next': 'Next: {list}',
'tr.access.clean': 'Walking parts are barrier-free for your profile',
'tr.access.some': 'Walking parts: {hard} barriers, {caution} cautions',
'tr.stop.yes': 'stop marked as accessible',
'tr.stop.limited': 'stop partly accessible',
'tr.stop.no': 'stop marked as not accessible',
'tr.stop.unknown': 'no accessibility data for the stop',
'tr.lowfloor': 'The ZTP timetable does not say whether a vehicle is low-floor. Check when boarding.',
'tr.alerts': 'Service notices',
'tr.showMap': 'Show on map',
'tr.mode.bus': 'bus',
'tr.mode.tram': 'tram',
'tr.board.title': 'Next departures',
'tr.board.none': 'No departures in the next 2 hours.',
'tr.board.loading': 'Loading the timetable…',
'tr.nearby': 'Public transport nearby',
'tr.nearby.none': 'No stops within 400 m.',
'tr.nearby.dist': '{m} m',
'tr.stopLabel': 'Stop {name}',
'tr.towards': 'towards: {list}',
'dir.n': 'north of the place',
'dir.ne': 'north-east of the place',
'dir.e': 'east of the place',
'dir.se': 'south-east of the place',
'dir.s': 'south of the place',
'dir.sw': 'south-west of the place',
'dir.w': 'west of the place',
'dir.nw': 'north-west of the place',
'tr.progress': 'Loading public transport',
'tr.stage.download': 'Downloading the timetable…',
'tr.stage.index': 'Preparing the timetable…',
'tr.stage.live': 'Checking live delays…',
'tr.stage.plan': 'Looking for connections…',
'tr.loadingNote': 'The first load of the timetable (about 0.4 MB) takes a moment; after that connections appear instantly.',
'tr.recommended': 'Recommended',
'route.recommend.long': 'Walking takes about {n} min. For a route this long we recommend public transport.',
'route.recommend.blocked': 'There is no barrier-free walking route. Check getting there by public transport.',
'route.recommend.cta': 'Show public transport',
'tr.leg.clean': 'barrier-free for your profile',
'tr.leg.some': 'barriers: {hard}, cautions: {caution}',
'tr.leg.dist': '{m} m',
'dash.sec.state': 'State of the data ({n} places)',
'dash.sec.stops': 'Stops ({total}) — who knows about accessibility',
'skip': 'Skip to content',
@ -928,13 +1073,19 @@ 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 (from Wawel and Kazimierz through the Old Town to TAURON Arena).',
'route.locOut': 'You are about {km} km 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.suggestions': 'Place suggestions',
'loc.insecure': 'Browsers only share location over HTTPS. Open the app at an https:// address.',
'loc.unsupported': 'This browser does not share location. Type a starting point.',
'loc.denied': 'Location permission was denied. Allow it in the browser and phone settings (Location), then try again.',
'loc.timeout': 'Finding your location takes too long. Step outside or type a starting point.',
'loc.unavailable': 'Could not get your location. Turn on location on your phone or type a starting point.',
'route.presets': 'Example routes',
'route.unknownPlace': 'No place found for “{text}”. Pick one of the suggestions.',
'route.same': 'Start and destination are the same.',
'route.ok': 'Route with no mapped barriers',
'route.okCheck': 'Route with no known barriers — check {n} spots along it',
'route.okCheck': 'Route with no known barriers — notes to check: {n}',
'route.blocked': 'No barrier-free route found',
'route.blocked.long': 'In the mapped data every way goes through a barrier beyond your limits. We show the best attempt with the barriers marked — do not treat it as accessible.',
'route.none': 'No route in the available data',
@ -971,6 +1122,8 @@ const en: Dict = {
'turn.sharp-right': 'Turn sharp right',
'turn.back': 'Turn back',
'seg.unnamed': 'unnamed',
'seg.near.park': 'area: {name}',
'seg.near.street': 'along {name}',
'seg.hw.footway': 'footpath',
'seg.hw.pedestrian': 'pedestrian street',
'seg.hw.path': 'path',

View file

@ -29,5 +29,8 @@ export function resolveLabel(labels: Labels, text: string): Place | null {
const q = fold(text.trim());
if (!q) return null;
const hits = [...labels.byLabel.entries()].filter(([l]) => fold(l).startsWith(q));
return hits.length === 1 ? hits[0]![1] : null;
if (hits.length === 1) return hits[0]![1];
// several prefix matches (e.g. a stadium and the stops named after it): an exact name match wins if it is unique
const exactName = hits.filter(([, pl]) => fold(pl.name ?? '') === q && pl.cat !== 'transit');
return exactName.length === 1 ? exactName[0]![1] : null;
}

50
src/lib/locate.test.ts Normal file
View file

@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getPosition, kmOutside, LocateFailure } from './locate';
type Cb = (p: GeolocationPosition) => void;
type Err = (e: GeolocationPositionError) => void;
const ok = (lat: number, lon: number) => ({ coords: { latitude: lat, longitude: lon } }) as GeolocationPosition;
const fail = (code: number) => ({ code }) as GeolocationPositionError;
function env(secure: boolean, answers: (GeolocationPosition | GeolocationPositionError)[]) {
const calls: PositionOptions[] = [];
vi.stubGlobal('window', { isSecureContext: secure });
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: (res: Cb, rej: Err, opts: PositionOptions) => {
calls.push(opts);
const a = answers.shift()!;
if ('coords' in a) res(a); else rej(a);
},
},
});
return calls;
}
afterEach(() => vi.unstubAllGlobals());
describe('device location', () => {
it('says "insecure" instead of failing silently when the page is not on HTTPS', async () => {
const calls = env(false, []);
await expect(getPosition()).rejects.toMatchObject({ reason: 'insecure' });
expect(calls).toHaveLength(0);
});
it('stops at once when permission is denied', async () => {
const calls = env(true, [fail(1)]);
await expect(getPosition()).rejects.toBeInstanceOf(LocateFailure);
expect(calls).toHaveLength(1);
});
it('tries a quick low-accuracy fix first, then GPS', async () => {
const calls = env(true, [fail(3), ok(50.06, 19.94)]);
expect(await getPosition()).toEqual([50.06, 19.94]);
expect(calls.map((c) => c.enableHighAccuracy)).toEqual([false, true]);
});
it('reports a timeout when both attempts time out', async () => {
env(true, [fail(3), fail(3)]);
await expect(getPosition()).rejects.toMatchObject({ reason: 'timeout' });
});
it('measures how far outside the area a position is', () => {
const bbox: [number, number, number, number] = [50.044, 19.92, 50.074, 20.0];
expect(kmOutside([50.06, 19.95], bbox)).toBe(0);
expect(kmOutside([50.1, 19.95], bbox)).toBeGreaterThan(2.5);
});
});

43
src/lib/locate.ts Normal file
View file

@ -0,0 +1,43 @@
// Device position for the route form: tries a quick network fix first, then GPS, and says *why* it failed.
import { haversine, type LatLon } from './geo';
export type LocateError = 'insecure' | 'unsupported' | 'denied' | 'unavailable' | 'timeout';
export class LocateFailure extends Error {
constructor(readonly reason: LocateError) { super(reason); }
}
const once = (opts: PositionOptions) =>
new Promise<GeolocationPosition>((res, rej) => navigator.geolocation.getCurrentPosition(res, rej, opts));
/**
* Browsers only expose location on HTTPS (or localhost): a phone opening http://192.168.x.x gets nothing, and the error looks like a
* generic failure, so that case is reported separately. A quick low-accuracy fix (Wi-Fi / cell, cached up to a minute) is tried before
* waiting for GPS, which can take a long time indoors.
*/
export async function getPosition(): Promise<LatLon> {
if (!('geolocation' in navigator)) throw new LocateFailure('unsupported');
if (!window.isSecureContext) throw new LocateFailure('insecure');
const attempts: PositionOptions[] = [
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 8_000 },
{ enableHighAccuracy: true, maximumAge: 0, timeout: 20_000 },
];
let last: LocateError = 'unavailable';
for (const opts of attempts) {
try {
const p = await once(opts);
return [p.coords.latitude, p.coords.longitude];
} catch (e) {
const code = (e as GeolocationPositionError).code;
if (code === 1) throw new LocateFailure('denied'); // asking again cannot help
last = code === 3 ? 'timeout' : 'unavailable';
}
}
throw new LocateFailure(last);
}
/** Distance in km from a point to the demo area's bounding box (0 when inside). */
export function kmOutside(p: LatLon, bbox: [number, number, number, number]): number {
const [s, w, n, e] = bbox;
const clamp: LatLon = [Math.min(Math.max(p[0], s), n), Math.min(Math.max(p[1], w), e)];
return haversine(p, clamp) / 1000;
}

View file

@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { DEFAULT_PROFILE } from './profile';
import { buildGraph, findRoute } from './route';
import type { Snapshot } from './types';
const snap = JSON.parse(readFileSync('public/data/kk-snapshot.json', 'utf8')) as Snapshot;
const graph = buildGraph(snap);
const place = (n: string) => snap.places.find((p) => p.name === n && p.cat !== 'transit')!;
describe('names for unnamed path segments', () => {
it('the snapshot carries named green areas', () => {
expect(snap.parks?.some((p) => /Planty/.test(p.n))).toBe(true);
});
it('labels a point inside a park with the park, and an unnamed way near a street with the street', () => {
// the first node of an unnamed way inside Planty
const inPlanty = snap.ways.findIndex((w, i) => {
if (w.name) return false;
const [lat, lon] = snap.nodes[w.nodes[0]!]!;
const l = graph.namer(i, [lat, lon]);
return l?.kind === 'park' && /Planty/.test(l.name);
});
expect(inPlanty).toBeGreaterThanOrEqual(0);
const streetLabelled = snap.ways.findIndex((w, i) => {
if (w.name || w.hw !== 'footway') return false;
const [lat, lon] = snap.nodes[w.nodes[0]!]!;
return graph.namer(i, [lat, lon])?.kind === 'street';
});
expect(streetLabelled).toBeGreaterThanOrEqual(0);
});
it('a real route has few segments left without any name', () => {
const a = place('Sukiennice'), b = place('Zamek Królewski na Wawelu');
const r = findRoute(graph, [a.lat, a.lon], [b.lat, b.lon], DEFAULT_PROFILE.wheelchair, { mode: 'balanced' });
const nameless = r.segments.filter((s) => !s.name && !s.near);
expect(r.segments.length).toBeGreaterThan(2);
expect(nameless.length / r.segments.length).toBeLessThan(0.3);
for (const s of r.segments) if (s.name) expect(s.near).toBeUndefined(); // an own name always wins
});
});

61
src/lib/placeNames.ts Normal file
View file

@ -0,0 +1,61 @@
// Names for path segments that have none in OSM: the park they run through, else the nearest named street.
import { Grid, distToSegment, type LatLon } from './geo';
import type { Snapshot } from './types';
export interface AreaLabel { name: string; kind: 'park' | 'street' }
export type Namer = (wayIdx: number, at: LatLon) => AreaLabel | null;
const STREETS = new Set(['residential', 'living_street', 'pedestrian', 'tertiary', 'secondary', 'primary', 'trunk', 'unclassified']);
const MAX_STREET_M = 45;
interface Ring { name: string; pts: [number, number][]; s: number; w: number; n: number; e: number }
const inside = (r: Ring, [lat, lon]: LatLon): boolean => {
if (lat < r.s || lat > r.n || lon < r.w || lon > r.e) return false;
let c = false;
for (let i = 0, j = r.pts.length - 1; i < r.pts.length; j = i++) {
const [yi, xi] = r.pts[i]!, [yj, xj] = r.pts[j]!;
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) c = !c;
}
return c;
};
/** Built lazily (the street index is only needed once a route has an unnamed segment). */
export function createNamer(snap: Snapshot): Namer {
const rings: Ring[] = (snap.parks ?? []).map((p) => {
const lats = p.p.map((x) => x[0]), lons = p.p.map((x) => x[1]);
return { name: p.n, pts: p.p, s: Math.min(...lats), n: Math.max(...lats), w: Math.min(...lons), e: Math.max(...lons) };
}).sort((a, b) => (a.n - a.s) * (a.e - a.w) - (b.n - b.s) * (b.e - b.w)); // smallest first: a playground beats the park around it
let streets: Grid<{ a: LatLon; b: LatLon; name: string }> | null = null;
const street = (at: LatLon, wayIdx: number): AreaLabel | null => {
if (!streets) {
const grid = new Grid<{ a: LatLon; b: LatLon; name: string }>(0.0006);
streets = grid;
snap.ways.forEach((w, wi) => {
if (!w.name || !STREETS.has(w.hw) || wi === wayIdx) return;
for (let i = 0; i < w.nodes.length - 1; i++) {
const a = snap.nodes[w.nodes[i]!]!, b = snap.nodes[w.nodes[i + 1]!]!;
const item = { a, b, name: w.name };
for (const p of [a, b, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as LatLon]) grid.add(p[0], p[1], item);
}
});
}
let best: AreaLabel | null = null, bd = MAX_STREET_M;
for (const s of streets.near(at[0], at[1], MAX_STREET_M)) {
const d = distToSegment(at, s.a, s.b);
if (d < bd) { bd = d; best = { name: s.name, kind: 'street' }; }
}
return best;
};
const cache = new Map<string, AreaLabel | null>();
return (wayIdx, at) => {
const key = `${wayIdx}:${at[0].toFixed(4)}:${at[1].toFixed(4)}`;
if (cache.has(key)) return cache.get(key)!;
const park = rings.find((r) => inside(r, at));
const label: AreaLabel | null = park ? { name: park.name, kind: 'park' } : street(at, wayIdx);
cache.set(key, label);
return label;
};
}

View file

@ -3,6 +3,7 @@
// graph; they carry a large penalty so the user still sees the best available path *and* that it is blocked.
import { Grid, bearing, haversine, type LatLon } from './geo';
import { isCobbles, kerbHeightCm, parseIncline, parseWidth, roughness, stepsHaveRamp, type Roughness } from './osm';
import { createNamer, type AreaLabel, type Namer } from './placeNames';
import type { Profile, Snapshot, Way } from './types';
export type Flag =
@ -18,6 +19,8 @@ export interface RouteGraph {
snapshot: Snapshot;
adj: Edge[][];
grid: Grid<number>;
/** Fallback names for unnamed ways: the park they cross, else the nearest street. */
namer: Namer;
}
export function buildGraph(snapshot: Snapshot): RouteGraph {
@ -35,7 +38,7 @@ export function buildGraph(snapshot: Snapshot): RouteGraph {
}
});
for (const n of used) grid.add(snapshot.nodes[n]![0], snapshot.nodes[n]![1], n);
return { snapshot, adj, grid };
return { snapshot, adj, grid, namer: createNamer(snapshot) };
}
export function snapToGraph(g: RouteGraph, p: LatLon, maxM = 150): number | null {
@ -136,6 +139,8 @@ export type Turn = 'start' | 'straight' | 'left' | 'right' | 'sharp-left' | 'sha
export interface RouteSegment {
name: string | null;
/** Set when the way itself has no name: where it runs (park) or along which street. */
near?: AreaLabel;
hw: string;
surface?: string;
rough: Roughness;
@ -150,6 +155,7 @@ export interface Barrier {
at: LatLon;
wayId: number;
name: string | null;
near?: AreaLabel;
lengthM: number;
detail?: string;
}
@ -253,13 +259,14 @@ function summarize(
minutes += (edge.len / 1000 / speed) * 60 * (wi.rough === 'rough' ? 1.4 : 1);
const a = nodes[path[i - 1]!.node]!, b = nodes[path[i]!.node]!;
const near = w.name ? null : g.namer(edge.way, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]);
const last = segments[segments.length - 1];
if (last && last.name === (w.name ?? null) && last.flags.join(',') === [...wi.flags].sort().join(',') && last.surface === w.surface && last.hw === w.hw) {
if (last && last.name === (w.name ?? null) && last.near?.name === near?.name && last.flags.join(',') === [...wi.flags].sort().join(',') && last.surface === w.surface && last.hw === w.hw) {
last.distanceM += edge.len;
last.coords.push(b);
} else {
segments.push({
name: w.name ?? null, hw: w.hw, surface: w.surface, rough: wi.rough,
name: w.name ?? null, ...(near ? { near } : {}), hw: w.hw, surface: w.surface, rough: wi.rough,
flags: [...wi.flags].sort() as Flag[], distanceM: edge.len, turn: 'start', coords: [a, b],
});
}
@ -270,7 +277,7 @@ function summarize(
const ex = barriers.get(key);
if (ex) ex.lengthM += edge.len;
else barriers.set(key, {
flag: f, at: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], wayId: w.id, name: w.name ?? null, lengthM: edge.len,
flag: f, at: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], wayId: w.id, name: w.name ?? null, ...(near ? { near } : {}), lengthM: edge.len,
detail: f === 'steps' ? (w.stepCount ? String(w.stepCount) : undefined) : f === 'cobbles' || f === 'rough' ? w.surface : f === 'steep' ? w.incline : undefined,
});
}

132
src/lib/transit.test.ts Normal file
View file

@ -0,0 +1,132 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { decodeFeed } from './gtfsrt';
import { DEFAULT_PROFILE } from './profile';
import { buildGraph } from './route';
import { buildTransitIndex, dateOf, delayAt, departuresFrom, midnightMs, planJourneys, NO_REALTIME, type Realtime, type TransitData } from './transit';
import type { Snapshot } from './types';
// ---- tiny protobuf encoder, only for building fixtures ----
const varint = (n: number): number[] => {
let v = BigInt.asUintN(64, BigInt(n));
const out: number[] = [];
do { let b = Number(v & 0x7fn); v >>= 7n; if (v) b |= 0x80; out.push(b); } while (v);
return out;
};
const tag = (f: number, w: number) => varint((f << 3) | w);
const num = (f: number, n: number) => [...tag(f, 0), ...varint(n)];
const msg = (f: number, ...parts: number[][]) => { const body = parts.flat(); return [...tag(f, 2), ...varint(body.length), ...body]; };
const str = (f: number, s: string) => msg(f, [...new TextEncoder().encode(s)]);
describe('GTFS-realtime reader', () => {
const feed = (...entities: number[][]) => new Uint8Array([...msg(1, str(1, '2.0'), num(3, 1791044700)), ...entities.flat()]);
it('reads trip updates incl. negative delays and skipped stops', () => {
const stu = (stop: string, delay: number) => msg(2, str(4, stop), msg(2, num(1, delay), num(2, 1791044763)), msg(3, num(1, delay)));
const f = decodeFeed(feed(msg(2, str(1, 'e1'), msg(3, msg(1, str(1, 'trip-1'), str(5, '124')), stu('A', -90), stu('B', 483), msg(2, str(4, 'C'), num(5, 1))))));
expect(f.timestamp).toBe(1791044700);
const t = f.trips[0]!;
expect(t.tripId).toBe('trip-1');
expect(t.routeId).toBe('124');
expect(t.stops.map((s) => [s.stopId, s.departure?.delay])).toEqual([['A', -90], ['B', 483], ['C', undefined]]);
expect(t.stops[2]!.skipped).toBe(true);
});
it('reads cancellations and alerts', () => {
const alert = msg(5, msg(5, str(2, '49')), msg(10, msg(1, str(1, 'Objazd'), str(2, 'pl'))), msg(11, msg(1, str(1, 'Szczegóły'))));
const f = decodeFeed(feed(msg(2, str(1, 'x'), msg(3, msg(1, str(1, 'c1'), num(4, 3)))), msg(2, str(1, 'a'), alert)));
expect(f.trips[0]!.canceled).toBe(true);
expect(f.alerts[0]).toMatchObject({ routeIds: ['49'], header: 'Objazd', description: 'Szczegóły' });
});
it('rejects bytes that are not a feed (e.g. an HTML fallback page)', () => {
expect(() => decodeFeed(new TextEncoder().encode('<!doctype html><html></html>'))).toThrow();
});
});
describe('Kraków time', () => {
it('maps a day to its local midnight, also on the 25-hour DST day', () => {
expect(new Date(midnightMs(20260705)).toISOString()).toBe('2026-07-04T22:00:00.000Z');
expect(new Date(midnightMs(20261025)).toISOString()).toBe('2026-10-24T22:00:00.000Z');
expect(new Date(midnightMs(20261026)).toISOString()).toBe('2026-10-25T23:00:00.000Z');
});
it('dateOf uses the Kraków calendar day, not UTC', () => {
expect(dateOf(Date.parse('2026-07-04T22:30:00Z'))).toBe(20260705);
});
});
describe('departures', () => {
// one line A -> B -> C, trips at 10:00 and 10:20 (first area stop), 3 min between stops; service 0 runs on 2026-10-05 only
const data: TransitData = {
meta: { builtAt: '', source: '', feeds: [], license: '' },
routes: [{ short: '99', mode: 'bus', color: '' }],
services: [[20261005]],
patterns: [{ r: 0, h: 'Terminus', s: ['zbA', 'zbB', 'zbC'], o: [[0, 3, 6, 0, 3, 6]], t: [[0, 600, 0, 0, 'T1'], [0, 620, 0, 0, 'T2']] }],
};
const ix = buildTransitIndex(data);
const at = (hhmm: string) => Date.parse(`2026-10-05T${hhmm}:00+02:00`);
it('lists upcoming departures in order, skipping the terminus and inactive days', () => {
expect(departuresFrom(ix, 'zbB', at('09:55'), 60).map((d) => d.tripId)).toEqual(['T1', 'T2']);
expect(departuresFrom(ix, 'zbC', at('09:55'), 60)).toEqual([]);
expect(departuresFrom(ix, 'zbA', Date.parse('2026-10-06T09:55:00+02:00'), 60)).toEqual([]);
expect(departuresFrom(ix, 'zbA', at('10:05'), 60).map((d) => d.tripId)).toEqual(['T2']);
});
it('applies real-time delays and cancellations', () => {
const rt: Realtime = {
...NO_REALTIME, fetchedAt: 1,
trips: new Map([
['T1', { tripId: 'T1', stops: [{ stopId: 'B', departure: { delay: 300 } }] }],
['T2', { tripId: 'T2', canceled: true, stops: [] }],
]),
};
const [d1, d2] = departuresFrom(ix, 'zbB', at('09:55'), 60, rt);
expect(d1!.delaySec).toBe(300);
expect(d1!.expected - d1!.scheduled).toBe(300_000);
expect(d2!.canceled).toBe(true);
expect(delayAt(rt, 'nope', 'zbB', 0)).toEqual({ delaySec: null, canceled: false });
});
it('does not apply the running trip\'s delay to the same trip id on another service day', () => {
const rt: Realtime = { ...NO_REALTIME, fetchedAt: at('10:00'), trips: new Map([['T1', { tripId: 'T1', delay: 240, stops: [] }]]) };
expect(delayAt(rt, 'T1', 'zbB', at('10:03')).delaySec).toBe(240);
expect(delayAt(rt, 'T1', 'zbB', at('10:03') + 24 * 3600_000).delaySec).toBeNull();
// an absurd delay (> 3 h) is a mismatched occurrence, not a prediction
const bad: Realtime = { ...rt, trips: new Map([['T1', { tripId: 'T1', stops: [{ stopId: 'B', departure: { time: (at('10:03') + 5 * 3600_000) / 1000 } }] }]]) };
expect(delayAt(bad, 'T1', 'zbB', at('10:03')).delaySec).toBeNull();
});
it('derives delay from an absolute predicted time when the feed has no delay field (trams)', () => {
const rt: Realtime = { ...NO_REALTIME, trips: new Map([['T1', { tripId: 'T1', stops: [{ stopId: 'B', departure: { time: Date.parse('2026-10-05T10:05:00+02:00') / 1000 } }] }]]) };
// scheduled at B: 10:03 -> predicted 10:05 = +120 s
expect(delayAt(rt, 'T1', 'zbB', at('10:03')).delaySec).toBe(120);
});
});
describe('journey planning on the real timetable', () => {
const snap = JSON.parse(readFileSync('public/data/kk-snapshot.json', 'utf8')) as Snapshot;
const ix = buildTransitIndex(JSON.parse(readFileSync('public/data/kk-transit.json', 'utf8')) as TransitData);
const graph = buildGraph(snap);
const stops = snap.places.filter((p) => p.cat === 'transit');
const place = (name: string) => snap.places.find((p) => p.name === name && p.cat !== 'transit')!;
it('finds a ride from the Old Town to Tauron Arena on a weekday morning, arriving after it leaves', () => {
const from = place('Sukiennice'), to = place('Tauron Arena Kraków');
const now = Date.parse('2026-10-05T08:00:00+02:00');
const js = planJourneys({ ix, rt: NO_REALTIME, graph, profile: DEFAULT_PROFILE.wheelchair, stops, from: [from.lat, from.lon], to: [to.lat, to.lon], now });
expect(js.length).toBeGreaterThan(0);
for (const j of js) {
const last = j.rides[j.rides.length - 1]!;
expect(j.rides[0]!.boardMs).toBeGreaterThan(now);
expect(last.alightMs).toBeGreaterThan(j.rides[0]!.boardMs);
expect(j.arriveMs).toBeGreaterThanOrEqual(last.alightMs);
for (const r of j.rides) expect(r.path.length).toBeGreaterThanOrEqual(2);
// a transfer never leaves before the first ride arrives
if (j.transfer) {
expect(j.rides[1]!.boardMs).toBeGreaterThan(j.rides[0]!.alightMs);
// the change between stops is walked with the router, and there is time for it before the next vehicle
expect(j.transfer.walk.coords.length).toBeGreaterThanOrEqual(2);
expect(j.transfer.walk.durationMin).toBeGreaterThan(0);
expect(j.rides[1]!.boardMs).toBeGreaterThanOrEqual(j.rides[0]!.alightMs + j.transfer.walk.durationMin * 60000);
}
expect(j.walkTo.coords.length).toBeGreaterThanOrEqual(2);
expect(j.walkFrom.coords.length).toBeGreaterThanOrEqual(2);
}
expect(js.map((j) => j.arriveMs)).toEqual([...js.map((j) => j.arriveMs)].sort((a, b) => a - b));
});
});

406
src/lib/transit.ts Normal file
View file

@ -0,0 +1,406 @@
// Public transport: ZTP Kraków timetable (static GTFS, public/data/kk-transit.json) + optional GTFS-Realtime delays.
// Everything here is pure except `loadTransit` / `fetchRealtime`. Times are epoch milliseconds; the timetable is in Kraków local time.
import { decodeFeed, type Alert, type TripUpdate } from './gtfsrt';
import { haversine, type LatLon } from './geo';
import { findRoute, HARD_FLAGS, type RouteGraph, type RouteResult } from './route';
import type { Place, Profile } from './types';
// ---------------------------------------------------------------- data
export type Mode = 'bus' | 'tram';
export interface TransitRoute { short: string; mode: Mode; color: string }
/** t: [serviceIdx, firstAreaStopDepartureMin, offsetVariantIdx, wheelchair (0 unknown / 1 low-floor / 2 no), tripId]; o: arrivals then departures (minutes relative to t0). */
export interface Pattern { r: number; h: string; s: string[]; o: number[][]; t: [number, number, number, number, string][] }
export interface TransitData {
meta: { builtAt: string; source: string; feeds: { mode: Mode; version: string | null }[]; license: string };
routes: TransitRoute[];
services: number[][];
patterns: Pattern[];
}
let dataPromise: Promise<TransitData> | null = null;
let downloaded = 0;
const listeners = new Set<(frac: number) => void>();
/** Download progress 0..1 of the timetable (1 once loaded); for a progress bar. */
export function onTransitProgress(fn: (frac: number) => void): () => void {
fn(dataPromise ? downloaded : 0);
listeners.add(fn);
return () => { listeners.delete(fn); };
}
const report = (f: number) => { downloaded = f; listeners.forEach((l) => l(f)); };
/** Timetable is loaded on first use (2.2 MB, ~0.4 MB compressed), not at app start. */
export function loadTransit(): Promise<TransitData> {
dataPromise ??= (async () => {
const res = await fetch(`${import.meta.env.BASE_URL}data/kk-transit.json`);
if (!res.ok) throw new Error(String(res.status));
const len = Number(res.headers.get('content-length')) || 0;
// with Content-Encoding the header is the compressed size while the stream yields decompressed bytes (about 5x as many)
const total = len * (res.headers.get('content-encoding') ? 5 : 1);
if (!res.body || !total) { const d = (await res.json()) as TransitData; report(1); return d; }
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
report(Math.min(0.97, got / total));
}
const all = new Uint8Array(got);
let at = 0;
for (const c of chunks) { all.set(c, at); at += c.length; }
const d = JSON.parse(new TextDecoder().decode(all)) as TransitData;
report(1);
return d;
})().catch((e: unknown) => { dataPromise = null; report(0); throw e; });
return dataPromise;
}
let indexCache: TransitIndex | null = null;
/** Timetable + lookup index, built once and shared by every screen. */
export async function loadTransitIndex(): Promise<TransitIndex> {
if (indexCache) return indexCache;
const d = await loadTransit();
await new Promise((r) => setTimeout(r, 0)); // let the UI paint the "preparing" step before the synchronous build
indexCache ??= buildTransitIndex(d);
return indexCache;
}
export const transitIndexReady = () => indexCache !== null;
// ---------------------------------------------------------------- Kraków time
const TZ = 'Europe/Warsaw';
const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: TZ, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
function localParts(ms: number) {
const o: Record<string, number> = {};
for (const p of fmt.formatToParts(new Date(ms))) if (p.type !== 'literal') o[p.type] = Number(p.value);
return o as { year: number; month: number; day: number; hour: number; minute: number; second: number };
}
/** Offset of Kraków local time from UTC at an instant, in ms. */
const tzOffset = (ms: number) => { const p = localParts(ms); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000; };
/** yyyymmdd of the Kraków calendar day containing `ms`. */
export function dateOf(ms: number): number { const p = localParts(ms); return p.year * 10000 + p.month * 100 + p.day; }
/** Epoch ms of local midnight that starts the given yyyymmdd. */
export function midnightMs(date: number): number {
const y = Math.floor(date / 10000), m = Math.floor(date / 100) % 100, d = date % 100;
const guess = Date.UTC(y, m - 1, d);
let ms = guess - tzOffset(guess);
ms = guess - tzOffset(ms); // second pass settles the DST edge
return ms;
}
const addDays = (date: number, n: number) => dateOf(midnightMs(date) + n * 864e5 + 12 * 36e5);
export const clock = (ms: number) => new Intl.DateTimeFormat('pl-PL', { timeZone: TZ, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(ms));
// ---------------------------------------------------------------- index + departures
export interface TransitIndex {
data: TransitData;
/** stop id -> [pattern, position] */
byStop: Map<string, [number, number][]>;
serviceDates: Set<number>[];
}
export function buildTransitIndex(data: TransitData): TransitIndex {
const byStop = new Map<string, [number, number][]>();
data.patterns.forEach((p, pi) => p.s.forEach((s, i) => { const l = byStop.get(s); if (l) l.push([pi, i]); else byStop.set(s, [[pi, i]]); }));
return { data, byStop, serviceDates: data.services.map((d) => new Set(d)) };
}
export interface Departure {
pattern: number;
pos: number;
route: TransitRoute;
line: string;
headsign: string;
tripId: string;
stopId: string;
scheduled: number;
/** Predicted time: scheduled + delay when a real-time update exists. */
expected: number;
delaySec: number | null;
canceled: boolean;
wheelchair: 0 | 1 | 2;
/** Scheduled arrival times at every area stop of this trip (same order as pattern.s). */
arrivals: number[];
}
export interface Realtime {
fetchedAt: number;
trips: Map<string, TripUpdate>;
alerts: Alert[];
/** Which feeds answered; both false means "no real-time data, timetable only". */
ok: { bus: boolean; tram: boolean };
}
export const NO_REALTIME: Realtime = { fetchedAt: 0, trips: new Map(), alerts: [], ok: { bus: false, tram: false } };
const RT_WINDOW_MS = 3 * 3600_000;
/** Delay of a trip at a stop in seconds (null = no prediction), or `canceled`. */
export function delayAt(rt: Realtime, tripId: string, stopId: string, scheduledMs: number): { delaySec: number | null; canceled: boolean } {
const tu = rt.trips.get(tripId);
if (!tu) return { delaySec: null, canceled: false };
// trip ids repeat on every service day, the feed describes only the run in progress: ignore other days' occurrences
if (rt.fetchedAt > 1 && Math.abs(scheduledMs - rt.fetchedAt) > RT_WINDOW_MS) return { delaySec: null, canceled: false };
if (tu.canceled) return { delaySec: null, canceled: true };
const stopCode = stopId.slice(2); // snapshot ids are `z<mode letter><gtfs stop id>`
const u = tu.stops.find((s) => s.stopId === stopCode);
if (u?.skipped) return { delaySec: null, canceled: true };
const ev = u?.departure ?? u?.arrival;
const sane = (d: number | undefined | null) => (d === undefined || d === null || Math.abs(d) > RT_WINDOW_MS / 1000 ? null : d);
if (ev?.delay !== undefined && !(ev.delay === 0 && ev.time === 0)) return { delaySec: sane(ev.delay), canceled: false };
if (ev?.time) return { delaySec: sane(Math.round(ev.time - scheduledMs / 1000)), canceled: false };
return { delaySec: sane(tu.delay), canceled: false };
}
/** Departures of one pattern at one position within [fromMs, toMs] (expected time), unsorted across days. */
function scanPattern(ix: TransitIndex, rt: Realtime, pi: number, pos: number, fromMs: number, toMs: number): Departure[] {
const out: Departure[] = [];
const p = ix.data.patterns[pi]!;
const stopId = p.s[pos]!;
const route = ix.data.routes[p.r]!;
const today = dateOf(fromMs);
for (const day of [addDays(today, -1), today, addDays(today, 1)]) {
const base = midnightMs(day);
// trips are sorted by t0; this stop is at most a few hours after t0
const lowMin = (fromMs - 15 * 60000 - base) / 60000 - 240;
let lo = 0, hi = p.t.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (p.t[mid]![1] < lowMin) lo = mid + 1; else hi = mid; }
for (let k = lo; k < p.t.length; k++) {
const [svc, t0, vi, wc, tripId] = p.t[k]!;
if (t0 * 60000 + base > toMs) break;
if (!ix.serviceDates[svc]!.has(day)) continue;
const offs = p.o[vi]!;
const n = p.s.length;
const scheduled = base + (t0 + offs[n + pos]!) * 60000;
const { delaySec, canceled } = delayAt(rt, tripId, stopId, scheduled);
const expected = scheduled + (delaySec ?? 0) * 1000;
if (expected < fromMs || expected > toMs) continue;
out.push({
pattern: pi, pos, route, line: route.short, headsign: p.h, tripId, stopId, scheduled, expected, delaySec, canceled,
wheelchair: (wc === 1 || wc === 2 ? wc : 0), arrivals: offs.slice(0, n).map((o) => base + (t0 + o) * 60000),
});
}
}
return out;
}
/** Upcoming departures from one stop, soonest (expected) first. Terminus-only visits are not departures. */
export function departuresFrom(ix: TransitIndex, stopId: string, fromMs: number, windowMin: number, rt: Realtime = NO_REALTIME): Departure[] {
const out: Departure[] = [];
for (const [pi, pos] of ix.byStop.get(stopId) ?? []) {
if (pos === ix.data.patterns[pi]!.s.length - 1) continue;
out.push(...scanPattern(ix, rt, pi, pos, fromMs, fromMs + windowMin * 60000));
}
return out.sort((a, b) => a.expected - b.expected);
}
/** First non-cancelled departure of one pattern at `pos` at or after `afterMs`, within two hours. */
function nextOnPattern(ix: TransitIndex, rt: Realtime, pi: number, pos: number, afterMs: number): Departure | null {
let best: Departure | null = null;
for (const d of scanPattern(ix, rt, pi, pos, afterMs, afterMs + 120 * 60000)) {
if (!d.canceled && (!best || d.expected < best.expected)) best = d;
}
return best;
}
// ---------------------------------------------------------------- real-time feed
const FEEDS: { mode: Mode; file: string }[] = [
{ mode: 'bus', file: 'TripUpdates_A.pb' }, { mode: 'tram', file: 'TripUpdates_T.pb' },
{ mode: 'bus', file: 'ServiceAlerts_A.pb' }, { mode: 'tram', file: 'ServiceAlerts_T.pb' },
];
/** ZTP does not send CORS headers: the dev/preview server proxies `rt/` (see vite.config.ts); a static host needs the same reverse-proxy rule. */
export async function fetchRealtime(signal?: AbortSignal): Promise<Realtime> {
const rt: Realtime = { fetchedAt: Date.now(), trips: new Map(), alerts: [], ok: { bus: false, tram: false } };
await Promise.all(FEEDS.map(async ({ mode, file }) => {
try {
// a slow feed must not hold up the plan: after 6 s we carry on with the timetable alone
const res = await fetch(new URL(`rt/${file}`, document.baseURI), { cache: 'no-store', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(6000)]) : AbortSignal.timeout(6000) });
if (!res.ok) return;
const feed = decodeFeed(new Uint8Array(await res.arrayBuffer()));
if (file.startsWith('Trip')) { for (const t of feed.trips) rt.trips.set(t.tripId, t); rt.ok[mode] = true; }
else rt.alerts.push(...feed.alerts);
} catch { /* feed unreachable or not protobuf (e.g. SPA fallback page): stay on the timetable */ }
}));
return rt;
}
// ---------------------------------------------------------------- journey planning
/** One vehicle ride between two area stops. */
export interface Ride {
dep: Departure;
from: Place;
to: Place;
/** Expected boarding / alighting time (timetable + real-time delay). */
boardMs: number;
alightMs: number;
stops: number;
/** Stop coordinates from boarding to alighting, for drawing on the map. */
path: LatLon[];
}
/** The walk between two stops when changing lines, computed with the step-free router like every other walking leg. */
export interface Transfer { from: Place; to: Place; distM: number; walkMin: number; waitMin: number; walk: RouteResult }
export interface Journey {
id: string;
rides: Ride[];
transfer: Transfer | null;
walkTo: RouteResult;
walkFrom: RouteResult;
startMs: number;
arriveMs: number;
/** Following departures of the first line from the boarding stop. */
next: Departure[];
/** Barriers on all walking legs (to the first stop, between stops, from the last stop): hard ones and cautions. */
hard: number;
caution: number;
}
/** Placeholder while searching: every transfer is re-walked with the router before it is shown (see `refine`). */
const ESTIMATE = { status: 'ok', coords: [], segments: [], barriers: [], distanceM: 0, durationMin: 0, stats: { knownSurfacePct: 0, unknownKerbs: 0, cobbleM: 0 } } as RouteResult;
const WALK_BUFFER_MIN = 1;
const TRANSFER_BUFFER_MIN = 2;
const MAX_STOP_DIST_M = 700;
const TRANSFER_RADIUS_M = 200;
const CANDIDATES = 6;
const TRANSFER_PENALTY_MIN = 4;
const SLOW_WALK_MS = 1.1; // m/s on the straight-line * 1.3 estimate used for transfers
function nearestStops(stops: Place[], at: LatLon): Place[] {
return stops
.map((s) => ({ s, d: haversine(at, [s.lat, s.lon]) }))
.filter((x) => x.d <= MAX_STOP_DIST_M)
.sort((a, b) => a.d - b.d)
.slice(0, CANDIDATES)
.map((x) => x.s);
}
/**
* Journeys by public transport: walk to a stop, ride one line (or two with one transfer), walk to the destination.
* Walking legs to/from the stops use the step-free router with the user's limits (a stop that cannot be reached is dropped);
* the transfer walk is estimated from distance. One best trip per line combination, earliest arrival (with a small transfer penalty) first.
*/
export function planJourneys(args: {
ix: TransitIndex; rt: Realtime; graph: RouteGraph; profile: Profile; stops: Place[]; from: LatLon; to: LatLon; now: number; limit?: number;
}): Journey[] {
const { ix, rt, graph, profile, stops, from, to, now } = args;
const walkable = (a: LatLon, b: LatLon) => { const r = findRoute(graph, a, b, profile, { mode: 'balanced' }); return r.status === 'none' ? null : r; };
const boarding = nearestStops(stops, from).flatMap((s) => { const w = walkable(from, [s.lat, s.lon]); return w ? [{ s, w }] : []; });
const alighting = new Map(nearestStops(stops, to).flatMap((s) => { const w = walkable([s.lat, s.lon], to); return w ? [[s.id, { s, w }] as const] : []; }));
if (!boarding.length || !alighting.size) return [];
const byId = new Map(stops.map((x) => [x.id, x]));
const here = (id: string): LatLon => { const s = byId.get(id)!; return [s.lat, s.lon]; };
const rideOf = (dep: Departure, pi: number, j: number): Ride | null => {
const pat = ix.data.patterns[pi]!;
const a = byId.get(pat.s[dep.pos]!), b = byId.get(pat.s[j]!);
if (!a || !b) return null;
const delayMs = (dep.delaySec ?? 0) * 1000;
const path = pat.s.slice(dep.pos, j + 1).flatMap((id) => (byId.has(id) ? [here(id)] : []));
return { dep, from: a, to: b, boardMs: dep.expected, alightMs: dep.arrivals[j]! + delayMs, stops: j - dep.pos, path };
};
// second legs: for a stop T, which (pattern, position) pairs get us to a stop near the destination?
const finish = new Map<string, { pi: number; pos: number; j: number; B: { s: Place; w: RouteResult } }[]>();
for (const [bid, B] of alighting) {
for (const [pi, jb] of ix.byStop.get(bid) ?? []) {
const pat = ix.data.patterns[pi]!;
for (let pos = 0; pos < jb; pos++) {
const t = pat.s[pos]!;
const l = finish.get(t) ?? finish.set(t, []).get(t)!;
l.push({ pi, pos, j: jb, B });
}
}
}
const nearT = new Map<string, Place[]>();
const transferStops = (s: Place) => nearT.get(s.id) ?? nearT.set(s.id, stops.filter((t) => finish.has(t.id) && haversine([s.lat, s.lon], [t.lat, t.lon]) <= TRANSFER_RADIUS_M)).get(s.id)!;
const score = (x: Journey) => x.arriveMs + (x.transfer ? TRANSFER_PENALTY_MIN * 60000 : 0);
const best = new Map<string, Journey>();
const consider = (j: Journey) => {
const key = j.rides.map((r) => `${r.dep.line}>${r.dep.headsign}`).join('+');
const cur = best.get(key);
if (!cur || score(j) < score(cur)) best.set(key, j);
};
for (const { s: A, w: walkTo } of boarding) {
const readyAt = now + (walkTo.durationMin + WALK_BUFFER_MIN) * 60000;
const deps = departuresFrom(ix, A.id, readyAt, 60, rt).filter((d) => !d.canceled);
for (const dep of deps) {
const pat = ix.data.patterns[dep.pattern]!;
const delayMs = (dep.delaySec ?? 0) * 1000;
// direct
for (let j = dep.pos + 1; j < pat.s.length; j++) {
const B = alighting.get(pat.s[j]!);
if (!B) continue;
const ride = rideOf(dep, dep.pattern, j);
if (!ride) break;
consider({ id: `${dep.tripId}:${A.id}:${B.s.id}`, rides: [ride], transfer: null, walkTo, walkFrom: B.w, startMs: now, arriveMs: ride.alightMs + B.w.durationMin * 60000, next: [], hard: 0, caution: 0 });
break;
}
// one transfer: get off at some later stop S, walk to a stop T of another line that reaches the destination
for (let k = dep.pos + 1; k < pat.s.length; k++) {
const S = byId.get(pat.s[k]!);
if (!S || alighting.has(S.id)) continue; // alighting here already covers it
const arrS = dep.arrivals[k]! + delayMs;
for (const T of transferStops(S)) {
const distM = Math.round(haversine([S.lat, S.lon], [T.lat, T.lon]) * 1.3);
const walkMin = distM / SLOW_WALK_MS / 60;
const readyT = arrS + (walkMin + TRANSFER_BUFFER_MIN) * 60000;
for (const opt of finish.get(T.id) ?? []) {
const p2 = ix.data.patterns[opt.pi]!;
if (p2.r === pat.r) continue; // the same line again (e.g. its other direction) is not a transfer
const dep2 = nextOnPattern(ix, rt, opt.pi, opt.pos, readyT);
if (!dep2) continue;
const ride1 = rideOf(dep, dep.pattern, k), ride2 = rideOf(dep2, opt.pi, opt.j);
if (!ride1 || !ride2) continue;
consider({
id: `${dep.tripId}:${dep2.tripId}`, rides: [ride1, ride2], walkTo, walkFrom: opt.B.w, startMs: now,
transfer: { from: S, to: T, distM, walkMin: Math.max(1, Math.round(walkMin)), waitMin: Math.max(0, Math.round((dep2.expected - arrS) / 60000 - walkMin)), walk: ESTIMATE },
arriveMs: ride2.alightMs + opt.B.w.durationMin * 60000, next: [], hard: 0, caution: 0,
});
}
}
}
}
}
// Re-walk every transfer with the router (profile-aware); it may take longer than the estimate, so the second ride is re-picked.
const refine = (j: Journey): Journey | null => {
const tr = j.transfer;
if (!tr) return j;
const walk = walkable([tr.from.lat, tr.from.lon], [tr.to.lat, tr.to.lon]);
if (!walk) return null; // cannot be walked with this profile
const [r1, r2] = j.rides as [Ride, Ride];
const walkMin = Math.max(1, Math.round(walk.durationMin));
const dep2 = nextOnPattern(ix, rt, r2.dep.pattern, r2.dep.pos, r1.alightMs + (walk.durationMin + TRANSFER_BUFFER_MIN) * 60000);
const ride2 = dep2 && rideOf(dep2, r2.dep.pattern, r2.dep.pos + r2.stops);
if (!dep2 || !ride2) return null;
return {
...j, id: `${r1.dep.tripId}:${dep2.tripId}`, rides: [r1, ride2],
transfer: { ...tr, distM: Math.round(walk.distanceM), walkMin, waitMin: Math.max(0, Math.round((ride2.boardMs - r1.alightMs) / 60000 - walk.durationMin)), walk },
arriveMs: ride2.alightMs + j.walkFrom.durationMin * 60000,
};
};
const journeys = [...best.values()].sort((a, b) => score(a) - score(b)).slice(0, (args.limit ?? 5) + 3)
.flatMap((j) => { const r = refine(j); return r ? [r] : []; })
.sort((a, b) => score(a) - score(b)).slice(0, args.limit ?? 5);
for (const j of journeys) {
const first = j.rides[0]!;
j.next = departuresFrom(ix, first.from.id, first.boardMs + 1000, 60, rt).filter((d) => d.line === first.dep.line && d.headsign === first.dep.headsign && !d.canceled).slice(0, 3);
const real = [j.walkTo, ...(j.transfer ? [j.transfer.walk] : []), j.walkFrom].flatMap((r) => r.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps'));
j.hard = real.filter((b) => HARD_FLAGS.has(b.flag)).length;
j.caution = real.length - j.hard;
}
return journeys;
}
/** Alerts that mention one of these lines (or have no route filter at all: general notices). */
export function alertsFor(rt: Realtime, lines: Set<string>): Alert[] {
return rt.alerts.filter((a) => (a.routeIds.length === 0 && a.stopIds.length === 0) || a.routeIds.some((r) => lines.has(r)));
}
export const minutesUntil = (ms: number, now: number) => Math.max(0, Math.round((ms - now) / 60000));

View file

@ -75,6 +75,8 @@ export interface Snapshot {
nodes: [number, number][];
ways: Way[];
kerbs: Record<string, string>;
/** Named green areas as closed outlines (older snapshots have none). */
parks?: { n: string; p: [number, number][] }[];
}
// ---------------- assessment ----------------

116
src/lib/useTransit.ts Normal file
View file

@ -0,0 +1,116 @@
import { useEffect, useMemo, useState } from 'react';
import type { LatLon } from './geo';
import type { RouteGraph } from './route';
import { fetchRealtime, loadTransitIndex, NO_REALTIME, onTransitProgress, planJourneys, type Journey, type Realtime, type TransitIndex } from './transit';
import type { Place, Profile } from './types';
/** Timetable index, built once on first use. */
export function useTransitData(enabled: boolean): { ix: TransitIndex | null; error: boolean } {
const [state, setState] = useState<{ ix: TransitIndex | null; error: boolean }>({ ix: null, error: false });
useEffect(() => {
if (!enabled || state.ix) return;
let alive = true;
loadTransitIndex()
.then((ix) => { if (alive) setState({ ix, error: false }); })
.catch(() => { if (alive) setState({ ix: null, error: true }); });
return () => { alive = false; };
}, [enabled, state.ix]);
return state;
}
let rtCache: { at: number; p: Promise<Realtime> } | null = null;
/** One shared real-time fetch for everything on screen (the feeds update about every 30 s). */
export function getRealtime(maxAgeMs = 15_000): Promise<Realtime> {
if (!rtCache || Date.now() - rtCache.at > maxAgeMs) rtCache = { at: Date.now(), p: fetchRealtime().catch(() => NO_REALTIME) };
return rtCache.p;
}
/** Real-time delays, refreshed while `enabled`. Falls back to NO_REALTIME (timetable only) when the feed is unreachable. */
export function useRealtime(enabled: boolean, everyMs = 30_000): Realtime {
const [rt, setRt] = useState<Realtime>(NO_REALTIME);
useEffect(() => {
if (!enabled) return;
let alive = true;
const load = () => { void getRealtime().then((r) => { if (alive) setRt(r); }); };
load();
const id = setInterval(load, everyMs);
return () => { alive = false; clearInterval(id); };
}, [enabled, everyMs]);
return rt;
}
/** Current time that ticks, so "in 4 min" labels stay true. */
export function useNow(everyMs = 15_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => { const id = setInterval(() => setNow(Date.now()), everyMs); return () => clearInterval(id); }, [everyMs]);
return now;
}
export type PlanStage = 'download' | 'index' | 'live' | 'plan';
export interface PlannerState {
status: 'idle' | 'loading' | 'ready' | 'error';
/** What the first plan is waiting for, and how far along it is (0-100): drives the loading bar. */
progress: { stage: PlanStage; pct: number };
journeys: Journey[];
rt: Realtime;
/** When the plan was computed. */
at: number;
selected: number;
select: (i: number) => void;
}
const paint = () => new Promise<void>((r) => setTimeout(r, 0));
/** Plans public-transport journeys for the current route ends and re-plans every 30 s with fresh delays. */
export function useJourneys(opts: {
enabled: boolean; ends: { from: LatLon; to: LatLon } | null; graph: RouteGraph | null; profile: Profile | null; stops: Place[];
}): PlannerState {
const { enabled, ends, graph, profile, stops } = opts;
const [plan, setPlan] = useState<{ journeys: Journey[]; rt: Realtime; at: number } | null>(null);
const [error, setError] = useState(false);
const [tick, setTick] = useState(0);
const [selId, setSelId] = useState<string | null>(null);
const [progress, setProgress] = useState<PlannerState['progress']>({ stage: 'download', pct: 0 });
useEffect(() => { setPlan(null); setSelId(null); }, [ends, profile]);
useEffect(() => {
if (!enabled || !ends) return;
const id = setInterval(() => setTick((t) => t + 1), 30_000);
return () => clearInterval(id);
}, [enabled, ends]);
// download (0-55 %) -> index (70 %) -> live delays (85 %) -> plan (100 %)
useEffect(() => {
if (!enabled || !ends || !graph || !profile) return;
let alive = true;
const off = onTransitProgress((f) => { if (alive) setProgress(f < 1 ? { stage: 'download', pct: Math.round(f * 55) } : { stage: 'index', pct: 62 }); });
void (async () => {
try {
setError(false);
setProgress((p) => (p.stage === 'download' ? p : { stage: 'download', pct: 0 }));
const ix: TransitIndex = await loadTransitIndex();
if (!alive) return;
setProgress({ stage: 'live', pct: 75 });
await paint();
const rt = await getRealtime();
if (!alive) return;
setProgress({ stage: 'plan', pct: 90 });
await paint();
const now = Date.now();
const journeys = planJourneys({ ix, rt, graph, profile, stops, from: ends.from, to: ends.to, now });
if (alive) { setPlan({ journeys, rt, at: now }); setProgress({ stage: 'plan', pct: 100 }); }
} catch {
if (alive) setError(true);
}
})();
return () => { alive = false; off(); };
}, [enabled, ends, graph, profile, stops, tick]);
const journeys = plan?.journeys ?? [];
const found = journeys.findIndex((j) => j.id === selId);
const selected = found >= 0 ? found : 0;
return useMemo<PlannerState>(() => ({
status: !enabled || !ends ? 'idle' : error ? 'error' : plan ? 'ready' : 'loading',
progress, journeys, rt: plan?.rt ?? NO_REALTIME, at: plan?.at ?? 0, selected,
select: (i: number) => setSelId(journeys[i]?.id ?? null),
}), [enabled, ends, error, plan, progress, journeys, selected]);
}

View file

@ -448,3 +448,119 @@ body { margin: 0; }
.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; }
/* ---- public transport ---- */
.kbb-line { display: inline-flex; align-items: center; justify-content: center; flex: none; min-width: 2.4rem; height: 1.6rem; padding: 0 0.45rem; border-radius: 0.3rem; color: #fff; font-weight: 700; font-size: 0.95rem; line-height: 1; }
.kbb-line--bus { background: #54278f; }
.kbb-line--tram { background: #a2461c; }
.kbb-delay { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 0.25rem; font-size: 0.8rem; font-weight: 700; white-space: nowrap; }
.kbb-delay--ok { background: #ecf3ec; color: #216e1f; }
.kbb-delay--late { background: #fff5c2; color: #5c4809; }
.kbb-delay--early { background: #e7f6f8; color: #1d5f6f; }
.kbb-delay--none { background: #f0f0f0; color: #454545; font-weight: 400; }
.kbb-deps { display: grid; gap: 0; }
.kbb-dep { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.1rem 0.75rem; padding: 0.7rem 0; border-top: 1px solid #e6e6e6; }
.kbb-dep:first-child { border-top: 0; }
.kbb-dep.is-canceled .kbb-dep__dir, .kbb-dep.is-canceled .kbb-dep__time { text-decoration: line-through; color: #71767a; }
.kbb-dep .kbb-line { min-width: 2.8rem; height: 2rem; font-size: 1.1rem; }
.kbb-dep__dir { font-size: 1.05rem; font-weight: 600; line-height: 1.25; overflow-wrap: anywhere; }
.kbb-dep__time { font-size: 1.15rem; font-weight: 700; white-space: nowrap; }
.kbb-dep__meta { grid-column: 2 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.6rem; font-size: 0.95rem; color: #454545; }
.kbb-dep__meta .kbb-delay { font-size: 0.85rem; }
/* one card per stop: same-named stops on opposite sides of the street must read as different places */
.kbb-stopcard { margin: 0 0 1.1rem; padding: 0.75rem 1rem 0.35rem; border: 1px solid #c9c9c9; border-left: 0.4rem solid #54278f; border-radius: 0.35rem; background: #fff; }
.kbb-stopcard--tram { border-left-color: #a2461c; }
.kbb-stopcard__head { display: flex; align-items: flex-start; gap: 0.6rem; padding-bottom: 0.4rem; border-bottom: 2px solid #dfe1e2; }
.kbb-stopcard__head .usa-icon { flex: none; margin-top: 0.15rem; fill: #3d4551; }
.kbb-stopcard__name { margin: 0; font-size: 1.15rem; line-height: 1.25; }
.kbb-stopcard__name .usa-button { font-size: inherit; font-weight: 700; text-align: left; }
.kbb-stopcard__where { margin: 0.1rem 0 0; font-size: 0.95rem; color: #454545; }
.kbb-jhead { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.5rem; }
.kbb-jhead__line { display: inline-flex; align-items: center; gap: 0.2rem; }
.kbb-jhead__time { font-weight: 700; }
.kbb-legs { margin: 0.5rem 0 0.5rem 0.5rem !important; padding-left: 0.75rem !important; border-left: 3px solid #dfe1e2; }
.kbb-leg { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.4rem 0; }
.kbb-leg__body { flex: 1; min-width: 0; }
.kbb-leg__icon { flex: none; fill: #565c65; margin-top: 0.1rem; }
.kbb-pin--stop { background: #54278f; color: #fff; border-color: #fff; }
.kbb-pin--stop .usa-icon { width: 1.1rem; height: 1.1rem; fill: currentColor; }
/* route form: walking / public transport toggle */
.kbb-modes { display: flex; gap: 0; margin: 0 0 0.5rem; border: 2px solid #005ea2; border-radius: 0.35rem; overflow: hidden; }
.kbb-mode { appearance: none; flex: 1 1 0; display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; min-height: 2.75rem; padding: 0.4rem 0.6rem; border: 0; background: #fff; color: #005ea2; font: inherit; font-weight: 700; cursor: pointer; }
.kbb-mode + .kbb-mode { border-left: 2px solid #005ea2; }
.kbb-mode .usa-icon { fill: currentColor; }
.kbb-mode.is-on { background: #005ea2; color: #fff; }
.kbb-mode:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
/* route form: A -> B with swap and locate buttons beside the fields */
.kbb-ft { max-width: none; }
.kbb-ft__row { display: flex; align-items: flex-end; gap: 0.6rem; margin-top: 0.5rem; }
.kbb-ft__row:first-of-type { margin-top: 0; }
.kbb-ft__dot { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 1.9rem; height: 1.9rem; margin-bottom: 0.45rem; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 0 1px #1b1b1b; color: #fff; font-weight: 700; font-size: 0.9rem; }
.kbb-ft__dot--a { background: #005ea2; }
.kbb-ft__dot--b { background: #1b1b1b; }
.kbb-ft__field { flex: 1; min-width: 0; }
.kbb-ft__field .usa-label { margin-top: 0; }
.kbb-ft__btn { margin-bottom: 0; }
.kbb-ft__btn:disabled { opacity: 0.5; cursor: not-allowed; }
.kbb-ft__btn .usa-icon + .usa-icon { margin-left: -0.35rem; }
.kbb-ft__go { width: 100%; margin: 1rem 0 0; }
.kbb-result .kbb-modes { margin: 0 0 1rem; }
.kbb-result { scroll-margin-top: 3.25rem; } /* keep the first row clear of the sticky sheet handle */
/* ---- location bubble (replaces the page-level error) ---- */
.kbb-ft__loc { position: relative; flex: none; }
.kbb-pop { position: absolute; z-index: 30; right: 0; bottom: calc(100% + 0.5rem); width: max-content; max-width: min(17rem, 72vw); padding: 0.55rem 0.75rem; border-radius: 0.35rem; background: #1b1b1b; color: #fff; font-size: 0.9rem; line-height: 1.35; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); animation: kbb-pop-in 0.22s ease-out both; }
.kbb-pop::after { content: ''; position: absolute; right: 0.95rem; top: 100%; border: 0.4rem solid transparent; border-top-color: #1b1b1b; }
.kbb-pop.is-leaving { animation: kbb-pop-out 0.5s ease-in both; }
@keyframes kbb-pop-in { from { opacity: 0; transform: translateY(0.4rem) scale(0.96); } to { opacity: 1; transform: none; } }
@keyframes kbb-pop-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-0.3rem); } }
@media (prefers-reduced-motion: reduce) { .kbb-pop, .kbb-pop.is-leaving { animation: none; } .kbb-pop.is-leaving { opacity: 0; } }
/* ---- "guide me" card ---- */
.kbb-guidecard { margin: 0 0 1.5rem; padding: 1rem; border: 1px solid #dfe1e2; border-radius: 0.35rem; background: #f7f9fa; }
.kbb-guidecard__go { width: 100%; margin: 0.25rem 0 0.6rem; }
.kbb-guidecard__alts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.5rem; margin-bottom: 0.75rem; }
.kbb-guidecard__alts .usa-button { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; width: 100%; margin: 0; padding-left: 0.5rem; padding-right: 0.5rem; text-align: center; line-height: 1.2; }
.kbb-guidecard__alts .usa-icon { flex: none; }
@media (max-width: 23em) { .kbb-guidecard__alts { grid-template-columns: 1fr; } }
.kbb-barrier { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; border: 1px solid #dfe1e2; border-radius: 0.35rem; background: #fff; }
.kbb-barrier > span { min-width: 0; }
.kbb-barrier .usa-button { flex: none; white-space: nowrap; margin: 0; }
.kbb-reach ol.usa-list { margin-left: 2.25rem; }
/* ---- place field with suggestions ---- */
.kbb-combo { position: relative; }
.kbb-combo__list { position: static; max-height: 15rem; overflow-y: auto; margin: 0.15rem 0 0 !important; padding: 0 !important; background: #fff; border: 1px solid #565c65; border-radius: 0.25rem; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); -webkit-overflow-scrolling: touch; }
.kbb-combo__opt { display: flex; align-items: center; gap: 0.6rem; min-height: 3rem; padding: 0.4rem 0.6rem; border-top: 1px solid #f0f0f0; cursor: pointer; }
.kbb-combo__opt:first-child { border-top: 0; }
.kbb-combo__opt:hover, .kbb-combo__opt.is-active { background: #e7f6f8; }
.kbb-combo__opt .kbb-cat { margin-right: 0; }
.kbb-combo__txt { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
.kbb-combo__txt > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- verdict box on the place page: one icon, big title beside it, the explanation below from the left edge ---- */
.kbb-verdict { padding: 1rem 1.25rem; border-left: 0.5rem solid; border-radius: 0 0.25rem 0.25rem 0; color: #1b1b1b; }
.kbb-verdict--match { background: #ecf3ec; border-color: #4d8055; }
.kbb-verdict--partial { background: #faf3d1; border-color: #ffbe2e; }
.kbb-verdict--conflict { background: #f7e9e1; border-color: #a2461c; }
.kbb-verdict--incomplete { background: #e7f6f8; border-color: #00bde3; }
.kbb-verdict--fail { background: #f4e3db; border-color: #d54309; }
.kbb-verdict__head { display: flex; align-items: center; gap: 0.75rem; }
.kbb-verdict__head .usa-icon { flex: none; fill: #1b1b1b; }
.kbb-verdict__title { margin: 0; font-size: 1.5rem; line-height: 1.2; font-weight: 700; color: #1b1b1b; }
.kbb-verdict__text { margin: 0.6rem 0 0; font-size: 1.05rem; line-height: 1.5; }
.kbb-extlink:hover, .kbb-extlink:active { color: #005ea2; }
.kbb-extlink--muted:hover { color: #005ea2; }
/* in-flow suggestion list: pushes the form down instead of covering the buttons below it */
.kbb-mode__tag { margin-left: 0.35rem; padding: 0.05rem 0.4rem; border-radius: 0.6rem; background: #ffbe2e; color: #1b1b1b; font-size: 0.7rem; line-height: 1.3; text-transform: uppercase; }
.kbb-loader { padding: 0.5rem 0 1rem; }
.kbb-progress { display: block; width: 100%; height: 0.9rem; appearance: none; -webkit-appearance: none; border: 0; border-radius: 0.45rem; background: #dfe1e2; overflow: hidden; }
.kbb-progress::-webkit-progress-bar { background: #dfe1e2; border-radius: 0.45rem; }
.kbb-progress::-webkit-progress-value { background: #005ea2; border-radius: 0.45rem; transition: width 0.25s ease; }
.kbb-progress::-moz-progress-bar { background: #005ea2; border-radius: 0.45rem; }
@media (prefers-reduced-motion: reduce) { .kbb-progress::-webkit-progress-value { transition: none; } }

View file

@ -2,19 +2,22 @@ import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import basicSsl from '@vitejs/plugin-basic-ssl';
import { APP_NAME } from './src/config';
export default defineConfig({
base: './',
plugins: [
react(),
// index.html uses %APP_NAME%: one name for the whole app (src/config.ts)
{ name: 'app-name', transformIndexHtml: (html: string) => html.replaceAll('%APP_NAME%', APP_NAME) },
// HTTPS=1 pnpm preview:https -> phones need a secure context for motion sensors and GPS
...(process.env.HTTPS ? [basicSsl()] : []),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['uswds/sprite.svg', 'icons/*.png', 'icons/*.svg'],
manifest: {
name: 'Kraków bez barier',
short_name: 'Bez barier',
name: APP_NAME,
short_name: APP_NAME,
description:
'Sprawdź dostępność miejsc i tras w Krakowie dla osób na wózkach i rodziców z wózkami dziecięcymi.',
lang: 'pl',
@ -56,6 +59,10 @@ export default defineConfig({
},
}),
],
// ZTP Kraków serves GTFS-Realtime without CORS headers: `rt/*` is proxied for `pnpm dev` and `pnpm preview`.
// A static production host needs the same rule (e.g. nginx `location /rt/ { proxy_pass https://gtfs.ztp.krakow.pl/; }`).
server: { proxy: { '/rt': { target: 'https://gtfs.ztp.krakow.pl', changeOrigin: true, rewrite: (p) => p.replace(/^\/rt/, '') } } },
preview: { proxy: { '/rt': { target: 'https://gtfs.ztp.krakow.pl', changeOrigin: true, rewrite: (p) => p.replace(/^\/rt/, '') } } },
css: {
preprocessorOptions: {
scss: {