287 lines
14 KiB
TypeScript
287 lines
14 KiB
TypeScript
import { useEffect, useRef, 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 { findAlternatives, HARD_FLAGS, type RouteGraph, type RouteOption, type RouteResult, type RouteSegment } from '../lib/route';
|
|
import type { Profile } from '../lib/types';
|
|
import { appleMapsUrl, googleMapsUrl } from '../lib/links';
|
|
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;
|
|
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;
|
|
presets: { id: string; fromId: string; toId: string }[];
|
|
onFocus: (p: LatLon) => void;
|
|
}
|
|
|
|
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;
|
|
const { t, tm } = useI18n();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [locating, setLocating] = useState(false);
|
|
const resultRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (result) { resultRef.current?.scrollIntoView({ block: 'start' }); resultRef.current?.focus({ preventScroll: true }); }
|
|
}, [options]);
|
|
|
|
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(findAlternatives(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">
|
|
<h1 id="route-title" className="margin-top-0 font-sans-xl">{t('route.title')}</h1>
|
|
<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" ref={resultRef} tabIndex={-1} className="margin-top-3 kbb-result">
|
|
{error && <Alert kind="error" role="alert">{error}</Alert>}
|
|
{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>
|
|
)}
|
|
{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' : notes.length ? 'usa-alert--warning' : 'usa-alert--success'}`}>
|
|
<div className="usa-alert__body">
|
|
<h2 className="usa-alert__heading">{blocked ? t('route.blocked') : notes.length ? t('route.okCheck', { n: notes.length }) : t('route.ok')}</h2>
|
|
<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>
|
|
|
|
<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) => (
|
|
<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>
|
|
)}
|
|
|
|
<h2 className="font-sans-lg">{t('route.steps')}</h2>
|
|
<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;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|