GdzieWjade/src/App.tsx
2026-10-03 15:02:13 +02:00

532 lines
27 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AboutView } from './components/AboutView';
import { AccessCard } from './components/AccessCard';
import { DashboardView } from './components/DashboardView';
import { LoginView } from './components/LoginView';
import { MapView } 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 { SettingsPanel } from './components/SettingsPanel';
import { SheetHandle, type Sheet } from './components/SheetHandle';
import { Alert, Icon } from './components/ui';
import { assessPlace, buildIndex } from './lib/assess';
import { findAccount, userKey, type Account } from './lib/auth';
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 { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n';
import { buildLabels } from './lib/labels';
import { refreshPlace } from './lib/live';
import { DEFAULT_PROFILE, isProfile } from './lib/profile';
import { computeReach, placesInReach, type Reach } from './lib/reach';
import { buildGraph, type RouteOption } from './lib/route';
import { deletePhoto, putPhoto, type ProcessedPhoto } from './lib/photo';
import { NoCreditsError, vetReport } from './lib/vet';
import { GuideBanner } from './components/GuideBanner';
import { findAlternatives } from './lib/route';
import { applySettings, defaultSettings, isSettings, type Settings } from './lib/settings';
import { clearLocal, isMetered, newId, useLocalState, useMedia, useOnline } from './lib/store';
import type { Assessment, Lang, Place, Profile, Snapshot, UserReport, Verdict } from './lib/types';
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 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);
/** Real place pairs from the snapshot; the result changes with the profile. Resolved by name at runtime. */
const PRESETS: { id: string; from: string; to: string }[] = [
{ id: 'wawel', from: 'Sukiennice', to: 'Zamek Królewski na Wawelu' },
{ id: 'kazimierz', from: 'Cinema City Kraków - Galeria Kazimierz', to: 'Sukiennice' },
{ id: 'remuh', from: 'Synagoga Remuh', to: 'Sukiennice' },
{ id: 'manggha', from: 'Narodowy Stary Teatr imienia Heleny Modrzejewskiej', to: 'Muzeum Sztuki i Techniki Japońskiej Manggha' },
{ id: 'schindler', from: 'Sukiennice', to: 'Schody z filmu Lista Schindlera' },
];
export default function App() {
// ---- identity (demo) ----
const [loginLang, setLoginLang] = useLocalState<Lang>('kbb.lang', detectLang(), isLang);
const [session, setSession] = useLocalState<string | null>('kbb.session', null, isStrOrNull);
const account: Account | null = findAccount(session);
const uid = account?.id ?? 'none';
const [settings, setSettings] = useLocalState<Settings>(userKey(uid, 'settings'), defaultSettings(loginLang), isSettings);
const [profile, setProfile] = useLocalState<Profile | null>(userKey(uid, 'profile'), null, (v): v is Profile | null => v === null || isProfile(v));
const [favorites, setFavorites] = useLocalState<string[]>(userKey(uid, 'favorites'), [], isStrArray);
const [userReports, setUserReports] = useLocalState<UserReport[]>('kbb.reports', [], (v): v is UserReport[] => Array.isArray(v));
const [decisions, setDecisions] = useLocalState<Record<string, Decision>>('kbb.decisions', {}, isObj<Record<string, Decision>>);
const [demand, setDemand] = useLocalState<Record<string, number>>('kbb.demand', {}, isObj<Record<string, number>>);
const lang: Lang = account ? settings.lang : loginLang;
const [view, setView] = useState<View>('places');
// Mobile portrait: the map fills the screen and the panel is a bottom sheet (peek / half / full).
const [sheet, setSheet] = useState<Sheet>('half');
const sheetMode = useMedia('(max-width: 63.99em) and (orientation: portrait)');
const setPane = useCallback((p: 'list' | 'map') => setSheet(p === 'map' ? 'peek' : 'half'), []);
const online = useOnline();
const i18n = useMemo<I18n>(() => {
const { t, tm } = makeT(lang);
return { lang, setLang: (l) => (account ? setSettings((s) => ({ ...s, lang: l })) : setLoginLang(l)), t, tm, date: (iso) => formatDate(iso, lang) ?? t('fact.dateUnknown') };
}, [lang, account, setSettings, setLoginLang]);
const { t } = i18n;
useEffect(() => { document.title = t('app.name'); }, [t]);
useEffect(() => {
if (account) { applySettings(settings); setLoginLang(settings.lang); }
else { document.documentElement.lang = loginLang; document.documentElement.style.fontSize = ''; document.documentElement.dataset.contrast = 'default'; }
}, [account, settings, loginLang, setLoginLang]);
// first login: seed the profile from the account's persona
useEffect(() => {
if (!account || profile) return;
const kind = account.preset ?? (account.role === 'official' ? 'wheelchair' : null);
if (kind) setProfile(DEFAULT_PROFILE[kind]);
}, [account, profile, setProfile]);
const signIn = (a: Account) => {
setSession(a.id);
setView(a.role === 'official' ? 'dashboard' : 'places');
setPane('list');
};
const signOut = () => { setSession(null); setView('places'); setSelectedId(null); };
// ---- data ----
const [snap, setSnap] = useState<Snapshot | null>(null);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
const ctrl = new AbortController();
fetch(`${import.meta.env.BASE_URL}data/kk-snapshot.json`, { signal: ctrl.signal })
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json() as Promise<Snapshot>; })
.then(setSnap)
.catch((e: unknown) => { if (!(e instanceof DOMException && e.name === 'AbortError')) setLoadError(true); });
return () => ctrl.abort();
}, []);
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
const [overrides, setOverrides] = useState<Record<string, Place>>({});
const realPlaces = useMemo<Place[]>(() => (snap ? snap.places.map((p) => overrides[p.id] ?? p) : []), [snap, overrides]);
const places = useMemo<Place[]>(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]);
const reports = useMemo<UserReport[]>(
() => [...DEMO_REPORTS, ...userReports].map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)),
[userReports, decisions],
);
const assessments = useMemo(() => {
const m = new Map<string, Assessment>();
if (index && profile) for (const p of places) m.set(p.id, assessPlace(p, profile, index, reports));
return m;
}, [places, profile, index, reports]);
const labels = useMemo(() => buildLabels(places), [places]);
const placeById = useMemo(() => new Map([...DEMO_PLACES, ...realPlaces].map((p) => [p.id, p])), [realPlaces]);
const previewCounts = useCallback((p: Profile): Record<Verdict, number> => {
const c: Record<Verdict, number> = { match: 0, partial: 0, conflict: 0, incomplete: 0, fail: 0 };
if (index) for (const pl of realPlaces) c[assessPlace(pl, p, index, reports).verdict]++;
return c;
}, [index, realPlaces, reports]);
// ---- places view ----
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const favSet = useMemo(() => new Set(favorites), [favorites]);
const results = useMemo(() => filterPlaces(places, assessments, filters, favSet), [places, assessments, filters, favSet]);
const mapPlaces = useMemo(
() => results.flatMap((place) => { const a = assessments.get(place.id); return a ? [{ place, verdict: a.verdict }] : []; }),
[results, assessments],
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = selectedId ? placeById.get(selectedId) ?? null : null;
const [refresh, setRefresh] = useState<RefreshState>({ kind: 'idle' });
const [simulateOutage, setSimulateOutage] = useState(false);
const detailHeading = useRef<HTMLHeadingElement>(null);
// ---- reach ----
const [reachState, setReachState] = useState<ReachState>({ minutes: null, summary: null });
const [reachLines, setReachLines] = useState<[LatLon, LatLon][] | null>(null);
const snapCache = useRef(new Map<string, number | null>());
const clearReach = useCallback(() => { setReachState({ minutes: null, summary: null }); setReachLines(null); }, []);
const doReach = (place: Place, minutes: number) => {
if (!graph || !profile) return;
const r: Reach | null = computeReach(graph, [place.lat, place.lon], profile, minutes);
if (!r) { setReachState({ minutes, summary: 'stuck' }); setReachLines(null); return; }
const inReach = placesInReach(graph, r, realPlaces.filter((p) => p.id !== place.id), snapCache.current);
const fit = inReach.filter((p) => { const v = assessments.get(p.id)?.verdict; return v === 'match' || v === 'partial'; }).length;
setReachState({ minutes, summary: { n: inReach.length, fit } });
setReachLines(r.lines);
setSheet('half');
};
const select = useCallback((id: string | null) => {
setSelectedId(id);
setRefresh({ kind: 'idle' });
clearReach();
if (id) {
setPane('list');
if (!id.startsWith('demo-')) setDemand((d) => ({ ...d, [id]: (d[id] ?? 0) + 1 }));
}
}, [clearReach, setDemand]);
useEffect(() => { if (selectedId) detailHeading.current?.focus(); }, [selectedId]);
useEffect(() => { clearReach(); }, [profile, clearReach]);
const back = () => {
const id = selectedId;
select(null);
if (id) setTimeout(() => document.getElementById(`place-${id}`)?.focus(), 0);
};
const doRefresh = async (p: Place) => {
setRefresh({ kind: 'loading' });
try {
if (simulateOutage) throw new Error('simulated outage');
const fresh = await refreshPlace(p.id);
setOverrides((o) => ({ ...o, [p.id]: { ...(p.ext ? { ext: p.ext } : {}), ...fresh } }));
setRefresh({ kind: 'ok', at: new Date().toISOString() });
} catch {
setRefresh({ kind: 'fail' });
}
};
const patchReport = (id: string, patch: Partial<UserReport>) =>
setUserReports((cur) => cur.map((r) => (r.id === id ? { ...r, ...patch } : r)));
// a vetting that was running when the tab closed will never finish
useEffect(() => {
setUserReports((cur) => (cur.some((r) => r.vetting?.state === 'running') ? cur.map((r) => (r.vetting?.state === 'running' ? { ...r, vetting: { state: 'failed', reason: 'error' } } : r)) : cur));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addReport = async (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => {
const id = newId();
const rep: UserReport = {
...r, id, createdAt: new Date().toISOString(), authorId: account?.id ?? 'guest',
...(photo ? { photoId: id } : {}), vetting: { state: 'running' },
};
if (photo) await putPhoto(id, photo.dataUrl);
// the on-device model is ~90 MB: ask before downloading it on a metered link
if (photo && isMetered() && !window.confirm(t('vet.metered'))) {
setUserReports((cur) => [...cur, { ...rep, vetting: { state: 'failed', reason: 'skipped' } }]);
return;
}
setUserReports((cur) => [...cur, rep]);
try {
const result = await vetReport(rep, photo ? { dataUrl: photo.dataUrl, blur: photo.blur } : null);
patchReport(id, { vetting: { state: 'done', result } });
} catch (e) {
patchReport(id, { vetting: { state: 'failed', reason: e instanceof NoCreditsError ? 'no-credits' : 'error' } });
}
};
const [cardId, setCardId] = useState<string | null>(null);
// ---- route ----
const [routeForm, setRouteForm] = useState<RouteForm>({ fromText: '', toText: '', fromPoint: null });
const [routeOptions, setRouteOptions] = useState<RouteOption[] | null>(null);
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 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);
const [focus, setFocus] = useState<{ at: LatLon; n: number } | null>(null);
const onResult = (o: RouteOption[] | null, from: LatLon | null, to: LatLon | null) => {
setRouteOptions(o);
setRouteSel(0);
setRouteEnds(from && to ? { from, to } : null);
if (o?.[0] && o[0].result.status !== 'none') setSheet('half');
};
useEffect(() => { setRouteOptions(null); setRouteEnds(null); setGuiding(false); }, [profile]);
useEffect(() => { if (view !== 'route' || !route || route.status === 'none') setGuiding(false); }, [view, route]);
const reroute = (from: LatLon) => {
if (!graph || !profile || !routeEnds) return;
const o = findAlternatives(graph, from, routeEnds.to, profile);
if (!o.length || o[0]!.result.status === 'none') return;
setRouteOptions(o);
setRouteSel(Math.max(0, o.findIndex((x) => x.recommended)));
setRouteEnds({ from, to: routeEnds.to });
};
const routeTo = (p: Place) => {
setRouteForm((f) => ({ ...f, toText: labels.labelOf.get(p.id) ?? p.name ?? '' }));
setRouteOptions(null);
setRouteEnds(null);
setView('route');
setPane('list');
};
const presets = useMemo(() => {
const byName = (n: string) => realPlaces.find((p) => p.name === n)?.id;
return PRESETS.flatMap((p) => { const a = byName(p.from), b = byName(p.to); return a && b ? [{ id: p.id, fromId: a, toId: b }] : []; });
}, [realPlaces]);
// ---- city panel ----
const stats = useMemo(() => computeStats(places, assessments), [places, assessments]);
const hotspots = useMemo(() => (snap && index ? computeHotspots(snap, index, realPlaces) : []), [snap, index, realPlaces]);
const [hotMode, setHotMode] = useState(false);
const [hotFocus, setHotFocus] = useState<Hotspot | null>(null);
const [exportMsg, setExportMsg] = useState<string | null>(null);
const demandList = useMemo(
() => Object.entries(demand)
.flatMap(([id, views]) => { const p = placeById.get(id); return p && assessments.get(id)?.verdict === 'incomplete' ? [{ place: p, views }] : []; })
.sort((a, b) => b.views - a.views).slice(0, 5),
[demand, placeById, assessments],
);
const decide = (id: string, status: 'accepted' | 'rejected') =>
setDecisions((d) => ({ ...d, [id]: { status, reviewedAt: new Date().toISOString(), reviewedBy: account ? t(`auth.${account.id}` as Key) : '' } }));
const doExport = (kind: 'csv' | 'geojson') => {
const stamp = new Date().toISOString().slice(0, 10);
const name = `krakow-bez-barier-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`;
if (kind === 'csv') download(name, 'text/csv', toCsv(realPlaces, assessments));
else download(name, 'application/geo+json', JSON.stringify(toGeoJson(realPlaces, assessments)));
setExportMsg(t('dash.export.done', { name }));
};
// ---- settings export / import ----
const [settingsMsg, setSettingsMsg] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
const exportSettings = () => download('kbb-settings.json', 'application/json', JSON.stringify({ app: 'kbb', v: 1, profile, settings, favorites }, null, 2));
const importSettings = async (file: File) => {
try {
const j: unknown = JSON.parse(await file.text());
if (!isObj<Record<string, unknown>>(j) || j.app !== 'kbb' || !isSettings(j.settings) || !(j.profile === null || isProfile(j.profile)) || !isStrArray(j.favorites)) throw new Error('shape');
setSettings(j.settings);
setProfile(j.profile);
setFavorites(j.favorites);
setSettingsMsg({ kind: 'success', text: t('set.import.ok') });
} catch {
setSettingsMsg({ kind: 'error', text: t('set.import.bad') });
}
};
// ---- shell ----
const mainRef = useRef<HTMLElement>(null);
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; }
mainRef.current?.focus();
}, [view]);
const [resetDone, setResetDone] = useState(false);
const reset = () => {
const keys = Object.keys(localStorage).filter((k) => k.startsWith('kbb.') && k !== 'kbb.lang');
clearLocal(keys);
setResetDone(true);
window.setTimeout(() => window.location.reload(), 600);
};
const official = account?.role === 'official';
const nav: { id: View; label: string; icon: string }[] = [
{ id: 'places', label: t('nav.places'), icon: 'location_on' },
{ id: 'route', label: t('nav.route'), icon: 'directions_walk' },
...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard'), icon: 'assessment' }] : []),
{ id: 'about', label: t('nav.about'), icon: 'info' },
{ id: 'profile', label: t('nav.profile'), icon: 'account_circle' },
];
const needsProfile = !!account && !profile;
const split = !!account && !needsProfile && (view === 'places' || view === 'route');
const ready = snap && index && graph && profile;
const card = cardId ? placeById.get(cardId) : null;
if (!account) {
return (
<I18nContext.Provider value={i18n}>
<div className="kbb-app"><main id="main" className="kbb-main"><LoginView onPick={signIn} lang={loginLang} onLang={setLoginLang} /></main></div>
</I18nContext.Provider>
);
}
return (
<I18nContext.Provider value={i18n}>
<div className={`kbb-app ${guiding && view === 'route' ? 'is-guiding' : ''}`}>
<a className="usa-skipnav" href="#main">{t('skip')}</a>
<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__body">
<p className="usa-alert__text">
<strong>{t('banner.proto')}</strong>{' '}
<span className="kbb-banner-detail">{snap ? t('banner.data', { date: i18n.date(snap.meta.fetchedAt.slice(0, 10)) }) : ''}</span>
</p>
</div>
</div>
</section>
<header className="bg-white border-bottom-1px border-base-lighter">
<div className="kbb-head">
<p className="margin-0 display-flex flex-align-center text-bold text-primary-darker kbb-head__brand">
<Icon name="accessible_forward" size={4} className="margin-right-1" />
{t('app.name')}
</p>
<nav aria-label={t('nav.label')} className="kbb-head__nav">
<ul className="kbb-nav usa-button-group usa-button-group--segmented margin-0 padding-0">
{nav.map((n) => (
<li className="usa-button-group__item" key={n.id}>
<button
type="button" aria-current={view === n.id ? 'page' : undefined}
className={`kbb-nav__btn usa-button usa-button--small ${view === n.id ? '' : 'usa-button--outline'}`}
onClick={() => setView(n.id)}
>
<Icon name={n.icon} size={3} className="kbb-nav__icon" />
<span>{n.label}</span>
</button>
</li>
))}
</ul>
</nav>
<div className="kbb-head__tools">
<span className="font-sans-xs text-base-dark kbb-head__user">
<Icon name="accessibility_new" size={3} className="margin-right-05" />
{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}
</span>
<div role="group" aria-label={t('lang.label')} className="display-flex">
{(['pl', 'en'] as const).map((l) => (
<button
key={l} type="button" lang={l} aria-pressed={lang === l}
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}
onClick={() => i18n.setLang(l)}
>
{l.toUpperCase()}
</button>
))}
</div>
</div>
</div>
</header>
{guiding && view === 'route' && route && route.status !== 'none' && (
<GuideBanner result={route} onStop={() => setGuiding(false)} onPos={setUserPos} onReroute={reroute} />
)}
<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 && !ready && !needsProfile && view !== 'profile' && (
<div className="grid-container padding-y-4" role="status">{t('common.loading')}</div>
)}
{(view === 'profile' || needsProfile) && (
<ProfileView
key={account.id + String(!!profile)}
account={account}
profile={profile}
onChange={setProfile}
onDone={() => setView('places')}
onSignOut={signOut}
preview={previewCounts}
>
{!needsProfile && (
<SettingsPanel
settings={settings} onSettings={setSettings} favoritesCount={favorites.length}
onExport={exportSettings} onImport={(f) => void importSettings(f)} message={settingsMsg}
/>
)}
</ProfileView>
)}
{view === 'about' && !needsProfile && snap && (
<AboutView
snapshotDate={snap.meta.fetchedAt.slice(0, 10)} counts={snap.meta.counts} cityData={snap.meta.cityData}
simulateOutage={simulateOutage} onSimulate={setSimulateOutage} onReset={reset} resetDone={resetDone}
/>
)}
{view === 'dashboard' && !needsProfile && (
official ? (
ready && (
<DashboardView
stats={stats} hotspots={hotspots} reports={reports} placeById={placeById} demand={demandList}
officialName={t(`auth.${account.id}` as Key)}
onDecide={decide} onExport={doExport} exportMsg={exportMsg}
onShowHotspot={(h) => { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }}
/>
)
) : (
<div className="grid-container padding-y-4 kbb-narrow"><Alert kind="warning" heading={t('dash.title')}>{t('dash.role')}</Alert></div>
)
)}
{split && ready && (
<>
<div className="kbb-panel">
{sheetMode && <SheetHandle sheet={sheet} onSheet={setSheet} />}
<div className="kbb-panel__body" {...(sheetMode && sheet === 'peek' ? { inert: true } : {})}>
{view === 'places' && hotMode && (
<div className="margin-bottom-2">
<Alert kind="info" slim>
{t('dash.hot.mapBanner', { n: hotspots.length })}{' '}
<button type="button" className="usa-button usa-button--unstyled" onClick={() => { setHotMode(false); setHotFocus(null); }}>{t('dash.hot.clear')}</button>
</Alert>
</div>
)}
{view === 'places' && (selected ? (
<PlaceDetail
ref={detailHeading}
place={selected}
assessment={assessments.get(selected.id) ?? assessPlace(selected, profile, index, reports)}
reports={reports}
snapshotDate={snap.meta.fetchedAt.slice(0, 10)}
refresh={refresh}
saved={favSet.has(selected.id)}
onToggleSave={() => setFavorites((f) => (f.includes(selected.id) ? f.filter((x) => x !== selected.id) : [...f, selected.id]))}
onCard={() => setCardId(selected.id)}
reach={reachState}
onReach={(m) => doReach(selected, m)}
onClearReach={clearReach}
onBack={back}
onRouteTo={() => routeTo(selected)}
onShowOnMap={() => setPane('map')}
onRefresh={() => void doRefresh(selected)}
onAddReport={(r, photo) => void addReport(r, photo)}
onRemoveReport={(id) => { void deletePhoto(id); setUserReports((cur) => cur.filter((r) => r.id !== id)); }}
/>
) : (
<PlacesView
profile={profile} filters={filters} onFilters={setFilters} results={results}
assessments={assessments} onSelect={select}
/>
))}
{view === 'route' && (
<RouteView
graph={graph} labels={labels} profile={profile} bbox={snap.meta.bbox}
form={routeForm} onForm={setRouteForm} options={routeOptions} ends={routeEnds} guiding={guiding} onGuide={() => { setGuiding(true); setPane('map'); }} selected={routeSel} onSelect={setRouteSel} onResult={onResult}
presets={presets} onFocus={(at) => { setFocus({ at, n: Date.now() }); setPane('map'); }}
/>
)}
<p className="font-sans-3xs text-base-dark margin-top-4">{t('footer.attr')}</p>
</div>
</div>
<MapView
key={lang}
places={mapPlaces}
showPlaces={view === 'places'}
selectedId={view === 'places' ? selectedId : null}
onSelect={select}
route={view === 'route' ? route : null}
routeEnds={view === 'route' ? routeEnds : null}
altRoutes={view === 'route' ? altRoutes : []}
focus={focus}
visible={sheet}
bbox={snap.meta.bbox}
online={online}
basemap={basemap}
onBasemap={setBasemap}
userPos={view === 'route' && guiding ? userPos : null}
follow={guiding}
reach={view === 'places' ? reachLines : null}
hotspots={view === 'places' && hotMode ? hotspots : []}
hotspotFocus={hotFocus}
reduceMotion={settings.reduceMotion}
/>
</>
)}
</main>
{card && profile && (
<AccessCard
place={card}
assessment={assessments.get(card.id) ?? assessPlace(card, profile, index!, reports)}
profile={profile}
onClose={() => setCardId(null)}
/>
)}
</div>
</I18nContext.Provider>
);
}