Add Docker files and remove DEMO content.
This commit is contained in:
parent
58950fd0d1
commit
d9927fc6db
40 changed files with 938 additions and 187 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
scripts/.cache
|
||||||
|
*.pdf
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- build: type-check and bundle the PWA (the places snapshot and timetable in public/data are part of the repo) ----
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g pnpm@11.22.0
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ---- serve: static files + the /rt/ proxy for live transit data (see docker/nginx.conf) ----
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
|
||||||
68
README.md
68
README.md
|
|
@ -14,7 +14,9 @@ pnpm dev # or: pnpm build && pnpm preview
|
||||||
pnpm test # unit tests (parsing, verdicts, routing, reach, moderation)
|
pnpm test # unit tests (parsing, verdicts, routing, reach, moderation)
|
||||||
pnpm smoke # end-to-end flows against `pnpm preview` on :4173
|
pnpm smoke # end-to-end flows against `pnpm preview` on :4173
|
||||||
pnpm a11y # axe-core WCAG 2.2 AA scan of every main view
|
pnpm a11y # axe-core WCAG 2.2 AA scan of every main view
|
||||||
pnpm data:build # re-fetch OSM + ZTP data into public/data/kk-snapshot.json
|
pnpm data:build # re-fetch OSM + ZTP data into public/data/kk-snapshot.json and rebuild the timetable (kk-transit.json)
|
||||||
|
pnpm transit:build # only rebuild the timetable from the cached GTFS zips
|
||||||
|
pnpm offline # offline end-to-end check (needs `pnpm preview`): saves the app + map, cuts the network, reloads
|
||||||
```
|
```
|
||||||
|
|
||||||
Demo sign-in has no passwords: pick **Anna** (wheelchair), **Piotr** (stroller) or **Maria** (city
|
Demo sign-in has no passwords: pick **Anna** (wheelchair), **Piotr** (stroller) or **Maria** (city
|
||||||
|
|
@ -67,6 +69,69 @@ Without it the app says "timetable only" and shows no delays; it never pretends
|
||||||
The ZTP timetable has **no low-floor flag** (`wheelchair_accessible` is 0 for all 57k trips in the area) and the stops' `wheelchair_boarding`
|
The ZTP timetable has **no low-floor flag** (`wheelchair_accessible` is 0 for all 57k trips in the area) and the stops' `wheelchair_boarding`
|
||||||
is empty, so the app cannot promise an accessible vehicle and says so.
|
is empty, so the app cannot promise an accessible vehicle and says so.
|
||||||
|
|
||||||
|
## Deploy to production (Docker Compose)
|
||||||
|
|
||||||
|
The app is static files plus one small proxy rule, so production is a single nginx container (`Dockerfile`, `docker/nginx.conf`).
|
||||||
|
There is no database and no server-side state: accounts, settings, reports and decisions live in each user's browser.
|
||||||
|
|
||||||
|
**1. Settings you may want to change first** (compile-time constants in `src/config.ts`):
|
||||||
|
|
||||||
|
| Constant | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `APP_NAME` | The app's name everywhere: header, page title, PWA name, export file names. |
|
||||||
|
| `SHOW_PROTOTYPE_BANNER` | The "prototype" strip above the header. `false` by default. |
|
||||||
|
|
||||||
|
**2. Build and start** (Docker with Compose v2):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <this repo> && cd <repo>
|
||||||
|
docker compose up -d --build # serves http://localhost:8080
|
||||||
|
docker compose ps # "healthy" when /healthz answers
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick another port or bind address with `APP_BIND` (e.g. `APP_BIND=127.0.0.1:8080` in a `.env` file next to `docker-compose.yml`).
|
||||||
|
|
||||||
|
**3. HTTPS is required.** Browsers only allow location (GPS), the service worker (offline mode) and "install app" on `https://`
|
||||||
|
(or `localhost`). Two ways to get it:
|
||||||
|
|
||||||
|
- *Your own reverse proxy* (Traefik, nginx, a load balancer): point it at the `app` container's port 80 and let it terminate TLS.
|
||||||
|
Set `APP_BIND=127.0.0.1:8080` so the container is not reachable directly. Do not strip or cache `/sw.js`.
|
||||||
|
- *Caddy, included* (certificate issued and renewed automatically): create `.env`, point the DNS A/AAAA record of the host at the
|
||||||
|
server, open ports 80 and 443, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf 'DOMAIN=gdziewjade.example.org\nAPP_BIND=127.0.0.1:8080\n' > .env
|
||||||
|
docker compose --profile tls up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. What the container does**
|
||||||
|
|
||||||
|
- Serves the built app with gzip, a single-page-app fallback, and cache rules that make updates reach installed copies:
|
||||||
|
`index.html`, `sw.js`, the manifest and `data/*` are always revalidated; `assets/*` (hashed names) are cached for a year.
|
||||||
|
- Proxies `/rt/*` to `gtfs.ztp.krakow.pl` (live delays; ZTP sends no CORS headers) with a 15 s cache, so a crowd of users makes one
|
||||||
|
upstream request per feed per 15 s. If ZTP is down the app falls back to the timetable and says delays are unavailable.
|
||||||
|
- `GET /healthz` returns `ok` (used by the Compose health check). The filesystem is read-only.
|
||||||
|
|
||||||
|
**5. Updating**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build # rebuilds the image; installed apps pick up the new version on their next visit
|
||||||
|
```
|
||||||
|
|
||||||
|
To refresh the places and timetable data, run `pnpm data:build` on a machine with internet access (Overpass can rate-limit; the script
|
||||||
|
retries and rotates mirrors), commit `public/data/kk-snapshot.json` and `public/data/kk-transit.json`, then rebuild as above.
|
||||||
|
The GTFS feed changes every few days; rebuilding monthly keeps the offline copy reasonably fresh.
|
||||||
|
|
||||||
|
**6. Before going public**
|
||||||
|
|
||||||
|
- Map tiles come from `tile.openstreetmap.org`, whose usage policy discourages heavy and bulk use. The offline map pack downloads about
|
||||||
|
1,000 tiles per new device. For real traffic run your own tile server (or a commercial provider) and change the tile URL in
|
||||||
|
`src/components/MapView.tsx`, `vite.config.ts` (cache rule) and `src/lib/offline.ts`.
|
||||||
|
- Confirm the licence of the ZTP Kraków data (otwartedane.um.krakow.pl) and keep the OpenStreetMap attribution (ODbL).
|
||||||
|
- Sign-in is a demo (no passwords, no server). Put real authentication in front before trusting the city-office panel.
|
||||||
|
|
||||||
## Data and honesty rules
|
## Data and honesty rules
|
||||||
|
|
||||||
- **OpenStreetMap** (ODbL) via Overpass: places, steps, ramps, kerbs, surfaces, benches, toilets.
|
- **OpenStreetMap** (ODbL) via Overpass: places, steps, ramps, kerbs, surfaces, benches, toilets.
|
||||||
|
|
@ -79,7 +144,6 @@ is empty, so the app cannot promise an accessible vehicle and says so.
|
||||||
edit-date data "confirmed".
|
edit-date data "confirmed".
|
||||||
- Absence of data → "No data". User report vs OSM disagreement → "Conflicting data" with both sides shown.
|
- Absence of data → "No data". User report vs OSM disagreement → "Conflicting data" with both sides shown.
|
||||||
Refresh failure → last snapshot date plus a warning (demo switch in *Data → Demo controls*).
|
Refresh failure → last snapshot date plus a warning (demo switch in *Data → Demo controls*).
|
||||||
- Four fictional places and three sample reports are labelled **DEMO** and can be hidden in Settings.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
|
||||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Production: `docker compose up -d --build` (with HTTPS: `docker compose --profile tls up -d --build`, see README)
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: gdziewjade:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
# host:container. Behind Caddy or another proxy use APP_BIND=127.0.0.1:8080 so only the proxy can reach it.
|
||||||
|
ports:
|
||||||
|
- "${APP_BIND:-8080}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /var/run
|
||||||
|
- /tmp
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
profiles: ["tls"]
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
environment:
|
||||||
|
DOMAIN: ${DOMAIN:-localhost} # set DOMAIN=gdziewjade.example.org in .env for a real certificate
|
||||||
|
volumes:
|
||||||
|
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
5
docker/Caddyfile
Normal file
5
docker/Caddyfile
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Optional TLS front (compose profile "tls"): Caddy gets and renews the certificate for $DOMAIN by itself.
|
||||||
|
# Geolocation, the service worker and "install app" all require HTTPS outside localhost.
|
||||||
|
{$DOMAIN} {
|
||||||
|
reverse_proxy app:80
|
||||||
|
}
|
||||||
56
docker/nginx.conf
Normal file
56
docker/nginx.conf
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Cache for live GTFS-Realtime answers: the feeds change about every 30 s, so 15 s protects ZTP from a flood of identical requests.
|
||||||
|
proxy_cache_path /var/cache/nginx/rt levels=1 keys_zone=rt:1m max_size=20m inactive=2m use_temp_path=off;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 512;
|
||||||
|
gzip_types application/json application/javascript text/javascript text/css image/svg+xml application/manifest+json application/wasm;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Live transit delays. ZTP sends no CORS headers, so the browser cannot call it directly; the app asks for /rt/* on its own origin.
|
||||||
|
location /rt/ {
|
||||||
|
resolver 127.0.0.11 1.1.1.1 valid=300s ipv6=off; # resolved at request time: a ZTP outage must not stop nginx from starting
|
||||||
|
set $ztp gtfs.ztp.krakow.pl;
|
||||||
|
rewrite ^/rt/(.*)$ /$1 break;
|
||||||
|
proxy_pass https://$ztp;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Host $ztp;
|
||||||
|
proxy_connect_timeout 3s;
|
||||||
|
proxy_read_timeout 6s;
|
||||||
|
proxy_cache rt;
|
||||||
|
proxy_cache_valid 200 15s;
|
||||||
|
proxy_ignore_headers Cache-Control Expires Set-Cookie; # cache by our rule below, whatever the upstream says
|
||||||
|
proxy_cache_use_stale error timeout updating;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The service worker and the app shell must always be revalidated, or an update would never reach installed apps.
|
||||||
|
location = /sw.js { add_header Cache-Control "no-cache"; try_files $uri =404; }
|
||||||
|
location = /index.html { add_header Cache-Control "no-cache"; }
|
||||||
|
location = /manifest.webmanifest { add_header Cache-Control "no-cache"; types { application/manifest+json webmanifest; } }
|
||||||
|
|
||||||
|
# Hashed build output never changes under the same name.
|
||||||
|
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; }
|
||||||
|
location ~ ^/workbox-.*\.js$ { add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; }
|
||||||
|
|
||||||
|
# Data files keep their names: revalidate (the service worker precaches them by content hash).
|
||||||
|
location /data/ { add_header Cache-Control "no-cache"; try_files $uri =404; }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
index.html
19
index.html
|
|
@ -8,9 +8,26 @@
|
||||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
||||||
<title>%APP_NAME%</title>
|
<title>%APP_NAME%</title>
|
||||||
|
<style>
|
||||||
|
/* shown from the first byte of HTML until the app mounts and replaces #root: the JS bundle is the slow part on a first visit */
|
||||||
|
html, body { margin: 0; height: 100%; background: #fff; }
|
||||||
|
.boot { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; height: 100%; font-family: system-ui, sans-serif; color: #1a4480; }
|
||||||
|
.boot svg { width: 4rem; height: 4rem; }
|
||||||
|
.boot strong { font-size: 1.6rem; }
|
||||||
|
.boot i { position: relative; display: block; width: 14rem; max-width: 70vw; height: 0.5rem; overflow: hidden; border-radius: 0.25rem; background: #dfe1e2; }
|
||||||
|
.boot i::after { content: ''; position: absolute; inset: 0 auto 0 0; width: 35%; border-radius: 0.25rem; background: #005ea2; animation: boot 1.1s ease-in-out infinite; }
|
||||||
|
@keyframes boot { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .boot i::after { animation: none; width: 100%; opacity: 0.6; } }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root">
|
||||||
|
<div class="boot" role="status" aria-live="polite">
|
||||||
|
<svg viewBox="0 0 512 512" aria-hidden="true"><rect width="512" height="512" rx="92" fill="#005ea2"/><g fill="#fff" transform="translate(97.28 97.28) scale(13.2267)"><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>
|
||||||
|
<strong>%APP_NAME%</strong>
|
||||||
|
<i></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<noscript>Ta aplikacja wymaga JavaScriptu. / This app requires JavaScript.</noscript>
|
<noscript>Ta aplikacja wymaga JavaScriptu. / This app requires JavaScript.</noscript>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"icons": "node scripts/make-icons.mjs",
|
"icons": "node scripts/make-icons.mjs",
|
||||||
"preview:https": "HTTPS=1 vite preview --host --port 4443",
|
"preview:https": "HTTPS=1 vite preview --host --port 4443",
|
||||||
"dev:https": "HTTPS=1 vite --host --port 5443",
|
"dev:https": "HTTPS=1 vite --host --port 5443",
|
||||||
"sensors": "node scripts/sensors-e2e.mjs"
|
"sensors": "node scripts/sensors-e2e.mjs",
|
||||||
|
"offline": "node scripts/offline-e2e.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@huggingface/transformers": "^4.3.0",
|
"@huggingface/transformers": "^4.3.0",
|
||||||
|
|
|
||||||
50
scripts/offline-e2e.mjs
Normal file
50
scripts/offline-e2e.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Offline end-to-end check against `pnpm preview` (production build, service worker on). Run: node scripts/offline-e2e.mjs
|
||||||
|
// 1) first visit saves the app + the map pack, 2) with the network cut the app reloads, shows the offline notice and still routes.
|
||||||
|
import { chromium } from 'playwright-core';
|
||||||
|
const URL = process.env.URL ?? 'http://localhost:4173/';
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: 'pl-PL', isMobile: true, hasTouch: true });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
let step = 0;
|
||||||
|
const ok = (cond, msg) => { step++; if (!cond) { console.error(`FAIL ${step}: ${msg}`); process.exitCode = 1; } else console.log(`ok ${step}: ${msg}`); };
|
||||||
|
const caches = () => page.evaluate(async () => { const o = {}; for (const n of await window.caches.keys()) o[n] = (await (await window.caches.open(n)).keys()).length; return o; });
|
||||||
|
|
||||||
|
await page.goto(URL);
|
||||||
|
await page.click('text=Zaloguj jako Anna Kowalska');
|
||||||
|
await page.waitForSelector('#q', { state: 'attached', timeout: 60_000 });
|
||||||
|
ok(await page.evaluate(() => !!navigator.serviceWorker.controller), 'service worker controls the page');
|
||||||
|
let tiles = 0;
|
||||||
|
for (let i = 0; i < 150 && tiles < 1000; i++) { tiles = (await caches())['osm-tiles'] ?? 0; if (tiles < 1000) await page.waitForTimeout(1000); }
|
||||||
|
ok(tiles >= 1000, `offline map pack saved (${tiles} tiles)`);
|
||||||
|
let rec = null;
|
||||||
|
for (let i = 0; i < 60 && !rec; i++) { rec = JSON.parse((await page.evaluate(() => localStorage.getItem('kbb.offline'))) ?? 'null'); if (!rec) await page.waitForTimeout(500); }
|
||||||
|
ok(rec && rec.failed === 0 && rec.tiles >= 1000, 'pack record stored');
|
||||||
|
|
||||||
|
await ctx.setOffline(true);
|
||||||
|
await page.waitForSelector('.kbb-banner--warning', { timeout: 5000 });
|
||||||
|
ok(/Trasy na żywo/.test(await page.locator('.kbb-banner--warning').innerText()), 'offline notice says live routes are unavailable');
|
||||||
|
await page.click('.kbb-banner--warning .kbb-banner__close');
|
||||||
|
ok((await page.locator('.kbb-banner--warning').count()) === 0, 'offline notice can be dismissed');
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#q', { state: 'attached', timeout: 30_000 });
|
||||||
|
ok(true, 'app reloads and opens with no network');
|
||||||
|
ok((await page.locator('.kbb-banner--warning').count()) === 1, 'offline notice shown again after the reload');
|
||||||
|
|
||||||
|
await page.click('nav >> text=Trasa');
|
||||||
|
await page.fill('#route-from', 'Sukiennice');
|
||||||
|
await page.fill('#route-to', 'Zamek Królewski na Wawelu');
|
||||||
|
await page.click('button[type=submit]:has-text("Wyznacz")');
|
||||||
|
await page.waitForSelector('.kbb-modes', { timeout: 10_000 });
|
||||||
|
ok(true, 'walking route is computed offline');
|
||||||
|
await page.click('.kbb-mode:has-text("Komunikacja")');
|
||||||
|
// scoped to the planner: the offline banner also says "Brak połączenia", and the loader shares the #tr-title heading
|
||||||
|
const planner = page.locator('section[aria-labelledby=tr-title]');
|
||||||
|
await planner.locator('.kbb-jhead, .usa-alert').first().waitFor({ timeout: 20_000 });
|
||||||
|
const note = await page.locator('#tr-title + p').innerText();
|
||||||
|
ok(/Według rozkładu/.test(note), `transit planner answers from the saved timetable and says delays are unavailable ("${note.slice(0, 60)}")`);
|
||||||
|
|
||||||
|
await ctx.setOffline(false);
|
||||||
|
await page.waitForSelector('.kbb-banner--success', { timeout: 5000 });
|
||||||
|
ok(true, 'back-online notice appears');
|
||||||
|
await browser.close();
|
||||||
|
|
@ -9,7 +9,7 @@ async function run(kind) {
|
||||||
const page = await browser.newPage({ viewport: { width: 1000, height: 900 }, locale: 'pl-PL' });
|
const page = await browser.newPage({ viewport: { width: 1000, height: 900 }, locale: 'pl-PL' });
|
||||||
await page.goto(URL, { waitUntil: 'networkidle' });
|
await page.goto(URL, { waitUntil: 'networkidle' });
|
||||||
await page.click('text=Zaloguj jako Anna Kowalska');
|
await page.click('text=Zaloguj jako Anna Kowalska');
|
||||||
await page.fill('#q', 'Pracownia Przykładowa');
|
await page.fill('#q', 'Sukiennice');
|
||||||
await page.click('button[id^=place-]');
|
await page.click('button[id^=place-]');
|
||||||
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
||||||
await page.selectOption('#rep-attr', 'ramp');
|
await page.selectOption('#rep-attr', 'ramp');
|
||||||
|
|
@ -35,7 +35,7 @@ await run('motion');
|
||||||
const page = await browser.newPage({ locale: 'pl-PL' });
|
const page = await browser.newPage({ locale: 'pl-PL' });
|
||||||
await page.goto(URL, { waitUntil: 'networkidle' });
|
await page.goto(URL, { waitUntil: 'networkidle' });
|
||||||
await page.click('text=Zaloguj jako Anna Kowalska');
|
await page.click('text=Zaloguj jako Anna Kowalska');
|
||||||
await page.fill('#q', 'Pracownia Przykładowa');
|
await page.fill('#q', 'Sukiennice');
|
||||||
await page.click('button[id^=place-]');
|
await page.click('button[id^=place-]');
|
||||||
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
||||||
await page.selectOption('#rep-attr', 'ramp');
|
await page.selectOption('#rep-attr', 'ramp');
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ await page.click('text=Zaloguj jako Anna Kowalska');
|
||||||
await page.waitForSelector('#q');
|
await page.waitForSelector('#q');
|
||||||
const total = Number(((await page.locator('text=Wyniki:').first().innerText()).match(/\d+/) ?? ['0'])[0]);
|
const total = Number(((await page.locator('text=Wyniki:').first().innerText()).match(/\d+/) ?? ['0'])[0]);
|
||||||
ok(total > 4000, `places list loaded (${total} places)`);
|
ok(total > 4000, `places list loaded (${total} places)`);
|
||||||
await page.fill('#q', 'Pracownia Przykładowa');
|
await page.fill('#q', 'Sukiennice');
|
||||||
await page.click('button[id^=place-]');
|
await page.click('button[id^=place-]');
|
||||||
await page.waitForSelector('text=Szczegóły dostępności');
|
await page.waitForSelector('text=Szczegóły dostępności');
|
||||||
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
||||||
|
|
@ -38,7 +38,7 @@ await page.click('text=Wyloguj');
|
||||||
await page.click('text=Zaloguj jako Maria Wiśniewska');
|
await page.click('text=Zaloguj jako Maria Wiśniewska');
|
||||||
await page.waitForSelector('[role=tablist]');
|
await page.waitForSelector('[role=tablist]');
|
||||||
const pending = await page.locator('button:has-text("Zatwierdź")').count();
|
const pending = await page.locator('button:has-text("Zatwierdź")').count();
|
||||||
ok(pending >= 3, `moderation queue (first tab) has ${pending} pending reports`);
|
ok(pending >= 1, `moderation queue (first tab) shows the report filed above (${pending} pending)`);
|
||||||
await page.locator('button:has-text("Zatwierdź")').first().click();
|
await page.locator('button:has-text("Zatwierdź")').first().click();
|
||||||
ok((await page.locator('button:has-text("Zatwierdź")').count()) === pending - 1, 'accepting removes it from the queue');
|
ok((await page.locator('button:has-text("Zatwierdź")').count()) === pending - 1, 'accepting removes it from the queue');
|
||||||
await page.click('button:has-text("Cofnij"):not(:has-text("decyzję"))');
|
await page.click('button:has-text("Cofnij"):not(:has-text("decyzję"))');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const errors = [];
|
||||||
page.on('pageerror', (e) => errors.push(e.message));
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
await page.goto(URL, { waitUntil: 'networkidle' });
|
await page.goto(URL, { waitUntil: 'networkidle' });
|
||||||
await page.click('text=Zaloguj jako Anna Kowalska');
|
await page.click('text=Zaloguj jako Anna Kowalska');
|
||||||
await page.fill('#q', 'Pracownia Przykładowa');
|
await page.fill('#q', 'Sukiennice');
|
||||||
await page.click('button[id^=place-]');
|
await page.click('button[id^=place-]');
|
||||||
await page.waitForSelector('text=Szczegóły dostępności');
|
await page.waitForSelector('text=Szczegóły dostępności');
|
||||||
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
await page.click('text=Popraw lub uzupełnij >> nth=0');
|
||||||
|
|
|
||||||
41
src/App.tsx
41
src/App.tsx
|
|
@ -15,7 +15,6 @@ import { Alert, Icon } from './components/ui';
|
||||||
import { assessPlace, buildIndex } from './lib/assess';
|
import { assessPlace, buildIndex } from './lib/assess';
|
||||||
import { findAccount, userKey, type Account } from './lib/auth';
|
import { findAccount, userKey, type Account } from './lib/auth';
|
||||||
import { computeHotspots, computeStats, download, toCsv, toGeoJson, type Hotspot } from './lib/civic';
|
import { computeHotspots, computeStats, download, toCsv, toGeoJson, type Hotspot } from './lib/civic';
|
||||||
import { DEMO_PLACES, DEMO_REPORTS } from './lib/demo';
|
|
||||||
import type { LatLon } from './lib/geo';
|
import type { LatLon } from './lib/geo';
|
||||||
import { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n';
|
import { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n';
|
||||||
import { buildLabels } from './lib/labels';
|
import { buildLabels } from './lib/labels';
|
||||||
|
|
@ -29,7 +28,11 @@ import { NoCreditsError, vetReport } from './lib/vet';
|
||||||
import { GuideBanner } from './components/GuideBanner';
|
import { GuideBanner } from './components/GuideBanner';
|
||||||
import { findAlternatives } from './lib/route';
|
import { findAlternatives } from './lib/route';
|
||||||
import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings';
|
import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings';
|
||||||
import { APP_SLUG } from './config';
|
import { APP_SLUG, SHOW_PROTOTYPE_BANNER } from './config';
|
||||||
|
import { AppLoader, type LoadState } from './components/AppLoader';
|
||||||
|
import { StatusBanners } from './components/StatusBanners';
|
||||||
|
import { fetchJson } from './lib/fetchProgress';
|
||||||
|
import { useOfflinePack } from './lib/useOffline';
|
||||||
import { useJourneys } from './lib/useTransit';
|
import { useJourneys } from './lib/useTransit';
|
||||||
import { loadTransitIndex } from './lib/transit';
|
import { loadTransitIndex } from './lib/transit';
|
||||||
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
|
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
|
||||||
|
|
@ -93,22 +96,28 @@ export default function App() {
|
||||||
// ---- data ----
|
// ---- data ----
|
||||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||||
const [loadError, setLoadError] = useState(false);
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [load, setLoad] = useState<LoadState>({ stage: 'download', pct: 0 });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
fetch(`${import.meta.env.BASE_URL}data/kk-snapshot.json`, { signal: ctrl.signal })
|
fetchJson<Snapshot>(`${import.meta.env.BASE_URL}data/kk-snapshot.json`, (f) => setLoad({ stage: 'download', pct: Math.round(f * 75) }), ctrl.signal, typeof __DATA_BYTES__ === 'undefined' ? 0 : __DATA_BYTES__.snapshot)
|
||||||
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json() as Promise<Snapshot>; })
|
.then(async (s) => {
|
||||||
.then(setSnap)
|
setLoad({ stage: 'prepare', pct: 85 });
|
||||||
|
await new Promise((r) => setTimeout(r, 0)); // let the "preparing" step paint before the synchronous index / graph build
|
||||||
|
setSnap(s);
|
||||||
|
})
|
||||||
.catch((e: unknown) => { if (!(e instanceof DOMException && e.name === 'AbortError')) setLoadError(true); });
|
.catch((e: unknown) => { if (!(e instanceof DOMException && e.name === 'AbortError')) setLoadError(true); });
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
// the offline map pack (tiles) is saved once someone is signed in and the data is here
|
||||||
|
const pack = useOfflinePack(snap?.meta.bbox ?? null, !!account && !!snap);
|
||||||
|
|
||||||
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
|
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
|
||||||
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
|
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
|
||||||
const [overrides, setOverrides] = useState<Record<string, Place>>({});
|
const [overrides, setOverrides] = useState<Record<string, Place>>({});
|
||||||
const realPlaces = useMemo<Place[]>(() => (snap ? snap.places.map((p) => withCityOffer(overrides[p.id] ?? p)) : []), [snap, overrides]);
|
const realPlaces = useMemo<Place[]>(() => (snap ? snap.places.map((p) => withCityOffer(overrides[p.id] ?? p)) : []), [snap, overrides]);
|
||||||
const places = useMemo<Place[]>(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]);
|
const places = realPlaces;
|
||||||
const reports = useMemo<UserReport[]>(
|
const reports = useMemo<UserReport[]>(
|
||||||
() => [...DEMO_REPORTS, ...userReports].map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)),
|
() => userReports.map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)),
|
||||||
[userReports, decisions],
|
[userReports, decisions],
|
||||||
);
|
);
|
||||||
const assessments = useMemo(() => {
|
const assessments = useMemo(() => {
|
||||||
|
|
@ -117,7 +126,7 @@ export default function App() {
|
||||||
return m;
|
return m;
|
||||||
}, [places, profile, index, reports]);
|
}, [places, profile, index, reports]);
|
||||||
const labels = useMemo(() => buildLabels(places), [places]);
|
const labels = useMemo(() => buildLabels(places), [places]);
|
||||||
const placeById = useMemo(() => new Map([...DEMO_PLACES, ...realPlaces].map((p) => [p.id, p])), [realPlaces]);
|
const placeById = useMemo(() => new Map(realPlaces.map((p) => [p.id, p])), [realPlaces]);
|
||||||
|
|
||||||
const previewCounts = useCallback((p: Profile): Record<Verdict, number> => {
|
const previewCounts = useCallback((p: Profile): Record<Verdict, number> => {
|
||||||
const c: Record<Verdict, number> = { match: 0, partial: 0, conflict: 0, incomplete: 0, fail: 0 };
|
const c: Record<Verdict, number> = { match: 0, partial: 0, conflict: 0, incomplete: 0, fail: 0 };
|
||||||
|
|
@ -204,6 +213,10 @@ export default function App() {
|
||||||
...(photo ? { photoId: id } : {}), vetting: { state: 'running' },
|
...(photo ? { photoId: id } : {}), vetting: { state: 'running' },
|
||||||
};
|
};
|
||||||
if (photo) await putPhoto(id, photo.dataUrl);
|
if (photo) await putPhoto(id, photo.dataUrl);
|
||||||
|
if (photo && !navigator.onLine) {
|
||||||
|
setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'offline' } }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// the on-device model is ~90 MB: ask before downloading it on a metered link
|
// the on-device model is ~90 MB: ask before downloading it on a metered link
|
||||||
if (photo && isMetered() && !window.confirm(t('vet.metered'))) {
|
if (photo && isMetered() && !window.confirm(t('vet.metered'))) {
|
||||||
setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'skipped' } }]);
|
setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'skipped' } }]);
|
||||||
|
|
@ -342,7 +355,7 @@ export default function App() {
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return (
|
return (
|
||||||
<I18nContext.Provider value={i18n}>
|
<I18nContext.Provider value={i18n}>
|
||||||
<div className="kbb-app"><main id="main" className="kbb-main"><LoginView onPick={signIn} lang={loginLang} onLang={setLoginLang} /></main></div>
|
<div className="kbb-app"><main id="main" className="kbb-main"><LoginView onPick={signIn} lang={loginLang} onLang={setLoginLang} loading={snap || loadError ? null : load} /></main></div>
|
||||||
</I18nContext.Provider>
|
</I18nContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -352,6 +365,7 @@ export default function App() {
|
||||||
<div className={`kbb-app ${guiding && view === 'route' ? 'is-guiding' : ''}`}>
|
<div className={`kbb-app ${guiding && view === 'route' ? 'is-guiding' : ''}`}>
|
||||||
<a className="usa-skipnav" href="#main">{t('skip')}</a>
|
<a className="usa-skipnav" href="#main">{t('skip')}</a>
|
||||||
|
|
||||||
|
{SHOW_PROTOTYPE_BANNER && (
|
||||||
<section className="kbb-proto usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
|
<section className="kbb-proto usa-site-alert usa-site-alert--info usa-site-alert--slim" aria-label={t('banner.proto')}>
|
||||||
<div className="usa-alert">
|
<div className="usa-alert">
|
||||||
<div className="usa-alert__body">
|
<div className="usa-alert__body">
|
||||||
|
|
@ -362,6 +376,7 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<header className="bg-white border-bottom-1px border-base-lighter">
|
<header className="bg-white border-bottom-1px border-base-lighter">
|
||||||
<div className="kbb-head">
|
<div className="kbb-head">
|
||||||
|
|
@ -404,6 +419,8 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<StatusBanners online={online} dataDate={snap?.meta.fetchedAt.slice(0, 10) ?? new Date().toISOString().slice(0, 10)} pack={pack} />
|
||||||
|
|
||||||
{guiding && view === 'route' && route && route.status !== 'none' && (
|
{guiding && view === 'route' && route && route.status !== 'none' && (
|
||||||
<GuideBanner result={route} onStop={() => setGuiding(false)} onPos={setUserPos} onReroute={reroute} />
|
<GuideBanner result={route} onStop={() => setGuiding(false)} onPos={setUserPos} onReroute={reroute} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -411,7 +428,7 @@ export default function App() {
|
||||||
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${sheet}` : ''}`}>
|
<main id="main" ref={mainRef} tabIndex={-1} className={`kbb-main ${split ? `kbb-main--split is-${sheet}` : ''}`}>
|
||||||
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
|
{loadError && <div className="grid-container padding-y-4"><Alert kind="error" role="alert">{t('common.loadError')}</Alert></div>}
|
||||||
{!loadError && !ready && !needsProfile && view !== 'profile' && (
|
{!loadError && !ready && !needsProfile && view !== 'profile' && (
|
||||||
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
|
<AppLoader load={load} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(view === 'profile' || needsProfile) && (
|
{(view === 'profile' || needsProfile) && (
|
||||||
|
|
@ -436,7 +453,7 @@ export default function App() {
|
||||||
{view === 'about' && !needsProfile && snap && (
|
{view === 'about' && !needsProfile && snap && (
|
||||||
<AboutView
|
<AboutView
|
||||||
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts} cityData={snap.meta.cityData}
|
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts} cityData={snap.meta.cityData}
|
||||||
simulateOutage={simulateOutage} onSimulate={setSimulateOutage} onReset={reset} resetDone={resetDone}
|
simulateOutage={simulateOutage} onSimulate={setSimulateOutage} onReset={reset} resetDone={resetDone} pack={pack} online={online}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -476,6 +493,7 @@ export default function App() {
|
||||||
reports={reports}
|
reports={reports}
|
||||||
snapshotDate={snap.meta.fetchedAt.slice(0, 10)}
|
snapshotDate={snap.meta.fetchedAt.slice(0, 10)}
|
||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
|
online={online}
|
||||||
saved={favSet.has(selected.id)}
|
saved={favSet.has(selected.id)}
|
||||||
onToggleSave={() => setFavorites((f) => (f.includes(selected.id) ? f.filter((x) => x !== selected.id) : [...f, selected.id]))}
|
onToggleSave={() => setFavorites((f) => (f.includes(selected.id) ? f.filter((x) => x !== selected.id) : [...f, selected.id]))}
|
||||||
onCard={() => setCardId(selected.id)}
|
onCard={() => setCardId(selected.id)}
|
||||||
|
|
@ -521,7 +539,6 @@ export default function App() {
|
||||||
focus={focus}
|
focus={focus}
|
||||||
visible={sheet}
|
visible={sheet}
|
||||||
bbox={snap.meta.bbox}
|
bbox={snap.meta.bbox}
|
||||||
online={online}
|
|
||||||
basemap={basemap}
|
basemap={basemap}
|
||||||
onBasemap={setBasemap}
|
onBasemap={setBasemap}
|
||||||
userPos={view === 'route' && guiding ? userPos : null}
|
userPos={view === 'route' && guiding ? userPos : null}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } from '../lib/i18n';
|
||||||
import type { Confidence } from '../lib/types';
|
import type { Confidence } from '../lib/types';
|
||||||
|
import type { PackState } from '../lib/useOffline';
|
||||||
import { Alert, ConfidenceTag } from './ui';
|
import { Alert, ConfidenceTag } from './ui';
|
||||||
|
|
||||||
function AccordionSection({ id, title, children, open, toggle }: { id: string; title: string; children: ReactNode; open: string; toggle: (id: string) => void }) {
|
function AccordionSection({ id, title, children, open, toggle }: { id: string; title: string; children: ReactNode; open: string; toggle: (id: string) => void }) {
|
||||||
|
|
@ -26,9 +27,11 @@ interface Props {
|
||||||
onSimulate: (v: boolean) => void;
|
onSimulate: (v: boolean) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
resetDone: boolean;
|
resetDone: boolean;
|
||||||
|
pack: PackState;
|
||||||
|
online: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AboutView({ snapshotDate, counts, cityData, simulateOutage, onSimulate, onReset, resetDone }: Props) {
|
export function AboutView({ snapshotDate, counts, cityData, simulateOutage, onSimulate, onReset, resetDone, pack, online }: Props) {
|
||||||
const { t, date } = useI18n();
|
const { t, date } = useI18n();
|
||||||
const [open, setOpen] = useState<string>('data');
|
const [open, setOpen] = useState<string>('data');
|
||||||
const toggle = (id: string) => setOpen((cur) => (cur === id ? '' : id));
|
const toggle = (id: string) => setOpen((cur) => (cur === id ? '' : id));
|
||||||
|
|
@ -41,6 +44,28 @@ export function AboutView({ snapshotDate, counts, cityData, simulateOutage, onSi
|
||||||
{t('banner.data', { date: date(snapshotDate) })} {Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ')}
|
{t('banner.data', { date: date(snapshotDate) })} {Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="pack-title" className="kbb-offcard">
|
||||||
|
<h2 id="pack-title" className="margin-top-0 font-sans-md">{t('pack.title')}</h2>
|
||||||
|
<p className="margin-top-0">
|
||||||
|
{pack.phase === 'unsupported' ? t('pack.status.unsupported')
|
||||||
|
: pack.phase === 'downloading' ? t('pack.status.downloading')
|
||||||
|
: pack.record ? t('pack.status.ready', { tiles: pack.record.tiles, date: date(pack.record.at.slice(0, 10)) })
|
||||||
|
: t('pack.status.none')}
|
||||||
|
</p>
|
||||||
|
{pack.phase === 'downloading' && (
|
||||||
|
<progress className="kbb-progress kbb-progress--thin" value={pack.progress.done} max={Math.max(1, pack.progress.total)} aria-label={t('pack.title')} />
|
||||||
|
)}
|
||||||
|
{pack.phase !== 'unsupported' && pack.phase !== 'downloading' && (
|
||||||
|
<div className="kbb-offcard__actions">
|
||||||
|
<button type="button" className="usa-button margin-0" onClick={pack.start} disabled={!online}>
|
||||||
|
{t(pack.record ? 'pack.update' : 'pack.download')}{pack.record ? '' : ` (~${pack.estimateMb} MB)`}
|
||||||
|
</button>
|
||||||
|
{pack.record && <button type="button" className="usa-button usa-button--outline margin-0" onClick={pack.remove}>{t('pack.remove')}</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pack.record && <p className="font-sans-3xs text-base-dark margin-bottom-0">{t('pack.removeNote')}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="usa-accordion usa-accordion--bordered margin-top-3">
|
<div className="usa-accordion usa-accordion--bordered margin-top-3">
|
||||||
<AccordionSection open={open} toggle={toggle} id="data" title={t('about.data')}>
|
<AccordionSection open={open} toggle={toggle} id="data" title={t('about.data')}>
|
||||||
<p>{t('about.data.body1')}</p>
|
<p>{t('about.data.body1')}</p>
|
||||||
|
|
|
||||||
18
src/components/AppLoader.tsx
Normal file
18
src/components/AppLoader.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useI18n, type Key } from '../lib/i18n';
|
||||||
|
import { BrandMark } from './BrandMark';
|
||||||
|
|
||||||
|
export interface LoadState { stage: 'download' | 'prepare'; pct: number }
|
||||||
|
|
||||||
|
/** First-load splash: what is happening and how far along it is. */
|
||||||
|
export function AppLoader({ load }: { load: LoadState }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="kbb-splash" role="status" aria-live="polite">
|
||||||
|
<BrandMark size={64} />
|
||||||
|
<h1 className="kbb-splash__title">{t('app.name')}</h1>
|
||||||
|
<progress className="kbb-progress" value={load.pct} max={100} aria-label={t('load.title')}>{load.pct}%</progress>
|
||||||
|
<p className="kbb-splash__stage"><strong>{t(`load.${load.stage}` as Key)}</strong> {load.pct}%</p>
|
||||||
|
<p className="font-sans-xs text-base-dark">{t('load.note')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ export function DashQueue({ reports, placeById, onDecide, onUndo, onOpenPlace }:
|
||||||
<strong>{t(`dash.queue.status.${r.status === 'accepted' ? 'accepted' : 'rejected'}` as Key)}</strong>
|
<strong>{t(`dash.queue.status.${r.status === 'accepted' ? 'accepted' : 'rejected'}` as Key)}</strong>
|
||||||
{r.reviewedBy ? ` (${r.reviewedBy})` : ''}
|
{r.reviewedBy ? ` (${r.reviewedBy})` : ''}
|
||||||
</span>
|
</span>
|
||||||
{!r.demo && <button type="button" className="usa-button usa-button--unstyled" onClick={() => onUndo([r.id])}>{t('dash.q.revert')}</button>}
|
<button type="button" className="usa-button usa-button--unstyled" onClick={() => onUndo([r.id])}>{t('dash.q.revert')}</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { ACCOUNTS, GUEST, type Account } from '../lib/auth';
|
||||||
import { useI18n, type Key } from '../lib/i18n';
|
import { useI18n, type Key } from '../lib/i18n';
|
||||||
import type { Lang } from '../lib/types';
|
import type { Lang } from '../lib/types';
|
||||||
import { LangToggle } from './LangToggle';
|
import { LangToggle } from './LangToggle';
|
||||||
|
import type { LoadState } from './AppLoader';
|
||||||
import { Alert, Icon } from './ui';
|
import { Alert, Icon } from './ui';
|
||||||
|
|
||||||
export function LoginView({ onPick, lang, onLang }: { onPick: (a: Account) => void; lang: Lang; onLang: (l: Lang) => void }) {
|
export function LoginView({ onPick, lang, onLang, loading }: { onPick: (a: Account) => void; lang: Lang; onLang: (l: Lang) => void; loading?: LoadState | null }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<div className="grid-container padding-y-4 kbb-narrow">
|
<div className="grid-container padding-y-4 kbb-narrow">
|
||||||
|
|
@ -17,6 +18,12 @@ export function LoginView({ onPick, lang, onLang }: { onPick: (a: Account) => vo
|
||||||
<h1 className="margin-top-4">{t('auth.title')}</h1>
|
<h1 className="margin-top-4">{t('auth.title')}</h1>
|
||||||
<p className="usa-intro">{t('app.tagline')}</p>
|
<p className="usa-intro">{t('app.tagline')}</p>
|
||||||
<Alert kind="info" slim>{t('auth.intro')}</Alert>
|
<Alert kind="info" slim>{t('auth.intro')}</Alert>
|
||||||
|
{loading && (
|
||||||
|
<div className="kbb-loader margin-top-2" role="status">
|
||||||
|
<p className="margin-y-05"><strong>{t(loading.stage === 'download' ? 'load.download' : 'load.prepare')}</strong> {loading.pct}%</p>
|
||||||
|
<progress className="kbb-progress kbb-progress--thin" value={loading.pct} max={100} aria-label={t('load.title')}>{loading.pct}%</progress>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className="usa-card-group margin-top-3 padding-0">
|
<ul className="usa-card-group margin-top-3 padding-0">
|
||||||
{ACCOUNTS.map((a) => (
|
{ACCOUNTS.map((a) => (
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ interface Props {
|
||||||
/** Changes whenever the layout around the map changes (bottom sheet moved); triggers a re-measure. */
|
/** Changes whenever the layout around the map changes (bottom sheet moved); triggers a re-measure. */
|
||||||
visible: unknown;
|
visible: unknown;
|
||||||
bbox: [number, number, number, number];
|
bbox: [number, number, number, number];
|
||||||
online: boolean;
|
|
||||||
basemap: 'map' | 'satellite';
|
basemap: 'map' | 'satellite';
|
||||||
onBasemap: (b: 'map' | 'satellite') => void;
|
onBasemap: (b: 'map' | 'satellite') => void;
|
||||||
userPos: LatLon | null;
|
userPos: LatLon | null;
|
||||||
|
|
@ -97,7 +96,7 @@ export interface JourneyOverlay {
|
||||||
stops: { at: LatLon; label: string }[];
|
stops: { at: LatLon; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey, sheet }: Props) {
|
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey, sheet }: Props) {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<MLMap | null>(null);
|
const mapRef = useRef<MLMap | null>(null);
|
||||||
|
|
@ -113,6 +112,9 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
|
||||||
rmRef.current = reduceMotion;
|
rmRef.current = reduceMotion;
|
||||||
const reduced = () => rmRef.current || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reduced = () => rmRef.current || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
// first paint of the map: full-area loader; afterwards a thin bar whenever tiles are still arriving
|
||||||
|
const [firstPaint, setFirstPaint] = useState(true);
|
||||||
|
const [tilesLoading, setTilesLoading] = useState(false);
|
||||||
|
|
||||||
// The sheet hides the lower part of the map. Camera padding makes "centre" mean the middle of the part you can see, so a route fit
|
// The sheet hides the lower part of the map. Camera padding makes "centre" mean the middle of the part you can see, so a route fit
|
||||||
// or a focused place lands there. The inset comes from the sheet's target size (not from the DOM), so it is right while the sheet
|
// or a focused place lands there. The inset comes from the sheet's target size (not from the DOM), so it is right while the sheet
|
||||||
|
|
@ -212,10 +214,16 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
|
||||||
setReady(true);
|
setReady(true);
|
||||||
});
|
});
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
// offline, tiles outside the saved pack fail to load: expected, not worth a console error each
|
||||||
|
map.on('error', () => undefined);
|
||||||
|
let busyTimer = 0;
|
||||||
|
map.on('dataloading', () => { window.clearTimeout(busyTimer); busyTimer = window.setTimeout(() => setTilesLoading(true), 250); });
|
||||||
|
const splashGuard = window.setTimeout(() => setFirstPaint(false), 8000); // never leave the loader up if 'idle' does not come
|
||||||
|
map.on('idle', () => { window.clearTimeout(busyTimer); setTilesLoading(false); setFirstPaint(false); });
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => { map.resize(); if (!map.isMoving()) syncInsetRef.current(map); });
|
const ro = new ResizeObserver(() => { map.resize(); if (!map.isMoving()) syncInsetRef.current(map); });
|
||||||
ro.observe(ref.current);
|
ro.observe(ref.current);
|
||||||
return () => { ro.disconnect(); markers.current.forEach((m) => m.remove()); map.remove(); mapRef.current = null; setReady(false); };
|
return () => { window.clearTimeout(splashGuard); window.clearTimeout(busyTimer); ro.disconnect(); markers.current.forEach((m) => m.remove()); map.remove(); mapRef.current = null; setReady(false); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -446,9 +454,12 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
|
||||||
<button type="button" aria-pressed={basemap === 'map'} onClick={() => onBasemap('map')}>{t('map.layer.map')}</button>
|
<button type="button" aria-pressed={basemap === 'map'} onClick={() => onBasemap('map')}>{t('map.layer.map')}</button>
|
||||||
<button type="button" aria-pressed={basemap === 'satellite'} onClick={() => onBasemap('satellite')}>{t('map.layer.sat')}</button>
|
<button type="button" aria-pressed={basemap === 'satellite'} onClick={() => onBasemap('satellite')}>{t('map.layer.sat')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{firstPaint && (
|
||||||
|
<div className="kbb-mapsplash" role="status"><span className="kbb-spinner" aria-hidden="true" /><strong>{t('load.map')}</strong></div>
|
||||||
|
)}
|
||||||
|
{!firstPaint && tilesLoading && <div className="kbb-maploading" role="status"><span className="usa-sr-only">{t('load.mapTiles')}</span></div>}
|
||||||
<div ref={ref} className="kbb-map" role="region" aria-label={t('map.label')} aria-describedby="map-alt" />
|
<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>
|
<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 && (
|
{showPlaces && (
|
||||||
<details className="kbb-legend">
|
<details className="kbb-legend">
|
||||||
<summary>{t('map.legend')}</summary>
|
<summary>{t('map.legend')}</summary>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { forwardRef, useState } from 'react';
|
import { forwardRef, useState } from 'react';
|
||||||
import { googleMapsPlaceUrl } from '../lib/links';
|
import { googleMapsPlaceUrl } from '../lib/links';
|
||||||
import { useI18n, type Key } from '../lib/i18n';
|
import { useI18n, type Key } from '../lib/i18n';
|
||||||
import { DEMO_PLACES } from '../lib/demo';
|
|
||||||
import type { Assessment, Place, ReportAttr, Status, UserReport } from '../lib/types';
|
import type { Assessment, Place, ReportAttr, Status, UserReport } from '../lib/types';
|
||||||
import { Alert, FactCard, Icon, VERDICT_STYLE } from './ui';
|
import { Alert, FactCard, Icon, VERDICT_STYLE } from './ui';
|
||||||
import { ReportForm } from './ReportForm';
|
import { ReportForm } from './ReportForm';
|
||||||
|
|
@ -23,6 +22,8 @@ interface Props {
|
||||||
reports: UserReport[];
|
reports: UserReport[];
|
||||||
snapshotDate: string;
|
snapshotDate: string;
|
||||||
refresh: RefreshState;
|
refresh: RefreshState;
|
||||||
|
/** Live refresh from OpenStreetMap needs a connection. */
|
||||||
|
online: boolean;
|
||||||
saved: boolean;
|
saved: boolean;
|
||||||
onToggleSave: () => void;
|
onToggleSave: () => void;
|
||||||
onCard: () => void;
|
onCard: () => void;
|
||||||
|
|
@ -49,7 +50,6 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
||||||
const [reporting, setReporting] = useState<ReportAttr | null>(null);
|
const [reporting, setReporting] = useState<ReportAttr | null>(null);
|
||||||
const [thanks, setThanks] = useState(false);
|
const [thanks, setThanks] = useState(false);
|
||||||
const { place, assessment } = p;
|
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 address = [place.tags['addr:street'], place.tags['addr:housenumber']].filter(Boolean).join(' ');
|
||||||
const gmaps = googleMapsPlaceUrl([place.lat, place.lon]);
|
const gmaps = googleMapsPlaceUrl([place.lat, place.lon]);
|
||||||
const [reachOpen, setReachOpen] = useState(false);
|
const [reachOpen, setReachOpen] = useState(false);
|
||||||
|
|
@ -78,15 +78,6 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
||||||
</p>
|
</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={`kbb-verdict kbb-verdict--${assessment.verdict} margin-top-2`} role="status">
|
<div className={`kbb-verdict kbb-verdict--${assessment.verdict} margin-top-2`} role="status">
|
||||||
<div className="kbb-verdict__head">
|
<div className="kbb-verdict__head">
|
||||||
<Icon name={VERDICT_STYLE[assessment.verdict].icon} size={5} />
|
<Icon name={VERDICT_STYLE[assessment.verdict].icon} size={5} />
|
||||||
|
|
@ -191,7 +182,7 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
||||||
{mine.map((r) => (
|
{mine.map((r) => (
|
||||||
<li key={r.id} className="margin-bottom-2">
|
<li key={r.id} className="margin-bottom-2">
|
||||||
<ReportCard report={r}>
|
<ReportCard report={r}>
|
||||||
{!r.demo && <button type="button" className="usa-button usa-button--unstyled" onClick={() => p.onRemoveReport(r.id)}>{t('detail.reports.remove')}</button>}
|
<button type="button" className="usa-button usa-button--unstyled" onClick={() => p.onRemoveReport(r.id)}>{t('detail.reports.remove')}</button>
|
||||||
</ReportCard>
|
</ReportCard>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -202,12 +193,8 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
||||||
<section aria-labelledby="tools-h" className="margin-top-4">
|
<section aria-labelledby="tools-h" className="margin-top-4">
|
||||||
<h2 id="tools-h" className="margin-top-0 margin-bottom-1 font-sans-lg">{t('detail.tools')}</h2>
|
<h2 id="tools-h" className="margin-top-0 margin-bottom-1 font-sans-lg">{t('detail.tools')}</h2>
|
||||||
<ul className="kbb-rows usa-list--unstyled margin-0 padding-0">
|
<ul className="kbb-rows usa-list--unstyled margin-0 padding-0">
|
||||||
{place.demo ? (
|
|
||||||
<li><p className="font-sans-xs text-base-dark margin-y-1">{t('detail.refreshDemo')}</p></li>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<li>
|
<li>
|
||||||
<button type="button" className="kbb-row" onClick={p.onRefresh} disabled={p.refresh.kind === 'loading'}>
|
<button type="button" className="kbb-row" onClick={p.onRefresh} disabled={p.refresh.kind === 'loading' || !p.online} title={p.online ? undefined : t('off.title')}>
|
||||||
<Icon name="update" size={3} className="kbb-row__icon" />
|
<Icon name="update" size={3} className="kbb-row__icon" />
|
||||||
<span className="kbb-row__text">{p.refresh.kind === 'loading' ? t('detail.refreshing') : t('detail.refresh')}</span>
|
<span className="kbb-row__text">{p.refresh.kind === 'loading' ? t('detail.refreshing') : t('detail.refresh')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -219,8 +206,6 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
||||||
<Icon name="launch" size={2} className="kbb-row__end" />
|
<Icon name="launch" size={2} className="kbb-row__end" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<li>
|
<li>
|
||||||
<button type="button" className="kbb-row" onClick={p.onCard}>
|
<button type="button" className="kbb-row" onClick={p.onCard}>
|
||||||
<Icon name="print" size={3} className="kbb-row__icon" />
|
<Icon name="print" size={3} className="kbb-row__icon" />
|
||||||
|
|
@ -237,8 +222,6 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const demoState = (s: string) => (s === 'verified' ? 'match' : s === 'conflict' ? 'conflict' : 'incomplete');
|
|
||||||
|
|
||||||
function osmPath(id: string) {
|
function osmPath(id: string) {
|
||||||
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
|
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
|
||||||
return `${kind}/${id.slice(1)}`;
|
return `${kind}/${id.slice(1)}`;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useI18n, type Key } from '../lib/i18n';
|
import { useI18n, type Key } from '../lib/i18n';
|
||||||
import { VERDICT_ORDER } from '../lib/assess';
|
import { VERDICT_ORDER } from '../lib/assess';
|
||||||
import { DEMO_PLACES } from '../lib/demo';
|
|
||||||
import { fold } from '../lib/text';
|
import { fold } from '../lib/text';
|
||||||
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
|
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
|
||||||
import { CategoryIcon, Icon, VerdictTag } from './ui';
|
import { CategoryIcon, Icon, VerdictTag } from './ui';
|
||||||
|
|
@ -28,7 +27,7 @@ export function filterPlaces(places: Place[], assessments: Map<string, Assessmen
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const va = VERDICT_ORDER[assessments.get(a.id)?.verdict ?? 'incomplete'];
|
const va = VERDICT_ORDER[assessments.get(a.id)?.verdict ?? 'incomplete'];
|
||||||
const vb = VERDICT_ORDER[assessments.get(b.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');
|
return va - vb || (a.name ?? '').localeCompare(b.name ?? '', 'pl');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +120,6 @@ export function PlacesView({ profile, filters, onFilters, results, counts, asses
|
||||||
|
|
||||||
function PlaceRow({ place: p, assessment: a, onSelect }: { place: Place; assessment: Assessment | undefined; onSelect: (id: string) => void }) {
|
function PlaceRow({ place: p, assessment: a, onSelect }: { place: Place; assessment: Assessment | undefined; onSelect: (id: string) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isDemo = DEMO_PLACES.some((d) => d.id === p.id);
|
|
||||||
return (
|
return (
|
||||||
<li className="margin-bottom-1">
|
<li className="margin-bottom-1">
|
||||||
<button
|
<button
|
||||||
|
|
@ -135,7 +133,6 @@ function PlaceRow({ place: p, assessment: a, onSelect }: { place: Place; assessm
|
||||||
{t(`cat.${p.cat}` as Key)}
|
{t(`cat.${p.cat}` as Key)}
|
||||||
{p.tags['addr:street'] ? ` · ${p.tags['addr:street']} ${p.tags['addr:housenumber'] ?? ''}` : ''}
|
{p.tags['addr:street'] ? ` · ${p.tags['addr:street']} ${p.tags['addr:housenumber'] ?? ''}` : ''}
|
||||||
</span>
|
</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 className="display-block margin-top-1">{a && <VerdictTag verdict={a.verdict} />}</span>
|
||||||
</span>
|
</span>
|
||||||
<Icon name="navigate_next" size={3} className="text-base margin-top-05" />
|
<Icon name="navigate_next" size={3} className="text-base margin-top-05" />
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ export function ReportCard({ report: r, placeName, headingLevel = 'h3', detailed
|
||||||
{placeName && <p className="kbb-rcard__claim">{claim}</p>}
|
{placeName && <p className="kbb-rcard__claim">{claim}</p>}
|
||||||
<p className="kbb-rcard__meta">
|
<p className="kbb-rcard__meta">
|
||||||
{date(r.createdAt.slice(0, 10))}
|
{date(r.createdAt.slice(0, 10))}
|
||||||
{r.demo && <span className="usa-tag bg-ink text-white margin-left-1">{t('detail.reports.demo')}</span>}
|
|
||||||
</p>
|
</p>
|
||||||
{r.measure && (
|
{r.measure && (
|
||||||
<p className="kbb-rcard__line">
|
<p className="kbb-rcard__line">
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,6 @@ export function SettingsPanel({ settings, onSettings, favoritesCount, onExport,
|
||||||
<input className="usa-checkbox__input" id="set-motion" type="checkbox" checked={settings.reduceMotion} onChange={(e) => set('reduceMotion', e.target.checked)} />
|
<input className="usa-checkbox__input" id="set-motion" type="checkbox" checked={settings.reduceMotion} onChange={(e) => set('reduceMotion', e.target.checked)} />
|
||||||
<label className="usa-checkbox__label" htmlFor="set-motion">{t('set.motion')}</label>
|
<label className="usa-checkbox__label" htmlFor="set-motion">{t('set.motion')}</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="usa-checkbox">
|
|
||||||
<input className="usa-checkbox__input" id="set-demo" type="checkbox" checked={settings.showDemoPlaces} onChange={(e) => set('showDemoPlaces', e.target.checked)} aria-describedby="set-demo-h" />
|
|
||||||
<label className="usa-checkbox__label" htmlFor="set-demo">{t('set.demoPlaces')}</label>
|
|
||||||
</div>
|
|
||||||
<p id="set-demo-h" className="usa-hint margin-top-05">{t('set.demoPlaces.help')}</p>
|
|
||||||
<label className="usa-label" htmlFor="set-lang">{t('set.language')}</label>
|
<label className="usa-label" htmlFor="set-lang">{t('set.language')}</label>
|
||||||
<select id="set-lang" className="usa-select" value={settings.lang} onChange={(e) => set('lang', e.target.value as Settings['lang'])}>
|
<select id="set-lang" className="usa-select" value={settings.lang} onChange={(e) => set('lang', e.target.value as Settings['lang'])}>
|
||||||
<option value="pl">Polski</option>
|
<option value="pl">Polski</option>
|
||||||
|
|
|
||||||
90
src/components/StatusBanners.tsx
Normal file
90
src/components/StatusBanners.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import type { PackState } from '../lib/useOffline';
|
||||||
|
import { Icon } from './ui';
|
||||||
|
|
||||||
|
function Banner({ kind, icon, onClose, children, actions }: {
|
||||||
|
kind: 'warning' | 'info' | 'success'; icon: string; onClose?: () => void; children: React.ReactNode; actions?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className={`kbb-banner kbb-banner--${kind}`} role="status">
|
||||||
|
<Icon name={icon} size={3} className="kbb-banner__icon" />
|
||||||
|
<div className="kbb-banner__body">{children}</div>
|
||||||
|
{actions && <div className="kbb-banner__actions">{actions}</div>}
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" className="kbb-banner__close" onClick={onClose} aria-label={t('banner.dismiss')} title={t('banner.dismiss')}>
|
||||||
|
<Icon name="close" size={3} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection and offline-pack notices under the header. The "offline" notice can be dismissed and comes back the next time the
|
||||||
|
* connection drops; it says what stops working (live data) and that what is shown may be out of date.
|
||||||
|
*/
|
||||||
|
export function StatusBanners({ online, dataDate, pack }: { online: boolean; dataDate: string; pack: PackState }) {
|
||||||
|
const { t, date } = useI18n();
|
||||||
|
const [offDismissed, setOffDismissed] = useState(false);
|
||||||
|
const [packDismissed, setPackDismissed] = useState(false);
|
||||||
|
const [backOnline, setBackOnline] = useState(false);
|
||||||
|
const wasOffline = useRef(false);
|
||||||
|
const [doneFlash, setDoneFlash] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!online) { wasOffline.current = true; setBackOnline(false); return; }
|
||||||
|
setOffDismissed(false);
|
||||||
|
if (!wasOffline.current) return;
|
||||||
|
wasOffline.current = false;
|
||||||
|
setBackOnline(true);
|
||||||
|
const id = window.setTimeout(() => setBackOnline(false), 4000);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [online]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pack.justFinished) return;
|
||||||
|
setDoneFlash(true);
|
||||||
|
const id = window.setTimeout(() => setDoneFlash(false), 6000);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [pack.justFinished]);
|
||||||
|
|
||||||
|
const pct = pack.progress.total ? Math.round((pack.progress.done / pack.progress.total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="kbb-banners">
|
||||||
|
{!online && !offDismissed && (
|
||||||
|
<Banner kind="warning" icon="warning" onClose={() => setOffDismissed(true)}>
|
||||||
|
<strong>{t('off.title')}</strong>
|
||||||
|
<span>{t('off.body', { date: date(dataDate) })}</span>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{online && backOnline && <Banner kind="success" icon="check_circle" onClose={() => setBackOnline(false)}>{t('off.back')}</Banner>}
|
||||||
|
|
||||||
|
{!packDismissed && pack.phase === 'consent' && (
|
||||||
|
<Banner
|
||||||
|
kind="info" icon="info" onClose={() => { setPackDismissed(true); pack.decline(); }}
|
||||||
|
actions={<button type="button" className="usa-button usa-button--small margin-0" onClick={pack.start}>{t('pack.download')}</button>}
|
||||||
|
>
|
||||||
|
{t('pack.consent', { mb: pack.estimateMb })}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{!packDismissed && pack.phase === 'downloading' && (
|
||||||
|
<Banner kind="info" icon="update" onClose={() => setPackDismissed(true)}>
|
||||||
|
<strong>{t('pack.downloading')}</strong>
|
||||||
|
<progress className="kbb-progress kbb-progress--thin" value={pct} max={100} aria-label={t('pack.downloading')}>{pct}%</progress>
|
||||||
|
<span className="kbb-banner__small">{t('pack.progress', { done: pack.progress.done, total: pack.progress.total })}</span>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{!packDismissed && pack.phase === 'error' && (
|
||||||
|
<Banner
|
||||||
|
kind="warning" icon="error" onClose={() => setPackDismissed(true)}
|
||||||
|
actions={<button type="button" className="usa-button usa-button--small margin-0" onClick={() => { setPackDismissed(false); pack.start(); }}>{t('pack.retry')}</button>}
|
||||||
|
>
|
||||||
|
{t('pack.error', { failed: pack.progress.failed })}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{doneFlash && pack.phase === 'ready' && <Banner kind="success" icon="check_circle" onClose={() => setDoneFlash(false)}>{t('pack.ready')}</Banner>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -188,7 +188,7 @@ export function VettingSummary({ v, detailed = false }: { v: VetState | undefine
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
if (v.state === 'running') return <p className="font-sans-xs margin-y-05" role="status"><Icon name="update" size={2} className="margin-right-05" />{t('vet.running')}</p>;
|
if (v.state === 'running') return <p className="font-sans-xs margin-y-05" role="status"><Icon name="update" size={2} className="margin-right-05" />{t('vet.running')}</p>;
|
||||||
if (v.state === 'failed') {
|
if (v.state === 'failed') {
|
||||||
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : v.reason === 'skipped' ? 'vet.skipped' : 'vet.error')}</p>;
|
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : v.reason === 'skipped' ? 'vet.skipped' : v.reason === 'offline' ? 'vet.offline' : 'vet.error')}</p>;
|
||||||
}
|
}
|
||||||
const r = v.result;
|
const r = v.result;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
// The one place that names the app. Change APP_NAME and every screen, the page title, the PWA manifest and exported file names follow
|
// App-wide switches. Everything the deployer may want to change lives here.
|
||||||
// (see vite.config.ts for the HTML title / manifest, `app.name` in i18n.ts for the UI).
|
|
||||||
|
/**
|
||||||
|
* The one place that names the app. Change it and every screen, the page title, the PWA manifest and exported file names follow
|
||||||
|
* (see vite.config.ts for the HTML title / manifest, `app.name` in i18n.ts for the UI).
|
||||||
|
*/
|
||||||
export const APP_NAME = 'GdzieWjade';
|
export const APP_NAME = 'GdzieWjade';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "prototype" notice above the header (with the data date). Off for production; set to true for demos.
|
||||||
|
* It is hidden on phones anyway; this removes it everywhere.
|
||||||
|
*/
|
||||||
|
export const SHOW_PROTOTYPE_BANNER = false;
|
||||||
|
|
||||||
|
/** File-name friendly form of the name, e.g. "gdziewjade". */
|
||||||
export const APP_SLUG = APP_NAME.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
export const APP_SLUG = APP_NAME.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
|
|
||||||
2
src/global.d.ts
vendored
Normal file
2
src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/** Decoded byte sizes of the big data files, injected at build time (vite.config.ts) for the loading progress bars. */
|
||||||
|
declare const __DATA_BYTES__: { snapshot: number; transit: number };
|
||||||
|
|
@ -74,7 +74,7 @@ export function assessPlace(
|
||||||
place: Place, profile: Profile, ix: Index, reports: UserReport[] = [], now = new Date(),
|
place: Place, profile: Profile, ix: Index, reports: UserReport[] = [], now = new Date(),
|
||||||
): Assessment {
|
): Assessment {
|
||||||
const p = pt(place);
|
const p = pt(place);
|
||||||
const src: SourceKind = place.demo ? 'demo' : 'osm-tag';
|
const src: SourceKind = 'osm-tag';
|
||||||
const t = place.tags;
|
const t = place.tags;
|
||||||
const facts: Fact[] = [];
|
const facts: Fact[] = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,9 @@ export interface Stats {
|
||||||
const entranceOf = (a: Assessment) => a.facts.find((f) => f.key === 'entrance');
|
const entranceOf = (a: Assessment) => a.facts.find((f) => f.key === 'entrance');
|
||||||
|
|
||||||
export function computeStats(places: Place[], assessments: Map<string, Assessment>): Stats {
|
export function computeStats(places: Place[], assessments: Map<string, Assessment>): Stats {
|
||||||
const real = places.filter((p) => !p.demo);
|
|
||||||
const rows = new Map<PlaceCategory, CatRow>();
|
const rows = new Map<PlaceCategory, CatRow>();
|
||||||
let entranceKnown = 0, fresh = 0, verified = 0;
|
let entranceKnown = 0, fresh = 0, verified = 0;
|
||||||
for (const p of real) {
|
for (const p of places) {
|
||||||
const f = assessments.get(p.id) && entranceOf(assessments.get(p.id)!);
|
const f = assessments.get(p.id) && entranceOf(assessments.get(p.id)!);
|
||||||
if (!f) continue;
|
if (!f) continue;
|
||||||
const row = rows.get(p.cat) ?? { cat: p.cat, total: 0, known: 0, stale: 0, unknown: 0 };
|
const row = rows.get(p.cat) ?? { cat: p.cat, total: 0, known: 0, stale: 0, unknown: 0 };
|
||||||
|
|
@ -35,9 +34,9 @@ export function computeStats(places: Place[], assessments: Map<string, Assessmen
|
||||||
if (f.confidence === 'verified' || f.confidence === 'mapped') fresh++;
|
if (f.confidence === 'verified' || f.confidence === 'mapped') fresh++;
|
||||||
if (f.confidence === 'verified') verified++;
|
if (f.confidence === 'verified') verified++;
|
||||||
}
|
}
|
||||||
const transit = real.filter((p) => p.ext);
|
const transit = places.filter((p) => p.ext);
|
||||||
return {
|
return {
|
||||||
total: real.length, entranceKnown, fresh, verified,
|
total: places.length, entranceKnown, fresh, verified,
|
||||||
transitTotal: transit.length,
|
transitTotal: transit.length,
|
||||||
transitOperatorKnown: transit.filter((p) => p.ext?.gtfsWheelchair === 1 || p.ext?.gtfsWheelchair === 2).length,
|
transitOperatorKnown: transit.filter((p) => p.ext?.gtfsWheelchair === 1 || p.ext?.gtfsWheelchair === 2).length,
|
||||||
transitCommunityKnown: transit.filter((p) => !!p.tags.wheelchair).length,
|
transitCommunityKnown: transit.filter((p) => !!p.tags.wheelchair).length,
|
||||||
|
|
@ -59,7 +58,7 @@ export interface Hotspot {
|
||||||
/** Crossings with a high or unknown kerb, ranked by how many places and stops they serve. */
|
/** Crossings with a high or unknown kerb, ranked by how many places and stops they serve. */
|
||||||
export function computeHotspots(snapshot: Snapshot, ix: Index, places: Place[], limit = 10): Hotspot[] {
|
export function computeHotspots(snapshot: Snapshot, ix: Index, places: Place[], limit = 10): Hotspot[] {
|
||||||
const grid = new Grid<Place>();
|
const grid = new Grid<Place>();
|
||||||
for (const p of places) if (!p.demo) grid.add(p.lat, p.lon, p);
|
for (const p of places) grid.add(p.lat, p.lon, p);
|
||||||
const out: Hotspot[] = [];
|
const out: Hotspot[] = [];
|
||||||
for (const w of snapshot.ways) {
|
for (const w of snapshot.ways) {
|
||||||
if (w.footway !== 'crossing' || w.nodes.length < 2) continue;
|
if (w.footway !== 'crossing' || w.nodes.length < 2) continue;
|
||||||
|
|
@ -106,14 +105,14 @@ const esc = (v: string | number) => {
|
||||||
|
|
||||||
export function toCsv(places: Place[], assessments: Map<string, Assessment>): string {
|
export function toCsv(places: Place[], assessments: Map<string, Assessment>): string {
|
||||||
const lines = [COLS.join(',')];
|
const lines = [COLS.join(',')];
|
||||||
for (const p of places) if (!p.demo) lines.push(COLS.map((c) => esc(row(p, assessments.get(p.id))[c])).join(','));
|
for (const p of places) lines.push(COLS.map((c) => esc(row(p, assessments.get(p.id))[c])).join(','));
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toGeoJson(places: Place[], assessments: Map<string, Assessment>) {
|
export function toGeoJson(places: Place[], assessments: Map<string, Assessment>) {
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: places.filter((p) => !p.demo).map((p) => ({
|
features: places.map((p) => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] },
|
geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] },
|
||||||
properties: row(p, assessments.get(p.id)),
|
properties: row(p, assessments.get(p.id)),
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
// 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: 'city', 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-2', placeId: 'demo-nodata', attr: 'entrance', value: 'yes', demo: true, authorId: 'anna',
|
|
||||||
note: 'Przykład: wejście z poziomu chodnika, drzwi bez progu.', createdAt: '2026-09-30T12:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-report-3', placeId: 'demo-nodata', attr: 'width', value: 85, demo: true, authorId: 'piotr',
|
|
||||||
note: 'Przykład: zmierzone 85 cm w świetle drzwi.', createdAt: '2026-10-01T08:30:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo-report-1', placeId: 'demo-conflict', attr: 'entrance', value: 'no', demo: true,
|
|
||||||
note: 'Przykład: przy wejściu są dwa stopnie, brak podjazdu.', createdAt: '2026-09-28T10:00:00Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
33
src/lib/fetchProgress.ts
Normal file
33
src/lib/fetchProgress.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* GET a JSON file and report download progress (0..1), capped below 1 until parsing is done.
|
||||||
|
* `expectedBytes` is the decoded size (known at build time): compressed responses are streamed chunked, without a usable
|
||||||
|
* Content-Length, and the stream yields decoded bytes, so the header cannot be trusted for a bar. Without it, the header is used
|
||||||
|
* (scaled by ~5 when the body is compressed), and without either the result is reported only when it is done.
|
||||||
|
*/
|
||||||
|
export async function fetchJson<T>(url: string, onProgress?: (frac: number) => void, signal?: AbortSignal, expectedBytes = 0): Promise<T> {
|
||||||
|
const res = await fetch(url, signal ? { signal } : undefined);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const len = Number(res.headers.get('content-length')) || 0;
|
||||||
|
const total = expectedBytes || len * (res.headers.get('content-encoding') ? 5 : 1);
|
||||||
|
if (!res.body || !total) {
|
||||||
|
const d = (await res.json()) as T;
|
||||||
|
onProgress?.(1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let got = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
got += value.length;
|
||||||
|
onProgress?.(Math.min(0.97, got / total));
|
||||||
|
}
|
||||||
|
const all = new Uint8Array(got);
|
||||||
|
let at = 0;
|
||||||
|
for (const c of chunks) { all.set(c, at); at += c.length; }
|
||||||
|
const d = JSON.parse(new TextDecoder().decode(all)) as T;
|
||||||
|
onProgress?.(1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,32 @@ const pl = {
|
||||||
'tr.nearby.none': 'Brak przystanków w promieniu 400 m.',
|
'tr.nearby.none': 'Brak przystanków w promieniu 400 m.',
|
||||||
'tr.nearby.dist': '{m} m',
|
'tr.nearby.dist': '{m} m',
|
||||||
'tr.stopLabel': 'Przystanek {name}',
|
'tr.stopLabel': 'Przystanek {name}',
|
||||||
|
'banner.dismiss': 'Zamknij komunikat',
|
||||||
|
'off.title': 'Brak połączenia z internetem. ',
|
||||||
|
'off.body': 'Trasy na żywo (odjazdy i opóźnienia) są niedostępne. Dane mogą być nieaktualne: stan z {date}.',
|
||||||
|
'off.back': 'Połączenie wróciło. Dane na żywo są znów dostępne.',
|
||||||
|
'pack.download': 'Pobierz',
|
||||||
|
'pack.consent': 'Zapisać mapę obszaru na urządzeniu (ok. {mb} MB), żeby działała bez internetu?',
|
||||||
|
'pack.downloading': 'Zapisuję mapę na urządzeniu, żeby działała offline…',
|
||||||
|
'pack.progress': 'Kafelki mapy: {done} z {total}',
|
||||||
|
'pack.ready': 'Gotowe: aplikacja działa offline (mapa, miejsca, ścieżki i rozkład jazdy).',
|
||||||
|
'pack.error': 'Nie udało się zapisać całej mapy offline (brakuje {failed} kafelków). Spróbuj ponownie przy lepszym połączeniu.',
|
||||||
|
'pack.retry': 'Ponów',
|
||||||
|
'pack.title': 'Tryb offline',
|
||||||
|
'pack.status.ready': 'Zapisano mapę ({tiles} kafelków) i dane: {date}. Aplikacja działa bez internetu; na żywo dostępne są tylko opóźnienia komunikacji.',
|
||||||
|
'pack.status.none': 'Mapa nie jest jeszcze zapisana na urządzeniu.',
|
||||||
|
'pack.status.downloading': 'Trwa zapisywanie mapy…',
|
||||||
|
'pack.status.unsupported': 'Tryb offline działa po zainstalowaniu aplikacji z wersji produkcyjnej (w trybie developerskim i w przeglądarkach bez Service Workera jest wyłączony).',
|
||||||
|
'pack.update': 'Uzupełnij mapę offline',
|
||||||
|
'pack.remove': 'Usuń mapę z urządzenia',
|
||||||
|
'pack.removeNote': 'Dane aplikacji (miejsca, ścieżki, rozkład) zostają. Usuwa tylko kafelki mapy.',
|
||||||
|
'load.title': 'Wczytywanie danych',
|
||||||
|
'load.download': 'Pobieram miejsca, ścieżki i przystanki…',
|
||||||
|
'load.prepare': 'Przygotowuję oceny i trasy…',
|
||||||
|
'load.note': 'Tylko za pierwszym razem. Potem dane są zapisane na urządzeniu i aplikacja startuje od razu, także bez internetu.',
|
||||||
|
'load.map': 'Wczytuję mapę…',
|
||||||
|
'load.mapTiles': 'Doczytuję mapę…',
|
||||||
|
'vet.offline': 'Ocena automatyczna pominięta (brak połączenia). Zgłoszenie zapisano na urządzeniu.',
|
||||||
'tr.towards': 'w stronę: {list}',
|
'tr.towards': 'w stronę: {list}',
|
||||||
'dir.n': 'na północ od miejsca',
|
'dir.n': 'na północ od miejsca',
|
||||||
'dir.ne': 'na północny wschód od miejsca',
|
'dir.ne': 'na północny wschód od miejsca',
|
||||||
|
|
@ -176,7 +202,6 @@ const pl = {
|
||||||
'src.osm-tag': 'OpenStreetMap (opis miejsca)',
|
'src.osm-tag': 'OpenStreetMap (opis miejsca)',
|
||||||
'src.osm-geometry': 'OpenStreetMap (obiekty w pobliżu)',
|
'src.osm-geometry': 'OpenStreetMap (obiekty w pobliżu)',
|
||||||
'src.user-report': 'Zgłoszenie użytkownika (to urządzenie)',
|
'src.user-report': 'Zgłoszenie użytkownika (to urządzenie)',
|
||||||
'src.demo': 'Dane przykładowe DEMO',
|
|
||||||
'src.none': 'Brak źródła',
|
'src.none': 'Brak źródła',
|
||||||
'fact.updated': 'Dane z',
|
'fact.updated': 'Dane z',
|
||||||
'fact.dateUnknown': 'data nieznana',
|
'fact.dateUnknown': 'data nieznana',
|
||||||
|
|
@ -261,14 +286,10 @@ const pl = {
|
||||||
'detail.refreshing': 'Pobieranie…',
|
'detail.refreshing': 'Pobieranie…',
|
||||||
'detail.refreshed': 'Odświeżono z OpenStreetMap: {time}.',
|
'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.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.facts': 'Szczegóły dostępności',
|
||||||
'detail.reports': 'Zgłoszenia użytkowników',
|
'detail.reports': 'Zgłoszenia użytkowników',
|
||||||
'detail.reports.none': 'Brak zgłoszeń.',
|
'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.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',
|
'detail.reports.remove': 'Usuń zgłoszenie',
|
||||||
|
|
||||||
'rep.title': 'Zgłoś poprawkę lub uzupełnienie',
|
'rep.title': 'Zgłoś poprawkę lub uzupełnienie',
|
||||||
|
|
@ -307,7 +328,6 @@ const pl = {
|
||||||
'loc.denied': 'Brak zgody na lokalizację. Zezwól w ustawieniach przeglądarki i telefonu (Lokalizacja), potem spróbuj ponownie.',
|
'loc.denied': 'Brak zgody na lokalizację. Zezwól w ustawieniach przeglądarki i telefonu (Lokalizacja), potem spróbuj ponownie.',
|
||||||
'loc.timeout': 'Ustalanie lokalizacji trwa za długo. Wyjdź na otwartą przestrzeń albo wpisz punkt startu.',
|
'loc.timeout': 'Ustalanie lokalizacji trwa za długo. Wyjdź na otwartą przestrzeń albo wpisz punkt startu.',
|
||||||
'loc.unavailable': 'Nie udało się ustalić lokalizacji. Włącz lokalizację w telefonie albo wpisz punkt startu.',
|
'loc.unavailable': 'Nie udało się ustalić lokalizacji. Włącz lokalizację w telefonie albo wpisz punkt startu.',
|
||||||
'route.presets': 'Przykładowe trasy',
|
|
||||||
'route.unknownPlace': 'Nie znaleziono miejsca „{text}”. Wybierz pozycję z podpowiedzi.',
|
'route.unknownPlace': 'Nie znaleziono miejsca „{text}”. Wybierz pozycję z podpowiedzi.',
|
||||||
'route.same': 'Start i cel są takie same.',
|
'route.same': 'Start i cel są takie same.',
|
||||||
'route.ok': 'Trasa bez zmapowanych barier',
|
'route.ok': 'Trasa bez zmapowanych barier',
|
||||||
|
|
@ -445,8 +465,6 @@ const pl = {
|
||||||
'set.contrast.help': 'Mocniejsze obramowania i ciemniejszy tekst.',
|
'set.contrast.help': 'Mocniejsze obramowania i ciemniejszy tekst.',
|
||||||
'set.motion': 'Ogranicz animacje mapy',
|
'set.motion': 'Ogranicz animacje mapy',
|
||||||
'set.language': 'Język',
|
'set.language': 'Język',
|
||||||
'set.demoPlaces': 'Pokaż fikcyjne miejsca DEMO',
|
|
||||||
'set.demoPlaces.help': 'Pokazują stany danych (potwierdzone, sprzeczne, nieaktualne, brak). Wyłącz, by widzieć tylko prawdziwe miejsca.',
|
|
||||||
'set.account': 'Konto',
|
'set.account': 'Konto',
|
||||||
'set.data': 'Twoje dane',
|
'set.data': 'Twoje dane',
|
||||||
'set.export': 'Pobierz ustawienia (JSON)',
|
'set.export': 'Pobierz ustawienia (JSON)',
|
||||||
|
|
@ -834,6 +852,32 @@ const en: Dict = {
|
||||||
'tr.nearby.none': 'No stops within 400 m.',
|
'tr.nearby.none': 'No stops within 400 m.',
|
||||||
'tr.nearby.dist': '{m} m',
|
'tr.nearby.dist': '{m} m',
|
||||||
'tr.stopLabel': 'Stop {name}',
|
'tr.stopLabel': 'Stop {name}',
|
||||||
|
'banner.dismiss': 'Dismiss notice',
|
||||||
|
'off.title': 'No internet connection. ',
|
||||||
|
'off.body': 'Live routes (departures and delays) are unavailable. Data may be out of date: as of {date}.',
|
||||||
|
'off.back': 'You are back online. Live data is available again.',
|
||||||
|
'pack.download': 'Download',
|
||||||
|
'pack.consent': 'Save the area map on this device (about {mb} MB) so it works without internet?',
|
||||||
|
'pack.downloading': 'Saving the map on this device so it works offline…',
|
||||||
|
'pack.progress': 'Map tiles: {done} of {total}',
|
||||||
|
'pack.ready': 'Done: the app works offline (map, places, paths and timetable).',
|
||||||
|
'pack.error': 'Could not save the whole offline map ({failed} tiles missing). Try again on a better connection.',
|
||||||
|
'pack.retry': 'Retry',
|
||||||
|
'pack.title': 'Offline mode',
|
||||||
|
'pack.status.ready': 'Saved the map ({tiles} tiles) and data: {date}. The app works without internet; only live transit delays need a connection.',
|
||||||
|
'pack.status.none': 'The map is not saved on this device yet.',
|
||||||
|
'pack.status.downloading': 'Saving the map…',
|
||||||
|
'pack.status.unsupported': 'Offline mode works once the production build is installed (it is off in development and in browsers without a Service Worker).',
|
||||||
|
'pack.update': 'Fill in the offline map',
|
||||||
|
'pack.remove': 'Remove the map from this device',
|
||||||
|
'pack.removeNote': 'App data (places, paths, timetable) stays. Only the map tiles are removed.',
|
||||||
|
'load.title': 'Loading data',
|
||||||
|
'load.download': 'Downloading places, paths and stops…',
|
||||||
|
'load.prepare': 'Preparing ratings and routes…',
|
||||||
|
'load.note': 'First time only. After that the data is stored on the device and the app starts instantly, also without internet.',
|
||||||
|
'load.map': 'Loading the map…',
|
||||||
|
'load.mapTiles': 'Loading more map…',
|
||||||
|
'vet.offline': 'Automatic assessment skipped (no connection). The report is saved on this device.',
|
||||||
'tr.towards': 'towards: {list}',
|
'tr.towards': 'towards: {list}',
|
||||||
'dir.n': 'north of the place',
|
'dir.n': 'north of the place',
|
||||||
'dir.ne': 'north-east of the place',
|
'dir.ne': 'north-east of the place',
|
||||||
|
|
@ -950,7 +994,6 @@ const en: Dict = {
|
||||||
'src.osm-tag': 'OpenStreetMap (place tags)',
|
'src.osm-tag': 'OpenStreetMap (place tags)',
|
||||||
'src.osm-geometry': 'OpenStreetMap (nearby objects)',
|
'src.osm-geometry': 'OpenStreetMap (nearby objects)',
|
||||||
'src.user-report': 'User report (this device)',
|
'src.user-report': 'User report (this device)',
|
||||||
'src.demo': 'DEMO sample data',
|
|
||||||
'src.none': 'No source',
|
'src.none': 'No source',
|
||||||
'fact.updated': 'Data from',
|
'fact.updated': 'Data from',
|
||||||
'fact.dateUnknown': 'date unknown',
|
'fact.dateUnknown': 'date unknown',
|
||||||
|
|
@ -1035,14 +1078,10 @@ const en: Dict = {
|
||||||
'detail.refreshing': 'Fetching…',
|
'detail.refreshing': 'Fetching…',
|
||||||
'detail.refreshed': 'Refreshed from OpenStreetMap: {time}.',
|
'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.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.facts': 'Accessibility details',
|
||||||
'detail.reports': 'User reports',
|
'detail.reports': 'User reports',
|
||||||
'detail.reports.none': 'No 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.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',
|
'detail.reports.remove': 'Remove report',
|
||||||
|
|
||||||
'rep.title': 'Report a correction or addition',
|
'rep.title': 'Report a correction or addition',
|
||||||
|
|
@ -1081,7 +1120,6 @@ const en: Dict = {
|
||||||
'loc.denied': 'Location permission was denied. Allow it in the browser and phone settings (Location), then try again.',
|
'loc.denied': 'Location permission was denied. Allow it in the browser and phone settings (Location), then try again.',
|
||||||
'loc.timeout': 'Finding your location takes too long. Step outside or type a starting point.',
|
'loc.timeout': 'Finding your location takes too long. Step outside or type a starting point.',
|
||||||
'loc.unavailable': 'Could not get your location. Turn on location on your phone or type a starting point.',
|
'loc.unavailable': 'Could not get your location. Turn on location on your phone or type a starting point.',
|
||||||
'route.presets': 'Example routes',
|
|
||||||
'route.unknownPlace': 'No place found for “{text}”. Pick one of the suggestions.',
|
'route.unknownPlace': 'No place found for “{text}”. Pick one of the suggestions.',
|
||||||
'route.same': 'Start and destination are the same.',
|
'route.same': 'Start and destination are the same.',
|
||||||
'route.ok': 'Route with no mapped barriers',
|
'route.ok': 'Route with no mapped barriers',
|
||||||
|
|
@ -1219,8 +1257,6 @@ const en: Dict = {
|
||||||
'set.contrast.help': 'Stronger borders and darker text.',
|
'set.contrast.help': 'Stronger borders and darker text.',
|
||||||
'set.motion': 'Reduce map animation',
|
'set.motion': 'Reduce map animation',
|
||||||
'set.language': 'Language',
|
'set.language': 'Language',
|
||||||
'set.demoPlaces': 'Show fictional DEMO places',
|
|
||||||
'set.demoPlaces.help': 'They show data states (confirmed, conflicting, outdated, missing). Turn off to see real places only.',
|
|
||||||
'set.account': 'Account',
|
'set.account': 'Account',
|
||||||
'set.data': 'Your data',
|
'set.data': 'Your data',
|
||||||
'set.export': 'Download settings (JSON)',
|
'set.export': 'Download settings (JSON)',
|
||||||
|
|
|
||||||
90
src/lib/offline.test.ts
Normal file
90
src/lib/offline.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fetchJson } from './fetchProgress';
|
||||||
|
import { PACK_ZOOMS, packTiles, tileUrl } from './offline';
|
||||||
|
import type { Snapshot } from './types';
|
||||||
|
|
||||||
|
describe('offline tile pack', () => {
|
||||||
|
const bbox = (JSON.parse(readFileSync('public/data/kk-snapshot.json', 'utf8')) as Snapshot).meta.bbox;
|
||||||
|
const tiles = packTiles(bbox);
|
||||||
|
|
||||||
|
it('covers the whole demo area at every pack zoom, a few hundred tiles in total', () => {
|
||||||
|
expect(new Set(tiles.map((t) => t.z))).toEqual(new Set(PACK_ZOOMS));
|
||||||
|
expect(tiles.length).toBeGreaterThan(300);
|
||||||
|
expect(tiles.length).toBeLessThan(1500); // OSM asks for restraint with bulk downloads
|
||||||
|
});
|
||||||
|
it('also covers the pannable margin at overview zooms and a ring of tiles at street zooms', () => {
|
||||||
|
const [s, w, n, e] = bbox;
|
||||||
|
const at = (lat: number, lon: number, z: number) => ({ z, x: Math.floor(((lon + 180) / 360) * 2 ** z), y: Math.floor(((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * 2 ** z) });
|
||||||
|
expect(tiles).toContainEqual(at(s - 0.029, w - 0.039, 14)); // corner of the pannable region
|
||||||
|
expect(tiles).toContainEqual(at(n + 0.029, e + 0.039, 15));
|
||||||
|
const a = at(s, w, 16);
|
||||||
|
expect(tiles).toContainEqual({ ...a, x: a.x - 1 }); // ring at z16
|
||||||
|
});
|
||||||
|
it('contains the tile of the area centre at the deepest zoom', () => {
|
||||||
|
const [s, w, n, e] = bbox;
|
||||||
|
const lat = (s + n) / 2, lon = (w + e) / 2;
|
||||||
|
const z = 17;
|
||||||
|
const x = Math.floor(((lon + 180) / 360) * 2 ** z);
|
||||||
|
const y = Math.floor(((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * 2 ** z);
|
||||||
|
expect(tiles).toContainEqual({ z, x, y });
|
||||||
|
});
|
||||||
|
it('uses the same host as the map style, so the service worker rule matches', () => {
|
||||||
|
expect(tileUrl({ z: 15, x: 17_600, y: 11_000 })).toBe('https://tile.openstreetmap.org/15/17600/11000.png');
|
||||||
|
const style = readFileSync('src/components/MapView.tsx', 'utf8');
|
||||||
|
expect(style).toContain('https://tile.openstreetmap.org/{z}/{x}/{y}.png');
|
||||||
|
expect(readFileSync('vite.config.ts', 'utf8')).toContain('tile\\.openstreetmap\\.org');
|
||||||
|
});
|
||||||
|
it('has no duplicate tiles', () => {
|
||||||
|
expect(new Set(tiles.map(tileUrl)).size).toBe(tiles.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('download progress', () => {
|
||||||
|
const body = JSON.stringify({ hello: 'world', n: Array.from({ length: 2000 }, (_, i) => i) });
|
||||||
|
const stream = (chunks: Uint8Array[]) => new ReadableStream<Uint8Array>({ start(c) { chunks.forEach((x) => c.enqueue(x)); c.close(); } });
|
||||||
|
const bytes = new TextEncoder().encode(body);
|
||||||
|
|
||||||
|
it('reports increasing progress up to 1 and returns the parsed JSON', async () => {
|
||||||
|
const half = Math.floor(bytes.length / 2);
|
||||||
|
const fetchMock = async () => new Response(stream([bytes.slice(0, half), bytes.slice(half)]), { headers: { 'content-length': String(bytes.length) } });
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
globalThis.fetch = fetchMock as typeof fetch;
|
||||||
|
try {
|
||||||
|
const seen: number[] = [];
|
||||||
|
const data = await fetchJson<{ hello: string }>('/x.json', (f) => seen.push(f));
|
||||||
|
expect(data.hello).toBe('world');
|
||||||
|
expect(seen.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(seen[seen.length - 1]).toBe(1);
|
||||||
|
expect([...seen].sort((a, b) => a - b)).toEqual(seen);
|
||||||
|
expect(Math.max(...seen.slice(0, -1))).toBeLessThan(1);
|
||||||
|
} finally { globalThis.fetch = real; }
|
||||||
|
});
|
||||||
|
it('uses the known decoded size when the server streams without a usable Content-Length (gzip + chunked)', async () => {
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
const third = Math.floor(bytes.length / 3);
|
||||||
|
globalThis.fetch = (async () => new Response(stream([bytes.slice(0, third), bytes.slice(third, 2 * third), bytes.slice(2 * third)]), { headers: { 'content-encoding': 'gzip' } })) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const seen: number[] = [];
|
||||||
|
await fetchJson('/x.json', (f) => seen.push(f), undefined, bytes.length);
|
||||||
|
expect(seen.length).toBeGreaterThanOrEqual(4); // three chunks + done
|
||||||
|
expect(seen[0]).toBeCloseTo(third / bytes.length, 2);
|
||||||
|
expect(seen[1]).toBeGreaterThan(seen[0]!);
|
||||||
|
expect(seen[seen.length - 1]).toBe(1);
|
||||||
|
} finally { globalThis.fetch = real; }
|
||||||
|
});
|
||||||
|
it('still works without a Content-Length header', async () => {
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => new Response(body)) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const seen: number[] = [];
|
||||||
|
expect((await fetchJson<{ hello: string }>('/x.json', (f) => seen.push(f))).hello).toBe('world');
|
||||||
|
expect(seen).toEqual([1]);
|
||||||
|
} finally { globalThis.fetch = real; }
|
||||||
|
});
|
||||||
|
it('rejects on an HTTP error', async () => {
|
||||||
|
const real = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () => new Response('nope', { status: 404 })) as typeof fetch;
|
||||||
|
try { await expect(fetchJson('/x.json')).rejects.toThrow('404'); } finally { globalThis.fetch = real; }
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/lib/offline.ts
Normal file
112
src/lib/offline.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// Offline pack: the map tiles of the demo area, saved through the service worker's tile cache so the map works without a connection.
|
||||||
|
// (The app shell, places, paths and the timetable are precached by the service worker itself, see vite.config.ts.)
|
||||||
|
// OSM's tile policy discourages bulk downloads: the pack is limited to the small demo area, fetched gently (few parallel requests),
|
||||||
|
// started by the app only on an unmetered connection, and each tile is downloaded once per device. A production deployment should
|
||||||
|
// point the map at its own tile server instead.
|
||||||
|
export const TILE_CACHE = 'osm-tiles';
|
||||||
|
export const PACK_VERSION = 1;
|
||||||
|
export const PACK_ZOOMS = [12, 13, 14, 15, 16, 17] as const;
|
||||||
|
const KEY = 'kbb.offline';
|
||||||
|
const PARALLEL = 5;
|
||||||
|
/** Average OSM raster tile, for the size estimate shown to the user. */
|
||||||
|
export const AVG_TILE_KB = 22;
|
||||||
|
|
||||||
|
export interface Tile { z: number; x: number; y: number }
|
||||||
|
export const tileUrl = (t: Tile) => `https://tile.openstreetmap.org/${t.z}/${t.x}/${t.y}.png`;
|
||||||
|
|
||||||
|
const lon2x = (lon: number, z: number) => Math.floor(((lon + 180) / 360) * 2 ** z);
|
||||||
|
const lat2y = (lat: number, z: number) => {
|
||||||
|
const r = (lat * Math.PI) / 180;
|
||||||
|
return Math.floor(((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * 2 ** z);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The map can be panned this far past the data area (MapView `maxBounds`): overview zooms cover all of it, so the edge is never blank. */
|
||||||
|
export const PAN_MARGIN = { lat: 0.03, lon: 0.04 };
|
||||||
|
/** Deepest zoom that also gets a one-tile ring around the data area (z17 stays the area itself to keep the download modest). */
|
||||||
|
const RING_MAX_Z = 16;
|
||||||
|
|
||||||
|
/** Tiles to keep for offline use around bbox [south, west, north, east]: padded area at overview zooms, area + one tile ring at street zooms. */
|
||||||
|
export function packTiles(bbox: readonly [number, number, number, number], zooms: readonly number[] = PACK_ZOOMS): Tile[] {
|
||||||
|
const [s0, w0, n0, e0] = bbox;
|
||||||
|
const out: Tile[] = [];
|
||||||
|
for (const z of zooms) {
|
||||||
|
const wide = z <= 15;
|
||||||
|
const [s, w, n, e] = wide ? [s0 - PAN_MARGIN.lat, w0 - PAN_MARGIN.lon, n0 + PAN_MARGIN.lat, e0 + PAN_MARGIN.lon] : [s0, w0, n0, e0];
|
||||||
|
const ring = !wide && z <= RING_MAX_Z ? 1 : 0;
|
||||||
|
for (let x = lon2x(w, z) - ring; x <= lon2x(e, z) + ring; x++) for (let y = lat2y(n, z) - ring; y <= lat2y(s, z) + ring; y++) out.push({ z, x, y });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackRecord { version: number; at: string; tiles: number; failed: number }
|
||||||
|
export function readRecord(): PackRecord | null {
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? 'null');
|
||||||
|
if (v && typeof v === 'object' && (v as PackRecord).version === PACK_VERSION && typeof (v as PackRecord).at === 'string') return v as PackRecord;
|
||||||
|
} catch { /* unreadable: treat as not downloaded */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export const writeRecord = (r: PackRecord | null) => {
|
||||||
|
try { if (r) localStorage.setItem(KEY, JSON.stringify(r)); else localStorage.removeItem(KEY); } catch { /* storage blocked */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const offlineSupported = () => 'serviceWorker' in navigator && 'caches' in window;
|
||||||
|
|
||||||
|
/** The service worker must control this page, or tile fetches would not be stored. Waits briefly for a first-visit claim. */
|
||||||
|
export async function swControlsPage(timeoutMs = 5000): Promise<boolean> {
|
||||||
|
if (!offlineSupported()) return false;
|
||||||
|
// `ready` never settles when registration is blocked (private mode, disabled storage): do not wait for it forever
|
||||||
|
const reg = await Promise.race([navigator.serviceWorker.ready, new Promise<null>((r) => setTimeout(() => r(null), timeoutMs))]);
|
||||||
|
if (!reg) return false;
|
||||||
|
if (navigator.serviceWorker.controller) return true;
|
||||||
|
return new Promise<boolean>((res) => {
|
||||||
|
const t = window.setTimeout(() => res(!!navigator.serviceWorker.controller), timeoutMs);
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => { window.clearTimeout(t); res(true); }, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which of the pack's tiles are already in the cache. */
|
||||||
|
export async function cachedTiles(tiles: Tile[]): Promise<Set<string>> {
|
||||||
|
const cache = await caches.open(TILE_CACHE);
|
||||||
|
const have = new Set((await cache.keys()).map((r) => r.url));
|
||||||
|
return new Set(tiles.map(tileUrl).filter((u) => have.has(u)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackProgress { done: number; total: number; failed: number }
|
||||||
|
/**
|
||||||
|
* Fetches every missing tile through the service worker (its CacheFirst rule stores them). Resolves with the final counts;
|
||||||
|
* gives up early when the network is clearly gone (several failures in a row).
|
||||||
|
*/
|
||||||
|
export async function downloadPack(tiles: Tile[], onProgress: (p: PackProgress) => void, signal?: AbortSignal): Promise<PackProgress> {
|
||||||
|
const have = await cachedTiles(tiles);
|
||||||
|
const todo = tiles.filter((t) => !have.has(tileUrl(t)));
|
||||||
|
const p: PackProgress = { done: tiles.length - todo.length, total: tiles.length, failed: 0 };
|
||||||
|
onProgress({ ...p });
|
||||||
|
let streak = 0;
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (next < todo.length && !signal?.aborted && streak < 6) {
|
||||||
|
const t = todo[next++]!;
|
||||||
|
try {
|
||||||
|
const res = await fetch(tileUrl(t), signal ? { signal } : undefined);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
await res.arrayBuffer(); // drain, so the cache write completes
|
||||||
|
p.done++;
|
||||||
|
streak = 0;
|
||||||
|
} catch {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
p.failed++;
|
||||||
|
streak++;
|
||||||
|
}
|
||||||
|
onProgress({ ...p });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: PARALLEL }, worker));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the downloaded tiles and the record (the app shell and data stay: they are the app itself). */
|
||||||
|
export async function clearPack(): Promise<void> {
|
||||||
|
writeRecord(null);
|
||||||
|
if ('caches' in window) await caches.delete(TILE_CACHE);
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,10 @@ export interface Settings {
|
||||||
textScale: 100 | 125 | 150;
|
textScale: 100 | 125 | 150;
|
||||||
highContrast: boolean;
|
highContrast: boolean;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
showDemoPlaces: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettings = (lang: Lang): Settings => ({
|
export const defaultSettings = (lang: Lang): Settings => ({
|
||||||
lang, textScale: 100, highContrast: false, reduceMotion: false, showDemoPlaces: true,
|
lang, textScale: 100, highContrast: false, reduceMotion: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function isSettings(v: unknown): v is Settings {
|
export function isSettings(v: unknown): v is Settings {
|
||||||
|
|
@ -17,7 +16,7 @@ export function isSettings(v: unknown): v is Settings {
|
||||||
const s = v as Record<string, unknown>;
|
const s = v as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
(s.lang === 'pl' || s.lang === 'en') && (s.textScale === 100 || s.textScale === 125 || s.textScale === 150) &&
|
(s.lang === 'pl' || s.lang === 'en') && (s.textScale === 100 || s.textScale === 125 || s.textScale === 150) &&
|
||||||
typeof s.highContrast === 'boolean' && typeof s.reduceMotion === 'boolean' && typeof s.showDemoPlaces === 'boolean'
|
typeof s.highContrast === 'boolean' && typeof s.reduceMotion === 'boolean'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Public transport: ZTP Kraków timetable (static GTFS, public/data/kk-transit.json) + optional GTFS-Realtime delays.
|
// Public transport: ZTP Kraków timetable (static GTFS, public/data/kk-transit.json) + optional GTFS-Realtime delays.
|
||||||
// Everything here is pure except `loadTransit` / `fetchRealtime`. Times are epoch milliseconds; the timetable is in Kraków local time.
|
// Everything here is pure except `loadTransit` / `fetchRealtime`. Times are epoch milliseconds; the timetable is in Kraków local time.
|
||||||
|
import { fetchJson } from './fetchProgress';
|
||||||
import { decodeFeed, type Alert, type TripUpdate } from './gtfsrt';
|
import { decodeFeed, type Alert, type TripUpdate } from './gtfsrt';
|
||||||
import { haversine, type LatLon } from './geo';
|
import { haversine, type LatLon } from './geo';
|
||||||
import { findRoute, HARD_FLAGS, type RouteGraph, type RouteResult } from './route';
|
import { findRoute, HARD_FLAGS, type RouteGraph, type RouteResult } from './route';
|
||||||
|
|
@ -31,30 +32,8 @@ const report = (f: number) => { downloaded = f; listeners.forEach((l) => l(f));
|
||||||
|
|
||||||
/** Timetable is loaded on first use (2.2 MB, ~0.4 MB compressed), not at app start. */
|
/** Timetable is loaded on first use (2.2 MB, ~0.4 MB compressed), not at app start. */
|
||||||
export function loadTransit(): Promise<TransitData> {
|
export function loadTransit(): Promise<TransitData> {
|
||||||
dataPromise ??= (async () => {
|
dataPromise ??= fetchJson<TransitData>(`${import.meta.env.BASE_URL}data/kk-transit.json`, report, undefined, typeof __DATA_BYTES__ === 'undefined' ? 0 : __DATA_BYTES__.transit)
|
||||||
const res = await fetch(`${import.meta.env.BASE_URL}data/kk-transit.json`);
|
.catch((e: unknown) => { dataPromise = null; report(0); throw e; });
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const len = Number(res.headers.get('content-length')) || 0;
|
|
||||||
// with Content-Encoding the header is the compressed size while the stream yields decompressed bytes (about 5x as many)
|
|
||||||
const total = len * (res.headers.get('content-encoding') ? 5 : 1);
|
|
||||||
if (!res.body || !total) { const d = (await res.json()) as TransitData; report(1); return d; }
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
let got = 0;
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
chunks.push(value);
|
|
||||||
got += value.length;
|
|
||||||
report(Math.min(0.97, got / total));
|
|
||||||
}
|
|
||||||
const all = new Uint8Array(got);
|
|
||||||
let at = 0;
|
|
||||||
for (const c of chunks) { all.set(c, at); at += c.length; }
|
|
||||||
const d = JSON.parse(new TextDecoder().decode(all)) as TransitData;
|
|
||||||
report(1);
|
|
||||||
return d;
|
|
||||||
})().catch((e: unknown) => { dataPromise = null; report(0); throw e; });
|
|
||||||
return dataPromise;
|
return dataPromise;
|
||||||
}
|
}
|
||||||
let indexCache: TransitIndex | null = null;
|
let indexCache: TransitIndex | null = null;
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ export interface Place {
|
||||||
/** ISO date of the last edit of the OSM element (not necessarily of the access tags). */
|
/** ISO date of the last edit of the OSM element (not necessarily of the access tags). */
|
||||||
ts: string | null;
|
ts: string | null;
|
||||||
tags: Record<string, string>;
|
tags: Record<string, string>;
|
||||||
/** Fictional place that exists only to demonstrate data states. */
|
|
||||||
demo?: boolean;
|
|
||||||
/** City-feed extras (GTFS stops). */
|
/** City-feed extras (GTFS stops). */
|
||||||
ext?: { gtfsWheelchair: number | null; feedDate: string | null; mode: 'tram' | 'bus'; osmMatch: string | null };
|
ext?: { gtfsWheelchair: number | null; feedDate: string | null; mode: 'tram' | 'bus'; osmMatch: string | null };
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +92,7 @@ export type Status = 'ok' | 'warn' | 'fail' | 'unknown' | 'conflict';
|
||||||
*/
|
*/
|
||||||
export type Confidence = 'verified' | 'mapped' | 'stale' | 'inferred' | 'user' | 'none';
|
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 SourceKind = 'osm-tag' | 'osm-geometry' | 'user-report' | 'city-review' | 'city-gtfs' | 'none';
|
||||||
|
|
||||||
export type FactKey = 'entrance' | 'steps' | 'ramp' | 'width' | 'surface' | 'toilet' | 'rest' | 'changing';
|
export type FactKey = 'entrance' | 'steps' | 'ramp' | 'width' | 'surface' | 'toilet' | 'rest' | 'changing';
|
||||||
|
|
||||||
|
|
@ -154,8 +152,6 @@ export interface UserReport {
|
||||||
status?: 'pending' | 'accepted' | 'rejected';
|
status?: 'pending' | 'accepted' | 'rejected';
|
||||||
reviewedAt?: string;
|
reviewedAt?: string;
|
||||||
reviewedBy?: string;
|
reviewedBy?: string;
|
||||||
/** Seeded example, not a real user report. */
|
|
||||||
demo?: boolean;
|
|
||||||
/** Photo stored in IndexedDB under this id (EXIF stripped, downscaled). */
|
/** Photo stored in IndexedDB under this id (EXIF stripped, downscaled). */
|
||||||
photoId?: string;
|
photoId?: string;
|
||||||
/** Measured on site, if any. */
|
/** Measured on site, if any. */
|
||||||
|
|
@ -187,4 +183,4 @@ export interface Vetting {
|
||||||
export type VetState =
|
export type VetState =
|
||||||
| { state: 'running' }
|
| { state: 'running' }
|
||||||
| { state: 'done'; result: Vetting }
|
| { state: 'done'; result: Vetting }
|
||||||
| { state: 'failed'; reason: 'no-credits' | 'error' | 'skipped' };
|
| { state: 'failed'; reason: 'no-credits' | 'error' | 'skipped' | 'offline' };
|
||||||
|
|
|
||||||
83
src/lib/useOffline.ts
Normal file
83
src/lib/useOffline.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { isMetered } from './store';
|
||||||
|
import { AVG_TILE_KB, cachedTiles, clearPack, downloadPack, offlineSupported, packTiles, readRecord, swControlsPage, writeRecord, type PackProgress, type PackRecord } from './offline';
|
||||||
|
|
||||||
|
export type PackPhase = 'checking' | 'unsupported' | 'idle' | 'consent' | 'downloading' | 'ready' | 'error';
|
||||||
|
export interface PackState {
|
||||||
|
phase: PackPhase;
|
||||||
|
progress: PackProgress;
|
||||||
|
record: PackRecord | null;
|
||||||
|
estimateMb: number;
|
||||||
|
/** Became ready during this session (to show a one-off "ready for offline" notice). */
|
||||||
|
justFinished: boolean;
|
||||||
|
start: () => void;
|
||||||
|
decline: () => void;
|
||||||
|
remove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO: PackProgress = { done: 0, total: 0, failed: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the offline map pack for `bbox` on the device. It starts by itself on an unmetered connection; on a metered one it asks first.
|
||||||
|
* Only works in a production build (the service worker is off in `pnpm dev`).
|
||||||
|
*/
|
||||||
|
export function useOfflinePack(bbox: readonly [number, number, number, number] | null, enabled: boolean): PackState {
|
||||||
|
const tiles = useMemo(() => (bbox ? packTiles(bbox) : []), [bbox]);
|
||||||
|
const [phase, setPhase] = useState<PackPhase>('checking');
|
||||||
|
const [progress, setProgress] = useState<PackProgress>(ZERO);
|
||||||
|
const [record, setRecord] = useState<PackRecord | null>(() => readRecord());
|
||||||
|
const [justFinished, setJustFinished] = useState(false);
|
||||||
|
const abort = useRef<AbortController | null>(null);
|
||||||
|
const running = useRef(false);
|
||||||
|
|
||||||
|
const run = useCallback(async () => {
|
||||||
|
if (running.current || tiles.length === 0) return;
|
||||||
|
running.current = true;
|
||||||
|
setPhase('downloading');
|
||||||
|
setProgress({ done: 0, total: tiles.length, failed: 0 });
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abort.current = ctrl;
|
||||||
|
try {
|
||||||
|
if (!(await swControlsPage())) { if (!ctrl.signal.aborted) setPhase('unsupported'); return; } // no service worker here: offline mode simply is not available
|
||||||
|
const res = await downloadPack(tiles, setProgress, ctrl.signal);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
if (res.done >= tiles.length * 0.98) {
|
||||||
|
const rec: PackRecord = { version: 1, at: new Date().toISOString(), tiles: res.done, failed: res.failed };
|
||||||
|
writeRecord(rec);
|
||||||
|
setRecord(rec);
|
||||||
|
setPhase('ready');
|
||||||
|
setJustFinished(true);
|
||||||
|
} else setPhase('error');
|
||||||
|
} catch {
|
||||||
|
if (!ctrl.signal.aborted) setPhase('error');
|
||||||
|
} finally {
|
||||||
|
running.current = false;
|
||||||
|
}
|
||||||
|
}, [tiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || tiles.length === 0) return;
|
||||||
|
if (!import.meta.env.PROD || !offlineSupported()) { setPhase('unsupported'); return; }
|
||||||
|
let alive = true;
|
||||||
|
void (async () => {
|
||||||
|
const rec = readRecord();
|
||||||
|
const have = rec ? (await cachedTiles(tiles)).size : 0;
|
||||||
|
if (!alive) return;
|
||||||
|
if (rec && have >= tiles.length * 0.95) { setRecord(rec); setPhase('ready'); setProgress({ done: have, total: tiles.length, failed: 0 }); return; }
|
||||||
|
if (sessionStorage.getItem('kbb.offline.declined') === '1') { setPhase('idle'); return; }
|
||||||
|
if (isMetered()) setPhase('consent');
|
||||||
|
else void run();
|
||||||
|
})();
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [enabled, tiles, run]);
|
||||||
|
|
||||||
|
useEffect(() => () => abort.current?.abort(), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase, progress, record, justFinished,
|
||||||
|
estimateMb: Math.round((tiles.length * AVG_TILE_KB) / 1024),
|
||||||
|
start: () => { sessionStorage.removeItem('kbb.offline.declined'); void run(); },
|
||||||
|
decline: () => { sessionStorage.setItem('kbb.offline.declined', '1'); setPhase('idle'); },
|
||||||
|
remove: () => { abort.current?.abort(); void clearPack().then(() => { setRecord(null); setJustFinished(false); setPhase('idle'); setProgress(ZERO); }); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ export function useTransitData(enabled: boolean): { ix: TransitIndex | null; err
|
||||||
let rtCache: { at: number; p: Promise<Realtime> } | null = null;
|
let rtCache: { at: number; p: Promise<Realtime> } | null = null;
|
||||||
/** One shared real-time fetch for everything on screen (the feeds update about every 30 s). */
|
/** One shared real-time fetch for everything on screen (the feeds update about every 30 s). */
|
||||||
export function getRealtime(maxAgeMs = 15_000): Promise<Realtime> {
|
export function getRealtime(maxAgeMs = 15_000): Promise<Realtime> {
|
||||||
|
if (typeof navigator !== 'undefined' && !navigator.onLine) return Promise.resolve(NO_REALTIME); // offline: timetable only, no wait for a feed that cannot answer
|
||||||
if (!rtCache || Date.now() - rtCache.at > maxAgeMs) rtCache = { at: Date.now(), p: fetchRealtime().catch(() => NO_REALTIME) };
|
if (!rtCache || Date.now() - rtCache.at > maxAgeMs) rtCache = { at: Date.now(), p: fetchRealtime().catch(() => NO_REALTIME) };
|
||||||
return rtCache.p;
|
return rtCache.p;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -564,3 +564,35 @@ body { margin: 0; }
|
||||||
.kbb-progress::-webkit-progress-value { background: #005ea2; border-radius: 0.45rem; transition: width 0.25s ease; }
|
.kbb-progress::-webkit-progress-value { background: #005ea2; border-radius: 0.45rem; transition: width 0.25s ease; }
|
||||||
.kbb-progress::-moz-progress-bar { background: #005ea2; border-radius: 0.45rem; }
|
.kbb-progress::-moz-progress-bar { background: #005ea2; border-radius: 0.45rem; }
|
||||||
@media (prefers-reduced-motion: reduce) { .kbb-progress::-webkit-progress-value { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .kbb-progress::-webkit-progress-value { transition: none; } }
|
||||||
|
|
||||||
|
/* ---- status banners (offline, offline-pack download) ---- */
|
||||||
|
.kbb-banners:empty { display: none; }
|
||||||
|
.kbb-banner { display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.55rem 0.5rem 0.55rem 0.9rem; border-bottom: 1px solid rgba(0, 0, 0, 0.15); font-size: 0.95rem; line-height: 1.35; }
|
||||||
|
.kbb-banner--warning { background: #faf3d1; border-left: 0.4rem solid #ffbe2e; }
|
||||||
|
.kbb-banner--info { background: #e7f6f8; border-left: 0.4rem solid #00bde3; }
|
||||||
|
.kbb-banner--success { background: #ecf3ec; border-left: 0.4rem solid #4d8055; }
|
||||||
|
.kbb-banner__icon { flex: none; margin-top: 0.15rem; fill: #1b1b1b; }
|
||||||
|
.kbb-banner__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.kbb-banner__small { font-size: 0.85rem; color: #454545; }
|
||||||
|
.kbb-banner__actions { flex: none; align-self: center; }
|
||||||
|
.kbb-banner__close { appearance: none; flex: none; display: inline-flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; margin: -0.5rem 0 -0.5rem 0; padding: 0; border: 0; border-radius: 0.25rem; background: none; color: #1b1b1b; cursor: pointer; }
|
||||||
|
.kbb-banner__close .usa-icon { fill: currentColor; }
|
||||||
|
.kbb-banner__close:hover { background: rgba(0, 0, 0, 0.08); }
|
||||||
|
.kbb-banner__close:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
|
||||||
|
.kbb-progress--thin { height: 0.5rem; border-radius: 0.25rem; }
|
||||||
|
.kbb-progress--thin::-webkit-progress-bar, .kbb-progress--thin::-webkit-progress-value { border-radius: 0.25rem; }
|
||||||
|
.kbb-offcard { margin: 1rem 0 1.5rem; padding: 1rem; border: 1px solid #dfe1e2; border-radius: 0.35rem; background: #f7f9fa; }
|
||||||
|
.kbb-offcard__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
|
||||||
|
|
||||||
|
/* ---- first-load splash and map loading ---- */
|
||||||
|
.kbb-splash { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.6rem; min-height: 100%; padding: 2rem 1.5rem; text-align: center; }
|
||||||
|
.kbb-splash .kbb-progress { max-width: 22rem; }
|
||||||
|
.kbb-splash__title { margin: 0.25rem 0 0; font-size: 1.75rem; color: #1a4480; }
|
||||||
|
.kbb-splash__stage { margin: 0; }
|
||||||
|
.kbb-maploading { position: absolute; z-index: 3; left: 0; right: 0; top: 0; height: 0.3rem; overflow: hidden; background: rgba(0, 94, 162, 0.2); pointer-events: none; }
|
||||||
|
.kbb-maploading::after { content: ''; position: absolute; inset: 0 auto 0 0; width: 35%; background: #005ea2; animation: kbb-slide 1.1s ease-in-out infinite; }
|
||||||
|
@keyframes kbb-slide { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
|
||||||
|
.kbb-mapsplash { position: absolute; z-index: 4; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.75rem; background: #e9eef2; color: #1b1b1b; }
|
||||||
|
.kbb-spinner { width: 2.75rem; height: 2.75rem; border: 0.35rem solid #c9d3da; border-top-color: #005ea2; border-radius: 50%; animation: kbb-spin 0.9s linear infinite; }
|
||||||
|
@keyframes kbb-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .kbb-maploading::after { animation: none; width: 100%; opacity: 0.6; } .kbb-spinner { animation-duration: 3s; } }
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,16 @@ import { defineConfig } from 'vitest/config';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import basicSsl from '@vitejs/plugin-basic-ssl';
|
import basicSsl from '@vitejs/plugin-basic-ssl';
|
||||||
|
import { statSync } from 'node:fs';
|
||||||
import { APP_NAME } from './src/config';
|
import { APP_NAME } from './src/config';
|
||||||
|
|
||||||
|
// Exact (decoded) sizes of the big data files: servers compress and stream them without a Content-Length, so the loading bar needs these.
|
||||||
|
const size = (f: string) => { try { return statSync(f).size; } catch { return 0; } };
|
||||||
|
const DATA_BYTES = { snapshot: size('public/data/kk-snapshot.json'), transit: size('public/data/kk-transit.json') };
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
|
define: { __DATA_BYTES__: JSON.stringify(DATA_BYTES) },
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
// index.html uses %APP_NAME%: one name for the whole app (src/config.ts)
|
// index.html uses %APP_NAME%: one name for the whole app (src/config.ts)
|
||||||
|
|
@ -33,25 +39,41 @@ export default defineConfig({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
// Offline-first: the whole app (code, fonts, icons, places, paths, timetable) is precached on the first visit.
|
||||||
globPatterns: ['**/*.{js,mjs,css,html,svg,png,json,woff2}'],
|
globPatterns: ['**/*.{js,mjs,css,html,svg,png,json,woff2}'],
|
||||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 12 * 1024 * 1024, // the places snapshot is ~5.5 MB
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/rt\//], // live feeds are never replaced by the app shell
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
skipWaiting: true,
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// street map: the offline pack (src/lib/offline.ts) fills this cache; tiles seen while browsing land here too
|
||||||
|
urlPattern: /^https:\/\/tile\.openstreetmap\.org\/.*/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'osm-tiles',
|
||||||
|
expiration: { maxEntries: 3000, maxAgeSeconds: 60 * 60 * 24 * 180, purgeOnQuotaError: true },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/server\.arcgisonline\.com\/.*/,
|
urlPattern: /^https:\/\/server\.arcgisonline\.com\/.*/,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'sat-tiles',
|
cacheName: 'sat-tiles',
|
||||||
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 14 },
|
expiration: { maxEntries: 1500, maxAgeSeconds: 60 * 60 * 24 * 60, purgeOnQuotaError: true },
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*/,
|
// photo assessment: the on-device AI runtime and model are large; once used online they work offline too
|
||||||
|
urlPattern: ({ url }: { url: URL }) => url.pathname.endsWith('.wasm') || /(^|\.)(huggingface\.co|hf\.co)$/.test(url.hostname),
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'osm-tiles',
|
cacheName: 'ai-runtime',
|
||||||
expiration: { maxEntries: 600, maxAgeSeconds: 60 * 60 * 24 * 14 },
|
expiration: { maxEntries: 40, purgeOnQuotaError: true },
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue