10-04-2026
This commit is contained in:
parent
70a7776da5
commit
761b1156c1
50 changed files with 11997 additions and 150 deletions
547
frontend/_src/components/TimelineView.vue
Normal file
547
frontend/_src/components/TimelineView.vue
Normal 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.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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue