GdzieWjade/src/components/RouteView.tsx

202 lines
9.3 KiB
TypeScript
Raw Normal View History

2026-10-03 13:33:50 +02:00
import { useState, type FormEvent } from 'react';
import { hasKey, useI18n, type Key } from '../lib/i18n';
import type { LatLon } from '../lib/geo';
import { resolveLabel, type Labels } from '../lib/labels';
import { findRoute, HARD_FLAGS, type RouteGraph, type RouteResult, type RouteSegment } from '../lib/route';
import type { Profile } from '../lib/types';
import { Alert, Icon } from './ui';
export interface RouteForm {
fromText: string;
toText: string;
/** Set when the start came from device geolocation. */
fromPoint: LatLon | null;
}
interface Props {
graph: RouteGraph;
labels: Labels;
profile: Profile;
bbox: [number, number, number, number];
form: RouteForm;
onForm: (f: RouteForm) => void;
result: RouteResult | null;
onResult: (r: RouteResult | null, from: LatLon | null, to: LatLon | null) => void;
presets: { id: string; fromId: string; toId: string }[];
onFocus: (p: LatLon) => void;
}
export function RouteView({ graph, labels, profile, bbox, form, onForm, result, onResult, presets, onFocus }: Props) {
const { t, tm } = useI18n();
const [error, setError] = useState<string | null>(null);
const [locating, setLocating] = useState(false);
const run = (f: RouteForm) => {
setError(null);
const from = f.fromPoint ?? (() => { const p = resolveLabel(labels, f.fromText); return p ? ([p.lat, p.lon] as LatLon) : null; })();
const to = (() => { const p = resolveLabel(labels, f.toText); return p ? ([p.lat, p.lon] as LatLon) : null; })();
if (!from) { setError(t('route.unknownPlace', { text: f.fromText })); onResult(null, null, null); return; }
if (!to) { setError(t('route.unknownPlace', { text: f.toText })); onResult(null, null, null); return; }
if (from[0] === to[0] && from[1] === to[1]) { setError(t('route.same')); onResult(null, null, null); return; }
onResult(findRoute(graph, from, to, profile), from, to);
};
const submit = (e: FormEvent) => { e.preventDefault(); run(form); };
const locate = () => {
setLocating(true);
setError(null);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
const { latitude: lat, longitude: lon } = pos.coords;
const [s, w, n, e] = bbox;
if (lat < s || lat > n || lon < w || lon > e) { setError(t('route.locOut')); return; }
onForm({ ...form, fromText: t('route.myLocation'), fromPoint: [lat, lon] });
},
() => { setLocating(false); setError(t('route.locFail')); },
{ enableHighAccuracy: true, timeout: 10_000 },
);
};
const swap = () => {
if (form.fromPoint) return;
onForm({ fromText: form.toText, toText: form.fromText, fromPoint: null });
};
return (
<section aria-labelledby="route-title">
<h2 id="route-title" className="margin-top-0">{t('route.title')}</h2>
<p className="font-sans-xs text-base-dark margin-top-0">{t('route.intro')}</p>
<form className="usa-form maxw-full" onSubmit={submit}>
<datalist id="place-labels">{labels.list.map((l) => <option key={l} value={l} />)}</datalist>
<label className="usa-label margin-top-0" htmlFor="route-from">{t('route.from')}</label>
<input
id="route-from" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
value={form.fromText} onChange={(e) => onForm({ ...form, fromText: e.target.value, fromPoint: null })}
/>
{'geolocation' in navigator && (
<button type="button" className="usa-button usa-button--unstyled margin-top-1" onClick={locate} disabled={locating}>
<Icon name="my_location" size={3} className="margin-right-05" />
{locating ? t('route.locating') : t('route.myLocation')}
</button>
)}
<div className="margin-top-1">
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={swap} disabled={!!form.fromPoint}>
<Icon name="arrow_downward" size={3} className="margin-right-05" />{t('route.swap')}
</button>
</div>
<label className="usa-label" htmlFor="route-to">{t('route.to')}</label>
<input
id="route-to" className="usa-input maxw-full" list="place-labels" autoComplete="off" placeholder={t('route.placeholder')}
value={form.toText} onChange={(e) => onForm({ ...form, toText: e.target.value })}
/>
<button type="submit" className="usa-button margin-top-3">
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('route.go')}
</button>
</form>
{presets.length > 0 && (
<div className="margin-top-2">
<p className="text-bold margin-bottom-1">{t('route.presets')}</p>
<ul className="usa-list--unstyled margin-0 padding-0">
{presets.map((p) => {
const a = labels.byLabel.size ? [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.fromId) : undefined;
const b = [...labels.byLabel.entries()].find(([, pl]) => pl.id === p.toId);
if (!a || !b) return null;
return (
<li key={p.id} className="margin-bottom-1">
<button
type="button" className="usa-button usa-button--outline usa-button--small margin-0 text-left"
onClick={() => { const f = { fromText: a[0], toText: b[0], fromPoint: null }; onForm(f); run(f); }}
>
{a[1].name} → {b[1].name}
</button>
</li>
);
})}
</ul>
</div>
)}
<div aria-live="polite" className="margin-top-3">
{error && <Alert kind="error" role="alert">{error}</Alert>}
{result && <Result result={result} onFocus={onFocus} t={t} tm={tm} />}
</div>
</section>
);
}
function Result({ result, onFocus, t }: {
result: RouteResult; onFocus: (p: LatLon) => void; t: ReturnType<typeof useI18n>['t']; tm: ReturnType<typeof useI18n>['tm'];
}) {
if (result.status === 'none') {
return <Alert kind="warning" heading={t('route.none')}>{t(`route.none.${result.reason ?? 'disconnected'}` as Key)}</Alert>;
}
const blocked = result.status === 'blocked';
const km = (result.distanceM / 1000).toFixed(2);
const min = Math.max(1, Math.round(result.durationMin));
const notes = result.barriers.filter((b) => b.flag !== 'surface-unknown' && b.flag !== 'ramp-steps');
return (
<>
<div className={`usa-alert ${blocked ? 'usa-alert--error' : 'usa-alert--success'}`}>
<div className="usa-alert__body">
<h3 className="usa-alert__heading">{blocked ? t('route.blocked') : t('route.ok')}</h3>
<p className="usa-alert__text">
{blocked ? t('route.blocked.long') : t('route.summary', { km, min })}
</p>
{blocked && <p className="usa-alert__text">{t('route.summary', { km, min })}</p>}
</div>
</div>
<p className="font-sans-xs margin-bottom-0">{t('route.quality', { pct: result.stats.knownSurfacePct })}</p>
{result.stats.unknownKerbs > 0 && <p className="font-sans-xs margin-y-05">{t('route.unknownKerbs', { n: result.stats.unknownKerbs })}</p>}
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.estimate')}</p>
<p className="font-sans-xs text-base-dark margin-y-05">{t('route.legend')}</p>
<h3>{t('route.barriers')}</h3>
{notes.length === 0 ? (
<p>{t('route.barriers.none')}</p>
) : (
<ul className="usa-list--unstyled margin-0 padding-0">
{notes.map((b, i) => (
<li key={`${b.wayId}-${b.flag}-${i}`} className="border-1px border-base-lighter radius-md padding-1 margin-bottom-1 display-flex flex-justify flex-align-center">
<span>
<Icon name={HARD_FLAGS.has(b.flag) ? 'error' : 'warning'} size={3} className={`margin-right-05 ${HARD_FLAGS.has(b.flag) ? 'text-error-dark' : 'text-warning-darker'}`} />
<strong>{t(`flag.${b.flag}` as Key)}</strong>
{b.name ? ` · ${b.name}` : ''}
{b.detail && b.flag === 'steps' ? ` · ${b.detail}` : ''}
{b.lengthM >= 10 ? ` · ${Math.round(b.lengthM / 5) * 5} m` : ''}
</span>
<button type="button" className="usa-button usa-button--unstyled font-sans-xs margin-left-1" onClick={() => onFocus(b.at)}>
{t('route.focus')}
</button>
</li>
))}
</ul>
)}
<h3>{t('route.steps')}</h3>
<ol className="usa-list">
{result.segments.map((s, i) => (
<li key={i}>{segmentText(s, i === 0, t)}</li>
))}
</ol>
</>
);
}
function segmentText(s: RouteSegment, first: boolean, t: ReturnType<typeof useI18n>['t']) {
const kindKey = `seg.hw.${s.hw}`;
const kindLabel = hasKey(kindKey) ? t(kindKey) : s.hw;
const surface = s.surface ? t('seg.surfaceSuffix', { surface: `@surface.${s.surface}` }) : '';
const flags = s.flags.filter((f) => f !== 'surface-unknown' && f !== 'ramp-steps').map((f) => t(`flag.${f}` as Key));
const line = t('seg.line', {
turn: t(`turn.${first ? 'start' : s.turn}` as Key),
name: s.name ?? t('seg.unnamed'),
kind: kindLabel,
m: Math.max(5, Math.round(s.distanceM / 5) * 5),
surface,
});
return flags.length ? `${line}. ${t('status.warn')}: ${flags.join(', ')}.` : line;
}