547 lines
15 KiB
Vue
547 lines
15 KiB
Vue
<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.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
|
||
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>
|