diff --git a/frontend/package.json b/frontend/package.json index 3d26b8c..27339b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.2.4", + "version": "0.2.5", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f989adc..ca993f1 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -484,14 +484,20 @@ html.role-pending .manager-only { display: none !important; } overflow: hidden; } .vis-item.vis-range:hover .bar-inner { filter: brightness(0.92); } -/* Label wider than the bar: JS adds .bar-of, which lifts the clipping and - places the label just past the bar's right edge as dark text on the track. */ -.vis-item.vis-range.bar-of .vis-item-overflow { overflow: visible; } -.vis-item.vis-range.bar-of .bar-inner { overflow: visible; } -.vis-item.vis-range.bar-of .bar-text { - position: absolute; left: 100%; top: 50%; transform: translateY(-50%); - margin-left: 5px; color: var(--text); white-space: nowrap; pointer-events: none; +/* Label wider than the bar: JS adds .bar-of (label past the bar's right edge) or + .bar-of-left (past the left edge, when the right side would hit the next bar), + as dark text on the track instead of clipping inside the bar. */ +.vis-item.vis-range.bar-of .vis-item-overflow, +.vis-item.vis-range.bar-of-left .vis-item-overflow { overflow: visible; } +.vis-item.vis-range.bar-of .bar-inner, +.vis-item.vis-range.bar-of-left .bar-inner { overflow: visible; } +.vis-item.vis-range.bar-of .bar-text, +.vis-item.vis-range.bar-of-left .bar-text { + position: absolute; top: 50%; transform: translateY(-50%); + color: var(--text); white-space: nowrap; pointer-events: none; } +.vis-item.vis-range.bar-of .bar-text { left: 100%; margin-left: 5px; } +.vis-item.vis-range.bar-of-left .bar-text { right: 100%; margin-right: 5px; text-align: right; } .vis-foreground .vis-group { min-height: var(--row-h); } .vis-labelset .vis-label { font-size: var(--label-font); display: flex; align-items: center; min-height: var(--row-h); } diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts index ca7fabb..fb49db5 100644 --- a/frontend/src/timeline.ts +++ b/frontend/src/timeline.ts @@ -138,27 +138,66 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) window.addEventListener('resize', scheduleHeightFit); scheduleHeightFit(); - // When a bar's label is wider than the bar itself, move it just past the bar's - // right edge (dark text on the track) instead of clipping it inside. Re-run on - // every redraw (zoom/pan changes bar widths). Batched: reset all, measure all, - // then apply — so at most one reflow per pass. + // When a bar's label is wider than the bar itself, move it just outside the bar + // (dark text on the track) instead of clipping it inside. Default side is the + // right of the bar; if the label would collide with the next bar on the same + // row, put it to the left instead. Re-run on every redraw (zoom/pan changes bar + // widths). Batched: reset all, measure all, then apply — at most one reflow. let barTextRaf = 0; + const BAR_LABEL_GAP = 6; function layoutBarText(): void { const items = Array.from(el.timelineEl.querySelectorAll('.vis-item.vis-range')); - for (const it of items) it.classList.remove('bar-of'); - const overflowing: HTMLElement[] = []; + for (const it of items) it.classList.remove('bar-of', 'bar-of-left'); + + const center = el.timelineEl.querySelector('.vis-panel.vis-center'); + const bound = center ? center.getBoundingClientRect() : { left: 0, right: window.innerWidth }; + + // Per row (bars share a vertical band), collect every bar's horizontal span so + // we can find each overflowing label's nearest neighbour on either side. + const rows = new Map(); + const overflow: { it: HTMLElement; row: number; left: number; right: number; textW: number }[] = []; for (const it of items) { const inner = it.querySelector('.bar-inner'); - if (inner && inner.scrollWidth > inner.clientWidth + 1) overflowing.push(it); + if (!inner) continue; + const r = inner.getBoundingClientRect(); + if (r.width === 0) continue; + const row = Math.round(r.top); + (rows.get(row) ?? rows.set(row, []).get(row)!).push({ left: r.left, right: r.right }); + if (inner.scrollWidth > inner.clientWidth + 1) { + const txt = it.querySelector('.bar-text'); + overflow.push({ it, row, left: r.left, right: r.right, textW: txt ? txt.scrollWidth : 0 }); + } } - for (const it of overflowing) it.classList.add('bar-of'); + + const left: HTMLElement[] = []; + const right: HTMLElement[] = []; + for (const c of overflow) { + const bars = rows.get(c.row)!; + let nextLeft = bound.right; + let prevRight = bound.left; + for (const b of bars) { + if (b.left > c.right + 0.5 && b.left < nextLeft) nextLeft = b.left; + if (b.right < c.left - 0.5 && b.right > prevRight) prevRight = b.right; + } + const need = c.textW + BAR_LABEL_GAP; + const gapRight = nextLeft - c.right; + const gapLeft = c.left - prevRight; + // Prefer right; fall back to left when the right label would overlap the + // next bar; if neither side fits, take whichever has more room. + if (need <= gapRight) right.push(c.it); + else if (need <= gapLeft) left.push(c.it); + else if (gapRight >= gapLeft) right.push(c.it); + else left.push(c.it); + } + for (const it of right) it.classList.add('bar-of'); + for (const it of left) it.classList.add('bar-of-left'); } function scheduleBarText(): void { - if (barTextRaf) return; - // Double rAF: let vis finish positioning bars before we measure widths. - barTextRaf = requestAnimationFrame(() => - requestAnimationFrame(() => { barTextRaf = 0; layoutBarText(); }), - ); + // Debounce (not throttle): a zoom/redraw fires several 'changed' events in a + // burst and vis recreates item DOM between them. Wait for the burst to settle + // so we measure final bar widths and our classes aren't wiped by a later pass. + clearTimeout(barTextRaf); + barTextRaf = window.setTimeout(layoutBarText, 110); } // 'changed' = data/redraw; 'rangechanged' = zoom/pan settled (bars resized). timeline.on('changed', scheduleBarText);