From 96703b6b30c3541055f8f54aad1a9c0cb3a29cf2 Mon Sep 17 00:00:00 2001 From: InfoX Date: Sat, 3 Oct 2026 14:29:28 +0200 Subject: [PATCH] Works :) --- .DS_Store | Bin 6148 -> 8196 bytes README.md | 110 +++++ package.json | 11 +- pnpm-lock.yaml | 211 ++++++++++ pnpm-workspace.yaml | 2 + scripts/axe.mjs | 37 ++ scripts/sensors-e2e.mjs | 47 +++ scripts/smoke.mjs | 57 +++ scripts/vet-e2e.mjs | 30 ++ scripts/voice-e2e.mjs | 28 ++ src/App.tsx | 399 ++++++++++++++---- src/components/AboutView.tsx | 13 +- src/components/AccessCard.tsx | 54 +++ src/components/DashboardView.tsx | 228 ++++++++++ src/components/GuideBanner.tsx | 149 +++++++ src/components/LoginView.tsx | 54 +++ src/components/MapView.tsx | 131 +++++- src/components/MeasureTools.tsx | 191 +++++++++ src/components/PlaceDetail.tsx | 104 ++++- src/components/PlacesView.tsx | 15 +- src/components/ProfileView.tsx | 235 ++++++++--- src/components/ReportForm.tsx | 83 +++- src/components/RouteView.tsx | 110 ++++- src/components/SettingsPanel.tsx | 60 +++ src/components/ui.tsx | 77 +++- src/lib/assess.ts | 22 +- src/lib/auth.ts | 24 ++ src/lib/civic.ts | 133 ++++++ src/lib/demo.ts | 8 + src/lib/guide.ts | 103 +++++ src/lib/i18n.ts | 692 ++++++++++++++++++++++++++++++- src/lib/lib.test.ts | 278 +++++++++++++ src/lib/links.ts | 31 ++ src/lib/photo.ts | 91 ++++ src/lib/profile.ts | 21 + src/lib/reach.ts | 50 +++ src/lib/route.ts | 114 ++++- src/lib/settings.ts | 31 ++ src/lib/store.ts | 47 ++- src/lib/types.ts | 41 ++ src/lib/usePhoto.ts | 14 + src/lib/vet.ts | 137 ++++++ src/lib/voice.ts | 125 ++++++ src/styles/app.css | 90 ++++ vite.config.ts | 12 + 45 files changed, 4260 insertions(+), 240 deletions(-) create mode 100644 README.md create mode 100644 scripts/axe.mjs create mode 100644 scripts/sensors-e2e.mjs create mode 100644 scripts/smoke.mjs create mode 100644 scripts/vet-e2e.mjs create mode 100644 scripts/voice-e2e.mjs create mode 100644 src/components/AccessCard.tsx create mode 100644 src/components/DashboardView.tsx create mode 100644 src/components/GuideBanner.tsx create mode 100644 src/components/LoginView.tsx create mode 100644 src/components/MeasureTools.tsx create mode 100644 src/components/SettingsPanel.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/civic.ts create mode 100644 src/lib/guide.ts create mode 100644 src/lib/links.ts create mode 100644 src/lib/photo.ts create mode 100644 src/lib/reach.ts create mode 100644 src/lib/settings.ts create mode 100644 src/lib/usePhoto.ts create mode 100644 src/lib/vet.ts create mode 100644 src/lib/voice.ts diff --git a/.DS_Store b/.DS_Store index fd0af158d617932865c5da15705eadd838c6c99d..c62ed143f4028dd8dbcd929ee485caff39bc366a 100644 GIT binary patch literal 8196 zcmeHMU2GIp6u#fI&>1?=0g5fS6BY_V$O5GlTmH=UPx&vfE!~!%Wp-zz1JjwZGrKL6 z#>T`4Q7}Gf{O3udkp}}}e9=Tj^ik0SV|*ZL)E7+jMdiUWb7u*(G%>~)8|Nl-&%O7Y zbMHN8zHeslEMp9<1$_-;wTv;5E?+8@RNbZcdjFbGgm9#sB*>q!LFO?xJLC>#tqIoA zK$L+f15pN|3`7}-GVou>0KK#QB2Mz&=h~=_G7x3pf0+UQ`4Feem&s6$aTz>1s0xk% zB>f{mP^eAkfWRjn%48_VxC~rrPJVho=!)=&0q#!vxNlA}8Okv(xjO@QX9!nDctSz2 zI{C!|bA}|BQ5|I<%D}`7`1Gk@F3YhzGjBh?J6Y0P$_`ql>($qP38Acf+Vl#sLaY=I zruv#!PE^hInZO;x--=36l~oM zd{bN@N}`leYDPwy8WZt#4Qoad@safn^@(`n+WSXGC2?Ne>WzCd$E*R{d7Q_A@L9lS zj*IK`#Gov<*p{{YpvqFEpuQ>vCC#XmXR1Bv-oE{EMwwHj-FFUprenMNjJ!)Sqg1&R z<<^{I@6H>(z9;8cg#p{mD6?~#l`B}9XS7&W$~&`t_tCZPu z=2pvcAEndw%!f@Q-#ujNUVpFV<_udm?IS%!(>1e}(Pe7`#%v@c>1tK&`~~;MRCO}b zbRlaVFPPp5rA`!sHR6j@`G8J)+Ue;2D!Ot}qu-Tv*EY&(x20tbOVK61PV;(M?J4s7E8D}t&DFA+&N%}E6rvlB zwX~}0A%EE&n(K8A8a8d|O1Soer0aA0wYDOKIG9>d;Nk36zwLT@*#H zKV6Hi2Qe(DonNJ<)NWDvOw1^XX%|hxKHXMM0kN)hYwD6Kc(d;PW67WuB)TqH&xa*? z$EmD=wX+@UF!61Kong<0S{z;sBMfl5?k0hSg~dWbSShR$nuKOygRoK9C3FgDK@*M&jxfvv5K4qQ$or*Q zI6Xlo?=F*I`1Z8 zt3;xwA_QG|O>C`1Bvi_HHxX-+L( z*Vzy3XLggYJ_~bEi)E-Mgg<~S*hLuk5#EhM$Us9DItE~45XTAar!a~~Fos9*1fl&b zp2E|34$tESyo6Wr8X^7-yh(V!gm>^RKEQ{#jE_U)e@(dm3BQcP^3D>9xx}v%v0O~$ zZOgHbkfy|dJDsLxuZ(ZI)<6GuO#c0UY6chWI?6zlfxnail((naTgh~?hnqmUI9-S7 z@{4ab#%16_m2cvY2gh-exBf7sc0AN2LpjDJX{h|`9|HdN5ANvwAHDwr=m)r+>eZiQ C?sk#@ delta 124 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{MGjdEU6q~50$jCG?VE1GL8J5ZX0#7Czh+p5> zV9UCgor6P=8K?>f1h|2OD@en}!tczJ`DH8>K*Ed+3`|g(1w;cmAfrLHF>H?KnZpbK DG@lZb diff --git a/README.md b/README.md new file mode 100644 index 0000000..f077d53 --- /dev/null +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 7814c3a..cabd06c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1b3ad5..0aaff4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d342b82..31a031c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,6 @@ allowBuilds: "@parcel/watcher": false esbuild: true + onnxruntime-node: false + protobufjs: false sharp: true diff --git a/scripts/axe.mjs b/scripts/axe.mjs new file mode 100644 index 0000000..8bac565 --- /dev/null +++ b/scripts/axe.mjs @@ -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; diff --git a/scripts/sensors-e2e.mjs b/scripts/sensors-e2e.mjs new file mode 100644 index 0000000..2a0beee --- /dev/null +++ b/scripts/sensors-e2e.mjs @@ -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; diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..6dd9da8 --- /dev/null +++ b/scripts/smoke.mjs @@ -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(); diff --git a/scripts/vet-e2e.mjs b/scripts/vet-e2e.mjs new file mode 100644 index 0000000..d855a34 --- /dev/null +++ b/scripts/vet-e2e.mjs @@ -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(); diff --git a/scripts/voice-e2e.mjs b/scripts/voice-e2e.mjs new file mode 100644 index 0000000..cb2c791 --- /dev/null +++ b/scripts/voice-e2e.mjs @@ -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(); diff --git a/src/App.tsx b/src/App.tsx index 3b294bc..093a1e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = (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('kbb.lang', detectLang(), isLang); - const [profile, setProfile] = useLocalState('kbb.profile', null, (v): v is Profile | null => v === null || isProfile(v)); + // ---- identity (demo) ---- + const [loginLang, setLoginLang] = useLocalState('kbb.lang', detectLang(), isLang); + const [session, setSession] = useLocalState('kbb.session', null, isStrOrNull); + const account: Account | null = findAccount(session); + const uid = account?.id ?? 'none'; + const [settings, setSettings] = useLocalState(userKey(uid, 'settings'), defaultSettings(loginLang), isSettings); + const [profile, setProfile] = useLocalState(userKey(uid, 'profile'), null, (v): v is Profile | null => v === null || isProfile(v)); + const [favorites, setFavorites] = useLocalState(userKey(uid, 'favorites'), [], isStrArray); const [userReports, setUserReports] = useLocalState('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v)); - const [view, setView] = useState(profile ? 'places' : 'profile'); + const [decisions, setDecisions] = useLocalState>('kbb.decisions', {}, isObj>); + const [demand, setDemand] = useLocalState>('kbb.demand', {}, isObj>); + + const lang: Lang = account ? settings.lang : loginLang; + const [view, setView] = useState('places'); const [pane, setPane] = useState<'list' | 'map'>('list'); const online = useOnline(); const i18n = useMemo(() => { 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(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>({}); - const places = useMemo( - () => (snap ? [...DEMO_PLACES, ...snap.places.map((p) => overrides[p.id] ?? p)] : []), - [snap, overrides], + const realPlaces = useMemo(() => (snap ? snap.places.map((p) => overrides[p.id] ?? p) : []), [snap, overrides]); + const places = useMemo(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]); + const reports = useMemo( + () => [...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>(); + const m = new Map(); 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 => { + const c: Record = { 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(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(null); - const selected = selectedId ? places.find((p) => p.id === selectedId) ?? null : null; + const selected = selectedId ? placeById.get(selectedId) ?? null : null; const [refresh, setRefresh] = useState({ kind: 'idle' }); const [simulateOutage, setSimulateOutage] = useState(false); const detailHeading = useRef(null); + // ---- reach ---- + const [reachState, setReachState] = useState({ minutes: null, summary: null }); + const [reachLines, setReachLines] = useState<[LatLon, LatLon][] | null>(null); + const snapCache = useRef(new Map()); + 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) => - setUserReports((cur) => [...cur, { ...r, id: newId(), createdAt: new Date().toISOString() }]); + const patchReport = (id: string, patch: Partial) => + 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, 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(null); - // ---- route state ---- + // ---- route ---- const [routeForm, setRouteForm] = useState({ fromText: '', toText: '', fromPoint: null }); - const [route, setRoute] = useState(null); + const [routeOptions, setRouteOptions] = useState(null); + const [routeSel, setRouteSel] = useState(0); + const [guiding, setGuiding] = useState(false); + const [userPos, setUserPos] = useState(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(null); + const [exportMsg, setExportMsg] = useState(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>(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(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 ( + +
+
+ ); + } return ( @@ -165,45 +333,51 @@ export default function App() {

{t('banner.proto')}{' '} - {snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''} + {snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}

-
-

+

+

{t('app.name')}

-
- {(['pl', 'en'] as const).map((l) => ( - - ))} + +
+ + + {account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)} + +
+ {(['pl', 'en'] as const).map((l) => ( + + ))} +
-
{split && ( @@ -223,44 +397,88 @@ export default function App() { )} + {guiding && view === 'route' && route && route.status !== 'none' && ( + setGuiding(false)} onPos={setUserPos} onReroute={reroute} /> + )} +
{loadError &&
{t('common.loadError')}
} - {!loadError && !ready && view !== 'profile' && !needsProfile && ( + {!loadError && !ready && !needsProfile && view !== 'profile' && (
{t('common.loading')}
)} {(view === 'profile' || needsProfile) && ( { 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 && ( + void importSettings(f)} message={settingsMsg} + /> + )} + )} {view === 'about' && !needsProfile && snap && ( )} + {view === 'dashboard' && !needsProfile && ( + official ? ( + ready && ( + { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }} + /> + ) + ) : ( +
{t('dash.role')}
+ ) + )} + {split && ready && ( <>
+ {view === 'places' && hotMode && ( +
+ + {t('dash.hot.mapBanner', { n: hotspots.length })}{' '} + + +
+ )} {view === 'places' && (selected ? ( 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)); }} /> ) : ( { 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'); }} /> )}

{t('footer.attr')}

@@ -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} /> )}
+ + {card && profile && ( + setCardId(null)} + /> + )}
); } + diff --git a/src/components/AboutView.tsx b/src/components/AboutView.tsx index 7e82709..0e27629 100644 --- a/src/components/AboutView.tsx +++ b/src/components/AboutView.tsx @@ -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 ( <> -

+

-

+ ); @@ -21,13 +21,14 @@ const CONFS: Confidence[] = ['verified', 'mapped', 'stale', 'inferred', 'user', interface Props { snapshotDate: string; counts: Record; + 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('data'); const toggle = (id: string) => setOpen((cur) => (cur === id ? '' : id)); @@ -45,7 +46,11 @@ export function AboutView({ snapshotDate, counts, simulateOutage, onSimulate, on

{t('about.data.body1')}

{t('about.data.body2')}

{t('about.data.body3')}

-

{t('about.legend')}

+

+ {cityData.name} — {cityData.url}.{' '} + {cityData.feedDates.map((f) => `${f.mode}: ${f.version ?? '?'}`).join(', ')}. {cityData.license}. +

+

{t('about.legend')}

    {CONFS.map((c) => (
  • diff --git a/src/components/AccessCard.tsx b/src/components/AccessCard.tsx new file mode 100644 index 0000000..6ac9ebf --- /dev/null +++ b/src/components/AccessCard.tsx @@ -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(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 ( +
    +
    +
    + + +
    +

    {t('card.title')} · {t('app.name')}

    +

    {place.name}

    + {address &&

    {address}

    } +

    {t('card.for', { profile: t(`profile.${profile.kind}`) })}

    +

    {t(`verdict.${assessment.verdict}.long`)}

    +
      + {assessment.facts.map((f) => ( +
    • +
      + {t(`fact.title.${f.key}`)} + +
      +

      {tm(f.headline)}

      + + {f.alt &&

      {t('fact.otherSource')}: {tm(f.alt.headline)}

      } +
    • + ))} +
    +

    {t('card.legend')}

    +

    {t('card.generated', { date: date(new Date().toISOString().slice(0, 10)) })} {t('footer.attr')}

    +
    +
    + ); +} diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx new file mode 100644 index 0000000..661440c --- /dev/null +++ b/src/components/DashboardView.tsx @@ -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; + 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 ? {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 ( +
    +

    {t('dash.title')}

    +

    {t('dash.intro')}

    + +
      + {kpis.map((k) => ( +
    • + {k.value} + {k.label} + {k.sub && {k.sub}} +
    • + ))} +
    + + + {t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })} + + +
    +

    {t('dash.gaps')}

    +
    + + + + + + + + + + + + + + {stats.byCat.map((r) => ( + + + + + + + + + ))} + +
    {t('dash.gaps.caption')}
    {t('dash.gaps.cat')}{t('dash.gaps.total')}{t('dash.gaps.known')}{t('dash.gaps.stale')}{t('dash.gaps.unknown')}{t('dash.gaps.caption')}
    {t(`cat.${r.cat}` as Key)}{r.total}{r.known} ({pct(r.known, r.total)}%){r.stale} ({pct(r.stale, r.total)}%){r.unknown} ({pct(r.unknown, r.total)}%)
    +
    +
    + +
    +

    {t('dash.hot')}

    +

    {t('dash.hot.intro')}

    + {hotspots.length === 0 ?

    {t('dash.hot.none')}

    : ( +
      + {hotspots.map((h, i) => ( +
    1. + + {i + 1} + {h.street ?? '—'} + + {t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')} + + {t('dash.hot.near', { n: h.near })}{h.more > 0 ? ` · ${t('dash.hot.more', { n: h.more })}` : ''} + + +
    2. + ))} +
    + )} +
    + +
    +

    {t('dash.queue')}

    +

    {t('dash.queue.intro')}

    + {pending.length > 1 && ( +
    + + +
    + )} + {pending.length === 0 ?

    {t('dash.queue.empty')}

    : ( +
      + {pending.map((r) => { + const p = placeById.get(r.placeId); + return ( +
    • + +
      +

      + {t('dash.queue.place')}: {p?.name ?? r.placeId}{' '} + {r.demo && {t('detail.reports.demo')}} +

      +

      + {t(`rep.attr.${r.attr}` as Key)}: {typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)} + · {date(r.createdAt.slice(0, 10))} +

      + {r.measure &&

      {t('dash.queue.measure')}: {r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${r.measure.method}` as Key)})

      } + {r.note &&

      {r.note}

      } + + + +
      +
    • + ); + })} +
    + )} + {decided.length > 0 && ( +
    + {t('dash.queue.decided', { n: decided.length })} +
      + {decided.map((r) => ( +
    • + {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})` : ''} +
    • + ))} +
    +
    + )} +

    {officialName}

    +
    + +
    +

    {t('dash.demand')}

    +

    {t('dash.demand.intro')}

    + {demand.length === 0 ?

    {t('dash.demand.none')}

    : ( +
      + {demand.map(({ place, views }) => ( +
    • {place.name} — {t('dash.demand.views', { n: views })}
    • + ))} +
    + )} +
    + +
    +

    {t('dash.export')}

    +

    {t('dash.export.intro')}

    + + +
    {exportMsg && {exportMsg}}
    +
    +
    + ); +} + +/** 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; }; diff --git a/src/components/GuideBanner.tsx b/src/components/GuideBanner.tsx new file mode 100644 index 0000000..2669bc7 --- /dev/null +++ b/src/components/GuideBanner.tsx @@ -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(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 } | null = null; + const wl = (navigator as unknown as { wakeLock?: { request: (t: 'screen') => Promise<{ release: () => Promise }> } }).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(null); + latest.current = st; + const player = useRef(null); + const tracker = useRef(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 ( +
    +
    +

    + + {headline} +

    + {barrierLine &&

    {barrierLine}

    } + {st && !st.arrived && ( +

    {t('guide.remaining', { km: (st.remaining / 1000).toFixed(2), min: etaMin ?? 0 })}

    + )} + {err &&

    {t(err === 'denied' ? 'guide.denied' : 'guide.noGps')}

    } + {sim &&

    {t('guide.simOn')}

    } +
    +
    + {st?.offRoute && pos && ( + + )} +
    + + +
    + + +
    +
    + ); +} diff --git a/src/components/LoginView.tsx b/src/components/LoginView.tsx new file mode 100644 index 0000000..c303258 --- /dev/null +++ b/src/components/LoginView.tsx @@ -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 ( +
    +
    +

    + {t('app.name')} +

    +
    + {(['pl', 'en'] as const).map((l) => ( + + ))} +
    +
    +

    {t('auth.title')}

    +

    {t('app.tagline')}

    + {t('auth.intro')} + +
      + {ACCOUNTS.map((a) => ( +
    • +
      +
      +

      + {t(`auth.${a.id}` as Key)} + {t('auth.demoBadge')} +

      +
      +
      +

      {t(`auth.role.${a.role}` as Key)} — {t(`auth.${a.id}.desc` as Key)}

      + {a.role === 'official' &&

      {t('auth.official.note')}

      } +
      +
      + +
      +
      +
    • + ))} +
    +
    + +

    {t('auth.guest.hint')}

    +
    +
    + ); +} diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 88b6759..57e3dee 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -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(null); const mapRef = useRef(null); const markers = useRef([]); const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; + const hotMarkers = useRef([]); + const meMarker = useRef(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 (
    +
    + + +

    {t('map.alt')}

    {!online &&

    {t('map.offline')}

    } diff --git a/src/components/MeasureTools.tsx b/src/components/MeasureTools.tsx new file mode 100644 index 0000000..0de54ca --- /dev/null +++ b/src/components/MeasureTools.tsx @@ -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(null); + const [state, setState] = useState('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(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 ( +
    +

    {t('measure.incline.title')}

    +
      +
    1. {t('measure.incline.s1')}
    2. +
    3. {t('measure.incline.s2')}
    4. +
    5. {t('measure.incline.s3')}
    6. +
    + {(state === 'idle' || state === 'denied' || state === 'none' || state === 'insecure') && ( + + )} + {state === 'insecure' && {t('measure.incline.insecure')}} + {state === 'denied' && {t('measure.incline.denied')}} + {state === 'none' && {t('measure.incline.none')}} + {diag && (state === 'denied' || state === 'none' || state === 'insecure') &&

    {t('measure.incline.diag', { d: diag })}

    } + {(state === 'live' || state === 'waiting') && ( + <> +

    {pct === null ? '…' : `${pct.toFixed(1)} %`}

    + + + + )} + +
    + ); +} + +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(null); + const img = useRef(null); + const [pts, setPts] = useState([]); + 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) => { + 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 ( +
    +

    {t('measure.kerb.title')}

    + {!photo ? {t('measure.kerb.needPhoto')} : ( + <> +

    {t('measure.kerb.intro')}

    + + + {ref === 'custom' && ( + <> + + { setCustom(e.target.value); setPts([]); }} /> + + )} +

    {t(step as 'measure.kerb.done')}

    + +

    {t('measure.kerb.caveat')}

    + {cm !== null &&

    ≈ {cm} cm

    } + + + + )} + + +
    + ); +} diff --git a/src/components/PlaceDetail.tsx b/src/components/PlaceDetail.tsx index 8e5bb89..ac8fc8f 100644 --- a/src/components/PlaceDetail.tsx +++ b/src/components/PlaceDetail.tsx @@ -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) => void; + onAddReport: (r: Omit, photo: ProcessedPhoto | null) => void; onRemoveReport: (id: string) => void; } @@ -33,6 +43,7 @@ export const PlaceDetail = forwardRef(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 (
    @@ -41,9 +52,9 @@ export const PlaceDetail = forwardRef(function PlaceD {t('detail.back')} -

    +

    {place.name ?? t('common.noName')} -

    + {address &&

    {address}

    } {demo && ( @@ -57,8 +68,9 @@ export const PlaceDetail = forwardRef(function PlaceD
    -

    +

    {t(`verdict.${assessment.verdict}.long`)}

    + {missing.length > 0 &&

    {t('verdict.missing', { list: missing.join(', ') })}

    }
    @@ -71,7 +83,44 @@ export const PlaceDetail = forwardRef(function PlaceD
    -

    {t('detail.facts')}

    +
    + + +
    + +
    +

    + {t('reach.title')} +

    +

    {t('reach.intro')}

    +
      + {[5, 10, 15].map((m) => ( +
    • + +
    • + ))} +
    +
    + {p.reach.summary === 'stuck' &&

    {t('reach.stuck')}

    } + {p.reach.summary && p.reach.summary !== 'stuck' && p.reach.minutes && ( + <> +

    + {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 })} +

    +

    {t('reach.show')} {t('reach.caveat')}

    + + + )} +
    +
    + +

    {t('detail.facts')}

      {assessment.facts.map((f) => ( { setReporting(f.key as ReportAttr); setThanks(false); }} /> @@ -84,30 +133,17 @@ export const PlaceDetail = forwardRef(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); }} /> )} -

      {t('detail.reports')}

      +

      {t('detail.reports')}

      {t('detail.reports.note')}

      {mine.length === 0 ? (

      {t('detail.reports.none')}

      ) : (
        - {mine.map((r) => ( -
      • - {t(`rep.attr.${r.attr}`)}:{' '} - {typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)} - {r.demo && {t('detail.reports.demo')}} - — {date(r.createdAt.slice(0, 10))} - {r.note &&
        {r.note}
        } - {!r.demo && ( - - )} -
      • - ))} + {mine.map((r) => p.onRemoveReport(r.id)} />)}
      )} @@ -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 ( +
    • +
      + {photo && {t('rep.photo.alt')}} +
      + {t(`rep.attr.${r.attr}`)}:{' '} + {typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)} + {r.demo && {t('detail.reports.demo')}} + — {date(r.createdAt.slice(0, 10))} + {r.measure &&
      {t(`vet.measure.${r.measure.kind}`)}: {r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${r.measure.method}`)})
      } + {r.note &&
      {r.note}
      } + + {!r.demo && ( + + )} +
      +
      +
    • + ); +} diff --git a/src/components/PlacesView.tsx b/src/components/PlacesView.tsx index 59c7b83..ffc6d16 100644 --- a/src/components/PlacesView.tsx +++ b/src/components/PlacesView.tsx @@ -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, f: Filters): Place[] { +export function filterPlaces(places: Place[], assessments: Map, f: Filters, saved: ReadonlySet): 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 (
      -

      {t('places.title')}

      +

      {t('places.title')}

      {t('places.hint', { profile: t(`profile.${profile.kind}`).toLowerCase() })}

      @@ -58,6 +59,10 @@ export function PlacesView({ profile, filters, onFilters, results, assessments, +
      + set({ onlySaved: e.target.checked })} /> + +
      set({ onlyFit: e.target.checked })} /> diff --git a/src/components/ProfileView.tsx b/src/components/ProfileView.tsx index 9ec8088..4af3a6a 100644 --- a/src/components/ProfileView.tsx +++ b/src/components/ProfileView.tsx @@ -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; + 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(initial ?? DEFAULT_PROFILE.wheelchair); - const set = (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 ?? DEFAULT_PROFILE[account.preset ?? 'wheelchair']); + const update = (p: Profile) => { setDraft(p); if (profile) onChange(p); }; + const set = (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 | 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 (
      -

      {t('profile.title')}

      +

      {profile ? t('profile.title') : t('profile.welcome')}

      {t('profile.intro')}

      -
      -
      - {t('profile.kind')} - {(['wheelchair', 'stroller'] as const).map((k) => ( -
      - pickKind(k)} - /> - -
      - ))} -
      -
      - {t('profile.limits')} - set('maxKerbCm', v)} /> - set('minWidthM', v / 100)} /> - set('maxInclinePct', v)} /> -
      - -
      - {t('profile.prefs')} - set('avoidCobbles', v)} /> - set('needToilet', v)} /> - set('needRest', v)} /> - {p.kind === 'stroller' && ( - set('needChanging', v)} /> - )} -
      - - -

      - - {t('profile.privacy')} +

      +

      + + {account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)} + · {t(`auth.role.${account.role}` as Key)} + {account.id !== 'guest' && {t('auth.demoBadge')}}

      - + +
      + +
      +

      {t('profile.step1')}

      +
      + {t('profile.step1')} +
      + {(['wheelchair', 'stroller'] as ProfileKind[]).map((k) => ( +
      + update(DEFAULT_PROFILE[k])} + /> + +
      + ))} +
      +
      +
      + +
      +

      + {t('profile.step2')} + +

      +
      + {t('profile.step2')} +
      + {presets.map((p) => ( +
      + update({ ...p.values, ...keepPrefs(draft) })} + /> + +
      + ))} + {active === 'custom' && ( +
      + + +
      + )} +
      +
      +
      + +
      +

      {t('profile.step3')}

      +

      {t('profile.step3.help')}

      + set('maxKerbCm', v)} /> + set('minWidthM', v / 100)} /> + set('maxInclinePct', v)} /> +
      + +
      +

      {t('profile.step4')}

      + set('avoidCobbles', v)} /> + set('needToilet', v)} /> + set('needRest', v)} /> + {draft.kind === 'stroller' && set('needChanging', v)} />} +
      + +
      +
      +

      {t('profile.summary')}

      +

      {summary}

      +

      {t('profile.preview')}

      +
        + {counts && VERDICTS.map((v) => ( +
      • + + + {t('profile.preview.n', { n: counts[v], label: t(`verdict.count.${v}` as Key) })} + +
      • + ))} +
      +
      +
      + +

      + {t('profile.privacy')} {profile && t('profile.autosave')} +

      + + + {children}
      ); } -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 ( -
      - +
      +
      + + +
      onChange(Number(e.target.value))} + aria-describedby={`hint-${id}`} onChange={(e) => onChange(Number(e.target.value))} /> + +

      {hint}

      ); } -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 ( -
      - onChange(e.target.checked)} /> - +
      +
      + onChange(e.target.checked)} /> + +
      +
      ); } diff --git a/src/components/ReportForm.tsx b/src/components/ReportForm.tsx index 5ccfd22..96b9bb4 100644 --- a/src/components/ReportForm.tsx +++ b/src/components/ReportForm.tsx @@ -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) => void; + onSubmit: (r: Omit, 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(null); + const [photo, setPhoto] = useState(null); + const [photoErr, setPhotoErr] = useState(false); + const [measure, setMeasure] = useState(null); + const [tool, setTool] = useState<'incline' | 'kerb' | null>(null); + const [manual, setManual] = useState(''); const headingRef = useRef(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 (
      -

      {t('rep.title')}

      +

      {t('rep.title')}

      void pick(e.target.files?.[0])} /> + + void pick(e.target.files?.[0])} /> +
      + {photoErr && {t('rep.photo.error')}} + {photo && ( +
      + {t('rep.photo.alt')} +
      +

      {t('rep.photo.processed')}

      + +
      +
      + )} +

      {t('rep.photo.privacy')}

      + + {kind && ( +
      +

      {t(kind === 'kerb' ? 'rep.measure.kerb' : 'rep.measure.incline')}

      +
      + + + setManual(e.target.value)} /> + +
      + {measure && ( +

      + + {t(`vet.measure.${measure.kind}`)}: {measure.value} {measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${measure.method}`)}) + +

      + )} + {tool === 'incline' && setTool(null)} onUse={(pct) => { setMeasure({ kind: 'incline', value: pct, unit: 'pct', method: 'inclinometer' }); setTool(null); }} />} + {tool === 'kerb' && setTool(null)} onUse={(cm) => { setMeasure({ kind: 'kerb', value: cm, unit: 'cm', method: 'photo-ref' }); setTool(null); }} />} +

      {t('rep.measure.lidar')}

      +
      + )} + +