Show the longest gap in the timing charts

The bar gains a faded stretch from the typical gap out to the longest one of
the window, behind the solid shortest-to-typical part. Where the solid ends is
the median, so the boundary marks "typical" without another mark on the track.

Extending the linear axis to reach it does not work. Gaps are heavily skewed —
one long overnight gap a night against daytime gaps a tenth its length — and
on a linear scale the everyday range collapsed into the first tenth of the
track, to the point that four different "since" values rendered as the same
picture. So the axis is stretched the way the sleep trend's is above 10h: the
typical gap is pinned to the middle, shortest-to-typical takes the left half
and typical-to-longest the right. Every row then reads the same way, left of
centre sooner than usual and right of centre longer, however extreme that
row's tail is. A note in the panel says as much, since a stretched axis is not
something to leave to inference.

The right edge now being the longest gap retires the old cap at twice the
typical: the marker's chevron means "past the longest gap yet" rather than
past an arbitrary cutoff.
This commit is contained in:
Alexander Heldt
2026-08-24 15:06:12 +00:00
parent 8d0ebdd4bc
commit 17ce68da08
4 changed files with 54 additions and 48 deletions
+46 -48
View File
@@ -729,6 +729,7 @@
function drawTimingChart(svg, note, row, gaps, since) {
const typical = median(gaps);
const shortest = gaps[0] ?? null;
const longest = gaps[gaps.length - 1] ?? null;
// Fewer than two events in the window means there is no gap to draw, so the
// row falls back to a sentence rather than an axis with nothing on it.
@@ -748,34 +749,46 @@
const innerW = W - ML - MR;
const trackY = 20, trackH = 8;
// A gap in tracking can leave "since" at days while the typical gap is
// hours, which would squash the band to a sliver. Past twice the typical
// the marker parks at the right edge behind a chevron; its label still
// carries the real number, so nothing reads as being in range when it isn't.
const cap = typical * 2;
const over = since != null && since > cap;
const cur = since == null ? null : (over ? cap : since);
// Gaps are heavily skewed: one long overnight gap a night sits alongside
// daytime gaps a tenth its length, and on a plain linear axis it squashes
// the everyday range — the part you actually read — into the first tenth of
// the track. So the axis is stretched, the way the sleep trend's is above
// 10h: the typical gap is pinned to the middle, the shortest-to-typical
// stretch gets the left half and typical-to-longest the right. Reading it
// is then the same in every row — left of centre is sooner than usual,
// right of centre is longer — however extreme that row's tail happens to be.
const over = since != null && since > longest;
const cur = since == null ? null : Math.min(since, longest);
let lo = Math.min(shortest, cur ?? shortest);
let hi = Math.max(typical, cur ?? typical);
if (!(hi > lo)) { const pad = Math.max(60_000, hi * 0.25); lo -= pad; hi += pad; }
const xOf = (v) => ML + ((v - lo) / (hi - lo)) * innerW;
const lo = Math.min(shortest, cur ?? shortest);
const halfW = innerW / 2;
const mid = ML + halfW;
const xOf = (v) => {
if (v <= typical) return typical > lo ? ML + ((v - lo) / (typical - lo)) * halfW : mid;
return longest > typical ? mid + ((v - typical) / (longest - typical)) * halfW : mid;
};
// The band collapses to a dot when a single gap is all there is, so it gets
// centred on the point instead of growing to the right of it.
const bandA = xOf(shortest), bandB = xOf(typical);
const bandW = Math.max(trackH + 2, bandB - bandA);
const bandX = bandB - bandA < trackH + 2 ? (bandA + bandB) / 2 - bandW / 2 : bandA;
// Two bands: the full spread of gaps in the window, and inside it the solid
// stretch from the shortest to the typical one. Where the solid ends is the
// median, so the boundary itself marks "typical" without another mark.
const xShort = xOf(shortest), xTypical = xOf(typical), xLong = xOf(longest);
const dot = (a, b) => b - a < trackH + 2;
const bandRect = (cls, a, b, extra) => {
const w = Math.max(trackH + 2, b - a);
const x = dot(a, b) ? (a + b) / 2 - w / 2 : a;
return `<rect class="${cls}" x="${x.toFixed(1)}" y="${trackY}" width="${w.toFixed(1)}" ` +
`height="${trackH}" rx="${trackH / 2}">${extra}</rect>`;
};
const parts = [
`<rect class="tm-track" x="${ML}" y="${trackY}" width="${innerW}" height="${trackH}" rx="${trackH / 2}"/>`,
`<rect class="tm-band ${row.cls}" x="${bandX.toFixed(1)}" y="${trackY}" ` +
`width="${bandW.toFixed(1)}" height="${trackH}" rx="${trackH / 2}">` +
`<title>${escapeText(`Usually ${formatDuration(shortest)}${formatDuration(typical)} between ${row.noun}s`)}</title></rect>`,
bandRect(`tm-range ${row.cls}`, xShort, xLong,
`<title>${escapeText(`${formatDuration(shortest)}${formatDuration(longest)} between ${row.noun}s over the last 7 days`)}</title>`),
bandRect(`tm-band ${row.cls}`, xShort, xTypical,
`<title>${escapeText(`Typically ${formatDuration(typical)} between ${row.noun}s`)}</title>`),
];
// Ticks tie each label to the exact point it describes, which is what keeps
// a centred label honest when the band is too narrow to hang one off each end.
// Ticks tie the summary line under the track to the three points it names.
const tickY = trackY + trackH + 5;
const tick = (x) =>
`<line class="tm-tick" x1="${x.toFixed(1)}" y1="${tickY}" x2="${x.toFixed(1)}" y2="${tickY + 4}"/>`;
@@ -785,34 +798,19 @@
// and its value is an explicit dx offset.
const word = (w, dx) => `<tspan class="tm-word"${dx ? ` dx="${dx}"` : ""}>${w}</tspan>`;
const val = (w, ms, dx) => `${word(w, dx)}<tspan dx="3">${escapeText(formatDuration(ms))}</tspan>`;
// Rough advance width for the 9px label font — only needs to be close
// enough to stop a centred label running off either edge.
const widthOf = (plain) => plain.length * 5;
const centred = (markup, plain, x) => {
const half = widthOf(plain) / 2;
const cx = Math.min(Math.max(x, half + 4), W - half - 4);
return `<text x="${cx.toFixed(1)}" y="${labelY}" text-anchor="middle">${markup}</text>`;
};
const mid = (bandA + bandB) / 2;
if (bandB - bandA < 4) {
// One gap, or gaps that round to the same figure: a single point to name.
parts.push(tick(mid));
parts.push(centred(val("typical", typical), `typical ${formatDuration(typical)}`, mid));
} else if (bandB - bandA < 150) {
// Too narrow to hang a label off each end without the two touching, so
// both values ride together over the middle of the band rather than being
// pushed out to the track ends, where they would imply a spread the band
// doesn't have.
parts.push(tick(bandA), tick(bandB));
const plain = `shortest ${formatDuration(shortest)} · typical ${formatDuration(typical)}`;
parts.push(centred(
`${val("shortest", shortest)}${word("·", 4)}${val("typical", typical, 4)}`,
plain, mid));
// Three values will not fit as labels hung off their own points, so they
// read as one centred line and the ticks carry the positions.
if (longest - shortest < 60_000) {
parts.push(tick(xTypical));
parts.push(`<text x="${(W / 2).toFixed(1)}" y="${labelY}" text-anchor="middle">${val("typical", typical)}</text>`);
} else {
parts.push(tick(bandA), tick(bandB));
parts.push(`<text x="${bandA.toFixed(1)}" y="${labelY}" text-anchor="start">${val("shortest", shortest)}</text>`);
parts.push(`<text x="${bandB.toFixed(1)}" y="${labelY}" text-anchor="end">${val("typical", typical)}</text>`);
parts.push(tick(xShort), tick(xTypical), tick(xLong));
parts.push(
`<text x="${(W / 2).toFixed(1)}" y="${labelY}" text-anchor="middle">` +
`${val("shortest", shortest)}${word("·", 4)}${val("typical", typical, 4)}` +
`${word("·", 4)}${val("longest", longest, 4)}</text>`
);
}
if (cur != null) {
@@ -841,7 +839,7 @@
svg.innerHTML = parts.join("");
svg.setAttribute("aria-label",
`${row.label}: typically ${formatDuration(typical)} between ${row.noun}s, ` +
`shortest ${formatDuration(shortest)}` +
`shortest ${formatDuration(shortest)}, longest ${formatDuration(longest)}` +
(since == null ? "" : `, ${formatDuration(since)} since the last one`) + ".");
}