polish: flip overflowing bar label to the left when it would hit the next bar (v0.2.5)
Extends the bar-label overflow handling: an oversized label defaults to the right of the bar, but if it would collide with the next bar on the same row it is placed to the left instead (whichever side has room; larger gap wins when neither fits). Also make the trigger a debounce (was a throttle) so the layout runs after a zoom/redraw burst settles, once bar widths are final. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9b6ca6f848
commit
de4cf1743b
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
@ -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<HTMLElement>('.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<HTMLElement>('.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<number, { left: number; right: number }[]>();
|
||||
const overflow: { it: HTMLElement; row: number; left: number; right: number; textW: number }[] = [];
|
||||
for (const it of items) {
|
||||
const inner = it.querySelector<HTMLElement>('.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<HTMLElement>('.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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user