Der Kopf traegt jetzt Name, Zustand und Kennzahlen: Der Status sitzt als farbiges Dropdown direkt hinter dem Namen und nennt darunter, wann und von wem er gemeldet wurde. Laenge und Hoehenmeter stehen mit Icons daneben. Karte und Hoehenprofil links, Marker, Kommentare und Versionen rechts daneben in einer Spalte. Die Reiter entfallen - alles ist gleichzeitig zu sehen. Versionen sind Archiv und deshalb eingeklappt; der GPX-Upload sitzt dort statt im Kopf. Marker lassen sich jetzt auch loeschen und auch ueber das Hoehenprofil setzen: Die angeklickte Stelle wird auf dieselbe Linie zurueckgerechnet, die ein Klick auf der Karte trifft. Die Flag-Icons wurden bisher gespeichert, aber nirgends gezeichnet. Sie erscheinen jetzt in der Karte, im Popup, in der Markerliste und in der Verwaltung. Erlaubt ist jeder Name von lucide.dev - moeglich macht das ein Glob ueber die Icon-Dateien, der jedes Icon zu einem eigenen, erst bei Bedarf geladenen Chunk macht. Die Liste im Dialog sind nur Vorschlaege. Die Karte kommt von CARTO statt von OSM direkt: zurueckhaltend gezeichnet, sodass die Trail-Linie darueber steht, und mit einer echten dunklen Fassung. Das vorherige Abdunkeln der OSM-Kacheln ergab nur ein dunkleres Bild, keine dunkle Karte. In der Kachelvorschau der Liste sind Zoom, Massstab und Quellenangabe abgeschaltet - dort ist die Karte Bild, nicht Werkzeug; die grosse Karte traegt die Angabe weiterhin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
210 lines
7.3 KiB
Svelte
210 lines
7.3 KiB
Svelte
<script lang="ts">
|
||
/**
|
||
* Höhenprofil als SVG-Fläche. Die hervorgehobene Stelle kommt als Prop
|
||
* herein und geht beim Überfahren als Callback hinaus — die Elternseite
|
||
* hält den Wert und reicht ihn ebenso an die Karte weiter. So zeigen
|
||
* Karte und Profil immer dieselbe Stelle, egal wo gerade gezeigt wird.
|
||
* Gekoppelt wird über die Distanz, nicht über den Index: Die Karte zeigt
|
||
* eine vereinfachte Punktreihe, das Profil alle Originalpunkte.
|
||
*/
|
||
let {
|
||
elevation,
|
||
onhover,
|
||
onselect,
|
||
highlightDistance = null,
|
||
height = '120px',
|
||
}: {
|
||
elevation: { d: number; ele: number }[]
|
||
onhover?: (distance: number | null) => void
|
||
/**
|
||
* Klick auf eine Stelle des Profils. Damit lässt sich ein Marker auch
|
||
* über die Höhe finden — im steilen Stück, statt die Linie auf der
|
||
* Karte abzusuchen.
|
||
*/
|
||
onselect?: (distance: number) => void
|
||
highlightDistance?: number | null
|
||
height?: string
|
||
} = $props()
|
||
|
||
// Internes Koordinatensystem; das SVG skaliert über viewBox mit.
|
||
const W = 1000
|
||
const H = 200
|
||
|
||
const stats = $derived.by(() => {
|
||
if (elevation.length < 2) return null
|
||
|
||
const eles = elevation.map((p) => p.ele)
|
||
const min = Math.min(...eles)
|
||
const max = Math.max(...eles)
|
||
const totalD = elevation[elevation.length - 1].d || 1
|
||
// Bei flachen Strecken wäre die Spanne 0 und die Division ungültig.
|
||
const span = max - min || 1
|
||
|
||
return { min, max, totalD, span }
|
||
})
|
||
|
||
const path = $derived.by(() => {
|
||
if (!stats) return ''
|
||
const { min, totalD, span } = stats
|
||
|
||
const pts = elevation.map((p) => {
|
||
const x = (p.d / totalD) * W
|
||
const y = H - ((p.ele - min) / span) * H
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||
})
|
||
|
||
// Fläche schließen: runter zur Grundlinie und zurück zum Anfang
|
||
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
|
||
})
|
||
|
||
/**
|
||
* Der Punkt zur hervorgehobenen Distanz. Gesucht wird binär, weil das
|
||
* Profil alle Originalpunkte enthält — bei jeder Mausbewegung linear
|
||
* durch mehrere tausend Einträge zu laufen wäre unnötig teuer.
|
||
*/
|
||
const hover = $derived.by(() => {
|
||
if (!stats || highlightDistance == null) return null
|
||
|
||
let lo = 0
|
||
let hi = elevation.length - 1
|
||
while (hi - lo > 1) {
|
||
const mid = (lo + hi) >> 1
|
||
if (elevation[mid].d <= highlightDistance) lo = mid
|
||
else hi = mid
|
||
}
|
||
|
||
const p =
|
||
Math.abs(elevation[lo].d - highlightDistance) <=
|
||
Math.abs(elevation[hi].d - highlightDistance)
|
||
? elevation[lo]
|
||
: elevation[hi]
|
||
|
||
return {
|
||
ele: p.ele,
|
||
d: p.d,
|
||
// In Prozent, damit die HTML-Overlays dieselbe Stelle treffen wie
|
||
// das über viewBox skalierte SVG.
|
||
left: (p.d / stats.totalD) * 100,
|
||
top: (1 - (p.ele - stats.min) / stats.span) * 100,
|
||
}
|
||
})
|
||
|
||
/** Distanz an der Mausposition; null, wenn es keine Punkte gibt. */
|
||
function distanceAt(e: MouseEvent): number | null {
|
||
if (!elevation.length) return null
|
||
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const ratio = (e.clientX - rect.left) / rect.width
|
||
const idx = Math.round(ratio * (elevation.length - 1))
|
||
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
|
||
|
||
return elevation[clamped].d
|
||
}
|
||
|
||
function handleMove(e: MouseEvent) {
|
||
const d = distanceAt(e)
|
||
if (d !== null) onhover?.(d)
|
||
}
|
||
|
||
function handleClick(e: MouseEvent) {
|
||
if (!onselect) return
|
||
|
||
const d = distanceAt(e)
|
||
if (d !== null) onselect(d)
|
||
}
|
||
|
||
function handleLeave() {
|
||
onhover?.(null)
|
||
}
|
||
|
||
/**
|
||
* Tastaturweg zum Setzen: Mit den Pfeiltasten die Stelle wählen, mit
|
||
* Enter oder Leertaste bestätigen. Ohne das wäre das Profil ein reines
|
||
* Mausziel.
|
||
*/
|
||
function handleKey(e: KeyboardEvent) {
|
||
if (!onselect || !stats) return
|
||
|
||
const total = stats.totalD
|
||
const step = total / 50
|
||
const current = highlightDistance ?? 0
|
||
|
||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||
e.preventDefault()
|
||
const next = e.key === 'ArrowRight' ? current + step : current - step
|
||
onhover?.(Math.max(0, Math.min(total, next)))
|
||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault()
|
||
if (highlightDistance != null) onselect(highlightDistance)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
{#if stats}
|
||
<div class="relative w-full" style:height>
|
||
<!--
|
||
Die Fläche ist ein echter Knopf: Sie reagiert auf Zeiger und
|
||
Tastatur, und ein <svg> mit Rollen und tabindex bleibt für
|
||
Hilfsmittel ein Bild mit angeklebtem Verhalten.
|
||
-->
|
||
<button
|
||
type="button"
|
||
class="block w-full h-full border-0 bg-transparent p-0"
|
||
class:cursor-pointer={!!onselect}
|
||
aria-label={onselect
|
||
? 'Höhenprofil – hier klicken setzt einen Marker an dieser Stelle'
|
||
: 'Höhenprofil'}
|
||
onmousemove={handleMove}
|
||
onmouseleave={handleLeave}
|
||
onclick={handleClick}
|
||
onkeydown={handleKey}
|
||
>
|
||
<svg
|
||
viewBox="0 0 {W} {H}"
|
||
preserveAspectRatio="none"
|
||
class="w-full h-full"
|
||
role="img"
|
||
aria-hidden="true"
|
||
>
|
||
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
|
||
{#if hover}
|
||
<line
|
||
x1={(hover.left / 100) * W}
|
||
y1="0"
|
||
x2={(hover.left / 100) * W}
|
||
y2={H}
|
||
stroke="rgb(14 165 233)"
|
||
stroke-width="1"
|
||
vector-effect="non-scaling-stroke"
|
||
/>
|
||
{/if}
|
||
</svg>
|
||
</button>
|
||
|
||
{#if hover}
|
||
<!--
|
||
Punkt und Beschriftung liegen als HTML über dem SVG: dessen
|
||
viewBox wird ungleichmäßig gestreckt, ein Kreis darin würde
|
||
zur Ellipse und Text mitverzerrt.
|
||
-->
|
||
<span
|
||
class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500 ring-2 ring-background"
|
||
style:left="{hover.left}%"
|
||
style:top="{hover.top}%"
|
||
></span>
|
||
<span
|
||
class="pointer-events-none absolute top-0 -translate-x-1/2 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium whitespace-nowrap shadow-sm"
|
||
style:left="clamp(2.5rem, {hover.left}%, calc(100% - 2.5rem))"
|
||
>
|
||
{Math.round(hover.ele)} m · {(hover.d / 1000).toFixed(2)} km
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
||
<span>{Math.round(stats.min)} m</span>
|
||
<span>{(stats.totalD / 1000).toFixed(1)} km</span>
|
||
<span>{Math.round(stats.max)} m</span>
|
||
</div>
|
||
{:else}
|
||
<p class="text-sm text-muted-foreground">Keine Höhendaten vorhanden.</p>
|
||
{/if}
|