Fix bus stops and rebrand (TBD).
This commit is contained in:
parent
8bdda8ae02
commit
7b32a47fc0
31 changed files with 2235 additions and 171 deletions
22
README.md
22
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
1
public/data/kk-transit.json
Normal file
1
public/data/kk-transit.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
135
scripts/build-transit.mjs
Normal 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 });
|
||||
|
|
@ -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))}`);
|
||||
|
|
|
|||
47
src/App.tsx
47
src/App.tsx
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
116
src/components/PlaceInput.tsx
Normal file
116
src/components/PlaceInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
274
src/components/TransitUi.tsx
Normal file
274
src/components/TransitUi.tsx
Normal 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
5
src/config.ts
Normal 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
170
src/lib/gtfsrt.ts
Normal 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;
|
||||
}
|
||||
165
src/lib/i18n.ts
165
src/lib/i18n.ts
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
50
src/lib/locate.test.ts
Normal 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
43
src/lib/locate.ts
Normal 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;
|
||||
}
|
||||
39
src/lib/placeNames.test.ts
Normal file
39
src/lib/placeNames.test.ts
Normal 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
61
src/lib/placeNames.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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
132
src/lib/transit.test.ts
Normal 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
406
src/lib/transit.ts
Normal 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));
|
||||
|
|
@ -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
116
src/lib/useTransit.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue