thats-me/frontend/_src/components/TimelineView.vue
Kevin Adametz f79dcb9928 Optimiere LifeWave-Timeline-Performance und entferne toten Code in _src
- TimelineView: Shader-Event-Mapping in computed ausgelagert, deep-Watch
  entfernt, natives Wheel-Scrolling statt @wheel.prevent
- Events-Store: shallowRef statt tiefer Reaktivität, String-Sortierung
  statt Date-Objekten, getGlowColor memoized, DB-Writes debounced
- Edits werden jetzt dedupliziert als 'update' in die SyncQueue geschrieben
  (vorher gingen Änderungen beim Sync verloren)
- useImageCache: In-Flight-Deduping, Cache-Limit mit revokeObjectURL
- FloatingLines: Antialiasing aus (Fullscreen-Quad), deep-Watches entfernt
- Ungenutzte Seiten/Komponenten entfernt (~6.700 Zeilen, nicht geroutet)
- implementation-plan.md: Hinweis zu update-Action im Sync ergänzt
2026-07-07 15:54:35 +00:00

552 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="timeline-container">
<div
class="timeline"
ref="timelineRef"
@scroll="onScroll"
@wheel="onWheel"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend.passive="onTouchEnd"
>
<div class="timeline__track" :style="{ width: trackWidth + 'px' }">
<!-- GlowDots — only visible ones rendered -->
<GlowDot
v-for="{ event, globalIndex } in visibleEvents"
:key="event.id"
:event="event"
:x="getEventX(globalIndex)"
:is-ghost="event.id === '__ghost__'"
:selected="eventsStore.selectedEventId === event.id"
@select="$emit('dotSelect', event.id)"
/>
<!-- Month labels — only visible -->
<div class="timeline__labels">
<div
v-for="{ label, globalIndex } in visibleLabels"
:key="label.key"
class="timeline__month"
:style="{ left: getEventX(globalIndex) + 'px' }"
:class="{ 'timeline__month--active': label.key === activeLabel }"
@click="scrollToIndex(globalIndex)"
>
{{ label.month }}
</div>
</div>
</div>
</div>
<!-- Sticky year labels with nav arrows (outside scroll container) -->
<div class="timeline__sticky-years">
<TransitionGroup name="year-fade">
<div
v-for="label in stickyYearLabels"
:key="label.year"
class="timeline__sticky-year-group"
:style="{ left: label.left + 'px' }"
>
<button
v-if="label.hasPrev"
class="timeline__year-arrow timeline__year-arrow--prev"
@click="scrollToYearCenter(label.year - 1)"
>
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M7 1L3 5l4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="timeline__sticky-year" @click="scrollToYearCenter(label.year)">
{{ label.year }}
</span>
<button
v-if="label.hasNext"
class="timeline__year-arrow timeline__year-arrow--next"
@click="scrollToYearCenter(label.year + 1)"
>
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</TransitionGroup>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useEventsStore } from 'stores/events'
import GlowDot from 'components/GlowDot.vue'
const emit = defineEmits(['dotSelect', 'viewUpdate'])
const eventsStore = useEventsStore()
const timelineRef = ref(null)
const scrollLeft = ref(0)
const viewportWidth = ref(400)
const containerHeight = ref(400)
// Zoom: 1.0 = default, range 0.4–3.0
const zoomLevel = ref(1)
const MIN_ZOOM = 0.4
const MAX_ZOOM = 3.0
const ZOOM_STEP = 0.08
// Spacing: ~4 events visible at a time, scaled by zoom
const BASE_SPACING = computed(() => viewportWidth.value / 2.5)
const EVENT_SPACING = computed(() => BASE_SPACING.value * zoomLevel.value)
const PADDING = computed(() => viewportWidth.value / 2)
// Display events: sorted events + ghost when creating
const showGhost = computed(() => eventsStore.panelOpen && !eventsStore.editingEventId)
const displayEvents = computed(() => {
const sorted = [...eventsStore.sortedEvents]
if (!showGhost.value) return sorted
const ghost = eventsStore.ghostEvent
// Dates are ISO strings (YYYY-MM-DD) — string comparison is chronological
const insertIdx = sorted.findIndex((e) => e.date > ghost.date)
if (insertIdx === -1) {
sorted.push(ghost)
} else {
sorted.splice(insertIdx, 0, ghost)
}
return sorted
})
// Track width
const trackWidth = computed(() => {
const count = displayEvents.value.length
if (count === 0) return viewportWidth.value
return PADDING.value * 2 + (count - 1) * EVENT_SPACING.value
})
// X position for event at given index
function getEventX(index) {
return PADDING.value + index * EVENT_SPACING.value
}
// Month names
const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
// One label per event showing its month (parsed from ISO string, no Date allocation)
const eventLabels = computed(() => {
return displayEvents.value.map((event, index) => {
const year = event.date.slice(0, 4)
const monthIdx = Number(event.date.slice(5, 7)) - 1
return {
key: `${event.id}-${index}`,
month: MONTHS[monthIdx],
year: Number(year),
fullMonth: `${year}-${monthIdx}`
}
})
})
// Year ranges — for each year, the world-space X range of its events
const yearRanges = computed(() => {
const ranges = []
const events = displayEvents.value
if (events.length === 0) return ranges
let currentYear = Number(events[0].date.slice(0, 4))
let startIdx = 0
for (let i = 1; i < events.length; i++) {
const year = Number(events[i].date.slice(0, 4))
if (year !== currentYear) {
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
currentYear = year
startIdx = i
}
}
ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(events.length - 1) })
return ranges
})
// All years that exist in the data (for prev/next navigation)
const allYears = computed(() => yearRanges.value.map(r => r.year))
// Sticky year labels — positioned relative to viewport, clamped to edges
const YEAR_MARGIN = 24
const stickyYearLabels = computed(() => {
const sl = scrollLeft.value
const vw = viewportWidth.value
const viewLeft = sl
const viewRight = sl + vw
const years = allYears.value
// Find years whose event range overlaps the viewport
const visible = yearRanges.value.filter(r => r.endX >= viewLeft && r.startX <= viewRight)
if (visible.length === 0) return []
function makeLabel(year, left) {
const idx = years.indexOf(year)
return { year, left, hasPrev: idx > 0, hasNext: idx < years.length - 1 }
}
if (visible.length === 1) {
const r = visible[0]
const visStart = Math.max(r.startX, viewLeft)
const visEnd = Math.min(r.endX, viewRight)
const center = (visStart + visEnd) / 2 - sl
const clamped = Math.max(YEAR_MARGIN, Math.min(vw - YEAR_MARGIN, center))
return [makeLabel(r.year, clamped)]
}
// Multiple years: first pins left, last pins right, middles float naturally
const result = []
for (let i = 0; i < visible.length; i++) {
const r = visible[i]
const visStart = Math.max(r.startX, viewLeft)
const visEnd = Math.min(r.endX, viewRight)
const center = (visStart + visEnd) / 2 - sl
let pos
if (i === 0) {
pos = Math.max(YEAR_MARGIN, Math.min(vw / 2 - 30, center))
} else if (i === visible.length - 1) {
pos = Math.min(vw - YEAR_MARGIN, Math.max(vw / 2 + 30, center))
} else {
pos = Math.max(YEAR_MARGIN + 60, Math.min(vw - YEAR_MARGIN - 60, center))
}
result.push(makeLabel(r.year, pos))
}
return result
})
// Virtualization: only render events near the viewport
const VIS_BUFFER = 2
const visibleRange = computed(() => {
const total = displayEvents.value.length
if (total === 0) return { start: 0, end: -1 }
const spacing = EVENT_SPACING.value
if (spacing <= 0) return { start: 0, end: total - 1 }
const start = Math.max(0,
Math.floor((scrollLeft.value - PADDING.value) / spacing) - VIS_BUFFER
)
const end = Math.min(total - 1,
Math.ceil((scrollLeft.value + viewportWidth.value - PADDING.value) / spacing) + VIS_BUFFER
)
return { start, end }
})
const visibleEvents = computed(() => {
const { start, end } = visibleRange.value
if (end < start) return []
return displayEvents.value.slice(start, end + 1).map((event, i) => ({
event,
globalIndex: start + i
}))
})
const visibleLabels = computed(() => {
const { start, end } = visibleRange.value
if (end < start) return []
return eventLabels.value.slice(start, end + 1).map((label, i) => ({
label,
globalIndex: start + i
}))
})
// Active label — closest event to center of viewport (O(1) via index)
const activeLabel = computed(() => {
const total = displayEvents.value.length
if (total === 0) return null
const centerX = scrollLeft.value + viewportWidth.value / 2
const spacing = EVENT_SPACING.value
if (spacing <= 0) return null
const index = Math.round((centerX - PADDING.value) / spacing)
const clamped = Math.max(0, Math.min(total - 1, index))
return eventLabels.value[clamped]?.key ?? null
})
function onScroll() {
if (timelineRef.value) {
scrollLeft.value = timelineRef.value.scrollLeft
}
}
function scrollToIndex(index) {
if (!timelineRef.value) return
const x = getEventX(index)
timelineRef.value.scrollTo({
left: x - viewportWidth.value / 2,
behavior: 'smooth'
})
}
function scrollToYearCenter(year) {
if (!timelineRef.value) return
// Find exact year, or nearest in the requested direction
let range = yearRanges.value.find(r => r.year === year)
if (!range) {
// Find closest year
const sorted = [...yearRanges.value].sort((a, b) => Math.abs(a.year - year) - Math.abs(b.year - year))
range = sorted[0]
}
if (!range) return
const centerX = (range.startX + range.endX) / 2
timelineRef.value.scrollTo({
left: centerX - viewportWidth.value / 2,
behavior: 'smooth'
})
}
function updateViewportWidth() {
if (timelineRef.value) {
viewportWidth.value = timelineRef.value.clientWidth || 400
containerHeight.value = timelineRef.value.clientHeight || 400
}
}
// Zoom while keeping the viewport center stable
function applyZoom(newZoom, centerClientX) {
const el = timelineRef.value
if (!el) return
// Default center to viewport middle
const rect = el.getBoundingClientRect()
const cx = centerClientX !== undefined ? centerClientX - rect.left : viewportWidth.value / 2
// World-space X under the center point before zoom
const worldXBefore = el.scrollLeft + cx
// Ratio of old spacing to new
const oldZoom = zoomLevel.value
const ratio = newZoom / oldZoom
zoomLevel.value = newZoom
// After Vue updates, restore scroll so the same world point stays under center
nextTick(() => {
el.scrollLeft = worldXBefore * ratio - cx
scrollLeft.value = el.scrollLeft
})
}
// Desktop: Ctrl+wheel or pinch on trackpad (both fire wheel with ctrlKey)
function onWheel(e) {
if (e.ctrlKey || e.metaKey) {
// Pinch / Ctrl+scroll → zoom (prevent browser page zoom)
e.preventDefault()
const delta = -e.deltaY * ZOOM_STEP * 0.1
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoomLevel.value + delta))
if (newZoom !== zoomLevel.value) {
applyZoom(newZoom, e.clientX)
}
} else if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
// Redirect vertical wheel to horizontal scroll; horizontal deltas
// (trackpads) scroll natively with momentum — onScroll syncs the ref
timelineRef.value?.scrollBy({ left: e.deltaY })
}
}
// Touch: pinch-to-zoom
let touchStartDist = 0
let touchStartZoom = 1
function getTouchDist(touches) {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
function onTouchStart(e) {
if (e.touches.length === 2) {
touchStartDist = getTouchDist(e.touches)
touchStartZoom = zoomLevel.value
}
}
function onTouchMove(e) {
if (e.touches.length === 2) {
const dist = getTouchDist(e.touches)
const scale = dist / touchStartDist
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, touchStartZoom * scale))
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2
applyZoom(newZoom, cx)
}
}
function onTouchEnd() {
touchStartDist = 0
}
// Scroll to center on the last event on mount
let resizeObserver = null
// Shader point data — only recomputed when events or spacing change,
// NOT on every scroll tick (scroll only changes which slice is visible)
const shaderEvents = computed(() => {
return displayEvents.value.map((e, i) => ({
emotion: e.emotion,
x: getEventX(i),
color: eventsStore.getGlowColor(e)
}))
})
// Emit timeline state so the layout can position shader points
function emitViewState() {
const { start, end } = visibleRange.value
emit('viewUpdate', {
scrollLeft: scrollLeft.value,
viewportWidth: viewportWidth.value,
containerHeight: containerHeight.value,
visibleStart: start,
visibleEnd: end,
events: shaderEvents.value
})
}
watch(
[scrollLeft, viewportWidth, containerHeight, shaderEvents],
emitViewState
)
onMounted(async () => {
await nextTick()
if (!timelineRef.value) return
updateViewportWidth()
const events = displayEvents.value
if (events.length === 0) return
const lastX = getEventX(events.length - 1)
timelineRef.value.scrollLeft = lastX - viewportWidth.value / 2
scrollLeft.value = timelineRef.value.scrollLeft
// Update viewport width on resize
resizeObserver = new ResizeObserver(updateViewportWidth)
resizeObserver.observe(timelineRef.value)
// Emit initial state
emitViewState()
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
function zoomIn() {
const newZoom = Math.min(MAX_ZOOM, zoomLevel.value + ZOOM_STEP * 2)
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
}
function zoomOut() {
const newZoom = Math.max(MIN_ZOOM, zoomLevel.value - ZOOM_STEP * 2)
if (newZoom !== zoomLevel.value) applyZoom(newZoom)
}
defineExpose({ timelineRef, zoomIn, zoomOut, zoomLevel, MIN_ZOOM, MAX_ZOOM })
</script>
<style scoped>
.timeline-container {
position: absolute;
top: 40px;
left: 0;
right: 0;
bottom: 60px;
}
.timeline {
position: absolute;
inset: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.timeline::-webkit-scrollbar {
display: none;
}
.timeline__track {
position: relative;
height: 100%;
}
/* Month labels */
.timeline__labels {
position: absolute;
bottom: 44px;
left: 0;
right: 0;
height: 24px;
}
.timeline__month {
position: absolute;
transform: translateX(-50%);
font-size: 12px;
font-weight: 400;
opacity: 0.35;
transition: opacity 0.3s ease, font-size 0.3s ease, font-weight 0.3s ease;
white-space: nowrap;
cursor: pointer;
}
.timeline__month--active {
font-size: 15px;
font-weight: 700;
opacity: 1;
}
/* Sticky year labels — positioned outside the scroll container */
.timeline__sticky-years {
position: absolute;
bottom: 4px;
left: 0;
right: 0;
height: 32px;
pointer-events: none;
z-index: 2;
}
.timeline__sticky-year-group {
position: absolute;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
pointer-events: auto;
transition: left 0.15s ease-out;
}
.timeline__sticky-year {
font-size: 16px;
font-weight: 700;
opacity: 0.6;
white-space: nowrap;
cursor: pointer;
}
.timeline__year-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: inherit;
opacity: 0.45;
cursor: pointer;
padding: 0;
transition: opacity 0.2s ease, background 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.timeline__year-arrow:active {
opacity: 0.8;
background: rgba(255, 255, 255, 0.25);
}
/* TransitionGroup animations */
.year-fade-enter-active,
.year-fade-leave-active {
transition: opacity 0.3s ease;
}
.year-fade-enter-from,
.year-fade-leave-to {
opacity: 0;
}
</style>