- Wunschliste (Spalte travel_cities.wish): eigene Punktform auf der Karte, zählt nicht als besucht, für andere Konten unsichtbar; "War ich da ✓" - Besuche je Stadt (Tabellen travel_visits, travel_visit_photos): Zeitraum, Begleitung, Notizen, Fotos (Upload ohne Metadaten); nur für das eigene Konto; ein Besuch macht aus einem Wunsch einen Besuch - Fotos von Besuchen zählen beim Aufräumen verwaister Bilder als benutzt; beim Entfernen eines Fotos, Besuchs oder einer Stadt werden die Dateien gelöscht - Zeitleiste "Wann war ich wo?" nach Jahren, Fotos mit Lightbox - Statistik: Länder (Welt, je Kontinent), Landfläche, äußerste Orte, Kilometer entlang der Besuche - Ansichten unter der Karte als Tabs: Besuchte Orte, Zeitleiste, Statistik, Wunschliste - Migration Schritt 4, Tests für Wunschliste, Besuche, Fotos und Aufräumen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
80 lines
4.3 KiB
React
80 lines
4.3 KiB
React
import LAENDER from '../../data/laender.json';
|
||
import { BY_CODE, CONTINENTS, continentOf, countryName, distanceKm, num } from './laender.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, onSelectCity }) {
|
||
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: Besuche in zeitlicher Reihenfolge, Luftlinie von Ort zu Ort
|
||
const byId = new Map(cities.map((c) => [c.id, c]));
|
||
const route = [...visits].sort((a, b) => a.dateFrom.localeCompare(b.dateFrom)).map((v) => byId.get(v.cityId)).filter(Boolean);
|
||
let km = 0;
|
||
for (let i = 1; i < route.length; i += 1) if (route[i].id !== route[i - 1].id) km += distanceKm(route[i - 1], route[i]);
|
||
const days = visits.reduce((s, v) => s + 1 + (v.dateTo ? Math.round((new Date(v.dateTo) - new Date(v.dateFrom)) / 864e5) : 0), 0);
|
||
|
||
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.length > 1 ? `${num(Math.round(km))} km` : '–'}</strong>
|
||
<span>{route.length > 1 ? `Luftlinie entlang ${visits.length} Besuchen (${days} Reisetage)` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
);
|
||
}
|