Compare labels: place with least-squares blocks so isolated labels stay put (#87)
The distribution-bar key de-collides labels by sweeping each one right to clear its neighbour, which collapsed a row toward the right edge whenever an early bucket was tiny — Cool/Comfortable slid far from their large segments instead of sitting under them. Replace the sweep with standard 1-D label placement: keep each label at its segment midpoint, merge only labels that actually overlap into a block, and position each block at the least-squares mean of its members' ideals. Isolated labels no longer move; clusters centre on their segments instead of sliding to an edge. The run is clamped inside the bar, and mobile keeps its plain wrapping row.
This commit is contained in:
parent
a88bb4f2e6
commit
cd55b63e48
2 changed files with 56 additions and 34 deletions
|
|
@ -419,15 +419,13 @@ function rankCard(loc, s, rank) {
|
|||
`</div>`;
|
||||
// Key under the bar: a color swatch + name + share for every non-empty bucket, in
|
||||
// the same cold→hot order as the bar, so small spans are still clearly labeled. On
|
||||
// desktop each label is positioned at its segment's midpoint (the ends anchored to
|
||||
// the bar edges); on mobile it falls back to a plain wrapping row.
|
||||
// desktop each label wants its segment's midpoint (recorded in data-mid); spreadKeys
|
||||
// then de-collides them after layout. On mobile it falls back to a plain wrapping row.
|
||||
let acc = 0;
|
||||
const key = `<div class="cmp-key">` +
|
||||
parts.map((p, i) => {
|
||||
parts.map((p) => {
|
||||
const mid = acc + p.w / 2; acc += p.w;
|
||||
const pos = i === 0 ? "left:0" : i === parts.length - 1 ? "right:0" : `left:${mid}%`;
|
||||
const edge = i === 0 ? " cmp-key-first" : i === parts.length - 1 ? " cmp-key-last" : "";
|
||||
return `<span class="cmp-key-item${edge}" style="${pos}"><i class="cmp-sw ${p.cls}"></i>${p.label} <b>${pct(p.w)}</b></span>`;
|
||||
return `<span class="cmp-key-item" data-mid="${mid}" style="left:${mid}%"><i class="cmp-sw ${p.cls}"></i>${p.label} <b>${pct(p.w)}</b></span>`;
|
||||
}).join("") +
|
||||
`</div>`;
|
||||
const bands = `<div class="cmp-bands"><b>${pct(s.tolPct)}</b> within tolerance · typically <b>${deg(s.mad)}</b> off comfort</div>`;
|
||||
|
|
@ -523,40 +521,66 @@ function renderResults() {
|
|||
spreadKeys();
|
||||
}
|
||||
|
||||
// The desktop key positions each label at its segment's midpoint, so two small
|
||||
// adjacent buckets (e.g. Warm 1% right next to Too hot 2%) render their labels on
|
||||
// top of each other. After layout, measure each label and sweep the row so no two
|
||||
// overlap: push right to clear the previous label, clamp the tail to the right edge,
|
||||
// then pull left — keeping every label as close to its segment as the space allows.
|
||||
// The desktop key wants each label centred on its segment's midpoint, but small or
|
||||
// adjacent buckets make labels overlap. After layout, de-collide each row with the
|
||||
// classic 1-D label placement: keep every label at its ideal midpoint, and only where
|
||||
// labels would touch, merge them into a block positioned to minimise the total shift
|
||||
// from their ideals — so an isolated label never moves, and a cluster centres on its
|
||||
// members rather than sliding to one edge. Finally clamp the run inside the bar.
|
||||
// Mobile uses a plain wrapping row, so there we just clear any positions we applied.
|
||||
function spreadKeys() {
|
||||
const desktop = window.matchMedia("(min-width: 641px)").matches;
|
||||
const GAP = 10;
|
||||
results.querySelectorAll(".cmp-key").forEach((keyEl) => {
|
||||
const items = [...keyEl.children];
|
||||
if (!desktop) {
|
||||
items.forEach((el) => { el.style.left = el.style.right = el.style.transform = ""; });
|
||||
items.forEach((el) => { el.style.left = el.style.transform = ""; });
|
||||
return;
|
||||
}
|
||||
if (items.length < 2) return;
|
||||
const W = keyEl.clientWidth, gap = 10;
|
||||
// Ideal centre (px) for each label, honouring the end anchors (first pinned to
|
||||
// the left edge, last to the right); middles sit at their inline midpoint %.
|
||||
const m = items.map((el, i) => {
|
||||
const hw = el.offsetWidth / 2;
|
||||
const c = i === 0 ? hw
|
||||
: i === items.length - 1 ? W - hw
|
||||
: (parseFloat(el.style.left) / 100) * W;
|
||||
return { el, hw, c };
|
||||
});
|
||||
for (let i = 1; i < m.length; i++) // forward: clear the previous label
|
||||
m[i].c = Math.max(m[i].c, m[i - 1].c + m[i - 1].hw + gap + m[i].hw);
|
||||
m[m.length - 1].c = Math.min(m[m.length - 1].c, W - m[m.length - 1].hw);
|
||||
for (let i = m.length - 2; i >= 0; i--) // backward: pull back within the edge
|
||||
m[i].c = Math.min(m[i].c, m[i + 1].c - m[i + 1].hw - gap - m[i].hw);
|
||||
m[0].c = Math.max(m[0].c, m[0].hw); // keep the first inside the left edge
|
||||
m.forEach(({ el, c }) => {
|
||||
el.style.left = `${c}px`; el.style.right = "auto"; el.style.transform = "translateX(-50%)";
|
||||
if (!items.length) return;
|
||||
const W = keyEl.clientWidth;
|
||||
// Each label's ideal left edge = its segment midpoint minus half its width.
|
||||
const labs = items.map((el) => {
|
||||
const w = el.offsetWidth;
|
||||
return { el, w, ideal: (parseFloat(el.dataset.mid) / 100) * W - w / 2 };
|
||||
});
|
||||
// Sweep left→right, growing a block whenever the next label would overlap the
|
||||
// current one. A block records Σ(idealₖ − offsetₖ) and its count so its left edge
|
||||
// = that mean, the least-squares spot for its members.
|
||||
const blocks = [];
|
||||
for (const it of labs) {
|
||||
let b = { items: [it], left: it.ideal, width: it.w, sum: it.ideal, n: 1 };
|
||||
blocks.push(b);
|
||||
while (blocks.length >= 2) {
|
||||
const a = blocks[blocks.length - 2], cur = blocks[blocks.length - 1];
|
||||
if (a.left + a.width + GAP <= cur.left) break; // no overlap → settled
|
||||
const offset = a.width + GAP; // cur's items shift this far into a
|
||||
a.sum += cur.sum - cur.n * offset;
|
||||
a.n += cur.n;
|
||||
a.width += GAP + cur.width;
|
||||
a.items = a.items.concat(cur.items);
|
||||
a.left = a.sum / a.n;
|
||||
blocks.pop();
|
||||
}
|
||||
}
|
||||
// Clamp the run inside the bar: off the left wall forward, off the right wall back.
|
||||
let minLeft = 0;
|
||||
for (const b of blocks) { if (b.left < minLeft) b.left = minLeft; minLeft = b.left + b.width + GAP; }
|
||||
let maxRight = W;
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const b = blocks[i];
|
||||
if (b.left + b.width > maxRight) b.left = maxRight - b.width;
|
||||
maxRight = b.left - GAP;
|
||||
}
|
||||
// Lay each block's labels out left→right from the block's left edge.
|
||||
for (const b of blocks) {
|
||||
let x = b.left;
|
||||
for (const it of b.items) {
|
||||
it.el.style.left = `${x + it.w / 2}px`;
|
||||
it.el.style.transform = "translateX(-50%)";
|
||||
x += it.w + GAP;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -939,12 +939,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
.cmp-key-item b { color: var(--text); font-weight: 700; }
|
||||
.cmp-sw { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* Desktop: spread the labels across the bar's width, each centred under its own
|
||||
segment (first/last pinned to the ends so they never spill past the edge). */
|
||||
segment; spreadKeys() then de-collides them and keeps the run inside the edges. */
|
||||
@media (min-width: 641px) {
|
||||
.cmp-key { position: relative; display: block; height: 18px; }
|
||||
.cmp-key-item { position: absolute; transform: translateX(-50%); white-space: nowrap; }
|
||||
.cmp-key-first { transform: none; }
|
||||
.cmp-key-last { transform: none; }
|
||||
}
|
||||
|
||||
.cmp-bands { margin: 8px 0 0; font-size: 12.5px; color: var(--muted); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue