GdzieWjade/src/components/RouteView.tsx

288 lines
14 KiB
TypeScript
Raw Normal View History

2026-10-03 14:29:28 +02:00
import { useEffect, useRef, useState, type FormEvent } from 'react';
2026-10-03 13:33:50 +02:00
import { hasKey, useI18n, type Key } from '../lib/i18n';
import type { LatLon } from '../lib/geo';
import { resolveLabel, type Labels } from '../lib/labels';
2026-10-03 14:29:28 +02:00
import { findAlternatives, HARD_FLAGS, type RouteGraph, type RouteOption, type RouteResult, type RouteSegment } from '../lib/route';
2026-10-03 13:33:50 +02:00
import type { Profile } from '../lib/types';
2026-10-03 14:29:28 +02:00
import { appleMapsUrl, googleMapsUrl } from '../lib/links';
2026-10-03 13:33:50 +02:00
import { Alert, Icon } from './ui';
export interface RouteForm {
fromText: string;
toText: string;
/** Set when the start came from device geolocation. */
fromPoint: LatLon | null;
}
interface Props {
graph: RouteGraph;
labels: Labels;
profile: Profile;
bbox: [number, number, number, number];
form: RouteForm;
onForm: (f: RouteForm) => void;
2026-10-03 14:29:28 +02:00
options: RouteOption[] | null;
ends: { from: LatLon; to: LatLon } | null;
guiding: boolean;
onGuide: () => void;
selected: number;
onSelect: (i: number) => void;
onResult: (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => void;
2026-10-03 13:33:50 +02:00
presets: { id: string; fromId: string; toId: string }[];
onFocus: (p: LatLon) => void;
}
2026-10-03 14:29:28 +02:00
export function RouteView({ graph, labels, profile, bbox, form, onForm, options, ends, guiding, onGuide, selected, onSelect, onResult, presets, onFocus }: Props) {
const result = options?.[selected]?.result ?? null;
2026-10-03 13:33:50 +02:00
const { t, tm } = useI18n();
const [error, setError] = useState<string | null>(null);
const [locating, setLocating] = useState(false);
2026-10-03 14:29:28 +02:00
const resultRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (result) { resultRef.current?.scrollIntoView({ block: 'start' }); resultRef.current?.focus({ preventScroll: true }); }
}, [options]);
2026-10-03 13:33:50 +02:00
const run = (f: RouteForm) => {
setError(null);
const from = f.fromPoint ?? (() => { const p = resolveLabel(labels, f.fromText); return p ? ([p.lat, p.lon] as LatLon) : null; })();
const to = (() => { const p = resolveLabel(labels, f.toText); return p ? ([p.lat, p.lon] as LatLon) : null; })();
if (!from) { setError(t('route.unknownPlace', { text: f.fromText })); onResult(null, null, null); return; }
if (!to) { setError(t('route.unknownPlace', { text: f.toText })); onResult(null, null, null); return; }
if (from[0] === to[0] && from[1] === to[1]) { setError(t('route.same')); onResult(null, null, null); return; }
2026-10-03 14:29:28 +02:00
onResult(findAlternatives(graph, from, to, profile), from, to);
2026-10-03 13:33:50 +02:00
};
const submit = (e: FormEvent) => { e.preventDefault(); run(form); };
const locate = () => {
setLocating(true);
setError(null);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
const { latitude: lat, longitude: lon } = pos.coords;
const [s, w, n, e] = bbox;
if (lat < s || lat > n || lon < w || lon > e) { setError(t('route.locOut')); return; }
onForm({ ...form, fromText: t('route.myLocation'), fromPoint: [lat, lon] });
},
() => { setLocating(false); setError(t('route.locFail')); },
{ enableHighAccuracy: true, timeout: 10_000 },
);
};
const swap = () => {
if (form.fromPoint) return;
onForm({ fromText: form.toText, toText: form.fromText, fromPoint: null });
};
return (
<section aria-labelledby="route-title">
2026-10-03 14:29:28 +02:00
<h1 id="route-title" className="margin-top-0 font-sans-xl">{t('route.title')}</h1>
2026-10-03 13:33:50 +02:00
<p className="font-sans-xs text-base-dark margin-top-0">{t('route.intro')}</p>
<form className="usa-form maxw-full" onSubmit={submit}>
<datalist id="place-labels">{labels.list.map((l) => <option key={l} value={l} />)}</datalist>
<label className="usa-label margin-top-0" htmlFor="route-from">{t('route.from')}</label>
<input
id="route-from" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
value={form.fromText} onChange={(e) => onForm({ ...form, fromText: e.target.value, fromPoint: null })}
/>
{'geolocation' in navigator && (
<button type="button" className="usa-button usa-button--unstyled margin-top-1" onClick={locate} disabled={locating}>
<Icon name="my_location" size={3} className="margin-right-05" />
{locating ? t('route.locating') : t('route.myLocation')}
</button>
)}
<div className="margin-top-1">
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={swap} disabled={!!form.fromPoint}>
<Icon name="arrow_downward" size={3} className="margin-right-05" />{t('route.swap')}
</button>
</div>
<label className="usa-label" htmlFor="route-to">{t('route.to')}</label>
<input
id="route-to" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
value={form.toText} onChange={(e) => onForm({ ...form, toText: e.target.value })}
/>
<button type="submit" className="usa-button margin-top-3">
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('route.go')}
</button>
</form>
{presets.length > 0 && (
<div className="margin-top-2">
<p className="text-bold margin-bottom-1">{t('route.presets')}</p>
<ul className="usa-list--unstyled margin-0 padding-0">
{presets.map((p) => {
const a = labels.byLabel.size ? [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.fromId) : undefined;
const b = [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.toId);
if (!a || !b) return null;
return (
<li key={p.id} className="margin-bottom-1">
<button
type="button" className="usa-button usa-button--outline usa-button--small margin-0 text-left"
onClick={() => { const f = { fromText: a[0], toText: b[0], fromPoint: null }; onForm(f); run(f); }}
>
{a[1].name} → {b[1].name}
</button>
</li>
);
})}
</ul>
</div>
)}
2026-10-03 14:29:28 +02:00
<div aria-live="polite" ref={resultRef} tabIndex={-1} className="margin-top-3 kbb-result">
2026-10-03 13:33:50 +02:00
{error && <Alert kind="error" role="alert">{error}</Alert>}
2026-10-03 14:29:28 +02:00
{options && options.length > 0 && result && result.status !== 'none' && (
<Variants options={options} selected={selected} onSelect={onSelect} />
)}
{result && result.status !== 'none' && ends && (
<section aria-labelledby="guide-title" className="border-1px border-base-lighter radius-md padding-2 margin-bottom-3 bg-base-lightest">
<h2 id="guide-title" className="font-sans-md margin-top-0">{t('guide.start')}</h2>
<p className="font-sans-xs margin-top-0">{t('guide.start.help')}</p>
<button type="button" className="usa-button" onClick={onGuide} disabled={guiding}>
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('guide.start')}
</button>
<a className="usa-button usa-button--outline" href={googleMapsUrl(result, ends.from, ends.to)} target="_blank" rel="noopener noreferrer">
{t('guide.gmaps')}<Icon name="launch" size={2} className="margin-left-05" />
</a>
<a className="usa-button usa-button--outline" href={appleMapsUrl(ends.from, ends.to)} target="_blank" rel="noopener noreferrer">
{t('guide.apple')}<Icon name="launch" size={2} className="margin-left-05" />
</a>
<p className="font-sans-3xs text-base-dark margin-bottom-0">{t('guide.mapsNote')}</p>
</section>
)}
2026-10-03 13:33:50 +02:00
{result && <Result result={result} onFocus={onFocus} t={t} tm={tm} />}
</div>
</section>
);
}
function Result({ result, onFocus, t }: {
result: RouteResult; onFocus: (p: LatLon) => void; t: ReturnType<typeof useI18n>['t']; tm: ReturnType<typeof useI18n>['tm'];
}) {
if (result.status === 'none') {
return <Alert kind="warning" heading={t('route.none')}>{t(`route.none.${result.reason ?? 'disconnected'}` as Key)}</Alert>;
}
const blocked = result.status === 'blocked';
const km = (result.distanceM / 1000).toFixed(2);
const min = Math.max(1, Math.round(result.durationMin));
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
return (
<>
2026-10-03 14:29:28 +02:00
<div className={`usa-alert ${blocked ? 'usa-alert--error' : notes.length ? 'usa-alert--warning' : 'usa-alert--success'}`}>
2026-10-03 13:33:50 +02:00
<div className="usa-alert__body">
2026-10-03 14:29:28 +02:00
<h2 className="usa-alert__heading">{blocked ? t('route.blocked') : notes.length ? t('route.okCheck', { n: notes.length }) : t('route.ok')}</h2>
2026-10-03 13:33:50 +02:00
<p className="usa-alert__text">
{blocked ? t('route.blocked.long') : t('route.summary', { km, min })}
</p>
{blocked && <p className="usa-alert__text">{t('route.summary', { km, min })}</p>}
</div>
</div>
<p className="font-sans-xs margin-bottom-0">{t('route.quality', { pct: result.stats.knownSurfacePct })}</p>
{result.stats.unknownKerbs > 0 && <p className="font-sans-xs margin-y-05">{t('route.unknownKerbs', { n: result.stats.unknownKerbs })}</p>}
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.estimate')}</p>
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.legend')}</p>
2026-10-03 14:29:28 +02:00
<h2 className="font-sans-lg">{t('route.barriers')}</h2>
2026-10-03 13:33:50 +02:00
{notes.length === 0 ? (
<p>{t('route.barriers.none')}</p>
) : (
<ul className="usa-list--unstyled margin-0 padding-0">
{notes.map((b, i) => (
<li key={`${b.wayId}-${b.flag}-${i}`} className="border-1px border-base-lighter radius-md padding-1 margin-bottom-1 display-flex flex-justify flex-align-center">
<span>
<Icon name={HARD_FLAGS.has(b.flag) ? 'error' : 'warning'} size={3} className={`margin-right-05 ${HARD_FLAGS.has(b.flag) ? 'text-error-dark' : 'text-warning-darker'}`} />
<strong>{t(`flag.${b.flag}` as Key)}</strong>
{b.name ? ` · ${b.name}` : ''}
{b.detail && b.flag === 'steps' ? ` · ${b.detail}` : ''}
{b.lengthM >= 10 ? ` · ${Math.round(b.lengthM / 5) * 5} m` : ''}
</span>
<button type="button" className="usa-button usa-button--unstyled font-sans-xs margin-left-1" onClick={() => onFocus(b.at)}>
{t('route.focus')}
</button>
</li>
))}
</ul>
)}
2026-10-03 14:29:28 +02:00
<h2 className="font-sans-lg">{t('route.steps')}</h2>
2026-10-03 13:33:50 +02:00
<ol className="usa-list">
{result.segments.map((s, i) => (
<li key={i}>{segmentText(s, i === 0, t)}</li>
))}
</ol>
</>
);
}
function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI18n>['t']) {
const kindKey = `seg.hw.${s.hw}`;
const kindLabel = hasKey(kindKey) ? t(kindKey) : s.hw;
const surface = s.surface ? t('seg.surfaceSuffix', { surface: `@surface.${s.surface}` }) : '';
const flags = s.flags.filter((f) => f !== 'surface-unknown' && f !== 'ramp-steps').map((f) => t(`flag.${f}` as Key));
const line = t('seg.line', {
turn: t(`turn.${first ? 'start' : s.turn}` as Key),
name: s.name ?? t('seg.unnamed'),
kind: kindLabel,
m: Math.max(5, Math.round(s.distanceM / 5) * 5),
surface,
});
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
}
2026-10-03 14:29:28 +02:00
function Variants({ options, selected, onSelect }: { options: RouteOption[]; selected: number; onSelect: (i: number) => void }) {
const { t } = useI18n();
const rec = options.findIndex((o) => o.recommended);
const sel = options[selected];
const labelKey = (o: RouteOption): Key => (o.id === 'detour' ? 'route.alt.alt2' : `route.alt.${o.id}`) as Key;
return (
<section aria-labelledby="variants-title" className="margin-bottom-3">
<h2 id="variants-title" className="font-sans-lg margin-top-0">{t('route.alt.title')}</h2>
{sel && sel.counts.hard > 0 && rec >= 0 && rec !== selected && (
<div className="usa-alert usa-alert--warning usa-alert--slim margin-bottom-2" role="status">
<div className="usa-alert__body">
<p className="usa-alert__text">
{t('route.alt.warn', { n: sel.counts.hard })}{' '}
<button type="button" className="usa-button usa-button--unstyled" onClick={() => onSelect(rec)}>{t('route.alt.showRec')}</button>
</p>
</div>
</div>
)}
<fieldset className="usa-fieldset">
<legend className="usa-sr-only">{t('route.alt.pick')}</legend>
<ul className="usa-list--unstyled margin-0 padding-0">
{options.map((o, i) => (
<li key={`${o.id}-${i}`} className="margin-bottom-1">
<div className="usa-radio">
<input
className="usa-radio__input usa-radio__input--tile" id={`variant-${i}`} type="radio" name="variant"
checked={selected === i} onChange={() => onSelect(i)}
/>
<label className="usa-radio__label margin-top-0" htmlFor={`variant-${i}`}>
<span className="text-bold">{t(labelKey(o))}</span>
{o.recommended && <span className="usa-tag bg-success-dark text-white margin-left-1">{t('route.alt.recommended')}</span>}
<span className="usa-checkbox__label-description">
{(o.result.distanceM / 1000).toFixed(2)} km · {Math.max(1, Math.round(o.result.durationMin))} min ·{' '}
{i === 0 ? t('route.alt.base') : t('route.alt.overhead', { m: o.overheadM, min: o.overheadMin })}
</span>
<span className="usa-checkbox__label-description margin-top-05">
{o.counts.hard === 0 && o.counts.caution === 0 && o.counts.unknown === 0 ? (
<span className="text-success-dark text-bold">{t('route.alt.clean')}</span>
) : (
<>
<span className={o.counts.hard ? 'text-error-dark text-bold' : ''}>{t('route.alt.hard', { n: o.counts.hard })}</span>{' · '}
<span className={o.counts.caution ? 'text-warning-darker text-bold' : ''}>{t('route.alt.caution', { n: o.counts.caution })}</span>{' · '}
<span>{t('route.alt.unknown', { n: o.counts.unknown })}</span>
</>
)}
</span>
</label>
</div>
</li>
))}
</ul>
</fieldset>
</section>
);
}