200 lines
5.1 KiB
Vue
200 lines
5.1 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 } from 'vue'
|
|
import { useEventsStore } from 'stores/events'
|
|
import { useSettingsStore } from 'stores/settings'
|
|
import { useImageCache } 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
|
|
const { resolvedSrc: imageSrc } = props.event.image
|
|
? useImageCache(props.event.image, props.event.id)
|
|
: { resolvedSrc: computed(() => null) }
|
|
|
|
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"
|
|
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
|
const formattedDate = computed(() => {
|
|
const d = new Date(props.event.date)
|
|
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
|
|
})
|
|
|
|
// 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>
|