Scaffold and MVP
This commit is contained in:
commit
ccddc3683c
55 changed files with 9307 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
scripts/.cache
|
||||
node_modules
|
||||
dist
|
||||
BIN
17b7999e-e2c0-42fd-8402-b008fb4b392c.pdf
Normal file
BIN
17b7999e-e2c0-42fd-8402-b008fb4b392c.pdf
Normal file
Binary file not shown.
17
index.html
Normal file
17
index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1b1b1b" />
|
||||
<meta name="description" content="Sprawdź dostępność miejsc i tras w Krakowie dla osób na wózkach i rodziców z wózkami dziecięcymi — ze źródłem, datą i wiarygodnością każdej informacji." />
|
||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
||||
<title>Kraków bez barier</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>Ta aplikacja wymaga JavaScriptu. / This app requires JavaScript.</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
package.json
Normal file
38
package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "krakow-bez-barier",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"data:build": "node scripts/build-data.mjs",
|
||||
"predev": "node scripts/copy-worker.mjs",
|
||||
"prebuild": "node scripts/copy-worker.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uswds/uswds": "^3.14.0",
|
||||
"maplibre-gl": "^6.11.2",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"playwright-core": "^1.63.0",
|
||||
"sass": "^1.105.1",
|
||||
"sharp": "^0.35.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.2",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^5.0.3",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
5554
pnpm-lock.yaml
generated
Normal file
5554
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
allowBuilds:
|
||||
"@parcel/watcher": false
|
||||
esbuild: true
|
||||
sharp: true
|
||||
1
public/data/kk-snapshot.json
Normal file
1
public/data/kk-snapshot.json
Normal file
File diff suppressed because one or more lines are too long
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
BIN
public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
4
public/icons/icon.svg
Normal file
4
public/icons/icon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="92.16" fill="#005ea2"/>
|
||||
<g fill="#ffffff" transform="translate(97.28 97.28) scale(13.226666666666667)"><circle cx="17" cy="4.54" r="2"/><path d="M14 17h-2c0 1.65-1.35 3-3 3s-3-1.35-3-3 1.35-3 3-3v-2c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5zm3-3.5h-1.86l1.67-3.67C17.42 8.5 16.44 7 14.96 7h-5.2c-.81 0-1.54.47-1.87 1.2L7.22 10l1.92.53L9.79 9H12l-1.83 4.1c-.6 1.33.39 2.9 1.85 2.9H17v5h2v-5.5c0-1.1-.9-2-2-2z"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 547 B |
6
public/maplibre/maplibre-gl-shared.mjs
Normal file
6
public/maplibre/maplibre-gl-shared.mjs
Normal file
File diff suppressed because one or more lines are too long
10
public/maplibre/maplibre-gl-worker.mjs
Normal file
10
public/maplibre/maplibre-gl-worker.mjs
Normal file
File diff suppressed because one or more lines are too long
1
public/uswds/sprite.svg
Normal file
1
public/uswds/sprite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 70 KiB |
238
scripts/build-data.mjs
Normal file
238
scripts/build-data.mjs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// Builds public/data/kk-snapshot.json from OpenStreetMap via Overpass.
|
||||
// Raw responses are cached in scripts/.cache; delete it to refetch.
|
||||
// Data © OpenStreetMap contributors, ODbL 1.0.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { toPlace } from '../src/lib/osmPlace.ts';
|
||||
|
||||
const BBOX = [50.046, 19.923, 50.064, 19.956]; // S,W,N,E: Old Town + Kazimierz + Wawel
|
||||
const bb = BBOX.join(',');
|
||||
const OVERPASS = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
const queries = {
|
||||
places: `[out:json][timeout:90];
|
||||
(
|
||||
nwr["name"]["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|pharmacy|bank|clinic|doctors|dentist|hospital|post_office|cinema|theatre|library|arts_centre|townhall|community_centre|place_of_worship|university|college|school|police|courthouse)$"](${bb});
|
||||
nwr["name"]["tourism"~"^(museum|gallery|hotel|hostel|guest_house|apartment|attraction|information)$"](${bb});
|
||||
nwr["name"]["shop"](${bb});
|
||||
nwr["name"]["historic"~"^(castle|monument|church|building|city_gate|fort)$"](${bb});
|
||||
);
|
||||
out meta center;`,
|
||||
support: `[out:json][timeout:90];
|
||||
(
|
||||
nwr["amenity"="toilets"](${bb});
|
||||
nwr["highway"="elevator"](${bb});
|
||||
nwr["amenity"="bench"](${bb});
|
||||
node["highway"="bus_stop"]["name"](${bb});
|
||||
node["railway"="tram_stop"]["name"](${bb});
|
||||
);
|
||||
out meta center;`,
|
||||
net: `[out:json][timeout:120];
|
||||
way["highway"~"^(footway|pedestrian|path|steps|living_street|residential|service|tertiary|unclassified|track|cycleway)$"](${bb});
|
||||
out meta;
|
||||
>;
|
||||
out skel qt;`,
|
||||
kerbs: `[out:json][timeout:90];
|
||||
(
|
||||
node["kerb"](${bb});
|
||||
node["highway"="crossing"](${bb});
|
||||
);
|
||||
out tags;`,
|
||||
};
|
||||
|
||||
async function fetchRaw(name) {
|
||||
const file = `scripts/.cache/${name}.json`;
|
||||
if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8'));
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const res = await fetch(OVERPASS, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'krakow-bez-barier-hackathon/0.1',
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'data=' + encodeURIComponent(queries[name]),
|
||||
});
|
||||
if (res.ok) {
|
||||
const text = await res.text();
|
||||
await writeFile(file, text);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
console.warn(`${name}: HTTP ${res.status}, retry ${attempt}`);
|
||||
await new Promise((r) => setTimeout(r, 8000 * attempt));
|
||||
}
|
||||
throw new Error(`Overpass failed for ${name}`);
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 1e6) / 1e6;
|
||||
const day = (ts) => (ts ? ts.slice(0, 10) : null);
|
||||
|
||||
const [placesRaw, supportRaw, netRaw, kerbRaw] = await Promise.all(
|
||||
Object.keys(queries).map((q) => fetchRaw(q)),
|
||||
);
|
||||
|
||||
const places = placesRaw.elements.map((e) => toPlace(e)).filter(Boolean);
|
||||
const toilets = [], elevators = [], benches = [], stops = [];
|
||||
for (const el of supportRaw.elements) {
|
||||
const t = el.tags ?? {};
|
||||
if (t.amenity === 'toilets') toilets.push(toPlace(el, 'toilets'));
|
||||
else if (t.highway === 'elevator') elevators.push(toPlace(el, 'elevator'));
|
||||
else if (t.amenity === 'bench') {
|
||||
const lat = el.lat ?? el.center?.lat, lon = el.lon ?? el.center?.lon;
|
||||
if (lat != null) benches.push([round(lat), round(lon), day(el.timestamp)]);
|
||||
} else stops.push(toPlace(el, 'transit'));
|
||||
}
|
||||
for (const p of toilets) {
|
||||
const src = supportRaw.elements.find((e) => `${e.type[0]}${e.id}` === p.id).tags;
|
||||
for (const k of ['wheelchair', 'fee', 'opening_hours', 'access', 'toilets:wheelchair', 'changing_table', 'name', 'check_date']) {
|
||||
if (src[k] != null) p.tags[k] = src[k];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- city data: ZTP Kraków GTFS (stops + wheelchair_boarding) ----
|
||||
const GTFS = [
|
||||
{ mode: 'tram', url: 'https://gtfs.ztp.krakow.pl/GTFS_KRK_T.zip' },
|
||||
{ mode: 'bus', url: 'https://gtfs.ztp.krakow.pl/GTFS_KRK_A.zip' },
|
||||
];
|
||||
|
||||
function parseCsv(text) {
|
||||
const rows = [];
|
||||
let row = [], cur = '', q = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (q) {
|
||||
if (c === '"' && text[i + 1] === '"') { cur += '"'; i++; }
|
||||
else if (c === '"') q = false;
|
||||
else cur += c;
|
||||
} else if (c === '"') q = true;
|
||||
else if (c === ',') { row.push(cur); cur = ''; }
|
||||
else if (c === '\n') { row.push(cur.replace(/\r$/, '')); rows.push(row); row = []; cur = ''; }
|
||||
else cur += c;
|
||||
}
|
||||
if (cur || row.length) { row.push(cur); rows.push(row); }
|
||||
const [head, ...body] = rows;
|
||||
return body.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h.replace(/^\uFEFF/, ''), r[i]])));
|
||||
}
|
||||
|
||||
async function loadGtfs({ mode, url }) {
|
||||
const zip = `scripts/.cache/gtfs-${mode}.zip`;
|
||||
if (!existsSync(zip)) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'krakow-bez-barier-hackathon/0.1' } });
|
||||
if (!res.ok) throw new Error(`GTFS ${mode}: HTTP ${res.status}`);
|
||||
await writeFile(zip, Buffer.from(await res.arrayBuffer()));
|
||||
}
|
||||
const read = (f) => execFileSync('unzip', ['-p', zip, f], { maxBuffer: 256 * 1024 * 1024 }).toString('utf8');
|
||||
const feed = parseCsv(read('feed_info.txt'))[0];
|
||||
const stops = parseCsv(read('stops.txt'));
|
||||
return { mode, feedVersion: feed?.feed_version ?? null, stops };
|
||||
}
|
||||
|
||||
const feeds = await Promise.all(GTFS.map(loadGtfs));
|
||||
const osmStops = stops.filter((s) => s.tags);
|
||||
const transit = [];
|
||||
const feedDate = (v) => (v && /^\d{8}/.test(v) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : null);
|
||||
for (const f of feeds) {
|
||||
for (const s of f.stops) {
|
||||
const lat = Number(s.stop_lat), lon = Number(s.stop_lon);
|
||||
if (!(lat >= BBOX[0] && lat <= BBOX[2] && lon >= BBOX[1] && lon <= BBOX[3])) continue;
|
||||
let near = null, nd = 40;
|
||||
for (const o of osmStops) {
|
||||
const d = Math.hypot((o.lat - lat) * 111320, (o.lon - lon) * 71500);
|
||||
if (d < nd) { nd = d; near = o; }
|
||||
}
|
||||
const wb = s.wheelchair_boarding === '' || s.wheelchair_boarding == null ? null : Number(s.wheelchair_boarding);
|
||||
const tags = {};
|
||||
if (near?.tags.wheelchair) tags.wheelchair = near.tags.wheelchair;
|
||||
transit.push({
|
||||
id: `z${f.mode[0]}${s.stop_id}`, lat: round(lat), lon: round(lon), name: s.stop_name,
|
||||
cat: 'transit', kind: f.mode === 'tram' ? 'tram_stop' : 'bus_stop',
|
||||
ts: near?.ts ?? null, tags,
|
||||
ext: { gtfsWheelchair: wb, feedDate: feedDate(f.feedVersion), mode: f.mode, osmMatch: near?.id ?? null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pedestrian network ----
|
||||
const nodeCoord = new Map();
|
||||
for (const el of netRaw.elements) if (el.type === 'node') nodeCoord.set(el.id, [el.lat, el.lon]);
|
||||
|
||||
const kerbById = new Map();
|
||||
for (const el of kerbRaw.elements) {
|
||||
const k = el.tags?.kerb;
|
||||
if (k) kerbById.set(el.id, k);
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
const nodeIdx = new Map();
|
||||
const idxOf = (id) => {
|
||||
let i = nodeIdx.get(id);
|
||||
if (i === undefined) {
|
||||
const c = nodeCoord.get(id);
|
||||
i = nodes.push([round(c[0]), round(c[1])]) - 1;
|
||||
nodeIdx.set(id, i);
|
||||
}
|
||||
return i;
|
||||
};
|
||||
|
||||
const ways = [];
|
||||
const kerbs = {};
|
||||
const isNo = (v) => v === 'no' || v === 'private';
|
||||
for (const el of netRaw.elements) {
|
||||
if (el.type !== 'way') continue;
|
||||
const t = el.tags;
|
||||
const hw = t.highway;
|
||||
if (t.indoor || t.level && t.highway === 'corridor') continue;
|
||||
if (isNo(t.access) && !['yes', 'designated', 'permissive'].includes(t.foot)) continue;
|
||||
if (t.foot === 'no') continue;
|
||||
if (hw === 'cycleway' && !['yes', 'designated', 'permissive'].includes(t.foot)) continue;
|
||||
if (hw === 'service' && (t.access === 'private' || t.service === 'parking_aisle' || t.service === 'drive-through')) continue;
|
||||
if (hw === 'track' && t.foot !== 'yes') continue;
|
||||
if (el.nodes.some((n) => !nodeCoord.has(n))) continue;
|
||||
const w = {
|
||||
id: el.id,
|
||||
hw,
|
||||
ts: day(el.timestamp),
|
||||
nodes: el.nodes.map(idxOf),
|
||||
};
|
||||
if (t.name) w.name = t.name;
|
||||
const surface = t.surface ?? t['footway:surface'] ?? t['path:surface'];
|
||||
if (surface) w.surface = surface;
|
||||
if (t.smoothness) w.smooth = t.smoothness;
|
||||
if (t.incline) w.incline = t.incline;
|
||||
if (t.width) w.width = t.width;
|
||||
if (t.step_count) w.stepCount = Number(t.step_count) || undefined;
|
||||
if (t.ramp) w.ramp = t.ramp;
|
||||
if (t['ramp:wheelchair']) w.rampWheelchair = t['ramp:wheelchair'];
|
||||
if (t['ramp:stroller']) w.rampStroller = t['ramp:stroller'];
|
||||
if (t.handrail) w.handrail = t.handrail;
|
||||
if (t.footway === 'crossing' || t.footway === 'sidewalk') w.footway = t.footway;
|
||||
if (t.tunnel) w.tunnel = true;
|
||||
if (t.bridge) w.bridge = true;
|
||||
if (t.wheelchair) w.wheelchair = t.wheelchair;
|
||||
if (t['check_date:surface'] || t.check_date) w.checked = t['check_date:surface'] ?? t.check_date;
|
||||
ways.push(w);
|
||||
if (w.footway === 'crossing' || hw === 'footway') {
|
||||
for (const n of [el.nodes[0], el.nodes[el.nodes.length - 1]]) {
|
||||
const k = kerbById.get(n);
|
||||
if (k) kerbs[idxOf(n)] = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = {
|
||||
meta: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
bbox: BBOX,
|
||||
source: 'OpenStreetMap contributors',
|
||||
license: 'ODbL 1.0',
|
||||
endpoint: OVERPASS,
|
||||
cityData: { name: 'ZTP Kraków GTFS (stops.txt: wheelchair_boarding)', url: 'https://gtfs.ztp.krakow.pl/', feedDates: feeds.map((f) => ({ mode: f.mode, version: f.feedVersion })), license: 'verify at otwartedane.um.krakow.pl before reuse' },
|
||||
counts: { places: places.length + transit.length, transit: transit.length, toilets: toilets.length, elevators: elevators.length, benches: benches.length, ways: ways.length, nodes: nodes.length },
|
||||
},
|
||||
places: [...places, ...transit], toilets, elevators, benches, nodes, ways, kerbs,
|
||||
};
|
||||
await mkdir('public/data', { recursive: true });
|
||||
await writeFile('public/data/kk-snapshot.json', JSON.stringify(out));
|
||||
console.log(out.meta.counts);
|
||||
7
scripts/copy-worker.mjs
Normal file
7
scripts/copy-worker.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// MapLibre 6 runs as an ES-module worker that imports a shared chunk next to it; copy both
|
||||
// to public/ so they are served with stable names and precached by the service worker.
|
||||
import { copyFile, mkdir } from 'node:fs/promises';
|
||||
await mkdir('public/maplibre', { recursive: true });
|
||||
for (const f of ['maplibre-gl-worker.mjs', 'maplibre-gl-shared.mjs']) {
|
||||
await copyFile(`node_modules/maplibre-gl/dist/${f}`, `public/maplibre/${f}`);
|
||||
}
|
||||
23
scripts/make-icons.mjs
Normal file
23
scripts/make-icons.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Generates PWA icons from the USWDS `accessible_forward` glyph (public domain / MIT).
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const sprite = await readFile('public/uswds/sprite.svg', 'utf8');
|
||||
const inner = sprite.match(/<symbol[^>]*id="accessible_forward"[^>]*>([\s\S]*?)<\/symbol>/)?.[1];
|
||||
if (!inner) throw new Error('glyph not found');
|
||||
|
||||
const svg = (size, scale, rounded) => `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${rounded ? size * 0.18 : 0}" fill="#005ea2"/>
|
||||
<g fill="#ffffff" transform="translate(${(size - 24 * scale) / 2} ${(size - 24 * scale) / 2}) scale(${scale})">${inner}</g>
|
||||
</svg>`;
|
||||
|
||||
await writeFile('public/icons/icon.svg', svg(512, 512 * 0.62 / 24, true));
|
||||
for (const [name, size, scale, rounded] of [
|
||||
['icon-192.png', 192, 192 * 0.62 / 24, true],
|
||||
['icon-512.png', 512, 512 * 0.62 / 24, true],
|
||||
['icon-maskable-512.png', 512, 512 * 0.5 / 24, false],
|
||||
['apple-touch-icon.png', 180, 180 * 0.62 / 24, false],
|
||||
]) {
|
||||
await sharp(Buffer.from(svg(size, scale, rounded))).png().toFile(`public/icons/${name}`);
|
||||
}
|
||||
console.log('icons ok');
|
||||
22
scripts/shot.mjs
Normal file
22
scripts/shot.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Dev helper: node scripts/shot.mjs <out.png> [width] [height] [steps.json]
|
||||
// steps: [{"click":"text=Trasa"},{"fill":["#q","x"]},{"wait":500},{"eval":"js"}]
|
||||
import { chromium } from 'playwright-core';
|
||||
const [out, w = '1280', h = '800', stepsArg = '[]'] = process.argv.slice(2);
|
||||
const url = process.env.URL ?? 'http://localhost:4173/';
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: +w, height: +h }, locale: 'pl-PL' });
|
||||
const logs = [];
|
||||
page.on('console', (m) => { if (['error', 'warning'].includes(m.type())) logs.push(`${m.type()}: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => logs.push(`pageerror: ${e.message}`));
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
for (const s of JSON.parse(stepsArg)) {
|
||||
if (s.click) await page.locator(s.click).first().click();
|
||||
else if (s.fill) await page.locator(s.fill[0]).first().fill(s.fill[1]);
|
||||
else if (s.press) await page.keyboard.press(s.press);
|
||||
else if (s.wait) await page.waitForTimeout(s.wait);
|
||||
else if (s.eval) console.log('eval:', JSON.stringify(await page.evaluate(s.eval)));
|
||||
}
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: out });
|
||||
if (logs.length) console.log(logs.slice(0, 15).join('\n'));
|
||||
await browser.close();
|
||||
299
src/App.tsx
Normal file
299
src/App.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AboutView } from './components/AboutView';
|
||||
import { MapView } from './components/MapView';
|
||||
import { PlaceDetail, 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 { Alert, Icon } from './components/ui';
|
||||
import { assessPlace, buildIndex } from './lib/assess';
|
||||
import { DEMO_PLACES, DEMO_REPORTS } from './lib/demo';
|
||||
import type { LatLon } from './lib/geo';
|
||||
import { I18nContext, formatDate, makeT, type I18n } 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 { clearLocal, newId, useLocalState, useOnline } from './lib/store';
|
||||
import type { Lang, Place, Profile, Snapshot, UserReport } from './lib/types';
|
||||
|
||||
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'];
|
||||
|
||||
/** Verified against the snapshot: see README "Demo script". */
|
||||
const PRESETS: { id: string; fromId: string; toId: string }[] = [];
|
||||
|
||||
export default function App() {
|
||||
const [lang, setLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
|
||||
const [profile, setProfile] = useLocalState<Profile | null>('kbb.profile', null, (v): v is Profile | null => v === null || isProfile(v));
|
||||
const [userReports, setUserReports] = useLocalState<UserReport[]>('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v));
|
||||
const [view, setView] = useState<View>(profile ? 'places' : 'profile');
|
||||
const [pane, setPane] = useState<'list' | 'map'>('list');
|
||||
const online = useOnline();
|
||||
|
||||
const i18n = useMemo<I18n>(() => {
|
||||
const { t, tm } = makeT(lang);
|
||||
return { lang, setLang, t, tm, date: (iso) => formatDate(iso, lang) ?? t('fact.dateUnknown') };
|
||||
}, [lang, setLang]);
|
||||
const { t } = i18n;
|
||||
useEffect(() => { document.documentElement.lang = lang; document.title = t('app.name'); }, [lang, t]);
|
||||
|
||||
// ---- data ----
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
fetch(`${import.meta.env.BASE_URL}data/kk-snapshot.json`, { signal: ctrl.signal })
|
||||
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json() as Promise<Snapshot>; })
|
||||
.then(setSnap)
|
||||
.catch((e: unknown) => { if (!(e instanceof DOMException && e.name === 'AbortError')) setLoadError(true); });
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
|
||||
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
|
||||
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
|
||||
const [overrides, setOverrides] = useState<Record<string, Place>>({});
|
||||
const places = useMemo<Place[]>(
|
||||
() => (snap ? [...DEMO_PLACES, ...snap.places.map((p) => overrides[p.id] ?? p)] : []),
|
||||
[snap, overrides],
|
||||
);
|
||||
const reports = useMemo(() => [...DEMO_REPORTS, ...userReports], [userReports]);
|
||||
const assessments = useMemo(() => {
|
||||
const m = new Map<string, ReturnType<typeof assessPlace>>();
|
||||
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]);
|
||||
|
||||
// ---- places view state ----
|
||||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||
const results = useMemo(() => filterPlaces(places, assessments, filters), [places, assessments, filters]);
|
||||
const mapPlaces = useMemo(
|
||||
() => results.flatMap((place) => { const a = assessments.get(place.id); return a ? [{ place, verdict: a.verdict }] : []; }),
|
||||
[results, assessments],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selected = selectedId ? places.find((p) => p.id === selectedId) ?? null : null;
|
||||
const [refresh, setRefresh] = useState<RefreshState>({ kind: 'idle' });
|
||||
const [simulateOutage, setSimulateOutage] = useState(false);
|
||||
const detailHeading = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
const select = useCallback((id: string | null) => {
|
||||
setSelectedId(id);
|
||||
setRefresh({ kind: 'idle' });
|
||||
if (id) setPane('list');
|
||||
}, []);
|
||||
useEffect(() => { if (selectedId) detailHeading.current?.focus(); }, [selectedId]);
|
||||
|
||||
const back = () => {
|
||||
const id = selectedId;
|
||||
select(null);
|
||||
if (id) setTimeout(() => document.getElementById(`place-${id}`)?.focus(), 0);
|
||||
};
|
||||
|
||||
const doRefresh = async (p: Place) => {
|
||||
setRefresh({ kind: 'loading' });
|
||||
try {
|
||||
if (simulateOutage) throw new Error('simulated outage');
|
||||
const fresh = await refreshPlace(p.id);
|
||||
setOverrides((o) => ({ ...o, [p.id]: fresh }));
|
||||
setRefresh({ kind: 'ok', at: new Date().toISOString() });
|
||||
} catch {
|
||||
setRefresh({ kind: 'fail' });
|
||||
}
|
||||
};
|
||||
|
||||
const addReport = (r: Omit<UserReport, 'id' | 'createdAt'>) =>
|
||||
setUserReports((cur) => [...cur, { ...r, id: newId(), createdAt: new Date().toISOString() }]);
|
||||
|
||||
// ---- route state ----
|
||||
const [routeForm, setRouteForm] = useState<RouteForm>({ fromText: '', toText: '', fromPoint: null });
|
||||
const [route, setRoute] = useState<RouteResult | null>(null);
|
||||
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);
|
||||
setRouteEnds(from && to ? { from, to } : null);
|
||||
if (r && r.status !== 'none') setPane('map');
|
||||
};
|
||||
useEffect(() => { setRoute(null); setRouteEnds(null); }, [profile]);
|
||||
|
||||
const routeTo = (p: Place) => {
|
||||
setRouteForm((f) => ({ ...f, toText: labels.labelOf.get(p.id) ?? p.name ?? '' }));
|
||||
setRoute(null);
|
||||
setRouteEnds(null);
|
||||
setView('route');
|
||||
setPane('list');
|
||||
};
|
||||
|
||||
// ---- shell ----
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const first = useRef(true);
|
||||
useEffect(() => {
|
||||
if (first.current) { first.current = false; return; }
|
||||
mainRef.current?.focus();
|
||||
}, [view]);
|
||||
|
||||
const [resetDone, setResetDone] = useState(false);
|
||||
const reset = () => {
|
||||
clearLocal(KEYS);
|
||||
setProfile(null);
|
||||
setUserReports([]);
|
||||
setResetDone(true);
|
||||
setView('profile');
|
||||
};
|
||||
|
||||
const nav: { id: View; label: string }[] = [
|
||||
{ id: 'places', label: t('nav.places') },
|
||||
{ id: 'route', label: t('nav.route') },
|
||||
{ id: 'about', label: t('nav.about') },
|
||||
{ id: 'profile', label: t('nav.profile') },
|
||||
];
|
||||
const needsProfile = !profile;
|
||||
const split = (view === 'places' || view === 'route') && !needsProfile;
|
||||
const ready = snap && index && graph && profile;
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={i18n}>
|
||||
<div className="kbb-app">
|
||||
<a className="usa-skipnav" href="#main">{t('skip')}</a>
|
||||
|
||||
<section className="usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
|
||||
<div className="usa-alert">
|
||||
<div className="usa-alert__body">
|
||||
<p className="usa-alert__text">
|
||||
<strong>{t('banner.proto')}</strong>{' '}
|
||||
{snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<header className="bg-white border-bottom-1px border-base-lighter">
|
||||
<div className="grid-container padding-y-1 display-flex flex-justify flex-align-center flex-wrap">
|
||||
<p className="margin-0 display-flex flex-align-center text-bold font-sans-lg text-primary-darker">
|
||||
<Icon name="accessible_forward" size={4} className="margin-right-1" />
|
||||
{t('app.name')}
|
||||
</p>
|
||||
<div role="group" aria-label={t('lang.label')} className="display-flex">
|
||||
{(['pl', 'en'] as const).map((l) => (
|
||||
<button
|
||||
key={l} type="button" lang={l} aria-pressed={lang === l}
|
||||
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}
|
||||
onClick={() => setLang(l)}
|
||||
>
|
||||
{l.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label={t('nav.label')} className="grid-container padding-bottom-1">
|
||||
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
|
||||
{nav.map((n) => (
|
||||
<li className="usa-button-group__item" key={n.id}>
|
||||
<button
|
||||
type="button" aria-current={view === n.id ? 'page' : undefined}
|
||||
className={`usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
|
||||
onClick={() => setView(n.id)}
|
||||
>
|
||||
{n.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{split && (
|
||||
<div className="desktop:display-none bg-base-lightest border-bottom-1px border-base-lighter padding-1">
|
||||
<div role="group" aria-label={t('view.label')} className="grid-container padding-0">
|
||||
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
|
||||
{(['list', 'map'] as const).map((p) => (
|
||||
<li className="usa-button-group__item" key={p}>
|
||||
<button type="button" aria-pressed={pane === p} className={`usa-button usa-button--small ${pane === p ? '' : 'usa-button--outline'}`} onClick={() => setPane(p)}>
|
||||
<Icon name={p === 'list' ? 'list' : 'map'} size={3} className="margin-right-05" />
|
||||
{t(p === 'list' ? 'view.list' : 'view.map')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${pane}` : ''}`}>
|
||||
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
|
||||
{!loadError && !ready && view !== 'profile' && !needsProfile && (
|
||||
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
|
||||
)}
|
||||
|
||||
{(view === 'profile' || needsProfile) && (
|
||||
<ProfileView
|
||||
key={profile ? 'edit' : 'new'}
|
||||
initial={profile}
|
||||
onSave={(p) => { setProfile(p); setView('places'); setResetDone(false); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'about' && !needsProfile && snap && (
|
||||
<AboutView
|
||||
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts}
|
||||
simulateOutage={simulateOutage} onSimulate={setSimulateOutage} onReset={reset} resetDone={resetDone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{split && ready && (
|
||||
<>
|
||||
<div className="kbb-panel">
|
||||
{view === 'places' && (selected ? (
|
||||
<PlaceDetail
|
||||
ref={detailHeading}
|
||||
place={selected}
|
||||
assessment={assessments.get(selected.id) ?? assessPlace(selected, profile, index)}
|
||||
reports={reports}
|
||||
snapshotDate={snap.meta.fetchedAt.slice(0, 10)}
|
||||
refresh={refresh}
|
||||
onBack={back}
|
||||
onRouteTo={() => routeTo(selected)}
|
||||
onShowOnMap={() => setPane('map')}
|
||||
onRefresh={() => void doRefresh(selected)}
|
||||
onAddReport={addReport}
|
||||
onRemoveReport={(id) => setUserReports((cur) => cur.filter((r) => r.id !== id))}
|
||||
/>
|
||||
) : (
|
||||
<PlacesView
|
||||
profile={profile} filters={filters} onFilters={setFilters} results={results}
|
||||
assessments={assessments} onSelect={select}
|
||||
/>
|
||||
))}
|
||||
{view === 'route' && (
|
||||
<RouteView
|
||||
graph={graph} labels={labels} profile={profile} bbox={snap.meta.bbox}
|
||||
form={routeForm} onForm={setRouteForm} result={route} onResult={onResult}
|
||||
presets={PRESETS} onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
|
||||
/>
|
||||
)}
|
||||
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
|
||||
</div>
|
||||
<MapView
|
||||
key={lang}
|
||||
places={mapPlaces}
|
||||
showPlaces={view === 'places'}
|
||||
selectedId={view === 'places' ? selectedId : null}
|
||||
onSelect={select}
|
||||
route={view === 'route' ? route : null}
|
||||
routeEnds={view === 'route' ? routeEnds : null}
|
||||
focus={focus}
|
||||
visible={pane === 'map'}
|
||||
bbox={snap.meta.bbox}
|
||||
online={online}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/fonts/public-sans/PublicSans-Bold.woff2
Normal file
BIN
src/assets/fonts/public-sans/PublicSans-Bold.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/public-sans/PublicSans-BoldItalic.woff2
Normal file
BIN
src/assets/fonts/public-sans/PublicSans-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/public-sans/PublicSans-Italic.woff2
Normal file
BIN
src/assets/fonts/public-sans/PublicSans-Italic.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/public-sans/PublicSans-Light.woff2
Normal file
BIN
src/assets/fonts/public-sans/PublicSans-Light.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/public-sans/PublicSans-Medium.woff2
Normal file
BIN
src/assets/fonts/public-sans/PublicSans-Medium.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/public-sans/PublicSans-Regular.woff2
Normal file
BIN
src/assets/fonts/public-sans/PublicSans-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/public-sans/PublicSans-SemiBold.woff2
Normal file
BIN
src/assets/fonts/public-sans/PublicSans-SemiBold.woff2
Normal file
Binary file not shown.
78
src/components/AboutView.tsx
Normal file
78
src/components/AboutView.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useState, type ReactNode } from 'react';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import type { Confidence } from '../lib/types';
|
||||
import { Alert, ConfidenceTag } from './ui';
|
||||
|
||||
function AccordionSection({ id, title, children, open, toggle }: { id: string; title: string; children: ReactNode; open: string; toggle: (id: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
<h3 className="usa-accordion__heading">
|
||||
<button type="button" className="usa-accordion__button" aria-expanded={open === id} aria-controls={`acc-${id}`} onClick={() => toggle(id)}>
|
||||
{title}
|
||||
</button>
|
||||
</h3>
|
||||
<div id={`acc-${id}`} className="usa-accordion__content usa-prose" hidden={open !== id}>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFS: Confidence[] = ['verified', 'mapped', 'stale', 'inferred', 'user', 'none'];
|
||||
|
||||
interface Props {
|
||||
snapshotDate: string;
|
||||
counts: Record<string, number>;
|
||||
simulateOutage: boolean;
|
||||
onSimulate: (v: boolean) => void;
|
||||
onReset: () => void;
|
||||
resetDone: boolean;
|
||||
}
|
||||
|
||||
export function AboutView({ snapshotDate, counts, simulateOutage, onSimulate, onReset, resetDone }: Props) {
|
||||
const { t, date } = useI18n();
|
||||
const [open, setOpen] = useState<string>('data');
|
||||
const toggle = (id: string) => setOpen((cur) => (cur === id ? '' : id));
|
||||
|
||||
return (
|
||||
<div className="grid-container padding-y-4 kbb-narrow">
|
||||
<h1 className="margin-top-0">{t('about.title')}</h1>
|
||||
<p className="usa-intro">{t('about.intro')}</p>
|
||||
<p className="font-sans-xs text-base-dark">
|
||||
{t('banner.data', { date: date(snapshotDate) })} {Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ')}
|
||||
</p>
|
||||
|
||||
<div className="usa-accordion usa-accordion--bordered margin-top-3">
|
||||
<AccordionSection open={open} toggle={toggle} id="data" title={t('about.data')}>
|
||||
<p>{t('about.data.body1')}</p>
|
||||
<p>{t('about.data.body2')}</p>
|
||||
<p>{t('about.data.body3')}</p>
|
||||
<h4>{t('about.legend')}</h4>
|
||||
<ul className="usa-list--unstyled">
|
||||
{CONFS.map((c) => (
|
||||
<li key={c} className="margin-bottom-1">
|
||||
<ConfidenceTag confidence={c} /> <span>{t(`conf.${c}.help`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionSection>
|
||||
<AccordionSection open={open} toggle={toggle} id="missing" title={t('about.missing')}><p>{t('about.missing.body')}</p></AccordionSection>
|
||||
<AccordionSection open={open} toggle={toggle} id="arch" title={t('about.arch')}><p>{t('about.arch.body')}</p></AccordionSection>
|
||||
<AccordionSection open={open} toggle={toggle} id="refresh" title={t('about.refresh')}><p>{t('about.refresh.body')}</p></AccordionSection>
|
||||
<AccordionSection open={open} toggle={toggle} id="privacy" title={t('about.privacy')}><p>{t('about.privacy.body')}</p></AccordionSection>
|
||||
<AccordionSection open={open} toggle={toggle} id="a11y" title={t('about.a11y')}><p>{t('about.a11y.body')}</p></AccordionSection>
|
||||
<AccordionSection open={open} toggle={toggle} id="biz" title={t('about.biz')}><p>{t('about.biz.body')}</p></AccordionSection>
|
||||
<AccordionSection open={open} toggle={toggle} id="limits" title={t('about.limits')}><p>{t('about.limits.body')}</p></AccordionSection>
|
||||
</div>
|
||||
|
||||
<h2 className="margin-top-4">{t('about.demo')}</h2>
|
||||
<div className="usa-checkbox">
|
||||
<input className="usa-checkbox__input" id="simulate" type="checkbox" checked={simulateOutage} onChange={(e) => onSimulate(e.target.checked)} aria-describedby="simulate-help" />
|
||||
<label className="usa-checkbox__label" htmlFor="simulate">{t('about.simulate')}</label>
|
||||
</div>
|
||||
<p id="simulate-help" className="usa-hint">{t('about.simulate.help')}</p>
|
||||
<button type="button" className="usa-button usa-button--secondary margin-top-2" onClick={onReset}>{t('about.reset')}</button>
|
||||
<div aria-live="polite" className="margin-top-1">{resetDone && <Alert kind="success" slim>{t('about.resetDone')}</Alert>}</div>
|
||||
|
||||
<p className="font-sans-xs text-base-dark margin-top-4">{t('about.attr')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
src/components/MapView.tsx
Normal file
231
src/components/MapView.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import * as maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, Map as MLMap, StyleSpecification } from 'maplibre-gl';
|
||||
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 { HARD_FLAGS, type RouteResult } from '../lib/route';
|
||||
import type { Place, Verdict } from '../lib/types';
|
||||
import { VERDICT_STYLE } from './ui';
|
||||
|
||||
maplibregl.setWorkerUrl(new URL(`${import.meta.env.BASE_URL}maplibre/maplibre-gl-worker.mjs`, document.baseURI).href);
|
||||
|
||||
const STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||||
};
|
||||
|
||||
const reduceMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const lngLat = (p: LatLon): [number, number] => [p[1], p[0]];
|
||||
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
|
||||
|
||||
interface Props {
|
||||
places: { place: Place; verdict: Verdict }[];
|
||||
showPlaces: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
route: RouteResult | null;
|
||||
routeEnds: { from: LatLon; to: LatLon } | null;
|
||||
focus: { at: LatLon; n: number } | null;
|
||||
/** Re-measure when the pane becomes visible. */
|
||||
visible: boolean;
|
||||
bbox: [number, number, number, number];
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, focus, visible, bbox, online }: Props) {
|
||||
const { t, lang } = useI18n();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<MLMap | null>(null);
|
||||
const markers = useRef<maplibregl.Marker[]>([]);
|
||||
const onSelectRef = useRef(onSelect);
|
||||
onSelectRef.current = onSelect;
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// ---- init ----
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const [s, w, n, e] = bbox;
|
||||
const map = new maplibregl.Map({
|
||||
container: ref.current,
|
||||
style: STYLE,
|
||||
center: [(w + e) / 2, (s + n) / 2],
|
||||
zoom: 15,
|
||||
minZoom: 12,
|
||||
maxBounds: [[w - 0.04, s - 0.03], [e + 0.04, n + 0.03]],
|
||||
attributionControl: { compact: true },
|
||||
locale: {
|
||||
'NavigationControl.ZoomIn': t('map.zoomIn'),
|
||||
'NavigationControl.ZoomOut': t('map.zoomOut'),
|
||||
'AttributionControl.ToggleAttribution': t('map.attrToggle'),
|
||||
},
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
||||
map.on('load', () => {
|
||||
map.addSource('places', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addSource('route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addLayer({
|
||||
id: 'route-casing', type: 'line', source: 'route',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 9 },
|
||||
});
|
||||
const line = (id: string, status: string, color: string, width: number, dash?: number[]) =>
|
||||
map.addLayer({
|
||||
id, type: 'line', source: 'route', filter: ['==', ['get', 'status'], status],
|
||||
layout: { 'line-cap': dash ? 'butt' : 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': color, 'line-width': width, ...(dash ? { 'line-dasharray': dash } : {}) },
|
||||
});
|
||||
line('route-ok', 'ok', '#005ea2', 5);
|
||||
line('route-warn', 'warn', '#a2461c', 5, [2, 1.5]);
|
||||
line('route-blocked', 'blocked', '#b50909', 6, [0.4, 1.6]);
|
||||
map.addLayer({
|
||||
id: 'places', type: 'circle', source: 'places',
|
||||
paint: {
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, 3.5, 17, 9],
|
||||
'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],
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'places-selected', type: 'circle', source: 'places', filter: ['==', ['get', 'id'], ''],
|
||||
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, 8, 17, 15], 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': '#005ea2', 'circle-stroke-width': 4 },
|
||||
});
|
||||
map.on('click', 'places', (ev: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||
const id = ev.features?.[0]?.properties?.id as string | undefined;
|
||||
if (id) onSelectRef.current(id);
|
||||
});
|
||||
map.on('mouseenter', 'places', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', 'places', () => { map.getCanvas().style.cursor = ''; });
|
||||
setReady(true);
|
||||
});
|
||||
mapRef.current = map;
|
||||
const ro = new ResizeObserver(() => map.resize());
|
||||
ro.observe(ref.current);
|
||||
return () => { ro.disconnect(); markers.current.forEach((m) => m.remove()); map.remove(); mapRef.current = null; setReady(false); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (visible) mapRef.current?.resize(); }, [visible]);
|
||||
|
||||
// ---- places ----
|
||||
const fc = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: places.map(({ place, verdict }) => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'Point' as const, coordinates: [place.lon, place.lat] },
|
||||
properties: { id: place.id, verdict },
|
||||
})),
|
||||
}),
|
||||
[places],
|
||||
);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!ready || !map) return;
|
||||
(map.getSource('places') as GeoJSONSource).setData(showPlaces ? fc : { type: 'FeatureCollection', features: [] });
|
||||
}, [ready, fc, showPlaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
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 });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, selectedId, showPlaces]);
|
||||
|
||||
// ---- route ----
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!ready || !map) return;
|
||||
markers.current.forEach((m) => m.remove());
|
||||
markers.current = [];
|
||||
const src = map.getSource('route') as GeoJSONSource;
|
||||
if (!route || route.status === 'none') {
|
||||
src.setData({ type: 'FeatureCollection', features: [] });
|
||||
return;
|
||||
}
|
||||
src.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: route.segments.map((s) => {
|
||||
const hard = s.flags.some((f) => HARD_FLAGS.has(f));
|
||||
const warn = s.flags.some((f) => f !== 'surface-unknown' && f !== 'ramp-steps' && !HARD_FLAGS.has(f));
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
geometry: { type: 'LineString' as const, coordinates: s.coords.map(lngLat) },
|
||||
properties: { status: hard ? 'blocked' : warn ? 'warn' : 'ok' },
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const addMarker = (el: HTMLElement, at: LatLon, popupText?: string) => {
|
||||
const m = new maplibregl.Marker({ element: el }).setLngLat(lngLat(at));
|
||||
if (popupText) m.setPopup(new maplibregl.Popup({ offset: 16, closeButton: true }).setText(popupText));
|
||||
m.addTo(map);
|
||||
markers.current.push(m);
|
||||
};
|
||||
const pin = (label: string, aria: string, cls: string) => {
|
||||
const b = document.createElement('div');
|
||||
b.className = `kbb-pin ${cls}`;
|
||||
b.textContent = label;
|
||||
b.setAttribute('role', 'img');
|
||||
b.setAttribute('aria-label', aria);
|
||||
return b;
|
||||
};
|
||||
if (routeEnds) {
|
||||
addMarker(pin('A', t('route.start'), 'kbb-pin--start'), routeEnds.from);
|
||||
addMarker(pin('B', t('route.end'), 'kbb-pin--end'), routeEnds.to);
|
||||
}
|
||||
for (const b of route.barriers) {
|
||||
if (b.flag === 'surface-unknown' || b.flag === 'ramp-steps') continue;
|
||||
const hard = HARD_FLAGS.has(b.flag);
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = `kbb-pin kbb-pin--barrier ${hard ? 'kbb-pin--hard' : ''}`;
|
||||
const text = `${t(`flag.${b.flag}` as Key)}${b.name ? ` · ${b.name}` : ''}`;
|
||||
el.setAttribute('aria-label', text);
|
||||
el.innerHTML = `<svg class="usa-icon" aria-hidden="true" focusable="false"><use href="${SPRITE}#${hard ? 'error' : 'warning'}"></use></svg>`;
|
||||
addMarker(el, b.at, text);
|
||||
}
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
route.coords.forEach((c) => bounds.extend(lngLat(c)));
|
||||
map.fitBounds(bounds, { padding: 70, duration: reduceMotion() ? 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 });
|
||||
}, [ready, focus]);
|
||||
|
||||
return (
|
||||
<div className="kbb-map-wrap">
|
||||
<div ref={ref} className="kbb-map" role="region" aria-label={t('map.label')} aria-describedby="map-alt" />
|
||||
<p id="map-alt" className="usa-sr-only">{t('map.alt')}</p>
|
||||
{!online && <p className="kbb-map-note usa-alert usa-alert--warning usa-alert--slim margin-0"><span className="usa-alert__body"><span className="usa-alert__text">{t('map.offline')}</span></span></p>}
|
||||
{showPlaces && (
|
||||
<details className="kbb-legend">
|
||||
<summary>{t('map.legend')}</summary>
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{(['match', 'partial', 'conflict', 'incomplete', 'fail'] as Verdict[]).map((v) => (
|
||||
<li key={v} className="display-flex flex-align-center">
|
||||
<span className="kbb-swatch" style={{ background: VERDICT_STYLE[v].hex, borderColor: v === 'incomplete' ? '#565c65' : '#1b1b1b' }} aria-hidden="true" />
|
||||
{t(`verdict.${v}`)}{v === 'incomplete' ? ` (${t('map.hollow')})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/components/PlaceDetail.tsx
Normal file
142
src/components/PlaceDetail.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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 { ReportForm } from './ReportForm';
|
||||
|
||||
export type RefreshState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ok'; at: string }
|
||||
| { kind: 'fail' };
|
||||
|
||||
interface Props {
|
||||
place: Place;
|
||||
assessment: Assessment;
|
||||
reports: UserReport[];
|
||||
snapshotDate: string;
|
||||
refresh: RefreshState;
|
||||
onBack: () => void;
|
||||
onRouteTo: () => void;
|
||||
onShowOnMap: () => void;
|
||||
onRefresh: () => void;
|
||||
onAddReport: (r: Omit<UserReport, 'id' | 'createdAt'>) => void;
|
||||
onRemoveReport: (id: string) => void;
|
||||
}
|
||||
|
||||
export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceDetail(p, headingRef) {
|
||||
const { t, tm, date } = useI18n();
|
||||
const [reporting, setReporting] = useState<ReportAttr | null>(null);
|
||||
const [thanks, setThanks] = useState(false);
|
||||
const { place, assessment } = p;
|
||||
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);
|
||||
|
||||
return (
|
||||
<article aria-labelledby="detail-title">
|
||||
<button type="button" className="usa-button usa-button--unstyled display-flex flex-align-center margin-bottom-2" onClick={p.onBack}>
|
||||
<Icon name="arrow_back" size={3} className="margin-right-05" />
|
||||
{t('detail.back')}
|
||||
</button>
|
||||
|
||||
<h2 id="detail-title" ref={headingRef} tabIndex={-1} className="margin-0">
|
||||
{place.name ?? t('common.noName')}
|
||||
</h2>
|
||||
{address && <p className="margin-top-05 margin-bottom-0 text-base-dark">{address}</p>}
|
||||
|
||||
{demo && (
|
||||
<div className="margin-top-1">
|
||||
<span className="usa-tag bg-ink text-white">{t('detail.demo')}</span>
|
||||
<p className="font-sans-xs margin-top-1 margin-bottom-0">
|
||||
{t('detail.demoNote', { state: t(`verdict.${demoState(demo.demoState)}`) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`usa-alert ${VERDICT_STYLE[assessment.verdict].alert} margin-top-2`} role="status">
|
||||
<div className="usa-alert__body">
|
||||
<h3 className="usa-alert__heading"><VerdictTag verdict={assessment.verdict} /></h3>
|
||||
<p className="usa-alert__text">{t(`verdict.${assessment.verdict}.long`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="display-flex flex-wrap margin-top-2">
|
||||
<button type="button" className="usa-button" onClick={p.onRouteTo}>
|
||||
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('detail.routeTo')}
|
||||
</button>
|
||||
<button type="button" className="usa-button usa-button--outline tablet:display-none" onClick={p.onShowOnMap}>
|
||||
<Icon name="map" size={3} className="margin-right-05" />{t('view.map')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="margin-top-3">{t('detail.facts')}</h3>
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{assessment.facts.map((f) => (
|
||||
<FactCard key={f.key} fact={f} onReport={() => { setReporting(f.key as ReportAttr); setThanks(false); }} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{thanks && <Alert kind="success" slim role="status">{t('rep.thanks')}</Alert>}
|
||||
{reporting && (
|
||||
<ReportForm
|
||||
placeId={place.id}
|
||||
initialAttr={reporting}
|
||||
onCancel={() => setReporting(null)}
|
||||
onSubmit={(r) => { p.onAddReport(r); setReporting(null); setThanks(true); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 className="margin-top-3">{t('detail.reports')}</h3>
|
||||
<p className="font-sans-xs text-base-dark">{t('detail.reports.note')}</p>
|
||||
{mine.length === 0 ? (
|
||||
<p>{t('detail.reports.none')}</p>
|
||||
) : (
|
||||
<ul className="usa-list">
|
||||
{mine.map((r) => (
|
||||
<li key={r.id}>
|
||||
<strong>{t(`rep.attr.${r.attr}`)}:</strong>{' '}
|
||||
{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)}
|
||||
{r.demo && <span className="usa-tag margin-left-1 bg-ink text-white">{t('detail.reports.demo')}</span>}
|
||||
<span className="text-base-dark"> — {date(r.createdAt.slice(0, 10))}</span>
|
||||
{r.note && <div className="text-base-dark font-sans-xs">{r.note}</div>}
|
||||
{!r.demo && (
|
||||
<button type="button" className="usa-button usa-button--unstyled margin-left-1 font-sans-xs" onClick={() => p.onRemoveReport(r.id)}>
|
||||
{t('detail.reports.remove')}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="border-top-1px border-base-lighter padding-top-2 margin-top-3">
|
||||
{!place.demo && (
|
||||
<>
|
||||
<button type="button" className="usa-button usa-button--outline" onClick={p.onRefresh} disabled={p.refresh.kind === 'loading'}>
|
||||
<Icon name="update" size={3} className="margin-right-05" />
|
||||
{p.refresh.kind === 'loading' ? t('detail.refreshing') : t('detail.refresh')}
|
||||
</button>
|
||||
<a className="usa-button usa-button--unstyled margin-left-1" href={`https://www.openstreetmap.org/${osmPath(place.id)}`} target="_blank" rel="noreferrer">
|
||||
{t('detail.openOsm')}
|
||||
<Icon name="launch" size={2} className="margin-left-05" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{place.demo && <p className="font-sans-xs text-base-dark">{t('detail.refreshDemo')}</p>}
|
||||
<div aria-live="polite" className="margin-top-1">
|
||||
{p.refresh.kind === 'ok' && <Alert kind="success" slim>{t('detail.refreshed', { time: new Date(p.refresh.at).toLocaleTimeString() })}</Alert>}
|
||||
{p.refresh.kind === 'fail' && <Alert kind="warning" role="alert">{t('detail.refreshFail', { date: date(p.snapshotDate) })}</Alert>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
const demoState = (s: string) => (s === 'verified' ? 'match' : s === 'conflict' ? 'conflict' : 'incomplete');
|
||||
|
||||
function osmPath(id: string) {
|
||||
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
|
||||
return `${kind}/${id.slice(1)}`;
|
||||
}
|
||||
105
src/components/PlacesView.tsx
Normal file
105
src/components/PlacesView.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useI18n, type Key } from '../lib/i18n';
|
||||
import { VERDICT_ORDER } from '../lib/assess';
|
||||
import { DEMO_PLACES } from '../lib/demo';
|
||||
import { fold } from '../lib/text';
|
||||
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
|
||||
import { Icon, VerdictTag } from './ui';
|
||||
|
||||
export interface Filters { query: string; cat: PlaceCategory | 'all'; onlyFit: boolean }
|
||||
export const DEFAULT_FILTERS: Filters = { query: '', cat: 'all', onlyFit: false };
|
||||
|
||||
const CATS: (PlaceCategory | 'all')[] = ['all', 'food', 'culture', 'shop', 'stay', 'health', 'services'];
|
||||
const PAGE = 40;
|
||||
|
||||
export function filterPlaces(places: Place[], assessments: Map<string, Assessment>, f: Filters): Place[] {
|
||||
const q = fold(f.query.trim());
|
||||
return places
|
||||
.filter((p) => {
|
||||
if (f.cat !== 'all' && p.cat !== f.cat) return false;
|
||||
const v = assessments.get(p.id)?.verdict;
|
||||
if (f.onlyFit && v !== 'match' && v !== 'partial') return false;
|
||||
if (!q) return true;
|
||||
return fold(`${p.name ?? ''} ${p.tags['addr:street'] ?? ''} ${p.tags['addr:housenumber'] ?? ''}`).includes(q);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const va = VERDICT_ORDER[assessments.get(a.id)?.verdict ?? 'incomplete'];
|
||||
const vb = VERDICT_ORDER[assessments.get(b.id)?.verdict ?? 'incomplete'];
|
||||
return va - vb || Number(!!a.demo) - Number(!!b.demo) || (a.name ?? '').localeCompare(b.name ?? '', 'pl');
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {
|
||||
profile: Profile;
|
||||
filters: Filters;
|
||||
onFilters: (f: Filters) => void;
|
||||
results: Place[];
|
||||
assessments: Map<string, Assessment>;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PlacesView({ profile, filters, onFilters, results, assessments, onSelect }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
const visible = useMemo(() => results.slice(0, shown), [results, shown]);
|
||||
const set = (patch: Partial<Filters>) => { setShown(PAGE); onFilters({ ...filters, ...patch }); };
|
||||
|
||||
return (
|
||||
<section aria-labelledby="places-title">
|
||||
<h2 id="places-title" className="margin-top-0">{t('places.title')}</h2>
|
||||
<p className="font-sans-xs text-base-dark margin-top-0">
|
||||
{t('places.hint', { profile: t(`profile.${profile.kind}`).toLowerCase() })}
|
||||
</p>
|
||||
|
||||
<div role="search">
|
||||
<label className="usa-label margin-top-0" htmlFor="q">{t('places.search')}</label>
|
||||
<input id="q" className="usa-input maxw-full" type="search" value={filters.query} autoComplete="off" onChange={(e) => set({ query: e.target.value })} />
|
||||
<label className="usa-label" htmlFor="cat">{t('places.category')}</label>
|
||||
<select id="cat" className="usa-select maxw-full" value={filters.cat} onChange={(e) => set({ cat: e.target.value as Filters['cat'] })}>
|
||||
{CATS.map((c) => <option key={c} value={c}>{t(`cat.${c}` as Key)}</option>)}
|
||||
</select>
|
||||
<div className="usa-checkbox margin-top-2">
|
||||
<input className="usa-checkbox__input" id="onlyfit" type="checkbox" checked={filters.onlyFit} onChange={(e) => set({ onlyFit: e.target.checked })} />
|
||||
<label className="usa-checkbox__label" htmlFor="onlyfit">{t('places.onlyFit')}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="margin-top-3 margin-bottom-1 text-bold" role="status" aria-live="polite">{t('places.count', { n: results.length })}</p>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<p>{t('places.empty')}</p>
|
||||
) : (
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{visible.map((p) => {
|
||||
const a = assessments.get(p.id);
|
||||
const isDemo = DEMO_PLACES.some((d) => d.id === p.id);
|
||||
return (
|
||||
<li key={p.id} className="margin-bottom-1">
|
||||
<button
|
||||
type="button" id={`place-${p.id}`} onClick={() => onSelect(p.id)}
|
||||
className="kbb-place-btn width-full text-left bg-white border-1px border-base-lighter radius-md padding-2 display-flex flex-justify flex-align-start"
|
||||
>
|
||||
<span className="display-block">
|
||||
<span className="display-block text-bold text-ink">{p.name}</span>
|
||||
<span className="display-block font-sans-xs text-base-dark margin-top-05">
|
||||
{t(`cat.${p.cat}` as Key)}
|
||||
{p.tags['addr:street'] ? ` · ${p.tags['addr:street']} ${p.tags['addr:housenumber'] ?? ''}` : ''}
|
||||
</span>
|
||||
{isDemo && <span className="usa-tag bg-ink text-white margin-top-05">{t('detail.demo')}</span>}
|
||||
<span className="display-block margin-top-1">{a && <VerdictTag verdict={a.verdict} />}</span>
|
||||
</span>
|
||||
<Icon name="navigate_next" size={3} className="text-base margin-top-05" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{results.length > shown && (
|
||||
<button type="button" className="usa-button usa-button--outline margin-top-2" onClick={() => setShown((n) => n + PAGE)}>
|
||||
{t('places.more')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
93
src/components/ProfileView.tsx
Normal file
93
src/components/ProfileView.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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';
|
||||
|
||||
interface Props {
|
||||
initial: Profile | null;
|
||||
onSave: (p: Profile) => void;
|
||||
}
|
||||
|
||||
export function ProfileView({ initial, onSave }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [p, setP] = useState<Profile>(initial ?? DEFAULT_PROFILE.wheelchair);
|
||||
const set = <K extends keyof Profile>(k: K, v: Profile[K]) => setP((cur) => ({ ...cur, [k]: v }));
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); onSave(p); };
|
||||
const pickKind = (kind: ProfileKind) => setP(DEFAULT_PROFILE[kind]);
|
||||
|
||||
return (
|
||||
<div className="grid-container padding-y-4 kbb-narrow">
|
||||
<h1 className="margin-top-0">{t('profile.title')}</h1>
|
||||
<p className="usa-intro">{t('profile.intro')}</p>
|
||||
<form className="usa-form usa-form--large" onSubmit={submit}>
|
||||
<fieldset className="usa-fieldset">
|
||||
<legend className="usa-legend usa-legend--large">{t('profile.kind')}</legend>
|
||||
{(['wheelchair', 'stroller'] as const).map((k) => (
|
||||
<div className="usa-radio" key={k}>
|
||||
<input
|
||||
className="usa-radio__input usa-radio__input--tile" id={`kind-${k}`} type="radio" name="kind"
|
||||
checked={p.kind === k} onChange={() => pickKind(k)}
|
||||
/>
|
||||
<label className="usa-radio__label" htmlFor={`kind-${k}`}>
|
||||
{t(`profile.${k}`)}
|
||||
<span className="usa-checkbox__label-description">{t(`profile.${k}.hint`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="usa-fieldset margin-top-4">
|
||||
<legend className="usa-legend usa-legend--large">{t('profile.limits')}</legend>
|
||||
<Range id="kerb" label={t('profile.kerb')} min={0} max={10} step={1} value={p.maxKerbCm} unit={t('unit.cm')} onChange={(v) => set('maxKerbCm', v)} />
|
||||
<Range id="width" label={t('profile.width')} min={60} max={120} step={5} value={Math.round(p.minWidthM * 100)} unit={t('unit.cm')} onChange={(v) => set('minWidthM', v / 100)} />
|
||||
<Range id="incline" label={t('profile.incline')} min={3} max={12} step={1} value={p.maxInclinePct} unit={t('unit.pct')} onChange={(v) => set('maxInclinePct', v)} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="usa-fieldset margin-top-4">
|
||||
<legend className="usa-legend usa-legend--large">{t('profile.prefs')}</legend>
|
||||
<Check id="cobbles" label={t('profile.cobbles')} checked={p.avoidCobbles} onChange={(v) => set('avoidCobbles', v)} />
|
||||
<Check id="toilet" label={t('profile.toilet')} checked={p.needToilet} onChange={(v) => set('needToilet', v)} />
|
||||
<Check id="rest" label={t('profile.rest')} checked={p.needRest} onChange={(v) => set('needRest', v)} />
|
||||
{p.kind === 'stroller' && (
|
||||
<Check id="changing" label={t('profile.changing')} checked={p.needChanging} onChange={(v) => set('needChanging', v)} />
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" className="usa-button usa-button--big margin-top-4">
|
||||
{t('profile.save')}
|
||||
<Icon name="arrow_forward" size={3} className="margin-left-1" />
|
||||
</button>
|
||||
<p className="font-sans-xs text-base-dark margin-top-2">
|
||||
<Icon name="lock" size={2} className="margin-right-05" />
|
||||
{t('profile.privacy')}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Range({ id, label, min, max, step, value, unit, onChange }: {
|
||||
id: string; label: string; min: number; max: number; step: number; value: number; unit: string; onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="margin-top-2">
|
||||
<label className="usa-label" htmlFor={`range-${id}`}>
|
||||
{label}: <strong>{value} {unit}</strong>
|
||||
</label>
|
||||
<input
|
||||
id={`range-${id}`} className="usa-range" type="range" min={min} max={max} step={step} value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ id, label, checked, onChange }: { id: string; label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="usa-checkbox">
|
||||
<input className="usa-checkbox__input" id={`chk-${id}`} type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<label className="usa-checkbox__label" htmlFor={`chk-${id}`}>{label}</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/ReportForm.tsx
Normal file
73
src/components/ReportForm.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import type { ReportAttr, ReportValue, UserReport } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
placeId: string;
|
||||
initialAttr?: ReportAttr;
|
||||
onSubmit: (r: Omit<UserReport, 'id' | 'createdAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ATTRS: ReportAttr[] = ['entrance', 'steps', 'width', 'toilet', 'ramp'];
|
||||
|
||||
export function ReportForm({ placeId, initialAttr = 'entrance', onSubmit, onCancel }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [attr, setAttr] = useState<ReportAttr>(initialAttr);
|
||||
const [value, setValue] = useState<'yes' | 'limited' | 'no'>('yes');
|
||||
const [width, setWidth] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
useEffect(() => headingRef.current?.focus(), []);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
let v: ReportValue = value;
|
||||
if (attr === 'width') {
|
||||
const n = Number(width.replace(',', '.'));
|
||||
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) } : {}) });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="usa-form border-1px border-primary radius-md padding-2 bg-primary-lighter maxw-full" onSubmit={submit} aria-labelledby="rep-title">
|
||||
<h3 id="rep-title" ref={headingRef} tabIndex={-1} className="margin-top-0">{t('rep.title')}</h3>
|
||||
<label className="usa-label" htmlFor="rep-attr">{t('rep.attr')}</label>
|
||||
<select id="rep-attr" className="usa-select" value={attr} onChange={(e) => { setAttr(e.target.value as ReportAttr); setError(null); }}>
|
||||
{ATTRS.map((a) => <option key={a} value={a}>{t(`rep.attr.${a}`)}</option>)}
|
||||
</select>
|
||||
|
||||
{attr === 'width' ? (
|
||||
<>
|
||||
<label className="usa-label" htmlFor="rep-width">{t('rep.widthLabel')}</label>
|
||||
<input
|
||||
id="rep-width" className={`usa-input width-card ${error ? 'usa-input--error' : ''}`} inputMode="numeric"
|
||||
value={width} onChange={(e) => setWidth(e.target.value)} aria-invalid={!!error} aria-describedby={error ? 'rep-err' : undefined}
|
||||
/>
|
||||
{error && <span id="rep-err" className="usa-error-message" role="alert">{error}</span>}
|
||||
</>
|
||||
) : (
|
||||
<fieldset className="usa-fieldset margin-top-2">
|
||||
<legend className="usa-legend">{t('rep.value')}</legend>
|
||||
{(['yes', 'limited', 'no'] as const).map((v) => (
|
||||
<div className="usa-radio" key={v}>
|
||||
<input className="usa-radio__input" id={`rep-v-${v}`} type="radio" name="rep-value" checked={value === v} onChange={() => setValue(v)} />
|
||||
<label className="usa-radio__label" htmlFor={`rep-v-${v}`}>{t(`rep.${v}`)}</label>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<label className="usa-label" htmlFor="rep-note">{t('rep.note')}</label>
|
||||
<textarea id="rep-note" className="usa-textarea height-10" maxLength={300} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
<p className="usa-hint margin-top-1">{t('rep.disclaimer')}</p>
|
||||
<div className="margin-top-2">
|
||||
<button type="submit" className="usa-button">{t('rep.submit')}</button>
|
||||
<button type="button" className="usa-button usa-button--outline" onClick={onCancel}>{t('rep.cancel')}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
201
src/components/RouteView.tsx
Normal file
201
src/components/RouteView.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { useState, type FormEvent } from 'react';
|
||||
import { hasKey, useI18n, type Key } from '../lib/i18n';
|
||||
import type { LatLon } from '../lib/geo';
|
||||
import { resolveLabel, type Labels } from '../lib/labels';
|
||||
import { findRoute, HARD_FLAGS, type RouteGraph, type RouteResult, type RouteSegment } from '../lib/route';
|
||||
import type { Profile } from '../lib/types';
|
||||
import { Alert, Icon } from './ui';
|
||||
|
||||
export interface RouteForm {
|
||||
fromText: string;
|
||||
toText: string;
|
||||
/** Set when the start came from device geolocation. */
|
||||
fromPoint: LatLon | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
graph: RouteGraph;
|
||||
labels: Labels;
|
||||
profile: Profile;
|
||||
bbox: [number, number, number, number];
|
||||
form: RouteForm;
|
||||
onForm: (f: RouteForm) => void;
|
||||
result: RouteResult | null;
|
||||
onResult: (r: RouteResult | null, from: LatLon | null, to: LatLon | null) => void;
|
||||
presets: { id: string; fromId: string; toId: string }[];
|
||||
onFocus: (p: LatLon) => void;
|
||||
}
|
||||
|
||||
export function RouteView({ graph, labels, profile, bbox, form, onForm, result, onResult, presets, onFocus }: Props) {
|
||||
const { t, tm } = useI18n();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
const run = (f: RouteForm) => {
|
||||
setError(null);
|
||||
const from = f.fromPoint ?? (() => { const p = resolveLabel(labels, f.fromText); return p ? ([p.lat, p.lon] as LatLon) : null; })();
|
||||
const to = (() => { const p = resolveLabel(labels, f.toText); return p ? ([p.lat, p.lon] as LatLon) : null; })();
|
||||
if (!from) { setError(t('route.unknownPlace', { text: f.fromText })); onResult(null, null, null); return; }
|
||||
if (!to) { setError(t('route.unknownPlace', { text: f.toText })); onResult(null, null, null); return; }
|
||||
if (from[0] === to[0] && from[1] === to[1]) { setError(t('route.same')); onResult(null, null, null); return; }
|
||||
onResult(findRoute(graph, from, to, profile), from, to);
|
||||
};
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); run(form); };
|
||||
|
||||
const locate = () => {
|
||||
setLocating(true);
|
||||
setError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLocating(false);
|
||||
const { latitude: lat, longitude: lon } = pos.coords;
|
||||
const [s, w, n, e] = bbox;
|
||||
if (lat < s || lat > n || lon < w || lon > e) { setError(t('route.locOut')); return; }
|
||||
onForm({ ...form, fromText: t('route.myLocation'), fromPoint: [lat, lon] });
|
||||
},
|
||||
() => { setLocating(false); setError(t('route.locFail')); },
|
||||
{ enableHighAccuracy: true, timeout: 10_000 },
|
||||
);
|
||||
};
|
||||
|
||||
const swap = () => {
|
||||
if (form.fromPoint) return;
|
||||
onForm({ fromText: form.toText, toText: form.fromText, fromPoint: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="route-title">
|
||||
<h2 id="route-title" className="margin-top-0">{t('route.title')}</h2>
|
||||
<p className="font-sans-xs text-base-dark margin-top-0">{t('route.intro')}</p>
|
||||
|
||||
<form className="usa-form maxw-full" onSubmit={submit}>
|
||||
<datalist id="place-labels">{labels.list.map((l) => <option key={l} value={l} />)}</datalist>
|
||||
<label className="usa-label margin-top-0" htmlFor="route-from">{t('route.from')}</label>
|
||||
<input
|
||||
id="route-from" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
|
||||
value={form.fromText} onChange={(e) => onForm({ ...form, fromText: e.target.value, fromPoint: null })}
|
||||
/>
|
||||
{'geolocation' in navigator && (
|
||||
<button type="button" className="usa-button usa-button--unstyled margin-top-1" onClick={locate} disabled={locating}>
|
||||
<Icon name="my_location" size={3} className="margin-right-05" />
|
||||
{locating ? t('route.locating') : t('route.myLocation')}
|
||||
</button>
|
||||
)}
|
||||
<div className="margin-top-1">
|
||||
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={swap} disabled={!!form.fromPoint}>
|
||||
<Icon name="arrow_downward" size={3} className="margin-right-05" />{t('route.swap')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="usa-label" htmlFor="route-to">{t('route.to')}</label>
|
||||
<input
|
||||
id="route-to" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
|
||||
value={form.toText} onChange={(e) => onForm({ ...form, toText: e.target.value })}
|
||||
/>
|
||||
<button type="submit" className="usa-button margin-top-3">
|
||||
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('route.go')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{presets.length > 0 && (
|
||||
<div className="margin-top-2">
|
||||
<p className="text-bold margin-bottom-1">{t('route.presets')}</p>
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{presets.map((p) => {
|
||||
const a = labels.byLabel.size ? [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.fromId) : undefined;
|
||||
const b = [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.toId);
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<li key={p.id} className="margin-bottom-1">
|
||||
<button
|
||||
type="button" className="usa-button usa-button--outline usa-button--small margin-0 text-left"
|
||||
onClick={() => { const f = { fromText: a[0], toText: b[0], fromPoint: null }; onForm(f); run(f); }}
|
||||
>
|
||||
{a[1].name} → {b[1].name}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div aria-live="polite" className="margin-top-3">
|
||||
{error && <Alert kind="error" role="alert">{error}</Alert>}
|
||||
{result && <Result result={result} onFocus={onFocus} t={t} tm={tm} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Result({ result, onFocus, t }: {
|
||||
result: RouteResult; onFocus: (p: LatLon) => void; t: ReturnType<typeof useI18n>['t']; tm: ReturnType<typeof useI18n>['tm'];
|
||||
}) {
|
||||
if (result.status === 'none') {
|
||||
return <Alert kind="warning" heading={t('route.none')}>{t(`route.none.${result.reason ?? 'disconnected'}` as Key)}</Alert>;
|
||||
}
|
||||
const blocked = result.status === 'blocked';
|
||||
const km = (result.distanceM / 1000).toFixed(2);
|
||||
const min = Math.max(1, Math.round(result.durationMin));
|
||||
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
|
||||
return (
|
||||
<>
|
||||
<div className={`usa-alert ${blocked ? 'usa-alert--error' : 'usa-alert--success'}`}>
|
||||
<div className="usa-alert__body">
|
||||
<h3 className="usa-alert__heading">{blocked ? t('route.blocked') : t('route.ok')}</h3>
|
||||
<p className="usa-alert__text">
|
||||
{blocked ? t('route.blocked.long') : t('route.summary', { km, min })}
|
||||
</p>
|
||||
{blocked && <p className="usa-alert__text">{t('route.summary', { km, min })}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-sans-xs margin-bottom-0">{t('route.quality', { pct: result.stats.knownSurfacePct })}</p>
|
||||
{result.stats.unknownKerbs > 0 && <p className="font-sans-xs margin-y-05">{t('route.unknownKerbs', { n: result.stats.unknownKerbs })}</p>}
|
||||
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.estimate')}</p>
|
||||
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.legend')}</p>
|
||||
|
||||
<h3>{t('route.barriers')}</h3>
|
||||
{notes.length === 0 ? (
|
||||
<p>{t('route.barriers.none')}</p>
|
||||
) : (
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{notes.map((b, i) => (
|
||||
<li key={`${b.wayId}-${b.flag}-${i}`} className="border-1px border-base-lighter radius-md padding-1 margin-bottom-1 display-flex flex-justify flex-align-center">
|
||||
<span>
|
||||
<Icon name={HARD_FLAGS.has(b.flag) ? 'error' : 'warning'} size={3} className={`margin-right-05 ${HARD_FLAGS.has(b.flag) ? 'text-error-dark' : 'text-warning-darker'}`} />
|
||||
<strong>{t(`flag.${b.flag}` as Key)}</strong>
|
||||
{b.name ? ` · ${b.name}` : ''}
|
||||
{b.detail && b.flag === 'steps' ? ` · ${b.detail}` : ''}
|
||||
{b.lengthM >= 10 ? ` · ${Math.round(b.lengthM / 5) * 5} m` : ''}
|
||||
</span>
|
||||
<button type="button" className="usa-button usa-button--unstyled font-sans-xs margin-left-1" onClick={() => onFocus(b.at)}>
|
||||
{t('route.focus')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3>{t('route.steps')}</h3>
|
||||
<ol className="usa-list">
|
||||
{result.segments.map((s, i) => (
|
||||
<li key={i}>{segmentText(s, i === 0, t)}</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI18n>['t']) {
|
||||
const kindKey = `seg.hw.${s.hw}`;
|
||||
const kindLabel = hasKey(kindKey) ? t(kindKey) : s.hw;
|
||||
const surface = s.surface ? t('seg.surfaceSuffix', { surface: `@surface.${s.surface}` }) : '';
|
||||
const flags = s.flags.filter((f) => f !== 'surface-unknown' && f !== 'ramp-steps').map((f) => t(`flag.${f}` as Key));
|
||||
const line = t('seg.line', {
|
||||
turn: t(`turn.${first ? 'start' : s.turn}` as Key),
|
||||
name: s.name ?? t('seg.unnamed'),
|
||||
kind: kindLabel,
|
||||
m: Math.max(5, Math.round(s.distanceM / 5) * 5),
|
||||
surface,
|
||||
});
|
||||
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
|
||||
}
|
||||
138
src/components/ui.tsx
Normal file
138
src/components/ui.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import type { Confidence, Fact, SourceKind, Status, Verdict } from '../lib/types';
|
||||
|
||||
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
|
||||
|
||||
export function Icon({ name, size = 3, className = '' }: { name: string; size?: 2 | 3 | 4 | 5; className?: string }) {
|
||||
return (
|
||||
<svg className={`usa-icon usa-icon--size-${size} ${className}`} aria-hidden="true" focusable="false" role="img">
|
||||
<use href={`${SPRITE}#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const VERDICT_STYLE: Record<Verdict, { icon: string; tag: string; alert: string; hex: string }> = {
|
||||
match: { icon: 'check_circle', tag: 'bg-success-dark text-white', alert: 'usa-alert--success', hex: '#00a91c' },
|
||||
partial: { icon: 'warning', tag: 'bg-warning text-ink', alert: 'usa-alert--warning', hex: '#ffbe2e' },
|
||||
conflict: { icon: 'report', tag: 'bg-accent-warm-darker text-white', alert: 'usa-alert--warning', hex: '#a2461c' },
|
||||
incomplete: { icon: 'help', tag: 'bg-base-lighter text-ink', alert: 'usa-alert--info', hex: '#ffffff' },
|
||||
fail: { icon: 'error', tag: 'bg-error-dark text-white', alert: 'usa-alert--error', hex: '#b50909' },
|
||||
};
|
||||
|
||||
export function VerdictTag({ verdict }: { verdict: Verdict }) {
|
||||
const { t } = useI18n();
|
||||
const s = VERDICT_STYLE[verdict];
|
||||
return (
|
||||
<span className={`usa-tag display-inline-flex flex-align-center ${s.tag}`}>
|
||||
<Icon name={s.icon} size={2} className="margin-right-05" />
|
||||
{t(`verdict.${verdict}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<Status, { icon: string; text: string }> = {
|
||||
ok: { icon: 'check_circle', text: 'text-success-dark' },
|
||||
warn: { icon: 'warning', text: 'text-warning-darker' },
|
||||
fail: { icon: 'error', text: 'text-error-dark' },
|
||||
unknown: { icon: 'help', text: 'text-base-dark' },
|
||||
conflict: { icon: 'report', text: 'text-accent-warm-darker' },
|
||||
};
|
||||
|
||||
export function StatusLabel({ status }: { status: Status }) {
|
||||
const { t } = useI18n();
|
||||
const s = STATUS_STYLE[status];
|
||||
return (
|
||||
<span className={`display-inline-flex flex-align-center text-bold ${s.text}`}>
|
||||
<Icon name={s.icon} size={3} className="margin-right-05" />
|
||||
{t(`status.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CONF_STYLE: Record<Confidence, string> = {
|
||||
verified: 'bg-success-dark text-white',
|
||||
mapped: 'bg-primary text-white',
|
||||
stale: 'bg-warning text-ink',
|
||||
inferred: 'bg-base-dark text-white',
|
||||
user: 'bg-accent-warm-darker text-white',
|
||||
none: 'bg-base-lighter text-ink',
|
||||
};
|
||||
const CONF_ICON: Record<Confidence, string> = {
|
||||
verified: 'verified', mapped: 'check', stale: 'update', inferred: 'info_outline', user: 'edit', none: 'help_outline',
|
||||
};
|
||||
|
||||
export function ConfidenceTag({ confidence }: { confidence: Confidence }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<span className={`usa-tag display-inline-flex flex-align-center ${CONF_STYLE[confidence]}`} title={t(`conf.${confidence}.help`)}>
|
||||
<Icon name={CONF_ICON[confidence]} size={2} className="margin-right-05" />
|
||||
{t(`conf.${confidence}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceLine({ source, confidence, date }: { source: SourceKind; confidence: Confidence; date: string | null }) {
|
||||
const { t, date: fmt } = useI18n();
|
||||
return (
|
||||
<dl className="kbb-source font-sans-3xs margin-top-1 margin-bottom-0">
|
||||
<div>
|
||||
<dt className="text-base-dark">{t('src.label')}</dt>
|
||||
<dd>{t(`src.${source}`)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-base-dark">{t('fact.updated')}</dt>
|
||||
<dd>{date ? fmt(date) : t('fact.dateUnknown')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-base-dark">{t('fact.confidence')}</dt>
|
||||
<dd><ConfidenceTag confidence={confidence} /></dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function FactCard({ fact, onReport }: { fact: Fact; onReport?: (() => void) | undefined }) {
|
||||
const { t, tm } = useI18n();
|
||||
const canReport = onReport && ['entrance', 'steps', 'width', 'toilet', 'ramp'].includes(fact.key);
|
||||
return (
|
||||
<li className="border-1px border-base-lighter radius-md padding-2 margin-bottom-2 bg-white">
|
||||
<div className="display-flex flex-justify flex-wrap flex-align-center">
|
||||
<h4 className="margin-0 font-sans-md">{t(`fact.title.${fact.key}`)}</h4>
|
||||
<StatusLabel status={fact.status} />
|
||||
</div>
|
||||
<p className="margin-top-1 margin-bottom-0">{tm(fact.headline)}</p>
|
||||
{fact.detail && <p className="margin-top-05 margin-bottom-0 text-base-dark font-sans-xs">{tm(fact.detail)}</p>}
|
||||
<SourceLine source={fact.source} confidence={fact.confidence} date={fact.date} />
|
||||
{fact.alt && (
|
||||
<div className="usa-alert usa-alert--warning usa-alert--slim margin-top-2">
|
||||
<div className="usa-alert__body">
|
||||
<p className="usa-alert__text">
|
||||
<strong>{t('fact.otherSource')}:</strong> {tm(fact.alt.headline)}
|
||||
</p>
|
||||
<SourceLine source={fact.alt.source} confidence={fact.alt.confidence} date={fact.alt.date} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="display-flex flex-wrap flex-align-center margin-top-1">
|
||||
{fact.required && <span className="font-sans-3xs text-base-dark margin-right-2">{t('fact.required')}</span>}
|
||||
{canReport && (
|
||||
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={onReport}>
|
||||
{t('fact.report')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function Alert({ kind, heading, children, slim = false, role }: { kind: 'info' | 'warning' | 'error' | 'success'; heading?: string; children: ReactNode; slim?: boolean; role?: 'alert' | 'status' }) {
|
||||
return (
|
||||
<div className={`usa-alert usa-alert--${kind} ${slim ? 'usa-alert--slim' : ''}`} role={role}>
|
||||
<div className="usa-alert__body">
|
||||
{heading && <h4 className="usa-alert__heading">{heading}</h4>}
|
||||
<div className="usa-alert__text">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
src/lib/assess.ts
Normal file
281
src/lib/assess.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
// Place assessment: turns raw OSM tags + nearby geometry + user reports into a fit report
|
||||
// for one profile. Absence of data is never reported as accessibility.
|
||||
import { Grid, distToSegment, haversine, type LatLon } from './geo';
|
||||
import { MONTHS_24, isCobbles, monthsSince, roughness, stepsHaveRamp } from './osm';
|
||||
import type {
|
||||
Assessment, Confidence, Fact, Msg, Place, Profile, ReportValue, Snapshot, SourceKind, Status,
|
||||
UserReport, Verdict, Way,
|
||||
} from './types';
|
||||
|
||||
interface Seg { way: Way; a: LatLon; b: LatLon }
|
||||
|
||||
export interface Index {
|
||||
snapshot: Snapshot;
|
||||
segs: Grid<Seg>;
|
||||
elevators: Grid<Place>;
|
||||
benches: Grid<{ lat: number; lon: number; ts: string | null }>;
|
||||
toilets: Place[];
|
||||
}
|
||||
|
||||
export function buildIndex(snapshot: Snapshot): Index {
|
||||
const segs = new Grid<Seg>();
|
||||
for (const way of snapshot.ways) {
|
||||
for (let i = 0; i < way.nodes.length - 1; i++) {
|
||||
const a = snapshot.nodes[way.nodes[i]!]!, b = snapshot.nodes[way.nodes[i + 1]!]!;
|
||||
const seg = { way, a, b };
|
||||
segs.add(a[0], a[1], seg);
|
||||
segs.add(b[0], b[1], seg);
|
||||
segs.add((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, seg);
|
||||
}
|
||||
}
|
||||
const elevators = new Grid<Place>();
|
||||
for (const e of snapshot.elevators) elevators.add(e.lat, e.lon, e);
|
||||
const benches = new Grid<{ lat: number; lon: number; ts: string | null }>();
|
||||
for (const [lat, lon, ts] of snapshot.benches) benches.add(lat, lon, { lat, lon, ts });
|
||||
return { snapshot, segs, elevators, benches, toilets: snapshot.toilets };
|
||||
}
|
||||
|
||||
const pt = (p: { lat: number; lon: number }): LatLon => [p.lat, p.lon];
|
||||
|
||||
function nearbyWays(ix: Index, p: LatLon, radius: number, pred: (w: Way) => boolean) {
|
||||
const seen = new Map<Way, number>();
|
||||
for (const s of ix.segs.near(p[0], p[1], radius + 70)) {
|
||||
if (!pred(s.way)) continue;
|
||||
const d = distToSegment(p, s.a, s.b);
|
||||
if (d <= radius && d < (seen.get(s.way) ?? Infinity)) seen.set(s.way, d);
|
||||
}
|
||||
return [...seen.entries()].map(([way, dist]) => ({ way, dist })).sort((x, y) => x.dist - y.dist);
|
||||
}
|
||||
|
||||
/** Date + confidence for an OSM-sourced value. */
|
||||
export function osmConfidence(
|
||||
surveyDate: string | undefined, editDate: string | null, now: Date,
|
||||
): { confidence: Confidence; date: string | null } {
|
||||
const sv = monthsSince(surveyDate ?? null, now);
|
||||
if (surveyDate && sv !== null && sv <= MONTHS_24) return { confidence: 'verified', date: surveyDate };
|
||||
const m = monthsSince(editDate, now);
|
||||
if (m === null) return { confidence: 'stale', date: null };
|
||||
return { confidence: m <= MONTHS_24 ? 'mapped' : 'stale', date: editDate };
|
||||
}
|
||||
|
||||
const round10 = (n: number) => Math.round(n / 10) * 10;
|
||||
const entranceStatus = (v: string | undefined): Status =>
|
||||
v === 'yes' || v === 'designated' ? 'ok' : v === 'limited' ? 'warn' : v === 'no' ? 'fail' : 'unknown';
|
||||
const reportEntrance = (v: ReportValue): 'yes' | 'limited' | 'no' | null =>
|
||||
v === 'yes' || v === 'limited' || v === 'no' ? v : null;
|
||||
|
||||
function latestReport(reports: UserReport[], placeId: string, attr: UserReport['attr']) {
|
||||
return reports
|
||||
.filter((r) => r.placeId === placeId && r.attr === attr && r.status !== 'rejected')
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
||||
}
|
||||
|
||||
export function assessPlace(
|
||||
place: Place, profile: Profile, ix: Index, reports: UserReport[] = [], now = new Date(),
|
||||
): Assessment {
|
||||
const p = pt(place);
|
||||
const src: SourceKind = place.demo ? 'demo' : 'osm-tag';
|
||||
const t = place.tags;
|
||||
const facts: Fact[] = [];
|
||||
|
||||
// ---- entrance ----
|
||||
{
|
||||
const rep = latestReport(reports, place.id, 'entrance');
|
||||
const repVal = rep ? reportEntrance(rep.value) : null;
|
||||
const accepted = rep?.status === 'accepted';
|
||||
const desc = t['wheelchair:description:pl'] ?? t['wheelchair:description'] ?? t['wheelchair:description:en'];
|
||||
const detail: Msg | undefined = desc ? { k: 'raw', p: { text: desc } } : undefined;
|
||||
const gt = place.ext?.gtfsWheelchair;
|
||||
const osm = t.wheelchair ?? (gt === 1 ? 'yes' : gt === 2 ? 'no' : undefined);
|
||||
const cityOnly = !t.wheelchair && (gt === 1 || gt === 2);
|
||||
const repFact = (): Fact => ({
|
||||
key: 'entrance', status: entranceStatus(repVal ?? undefined), headline: { k: `f.entrance.${repVal}` }, detail,
|
||||
source: accepted ? 'city-review' : 'user-report', confidence: accepted ? 'verified' : 'user',
|
||||
date: (accepted ? rep?.reviewedAt ?? rep?.createdAt : rep?.createdAt)?.slice(0, 10) ?? null, required: true,
|
||||
});
|
||||
if (osm) {
|
||||
const { confidence, date } = cityOnly
|
||||
? { confidence: 'mapped' as Confidence, date: place.ext?.feedDate ?? null }
|
||||
: osmConfidence(t['check_date:wheelchair'] ?? t['survey:date'] ?? t.check_date, place.ts, now);
|
||||
const f: Fact = {
|
||||
key: 'entrance', status: entranceStatus(osm), headline: { k: `f.entrance.${osm === 'designated' ? 'yes' : osm}` },
|
||||
detail, source: cityOnly ? 'city-gtfs' : src, confidence, date, required: true,
|
||||
};
|
||||
const differs = repVal && rep && repVal !== osm && !(osm === 'designated' && repVal === 'yes');
|
||||
if (differs && rep) {
|
||||
if (accepted) {
|
||||
const primary = repFact();
|
||||
primary.alt = { headline: f.headline, source: f.source, confidence: f.confidence, date: f.date };
|
||||
facts.push(primary);
|
||||
} else {
|
||||
f.status = 'conflict';
|
||||
f.alt = { headline: { k: `f.entrance.${repVal}` }, source: 'user-report', confidence: 'user', date: rep.createdAt.slice(0, 10) };
|
||||
facts.push(f);
|
||||
}
|
||||
} else if (accepted && rep) {
|
||||
facts.push({ ...f, confidence: 'verified', source: 'city-review', date: rep.reviewedAt?.slice(0, 10) ?? f.date });
|
||||
} else facts.push(f);
|
||||
} else if (repVal && rep) {
|
||||
facts.push(repFact());
|
||||
} else if (place.ext) {
|
||||
facts.push({
|
||||
key: 'entrance', status: 'unknown', headline: { k: 'f.entrance.cityNone' }, detail: { k: 'f.entrance.cityNoneHint' },
|
||||
source: 'city-gtfs', confidence: 'none', date: place.ext.feedDate, required: true,
|
||||
});
|
||||
} else {
|
||||
facts.push({
|
||||
key: 'entrance', status: 'unknown', headline: { k: 'f.entrance.none' }, detail,
|
||||
source: 'none', confidence: 'none', date: null, required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- steps near the entrance (inferred from geometry) ----
|
||||
const rampWays = nearbyWays(ix, p, 40, (w) => w.hw !== 'steps' && (w.rampWheelchair === 'yes' || (profile.kind === 'stroller' && w.rampStroller === 'yes') || w.ramp === 'yes'));
|
||||
const liftNear = ix.elevators.near(p[0], p[1], 60).map((e) => haversine(p, pt(e))).filter((d) => d <= 40).sort((a, b) => a - b)[0];
|
||||
const stepsNear = nearbyWays(ix, p, 25, (w) => w.hw === 'steps' && !stepsHaveRamp(w, profile.kind));
|
||||
{
|
||||
const first = stepsNear[0];
|
||||
if (first) {
|
||||
const d = round10(first.dist) || 5;
|
||||
const n = first.way.stepCount;
|
||||
const entranceTag = t.wheelchair;
|
||||
const hasAlt = rampWays.length > 0 || liftNear !== undefined;
|
||||
const conflict = (entranceTag === 'yes' || entranceTag === 'designated') && first.dist <= 12 && !hasAlt;
|
||||
facts.push({
|
||||
key: 'steps', status: conflict ? 'conflict' : 'warn',
|
||||
headline: { k: conflict ? 'f.steps.conflict' : 'f.steps.warn', p: { d, n: n ?? '?' } },
|
||||
detail: { k: 'f.steps.hint' }, source: 'osm-geometry', confidence: 'inferred', date: first.way.ts, required: false,
|
||||
});
|
||||
} else {
|
||||
facts.push({
|
||||
key: 'steps', status: 'unknown', headline: { k: 'f.steps.none' }, detail: { k: 'f.steps.noneHint' },
|
||||
source: 'osm-geometry', confidence: 'inferred', date: null, required: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ramp / lift ----
|
||||
{
|
||||
const r = rampWays[0];
|
||||
if (r) {
|
||||
facts.push({ key: 'ramp', status: 'ok', headline: { k: 'f.ramp.yes', p: { d: round10(r.dist) || 5 } }, source: 'osm-geometry', confidence: 'inferred', date: r.way.ts, required: false });
|
||||
} else if (liftNear !== undefined) {
|
||||
facts.push({ key: 'ramp', status: 'ok', headline: { k: 'f.ramp.lift', p: { d: round10(liftNear) || 5 } }, source: 'osm-geometry', confidence: 'inferred', date: null, required: false });
|
||||
} else {
|
||||
facts.push({ key: 'ramp', status: 'unknown', headline: { k: 'f.ramp.none' }, source: 'none', confidence: 'none', date: null, required: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- entrance width (rarely mapped; mostly crowd-sourced) ----
|
||||
{
|
||||
const rep = latestReport(reports, place.id, 'width');
|
||||
if (rep && typeof rep.value === 'number') {
|
||||
const m = rep.value / 100;
|
||||
const ok = m >= profile.minWidthM;
|
||||
facts.push({
|
||||
key: 'width', status: ok ? 'ok' : 'fail',
|
||||
headline: { k: ok ? 'f.width.ok' : 'f.width.fail', p: { cm: rep.value, min: Math.round(profile.minWidthM * 100) } },
|
||||
source: 'user-report', confidence: 'user', date: rep.createdAt.slice(0, 10), required: false,
|
||||
});
|
||||
} else {
|
||||
facts.push({ key: 'width', status: 'unknown', headline: { k: 'f.width.none' }, detail: { k: 'f.width.noneHint' }, source: 'none', confidence: 'none', date: null, required: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- surface outside (nearest way) ----
|
||||
{
|
||||
const w = nearbyWays(ix, p, 25, (x) => x.hw !== 'steps' && x.hw !== 'service')[0];
|
||||
const rough = w ? roughness(w.way.surface, w.way.smooth) : 'unknown';
|
||||
if (w && w.way.surface && rough !== 'unknown') {
|
||||
const bad = rough === 'rough';
|
||||
const status: Status = bad && (profile.avoidCobbles || !isCobbles(w.way.surface)) ? 'warn' : 'ok';
|
||||
facts.push({
|
||||
key: 'surface', status, headline: { k: `f.surface.${rough}`, p: { surface: `@surface.${w.way.surface}` } },
|
||||
source: 'osm-geometry', confidence: 'inferred', date: w.way.ts, required: false,
|
||||
});
|
||||
} else {
|
||||
facts.push({ key: 'surface', status: 'unknown', headline: { k: 'f.surface.unknown' }, source: 'none', confidence: 'none', date: null, required: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- toilet ----
|
||||
{
|
||||
const required = profile.needToilet;
|
||||
const own = t['toilets:wheelchair'];
|
||||
const { confidence, date } = osmConfidence(t['check_date:wheelchair'] ?? t['survey:date'] ?? t.check_date, place.ts, now);
|
||||
if (own === 'yes' || own === 'no') {
|
||||
facts.push({ key: 'toilet', status: own === 'yes' ? 'ok' : 'fail', headline: { k: `f.toilet.own.${own}` }, source: src, confidence, date, required });
|
||||
} else {
|
||||
const wc = ix.toilets
|
||||
.map((x) => ({ x, d: haversine(p, pt(x)) }))
|
||||
.filter(({ d }) => d <= 400)
|
||||
.sort((a, b) => a.d - b.d);
|
||||
const acc = wc.find(({ x }) => x.tags.wheelchair === 'yes' || x.tags.wheelchair === 'designated' || x.tags['toilets:wheelchair'] === 'yes');
|
||||
if (acc) {
|
||||
const c = osmConfidence(acc.x.tags.check_date, acc.x.ts, now);
|
||||
facts.push({
|
||||
key: 'toilet', status: acc.d <= 250 ? 'ok' : 'warn',
|
||||
headline: { k: 'f.toilet.near', p: { d: round10(acc.d) } },
|
||||
detail: acc.x.tags.fee === 'yes' ? { k: 'f.toilet.fee' } : undefined,
|
||||
source: 'osm-geometry', confidence: c.confidence === 'verified' ? 'verified' : 'inferred', date: c.date, required,
|
||||
});
|
||||
} else if (wc[0]) {
|
||||
facts.push({ key: 'toilet', status: 'unknown', headline: { k: 'f.toilet.nearUnknown', p: { d: round10(wc[0].d) } }, source: 'osm-geometry', confidence: 'inferred', date: wc[0].x.ts, required });
|
||||
} else {
|
||||
facts.push({ key: 'toilet', status: 'unknown', headline: { k: 'f.toilet.none' }, source: 'none', confidence: 'none', date: null, required });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rest places ----
|
||||
{
|
||||
const required = profile.needRest;
|
||||
const real = [...new Set(ix.benches.near(p[0], p[1], 150))]
|
||||
.map((b) => ({ d: haversine(p, [b.lat, b.lon]), ts: b.ts }))
|
||||
.filter(({ d }) => d <= 150)
|
||||
.sort((a, c) => a.d - c.d);
|
||||
const first = real[0];
|
||||
if (first) {
|
||||
const newest = real.map((r) => r.ts).filter((x): x is string => !!x).sort().at(-1) ?? null;
|
||||
facts.push({ key: 'rest', status: 'ok', headline: { k: 'f.rest.ok', p: { n: real.length, d: round10(first.d) || 5 } }, source: 'osm-geometry', confidence: 'inferred', date: newest, required });
|
||||
} else {
|
||||
facts.push({ key: 'rest', status: 'unknown', headline: { k: 'f.rest.none' }, detail: { k: 'f.rest.noneHint' }, source: 'none', confidence: 'none', date: null, required });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- baby changing (strollers) ----
|
||||
{
|
||||
const required = profile.needChanging && profile.kind === 'stroller';
|
||||
const own = t.changing_table ?? t.diaper;
|
||||
const { confidence, date } = osmConfidence(t['check_date:wheelchair'] ?? t['survey:date'] ?? t.check_date, place.ts, now);
|
||||
if (own === 'yes' || own === 'no') {
|
||||
facts.push({ key: 'changing', status: own === 'yes' ? 'ok' : 'fail', headline: { k: `f.changing.${own}` }, source: src, confidence, date, required });
|
||||
} else {
|
||||
const near = ix.toilets
|
||||
.filter((x) => x.tags.changing_table === 'yes')
|
||||
.map((x) => ({ x, d: haversine(p, pt(x)) }))
|
||||
.filter(({ d }) => d <= 300)
|
||||
.sort((a, b) => a.d - b.d)[0];
|
||||
if (near) facts.push({ key: 'changing', status: 'warn', headline: { k: 'f.changing.near', p: { d: round10(near.d) } }, source: 'osm-geometry', confidence: 'inferred', date: near.x.ts, required });
|
||||
else facts.push({ key: 'changing', status: 'unknown', headline: { k: 'f.changing.none' }, source: 'none', confidence: 'none', date: null, required });
|
||||
}
|
||||
}
|
||||
|
||||
return { placeId: place.id, verdict: verdictOf(facts), facts };
|
||||
}
|
||||
|
||||
export function verdictOf(facts: Fact[]): Verdict {
|
||||
const required = facts.filter((f) => f.required);
|
||||
if (required.some((f) => f.status === 'fail')) return 'fail';
|
||||
if (facts.some((f) => f.status === 'conflict')) return 'conflict';
|
||||
const entrance = facts.find((f) => f.key === 'entrance');
|
||||
if (
|
||||
required.some((f) => f.status === 'unknown') ||
|
||||
(entrance && ['user', 'none', 'stale'].includes(entrance.confidence))
|
||||
) return 'incomplete';
|
||||
if (facts.some((f) => f.status === 'warn')) return 'partial';
|
||||
return 'match';
|
||||
}
|
||||
|
||||
export const VERDICT_ORDER: Record<Verdict, number> = { match: 0, partial: 1, conflict: 2, incomplete: 3, fail: 4 };
|
||||
34
src/lib/demo.ts
Normal file
34
src/lib/demo.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Fictional places that exist only to demonstrate every data state. Always labelled DEMO in the UI.
|
||||
import type { Place, UserReport } from './types';
|
||||
|
||||
export type DemoState = 'verified' | 'conflict' | 'stale' | 'nodata';
|
||||
|
||||
export const DEMO_PLACES: (Place & { demoState: DemoState })[] = [
|
||||
{
|
||||
id: 'demo-verified', demo: true, demoState: 'verified', name: 'Kawiarnia Przykładowa (DEMO)',
|
||||
lat: 50.0621, lon: 19.9396, cat: 'food', kind: 'cafe', ts: '2026-08-14',
|
||||
tags: { wheelchair: 'yes', 'check_date:wheelchair': '2026-08-14', 'toilets:wheelchair': 'yes', changing_table: 'yes', 'addr:street': 'ulica Przykładowa', 'addr:housenumber': '1' },
|
||||
},
|
||||
{
|
||||
id: 'demo-conflict', demo: true, demoState: 'conflict', name: 'Księgarnia Przykładowa (DEMO)',
|
||||
lat: 50.0613, lon: 19.9359, cat: 'shop', kind: 'books', ts: '2026-03-02',
|
||||
tags: { wheelchair: 'yes', 'addr:street': 'ulica Przykładowa', 'addr:housenumber': '2' },
|
||||
},
|
||||
{
|
||||
id: 'demo-stale', demo: true, demoState: 'stale', name: 'Muzeum Przykładowe (DEMO)',
|
||||
lat: 50.0606, lon: 19.9386, cat: 'culture', kind: 'museum', ts: '2021-05-20',
|
||||
tags: { wheelchair: 'limited', 'wheelchair:description:pl': 'Winda tylko do pierwszego piętra (opis z 2021 r.)', 'addr:street': 'ulica Przykładowa', 'addr:housenumber': '3' },
|
||||
},
|
||||
{
|
||||
id: 'demo-nodata', demo: true, demoState: 'nodata', name: 'Pracownia Przykładowa (DEMO)',
|
||||
lat: 50.0628, lon: 19.9366, cat: 'services', kind: 'craft', ts: '2026-06-01',
|
||||
tags: { 'addr:street': 'ulica Przykładowa', 'addr:housenumber': '4' },
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_REPORTS: UserReport[] = [
|
||||
{
|
||||
id: 'demo-report-1', placeId: 'demo-conflict', attr: 'entrance', value: 'no', demo: true,
|
||||
note: 'Przykład: przy wejściu są dwa stopnie, brak podjazdu.', createdAt: '2026-09-28T10:00:00Z',
|
||||
},
|
||||
];
|
||||
67
src/lib/geo.ts
Normal file
67
src/lib/geo.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
const R = 6371008.8;
|
||||
const rad = (d: number) => (d * Math.PI) / 180;
|
||||
|
||||
export type LatLon = readonly [lat: number, lon: number];
|
||||
|
||||
export function haversine(a: LatLon, b: LatLon): number {
|
||||
const dLat = rad(b[0] - a[0]);
|
||||
const dLon = rad(b[1] - a[1]);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a[0])) * Math.cos(rad(b[0])) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
/** Initial bearing a→b in degrees, 0 = north. */
|
||||
export function bearing(a: LatLon, b: LatLon): number {
|
||||
const y = Math.sin(rad(b[1] - a[1])) * Math.cos(rad(b[0]));
|
||||
const x =
|
||||
Math.cos(rad(a[0])) * Math.sin(rad(b[0])) -
|
||||
Math.sin(rad(a[0])) * Math.cos(rad(b[0])) * Math.cos(rad(b[1] - a[1]));
|
||||
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
/** Distance in metres from point p to segment a–b (local equirectangular projection). */
|
||||
export function distToSegment(p: LatLon, a: LatLon, b: LatLon): number {
|
||||
const k = Math.cos(rad(p[0]));
|
||||
const px = p[1] * k, py = p[0];
|
||||
const ax = a[1] * k, ay = a[0];
|
||||
const bx = b[1] * k, by = b[0];
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
|
||||
const cx = ax + t * dx, cy = ay + t * dy;
|
||||
return Math.hypot(px - cx, py - cy) * (Math.PI / 180) * R;
|
||||
}
|
||||
|
||||
/** Uniform grid for neighbourhood lookups; cell size in degrees of latitude. */
|
||||
export class Grid<T> {
|
||||
private cells = new Map<string, T[]>();
|
||||
constructor(private cell = 0.0006) {}
|
||||
private key(ix: number, iy: number) {
|
||||
return `${ix}:${iy}`;
|
||||
}
|
||||
private ix(lon: number) {
|
||||
return Math.floor(lon / (this.cell / Math.cos(rad(50))));
|
||||
}
|
||||
private iy(lat: number) {
|
||||
return Math.floor(lat / this.cell);
|
||||
}
|
||||
add(lat: number, lon: number, item: T) {
|
||||
const k = this.key(this.ix(lon), this.iy(lat));
|
||||
const list = this.cells.get(k);
|
||||
if (list) list.push(item);
|
||||
else this.cells.set(k, [item]);
|
||||
}
|
||||
/** Items whose cell is within `radiusM` of the point (superset; caller refines). */
|
||||
near(lat: number, lon: number, radiusM: number): T[] {
|
||||
const dLat = radiusM / 111_320;
|
||||
const x0 = this.ix(lon - dLat / Math.cos(rad(lat))), x1 = this.ix(lon + dLat / Math.cos(rad(lat)));
|
||||
const y0 = this.iy(lat - dLat), y1 = this.iy(lat + dLat);
|
||||
const out: T[] = [];
|
||||
for (let x = x0; x <= x1; x++)
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
const l = this.cells.get(this.key(x, y));
|
||||
if (l) out.push(...l);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
676
src/lib/i18n.ts
Normal file
676
src/lib/i18n.ts
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type { Lang, Msg } from './types';
|
||||
|
||||
const pl = {
|
||||
'app.name': 'Kraków bez barier',
|
||||
'app.tagline': 'Sprawdź, czy miejsce i trasa pasują do Twoich potrzeb — z informacją, skąd to wiemy.',
|
||||
'banner.proto': 'Prototyp na hackathon.',
|
||||
'banner.data': 'Dane: zapis OpenStreetMap z {date}. Miejsca oznaczone DEMO są fikcyjne.',
|
||||
'nav.label': 'Główna nawigacja',
|
||||
'nav.places': 'Miejsca',
|
||||
'nav.route': 'Trasa',
|
||||
'nav.about': 'O danych',
|
||||
'nav.profile': 'Profil',
|
||||
'lang.label': 'Język',
|
||||
'skip': 'Przejdź do treści',
|
||||
|
||||
'profile.title': 'Twoje potrzeby',
|
||||
'profile.intro': 'Wybierz, kim podróżujesz. Zapytamy tylko o preferencje dotyczące barier — nie o stan zdrowia. Zapisujemy je wyłącznie na tym urządzeniu.',
|
||||
'profile.kind': 'Podróżuję',
|
||||
'profile.wheelchair': 'Na wózku inwalidzkim',
|
||||
'profile.wheelchair.hint': 'Wózek ręczny lub elektryczny',
|
||||
'profile.stroller': 'Z wózkiem dziecięcym',
|
||||
'profile.stroller.hint': 'Rodzic lub opiekun z wózkiem',
|
||||
'profile.limits': 'Granice, których nie chcesz przekraczać',
|
||||
'profile.kerb': 'Najwyższy krawężnik',
|
||||
'profile.width': 'Najwęższe przejście',
|
||||
'profile.incline': 'Największe nachylenie',
|
||||
'profile.prefs': 'Na co zwracać uwagę',
|
||||
'profile.cobbles': 'Unikaj kostki brukowej i nierównej nawierzchni',
|
||||
'profile.toilet': 'Potrzebuję dostępnej toalety w pobliżu',
|
||||
'profile.rest': 'Potrzebuję miejsc do odpoczynku (ławki)',
|
||||
'profile.changing': 'Potrzebuję przewijaka',
|
||||
'profile.save': 'Zapisz i szukaj',
|
||||
'profile.saved': 'Profil zapisany na tym urządzeniu.',
|
||||
'profile.privacy': 'Bez konta, bez analityki. Profil nigdy nie opuszcza Twojego urządzenia.',
|
||||
'unit.cm': 'cm',
|
||||
'unit.pct': '%',
|
||||
|
||||
'places.title': 'Szukaj miejsca',
|
||||
'places.search': 'Nazwa lub adres',
|
||||
'places.category': 'Rodzaj miejsca',
|
||||
'places.onlyFit': 'Pokaż tylko pasujące lub częściowo pasujące',
|
||||
'places.count': 'Wyniki: {n}',
|
||||
'places.more': 'Pokaż więcej',
|
||||
'places.empty': 'Brak miejsc dla tych kryteriów.',
|
||||
'places.hint': 'Oceny liczone są dla Twojego profilu: {profile}.',
|
||||
'view.list': 'Lista',
|
||||
'view.map': 'Mapa',
|
||||
'view.label': 'Widok wyników',
|
||||
'cat.all': 'Wszystkie',
|
||||
'cat.food': 'Jedzenie i picie',
|
||||
'cat.culture': 'Kultura i zabytki',
|
||||
'cat.shop': 'Sklepy',
|
||||
'cat.stay': 'Noclegi',
|
||||
'cat.health': 'Zdrowie',
|
||||
'cat.services': 'Usługi',
|
||||
'cat.toilets': 'Toalety',
|
||||
'cat.elevator': 'Windy',
|
||||
'cat.transit': 'Transport',
|
||||
|
||||
'verdict.match': 'Pasuje wg danych',
|
||||
'verdict.partial': 'Częściowo pasuje',
|
||||
'verdict.conflict': 'Sprzeczne dane',
|
||||
'verdict.incomplete': 'Za mało danych',
|
||||
'verdict.fail': 'Nie pasuje',
|
||||
'verdict.match.long': 'Zmapowane dane są zgodne z Twoim profilem. To nie jest gwarancja — sprawdź datę i źródło poniżej.',
|
||||
'verdict.partial.long': 'Dane są zgodne z profilem, ale są uwagi, które warto sprawdzić przed wyjazdem.',
|
||||
'verdict.conflict.long': 'Źródła się nie zgadzają. Nie traktuj tego miejsca jako dostępnego, dopóki ktoś tego nie potwierdzi.',
|
||||
'verdict.incomplete.long': 'Brakuje danych albo są nieaktualne. Brak informacji nie oznacza, że miejsce jest dostępne.',
|
||||
'verdict.fail.long': 'Zmapowane dane wskazują barierę niezgodną z Twoimi granicami.',
|
||||
|
||||
'status.ok': 'W porządku',
|
||||
'status.warn': 'Uwaga',
|
||||
'status.fail': 'Bariera',
|
||||
'status.unknown': 'Brak danych',
|
||||
'status.conflict': 'Sprzeczne',
|
||||
|
||||
'conf.verified': 'Potwierdzone',
|
||||
'conf.mapped': 'Zmapowane',
|
||||
'conf.stale': 'Mogło się zmienić',
|
||||
'conf.inferred': 'Wywnioskowane',
|
||||
'conf.user': 'Niezweryfikowane zgłoszenie',
|
||||
'conf.none': 'Brak',
|
||||
'conf.verified.help': 'Ma datę kontroli (check_date lub survey:date) z ostatnich 24 miesięcy.',
|
||||
'conf.mapped.help': 'Wpis w OSM edytowany w ciągu 24 miesięcy, bez daty kontroli.',
|
||||
'conf.stale.help': 'Wpis w OSM nie był zmieniany od ponad 24 miesięcy.',
|
||||
'conf.inferred.help': 'Wyliczone z położenia pobliskich obiektów, nie opisane dla tego miejsca.',
|
||||
'conf.user.help': 'Zgłoszone w tej aplikacji, jeszcze nikt tego nie potwierdził.',
|
||||
'conf.none.help': 'Nic nie wiadomo.',
|
||||
'src.label': 'Źródło',
|
||||
'src.osm-tag': 'OpenStreetMap (opis miejsca)',
|
||||
'src.osm-geometry': 'OpenStreetMap (obiekty w pobliżu)',
|
||||
'src.user-report': 'Zgłoszenie użytkownika (to urządzenie)',
|
||||
'src.demo': 'Dane przykładowe DEMO',
|
||||
'src.none': 'Brak źródła',
|
||||
'fact.updated': 'Dane z',
|
||||
'fact.dateUnknown': 'data nieznana',
|
||||
'fact.confidence': 'Wiarygodność',
|
||||
'fact.otherSource': 'Inne źródło mówi',
|
||||
'fact.report': 'Popraw lub uzupełnij',
|
||||
'fact.required': 'ważne dla Twojego profilu',
|
||||
|
||||
'fact.title.entrance': 'Wejście',
|
||||
'fact.title.steps': 'Schody przy wejściu',
|
||||
'fact.title.ramp': 'Podjazd lub winda',
|
||||
'fact.title.width': 'Szerokość wejścia',
|
||||
'fact.title.surface': 'Nawierzchnia przed wejściem',
|
||||
'fact.title.toilet': 'Toaleta',
|
||||
'fact.title.rest': 'Miejsca odpoczynku',
|
||||
'fact.title.changing': 'Przewijak',
|
||||
|
||||
'f.entrance.yes': 'Wejście oznaczone jako dostępne dla wózka.',
|
||||
'f.entrance.limited': 'Dostępne z ograniczeniami (np. część obiektu lub z pomocą).',
|
||||
'f.entrance.no': 'Wejście oznaczone jako niedostępne dla wózka.',
|
||||
'f.entrance.none': 'Nikt nie opisał, czy wejście jest dostępne.',
|
||||
'f.steps.warn': 'Zmapowane schody ok. {d} m od wejścia (liczba stopni: {n}).',
|
||||
'f.steps.conflict': 'Wejście oznaczone jako dostępne, ale zmapowane są schody ok. {d} m dalej (liczba stopni: {n}) i brak podjazdu lub windy.',
|
||||
'f.steps.hint': 'Wywnioskowane z położenia schodów — mogą prowadzić do innego wejścia.',
|
||||
'f.steps.none': 'Brak zmapowanych schodów w pobliżu.',
|
||||
'f.steps.noneHint': 'To nie dowodzi, że schodów nie ma — mogą być niezmapowane.',
|
||||
'f.ramp.yes': 'Zmapowany podjazd ok. {d} m od wejścia.',
|
||||
'f.ramp.lift': 'Zmapowana winda ok. {d} m od wejścia.',
|
||||
'f.ramp.none': 'Brak danych o podjeździe lub windzie.',
|
||||
'f.width.ok': 'Zgłoszona szerokość: {cm} cm (Twoje minimum: {min} cm).',
|
||||
'f.width.fail': 'Zgłoszona szerokość: {cm} cm — mniej niż Twoje minimum ({min} cm).',
|
||||
'f.width.none': 'Szerokość wejścia nie jest zmapowana.',
|
||||
'f.width.noneHint': 'W OSM prawie nikt jej nie opisuje. Możesz ją zgłosić.',
|
||||
'f.surface.good': 'Nawierzchnia: {surface} — równa.',
|
||||
'f.surface.fair': 'Nawierzchnia: {surface} — raczej równa.',
|
||||
'f.surface.rough': 'Nawierzchnia: {surface} — nierówna, wolniejszy i bardziej męczący przejazd.',
|
||||
'f.surface.unknown': 'Brak danych o nawierzchni przed wejściem.',
|
||||
'f.toilet.own.yes': 'Toaleta w obiekcie oznaczona jako dostępna dla wózka.',
|
||||
'f.toilet.own.no': 'Toaleta w obiekcie oznaczona jako niedostępna dla wózka.',
|
||||
'f.toilet.near': 'Dostępna toaleta publiczna ok. {d} m stąd.',
|
||||
'f.toilet.fee': 'Toaleta jest płatna.',
|
||||
'f.toilet.nearUnknown': 'Toaleta publiczna ok. {d} m stąd, ale nie wiadomo, czy jest dostępna dla wózka.',
|
||||
'f.toilet.none': 'Brak danych o dostępnej toalecie.',
|
||||
'f.rest.ok': 'Zmapowane ławki w pobliżu: {n}, najbliższa ok. {d} m.',
|
||||
'f.rest.none': 'Brak zmapowanych ławek w promieniu 150 m.',
|
||||
'f.rest.noneHint': 'Mogą istnieć niezmapowane.',
|
||||
'f.changing.yes': 'Przewijak oznaczony w obiekcie.',
|
||||
'f.changing.no': 'Brak przewijaka (oznaczone w OSM).',
|
||||
'f.changing.near': 'Przewijak w toalecie publicznej ok. {d} m stąd.',
|
||||
'f.changing.none': 'Brak danych o przewijaku.',
|
||||
'raw': '{text}',
|
||||
|
||||
'surface.sett': 'kostka brukowa',
|
||||
'surface.cobblestone': 'bruk',
|
||||
'surface.unhewn_cobblestone': 'nieociosany bruk',
|
||||
'surface.paving_stones': 'płyty chodnikowe',
|
||||
'surface.asphalt': 'asfalt',
|
||||
'surface.concrete': 'beton',
|
||||
'surface.paved': 'utwardzona',
|
||||
'surface.concrete:plates': 'płyty betonowe',
|
||||
'surface.concrete:lanes': 'pasy betonowe',
|
||||
'surface.compacted': 'ubita',
|
||||
'surface.fine_gravel': 'drobny żwir',
|
||||
'surface.gravel': 'żwir',
|
||||
'surface.wood': 'drewno',
|
||||
'surface.dirt': 'ziemia',
|
||||
'surface.ground': 'grunt',
|
||||
'surface.grass': 'trawa',
|
||||
'surface.unpaved': 'nieutwardzona',
|
||||
'surface.stone': 'kamień',
|
||||
'surface.rock': 'skała',
|
||||
'surface.metal': 'metal',
|
||||
'surface.metal_grid': 'kratka metalowa',
|
||||
'surface.tiles': 'płytki',
|
||||
'surface.sand': 'piasek',
|
||||
'surface.pebblestone': 'kamyki',
|
||||
|
||||
'detail.back': 'Wróć do listy',
|
||||
'detail.routeTo': 'Trasa do tego miejsca',
|
||||
'detail.openOsm': 'Zobacz w OpenStreetMap',
|
||||
'detail.refresh': 'Odśwież dane z OSM',
|
||||
'detail.refreshing': 'Pobieranie…',
|
||||
'detail.refreshed': 'Odświeżono z OpenStreetMap: {time}.',
|
||||
'detail.refreshFail': 'Nie udało się połączyć z OpenStreetMap. Widzisz zapis z {date}. To, że nie udało się odświeżyć, nie oznacza, że dane są aktualne.',
|
||||
'detail.refreshDemo': 'To miejsce jest przykładowe — nie ma go w OpenStreetMap.',
|
||||
'detail.demo': 'DEMO',
|
||||
'detail.demoNote': 'Fikcyjne miejsce pokazujące stan danych: „{state}”.',
|
||||
'detail.facts': 'Szczegóły dostępności',
|
||||
'detail.reports': 'Zgłoszenia użytkowników',
|
||||
'detail.reports.none': 'Brak zgłoszeń.',
|
||||
'detail.reports.note': 'Zgłoszenia są niezweryfikowane i oddzielone od danych OSM. W tym prototypie zapisują się tylko na tym urządzeniu.',
|
||||
'detail.reports.demo': 'przykładowe',
|
||||
'detail.reports.remove': 'Usuń zgłoszenie',
|
||||
|
||||
'rep.title': 'Zgłoś poprawkę lub uzupełnienie',
|
||||
'rep.attr': 'Czego dotyczy zgłoszenie',
|
||||
'rep.attr.entrance': 'Dostępność wejścia',
|
||||
'rep.attr.steps': 'Schody przy wejściu',
|
||||
'rep.attr.width': 'Szerokość wejścia',
|
||||
'rep.attr.toilet': 'Toaleta dostępna dla wózka',
|
||||
'rep.attr.ramp': 'Podjazd lub winda',
|
||||
'rep.value': 'Co zastałeś(-aś) na miejscu',
|
||||
'rep.yes': 'Tak',
|
||||
'rep.limited': 'Częściowo',
|
||||
'rep.no': 'Nie',
|
||||
'rep.widthLabel': 'Szerokość w centymetrach',
|
||||
'rep.note': 'Notatka (opcjonalnie)',
|
||||
'rep.submit': 'Zapisz zgłoszenie',
|
||||
'rep.cancel': 'Anuluj',
|
||||
'rep.thanks': 'Zgłoszenie zapisane. Jest oznaczone jako niezweryfikowane.',
|
||||
'rep.widthInvalid': 'Podaj szerokość od 30 do 300 cm.',
|
||||
'rep.disclaimer': 'Nie zbieramy danych osobowych. Zgłoszenie zawiera tylko to, co wpiszesz.',
|
||||
|
||||
'route.title': 'Zaplanuj trasę',
|
||||
'route.intro': 'Trasa omija schody, wysokie krawężniki i strome odcinki według Twojego profilu. Brak danych o odcinku nie jest traktowany jako potwierdzenie dostępności.',
|
||||
'route.from': 'Skąd',
|
||||
'route.to': 'Dokąd',
|
||||
'route.placeholder': 'Zacznij pisać nazwę miejsca',
|
||||
'route.swap': 'Zamień miejscami',
|
||||
'route.go': 'Wyznacz trasę',
|
||||
'route.myLocation': 'Użyj mojej lokalizacji',
|
||||
'route.locating': 'Ustalanie lokalizacji…',
|
||||
'route.locOut': 'Jesteś poza obszarem demonstracyjnym (Stare Miasto, Kazimierz, Wawel).',
|
||||
'route.locFail': 'Nie udało się ustalić lokalizacji. Wpisz punkt startu.',
|
||||
'route.presets': 'Przykładowe trasy',
|
||||
'route.unknownPlace': 'Nie znaleziono miejsca „{text}”. Wybierz pozycję z podpowiedzi.',
|
||||
'route.same': 'Start i cel są takie same.',
|
||||
'route.ok': 'Trasa bez zmapowanych barier',
|
||||
'route.blocked': 'Nie znaleziono trasy bez barier',
|
||||
'route.blocked.long': 'W zmapowanych danych każda droga prowadzi przez barierę niezgodną z Twoimi granicami. Pokazujemy najlepszą próbę z zaznaczonymi barierami — nie traktuj jej jako dostępnej.',
|
||||
'route.none': 'Brak trasy w dostępnych danych',
|
||||
'route.none.no-start': 'Punkt startu jest zbyt daleko od zmapowanych chodników.',
|
||||
'route.none.no-end': 'Cel jest zbyt daleko od zmapowanych chodników.',
|
||||
'route.none.disconnected': 'Nie ma połączenia między tymi punktami w zmapowanych danych.',
|
||||
'route.summary': 'Długość {km} km · ok. {min} min (szacunek)',
|
||||
'route.quality': 'Nawierzchnia znana na {pct}% trasy.',
|
||||
'route.unknownKerbs': 'Skrzyżowania z nieznaną wysokością krawężnika: {n}.',
|
||||
'route.barriers': 'Bariery i uwagi na trasie',
|
||||
'route.barriers.none': 'Brak zmapowanych barier — ale dane mają luki.',
|
||||
'route.steps': 'Opis trasy tekstem',
|
||||
'route.focus': 'Pokaż na mapie',
|
||||
'route.legend': 'Linia ciągła — brak barier, kreskowana — uwaga, kropkowana — bariera.',
|
||||
'route.start': 'Start',
|
||||
'route.end': 'Cel',
|
||||
'route.estimate': 'Czas to szacunek dla spokojnego tempa; nie uwzględnia świateł ani tłumu.',
|
||||
'flag.steps': 'Schody',
|
||||
'flag.kerb': 'Zbyt wysoki krawężnik',
|
||||
'flag.kerb-unknown': 'Przejście — wysokość krawężnika nieznana',
|
||||
'flag.cobbles': 'Kostka brukowa',
|
||||
'flag.rough': 'Nierówna nawierzchnia',
|
||||
'flag.steep': 'Stromy odcinek',
|
||||
'flag.slope-unknown': 'Pochylenie — wartość nieznana',
|
||||
'flag.narrow': 'Zbyt wąskie przejście',
|
||||
'flag.no-wheelchair': 'Oznaczone jako niedostępne dla wózka',
|
||||
'flag.surface-unknown': 'Nawierzchnia nieznana',
|
||||
'flag.ramp-steps': 'Schody z podjazdem',
|
||||
'turn.start': 'Wyruszasz',
|
||||
'turn.straight': 'Idź prosto',
|
||||
'turn.left': 'Skręć w lewo',
|
||||
'turn.right': 'Skręć w prawo',
|
||||
'turn.sharp-left': 'Skręć ostro w lewo',
|
||||
'turn.sharp-right': 'Skręć ostro w prawo',
|
||||
'turn.back': 'Zawróć',
|
||||
'seg.unnamed': 'bez nazwy',
|
||||
'seg.hw.footway': 'chodnik',
|
||||
'seg.hw.pedestrian': 'ulica piesza',
|
||||
'seg.hw.path': 'ścieżka',
|
||||
'seg.hw.steps': 'schody',
|
||||
'seg.hw.cycleway': 'droga rowerowa',
|
||||
'seg.hw.service': 'droga dojazdowa',
|
||||
'seg.hw.living_street': 'strefa zamieszkania',
|
||||
'seg.hw.residential': 'ulica osiedlowa',
|
||||
'seg.hw.tertiary': 'ulica',
|
||||
'seg.hw.unclassified': 'ulica',
|
||||
'seg.hw.track': 'droga gruntowa',
|
||||
'seg.line': '{turn}: {name} ({kind}) — {m} m{surface}',
|
||||
'seg.surfaceSuffix': ', {surface}',
|
||||
|
||||
'map.label': 'Mapa miejsc i trasy',
|
||||
'map.alt': 'Mapa jest uzupełnieniem. Te same informacje znajdziesz w liście miejsc i w opisie trasy tekstem.',
|
||||
'map.legend': 'Legenda mapy',
|
||||
'map.hollow': 'pusty środek = brak danych',
|
||||
'map.attribution': '© autorzy OpenStreetMap',
|
||||
'map.zoomIn': 'Przybliż',
|
||||
'map.zoomOut': 'Oddal',
|
||||
'map.attrToggle': 'Pokaż lub ukryj źródła mapy',
|
||||
'map.offline': 'Brak połączenia — kafelki mapy mogą się nie wczytać, dane miejsc działają offline.',
|
||||
|
||||
'about.title': 'O danych i projekcie',
|
||||
'about.intro': 'Narzędzie pokazuje szczegóły dostępności z otwartych źródeł, a nie ogólne „dostępne / niedostępne”. Każda informacja ma źródło, datę i poziom wiarygodności.',
|
||||
'about.data': 'Źródła danych i ich wiarygodność',
|
||||
'about.data.body1': 'Mapa, miejsca, schody, krawężniki, nawierzchnie, ławki i toalety pochodzą z OpenStreetMap (licencja ODbL, wymagane oznaczenie źródła). Pobieramy je przez publiczne API Overpass dla obszaru demonstracyjnego i zapisujemy jako migawkę z datą w aplikacji.',
|
||||
'about.data.body2': 'Data przy informacji to data ostatniej edycji obiektu w OSM, o ile nie ma daty kontroli (check_date, survey:date). Edycja nie zawsze dotyczyła danych o dostępności — dlatego bez daty kontroli nigdy nie nazywamy danych „potwierdzonymi”.',
|
||||
'about.data.body3': 'Dane miejskie (Otwarte Dane Krakowa, MSIP) nie są jeszcze wpięte w prototyp. Architektura przewiduje je jako kolejny adapter; przed użyciem trzeba sprawdzić licencję każdego zbioru.',
|
||||
'about.legend': 'Poziomy wiarygodności',
|
||||
'about.missing': 'Co widzisz, gdy danych brakuje lub są sprzeczne',
|
||||
'about.missing.body': 'Brak informacji pokazujemy jako „Brak danych” z szarą ikoną znaku zapytania — nigdy jako dostępność. Gdy zgłoszenie użytkownika przeczy OSM, miejsce dostaje status „Sprzeczne dane” i oba stanowiska są widoczne. Gdy nie da się odświeżyć źródła, widzisz datę ostatniego zapisu i ostrzeżenie.',
|
||||
'about.arch': 'Architektura i dodawanie miast',
|
||||
'about.arch.body': 'Trzy warstwy: (1) adaptery źródeł (OSM, zgłoszenia, w przyszłości dane miejskie), (2) normalizacja do jednego modelu faktu {wartość, źródło, data, wiarygodność}, (3) prezentacja zależna od profilu. Nowe miasto to konfiguracja: obszar geograficzny i lista adapterów — bez zmian w interfejsie. Nowe kategorie miejsc dodaje się w jednej tabeli.',
|
||||
'about.refresh': 'Aktualizacja i poprawianie danych',
|
||||
'about.refresh.body': 'Migawkę odbudowuje skrypt (npm: data:build), a pojedyncze miejsce można odświeżyć na żywo z Overpass. Błędne dane poprawia się w OSM, a w aplikacji — zgłoszeniem, które jest wyraźnie oznaczone jako niezweryfikowane. W wersji produkcyjnej zgłoszenia trafiają do kolejki moderacji, a po potwierdzeniu przez dwie niezależne osoby — do OSM.',
|
||||
'about.privacy': 'Prywatność i bezpieczeństwo',
|
||||
'about.privacy.body': 'Nie pytamy o niepełnosprawność — tylko o preferencje dotyczące barier. Profil i zgłoszenia są przechowywane lokalnie na urządzeniu. Brak kont i analityki. Lokalizacja (opcjonalna) jest używana tylko w przeglądarce. Połączenia z mapą i API są szyfrowane (HTTPS).',
|
||||
'about.a11y': 'Dostępność samej aplikacji',
|
||||
'about.a11y.body': 'Cel: WCAG 2.2 AA. Zrobione: pełna obsługa klawiaturą, widoczny fokus, kontrast z systemu USWDS, informacje nie tylko kolorem (ikona + tekst), lista i opis trasy tekstem jako odpowiednik mapy, markery barier jako przyciski. Do dopracowania: wybór miejsca bezpośrednio z mapy nie jest dostępny z klawiatury (użyj listy), brak testów z czytnikami na wszystkich platformach, brak trybu wysokiego kontrastu poza ustawieniami systemu.',
|
||||
'about.biz': 'Model biznesowy i skalowanie',
|
||||
'about.biz.body': 'Bezpłatna aplikacja dla mieszkańców i turystów. Przychód z usług B2B: (1) widżet i raport „dostępność obiektu” dla hoteli, organizatorów wydarzeń i zarządców nieruchomości, (2) API dostępności dla systemów rezerwacyjnych i aplikacji turystycznych, (3) pakiet wdrożeniowy dla miast. Koszty to hosting statyczny, okresowe pobieranie z Overpass i moderacja zgłoszeń. Za produkt odpowiada podmiot prowadzący usługę, nie UMK.',
|
||||
'about.limits': 'Znane ograniczenia prototypu',
|
||||
'about.limits.body': 'Obszar: Stare Miasto, Kazimierz, Wawel. Zgłoszenia tylko lokalnie. Brak danych miejskich. Wysokość krawężników znana tylko dla części przejść. Szerokość wejść prawie nigdy nie jest zmapowana. Czasy przejścia to szacunki. Kafelki mapy z serwera OSM nadają się do demonstracji — produkcja wymaga własnego dostawcy kafelków.',
|
||||
'about.demo': 'Kontrolki demonstracyjne',
|
||||
'about.simulate': 'Symuluj niedostępność źródła danych (OSM)',
|
||||
'about.simulate.help': 'Odświeżenie miejsca zakończy się błędem — pokazuje, co zobaczy użytkownik.',
|
||||
'about.attr': 'Mapa i dane © autorzy OpenStreetMap, licencja ODbL. Interfejs: U.S. Web Design System (domena publiczna / MIT), czcionka Public Sans (OFL). Silnik mapy: MapLibre GL JS (BSD-3).',
|
||||
'about.reset': 'Wyczyść dane lokalne (profil i zgłoszenia)',
|
||||
'about.resetDone': 'Dane lokalne usunięte.',
|
||||
|
||||
'common.close': 'Zamknij',
|
||||
'common.loading': 'Wczytywanie danych…',
|
||||
'common.loadError': 'Nie udało się wczytać danych. Odśwież stronę.',
|
||||
'common.noName': 'Miejsce bez nazwy',
|
||||
'common.km': 'km',
|
||||
'footer.attr': 'Dane © autorzy OpenStreetMap (ODbL).',
|
||||
} as const;
|
||||
|
||||
export type Key = keyof typeof pl;
|
||||
export const hasKey = (k: string): k is Key => k in pl;
|
||||
type Dict = Record<Key, string>;
|
||||
|
||||
const en: Dict = {
|
||||
'app.name': 'Kraków without barriers',
|
||||
'app.tagline': 'Check whether a place or route fits your needs — and see how we know.',
|
||||
'banner.proto': 'Hackathon prototype.',
|
||||
'banner.data': 'Data: OpenStreetMap snapshot from {date}. Places marked DEMO are fictional.',
|
||||
'nav.label': 'Main navigation',
|
||||
'nav.places': 'Places',
|
||||
'nav.route': 'Route',
|
||||
'nav.about': 'About the data',
|
||||
'nav.profile': 'Profile',
|
||||
'lang.label': 'Language',
|
||||
'skip': 'Skip to content',
|
||||
|
||||
'profile.title': 'Your needs',
|
||||
'profile.intro': 'Choose how you travel. We only ask about barrier preferences — never about health. They are stored on this device only.',
|
||||
'profile.kind': 'I travel',
|
||||
'profile.wheelchair': 'In a wheelchair',
|
||||
'profile.wheelchair.hint': 'Manual or powered',
|
||||
'profile.stroller': 'With a stroller',
|
||||
'profile.stroller.hint': 'Parent or carer with a pushchair',
|
||||
'profile.limits': 'Limits you do not want to exceed',
|
||||
'profile.kerb': 'Highest kerb',
|
||||
'profile.width': 'Narrowest passage',
|
||||
'profile.incline': 'Steepest slope',
|
||||
'profile.prefs': 'What to watch for',
|
||||
'profile.cobbles': 'Avoid cobblestones and rough surfaces',
|
||||
'profile.toilet': 'I need an accessible toilet nearby',
|
||||
'profile.rest': 'I need places to rest (benches)',
|
||||
'profile.changing': 'I need a baby-changing table',
|
||||
'profile.save': 'Save and search',
|
||||
'profile.saved': 'Profile saved on this device.',
|
||||
'profile.privacy': 'No account, no analytics. Your profile never leaves your device.',
|
||||
'unit.cm': 'cm',
|
||||
'unit.pct': '%',
|
||||
|
||||
'places.title': 'Find a place',
|
||||
'places.search': 'Name or address',
|
||||
'places.category': 'Type of place',
|
||||
'places.onlyFit': 'Show only matching or partly matching',
|
||||
'places.count': 'Results: {n}',
|
||||
'places.more': 'Show more',
|
||||
'places.empty': 'No places for these criteria.',
|
||||
'places.hint': 'Ratings are computed for your profile: {profile}.',
|
||||
'view.list': 'List',
|
||||
'view.map': 'Map',
|
||||
'view.label': 'Results view',
|
||||
'cat.all': 'All',
|
||||
'cat.food': 'Food and drink',
|
||||
'cat.culture': 'Culture and sights',
|
||||
'cat.shop': 'Shops',
|
||||
'cat.stay': 'Stays',
|
||||
'cat.health': 'Health',
|
||||
'cat.services': 'Services',
|
||||
'cat.toilets': 'Toilets',
|
||||
'cat.elevator': 'Lifts',
|
||||
'cat.transit': 'Transport',
|
||||
|
||||
'verdict.match': 'Fits, per the data',
|
||||
'verdict.partial': 'Partly fits',
|
||||
'verdict.conflict': 'Conflicting data',
|
||||
'verdict.incomplete': 'Not enough data',
|
||||
'verdict.fail': 'Does not fit',
|
||||
'verdict.match.long': 'Mapped data matches your profile. It is not a guarantee — check the date and source below.',
|
||||
'verdict.partial.long': 'Data matches your profile, but there are notes worth checking before you go.',
|
||||
'verdict.conflict.long': 'Sources disagree. Do not treat this place as accessible until someone confirms it.',
|
||||
'verdict.incomplete.long': 'Data is missing or outdated. No information does not mean the place is accessible.',
|
||||
'verdict.fail.long': 'Mapped data shows a barrier beyond your limits.',
|
||||
|
||||
'status.ok': 'OK',
|
||||
'status.warn': 'Caution',
|
||||
'status.fail': 'Barrier',
|
||||
'status.unknown': 'No data',
|
||||
'status.conflict': 'Conflicting',
|
||||
|
||||
'conf.verified': 'Confirmed',
|
||||
'conf.mapped': 'Mapped',
|
||||
'conf.stale': 'May have changed',
|
||||
'conf.inferred': 'Inferred',
|
||||
'conf.user': 'Unverified report',
|
||||
'conf.none': 'None',
|
||||
'conf.verified.help': 'Has a survey date (check_date or survey:date) within the last 24 months.',
|
||||
'conf.mapped.help': 'OSM entry edited within 24 months, with no survey date.',
|
||||
'conf.stale.help': 'OSM entry has not been changed for over 24 months.',
|
||||
'conf.inferred.help': 'Worked out from nearby objects, not described for this place.',
|
||||
'conf.user.help': 'Reported in this app; nobody has confirmed it yet.',
|
||||
'conf.none.help': 'Nothing is known.',
|
||||
'src.label': 'Source',
|
||||
'src.osm-tag': 'OpenStreetMap (place tags)',
|
||||
'src.osm-geometry': 'OpenStreetMap (nearby objects)',
|
||||
'src.user-report': 'User report (this device)',
|
||||
'src.demo': 'DEMO sample data',
|
||||
'src.none': 'No source',
|
||||
'fact.updated': 'Data from',
|
||||
'fact.dateUnknown': 'date unknown',
|
||||
'fact.confidence': 'Reliability',
|
||||
'fact.otherSource': 'Another source says',
|
||||
'fact.report': 'Correct or add',
|
||||
'fact.required': 'matters for your profile',
|
||||
|
||||
'fact.title.entrance': 'Entrance',
|
||||
'fact.title.steps': 'Steps near the entrance',
|
||||
'fact.title.ramp': 'Ramp or lift',
|
||||
'fact.title.width': 'Entrance width',
|
||||
'fact.title.surface': 'Surface outside',
|
||||
'fact.title.toilet': 'Toilet',
|
||||
'fact.title.rest': 'Places to rest',
|
||||
'fact.title.changing': 'Baby changing',
|
||||
|
||||
'f.entrance.yes': 'Entrance is tagged as wheelchair accessible.',
|
||||
'f.entrance.limited': 'Accessible with limits (for example part of the venue, or with help).',
|
||||
'f.entrance.no': 'Entrance is tagged as not wheelchair accessible.',
|
||||
'f.entrance.none': 'Nobody has described whether the entrance is accessible.',
|
||||
'f.steps.warn': 'Mapped steps about {d} m from the entrance (step count: {n}).',
|
||||
'f.steps.conflict': 'Entrance is tagged accessible, yet steps are mapped about {d} m away (step count: {n}) with no ramp or lift nearby.',
|
||||
'f.steps.hint': 'Inferred from where steps are — they may serve a different entrance.',
|
||||
'f.steps.none': 'No steps mapped nearby.',
|
||||
'f.steps.noneHint': 'This does not prove there are none — they may be unmapped.',
|
||||
'f.ramp.yes': 'Mapped ramp about {d} m from the entrance.',
|
||||
'f.ramp.lift': 'Mapped lift about {d} m from the entrance.',
|
||||
'f.ramp.none': 'No data about a ramp or lift.',
|
||||
'f.width.ok': 'Reported width: {cm} cm (your minimum: {min} cm).',
|
||||
'f.width.fail': 'Reported width: {cm} cm — below your minimum ({min} cm).',
|
||||
'f.width.none': 'Entrance width is not mapped.',
|
||||
'f.width.noneHint': 'Almost nobody maps it in OSM. You can report it.',
|
||||
'f.surface.good': 'Surface: {surface} — smooth.',
|
||||
'f.surface.fair': 'Surface: {surface} — fairly smooth.',
|
||||
'f.surface.rough': 'Surface: {surface} — rough; slower and more tiring to travel on.',
|
||||
'f.surface.unknown': 'No data about the surface outside.',
|
||||
'f.toilet.own.yes': 'Toilet in the venue is tagged as wheelchair accessible.',
|
||||
'f.toilet.own.no': 'Toilet in the venue is tagged as not wheelchair accessible.',
|
||||
'f.toilet.near': 'Accessible public toilet about {d} m away.',
|
||||
'f.toilet.fee': 'The toilet is paid.',
|
||||
'f.toilet.nearUnknown': 'Public toilet about {d} m away, but its wheelchair access is unknown.',
|
||||
'f.toilet.none': 'No data about an accessible toilet.',
|
||||
'f.rest.ok': 'Mapped benches nearby: {n}, nearest about {d} m.',
|
||||
'f.rest.none': 'No benches mapped within 150 m.',
|
||||
'f.rest.noneHint': 'Unmapped ones may exist.',
|
||||
'f.changing.yes': 'Baby-changing table tagged in the venue.',
|
||||
'f.changing.no': 'No baby-changing table (tagged in OSM).',
|
||||
'f.changing.near': 'Baby-changing table in a public toilet about {d} m away.',
|
||||
'f.changing.none': 'No data about a baby-changing table.',
|
||||
'raw': '{text}',
|
||||
|
||||
'surface.sett': 'setts (cobbles)',
|
||||
'surface.cobblestone': 'cobblestone',
|
||||
'surface.unhewn_cobblestone': 'rough cobblestone',
|
||||
'surface.paving_stones': 'paving slabs',
|
||||
'surface.asphalt': 'asphalt',
|
||||
'surface.concrete': 'concrete',
|
||||
'surface.paved': 'paved',
|
||||
'surface.concrete:plates': 'concrete plates',
|
||||
'surface.concrete:lanes': 'concrete lanes',
|
||||
'surface.compacted': 'compacted',
|
||||
'surface.fine_gravel': 'fine gravel',
|
||||
'surface.gravel': 'gravel',
|
||||
'surface.wood': 'wood',
|
||||
'surface.dirt': 'dirt',
|
||||
'surface.ground': 'ground',
|
||||
'surface.grass': 'grass',
|
||||
'surface.unpaved': 'unpaved',
|
||||
'surface.stone': 'stone',
|
||||
'surface.rock': 'rock',
|
||||
'surface.metal': 'metal',
|
||||
'surface.metal_grid': 'metal grating',
|
||||
'surface.tiles': 'tiles',
|
||||
'surface.sand': 'sand',
|
||||
'surface.pebblestone': 'pebbles',
|
||||
|
||||
'detail.back': 'Back to the list',
|
||||
'detail.routeTo': 'Route to this place',
|
||||
'detail.openOsm': 'View on OpenStreetMap',
|
||||
'detail.refresh': 'Refresh from OSM',
|
||||
'detail.refreshing': 'Fetching…',
|
||||
'detail.refreshed': 'Refreshed from OpenStreetMap: {time}.',
|
||||
'detail.refreshFail': 'Could not reach OpenStreetMap. You are seeing the snapshot from {date}. A failed refresh does not mean the data is current.',
|
||||
'detail.refreshDemo': 'This place is a sample — it does not exist in OpenStreetMap.',
|
||||
'detail.demo': 'DEMO',
|
||||
'detail.demoNote': 'Fictional place showing the data state: “{state}”.',
|
||||
'detail.facts': 'Accessibility details',
|
||||
'detail.reports': 'User reports',
|
||||
'detail.reports.none': 'No reports.',
|
||||
'detail.reports.note': 'Reports are unverified and kept apart from OSM data. In this prototype they are stored on this device only.',
|
||||
'detail.reports.demo': 'sample',
|
||||
'detail.reports.remove': 'Remove report',
|
||||
|
||||
'rep.title': 'Report a correction or addition',
|
||||
'rep.attr': 'What the report is about',
|
||||
'rep.attr.entrance': 'Entrance accessibility',
|
||||
'rep.attr.steps': 'Steps at the entrance',
|
||||
'rep.attr.width': 'Entrance width',
|
||||
'rep.attr.toilet': 'Wheelchair-accessible toilet',
|
||||
'rep.attr.ramp': 'Ramp or lift',
|
||||
'rep.value': 'What you found on site',
|
||||
'rep.yes': 'Yes',
|
||||
'rep.limited': 'Partly',
|
||||
'rep.no': 'No',
|
||||
'rep.widthLabel': 'Width in centimetres',
|
||||
'rep.note': 'Note (optional)',
|
||||
'rep.submit': 'Save report',
|
||||
'rep.cancel': 'Cancel',
|
||||
'rep.thanks': 'Report saved. It is marked as unverified.',
|
||||
'rep.widthInvalid': 'Enter a width between 30 and 300 cm.',
|
||||
'rep.disclaimer': 'We collect no personal data. A report contains only what you type.',
|
||||
|
||||
'route.title': 'Plan a route',
|
||||
'route.intro': 'The route avoids steps, high kerbs and steep sections for your profile. Missing data about a section is not treated as confirmation that it is accessible.',
|
||||
'route.from': 'From',
|
||||
'route.to': 'To',
|
||||
'route.placeholder': 'Start typing a place name',
|
||||
'route.swap': 'Swap start and destination',
|
||||
'route.go': 'Find route',
|
||||
'route.myLocation': 'Use my location',
|
||||
'route.locating': 'Finding your location…',
|
||||
'route.locOut': 'You are outside the demo area (Old Town, Kazimierz, Wawel).',
|
||||
'route.locFail': 'Could not get your location. Type a starting point.',
|
||||
'route.presets': 'Example routes',
|
||||
'route.unknownPlace': 'No place found for “{text}”. Pick one of the suggestions.',
|
||||
'route.same': 'Start and destination are the same.',
|
||||
'route.ok': 'Route with no mapped barriers',
|
||||
'route.blocked': 'No barrier-free route found',
|
||||
'route.blocked.long': 'In the mapped data every way goes through a barrier beyond your limits. We show the best attempt with the barriers marked — do not treat it as accessible.',
|
||||
'route.none': 'No route in the available data',
|
||||
'route.none.no-start': 'The start is too far from mapped footpaths.',
|
||||
'route.none.no-end': 'The destination is too far from mapped footpaths.',
|
||||
'route.none.disconnected': 'The mapped data has no connection between these points.',
|
||||
'route.summary': '{km} km · about {min} min (estimate)',
|
||||
'route.quality': 'Surface known for {pct}% of the route.',
|
||||
'route.unknownKerbs': 'Crossings with unknown kerb height: {n}.',
|
||||
'route.barriers': 'Barriers and notes on the route',
|
||||
'route.barriers.none': 'No mapped barriers — but the data has gaps.',
|
||||
'route.steps': 'Route as text',
|
||||
'route.focus': 'Show on map',
|
||||
'route.legend': 'Solid line — no barriers, dashed — caution, dotted — barrier.',
|
||||
'route.start': 'Start',
|
||||
'route.end': 'Destination',
|
||||
'route.estimate': 'Time is an estimate at a relaxed pace; it ignores traffic lights and crowds.',
|
||||
'flag.steps': 'Steps',
|
||||
'flag.kerb': 'Kerb too high',
|
||||
'flag.kerb-unknown': 'Crossing — kerb height unknown',
|
||||
'flag.cobbles': 'Cobblestones',
|
||||
'flag.rough': 'Rough surface',
|
||||
'flag.steep': 'Steep section',
|
||||
'flag.slope-unknown': 'Slope — value unknown',
|
||||
'flag.narrow': 'Passage too narrow',
|
||||
'flag.no-wheelchair': 'Tagged as not wheelchair accessible',
|
||||
'flag.surface-unknown': 'Surface unknown',
|
||||
'flag.ramp-steps': 'Steps with a ramp',
|
||||
'turn.start': 'Set off',
|
||||
'turn.straight': 'Continue straight',
|
||||
'turn.left': 'Turn left',
|
||||
'turn.right': 'Turn right',
|
||||
'turn.sharp-left': 'Turn sharp left',
|
||||
'turn.sharp-right': 'Turn sharp right',
|
||||
'turn.back': 'Turn back',
|
||||
'seg.unnamed': 'unnamed',
|
||||
'seg.hw.footway': 'footpath',
|
||||
'seg.hw.pedestrian': 'pedestrian street',
|
||||
'seg.hw.path': 'path',
|
||||
'seg.hw.steps': 'steps',
|
||||
'seg.hw.cycleway': 'cycle path',
|
||||
'seg.hw.service': 'service road',
|
||||
'seg.hw.living_street': 'living street',
|
||||
'seg.hw.residential': 'residential street',
|
||||
'seg.hw.tertiary': 'street',
|
||||
'seg.hw.unclassified': 'street',
|
||||
'seg.hw.track': 'dirt track',
|
||||
'seg.line': '{turn}: {name} ({kind}) — {m} m{surface}',
|
||||
'seg.surfaceSuffix': ', {surface}',
|
||||
|
||||
'map.label': 'Map of places and route',
|
||||
'map.alt': 'The map is a supplement. The same information is in the list of places and in the text route.',
|
||||
'map.legend': 'Map legend',
|
||||
'map.hollow': 'hollow centre = no data',
|
||||
'map.attribution': '© OpenStreetMap contributors',
|
||||
'map.zoomIn': 'Zoom in',
|
||||
'map.zoomOut': 'Zoom out',
|
||||
'map.attrToggle': 'Toggle map credits',
|
||||
'map.offline': 'You are offline — map tiles may not load; place data works offline.',
|
||||
|
||||
'about.title': 'About the data and the project',
|
||||
'about.intro': 'The tool shows accessibility details from open sources, not a blanket “accessible / not accessible”. Every piece of information has a source, a date and a reliability level.',
|
||||
'about.data': 'Data sources and their reliability',
|
||||
'about.data.body1': 'The map, places, steps, kerbs, surfaces, benches and toilets come from OpenStreetMap (ODbL licence, attribution required). We fetch them through the public Overpass API for the demo area and keep a dated snapshot in the app.',
|
||||
'about.data.body2': 'The date next to a fact is the last edit of the object in OSM unless there is a survey date (check_date, survey:date). An edit may not have touched the accessibility tags — so without a survey date we never call data “confirmed”.',
|
||||
'about.data.body3': 'City data (Kraków Open Data, MSIP) is not wired into the prototype yet. The architecture treats it as one more adapter; each dataset’s licence must be checked before use.',
|
||||
'about.legend': 'Reliability levels',
|
||||
'about.missing': 'What you see when data is missing or conflicting',
|
||||
'about.missing.body': 'Missing information is shown as “No data” with a grey question-mark icon — never as accessibility. When a user report contradicts OSM, the place gets “Conflicting data” and both positions are visible. When the source cannot be refreshed you see the date of the last snapshot and a warning.',
|
||||
'about.arch': 'Architecture and adding cities',
|
||||
'about.arch.body': 'Three layers: (1) source adapters (OSM, user reports, later city data), (2) normalisation into one fact model {value, source, date, reliability}, (3) profile-aware presentation. A new city is configuration: a geographic area and a list of adapters — no interface changes. New place categories go in a single table.',
|
||||
'about.refresh': 'Updating and correcting data',
|
||||
'about.refresh.body': 'A script rebuilds the snapshot (npm: data:build), and a single place can be refreshed live from Overpass. Wrong data is fixed in OSM, and in the app by a report that is clearly marked unverified. In production reports go to a moderation queue and, once two independent people confirm, to OSM.',
|
||||
'about.privacy': 'Privacy and security',
|
||||
'about.privacy.body': 'We do not ask about disability — only barrier preferences. Profile and reports are stored locally on the device. No accounts, no analytics. Location (optional) is used only in the browser. Connections to the map and APIs are encrypted (HTTPS).',
|
||||
'about.a11y': 'Accessibility of the app itself',
|
||||
'about.a11y.body': 'Target: WCAG 2.2 AA. Done: full keyboard operation, visible focus, contrast from the USWDS system, information never by colour alone (icon + text), list and text route as equivalent of the map, barrier markers as buttons. To improve: picking a place directly on the map is not keyboard accessible (use the list), not tested with screen readers on every platform, no dedicated high-contrast mode beyond system settings.',
|
||||
'about.biz': 'Business model and scaling',
|
||||
'about.biz.body': 'Free app for residents and tourists. Revenue from B2B: (1) an “venue accessibility” widget and report for hotels, event organisers and property managers, (2) an accessibility API for booking systems and travel apps, (3) a roll-out package for cities. Costs are static hosting, periodic Overpass pulls and report moderation. The operating entity owns the product, not the city office.',
|
||||
'about.limits': 'Known prototype limitations',
|
||||
'about.limits.body': 'Area: Old Town, Kazimierz, Wawel. Reports are local only. No city data. Kerb height is known for only some crossings. Entrance width is almost never mapped. Walking times are estimates. OSM tile servers are fine for a demo — production needs its own tile provider.',
|
||||
'about.demo': 'Demo controls',
|
||||
'about.simulate': 'Simulate the data source being unavailable (OSM)',
|
||||
'about.simulate.help': 'Refreshing a place will fail — shows what a user would see.',
|
||||
'about.attr': 'Map and data © OpenStreetMap contributors, ODbL licence. Interface: U.S. Web Design System (public domain / MIT), Public Sans typeface (OFL). Map engine: MapLibre GL JS (BSD-3).',
|
||||
'about.reset': 'Clear local data (profile and reports)',
|
||||
'about.resetDone': 'Local data cleared.',
|
||||
|
||||
'common.close': 'Close',
|
||||
'common.loading': 'Loading data…',
|
||||
'common.loadError': 'Could not load the data. Reload the page.',
|
||||
'common.noName': 'Unnamed place',
|
||||
'common.km': 'km',
|
||||
'footer.attr': 'Data © OpenStreetMap contributors (ODbL).',
|
||||
};
|
||||
|
||||
export const DICT: Record<Lang, Dict> = { pl, en };
|
||||
|
||||
export type Translate = (key: Key, params?: Record<string, string | number>) => string;
|
||||
|
||||
export function makeT(lang: Lang): { t: Translate; tm: (m: Msg) => string } {
|
||||
const dict = DICT[lang];
|
||||
const fmt = (s: string, params?: Record<string, string | number>) =>
|
||||
s.replace(/\{(\w+)\}/g, (_, k: string) => {
|
||||
const v = params?.[k];
|
||||
if (v === undefined) return `{${k}}`;
|
||||
if (typeof v === 'string' && v.startsWith('@')) return (dict as Record<string, string>)[v.slice(1)] ?? v.slice(1);
|
||||
return String(v);
|
||||
});
|
||||
const t: Translate = (key, params) => fmt(dict[key], params);
|
||||
const tm = (m: Msg) => fmt((dict as Record<string, string>)[m.k] ?? m.k, m.p);
|
||||
return { t, tm };
|
||||
}
|
||||
|
||||
export interface I18n {
|
||||
lang: Lang;
|
||||
setLang: (l: Lang) => void;
|
||||
t: Translate;
|
||||
tm: (m: Msg) => string;
|
||||
date: (iso: string | null) => string;
|
||||
}
|
||||
|
||||
export const I18nContext = createContext<I18n | null>(null);
|
||||
export function useI18n(): I18n {
|
||||
const v = useContext(I18nContext);
|
||||
if (!v) throw new Error('I18nContext missing');
|
||||
return v;
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null, lang: Lang): string | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return new Intl.DateTimeFormat(lang === 'pl' ? 'pl-PL' : 'en-GB', { year: 'numeric', month: 'long', day: 'numeric' }).format(d);
|
||||
}
|
||||
33
src/lib/labels.ts
Normal file
33
src/lib/labels.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { fold } from './text';
|
||||
import type { Place } from './types';
|
||||
|
||||
export interface Labels {
|
||||
byLabel: Map<string, Place>;
|
||||
labelOf: Map<string, string>;
|
||||
list: string[];
|
||||
}
|
||||
|
||||
/** Unique human-readable label per place, usable as a datalist value. */
|
||||
export function buildLabels(places: Place[]): Labels {
|
||||
const byLabel = new Map<string, Place>();
|
||||
const labelOf = new Map<string, string>();
|
||||
for (const p of places) {
|
||||
if (!p.name) continue;
|
||||
const addr = [p.tags['addr:street'], p.tags['addr:housenumber']].filter(Boolean).join(' ');
|
||||
let label = addr ? `${p.name} — ${addr}` : p.name;
|
||||
if (byLabel.has(label)) label = `${label} (${p.id})`;
|
||||
byLabel.set(label, p);
|
||||
labelOf.set(p.id, label);
|
||||
}
|
||||
return { byLabel, labelOf, list: [...byLabel.keys()] };
|
||||
}
|
||||
|
||||
/** Exact label, else unique folded match on name prefix. */
|
||||
export function resolveLabel(labels: Labels, text: string): Place | null {
|
||||
const exact = labels.byLabel.get(text.trim());
|
||||
if (exact) return exact;
|
||||
const q = fold(text.trim());
|
||||
if (!q) return null;
|
||||
const hits = [...labels.byLabel.entries()].filter(([l]) => fold(l).startsWith(q));
|
||||
return hits.length === 1 ? hits[0]![1] : null;
|
||||
}
|
||||
115
src/lib/lib.test.ts
Normal file
115
src/lib/lib.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { assessPlace, buildIndex, osmConfidence, verdictOf } from './assess';
|
||||
import { kerbHeightCm, parseIncline, parseWidth, roughness } from './osm';
|
||||
import { buildGraph, findRoute, snapToGraph } from './route';
|
||||
import type { Fact, Profile, Snapshot } from './types';
|
||||
|
||||
const snap = JSON.parse(readFileSync('public/data/kk-snapshot.json', 'utf8')) as Snapshot;
|
||||
const wheelchair: Profile = {
|
||||
kind: 'wheelchair', maxKerbCm: 3, minWidthM: 0.9, maxInclinePct: 6, avoidCobbles: true,
|
||||
needToilet: true, needRest: false, needChanging: false,
|
||||
};
|
||||
const NOW = new Date('2026-10-03');
|
||||
|
||||
describe('tag parsing', () => {
|
||||
it('parses incline', () => {
|
||||
expect(parseIncline('8%')).toBe(8);
|
||||
expect(parseIncline('-6 %')).toBe(6);
|
||||
expect(parseIncline('up')).toBeNull();
|
||||
expect(parseIncline('45°')).toBeCloseTo(100, 0);
|
||||
});
|
||||
it('parses width', () => {
|
||||
expect(parseWidth('2.5')).toBe(2.5);
|
||||
expect(parseWidth('90 cm')).toBeCloseTo(0.9);
|
||||
expect(parseWidth('0,8')).toBeCloseTo(0.8);
|
||||
expect(parseWidth('wide')).toBeNull();
|
||||
});
|
||||
it('classifies surface', () => {
|
||||
expect(roughness('sett', undefined)).toBe('rough');
|
||||
expect(roughness('asphalt', undefined)).toBe('good');
|
||||
expect(roughness('asphalt', 'bad')).toBe('rough');
|
||||
expect(roughness(undefined, undefined)).toBe('unknown');
|
||||
});
|
||||
it('maps kerbs', () => {
|
||||
expect(kerbHeightCm('flush')).toBe(0);
|
||||
expect(kerbHeightCm('raised')).toBeGreaterThan(3);
|
||||
expect(kerbHeightCm(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidence', () => {
|
||||
it('verified only with a recent survey date', () => {
|
||||
expect(osmConfidence('2026-01-01', '2020-01-01', NOW).confidence).toBe('verified');
|
||||
expect(osmConfidence('2020-01-01', '2026-05-01', NOW).confidence).toBe('mapped');
|
||||
expect(osmConfidence(undefined, '2022-01-01', NOW).confidence).toBe('stale');
|
||||
expect(osmConfidence(undefined, null, NOW).confidence).toBe('stale');
|
||||
});
|
||||
});
|
||||
|
||||
const fact = (over: Partial<Fact>): Fact => ({
|
||||
key: 'entrance', status: 'ok', headline: { k: 'x' }, source: 'osm-tag', confidence: 'mapped', date: null, required: true, ...over,
|
||||
});
|
||||
|
||||
describe('verdict', () => {
|
||||
it('missing data never becomes a match', () => {
|
||||
expect(verdictOf([fact({ status: 'unknown', confidence: 'none', source: 'none' })])).toBe('incomplete');
|
||||
});
|
||||
it('stale or user-only entrance is incomplete', () => {
|
||||
expect(verdictOf([fact({ confidence: 'stale' })])).toBe('incomplete');
|
||||
expect(verdictOf([fact({ confidence: 'user', source: 'user-report' })])).toBe('incomplete');
|
||||
});
|
||||
it('fail beats conflict beats the rest', () => {
|
||||
expect(verdictOf([fact({ status: 'fail' }), fact({ key: 'steps', status: 'conflict', required: false })])).toBe('fail');
|
||||
expect(verdictOf([fact({}), fact({ key: 'steps', status: 'conflict', required: false })])).toBe('conflict');
|
||||
expect(verdictOf([fact({}), fact({ key: 'surface', status: 'warn', required: false })])).toBe('partial');
|
||||
expect(verdictOf([fact({})])).toBe('match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assessPlace on real snapshot', () => {
|
||||
const ix = buildIndex(snap);
|
||||
it('flags a conflicting user report', () => {
|
||||
const place = snap.places.find((p) => p.tags.wheelchair === 'yes')!;
|
||||
const a = assessPlace(place, wheelchair, ix, [
|
||||
{ id: 'r', placeId: place.id, attr: 'entrance', value: 'no', createdAt: '2026-10-01T00:00:00Z' },
|
||||
], NOW);
|
||||
expect(a.verdict).toBe('conflict');
|
||||
expect(a.facts.find((f) => f.key === 'entrance')?.alt?.confidence).toBe('user');
|
||||
});
|
||||
it('place without wheelchair tag is not accessible-by-default', () => {
|
||||
const place = snap.places.find((p) => !p.tags.wheelchair)!;
|
||||
const a = assessPlace(place, wheelchair, ix, [], NOW);
|
||||
expect(a.facts.find((f) => f.key === 'entrance')?.status).toBe('unknown');
|
||||
expect(a.verdict).toBe('incomplete');
|
||||
});
|
||||
it('every place yields a verdict', () => {
|
||||
for (const p of snap.places.slice(0, 400)) expect(assessPlace(p, wheelchair, ix, [], NOW).facts.length).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing', () => {
|
||||
const g = buildGraph(snap);
|
||||
const rynek: [number, number] = [50.0617, 19.9373];
|
||||
const wawel: [number, number] = [50.0546, 19.9354];
|
||||
it('snaps and routes between landmarks', () => {
|
||||
expect(snapToGraph(g, rynek)).not.toBeNull();
|
||||
const r = findRoute(g, rynek, wawel, wheelchair);
|
||||
expect(r.status).not.toBe('none');
|
||||
expect(r.distanceM).toBeGreaterThan(400);
|
||||
expect(r.distanceM).toBeLessThan(2500);
|
||||
expect(r.segments.length).toBeGreaterThan(1);
|
||||
});
|
||||
it('reports ok routes without hard barriers', () => {
|
||||
const r = findRoute(g, rynek, wawel, wheelchair);
|
||||
if (r.status === 'ok') expect(r.barriers.some((b) => b.flag === 'steps')).toBe(false);
|
||||
});
|
||||
it('returns none when start is far from any path', () => {
|
||||
expect(findRoute(g, [51.0, 20.5], rynek, wheelchair).status).toBe('none');
|
||||
});
|
||||
it('avoiding cobbles never lengthens cobble distance', () => {
|
||||
const a = findRoute(g, rynek, wawel, { ...wheelchair, avoidCobbles: true });
|
||||
const b = findRoute(g, rynek, wawel, { ...wheelchair, avoidCobbles: false });
|
||||
expect(a.stats.cobbleM).toBeLessThanOrEqual(b.stats.cobbleM + 1);
|
||||
});
|
||||
});
|
||||
19
src/lib/live.ts
Normal file
19
src/lib/live.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Single-element refresh from the public Overpass API. Fails loudly: the caller must show the failure.
|
||||
import { toPlace, type OsmElement } from './osmPlace.ts';
|
||||
import type { Place } from './types';
|
||||
|
||||
const ENDPOINT = 'https://overpass-api.de/api/interpreter';
|
||||
const TYPES = { n: 'node', w: 'way', r: 'relation' } as const;
|
||||
|
||||
export async function refreshPlace(id: string, signal?: AbortSignal): Promise<Place> {
|
||||
const kind = TYPES[id[0] as keyof typeof TYPES];
|
||||
const num = id.slice(1);
|
||||
if (!kind || !/^\d+$/.test(num)) throw new Error('bad id');
|
||||
const res = await fetch(`${ENDPOINT}?data=${encodeURIComponent(`[out:json][timeout:15];${kind}(${num});out meta center;`)}`, { signal });
|
||||
if (!res.ok) throw new Error(`Overpass HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { elements?: OsmElement[] };
|
||||
const el = json.elements?.[0];
|
||||
const place = el ? toPlace(el) : null;
|
||||
if (!place) throw new Error('element not found');
|
||||
return place;
|
||||
}
|
||||
77
src/lib/osm.ts
Normal file
77
src/lib/osm.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Interpretation of OSM access tags. Pure functions, unit tested.
|
||||
import type { Profile, Way } from './types';
|
||||
|
||||
export type Roughness = 'good' | 'fair' | 'rough' | 'unknown';
|
||||
|
||||
const GOOD = new Set(['asphalt', 'concrete', 'concrete:plates', 'concrete:lanes', 'paved', 'tiles', 'metal', 'paving_stones:flat']);
|
||||
const FAIR = new Set(['paving_stones', 'compacted', 'fine_gravel', 'wood']);
|
||||
const ROUGH = new Set([
|
||||
'sett', 'cobblestone', 'unhewn_cobblestone', 'stone', 'rock', 'gravel', 'pebblestone', 'dirt', 'ground',
|
||||
'grass', 'unpaved', 'sand', 'mud', 'metal_grid',
|
||||
]);
|
||||
|
||||
export function roughness(surface: string | undefined, smoothness: string | undefined): Roughness {
|
||||
if (smoothness) {
|
||||
if (['bad', 'very_bad', 'horrible', 'very_horrible', 'impassable'].includes(smoothness)) return 'rough';
|
||||
if (['excellent', 'good'].includes(smoothness)) return 'good';
|
||||
if (smoothness === 'intermediate') return 'fair';
|
||||
}
|
||||
if (!surface) return 'unknown';
|
||||
if (GOOD.has(surface)) return 'good';
|
||||
if (FAIR.has(surface)) return 'fair';
|
||||
if (ROUGH.has(surface)) return 'rough';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export const isCobbles = (surface?: string) =>
|
||||
surface === 'sett' || surface === 'cobblestone' || surface === 'unhewn_cobblestone';
|
||||
|
||||
/** Absolute slope in percent, or null when a direction word gives no magnitude. */
|
||||
export function parseIncline(v: string | undefined): number | null {
|
||||
if (!v) return null;
|
||||
const m = /^(-?\d+(?:[.,]\d+)?)\s*(%|°)?$/.exec(v.trim());
|
||||
if (!m) return null;
|
||||
const n = Math.abs(Number(m[1]!.replace(',', '.')));
|
||||
if (Number.isNaN(n)) return null;
|
||||
return m[2] === '°' ? Math.tan((n * Math.PI) / 180) * 100 : n;
|
||||
}
|
||||
|
||||
/** Width in metres. OSM default unit is metres; accepts "90 cm", "2.5 m", "0,9". */
|
||||
export function parseWidth(v: string | undefined): number | null {
|
||||
if (!v) return null;
|
||||
const m = /^(\d+(?:[.,]\d+)?)\s*(m|cm)?$/.exec(v.trim());
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]!.replace(',', '.'));
|
||||
if (Number.isNaN(n)) return null;
|
||||
return m[2] === 'cm' ? n / 100 : n;
|
||||
}
|
||||
|
||||
/** Approximate kerb height in cm from the OSM `kerb` value (flush ≈ 0, lowered ≤ ~3, raised > 3). */
|
||||
export function kerbHeightCm(kerb: string | undefined): number | null {
|
||||
switch (kerb) {
|
||||
case 'flush':
|
||||
case 'no':
|
||||
return 0;
|
||||
case 'lowered':
|
||||
return 3;
|
||||
case 'raised':
|
||||
return 8;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A way with a ramp the user's device can use, parallel to or instead of steps. */
|
||||
export function stepsHaveRamp(w: Way, kind: Profile['kind']): boolean {
|
||||
if (kind === 'stroller' && w.rampStroller === 'yes') return true;
|
||||
return w.rampWheelchair === 'yes';
|
||||
}
|
||||
|
||||
export const MONTHS_24 = 24;
|
||||
|
||||
export function monthsSince(iso: string | null, now = new Date()): number | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24 * 30.4375);
|
||||
}
|
||||
55
src/lib/osmPlace.ts
Normal file
55
src/lib/osmPlace.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// OSM element → Place. Shared by the snapshot builder (scripts/build-data.mjs) and the live refresh.
|
||||
import type { Place, PlaceCategory } from './types.ts';
|
||||
|
||||
export interface OsmElement {
|
||||
type: 'node' | 'way' | 'relation';
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
timestamp?: string;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const PLACE_TAGS = [
|
||||
'name:en', 'wheelchair', 'wheelchair:description', 'wheelchair:description:pl', 'wheelchair:description:en',
|
||||
'toilets:wheelchair', 'changing_table', 'diaper', 'level', 'check_date', 'check_date:wheelchair',
|
||||
'survey:date', 'ramp:wheelchair', 'ramp:stroller', 'step_count', 'addr:street', 'addr:housenumber',
|
||||
'website', 'opening_hours', 'entrance', 'door',
|
||||
];
|
||||
|
||||
export function category(t: Record<string, string>): PlaceCategory {
|
||||
const a = t.amenity, to = t.tourism, h = t.historic;
|
||||
if (['restaurant', 'cafe', 'bar', 'pub', 'fast_food', 'ice_cream', 'food_court'].includes(a ?? '')) return 'food';
|
||||
if (['pharmacy', 'clinic', 'doctors', 'dentist', 'hospital'].includes(a ?? '')) return 'health';
|
||||
if (['museum', 'gallery', 'attraction', 'information'].includes(to ?? '') || ['cinema', 'theatre', 'library', 'arts_centre'].includes(a ?? '') || h) return 'culture';
|
||||
if (['hotel', 'hostel', 'guest_house', 'apartment'].includes(to ?? '')) return 'stay';
|
||||
if (a === 'place_of_worship') return 'culture';
|
||||
if (t.shop) return 'shop';
|
||||
return 'services';
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 1e6) / 1e6;
|
||||
const day = (ts: string | undefined) => (ts ? ts.slice(0, 10) : null);
|
||||
|
||||
export function toPlace(el: OsmElement, cat?: PlaceCategory): Place | null {
|
||||
const t = el.tags ?? {};
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lon = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lon == null) return null;
|
||||
const tags: Record<string, string> = {};
|
||||
for (const k of PLACE_TAGS) {
|
||||
const v = t[k];
|
||||
if (v != null) tags[k] = v;
|
||||
}
|
||||
return {
|
||||
id: `${el.type[0]}${el.id}`,
|
||||
lat: round(lat),
|
||||
lon: round(lon),
|
||||
name: t.name ?? null,
|
||||
cat: cat ?? category(t),
|
||||
kind: t.amenity ?? t.tourism ?? t.shop ?? t.historic ?? t.highway ?? t.railway ?? null,
|
||||
ts: day(el.timestamp),
|
||||
tags,
|
||||
};
|
||||
}
|
||||
17
src/lib/profile.ts
Normal file
17
src/lib/profile.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Profile, ProfileKind } from './types';
|
||||
|
||||
export const DEFAULT_PROFILE: Record<ProfileKind, Profile> = {
|
||||
wheelchair: { kind: 'wheelchair', maxKerbCm: 3, minWidthM: 0.9, maxInclinePct: 6, avoidCobbles: true, needToilet: true, needRest: false, needChanging: false },
|
||||
stroller: { kind: 'stroller', maxKerbCm: 5, minWidthM: 0.8, maxInclinePct: 8, avoidCobbles: false, needToilet: false, needRest: false, needChanging: true },
|
||||
};
|
||||
|
||||
export function isProfile(v: unknown): v is Profile {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const p = v as Record<string, unknown>;
|
||||
return (
|
||||
(p.kind === 'wheelchair' || p.kind === 'stroller') &&
|
||||
typeof p.maxKerbCm === 'number' && typeof p.minWidthM === 'number' && typeof p.maxInclinePct === 'number' &&
|
||||
typeof p.avoidCobbles === 'boolean' && typeof p.needToilet === 'boolean' &&
|
||||
typeof p.needRest === 'boolean' && typeof p.needChanging === 'boolean'
|
||||
);
|
||||
}
|
||||
287
src/lib/route.ts
Normal file
287
src/lib/route.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// Profile-aware pedestrian routing over the OSM snapshot (A*).
|
||||
// Hard barriers (steps, raised kerbs above the user's limit, wheelchair=no) are not removed from the
|
||||
// graph; they carry a large penalty so the user still sees the best available path *and* that it is blocked.
|
||||
import { Grid, bearing, haversine, type LatLon } from './geo';
|
||||
import { isCobbles, kerbHeightCm, parseIncline, parseWidth, roughness, stepsHaveRamp, type Roughness } from './osm';
|
||||
import type { Profile, Snapshot, Way } from './types';
|
||||
|
||||
export type Flag =
|
||||
| 'steps' | 'ramp-steps' | 'kerb' | 'kerb-unknown' | 'cobbles' | 'rough' | 'steep'
|
||||
| 'slope-unknown' | 'narrow' | 'no-wheelchair' | 'surface-unknown';
|
||||
|
||||
const HARD: ReadonlySet<Flag> = new Set<Flag>(['steps', 'kerb', 'no-wheelchair']);
|
||||
const BLOCK_PENALTY_M = 5000;
|
||||
|
||||
interface Edge { to: number; len: number; way: number }
|
||||
|
||||
export interface RouteGraph {
|
||||
snapshot: Snapshot;
|
||||
adj: Edge[][];
|
||||
grid: Grid<number>;
|
||||
}
|
||||
|
||||
export function buildGraph(snapshot: Snapshot): RouteGraph {
|
||||
const adj: Edge[][] = snapshot.nodes.map(() => []);
|
||||
const grid = new Grid<number>(0.0008);
|
||||
const used = new Set<number>();
|
||||
snapshot.ways.forEach((w, wi) => {
|
||||
for (let i = 0; i < w.nodes.length - 1; i++) {
|
||||
const a = w.nodes[i]!, b = w.nodes[i + 1]!;
|
||||
const len = haversine(snapshot.nodes[a]!, snapshot.nodes[b]!);
|
||||
adj[a]!.push({ to: b, len, way: wi });
|
||||
adj[b]!.push({ to: a, len, way: wi });
|
||||
used.add(a);
|
||||
used.add(b);
|
||||
}
|
||||
});
|
||||
for (const n of used) grid.add(snapshot.nodes[n]![0], snapshot.nodes[n]![1], n);
|
||||
return { snapshot, adj, grid };
|
||||
}
|
||||
|
||||
export function snapToGraph(g: RouteGraph, p: LatLon, maxM = 150): number | null {
|
||||
let best: number | null = null, bd = maxM;
|
||||
for (const n of g.grid.near(p[0], p[1], maxM)) {
|
||||
const d = haversine(p, g.snapshot.nodes[n]!);
|
||||
if (d < bd) { bd = d; best = n; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const BASE: Record<string, number> = {
|
||||
footway: 1, pedestrian: 1, path: 1.2, steps: 1, living_street: 1.05, residential: 1.15, service: 1.3,
|
||||
unclassified: 1.3, tertiary: 1.5, cycleway: 1.1, track: 1.6,
|
||||
};
|
||||
|
||||
interface WayInfo { mult: number; fixed: number; flags: Flag[]; rough: Roughness }
|
||||
|
||||
export function wayInfo(g: RouteGraph, w: Way, profile: Profile): WayInfo {
|
||||
const flags: Flag[] = [];
|
||||
let mult = BASE[w.hw] ?? 1.3;
|
||||
let fixed = 0;
|
||||
|
||||
if (w.hw === 'steps') {
|
||||
if (stepsHaveRamp(w, profile.kind)) { flags.push('ramp-steps'); mult *= 1.6; }
|
||||
else { flags.push('steps'); fixed += BLOCK_PENALTY_M; }
|
||||
}
|
||||
if (w.wheelchair === 'no') { flags.push('no-wheelchair'); fixed += BLOCK_PENALTY_M; }
|
||||
else if (w.wheelchair === 'yes' || w.wheelchair === 'designated') mult *= 0.9;
|
||||
|
||||
if (w.footway === 'crossing' && w.nodes.length >= 2) {
|
||||
const heights = [w.nodes[0]!, w.nodes[w.nodes.length - 1]!].map((n) => kerbHeightCm(g.snapshot.kerbs[n]));
|
||||
const known = heights.filter((h): h is number => h !== null);
|
||||
if (known.some((h) => h > profile.maxKerbCm)) { flags.push('kerb'); fixed += BLOCK_PENALTY_M; }
|
||||
else if (known.length < 2) { flags.push('kerb-unknown'); fixed += 25; }
|
||||
}
|
||||
|
||||
const rough = roughness(w.surface, w.smooth);
|
||||
if (w.hw !== 'steps') {
|
||||
if (rough === 'rough') {
|
||||
flags.push(isCobbles(w.surface) ? 'cobbles' : 'rough');
|
||||
mult *= profile.avoidCobbles ? 3 : 1.5;
|
||||
} else if (rough === 'fair') mult *= 1.15;
|
||||
else if (rough === 'unknown') { flags.push('surface-unknown'); mult *= 1.1; }
|
||||
}
|
||||
|
||||
if (w.hw !== 'steps') {
|
||||
const inc = parseIncline(w.incline);
|
||||
if (inc !== null) {
|
||||
if (inc > profile.maxInclinePct * 1.5) { flags.push('steep'); mult *= 8; }
|
||||
else if (inc > profile.maxInclinePct) { flags.push('steep'); mult *= 4; }
|
||||
} else if (w.incline === 'up' || w.incline === 'down') { flags.push('slope-unknown'); mult *= 1.2; }
|
||||
}
|
||||
|
||||
const width = parseWidth(w.width);
|
||||
if (width !== null && width < profile.minWidthM && w.hw !== 'steps') { flags.push('narrow'); mult *= 6; }
|
||||
|
||||
return { mult, fixed, flags, rough };
|
||||
}
|
||||
|
||||
class MinHeap {
|
||||
private a: [number, number][] = [];
|
||||
get size() { return this.a.length; }
|
||||
push(prio: number, node: number) {
|
||||
const a = this.a;
|
||||
a.push([prio, node]);
|
||||
let i = a.length - 1;
|
||||
while (i > 0) {
|
||||
const p = (i - 1) >> 1;
|
||||
if (a[p]![0] <= a[i]![0]) break;
|
||||
[a[p], a[i]] = [a[i]!, a[p]!];
|
||||
i = p;
|
||||
}
|
||||
}
|
||||
pop(): [number, number] | undefined {
|
||||
const a = this.a;
|
||||
if (a.length === 0) return undefined;
|
||||
const top = a[0]!;
|
||||
const last = a.pop()!;
|
||||
if (a.length > 0) {
|
||||
a[0] = last;
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
const l = 2 * i + 1, r = l + 1;
|
||||
let m = i;
|
||||
if (l < a.length && a[l]![0] < a[m]![0]) m = l;
|
||||
if (r < a.length && a[r]![0] < a[m]![0]) m = r;
|
||||
if (m === i) break;
|
||||
[a[m], a[i]] = [a[i]!, a[m]!];
|
||||
i = m;
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
}
|
||||
|
||||
export type Turn = 'start' | 'straight' | 'left' | 'right' | 'sharp-left' | 'sharp-right' | 'back';
|
||||
|
||||
export interface RouteSegment {
|
||||
name: string | null;
|
||||
hw: string;
|
||||
surface?: string;
|
||||
rough: Roughness;
|
||||
flags: Flag[];
|
||||
distanceM: number;
|
||||
turn: Turn;
|
||||
coords: LatLon[];
|
||||
}
|
||||
|
||||
export interface Barrier {
|
||||
flag: Flag;
|
||||
at: LatLon;
|
||||
wayId: number;
|
||||
name: string | null;
|
||||
lengthM: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RouteResult {
|
||||
status: 'ok' | 'blocked' | 'none';
|
||||
reason?: 'no-start' | 'no-end' | 'disconnected';
|
||||
coords: LatLon[];
|
||||
segments: RouteSegment[];
|
||||
barriers: Barrier[];
|
||||
distanceM: number;
|
||||
durationMin: number;
|
||||
stats: { knownSurfacePct: number; unknownKerbs: number; cobbleM: number };
|
||||
}
|
||||
|
||||
const NONE = (reason: RouteResult['reason']): RouteResult => ({
|
||||
status: 'none', reason, coords: [], segments: [], barriers: [], distanceM: 0, durationMin: 0,
|
||||
stats: { knownSurfacePct: 0, unknownKerbs: 0, cobbleM: 0 },
|
||||
});
|
||||
|
||||
export function findRoute(g: RouteGraph, from: LatLon, to: LatLon, profile: Profile): RouteResult {
|
||||
const s = snapToGraph(g, from), e = snapToGraph(g, to);
|
||||
if (s === null) return NONE('no-start');
|
||||
if (e === null) return NONE('no-end');
|
||||
const { nodes, ways } = g.snapshot;
|
||||
const infos = new Map<number, WayInfo>();
|
||||
const info = (wi: number) => {
|
||||
let i = infos.get(wi);
|
||||
if (!i) { i = wayInfo(g, ways[wi]!, profile); infos.set(wi, i); }
|
||||
return i;
|
||||
};
|
||||
const edgeCount = (wi: number) => Math.max(1, ways[wi]!.nodes.length - 1);
|
||||
|
||||
const goal = nodes[e]!;
|
||||
const dist = new Map<number, number>([[s, 0]]);
|
||||
const prev = new Map<number, { node: number; edge: Edge }>();
|
||||
const done = new Set<number>();
|
||||
const heap = new MinHeap();
|
||||
heap.push(haversine(nodes[s]!, goal) * 0.85, s);
|
||||
let reached = s === e;
|
||||
while (heap.size > 0 && !reached) {
|
||||
const [, u] = heap.pop()!;
|
||||
if (done.has(u)) continue;
|
||||
done.add(u);
|
||||
if (u === e) { reached = true; break; }
|
||||
const du = dist.get(u)!;
|
||||
for (const edge of g.adj[u]!) {
|
||||
if (done.has(edge.to)) continue;
|
||||
const wi = info(edge.way);
|
||||
const nd = du + edge.len * wi.mult + wi.fixed / edgeCount(edge.way);
|
||||
if (nd < (dist.get(edge.to) ?? Infinity)) {
|
||||
dist.set(edge.to, nd);
|
||||
prev.set(edge.to, { node: u, edge });
|
||||
heap.push(nd + haversine(nodes[edge.to]!, goal) * 0.85, edge.to);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reached) return NONE('disconnected');
|
||||
|
||||
const path: { node: number; edge: Edge | null }[] = [];
|
||||
for (let cur: number | undefined = e; cur !== undefined; ) {
|
||||
const p = prev.get(cur);
|
||||
path.push({ node: cur, edge: p?.edge ?? null });
|
||||
cur = p?.node;
|
||||
}
|
||||
path.reverse();
|
||||
|
||||
return summarize(g, path, info, profile);
|
||||
}
|
||||
|
||||
function summarize(
|
||||
g: RouteGraph, path: { node: number; edge: Edge | null }[], info: (wi: number) => WayInfo, profile: Profile,
|
||||
): RouteResult {
|
||||
const { nodes, ways } = g.snapshot;
|
||||
const coords = path.map((p) => nodes[p.node]!);
|
||||
const segments: RouteSegment[] = [];
|
||||
const barriers = new Map<string, Barrier>();
|
||||
let total = 0, known = 0, cobbleM = 0, minutes = 0;
|
||||
const speed = profile.kind === 'wheelchair' ? 3.6 : 4.0; // km/h, deliberately conservative
|
||||
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const edge = path[i]!.edge!;
|
||||
const w = ways[edge.way]!;
|
||||
const wi = info(edge.way);
|
||||
total += edge.len;
|
||||
if (!wi.flags.includes('surface-unknown') && w.hw !== 'steps') known += edge.len;
|
||||
if (wi.flags.includes('cobbles')) cobbleM += edge.len;
|
||||
minutes += (edge.len / 1000 / speed) * 60 * (wi.rough === 'rough' ? 1.4 : 1);
|
||||
|
||||
const a = nodes[path[i - 1]!.node]!, b = nodes[path[i]!.node]!;
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.name === (w.name ?? null) && last.flags.join(',') === [...wi.flags].sort().join(',') && last.surface === w.surface && last.hw === w.hw) {
|
||||
last.distanceM += edge.len;
|
||||
last.coords.push(b);
|
||||
} else {
|
||||
segments.push({
|
||||
name: w.name ?? null, hw: w.hw, surface: w.surface, rough: wi.rough,
|
||||
flags: [...wi.flags].sort() as Flag[], distanceM: edge.len, turn: 'start', coords: [a, b],
|
||||
});
|
||||
}
|
||||
|
||||
for (const f of wi.flags) {
|
||||
if (f === 'surface-unknown' || f === 'ramp-steps') continue;
|
||||
const key = `${edge.way}:${f}`;
|
||||
const ex = barriers.get(key);
|
||||
if (ex) ex.lengthM += edge.len;
|
||||
else barriers.set(key, {
|
||||
flag: f, at: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], wayId: w.id, name: w.name ?? null, lengthM: edge.len,
|
||||
detail: f === 'steps' ? (w.stepCount ? String(w.stepCount) : undefined) : f === 'cobbles' || f === 'rough' ? w.surface : f === 'steep' ? w.incline : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const p = segments[i - 1]!.coords, c = segments[i]!.coords;
|
||||
const b1 = bearing(p[p.length - 2]!, p[p.length - 1]!), b2 = bearing(c[0]!, c[1]!);
|
||||
const d = ((b2 - b1 + 540) % 360) - 180;
|
||||
const ad = Math.abs(d);
|
||||
segments[i]!.turn = ad < 25 ? 'straight' : ad > 155 ? 'back' : ad > 100 ? (d > 0 ? 'sharp-right' : 'sharp-left') : d > 0 ? 'right' : 'left';
|
||||
}
|
||||
|
||||
const list = [...barriers.values()];
|
||||
const blocked = list.some((b) => HARD.has(b.flag));
|
||||
return {
|
||||
status: blocked ? 'blocked' : 'ok', coords, segments, barriers: list,
|
||||
distanceM: total, durationMin: minutes,
|
||||
stats: {
|
||||
knownSurfacePct: total ? Math.round((known / total) * 100) : 0,
|
||||
unknownKerbs: list.filter((b) => b.flag === 'kerb-unknown').length,
|
||||
cobbleM,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { HARD as HARD_FLAGS };
|
||||
40
src/lib/store.ts
Normal file
40
src/lib/store.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** localStorage-backed state that degrades to memory when storage is blocked. */
|
||||
export function useLocalState<T>(key: string, initial: T, valid?: (v: unknown) => v is T) {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw !== null) {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!valid || valid(parsed)) return parsed as T;
|
||||
}
|
||||
} catch { /* storage blocked or corrupt: fall back to initial */ }
|
||||
return initial;
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch { /* ignore */ }
|
||||
}, [key, value]);
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
|
||||
export function clearLocal(keys: string[]) {
|
||||
for (const k of keys) {
|
||||
try { localStorage.removeItem(k); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function useOnline() {
|
||||
const [online, setOnline] = useState(() => navigator.onLine);
|
||||
useEffect(() => {
|
||||
const up = () => setOnline(true), down = () => setOnline(false);
|
||||
window.addEventListener('online', up);
|
||||
window.addEventListener('offline', down);
|
||||
return () => { window.removeEventListener('online', up); window.removeEventListener('offline', down); };
|
||||
}, []);
|
||||
return online;
|
||||
}
|
||||
|
||||
export const newId = () => crypto.randomUUID();
|
||||
4
src/lib/text.ts
Normal file
4
src/lib/text.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** Lower-case, strip diacritics (incl. Polish ł) for forgiving search. */
|
||||
export function fold(s: string): string {
|
||||
return s.toLowerCase().replace(/ł/g, 'l').normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
147
src/lib/types.ts
Normal file
147
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
export type Lang = 'pl' | 'en';
|
||||
|
||||
export type ProfileKind = 'wheelchair' | 'stroller';
|
||||
|
||||
/** Preferences only — never a diagnosis. Stored on the device. */
|
||||
export interface Profile {
|
||||
kind: ProfileKind;
|
||||
/** Highest kerb the user can cross, cm. */
|
||||
maxKerbCm: number;
|
||||
/** Narrowest passage, metres. */
|
||||
minWidthM: number;
|
||||
/** Steepest slope, percent. */
|
||||
maxInclinePct: number;
|
||||
avoidCobbles: boolean;
|
||||
needToilet: boolean;
|
||||
needRest: boolean;
|
||||
needChanging: boolean;
|
||||
}
|
||||
|
||||
export type PlaceCategory = 'food' | 'culture' | 'shop' | 'stay' | 'health' | 'services' | 'toilets' | 'elevator' | 'transit';
|
||||
|
||||
export interface Place {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string | null;
|
||||
cat: PlaceCategory;
|
||||
kind: string | null;
|
||||
/** ISO date of the last edit of the OSM element (not necessarily of the access tags). */
|
||||
ts: string | null;
|
||||
tags: Record<string, string>;
|
||||
/** Fictional place that exists only to demonstrate data states. */
|
||||
demo?: boolean;
|
||||
/** City-feed extras (GTFS stops). */
|
||||
ext?: { gtfsWheelchair: number | null; feedDate: string | null; mode: 'tram' | 'bus'; osmMatch: string | null };
|
||||
}
|
||||
|
||||
export interface Way {
|
||||
id: number;
|
||||
hw: string;
|
||||
ts: string | null;
|
||||
nodes: number[];
|
||||
name?: string;
|
||||
surface?: string;
|
||||
smooth?: string;
|
||||
incline?: string;
|
||||
width?: string;
|
||||
stepCount?: number;
|
||||
ramp?: string;
|
||||
rampWheelchair?: string;
|
||||
rampStroller?: string;
|
||||
handrail?: string;
|
||||
footway?: 'crossing' | 'sidewalk';
|
||||
tunnel?: boolean;
|
||||
bridge?: boolean;
|
||||
wheelchair?: string;
|
||||
checked?: string;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
meta: {
|
||||
fetchedAt: string;
|
||||
bbox: [number, number, number, number];
|
||||
source: string;
|
||||
license: string;
|
||||
endpoint: string;
|
||||
cityData: { name: string; url: string; feedDates: { mode: string; version: string | null }[]; license: string };
|
||||
counts: Record<string, number>;
|
||||
};
|
||||
places: Place[];
|
||||
toilets: Place[];
|
||||
elevators: Place[];
|
||||
/** [lat, lon, ISO date | null] */
|
||||
benches: [number, number, string | null][];
|
||||
nodes: [number, number][];
|
||||
ways: Way[];
|
||||
kerbs: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------------- assessment ----------------
|
||||
|
||||
export type Status = 'ok' | 'warn' | 'fail' | 'unknown' | 'conflict';
|
||||
|
||||
/**
|
||||
* How far a piece of information can be trusted. Ordered strongest first.
|
||||
* - verified: dated survey (check_date / survey:date) within 24 months
|
||||
* - mapped: present in OSM, edited within 24 months, no survey date
|
||||
* - stale: present in OSM but not touched for over 24 months
|
||||
* - inferred: derived from nearby geometry, not stated for this place
|
||||
* - user: unverified report from a user of this app
|
||||
* - none: nothing is known
|
||||
*/
|
||||
export type Confidence = 'verified' | 'mapped' | 'stale' | 'inferred' | 'user' | 'none';
|
||||
|
||||
export type SourceKind = 'osm-tag' | 'osm-geometry' | 'user-report' | 'city-review' | 'city-gtfs' | 'demo' | 'none';
|
||||
|
||||
export type FactKey = 'entrance' | 'steps' | 'ramp' | 'width' | 'surface' | 'toilet' | 'rest' | 'changing';
|
||||
|
||||
/** Translatable message: key into the i18n dictionary plus parameters. */
|
||||
export interface Msg {
|
||||
k: string;
|
||||
p?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface Fact {
|
||||
key: FactKey;
|
||||
status: Status;
|
||||
headline: Msg;
|
||||
detail?: Msg;
|
||||
source: SourceKind;
|
||||
confidence: Confidence;
|
||||
/** ISO date this information dates from, when known. */
|
||||
date: string | null;
|
||||
/** Required for this profile, i.e. it can decide the verdict. */
|
||||
required: boolean;
|
||||
/** Second opinion that disagrees (user report vs OSM). */
|
||||
alt?: { headline: Msg; source: SourceKind; confidence: Confidence; date: string | null };
|
||||
}
|
||||
|
||||
export type Verdict = 'match' | 'partial' | 'fail' | 'incomplete' | 'conflict';
|
||||
|
||||
export interface Assessment {
|
||||
placeId: string;
|
||||
verdict: Verdict;
|
||||
facts: Fact[];
|
||||
}
|
||||
|
||||
export type ReportAttr = 'entrance' | 'steps' | 'width' | 'toilet' | 'ramp';
|
||||
export type ReportValue = 'yes' | 'limited' | 'no' | number;
|
||||
|
||||
export interface UserReport {
|
||||
id: string;
|
||||
placeId: string;
|
||||
attr: ReportAttr;
|
||||
/** yes/limited/no, or entrance width in cm. */
|
||||
value: ReportValue;
|
||||
note?: string;
|
||||
createdAt: string;
|
||||
/** Who wrote it (demo account id). */
|
||||
authorId?: string;
|
||||
/** City moderation outcome; absent = pending. */
|
||||
status?: 'pending' | 'accepted' | 'rejected';
|
||||
reviewedAt?: string;
|
||||
reviewedBy?: string;
|
||||
/** Seeded example, not a real user report. */
|
||||
demo?: boolean;
|
||||
}
|
||||
16
src/main.tsx
Normal file
16
src/main.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import App from './App';
|
||||
import './styles/uswds-theme.scss';
|
||||
import './styles/app.css';
|
||||
|
||||
registerSW({ immediate: true });
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('#root missing');
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
55
src/styles/app.css
Normal file
55
src/styles/app.css
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* USWDS `sans` role resolves to "Public Sans Web"; faces declared here so Vite fingerprints the files. */
|
||||
@font-face { font-family: 'Public Sans Web'; font-style: normal; font-weight: 300; font-display: swap; src: url('../assets/fonts/public-sans/PublicSans-Light.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Public Sans Web'; font-style: normal; font-weight: 400; font-display: swap; src: url('../assets/fonts/public-sans/PublicSans-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Public Sans Web'; font-style: italic; font-weight: 400; font-display: swap; src: url('../assets/fonts/public-sans/PublicSans-Italic.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Public Sans Web'; font-style: normal; font-weight: 500; font-display: swap; src: url('../assets/fonts/public-sans/PublicSans-Medium.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Public Sans Web'; font-style: normal; font-weight: 600; font-display: swap; src: url('../assets/fonts/public-sans/PublicSans-SemiBold.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Public Sans Web'; font-style: normal; font-weight: 700; font-display: swap; src: url('../assets/fonts/public-sans/PublicSans-Bold.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Public Sans Web'; font-style: italic; font-weight: 700; font-display: swap; src: url('../assets/fonts/public-sans/PublicSans-BoldItalic.woff2') format('woff2'); }
|
||||
|
||||
/* Layout glue only. Colour, type, spacing and components come from USWDS. */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; }
|
||||
.kbb-app { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||
.kbb-main { flex: 1; min-height: 0; overflow: auto; background: #fff; }
|
||||
.kbb-main:focus { outline: none; }
|
||||
.kbb-narrow { max-width: 44rem; }
|
||||
|
||||
.kbb-main--split { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); overflow: hidden; }
|
||||
.kbb-panel { overflow: auto; padding: 1rem 1rem 2rem; background: #fff; min-width: 0; }
|
||||
.kbb-map-wrap { position: relative; min-height: 0; height: 100%; }
|
||||
.kbb-map { position: absolute; inset: 0; }
|
||||
@media (min-width: 64em) {
|
||||
.kbb-main--split { grid-template-columns: minmax(24rem, 30rem) minmax(0, 1fr); }
|
||||
.kbb-panel { border-right: 1px solid #dfe1e2; }
|
||||
}
|
||||
@media (max-width: 63.99em) {
|
||||
.kbb-main--split.is-list .kbb-map-wrap { display: none; }
|
||||
.kbb-main--split.is-map .kbb-panel { display: none; }
|
||||
}
|
||||
|
||||
.kbb-place-btn { appearance: none; cursor: pointer; font: inherit; color: inherit; }
|
||||
.kbb-place-btn:hover { background: #f0f0f0; }
|
||||
.kbb-place-btn:focus-visible, .kbb-pin:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: 0; }
|
||||
|
||||
.kbb-source { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; }
|
||||
.kbb-source > div { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.kbb-source dt::after { content: ':'; }
|
||||
.kbb-source dd { margin: 0; }
|
||||
|
||||
.kbb-legend { position: absolute; left: 0.5rem; bottom: 1.75rem; z-index: 2; max-width: 17rem; padding: 0.5rem 0.75rem; background: #fff; border: 1px solid #565c65; border-radius: 0.25rem; font-size: 0.875rem; }
|
||||
.kbb-legend summary { cursor: pointer; font-weight: 700; }
|
||||
.kbb-legend ul { margin-top: 0.5rem !important; }
|
||||
.kbb-legend li { padding: 0.15rem 0; }
|
||||
.kbb-swatch { flex: none; width: 0.9rem; height: 0.9rem; border: 2px solid; border-radius: 50%; margin-right: 0.5rem; }
|
||||
.kbb-map-note { position: absolute; top: 0.5rem; left: 0.5rem; right: 3.5rem; z-index: 2; }
|
||||
|
||||
.kbb-pin { display: flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 50%; border: 2px solid #1b1b1b; background: #fff; color: #1b1b1b; font: 700 0.875rem/1 inherit; padding: 0; }
|
||||
.kbb-pin--start { background: #005ea2; color: #fff; border-color: #fff; }
|
||||
.kbb-pin--end { background: #1b1b1b; color: #fff; border-color: #fff; }
|
||||
.kbb-pin--barrier { background: #ffbe2e; cursor: pointer; }
|
||||
.kbb-pin--barrier .usa-icon { width: 1.1rem; height: 1.1rem; fill: currentColor; }
|
||||
.kbb-pin--hard { background: #b50909; color: #fff; border-color: #fff; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
|
||||
19
src/styles/uswds-theme.scss
Normal file
19
src/styles/uswds-theme.scss
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// U.S. Web Design System 3, configured through its own theme settings.
|
||||
// Public Sans for body and headings: full Latin Extended coverage (Polish diacritics).
|
||||
@use 'uswds-core' with (
|
||||
$theme-font-path: '../assets/fonts',
|
||||
$theme-font-type-sans: 'public-sans',
|
||||
$theme-font-role-body: 'sans',
|
||||
$theme-font-role-heading: 'sans',
|
||||
$theme-font-role-ui: 'sans',
|
||||
$theme-font-weight-light: 3,
|
||||
$theme-font-weight-normal: 4,
|
||||
$theme-font-weight-medium: 5,
|
||||
$theme-font-weight-semibold: 6,
|
||||
$theme-font-weight-bold: 7,
|
||||
$theme-show-compile-warnings: false,
|
||||
$theme-image-path: '../../node_modules/@uswds/uswds/dist/img',
|
||||
$theme-global-link-styles: true,
|
||||
$theme-show-notifications: false
|
||||
);
|
||||
@forward 'uswds';
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "vite-plugin-pwa/client", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
57
vite.config.ts
Normal file
57
vite.config.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['uswds/sprite.svg', 'icons/*.png', 'icons/*.svg'],
|
||||
manifest: {
|
||||
name: 'Kraków bez barier',
|
||||
short_name: 'Bez barier',
|
||||
description:
|
||||
'Sprawdź dostępność miejsc i tras w Krakowie dla osób na wózkach i rodziców z wózkami dziecięcymi.',
|
||||
lang: 'pl',
|
||||
start_url: './',
|
||||
scope: './',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#1b1b1b',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,mjs,css,html,svg,png,json,woff2}'],
|
||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
||||
navigateFallback: 'index.html',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'osm-tiles',
|
||||
expiration: { maxEntries: 600, maxAgeSeconds: 60 * 60 * 24 * 14 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
loadPaths: ['node_modules/@uswds/uswds/packages'],
|
||||
quietDeps: true,
|
||||
silenceDeprecations: ['import', 'mixed-decls', 'global-builtin', 'color-functions'],
|
||||
},
|
||||
},
|
||||
},
|
||||
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue