Ctrl K
ComunidadGuías…

src/lib/chart-path.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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;
}