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
@@ -199,8 +199,10 @@ Texte aus Wikipedia, Einwohner aus Wikidata, Orte über Open-Meteo/GeoNames).
|
|||||||
Besuche sieht nur das eigene Konto; Fotos werden ohne Metadaten gespeichert.
|
Besuche sieht nur das eigene Konto; Fotos werden ohne Metadaten gespeichert.
|
||||||
- **Zeitleiste:** „Wann war ich wo?“ – alle Besuche nach Jahren, mit Fotos (Klick vergrößert).
|
- **Zeitleiste:** „Wann war ich wo?“ – alle Besuche nach Jahren, mit Fotos (Klick vergrößert).
|
||||||
- **Statistik:** Anteil der Welt (Länder und Gebiete der Länderdaten) und der Landfläche, je Kontinent,
|
- **Statistik:** Anteil der Welt (Länder und Gebiete der Länderdaten) und der Landfläche, je Kontinent,
|
||||||
nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie) entlang der
|
nördlichster/südlichster/östlichster/westlichster Ort und die Kilometer (Luftlinie). Mit **Wohnort**
|
||||||
Besuche in zeitlicher Reihenfolge.
|
(im Tab „Statistik“ festlegen, erscheint als ⌂ auf der Karte) zählt jede Reise mit Hin- und Heimweg;
|
||||||
|
Reisen mit höchstens einem Tag Abstand gelten als Rundreise, Besuche während eines längeren Aufenthalts
|
||||||
|
als Ausflug hin und zurück. „Wie gerechnet?“ listet alle Teilstrecken. Reisetage zählen jeden Tag einmal.
|
||||||
|
|
||||||
Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte der übrigen Konten ein – je Person in einer eigenen Farbe,
|
Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte der übrigen Konten ein – je Person in einer eigenen Farbe,
|
||||||
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
|
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
|
||||||
|
|||||||
@@ -232,6 +232,16 @@ CREATE TABLE IF NOT EXISTS travel_visits (
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Wohnort pro Konto: Start und Ziel jeder Reise für die Kilometer-Statistik
|
||||||
|
CREATE TABLE IF NOT EXISTS travel_home (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
geoname_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS travel_visit_photos (
|
CREATE TABLE IF NOT EXISTS travel_visit_photos (
|
||||||
visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE,
|
visit_id INTEGER NOT NULL REFERENCES travel_visits(id) ON DELETE CASCADE,
|
||||||
file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert)
|
file TEXT NOT NULL, -- Datei in data/uploads (ohne Metadaten gespeichert)
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ def _visits(conn, user_id: int) -> list:
|
|||||||
def _state(conn, user_id: int) -> dict:
|
def _state(conn, user_id: int) -> dict:
|
||||||
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall()
|
cities = conn.execute("SELECT * FROM travel_cities WHERE user_id = ? ORDER BY name", (user_id,)).fetchall()
|
||||||
countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
|
countries = conn.execute("SELECT code FROM travel_countries WHERE user_id = ? ORDER BY code", (user_id,)).fetchall()
|
||||||
|
home = conn.execute("SELECT * FROM travel_home WHERE user_id = ?", (user_id,)).fetchone()
|
||||||
return {
|
return {
|
||||||
"cities": [_city(r) for r in cities],
|
"cities": [_city(r) for r in cities],
|
||||||
"countries": [r["code"] for r in countries],
|
"countries": [r["code"] for r in countries],
|
||||||
|
"home": {k: home[k] for k in ("name", "country", "lat", "lon")} | {"geonameId": home["geoname_id"]} if home else None,
|
||||||
"visits": _visits(conn, user_id),
|
"visits": _visits(conn, user_id),
|
||||||
"others": _others(conn, user_id),
|
"others": _others(conn, user_id),
|
||||||
}
|
}
|
||||||
@@ -178,6 +180,39 @@ def update_city(city_id: int, data: dict = Body(...), user: dict = Depends(curre
|
|||||||
return _state(conn, user["id"])
|
return _state(conn, user["id"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Wohnort ----------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/reisen/wohnort")
|
||||||
|
def set_home(data: dict = Body(...), user: dict = Depends(current_user)):
|
||||||
|
"""Wohnort festlegen (Ergebnis der Städtesuche); nur für die eigene Kilometer-Statistik."""
|
||||||
|
geoname_id = data.get("geonameId")
|
||||||
|
if isinstance(geoname_id, bool) or not isinstance(geoname_id, int) or geoname_id <= 0:
|
||||||
|
raise ValidationError("Ungültige Stadt.")
|
||||||
|
country = data.get("country")
|
||||||
|
if not isinstance(country, str) or not re.fullmatch(r"[A-Z]{2}", country):
|
||||||
|
raise ValidationError("Ungültiges Land.")
|
||||||
|
values = (
|
||||||
|
user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), country,
|
||||||
|
_number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
|
||||||
|
)
|
||||||
|
with transaction() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO travel_home (user_id, geoname_id, name, country, lat, lon) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET geoname_id = excluded.geoname_id, name = excluded.name,
|
||||||
|
country = excluded.country, lat = excluded.lat, lon = excluded.lon""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return _state(conn, user["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/reisen/wohnort")
|
||||||
|
def delete_home(user: dict = Depends(current_user)):
|
||||||
|
with transaction() as conn:
|
||||||
|
conn.execute("DELETE FROM travel_home WHERE user_id = ?", (user["id"],))
|
||||||
|
return _state(conn, user["id"])
|
||||||
|
|
||||||
|
|
||||||
# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ----------
|
# ---------- Besuche: Zeitraum, Begleitung, Notizen, Fotos ----------
|
||||||
|
|
||||||
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
||||||
|
|||||||
@@ -112,3 +112,13 @@ def test_reisefotos_ueberleben_aufraeumen(login):
|
|||||||
os.utime(DATA_DIR / "uploads" / f, (old, old))
|
os.utime(DATA_DIR / "uploads" / f, (old, old))
|
||||||
projects._cleanup_orphans()
|
projects._cleanup_orphans()
|
||||||
assert (DATA_DIR / "uploads" / f).exists()
|
assert (DATA_DIR / "uploads" / f).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wohnort_setzen_und_entfernen(login):
|
||||||
|
anna, ben = login("anna"), login("ben")
|
||||||
|
berlin = {k: BERLIN[k] for k in ("geonameId", "name", "country", "lat", "lon")}
|
||||||
|
data = anna.put("/api/reisen/wohnort", json=berlin).json()
|
||||||
|
assert data["home"] == berlin
|
||||||
|
assert ben.get("/api/reisen").json()["home"] is None # nur das eigene Konto
|
||||||
|
assert anna.put("/api/reisen/wohnort", json={**berlin, "lat": 99}).status_code == 400
|
||||||
|
assert anna.delete("/api/reisen/wohnort").json()["home"] is None
|
||||||
@@ -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
|
// 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 [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 [q, setQ] = useState('');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -56,33 +58,35 @@ export default function CitySearch({ cities, onAdd, busy }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="city-search-row">
|
<div className="city-search-row">
|
||||||
|
{mode === 'add' && (
|
||||||
<div className="sort-toggle" role="group" aria-label="Hinzufügen als">
|
<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(false)}>Besucht</button>
|
||||||
<button type="button" aria-pressed={wish} onClick={() => setWish(true)}>☆ Wunschliste</button>
|
<button type="button" aria-pressed={wish} onClick={() => setWish(true)}>☆ Wunschliste</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="city-search combo">
|
<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">
|
<div className="prompt-input">
|
||||||
<input
|
<input
|
||||||
id="city-search"
|
id={ids}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
onBlur={() => setOpen(false)}
|
onBlur={() => setOpen(false)}
|
||||||
onKeyDown={onKeyDown}
|
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"
|
role="combobox"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-expanded={show && results.length > 0}
|
aria-expanded={show && results.length > 0}
|
||||||
aria-controls="city-results"
|
aria-controls={`${ids}-results`}
|
||||||
aria-activedescendant={show && active >= 0 ? `city-result-${active}` : undefined}
|
aria-activedescendant={show && active >= 0 ? `${ids}-result-${active}` : undefined}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{show && (
|
{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 === 'loading' && results.length === 0 && <li className="combo-info">Suche …</li>}
|
||||||
{state === 'error' && <li className="combo-info">Suche gerade nicht erreichbar.</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>}
|
{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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={r.geonameId}
|
key={r.geonameId}
|
||||||
id={`city-result-${i}`}
|
id={`${ids}-result-${i}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === active}
|
aria-selected={i === active}
|
||||||
aria-disabled={added || undefined}
|
aria-disabled={added || undefined}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import LAENDER from '../../data/laender.json';
|
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 pct = (part, total) => (total ? (part / total) * 100 : 0);
|
||||||
const fmtPct = (p) => `${p.toLocaleString('de-DE', { maximumFractionDigits: p < 10 ? 1 : 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.
|
* 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).
|
* 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 codes = [...visited].filter((c) => BY_CODE.has(c));
|
||||||
const area = codes.reduce((s, c) => s + (BY_CODE.get(c).area || 0), 0);
|
const area = codes.reduce((s, c) => s + (BY_CODE.get(c).area || 0), 0);
|
||||||
const continents = CONTINENTS.map((name) => {
|
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'}`],
|
['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
|
// Kilometer: Reisen ab Wohnort, Rundreisen und Ausflüge (siehe route.js)
|
||||||
const byId = new Map(cities.map((c) => [c.id, c]));
|
const route = travelRoute(visits, cities, home);
|
||||||
const route = [...visits].sort((a, b) => a.dateFrom.localeCompare(b.dateFrom)).map((v) => byId.get(v.cityId)).filter(Boolean);
|
const days = travelDays(visits);
|
||||||
let km = 0;
|
const summary = [
|
||||||
for (let i = 1; i < route.length; i += 1) if (route[i].id !== route[i - 1].id) km += distanceKm(route[i - 1], route[i]);
|
`${route.trips} ${route.trips === 1 ? 'Reise' : 'Reisen'}${home ? ` ab ${home.name}` : ''}`,
|
||||||
const days = visits.reduce((s, v) => s + 1 + (v.dateTo ? Math.round((new Date(v.dateTo) - new Date(v.dateFrom)) / 864e5) : 0), 0);
|
route.excursions && `${route.excursions} ${route.excursions === 1 ? 'Ausflug' : 'Ausflüge'}`,
|
||||||
|
`${days} Reisetage`,
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="travel-stats">
|
<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>{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>{cities.length}</strong><span>{cities.length === 1 ? 'Stadt' : 'Städte'} besucht · {wishes} auf der Wunschliste</span></div>
|
||||||
<div className="stat-tile">
|
<div className="stat-tile">
|
||||||
<strong>{route.length > 1 ? `${num(Math.round(km))} km` : '–'}</strong>
|
<strong>{route.legs.length ? `${num(Math.round(route.km))} km` : '–'}</strong>
|
||||||
<span>{route.length > 1 ? `Luftlinie entlang ${visits.length} Besuchen (${days} Reisetage)` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
|
<span>{route.legs.length ? `Luftlinie · ${summary}` : 'Besuche mit Datum eintragen, um Kilometer zu sehen'}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<section className="stat-continents" aria-labelledby="stat-kontinente">
|
||||||
<h2 className="section-title" id="stat-kontinente">Kontinente</h2>
|
<h2 className="section-title" id="stat-kontinente">Kontinente</h2>
|
||||||
{continents.map((c) => (
|
{continents.map((c) => (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function mainPart(feature) {
|
|||||||
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
|
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
|
||||||
* offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt.
|
* 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 box = useRef(null);
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const layer = useRef(null);
|
const layer = useRef(null);
|
||||||
@@ -133,6 +133,14 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
|
|||||||
<title>{c.name}</title>
|
<title>{c.name}</title>
|
||||||
</path>
|
</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) => {
|
{[...otherCities, ...cities].map((city) => {
|
||||||
const p = projection([city.lon, city.lat]);
|
const p = projection([city.lon, city.lat]);
|
||||||
if (!p) return null;
|
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}
|
visited={visited}
|
||||||
cities={own}
|
cities={own}
|
||||||
otherCities={otherCities}
|
otherCities={otherCities}
|
||||||
|
home={data?.home}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelect={(sel) => select(sel)}
|
onSelect={(sel) => select(sel)}
|
||||||
/>
|
/>
|
||||||
@@ -253,7 +254,17 @@ export default function Reisen() {
|
|||||||
<Timeline visits={visits} cities={own} onSelectCity={showCity} onEdit={openVisit} onPhoto={setZoom} />
|
<Timeline visits={visits} cities={own} onSelectCity={showCity} onEdit={openVisit} onPhoto={setZoom} />
|
||||||
)}
|
)}
|
||||||
{data && tab === 'statistik' && (
|
{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' && (
|
{data && tab === 'wunsch' && (
|
||||||
wishes.length ? (
|
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 { 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; }
|
.link-picker ul.visit-photos li { width: auto; padding: 0; border: 0; }
|
||||||
.visit-hint { margin: -8px 0 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