- Wohnorte als Liste mit "seit"-Datum (Tabelle travel_homes, ersetzt den einzelnen Wohnort; Migration Schritt 5 übernimmt ihn): Reisen starten beim damals gültigen Wohnort, Wechsel zählen als Umzug, Tage am Wohnort nicht als Reisetage - "In dieser Zeit hier gewohnt" im Besuch: legt Wohnort ab Beginn und den vorherigen wieder ab Ende + 1 Tag an - Anreise je Besuch (automatisch / von zuhause / vom vorherigen Ort): Rundreisen auch mit mehreren Tagen zwischen den Orten - Fix: Besuche bleiben bearbeitbar, auch wenn eine Fotodatei fehlt; Speicherfehler erscheinen im Dialog statt verdeckt dahinter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
6.5 KiB
React
128 lines
6.5 KiB
React
import { useState } from 'react';
|
||
import LAENDER from '../../data/laender.json';
|
||
import CitySearch from './CitySearch.jsx';
|
||
import { BY_CODE, CONTINENTS, continentOf, countryName, fmtDay, num } from './laender.js';
|
||
import { travelDays, travelRoute } from './route.js';
|
||
|
||
const pct = (part, total) => (total ? (part / total) * 100 : 0);
|
||
const fmtPct = (p) => `${p.toLocaleString('de-DE', { maximumFractionDigits: p < 10 ? 1 : 0 })} %`;
|
||
const TOTAL_AREA = LAENDER.reduce((s, c) => s + (c.area || 0), 0);
|
||
|
||
/**
|
||
* Statistik: Länder (Welt und je Kontinent), Fläche, äußerste Punkte, Kilometer entlang der Zeitleiste.
|
||
* Grundlage für "Welt": alle Länder und Gebiete der Länderdaten (world-countries).
|
||
*/
|
||
export default function TravelStats({ visited, cities, visits, wishes, homes, onAddHome, onRemoveHome, busy, onSelectCity }) {
|
||
const [since, setSince] = useState('');
|
||
const codes = [...visited].filter((c) => BY_CODE.has(c));
|
||
const area = codes.reduce((s, c) => s + (BY_CODE.get(c).area || 0), 0);
|
||
const continents = CONTINENTS.map((name) => {
|
||
const total = LAENDER.filter((c) => c.continent === name).length;
|
||
const seen = codes.filter((c) => continentOf(c) === name).length;
|
||
return { name, total, seen };
|
||
}).filter((c) => c.total);
|
||
|
||
// Äußerste Punkte (nur besuchte Städte)
|
||
const extreme = (pick) => (cities.length ? cities.reduce((a, b) => (pick(b) > pick(a) ? b : a)) : null);
|
||
const points = [
|
||
['Nördlichster Ort', extreme((c) => c.lat), (c) => `${c.lat.toFixed(1).replace('.', ',')}° N`],
|
||
['Südlichster Ort', extreme((c) => -c.lat), (c) => `${Math.abs(c.lat).toFixed(1).replace('.', ',')}° ${c.lat < 0 ? 'S' : 'N'}`],
|
||
['Östlichster Ort', extreme((c) => c.lon), (c) => `${Math.abs(c.lon).toFixed(1).replace('.', ',')}° ${c.lon < 0 ? 'W' : 'O'}`],
|
||
['Westlichster Ort', extreme((c) => -c.lon), (c) => `${Math.abs(c.lon).toFixed(1).replace('.', ',')}° ${c.lon < 0 ? 'W' : 'O'}`],
|
||
];
|
||
|
||
// Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js)
|
||
const route = travelRoute(visits, cities, homes);
|
||
const days = travelDays(visits, cities, homes);
|
||
const summary = [
|
||
`${route.trips} ${route.trips === 1 ? 'Reise' : 'Reisen'}${homes.length ? ' ab Wohnort' : ''}`,
|
||
route.excursions && `${route.excursions} ${route.excursions === 1 ? 'Ausflug' : 'Ausflüge'}`,
|
||
`${days} Reisetage`,
|
||
].filter(Boolean).join(' · ');
|
||
|
||
return (
|
||
<div className="travel-stats">
|
||
<div className="stat-tiles">
|
||
<div className="stat-tile"><strong>{fmtPct(pct(codes.length, LAENDER.length))}</strong><span>der Welt ({codes.length} von {LAENDER.length} Ländern und Gebieten)</span></div>
|
||
<div className="stat-tile"><strong>{fmtPct(pct(area, TOTAL_AREA))}</strong><span>der Landfläche ({num(Math.round(area))} km²)</span></div>
|
||
<div className="stat-tile"><strong>{cities.length}</strong><span>{cities.length === 1 ? 'Stadt' : 'Städte'} besucht · {wishes} auf der Wunschliste</span></div>
|
||
<div className="stat-tile">
|
||
<strong>{route.legs.length ? `${num(Math.round(route.km))} km` : '–'}</strong>
|
||
<span>{route.legs.length ? `Luftlinie · ${summary}` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<section className="stat-home" aria-labelledby="stat-wohnort">
|
||
<h2 className="section-title" id="stat-wohnort">Wohnorte</h2>
|
||
{homes.length ? (
|
||
<ul className="stat-homes">
|
||
{homes.map((h) => (
|
||
<li key={h.id}>
|
||
⌂ <strong>{h.name}</strong> <small>{countryName(h.country)} · {h.since ? `seit ${fmtDay(h.since)}` : 'von Anfang an'}</small>
|
||
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemoveHome(h)} aria-label={`Wohnort ${h.name} entfernen`}>✕</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="hint">Mit Wohnort zählt jede Reise als Hin- und Rückweg ab zuhause.</p>
|
||
)}
|
||
<div className="stat-home-add">
|
||
<label className="stat-home-since">
|
||
<span>Seit</span>
|
||
<input type="date" value={since} onChange={(e) => setSince(e.target.value)} aria-describedby="since-hint" />
|
||
</label>
|
||
<CitySearch mode="home" cities={[]} onAdd={async (r) => { if (await onAddHome({ ...r, since: since || null })) setSince(''); }} busy={busy} />
|
||
</div>
|
||
<p className="hint" id="since-hint">
|
||
„Seit“ leer = von Anfang an. Zog man um oder wohnte länger im Ausland, einen weiteren Wohnort mit Datum eintragen –
|
||
oder beim Besuch „In dieser Zeit hier gewohnt“ wählen.
|
||
</p>
|
||
{route.legs.length > 0 && (
|
||
<details className="stat-route">
|
||
<summary>Wie gerechnet? ({route.legs.length} Teilstrecken)</summary>
|
||
<ol>
|
||
{route.legs.map((l, i) => (
|
||
<li key={i}>
|
||
<span>{l.from} → {l.to}</span>
|
||
<small>{l.kind}</small>
|
||
<strong>{num(Math.round(l.km))} km</strong>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
{!homes.length && <p className="hint">Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden direkt nacheinander verbunden.</p>}
|
||
</details>
|
||
)}
|
||
</section>
|
||
|
||
<section className="stat-continents" aria-labelledby="stat-kontinente">
|
||
<h2 className="section-title" id="stat-kontinente">Kontinente</h2>
|
||
{continents.map((c) => (
|
||
<div key={c.name} className="meter">
|
||
<div className="meter-label"><span>{c.name}</span><strong>{c.seen} / {c.total} · {fmtPct(pct(c.seen, c.total))}</strong></div>
|
||
<span className="meter-bar"><i style={{ width: `${pct(c.seen, c.total)}%` }} /></span>
|
||
</div>
|
||
))}
|
||
</section>
|
||
|
||
{cities.length > 0 && (
|
||
<section aria-labelledby="stat-punkte">
|
||
<h2 className="section-title" id="stat-punkte">Äußerste Punkte</h2>
|
||
<dl className="info-facts stat-points">
|
||
{points.map(([label, city, fmt]) => (
|
||
<div key={label}>
|
||
<dt>{label}</dt>
|
||
<dd>
|
||
<button type="button" className="link-btn" onClick={() => onSelectCity(city)}>
|
||
{BY_CODE.get(city.country)?.flag} {city.name}
|
||
</button>
|
||
<small> {countryName(city.country)} · {fmt(city)}</small>
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|