Improved event compression on tracks of the overlay.

This commit is contained in:
Leo Vasanko 2025-08-26 13:43:35 -06:00
parent 85ce3678ed
commit 57305e531b

View File

@ -1,7 +1,13 @@
<template> <template>
<div class="week-overlay"> <div class="week-overlay" :style="{ gridColumn: '1 / -1' }" ref="weekOverlayRef">
<div <div
v-for="span in eventSpans" v-for="seg in eventSegments"
:key="'seg-' + seg.startIdx + '-' + seg.endIdx"
:class="['segment-grid', { compress: isSegmentCompressed(seg) }]"
:style="segmentStyle(seg)"
>
<div
v-for="span in seg.events"
:key="span.id" :key="span.id"
class="event-span" class="event-span"
dir="auto" dir="auto"
@ -9,7 +15,7 @@
:data-id="span.id" :data-id="span.id"
:data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0" :data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0"
:style="{ :style="{
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, gridColumn: `${span.startIdxRel + 1} / ${span.endIdxRel + 2}`,
gridRow: `${span.row}`, gridRow: `${span.row}`,
}" }"
@click="handleEventClick(span)" @click="handleEventClick(span)"
@ -26,9 +32,10 @@
></div> ></div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { daysInclusive, addDaysStr } from '@/utils/date' import { daysInclusive, addDaysStr } from '@/utils/date'
@ -41,45 +48,123 @@ const store = useCalendarStore()
// Drag state // Drag state
const dragState = ref(null) const dragState = ref(null)
const justDragged = ref(false) const justDragged = ref(false)
const weekOverlayRef = ref(null)
const segmentCompression = ref({}) // key -> boolean
// Consolidate already-provided day.events into contiguous spans (no recurrence generation) // Build event segments: each segment is a contiguous day range with at least one bridging event between any adjacent days within it.
const eventSpans = computed(() => { const eventSegments = computed(() => {
const weekEvents = new Map() // Construct spans across the week
props.week.days.forEach((day, dayIndex) => { const spanMap = new Map()
props.week.days.forEach((day, di) => {
day.events.forEach((ev) => { day.events.forEach((ev) => {
const key = ev.id const k = ev.id
if (!weekEvents.has(key)) { if (!spanMap.has(k)) spanMap.set(k, { ...ev, startIdx: di, endIdx: di })
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex }) else spanMap.get(k).endIdx = Math.max(spanMap.get(k).endIdx, di)
} else {
const ref = weekEvents.get(key)
ref.endIdx = Math.max(ref.endIdx, dayIndex)
}
}) })
}) })
const arr = Array.from(weekEvents.values()) const spans = Array.from(spanMap.values())
arr.sort((a, b) => { // Sort so longer multi-day first, then earlier, then id for stability
const spanA = a.endIdx - a.startIdx spans.sort((a, b) => {
const spanB = b.endIdx - b.startIdx const la = a.endIdx - a.startIdx
if (spanA !== spanB) return spanB - spanA const lb = b.endIdx - b.startIdx
if (la !== lb) return lb - la
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// For one-day events that are otherwise equal, sort by color (0 first) const ca = a.colorId != null ? a.colorId : 0
if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) { const cb = b.colorId != null ? b.colorId : 0
const colorA = a.colorId || 0 if (ca !== cb) return ca - cb
const colorB = b.colorId || 0
if (colorA !== colorB) return colorA - colorB
}
return String(a.id).localeCompare(String(b.id)) return String(a.id).localeCompare(String(b.id))
}) })
// Assign non-overlapping rows // Identify breaks
const rowsLastEnd = [] const breaks = []
arr.forEach((ev) => { for (let d = 0; d < 6; d++) {
let row = 0 const bridged = spans.some((sp) => sp.startIdx <= d && sp.endIdx >= d + 1)
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++ if (!bridged) breaks.push(d)
if (row === rowsLastEnd.length) rowsLastEnd.push(-1) }
rowsLastEnd[row] = ev.endIdx const rawSegments = []
ev.row = row + 1 let segStart = 0
for (const b of breaks) {
rawSegments.push([segStart, b])
segStart = b + 1
}
rawSegments.push([segStart, 6])
const segments = rawSegments.map(([s, e]) => {
const evs = spans.filter((sp) => sp.startIdx >= s && sp.endIdx <= e)
// Row packing in this segment (gap fill)
const rows = [] // each row: intervals
function fits(row, a, b) {
return row.every((iv) => b < iv.start || a > iv.end)
}
function addInterval(row, a, b) {
let inserted = false
for (let i = 0; i < row.length; i++) {
if (b < row[i].start) {
row.splice(i, 0, { start: a, end: b })
inserted = true
break
}
}
if (!inserted) row.push({ start: a, end: b })
}
evs.forEach((ev) => {
let placed = false
for (let r = 0; r < rows.length; r++) {
if (fits(rows[r], ev.startIdx, ev.endIdx)) {
addInterval(rows[r], ev.startIdx, ev.endIdx)
ev.row = r + 1
placed = true
break
}
}
if (!placed) {
rows.push([{ start: ev.startIdx, end: ev.endIdx }])
ev.row = rows.length
}
ev.startIdxRel = ev.startIdx - s
ev.endIdxRel = ev.endIdx - s
}) })
return arr return { startIdx: s, endIdx: e, events: evs, rowsCount: rows.length }
})
return segments
})
function segmentStyle(seg) {
return { gridColumn: `${seg.startIdx + 1} / ${seg.endIdx + 2}` }
}
function segmentKey(seg) {
return seg.startIdx + '-' + seg.endIdx
}
function isSegmentCompressed(seg) {
return !!segmentCompression.value[segmentKey(seg)]
}
function recomputeCompression() {
const el = weekOverlayRef.value
if (!el) return
const available = el.clientHeight || 0
if (!available) return
const cs = getComputedStyle(el)
const fontSize = parseFloat(cs.fontSize) || 16
const baseRowPx = fontSize * 1.5 // desired row height (matches CSS 1.5em)
const marginTop = 0 // already applied outside height
const usable = Math.max(0, available - marginTop)
const nextMap = {}
for (const seg of eventSegments.value) {
const desired = (seg.rowsCount || 1) * baseRowPx
nextMap[segmentKey(seg)] = desired > usable
}
segmentCompression.value = nextMap
}
watch(eventSegments, () => nextTick(() => recomputeCompression()))
onMounted(() => {
nextTick(() => recomputeCompression())
window.addEventListener('resize', recomputeCompression)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', recomputeCompression)
}) })
function handleEventClick(span) { function handleEventClick(span) {
@ -403,16 +488,22 @@ function applyRangeDuringDrag(st, startDate, endDate) {
.week-overlay { .week-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none;
z-index: 15;
display: grid; display: grid;
/* Prevent content from expanding tracks beyond container width */ grid-template-columns: repeat(7, 1fr);
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1.5em);
row-gap: 0.05em;
margin-top: 1.8em; margin-top: 1.8em;
pointer-events: none;
}
.segment-grid {
display: grid;
gap: 2px;
align-content: start; align-content: start;
pointer-events: none;
overflow: hidden;
grid-auto-columns: 1fr;
grid-auto-rows: 1.5em;
}
.segment-grid.compress {
grid-auto-rows: 1fr;
} }
.event-span { .event-span {
@ -430,13 +521,8 @@ function applyRangeDuringDrag(st, startDate, endDate) {
align-items: center; align-items: center;
position: relative; position: relative;
user-select: none; user-select: none;
height: 100%;
width: 100%;
max-width: 100%;
box-sizing: border-box;
z-index: 1; z-index: 1;
text-align: center; text-align: center;
min-width: 0;
} }
/* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */