10-04-2026

This commit is contained in:
Kevin Adametz 2026-04-22 12:57:10 +02:00
parent 70a7776da5
commit 761b1156c1
50 changed files with 11997 additions and 150 deletions

View file

@ -0,0 +1,547 @@
<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>