src/lib/chart-path.ts
Rama main · réplica de lectura del repositorio original.
// Path geometry shared by the app's hand-rolled SVG charts (the dive profile,
// its silhouette, the activity and admin bars), in a module of its own because
// a bar chart importing a dive-profile module to draw a shape reads as an
// accident.
export interface Point {
x: number;
y: number;
}
// Coordinates are rounded before they reach a path string. Two decimals is well
// under a viewBox unit - which is itself a fraction of a pixel at any size this
// renders at - and it keeps the `d` attribute readable and assertable instead of
// being a wall of `123.45600000000002`.
function round(value: number): number {
return Number(value.toFixed(2));
}
// The `d` of the filled area under a curve: along the points, down to the
// baseline, back along it, closed.
//
// Kept in `lib/` rather than inlined in the component so the path string is
// assertable - it is the one piece of SVG in these charts with a shape worth
// getting wrong.
export function buildAreaPath(points: Point[], baselineY: number): string {
if (points.length === 0) return "";
const line = points
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`)
.join(" ");
return `${line} L${points[points.length - 1].x},${baselineY} L${points[0].x},${baselineY} Z`;
}
// The cubic segments joining `points`, without the leading `M` - the body of
// `smoothPath`.
//
// Monotone cubic interpolation (Fritsch-Carlson), specifically, rather than the
// Catmull-Rom spline that every "smooth line chart" snippet reaches for. The
// difference is the whole reason smoothing is safe to apply to data at all:
// Catmull-Rom overshoots around a local extreme, so a dip in a line gets drawn
// dipping *below* its lowest value. On a dive profile that would render a depth
// the diver never reached, below the deepest point of the dive.
// Monotone cubic is constructed to never leave the interval between two
// neighbouring points, so the curve can only ever say what the numbers say.
//
// Assumes x is non-decreasing, which every caller satisfies (both charts plot
// against time, oldest first). Two samples at the same instant would make the
// slope infinite, so a zero-width step is treated as flat.
function curveCommands(points: Point[]): string {
const count = points.length;
// The secant slope of each interval, and the width it spans.
const widths: number[] = [];
const secants: number[] = [];
for (let index = 0; index < count - 1; index++) {
const width = points[index + 1].x - points[index].x;
widths.push(width);
secants.push(
width > 0 ? (points[index + 1].y - points[index].y) / width : 0,
);
}
// The tangent at each point: the average of the two secants meeting there,
// flattened to zero at a local extreme (where the secants have opposite
// signs) so the curve turns over cleanly instead of overshooting.
const tangents: number[] = new Array(count);
tangents[0] = secants[0];
tangents[count - 1] = secants[count - 2];
for (let index = 1; index < count - 1; index++) {
tangents[index] =
secants[index - 1] * secants[index] <= 0
? 0
: (secants[index - 1] + secants[index]) / 2;
}
// Fritsch-Carlson's limiter: pull any tangent pair back inside the circle of
// radius 3 that guarantees monotonicity over the interval. This is the step
// that makes the "never overshoots" claim above true rather than approximate.
for (let index = 0; index < count - 1; index++) {
if (secants[index] === 0) {
tangents[index] = 0;
tangents[index + 1] = 0;
continue;
}
const start = tangents[index] / secants[index];
const end = tangents[index + 1] / secants[index];
const distance = start * start + end * end;
if (distance > 9) {
const scale = 3 / Math.sqrt(distance);
tangents[index] = scale * start * secants[index];
tangents[index + 1] = scale * end * secants[index];
}
}
// A cubic Hermite segment expressed as the bezier SVG wants: the control
// points sit a third of the interval along each endpoint's tangent.
return points
.slice(1)
.map((point, index) => {
const width = widths[index];
const c1x = points[index].x + width / 3;
const c1y = points[index].y + (tangents[index] * width) / 3;
const c2x = point.x - width / 3;
const c2y = point.y - (tangents[index + 1] * width) / 3;
return `C${round(c1x)},${round(c1y)} ${round(c2x)},${round(c2y)} ${round(point.x)},${round(point.y)}`;
})
.join(" ");
}
// The `d` of a smooth curve through `points`.
//
// A single point produces a bare `M`, which draws nothing - deliberately the
// same no-op a one-point `<polyline>` was, so a stretch of diving containing one
// dive still costs nothing to render.
export function smoothPath(points: Point[]): string {
if (points.length === 0) return "";
const start = `M${round(points[0].x)},${round(points[0].y)}`;
if (points.length === 1) return start;
return `${start} ${curveCommands(points)}`;
}
// The `d` of a bar with its top two corners rounded and its bottom two square.
//
// `<rect rx>` would be the obvious way to draw a bar, and it rounds all four
// corners - which lifts the bar off the axis it is measured from, leaving a
// visible notch either side of the baseline. Rounding only the top is what makes
// a bar read as a column standing on the axis rather than as a floating pill.
//
// The radius is clamped to what the bar can actually carry: half its width (past
// that the two arcs would cross) and its own height (a one-dive bar in a
// hundred-dive year is a few units tall, and a 4-unit radius on it would bow the
// sides). A zero-height bar draws nothing at all, which is right - "no diving
// that month" is an absence, not a sliver.
export function barPath(
x: number,
y: number,
width: number,
height: number,
radius: number,
): string {
if (height <= 0 || width <= 0) return "";
const r = Math.min(radius, width / 2, height);
const right = x + width;
const bottom = y + height;
return [
`M${round(x)},${round(bottom)}`,
`L${round(x)},${round(y + r)}`,
`Q${round(x)},${round(y)} ${round(x + r)},${round(y)}`,
`L${round(right - r)},${round(y)}`,
`Q${round(right)},${round(y)} ${round(right)},${round(y + r)}`,
`L${round(right)},${round(bottom)}`,
"Z",
].join(" ");
}
// Split a chronological series into runs of points close enough together that a
// line joining them means something, as arrays of indices into the original - so
// a line is never drawn across a gap of more than `maxGapMs`.
//
// A line across a gap states something the data doesn't: a transmitter that
// drops out for ten minutes mid-dive drawn as a straight line reads as "the
// pressure fell smoothly" (224 of 441 samples on `Dive_2025-06-02-1155.xml`),
// and a gas trend joining April to October as "my consumption was steady all
// summer", when the truth both times is "nothing was recorded here".
export function segmentByTimeGap(t: number[], maxGapMs: number): number[][] {
if (t.length === 0) return [];
const segments: number[][] = [[0]];
for (let index = 1; index < t.length; index++) {
if (t[index] - t[index - 1] > maxGapMs) {
segments.push([index]);
} else {
segments[segments.length - 1].push(index);
}
}
return segments;
}