Fix maps inconcistencies, improve GPS handling, Improve Offline performance

This commit is contained in:
InfoX 2026-10-03 23:42:38 +02:00
parent fa5f176004
commit 0ce1b7f376
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
14 changed files with 265 additions and 143 deletions

View file

@ -7,7 +7,7 @@ import { MapView, type JourneyOverlay } from './components/MapView';
import { PlaceDetail, type ReachState, type RefreshState } from './components/PlaceDetail';
import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './components/PlacesView';
import { ProfileView } from './components/ProfileView';
import { RouteView, type RouteForm } from './components/RouteView';
import { RouteView, transitRecommendation, type RouteForm } from './components/RouteView';
import { LangToggle } from './components/LangToggle';
import { SettingsPanel } from './components/SettingsPanel';
import { SheetHandle, type Sheet } from './components/SheetHandle';
@ -34,7 +34,7 @@ import { StatusBanners } from './components/StatusBanners';
import { fetchJson } from './lib/fetchProgress';
import { useOfflinePack } from './lib/useOffline';
import { useJourneys } from './lib/useTransit';
import { loadTransitIndex } from './lib/transit';
import { loadTransitIndex, transitDominated } from './lib/transit';
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types';
@ -42,14 +42,14 @@ type View = 'places' | 'route' | 'dashboard' | 'about' | 'profile';
type Decision = { status: 'accepted' | 'rejected'; reviewedAt: string; reviewedBy: string };
const isLang = (v: unknown): v is Lang => v === 'pl' || v === 'en';
const detectLang = (): Lang => (navigator.language.toLowerCase().startsWith('pl') ? 'pl' : 'en');
const DEFAULT_LANG: Lang = 'pl';
const isStrOrNull = (v: unknown): v is string | null => v === null || typeof v === 'string';
const isStrArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === 'string');
const isObj = <T extends object>(v: unknown): v is T => typeof v === 'object' && v !== null && !Array.isArray(v);
export default function App() {
// ---- identity (demo) ----
const [loginLang, setLoginLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
const [loginLang, setLoginLang] = useLocalState<Lang>('kbb.lang', DEFAULT_LANG, isLang);
const [session, setSession] = useLocalState<string | null>('kbb.session', null, isStrOrNull);
const account: Account | null = findAccount(session);
const uid = account?.id ?? 'none';
@ -243,7 +243,7 @@ export default function App() {
const [routeSel, setRouteSel] = useState(0);
const [guiding, setGuiding] = useState(false);
const [userPos, setUserPos] = useState<LatLon | null>(null);
const [basemap, setBasemap] = useLocalState<'map' | 'satellite'>('kbb.basemap', 'map', (v): v is 'map' | 'satellite' => v === 'map' || v === 'satellite');
const [basemap, setBasemap] = useLocalState<'map' | 'satellite'>('kbb.basemap', 'satellite', (v): v is 'map' | 'satellite' => v === 'map' || v === 'satellite');
const route = routeOptions?.[routeSel]?.result ?? null;
const altRoutes = useMemo(() => (routeOptions ?? []).filter((_, i) => i !== routeSel).map((o) => o.result.coords), [routeOptions, routeSel]);
const [routeEnds, setRouteEnds] = useState<{ from: LatLon; to: LatLon } | null>(null);
@ -252,19 +252,26 @@ export default function App() {
const transitStops = useMemo(() => realPlaces.filter((p) => p.cat === 'transit'), [realPlaces]);
// warm the timetable as soon as a route exists, so the "Komunikacja" tab is usually instant (skipped on metered links)
useEffect(() => { if (routeEnds && !isMetered()) void loadTransitIndex().catch(() => undefined); }, [routeEnds]);
const planner = useJourneys({ enabled: view === 'route' && routeTab === 'transit', ends: routeEnds, graph, profile, stops: transitStops });
// planned in the background too (not on metered links): the comparison with walking decides whether the transit tab is offered at all
const planner = useJourneys({ enabled: view === 'route' && !!routeEnds && (routeTab === 'transit' || !isMetered()), ends: routeEnds, graph, profile, stops: transitStops });
const walkOpt = routeOptions?.[routeSel];
const transitUnavailable = !!walkOpt && walkOpt.result.status !== 'none' && planner.status === 'ready'
&& transitDominated({ durationMin: walkOpt.result.durationMin, distanceM: walkOpt.result.distanceM }, planner.journeys);
useEffect(() => { if (transitUnavailable && routeTab === 'transit') setRouteTab('walk'); }, [transitUnavailable, routeTab]);
const journey = useMemo<JourneyOverlay | null>(() => {
const j = view === 'route' && routeTab === 'transit' && planner.status === 'ready' ? planner.journeys[planner.selected] : undefined;
if (!j) return null;
return {
walks: [j.walkTo.coords, ...(j.transfer ? [j.transfer.walk.coords] : []), j.walkFrom.coords],
walks: [j.walkTo, ...(j.transfer ? [j.transfer.walk] : []), j.walkFrom],
rides: j.rides.map((r) => ({ path: r.path, mode: r.dep.route.mode })),
stops: j.rides.flatMap((r) => [{ at: [r.from.lat, r.from.lon] as LatLon, label: `${r.dep.line} · ${r.from.name ?? ''}` }, { at: [r.to.lat, r.to.lon] as LatLon, label: `${r.dep.line} · ${r.to.name ?? ''}` }]),
};
}, [view, routeTab, planner]);
const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => {
setRouteOptions(o);
setRouteSel(Math.max(0, o?.findIndex((x) => x.recommended) ?? 0)); // the recommended variant is the default
const sel = Math.max(0, o?.findIndex((x) => x.recommended) ?? 0); // the recommended variant is the default
setRouteSel(sel);
setRouteTab(from && to && transitRecommendation(o?.[sel]?.result ?? null) ? 'transit' : 'walk'); // show what is advised right away
setRouteEnds(from && to ? { from, to } : null);
if (o?.[0] && o[0].result.status !== 'none') setSheet('half');
};
@ -522,8 +529,8 @@ export default function App() {
))}
{view === 'route' && (
<RouteView
graph={graph} labels={labels} profile={profile} bbox={snap.meta.bbox}
tab={routeTab} onTab={setRouteTab} planner={planner} onShowTransit={() => setSheet((s) => (s === 'full' ? 'half' : s))}
graph={graph} labels={labels} profile={profile}
tab={routeTab} onTab={setRouteTab} transitUnavailable={transitUnavailable} planner={planner} onShowTransit={() => setSheet((s) => (s === 'full' ? 'half' : s))}
form={routeForm} onForm={setRouteForm} options={routeOptions} ends={routeEnds} guiding={guiding} onGuide={() => { setGuiding(true); setPane('map'); }} selected={routeSel} onSelect={setRouteSel} onResult={onResult}
onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
/>
@ -545,7 +552,7 @@ export default function App() {
focus={focus}
visible={sheet}
bbox={snap.meta.bbox}
basemap={basemap}
basemap={online ? basemap : 'map'} // satellite tiles are not in the offline pack: without internet the street map is shown
onBasemap={setBasemap}
userPos={view === 'route' && guiding ? userPos : null}
follow={guiding}

View file

@ -8,8 +8,11 @@ import type { Hotspot } from '../lib/civic';
import { HARD_FLAGS, type RouteResult } from '../lib/route';
import type { Place, Verdict } from '../lib/types';
import { CATEGORY_ICON, glyphBitmap } from '../lib/categoryIcon';
import { KRAKOW_BOUNDS } from '../config';
import { registerTileProtocol, tileTemplate } from '../lib/tileProtocol';
import { VERDICT_STYLE } from './ui';
registerTileProtocol();
maplibregl.setWorkerUrl(new URL(`${import.meta.env.BASE_URL}maplibre/maplibre-gl-worker.mjs`, document.baseURI).href);
const STYLE: StyleSpecification = {
@ -17,7 +20,7 @@ const STYLE: StyleSpecification = {
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tiles: [tileTemplate('osm')],
tileSize: 256,
maxzoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a>',
@ -27,7 +30,7 @@ const STYLE: StyleSpecification = {
};
STYLE.sources.sat = {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tiles: [tileTemplate('sat')],
tileSize: 256,
maxzoom: 19,
attribution: 'Imagery © Esri, Maxar, Earthstar Geographics',
@ -60,6 +63,32 @@ async function addGlyphs(map: MLMap) {
} catch { /* glyphs are decoration: the coloured dots still work without them */ }
}
/** Warning / error marker for a barrier or caution on a walking route (null for the kinds that are not worth a marker). */
function barrierPin(b: RouteResult['barriers'][number], t: (k: Key) => string): { el: HTMLElement; text: string } | null {
if (b.flag === 'surface-unknown' || b.flag === 'ramp-steps') return null;
const hard = HARD_FLAGS.has(b.flag);
const el = document.createElement('button');
el.type = 'button';
el.className = `kbb-pin kbb-pin--barrier ${hard ? 'kbb-pin--hard' : ''}`;
const text = `${t(`flag.${b.flag}` as Key)}${b.name ? ` · ${b.name}` : ''}`;
el.setAttribute('aria-label', text);
el.innerHTML = `<svg class="usa-icon" aria-hidden="true" focusable="false"><use href="${SPRITE}#${hard ? 'error' : 'warning'}"></use></svg>`;
return { el, text };
}
/** One line feature per route segment, with the barrier status that picks its colour. */
function walkFeatures(route: RouteResult, extra: Record<string, string>) {
return route.segments.map((s) => {
const hard = s.flags.some((f) => HARD_FLAGS.has(f));
const warn = s.flags.some((f) => f !== 'surface-unknown' && f !== 'ramp-steps' && !HARD_FLAGS.has(f));
return {
type: 'Feature' as const,
geometry: { type: 'LineString' as const, coordinates: s.coords.map(lngLat) },
properties: { ...extra, status: hard ? 'blocked' : warn ? 'warn' : 'ok' },
};
});
}
const lngLat = (p: LatLon): [number, number] => [p[1], p[0]];
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
@ -91,7 +120,7 @@ interface Props {
}
export interface JourneyOverlay {
walks: LatLon[][];
walks: RouteResult[];
rides: { path: LatLon[]; mode: 'bus' | 'tram' }[];
stops: { at: LatLon; label: string }[];
}
@ -148,7 +177,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
maxPitch: 0, // flat map only: no two-finger tilt, no ctrl/right-drag tilt
dragRotate: false,
touchPitch: false,
maxBounds: [[w - 0.04, s - 0.03], [e + 0.04, n + 0.03]],
maxBounds: [[KRAKOW_BOUNDS[1], KRAKOW_BOUNDS[0]], [KRAKOW_BOUNDS[3], KRAKOW_BOUNDS[2]]],
attributionControl: { compact: true },
locale: {
'NavigationControl.ZoomIn': t('map.zoomIn'),
@ -167,7 +196,17 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
map.addSource('journey', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'journey-casing', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'ride'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 10 } });
map.addLayer({ id: 'journey-ride', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'ride'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': ['match', ['get', 'mode'], 'tram', '#a2461c', '#54278f'], 'line-width': 6 } });
map.addLayer({ id: 'journey-walk', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'walk'], layout: { 'line-cap': 'butt', 'line-join': 'round' }, paint: { 'line-color': '#005ea2', 'line-width': 4.5, 'line-dasharray': [1.5, 1.5] } });
// walking legs look exactly like a walking route: white casing, solid blue, brown dashes at cautions, red at hard barriers
map.addLayer({ id: 'journey-walk-casing', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'walk'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 9 } });
const walkLine = (status: string, color: string, width: number, dash?: number[]) =>
map.addLayer({
id: `journey-walk-${status}`, type: 'line', source: 'journey', filter: ['all', ['==', ['get', 'kind'], 'walk'], ['==', ['get', 'status'], status]],
layout: { 'line-cap': dash ? 'butt' : 'round', 'line-join': 'round' },
paint: { 'line-color': color, 'line-width': width, ...(dash ? { 'line-dasharray': dash } : {}) },
});
walkLine('ok', '#005ea2', 5);
walkLine('warn', '#a2461c', 5, [2, 1.5]);
walkLine('blocked', '#b50909', 6, [0.4, 1.6]);
map.addSource('reach', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'reach', type: 'line', source: 'reach',
@ -280,18 +319,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
src.setData({ type: 'FeatureCollection', features: [] });
return;
}
src.setData({
type: 'FeatureCollection',
features: route.segments.map((s) => {
const hard = s.flags.some((f) => HARD_FLAGS.has(f));
const warn = s.flags.some((f) => f !== 'surface-unknown' && f !== 'ramp-steps' && !HARD_FLAGS.has(f));
return {
type: 'Feature' as const,
geometry: { type: 'LineString' as const, coordinates: s.coords.map(lngLat) },
properties: { status: hard ? 'blocked' : warn ? 'warn' : 'ok' },
};
}),
});
src.setData({ type: 'FeatureCollection', features: walkFeatures(route, {}) });
const addMarker = (el: HTMLElement, at: LatLon, popupText?: string) => {
const m = new maplibregl.Marker({ element: el }).setLngLat(lngLat(at));
@ -312,15 +340,8 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
addMarker(pin('B', t('route.end'), 'kbb-pin--end'), routeEnds.to);
}
for (const b of route.barriers) {
if (b.flag === 'surface-unknown' || b.flag === 'ramp-steps') continue;
const hard = HARD_FLAGS.has(b.flag);
const el = document.createElement('button');
el.type = 'button';
el.className = `kbb-pin kbb-pin--barrier ${hard ? 'kbb-pin--hard' : ''}`;
const text = `${t(`flag.${b.flag}` as Key)}${b.name ? ` · ${b.name}` : ''}`;
el.setAttribute('aria-label', text);
el.innerHTML = `<svg class="usa-icon" aria-hidden="true" focusable="false"><use href="${SPRITE}#${hard ? 'error' : 'warning'}"></use></svg>`;
addMarker(el, b.at, text);
const pinned = barrierPin(b, t);
if (pinned) addMarker(pinned.el, b.at, pinned.text);
}
const bounds = new maplibregl.LngLatBounds();
route.coords.forEach((c) => bounds.extend(lngLat(c)));
@ -339,7 +360,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
src.setData({
type: 'FeatureCollection',
features: [
...journey.walks.map((w) => ({ type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: w.map(lngLat) }, properties: { kind: 'walk', mode: '' } })),
...journey.walks.flatMap((w) => walkFeatures(w, { kind: 'walk', mode: '' })),
...journey.rides.map((r) => ({ type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: r.path.map(lngLat) }, properties: { kind: 'ride', mode: r.mode } })),
],
});
@ -361,6 +382,12 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
mark(pin('A', t('route.start'), 'kbb-pin--start'), routeEnds.from);
mark(pin('B', t('route.end'), 'kbb-pin--end'), routeEnds.to);
}
for (const w of journey.walks) {
for (const b of w.barriers) {
const pinned = barrierPin(b, t);
if (pinned) mark(pinned.el, b.at, pinned.text);
}
}
for (const s of journey.stops) {
const el = document.createElement('div');
el.className = 'kbb-pin kbb-pin--stop';
@ -370,7 +397,7 @@ export function MapView({ places, showPlaces, selectedId, onSelect, route, route
mark(el, s.at, s.label);
}
const bounds = new maplibregl.LngLatBounds();
[...journey.walks.flat(), ...journey.rides.flatMap((r) => r.path)].forEach((c) => bounds.extend(lngLat(c)));
[...journey.walks.flatMap((w) => w.coords), ...journey.rides.flatMap((r) => r.path)].forEach((c) => bounds.extend(lngLat(c)));
if (!bounds.isEmpty()) fit(map, bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 16 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, journey, routeEnds, lang]);

View file

@ -1,20 +1,27 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { hasKey, useI18n, type Key } from '../lib/i18n';
import { useI18n, type Key } from '../lib/i18n';
import type { LatLon } from '../lib/geo';
import { resolveLabel, type Labels } from '../lib/labels';
import type { AreaLabel } from '../lib/placeNames';
import { findAlternatives, HARD_FLAGS, type RouteGraph, type RouteOption, type RouteResult, type RouteSegment } from '../lib/route';
import { findAlternatives, type RouteGraph, type RouteOption, type RouteResult } from '../lib/route';
import type { Profile } from '../lib/types';
import { appleMapsUrl, googleMapsUrl } from '../lib/links';
import { Alert, Icon } from './ui';
import { PlaceInput } from './PlaceInput';
import { getPosition, kmOutside, LocateFailure } from '../lib/locate';
import { getPosition, LocateFailure } from '../lib/locate';
import { TransitPlan } from './TransitUi';
import { WalkDetails } from './WalkDetails';
import type { PlannerState } from '../lib/useTransit';
/** Walking longer than this (minutes) gets public transport recommended. */
const LONG_WALK_MIN = 30;
/** Walking is a poor answer when there is no barrier-free walk or it takes long: public transport is recommended then. */
export function transitRecommendation(result: RouteResult | null): 'long' | 'blocked' | null {
if (!result) return null;
if (result.status === 'none' || result.status === 'blocked') return 'blocked';
return result.durationMin > LONG_WALK_MIN ? 'long' : null;
}
export interface RouteForm {
fromText: string;
toText: string;
@ -26,7 +33,6 @@ interface Props {
graph: RouteGraph;
labels: Labels;
profile: Profile;
bbox: [number, number, number, number];
form: RouteForm;
onForm: (f: RouteForm) => void;
options: RouteOption[] | null;
@ -39,11 +45,13 @@ interface Props {
onFocus: (p: LatLon) => void;
tab: 'walk' | 'transit';
onTab: (t: 'walk' | 'transit') => void;
/** Transit is slower than walking and has more barriers: its tab is switched off. */
transitUnavailable: boolean;
planner: PlannerState;
onShowTransit: () => void;
}
export function RouteView({ graph, labels, profile, bbox, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, onFocus, tab, onTab, planner, onShowTransit }: Props) {
export function RouteView({ graph, labels, profile, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, onFocus, tab, onTab, transitUnavailable, planner, onShowTransit }: Props) {
const result = options?.[selected]?.result ?? null;
const { t, tm } = useI18n();
const [error, setError] = useState<string | null>(null);
@ -62,8 +70,7 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, options,
};
const resultRef = useRef<HTMLDivElement>(null);
// a long walk, or no barrier-free walk at all, is where public transport is the better answer
const recommendTransit: 'long' | 'blocked' | null =
!result ? null : result.status === 'none' || result.status === 'blocked' ? 'blocked' : result.durationMin > LONG_WALK_MIN ? 'long' : null;
const recommendTransit = transitUnavailable ? null : transitRecommendation(result);
useEffect(() => {
if (result) { resultRef.current?.scrollIntoView({ block: 'start' }); resultRef.current?.focus({ preventScroll: true }); }
}, [options]);
@ -85,8 +92,6 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, options,
getPosition().then(
(pos) => {
setLocating(false);
const km = kmOutside(pos, bbox);
if (km > 0.05) { showLocMsg(t('route.locOut', { km: km < 10 ? km.toFixed(1) : String(Math.round(km)) })); return; }
onForm({ ...form, fromText: t('route.myLocation'), fromPoint: pos });
},
(e: unknown) => {
@ -146,20 +151,21 @@ export function RouteView({ graph, labels, profile, bbox, form, onForm, options,
{ends && (
<div className="kbb-modes" role="group" aria-label={t('route.tabs')}>
{(['walk', 'transit'] as const).map((m) => (
<button key={m} type="button" aria-pressed={tab === m} className={`kbb-mode ${tab === m ? 'is-on' : ''}`} onClick={() => onTab(m)}>
<button key={m} type="button" aria-pressed={tab === m} disabled={m === 'transit' && transitUnavailable} className={`kbb-mode ${tab === m ? 'is-on' : ''}`} onClick={() => onTab(m)}>
<Icon name={m === 'walk' ? 'directions_walk' : 'directions_bus'} size={3} />{t(m === 'walk' ? 'route.tab.walk' : 'route.tab.transit')}
{m === 'transit' && recommendTransit && <span className="kbb-mode__tag">{t('tr.recommended')}</span>}
</button>
))}
</div>
)}
{ends && transitUnavailable && <p className="font-sans-3xs text-base-dark margin-y-05" role="status">{t('tr.unavailable')}</p>}
{ends && tab === 'walk' && recommendTransit && (
<Alert kind="info" slim>
{recommendTransit === 'blocked' ? t('route.recommend.blocked') : t('route.recommend.long', { n: Math.round(result?.durationMin ?? 0) })}{' '}
<button type="button" className="usa-button usa-button--unstyled" onClick={() => onTab('transit')}>{t('route.recommend.cta')}</button>
</Alert>
)}
{ends && tab === 'transit' && <TransitPlan planner={planner} onShow={onShowTransit} />}
{ends && tab === 'transit' && <TransitPlan planner={planner} onShow={onShowTransit} onFocus={onFocus} />}
{result && tab === 'walk' && <Summary result={result} t={t} />}
{options && options.length > 0 && result && result.status !== 'none' && tab === 'walk' && (
<Variants options={options} selected={selected} onSelect={onSelect} />
@ -210,53 +216,12 @@ function Summary({ result, t }: { result: RouteResult; t: T }) {
);
}
/** Where a segment or barrier is: its own name, else the park it crosses / the street it runs along. */
function placeName(x: { name: string | null; near?: AreaLabel | undefined }, t: T): string | null {
if (x.name) return x.name;
return x.near ? t(`seg.near.${x.near.kind}` as Key, { name: x.near.name }) : null;
}
function Details({ result, onFocus, t }: { result: RouteResult; onFocus: (p: LatLon) => void; t: T }) {
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
return (
<>
<h2 className="font-sans-lg">{t('route.barriers')}</h2>
{notes.length === 0 ? (
<p>{t('route.barriers.none')}</p>
) : (
<ul className="usa-list--unstyled margin-0 padding-0">
{notes.map((b, i) => {
const where = placeName(b, t);
return (
<li key={`${b.wayId}-${b.flag}-${i}`} className="kbb-barrier">
<span>
<Icon name={HARD_FLAGS.has(b.flag) ? 'error' : 'warning'} size={3} className={`margin-right-05 ${HARD_FLAGS.has(b.flag) ? 'text-error-dark' : 'text-warning-darker'}`} />
<strong>{t(`flag.${b.flag}` as Key)}</strong>
{where ? ` · ${where}` : ''}
{b.detail && b.flag === 'steps' ? ` · ${b.detail}` : ''}
{b.lengthM >= 10 ? ` · ${Math.round(b.lengthM / 5) * 5} m` : ''}
</span>
<button type="button" className="usa-button usa-button--unstyled font-sans-xs margin-left-1" onClick={() => onFocus(b.at)}>
{t('route.focus')}
</button>
</li>
);
})}
</ul>
)}
<details className="kbb-reach border-1px border-base-lighter radius-md margin-top-2 bg-base-lightest">
<summary>
<Icon name="list" size={3} className="margin-right-05 text-primary" />
<span className="flex-1">{t('route.steps')}</span>
<Icon name="expand_more" size={3} className="kbb-chevron" />
</summary>
<ol className="usa-list margin-top-0 padding-right-2">
{result.segments.map((s, i) => (
<li key={i}>{segmentText(s, i === 0, t)}</li>
))}
</ol>
</details>
<WalkDetails result={result} onFocus={onFocus} />
<p className="font-sans-xs margin-bottom-0">{t('route.quality', { pct: result.stats.knownSurfacePct })}</p>
{result.stats.unknownKerbs > 0 && <p className="font-sans-xs margin-y-05">{t('route.unknownKerbs', { n: result.stats.unknownKerbs })}</p>}
@ -266,21 +231,6 @@ function Details({ result, onFocus, t }: { result: RouteResult; onFocus: (p: Lat
);
}
function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI18n>['t']) {
const kindKey = `seg.hw.${s.hw}`;
const kindLabel = hasKey(kindKey) ? t(kindKey) : s.hw;
const surface = s.surface ? t('seg.surfaceSuffix', { surface: `@surface.${s.surface}` }) : '';
const flags = s.flags.filter((f) => f !== 'surface-unknown' && f !== 'ramp-steps').map((f) => t(`flag.${f}` as Key));
const line = t('seg.line', {
turn: t(`turn.${first ? 'start' : s.turn}` as Key),
name: placeName(s, t) ?? t('seg.unnamed'),
kind: kindLabel,
m: Math.max(5, Math.round(s.distanceM / 5) * 5),
surface,
});
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
}
function Variants({ options, selected, onSelect }: { options: RouteOption[]; selected: number; onSelect: (i: number) => void }) {
const { t } = useI18n();
const rec = options.findIndex((o) => o.recommended);

View file

@ -1,11 +1,12 @@
import { useMemo, useState } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import { bearing, haversine } from '../lib/geo';
import { bearing, haversine, type LatLon } from '../lib/geo';
import { HARD_FLAGS, type RouteResult } from '../lib/route';
import { alertsFor, clock, departuresFrom, minutesUntil, type Departure, type Journey, type Ride, type TransitIndex, type TransitRoute } from '../lib/transit';
import { useNow, useRealtime, useTransitData, type PlannerState } from '../lib/useTransit';
import type { Place } from '../lib/types';
import { Alert, Icon } from './ui';
import { WalkDetails } from './WalkDetails';
/** Line number in a coloured tag; tram and bus differ by colour and by the spoken label. */
export function LineBadge({ route }: { route: TransitRoute }) {
@ -188,7 +189,7 @@ function RideLine({ ride, now }: { ride: Ride; now: number }) {
}
/** The list of connections for the current route ends. Selecting one draws it on the map. */
export function TransitPlan({ planner, onShow }: { planner: PlannerState; onShow: () => void }) {
export function TransitPlan({ planner, onShow, onFocus }: { planner: PlannerState; onShow: () => void; onFocus: (p: LatLon) => void }) {
const { t } = useI18n();
const now = useNow();
const { status, journeys, rt, selected } = planner;
@ -215,7 +216,7 @@ export function TransitPlan({ planner, onShow }: { planner: PlannerState; onShow
<fieldset className="usa-fieldset">
<legend className="usa-sr-only">{t('tr.pick')}</legend>
<ul className="usa-list--unstyled margin-0 padding-0">
{journeys.map((j, i) => <JourneyCard key={j.id} j={j} i={i} selected={selected === i} now={now} onSelect={() => { planner.select(i); onShow(); }} />)}
{journeys.map((j, i) => <JourneyCard key={j.id} j={j} i={i} selected={selected === i} now={now} onFocus={onFocus} onSelect={() => { planner.select(i); onShow(); }} />)}
</ul>
</fieldset>
)}
@ -232,7 +233,7 @@ export function TransitPlan({ planner, onShow }: { planner: PlannerState; onShow
);
}
function JourneyCard({ j, i, selected, now, onSelect }: { j: Journey; i: number; selected: boolean; now: number; onSelect: () => void }) {
function JourneyCard({ j, i, selected, now, onSelect, onFocus }: { j: Journey; i: number; selected: boolean; now: number; onSelect: () => void; onFocus: (p: LatLon) => void }) {
const { t } = useI18n();
const first = j.rides[0]!, last = j.rides[j.rides.length - 1]!;
const leave = first.boardMs - (j.walkTo.durationMin + 1) * 60000;
@ -257,15 +258,15 @@ function JourneyCard({ j, i, selected, now, onSelect }: { j: Journey; i: number;
</div>
{selected && (
<ol className="kbb-legs usa-list--unstyled margin-0 padding-0" aria-label={t('tr.title')}>
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.leave', { time: clock(leave) })} · {t('tr.walkTo', { n: walkTo, stop: first.from.name ?? '' })}<WalkMeta walk={j.walkTo} /></div></li>
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.leave', { time: clock(leave) })} · {t('tr.walkTo', { n: walkTo, stop: first.from.name ?? '' })}<WalkMeta walk={j.walkTo} /><WalkDetails result={j.walkTo} onFocus={onFocus} /></div></li>
<RideLine ride={first} now={now} />
{j.transfer && (
<>
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.transfer', { m: j.transfer.distM, n: j.transfer.walkMin, w: j.transfer.waitMin })}<WalkMeta walk={j.transfer.walk} /></div></li>
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.transfer', { m: j.transfer.distM, n: j.transfer.walkMin, w: j.transfer.waitMin })}<WalkMeta walk={j.transfer.walk} /><WalkDetails result={j.transfer.walk} onFocus={onFocus} /></div></li>
<RideLine ride={j.rides[1]!} now={now} />
</>
)}
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.walkEnd', { n: walkEnd, stop: last.to.name ?? '' })}<WalkMeta walk={j.walkFrom} /> · {t('tr.arrive', { time: clock(j.arriveMs) })}</div></li>
<li className="kbb-leg"><Icon name="directions_walk" size={3} className="kbb-leg__icon" /><div className="kbb-leg__body">{t('tr.walkEnd', { n: walkEnd, stop: last.to.name ?? '' })}<WalkMeta walk={j.walkFrom} /> · {t('tr.arrive', { time: clock(j.arriveMs) })}<WalkDetails result={j.walkFrom} onFocus={onFocus} /></div></li>
{j.next.length > 0 && <li className="font-sans-xs text-base-dark margin-top-1">{t('tr.next', { list: j.next.map((d) => clock(d.expected)).join(', ') })}</li>}
</ol>
)}

View file

@ -0,0 +1,75 @@
import { hasKey, useI18n, type Key } from '../lib/i18n';
import type { LatLon } from '../lib/geo';
import { HARD_FLAGS, type RouteResult, type RouteSegment } from '../lib/route';
import type { AreaLabel } from '../lib/placeNames';
import { Icon } from './ui';
type T = ReturnType<typeof useI18n>['t'];
/** Where a segment or barrier is: its own name, else the park it crosses / the street it runs along. */
function placeName(x: { name: string | null; near?: AreaLabel | undefined }, t: T): string | null {
if (x.name) return x.name;
return x.near ? t(`seg.near.${x.near.kind}` as Key, { name: x.near.name }) : null;
}
function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI18n>['t']) {
const kindKey = `seg.hw.${s.hw}`;
const kindLabel = hasKey(kindKey) ? t(kindKey) : s.hw;
const surface = s.surface ? t('seg.surfaceSuffix', { surface: `@surface.${s.surface}` }) : '';
const flags = s.flags.filter((f) => f !== 'surface-unknown' && f !== 'ramp-steps').map((f) => t(`flag.${f}` as Key));
const line = t('seg.line', {
turn: t(`turn.${first ? 'start' : s.turn}` as Key),
name: placeName(s, t) ?? t('seg.unnamed'),
kind: kindLabel,
m: Math.max(5, Math.round(s.distanceM / 5) * 5),
surface,
});
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
}
/** Barriers and cautions of a walking route plus its turn-by-turn steps: the same on the walking tab and in every walking leg of a transit journey. */
export function WalkDetails({ result, onFocus }: { result: RouteResult; onFocus: (p: LatLon) => void }) {
const { t } = useI18n();
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
return (
<>
{notes.length === 0 ? (
<p>{t('route.barriers.none')}</p>
) : (
<ul className="usa-list--unstyled margin-0 padding-0">
{notes.map((b, i) => {
const where = placeName(b, t);
return (
<li key={`${b.wayId}-${b.flag}-${i}`} className="kbb-barrier">
<span>
<Icon name={HARD_FLAGS.has(b.flag) ? 'error' : 'warning'} size={3} className={`margin-right-05 ${HARD_FLAGS.has(b.flag) ? 'text-error-dark' : 'text-warning-darker'}`} />
<strong>{t(`flag.${b.flag}` as Key)}</strong>
{where ? ` · ${where}` : ''}
{b.detail && b.flag === 'steps' ? ` · ${b.detail}` : ''}
{b.lengthM >= 10 ? ` · ${Math.round(b.lengthM / 5) * 5} m` : ''}
</span>
<button type="button" className="usa-button usa-button--unstyled font-sans-xs margin-left-1" onClick={() => onFocus(b.at)}>
{t('route.focus')}
</button>
</li>
);
})}
</ul>
)}
<details className="kbb-reach border-1px border-base-lighter radius-md margin-top-2 bg-base-lightest">
<summary>
<Icon name="list" size={3} className="margin-right-05 text-primary" />
<span className="flex-1">{t('route.steps')}</span>
<Icon name="expand_more" size={3} className="kbb-chevron" />
</summary>
<ol className="usa-list margin-top-0 padding-right-2">
{result.segments.map((s, i) => (
<li key={i}>{segmentText(s, i === 0, t)}</li>
))}
</ol>
</details>
</>
);
}

View file

@ -12,5 +12,8 @@ export const APP_NAME = 'GdzieWjade';
*/
export const SHOW_PROTOTYPE_BANNER = false;
/** The whole city of Kraków [south, west, north, east]: how far the map can be panned. */
export const KRAKOW_BOUNDS: [number, number, number, number] = [49.94, 19.72, 50.15, 20.3];
/** 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, '');

View file

@ -18,6 +18,7 @@ const pl = {
'route.tab.walk': 'Pieszo',
'route.tab.transit': 'Komunikacja',
'tr.title': 'Komunikacja miejska',
'tr.unavailable': 'Komunikacja niedostępna: do celu jest bardzo blisko, a przejazd nadkłada drogę.',
'tr.loading': 'Wczytuję rozkład jazdy…',
'tr.error': 'Nie udało się wczytać rozkładu jazdy. Spróbuj ponownie później.',
'tr.none': 'Brak połączenia w najbliższych 1,5 godziny (do jednej przesiadki) między przystankami w pobliżu startu i celu.',
@ -321,7 +322,6 @@ const pl = {
'route.go': 'Wyznacz trasę',
'route.myLocation': 'Użyj mojej lokalizacji',
'route.locating': 'Ustalanie lokalizacji…',
'route.locOut': 'Jesteś ok. {km} km poza obszarem demonstracyjnym (od Wawelu i Kazimierza przez Stare Miasto po TAURON Arenę).',
'route.locFail': 'Nie udało się ustalić lokalizacji. Wpisz punkt startu.',
'route.suggestions': 'Podpowiedzi miejsc',
'loc.insecure': 'Przeglądarka udostępnia lokalizację tylko przez HTTPS. Otwórz aplikację pod adresem https://.',
@ -398,7 +398,7 @@ const pl = {
'about.title': 'O danych i projekcie',
'about.intro': 'Narzędzie pokazuje szczegóły dostępności z otwartych źródeł, a nie ogólne „dostępne / niedostępne”. Każda informacja ma źródło, datę i poziom wiarygodności.',
'about.data': 'Źródła danych i ich wiarygodność',
'about.data.body1': 'Mapa, miejsca, schody, krawężniki, nawierzchnie, ławki i toalety pochodzą z OpenStreetMap (licencja ODbL, wymagane oznaczenie źródła). Pobieramy je przez publiczne API Overpass dla obszaru demonstracyjnego i zapisujemy jako migawkę z datą w aplikacji.',
'about.data.body1': 'Mapa, miejsca, schody, krawężniki, nawierzchnie, ławki i toalety pochodzą z OpenStreetMap (licencja ODbL, wymagane oznaczenie źródła). Pobieramy je przez publiczne API Overpass i zapisujemy jako migawkę z datą w aplikacji.',
'about.data.body2': 'Data przy informacji to data ostatniej edycji obiektu w OSM, o ile nie ma daty kontroli (check_date, survey:date). Edycja nie zawsze dotyczyła danych o dostępności — dlatego bez daty kontroli nigdy nie nazywamy danych „potwierdzonymi”.',
'about.data.body3': 'Dane miejskie: przystanki pochodzą z oficjalnego strumienia GTFS ZTP Kraków (pole wheelchair_boarding). Ma ono dziś wartość „brak informacji” dla każdego przystanku w obszarze — pokazujemy to wprost i uzupełniamy opisem społeczności z OSM. Kolejne zbiory (Otwarte Dane Krakowa, MSIP) to kolejne adaptery; licencję każdego trzeba sprawdzić przed użyciem.',
'about.legend': 'Poziomy wiarygodności',
@ -415,7 +415,7 @@ const pl = {
'about.biz': 'Model biznesowy i skalowanie',
'about.biz.body': 'Bezpłatna aplikacja dla mieszkańców i turystów. Przychód z usług B2B: (1) widżet i raport „dostępność obiektu” dla hoteli, organizatorów wydarzeń i zarządców nieruchomości, (2) API dostępności dla systemów rezerwacyjnych i aplikacji turystycznych, (3) pakiet wdrożeniowy dla miast. Koszty to hosting statyczny, okresowe pobieranie z Overpass i moderacja zgłoszeń. Za produkt odpowiada podmiot prowadzący usługę, nie UMK.',
'about.limits': 'Znane ograniczenia prototypu',
'about.limits.body': 'Obszar: od Wawelu i Kazimierza przez Stare Miasto po TAURON Arenę i Czyżyny. Logowanie i zgłoszenia są demonstracyjne i lokalne (bez serwera). Dane miejskie obejmują na razie tylko przystanki. Wysokość krawężników znana tylko dla części przejść. Szerokość wejść prawie nigdy nie jest zmapowana. Czasy przejścia to szacunki. Kafelki mapy z serwera OSM nadają się do demonstracji — produkcja wymaga własnego dostawcy kafelków.',
'about.limits.body': 'Obszar: Kraków. Logowanie i zgłoszenia są demonstracyjne i lokalne (bez serwera). Dane miejskie obejmują na razie tylko przystanki. Wysokość krawężników znana tylko dla części przejść. Szerokość wejść prawie nigdy nie jest zmapowana. Czasy przejścia to szacunki. Kafelki mapy z serwera OSM nadają się do demonstracji — produkcja wymaga własnego dostawcy kafelków.',
'about.demo': 'Kontrolki demonstracyjne',
'about.simulate': 'Symuluj niedostępność źródła danych (OSM)',
'about.simulate.help': 'Odświeżenie miejsca zakończy się błędem — pokazuje, co zobaczy użytkownik.',
@ -498,7 +498,7 @@ const pl = {
'profile.summary.toilet': ' Szukam dostępnej toalety w pobliżu.',
'profile.summary.rest': ' Chcę mieć miejsca do odpoczynku.',
'profile.summary.changing': ' Potrzebuję przewijaka.',
'profile.preview': 'Podgląd wyników w obszarze demonstracyjnym',
'profile.preview': 'Podgląd wyników w Krakowie',
'profile.preview.n': '{n} {label}',
'profile.autosave': 'Zmiany zapisują się od razu.',
'profile.done': 'Gotowe — szukaj miejsc',
@ -811,6 +811,7 @@ const en: Dict = {
'route.tab.walk': 'On foot',
'route.tab.transit': 'Transit',
'tr.title': 'Public transport',
'tr.unavailable': 'Public transport is unavailable: the destination is very close and the ride would go the long way round.',
'tr.loading': 'Loading the timetable…',
'tr.error': 'Could not load the timetable. Try again later.',
'tr.none': 'No connection in the next 1.5 hours (up to one transfer) between stops near the start and the destination.',
@ -1114,7 +1115,6 @@ const en: Dict = {
'route.go': 'Find route',
'route.myLocation': 'Use my location',
'route.locating': 'Finding your location…',
'route.locOut': 'You are about {km} km outside the demo area (from Wawel and Kazimierz through the Old Town to TAURON Arena).',
'route.locFail': 'Could not get your location. Type a starting point.',
'route.suggestions': 'Place suggestions',
'loc.insecure': 'Browsers only share location over HTTPS. Open the app at an https:// address.',
@ -1191,7 +1191,7 @@ const en: Dict = {
'about.title': 'About the data and the project',
'about.intro': 'The tool shows accessibility details from open sources, not a blanket “accessible / not accessible”. Every piece of information has a source, a date and a reliability level.',
'about.data': 'Data sources and their reliability',
'about.data.body1': 'The map, places, steps, kerbs, surfaces, benches and toilets come from OpenStreetMap (ODbL licence, attribution required). We fetch them through the public Overpass API for the demo area and keep a dated snapshot in the app.',
'about.data.body1': 'The map, places, steps, kerbs, surfaces, benches and toilets come from OpenStreetMap (ODbL licence, attribution required). We fetch them through the public Overpass API and keep a dated snapshot in the app.',
'about.data.body2': 'The date next to a fact is the last edit of the object in OSM unless there is a survey date (check_date, survey:date). An edit may not have touched the accessibility tags — so without a survey date we never call data “confirmed”.',
'about.data.body3': 'City data: stops come from the official ZTP Kraków GTFS feed (the wheelchair_boarding field). It currently says “no information” for every stop in the area — we show that plainly and fill in with the OSM community’s description. More datasets (Kraków Open Data, MSIP) are more adapters; each licence must be checked before use.',
'about.legend': 'Reliability levels',
@ -1208,7 +1208,7 @@ const en: Dict = {
'about.biz': 'Business model and scaling',
'about.biz.body': 'Free app for residents and tourists. Revenue from B2B: (1) an “venue accessibility” widget and report for hotels, event organisers and property managers, (2) an accessibility API for booking systems and travel apps, (3) a roll-out package for cities. Costs are static hosting, periodic Overpass pulls and report moderation. The operating entity owns the product, not the city office.',
'about.limits': 'Known prototype limitations',
'about.limits.body': 'Area: from Wawel and Kazimierz through the Old Town to TAURON Arena and Czyżyny. Sign-in and reports are demo-only and local (no server). City data covers stops only for now. Kerb height is known for only some crossings. Entrance width is almost never mapped. Walking times are estimates. OSM tile servers are fine for a demo — production needs its own tile provider.',
'about.limits.body': 'Area: Kraków. Sign-in and reports are demo-only and local (no server). City data covers stops only for now. Kerb height is known for only some crossings. Entrance width is almost never mapped. Walking times are estimates. OSM tile servers are fine for a demo — production needs its own tile provider.',
'about.demo': 'Demo controls',
'about.simulate': 'Simulate the data source being unavailable (OSM)',
'about.simulate.help': 'Refreshing a place will fail — shows what a user would see.',
@ -1291,7 +1291,7 @@ const en: Dict = {
'profile.summary.toilet': ' I look for an accessible toilet nearby.',
'profile.summary.rest': ' I want places to rest.',
'profile.summary.changing': ' I need a baby-changing table.',
'profile.preview': 'Preview of results in the demo area',
'profile.preview': 'Preview of results in Kraków',
'profile.preview.n': '{n} {label}',
'profile.autosave': 'Changes are saved straight away.',
'profile.done': 'Done — find places',

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getPosition, kmOutside, LocateFailure } from './locate';
import { getPosition, LocateFailure } from './locate';
type Cb = (p: GeolocationPosition) => void;
type Err = (e: GeolocationPositionError) => void;
@ -42,9 +42,4 @@ describe('device location', () => {
env(true, [fail(3), fail(3)]);
await expect(getPosition()).rejects.toMatchObject({ reason: 'timeout' });
});
it('measures how far outside the area a position is', () => {
const bbox: [number, number, number, number] = [50.044, 19.92, 50.074, 20.0];
expect(kmOutside([50.06, 19.95], bbox)).toBe(0);
expect(kmOutside([50.1, 19.95], bbox)).toBeGreaterThan(2.5);
});
});

View file

@ -1,5 +1,5 @@
// Device position for the route form: tries a quick network fix first, then GPS, and says *why* it failed.
import { haversine, type LatLon } from './geo';
import type { LatLon } from './geo';
export type LocateError = 'insecure' | 'unsupported' | 'denied' | 'unavailable' | 'timeout';
export class LocateFailure extends Error {
@ -18,8 +18,9 @@ export async function getPosition(): Promise<LatLon> {
if (!('geolocation' in navigator)) throw new LocateFailure('unsupported');
if (!window.isSecureContext) throw new LocateFailure('insecure');
const attempts: PositionOptions[] = [
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 8_000 },
{ enableHighAccuracy: true, maximumAge: 0, timeout: 20_000 },
// iOS Safari starts the timeout while its permission prompt is still on screen, so a short one fails before the user can tap "Allow"
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 20_000 },
{ enableHighAccuracy: true, maximumAge: 0, timeout: 30_000 },
];
let last: LocateError = 'unavailable';
for (const opts of attempts) {
@ -34,10 +35,3 @@ export async function getPosition(): Promise<LatLon> {
}
throw new LocateFailure(last);
}
/** Distance in km from a point to the demo area's bounding box (0 when inside). */
export function kmOutside(p: LatLon, bbox: [number, number, number, number]): number {
const [s, w, n, e] = bbox;
const clamp: LatLon = [Math.min(Math.max(p[0], s), n), Math.min(Math.max(p[1], w), e)];
return haversine(p, clamp) / 1000;
}

View file

@ -31,8 +31,8 @@ describe('offline tile pack', () => {
});
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');
const gate = readFileSync('src/lib/tileProtocol.ts', 'utf8');
expect(gate).toContain('https://tile.openstreetmap.org/${z}/${x}/${y}.png');
expect(readFileSync('vite.config.ts', 'utf8')).toContain('tile\\.openstreetmap\\.org');
});
it('has no duplicate tiles', () => {

32
src/lib/tileProtocol.ts Normal file
View file

@ -0,0 +1,32 @@
// Map tiles through one gate: a saved copy first, the network only while online, and never an unbounded wait.
// Offline (or on a connection that hangs), the map would otherwise keep requests open for every missing tile: the loader never
// finishes and the whole page crawls. Here a missing tile fails at once when offline, and after a timeout when online.
import * as maplibregl from 'maplibre-gl';
const SOURCES = {
osm: { cache: 'osm-tiles', url: (z: string, x: string, y: string) => `https://tile.openstreetmap.org/${z}/${x}/${y}.png` },
sat: { cache: 'sat-tiles', url: (z: string, x: string, y: string) => `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}` },
} as const;
const TIMEOUT_MS = 10_000;
export const tileTemplate = (src: keyof typeof SOURCES) => `kbbtile://${src}/{z}/{x}/{y}`;
let registered = false;
export function registerTileProtocol(): void {
if (registered) return;
registered = true;
maplibregl.addProtocol('kbbtile', async (params, abort) => {
const [src, z, x, y] = params.url.replace('kbbtile://', '').split('/');
const def = src === 'osm' || src === 'sat' ? SOURCES[src] : null;
if (!def || !z || !x || !y) throw new Error('bad tile url');
const url = def.url(z, x, y);
try {
const hit = 'caches' in window ? await (await caches.open(def.cache)).match(url) : undefined;
if (hit) return { data: await hit.arrayBuffer() };
} catch { /* cache unreadable: fall through to the network */ }
if (!navigator.onLine) throw new Error('offline');
const res = await fetch(url, { signal: AbortSignal.any([abort.signal, AbortSignal.timeout(TIMEOUT_MS)]) });
if (!res.ok) throw new Error(`tile ${res.status}`);
return { data: await res.arrayBuffer() };
});
}

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { decodeFeed } from './gtfsrt';
import { DEFAULT_PROFILE } from './profile';
import { buildGraph } from './route';
import { buildTransitIndex, dateOf, delayAt, departuresFrom, midnightMs, planJourneys, NO_REALTIME, type Realtime, type TransitData } from './transit';
import { buildTransitIndex, dateOf, delayAt, departuresFrom, midnightMs, planJourneys, transitDominated, NO_REALTIME, type Realtime, type Journey, type TransitData } from './transit';
import type { Snapshot } from './types';
// ---- tiny protobuf encoder, only for building fixtures ----
@ -130,3 +130,17 @@ describe('journey planning on the real timetable', () => {
expect(js.map((j) => j.arriveMs)).toEqual([...js.map((j) => j.arriveMs)].sort((a, b) => a - b));
});
});
describe('transit vs walking', () => {
const j = (m: number) => ({ rides: [], transfer: null, walkTo: { distanceM: m }, walkFrom: { distanceM: 0 } }) as unknown as Journey;
it('is switched off only for a very short walk and a much longer journey', () => {
expect(transitDominated({ durationMin: 4, distanceM: 300 }, [j(900)])).toBe(true);
expect(transitDominated({ durationMin: 4, distanceM: 300 }, [j(500)])).toBe(false); // no big detour
expect(transitDominated({ durationMin: 25, distanceM: 2000 }, [j(9000)])).toBe(false); // walk not very short
expect(transitDominated({ durationMin: 45, distanceM: 3500 }, [j(20_000)])).toBe(false); // long walk: always allowed
expect(transitDominated({ durationMin: 4, distanceM: 300 }, [])).toBe(false);
});
it('judges by the journey covering the least distance', () => {
expect(transitDominated({ durationMin: 4, distanceM: 300 }, [j(2000), j(500)])).toBe(false);
});
});

View file

@ -237,6 +237,27 @@ export interface Journey {
caution: number;
}
/** A walk this short (minutes) is never worth a vehicle, if the vehicle also goes the long way round. */
export const VERY_SHORT_WALK_MIN = 10;
/** "Goes the long way round": the journey covers at least this many times the walking distance. */
export const BIG_DETOUR = 2;
/** Metres a journey covers: walking legs plus the line's stop-to-stop path. */
export function journeyDistanceM(j: Journey): number {
const rides = j.rides.reduce((sum, r) => sum + r.path.reduce((d, p, k) => (k ? d + haversine(r.path[k - 1]!, p) : d), 0), 0);
return j.walkTo.distanceM + (j.transfer?.walk.distanceM ?? 0) + j.walkFrom.distanceM + rides;
}
/**
* The transit option is switched off only for a very short walk where every journey covers far more distance than walking.
* A long walk (or one with barriers) always keeps transit available. With no journey at all, the tab explains that itself.
*/
export function transitDominated(walk: { durationMin: number; distanceM: number }, journeys: readonly Journey[]): boolean {
if (journeys.length === 0 || walk.durationMin > VERY_SHORT_WALK_MIN) return false;
const shortest = Math.min(...journeys.map(journeyDistanceM));
return shortest >= walk.distanceM * BIG_DETOUR;
}
/** Placeholder while searching: every transfer is re-walked with the router before it is shown (see `refine`). */
const ESTIMATE = { status: 'ok', coords: [], segments: [], barriers: [], distanceM: 0, durationMin: 0, stats: { knownSurfacePct: 0, unknownKerbs: 0, cobbleM: 0 } } as RouteResult;
const WALK_BUFFER_MIN = 1;

View file

@ -37,7 +37,9 @@ body { margin: 0; }
.kbb-source { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; }
.kbb-source > div { display: flex; align-items: center; gap: 0.4rem; }
.kbb-source dt::after { content: ':'; }
.kbb-source dd { margin: 0; }
.kbb-source dd { margin: 0; min-width: 0; }
.kbb-source > div { flex-wrap: wrap; max-width: 100%; min-width: 0; }
.kbb-source .usa-tag { white-space: normal; max-width: 100%; overflow-wrap: anywhere; }
.kbb-legend { position: absolute; left: 0.5rem; bottom: 1.75rem; z-index: 2; max-width: 17rem; padding: 0.5rem 0.75rem; background: #fff; border: 1px solid #565c65; border-radius: 0.25rem; font-size: 0.875rem; }
.kbb-legend summary { cursor: pointer; font-weight: 700; }
@ -489,6 +491,7 @@ button:focus:not(:focus-visible), summary:focus:not(:focus-visible), [role='tab'
.kbb-mode { appearance: none; flex: 1 1 0; display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; min-height: 2.75rem; padding: 0.4rem 0.6rem; border: 0; background: #fff; color: #005ea2; font: inherit; font-weight: 700; cursor: pointer; }
.kbb-mode + .kbb-mode { border-left: 2px solid #005ea2; }
.kbb-mode .usa-icon { fill: currentColor; }
.kbb-mode:disabled { background: #f0f0f0; color: #565c65; cursor: not-allowed; }
.kbb-mode.is-on { background: #005ea2; color: #fff; }
.kbb-mode:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }