Reisen: Wohnort und nachvollziehbare Kilometer-Rechnung
- Wohnort pro Konto (Tabelle travel_home), im Tab "Statistik" über die Städtesuche festlegen, auf der Karte als ⌂ - Kilometer (route.js): jede Reise mit Hin- und Heimweg ab Wohnort; Reisen mit höchstens einem Tag Abstand als Rundreise; Besuche während eines längeren Aufenthalts als Ausflug hin und zurück; ohne Wohnort werden Reisen direkt verbunden - "Wie gerechnet?" listet alle Teilstrecken; Reisetage zählen jeden Tag nur einmal (Ausflüge im Aufenthalt nicht doppelt) - Wohnort-Suche mit eigenen IDs (zwei Suchfelder auf der Seite) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdbb31065f
commit
56c4f38ef0
10 files changed
+210
-22
No files matched your search
@@ -4,8 +4,10 @@ import { BY_CODE, people } from './laender.js';
|
||||
|
||||
// Stadt suchen (Open-Meteo über den Server) und hinzufügen; schon eingetragene sind markiert
|
||||
|
||||
export default function CitySearch({ cities, onAdd, busy }) {
|
||||
// mode: 'add' (Stadt besucht/Wunschliste hinzufügen) oder 'home' (Wohnort wählen)
|
||||
export default function CitySearch({ cities, onAdd, busy, mode = 'add' }) {
|
||||
const [wish, setWish] = useState(false); // Besucht oder Wunschliste
|
||||
const ids = mode === 'home' ? 'home-search' : 'city-search'; // eindeutig, auch wenn beide Suchen auf der Seite sind
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -56,33 +58,35 @@ export default function CitySearch({ cities, onAdd, busy }) {
|
||||
|
||||
return (
|
||||
<div className="city-search-row">
|
||||
{mode === 'add' && (
|
||||
<div className="sort-toggle" role="group" aria-label="Hinzufügen als">
|
||||
<button type="button" aria-pressed={!wish} onClick={() => setWish(false)}>Besucht</button>
|
||||
<button type="button" aria-pressed={wish} onClick={() => setWish(true)}>☆ Wunschliste</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="city-search combo">
|
||||
<label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label>
|
||||
<label className="sr-only" htmlFor={ids}>Stadt hinzufügen</label>
|
||||
<div className="prompt-input">
|
||||
<input
|
||||
id="city-search"
|
||||
id={ids}
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={wish ? 'Stadt auf die Wunschliste setzen …' : 'Stadt hinzufügen, in der du schon warst …'}
|
||||
placeholder={mode === 'home' ? 'Wohnort suchen …' : wish ? 'Stadt auf die Wunschliste setzen …' : 'Stadt hinzufügen, in der du schon warst …'}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={show && results.length > 0}
|
||||
aria-controls="city-results"
|
||||
aria-activedescendant={show && active >= 0 ? `city-result-${active}` : undefined}
|
||||
aria-controls={`${ids}-results`}
|
||||
aria-activedescendant={show && active >= 0 ? `${ids}-result-${active}` : undefined}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
{show && (
|
||||
<ul className="combo-list" id="city-results" role="listbox">
|
||||
<ul className="combo-list" id={`${ids}-results`} role="listbox">
|
||||
{state === 'loading' && results.length === 0 && <li className="combo-info">Suche …</li>}
|
||||
{state === 'error' && <li className="combo-info">Suche gerade nicht erreichbar.</li>}
|
||||
{state === 'idle' && results.length === 0 && <li className="combo-info">Keine Stadt gefunden.</li>}
|
||||
@@ -91,7 +95,7 @@ export default function CitySearch({ cities, onAdd, busy }) {
|
||||
return (
|
||||
<li
|
||||
key={r.geonameId}
|
||||
id={`city-result-${i}`}
|
||||
id={`${ids}-result-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
aria-disabled={added || undefined}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import LAENDER from '../../data/laender.json';
|
||||
import { BY_CODE, CONTINENTS, continentOf, countryName, distanceKm, num } from './laender.js';
|
||||
import CitySearch from './CitySearch.jsx';
|
||||
import { BY_CODE, CONTINENTS, continentOf, countryName, 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 })} %`;
|
||||
@@ -9,7 +11,7 @@ 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 }) {
|
||||
export default function TravelStats({ visited, cities, visits, wishes, home, onSetHome, onRemoveHome, busy, 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) => {
|
||||
@@ -27,12 +29,14 @@ export default function TravelStats({ visited, cities, visits, wishes, onSelectC
|
||||
['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);
|
||||
// Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js)
|
||||
const route = travelRoute(visits, cities, home);
|
||||
const days = travelDays(visits);
|
||||
const summary = [
|
||||
`${route.trips} ${route.trips === 1 ? 'Reise' : 'Reisen'}${home ? ` ab ${home.name}` : ''}`,
|
||||
route.excursions && `${route.excursions} ${route.excursions === 1 ? 'Ausflug' : 'Ausflüge'}`,
|
||||
`${days} Reisetage`,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div className="travel-stats">
|
||||
@@ -41,11 +45,39 @@ export default function TravelStats({ visited, cities, visits, wishes, onSelectC
|
||||
<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>
|
||||
<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">Wohnort</h2>
|
||||
{home ? (
|
||||
<p className="stat-home-current">
|
||||
⌂ <strong>{home.name}</strong> <small>{countryName(home.country)}</small>
|
||||
<button className="link-btn danger" type="button" disabled={busy} onClick={onRemoveHome}>Entfernen</button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="hint">Mit Wohnort zählt jede Reise als Hin- und Rückweg ab zuhause.</p>
|
||||
)}
|
||||
<CitySearch mode="home" cities={[]} onAdd={onSetHome} busy={busy} />
|
||||
{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>
|
||||
{!home && <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) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ function mainPart(feature) {
|
||||
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
|
||||
* offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt.
|
||||
*/
|
||||
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], selected, onSelect }) {
|
||||
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], home, selected, onSelect }) {
|
||||
const box = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const layer = useRef(null);
|
||||
@@ -133,6 +133,14 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
|
||||
<title>{c.name}</title>
|
||||
</path>
|
||||
))}
|
||||
{home && (() => {
|
||||
const p = projection([home.lon, home.lat]);
|
||||
return p && (
|
||||
<g transform={`translate(${p[0]},${p[1]})`}>
|
||||
<g className="map-home"><title>Wohnort: {home.name}</title><path d="M0 -6 L6 0 L4 0 L4 5 L-4 5 L-4 0 L-6 0 Z" /></g>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
{[...otherCities, ...cities].map((city) => {
|
||||
const p = projection([city.lon, city.lat]);
|
||||
if (!p) return null;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Reiseroute für die Kilometer-Statistik, aus den eingetragenen Besuchen (Luftlinie):
|
||||
// - Reise: Wohnort → Ziel → Wohnort. Beginnt die nächste Reise höchstens einen Tag nach dem Ende der vorigen,
|
||||
// ist es eine Rundreise (Wohnort → A → B → Wohnort).
|
||||
// - Ausflug: Besuch, der während eines längeren Aufenthalts beginnt (z. B. David während San José) –
|
||||
// zählt als Hin- und Rückweg zum Aufenthaltsort.
|
||||
// Ohne Wohnort fehlen Hin- und Heimwege; die Reisen werden dann direkt nacheinander verbunden.
|
||||
import { distanceKm } from './laender.js';
|
||||
|
||||
const day = (d) => Math.round(Date.parse(`${d}T12:00:00Z`) / 864e5);
|
||||
|
||||
export function travelRoute(visits, cities, home) {
|
||||
const byId = new Map(cities.map((c) => [c.id, c]));
|
||||
const items = visits
|
||||
.map((v) => ({ ...v, city: byId.get(v.cityId), to: v.dateTo || v.dateFrom }))
|
||||
.filter((v) => v.city)
|
||||
.sort((a, b) => a.dateFrom.localeCompare(b.dateFrom) || b.to.localeCompare(a.to));
|
||||
|
||||
// Ausflüge ihrem Aufenthalt zuordnen
|
||||
const trips = [];
|
||||
for (const v of items) {
|
||||
const base = trips.at(-1)?.visit;
|
||||
if (base && v.dateFrom >= base.dateFrom && v.dateFrom <= base.to) trips.at(-1).excursions.push(v);
|
||||
else trips.push({ visit: v, excursions: [] });
|
||||
}
|
||||
|
||||
const legs = [];
|
||||
const leg = (from, to, kind) => {
|
||||
if (!from || !to || (from.lat === to.lat && from.lon === to.lon)) return;
|
||||
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind });
|
||||
};
|
||||
let pos = null;
|
||||
trips.forEach((t, i) => {
|
||||
const chained = i > 0 && day(t.visit.dateFrom) - day(trips[i - 1].visit.to) <= 1;
|
||||
if (home && !chained) {
|
||||
leg(pos, home, 'Heimweg');
|
||||
pos = home;
|
||||
}
|
||||
// Ohne Wohnort werden getrennte Reisen direkt verbunden – das ist dann eine Weiterreise, kein Hinweg
|
||||
leg(pos, t.visit.city, chained || !home ? 'Weiterreise' : 'Hinweg');
|
||||
pos = t.visit.city;
|
||||
for (const ex of t.excursions) {
|
||||
leg(pos, ex.city, 'Ausflug');
|
||||
leg(ex.city, pos, 'Ausflug zurück');
|
||||
}
|
||||
});
|
||||
if (home && trips.length) leg(pos, home, 'Heimweg');
|
||||
|
||||
return {
|
||||
legs,
|
||||
km: legs.reduce((s, l) => s + l.km, 0),
|
||||
trips: trips.length,
|
||||
excursions: trips.reduce((s, t) => s + t.excursions.length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// Reisetage: jeder Kalendertag nur einmal (Ausflüge während eines Aufenthalts zählen nicht doppelt)
|
||||
export function travelDays(visits) {
|
||||
const days = new Set();
|
||||
for (const v of visits) {
|
||||
for (let d = day(v.dateFrom); d <= day(v.dateTo || v.dateFrom); d += 1) days.add(d);
|
||||
}
|
||||
return days.size;
|
||||
}
|
||||
@@ -205,6 +205,7 @@ export default function Reisen() {
|
||||
visited={visited}
|
||||
cities={own}
|
||||
otherCities={otherCities}
|
||||
home={data?.home}
|
||||
selected={selected}
|
||||
onSelect={(sel) => select(sel)}
|
||||
/>
|
||||
@@ -253,7 +254,17 @@ export default function Reisen() {
|
||||
<Timeline visits={visits} cities={own} onSelectCity={showCity} onEdit={openVisit} onPhoto={setZoom} />
|
||||
)}
|
||||
{data && tab === 'statistik' && (
|
||||
<TravelStats visited={visited} cities={cities} visits={visits} wishes={wishes.length} onSelectCity={showCity} />
|
||||
<TravelStats
|
||||
visited={visited}
|
||||
cities={cities}
|
||||
visits={visits}
|
||||
wishes={wishes.length}
|
||||
home={data.home}
|
||||
busy={busy}
|
||||
onSetHome={(result) => run('PUT', '/api/reisen/wohnort', result)}
|
||||
onRemoveHome={() => run('DELETE', '/api/reisen/wohnort')}
|
||||
onSelectCity={showCity}
|
||||
/>
|
||||
)}
|
||||
{data && tab === 'wunsch' && (
|
||||
wishes.length ? (
|
||||
|
||||
@@ -236,3 +236,16 @@
|
||||
.link-picker ul.visit-photos { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; max-height: none; }
|
||||
.link-picker ul.visit-photos li { width: auto; padding: 0; border: 0; }
|
||||
.visit-hint { margin: -8px 0 0; }
|
||||
|
||||
/* Wohnort */
|
||||
.map-home { transform: scale(var(--inv)); pointer-events: none; }
|
||||
.map-home path { fill: var(--bright); stroke: var(--bg); stroke-width: 1.5; paint-order: stroke; }
|
||||
.stat-home { display: grid; gap: 10px; max-width: 760px; }
|
||||
.stat-home-current { display: flex; align-items: baseline; gap: 10px; margin: 0; }
|
||||
.stat-home-current small { color: var(--muted); }
|
||||
.stat-home .city-search-row { margin: 0; }
|
||||
.stat-route summary { cursor: pointer; color: var(--accent); }
|
||||
.stat-route ol { margin: 8px 0 0; padding-left: 1.6em; display: grid; gap: 2px; }
|
||||
.stat-route li span { margin-right: 8px; }
|
||||
.stat-route li small { color: var(--muted); margin-right: 8px; }
|
||||
.stat-route li strong { font-variant-numeric: tabular-nums; }
|
||||
Reference in new issue
Block a user