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:
+46
-48
@@ -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`) + ".");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
{ "date": "2026-08-24", "text": "The timing charts now show the longest gap as well: each bar keeps its solid stretch from the shortest to the typical gap and fades on out to the longest one of the week. To stop the nightly long gap from squashing the daytime range into a sliver, the bars are stretched so the typical gap always sits dead centre — left of the middle is sooner than usual, right of it is longer, in every row" },
|
||||
{ "date": "2026-08-24", "text": "Each row of the timing panel is now a small chart instead of a number: the band spans the shortest to the typical gap over the last 7 days, and the marker is how long it has been since the last one. Inside the band means there is time yet, off the right-hand end means the puppy is due — and a row with only one event so far says so instead of drawing an empty axis" },
|
||||
{ "date": "2026-08-24", "text": "The timing panel now covers meals too — typical and shortest time between them, alongside the pee and poo gaps — so you can see the feeding rhythm the same way. It is titled just “Timing” now that it is no longer only about bathroom breaks" },
|
||||
{ "date": "2026-08-24", "text": "The “By hour of day” chart is now tappable: tap any block to read what it counts (“3 meals between 07:00 and 08:00”) just under the chart — until now that number only showed as a hover tooltip, which phones never get. The block you tapped gets a ring" },
|
||||
|
||||
@@ -191,6 +191,9 @@
|
||||
<p class="muted-note timing-note" id="timing-note-eat" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The axis is stretched (see drawTimingChart), so say what the middle
|
||||
of a bar means rather than leave it to be inferred. -->
|
||||
<p class="muted-note">The middle of every bar is that type's typical gap: left of it is sooner than usual, right of it is longer, and the faded stretch runs out to the longest gap of the week.</p>
|
||||
<p class="muted-note timing-hint" id="timing-hint"></p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -371,6 +371,10 @@ button.danger { background: var(--danger); }
|
||||
.timing-chart .tm-word { fill: var(--muted); }
|
||||
.timing-chart .tm-track { fill: var(--border); }
|
||||
.timing-chart .tm-tick { stroke: var(--muted); stroke-width: 1; opacity: 0.45; }
|
||||
.timing-chart .tm-band { fill-opacity: 1; }
|
||||
/* The full spread sits behind the solid shortest-to-typical stretch, faded so
|
||||
the everyday half of the range stays the one that reads first. */
|
||||
.timing-chart .tm-range { fill-opacity: 0.32; }
|
||||
.timing-chart .tm-pee { fill: var(--pee); }
|
||||
.timing-chart .tm-poo { fill: var(--poo); }
|
||||
.timing-chart .tm-eat { fill: var(--eat); }
|
||||
|
||||
Reference in New Issue
Block a user