thats-me/frontend/_src/components/TimelineView.vue
2026-04-22 12:57:10 +02:00

547 lines
15 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.prevent="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.43.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
const ghostDate = new Date(ghost.date)
const insertIdx = sorted.findIndex((e) => new Date(e.date) > ghostDate)
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
const eventLabels = computed(() => {
return displayEvents.value.map((event, index) => {
const d = new Date(event.date)
return {
key: `${event.id}-${index}`,
month: MONTHS[d.getMonth()],
year: d.getFullYear(),
fullMonth: `${d.getFullYear()}-${d.getMonth()}`
}
})
})
// 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 = new Date(events[0].date).getFullYear()
let startIdx = 0
for (let i = 1; i < events.length; i++) {
const year = new Date(events[i].date).getFullYear()
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
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 {
// Normal scroll — let the browser handle horizontal scroll
const el = timelineRef.value
if (el) {
el.scrollLeft += e.deltaX || e.deltaY
scrollLeft.value = el.scrollLeft
}
}
}
// 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
// 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: displayEvents.value.map((e, i) => ({
emotion: e.emotion,
x: getEventX(i),
color: eventsStore.getGlowColor(e)
}))
})
}
watch(
[scrollLeft, viewportWidth, containerHeight, displayEvents, zoomLevel],
emitViewState,
{ deep: true }
)
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>