thats-me/frontend/_src/components/GlowDot.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

208 lines
5.4 KiB
Vue

<template>
<div
class="glow-dot"
:class="{
'glow-dot--ghost': isGhost,
'glow-dot--selected': selected,
'glow-dot--dimmed': isDimmed,
'glow-dot--label-above': labelAbove
}"
:style="dotStyle"
@click.stop="onSelect"
>
<div class="glow-dot__inner" :style="{ boxShadow: glowShadow }">
<img
v-if="imageSrc"
:src="imageSrc"
class="glow-dot__image"
alt=""
/>
</div>
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle">
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span>
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
import { resolveImage } from 'composables/useImageCache'
const props = defineProps({
event: { type: Object, required: true },
x: { type: Number, default: 0 },
isGhost: { type: Boolean, default: false },
selected: { type: Boolean, default: false }
})
const emit = defineEmits(['select'])
const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
// Resolve image: cached thumbnail from IndexedDB or fetch & cache.
// Watched so the dot updates when the event's image changes while mounted.
const imageSrc = ref(null)
watch(
() => props.event.image,
async (img) => {
const src = await resolveImage(img, props.event.id)
// Guard against stale async result after the image changed again
if (props.event.image === img) imageSrc.value = src
},
{ immediate: true }
)
const fl = computed(() => settingsStore.floatingLines)
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
// CSS diameter derived from settings (circleRadius is half-size in px)
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
// Y position: emotion +1 → top (20%), 0 → middle (47%), -1 → bottom (74%)
const yPercent = computed(() => 48 - props.event.emotion * 30)
// Label above dot when dot is in lower half, below when in upper half
const labelAbove = computed(() => yPercent.value > 48)
// Format date as "12. Feb 2024" (parsed from ISO string, no Date allocation)
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
const formattedDate = computed(() => {
const [year, month, day] = props.event.date.split('-')
return `${Number(day)}. ${MONTH_SHORT[Number(month) - 1]} ${year}`
})
// Label font sizes per setting
const LABEL_FONT = { small: { title: 10, date: 9 }, medium: { title: 12, date: 11 }, large: { title: 14, date: 13 } }
const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
const labelColor = computed(() => fl.value.labelColor ?? '#ffffff')
const labelStyle = computed(() => ({
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
}))
const titleStyle = computed(() => ({
fontSize: `${labelFont.value.title}px`,
color: labelColor.value,
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
}))
const dateStyle = computed(() => ({
fontSize: `${labelFont.value.date}px`,
color: labelColor.value
}))
const dotStyle = computed(() => ({
left: `${props.x}px`,
top: `${yPercent.value}%`,
width: `${dotSize.value}px`,
height: `${dotSize.value}px`
}))
// Two-layer box-shadow: tight bright core + wide soft halo
const glowShadow = computed(() => {
const size = fl.value.glowSize
const strength = fl.value.glowStrength
const color = glowColor.value
const core = alphaHex(Math.min(strength / 3, 1))
const halo = alphaHex(Math.min(strength / 7, 1))
return `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
})
function alphaHex(a) {
return Math.round(Math.min(1, Math.max(0, a)) * 255).toString(16).padStart(2, '0')
}
const isDimmed = computed(() =>
eventsStore.selectedEventId !== null && !props.selected && !props.isGhost
)
function onSelect() {
if (!props.isGhost) emit('select')
}
</script>
<style scoped>
.glow-dot {
position: absolute;
transform: translate(-50%, -50%);
cursor: pointer;
z-index: 10;
transition: opacity 0.3s ease, transform 0.15s ease;
}
.glow-dot__inner {
position: relative;
width: 100%;
height: 100%;
border-radius: 50%;
background: #fff;
}
.glow-dot__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 50%;
}
/* States */
.glow-dot--ghost {
opacity: 1;
cursor: default;
}
.glow-dot--selected {
transform: translate(-50%, -50%) scale(1.15);
z-index: 15;
}
.glow-dot--dimmed {
opacity: 1;
}
/* Event label (title + date) */
.glow-dot__label {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: calc(100% + 6px);
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
max-width: 90px;
pointer-events: none;
}
/* When dot is in lower half, show label above */
.glow-dot--label-above .glow-dot__label {
top: auto;
bottom: calc(100% + 6px);
}
.glow-dot__title {
font-size: 10px;
font-weight: 600;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90px;
text-align: center;
line-height: 1.2;
}
.glow-dot__date {
font-size: 9px;
font-weight: 400;
opacity: 0.4;
white-space: nowrap;
line-height: 1.2;
}
</style>