Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 112 additions & 15 deletions src/lib/MethodComparison.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,62 @@
};
});

// Pins sit at their true positions; labels are laid out separately so close
// methods never print over each other. Each label centres on its pin when it
// fits, stays inside the track, and drops to the first lower row (with a
// leader tick back to its pin) where it touches no other label. Among those
// rows it prefers one where no leader runs through a label.
let trackWidth = $state(0);
let labelWidths = $state<number[]>([]);
let labelHeights = $state<number[]>([]);
const rowPitch = $derived(
labelHeights.some(Boolean) ? Math.max(...labelHeights) + 4 : null,
);
const labelPlaces = $derived.by(() => {
const nodes = spectrum?.nodes ?? [];
const places = nodes.map(() => ({ shift: null as number | null, row: 0 }));
if (!trackWidth || nodes.some((_, i) => !labelWidths[i])) return places;
const gap = 8;
type Box = { left: number; right: number; row: number; x: number };
const placed: Box[] = [];
const overlaps = (a: Box, b: Box) =>
a.row === b.row && a.left < b.right + gap && b.left < a.right + gap;
// A leader runs from the rail down through every row above its label.
const crosses = (leader: Box, label: Box) =>
label.row < leader.row &&
leader.x > label.left - gap &&
leader.x < label.right + gap;
const order = nodes
.map((_, i) => i)
.sort((a, b) => nodes[a].pct - nodes[b].pct);
for (const i of order) {
const width = labelWidths[i];
const x = (nodes[i].pct / 100) * trackWidth;
const left = Math.max(0, Math.min(x - width / 2, trackWidth - width));
// Row placed.length is always empty, so a free row always exists.
const candidates = Array.from(
{ length: placed.length + 1 },
(_, row) => ({
left,
right: left + width,
row,
x,
}),
).filter((box) => !placed.some((other) => overlaps(box, other)));
const box =
candidates.find(
(box) =>
!placed.some((other) => crosses(box, other) || crosses(other, box)),
) ?? candidates[0];
placed.push(box);
places[i] = { shift: box.left - x, row: box.row };
}
return places;
});
const labelRows = $derived(
Math.max(1, ...labelPlaces.map((place) => place.row + 1)),
);

const paceExplorer = $derived.by(() => {
if (
chosen?.result.status !== "available" ||
Expand Down Expand Up @@ -312,22 +368,36 @@
<span class="spectrum-spread">Method spread: {spectrum.spreadSeconds}s</span>
<span>Slower ({spectrum.maxTime})</span>
</div>
<div class="spectrum-track">
<div
class="spectrum-track"
bind:clientWidth={trackWidth}
style={`--label-rows: ${labelRows}${rowPitch === null ? "" : `; --pin-row: ${rowPitch}px`}`}
>
<div class="spectrum-rail"></div>
{#each spectrum.nodes as node}
<button
type="button"
{#each spectrum.nodes as node, i}
{@const place = labelPlaces[i]}
<div
class="spectrum-pin"
class:selected={node.selected}
style={`left: ${node.pct.toFixed(1)}%`}
disabled={!onSelect}
onclick={() => onSelect?.(node.id)}
title={`${node.label}: ${node.time} (${node.deltaText})`}
style={`left: ${node.pct.toFixed(1)}%; --label-row: ${place.row}${place.shift === null ? "" : `; --label-shift: ${place.shift.toFixed(1)}px`}`}
>
<span class="pin-dot" aria-hidden="true"></span>
<span class="pin-name">{node.shortLabel}</span>
<span class="pin-time">{node.time}</span>
</button>
{#if place.row > 0}
<span class="pin-leader" aria-hidden="true"></span>
{/if}
<button
type="button"
class="pin-label"
bind:offsetWidth={labelWidths[i]}
bind:offsetHeight={labelHeights[i]}
disabled={!onSelect}
onclick={() => onSelect?.(node.id)}
title={`${node.label}: ${node.time} (${node.deltaText})`}
>
<span class="pin-name">{node.shortLabel}</span>
<span class="pin-time">{node.time}</span>
</button>
</div>
{/each}
</div>
</div>
Expand Down Expand Up @@ -624,8 +694,9 @@
font-weight: 600;
}
.spectrum-track {
--pin-row: 2.6rem;
position: relative;
height: 46px;
height: calc(18px + var(--label-rows, 1) * var(--pin-row));
}
.spectrum-rail {
position: absolute;
Expand All @@ -636,25 +707,51 @@
border-radius: 3px;
background: var(--tm-color-border-subtle);
}
/* Zero-width anchor at the method's true position. The dot centres on it;
the label is shifted by the measured layout (centred before measuring). */
.spectrum-pin {
position: absolute;
top: 0;
transform: translateX(-50%);
width: 0;
}
.pin-leader {
position: absolute;
top: 16px;
left: -0.5px;
width: 1px;
height: calc(2px + var(--label-row) * var(--pin-row));
background: var(--tm-color-border-control);
}
.pin-label {
position: absolute;
top: calc(18px + var(--label-row) * var(--pin-row));
left: 0;
z-index: 1;
transform: translateX(var(--label-shift, -50%));
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 4px;
border: 0;
background: transparent;
border-radius: var(--tm-radius-control);
/* Opaque so a leader that must pass a label runs behind its text. */
background: var(--tm-color-surface);
color: var(--tm-color-text-muted);
font: inherit;
cursor: pointer;
}
.spectrum-pin:disabled {
.pin-label:disabled {
cursor: default;
}
.pin-label:focus-visible {
outline: 2px solid var(--tm-color-accent);
outline-offset: 2px;
}
.pin-dot {
position: absolute;
top: 3px;
left: -6px;
width: 12px;
height: 12px;
border-radius: 50%;
Expand Down
78 changes: 78 additions & 0 deletions tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,3 +1070,81 @@ test("benchmark swim selection leads the unified benchmark selector and marks on
await expect(estimate).not.toHaveAttribute("aria-describedby");
await expect(estimate).toContainText("1:06.72");
});

test("method spectrum keeps close method labels apart and inside the card", async ({
page,
}, testInfo) => {
if (testInfo.project.name === "mobile")
await page.setViewportSize({ width: 390, height: 844 });
// NCAA and Equivalence land about 12s apart while Colorado is two minutes
// slower, so the two faster labels would collide at their pin positions.
await page.goto("./#t=17%3A36.20&s=FR&d=1500&c=SCY&g=women&h=0");
await page.getByTestId("result-SCM").click();
await page.locator(".result-method summary").click();
const methods = page.getByRole("region", { name: "Compare methods in SCM" });
const spectrum = methods.locator(".method-spectrum");
await expect(spectrum).toBeVisible();
const pins = spectrum.locator(".spectrum-pin");
await expect(pins).toHaveCount(3);
for (const text of ["17:33.04", "17:45.52", "19:32.38"])
await expect(spectrum).toContainText(text);

for (const scheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme: scheme });
const card = (await spectrum.boundingBox())!;
const labels = await spectrum
.locator(".pin-name, .pin-time")
.evaluateAll((nodes) =>
nodes.map((node) => {
const r = node.getBoundingClientRect();
return {
text: node.textContent ?? "",
left: r.left,
right: r.right,
top: r.top,
bottom: r.bottom,
};
}),
);
expect(labels).toHaveLength(6);
for (const label of labels) {
expect(label.left, label.text).toBeGreaterThanOrEqual(card.x);
expect(label.right, label.text).toBeLessThanOrEqual(card.x + card.width);
expect(label.top, label.text).toBeGreaterThanOrEqual(card.y);
expect(label.bottom, label.text).toBeLessThanOrEqual(
card.y + card.height,
);
}
for (const [i, a] of labels.entries())
for (const b of labels.slice(i + 1))
expect(
a.right <= b.left ||
b.right <= a.left ||
a.bottom <= b.top ||
b.bottom <= a.top,
`${a.text} overlaps ${b.text}`,
).toBe(true);

// Dots stay at their true relative positions on the time axis.
const dots = await pins.evaluateAll((nodes) =>
nodes.map((node) => {
const r = node.querySelector(".pin-dot")!.getBoundingClientRect();
return {
time: node.querySelector(".pin-time")!.textContent!,
x: r.left + r.width / 2,
};
}),
);
const at = (time: string) => dots.find((dot) => dot.time === time)!.x;
const ncaa = at("17:33.04");
const equivalence = at("17:45.52");
const colorado = at("19:32.38");
expect(ncaa).toBeLessThan(equivalence);
expect(equivalence).toBeLessThan(colorado);
// 12.48s of a 119.34s spread: the gap is about a tenth of the span.
expect((equivalence - ncaa) / (colorado - ncaa)).toBeCloseTo(
12.48 / 119.34,
2,
);
}
});
Loading