This commit is contained in:
InfoX 2026-10-03 14:29:28 +02:00
parent ccddc3683c
commit 96703b6b30
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
45 changed files with 4260 additions and 240 deletions

BIN
.DS_Store vendored

Binary file not shown.

110
README.md Normal file
View file

@ -0,0 +1,110 @@
# Kraków bez barier
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: Old Town, Kazimierz, Wawel.
## Run
```bash
pnpm install
pnpm dev # or: pnpm build && pnpm preview
pnpm test # unit tests (parsing, verdicts, routing, reach, moderation)
pnpm smoke # end-to-end flows against `pnpm preview` on :4173
pnpm a11y # axe-core WCAG 2.2 AA scan of every main view
pnpm data:build # re-fetch OSM + ZTP data into public/data/kk-snapshot.json
```
Demo sign-in has no passwords: pick **Anna** (wheelchair), **Piotr** (stroller) or **Maria** (city
official, unlocks the City panel). Everything is stored in the browser, per account.
## What it does
| For residents and visitors | For city staff (role "Pracownik urzędu") |
| --- | --- |
| Guided profile: persona, equipment presets, limits with plain-language hints and tooltips, live preview of how many places fit | Data-quality KPIs computed from open sources, no database kept by the city |
| Fit report per place (entrance, steps, ramp/lift, width, surface, toilet, benches, baby changing) | Gaps by place type; operator-feed gap for transit stops |
| Step-free routing that avoids steps, high kerbs, steep and narrow sections, optionally cobbles; text route and barrier list | "Fix first" ranking of crossings with high or unknown kerb height, weighted by places served |
| Barrier-free reach: what you can get to in 5/10/15 min | Report moderation queue: approve → "verified by the city office" |
| Printable access card per place | Anonymous demand signals: where people look but data is missing |
| Report a correction (kept apart from OSM data, marked unverified) | CSV / GeoJSON export of the computed data |
| Saved places, text size, high contrast, reduced motion, PL/EN | |
## Routes, guidance and evidence
- **Route options:** the shortest path comes first (difficulties counted, not avoided), then variants that trade extra
distance for fewer difficulties. Each shows distance, time, overhead vs shortest, and counts of barriers / cautions /
unknown points; the best one is flagged "Recommended" and others are drawn faintly on the map.
- **Guide me:** live GPS guidance with turn prompts and barrier warnings at the top (optional voice, screen kept awake,
off-route re-routing). "Simulate GPS" walks the route for demos. Or hand the route to Google Maps / Apple Maps: waypoints
along our route keep their path close to ours, but those apps do not know the user's limits.
- **Map layers:** street map or satellite (Esri World Imagery; check its terms before production use).
- **Report evidence:** photo (EXIF stripped, downscaled), slope measured with the phone's tilt sensors, kerb height measured on
the photo against a reference object (card / A4 / ruler), or typed in. **LiDAR is not reachable from a web app** (no
LiDAR or WebXR on iOS Safari); `Measurement.method = 'lidar'` is reserved for a native iOS companion.
- **Automatic first look:** on-device CLIP zero-shot (free, private, no API key) labels the photo; rules combine it with the
claim and measurement into validity, consistency, severity and a triage lane (fast-track / review / likely reject).
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.
## Data and honesty rules
- **OpenStreetMap** (ODbL) via Overpass: places, steps, ramps, kerbs, surfaces, benches, toilets.
- **ZTP Kraków GTFS** (`stops.txt`, `wheelchair_boarding`): 82 stops in the area. The operator's field says
"no information" for **all** of them; OSM contributors describe 44. The app shows both and the City panel
points at the gap. Licence of the ZTP feed must be confirmed at otwartedane.um.krakow.pl before reuse.
- Reliability levels: `verified` (survey date ≤ 24 months, or approved by the city office) · `mapped`
(OSM edit ≤ 24 months) · `stale` (older) · `inferred` (from nearby objects) · `user` (unverified report) ·
`none`. The date shown is the last OSM edit of the object unless a survey date exists, so we never call
edit-date data "confirmed".
- Absence of data → "No data". User report vs OSM disagreement → "Conflicting data" with both sides shown.
Refresh failure → last snapshot date plus a warning (demo switch in *Data → Demo controls*).
- Four fictional places and three sample reports are labelled **DEMO** and can be hidden in Settings.
## Architecture
```
scripts/build-data.mjs adapters: Overpass (OSM) + GTFS (ZTP) → public/data/kk-snapshot.json
src/lib/osm.ts tag interpretation (surface, incline, width, kerb)
src/lib/assess.ts fact model {status, source, confidence, date} + verdict per profile
src/lib/route.ts A* over the pedestrian graph, profile-aware penalties
src/lib/reach.ts time-limited Dijkstra for barrier-free reach
src/lib/civic.ts city-panel aggregates, hotspot ranking, export
src/components/* USWDS 3 UI; MapView = MapLibre GL (raster OSM tiles)
```
Acquisition and presentation are separate: a new source is a new adapter producing the same fact shape; a
new city is a bounding box in `build-data.mjs` plus its adapters. Configuration lives in code constants today;
moving it to a per-city JSON is the first scaling step.
## Business model and go-live (proposal)
- Free for residents. Revenue from B2B: venue accessibility widget/report for hotels, event organisers and
property managers; accessibility API for booking and travel apps; roll-out package for other cities.
- Owner of the product: the operating entity (not UMK). Costs: static hosting, scheduled Overpass/GTFS pulls,
report moderation.
- Month-one launch: host static build + nightly data job; real SSO for staff; server-side reports with
rate limiting and two-person confirmation before pushing to OSM; replace OSM tile server with a paid or
self-hosted tile provider; confirm every dataset licence.
## Privacy and security
No accounts on a server, no analytics, no health questions — only barrier preferences. Profile, saved places
and reports are in `localStorage`. Geolocation (optional) never leaves the browser. HTTPS only.
The demo sign-in is not security: it only selects a local profile and role.
## Accessibility status
Target WCAG 2.2 AA. Automated: axe-core finds 0 violations on login, places, place detail, route, profile,
City panel, data pages (`pnpm a11y`). Built in: full keyboard operation, focus management on view changes,
information never by colour alone, tooltips dismissible with Esc, text size and contrast settings, text
equivalents for map content. **Known limits:** choosing a place by clicking a map pin is not keyboard
accessible (use the list); no manual screen-reader pass on every platform yet; high-contrast mode is basic.
## Known limitations
Demo area only. Reports and moderation are local to one browser. Kerb height is known for only part of the
crossings and entrance width is almost never mapped. Walking times are estimates. Routing ignores traffic
lights and temporary obstacles. OSM tile servers are for demos only.

View file

@ -11,9 +11,16 @@
"test": "vitest run",
"data:build": "node scripts/build-data.mjs",
"predev": "node scripts/copy-worker.mjs",
"prebuild": "node scripts/copy-worker.mjs"
"prebuild": "node scripts/copy-worker.mjs",
"smoke": "node scripts/smoke.mjs",
"a11y": "node scripts/axe.mjs",
"icons": "node scripts/make-icons.mjs",
"preview:https": "HTTPS=1 vite preview --host --port 4443",
"dev:https": "HTTPS=1 vite --host --port 5443",
"sensors": "node scripts/sensors-e2e.mjs"
},
"dependencies": {
"@huggingface/transformers": "^4.3.0",
"@uswds/uswds": "^3.14.0",
"maplibre-gl": "^6.11.2",
"react": "^19.3.0",
@ -24,7 +31,9 @@
"@types/node": "^26.6.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.1.1",
"axe-core": "^4.13.0",
"jsdom": "^30.1.1",
"playwright-core": "^1.63.0",
"sass": "^1.105.1",

211
pnpm-lock.yaml generated
View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@huggingface/transformers':
specifier: ^4.3.0
version: 4.3.0(@types/node@26.6.4)
'@uswds/uswds':
specifier: ^3.14.0
version: 3.14.0
@ -33,9 +36,15 @@ importers:
'@types/react-dom':
specifier: ^19.3.0
version: 19.3.0(@types/react@19.3.0)
'@vitejs/plugin-basic-ssl':
specifier: ^2.3.0
version: 2.3.0(vite@8.3.2(@types/node@26.6.4)(sass@1.105.1)(terser@5.51.2))
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1(vite@8.3.2(@types/node@26.6.4)(sass@1.105.1)(terser@5.51.2))
axe-core:
specifier: ^4.13.0
version: 4.13.0
jsdom:
specifier: ^30.1.1
version: 30.1.1
@ -633,6 +642,16 @@ packages:
'@noble/hashes':
optional: true
'@huggingface/jinja@0.5.10':
resolution: {integrity: sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==}
engines: {node: '>=18'}
'@huggingface/tokenizers@0.2.0':
resolution: {integrity: sha512-LidMHe1FpcSYH4vcSjXooda34pC0M8m1gmDOv7SQi6Iv+ib1zX7J3sHBCC9/3FPCkn2k1b55FneVqMRxrr99pg==}
'@huggingface/transformers@4.3.0':
resolution: {integrity: sha512-fL1A/WUZwouPrOlYxU5dzIwD2T5J781JiB2jDR8bFe5DwCj0Gfudq+NEXCMno49kQgajHA7xQkrRLJlqG1veEA==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@ -945,6 +964,33 @@ packages:
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
engines: {node: '>= 10.0.0'}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.5':
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
'@protobufjs/eventemitter@1.1.1':
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
'@protobufjs/fetch@1.1.1':
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.2':
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
'@rolldown/binding-android-arm-eabi@1.2.12':
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1410,6 +1456,12 @@ packages:
resolution: {integrity: sha512-kwZ50TqGszxj3+MQhm+c84hI0y8rIKoc7ZLKbuHg+8rTkFisp9lXOMRD2wclyyuYZahpADxMvaPong7bwL7cew==}
engines: {node: '>=20'}
'@vitejs/plugin-basic-ssl@2.3.0':
resolution: {integrity: sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
peerDependencies:
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
'@vitejs/plugin-react@6.1.1':
resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1445,6 +1497,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adm-zip@0.6.1:
resolution: {integrity: sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==}
engines: {node: '>=14.0'}
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
@ -1486,6 +1542,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axe-core@4.13.0:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
babel-plugin-polyfill-corejs2@0.4.17:
resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==}
peerDependencies:
@ -1688,6 +1748,10 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@ -1727,6 +1791,9 @@ packages:
filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
flatbuffers@25.9.23:
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@ -1786,6 +1853,10 @@ packages:
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
global-agent@4.1.3:
resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==}
engines: {node: '>=10.0'}
globalthis@1.0.4:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
@ -1797,6 +1868,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
guid-typescript@1.0.9:
resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@ -2095,6 +2169,9 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
lru-cache@11.5.3:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
@ -2116,6 +2193,10 @@ packages:
resolution: {integrity: sha512-Xh06pxoipjX/Ad1sUPGhiNh9go9naCNGZZ1IJm3sIeqK1kYGuMaMDSTIGDQ2igOLfVyukJU7kvEOA089VO7Z7g==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
matcher@4.0.0:
resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@ -2172,6 +2253,19 @@ packages:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
onnxruntime-common@1.30.0:
resolution: {integrity: sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==}
onnxruntime-common@1.31.0-dev.20260911-2a43ec07e:
resolution: {integrity: sha512-gBuF6U32YErKIAt+yD7DeGBpjRxhJ1uJwako6ygjokSZULDU6Pd+KWComX8Tumk1hYVHRxjPj7mdMnD0ryAPOw==}
onnxruntime-node@1.30.0:
resolution: {integrity: sha512-twhs1C2C/BFkz1yc5OY0KIU2GUq6DURO7hD4bx5Q2Qy3nAMJwRXW8xU3NVczE29VA9lolLOYepoD8fjTGOfIqw==}
os: [win32, darwin, linux]
onnxruntime-web@1.31.0-dev.20260914-8d85527a0:
resolution: {integrity: sha512-Iy7rtadoBgxS/LLvDr3QW38DB1PNXRnr0GJMcL0TAt7c9qjgVQl83UlGCVyeAnK2InpmW8Uc3PL8XIuqtDeF6g==}
own-keys@1.0.2:
resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
engines: {node: '>= 0.4'}
@ -2204,6 +2298,9 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
platform@1.3.6:
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
@ -2232,6 +2329,10 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
protobufjs@7.6.6:
resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==}
engines: {node: '>=12.0.0'}
protocol-buffers-schema@3.6.1:
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
@ -2339,6 +2440,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
serialize-error@8.1.0:
resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==}
engines: {node: '>=10'}
serialize-javascript@7.1.2:
resolution: {integrity: sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==}
engines: {node: '>=20.0.0'}
@ -2496,6 +2601,10 @@ packages:
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
engines: {node: '>=10'}
type-fest@0.20.2:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@ -3484,6 +3593,20 @@ snapshots:
'@exodus/bytes@1.16.0': {}
'@huggingface/jinja@0.5.10': {}
'@huggingface/tokenizers@0.2.0': {}
'@huggingface/transformers@4.3.0(@types/node@26.6.4)':
dependencies:
'@huggingface/jinja': 0.5.10
'@huggingface/tokenizers': 0.2.0
onnxruntime-node: 1.30.0
onnxruntime-web: 1.31.0-dev.20260914-8d85527a0
sharp: 0.35.5(@types/node@26.6.4)
transitivePeerDependencies:
- '@types/node'
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
@ -3721,6 +3844,26 @@ snapshots:
'@parcel/watcher-win32-x64': 2.6.0
optional: true
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.5': {}
'@protobufjs/eventemitter@1.1.1': {}
'@protobufjs/fetch@1.1.1':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/float@1.0.2': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.2': {}
'@rolldown/binding-android-arm-eabi@1.2.12':
optional: true
@ -4007,6 +4150,10 @@ snapshots:
dependencies:
lit: 3.3.3
'@vitejs/plugin-basic-ssl@2.3.0(vite@8.3.2(@types/node@26.6.4)(sass@1.105.1)(terser@5.51.2))':
dependencies:
vite: 8.3.2(@types/node@26.6.4)(sass@1.105.1)(terser@5.51.2)
'@vitejs/plugin-react@6.1.1(vite@8.3.2(@types/node@26.6.4)(sass@1.105.1)(terser@5.51.2))':
dependencies:
'@rolldown/pluginutils': 1.0.1
@ -4025,6 +4172,8 @@ snapshots:
acorn@8.18.0: {}
adm-zip@0.6.1: {}
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
@ -4067,6 +4216,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
axe-core@4.13.0: {}
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7):
dependencies:
'@babel/compat-data': 7.29.7
@ -4328,6 +4479,8 @@ snapshots:
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
@ -4354,6 +4507,8 @@ snapshots:
dependencies:
minimatch: 5.1.9
flatbuffers@25.9.23: {}
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@ -4430,6 +4585,13 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 2.0.2
global-agent@4.1.3:
dependencies:
globalthis: 1.0.4
matcher: 4.0.0
semver: 7.8.5
serialize-error: 8.1.0
globalthis@1.0.4:
dependencies:
define-properties: 1.2.1
@ -4439,6 +4601,8 @@ snapshots:
graceful-fs@4.2.11: {}
guid-typescript@1.0.9: {}
has-bigints@1.1.0: {}
has-property-descriptors@1.0.2:
@ -4725,6 +4889,8 @@ snapshots:
lodash.debounce@4.0.8: {}
long@5.3.2: {}
lru-cache@11.5.3: {}
lru-cache@5.1.1:
@ -4762,6 +4928,10 @@ snapshots:
quickselect: 3.0.0
tinyqueue: 3.0.0
matcher@4.0.0:
dependencies:
escape-string-regexp: 4.0.0
math-intrinsics@1.1.0: {}
mdn-data@2.27.1: {}
@ -4804,6 +4974,25 @@ snapshots:
obug@2.2.1: {}
onnxruntime-common@1.30.0: {}
onnxruntime-common@1.31.0-dev.20260911-2a43ec07e: {}
onnxruntime-node@1.30.0:
dependencies:
adm-zip: 0.6.1
global-agent: 4.1.3
onnxruntime-common: 1.30.0
onnxruntime-web@1.31.0-dev.20260914-8d85527a0:
dependencies:
flatbuffers: 25.9.23
guid-typescript: 1.0.9
long: 5.3.2
onnxruntime-common: 1.31.0-dev.20260911-2a43ec07e
platform: 1.3.6
protobufjs: 7.6.6
own-keys@1.0.2:
dependencies:
call-bound: 1.0.4
@ -4834,6 +5023,8 @@ snapshots:
picomatch@4.0.7: {}
platform@1.3.6: {}
playwright-core@1.63.0: {}
possible-typed-array-names@1.1.0: {}
@ -4856,6 +5047,20 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
protobufjs@7.6.6:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.1
'@protobufjs/fetch': 1.1.1
'@protobufjs/float': 1.0.2
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.2
'@types/node': 26.6.4
long: 5.3.2
protocol-buffers-schema@3.6.1: {}
punycode@2.3.1: {}
@ -5017,6 +5222,10 @@ snapshots:
semver@7.8.5: {}
serialize-error@8.1.0:
dependencies:
type-fest: 0.20.2
serialize-javascript@7.1.2: {}
set-function-length@1.2.2:
@ -5226,6 +5435,8 @@ snapshots:
type-fest@0.16.0: {}
type-fest@0.20.2: {}
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4

View file

@ -1,4 +1,6 @@
allowBuilds:
"@parcel/watcher": false
esbuild: true
onnxruntime-node: false
protobufjs: false
sharp: true

37
scripts/axe.mjs Normal file
View file

@ -0,0 +1,37 @@
// Automated a11y scan (axe-core, WCAG 2.2 AA rules) of the main views. Run against `pnpm preview`.
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright-core';
const axeSrc = await readFile('node_modules/axe-core/axe.min.js', 'utf8');
const URL = process.env.URL ?? 'http://localhost:4173/';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'pl-PL' });
let bad = 0;
async function scan(name) {
await page.addScriptTag({ content: axeSrc });
const r = await page.evaluate(() => axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa', 'best-practice'] } }));
console.log(`${name}: ${r.violations.length} violation(s)`);
for (const v of r.violations) { bad++; console.log(` [${v.impact}] ${v.id}: ${v.help} — ${v.nodes.length} node(s), e.g. ${v.nodes[0].target.join(' ')}`); }
}
await page.goto(URL, { waitUntil: 'networkidle' });
await scan('login');
await page.click('text=Zaloguj jako Maria Wiśniewska');
await page.waitForSelector('.kbb-kpis');
await scan('dashboard');
await page.click('nav >> text=Miejsca');
await page.waitForSelector('#q');
await scan('places');
await page.fill('#q', 'Sukiennice');
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.waitForSelector('text=Opis trasy tekstem');
await scan('route');
await page.click('nav >> text=Profil');
await page.waitForSelector('#st1');
await scan('profile + settings');
await page.click('nav >> text=Dane');
await scan('about');
await browser.close();
process.exitCode = bad ? 1 : 0;

47
scripts/sensors-e2e.mjs Normal file
View file

@ -0,0 +1,47 @@
// Feeds synthetic tilt events (tan 5° ≈ 8.7 %) through both sensor paths. Run against `pnpm preview`.
import { chromium } from 'playwright-core';
const URL = process.env.URL ?? 'http://localhost:4173/';
const browser = await chromium.launch();
let bad = 0;
const check = (c, m) => { console.log(`${c ? 'ok ' : 'FAIL'} ${m}`); if (!c) bad = 1; };
async function run(kind) {
const page = await browser.newPage({ viewport: { width: 1000, height: 900 }, locale: 'pl-PL' });
await page.goto(URL, { waitUntil: 'networkidle' });
await page.click('text=Zaloguj jako Anna Kowalska');
await page.fill('#q', 'Pracownia Przykładowa');
await page.click('button[id^=place-]');
await page.click('text=Popraw lub uzupełnij >> nth=0');
await page.selectOption('#rep-attr', 'ramp');
await page.click('text=Zmierz telefonem');
await page.click('text=Włącz czujniki');
for (let i = 0; i < 5; i++) {
await page.evaluate((k) => {
if (k === 'orientation') window.dispatchEvent(new DeviceOrientationEvent('deviceorientation', { beta: 5, gamma: 0 }));
else window.dispatchEvent(new DeviceMotionEvent('devicemotion', { accelerationIncludingGravity: { x: 0, y: 0.854, z: 9.77 } }));
}, kind);
await page.waitForTimeout(100);
}
const txt = await page.locator('[aria-labelledby=inc-title] p.text-bold').innerText();
check(/^8\.[67] %/.test(txt), `${kind}: reads ${txt} (expected ≈ 8.7 %)`);
await page.click('text=Wyzeruj tutaj');
await page.evaluate(() => window.dispatchEvent(new DeviceOrientationEvent('deviceorientation', { beta: 5, gamma: 0 })));
await page.close();
}
await run('orientation');
await run('motion');
// no events at all -> explicit message with diagnostics, not a silent failure
const page = await browser.newPage({ locale: 'pl-PL' });
await page.goto(URL, { waitUntil: 'networkidle' });
await page.click('text=Zaloguj jako Anna Kowalska');
await page.fill('#q', 'Pracownia Przykładowa');
await page.click('button[id^=place-]');
await page.click('text=Popraw lub uzupełnij >> nth=0');
await page.selectOption('#rep-attr', 'ramp');
await page.click('text=Zmierz telefonem');
await page.click('text=Włącz czujniki');
await page.waitForSelector('text=Diagnostyka', { timeout: 6000 });
check(true, 'silent device -> message + diagnostics: ' + (await page.locator('text=Diagnostyka').innerText()));
await browser.close();
process.exitCode = bad;

57
scripts/smoke.mjs Normal file
View file

@ -0,0 +1,57 @@
// End-to-end smoke test against `pnpm preview` (http://localhost:4173). Run: node scripts/smoke.mjs
import { chromium } from 'playwright-core';
const URL = process.env.URL ?? 'http://localhost:4173/';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'pl-PL' });
const errors = [];
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') errors.push(`console: ${m.text()}`); });
let step = 0;
const ok = (cond, msg) => { step++; if (!cond) { console.error(`FAIL ${step}: ${msg}`); process.exitCode = 1; } else console.log(`ok ${step}: ${msg}`); };
await page.goto(URL, { waitUntil: 'networkidle' });
ok(await page.locator('text=Zaloguj się').first().isVisible(), 'login screen shown');
// resident flow: report -> appears in own place detail as unverified
await page.click('text=Zaloguj jako Anna Kowalska');
await page.waitForSelector('#q');
ok((await page.locator('text=Wyniki:').first().innerText()).includes('27'), 'places list loaded (~2.7k)');
await page.fill('#q', 'Pracownia Przykładowa');
await page.click('button[id^=place-]');
await page.waitForSelector('text=Szczegóły dostępności');
await page.click('text=Popraw lub uzupełnij >> nth=0');
await page.selectOption('#rep-attr', 'entrance');
await page.click('label[for=rep-v-no]');
await page.click('text=Zapisz zgłoszenie');
ok(await page.locator('text=Zgłoszenie zapisane').isVisible(), 'report saved and flagged unverified');
// settings persist per account
await page.click('nav >> text=Profil');
await page.selectOption('#set-size', '125');
await page.reload({ waitUntil: 'networkidle' });
ok((await page.evaluate(() => document.documentElement.style.fontSize)) === '125%', 'text size persisted across reload');
// official flow: queue shows pending reports, accept one -> place becomes verified
await page.click('nav >> text=Profil');
await page.click('text=Wyloguj');
await page.click('text=Zaloguj jako Maria Wiśniewska');
await page.waitForSelector('.kbb-kpis');
ok(await page.locator('text=0 / 82').first().isVisible(), 'dashboard shows operator data gap (0 / 82 stops)');
const pending = await page.locator('button:has-text("Zatwierdź")').count();
ok(pending >= 3, `moderation queue has ${pending} pending reports`);
await page.locator('button:has-text("Zatwierdź")').first().click();
ok((await page.locator('button:has-text("Zatwierdź")').count()) === pending - 1, 'accepting removes it from the queue');
// 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.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');
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))}`);
await browser.close();

30
scripts/vet-e2e.mjs Normal file
View file

@ -0,0 +1,30 @@
// E2E for photo evidence + on-device AI triage. Needs `pnpm preview` and sample photos in scripts/.cache/img.
import { chromium } from 'playwright-core';
const URL = process.env.URL ?? 'http://localhost:4173/';
const IMG = process.env.IMG ?? 'scripts/.cache/img/staircase_entrance_building_steps.jpg';
const attr = process.env.ATTR ?? 'steps';
const claim = process.env.CLAIM ?? 'yes';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'pl-PL' });
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(URL, { waitUntil: 'networkidle' });
await page.click('text=Zaloguj jako Anna Kowalska');
await page.fill('#q', 'Pracownia Przykładowa');
await page.click('button[id^=place-]');
await page.waitForSelector('text=Szczegóły dostępności');
await page.click('text=Popraw lub uzupełnij >> nth=0');
await page.selectOption('#rep-attr', attr);
await page.click(`label[for=rep-v-${claim}]`);
await page.setInputFiles('#rep-gal', IMG);
await page.waitForSelector('img.kbb-thumb');
if (attr === 'steps') { await page.fill('#rep-manual', '14'); await page.click('text=Użyj wartości'); }
await page.click('text=Zapisz zgłoszenie');
console.log('submitted; waiting for model (first run downloads ~100 MB)…');
const t0 = Date.now();
await page.waitForSelector('text=/Szybka ścieżka|Do przeglądu|Prawdopodobnie do odrzucenia|Brak kredytów/', { timeout: 280000 });
console.log(`vetting finished in ${Math.round((Date.now() - t0) / 1000)}s`);
console.log((await page.locator('li:has(img.kbb-thumb)').first().innerText()).replace(/\n+/g, ' | '));
await page.screenshot({ path: process.env.OUT ?? 'scripts/.cache/vet.png' });
console.log('errors:', errors.slice(0, 3));
await browser.close();

28
scripts/voice-e2e.mjs Normal file
View file

@ -0,0 +1,28 @@
// Runs a simulated walk with voice on, with a stubbed speechSynthesis that records every utterance.
import { chromium } from 'playwright-core';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 850 }, locale: 'pl-PL' });
await page.addInitScript(() => {
window.__said = [];
let busy = false;
Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: {
speak(u) { window.__said.push({ t: Date.now(), text: u.text, overlapped: busy }); busy = true; setTimeout(() => { busy = false; u.onend && u.onend(); }, 1800); },
cancel() { busy = false; },
} });
});
await page.goto(process.env.URL ?? 'http://localhost:4173/', { waitUntil: 'networkidle' });
await page.click('text=Zaloguj jako Anna Kowalska');
await page.click('nav >> text=Trasa');
await page.click(process.env.PRESET ?? 'text=Sukiennice → Zamek Królewski na Wawelu');
await page.waitForSelector('text=Warianty trasy');
await page.click('label[for=variant-1]');
await page.click('button.usa-button:has-text("Prowadź")');
await page.selectOption('#guide-voice', 'all');
await page.click('button:has-text("Symulacja GPS")');
const secs = Number(process.env.SECS ?? 100);
await page.waitForTimeout(secs * 1000);
const said = await page.evaluate(() => window.__said);
const t0 = said[0]?.t ?? 0;
console.log(`${said.length} utterances in ${secs}s`);
for (const s of said) console.log(` +${Math.round((s.t - t0) / 1000)}s ${s.overlapped ? '[OVERLAP] ' : ''}${s.text}`);
await browser.close();

View file

@ -1,44 +1,94 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 { PlaceDetail, type RefreshState } from './components/PlaceDetail';
import { PlaceDetail, type ReachState, type RefreshState } from './components/PlaceDetail';
import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './components/PlacesView';
import { ProfileView } from './components/ProfileView';
import { RouteView, type RouteForm } from './components/RouteView';
import { SettingsPanel } from './components/SettingsPanel';
import { Alert, Icon } from './components/ui';
import { assessPlace, buildIndex } from './lib/assess';
import { findAccount, userKey, type Account } from './lib/auth';
import { computeHotspots, computeStats, download, toCsv, toGeoJson, type Hotspot } from './lib/civic';
import { DEMO_PLACES, DEMO_REPORTS } from './lib/demo';
import type { LatLon } from './lib/geo';
import { I18nContext, formatDate, makeT, type I18n } from './lib/i18n';
import { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n';
import { buildLabels } from './lib/labels';
import { refreshPlace } from './lib/live';
import { isProfile } from './lib/profile';
import { buildGraph, type RouteResult } from './lib/route';
import { DEFAULT_PROFILE, isProfile } from './lib/profile';
import { computeReach, placesInReach, type Reach } from './lib/reach';
import { buildGraph, type RouteOption } from './lib/route';
import { deletePhoto, putPhoto, type ProcessedPhoto } from './lib/photo';
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 { clearLocal, newId, useLocalState, useOnline } from './lib/store';
import type { Lang, Place, Profile, Snapshot, UserReport } from './lib/types';
import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types';
type View = 'places' | 'route' | 'dashboard' | 'about' | 'profile';
type Decision = { status: 'accepted' | 'rejected'; reviewedAt: string; reviewedBy: string };
type View = 'places' | 'route' | 'about' | 'profile';
const isLang = (v: unknown): v is Lang => v === 'pl' || v === 'en';
const detectLang = (): Lang => (navigator.language.toLowerCase().startsWith('pl') ? 'pl' : 'en');
const KEYS = ['kbb.profile', 'kbb.reports'];
const isStrOrNull = (v: unknown): v is string | null => v === null || typeof v === 'string';
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);
/** Verified against the snapshot: see README "Demo script". */
const PRESETS: { id: string; fromId: string; toId: string }[] = [];
/** 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() {
const [lang, setLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
const [profile, setProfile] = useLocalState<Profile | null>('kbb.profile', null, (v): v is Profile | null => v === null || isProfile(v));
// ---- identity (demo) ----
const [loginLang, setLoginLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
const [session, setSession] = useLocalState<string | null>('kbb.session', null, isStrOrNull);
const account: Account | null = findAccount(session);
const uid = account?.id ?? 'none';
const [settings, setSettings] = useLocalState<Settings>(userKey(uid, 'settings'), defaultSettings(loginLang), isSettings);
const [profile, setProfile] = useLocalState<Profile | null>(userKey(uid, 'profile'), null, (v): v is Profile | null => v === null || isProfile(v));
const [favorites, setFavorites] = useLocalState<string[]>(userKey(uid, 'favorites'), [], isStrArray);
const [userReports, setUserReports] = useLocalState<UserReport[]>('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v));
const [view, setView] = useState<View>(profile ? 'places' : 'profile');
const [decisions, setDecisions] = useLocalState<Record<string, Decision>>('kbb.decisions', {}, isObj<Record<string, Decision>>);
const [demand, setDemand] = useLocalState<Record<string, number>>('kbb.demand', {}, isObj<Record<string, number>>);
const lang: Lang = account ? settings.lang : loginLang;
const [view, setView] = useState<View>('places');
const [pane, setPane] = useState<'list' | 'map'>('list');
const online = useOnline();
const i18n = useMemo<I18n>(() => {
const { t, tm } = makeT(lang);
return { lang, setLang, t, tm, date: (iso) => formatDate(iso, lang) ?? t('fact.dateUnknown') };
}, [lang, setLang]);
return { lang, setLang: (l) => (account ? setSettings((s) => ({ ...s, lang: l })) : setLoginLang(l)), t, tm, date: (iso) => formatDate(iso, lang) ?? t('fact.dateUnknown') };
}, [lang, account, setSettings, setLoginLang]);
const { t } = i18n;
useEffect(() => { document.documentElement.lang = lang; document.title = t('app.name'); }, [lang, t]);
useEffect(() => { document.title = t('app.name'); }, [t]);
useEffect(() => {
if (account) { applySettings(settings); setLoginLang(settings.lang); }
else { document.documentElement.lang = loginLang; document.documentElement.style.fontSize = ''; document.documentElement.dataset.contrast = 'default'; }
}, [account, settings, loginLang, setLoginLang]);
// first login: seed the profile from the account's persona
useEffect(() => {
if (!account || profile) return;
const kind = account.preset ?? (account.role === 'official' ? 'wheelchair' : null);
if (kind) setProfile(DEFAULT_PROFILE[kind]);
}, [account, profile, setProfile]);
const signIn = (a: Account) => {
setSession(a.id);
setView(a.role === 'official' ? 'dashboard' : 'places');
setPane('list');
};
const signOut = () => { setSession(null); setView('places'); setSelectedId(null); };
// ---- data ----
const [snap, setSnap] = useState<Snapshot | null>(null);
@ -55,37 +105,67 @@ export default function App() {
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
const [overrides, setOverrides] = useState<Record<string, Place>>({});
const places = useMemo<Place[]>(
() => (snap ? [...DEMO_PLACES, ...snap.places.map((p) => overrides[p.id] ?? p)] : []),
[snap, overrides],
const realPlaces = useMemo<Place[]>(() => (snap ? snap.places.map((p) => overrides[p.id] ?? p) : []), [snap, overrides]);
const places = useMemo<Place[]>(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]);
const reports = useMemo<UserReport[]>(
() => [...DEMO_REPORTS, ...userReports].map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)),
[userReports, decisions],
);
const reports = useMemo(() => [...DEMO_REPORTS, ...userReports], [userReports]);
const assessments = useMemo(() => {
const m = new Map<string, ReturnType<typeof assessPlace>>();
const m = new Map<string, Assessment>();
if (index && profile) for (const p of places) m.set(p.id, assessPlace(p, profile, index, reports));
return m;
}, [places, profile, index, reports]);
const labels = useMemo(() => buildLabels(places), [places]);
const placeById = useMemo(() => new Map([...DEMO_PLACES, ...realPlaces].map((p) => [p.id, p])), [realPlaces]);
// ---- places view state ----
const previewCounts = useCallback((p: Profile): Record<Verdict, number> => {
const c: Record<Verdict, number> = { match: 0, partial: 0, conflict: 0, incomplete: 0, fail: 0 };
if (index) for (const pl of realPlaces) c[assessPlace(pl, p, index, reports).verdict]++;
return c;
}, [index, realPlaces, reports]);
// ---- places view ----
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const results = useMemo(() => filterPlaces(places, assessments, filters), [places, assessments, filters]);
const favSet = useMemo(() => new Set(favorites), [favorites]);
const results = useMemo(() => filterPlaces(places, assessments, filters, favSet), [places, assessments, filters, favSet]);
const mapPlaces = useMemo(
() => results.flatMap((place) => { const a = assessments.get(place.id); return a ? [{ place, verdict: a.verdict }] : []; }),
[results, assessments],
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = selectedId ? places.find((p) => p.id === selectedId) ?? null : null;
const selected = selectedId ? placeById.get(selectedId) ?? null : null;
const [refresh, setRefresh] = useState<RefreshState>({ kind: 'idle' });
const [simulateOutage, setSimulateOutage] = useState(false);
const detailHeading = useRef<HTMLHeadingElement>(null);
// ---- reach ----
const [reachState, setReachState] = useState<ReachState>({ minutes: null, summary: null });
const [reachLines, setReachLines] = useState<[LatLon, LatLon][] | null>(null);
const snapCache = useRef(new Map<string, number | null>());
const clearReach = useCallback(() => { setReachState({ minutes: null, summary: null }); setReachLines(null); }, []);
const doReach = (place: Place, minutes: number) => {
if (!graph || !profile) return;
const r: Reach | null = computeReach(graph, [place.lat, place.lon], profile, minutes);
if (!r) { setReachState({ minutes, summary: 'stuck' }); setReachLines(null); return; }
const inReach = placesInReach(graph, r, realPlaces.filter((p) => p.id !== place.id), snapCache.current);
const fit = inReach.filter((p) => { const v = assessments.get(p.id)?.verdict; return v === 'match' || v === 'partial'; }).length;
setReachState({ minutes, summary: { n: inReach.length, fit } });
setReachLines(r.lines);
setPane('map');
};
const select = useCallback((id: string | null) => {
setSelectedId(id);
setRefresh({ kind: 'idle' });
if (id) setPane('list');
}, []);
clearReach();
if (id) {
setPane('list');
if (!id.startsWith('demo-')) setDemand((d) => ({ ...d, [id]: (d[id] ?? 0) + 1 }));
}
}, [clearReach, setDemand]);
useEffect(() => { if (selectedId) detailHeading.current?.focus(); }, [selectedId]);
useEffect(() => { clearReach(); }, [profile, clearReach]);
const back = () => {
const id = selectedId;
@ -98,35 +178,113 @@ export default function App() {
try {
if (simulateOutage) throw new Error('simulated outage');
const fresh = await refreshPlace(p.id);
setOverrides((o) => ({ ...o, [p.id]: fresh }));
setOverrides((o) => ({ ...o, [p.id]: { ...(p.ext ? { ext: p.ext } : {}), ...fresh } }));
setRefresh({ kind: 'ok', at: new Date().toISOString() });
} catch {
setRefresh({ kind: 'fail' });
}
};
const addReport = (r: Omit<UserReport, 'id' | 'createdAt'>) =>
setUserReports((cur) => [...cur, { ...r, id: newId(), createdAt: new Date().toISOString() }]);
const patchReport = (id: string, patch: Partial<UserReport>) =>
setUserReports((cur) => cur.map((r) => (r.id === id ? { ...r, ...patch } : r)));
// a vetting that was running when the tab closed will never finish
useEffect(() => {
setUserReports((cur) => (cur.some((r) => r.vetting?.state === 'running') ? cur.map((r) => (r.vetting?.state === 'running' ? { ...r, vetting: { state: 'failed', reason: 'error' } } : r)) : cur));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addReport = async (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => {
const id = newId();
const rep: UserReport = {
...r, id, createdAt: new Date().toISOString(), authorId: account?.id ?? 'guest',
...(photo ? { photoId: id } : {}), vetting: { state: 'running' },
};
if (photo) await putPhoto(id, photo.dataUrl);
setUserReports((cur) => [...cur, rep]);
try {
const result = await vetReport(rep, photo ? { dataUrl: photo.dataUrl, blur: photo.blur } : null);
patchReport(id, { vetting: { state: 'done', result } });
} catch (e) {
patchReport(id, { vetting: { state: 'failed', reason: e instanceof NoCreditsError ? 'no-credits' : 'error' } });
}
};
const [cardId, setCardId] = useState<string | null>(null);
// ---- route state ----
// ---- route ----
const [routeForm, setRouteForm] = useState<RouteForm>({ fromText: '', toText: '', fromPoint: null });
const [route, setRoute] = useState<RouteResult | null>(null);
const [routeOptions, setRouteOptions] = useState<RouteOption[] | null>(null);
const [routeSel, setRouteSel] = useState(0);
const [guiding, setGuiding] = useState(false);
const [userPos, setUserPos] = useState<LatLon | null>(null);
const [basemap, setBasemap] = useLocalState<'map' | 'satellite'>('kbb.basemap', 'map', (v): v is 'map' | 'satellite' => v === 'map' || v === 'satellite');
const route = routeOptions?.[routeSel]?.result ?? null;
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 onResult = (r: RouteResult | null, from: LatLon | null, to: LatLon | null) => {
setRoute(r);
const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => {
setRouteOptions(o);
setRouteSel(0);
setRouteEnds(from && to ? { from, to } : null);
if (r && r.status !== 'none') setPane('map');
if (o?.[0] && o[0].result.status !== 'none') setPane('map');
};
useEffect(() => { setRouteOptions(null); setRouteEnds(null); setGuiding(false); }, [profile]);
useEffect(() => { if (view !== 'route' || !route || route.status === 'none') setGuiding(false); }, [view, route]);
const reroute = (from: LatLon) => {
if (!graph || !profile || !routeEnds) return;
const o = findAlternatives(graph, from, routeEnds.to, profile);
if (!o.length || o[0]!.result.status === 'none') return;
setRouteOptions(o);
setRouteSel(Math.max(0, o.findIndex((x) => x.recommended)));
setRouteEnds({ from, to: routeEnds.to });
};
useEffect(() => { setRoute(null); setRouteEnds(null); }, [profile]);
const routeTo = (p: Place) => {
setRouteForm((f) => ({ ...f, toText: labels.labelOf.get(p.id) ?? p.name ?? '' }));
setRoute(null);
setRouteOptions(null);
setRouteEnds(null);
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]);
const [hotMode, setHotMode] = useState(false);
const [hotFocus, setHotFocus] = useState<Hotspot | null>(null);
const [exportMsg, setExportMsg] = useState<string | null>(null);
const demandList = useMemo(
() => Object.entries(demand)
.flatMap(([id, views]) => { const p = placeById.get(id); return p && assessments.get(id)?.verdict === 'incomplete' ? [{ place: p, views }] : []; })
.sort((a, b) => b.views - a.views).slice(0, 5),
[demand, placeById, assessments],
);
const decide = (id: string, status: 'accepted' | 'rejected') =>
setDecisions((d) => ({ ...d, [id]: { status, reviewedAt: new Date().toISOString(), reviewedBy: account ? t(`auth.${account.id}` as Key) : '' } }));
const doExport = (kind: 'csv' | 'geojson') => {
const stamp = new Date().toISOString().slice(0, 10);
const name = `krakow-bez-barier-${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 }));
};
// ---- settings export / import ----
const [settingsMsg, setSettingsMsg] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
const exportSettings = () => download('kbb-settings.json', 'application/json', JSON.stringify({ app: 'kbb', v: 1, profile, settings, favorites }, null, 2));
const importSettings = async (file: File) => {
try {
const j: unknown = JSON.parse(await file.text());
if (!isObj<Record<string, unknown>>(j) || j.app !== 'kbb' || !isSettings(j.settings) || !(j.profile === null || isProfile(j.profile)) || !isStrArray(j.favorites)) throw new Error('shape');
setSettings(j.settings);
setProfile(j.profile);
setFavorites(j.favorites);
setSettingsMsg({ kind: 'success', text: t('set.import.ok') });
} catch {
setSettingsMsg({ kind: 'error', text: t('set.import.bad') });
}
};
// ---- shell ----
const mainRef = useRef<HTMLElement>(null);
@ -138,22 +296,32 @@ export default function App() {
const [resetDone, setResetDone] = useState(false);
const reset = () => {
clearLocal(KEYS);
setProfile(null);
setUserReports([]);
const keys = Object.keys(localStorage).filter((k) => k.startsWith('kbb.') && k !== 'kbb.lang');
clearLocal(keys);
setResetDone(true);
setView('profile');
window.setTimeout(() => window.location.reload(), 600);
};
const official = account?.role === 'official';
const nav: { id: View; label: string }[] = [
{ id: 'places', label: t('nav.places') },
{ id: 'route', label: t('nav.route') },
...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard') }] : []),
{ id: 'about', label: t('nav.about') },
{ id: 'profile', label: t('nav.profile') },
];
const needsProfile = !profile;
const split = (view === 'places' || view === 'route') && !needsProfile;
const needsProfile = !!account && !profile;
const split = !!account && !needsProfile && (view === 'places' || view === 'route');
const ready = snap && index && graph && profile;
const card = cardId ? placeById.get(cardId) : null;
if (!account) {
return (
<I18nContext.Provider value={i18n}>
<div className="kbb-app"><main id="main" className="kbb-main"><LoginView onPick={signIn} lang={loginLang} onLang={setLoginLang} /></main></div>
</I18nContext.Provider>
);
}
return (
<I18nContext.Provider value={i18n}>
@ -165,45 +333,51 @@ export default function App() {
<div className="usa-alert__body">
<p className="usa-alert__text">
<strong>{t('banner.proto')}</strong>{' '}
{snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}
<span className="kbb-banner-detail">{snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}</span>
</p>
</div>
</div>
</section>
<header className="bg-white border-bottom-1px border-base-lighter">
<div className="grid-container padding-y-1 display-flex flex-justify flex-align-center flex-wrap">
<p className="margin-0 display-flex flex-align-center text-bold font-sans-lg text-primary-darker">
<div className="kbb-head">
<p className="margin-0 display-flex flex-align-center text-bold text-primary-darker kbb-head__brand">
<Icon name="accessible_forward" size={4} className="margin-right-1" />
{t('app.name')}
</p>
<div role="group" aria-label={t('lang.label')} className="display-flex">
{(['pl', 'en'] as const).map((l) => (
<button
key={l} type="button" lang={l} aria-pressed={lang === l}
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}
onClick={() => setLang(l)}
>
{l.toUpperCase()}
</button>
))}
<nav aria-label={t('nav.label')} className="kbb-head__nav">
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
{nav.map((n) => (
<li className="usa-button-group__item" key={n.id}>
<button
type="button" aria-current={view === n.id ? 'page' : undefined}
className={`usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
onClick={() => setView(n.id)}
>
{n.label}
</button>
</li>
))}
</ul>
</nav>
<div className="kbb-head__tools">
<span className="font-sans-xs text-base-dark kbb-head__user">
<Icon name="accessibility_new" size={3} className="margin-right-05" />
{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}
</span>
<div role="group" aria-label={t('lang.label')} className="display-flex">
{(['pl', 'en'] as const).map((l) => (
<button
key={l} type="button" lang={l} aria-pressed={lang === l}
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}
onClick={() => i18n.setLang(l)}
>
{l.toUpperCase()}
</button>
))}
</div>
</div>
</div>
<nav aria-label={t('nav.label')} className="grid-container padding-bottom-1">
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
{nav.map((n) => (
<li className="usa-button-group__item" key={n.id}>
<button
type="button" aria-current={view === n.id ? 'page' : undefined}
className={`usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
onClick={() => setView(n.id)}
>
{n.label}
</button>
</li>
))}
</ul>
</nav>
</header>
{split && (
@ -223,44 +397,88 @@ export default function App() {
</div>
)}
{guiding && view === 'route' && route && route.status !== 'none' && (
<GuideBanner result={route} onStop={() => setGuiding(false)} onPos={setUserPos} onReroute={reroute} />
)}
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${pane}` : ''}`}>
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
{!loadError && !ready && view !== 'profile' && !needsProfile && (
{!loadError && !ready && !needsProfile && view !== 'profile' && (
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
)}
{(view === 'profile' || needsProfile) && (
<ProfileView
key={profile ? 'edit' : 'new'}
initial={profile}
onSave={(p) => { setProfile(p); setView('places'); setResetDone(false); }}
/>
key={account.id + String(!!profile)}
account={account}
profile={profile}
onChange={setProfile}
onDone={() => setView('places')}
onSignOut={signOut}
preview={previewCounts}
>
{!needsProfile && (
<SettingsPanel
settings={settings} onSettings={setSettings} favoritesCount={favorites.length}
onExport={exportSettings} onImport={(f) => void importSettings(f)} message={settingsMsg}
/>
)}
</ProfileView>
)}
{view === 'about' && !needsProfile && snap && (
<AboutView
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts}
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts} cityData={snap.meta.cityData}
simulateOutage={simulateOutage} onSimulate={setSimulateOutage} onReset={reset} resetDone={resetDone}
/>
)}
{view === 'dashboard' && !needsProfile && (
official ? (
ready && (
<DashboardView
stats={stats} hotspots={hotspots} reports={reports} placeById={placeById} demand={demandList}
officialName={t(`auth.${account.id}` as Key)}
onDecide={decide} onExport={doExport} exportMsg={exportMsg}
onShowHotspot={(h) => { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }}
/>
)
) : (
<div className="grid-container padding-y-4 kbb-narrow"><Alert kind="warning" heading={t('dash.title')}>{t('dash.role')}</Alert></div>
)
)}
{split && ready && (
<>
<div className="kbb-panel">
{view === 'places' && hotMode && (
<div className="margin-bottom-2">
<Alert kind="info" slim>
{t('dash.hot.mapBanner', { n: hotspots.length })}{' '}
<button type="button" className="usa-button usa-button--unstyled" onClick={() => { setHotMode(false); setHotFocus(null); }}>{t('dash.hot.clear')}</button>
</Alert>
</div>
)}
{view === 'places' && (selected ? (
<PlaceDetail
ref={detailHeading}
place={selected}
assessment={assessments.get(selected.id) ?? assessPlace(selected, profile, index)}
assessment={assessments.get(selected.id) ?? assessPlace(selected, profile, index, reports)}
reports={reports}
snapshotDate={snap.meta.fetchedAt.slice(0, 10)}
refresh={refresh}
saved={favSet.has(selected.id)}
onToggleSave={() => setFavorites((f) => (f.includes(selected.id) ? f.filter((x) => x !== selected.id) : [...f, selected.id]))}
onCard={() => setCardId(selected.id)}
reach={reachState}
onReach={(m) => doReach(selected, m)}
onClearReach={clearReach}
onBack={back}
onRouteTo={() => routeTo(selected)}
onShowOnMap={() => setPane('map')}
onRefresh={() => void doRefresh(selected)}
onAddReport={addReport}
onRemoveReport={(id) => setUserReports((cur) => cur.filter((r) => r.id !== id))}
onAddReport={(r, photo) => void addReport(r, photo)}
onRemoveReport={(id) => { void deletePhoto(id); setUserReports((cur) => cur.filter((r) => r.id !== id)); }}
/>
) : (
<PlacesView
@ -271,8 +489,8 @@ export default function App() {
{view === 'route' && (
<RouteView
graph={graph} labels={labels} profile={profile} bbox={snap.meta.bbox}
form={routeForm} onForm={setRouteForm} result={route} onResult={onResult}
presets={PRESETS} onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
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'); }}
/>
)}
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
@ -285,15 +503,34 @@ export default function App() {
onSelect={select}
route={view === 'route' ? route : null}
routeEnds={view === 'route' ? routeEnds : null}
altRoutes={view === 'route' ? altRoutes : []}
focus={focus}
visible={pane === 'map'}
bbox={snap.meta.bbox}
online={online}
basemap={basemap}
onBasemap={setBasemap}
userPos={view === 'route' && guiding ? userPos : null}
follow={guiding}
reach={view === 'places' ? reachLines : null}
hotspots={view === 'places' && hotMode ? hotspots : []}
hotspotFocus={hotFocus}
reduceMotion={settings.reduceMotion}
/>
</>
)}
</main>
{card && profile && (
<AccessCard
place={card}
assessment={assessments.get(card.id) ?? assessPlace(card, profile, index!, reports)}
profile={profile}
onClose={() => setCardId(null)}
/>
)}
</div>
</I18nContext.Provider>
);
}

View file

@ -6,11 +6,11 @@ import { Alert, ConfidenceTag } from './ui';
function AccordionSection({ id, title, children, open, toggle }: { id: string; title: string; children: ReactNode; open: string; toggle: (id: string) => void }) {
return (
<>
<h3 className="usa-accordion__heading">
<h2 className="usa-accordion__heading">
<button type="button" className="usa-accordion__button" aria-expanded={open === id} aria-controls={`acc-${id}`} onClick={() => toggle(id)}>
{title}
</button>
</h3>
</h2>
<div id={`acc-${id}`} className="usa-accordion__content usa-prose" hidden={open !== id}>{children}</div>
</>
);
@ -21,13 +21,14 @@ const CONFS: Confidence[] = ['verified', 'mapped', 'stale', 'inferred', 'user',
interface Props {
snapshotDate: string;
counts: Record<string, number>;
cityData: { name: string; url: string; feedDates: { mode: string; version: string | null }[]; license: string };
simulateOutage: boolean;
onSimulate: (v: boolean) => void;
onReset: () => void;
resetDone: boolean;
}
export function AboutView({ snapshotDate, counts, simulateOutage, onSimulate, onReset, resetDone }: Props) {
export function AboutView({ snapshotDate, counts, cityData, simulateOutage, onSimulate, onReset, resetDone }: Props) {
const { t, date } = useI18n();
const [open, setOpen] = useState<string>('data');
const toggle = (id: string) => setOpen((cur) => (cur === id ? '' : id));
@ -45,7 +46,11 @@ export function AboutView({ snapshotDate, counts, simulateOutage, onSimulate, on
<p>{t('about.data.body1')}</p>
<p>{t('about.data.body2')}</p>
<p>{t('about.data.body3')}</p>
<h4>{t('about.legend')}</h4>
<p className="font-sans-xs">
<strong>{cityData.name}</strong> — <a href={cityData.url} target="_blank" rel="noreferrer">{cityData.url}</a>.{' '}
{cityData.feedDates.map((f) => `${f.mode}: ${f.version ?? '?'}`).join(', ')}. {cityData.license}.
</p>
<h3>{t('about.legend')}</h3>
<ul className="usa-list--unstyled">
{CONFS.map((c) => (
<li key={c} className="margin-bottom-1">

View file

@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react';
import { useI18n } from '../lib/i18n';
import type { Assessment, Place, Profile } from '../lib/types';
import { Icon, SourceLine, StatusLabel, VerdictTag } from './ui';
interface Props { place: Place; assessment: Assessment; profile: Profile; onClose: () => void }
/** Printable one-page summary: for a venue's website, a carer, or a council officer. */
export function AccessCard({ place, assessment, profile, onClose }: Props) {
const { t, tm, date } = useI18n();
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => {
heading.current?.focus();
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const address = [place.tags['addr:street'], place.tags['addr:housenumber']].filter(Boolean).join(' ');
return (
<div className="kbb-card" role="dialog" aria-modal="true" aria-labelledby="card-title">
<div className="kbb-card__sheet">
<div className="kbb-card__bar">
<button type="button" className="usa-button" onClick={() => window.print()}>
<Icon name="list" size={3} className="margin-right-05" />{t('card.print')}
</button>
<button type="button" className="usa-button usa-button--outline" onClick={onClose}>
<Icon name="close" size={3} className="margin-right-05" />{t('card.close')}
</button>
</div>
<p className="margin-0 text-bold text-primary-darker">{t('card.title')} · {t('app.name')}</p>
<h2 id="card-title" ref={heading} tabIndex={-1} className="margin-top-05 margin-bottom-0">{place.name}</h2>
{address && <p className="margin-top-05 text-base-dark">{address}</p>}
<p className="margin-y-1"><VerdictTag verdict={assessment.verdict} /> <span className="font-sans-xs">{t('card.for', { profile: t(`profile.${profile.kind}`) })}</span></p>
<p>{t(`verdict.${assessment.verdict}.long`)}</p>
<ul className="usa-list--unstyled margin-0 padding-0">
{assessment.facts.map((f) => (
<li key={f.key} className="border-top-1px border-base-lighter padding-y-1">
<div className="display-flex flex-justify flex-wrap">
<strong>{t(`fact.title.${f.key}`)}</strong>
<StatusLabel status={f.status} />
</div>
<p className="margin-y-05">{tm(f.headline)}</p>
<SourceLine source={f.source} confidence={f.confidence} date={f.date} />
{f.alt && <p className="font-sans-xs margin-y-05"><strong>{t('fact.otherSource')}:</strong> {tm(f.alt.headline)}</p>}
</li>
))}
</ul>
<p className="font-sans-3xs text-base-dark margin-top-2">{t('card.legend')}</p>
<p className="font-sans-3xs text-base-dark">{t('card.generated', { date: date(new Date().toISOString().slice(0, 10)) })} {t('footer.attr')}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,228 @@
import { useState } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import { usePhoto } from '../lib/usePhoto';
import type { Hotspot, Stats } from '../lib/civic';
import type { Place, UserReport } from '../lib/types';
import { Alert, Icon, VettingSummary } from './ui';
interface Props {
stats: Stats;
hotspots: Hotspot[];
reports: UserReport[];
placeById: Map<string, Place>;
demand: { place: Place; views: number }[];
officialName: string;
onDecide: (id: string, status: 'accepted' | 'rejected') => void;
onShowHotspot: (h: Hotspot) => void;
onExport: (kind: 'csv' | 'geojson') => void;
exportMsg: string | null;
}
const pct = (n: number, d: number) => (d ? Math.round((n / d) * 100) : 0);
const TRIAGE_RANK = { 'fast-track': 0, review: 1, 'likely-reject': 2 } as const;
const SEVERITY_RANK = { critical: 0, major: 1, minor: 2, none: 3 } as const;
/** Lower = look at it first. Reports without a finished automatic assessment sit in the middle. */
function priority(r: UserReport): [number, number] {
const v = r.vetting?.state === 'done' ? r.vetting.result : null;
return v ? [TRIAGE_RANK[v.triage], SEVERITY_RANK[v.severity]] : [1, 3];
}
function QueueThumb({ id }: { id: string | undefined }) {
const { t } = useI18n();
const src = usePhoto(id);
return src ? <img className="kbb-thumb margin-right-2" src={src} alt={t('rep.photo.alt')} /> : null;
}
export function DashboardView({ stats, hotspots, reports, placeById, demand, officialName, onDecide, onShowHotspot, onExport, exportMsg }: Props) {
const { t, date } = useI18n();
const [sort, setSort] = useState<'ai' | 'new'>('ai');
const pending = reports
.filter((r) => !r.status || r.status === 'pending')
.sort((a, b) => {
if (sort === 'new') return b.createdAt.localeCompare(a.createdAt);
const [ta, sa] = priority(a), [tb, sb] = priority(b);
return ta - tb || sa - sb || b.createdAt.localeCompare(a.createdAt);
});
const decided = reports.filter((r) => r.status === 'accepted' || r.status === 'rejected');
const kpis: { label: string; value: string; sub?: string }[] = [
{ label: t('dash.kpi.places'), value: String(stats.total) },
{ label: t('dash.kpi.entrance'), value: `${pct(stats.entranceKnown, stats.total)}%`, sub: `${stats.entranceKnown} / ${stats.total}` },
{ label: t('dash.kpi.fresh'), value: `${pct(stats.fresh, stats.total)}%`, sub: `${stats.fresh} / ${stats.total}` },
{ label: t('dash.kpi.verified'), value: `${stats.verified}`, sub: `${pct(stats.verified, stats.total)}%` },
{ label: t('dash.kpi.stops'), value: `${stats.transitOperatorKnown} / ${stats.transitTotal}` },
{ label: t('dash.kpi.stopsOsm'), value: `${stats.transitCommunityKnown} / ${stats.transitTotal}` },
{ label: t('dash.kpi.pending'), value: String(pending.length) },
{ label: t('dash.kpi.agreement'), value: agreementValue(reports, t('dash.kpi.agreement.none')), sub: agreementSub(reports, (n) => t('dash.kpi.agreement.n', { n })) },
];
return (
<div className="grid-container padding-y-4 kbb-wide">
<h1 className="margin-top-0">{t('dash.title')}</h1>
<p className="usa-intro">{t('dash.intro')}</p>
<ul className="kbb-kpis usa-list--unstyled margin-0 padding-0">
{kpis.map((k) => (
<li key={k.label} className="kbb-kpi">
<span className="kbb-kpi__v">{k.value}</span>
<span className="kbb-kpi__l">{k.label}</span>
{k.sub && <span className="kbb-kpi__s">{k.sub}</span>}
</li>
))}
</ul>
<Alert kind="info">
{t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })}
</Alert>
<section aria-labelledby="gaps" className="margin-top-4">
<h2 id="gaps">{t('dash.gaps')}</h2>
<div className="usa-table-container--scrollable" tabIndex={0} role="region" aria-label={t('dash.gaps.caption')}>
<table className="usa-table usa-table--borderless width-full">
<caption className="usa-sr-only">{t('dash.gaps.caption')}</caption>
<thead>
<tr>
<th scope="col">{t('dash.gaps.cat')}</th>
<th scope="col" data-sortable>{t('dash.gaps.total')}</th>
<th scope="col">{t('dash.gaps.known')}</th>
<th scope="col">{t('dash.gaps.stale')}</th>
<th scope="col">{t('dash.gaps.unknown')}</th>
<th scope="col"><span className="usa-sr-only">{t('dash.gaps.caption')}</span></th>
</tr>
</thead>
<tbody>
{stats.byCat.map((r) => (
<tr key={r.cat}>
<th scope="row">{t(`cat.${r.cat}` as Key)}</th>
<td>{r.total}</td>
<td>{r.known} ({pct(r.known, r.total)}%)</td>
<td>{r.stale} ({pct(r.stale, r.total)}%)</td>
<td>{r.unknown} ({pct(r.unknown, r.total)}%)</td>
<td aria-hidden="true" className="kbb-bar">
<span className="kbb-bar__k" style={{ width: `${pct(r.known, r.total)}%` }} />
<span className="kbb-bar__s" style={{ width: `${pct(r.stale, r.total)}%` }} />
<span className="kbb-bar__u" style={{ width: `${pct(r.unknown, r.total)}%` }} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section aria-labelledby="hot" className="margin-top-4">
<h2 id="hot">{t('dash.hot')}</h2>
<p className="text-base-dark">{t('dash.hot.intro')}</p>
{hotspots.length === 0 ? <p>{t('dash.hot.none')}</p> : (
<ol className="usa-list--unstyled margin-0 padding-0">
{hotspots.map((h, i) => (
<li key={h.id} className="border-1px border-base-lighter radius-md padding-2 margin-bottom-1 display-flex flex-justify flex-align-center flex-wrap">
<span>
<span className="kbb-rank" aria-label={`${t('dash.hot.rank')} ${i + 1}`}>{i + 1}</span>
<strong>{h.street ?? '—'}</strong>
<span className={`usa-tag margin-left-1 ${h.kerb === 'raised' ? 'bg-error-dark text-white' : 'bg-base-lighter text-ink'}`}>
{t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')}
</span>
<span className="display-block font-sans-xs text-base-dark margin-top-05 margin-left-4">{t('dash.hot.near', { n: h.near })}{h.more > 0 ? ` · ${t('dash.hot.more', { n: h.more })}` : ''}</span>
</span>
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={() => onShowHotspot(h)}>
<Icon name="map" size={3} className="margin-right-05" />{t('dash.hot.show')}
</button>
</li>
))}
</ol>
)}
</section>
<section aria-labelledby="queue" className="margin-top-4">
<h2 id="queue">{t('dash.queue')}</h2>
<p className="text-base-dark">{t('dash.queue.intro')}</p>
{pending.length > 1 && (
<div className="margin-bottom-2">
<label className="usa-label margin-top-0" htmlFor="queue-sort">{t('dash.queue.sort')}</label>
<select id="queue-sort" className="usa-select" value={sort} onChange={(e) => setSort(e.target.value as 'ai' | 'new')}>
<option value="ai">{t('dash.queue.sort.ai')}</option>
<option value="new">{t('dash.queue.sort.new')}</option>
</select>
</div>
)}
{pending.length === 0 ? <p>{t('dash.queue.empty')}</p> : (
<ul className="usa-list--unstyled margin-0 padding-0">
{pending.map((r) => {
const p = placeById.get(r.placeId);
return (
<li key={r.id} className="border-1px border-base-lighter radius-md padding-2 margin-bottom-1 display-flex">
<QueueThumb id={r.photoId} />
<div className="flex-1">
<p className="margin-0">
<strong>{t('dash.queue.place')}:</strong> {p?.name ?? r.placeId}{' '}
{r.demo && <span className="usa-tag bg-ink text-white">{t('detail.reports.demo')}</span>}
</p>
<p className="margin-y-05">
{t(`rep.attr.${r.attr}` as Key)}: <strong>{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)}</strong>
<span className="text-base-dark"> · {date(r.createdAt.slice(0, 10))}</span>
</p>
{r.measure && <p className="font-sans-xs margin-y-05">{t('dash.queue.measure')}: <strong>{r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'}</strong> ({t(`vet.method.${r.measure.method}` as Key)})</p>}
{r.note && <p className="font-sans-xs text-base-dark margin-y-05">{r.note}</p>}
<VettingSummary v={r.vetting} detailed />
<button type="button" className="usa-button usa-button--small" onClick={() => onDecide(r.id, 'accepted')} aria-label={`${t('dash.queue.accept')}: ${p?.name ?? ''}`}>{t('dash.queue.accept')}</button>
<button type="button" className="usa-button usa-button--secondary usa-button--small" onClick={() => onDecide(r.id, 'rejected')} aria-label={`${t('dash.queue.reject')}: ${p?.name ?? ''}`}>{t('dash.queue.reject')}</button>
</div>
</li>
);
})}
</ul>
)}
{decided.length > 0 && (
<details className="margin-top-1">
<summary>{t('dash.queue.decided', { n: decided.length })}</summary>
<ul className="usa-list">
{decided.map((r) => (
<li key={r.id}>
{placeById.get(r.placeId)?.name ?? r.placeId} — {t(`rep.attr.${r.attr}` as Key)} — {t(`dash.queue.status.${r.status === 'accepted' ? 'accepted' : 'rejected'}` as Key)}
{r.reviewedBy ? ` (${r.reviewedBy})` : ''}
</li>
))}
</ul>
</details>
)}
<p className="font-sans-3xs text-base-dark">{officialName}</p>
</section>
<section aria-labelledby="demand" className="margin-top-4">
<h2 id="demand">{t('dash.demand')}</h2>
<p className="text-base-dark">{t('dash.demand.intro')}</p>
{demand.length === 0 ? <p>{t('dash.demand.none')}</p> : (
<ul className="usa-list">
{demand.map(({ place, views }) => (
<li key={place.id}><strong>{place.name}</strong> — {t('dash.demand.views', { n: views })}</li>
))}
</ul>
)}
</section>
<section aria-labelledby="export" className="margin-top-4">
<h2 id="export">{t('dash.export')}</h2>
<p className="text-base-dark">{t('dash.export.intro')}</p>
<button type="button" className="usa-button" onClick={() => onExport('csv')}>{t('dash.export.csv')}</button>
<button type="button" className="usa-button usa-button--outline" onClick={() => onExport('geojson')}>{t('dash.export.geojson')}</button>
<div aria-live="polite" className="margin-top-1">{exportMsg && <Alert kind="success" slim>{exportMsg}</Alert>}</div>
</section>
</div>
);
}
/** Share of decided reports where the automatic triage pointed the same way as the official (fast-track→accepted, likely-reject→rejected). */
function agreement(reports: UserReport[]): { agree: number; total: number } {
let agree = 0, total = 0;
for (const r of reports) {
if ((r.status !== 'accepted' && r.status !== 'rejected') || r.vetting?.state !== 'done') continue;
const triage = r.vetting.result.triage;
if (triage === 'review') continue;
total++;
if ((triage === 'fast-track' && r.status === 'accepted') || (triage === 'likely-reject' && r.status === 'rejected')) agree++;
}
return { agree, total };
}
const agreementValue = (reports: UserReport[], none: string) => { const a = agreement(reports); return a.total ? `${Math.round((a.agree / a.total) * 100)}%` : none; };
const agreementSub = (reports: UserReport[], fmt: (n: number) => string) => { const a = agreement(reports); return a.total ? fmt(a.total) : undefined; };

View file

@ -0,0 +1,149 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { LatLon } from '../lib/geo';
import { barrierPositions, buildPath, guideState, maneuvers, pointAt, type GuideState } from '../lib/guide';
import { CueTracker, VoicePlayer, type Utt } from '../lib/voice';
import { useI18n, type Key } from '../lib/i18n';
import { HARD_FLAGS, type RouteResult } from '../lib/route';
import { Icon } from './ui';
interface Props {
result: RouteResult;
onStop: () => void;
onPos: (p: LatLon | null) => void;
onReroute: (from: LatLon) => void;
}
const SIM_STEP_M = 6;
/** Live guidance: watches GPS (or a simulated walk), shows the next turn and upcoming barriers on top. */
export function GuideBanner({ result, onStop, onPos, onReroute }: Props) {
const { t, lang } = useI18n();
const path = useMemo(() => buildPath(result.coords), [result]);
const mans = useMemo(() => maneuvers(result, path), [result, path]);
const barriers = useMemo(() => barrierPositions(path, result.barriers, HARD_FLAGS), [path, result]);
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 simAlong = useRef(0);
// ---- position source ----
useEffect(() => {
if (sim) {
setErr(null);
simAlong.current = 0;
setPos(pointAt(path, 0));
const id = window.setInterval(() => {
simAlong.current = Math.min(path.total, simAlong.current + SIM_STEP_M);
setPos(pointAt(path, simAlong.current));
}, 1000);
return () => window.clearInterval(id);
}
if (!('geolocation' in navigator)) { setErr('none'); return; }
setErr(null);
const id = navigator.geolocation.watchPosition(
(p) => { setErr(null); setPos([p.coords.latitude, p.coords.longitude]); },
(e) => setErr(e.code === 1 ? 'denied' : 'none'),
{ enableHighAccuracy: true, maximumAge: 1000, timeout: 15_000 },
);
return () => navigator.geolocation.clearWatch(id);
}, [sim, path]);
useEffect(() => { onPos(pos); }, [pos, onPos]);
useEffect(() => () => onPos(null), [onPos]);
// ---- keep the screen on while guiding ----
useEffect(() => {
let lock: { release: () => Promise<void> } | null = null;
const wl = (navigator as unknown as { wakeLock?: { request: (t: 'screen') => Promise<{ release: () => Promise<void> }> } }).wakeLock;
void wl?.request('screen').then((l) => { lock = l; }).catch(() => undefined);
return () => { void lock?.release().catch(() => undefined); };
}, []);
const st = pos ? guideState(path, mans, barriers, pos) : null;
const round5 = (d: number) => Math.max(5, Math.round(d / 5) * 5);
let headline = t('guide.waiting');
if (st) {
if (st.arrived) headline = t('guide.arrived');
else if (st.offRoute) headline = t('guide.off');
else if (st.next) {
const m = st.next.maneuver;
headline = t('guide.next', { d: round5(st.next.distance), action: t(`turn.${m.turn}` as Key), name: m.name ? ` — ${m.name}` : '' });
} else headline = t('guide.straight', { d: round5(st.remaining) });
}
const barrierLine = st && !st.arrived && !st.offRoute && st.barrier
? t('guide.barrier', { d: round5(st.barrier.distance), what: t(`flag.${st.barrier.b.flag}` as Key) })
: null;
// ---- speech: events only (turn within 25 m, barrier within 60 m, off-route, arrival), never overlapping ----
const latest = useRef<GuideState | null>(null);
latest.current = st;
const player = useRef<VoicePlayer | null>(null);
const tracker = useRef<CueTracker | null>(null);
useEffect(() => {
if (voice === 'off' || !('speechSynthesis' in window)) { player.current?.stop(); player.current = null; tracker.current = null; return; }
const synth = window.speechSynthesis;
const make = (text: string): Utt => {
const u = new SpeechSynthesisUtterance(text);
u.lang = lang === 'pl' ? 'pl-PL' : 'en-GB';
return u as unknown as Utt;
};
player.current = new VoicePlayer({ speak: (u) => synth.speak(u as unknown as SpeechSynthesisUtterance), cancel: () => synth.cancel() }, make);
tracker.current = new CueTracker(
{
turn: (d, turn) => t('voice.turn', { d, turn }),
barrier: (d, what) => t('voice.barrier', { d, what }),
off: t('voice.off'),
arrived: t('voice.arrived'),
},
{ turn: (x) => t(`turn.${x}` as Key).toLocaleLowerCase(lang), flag: (f) => t(`flag.${f}` as Key).toLocaleLowerCase(lang) },
(at) => (latest.current?.along ?? 0) < at - 3,
);
player.current.say({ id: 'on', kind: 'info', text: t(voice === 'all' ? 'voice.onAll' : 'voice.onWarn'), priority: 1 });
return () => { player.current?.stop(); player.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [voice, lang]);
useEffect(() => {
if (!st || !player.current || !tracker.current) return;
for (const cue of tracker.current.update(st)) {
if (voice === 'warn' && cue.kind === 'turn') continue; // warnings-only mode: no turn prompts
player.current.say(cue);
}
}, [st, voice]);
const etaMin = st ? Math.max(1, Math.round(result.durationMin * (st.remaining / Math.max(1, path.total)))) : null;
return (
<section className="kbb-guide" aria-label={t('guide.active')}>
<div className="kbb-guide__main">
<p className="kbb-guide__headline" role="status" aria-live="polite">
<Icon name={st?.arrived ? 'check_circle' : st?.offRoute ? 'error' : 'directions_walk'} size={4} className="margin-right-1" />
{headline}
</p>
{barrierLine && <p className="kbb-guide__barrier" role="alert"><Icon name="warning" size={3} className="margin-right-05" />{barrierLine}</p>}
{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>}
{sim && <p className="kbb-guide__meta">{t('guide.simOn')}</p>}
</div>
<div className="kbb-guide__actions">
{st?.offRoute && pos && (
<button type="button" className="usa-button usa-button--accent-cool margin-0" onClick={() => onReroute(pos)}>{t('guide.reroute')}</button>
)}
<div className="kbb-guide__voice">
<label className="usa-sr-only" htmlFor="guide-voice">{t('guide.voice')}</label>
<select id="guide-voice" className="usa-select kbb-guide__select" value={voice} onChange={(e) => setVoice(e.target.value as 'off' | 'all' | 'warn')}>
<option value="off">{t('guide.voice.off')}</option>
<option value="all">{t('guide.voice.all')}</option>
<option value="warn">{t('guide.voice.warn')}</option>
</select>
</div>
<button type="button" className="usa-button usa-button--outline usa-button--inverse margin-0" aria-pressed={sim} onClick={() => setSim((v) => !v)}>{t('guide.demo')}</button>
<button type="button" className="usa-button usa-button--secondary margin-0" onClick={onStop}>{t('guide.stop')}</button>
</div>
</section>
);
}

View file

@ -0,0 +1,54 @@
import { ACCOUNTS, GUEST, type Account } from '../lib/auth';
import { useI18n, type Key } from '../lib/i18n';
import type { Lang } from '../lib/types';
import { Alert, Icon } from './ui';
export function LoginView({ onPick, lang, onLang }: { onPick: (a: Account) => void; lang: Lang; onLang: (l: Lang) => void }) {
const { t } = useI18n();
return (
<div className="grid-container padding-y-4 kbb-narrow">
<div className="display-flex flex-justify flex-align-center flex-wrap">
<p className="margin-0 display-flex flex-align-center text-bold font-sans-lg text-primary-darker">
<Icon name="accessible_forward" size={4} className="margin-right-1" />{t('app.name')}
</p>
<div role="group" aria-label={t('lang.label')} className="display-flex">
{(['pl', 'en'] as const).map((l) => (
<button key={l} type="button" lang={l} aria-pressed={lang === l} onClick={() => onLang(l)}
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}>{l.toUpperCase()}</button>
))}
</div>
</div>
<h1 className="margin-top-4">{t('auth.title')}</h1>
<p className="usa-intro">{t('app.tagline')}</p>
<Alert kind="info" slim>{t('auth.intro')}</Alert>
<ul className="usa-card-group margin-top-3 padding-0">
{ACCOUNTS.map((a) => (
<li key={a.id} className="usa-card width-full margin-bottom-2">
<div className="usa-card__container margin-0">
<div className="usa-card__header">
<h2 className="usa-card__heading font-sans-md">
{t(`auth.${a.id}` as Key)}
<span className="usa-tag bg-ink text-white margin-left-1">{t('auth.demoBadge')}</span>
</h2>
</div>
<div className="usa-card__body">
<p className="margin-top-0"><strong>{t(`auth.role.${a.role}` as Key)}</strong> — {t(`auth.${a.id}.desc` as Key)}</p>
{a.role === 'official' && <p className="font-sans-xs text-base-dark">{t('auth.official.note')}</p>}
</div>
<div className="usa-card__footer">
<button type="button" className="usa-button" onClick={() => onPick(a)}>
{t('auth.signIn', { name: t(`auth.${a.id}` as Key) })}
</button>
</div>
</div>
</li>
))}
</ul>
<div className="border-top-1px border-base-lighter padding-top-2">
<button type="button" className="usa-button usa-button--outline" onClick={() => onPick(GUEST)}>{t('auth.guest')}</button>
<p className="font-sans-xs text-base-dark margin-top-1">{t('auth.guest.hint')}</p>
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import type { LatLon } from '../lib/geo';
import type { Hotspot } from '../lib/civic';
import { HARD_FLAGS, type RouteResult } from '../lib/route';
import type { Place, Verdict } from '../lib/types';
import { VERDICT_STYLE } from './ui';
@ -23,8 +24,19 @@ const STYLE: StyleSpecification = {
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
};
STYLE.sources.sat = {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
maxzoom: 19,
attribution: 'Imagery © Esri, Maxar, Earthstar Geographics',
};
STYLE.layers.push({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: 'none' } });
const reduceMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function applyBasemap(map: MLMap, b: 'map' | 'satellite') {
map.setLayoutProperty('osm', 'visibility', b === 'map' ? 'visible' : 'none');
map.setLayoutProperty('sat', 'visibility', b === 'satellite' ? 'visible' : 'none');
}
const lngLat = (p: LatLon): [number, number] => [p[1], p[0]];
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
@ -35,20 +47,37 @@ interface Props {
onSelect: (id: string) => void;
route: RouteResult | null;
routeEnds: { from: LatLon; to: LatLon } | null;
/** Non-selected route options, drawn faintly for comparison. */
altRoutes: LatLon[][];
focus: { at: LatLon; n: number } | null;
/** Re-measure when the pane becomes visible. */
visible: boolean;
bbox: [number, number, number, number];
online: boolean;
basemap: 'map' | 'satellite';
onBasemap: (b: 'map' | 'satellite') => void;
userPos: LatLon | null;
follow: boolean;
reach: [LatLon, LatLon][] | null;
hotspots: Hotspot[];
hotspotFocus: Hotspot | null;
reduceMotion: boolean;
}
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, focus, visible, bbox, online }: Props) {
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion }: Props) {
const { t, lang } = useI18n();
const ref = useRef<HTMLDivElement>(null);
const mapRef = useRef<MLMap | null>(null);
const markers = useRef<maplibregl.Marker[]>([]);
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const hotMarkers = useRef<maplibregl.Marker[]>([]);
const meMarker = useRef<maplibregl.Marker | null>(null);
const basemapRef = useRef(basemap);
basemapRef.current = basemap;
const rmRef = useRef(reduceMotion);
rmRef.current = reduceMotion;
const reduced = () => rmRef.current || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const [ready, setReady] = useState(false);
// ---- init ----
@ -73,6 +102,13 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
map.on('load', () => {
map.addSource('places', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
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('reach', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'reach', type: 'line', source: 'reach',
layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#0b6e99', 'line-width': 3.5, 'line-opacity': 0.75 },
});
map.addLayer({
id: 'route-casing', type: 'line', source: 'route',
layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 9 },
@ -88,11 +124,13 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
line('route-blocked', 'blocked', '#b50909', 6, [0.4, 1.6]);
map.addLayer({
id: 'places', type: 'circle', source: 'places',
layout: { 'circle-sort-key': ['match', ['get', 'verdict'], 'incomplete', 0, 1] },
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, 3.5, 17, 9],
'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, ['match', ['get', 'verdict'], 'incomplete', 2.5, 5.5], 17, ['match', ['get', 'verdict'], 'incomplete', 6, 11.5]],
'circle-opacity': ['match', ['get', 'verdict'], 'incomplete', 0.8, 1],
'circle-color': ['match', ['get', 'verdict'], 'match', VERDICT_STYLE.match.hex, 'partial', VERDICT_STYLE.partial.hex, 'conflict', VERDICT_STYLE.conflict.hex, 'fail', VERDICT_STYLE.fail.hex, '#ffffff'],
'circle-stroke-color': ['match', ['get', 'verdict'], 'incomplete', '#565c65', '#1b1b1b'],
'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 2.5, 1.5],
'circle-stroke-color': ['match', ['get', 'verdict'], 'incomplete', '#565c65', '#ffffff'],
'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 1.5, 2],
},
});
map.addLayer({
@ -105,6 +143,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
});
map.on('mouseenter', 'places', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'places', () => { map.getCanvas().style.cursor = ''; });
applyBasemap(map, basemapRef.current);
setReady(true);
});
mapRef.current = map;
@ -139,7 +178,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
if (!ready || !map) return;
map.setFilter('places-selected', ['==', ['get', 'id'], showPlaces ? selectedId ?? '' : '']);
const sel = selectedId ? places.find((p) => p.place.id === selectedId)?.place : undefined;
if (sel && showPlaces) map.easeTo({ center: [sel.lon, sel.lat], zoom: Math.max(map.getZoom(), 17), duration: reduceMotion() ? 0 : 500 });
if (sel && showPlaces) map.easeTo({ center: [sel.lon, sel.lat], zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 500 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, selectedId, showPlaces]);
@ -198,18 +237,94 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
}
const bounds = new maplibregl.LngLatBounds();
route.coords.forEach((c) => bounds.extend(lngLat(c)));
map.fitBounds(bounds, { padding: 70, duration: reduceMotion() ? 0 : 600, maxZoom: 17 });
map.fitBounds(bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 17 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, route, routeEnds, lang]);
useEffect(() => {
const map = mapRef.current;
if (!ready || !map || !focus) return;
map.easeTo({ center: lngLat(focus.at), zoom: Math.max(map.getZoom(), 18), duration: reduceMotion() ? 0 : 500 });
map.easeTo({ center: lngLat(focus.at), zoom: Math.max(map.getZoom(), 18), duration: reduced() ? 0 : 500 });
}, [ready, focus]);
// ---- basemap (street map / satellite) ----
useEffect(() => {
const map = mapRef.current;
if (ready && map) applyBasemap(map, basemap);
}, [ready, basemap]);
// ---- live position (guidance) ----
useEffect(() => {
const map = mapRef.current;
if (!ready || !map) return;
if (!userPos) { meMarker.current?.remove(); meMarker.current = null; return; }
if (!meMarker.current) {
const el = document.createElement('div');
el.className = 'kbb-me';
el.setAttribute('role', 'img');
el.setAttribute('aria-label', t('guide.you'));
meMarker.current = new maplibregl.Marker({ element: el }).setLngLat(lngLat(userPos)).addTo(map);
} else meMarker.current.setLngLat(lngLat(userPos));
if (follow) map.easeTo({ center: lngLat(userPos), zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 600 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, userPos, follow]);
// ---- alternative routes (context) ----
useEffect(() => {
const map = mapRef.current;
if (!ready || !map) return;
(map.getSource('routeAlt') as GeoJSONSource).setData({
type: 'FeatureCollection',
features: altRoutes.map((c) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: c.map(lngLat) } })),
});
}, [ready, altRoutes]);
// ---- reach ----
useEffect(() => {
const map = mapRef.current;
if (!ready || !map) return;
(map.getSource('reach') as GeoJSONSource).setData({
type: 'FeatureCollection',
features: (reach ?? []).map(([a, b]) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: [lngLat(a), lngLat(b)] } })),
});
if (reach && reach.length) {
const bounds = new maplibregl.LngLatBounds();
reach.forEach(([a, b]) => { bounds.extend(lngLat(a)); bounds.extend(lngLat(b)); });
map.fitBounds(bounds, { padding: 60, duration: reduced() ? 0 : 500, maxZoom: 17 });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, reach]);
// ---- hotspots (city panel) ----
useEffect(() => {
const map = mapRef.current;
if (!ready || !map) return;
hotMarkers.current.forEach((m) => m.remove());
hotMarkers.current = hotspots.map((h, i) => {
const el = document.createElement('button');
el.type = 'button';
el.className = `kbb-pin kbb-pin--hard`;
el.textContent = String(i + 1);
const label = `${i + 1}. ${h.street ?? ''} — ${t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')}`;
el.setAttribute('aria-label', label);
return new maplibregl.Marker({ element: el }).setLngLat(lngLat(h.at)).setPopup(new maplibregl.Popup({ offset: 16 }).setText(label)).addTo(map);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, hotspots, lang]);
useEffect(() => {
const map = mapRef.current;
if (!ready || !map || !hotspotFocus) return;
map.easeTo({ center: lngLat(hotspotFocus.at), zoom: 18, duration: reduced() ? 0 : 500 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, hotspotFocus]);
return (
<div className="kbb-map-wrap">
<div className="kbb-basemap" role="group" aria-label={t('map.layer')}>
<button type="button" aria-pressed={basemap === 'map'} onClick={() => onBasemap('map')}>{t('map.layer.map')}</button>
<button type="button" aria-pressed={basemap === 'satellite'} onClick={() => onBasemap('satellite')}>{t('map.layer.sat')}</button>
</div>
<div ref={ref} className="kbb-map" role="region" aria-label={t('map.label')} aria-describedby="map-alt" />
<p id="map-alt" className="usa-sr-only">{t('map.alt')}</p>
{!online && <p className="kbb-map-note usa-alert usa-alert--warning usa-alert--slim margin-0"><span className="usa-alert__body"><span className="usa-alert__text">{t('map.offline')}</span></span></p>}

View file

@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from 'react';
import { useI18n } from '../lib/i18n';
import { Alert, Icon } from './ui';
interface PermCtor { requestPermission?: () => Promise<'granted' | 'denied'> }
type SensorState = 'idle' | 'waiting' | 'live' | 'denied' | 'none' | 'insecure';
/**
* Phone lying on a ramp: slope from the tilt sensors. Uses `deviceorientation` and falls back to the
* gravity vector of `devicemotion`. iOS needs HTTPS (or localhost) and a permission tap; both are handled
* and reported separately so a failure says why.
*/
export function InclineMeter({ onUse, onClose }: { onUse: (pct: number) => void; onClose: () => void }) {
const { t } = useI18n();
const [pct, setPct] = useState<number | null>(null);
const [state, setState] = useState<SensorState>('idle');
const [diag, setDiag] = useState('');
const zero = useRef({ b: 0, g: 0, theta: 0 });
const raw = useRef({ b: 0, g: 0, theta: 0 });
const count = useRef({ o: 0, m: 0 });
const smooth = useRef<number | null>(null);
const off = useRef<(() => void) | null>(null);
const stop = () => { off.current?.(); off.current = null; };
useEffect(() => stop, []);
const push = (slopePct: number) => {
smooth.current = smooth.current === null ? slopePct : smooth.current * 0.85 + slopePct * 0.15;
setPct(Math.round(smooth.current * 10) / 10);
setState('live');
};
const start = async () => {
stop();
const secure = window.isSecureContext;
const O = window.DeviceOrientationEvent as unknown as PermCtor | undefined;
const M = window.DeviceMotionEvent as unknown as PermCtor | undefined;
count.current = { o: 0, m: 0 };
const report = () => setDiag(`secure=${secure ? 'yes' : 'no'}, orientation=${count.current.o}, motion=${count.current.m}`);
if (!secure) { setState('insecure'); report(); return; }
if (!O && !M) { setState('none'); report(); return; }
// Both permission requests must start inside the tap, so fire them together.
const reqs = [O?.requestPermission?.(), M?.requestPermission?.()].filter((p): p is Promise<'granted' | 'denied'> => !!p);
if (reqs.length) {
try { if ((await Promise.all(reqs)).includes('denied')) { setState('denied'); report(); return; } }
catch { setState('denied'); report(); return; }
}
setState('waiting');
const rad = Math.PI / 180;
const onOrient = (e: DeviceOrientationEvent) => {
if (e.beta === null || e.gamma === null) return;
count.current.o++;
raw.current.b = e.beta; raw.current.g = e.gamma;
const b = (e.beta - zero.current.b) * rad, g = (e.gamma - zero.current.g) * rad;
const theta = Math.acos(Math.min(1, Math.max(-1, Math.cos(b) * Math.cos(g))));
push(Math.tan(theta) * 100);
};
const onMotion = (e: DeviceMotionEvent) => {
const a = e.accelerationIncludingGravity;
if (!a || a.x === null || a.y === null || a.z === null) return;
count.current.m++;
if (count.current.o > 0) return; // orientation is more accurate when it works
const g = Math.hypot(a.x, a.y, a.z);
if (g < 5) return;
const theta = Math.acos(Math.min(1, Math.abs(a.z) / g));
raw.current.theta = theta;
push(Math.tan(Math.max(0, theta - zero.current.theta)) * 100);
};
window.addEventListener('deviceorientation', onOrient);
window.addEventListener('devicemotion', onMotion);
off.current = () => { window.removeEventListener('deviceorientation', onOrient); window.removeEventListener('devicemotion', onMotion); };
window.setTimeout(() => setState((s) => { if (s === 'waiting') { report(); return 'none'; } return s; }), 2500);
};
const calibrate = () => { zero.current = { ...raw.current }; smooth.current = null; };
return (
<div className="border-1px border-primary radius-md padding-2 bg-white margin-top-2" role="group" aria-labelledby="inc-title">
<h3 id="inc-title" className="margin-top-0 font-sans-md">{t('measure.incline.title')}</h3>
<ol className="usa-list font-sans-xs">
<li>{t('measure.incline.s1')}</li>
<li>{t('measure.incline.s2')}</li>
<li>{t('measure.incline.s3')}</li>
</ol>
{(state === 'idle' || state === 'denied' || state === 'none' || state === 'insecure') && (
<button type="button" className="usa-button" onClick={() => void start()}>{t('measure.incline.start')}</button>
)}
{state === 'insecure' && <Alert kind="warning" slim role="alert">{t('measure.incline.insecure')}</Alert>}
{state === 'denied' && <Alert kind="warning" slim role="alert">{t('measure.incline.denied')}</Alert>}
{state === 'none' && <Alert kind="warning" slim role="alert">{t('measure.incline.none')}</Alert>}
{diag && (state === 'denied' || state === 'none' || state === 'insecure') && <p className="font-sans-3xs text-base-dark margin-y-05">{t('measure.incline.diag', { d: diag })}</p>}
{(state === 'live' || state === 'waiting') && (
<>
<p className="font-sans-2xl text-bold margin-y-1" aria-live="off">{pct === null ? '…' : `${pct.toFixed(1)} %`}</p>
<button type="button" className="usa-button usa-button--outline" onClick={calibrate}>{t('measure.incline.zero')}</button>
<button type="button" className="usa-button" disabled={pct === null} onClick={() => { if (pct !== null) { stop(); onUse(pct); } }}>{t('measure.use')}</button>
</>
)}
<button type="button" className="usa-button usa-button--unstyled margin-left-1" onClick={() => { stop(); onClose(); }}>{t('rep.cancel')}</button>
</div>
);
}
const REFS: { id: string; cm: number }[] = [
{ id: 'card', cm: 8.56 },
{ id: 'a4short', cm: 21 },
{ id: 'a4long', cm: 29.7 },
{ id: 'ruler', cm: 30 },
];
type P = { x: number; y: number };
/** Scale from a reference object of known length lying next to the kerb. Four taps on the photo. */
export function KerbPhotoMeter({ photo, onUse, onClose }: { photo: string | null; onUse: (cm: number) => void; onClose: () => void }) {
const { t } = useI18n();
const canvas = useRef<HTMLCanvasElement>(null);
const img = useRef<HTMLImageElement | null>(null);
const [pts, setPts] = useState<P[]>([]);
const [ref, setRef] = useState('card');
const [custom, setCustom] = useState('');
const refCm = ref === 'custom' ? Number(custom.replace(',', '.')) : REFS.find((r) => r.id === ref)?.cm ?? 0;
useEffect(() => {
if (!photo) return;
const im = new Image();
im.onload = () => { img.current = im; draw(); };
im.src = photo;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photo]);
useEffect(draw);
function draw() {
const c = canvas.current, im = img.current;
if (!c || !im) return;
c.width = im.width;
c.height = im.height;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.drawImage(im, 0, 0);
ctx.lineWidth = Math.max(3, im.width / 220);
const seg = (a: P | undefined, b: P | undefined, color: string) => {
if (!a) return;
ctx.fillStyle = color; ctx.strokeStyle = color;
ctx.beginPath(); ctx.arc(a.x, a.y, ctx.lineWidth * 2, 0, 7); ctx.fill();
if (b) { ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.beginPath(); ctx.arc(b.x, b.y, ctx.lineWidth * 2, 0, 7); ctx.fill(); }
};
seg(pts[0], pts[1], '#00bde3');
seg(pts[2], pts[3], '#ffbe2e');
}
const tap = (e: React.PointerEvent<HTMLCanvasElement>) => {
const c = canvas.current;
if (!c || pts.length >= 4) return;
const r = c.getBoundingClientRect();
setPts([...pts, { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height }]);
};
const dist = (a: P, b: P) => Math.hypot(a.x - b.x, a.y - b.y);
const cm = pts.length === 4 && refCm > 0 ? Math.round((refCm * dist(pts[2]!, pts[3]!) / dist(pts[0]!, pts[1]!)) * 10) / 10 : null;
const step = pts.length < 2 ? 'measure.kerb.tapRef' : pts.length < 4 ? 'measure.kerb.tapKerb' : 'measure.kerb.done';
return (
<div className="border-1px border-primary radius-md padding-2 bg-white margin-top-2" role="group" aria-labelledby="kerb-title">
<h3 id="kerb-title" className="margin-top-0 font-sans-md">{t('measure.kerb.title')}</h3>
{!photo ? <Alert kind="warning" slim>{t('measure.kerb.needPhoto')}</Alert> : (
<>
<p className="font-sans-xs">{t('measure.kerb.intro')}</p>
<label className="usa-label" htmlFor="ref-obj">{t('measure.kerb.ref')}</label>
<select id="ref-obj" className="usa-select" value={ref} onChange={(e) => { setRef(e.target.value); setPts([]); }}>
{REFS.map((r) => <option key={r.id} value={r.id}>{t(`measure.ref.${r.id}` as 'measure.ref.card')} ({r.cm} cm)</option>)}
<option value="custom">{t('measure.ref.custom')}</option>
</select>
{ref === 'custom' && (
<>
<label className="usa-label" htmlFor="ref-custom">{t('measure.kerb.refCm')}</label>
<input id="ref-custom" className="usa-input width-card" inputMode="decimal" value={custom} onChange={(e) => { setCustom(e.target.value); setPts([]); }} />
</>
)}
<p className="text-bold margin-top-2" role="status">{t(step as 'measure.kerb.done')}</p>
<canvas ref={canvas} onPointerDown={tap} className="kbb-measure-canvas" role="img" aria-label={t('measure.kerb.canvas')} />
<p className="font-sans-3xs text-base-dark">{t('measure.kerb.caveat')}</p>
{cm !== null && <p className="font-sans-xl text-bold margin-y-1">≈ {cm} cm</p>}
<button type="button" className="usa-button usa-button--outline" onClick={() => setPts([])}>{t('measure.kerb.reset')}</button>
<button type="button" className="usa-button" disabled={cm === null || cm <= 0 || cm > 60} onClick={() => { if (cm !== null) onUse(cm); }}>{t('measure.use')}</button>
</>
)}
<button type="button" className="usa-button usa-button--unstyled margin-left-1" onClick={onClose}>{t('rep.cancel')}</button>
<Icon name="info_outline" size={2} className="usa-sr-only" />
</div>
);
}

View file

@ -2,8 +2,12 @@ import { forwardRef, useState } from 'react';
import { useI18n } from '../lib/i18n';
import { DEMO_PLACES } from '../lib/demo';
import type { Assessment, Place, ReportAttr, UserReport } from '../lib/types';
import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag } from './ui';
import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag, VettingSummary } from './ui';
import { ReportForm } from './ReportForm';
import type { ProcessedPhoto } from '../lib/photo';
import { usePhoto } from '../lib/usePhoto';
export interface ReachState { minutes: number | null; summary: { n: number; fit: number } | 'stuck' | null }
export type RefreshState =
| { kind: 'idle' }
@ -17,11 +21,17 @@ interface Props {
reports: UserReport[];
snapshotDate: string;
refresh: RefreshState;
saved: boolean;
onToggleSave: () => void;
onCard: () => void;
reach: ReachState;
onReach: (minutes: number) => void;
onClearReach: () => void;
onBack: () => void;
onRouteTo: () => void;
onShowOnMap: () => void;
onRefresh: () => void;
onAddReport: (r: Omit<UserReport, 'id' | 'createdAt'>) => void;
onAddReport: (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => void;
onRemoveReport: (id: string) => void;
}
@ -33,6 +43,7 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
const demo = DEMO_PLACES.find((d) => d.id === place.id);
const address = [place.tags['addr:street'], place.tags['addr:housenumber']].filter(Boolean).join(' ');
const mine = p.reports.filter((r) => r.placeId === place.id);
const missing = assessment.facts.filter((f) => f.required && f.status === 'unknown').map((f) => t(`fact.title.${f.key}`).toLowerCase());
return (
<article aria-labelledby="detail-title">
@ -41,9 +52,9 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
{t('detail.back')}
</button>
<h2 id="detail-title" ref={headingRef} tabIndex={-1} className="margin-0">
<h1 id="detail-title" ref={headingRef} tabIndex={-1} className="margin-0 font-sans-xl">
{place.name ?? t('common.noName')}
</h2>
</h1>
{address && <p className="margin-top-05 margin-bottom-0 text-base-dark">{address}</p>}
{demo && (
@ -57,8 +68,9 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
<div className={`usa-alert ${VERDICT_STYLE[assessment.verdict].alert} margin-top-2`} role="status">
<div className="usa-alert__body">
<h3 className="usa-alert__heading"><VerdictTag verdict={assessment.verdict} /></h3>
<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>
</div>
@ -71,7 +83,44 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
</button>
</div>
<h3 className="margin-top-3">{t('detail.facts')}</h3>
<div className="display-flex flex-wrap margin-top-1">
<button type="button" className="usa-button usa-button--outline usa-button--small" aria-pressed={p.saved} onClick={p.onToggleSave}>
<Icon name={p.saved ? 'check_circle' : 'add'} size={3} className="margin-right-05" />{p.saved ? t('detail.saved') : t('detail.save')}
</button>
<button type="button" className="usa-button usa-button--outline usa-button--small" onClick={p.onCard}>
<Icon name="list" size={3} className="margin-right-05" />{t('detail.card')}
</button>
</div>
<section aria-labelledby="reach-title" className="border-1px border-base-lighter radius-md padding-2 margin-top-2 bg-base-lightest">
<h2 id="reach-title" className="margin-top-0 font-sans-md">
<Icon name="directions_walk" size={3} className="margin-right-05 text-primary" />{t('reach.title')}
</h2>
<p className="margin-top-0 font-sans-xs">{t('reach.intro')}</p>
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
{[5, 10, 15].map((m) => (
<li className="usa-button-group__item" key={m}>
<button type="button" aria-pressed={p.reach.minutes === m} className={`usa-button usa-button--small ${p.reach.minutes === m ? '' : 'usa-button--outline'}`} onClick={() => p.onReach(m)}>
{t('reach.minutes', { n: m })}
</button>
</li>
))}
</ul>
<div aria-live="polite" className="margin-top-1">
{p.reach.summary === 'stuck' && <p className="margin-0">{t('reach.stuck')}</p>}
{p.reach.summary && p.reach.summary !== 'stuck' && p.reach.minutes && (
<>
<p className="margin-y-05">
{p.reach.summary.n === 0 ? t('reach.result.zero') : t('reach.result', { min: p.reach.minutes, n: p.reach.summary.n, fit: p.reach.summary.fit })}
</p>
<p className="font-sans-3xs text-base-dark margin-y-05">{t('reach.show')} {t('reach.caveat')}</p>
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={p.onClearReach}>{t('reach.clear')}</button>
</>
)}
</div>
</section>
<h2 className="margin-top-3 font-sans-lg">{t('detail.facts')}</h2>
<ul className="usa-list--unstyled margin-0 padding-0">
{assessment.facts.map((f) => (
<FactCard key={f.key} fact={f} onReport={() => { setReporting(f.key as ReportAttr); setThanks(false); }} />
@ -84,30 +133,17 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
placeId={place.id}
initialAttr={reporting}
onCancel={() => setReporting(null)}
onSubmit={(r) => { p.onAddReport(r); setReporting(null); setThanks(true); }}
onSubmit={(r, photo) => { p.onAddReport(r, photo); setReporting(null); setThanks(true); }}
/>
)}
<h3 className="margin-top-3">{t('detail.reports')}</h3>
<h2 className="margin-top-3 font-sans-lg">{t('detail.reports')}</h2>
<p className="font-sans-xs text-base-dark">{t('detail.reports.note')}</p>
{mine.length === 0 ? (
<p>{t('detail.reports.none')}</p>
) : (
<ul className="usa-list">
{mine.map((r) => (
<li key={r.id}>
<strong>{t(`rep.attr.${r.attr}`)}:</strong>{' '}
{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)}
{r.demo && <span className="usa-tag margin-left-1 bg-ink text-white">{t('detail.reports.demo')}</span>}
<span className="text-base-dark"> — {date(r.createdAt.slice(0, 10))}</span>
{r.note && <div className="text-base-dark font-sans-xs">{r.note}</div>}
{!r.demo && (
<button type="button" className="usa-button usa-button--unstyled margin-left-1 font-sans-xs" onClick={() => p.onRemoveReport(r.id)}>
{t('detail.reports.remove')}
</button>
)}
</li>
))}
{mine.map((r) => <ReportItem key={r.id} r={r} onRemove={() => p.onRemoveReport(r.id)} />)}
</ul>
)}
@ -140,3 +176,27 @@ function osmPath(id: string) {
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
return `${kind}/${id.slice(1)}`;
}
function ReportItem({ r, onRemove }: { r: UserReport; onRemove: () => void }) {
const { t, date } = useI18n();
const photo = usePhoto(r.photoId);
return (
<li>
<div className="display-flex">
{photo && <img className="kbb-thumb margin-right-1" src={photo} alt={t('rep.photo.alt')} />}
<div>
<strong>{t(`rep.attr.${r.attr}`)}:</strong>{' '}
{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)}
{r.demo && <span className="usa-tag margin-left-1 bg-ink text-white">{t('detail.reports.demo')}</span>}
<span className="text-base-dark"> — {date(r.createdAt.slice(0, 10))}</span>
{r.measure && <div className="font-sans-xs">{t(`vet.measure.${r.measure.kind}`)}: {r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${r.measure.method}`)})</div>}
{r.note && <div className="text-base-dark font-sans-xs">{r.note}</div>}
<VettingSummary v={r.vetting} />
{!r.demo && (
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={onRemove}>{t('detail.reports.remove')}</button>
)}
</div>
</div>
</li>
);
}

View file

@ -6,17 +6,18 @@ import { fold } from '../lib/text';
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
import { Icon, VerdictTag } from './ui';
export interface Filters { query: string; cat: PlaceCategory | 'all'; onlyFit: boolean }
export const DEFAULT_FILTERS: Filters = { query: '', cat: 'all', onlyFit: false };
export interface Filters { query: string; cat: PlaceCategory | 'all'; onlyFit: boolean; onlySaved: boolean }
export const DEFAULT_FILTERS: Filters = { query: '', cat: 'all', onlyFit: false, onlySaved: false };
const CATS: (PlaceCategory | 'all')[] = ['all', 'food', 'culture', 'shop', 'stay', 'health', 'services'];
const CATS: (PlaceCategory | 'all')[] = ['all', 'food', 'culture', 'shop', 'stay', 'health', 'services', 'transit'];
const PAGE = 40;
export function filterPlaces(places: Place[], assessments: Map<string, Assessment>, f: Filters): Place[] {
export function filterPlaces(places: Place[], assessments: Map<string, Assessment>, f: Filters, saved: ReadonlySet<string>): Place[] {
const q = fold(f.query.trim());
return places
.filter((p) => {
if (f.cat !== 'all' && p.cat !== f.cat) return false;
if (f.onlySaved && !saved.has(p.id)) return false;
const v = assessments.get(p.id)?.verdict;
if (f.onlyFit && v !== 'match' && v !== 'partial') return false;
if (!q) return true;
@ -46,7 +47,7 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
return (
<section aria-labelledby="places-title">
<h2 id="places-title" className="margin-top-0">{t('places.title')}</h2>
<h1 id="places-title" className="margin-top-0 font-sans-xl">{t('places.title')}</h1>
<p className="font-sans-xs text-base-dark margin-top-0">
{t('places.hint', { profile: t(`profile.${profile.kind}`).toLowerCase() })}
</p>
@ -58,6 +59,10 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
<select id="cat" className="usa-select maxw-full" value={filters.cat} onChange={(e) => set({ cat: e.target.value as Filters['cat'] })}>
{CATS.map((c) => <option key={c} value={c}>{t(`cat.${c}` as Key)}</option>)}
</select>
<div className="usa-checkbox margin-top-2">
<input className="usa-checkbox__input" id="onlysaved" type="checkbox" checked={filters.onlySaved} onChange={(e) => set({ onlySaved: e.target.checked })} />
<label className="usa-checkbox__label" htmlFor="onlysaved">{t('places.onlySaved')}</label>
</div>
<div className="usa-checkbox margin-top-2">
<input className="usa-checkbox__input" id="onlyfit" type="checkbox" checked={filters.onlyFit} onChange={(e) => set({ onlyFit: e.target.checked })} />
<label className="usa-checkbox__label" htmlFor="onlyfit">{t('places.onlyFit')}</label>

View file

@ -1,93 +1,198 @@
import { useState, type FormEvent } from 'react';
import { useI18n } from '../lib/i18n';
import { DEFAULT_PROFILE } from '../lib/profile';
import type { Profile, ProfileKind } from '../lib/types';
import { Icon } from './ui';
import { useEffect, useMemo, useState } from 'react';
import type { Account } from '../lib/auth';
import { useI18n, type Key } from '../lib/i18n';
import { DEFAULT_PROFILE, PRESETS, activePreset, bucket } from '../lib/profile';
import type { Profile, ProfileKind, Verdict } from '../lib/types';
import { Icon, InfoTip, VERDICT_STYLE } from './ui';
interface Props {
initial: Profile | null;
onSave: (p: Profile) => void;
account: Account;
/** Saved profile; null until the user finishes onboarding (guests, new accounts). */
profile: Profile | null;
onChange: (p: Profile) => void;
onDone: () => void;
onSignOut: () => void;
preview: (p: Profile) => Record<Verdict, number>;
children?: React.ReactNode;
}
export function ProfileView({ initial, onSave }: Props) {
const VERDICTS: Verdict[] = ['match', 'partial', 'conflict', 'incomplete', 'fail'];
export function ProfileView({ account, profile, onChange, onDone, onSignOut, preview, children }: Props) {
const { t } = useI18n();
const [p, setP] = useState<Profile>(initial ?? DEFAULT_PROFILE.wheelchair);
const set = <K extends keyof Profile>(k: K, v: Profile[K]) => setP((cur) => ({ ...cur, [k]: v }));
const submit = (e: FormEvent) => { e.preventDefault(); onSave(p); };
const pickKind = (kind: ProfileKind) => setP(DEFAULT_PROFILE[kind]);
const [draft, setDraft] = useState<Profile>(profile ?? DEFAULT_PROFILE[account.preset ?? 'wheelchair']);
const update = (p: Profile) => { setDraft(p); if (profile) onChange(p); };
const set = <K extends keyof Profile>(k: K, v: Profile[K]) => update({ ...draft, [k]: v });
// Debounced live preview: how many places fall into each verdict with these limits.
const [counts, setCounts] = useState<Record<Verdict, number> | null>(null);
useEffect(() => {
const id = setTimeout(() => setCounts(preview(draft)), 250);
return () => clearTimeout(id);
}, [draft, preview]);
const active = activePreset(draft);
const presets = PRESETS.filter((p) => p.kind === draft.kind);
const kerbHint = `hint.kerb.${bucket(draft.maxKerbCm, [0, 3, 6, 10])}` as Key;
const widthHint = `hint.width.${bucket(Math.round(draft.minWidthM * 100), [65, 80, 90, 100])}` as Key;
const inclineHint = `hint.incline.${bucket(draft.maxInclinePct, [5, 8, 12])}` as Key;
const summary = useMemo(() => {
let s = t('profile.summary.text', { kerb: draft.maxKerbCm, width: Math.round(draft.minWidthM * 100), incline: draft.maxInclinePct });
if (draft.avoidCobbles) s += t('profile.summary.cobbles');
if (draft.needToilet) s += t('profile.summary.toilet');
if (draft.needRest) s += t('profile.summary.rest');
if (draft.needChanging && draft.kind === 'stroller') s += t('profile.summary.changing');
return s;
}, [draft, t]);
return (
<div className="grid-container padding-y-4 kbb-narrow">
<h1 className="margin-top-0">{t('profile.title')}</h1>
<h1 className="margin-top-0">{profile ? t('profile.title') : t('profile.welcome')}</h1>
<p className="usa-intro">{t('profile.intro')}</p>
<form className="usa-form usa-form--large" onSubmit={submit}>
<fieldset className="usa-fieldset">
<legend className="usa-legend usa-legend--large">{t('profile.kind')}</legend>
{(['wheelchair', 'stroller'] as const).map((k) => (
<div className="usa-radio" key={k}>
<input
className="usa-radio__input usa-radio__input--tile" id={`kind-${k}`} type="radio" name="kind"
checked={p.kind === k} onChange={() => pickKind(k)}
/>
<label className="usa-radio__label" htmlFor={`kind-${k}`}>
{t(`profile.${k}`)}
<span className="usa-checkbox__label-description">{t(`profile.${k}.hint`)}</span>
</label>
</div>
))}
</fieldset>
<fieldset className="usa-fieldset margin-top-4">
<legend className="usa-legend usa-legend--large">{t('profile.limits')}</legend>
<Range id="kerb" label={t('profile.kerb')} min={0} max={10} step={1} value={p.maxKerbCm} unit={t('unit.cm')} onChange={(v) => set('maxKerbCm', v)} />
<Range id="width" label={t('profile.width')} min={60} max={120} step={5} value={Math.round(p.minWidthM * 100)} unit={t('unit.cm')} onChange={(v) => set('minWidthM', v / 100)} />
<Range id="incline" label={t('profile.incline')} min={3} max={12} step={1} value={p.maxInclinePct} unit={t('unit.pct')} onChange={(v) => set('maxInclinePct', v)} />
</fieldset>
<fieldset className="usa-fieldset margin-top-4">
<legend className="usa-legend usa-legend--large">{t('profile.prefs')}</legend>
<Check id="cobbles" label={t('profile.cobbles')} checked={p.avoidCobbles} onChange={(v) => set('avoidCobbles', v)} />
<Check id="toilet" label={t('profile.toilet')} checked={p.needToilet} onChange={(v) => set('needToilet', v)} />
<Check id="rest" label={t('profile.rest')} checked={p.needRest} onChange={(v) => set('needRest', v)} />
{p.kind === 'stroller' && (
<Check id="changing" label={t('profile.changing')} checked={p.needChanging} onChange={(v) => set('needChanging', v)} />
)}
</fieldset>
<button type="submit" className="usa-button usa-button--big margin-top-4">
{t('profile.save')}
<Icon name="arrow_forward" size={3} className="margin-left-1" />
</button>
<p className="font-sans-xs text-base-dark margin-top-2">
<Icon name="lock" size={2} className="margin-right-05" />
{t('profile.privacy')}
<div className="border-1px border-base-lighter radius-md padding-2 bg-base-lightest display-flex flex-justify flex-align-center flex-wrap">
<p className="margin-0">
<Icon name="accessibility_new" size={3} className="margin-right-1 text-primary" />
<strong>{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}</strong>
<span className="text-base-dark"> · {t(`auth.role.${account.role}` as Key)}</span>
{account.id !== 'guest' && <span className="usa-tag bg-ink text-white margin-left-1">{t('auth.demoBadge')}</span>}
</p>
</form>
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={onSignOut}>
{t('auth.signOut')}
</button>
</div>
<section aria-labelledby="st1" className="margin-top-4">
<h2 id="st1" className="kbb-step"><span className="kbb-step__n" aria-hidden="true">1</span>{t('profile.step1')}</h2>
<fieldset className="usa-fieldset">
<legend className="usa-sr-only">{t('profile.step1')}</legend>
<div className="kbb-choice">
{(['wheelchair', 'stroller'] as ProfileKind[]).map((k) => (
<div className="usa-radio" key={k}>
<input
className="usa-radio__input usa-radio__input--tile" id={`kind-${k}`} type="radio" name="kind"
checked={draft.kind === k} onChange={() => update(DEFAULT_PROFILE[k])}
/>
<label className="usa-radio__label" htmlFor={`kind-${k}`}>
{t(`profile.${k}`)}
<span className="usa-checkbox__label-description">{t(`profile.${k}.hint`)}</span>
</label>
</div>
))}
</div>
</fieldset>
</section>
<section aria-labelledby="st2" className="margin-top-4">
<h2 id="st2" className="kbb-step">
<span className="kbb-step__n" aria-hidden="true">2</span>{t('profile.step2')}
<InfoTip topic={t('profile.step2')} text={t('tip.preset')} />
</h2>
<fieldset className="usa-fieldset">
<legend className="usa-sr-only">{t('profile.step2')}</legend>
<div className="kbb-choice">
{presets.map((p) => (
<div className="usa-radio" key={p.id}>
<input
className="usa-radio__input usa-radio__input--tile" id={`preset-${p.id}`} type="radio" name="preset"
checked={active === p.id} onChange={() => update({ ...p.values, ...keepPrefs(draft) })}
/>
<label className="usa-radio__label" htmlFor={`preset-${p.id}`}>
{t(`profile.preset.${p.id}` as Key)}
<span className="usa-checkbox__label-description">{t(`profile.preset.${p.id}.d` as Key)}</span>
</label>
</div>
))}
{active === 'custom' && (
<div className="usa-radio">
<input className="usa-radio__input usa-radio__input--tile" id="preset-custom" type="radio" name="preset" checked readOnly />
<label className="usa-radio__label" htmlFor="preset-custom">{t('profile.preset.custom')}</label>
</div>
)}
</div>
</fieldset>
</section>
<section aria-labelledby="st3" className="margin-top-4">
<h2 id="st3" className="kbb-step"><span className="kbb-step__n" aria-hidden="true">3</span>{t('profile.step3')}</h2>
<p className="margin-top-0 text-base-dark">{t('profile.step3.help')}</p>
<Slider id="kerb" label={t('profile.kerb')} tip={t('tip.kerb')} min={0} max={10} step={1} value={draft.maxKerbCm} unit={t('unit.cm')} hint={t(kerbHint)} minLabel="0" maxLabel="10 cm" onChange={(v) => set('maxKerbCm', v)} />
<Slider id="width" label={t('profile.width')} tip={t('tip.width')} min={60} max={120} step={5} value={Math.round(draft.minWidthM * 100)} unit={t('unit.cm')} hint={t(widthHint)} minLabel="60 cm" maxLabel="120 cm" onChange={(v) => set('minWidthM', v / 100)} />
<Slider id="incline" label={t('profile.incline')} tip={t('tip.incline')} min={3} max={12} step={1} value={draft.maxInclinePct} unit={t('unit.pct')} hint={t(inclineHint)} minLabel="3%" maxLabel="12%" onChange={(v) => set('maxInclinePct', v)} />
</section>
<section aria-labelledby="st4" className="margin-top-4">
<h2 id="st4" className="kbb-step"><span className="kbb-step__n" aria-hidden="true">4</span>{t('profile.step4')}</h2>
<Check id="cobbles" label={t('profile.cobbles')} tip={t('tip.cobbles')} checked={draft.avoidCobbles} onChange={(v) => set('avoidCobbles', v)} />
<Check id="toilet" label={t('profile.toilet')} tip={t('tip.toilet')} checked={draft.needToilet} onChange={(v) => set('needToilet', v)} />
<Check id="rest" label={t('profile.rest')} tip={t('tip.rest')} checked={draft.needRest} onChange={(v) => set('needRest', v)} />
{draft.kind === 'stroller' && <Check id="changing" label={t('profile.changing')} tip={t('tip.changing')} checked={draft.needChanging} onChange={(v) => set('needChanging', v)} />}
</section>
<section className="usa-summary-box margin-top-4" aria-labelledby="sum">
<div className="usa-summary-box__body">
<h2 className="usa-summary-box__heading" id="sum">{t('profile.summary')}</h2>
<p className="usa-summary-box__text">{summary}</p>
<h3 className="font-sans-sm margin-bottom-1">{t('profile.preview')}</h3>
<ul className="usa-list--unstyled display-flex flex-wrap margin-0 padding-0" aria-live="polite">
{counts && VERDICTS.map((v) => (
<li key={v} className="margin-right-1 margin-bottom-1">
<span className={`usa-tag display-inline-flex flex-align-center ${VERDICT_STYLE[v].tag}`}>
<Icon name={VERDICT_STYLE[v].icon} size={2} className="margin-right-05" />
{t('profile.preview.n', { n: counts[v], label: t(`verdict.count.${v}` as Key) })}
</span>
</li>
))}
</ul>
</div>
</section>
<p className="font-sans-xs text-base-dark margin-top-2">
<Icon name="lock" size={2} className="margin-right-05" />{t('profile.privacy')} {profile && t('profile.autosave')}
</p>
<button type="button" className="usa-button usa-button--big margin-top-2" onClick={() => { onChange(draft); onDone(); }}>
{t('profile.done')}
<Icon name="arrow_forward" size={3} className="margin-left-1" />
</button>
{children}
</div>
);
}
function Range({ id, label, min, max, step, value, unit, onChange }: {
id: string; label: string; min: number; max: number; step: number; value: number; unit: string; onChange: (v: number) => void;
const keepPrefs = (p: Profile) => ({ avoidCobbles: p.avoidCobbles, needToilet: p.needToilet, needRest: p.needRest, needChanging: p.needChanging });
function Slider({ id, label, tip, min, max, step, value, unit, hint, minLabel, maxLabel, onChange }: {
id: string; label: string; tip: string; min: number; max: number; step: number; value: number; unit: string;
hint: string; minLabel: string; maxLabel: string; onChange: (v: number) => void;
}) {
return (
<div className="margin-top-2">
<label className="usa-label" htmlFor={`range-${id}`}>
{label}: <strong>{value} {unit}</strong>
</label>
<div className="margin-top-3">
<div className="display-flex flex-align-center">
<label className="usa-label margin-0" htmlFor={`range-${id}`}>
{label}: <strong>{value} {unit}</strong>
</label>
<InfoTip topic={label} text={tip} />
</div>
<input
id={`range-${id}`} className="usa-range" type="range" min={min} max={max} step={step} value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-describedby={`hint-${id}`} onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="display-flex flex-justify font-sans-3xs text-base-dark" aria-hidden="true"><span>{minLabel}</span><span>{maxLabel}</span></div>
<p id={`hint-${id}`} className="margin-top-05 margin-bottom-0 font-sans-xs"><Icon name="info_outline" size={2} className="margin-right-05 text-primary" />{hint}</p>
</div>
);
}
function Check({ id, label, checked, onChange }: { id: string; label: string; checked: boolean; onChange: (v: boolean) => void }) {
function Check({ id, label, tip, checked, onChange }: { id: string; label: string; tip: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="usa-checkbox">
<input className="usa-checkbox__input" id={`chk-${id}`} type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<label className="usa-checkbox__label" htmlFor={`chk-${id}`}>{label}</label>
<div className="display-flex flex-align-center">
<div className="usa-checkbox">
<input className="usa-checkbox__input" id={`chk-${id}`} type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<label className="usa-checkbox__label" htmlFor={`chk-${id}`}>{label}</label>
</div>
<InfoTip topic={label} text={tip} />
</div>
);
}

View file

@ -1,11 +1,14 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useI18n } from '../lib/i18n';
import type { ReportAttr, ReportValue, UserReport } from '../lib/types';
import { processPhoto, type ProcessedPhoto } from '../lib/photo';
import type { Measurement, ReportAttr, ReportValue, UserReport } from '../lib/types';
import { InclineMeter, KerbPhotoMeter } from './MeasureTools';
import { Alert, Icon } from './ui';
interface Props {
placeId: string;
initialAttr?: ReportAttr;
onSubmit: (r: Omit<UserReport, 'id' | 'createdAt'>) => void;
onSubmit: (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => void;
onCancel: () => void;
}
@ -18,9 +21,30 @@ export function ReportForm({ placeId, initialAttr = 'entrance', onSubmit, onCanc
const [width, setWidth] = useState('');
const [note, setNote] = useState('');
const [error, setError] = useState<string | null>(null);
const [photo, setPhoto] = useState<ProcessedPhoto | null>(null);
const [photoErr, setPhotoErr] = useState(false);
const [measure, setMeasure] = useState<Measurement | null>(null);
const [tool, setTool] = useState<'incline' | 'kerb' | null>(null);
const [manual, setManual] = useState('');
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => headingRef.current?.focus(), []);
// what can be measured for this kind of report
const kind: Measurement['kind'] | null = attr === 'steps' ? 'kerb' : attr === 'ramp' ? 'incline' : null;
useEffect(() => { setMeasure(null); setTool(null); setManual(''); }, [attr]);
const pick = async (file: File | undefined) => {
if (!file) return;
setPhotoErr(false);
try { setPhoto(await processPhoto(file)); } catch { setPhotoErr(true); }
};
const useManual = () => {
const n = Number(manual.replace(',', '.'));
if (!kind || !Number.isFinite(n) || n <= 0) return;
setMeasure({ kind, value: Math.round(n * 10) / 10, unit: kind === 'kerb' ? 'cm' : 'pct', method: 'manual' });
};
const submit = (e: FormEvent) => {
e.preventDefault();
let v: ReportValue = value;
@ -29,12 +53,12 @@ export function ReportForm({ placeId, initialAttr = 'entrance', onSubmit, onCanc
if (!Number.isFinite(n) || n < 30 || n > 300) { setError(t('rep.widthInvalid')); return; }
v = Math.round(n);
}
onSubmit({ placeId, attr, value: v, ...(note.trim() ? { note: note.trim().slice(0, 300) } : {}) });
onSubmit({ placeId, attr, value: v, ...(measure ? { measure } : {}), ...(note.trim() ? { note: note.trim().slice(0, 300) } : {}) }, photo);
};
return (
<form className="usa-form border-1px border-primary radius-md padding-2 bg-primary-lighter maxw-full" onSubmit={submit} aria-labelledby="rep-title">
<h3 id="rep-title" ref={headingRef} tabIndex={-1} className="margin-top-0">{t('rep.title')}</h3>
<h2 id="rep-title" ref={headingRef} tabIndex={-1} className="margin-top-0 font-sans-lg">{t('rep.title')}</h2>
<label className="usa-label" htmlFor="rep-attr">{t('rep.attr')}</label>
<select id="rep-attr" className="usa-select" value={attr} onChange={(e) => { setAttr(e.target.value as ReportAttr); setError(null); }}>
{ATTRS.map((a) => <option key={a} value={a}>{t(`rep.attr.${a}`)}</option>)}
@ -61,9 +85,58 @@ export function ReportForm({ placeId, initialAttr = 'entrance', onSubmit, onCanc
</fieldset>
)}
<fieldset className="usa-fieldset margin-top-3">
<legend className="usa-legend">{t('rep.evidence')}</legend>
<p className="usa-hint margin-top-0">{t('rep.evidence.help')}</p>
<div className="display-flex flex-wrap">
<label className="usa-button usa-button--outline margin-0 margin-right-1 margin-bottom-1" htmlFor="rep-cam">
<Icon name="visibility" size={3} className="margin-right-05" />{t('rep.photo.take')}
</label>
<input id="rep-cam" className="usa-sr-only" type="file" accept="image/*" capture="environment" onChange={(e) => void pick(e.target.files?.[0])} />
<label className="usa-button usa-button--outline margin-0 margin-bottom-1" htmlFor="rep-gal">{t('rep.photo.choose')}</label>
<input id="rep-gal" className="usa-sr-only" type="file" accept="image/*" onChange={(e) => void pick(e.target.files?.[0])} />
</div>
{photoErr && <Alert kind="error" slim role="alert">{t('rep.photo.error')}</Alert>}
{photo && (
<div className="display-flex flex-align-center margin-y-1">
<img className="kbb-thumb" src={photo.dataUrl} alt={t('rep.photo.alt')} />
<div className="margin-left-2">
<p className="margin-0 font-sans-xs">{t('rep.photo.processed')}</p>
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={() => { setPhoto(null); setMeasure(null); }}>{t('rep.photo.remove')}</button>
</div>
</div>
)}
<p className="font-sans-3xs text-base-dark margin-top-0">{t('rep.photo.privacy')}</p>
{kind && (
<div className="margin-top-2">
<p className="text-bold margin-bottom-1">{t(kind === 'kerb' ? 'rep.measure.kerb' : 'rep.measure.incline')}</p>
<div className="display-flex flex-wrap flex-align-end">
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0 margin-right-1" onClick={() => setTool(kind === 'kerb' ? 'kerb' : 'incline')}>
{t(kind === 'kerb' ? 'rep.measure.kerb.tool' : 'rep.measure.incline.tool')}
</button>
<label className="usa-sr-only" htmlFor="rep-manual">{t('rep.measure.manual')}</label>
<input id="rep-manual" className="usa-input width-15 margin-0 margin-right-1" inputMode="decimal" placeholder={t(kind === 'kerb' ? 'rep.measure.manual.cm' : 'rep.measure.manual.pct')} value={manual} onChange={(e) => setManual(e.target.value)} />
<button type="button" className="usa-button usa-button--small usa-button--base margin-0" onClick={useManual}>{t('rep.measure.manual.use')}</button>
</div>
{measure && (
<p className="margin-top-1 text-success-dark text-bold" role="status">
<Icon name="check_circle" size={3} className="margin-right-05" />
{t(`vet.measure.${measure.kind}`)}: {measure.value} {measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${measure.method}`)})
<button type="button" className="usa-button usa-button--unstyled margin-left-1 font-sans-xs" onClick={() => setMeasure(null)}>{t('rep.photo.remove')}</button>
</p>
)}
{tool === 'incline' && <InclineMeter onClose={() => setTool(null)} onUse={(pct) => { setMeasure({ kind: 'incline', value: pct, unit: 'pct', method: 'inclinometer' }); setTool(null); }} />}
{tool === 'kerb' && <KerbPhotoMeter photo={photo?.dataUrl ?? null} onClose={() => setTool(null)} onUse={(cm) => { setMeasure({ kind: 'kerb', value: cm, unit: 'cm', method: 'photo-ref' }); setTool(null); }} />}
<p className="font-sans-3xs text-base-dark">{t('rep.measure.lidar')}</p>
</div>
)}
</fieldset>
<label className="usa-label" htmlFor="rep-note">{t('rep.note')}</label>
<textarea id="rep-note" className="usa-textarea height-10" maxLength={300} value={note} onChange={(e) => setNote(e.target.value)} />
<p className="usa-hint margin-top-1">{t('rep.disclaimer')}</p>
<p className="usa-hint margin-top-1">{t('rep.disclaimer')} {t('rep.ai.note')}</p>
<div className="margin-top-2">
<button type="submit" className="usa-button">{t('rep.submit')}</button>
<button type="button" className="usa-button usa-button--outline" onClick={onCancel}>{t('rep.cancel')}</button>

View file

@ -1,9 +1,10 @@
import { useState, type FormEvent } from 'react';
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 { findRoute, HARD_FLAGS, type RouteGraph, type RouteResult, type RouteSegment } from '../lib/route';
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';
export interface RouteForm {
@ -20,16 +21,26 @@ interface Props {
bbox: [number, number, number, number];
form: RouteForm;
onForm: (f: RouteForm) => void;
result: RouteResult | null;
onResult: (r: RouteResult | null, from: LatLon | null, to: LatLon | null) => void;
options: RouteOption[] | null;
ends: { from: LatLon; to: LatLon } | null;
guiding: boolean;
onGuide: () => void;
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;
}
export function RouteView({ graph, labels, profile, bbox, form, onForm, result, onResult, presets, onFocus }: Props) {
export function RouteView({ graph, labels, profile, bbox, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, presets, onFocus }: Props) {
const result = options?.[selected]?.result ?? null;
const { t, tm } = useI18n();
const [error, setError] = useState<string | null>(null);
const [locating, setLocating] = useState(false);
const resultRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (result) { resultRef.current?.scrollIntoView({ block: 'start' }); resultRef.current?.focus({ preventScroll: true }); }
}, [options]);
const run = (f: RouteForm) => {
setError(null);
@ -38,7 +49,7 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, result,
if (!from) { setError(t('route.unknownPlace', { text: f.fromText })); onResult(null, null, null); return; }
if (!to) { setError(t('route.unknownPlace', { text: f.toText })); onResult(null, null, null); return; }
if (from[0] === to[0] && from[1] === to[1]) { setError(t('route.same')); onResult(null, null, null); return; }
onResult(findRoute(graph, from, to, profile), from, to);
onResult(findAlternatives(graph, from, to, profile), from, to);
};
const submit = (e: FormEvent) => { e.preventDefault(); run(form); };
@ -65,7 +76,7 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, result,
return (
<section aria-labelledby="route-title">
<h2 id="route-title" className="margin-top-0">{t('route.title')}</h2>
<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}>
@ -119,8 +130,27 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, result,
</div>
)}
<div aria-live="polite" className="margin-top-3">
<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' && (
<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>
<p className="font-sans-xs margin-top-0">{t('guide.start.help')}</p>
<button type="button" className="usa-button" onClick={onGuide} disabled={guiding}>
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('guide.start')}
</button>
<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" />
</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" />
</a>
<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} />}
</div>
</section>
@ -139,9 +169,9 @@ function Result({ result, onFocus, t }: {
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
return (
<>
<div className={`usa-alert ${blocked ? 'usa-alert--error' : 'usa-alert--success'}`}>
<div className={`usa-alert ${blocked ? 'usa-alert--error' : notes.length ? 'usa-alert--warning' : 'usa-alert--success'}`}>
<div className="usa-alert__body">
<h3 className="usa-alert__heading">{blocked ? t('route.blocked') : t('route.ok')}</h3>
<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>
@ -153,7 +183,7 @@ function Result({ result, onFocus, t }: {
<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>
<h3>{t('route.barriers')}</h3>
<h2 className="font-sans-lg">{t('route.barriers')}</h2>
{notes.length === 0 ? (
<p>{t('route.barriers.none')}</p>
) : (
@ -175,7 +205,7 @@ function Result({ result, onFocus, t }: {
</ul>
)}
<h3>{t('route.steps')}</h3>
<h2 className="font-sans-lg">{t('route.steps')}</h2>
<ol className="usa-list">
{result.segments.map((s, i) => (
<li key={i}>{segmentText(s, i === 0, t)}</li>
@ -199,3 +229,59 @@ function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI1
});
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
}
function Variants({ options, selected, onSelect }: { options: RouteOption[]; selected: number; onSelect: (i: number) => void }) {
const { t } = useI18n();
const rec = options.findIndex((o) => o.recommended);
const sel = options[selected];
const labelKey = (o: RouteOption): Key => (o.id === 'detour' ? 'route.alt.alt2' : `route.alt.${o.id}`) as Key;
return (
<section aria-labelledby="variants-title" className="margin-bottom-3">
<h2 id="variants-title" className="font-sans-lg margin-top-0">{t('route.alt.title')}</h2>
{sel && sel.counts.hard > 0 && rec >= 0 && rec !== selected && (
<div className="usa-alert usa-alert--warning usa-alert--slim margin-bottom-2" role="status">
<div className="usa-alert__body">
<p className="usa-alert__text">
{t('route.alt.warn', { n: sel.counts.hard })}{' '}
<button type="button" className="usa-button usa-button--unstyled" onClick={() => onSelect(rec)}>{t('route.alt.showRec')}</button>
</p>
</div>
</div>
)}
<fieldset className="usa-fieldset">
<legend className="usa-sr-only">{t('route.alt.pick')}</legend>
<ul className="usa-list--unstyled margin-0 padding-0">
{options.map((o, i) => (
<li key={`${o.id}-${i}`} className="margin-bottom-1">
<div className="usa-radio">
<input
className="usa-radio__input usa-radio__input--tile" id={`variant-${i}`} type="radio" name="variant"
checked={selected === i} onChange={() => onSelect(i)}
/>
<label className="usa-radio__label margin-top-0" htmlFor={`variant-${i}`}>
<span className="text-bold">{t(labelKey(o))}</span>
{o.recommended && <span className="usa-tag bg-success-dark text-white margin-left-1">{t('route.alt.recommended')}</span>}
<span className="usa-checkbox__label-description">
{(o.result.distanceM / 1000).toFixed(2)} km · {Math.max(1, Math.round(o.result.durationMin))} min ·{' '}
{i === 0 ? t('route.alt.base') : t('route.alt.overhead', { m: o.overheadM, min: o.overheadMin })}
</span>
<span className="usa-checkbox__label-description margin-top-05">
{o.counts.hard === 0 && o.counts.caution === 0 && o.counts.unknown === 0 ? (
<span className="text-success-dark text-bold">{t('route.alt.clean')}</span>
) : (
<>
<span className={o.counts.hard ? 'text-error-dark text-bold' : ''}>{t('route.alt.hard', { n: o.counts.hard })}</span>{' · '}
<span className={o.counts.caution ? 'text-warning-darker text-bold' : ''}>{t('route.alt.caution', { n: o.counts.caution })}</span>{' · '}
<span>{t('route.alt.unknown', { n: o.counts.unknown })}</span>
</>
)}
</span>
</label>
</div>
</li>
))}
</ul>
</fieldset>
</section>
);
}

View file

@ -0,0 +1,60 @@
import { useRef } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import type { Settings } from '../lib/settings';
import { Alert } from './ui';
interface Props {
settings: Settings;
onSettings: (s: Settings) => void;
favoritesCount: number;
onExport: () => void;
onImport: (file: File) => void;
message: { kind: 'success' | 'error'; text: string } | null;
}
export function SettingsPanel({ settings, onSettings, favoritesCount, onExport, onImport, message }: Props) {
const { t } = useI18n();
const file = useRef<HTMLInputElement>(null);
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => onSettings({ ...settings, [k]: v });
return (
<section aria-labelledby="settings-title" className="margin-top-5 border-top-1px border-base-lighter padding-top-3">
<h2 id="settings-title" className="margin-top-0">{t('set.title')}</h2>
<p className="text-base-dark" role="status">{t('set.saved')}</p>
<fieldset className="usa-fieldset">
<legend className="usa-legend text-bold">{t('set.display')}</legend>
<label className="usa-label" htmlFor="set-size">{t('set.textSize')}</label>
<select id="set-size" className="usa-select" value={settings.textScale} onChange={(e) => set('textScale', Number(e.target.value) as Settings['textScale'])}>
{([100, 125, 150] as const).map((n) => <option key={n} value={n}>{t(`set.textSize.${n}` as Key)} ({n}%)</option>)}
</select>
<div className="usa-checkbox margin-top-2">
<input className="usa-checkbox__input" id="set-contrast" type="checkbox" checked={settings.highContrast} onChange={(e) => set('highContrast', e.target.checked)} aria-describedby="set-contrast-h" />
<label className="usa-checkbox__label" htmlFor="set-contrast">{t('set.contrast')}</label>
</div>
<p id="set-contrast-h" className="usa-hint margin-top-05">{t('set.contrast.help')}</p>
<div className="usa-checkbox">
<input className="usa-checkbox__input" id="set-motion" type="checkbox" checked={settings.reduceMotion} onChange={(e) => set('reduceMotion', e.target.checked)} />
<label className="usa-checkbox__label" htmlFor="set-motion">{t('set.motion')}</label>
</div>
<div className="usa-checkbox">
<input className="usa-checkbox__input" id="set-demo" type="checkbox" checked={settings.showDemoPlaces} onChange={(e) => set('showDemoPlaces', e.target.checked)} aria-describedby="set-demo-h" />
<label className="usa-checkbox__label" htmlFor="set-demo">{t('set.demoPlaces')}</label>
</div>
<p id="set-demo-h" className="usa-hint margin-top-05">{t('set.demoPlaces.help')}</p>
<label className="usa-label" htmlFor="set-lang">{t('set.language')}</label>
<select id="set-lang" className="usa-select" value={settings.lang} onChange={(e) => set('lang', e.target.value as Settings['lang'])}>
<option value="pl">Polski</option>
<option value="en">English</option>
</select>
</fieldset>
<h3 className="margin-top-4">{t('set.data')}</h3>
<p>{t('set.favorites', { n: favoritesCount })}</p>
<button type="button" className="usa-button usa-button--outline" onClick={onExport}>{t('set.export')}</button>
<button type="button" className="usa-button usa-button--outline" onClick={() => file.current?.click()}>{t('set.import')}</button>
<input ref={file} type="file" accept="application/json,.json" className="usa-sr-only" tabIndex={-1} aria-hidden="true"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ''; }} />
<div aria-live="polite" className="margin-top-1">{message && <Alert kind={message.kind} slim>{message.text}</Alert>}</div>
</section>
);
}

View file

@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../lib/i18n';
import type { Confidence, Fact, SourceKind, Status, Verdict } from '../lib/types';
import type { Confidence, Fact, SourceKind, Status, Verdict, VetState } from '../lib/types';
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
@ -98,7 +98,7 @@ export function FactCard({ fact, onReport }: { fact: Fact; onReport?: (() => voi
return (
<li className="border-1px border-base-lighter radius-md padding-2 margin-bottom-2 bg-white">
<div className="display-flex flex-justify flex-wrap flex-align-center">
<h4 className="margin-0 font-sans-md">{t(`fact.title.${fact.key}`)}</h4>
<h3 className="margin-0 font-sans-md">{t(`fact.title.${fact.key}`)}</h3>
<StatusLabel status={fact.status} />
</div>
<p className="margin-top-1 margin-bottom-0">{tm(fact.headline)}</p>
@ -136,3 +136,74 @@ export function Alert({ kind, heading, children, slim = false, role }: { kind: '
</div>
);
}
/** Accessible help bubble: hover, focus or click opens it; Escape or blur closes it (WCAG 1.4.13). */
export function InfoTip({ topic, text }: { topic: string; text: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [hover, setHover] = useState(false);
const id = useId();
const show = open || hover;
const bubble = useRef<HTMLSpanElement>(null);
const [shift, setShift] = useState(0);
useLayoutEffect(() => {
const el = bubble.current;
if (!show || !el) return;
el.style.transform = '';
const r = el.getBoundingClientRect();
const over = r.right - (window.innerWidth - 8);
setShift(over > 0 ? -Math.min(over, r.left - 8) : 0);
}, [show]);
return (
<span className="kbb-tip" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<button
type="button" className="kbb-tip__btn" aria-expanded={show} aria-controls={id} aria-label={t('tip.label', { topic })}
onClick={() => setOpen((o) => !o)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setHover(false); } }}
>
<Icon name="help_outline" size={3} />
</button>
<span id={id} ref={bubble} role="tooltip" className="kbb-tip__bubble" hidden={!show} style={{ transform: `translateX(${shift}px)` }}>{text}</span>
</span>
);
}
const TRIAGE_STYLE = { 'fast-track': 'bg-primary-dark text-white', review: 'bg-base-lighter text-ink', 'likely-reject': 'bg-base-dark text-white' } as const;
const SEVERITY_STYLE = { critical: 'bg-error-dark text-white', major: 'bg-accent-warm-darker text-white', minor: 'bg-warning text-ink', none: 'bg-base-lighter text-ink' } as const;
/** Automatic first assessment of a report. Always framed as advisory. */
export function VettingSummary({ v, detailed = false }: { v: VetState | undefined; detailed?: boolean }) {
const { t, tm } = useI18n();
if (!v) return null;
if (v.state === 'running') return <p className="font-sans-xs margin-y-05" role="status"><Icon name="update" size={2} className="margin-right-05" />{t('vet.running')}</p>;
if (v.state === 'failed') {
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : 'vet.error')}</p>;
}
const r = v.result;
return (
<div className="margin-y-05">
<p className="margin-y-05 display-flex flex-wrap flex-align-center">
<span className={`usa-tag margin-right-05 ${TRIAGE_STYLE[r.triage]}`}>{t(`vet.triage.${r.triage}`)}</span>
<span className={`usa-tag margin-right-05 ${SEVERITY_STYLE[r.severity]}`}>{t('vet.severityLabel')}: {t(`vet.severity.${r.severity}`)}</span>
<span className="usa-tag bg-base-lighter text-ink margin-right-05">{t(`vet.validity.${r.validity}`)}</span>
{r.flags.map((f) => <span key={f} className="usa-tag bg-warning text-ink margin-right-05">{t(`vet.flag.${f}`)}</span>)}
</p>
{detailed && (
<>
<p className="font-sans-xs margin-y-05">
{t('vet.classLabel')}: <strong>{t(`vet.class.${r.accessibilityClass}`)}</strong> · {t(`vet.consistency.${r.consistency}`)} · {t('vet.confidence', { pct: Math.round(r.confidence * 100) })}
</p>
<details className="font-sans-xs">
<summary>{t('dash.queue.reasons')}</summary>
<ul className="usa-list">
{r.reasons.map((m, i) => <li key={i}>{tm(m)}</li>)}
</ul>
<p className="text-base-dark margin-y-05">{t('vet.provider', { name: r.provider })}</p>
</details>
<p className="font-sans-3xs text-base-dark margin-y-05">{t('vet.disclaimer')}</p>
</>
)}
</div>
);
}

View file

@ -37,7 +37,7 @@ export function buildIndex(snapshot: Snapshot): Index {
const pt = (p: { lat: number; lon: number }): LatLon => [p.lat, p.lon];
function nearbyWays(ix: Index, p: LatLon, radius: number, pred: (w: Way) => boolean) {
export function nearbyWays(ix: Index, p: LatLon, radius: number, pred: (w: Way) => boolean) {
const seen = new Map<Way, number>();
for (const s of ix.segs.near(p[0], p[1], radius + 70)) {
if (!pred(s.way)) continue;
@ -262,6 +262,26 @@ export function assessPlace(
}
}
// ---- on-site measurements attached to reports ----
const applyMeasured = (key: 'steps' | 'ramp', attr: 'steps' | 'ramp', kind: 'kerb' | 'incline') => {
const rep = latestReport(reports, place.id, attr);
const m = rep?.measure;
const idx = facts.findIndex((f) => f.key === key);
if (!rep || !m || m.kind !== kind || idx < 0) return;
const limit = kind === 'kerb' ? profile.maxKerbCm : profile.maxInclinePct;
const over = m.value > limit;
const accepted = rep.status === 'accepted';
facts[idx] = {
key, status: over ? (accepted ? 'fail' : 'warn') : 'ok',
headline: { k: kind === 'kerb' ? 'f.steps.measured' : 'f.ramp.measured', p: kind === 'kerb' ? { cm: m.value, max: limit } : { pct: m.value, max: limit } },
detail: { k: 'f.measured.method', p: { method: `@vet.method.${m.method}` } },
source: accepted ? 'city-review' : 'user-report', confidence: accepted ? 'verified' : 'user',
date: (accepted ? rep.reviewedAt ?? rep.createdAt : rep.createdAt).slice(0, 10), required: over && accepted,
};
};
applyMeasured('steps', 'steps', 'kerb');
applyMeasured('ramp', 'ramp', 'incline');
return { placeId: place.id, verdict: verdictOf(facts), facts };
}

24
src/lib/auth.ts Normal file
View file

@ -0,0 +1,24 @@
// Demo-only identity. There is no server and no credential: picking an account just selects
// which local storage namespace and role the prototype uses. Production would use OIDC/SSO.
import type { ProfileKind } from './types';
export type Role = 'user' | 'official' | 'guest';
export interface Account {
id: string;
role: Role;
/** i18n key prefix: auth.<id>, auth.<id>.desc */
preset: ProfileKind | null;
}
export const ACCOUNTS: Account[] = [
{ id: 'anna', role: 'user', preset: 'wheelchair' },
{ id: 'piotr', role: 'user', preset: 'stroller' },
{ id: 'maria', role: 'official', preset: null },
];
export const GUEST: Account = { id: 'guest', role: 'guest', preset: null };
export const findAccount = (id: unknown): Account | null =>
typeof id === 'string' ? ACCOUNTS.find((a) => a.id === id) ?? (id === GUEST.id ? GUEST : null) : null;
export const userKey = (accountId: string, what: 'profile' | 'settings' | 'favorites') => `kbb.u.${accountId}.${what}`;

133
src/lib/civic.ts Normal file
View file

@ -0,0 +1,133 @@
// Aggregates for the city-office panel and open-data export. Pure functions over assessments.
import { Grid, haversine, type LatLon } from './geo';
import { nearbyWays, type Index } from './assess';
import { kerbHeightCm } from './osm';
import type { Assessment, Place, PlaceCategory, Snapshot } from './types';
export interface CatRow { cat: PlaceCategory; total: number; known: number; stale: number; unknown: number }
export interface Stats {
total: number;
entranceKnown: number;
fresh: number;
verified: number;
transitTotal: number;
transitOperatorKnown: number;
transitCommunityKnown: number;
byCat: CatRow[];
}
const entranceOf = (a: Assessment) => a.facts.find((f) => f.key === 'entrance');
export function computeStats(places: Place[], assessments: Map<string, Assessment>): Stats {
const real = places.filter((p) => !p.demo);
const rows = new Map<PlaceCategory, CatRow>();
let entranceKnown = 0, fresh = 0, verified = 0;
for (const p of real) {
const f = assessments.get(p.id) && entranceOf(assessments.get(p.id)!);
if (!f) continue;
const row = rows.get(p.cat) ?? { cat: p.cat, total: 0, known: 0, stale: 0, unknown: 0 };
row.total++;
if (f.status === 'unknown' || f.confidence === 'none') row.unknown++;
else if (f.confidence === 'stale') row.stale++;
else row.known++;
rows.set(p.cat, row);
if (f.status !== 'unknown') entranceKnown++;
if (f.confidence === 'verified' || f.confidence === 'mapped') fresh++;
if (f.confidence === 'verified') verified++;
}
const transit = real.filter((p) => p.ext);
return {
total: real.length, entranceKnown, fresh, verified,
transitTotal: transit.length,
transitOperatorKnown: transit.filter((p) => p.ext?.gtfsWheelchair === 1 || p.ext?.gtfsWheelchair === 2).length,
transitCommunityKnown: transit.filter((p) => !!p.tags.wheelchair).length,
byCat: [...rows.values()].sort((a, b) => b.total - a.total),
};
}
export interface Hotspot {
id: number;
at: LatLon;
street: string | null;
kerb: 'raised' | 'unknown';
near: number;
score: number;
/** Other flagged crossings on the same street (collapsed into this row). */
more: number;
}
/** Crossings with a high or unknown kerb, ranked by how many places and stops they serve. */
export function computeHotspots(snapshot: Snapshot, ix: Index, places: Place[], limit = 10): Hotspot[] {
const grid = new Grid<Place>();
for (const p of places) if (!p.demo) grid.add(p.lat, p.lon, p);
const out: Hotspot[] = [];
for (const w of snapshot.ways) {
if (w.footway !== 'crossing' || w.nodes.length < 2) continue;
const ends = [w.nodes[0]!, w.nodes[w.nodes.length - 1]!].map((n) => snapshot.kerbs[n]);
const heights = ends.map(kerbHeightCm);
const raised = heights.some((h) => h !== null && h > 3);
const unknown = heights.filter((h) => h !== null).length < 2;
if (!raised && !unknown) continue;
const mid = snapshot.nodes[w.nodes[Math.floor(w.nodes.length / 2)]!]!;
const near = new Set(grid.near(mid[0], mid[1], 120).filter((p) => haversine(mid, [p.lat, p.lon]) <= 120)).size;
if (near === 0) continue;
const street = nearbyWays(ix, mid, 35, (x) => !!x.name && x.footway !== 'crossing')[0]?.way.name ?? null;
out.push({ id: w.id, at: mid, street, kerb: raised ? 'raised' : 'unknown', near, score: near * (raised ? 2 : 1), more: 0 });
}
const best = new Map<string, Hotspot>();
for (const h of out.sort((a, b) => b.score - a.score)) {
const key = h.street ?? `#${h.id}`;
const cur = best.get(key);
if (cur) cur.more++;
else best.set(key, h);
}
return [...best.values()].slice(0, limit);
}
// ---------------- export ----------------
const COLS = ['id', 'name', 'category', 'kind', 'lat', 'lon', 'entrance_status', 'entrance_confidence', 'entrance_source', 'entrance_date', 'steps_status', 'toilet_status', 'licence'] as const;
function row(p: Place, a: Assessment | undefined): Record<(typeof COLS)[number], string | number> {
const f = (k: string) => a?.facts.find((x) => x.key === k);
const e = f('entrance');
return {
id: p.id, name: p.name ?? '', category: p.cat, kind: p.kind ?? '', lat: p.lat, lon: p.lon,
entrance_status: e?.status ?? 'unknown', entrance_confidence: e?.confidence ?? 'none', entrance_source: e?.source ?? 'none',
entrance_date: e?.date ?? '', steps_status: f('steps')?.status ?? '', toilet_status: f('toilet')?.status ?? '',
licence: 'ODbL 1.0 (c) OpenStreetMap contributors; ZTP Krakow GTFS',
};
}
const esc = (v: string | number) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
export function toCsv(places: Place[], assessments: Map<string, Assessment>): string {
const lines = [COLS.join(',')];
for (const p of places) if (!p.demo) lines.push(COLS.map((c) => esc(row(p, assessments.get(p.id))[c])).join(','));
return lines.join('\n');
}
export function toGeoJson(places: Place[], assessments: Map<string, Assessment>) {
return {
type: 'FeatureCollection' as const,
features: places.filter((p) => !p.demo).map((p) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] },
properties: row(p, assessments.get(p.id)),
})),
};
}
export function download(name: string, mime: string, content: string) {
const url = URL.createObjectURL(new Blob([content], { type: `${mime};charset=utf-8` }));
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

View file

@ -27,6 +27,14 @@ export const DEMO_PLACES: (Place & { demoState: DemoState })[] = [
];
export const DEMO_REPORTS: UserReport[] = [
{
id: 'demo-report-2', placeId: 'demo-nodata', attr: 'entrance', value: 'yes', demo: true, authorId: 'anna',
note: 'Przykład: wejście z poziomu chodnika, drzwi bez progu.', createdAt: '2026-09-30T12:00:00Z',
},
{
id: 'demo-report-3', placeId: 'demo-nodata', attr: 'width', value: 85, demo: true, authorId: 'piotr',
note: 'Przykład: zmierzone 85 cm w świetle drzwi.', createdAt: '2026-10-01T08:30:00Z',
},
{
id: 'demo-report-1', placeId: 'demo-conflict', attr: 'entrance', value: 'no', demo: true,
note: 'Przykład: przy wejściu są dwa stopnie, brak podjazdu.', createdAt: '2026-09-28T10:00:00Z',

103
src/lib/guide.ts Normal file
View file

@ -0,0 +1,103 @@
// Turn-by-turn guidance maths over a computed route. Pure functions, unit tested.
import { haversine, type LatLon } from './geo';
import type { Barrier, Flag, RouteResult, Turn } from './route';
export interface RoutePath {
coords: LatLon[];
/** cumulative metres at each coord */
cum: number[];
total: number;
}
export function buildPath(coords: LatLon[]): RoutePath {
const cum = [0];
for (let i = 1; i < coords.length; i++) cum.push(cum[i - 1]! + haversine(coords[i - 1]!, coords[i]!));
return { coords, cum, total: cum[cum.length - 1] ?? 0 };
}
const rad = (d: number) => (d * Math.PI) / 180;
/** Nearest point of the path to `pos`: distance along the path and lateral offset in metres. */
export function project(path: RoutePath, pos: LatLon): { along: number; off: number } {
let best = { along: 0, off: Infinity };
const k = Math.cos(rad(pos[0]));
for (let i = 0; i < path.coords.length - 1; i++) {
const a = path.coords[i]!, b = path.coords[i + 1]!;
const ax = a[1] * k, ay = a[0], bx = b[1] * k, by = b[0];
const px = pos[1] * k, py = pos[0];
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
const cx = ax + t * dx, cy = ay + t * dy;
const off = Math.hypot(px - cx, py - cy) * (Math.PI / 180) * 6371008.8;
if (off < best.off) best = { off, along: path.cum[i]! + t * (path.cum[i + 1]! - path.cum[i]!) };
}
return best;
}
/** Point at distance `along` metres from the start. */
export function pointAt(path: RoutePath, along: number): LatLon {
const d = Math.max(0, Math.min(path.total, along));
for (let i = 1; i < path.cum.length; i++) {
if (path.cum[i]! >= d) {
const span = path.cum[i]! - path.cum[i - 1]!;
const t = span === 0 ? 0 : (d - path.cum[i - 1]!) / span;
const a = path.coords[i - 1]!, b = path.coords[i]!;
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
}
return path.coords[path.coords.length - 1] ?? [0, 0];
}
export interface Maneuver { at: number; turn: Turn; name: string | null; segment: number }
/** Start distance of every segment, in order. */
export function maneuvers(result: RouteResult, path: RoutePath): Maneuver[] {
const out: Maneuver[] = [];
let acc = 0;
result.segments.forEach((s, i) => {
out.push({ at: acc, turn: i === 0 ? 'start' : s.turn, name: s.name, segment: i });
for (let k = 1; k < s.coords.length; k++) acc += haversine(s.coords[k - 1]!, s.coords[k]!);
});
// keep the path total as the single source of truth for the end
void path;
return out;
}
export interface BarrierAhead { flag: Flag; at: number; name: string | null; hard: boolean }
export function barrierPositions(path: RoutePath, barriers: Barrier[], hard: ReadonlySet<Flag>): BarrierAhead[] {
return barriers
.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps')
.map((b) => ({ flag: b.flag, name: b.name, hard: hard.has(b.flag), at: project(path, b.at).along }))
.sort((a, b) => a.at - b.at);
}
export interface GuideState {
along: number;
off: number;
remaining: number;
arrived: boolean;
offRoute: boolean;
next: { maneuver: Maneuver; distance: number } | null;
barrier: { b: BarrierAhead; distance: number } | null;
}
export const OFF_ROUTE_M = 35;
export const ARRIVED_M = 20;
export const BARRIER_LOOKAHEAD_M = 80;
/** Next real turn (not "straight") ahead of the user, nearest barrier ahead, remaining distance. */
export function guideState(path: RoutePath, mans: Maneuver[], barriers: BarrierAhead[], pos: LatLon): GuideState {
const { along, off } = project(path, pos);
const remaining = Math.max(0, path.total - along);
const nextM = mans.find((m) => m.at > along + 5 && m.turn !== 'straight' && m.turn !== 'start');
const barrier = barriers.find((b) => b.at >= along - 5 && b.at - along <= BARRIER_LOOKAHEAD_M);
return {
along, off, remaining,
arrived: remaining <= ARRIVED_M,
offRoute: off > OFF_ROUTE_M,
next: nextM ? { maneuver: nextM, distance: Math.max(0, nextM.at - along) } : null,
barrier: barrier ? { b: barrier, distance: Math.max(0, barrier.at - along) } : null,
};
}

View file

@ -9,7 +9,7 @@ const pl = {
'nav.label': 'Główna nawigacja',
'nav.places': 'Miejsca',
'nav.route': 'Trasa',
'nav.about': 'O danych',
'nav.about': 'Dane',
'nav.profile': 'Profil',
'lang.label': 'Język',
'skip': 'Przejdź do treści',
@ -220,6 +220,7 @@ const pl = {
'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.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',
@ -285,7 +286,7 @@ const pl = {
'about.data': 'Źródła danych i ich wiarygodność',
'about.data.body1': 'Mapa, miejsca, schody, krawężniki, nawierzchnie, ławki i toalety pochodzą z OpenStreetMap (licencja ODbL, wymagane oznaczenie źródła). Pobieramy je przez publiczne API Overpass dla obszaru demonstracyjnego i zapisujemy jako migawkę z datą w aplikacji.',
'about.data.body2': 'Data przy informacji to data ostatniej edycji obiektu w OSM, o ile nie ma daty kontroli (check_date, survey:date). Edycja nie zawsze dotyczyła danych o dostępności — dlatego bez daty kontroli nigdy nie nazywamy danych „potwierdzonymi”.',
'about.data.body3': 'Dane miejskie (Otwarte Dane Krakowa, MSIP) nie są jeszcze wpięte w prototyp. Architektura przewiduje je jako kolejny adapter; przed użyciem trzeba sprawdzić licencję każdego zbioru.',
'about.data.body3': 'Dane miejskie: przystanki pochodzą z oficjalnego strumienia GTFS ZTP Kraków (pole wheelchair_boarding). Ma ono dziś wartość „brak informacji” dla każdego przystanku w obszarze — pokazujemy to wprost i uzupełniamy opisem społeczności z OSM. Kolejne zbiory (Otwarte Dane Krakowa, MSIP) to kolejne adaptery; licencję każdego trzeba sprawdzić przed użyciem.',
'about.legend': 'Poziomy wiarygodności',
'about.missing': 'Co widzisz, gdy danych brakuje lub są sprzeczne',
'about.missing.body': 'Brak informacji pokazujemy jako „Brak danych” z szarą ikoną znaku zapytania — nigdy jako dostępność. Gdy zgłoszenie użytkownika przeczy OSM, miejsce dostaje status „Sprzeczne dane” i oba stanowiska są widoczne. Gdy nie da się odświeżyć źródła, widzisz datę ostatniego zapisu i ostrzeżenie.',
@ -300,7 +301,7 @@ const pl = {
'about.biz': 'Model biznesowy i skalowanie',
'about.biz.body': 'Bezpłatna aplikacja dla mieszkańców i turystów. Przychód z usług B2B: (1) widżet i raport „dostępność obiektu” dla hoteli, organizatorów wydarzeń i zarządców nieruchomości, (2) API dostępności dla systemów rezerwacyjnych i aplikacji turystycznych, (3) pakiet wdrożeniowy dla miast. Koszty to hosting statyczny, okresowe pobieranie z Overpass i moderacja zgłoszeń. Za produkt odpowiada podmiot prowadzący usługę, nie UMK.',
'about.limits': 'Znane ograniczenia prototypu',
'about.limits.body': 'Obszar: Stare Miasto, Kazimierz, Wawel. Zgłoszenia tylko lokalnie. Brak danych miejskich. Wysokość krawężników znana tylko dla części przejść. Szerokość wejść prawie nigdy nie jest zmapowana. Czasy przejścia to szacunki. Kafelki mapy z serwera OSM nadają się do demonstracji — produkcja wymaga własnego dostawcy kafelków.',
'about.limits.body': 'Obszar: Stare Miasto, Kazimierz, Wawel. Logowanie i zgłoszenia są demonstracyjne i lokalne (bez serwera). Dane miejskie obejmują na razie tylko przystanki. Wysokość krawężników znana tylko dla części przejść. Szerokość wejść prawie nigdy nie jest zmapowana. Czasy przejścia to szacunki. Kafelki mapy z serwera OSM nadają się do demonstracji — produkcja wymaga własnego dostawcy kafelków.',
'about.demo': 'Kontrolki demonstracyjne',
'about.simulate': 'Symuluj niedostępność źródła danych (OSM)',
'about.simulate.help': 'Odświeżenie miejsca zakończy się błędem — pokazuje, co zobaczy użytkownik.',
@ -314,6 +315,345 @@ const pl = {
'common.noName': 'Miejsce bez nazwy',
'common.km': 'km',
'footer.attr': 'Dane © autorzy OpenStreetMap (ODbL).',
'src.city-gtfs': 'Dane miejskie: ZTP Kraków (GTFS)',
'src.city-review': 'Zweryfikowane przez urząd (demo)',
'f.entrance.cityNone': 'Przewoźnik nie podaje informacji o dostępności tego przystanku.',
'f.entrance.cityNoneHint': 'W oficjalnych danych ZTP pole „wheelchair_boarding” ma wartość „brak informacji”. Nie oznacza to, że przystanek jest niedostępny ani że jest dostępny.',
'nav.dashboard': 'Panel urzędu',
'nav.settings': 'Ustawienia',
'nav.account': 'Konto',
'auth.title': 'Zaloguj się',
'auth.intro': 'To logowanie demonstracyjne. Nie ma haseł ani serwera — wybierz konto pokazowe. Ustawienia i profil zapisują się tylko w tej przeglądarce, osobno dla każdego konta.',
'auth.demoBadge': 'KONTO DEMO',
'auth.guest': 'Kontynuuj bez konta',
'auth.guest.hint': 'Ustawienia też będą zapamiętane na tym urządzeniu.',
'auth.signIn': 'Zaloguj jako {name}',
'auth.signOut': 'Wyloguj',
'auth.signedInAs': 'Zalogowano: {name}',
'auth.role.user': 'Mieszkaniec',
'auth.role.official': 'Pracownik urzędu',
'auth.role.guest': 'Gość',
'auth.anna': 'Anna Kowalska',
'auth.anna.desc': 'Mieszkanka, porusza się na wózku ręcznym.',
'auth.piotr': 'Piotr Nowak',
'auth.piotr.desc': 'Rodzic, spaceruje z dzieckiem w wózku.',
'auth.maria': 'Maria Wiśniewska',
'auth.maria.desc': 'Urząd miasta (rola demonstracyjna): panel jakości danych, kolejka zgłoszeń, eksport.',
'auth.official.note': 'Rola urzędnika odblokowuje „Panel urzędu”. W wersji produkcyjnej logowanie przez konto służbowe (SSO).',
'set.title': 'Ustawienia',
'set.saved': 'Zapisano automatycznie na tym urządzeniu.',
'set.display': 'Wygląd i czytelność',
'set.textSize': 'Rozmiar tekstu',
'set.textSize.100': 'Standardowy',
'set.textSize.125': 'Większy',
'set.textSize.150': 'Największy',
'set.contrast': 'Wysoki kontrast',
'set.contrast.help': 'Mocniejsze obramowania i ciemniejszy tekst.',
'set.motion': 'Ogranicz animacje mapy',
'set.language': 'Język',
'set.demoPlaces': 'Pokaż fikcyjne miejsca DEMO',
'set.demoPlaces.help': 'Pokazują stany danych (potwierdzone, sprzeczne, nieaktualne, brak). Wyłącz, by widzieć tylko prawdziwe miejsca.',
'set.account': 'Konto',
'set.data': 'Twoje dane',
'set.export': 'Pobierz ustawienia (JSON)',
'set.import': 'Wczytaj ustawienia z pliku',
'set.import.bad': 'Plik nie wygląda na ustawienia z tej aplikacji.',
'set.import.ok': 'Ustawienia wczytane.',
'set.favorites': 'Zapisane miejsca: {n}',
'profile.step1': 'Jak się poruszasz?',
'profile.step2': 'Jaki masz sprzęt?',
'profile.step3': 'Twoje granice',
'profile.step3.help': 'Ruszaj suwakami. Trasy i oceny miejsc przeliczają się od razu.',
'profile.step4': 'Co jeszcze jest ważne?',
'profile.preset.manual': 'Wózek ręczny',
'profile.preset.manual.d': 'Standardowa szerokość, radzi sobie z niskim krawężnikiem.',
'profile.preset.power': 'Wózek elektryczny',
'profile.preset.power.d': 'Cięższy i szerszy — ważne wąskie przejścia i nachylenia.',
'profile.preset.large': 'Skuter lub duży wózek',
'profile.preset.large.d': 'Potrzebuje szerokich wejść i płaskiego terenu.',
'profile.preset.single': 'Wózek standardowy',
'profile.preset.single.d': 'Typowy wózek spacerowy lub gondola.',
'profile.preset.twin': 'Bliźniaczy lub szeroki',
'profile.preset.twin.d': 'Szerszy — kłopot z wąskimi drzwiami i przejściami.',
'profile.preset.light': 'Lekka spacerówka',
'profile.preset.light.d': 'Łatwo wnieść po 1–2 stopniach, ale nie z ciężkim dzieckiem.',
'profile.preset.custom': 'Własne ustawienia',
'profile.preset.apply': 'Wybierz: {name}',
'profile.summary': 'Co to oznacza',
'profile.summary.text': 'Omijam schody i krawężniki wyższe niż {kerb} cm. Potrzebuję przejść szerszych niż {width} cm i unikam podejść stromszych niż {incline}%.',
'profile.summary.cobbles': ' Staram się unikać kostki brukowej.',
'profile.summary.toilet': ' Szukam dostępnej toalety w pobliżu.',
'profile.summary.rest': ' Chcę mieć miejsca do odpoczynku.',
'profile.summary.changing': ' Potrzebuję przewijaka.',
'profile.preview': 'Podgląd wyników w obszarze demonstracyjnym',
'profile.preview.n': '{n} {label}',
'profile.autosave': 'Zmiany zapisują się od razu.',
'profile.done': 'Gotowe — szukaj miejsc',
'profile.welcome': 'Zacznijmy od Ciebie',
'tip.label': 'Wyjaśnienie: {topic}',
'tip.kerb': 'Krawężnik to pionowy uskok między chodnikiem a jezdnią. Obniżony krawężnik ma ok. 3 cm, „równo z jezdnią” to 0 cm, zwykły ok. 8–15 cm.',
'tip.width': 'Najmniejsza szerokość drzwi lub przejścia, przez którą się mieścisz. Standardowe drzwi wejściowe mają ok. 90 cm, wewnętrzne 80 cm.',
'tip.incline': 'Nachylenie w procentach: 8% to 8 cm w górę na każdy metr. Norma dla podjazdów to najwyżej 6–8%.',
'tip.cobbles': 'Kostka brukowa jest nierówna: spowalnia, trzęsie i męczy. Trasa wybierze dłuższą drogę po gładkiej nawierzchni, jeśli taka jest w danych.',
'tip.toilet': 'Pokażemy toalety oznaczone w OSM jako dostępne dla wózka — w obiekcie lub publiczne w promieniu 400 m.',
'tip.rest': 'Ławki zmapowane w OSM w promieniu 150 m od miejsca.',
'tip.changing': 'Przewijak w obiekcie lub w pobliskiej toalecie publicznej, jeśli jest zmapowany.',
'tip.preset': 'Presety ustawiają typowe granice. Zawsze możesz je zmienić suwakami.',
'hint.kerb.0': 'Tylko przejścia równo z jezdnią.',
'hint.kerb.3': 'Obniżony krawężnik (ok. 3 cm).',
'hint.kerb.6': 'Niski krawężnik; zwykły (8 cm i więcej) zostanie ominięty.',
'hint.kerb.10': 'Prawie każdy krawężnik jest w porządku.',
'hint.width.65': 'Bardzo wąskie przejścia są w porządku.',
'hint.width.80': 'Mieścisz się w typowych drzwiach wewnętrznych (80 cm).',
'hint.width.90': 'Potrzebujesz co najmniej standardowych drzwi wejściowych (90 cm).',
'hint.width.100': 'Potrzebujesz szerokich drzwi i przejść.',
'hint.incline.5': 'Tylko łagodne pochylenia (do 1:20).',
'hint.incline.8': 'Typowy podjazd (do 1:12, czyli 8%).',
'hint.incline.12': 'Dajesz radę na stromych podjazdach.',
'verdict.count.match': 'pasuje',
'verdict.count.partial': 'częściowo',
'verdict.count.conflict': 'sprzeczne',
'verdict.count.incomplete': 'za mało danych',
'verdict.count.fail': 'nie pasuje',
'detail.save': 'Zapisz to miejsce',
'detail.unsave': 'Usuń z zapisanych',
'detail.saved': 'Zapisane',
'places.onlySaved': 'Tylko zapisane miejsca',
'detail.card': 'Karta dostępności (drukuj)',
'detail.reach': 'Co jest w zasięgu bez barier?',
'cat.transit.long': 'Przystanki',
'card.title': 'Karta dostępności',
'card.for': 'Ocena dla profilu: {profile}',
'card.generated': 'Wygenerowano {date} z danych OpenStreetMap i ZTP Kraków. To informacja pomocnicza, nie gwarancja.',
'card.print': 'Drukuj lub zapisz jako PDF',
'card.close': 'Zamknij kartę',
'card.legend': 'Wiarygodność: potwierdzone = ma datę kontroli; zmapowane = wpis w OSM; mogło się zmienić = starszy niż 24 mies.; wywnioskowane = z obiektów w pobliżu.',
'reach.title': 'Zasięg bez barier',
'reach.intro': 'Dokąd dojdziesz stąd bez schodów i barier, według Twojego profilu — w czasie:',
'reach.minutes': '{n} min',
'reach.result': 'W {min} min dojdziesz do {n} miejsc, z czego {fit} pasuje lub częściowo pasuje do Twojego profilu.',
'reach.result.zero': 'W tym czasie nie dojdziesz stąd do żadnego zmapowanego miejsca bez bariery.',
'reach.stuck': 'To miejsce jest odcięte od zmapowanej sieci chodników — zasięgu nie da się wyliczyć.',
'reach.show': 'Zasięg jest zaznaczony na mapie.',
'reach.clear': 'Ukryj zasięg',
'reach.caveat': 'Liczone po zmapowanych chodnikach. Niezmapowane przejścia mogą skrócić lub wydłużyć realny zasięg.',
'dash.title': 'Panel urzędu',
'dash.intro': 'Jakość i kompletność danych o dostępności — wyliczona na żywo z otwartych źródeł. Bez ręcznego utrzymywania bazy przez miasto.',
'dash.role': 'Dostępne tylko dla roli „Pracownik urzędu”. Zaloguj się kontem demo, aby zobaczyć panel.',
'dash.kpi.places': 'Miejsc w obszarze',
'dash.kpi.entrance': 'Z opisanym wejściem',
'dash.kpi.fresh': 'Z danymi nie starszymi niż 24 mies.',
'dash.kpi.verified': 'Potwierdzone datą kontroli',
'dash.kpi.stops': 'Przystanki z danymi przewoźnika o dostępności',
'dash.kpi.stopsOsm': 'Przystanki opisane przez społeczność (OSM)',
'dash.kpi.pending': 'Zgłoszenia czekające na decyzję',
'dash.insight': 'Wniosek: oficjalny strumień GTFS ZTP nie zawiera informacji o dostępności żadnego przystanku w tym obszarze ({n} z {total}), a społeczność OSM opisała {osm}. Najtańsza poprawa to uzupełnienie pola wheelchair_boarding w danych przewoźnika.',
'dash.gaps': 'Luki w danych według rodzaju miejsca',
'dash.gaps.cat': 'Rodzaj',
'dash.gaps.total': 'Miejsca',
'dash.gaps.known': 'Opisane',
'dash.gaps.stale': 'Nieaktualne',
'dash.gaps.unknown': 'Brak danych',
'dash.gaps.caption': 'Udział miejsc w kategorii według stanu wiedzy o wejściu.',
'dash.hot': 'Co naprawić najpierw: przejścia dla pieszych',
'dash.hot.intro': 'Przejścia z wysokim krawężnikiem albo nieznaną jego wysokością, uszeregowane według liczby miejsc i przystanków w promieniu 120 m. Dane pochodzą z OSM.',
'dash.hot.rank': 'Miejsce w rankingu',
'dash.hot.kerbRaised': 'Wysoki krawężnik',
'dash.hot.kerbUnknown': 'Wysokość nieznana',
'dash.hot.near': 'Miejsc i przystanków w pobliżu: {n}',
'dash.hot.show': 'Pokaż na mapie',
'dash.hot.none': 'Brak danych do rankingu.',
'dash.queue': 'Kolejka zgłoszeń od użytkowników',
'dash.queue.intro': 'Zgłoszenia są niezweryfikowane. Po zaakceptowaniu uzyskują poziom „potwierdzone” i źródło „urząd”. Odrzucone znikają z ocen.',
'dash.queue.empty': 'Brak zgłoszeń do rozpatrzenia.',
'dash.queue.accept': 'Zatwierdź',
'dash.queue.reject': 'Odrzuć',
'dash.queue.decided': 'Rozpatrzone: {n}',
'dash.queue.place': 'Miejsce',
'dash.queue.status.accepted': 'zatwierdzone',
'dash.queue.status.rejected': 'odrzucone',
'dash.demand': 'Gdzie brakuje danych, a ludzie szukają',
'dash.demand.intro': 'Miejsca często otwierane przez użytkowników, dla których ocena jest „za mało danych”. Licznik jest anonimowy i lokalny (bez identyfikatorów). W produkcji agregowany po stronie serwera z progiem minimalnej liczby osób.',
'dash.demand.none': 'Na razie brak sygnałów. Otwórz kilka miejsc na liście, a pojawią się tutaj.',
'dash.demand.views': 'otwarć: {n}',
'dash.export': 'Eksport otwartych danych',
'dash.export.intro': 'Dane wyliczone z OSM i ZTP, gotowe do publikacji lub wczytania w innych systemach (licencja ODbL — zachowaj oznaczenie źródła).',
'dash.export.csv': 'Pobierz CSV',
'dash.export.geojson': 'Pobierz GeoJSON',
'dash.export.done': 'Plik przygotowany: {name}',
'route.presets.note': 'Prawdziwe miejsca z danych OSM — przy zmianie profilu wynik się zmienia.',
'dash.hot.mapBanner': 'Na mapie: priorytetowe przejścia z rankingu urzędu ({n}).',
'dash.hot.clear': 'Ukryj ranking na mapie',
'verdict.missing': 'Brakuje danych o: {list}.',
'dash.hot.more': 'jeszcze {n} podobnych na tej ulicy',
'route.alt.title': 'Warianty trasy',
'route.alt.pick': 'Wybierz wariant trasy',
'route.alt.shortest': 'Najkrótsza',
'route.alt.balanced': 'Zrównoważona (wg Twojego profilu)',
'route.alt.strict': 'Najmniej trudności',
'route.alt.alt': 'Inna droga',
'route.alt.alt2': 'Inna droga (blisko najkrótszej)',
'route.alt.recommended': 'Zalecana',
'route.alt.base': 'punkt odniesienia',
'route.alt.overhead': '+{m} m, +{min} min względem najkrótszej',
'route.alt.clean': 'Bez zmapowanych trudności',
'route.alt.hard': 'Bariery: {n}',
'route.alt.caution': 'Uwagi: {n}',
'route.alt.unknown': 'Nieznane punkty: {n}',
'route.alt.warn': 'Wybrany wariant ma bariery nie do pokonania w Twoim profilu ({n}).',
'route.alt.showRec': 'Pokaż zalecaną',
'rep.evidence': 'Dowody (opcjonalnie)',
'rep.evidence.help': 'Zdjęcie i pomiar pomagają urzędowi szybciej zweryfikować zgłoszenie.',
'rep.photo.take': 'Zrób zdjęcie',
'rep.photo.choose': 'Wybierz z galerii',
'rep.photo.remove': 'Usuń',
'rep.photo.error': 'Nie udało się wczytać zdjęcia.',
'rep.photo.alt': 'Podgląd dodanego zdjęcia',
'rep.photo.processed': 'Zdjęcie zmniejszone, bez danych o lokalizacji (EXIF usunięty).',
'rep.photo.privacy': 'Nie fotografuj twarzy ani tablic rejestracyjnych. Zdjęcie zostaje na tym urządzeniu w prototypie.',
'rep.measure.kerb': 'Wysokość stopnia lub krawężnika',
'rep.measure.incline': 'Nachylenie podjazdu',
'rep.measure.kerb.tool': 'Zmierz na zdjęciu',
'rep.measure.incline.tool': 'Zmierz telefonem',
'rep.measure.manual': 'Wpisz zmierzoną wartość',
'rep.measure.manual.cm': 'np. 12 (cm)',
'rep.measure.manual.pct': 'np. 9 (%)',
'rep.measure.manual.use': 'Użyj wartości',
'rep.measure.lidar': 'Pomiar LiDAR iPhone’a wymaga aplikacji natywnej — przeglądarka nie ma do niego dostępu. Model danych jest na to przygotowany.',
'rep.ai.note': 'Po wysłaniu zgłoszenie dostaje wstępną ocenę automatyczną, a decyzję zawsze podejmuje pracownik urzędu.',
'measure.use': 'Użyj tej wartości',
'measure.incline.title': 'Nachylenie telefonem',
'measure.incline.s1': 'Połóż telefon ekranem do góry na podjeździe, wzdłuż kierunku spadku.',
'measure.incline.s2': 'Dla dokładności najpierw wyzeruj telefon na równym podłożu.',
'measure.incline.s3': 'Poczekaj, aż wartość się ustabilizuje, i użyj jej.',
'measure.incline.start': 'Włącz czujniki',
'measure.incline.zero': 'Wyzeruj tutaj (poziom)',
'measure.incline.denied': 'Brak zgody na czujniki ruchu. Zezwól w ustawieniach przeglądarki albo wpisz wartość ręcznie.',
'measure.incline.none': 'Przeglądarka nie dostarcza danych z czujników ruchu (np. komputer, albo czujniki są wyłączone). Na iPhonie sprawdź Ustawienia → Safari → Ruch i orientacja. Możesz też wpisać wartość ręcznie.',
'measure.kerb.title': 'Pomiar wysokości na zdjęciu',
'measure.kerb.intro': 'Połóż obok krawężnika przedmiot o znanym wymiarze (np. kartę płatniczą) i zrób zdjęcie z boku, na wprost. Dotknij dwóch końców przedmiotu, potem dolnej i górnej krawędzi stopnia.',
'measure.kerb.ref': 'Przedmiot odniesienia',
'measure.kerb.refCm': 'Długość w cm',
'measure.ref.card': 'Karta płatnicza (dłuższy bok)',
'measure.ref.a4short': 'Kartka A4 (krótszy bok)',
'measure.ref.a4long': 'Kartka A4 (dłuższy bok)',
'measure.ref.ruler': 'Linijka',
'measure.ref.custom': 'Inna długość',
'measure.kerb.tapRef': 'Krok 1: dotknij dwóch końców przedmiotu odniesienia (niebieska linia).',
'measure.kerb.tapKerb': 'Krok 2: dotknij dolnej i górnej krawędzi stopnia (żółta linia).',
'measure.kerb.done': 'Gotowe. Sprawdź wynik albo zacznij od nowa.',
'measure.kerb.reset': 'Zacznij od nowa',
'measure.kerb.canvas': 'Zdjęcie do pomiaru: dotykaj punktów, aby je zaznaczyć',
'measure.kerb.caveat': 'Dokładność ok. ±1–2 cm, o ile zdjęcie jest zrobione na wprost i przedmiot leży w tej samej płaszczyźnie co stopień. Wpisz wartość ręcznie, jeśli masz miarkę.',
'measure.kerb.needPhoto': 'Najpierw dodaj zdjęcie, potem zmierz na nim wysokość.',
'vet.measure.kerb': 'Wysokość',
'vet.measure.incline': 'Nachylenie',
'vet.method.photo-ref': 'ze zdjęcia',
'vet.method.inclinometer': 'telefonem',
'vet.method.manual': 'ręcznie',
'vet.method.lidar': 'LiDAR',
'vet.label.steps': 'schody',
'vet.label.raisedKerb': 'wysoki krawężnik',
'vet.label.loweredKerb': 'obniżony krawężnik',
'vet.label.ramp': 'podjazd',
'vet.label.flat': 'wejście na równo',
'vet.label.narrowDoor': 'wąskie drzwi',
'vet.label.cobbles': 'kostka brukowa',
'vet.label.lift': 'winda',
'vet.label.face': 'twarz',
'vet.label.text': 'tekst / zrzut ekranu',
'vet.label.unrelated': 'coś niezwiązanego',
'vet.r.top': 'Na zdjęciu: {label} ({pct}%).',
'vet.r.noPhoto': 'Brak zdjęcia — ocena tylko z deklaracji i pomiaru.',
'vet.r.blurry': 'Zdjęcie jest nieostre.',
'vet.r.face': 'Na zdjęciu może być twarz — wymaga ochrony danych osobowych.',
'vet.r.text': 'Zdjęcie wygląda na zrzut ekranu lub dokument.',
'vet.r.unrelated': 'Zdjęcie wygląda na niezwiązane z dostępnością.',
'vet.r.supports': 'Zdjęcie potwierdza deklarację zgłaszającego.',
'vet.r.contradicts': 'Zdjęcie przeczy deklaracji zgłaszającego.',
'vet.r.measure': '{kind}: {value} {unit} ({method}).',
'vet.title': 'Wstępna ocena automatyczna',
'vet.disclaimer': 'Automat tylko ustawia kolejność. Decyzję podejmuje człowiek.',
'vet.running': 'Ocena automatyczna w toku…',
'vet.noCredits': 'Ocena automatyczna niedostępna: Brak kredytów (No Credits left).',
'vet.error': 'Ocena automatyczna nie powiodła się.',
'vet.validity.valid': 'wiarygodne',
'vet.validity.unclear': 'niejednoznaczne',
'vet.validity.invalid': 'nieprawidłowe',
'vet.consistency.supports': 'zdjęcie potwierdza',
'vet.consistency.contradicts': 'zdjęcie przeczy',
'vet.consistency.neutral': 'bez rozstrzygnięcia',
'vet.severity.critical': 'krytyczna',
'vet.severity.major': 'poważna',
'vet.severity.minor': 'drobna',
'vet.severity.none': 'brak',
'vet.class.inaccessible': 'niedostępne',
'vet.class.difficult': 'utrudnione',
'vet.class.accessible': 'dostępne',
'vet.class.unknown': 'nieznane',
'vet.triage.fast-track': 'Szybka ścieżka',
'vet.triage.review': 'Do przeglądu',
'vet.triage.likely-reject': 'Prawdopodobnie do odrzucenia',
'vet.flag.blurry': 'nieostre',
'vet.flag.face': 'twarz',
'vet.flag.text': 'tekst',
'vet.flag.unrelated': 'niezwiązane',
'vet.flag.no-photo': 'bez zdjęcia',
'vet.severityLabel': 'Waga',
'vet.validityLabel': 'Wiarygodność zgłoszenia',
'vet.classLabel': 'Klasa dostępności',
'vet.confidence': 'pewność {pct}%',
'vet.provider': 'Model: {name}',
'dash.queue.sort': 'Sortuj',
'dash.queue.sort.ai': 'Priorytet wg oceny automatycznej',
'dash.queue.sort.new': 'Najnowsze',
'dash.queue.measure': 'Pomiar',
'dash.queue.reasons': 'Uzasadnienie automatu',
'dash.kpi.agreement': 'Zgodność oceny automatu z decyzją urzędu',
'dash.kpi.agreement.n': '{n} rozpatrzonych',
'dash.kpi.agreement.none': 'brak danych',
'f.steps.measured': 'Zmierzona wysokość stopnia: {cm} cm (Twoja granica: {max} cm).',
'f.ramp.measured': 'Zmierzone nachylenie podjazdu: {pct}% (Twoja granica: {max}%).',
'f.measured.method': 'Metoda pomiaru: {method}.',
'guide.start': 'Prowadź',
'guide.start.help': 'Nawigacja na żywo po GPS: wskazówki skrętów i ostrzeżenia o barierach na górze ekranu.',
'guide.gmaps': 'Otwórz w Google Maps',
'guide.apple': 'Apple Maps',
'guide.mapsNote': 'Aplikacje map nie znają Twoich limitów i mogą zmienić trasę. Punkty pośrednie trzymają ją blisko naszej, ale sprawdzaj bariery na ekranie.',
'guide.active': 'Nawigacja na żywo',
'guide.stop': 'Zakończ',
'guide.voice': 'Głos',
'guide.demo': 'Symulacja GPS',
'guide.simOn': 'Tryb demo: pozycja jest symulowana wzdłuż trasy.',
'guide.waiting': 'Czekam na sygnał GPS…',
'guide.noGps': 'Ta przeglądarka nie udostępnia GPS. Włącz symulację GPS.',
'guide.denied': 'Brak zgody na lokalizację. Zezwól w ustawieniach przeglądarki albo użyj symulacji.',
'guide.next': 'Za {d} m: {action}{name}',
'guide.straight': 'Idź prosto jeszcze {d} m',
'guide.arrived': 'Jesteś u celu',
'guide.off': 'Zboczyłeś z trasy',
'guide.reroute': 'Wyznacz od nowa stąd',
'guide.barrier': 'Uwaga za {d} m: {what}',
'guide.remaining': 'Zostało {km} km · ok. {min} min',
'guide.you': 'Twoja pozycja',
'map.layer': 'Warstwa mapy',
'map.layer.map': 'Mapa',
'map.layer.sat': 'Satelita',
'guide.voice.off': 'Głos: wyłączony',
'guide.voice.all': 'Głos: skręty i ostrzeżenia',
'guide.voice.warn': 'Głos: tylko ostrzeżenia',
'voice.turn': 'Za {d} metrów {turn}',
'voice.barrier': 'Uwaga, {what}, za {d} metrów',
'voice.off': 'Zboczyłeś z trasy',
'voice.arrived': 'Jesteś u celu',
'voice.onAll': 'Głos włączony: skręty i ostrzeżenia',
'voice.onWarn': 'Głos włączony: tylko ostrzeżenia',
'measure.incline.insecure': 'Czujniki ruchu działają w przeglądarce tylko przez bezpieczne połączenie (HTTPS). Otwórz aplikację pod adresem https://… (nie http:// ani adres IP w sieci lokalnej) albo wpisz wartość ręcznie.',
'measure.incline.diag': 'Diagnostyka: {d}',
'common.back': 'Wstecz',
'common.yes': 'tak',
'common.no': 'nie',
'common.na': 'brak',
} as const;
export type Key = keyof typeof pl;
@ -328,7 +668,7 @@ const en: Dict = {
'nav.label': 'Main navigation',
'nav.places': 'Places',
'nav.route': 'Route',
'nav.about': 'About the data',
'nav.about': 'Data',
'nav.profile': 'Profile',
'lang.label': 'Language',
'skip': 'Skip to content',
@ -539,6 +879,7 @@ const en: Dict = {
'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.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',
@ -604,7 +945,7 @@ const en: Dict = {
'about.data': 'Data sources and their reliability',
'about.data.body1': 'The map, places, steps, kerbs, surfaces, benches and toilets come from OpenStreetMap (ODbL licence, attribution required). We fetch them through the public Overpass API for the demo area and keep a dated snapshot in the app.',
'about.data.body2': 'The date next to a fact is the last edit of the object in OSM unless there is a survey date (check_date, survey:date). An edit may not have touched the accessibility tags — so without a survey date we never call data “confirmed”.',
'about.data.body3': 'City data (Kraków Open Data, MSIP) is not wired into the prototype yet. The architecture treats it as one more adapter; each dataset’s licence must be checked before use.',
'about.data.body3': 'City data: stops come from the official ZTP Kraków GTFS feed (the wheelchair_boarding field). It currently says “no information” for every stop in the area — we show that plainly and fill in with the OSM community’s description. More datasets (Kraków Open Data, MSIP) are more adapters; each licence must be checked before use.',
'about.legend': 'Reliability levels',
'about.missing': 'What you see when data is missing or conflicting',
'about.missing.body': 'Missing information is shown as “No data” with a grey question-mark icon — never as accessibility. When a user report contradicts OSM, the place gets “Conflicting data” and both positions are visible. When the source cannot be refreshed you see the date of the last snapshot and a warning.',
@ -619,7 +960,7 @@ const en: Dict = {
'about.biz': 'Business model and scaling',
'about.biz.body': 'Free app for residents and tourists. Revenue from B2B: (1) an “venue accessibility” widget and report for hotels, event organisers and property managers, (2) an accessibility API for booking systems and travel apps, (3) a roll-out package for cities. Costs are static hosting, periodic Overpass pulls and report moderation. The operating entity owns the product, not the city office.',
'about.limits': 'Known prototype limitations',
'about.limits.body': 'Area: Old Town, Kazimierz, Wawel. Reports are local only. No city data. Kerb height is known for only some crossings. Entrance width is almost never mapped. Walking times are estimates. OSM tile servers are fine for a demo — production needs its own tile provider.',
'about.limits.body': 'Area: Old Town, Kazimierz, Wawel. Sign-in and reports are demo-only and local (no server). City data covers stops only for now. Kerb height is known for only some crossings. Entrance width is almost never mapped. Walking times are estimates. OSM tile servers are fine for a demo — production needs its own tile provider.',
'about.demo': 'Demo controls',
'about.simulate': 'Simulate the data source being unavailable (OSM)',
'about.simulate.help': 'Refreshing a place will fail — shows what a user would see.',
@ -633,6 +974,345 @@ const en: Dict = {
'common.noName': 'Unnamed place',
'common.km': 'km',
'footer.attr': 'Data © OpenStreetMap contributors (ODbL).',
'src.city-gtfs': 'City data: ZTP Kraków (GTFS)',
'src.city-review': 'Verified by the city office (demo)',
'f.entrance.cityNone': 'The operator gives no information about this stop’s accessibility.',
'f.entrance.cityNoneHint': 'In the official ZTP data the “wheelchair_boarding” field says “no information”. That does not mean the stop is inaccessible, nor that it is accessible.',
'nav.dashboard': 'City panel',
'nav.settings': 'Settings',
'nav.account': 'Account',
'auth.title': 'Sign in',
'auth.intro': 'This is a demo sign-in. There are no passwords and no server — pick a demo account. Profile and settings are saved only in this browser, separately for each account.',
'auth.demoBadge': 'DEMO ACCOUNT',
'auth.guest': 'Continue without an account',
'auth.guest.hint': 'Settings are still remembered on this device.',
'auth.signIn': 'Sign in as {name}',
'auth.signOut': 'Sign out',
'auth.signedInAs': 'Signed in: {name}',
'auth.role.user': 'Resident',
'auth.role.official': 'City official',
'auth.role.guest': 'Guest',
'auth.anna': 'Anna Kowalska',
'auth.anna.desc': 'Resident who uses a manual wheelchair.',
'auth.piotr': 'Piotr Nowak',
'auth.piotr.desc': 'Parent walking with a child in a stroller.',
'auth.maria': 'Maria Wiśniewska',
'auth.maria.desc': 'City office (demo role): data-quality panel, report queue, export.',
'auth.official.note': 'The official role unlocks the “City panel”. In production: sign-in through the office account (SSO).',
'set.title': 'Settings',
'set.saved': 'Saved automatically on this device.',
'set.display': 'Appearance and readability',
'set.textSize': 'Text size',
'set.textSize.100': 'Standard',
'set.textSize.125': 'Larger',
'set.textSize.150': 'Largest',
'set.contrast': 'High contrast',
'set.contrast.help': 'Stronger borders and darker text.',
'set.motion': 'Reduce map animation',
'set.language': 'Language',
'set.demoPlaces': 'Show fictional DEMO places',
'set.demoPlaces.help': 'They show data states (confirmed, conflicting, outdated, missing). Turn off to see real places only.',
'set.account': 'Account',
'set.data': 'Your data',
'set.export': 'Download settings (JSON)',
'set.import': 'Load settings from a file',
'set.import.bad': 'The file does not look like settings from this app.',
'set.import.ok': 'Settings loaded.',
'set.favorites': 'Saved places: {n}',
'profile.step1': 'How do you get around?',
'profile.step2': 'What equipment do you have?',
'profile.step3': 'Your limits',
'profile.step3.help': 'Move the sliders. Routes and place ratings recalculate straight away.',
'profile.step4': 'What else matters?',
'profile.preset.manual': 'Manual wheelchair',
'profile.preset.manual.d': 'Standard width, copes with a low kerb.',
'profile.preset.power': 'Powered wheelchair',
'profile.preset.power.d': 'Heavier and wider — narrow passages and slopes matter.',
'profile.preset.large': 'Scooter or large chair',
'profile.preset.large.d': 'Needs wide entrances and level ground.',
'profile.preset.single': 'Standard stroller',
'profile.preset.single.d': 'A typical pushchair or carrycot.',
'profile.preset.twin': 'Twin or wide',
'profile.preset.twin.d': 'Wider — trouble with narrow doors and passages.',
'profile.preset.light': 'Lightweight stroller',
'profile.preset.light.d': 'Easy to lift over 1–2 steps, but not with a heavy child.',
'profile.preset.custom': 'Custom settings',
'profile.preset.apply': 'Choose: {name}',
'profile.summary': 'What this means',
'profile.summary.text': 'I avoid steps and kerbs higher than {kerb} cm. I need passages wider than {width} cm and avoid slopes steeper than {incline}%.',
'profile.summary.cobbles': ' I try to avoid cobblestones.',
'profile.summary.toilet': ' I look for an accessible toilet nearby.',
'profile.summary.rest': ' I want places to rest.',
'profile.summary.changing': ' I need a baby-changing table.',
'profile.preview': 'Preview of results in the demo area',
'profile.preview.n': '{n} {label}',
'profile.autosave': 'Changes are saved straight away.',
'profile.done': 'Done — find places',
'profile.welcome': 'Let’s start with you',
'tip.label': 'Explanation: {topic}',
'tip.kerb': 'A kerb is the vertical step between pavement and road. A lowered kerb is about 3 cm, “flush” is 0 cm, a normal one about 8–15 cm.',
'tip.width': 'The narrowest door or passage you fit through. Standard entrance doors are about 90 cm, interior doors 80 cm.',
'tip.incline': 'Slope in percent: 8% is 8 cm up for every metre. Ramp guidelines say 6–8% at most.',
'tip.cobbles': 'Cobblestones are bumpy: slow, jarring and tiring. The route picks a longer way on smooth surface when the data has one.',
'tip.toilet': 'We show toilets tagged in OSM as wheelchair accessible — in the venue or public within 400 m.',
'tip.rest': 'Benches mapped in OSM within 150 m of the place.',
'tip.changing': 'A changing table in the venue or in a nearby public toilet, if mapped.',
'tip.preset': 'Presets set typical limits. You can always change them with the sliders.',
'hint.kerb.0': 'Only crossings flush with the road.',
'hint.kerb.3': 'Lowered kerb (about 3 cm).',
'hint.kerb.6': 'Low kerb; a normal one (8 cm and up) is avoided.',
'hint.kerb.10': 'Almost any kerb is fine.',
'hint.width.65': 'Very narrow passages are fine.',
'hint.width.80': 'You fit typical interior doors (80 cm).',
'hint.width.90': 'You need at least a standard entrance door (90 cm).',
'hint.width.100': 'You need wide doors and passages.',
'hint.incline.5': 'Gentle slopes only (up to 1:20).',
'hint.incline.8': 'A typical ramp (up to 1:12, i.e. 8%).',
'hint.incline.12': 'You can manage steep ramps.',
'verdict.count.match': 'fit',
'verdict.count.partial': 'partly',
'verdict.count.conflict': 'conflicting',
'verdict.count.incomplete': 'not enough data',
'verdict.count.fail': 'do not fit',
'detail.save': 'Save this place',
'detail.unsave': 'Remove from saved',
'detail.saved': 'Saved',
'places.onlySaved': 'Saved places only',
'detail.card': 'Access card (print)',
'detail.reach': 'What is within barrier-free reach?',
'cat.transit.long': 'Stops',
'card.title': 'Access card',
'card.for': 'Rating for profile: {profile}',
'card.generated': 'Generated {date} from OpenStreetMap and ZTP Kraków data. Guidance only, not a guarantee.',
'card.print': 'Print or save as PDF',
'card.close': 'Close card',
'card.legend': 'Reliability: confirmed = has a survey date; mapped = OSM entry; may have changed = older than 24 months; inferred = from nearby objects.',
'reach.title': 'Barrier-free reach',
'reach.intro': 'Where you can get from here without steps or barriers, for your profile — within:',
'reach.minutes': '{n} min',
'reach.result': 'In {min} min you can reach {n} places, {fit} of which fit or partly fit your profile.',
'reach.result.zero': 'In that time you cannot reach any mapped place from here without a barrier.',
'reach.stuck': 'This place is cut off from the mapped footpath network — reach cannot be computed.',
'reach.show': 'Reach is drawn on the map.',
'reach.clear': 'Hide reach',
'reach.caveat': 'Computed over mapped footpaths. Unmapped crossings may shorten or extend the real reach.',
'dash.title': 'City panel',
'dash.intro': 'Quality and completeness of accessibility data — computed live from open sources. No manual database upkeep by the city.',
'dash.role': 'Available only to the “City official” role. Sign in with the demo account to see the panel.',
'dash.kpi.places': 'Places in the area',
'dash.kpi.entrance': 'With a described entrance',
'dash.kpi.fresh': 'With data no older than 24 months',
'dash.kpi.verified': 'Confirmed by a survey date',
'dash.kpi.stops': 'Stops with operator accessibility data',
'dash.kpi.stopsOsm': 'Stops described by the community (OSM)',
'dash.kpi.pending': 'Reports awaiting a decision',
'dash.insight': 'Finding: the official ZTP GTFS feed has no accessibility information for any stop in this area ({n} of {total}), while the OSM community described {osm}. The cheapest improvement is to fill the wheelchair_boarding field in the operator’s data.',
'dash.gaps': 'Data gaps by type of place',
'dash.gaps.cat': 'Type',
'dash.gaps.total': 'Places',
'dash.gaps.known': 'Described',
'dash.gaps.stale': 'Outdated',
'dash.gaps.unknown': 'No data',
'dash.gaps.caption': 'Share of places in each category by what is known about the entrance.',
'dash.hot': 'Fix first: pedestrian crossings',
'dash.hot.intro': 'Crossings with a high kerb or an unknown kerb height, ranked by the number of places and stops within 120 m. Data comes from OSM.',
'dash.hot.rank': 'Rank',
'dash.hot.kerbRaised': 'High kerb',
'dash.hot.kerbUnknown': 'Height unknown',
'dash.hot.near': 'Places and stops nearby: {n}',
'dash.hot.show': 'Show on map',
'dash.hot.none': 'No data to rank.',
'dash.queue': 'Queue of user reports',
'dash.queue.intro': 'Reports are unverified. Once approved they become “confirmed” with the source “city office”. Rejected ones drop out of ratings.',
'dash.queue.empty': 'No reports to review.',
'dash.queue.accept': 'Approve',
'dash.queue.reject': 'Reject',
'dash.queue.decided': 'Reviewed: {n}',
'dash.queue.place': 'Place',
'dash.queue.status.accepted': 'approved',
'dash.queue.status.rejected': 'rejected',
'dash.demand': 'Where data is missing and people look',
'dash.demand.intro': 'Places users open often whose rating is “not enough data”. The counter is anonymous and local (no identifiers). In production it is aggregated server-side with a minimum-people threshold.',
'dash.demand.none': 'No signals yet. Open a few places from the list and they will appear here.',
'dash.demand.views': 'opens: {n}',
'dash.export': 'Open data export',
'dash.export.intro': 'Data computed from OSM and ZTP, ready to publish or load into other systems (ODbL licence — keep the source attribution).',
'dash.export.csv': 'Download CSV',
'dash.export.geojson': 'Download GeoJSON',
'dash.export.done': 'File prepared: {name}',
'route.presets.note': 'Real places from OSM data — the result changes with your profile.',
'dash.hot.mapBanner': 'On the map: priority crossings from the city ranking ({n}).',
'dash.hot.clear': 'Hide ranking on map',
'verdict.missing': 'Missing data about: {list}.',
'dash.hot.more': '{n} more like it on this street',
'route.alt.title': 'Route options',
'route.alt.pick': 'Choose a route option',
'route.alt.shortest': 'Shortest',
'route.alt.balanced': 'Balanced (for your profile)',
'route.alt.strict': 'Fewest difficulties',
'route.alt.alt': 'Different way',
'route.alt.alt2': 'Different way (near-shortest)',
'route.alt.recommended': 'Recommended',
'route.alt.base': 'baseline',
'route.alt.overhead': '+{m} m, +{min} min vs shortest',
'route.alt.clean': 'No mapped difficulties',
'route.alt.hard': 'Barriers: {n}',
'route.alt.caution': 'Cautions: {n}',
'route.alt.unknown': 'Unknown points: {n}',
'route.alt.warn': 'The selected option has barriers you cannot pass with your profile ({n}).',
'route.alt.showRec': 'Show the recommended one',
'rep.evidence': 'Evidence (optional)',
'rep.evidence.help': 'A photo and a measurement help the city office verify the report faster.',
'rep.photo.take': 'Take a photo',
'rep.photo.choose': 'Choose from gallery',
'rep.photo.remove': 'Remove',
'rep.photo.error': 'Could not read the photo.',
'rep.photo.alt': 'Preview of the attached photo',
'rep.photo.processed': 'Photo downscaled, location data (EXIF) removed.',
'rep.photo.privacy': 'Do not photograph faces or number plates. In the prototype the photo stays on this device.',
'rep.measure.kerb': 'Height of the step or kerb',
'rep.measure.incline': 'Ramp slope',
'rep.measure.kerb.tool': 'Measure on the photo',
'rep.measure.incline.tool': 'Measure with the phone',
'rep.measure.manual': 'Enter a measured value',
'rep.measure.manual.cm': 'e.g. 12 (cm)',
'rep.measure.manual.pct': 'e.g. 9 (%)',
'rep.measure.manual.use': 'Use value',
'rep.measure.lidar': 'iPhone LiDAR measuring needs a native app — browsers cannot access it. The data model is ready for it.',
'rep.ai.note': 'After sending, the report gets an automatic first assessment; a city official always makes the decision.',
'measure.use': 'Use this value',
'measure.incline.title': 'Slope with the phone',
'measure.incline.s1': 'Lay the phone screen-up on the ramp, along the direction of the slope.',
'measure.incline.s2': 'For accuracy, first zero the phone on level ground.',
'measure.incline.s3': 'Wait for the value to settle, then use it.',
'measure.incline.start': 'Enable sensors',
'measure.incline.zero': 'Zero here (level)',
'measure.incline.denied': 'Motion sensor permission was denied. Allow it in browser settings or enter the value by hand.',
'measure.incline.none': 'The browser delivers no motion-sensor data (e.g. a computer, or sensors are turned off). On iPhone check Settings → Safari → Motion & Orientation Access. You can also enter the value by hand.',
'measure.kerb.title': 'Height from a photo',
'measure.kerb.intro': 'Lay an object of known size (e.g. a bank card) next to the kerb and photograph it side-on. Tap the two ends of the object, then the bottom and top edge of the step.',
'measure.kerb.ref': 'Reference object',
'measure.kerb.refCm': 'Length in cm',
'measure.ref.card': 'Bank card (long side)',
'measure.ref.a4short': 'A4 sheet (short side)',
'measure.ref.a4long': 'A4 sheet (long side)',
'measure.ref.ruler': 'Ruler',
'measure.ref.custom': 'Other length',
'measure.kerb.tapRef': 'Step 1: tap the two ends of the reference object (blue line).',
'measure.kerb.tapKerb': 'Step 2: tap the bottom and top edge of the step (yellow line).',
'measure.kerb.done': 'Done. Check the result or start over.',
'measure.kerb.reset': 'Start over',
'measure.kerb.canvas': 'Photo to measure on: tap points to mark them',
'measure.kerb.caveat': 'Accuracy about ±1–2 cm if the photo is taken square-on and the object lies in the same plane as the step. Enter the value by hand if you have a tape measure.',
'measure.kerb.needPhoto': 'Add a photo first, then measure the height on it.',
'vet.measure.kerb': 'Height',
'vet.measure.incline': 'Slope',
'vet.method.photo-ref': 'from photo',
'vet.method.inclinometer': 'phone sensor',
'vet.method.manual': 'by hand',
'vet.method.lidar': 'LiDAR',
'vet.label.steps': 'steps',
'vet.label.raisedKerb': 'high kerb',
'vet.label.loweredKerb': 'lowered kerb',
'vet.label.ramp': 'ramp',
'vet.label.flat': 'level entrance',
'vet.label.narrowDoor': 'narrow door',
'vet.label.cobbles': 'cobblestones',
'vet.label.lift': 'lift',
'vet.label.face': 'face',
'vet.label.text': 'text / screenshot',
'vet.label.unrelated': 'something unrelated',
'vet.r.top': 'In the photo: {label} ({pct}%).',
'vet.r.noPhoto': 'No photo — assessed from the claim and measurement only.',
'vet.r.blurry': 'The photo is blurry.',
'vet.r.face': 'The photo may show a face — personal data needs protecting.',
'vet.r.text': 'The photo looks like a screenshot or a document.',
'vet.r.unrelated': 'The photo looks unrelated to accessibility.',
'vet.r.supports': 'The photo supports the reporter’s claim.',
'vet.r.contradicts': 'The photo contradicts the reporter’s claim.',
'vet.r.measure': '{kind}: {value} {unit} ({method}).',
'vet.title': 'Automatic first assessment',
'vet.disclaimer': 'The automation only sets the order. A person decides.',
'vet.running': 'Automatic assessment running…',
'vet.noCredits': 'Automatic assessment unavailable: No Credits left.',
'vet.error': 'Automatic assessment failed.',
'vet.validity.valid': 'plausible',
'vet.validity.unclear': 'unclear',
'vet.validity.invalid': 'invalid',
'vet.consistency.supports': 'photo supports it',
'vet.consistency.contradicts': 'photo contradicts it',
'vet.consistency.neutral': 'inconclusive',
'vet.severity.critical': 'critical',
'vet.severity.major': 'major',
'vet.severity.minor': 'minor',
'vet.severity.none': 'none',
'vet.class.inaccessible': 'inaccessible',
'vet.class.difficult': 'difficult',
'vet.class.accessible': 'accessible',
'vet.class.unknown': 'unknown',
'vet.triage.fast-track': 'Fast track',
'vet.triage.review': 'Needs review',
'vet.triage.likely-reject': 'Likely reject',
'vet.flag.blurry': 'blurry',
'vet.flag.face': 'face',
'vet.flag.text': 'text',
'vet.flag.unrelated': 'unrelated',
'vet.flag.no-photo': 'no photo',
'vet.severityLabel': 'Severity',
'vet.validityLabel': 'Report validity',
'vet.classLabel': 'Accessibility class',
'vet.confidence': 'confidence {pct}%',
'vet.provider': 'Model: {name}',
'dash.queue.sort': 'Sort',
'dash.queue.sort.ai': 'Priority by automatic assessment',
'dash.queue.sort.new': 'Newest',
'dash.queue.measure': 'Measurement',
'dash.queue.reasons': 'Why the automation says so',
'dash.kpi.agreement': 'Automation agrees with the office decision',
'dash.kpi.agreement.n': '{n} reviewed',
'dash.kpi.agreement.none': 'no data yet',
'f.steps.measured': 'Measured step height: {cm} cm (your limit: {max} cm).',
'f.ramp.measured': 'Measured ramp slope: {pct}% (your limit: {max}%).',
'f.measured.method': 'Measured by: {method}.',
'guide.start': 'Guide me',
'guide.start.help': 'Live GPS navigation: turn prompts and barrier warnings at the top of the screen.',
'guide.gmaps': 'Open in Google Maps',
'guide.apple': 'Apple Maps',
'guide.mapsNote': 'Map apps do not know your limits and may change the route. Waypoints keep it close to ours, but watch for barriers on screen.',
'guide.active': 'Live navigation',
'guide.stop': 'End',
'guide.voice': 'Voice',
'guide.demo': 'Simulate GPS',
'guide.simOn': 'Demo mode: position is simulated along the route.',
'guide.waiting': 'Waiting for a GPS fix…',
'guide.noGps': 'This browser exposes no GPS. Turn on GPS simulation.',
'guide.denied': 'Location permission was denied. Allow it in browser settings or use the simulation.',
'guide.next': 'In {d} m: {action}{name}',
'guide.straight': 'Continue straight for {d} m',
'guide.arrived': 'You have arrived',
'guide.off': 'You are off the route',
'guide.reroute': 'Re-route from here',
'guide.barrier': 'Caution in {d} m: {what}',
'guide.remaining': '{km} km left · about {min} min',
'guide.you': 'Your position',
'map.layer': 'Map layer',
'map.layer.map': 'Map',
'map.layer.sat': 'Satellite',
'guide.voice.off': 'Voice: off',
'guide.voice.all': 'Voice: turns and warnings',
'guide.voice.warn': 'Voice: warnings only',
'voice.turn': 'In {d} metres, {turn}',
'voice.barrier': 'Caution, {what}, in {d} metres',
'voice.off': 'You are off the route',
'voice.arrived': 'You have arrived',
'voice.onAll': 'Voice on: turns and warnings',
'voice.onWarn': 'Voice on: warnings only',
'measure.incline.insecure': 'Motion sensors work in the browser only over a secure connection (HTTPS). Open the app at an https://… address (not http:// or a local-network IP) or enter the value by hand.',
'measure.incline.diag': 'Diagnostics: {d}',
'common.back': 'Back',
'common.yes': 'yes',
'common.no': 'no',
'common.na': 'none',
};
export const DICT: Record<Lang, Dict> = { pl, en };

View file

@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest';
import { assessPlace, buildIndex, osmConfidence, verdictOf } from './assess';
import { kerbHeightCm, parseIncline, parseWidth, roughness } from './osm';
import { buildGraph, findRoute, snapToGraph } from './route';
import { computeReach } from './reach';
import { computeHotspots } from './civic';
import type { Fact, Profile, Snapshot } from './types';
const snap = JSON.parse(readFileSync('public/data/kk-snapshot.json', 'utf8')) as Snapshot;
@ -113,3 +115,279 @@ describe('routing', () => {
expect(a.stats.cobbleM).toBeLessThanOrEqual(b.stats.cobbleM + 1);
});
});
describe('city data and moderation', () => {
const ix = buildIndex(snap);
const stop = snap.places.find((p) => p.ext && !p.tags.wheelchair)!;
it('operator feed with no information stays unknown, with its source named', () => {
const e = assessPlace(stop, wheelchair, ix, [], NOW).facts.find((f) => f.key === 'entrance')!;
expect(e.status).toBe('unknown');
expect(e.source).toBe('city-gtfs');
expect(e.confidence).toBe('none');
});
it('an accepted report overrides OSM and is verified by the city office', () => {
const place = snap.places.find((p) => p.tags.wheelchair === 'yes' && !p.ext)!;
const a = assessPlace(place, wheelchair, ix, [
{ id: 'r', placeId: place.id, attr: 'entrance', value: 'no', createdAt: '2026-10-01T00:00:00Z', status: 'accepted', reviewedAt: '2026-10-02T00:00:00Z' },
], NOW);
const e = a.facts.find((f) => f.key === 'entrance')!;
expect(e.source).toBe('city-review');
expect(e.confidence).toBe('verified');
expect(e.status).toBe('fail');
expect(e.alt?.source).toBe('osm-tag');
expect(a.verdict).toBe('fail');
});
it('a rejected report is ignored', () => {
const place = snap.places.find((p) => p.tags.wheelchair === 'yes' && !p.ext)!;
const base = assessPlace(place, wheelchair, ix, [], NOW);
const withRejected = assessPlace(place, wheelchair, ix, [
{ id: 'r', placeId: place.id, attr: 'entrance', value: 'no', createdAt: '2026-10-01T00:00:00Z', status: 'rejected' },
], NOW);
expect(withRejected.verdict).toBe(base.verdict);
});
});
describe('reach and hotspots', () => {
const g = buildGraph(snap);
const rynek: [number, number] = [50.0617, 19.9374];
it('longer time never shrinks the reachable set', () => {
const a = computeReach(g, rynek, wheelchair, 5)!;
const b = computeReach(g, rynek, wheelchair, 10)!;
expect(a.nodes.size).toBeGreaterThan(20);
for (const n of a.nodes) expect(b.nodes.has(n)).toBe(true);
expect(b.nodes.size).toBeGreaterThanOrEqual(a.nodes.size);
});
it('returns null when the start is off the network', () => {
expect(computeReach(g, [51, 20.5], wheelchair, 10)).toBeNull();
});
it('ranks crossings, one row per street, best first', () => {
const h = computeHotspots(snap, buildIndex(snap), snap.places);
expect(h.length).toBeGreaterThan(0);
expect(h.length).toBeLessThanOrEqual(10);
for (let i = 1; i < h.length; i++) expect(h[i - 1]!.score).toBeGreaterThanOrEqual(h[i]!.score);
const streets = h.map((x) => x.street).filter(Boolean);
expect(new Set(streets).size).toBe(streets.length);
});
});
import { claimOf, interpret, LABELS, type LabelKey } from './vet';
const zero = Object.fromEntries(Object.keys(LABELS).map((k) => [k, 0])) as Record<LabelKey, number>;
const sc = (o: Partial<Record<LabelKey, number>>) => ({ ...zero, ...o });
const rep = (attr: 'entrance' | 'steps' | 'ramp' | 'width', value: 'yes' | 'no' | 'limited' | number, measure?: import('./types').Measurement) => ({ attr, value, ...(measure ? { measure } : {}) });
describe('AI triage (pure decision logic)', () => {
it('maps claims to barrier/access', () => {
expect(claimOf({ attr: 'entrance', value: 'no' })).toBe('barrier');
expect(claimOf({ attr: 'steps', value: 'yes' })).toBe('barrier');
expect(claimOf({ attr: 'ramp', value: 'yes' })).toBe('access');
expect(claimOf({ attr: 'width', value: 70 })).toBe('barrier');
});
it('photo of steps supporting a barrier claim is fast-tracked as critical', () => {
const v = interpret(sc({ steps: 0.7, raisedKerb: 0.1 }), { report: rep('entrance', 'no'), blur: 200, hasPhoto: true }, 't');
expect(v.validity).toBe('valid');
expect(v.consistency).toBe('supports');
expect(v.severity).toBe('critical');
expect(v.accessibilityClass).toBe('inaccessible');
expect(v.triage).toBe('fast-track');
});
it('photo that contradicts the claim goes to human review, not fast track', () => {
const v = interpret(sc({ flat: 0.5, ramp: 0.3, steps: 0.05 }), { report: rep('entrance', 'no'), blur: 200, hasPhoto: true }, 't');
expect(v.consistency).toBe('contradicts');
expect(v.triage).toBe('review');
});
it('unrelated or text photos are flagged and likely rejected', () => {
const v = interpret(sc({ unrelated: 0.8, text: 0.05, steps: 0.05 }), { report: rep('entrance', 'no'), blur: 200, hasPhoto: true }, 't');
expect(v.flags).toContain('unrelated');
expect(v.validity).toBe('invalid');
expect(v.triage).toBe('likely-reject');
expect(v.severity).toBe('none');
});
it('blurry photos are never "valid"', () => {
const v = interpret(sc({ steps: 0.9 }), { report: rep('entrance', 'no'), blur: 5, hasPhoto: true }, 't');
expect(v.flags).toContain('blurry');
expect(v.validity).toBe('unclear');
expect(v.triage).toBe('review');
});
it('without a photo, measurement alone sets severity but never fast-tracks', () => {
const v = interpret(null, { report: rep('steps', 'yes', { kind: 'kerb', value: 14, unit: 'cm', method: 'manual' }), hasPhoto: false }, 't');
expect(v.flags).toContain('no-photo');
expect(v.severity).toBe('critical');
expect(v.triage).toBe('review');
});
it('measured slope escalates severity', () => {
const v = interpret(sc({ ramp: 0.6, flat: 0.1 }), { report: rep('ramp', 'yes', { kind: 'incline', value: 9, unit: 'pct', method: 'inclinometer' }), blur: 200, hasPhoto: true }, 't');
expect(v.severity).toBe('major');
});
it('a face triggers a privacy flag', () => {
const v = interpret(sc({ face: 0.6, steps: 0.1 }), { report: rep('entrance', 'no'), blur: 200, hasPhoto: true }, 't');
expect(v.flags).toContain('face');
});
});
describe('measured reports feed the fit report', () => {
const ix = buildIndex(snap);
const place = snap.places.find((p) => p.tags.wheelchair === 'yes' && !p.ext)!;
const m = { kind: 'kerb' as const, value: 14, unit: 'cm' as const, method: 'photo-ref' as const };
const base = { id: 'r', placeId: place.id, attr: 'steps' as const, value: 'yes' as const, measure: m, createdAt: '2026-10-01T00:00:00Z' };
it('an unverified measurement over the limit is a caution, not a failure', () => {
const f = assessPlace(place, wheelchair, ix, [base], NOW).facts.find((x) => x.key === 'steps')!;
expect(f.status).toBe('warn');
expect(f.confidence).toBe('user');
});
it('an approved measurement over the limit is a barrier verified by the office', () => {
const a = assessPlace(place, wheelchair, ix, [{ ...base, status: 'accepted', reviewedAt: '2026-10-02T00:00:00Z' }], NOW);
const f = a.facts.find((x) => x.key === 'steps')!;
expect(f.status).toBe('fail');
expect(f.confidence).toBe('verified');
expect(a.verdict).toBe('fail');
});
});
import { barrierPositions, buildPath, guideState, maneuvers, pointAt, project } from './guide';
import { appleMapsUrl, googleMapsUrl, waypointsFor } from './links';
import { HARD_FLAGS } from './route';
describe('live guidance and map-app links', () => {
const g = buildGraph(snap);
const P = (n: string) => snap.places.find((p) => p.name === n)!;
const a = P('Sukiennice'), b = P('Zamek Królewski na Wawelu');
const from: [number, number] = [a.lat, a.lon], to: [number, number] = [b.lat, b.lon];
const r = findRoute(g, from, to, wheelchair);
const path = buildPath(r.coords);
const mans = maneuvers(r, path);
const bars = barrierPositions(path, r.barriers, HARD_FLAGS);
it('projects points on the route and round-trips along-distance', () => {
const p = pointAt(path, 300);
const pr = project(path, p);
expect(pr.off).toBeLessThan(1);
expect(Math.abs(pr.along - 300)).toBeLessThan(2);
});
it('reports arrival, remaining distance and off-route', () => {
const start = guideState(path, mans, bars, pointAt(path, 0));
expect(start.arrived).toBe(false);
expect(start.remaining).toBeCloseTo(path.total, -1);
expect(guideState(path, mans, bars, pointAt(path, path.total)).arrived).toBe(true);
const away: [number, number] = [r.coords[10]![0] + 0.002, r.coords[10]![1]];
expect(guideState(path, mans, bars, away).offRoute).toBe(true);
});
it('announces the next real turn ahead, never one behind', () => {
const turnAt = mans.find((m) => m.turn !== 'start' && m.turn !== 'straight')!;
const before = guideState(path, mans, bars, pointAt(path, Math.max(0, turnAt.at - 30)));
expect(before.next?.maneuver.at).toBe(turnAt.at);
expect(before.next!.distance).toBeGreaterThan(20);
const after = guideState(path, mans, bars, pointAt(path, turnAt.at + 20));
expect(after.next?.maneuver.at ?? Infinity).toBeGreaterThan(turnAt.at);
});
it('builds Google/Apple Maps links with at most 8 waypoints', () => {
const url = new URL(googleMapsUrl(r, from, to));
expect(url.origin + url.pathname).toBe('https://www.google.com/maps/dir/');
expect(url.searchParams.get('travelmode')).toBe('walking');
expect(url.searchParams.get('origin')).toBe(`${a.lat.toFixed(6)},${a.lon.toFixed(6)}`);
const wp = (url.searchParams.get('waypoints') ?? '').split('|').filter(Boolean);
expect(wp.length).toBeLessThanOrEqual(8);
expect(waypointsFor(r).length).toBeLessThanOrEqual(8);
expect(appleMapsUrl(from, to)).toContain('dirflg=w');
});
});
import { CueTracker, VoicePlayer, type Cue, type Utt } from './voice';
import type { GuideState } from './guide';
function fakeSynth() {
const spoken: string[] = [];
const live: Utt[] = [];
let cancels = 0;
return {
spoken, cancels: () => cancels,
synth: { speak: (u: Utt) => { spoken.push(u.text); live.push(u); }, cancel: () => { cancels++; live.length = 0; } },
make: (text: string): Utt => ({ text, onend: null, onerror: null }),
finish: () => live.shift()?.onend?.(),
};
}
const cue = (id: string, priority: number, extra: Partial<Cue> = {}): Cue => ({ id, kind: 'turn', text: id, priority, ...extra });
describe('voice player never talks over itself', () => {
it('queues while speaking and speaks the queued cue afterwards', () => {
const f = fakeSynth();
const p = new VoicePlayer(f.synth, f.make, (fn) => fn());
p.say(cue('A', 2));
p.say(cue('B', 2));
expect(f.spoken).toEqual(['A']);
f.finish();
expect(f.spoken).toEqual(['A', 'B']);
f.finish();
p.stop();
});
it('keeps only the most important pending cue', () => {
const f = fakeSynth();
const p = new VoicePlayer(f.synth, f.make, (fn) => fn());
p.say(cue('A', 2));
p.say(cue('hi', 3));
p.say(cue('lo', 2));
f.finish();
expect(f.spoken).toEqual(['A', 'hi']);
p.stop();
});
it('only a much more urgent cue interrupts', () => {
const f = fakeSynth();
const p = new VoicePlayer(f.synth, f.make, (fn) => fn());
p.say(cue('turn', 2));
p.say(cue('caution', 3));
expect(f.cancels()).toBe(0);
p.say(cue('STAIRS', 4));
expect(f.cancels()).toBe(1);
expect(f.spoken).toEqual(['turn', 'STAIRS']);
p.stop();
});
it('drops a queued cue that is stale by the time it would be spoken', () => {
const f = fakeSynth();
const p = new VoicePlayer(f.synth, f.make, (fn) => fn());
let stale = false;
p.say(cue('A', 2));
p.say(cue('B', 2, { valid: () => !stale }));
stale = true;
f.finish();
expect(f.spoken).toEqual(['A']);
p.stop();
});
});
describe('cue tracker speaks events once, and only when they matter', () => {
const mk = () => new CueTracker(
{ turn: (d, t) => `turn ${d} ${t}`, barrier: (d, w) => `barrier ${d} ${w}`, off: 'off', arrived: 'arrived' },
{ turn: (t) => t, flag: (f) => f },
() => true,
);
const base: GuideState = { along: 0, off: 0, remaining: 500, arrived: false, offRoute: false, next: null, barrier: null };
const turnAt = (distance: number): GuideState => ({ ...base, next: { distance, maneuver: { at: 100, turn: 'left', name: 'X', segment: 3 } } });
const stairs = (distance: number): GuideState => ({ ...base, barrier: { distance, b: { flag: 'steps', at: 200, name: null, hard: true } } });
it('says nothing far from a turn, announces once at ≤25 m, never repeats', () => {
const t = mk();
expect(t.update(turnAt(120))).toHaveLength(0);
expect(t.update(turnAt(60))).toHaveLength(0);
const c = t.update(turnAt(22));
expect(c).toHaveLength(1);
expect(c[0]!.text).toBe('turn 20 left');
expect(t.update(turnAt(15))).toHaveLength(0);
expect(t.update(turnAt(5))).toHaveLength(0);
});
it('warns about a barrier once, at ≤60 m, as high priority when it is a hard one', () => {
const t = mk();
expect(t.update(stairs(90))).toHaveLength(0);
const c = t.update(stairs(50));
expect(c).toHaveLength(1);
expect(c[0]).toMatchObject({ kind: 'barrier', priority: 4, text: 'barrier 50 steps' });
expect(t.update(stairs(30))).toHaveLength(0);
});
it('announces leaving the route once, and arrival once', () => {
const t = mk();
expect(t.update({ ...base, offRoute: true, off: 60 })).toHaveLength(1);
expect(t.update({ ...base, offRoute: true, off: 70 })).toHaveLength(0);
expect(t.update(base)).toHaveLength(0);
expect(t.update({ ...base, arrived: true, remaining: 5 })).toHaveLength(1);
expect(t.update({ ...base, arrived: true, remaining: 2 })).toHaveLength(0);
});
});

31
src/lib/links.ts Normal file
View file

@ -0,0 +1,31 @@
import type { LatLon } from './geo';
import { buildPath, maneuvers, pointAt } from './guide';
import type { RouteResult } from './route';
const fmt = (p: LatLon) => `${p[0].toFixed(6)},${p[1].toFixed(6)}`;
/** Up to `max` intermediate points: the turns first, otherwise evenly spaced. */
export function waypointsFor(result: RouteResult, max = 8): LatLon[] {
const path = buildPath(result.coords);
if (path.total < 150) return [];
const turns = maneuvers(result, path).filter((m) => m.turn !== 'start' && m.turn !== 'straight' && m.at > 40 && m.at < path.total - 40).map((m) => m.at);
let picks = turns;
if (picks.length > max) picks = Array.from({ length: max }, (_, i) => turns[Math.round(((i + 1) * turns.length) / (max + 1))]!);
if (picks.length < 3) picks = Array.from({ length: Math.min(max, 4) }, (_, i) => ((i + 1) * path.total) / (Math.min(max, 4) + 1));
return [...new Set(picks)].sort((a, b) => a - b).map((d) => pointAt(path, d));
}
/**
* Google Maps directions URL (walking). Waypoints along our route keep Google's path close to ours;
* Google may still adjust it and does not know the user's step-free limits.
*/
export function googleMapsUrl(result: RouteResult, from: LatLon, to: LatLon): string {
const wp = waypointsFor(result);
const q = new URLSearchParams({ api: '1', origin: fmt(from), destination: fmt(to), travelmode: 'walking' });
if (wp.length) q.set('waypoints', wp.map(fmt).join('|'));
return `https://www.google.com/maps/dir/?${q.toString()}`;
}
export function appleMapsUrl(from: LatLon, to: LatLon): string {
return `https://maps.apple.com/?saddr=${fmt(from)}&daddr=${fmt(to)}&dirflg=w`;
}

91
src/lib/photo.ts Normal file
View file

@ -0,0 +1,91 @@
// Client-side photo handling. Re-encoding through a canvas drops all EXIF (including GPS) and
// downscales, so only pixels leave the capture step.
const MAX_SIDE = 1024;
export interface ProcessedPhoto { dataUrl: string; width: number; height: number; blur: number }
export async function processPhoto(file: File): Promise<ProcessedPhoto> {
const bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
const scale = Math.min(1, MAX_SIDE / Math.max(bmp.width, bmp.height));
const w = Math.max(1, Math.round(bmp.width * scale)), h = Math.max(1, Math.round(bmp.height * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas unavailable');
ctx.drawImage(bmp, 0, 0, w, h);
bmp.close();
return { dataUrl: canvas.toDataURL('image/jpeg', 0.82), width: w, height: h, blur: sharpness(canvas) };
}
/** Variance of the Laplacian on a 256px grayscale copy. Low = blurry. */
export function sharpness(src: HTMLCanvasElement): number {
const side = 256;
const k = Math.min(1, side / Math.max(src.width, src.height));
const w = Math.max(3, Math.round(src.width * k)), h = Math.max(3, Math.round(src.height * k));
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const ctx = c.getContext('2d', { willReadFrequently: true });
if (!ctx) return 0;
ctx.drawImage(src, 0, 0, w, h);
const { data } = ctx.getImageData(0, 0, w, h);
const g = new Float32Array(w * h);
for (let i = 0; i < g.length; i++) g[i] = 0.299 * data[i * 4]! + 0.587 * data[i * 4 + 1]! + 0.114 * data[i * 4 + 2]!;
let sum = 0, sum2 = 0, n = 0;
for (let y = 1; y < h - 1; y++)
for (let x = 1; x < w - 1; x++) {
const i = y * w + x;
const lap = 4 * g[i]! - g[i - 1]! - g[i + 1]! - g[i - w]! - g[i + w]!;
sum += lap;
sum2 += lap * lap;
n++;
}
const mean = sum / n;
return sum2 / n - mean * mean;
}
export const BLUR_THRESHOLD = 40;
// ---------------- IndexedDB (photos are too big for localStorage) ----------------
const DB = 'kbb-photos';
const open = () => new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB, 1);
req.onupgradeneeded = () => req.result.createObjectStore('p');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
export async function putPhoto(id: string, dataUrl: string): Promise<void> {
const db = await open();
await new Promise<void>((res, rej) => {
const tx = db.transaction('p', 'readwrite');
tx.objectStore('p').put(dataUrl, id);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
export async function getPhoto(id: string): Promise<string | null> {
try {
const db = await open();
return await new Promise<string | null>((res, rej) => {
const r = db.transaction('p').objectStore('p').get(id);
r.onsuccess = () => res((r.result as string | undefined) ?? null);
r.onerror = () => rej(r.error);
});
} catch { return null; }
}
export async function deletePhoto(id: string): Promise<void> {
try {
const db = await open();
await new Promise<void>((res) => {
const tx = db.transaction('p', 'readwrite');
tx.objectStore('p').delete(id);
tx.oncomplete = () => res();
tx.onerror = () => res();
});
} catch { /* ignore */ }
}

View file

@ -15,3 +15,24 @@ export function isProfile(v: unknown): v is Profile {
typeof p.needRest === 'boolean' && typeof p.needChanging === 'boolean'
);
}
export interface Preset { id: string; kind: ProfileKind; values: Profile }
const W = DEFAULT_PROFILE.wheelchair, S = DEFAULT_PROFILE.stroller;
export const PRESETS: Preset[] = [
{ id: 'manual', kind: 'wheelchair', values: W },
{ id: 'power', kind: 'wheelchair', values: { ...W, maxKerbCm: 2, minWidthM: 0.85, maxInclinePct: 8 } },
{ id: 'large', kind: 'wheelchair', values: { ...W, maxKerbCm: 2, minWidthM: 1.0, maxInclinePct: 6 } },
{ id: 'single', kind: 'stroller', values: S },
{ id: 'twin', kind: 'stroller', values: { ...S, maxKerbCm: 3, minWidthM: 0.9, maxInclinePct: 6 } },
{ id: 'light', kind: 'stroller', values: { ...S, maxKerbCm: 6, minWidthM: 0.65, maxInclinePct: 10 } },
];
const sameLimits = (a: Profile, b: Profile) =>
a.kind === b.kind && a.maxKerbCm === b.maxKerbCm && Math.abs(a.minWidthM - b.minWidthM) < 0.001 && a.maxInclinePct === b.maxInclinePct;
export const activePreset = (p: Profile): string =>
PRESETS.find((x) => sameLimits(x.values, p))?.id ?? 'custom';
/** Picks the i18n hint bucket for a slider value. */
export const bucket = (v: number, steps: number[]): number => steps.find((s) => v <= s) ?? steps[steps.length - 1]!;

50
src/lib/reach.ts Normal file
View file

@ -0,0 +1,50 @@
// Barrier-free reach: Dijkstra over the profile-filtered network, limited by walking time.
import type { LatLon } from './geo';
import { HARD_FLAGS, MinHeap, snapToGraph, wayInfo, type RouteGraph } from './route';
import type { Place, Profile } from './types';
export interface Reach {
/** Line segments of every traversable edge reached, as [lat,lon] pairs. */
lines: [LatLon, LatLon][];
nodes: Set<number>;
}
export function computeReach(g: RouteGraph, from: LatLon, profile: Profile, minutes: number): Reach | null {
const start = snapToGraph(g, from, 100);
if (start === null) return null;
const speedMPerMin = ((profile.kind === 'wheelchair' ? 3.6 : 4.0) * 1000) / 60;
const limit = minutes * speedMPerMin; // metres-equivalent budget
const { nodes, ways } = g.snapshot;
const infoCache = new Map<number, ReturnType<typeof wayInfo>>();
const dist = new Map<number, number>([[start, 0]]);
const heap = new MinHeap();
heap.push(0, start);
const done = new Set<number>();
const lines: [LatLon, LatLon][] = [];
for (let top = heap.pop(); top; top = heap.pop()) {
const [d, u] = top;
if (done.has(u)) continue;
done.add(u);
for (const e of g.adj[u]!) {
let info = infoCache.get(e.way);
if (!info) { info = wayInfo(g, ways[e.way]!, profile); infoCache.set(e.way, info); }
if (info.flags.some((f) => HARD_FLAGS.has(f))) continue;
const cost = e.len * (info.rough === 'rough' ? 1.4 : 1);
const nd = d + cost;
if (nd > limit) continue;
lines.push([nodes[u]!, nodes[e.to]!]);
if (nd < (dist.get(e.to) ?? Infinity)) { dist.set(e.to, nd); heap.push(nd, e.to); }
}
}
return { lines, nodes: new Set(done) };
}
/** Places whose nearest network node was reached. */
export function placesInReach(g: RouteGraph, reach: Reach, places: Place[], snapCache: Map<string, number | null>): Place[] {
return places.filter((p) => {
let n = snapCache.get(p.id);
if (n === undefined) { n = snapToGraph(g, [p.lat, p.lon], 60); snapCache.set(p.id, n); }
return n !== null && reach.nodes.has(n);
});
}

View file

@ -54,7 +54,7 @@ const BASE: Record<string, number> = {
interface WayInfo { mult: number; fixed: number; flags: Flag[]; rough: Roughness }
export function wayInfo(g: RouteGraph, w: Way, profile: Profile): WayInfo {
export function wayInfo(g: RouteGraph, w: Way, profile: Profile, strict = false): WayInfo {
const flags: Flag[] = [];
let mult = BASE[w.hw] ?? 1.3;
let fixed = 0;
@ -70,16 +70,16 @@ export function wayInfo(g: RouteGraph, w: Way, profile: Profile): WayInfo {
const heights = [w.nodes[0]!, w.nodes[w.nodes.length - 1]!].map((n) => kerbHeightCm(g.snapshot.kerbs[n]));
const known = heights.filter((h): h is number => h !== null);
if (known.some((h) => h > profile.maxKerbCm)) { flags.push('kerb'); fixed += BLOCK_PENALTY_M; }
else if (known.length < 2) { flags.push('kerb-unknown'); fixed += 25; }
else if (known.length < 2) { flags.push('kerb-unknown'); fixed += strict ? 300 : 25; }
}
const rough = roughness(w.surface, w.smooth);
if (w.hw !== 'steps') {
if (rough === 'rough') {
flags.push(isCobbles(w.surface) ? 'cobbles' : 'rough');
mult *= profile.avoidCobbles ? 3 : 1.5;
} else if (rough === 'fair') mult *= 1.15;
else if (rough === 'unknown') { flags.push('surface-unknown'); mult *= 1.1; }
mult *= strict ? 6 : profile.avoidCobbles ? 3 : 1.5;
} else if (rough === 'fair') mult *= strict ? 1.3 : 1.15;
else if (rough === 'unknown') { flags.push('surface-unknown'); mult *= strict ? 1.3 : 1.1; }
}
if (w.hw !== 'steps') {
@ -87,7 +87,7 @@ export function wayInfo(g: RouteGraph, w: Way, profile: Profile): WayInfo {
if (inc !== null) {
if (inc > profile.maxInclinePct * 1.5) { flags.push('steep'); mult *= 8; }
else if (inc > profile.maxInclinePct) { flags.push('steep'); mult *= 4; }
} else if (w.incline === 'up' || w.incline === 'down') { flags.push('slope-unknown'); mult *= 1.2; }
} else if (w.incline === 'up' || w.incline === 'down') { flags.push('slope-unknown'); mult *= strict ? 3 : 1.2; }
}
const width = parseWidth(w.width);
@ -96,7 +96,7 @@ export function wayInfo(g: RouteGraph, w: Way, profile: Profile): WayInfo {
return { mult, fixed, flags, rough };
}
class MinHeap {
export class MinHeap {
private a: [number, number][] = [];
get size() { return this.a.length; }
push(prio: number, node: number) {
@ -170,7 +170,11 @@ const NONE = (reason: RouteResult['reason']): RouteResult => ({
stats: { knownSurfacePct: 0, unknownKerbs: 0, cobbleM: 0 },
});
export function findRoute(g: RouteGraph, from: LatLon, to: LatLon, profile: Profile): RouteResult {
export type RouteMode = 'shortest' | 'balanced' | 'strict';
export interface RouteOpts { mode?: RouteMode; /** Way indexes to discourage, to force a different path. */ avoid?: ReadonlySet<number> }
export function findRoute(g: RouteGraph, from: LatLon, to: LatLon, profile: Profile, opts: RouteOpts = {}): RouteResult {
const mode = opts.mode ?? 'balanced';
const s = snapToGraph(g, from), e = snapToGraph(g, to);
if (s === null) return NONE('no-start');
if (e === null) return NONE('no-end');
@ -181,6 +185,12 @@ export function findRoute(g: RouteGraph, from: LatLon, to: LatLon, profile: Prof
if (!i) { i = wayInfo(g, ways[wi]!, profile); infos.set(wi, i); }
return i;
};
const costInfos = new Map<number, WayInfo>();
const costInfo = (wi: number) => {
let i = costInfos.get(wi);
if (!i) { i = wayInfo(g, ways[wi]!, profile, mode === 'strict'); costInfos.set(wi, i); }
return i;
};
const edgeCount = (wi: number) => Math.max(1, ways[wi]!.nodes.length - 1);
const goal = nodes[e]!;
@ -198,8 +208,11 @@ export function findRoute(g: RouteGraph, from: LatLon, to: LatLon, profile: Prof
const du = dist.get(u)!;
for (const edge of g.adj[u]!) {
if (done.has(edge.to)) continue;
const wi = info(edge.way);
const nd = du + edge.len * wi.mult + wi.fixed / edgeCount(edge.way);
let step: number;
if (mode === 'shortest') step = edge.len;
else { const wi = costInfo(edge.way); step = edge.len * wi.mult + wi.fixed / edgeCount(edge.way); }
if (opts.avoid?.has(edge.way)) step *= 1.35;
const nd = du + step;
if (nd < (dist.get(edge.to) ?? Infinity)) {
dist.set(edge.to, nd);
prev.set(edge.to, { node: u, edge });
@ -284,4 +297,85 @@ function summarize(
};
}
// ---------------- alternatives ----------------
export interface RouteCounts { hard: number; caution: number; unknown: number }
export interface RouteOption {
id: 'shortest' | 'balanced' | 'strict' | 'alt' | 'detour';
result: RouteResult;
counts: RouteCounts;
overheadM: number;
overheadMin: number;
score: number;
recommended: boolean;
}
const INFO_ONLY: ReadonlySet<Flag> = new Set<Flag>(['surface-unknown', 'ramp-steps']);
const UNKNOWN: ReadonlySet<Flag> = new Set<Flag>(['kerb-unknown', 'slope-unknown']);
export function countDifficulties(r: RouteResult): RouteCounts {
const c = { hard: 0, caution: 0, unknown: 0 };
for (const b of r.barriers) {
if (INFO_ONLY.has(b.flag)) continue;
if (HARD.has(b.flag)) c.hard++;
else if (UNKNOWN.has(b.flag)) c.unknown++;
else c.caution++;
}
return c;
}
const scoreOf = (c: RouteCounts) => c.hard * 1000 + c.caution * 10 + c.unknown * 2;
const signature = (r: RouteResult) => r.coords.map((p) => `${p[0].toFixed(5)},${p[1].toFixed(5)}`).join('|');
/** Indexes of ways that have at least one edge on the route. */
function usedWays(g: RouteGraph, r: RouteResult): Set<number> {
const onRoute = new Set(r.coords.map((p) => `${p[0]},${p[1]}`));
const out = new Set<number>();
g.snapshot.ways.forEach((w, i) => {
for (let k = 0; k < w.nodes.length - 1; k++) {
const a = g.snapshot.nodes[w.nodes[k]!]!, b = g.snapshot.nodes[w.nodes[k + 1]!]!;
if (onRoute.has(`${a[0]},${a[1]}`) && onRoute.has(`${b[0]},${b[1]}`)) { out.add(i); break; }
}
});
return out;
}
/**
* Shortest path first (distance only: difficulties are counted, not avoided), then variants that trade
* extra distance for fewer difficulties. Duplicates are dropped. The best-scoring variant is flagged.
*/
export function findAlternatives(g: RouteGraph, from: LatLon, to: LatLon, profile: Profile): RouteOption[] {
const shortest = findRoute(g, from, to, profile, { mode: 'shortest' });
if (shortest.status === 'none') {
return [{ id: 'shortest', result: shortest, counts: { hard: 0, caution: 0, unknown: 0 }, overheadM: 0, overheadMin: 0, score: 0, recommended: false }];
}
const balanced = findRoute(g, from, to, profile, { mode: 'balanced' });
const strict = findRoute(g, from, to, profile, { mode: 'strict' });
const used = new Set([...usedWays(g, balanced), ...usedWays(g, strict)]);
// a different "good" path, and a different near-shortest path
const alt = findRoute(g, from, to, profile, { mode: 'balanced', avoid: used });
const detour = findRoute(g, from, to, profile, { mode: 'shortest', avoid: usedWays(g, shortest) });
const seen = new Set<string>();
const cand: [RouteOption['id'], RouteResult][] = [['shortest', shortest], ['balanced', balanced], ['strict', strict], ['alt', alt], ['detour', detour]];
const options: RouteOption[] = [];
for (const [id, result] of cand) {
if (result.status === 'none') continue;
const sig = signature(result);
if (seen.has(sig)) continue;
seen.add(sig);
const counts = countDifficulties(result);
options.push({
id, result, counts, score: scoreOf(counts), recommended: false,
overheadM: Math.max(0, Math.round(result.distanceM - shortest.distanceM)),
overheadMin: Math.max(0, Math.round(result.durationMin - shortest.durationMin)),
});
}
const byScore = (a: RouteOption, b: RouteOption) => a.score - b.score || a.result.distanceM - b.result.distanceM;
const best = [...options].sort(byScore)[0];
if (best) best.recommended = true;
const [first, ...rest] = options;
return first ? [first, ...rest.sort(byScore)] : options;
}
export { HARD as HARD_FLAGS };

31
src/lib/settings.ts Normal file
View file

@ -0,0 +1,31 @@
import type { Lang } from './types';
export interface Settings {
lang: Lang;
textScale: 100 | 125 | 150;
highContrast: boolean;
reduceMotion: boolean;
showDemoPlaces: boolean;
}
export const defaultSettings = (lang: Lang): Settings => ({
lang, textScale: 100, highContrast: false, reduceMotion: false, showDemoPlaces: true,
});
export function isSettings(v: unknown): v is Settings {
if (typeof v !== 'object' || v === null) return false;
const s = v as Record<string, unknown>;
return (
(s.lang === 'pl' || s.lang === 'en') && (s.textScale === 100 || s.textScale === 125 || s.textScale === 150) &&
typeof s.highContrast === 'boolean' && typeof s.reduceMotion === 'boolean' && typeof s.showDemoPlaces === 'boolean'
);
}
/** Applies display settings to the document root. */
export function applySettings(s: Settings) {
const root = document.documentElement;
root.lang = s.lang;
root.style.fontSize = `${s.textScale}%`;
root.dataset.contrast = s.highContrast ? 'high' : 'default';
root.dataset.motion = s.reduceMotion ? 'reduce' : 'default';
}

View file

@ -1,23 +1,36 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
/** localStorage-backed state that degrades to memory when storage is blocked. */
function read<T>(key: string, initial: T, valid?: (v: unknown) => v is T): T {
try {
const raw = localStorage.getItem(key);
if (raw !== null) {
const parsed: unknown = JSON.parse(raw);
if (!valid || valid(parsed)) return parsed as T;
}
} catch { /* storage blocked or corrupt: fall back to initial */ }
return initial;
}
/** localStorage-backed state keyed by `key`; re-reads when the key changes. Degrades to memory. */
export function useLocalState<T>(key: string, initial: T, valid?: (v: unknown) => v is T) {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
if (raw !== null) {
const parsed: unknown = JSON.parse(raw);
if (!valid || valid(parsed)) return parsed as T;
}
} catch { /* storage blocked or corrupt: fall back to initial */ }
return initial;
});
const [state, setState] = useState(() => ({ key, value: read(key, initial, valid) }));
const value = state.key === key ? state.value : read(key, initial, valid);
if (state.key !== key) setState({ key, value });
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch { /* ignore */ }
}, [key, value]);
return [value, setValue] as const;
try { localStorage.setItem(state.key, JSON.stringify(state.value)); } catch { /* ignore */ }
}, [state]);
const set = useCallback(
(u: T | ((cur: T) => T)) =>
setState((cur) => {
const base = cur.key === key ? cur.value : read(key, initial, valid);
return { key, value: typeof u === 'function' ? (u as (c: T) => T)(base) : u };
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[key],
);
return [value, set] as const;
}
export function clearLocal(keys: string[]) {

View file

@ -126,6 +126,16 @@ export interface Assessment {
}
export type ReportAttr = 'entrance' | 'steps' | 'width' | 'toilet' | 'ramp';
export type MeasureMethod = 'photo-ref' | 'inclinometer' | 'manual' | 'lidar';
/** A number measured on site. `lidar` is reserved for a future native iOS companion app. */
export interface Measurement {
kind: 'kerb' | 'incline';
value: number;
unit: 'cm' | 'pct';
method: MeasureMethod;
}
export type ReportValue = 'yes' | 'limited' | 'no' | number;
export interface UserReport {
@ -144,4 +154,35 @@ export interface UserReport {
reviewedBy?: string;
/** Seeded example, not a real user report. */
demo?: boolean;
/** Photo stored in IndexedDB under this id (EXIF stripped, downscaled). */
photoId?: string;
/** Measured on site, if any. */
measure?: Measurement;
/** Automated first look; a human always decides. */
vetting?: VetState;
}
// ---------------- AI triage ----------------
export type EvidenceFlag = 'blurry' | 'face' | 'text' | 'unrelated' | 'no-photo';
export interface Vetting {
provider: string;
at: string;
validity: 'valid' | 'unclear' | 'invalid';
/** Does the photo agree with what the reporter claims? */
consistency: 'supports' | 'contradicts' | 'neutral';
severity: 'critical' | 'major' | 'minor' | 'none';
accessibilityClass: 'inaccessible' | 'difficult' | 'accessible' | 'unknown';
triage: 'fast-track' | 'review' | 'likely-reject';
confidence: number;
flags: EvidenceFlag[];
/** Top photo labels with probabilities (0..1). */
top: { key: string; score: number }[];
reasons: Msg[];
}
export type VetState =
| { state: 'running' }
| { state: 'done'; result: Vetting }
| { state: 'failed'; reason: 'no-credits' | 'error' };

14
src/lib/usePhoto.ts Normal file
View file

@ -0,0 +1,14 @@
import { useEffect, useState } from 'react';
import { getPhoto } from './photo';
/** Loads a report photo from IndexedDB. */
export function usePhoto(id: string | undefined): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
if (!id) { setUrl(null); return; }
void getPhoto(id).then((u) => { if (alive) setUrl(u); });
return () => { alive = false; };
}, [id]);
return url;
}

137
src/lib/vet.ts Normal file
View file

@ -0,0 +1,137 @@
// First-pass triage of a user report: photo + measurement + claim -> validity, consistency, severity.
// Runs fully on-device (CLIP zero-shot via transformers.js): free, private, no API key.
// It only prioritises the queue. A city official always makes the decision.
import { BLUR_THRESHOLD } from './photo';
import type { EvidenceFlag, Msg, UserReport, Vetting } from './types';
export const LABELS = {
steps: 'steps or a staircase at a building entrance',
raisedKerb: 'a high raised kerb at the edge of a road',
loweredKerb: 'a lowered dropped kerb at a pedestrian crossing',
ramp: 'a wheelchair ramp',
flat: 'a flat level doorway entrance without steps',
narrowDoor: 'a narrow door',
cobbles: 'a cobblestone street',
lift: 'an elevator',
face: 'a selfie close-up of a person face',
text: 'a screenshot or a document with text',
unrelated: 'food, an animal, a landscape or an indoor room',
} as const;
export type LabelKey = keyof typeof LABELS;
const KEYS = Object.keys(LABELS) as LabelKey[];
/** Raised when no vision backend can run (model could not be loaded). The UI says "No Credits left". */
export class NoCreditsError extends Error {
constructor() { super('No Credits left'); }
}
export interface VisionProvider {
id: string;
classify(dataUrl: string, phrases: string[]): Promise<{ label: string; score: number }[]>;
}
let clip: Promise<(img: string, labels: string[]) => Promise<{ label: string; score: number }[]>> | null = null;
export const clipProvider: VisionProvider = {
id: 'on-device CLIP (Xenova/clip-vit-base-patch32)',
async classify(dataUrl, phrases) {
clip ??= (async () => {
const { pipeline, env } = await import('@huggingface/transformers');
env.allowLocalModels = false;
const p = await pipeline('zero-shot-image-classification', 'Xenova/clip-vit-base-patch32');
return async (img: string, labels: string[]) => {
const out = (await p(img, labels)) as { label: string; score: number }[];
return out;
};
})();
try {
return await (await clip)(dataUrl, phrases);
} catch {
clip = null;
throw new NoCreditsError();
}
},
};
const sum = (s: Record<LabelKey, number>, ...k: LabelKey[]) => k.reduce((a, x) => a + (s[x] ?? 0), 0);
const pct = (n: number) => Math.round(n * 100);
/** Does the report claim a barrier, an accessible place, or neither? */
export function claimOf(r: Pick<UserReport, 'attr' | 'value'>): 'barrier' | 'access' | 'neutral' {
const v = r.value;
if (r.attr === 'entrance') return v === 'no' || v === 'limited' ? 'barrier' : v === 'yes' ? 'access' : 'neutral';
if (r.attr === 'steps') return v === 'yes' ? 'barrier' : v === 'no' ? 'access' : 'neutral';
if (r.attr === 'ramp') return v === 'yes' ? 'access' : v === 'no' ? 'barrier' : 'neutral';
if (r.attr === 'width' && typeof v === 'number') return v < 80 ? 'barrier' : v >= 90 ? 'access' : 'neutral';
return 'neutral';
}
interface Ctx { report: Pick<UserReport, 'attr' | 'value' | 'measure'>; blur?: number | undefined; hasPhoto: boolean }
/** Pure decision logic: unit-tested, no model needed. */
export function interpret(scores: Record<LabelKey, number> | null, ctx: Ctx, provider: string, now = new Date()): Vetting {
const flags: EvidenceFlag[] = [];
const reasons: Msg[] = [];
const m = ctx.report.measure;
const claim = claimOf(ctx.report);
const s = scores ?? (Object.fromEntries(KEYS.map((k) => [k, 0])) as Record<LabelKey, number>);
if (!ctx.hasPhoto || !scores) {
flags.push('no-photo');
reasons.push({ k: 'vet.r.noPhoto' });
}
if (ctx.hasPhoto && ctx.blur !== undefined && ctx.blur < BLUR_THRESHOLD) { flags.push('blurry'); reasons.push({ k: 'vet.r.blurry' }); }
if (s.face >= 0.35) { flags.push('face'); reasons.push({ k: 'vet.r.face' }); }
if (s.text >= 0.35) { flags.push('text'); reasons.push({ k: 'vet.r.text' }); }
if (s.unrelated >= 0.4) { flags.push('unrelated'); reasons.push({ k: 'vet.r.unrelated' }); }
const barrier = sum(s, 'steps', 'raisedKerb', 'narrowDoor');
const access = sum(s, 'ramp', 'flat', 'loweredKerb', 'lift');
const relevant = barrier + access + sum(s, 'cobbles');
let validity: Vetting['validity'];
if (!scores) validity = 'unclear';
else if ((flags.includes('face') || flags.includes('text') || flags.includes('unrelated')) && relevant < 0.4) validity = 'invalid';
else if (relevant < 0.55 || flags.includes('blurry')) validity = 'unclear';
else validity = 'valid';
let consistency: Vetting['consistency'] = 'neutral';
if (scores && claim === 'barrier') consistency = barrier >= 0.3 && barrier > access ? 'supports' : access >= 0.4 && access > barrier ? 'contradicts' : 'neutral';
if (scores && claim === 'access') consistency = access >= 0.3 && access > barrier ? 'supports' : barrier >= 0.4 && barrier > access ? 'contradicts' : 'neutral';
if (consistency === 'supports') reasons.push({ k: 'vet.r.supports' });
if (consistency === 'contradicts') reasons.push({ k: 'vet.r.contradicts' });
const kerb = m?.kind === 'kerb' ? m.value : null;
const incline = m?.kind === 'incline' ? m.value : null;
if (m) reasons.push({ k: 'vet.r.measure', p: { kind: `@vet.measure.${m.kind}`, value: m.value, unit: m.unit === 'cm' ? 'cm' : '%', method: `@vet.method.${m.method}` } });
let severity: Vetting['severity'] = 'none';
if ((s.steps >= 0.4 && claim === 'barrier' && consistency === 'supports') || (kerb !== null && kerb >= 12) || (incline !== null && incline >= 12)) severity = 'critical';
else if (barrier >= 0.4 || (kerb !== null && kerb >= 5) || (incline !== null && incline >= 8)) severity = 'major';
else if (s.cobbles >= 0.3 || s.narrowDoor >= 0.3 || (kerb !== null && kerb >= 3) || (incline !== null && incline >= 6)) severity = 'minor';
if (validity === 'invalid') severity = 'none';
const accessibilityClass: Vetting['accessibilityClass'] =
severity === 'critical' ? 'inaccessible' : severity === 'major' || severity === 'minor' ? 'difficult' : access >= 0.4 ? 'accessible' : 'unknown';
let triage: Vetting['triage'] = 'review';
if (validity === 'invalid') triage = 'likely-reject';
else if (validity === 'valid' && consistency === 'supports' && (severity === 'critical' || severity === 'major')) triage = 'fast-track';
const top = (Object.entries(s) as [LabelKey, number][]).sort((a, b) => b[1] - a[1]).slice(0, 3).filter(([, v]) => v > 0.05).map(([key, score]) => ({ key, score }));
for (const t of top.slice(0, 2)) reasons.unshift({ k: 'vet.r.top', p: { label: `@vet.label.${t.key}`, pct: pct(t.score) } });
const confidence = scores ? Math.max(0, Math.min(1, relevant * (consistency === 'supports' ? 1 : consistency === 'contradicts' ? 0.6 : 0.8) * (flags.includes('blurry') ? 0.6 : 1))) : 0;
return { provider, at: now.toISOString(), validity, consistency, severity, accessibilityClass, triage, confidence, flags, top, reasons };
}
/** Runs the photo through the provider and interprets the result. */
export async function vetReport(
report: Pick<UserReport, 'attr' | 'value' | 'measure'>, photo: { dataUrl: string; blur: number } | null, provider: VisionProvider = clipProvider,
): Promise<Vetting> {
if (!photo) return interpret(null, { report, hasPhoto: false }, provider.id);
const raw = await provider.classify(photo.dataUrl, KEYS.map((k) => LABELS[k]));
const byPhrase = new Map(raw.map((r) => [r.label, r.score]));
const scores = Object.fromEntries(KEYS.map((k) => [k, byPhrase.get(LABELS[k]) ?? 0])) as Record<LabelKey, number>;
return interpret(scores, { report, blur: photo.blur, hasPhoto: true }, provider.id);
}

125
src/lib/voice.ts Normal file
View file

@ -0,0 +1,125 @@
// Quiet voice guidance: speak only discrete events (a turn about to happen, a barrier ahead,
// leaving the route, arriving) and never talk over ourselves.
import type { GuideState } from './guide';
export type CueKind = 'turn' | 'barrier' | 'off' | 'arrived' | 'info';
export interface Cue {
id: string;
kind: CueKind;
text: string;
/** Higher interrupts lower only when it is at least 2 levels above. */
priority: number;
/** Re-checked before a queued cue is finally spoken; stale cues are dropped. */
valid?: () => boolean;
}
export const TURN_CUE_M = 25;
export const BARRIER_CUE_M = 60;
const MAX_SPEAK_MS = 8000;
const GAP_MS = 500;
export interface Utt { text: string; onend: (() => void) | null; onerror: (() => void) | null }
export interface Synth { speak(u: Utt): void; cancel(): void }
/** One utterance at a time. A queued cue waits (only the most important one is kept) instead of cutting in. */
export class VoicePlayer {
private speaking: Cue | null = null;
private pending: Cue | null = null;
private guard: ReturnType<typeof setTimeout> | null = null;
constructor(private synth: Synth, private make: (text: string) => Utt, private later: (fn: () => void, ms: number) => unknown = setTimeout) {}
say(cue: Cue) {
if (this.speaking) {
if (cue.priority >= this.speaking.priority + 2) { this.pending = null; this.synth.cancel(); this.start(cue); }
else if (!this.pending || cue.priority >= this.pending.priority) this.pending = cue;
return;
}
this.start(cue);
}
stop() {
this.pending = null;
this.speaking = null;
if (this.guard) clearTimeout(this.guard);
this.synth.cancel();
}
private start(cue: Cue) {
this.speaking = cue;
const u = this.make(cue.text);
const done = () => this.finish(cue);
u.onend = done;
u.onerror = done;
if (this.guard) clearTimeout(this.guard);
this.guard = setTimeout(done, MAX_SPEAK_MS); // some browsers never fire `end`
this.synth.speak(u);
}
private finish(cue: Cue) {
if (this.speaking !== cue) return; // cancelled or replaced
if (this.guard) clearTimeout(this.guard);
this.speaking = null;
const next = this.pending;
this.pending = null;
if (next && (!next.valid || next.valid())) this.later(() => this.say(next), GAP_MS);
}
}
export interface CueTexts {
turn: (distance: number, turn: string) => string;
barrier: (distance: number, what: string) => string;
off: string;
arrived: string;
}
const tens = (d: number) => Math.max(10, Math.round(d / 10) * 10);
/** Decides, from successive guidance states, which cues are due. Each event fires once. */
export class CueTracker {
private said = new Set<string>();
private wasOff = false;
private arrived = false;
constructor(private texts: CueTexts, private labels: { turn: (t: string) => string; flag: (f: string) => string }, private stillAhead: (at: number) => boolean) {}
update(s: GuideState): Cue[] {
const out: Cue[] = [];
if (s.arrived) {
if (!this.arrived) { this.arrived = true; out.push({ id: 'arrived', kind: 'arrived', text: this.texts.arrived, priority: 4 }); }
return out;
}
if (s.offRoute) {
if (!this.wasOff) { this.wasOff = true; out.push({ id: `off:${this.said.size}`, kind: 'off', text: this.texts.off, priority: 3 }); }
return out; // nothing else makes sense while off the route
}
this.wasOff = false;
if (s.barrier && s.barrier.distance <= BARRIER_CUE_M) {
const b = s.barrier.b;
const id = `b:${Math.round(b.at)}:${b.flag}`;
if (!this.said.has(id)) {
this.said.add(id);
out.push({
id, kind: 'barrier', priority: b.hard ? 4 : 3,
text: this.texts.barrier(tens(s.barrier.distance), this.labels.flag(b.flag)),
valid: () => this.stillAhead(b.at),
});
}
}
if (s.next && s.next.distance <= TURN_CUE_M) {
const m = s.next.maneuver;
const id = `m:${m.segment}`;
if (!this.said.has(id)) {
this.said.add(id);
out.push({
id, kind: 'turn', priority: 2,
text: this.texts.turn(tens(s.next.distance), this.labels.turn(m.turn)),
valid: () => this.stillAhead(m.at),
});
}
}
return out;
}
}

View file

@ -53,3 +53,93 @@ body { margin: 0; }
.kbb-pin--hard { background: #b50909; color: #fff; border-color: #fff; }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
/* ---- profile / tooltips ---- */
.kbb-step { display: flex; align-items: center; gap: 0.6rem; margin: 0 0 0.5rem; }
.kbb-step__n { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 2rem; height: 2rem; border-radius: 50%; background: #005ea2; color: #fff; font-size: 1rem; font-weight: 700; }
.kbb-choice { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: 0.5rem; }
.kbb-choice .usa-radio { margin: 0; }
.kbb-choice .usa-radio__label { margin-top: 0; height: 100%; }
.kbb-tip { position: relative; display: inline-flex; margin-left: 0.25rem; }
.kbb-tip__btn { appearance: none; display: inline-flex; align-items: center; justify-content: center; min-width: 2.75rem; min-height: 2.75rem; padding: 0; background: none; border: 0; border-radius: 50%; color: #005ea2; cursor: pointer; }
.kbb-tip__btn:hover { background: #e7f6f8; }
.kbb-tip__btn:focus-visible { outline: 0.25rem solid #2491ff; }
.kbb-tip__bubble { position: absolute; z-index: 20; top: 100%; left: -0.5rem; width: max-content; max-width: min(19rem, 78vw); padding: 0.6rem 0.75rem; background: #1b1b1b; color: #fff; border-radius: 0.25rem; font-size: 0.9rem; line-height: 1.4; font-weight: 400; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); }
.kbb-tip__bubble[hidden] { display: none; }
/* ---- dashboard ---- */
.kbb-wide { max-width: 64rem; }
.kbb-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.75rem; margin-bottom: 1rem !important; }
.kbb-kpi { display: flex; flex-direction: column; padding: 0.9rem 1rem; border: 1px solid #dfe1e2; border-left: 0.35rem solid #005ea2; border-radius: 0.25rem; background: #fff; }
.kbb-kpi__v { font-size: 1.9rem; font-weight: 700; line-height: 1.1; color: #1b1b1b; }
.kbb-kpi__l { font-size: 0.9rem; margin-top: 0.25rem; color: #3d4551; }
.kbb-kpi__s { font-size: 0.8rem; color: #565c65; }
.kbb-bar { min-width: 8rem; white-space: nowrap; }
.kbb-bar span { display: inline-block; height: 0.75rem; vertical-align: middle; }
.kbb-bar__k { background: #00a91c; }
.kbb-bar__s { background: #ffbe2e; }
.kbb-bar__u { background: #a9aeb1; }
.kbb-rank { display: inline-flex; align-items: center; justify-content: center; width: 1.9rem; height: 1.9rem; margin-right: 0.6rem; border-radius: 50%; background: #1b1b1b; color: #fff; font-weight: 700; }
/* ---- access card ---- */
.kbb-card { position: fixed; inset: 0; z-index: 100; overflow: auto; padding: 1rem; background: rgba(27, 27, 27, 0.7); }
.kbb-card__sheet { max-width: 46rem; margin: 0 auto; padding: 1.5rem; background: #fff; border-radius: 0.25rem; }
.kbb-card__bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.kbb-card__bar .usa-button { margin: 0; }
@media print {
.kbb-app > :not(.kbb-card) { display: none !important; }
.kbb-card { position: static; padding: 0; background: none; overflow: visible; }
.kbb-card__sheet { max-width: none; padding: 0; }
.kbb-card__bar { display: none; }
}
/* ---- high contrast (settings) ---- */
:root[data-contrast='high'] { --kbb-border: #1b1b1b; }
:root[data-contrast='high'] body { color: #000; }
:root[data-contrast='high'] .text-base-dark, :root[data-contrast='high'] .text-base, :root[data-contrast='high'] .usa-hint { color: #1b1b1b !important; }
:root[data-contrast='high'] .border-base-lighter { border-color: #1b1b1b !important; }
:root[data-contrast='high'] .usa-input, :root[data-contrast='high'] .usa-select, :root[data-contrast='high'] .usa-textarea { border-width: 2px; border-color: #000; }
:root[data-contrast='high'] .usa-button--outline { box-shadow: inset 0 0 0 3px #1b1b1b; color: #1b1b1b; }
:root[data-motion='reduce'] * { transition: none !important; animation: none !important; }
/* ---- header ---- */
.kbb-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.25rem; padding: 0.6rem 1rem; }
.kbb-head__nav { order: 3; width: 100%; overflow-x: auto; }
.kbb-head__tools { display: flex; align-items: center; gap: 0.75rem; margin-left: auto; }
.kbb-head__user { display: none; }
@media (min-width: 40em) { .kbb-head__user { display: inline-flex; align-items: center; } }
@media (min-width: 64em) { .kbb-head__nav { order: 0; width: auto; flex: 1; } }
.usa-range { max-width: 100%; width: 100%; }
.kbb-result:focus { outline: none; }
.kbb-head__brand { font-size: 1.1rem; }
.kbb-banner-detail { display: none; }
@media (min-width: 40em) { .kbb-banner-detail { display: inline; } .kbb-head__brand { font-size: 1.35rem; } }
.usa-site-alert .usa-alert { padding-top: 0.25rem; padding-bottom: 0.25rem; }
/* ---- evidence ---- */
.kbb-measure-canvas { display: block; width: 100%; max-height: 22rem; object-fit: contain; background: #1b1b1b; border-radius: 0.25rem; touch-action: manipulation; cursor: crosshair; }
.kbb-thumb { width: 5.5rem; height: 5.5rem; object-fit: cover; border-radius: 0.25rem; border: 1px solid #a9aeb1; flex: none; }
/* ---- live guidance banner ---- */
.kbb-guide { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; padding: 0.75rem 1rem; background: #1b1b1b; color: #fff; border-bottom: 4px solid #00bde3; }
.kbb-guide__main { flex: 1 1 18rem; min-width: 0; }
.kbb-guide__headline { margin: 0; font-size: 1.35rem; font-weight: 700; line-height: 1.25; display: flex; align-items: center; }
.kbb-guide__headline .usa-icon { flex: none; fill: #00bde3; }
.kbb-guide__barrier { margin: 0.35rem 0 0; padding: 0.3rem 0.6rem; background: #ffbe2e; color: #1b1b1b; font-weight: 700; border-radius: 0.25rem; display: inline-flex; align-items: center; }
.kbb-guide__meta { margin: 0.25rem 0 0; font-size: 0.9rem; color: #dfe1e2; }
.kbb-guide__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.kbb-guide .usa-button--inverse[aria-pressed='true'] { background: #fff; color: #1b1b1b; box-shadow: none; }
/* ---- basemap switch ---- */
.kbb-basemap { position: absolute; top: 0.5rem; left: 0.5rem; z-index: 2; display: flex; background: #fff; border-radius: 0.25rem; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); }
.kbb-basemap button { appearance: none; border: 0; background: #fff; color: #005ea2; font: inherit; font-weight: 700; padding: 0.55rem 0.9rem; min-height: 2.75rem; cursor: pointer; }
.kbb-basemap button:first-child { border-radius: 0.25rem 0 0 0.25rem; }
.kbb-basemap button:last-child { border-radius: 0 0.25rem 0.25rem 0; }
.kbb-basemap button[aria-pressed='true'] { background: #005ea2; color: #fff; }
.kbb-basemap button:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
.kbb-me { width: 1.4rem; height: 1.4rem; border-radius: 50%; background: #1a73e8; border: 3px solid #fff; box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.35); }
.kbb-guide__voice { display: flex; }
.kbb-guide__select { margin: 0; min-height: 2.5rem; padding: 0.4rem 2rem 0.4rem 0.6rem; background-color: transparent; color: #fff; border: 2px solid #fff; font-size: 0.95rem; }
.kbb-guide__select option { color: #1b1b1b; }

View file

@ -1,11 +1,14 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import basicSsl from '@vitejs/plugin-basic-ssl';
export default defineConfig({
base: './',
plugins: [
react(),
// 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'],
@ -31,6 +34,15 @@ export default defineConfig({
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
navigateFallback: 'index.html',
runtimeCaching: [
{
urlPattern: /^https:\/\/server\.arcgisonline\.com\/.*/,
handler: 'CacheFirst',
options: {
cacheName: 'sat-tiles',
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 14 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*/,
handler: 'CacheFirst',