Ctrl K
ComunidadGuías…

src/lib/dive-profile.ts

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

Archivo original ↗Preguntar a KiBot
import type {
  DiveMixture,
  DiveProfile,
  DiveProfileEvent,
  DiveProfilePressureSeries,
  DiveProfileSeries,
} from "@/lib/api/dives";
import { niceDomain, type Domain } from "@/lib/chart-scale";
import {
  formatDurationForForm,
  formatDurationHoursMinutes,
} from "@/lib/date-time";
import {
  toDisplayUnits,
  unitLabel,
  unitSeparator,
  unitWord,
  type Dimension,
  type UnitSystem,
} from "@/lib/units";

// All of the dive profile chart's arithmetic, kept out of the component per the
// repo convention: pure functions in `lib/` get Vitest coverage, components
// aren't rendered in tests. If a number on that chart could be wrong, its
// derivation belongs here.

// The profile's axis is milliseconds (DiveJSON §5.1): a series' `times`, a
// profile's `duration` and an event's `time`, counted from the recording's own
// start - until `placeOnDiveAxis` moves them onto the axis a dive's recordings
// share, which is the axis the chart draws on. Every helper here takes and
// returns that unit, and a second exists only
// where a person reads one - `formatElapsed` and `formatElapsedSpoken` convert,
// once. The `ndl` and `tts` *values* are seconds still: readings, not positions
// on the axis, and `PROFILE_CHANNELS` scales them on their own terms.
export const MILLISECONDS_PER_SECOND = 1000;

/** An axis instant as the `M:SS` the x-axis labels and the crosshair print. */
export function formatElapsed(milliseconds: number): string {
  return formatDurationForForm(
    Math.round(milliseconds / MILLISECONDS_PER_SECOND),
  );
}

/**
 * An axis span read aloud - "1h 25min" rather than "85:00", which is not a
 * length of time to a screen reader.
 */
export function formatElapsedSpoken(milliseconds: number): string {
  return formatDurationHoursMinutes(milliseconds / MILLISECONDS_PER_SECOND);
}

export type ProfileChannelKey =
  | "depth"
  | "ceiling"
  | "temperature"
  | "pressure"
  // What the diver's wrist sensor or strap read, in whole beats per minute: a
  // measurement like the four above, drawn under the depth plot only because
  // that plot's edges are taken.
  | "heart_rate"
  // The six the device's own decompression arithmetic produces. They are
  // channels on exactly the terms the first four are - a domain, a scale, a
  // unit and a toggle - and they are drawn away from the depth plot only
  // because the plot has two edges and these carry four scales of their own
  // between them. See `CHANNEL_AXIS`.
  | "ndl"
  | "tts"
  | "ppo2"
  | "cns"
  | "gradient_factor"
  | "surface_gradient_factor";

export interface ProfileChannel {
  key: ProfileChannelKey;
  label: string;
  unit: string;
  // What the API's integer `values` are divided by to reach display units.
  //
  // For the depth plot's four channels this is exactly the API's own
  // constant - `DEPTH_SCALE`/`CEILING_SCALE`/`TEMPERATURE_SCALE`/`PRESSURE_SCALE` in
  // `schemas/dive_profile.py`, and those two lists are a pair. For `ndl` and
  // `tts` it is not: the format's scale is 1 because the wire carries whole
  // seconds, and a diver reads a no-decompression limit in minutes, so the
  // divisor is 60 and the axis is numbered in the unit the wrist showed.
  scale: number;
  // How many decimals a reading of this channel is worth showing. Same
  // resolution as the stored scale: showing more digits than were stored is a
  // false claim about the sensor. Temperature's is the most it is ever quoted
  // to: the format fills hundredths with zeros for a source that states tenths
  // or whole degrees, so `toChannelSeries` narrows it to the grain each
  // recording's readings actually carry.
  decimals: number;
  // The Tailwind text color class the curve, its axis and its readout all use.
  colorClass: string;
  // Depth grows downward from the surface, so its axis is upside down relative
  // to every other chart in the app. The flag lives here rather than as an `if`
  // in the component, so nothing else has to remember which channel is special.
  inverted: boolean;
  // Drawn as a dashed line rather than a solid one - in the plot *and* in the
  // legend swatch, which is the reason this is a property of the channel rather
  // than a check at the one place that draws a polyline. A solid swatch next to
  // a dashed curve is the one thing a legend must not say, and the two live far
  // enough apart in the component to drift if each decided for itself.
  dashed: boolean;
  // What a gap in this channel's samples means, and so how hard the chart works
  // to avoid drawing across one.
  //
  // For a measured channel a gap means "not recorded", and with fewer than three
  // samples there is no cadence to judge one by - so joining them is the only
  // honest thing left, and `gapThreshold`'s `Infinity` says exactly that. For
  // the deco ceiling a gap means **no obligation existed**, which is a fact
  // about the dive rather than about the sensor, and joining across it draws a
  // forbidden zone over water the diver was free to be in.
  //
  // Only the second kind is worth refusing to draw for, and refusing has a real
  // cost: a two-sample channel splits into two runs of one, which draw nothing,
  // and a channel with nothing drawable is not plotted at all. Paid for the
  // ceiling; not paid for depth, where it would replace a two-point line with
  // "this recording's file recorded no samples to plot" over a dive that
  // recorded two.
  //
  // Coincides with `dashed` today and says something different: that one is how
  // the curve is drawn, this is what its absence means.
  gapsAreMeaningful: boolean;
}

export const PROFILE_CHANNELS: Record<ProfileChannelKey, ProfileChannel> = {
  depth: {
    key: "depth",
    label: "Depth",
    unit: "m",
    scale: 100, // centimeters
    decimals: 1,
    colorClass: "text-teal",
    inverted: true,
    dashed: false,
    gapsAreMeaningful: false,
  },
  ceiling: {
    key: "ceiling",
    label: "Deco ceiling",
    unit: "m",
    // Depth's scale, not one of its own - see `CEILING_SCALE` in the API's
    // `schemas/dive_profile.py`. A ceiling is a depth, and it is drawn against
    // the depth axis, so the two have to divide by the same number or the
    // shaded region would not line up with the curve it bounds.
    scale: 100, // centimeters
    decimals: 1,
    colorClass: "text-ceiling",
    inverted: true,
    // The only dashed curve on the chart, and the dash is load-bearing: it is
    // the one line **on the depth plot** that was never measured. Depth,
    // temperature and pressure are readings; a ceiling is a computed limit that
    // moved as the diver's tissues loaded, and a solid line would present the
    // two as the same kind of fact.
    //
    // The rows under the depth plot are drawn solid, which is not an exception
    // to that rule but the absence of the case it answers: no row there puts a
    // measured curve beside a computed one - heart rate has a row of its own,
    // and every curve on the others is the device's own arithmetic - so a dash
    // would distinguish nothing from nothing.
    dashed: true,
    // A break in this series is a stretch of dive with no decompression
    // obligation, not a sensor dropping out.
    gapsAreMeaningful: true,
  },
  temperature: {
    key: "temperature",
    label: "Temperature",
    unit: "°C",
    scale: 100, // hundredths of a degree
    decimals: 2,
    colorClass: "text-coral",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
  pressure: {
    key: "pressure",
    label: "Tank pressure",
    unit: "bar",
    scale: 10, // tenths of a bar
    decimals: 0,
    colorClass: "text-pressure",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
  heart_rate: {
    key: "heart_rate",
    label: "Heart rate",
    unit: "bpm",
    scale: 1, // whole beats per minute
    decimals: 0,
    colorClass: "text-heart-rate",
    inverted: false,
    dashed: false,
    // A dropout is a stretch the sensor lost contact, not a fact about the dive.
    gapsAreMeaningful: false,
  },
  // The six below are the device's own decompression arithmetic. None of them is
  // inverted (more is up, on all six), none is dashed - see the note on the
  // ceiling's dash - and a gap in any of them means "not recorded here", the
  // measured channels' meaning rather than the ceiling's.
  ndl: {
    key: "ndl",
    label: "No-deco time",
    unit: "min",
    // Seconds on the wire. See `scale` above for why this one is not the API's
    // own constant.
    scale: 60,
    decimals: 0,
    colorClass: "text-ndl",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
  tts: {
    key: "tts",
    label: "Time to surface",
    unit: "min",
    scale: 60,
    decimals: 0,
    colorClass: "text-tts",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
  ppo2: {
    key: "ppo2",
    label: "ppO₂",
    unit: "bar",
    scale: 100, // hundredths of a bar
    // Two, because tenths cannot tell 1.30 from 1.32 and the limits divers set
    // themselves live in that last digit.
    decimals: 2,
    colorClass: "text-ppo2",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
  cns: {
    key: "cns",
    label: "CNS",
    unit: "%",
    scale: 10, // tenths of a percent
    decimals: 1,
    colorClass: "text-cns",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
  gradient_factor: {
    key: "gradient_factor",
    // Says where it was read, beside the surface reading and the "GF 30/85"
    // settings the recordings card names.
    label: "Gradient factor at depth",
    unit: "%",
    scale: 1, // whole percent
    decimals: 0,
    colorClass: "text-gradient-factor",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
  surface_gradient_factor: {
    key: "surface_gradient_factor",
    label: "Surface gradient factor",
    unit: "%",
    scale: 1, // whole percent
    decimals: 0,
    colorClass: "text-surface-gradient-factor",
    inverted: false,
    dashed: false,
    gapsAreMeaningful: false,
  },
};

// The channels in the order they're plotted, listed in the legend and read out.
// That is the order the API's `PROFILE_CHANNEL_ORDER` sorts a profile's
// `channels` by and DiveJSON §6.4 declares its members in, but for the surface
// gradient factor ahead of the gradient factor. Nothing here reads the API's
// order, so the two can differ. A separate list rather than `Object.keys` on
// the record above, which gives an order only by accident of how the object
// happens to be written.
export const PROFILE_CHANNEL_KEYS: readonly ProfileChannelKey[] = [
  "depth",
  "ceiling",
  "temperature",
  "pressure",
  "heart_rate",
  "ndl",
  "tts",
  "ppo2",
  "cns",
  "surface_gradient_factor",
  "gradient_factor",
];

// Everything the legend switches, which is every channel plus the event
// markers - and the markers are emphatically *not* another channel. They have no
// scale, no unit and nothing to invert, so a `PROFILE_CHANNELS` entry would have
// had to invent all three; what they share with a channel is only that the
// legend names them and the diver can turn them off.
//
// A second list rather than a widened `ProfileChannelKey`, so that stays the type
// of "a thing with a domain" - which is what every axis, readout and segmenter in
// this module is written against, and none of them can say anything about a
// marker.
export type ProfileViewKey = ProfileChannelKey | "events";

export const PROFILE_VIEW_KEYS: readonly ProfileViewKey[] = [
  ...PROFILE_CHANNEL_KEYS,
  "events",
];

// What the legend calls the markers. No unit follows it, unlike every channel
// beside it - "Markers" is already the word the profile card's description and
// the chart's accessible summary use for them.
export const EVENTS_LABEL = "Markers";

// Which channels share one domain, and therefore one labelled scale. Depth and
// the ceiling are one axis by construction - the same meters, so a 3 m ceiling
// cannot be drawn below a 40 m depth - and the two gradient factors and the CNS
// clock are one because they are all whole percent.
//
// Named after the quantity rather than after the unit: `ppo2` and `pressure` are
// both bar and are emphatically **not** one axis, because 1.3 bar of oxygen on a
// 230-bar tank scale is a flat line along the baseline.
export type ProfileAxisKey =
  | "depth"
  | "temperature"
  | "pressure"
  | "heart_rate"
  | "no_deco"
  | "time_to_surface"
  | "ppo2"
  | "percent";

const CHANNEL_AXIS: Record<ProfileChannelKey, ProfileAxisKey> = {
  depth: "depth",
  ceiling: "depth",
  temperature: "temperature",
  pressure: "pressure",
  heart_rate: "heart_rate",
  // Minutes both, and still two rows: a no-deco limit runs to 99 while time to
  // surface is a few minutes on most dives, so on one scale it is a flat line.
  ndl: "no_deco",
  tts: "time_to_surface",
  ppo2: "ppo2",
  cns: "percent",
  gradient_factor: "percent",
  surface_gradient_factor: "percent",
};

// The axes that go into the panel underneath the depth plot, in the order their
// rows stack - every axis but the three the plot itself carries.
//
// **The split is forced, not stylistic.** The depth plot has two edges and holds
// at most three scales, and they are taken. So every other axis is housed where a
// labelled scale can be given it, and the panel is one row per axis so that every
// curve on the chart is drawn against numbers that belong to it.
//
// Heart rate first: it is the one measurement among them, and the rows below it
// are the computer's arithmetic.
export const PANEL_AXES: readonly ProfileAxisKey[] = [
  "heart_rate",
  "no_deco",
  "time_to_surface",
  "ppo2",
  "percent",
];

// The value an axis will not scale past however high a reading on it goes, or
// null where the readings are the only bound there is.
//
// Only the percent axis has one, and only because one of its channels can be
// **wrong**. A Suunto Ocean's `gf99` reaches 14 060 % on a deep decompression
// ascent - not a reading on some other scale, a fault: the same device's saved
// tissue tensions put the real figure near 60, and across a 79-dive corpus the
// inequality GF99 <= surface GF, which no decompression model can violate, holds
// on every sample of the 75 clean dives and fails on two samples in five of the
// four broken ones.
//
// Fitted to that reading the row runs to 15 000, and the surface gradient factor
// - the channel that is correct, and the whole readable story of the ascent - is
// drawn across 1.1 % of it. **One channel's fault erases another channel's
// data**, which is what the bound exists to stop.
//
// 200 rather than 100, and the number comes from the quantity rather than from
// this dive's readings. 100 % is the M-value, and a gradient factor past twice it
// is not telling a diver anything they can act on; below that every real reading
// still fits, since the surface gradient factor peaks at 121 across the corpus's
// 75 clean dives and at 173 across all 79. A ceiling of 100 would draw a healthy
// 121 leaving the panel on a dive where nothing was wrong, which is the same
// failure this fixes, one order of magnitude down.
//
// See DECISIONS.md, *"The percent axis stops at 200 %, and the curve that
// overruns it is drawn leaving the row"*, for the alternatives and why a
// percentile is not one of them.
const AXIS_BOUND: Record<ProfileAxisKey, number | null> = {
  depth: null,
  temperature: null,
  pressure: null,
  // A sensor reads a heart rate in a band an axis fits well, and cannot be wrong
  // by the orders of magnitude the percent bound exists for.
  heart_rate: null,
  no_deco: null,
  time_to_surface: null,
  ppo2: null,
  percent: 200,
};

/**
 * The domain one axis is drawn against, fitted to `values` up to whatever
 * ceiling that axis declares.
 *
 * Below the ceiling this is `niceDomain` and nothing else, so every dive whose
 * readings are in range is scaled exactly as it always was. Above it the axis is
 * the declared band, which is deliberately not derived from the readings at all:
 * a rule that moved with the data - a percentile, say - would draw the same
 * channel against a different scale on adjacent dives with nothing on screen to
 * say so. On the worst dive in the corpus the 99th percentile still yields 2 000
 * and the 95th yields 400.
 *
 * **No reading is altered by any of this.** The series, the crosshair readout,
 * the accessible summary and everything downstream of the API still carry the
 * figure the device wrote; what is bounded is the axis, and a curve that leaves
 * the band is drawn leaving it.
 *
 * Zero-anchored in the bounded branch, which is right for the one axis that has
 * a bound: DiveJSON §6.4 floors all three percent channels at zero, so there is
 * no reading below it for the band to cut off.
 */
export function axisDomain(
  axis: ProfileAxisKey,
  values: readonly number[],
): Domain {
  const fitted = niceDomain([...values]);
  const bound = AXIS_BOUND[axis];

  return bound === null || fitted.max <= bound
    ? fitted
    : niceDomain([0, bound]);
}

// The least a temperature axis spans, in degrees Celsius. Many sources state
// tenths of a degree, and fitted tight a dive whose water moved 0.4 °C draws each
// tenth as a quarter of the plot - a staircase that reads as a swing. Twenty
// tenths keeps one step to a twentieth of the height.
const MIN_TEMPERATURE_SPAN_C = 2;

/**
 * The temperature axis: `niceDomain` over the readings, widened about their
 * middle to at least `MIN_TEMPERATURE_SPAN_C` in the diver's units. A dive whose
 * readings span more is fitted exactly as `niceDomain` fits it.
 */
export function temperatureDomain(
  values: readonly number[],
  units: UnitSystem,
): Domain {
  if (values.length === 0) return niceDomain([]);

  const span =
    toChannelDisplay(MIN_TEMPERATURE_SPAN_C, "temperature", units) -
    toChannelDisplay(0, "temperature", units);
  const middle = (Math.min(...values) + Math.max(...values)) / 2;
  return niceDomain([...values, middle - span / 2, middle + span / 2]);
}

// What follows the number on a panel row's top tick - the separator and the unit
// both, so "100%" is written the way that quantity is. Read off the channels
// themselves rather than written out again, so a row cannot come to disagree with
// the curves in it.
//
// Minutes, beats per minute and bar get none. The legend already names a no-deco
// limit, a time to surface, a heart rate and a ppO2, and the widest label in the
// left gutter sets where the whole plot starts, so " min", " bpm" or " bar" would
// cost every row its width.
export function axisUnitSuffix(
  axis: ProfileAxisKey,
  units: UnitSystem,
): string {
  const key = PROFILE_CHANNEL_KEYS.find(
    (candidate) => CHANNEL_AXIS[candidate] === axis,
  );
  if (!key) return "";

  const { unit } = displayChannel(PROFILE_CHANNELS[key], units);
  return unit === "min" || unit === "bpm" || unit === "bar"
    ? ""
    : `${channelSeparator(key)}${unit}`;
}

/** The axis a channel is scaled against. */
export const channelAxis = (key: ProfileChannelKey): ProfileAxisKey =>
  CHANNEL_AXIS[key];

/** Which channels of a selection belong to one axis, in the legend's order. */
export function channelsOnAxis(
  shown: readonly ProfileChannelKey[],
  axis: ProfileAxisKey,
): ProfileChannelKey[] {
  return PROFILE_CHANNEL_KEYS.filter(
    (key) => CHANNEL_AXIS[key] === axis && shown.includes(key),
  );
}

/**
 * Where a selection's scales go: which channel labels the depth plot's left edge,
 * which labels its right, and which rows the panel under it grows.
 *
 * A pure function over channel keys rather than logic inside the component,
 * because the rule has one input per channel and the combination that breaks it
 * is never the obvious one - `lib/dive-profile.test.ts` sweeps every selection,
 * which no sequence of renders could afford to.
 *
 * **Sides are fixed while there are two scales to tell apart**: meters on the
 * left, temperature on the right, and pressure taking whichever of the two the
 * others left free. A diver reads this chart across a logbook of dives, and an
 * axis that changed edges with the channel mix would make them re-read the colour
 * of the numbers every time. A single scale goes on the left and the right edge
 * stays empty — there is no side to protect when nothing shares the plot with it.
 *
 * The panel rows are independent of all that: they are their own plots with their
 * own left edges, so nothing about the depth plot's edges changes when one
 * appears.
 */
export interface ProfileScalePlacement {
  /** Labels the depth plot's left edge; null only when nothing is plotted on it. */
  left: ProfileChannelKey | null;
  /** Labels its right edge, exactly when the plot holds a second scale. */
  right: ProfileChannelKey | null;
  /**
   * Labels the right edge too, a number under each of `right`'s, when the plot
   * holds a third scale - pressure under temperature, its domain fitted to
   * temperature's ticks.
   */
  underRight: ProfileChannelKey | null;
  /** One row per axis the selection puts below the depth plot, in `PANEL_AXES` order. */
  panels: ProfileAxisKey[];
}

export function profileScalePlacement(
  shown: readonly ProfileChannelKey[],
): ProfileScalePlacement {
  const has = (key: ProfileChannelKey) => shown.includes(key);

  // "Depth, or the ceiling if depth is off" - one scale, labelled in whichever
  // of the two is drawing it.
  const vertical: ProfileChannelKey | null = has("depth")
    ? "depth"
    : has("ceiling")
      ? "ceiling"
      : null;
  const temperature: ProfileChannelKey | null = has("temperature")
    ? "temperature"
    : null;
  const pressure: ProfileChannelKey | null = has("pressure")
    ? "pressure"
    : null;

  const scales = [vertical, temperature, pressure].filter(
    (key) => key !== null,
  );
  const hasTwoScales = scales.length > 1;

  return {
    left: hasTwoScales ? (vertical ?? pressure) : (scales[0] ?? null),
    // Reachable only with meters on the left, since two scales without
    // temperature means depth (or the ceiling) and pressure - so this can never
    // hand pressure to both edges at once.
    right: hasTwoScales ? (temperature ?? pressure) : null,
    underRight: scales.length === 3 ? pressure : null,
    panels: PANEL_AXES.filter((axis) =>
      shown.some((key) => CHANNEL_AXIS[key] === axis),
    ),
  };
}

export interface ChannelSeries {
  channel: ProfileChannel;
  // Elapsed milliseconds, as served. Kept as `t` rather than following the
  // wire's `times`: this is the chart's own shape, and every consumer of it below
  // reads a *converted* series, which is the difference worth keeping visible.
  t: number[];
  // Display units - the wire's integer `values` divided by the channel's scale.
  values: number[];
}

// What `lib/units.ts` supplies for a channel it has no dimension for: the two
// things a `Dimension` would have answered that the channel's own `unit` does
// not - how it is spoken aloud, and what sits between the value and the label.
interface InvariantUnit {
  /** Spoken form, for the accessible summary: "%" is read aloud as nothing. */
  word: string;
  /** What sits between value and label - "" where the unit attaches. */
  separator: string;
}

// Which of `lib/units.ts`'s dimensions each channel is a reading of - or, for a
// channel that reads the same number in both systems, what that module would
// have supplied if it had one.
//
// The ceiling is a **depth**, and sharing depth's dimension is what keeps the two
// converting identically - the same binding `scale` already has for the same
// reason. A shaded deco region drawn against a curve converted by any other factor
// would drift off the water it bounds.
//
// **Heart rate and the six deco channels have no dimension, and inventing one
// would have been the wrong half of the choice.** `lib/units.ts` names ppO₂, CNS
// and duration as deliberately absent from `Dimension` - a bar is a bar and a
// percent is a percent in both systems, and a minute is not a length - the
// gradient factors are percentages too, and a beat per minute reads the same in
// either system. Adding unit-invariant dimensions there would put
// identity conversions in the one module whose entire job is that a conversion
// exists, so the widening happens here instead, where the exception is local to
// the chart that has it.
const CHANNEL_DIMENSION: Record<ProfileChannelKey, Dimension | InvariantUnit> =
  {
    depth: "depth",
    ceiling: "depth",
    temperature: "temperature",
    pressure: "pressure",
    heart_rate: { word: "beats per minute", separator: " " },
    ndl: { word: "minutes", separator: " " },
    tts: { word: "minutes", separator: " " },
    ppo2: { word: "bar", separator: " " },
    // Attached, the way a degree is: "23.4%" is how a percentage is written
    // everywhere, including on the computers these readings come off.
    cns: { word: "percent", separator: "" },
    gradient_factor: { word: "percent", separator: "" },
    surface_gradient_factor: { word: "percent", separator: "" },
  };

// A `Dimension` is a string and the invariant spelling is an object, so `typeof`
// is the discriminant and each of the four readers below narrows on it directly.
// Deliberately not routed through a `dimensionOrNull` helper: three of the four
// want the *other* branch's value, so such a helper would be followed by a cast
// or a `!` at every call site to recover what it had just discarded.

/**
 * A channel as it is labelled and quoted for one system.
 *
 * The metric channel unchanged, or one carrying the imperial unit and no decimals
 * at all: a foot and a psi are each finer than the tenth of a metric unit the
 * stored scale resolves to, so a decimal there would be inventing precision
 * rather than preserving it. A degree Fahrenheit is coarser than a tenth of a
 * degree Celsius, and is quoted whole all the same, which is how a diver reads
 * one.
 *
 * A channel with no dimension is returned unchanged in **both** systems - there is
 * no imperial spelling of a percent, and dropping its decimals would round every
 * ppO₂ to a whole bar.
 */
export function displayChannel(
  channel: ProfileChannel,
  units: UnitSystem,
): ProfileChannel {
  const entry = CHANNEL_DIMENSION[channel.key];
  if (units === "metric" || typeof entry !== "string") return channel;

  return {
    ...channel,
    unit: unitLabel(entry, units),
    decimals: 0,
  };
}

/**
 * One reading, converted - **after** the wire scale has been divided out.
 *
 * That order is the whole rule: `scale` is a pair with `schemas/dive_profile.py`
 * and describes how the API encodes an integer, not how a diver reads one. Folding
 * a unit conversion into it would make this app's idea of a centimetre disagree
 * with the API's.
 *
 * A channel with no dimension passes through: the number a diver reads is the
 * same in either system.
 */
function toChannelDisplay(
  scaledValue: number,
  key: ProfileChannelKey,
  units: UnitSystem,
): number {
  const entry = CHANNEL_DIMENSION[key];
  return typeof entry === "string"
    ? toDisplayUnits(scaledValue, entry, units)
    : scaledValue;
}

/** The spoken unit for a channel, for the chart's accessible description. */
export function channelWord(key: ProfileChannelKey, units: UnitSystem): string {
  const entry = CHANNEL_DIMENSION[key];
  return typeof entry === "string" ? unitWord(entry, units) : entry.word;
}

/** What sits between a reading and its unit - see `DimensionSpec.separator`. */
function channelSeparator(key: ProfileChannelKey): string {
  const entry = CHANNEL_DIMENSION[key];
  return typeof entry === "string" ? unitSeparator(entry) : entry.separator;
}

// The profile as the chart draws it: every channel ends at `dive_end_time` and
// `duration` becomes that instant, so the axis, every scale fitted from the
// series, the crosshair and the accessible summary all stop where the dive did.
// A computer goes on recording at the surface for minutes after a dive, in case
// the diver descends again; those samples stay in the data and off the chart.
//
// A sample at the end is kept, so depth ends on the reading that closed the
// dive, and nothing is invented to reach the edge. Events are left alone: the
// chart clips its markers to `duration`. With no end in the response - null, or
// a body from a build that did not send one - the profile comes back whole.
export function cutAtDiveEnd(profile: DiveProfile): DiveProfile {
  const end = profile.dive_end_time;
  if (end == null || end >= profile.duration) return profile;

  const cut = <S extends DiveProfileSeries>(series: S): S => {
    const past = series.times.findIndex((time) => time > end);
    return past === -1
      ? series
      : {
          ...series,
          times: series.times.slice(0, past),
          values: series.values.slice(0, past),
        };
  };

  const visible: DiveProfile = {
    ...profile,
    duration: end,
    pressures: (profile.pressures ?? []).map(cut),
  };
  for (const key of PROFILE_SERIES_KEYS) {
    const series = profile[key];
    if (series) visible[key] = cut(series);
  }
  return visible;
}

// Where one recording sits on the time axis a dive's recordings share: its own 0
// at `start`, the axis running from 0 to `end` (`diveAxisEnd`). `start` is the
// API's `start_on_dive_axis`, which places every recording it can by the
// computers' clocks; the web never compares clocks itself.
export interface DiveAxis {
  start: number;
  end: number;
}

// The profile moved `start` milliseconds along the axis: every series' `times`,
// every event's `time`, `duration` and `dive_end_time`. Run after
// `cutAtDiveEnd`, so the cut is made on the recording's own clock, and the
// readings themselves are left alone - nothing derived from the spacing of the
// samples changes.
export function placeOnDiveAxis(
  profile: DiveProfile,
  start: number,
): DiveProfile {
  if (start === 0) return profile;

  const shift = <S extends DiveProfileSeries>(series: S): S => ({
    ...series,
    times: series.times.map((time) => time + start),
  });

  const placed: DiveProfile = {
    ...profile,
    duration: profile.duration + start,
    dive_end_time:
      profile.dive_end_time == null
        ? profile.dive_end_time
        : profile.dive_end_time + start,
    pressures: (profile.pressures ?? []).map(shift),
    events: profile.events?.map((event) => ({
      ...event,
      time: event.time + start,
    })),
  };
  for (const key of PROFILE_SERIES_KEYS) {
    const series = profile[key];
    if (series) placed[key] = shift(series);
  }
  return placed;
}

// Where the shared axis ends: the latest visible end - what `cutAtDiveEnd`
// leaves - over every placed recording whose samples are in hand. One extent
// for all of them, so switching between two placed recordings keeps the axis.
export function diveAxisEnd(
  placed: readonly { start: number; profile: DiveProfile }[],
): number {
  return Math.max(
    0,
    ...placed.map(
      ({ start, profile }) => start + cutAtDiveEnd(profile).duration,
    ),
  );
}

// A channel's stored integers as display units, or `null` when the profile
// doesn't carry that channel.
//
// Divides, never multiplies by a reciprocal: `1234 / 100` is the correctly
// rounded `12.34`, whereas `1234 * 0.01` is `12.340000000000002` - exactly the
// noise the integer encoding was chosen to remove.
// Every channel the API serves as a single series, which is all of them but tank
// pressure - that one is a list, one entry per cylinder, and has
// `toPressureSeries` below. Derived rather than spelled out, so a channel added
// to `ProfileChannelKey` is one this accepts without a second edit.
export type ProfileSeriesKey = Exclude<ProfileChannelKey, "pressure">;

const PROFILE_SERIES_KEYS = PROFILE_CHANNEL_KEYS.filter(
  (key): key is ProfileSeriesKey => key !== "pressure",
);

export function toChannelSeries(
  profile: DiveProfile,
  key: ProfileSeriesKey,
  units: UnitSystem,
): ChannelSeries | null {
  const recorded: DiveProfileSeries | null | undefined = profile[key];
  const after = SPURIOUS_ZERO_AFTER[key];
  const series =
    recorded && after !== undefined
      ? withoutSpuriousZeros(recorded, after)
      : recorded;
  if (!series || series.times.length === 0) return null;

  const channel = PROFILE_CHANNELS[key];
  const display = displayChannel(channel, units);
  // Converted here, once, rather than at each of the dozen places downstream that
  // read `values`. Everything past this point - the domains, the axis ticks, the
  // crosshair, the accessible extremes - is then already in the units it renders
  // in, so `niceDomain` picks round numbers in the system the diver is reading
  // and there is nowhere left for a conversion to be forgotten.
  return {
    channel:
      key === "temperature"
        ? {
            ...display,
            decimals: Math.min(
              display.decimals,
              grainDecimals(series.values, channel.scale),
            ),
          }
        : display,
    t: series.times,
    values: series.values.map((value) =>
      toChannelDisplay(value / channel.scale, key, units),
    ),
  };
}

// The channels a computer writes a zero into when it has stopped computing them
// - at the end of the dive, on a surface swim, or for a glitched sample - and the
// reading, in the wire's units, from which a fall to zero in one sample cannot
// be real. Neither quantity can drop that far that fast, so the zero is not a
// reading.
const SPURIOUS_ZERO_AFTER: Partial<Record<ProfileSeriesKey, number>> = {
  // The 100-minute cap a no-deco limit shows while it has nothing to count
  // down. A zero after it would plot as an obligation the diver never had.
  ndl: 100 * 60,
  // A surface gradient factor falls only as fast as the fastest tissue
  // off-gases, a few percent of itself in a sample. From 1 % a zero is a
  // reading rounding down; across the corpus nothing falls to zero from 2-5 %,
  // and everything from 6 % up is a device writing nothing.
  surface_gradient_factor: 5,
};

// A series without its spurious zeros: a run of zeros that starts straight after
// a reading of at least `after` is dropped whole, up to the next reading, while
// one reached by counting down - a minute, then none - is real and stays.
export function withoutSpuriousZeros(
  series: DiveProfileSeries,
  after: number,
): DiveProfileSeries {
  const kept: number[] = [];
  let spurious = false;
  series.values.forEach((value, index) => {
    if (value !== 0) spurious = false;
    else if (index > 0 && series.values[index - 1] >= after) spurious = true;
    if (!spurious) kept.push(index);
  });
  if (kept.length === series.values.length) return series;

  return {
    ...series,
    times: kept.map((index) => series.times[index]),
    values: kept.map((index) => series.values[index]),
  };
}

// The fewest decimals that state every one of a series' stored integers exactly
// once divided by `scale`, a power of ten: 2 for hundredths with any odd
// hundredth among them, 1 where every reading is a whole tenth, 0 where every
// one is a whole unit. A source coarser than the format's grain arrives with the
// grain filled by zeros, and this is how the readout tells those apart from
// digits a sensor measured.
export function grainDecimals(
  values: readonly number[],
  scale: number,
): number {
  let decimals = 0;
  for (let step = scale; step > 1; step /= 10, decimals += 1) {
    if (values.every((value) => value % step === 0)) return decimals;
  }
  return decimals;
}

// Same, per cylinder. Each cylinder is its own line on the chart, keyed by its
// `gas_number` and named through `cylinderName`.
export function toPressureSeries(
  profile: DiveProfile,
  units: UnitSystem,
): (ChannelSeries & { gasNumber: number })[] {
  const channel = PROFILE_CHANNELS.pressure;
  return (profile.pressures ?? [])
    .filter((cylinder) => cylinder.times.length > 0)
    .map((cylinder: DiveProfilePressureSeries) => ({
      channel: displayChannel(channel, units),
      gasNumber: cylinder.gas_number,
      t: cylinder.times,
      values: cylinder.values.map((value) =>
        toChannelDisplay(value / channel.scale, "pressure", units),
      ),
    }));
}

// The dive's cylinders as far as naming one goes: only their labels, in the
// order the Tanks card lists them.
export type CylinderLabels = readonly Pick<DiveMixture, "gas_number">[];

// What to call the cylinder a pressure series or a gas switch names by its
// label: the tank's position in the Tanks card's own 1-based numbering where
// exactly one of the dive's cylinders carries that label, and otherwise the
// device's label with the word kept - "gas 3", never a bare 3 that reads as a
// row of the card. That is the rule `tankGasUseRows` names a tank by, including
// a label two cylinders share joining neither. The label itself counts from 0,
// so showing it bare would name the card's next row.
export function cylinderName(
  gasNumber: number,
  cylinders: CylinderLabels,
): string {
  const positions = cylinders.flatMap((cylinder, index) =>
    cylinder.gas_number === gasNumber ? [index] : [],
  );
  return positions.length === 1
    ? `tank ${positions[0] + 1}`
    : `gas ${gasNumber}`;
}

// The one vertical axis depth and the deco ceiling are both scaled against.
//
// One domain across both, exactly as every cylinder shares one pressure domain
// and for a sharper version of the same reason: a ceiling is a bound *on* the
// depth curve, so on an axis of its own a 3 m ceiling could be drawn below a
// 40 m depth. Anchored at the surface by feeding `niceDomain` the `0` that makes
// `Math.floor(0 / step) * step` land on it.
//
// Both channels' values go in whether or not they're currently plotted, so the
// axis doesn't shift under the curves when the ceiling is toggled. On real data
// that costs nothing - a ceiling is always shallower than the depth it was
// computed at - but a domain that depends on what's visible is a needless way
// for the picture to move.
export function depthDomain(
  depthValues: readonly number[],
  ceilingValues: readonly number[],
): Domain {
  return niceDomain([0, ...depthValues, ...ceilingValues]);
}

// The event nearest the instant `at`, or null when the closest is further away
// than `maxDeltaMs`.
//
// A tolerance rather than a nearest-always, because unlike a channel readout an
// event marker is a discrete thing at a discrete instant: naming the dive's only
// gas switch while the cursor sits twenty minutes away from it would be a
// caption for something that isn't under the crosshair. The caller sets the
// tolerance from the chart's own geometry, so it stays a fixed distance in
// pixels rather than a fixed span of time - a tolerance that reads well on a
// 20-minute dive is invisible on a three-hour one.
//
// A linear scan over a list the API caps at 200, and deliberately not a binary
// search: the saving is unmeasurable and the scan doesn't care whether the list
// arrived sorted.
//
// **Ties go to the earlier event**, and that is compared on `time` rather than left
// to iteration order. `delta < bestDelta` alone would mean "first in the array",
// which is only the same thing on a sorted list - and not assuming sorted input
// is the whole reason this is a scan. The API does sort in `shape_events`, so this
// decides nothing today; a comment claiming one rule while the code follows
// another is the part that would eventually cost someone an afternoon.
export function nearestEvent(
  events: readonly DiveProfileEvent[],
  at: number,
  maxDeltaMs: number,
): DiveProfileEvent | null {
  let best: DiveProfileEvent | null = null;
  let bestDelta = Number.POSITIVE_INFINITY;

  for (const event of events) {
    const delta = Math.abs(event.time - at);
    if (delta > maxDeltaMs) continue;

    if (
      best === null ||
      delta < bestDelta ||
      (delta === bestDelta && event.time < best.time)
    ) {
      best = event;
      bestDelta = delta;
    }
  }

  return best;
}

// How far into the recording a gas switch is the computer logging the gas the
// dive starts on rather than a switch. Every first switch in the local corpus
// sits inside the opening 10 s, and every later one minutes in.
const STARTING_GAS_MS = 30 * MILLISECONDS_PER_SECOND;

// What a marker says, in words - for the crosshair readout and for the chart's
// accessible summary.
//
// An event with **no type** is the device's own wording and is passed through as
// it stands: that is the entire point of the absent type, and rephrasing
// "Mandatory Safety Stop Broken" into something tidier would be inventing a
// claim about a dive. The API guarantees a label wherever it sends no type (its
// `shape_events` drops one without), so the fallback noun here is only for
// a payload that broke that promise - an unlabelled tick with no words is still
// better than the string "undefined".
//
// **One value per distinct meaning, not one per vendor string**, which is why
// there is no case for "Mandatory Safety Stop Broken" beside
// `safety_stop_violation`: the spelling travels in `label` and the chart says
// what happened.
//
// The `default` is not dead code, however much the exhaustive `case` list makes
// it look like one. `ProfileEventType` is closed *today*, and the two repos
// deploy independently: an API that grows a fourteenth type reaches a browser
// still running this bundle, where `event.type` is a string TypeScript merely
// believes is one of thirteen. Without the branch the switch falls off the end
// and returns `undefined` from a function typed `: string`, which renders as an
// empty tooltip line and puts the literal word "undefined" in the chart's
// `aria-label`. It is also where a null type lands, and where an older build's
// `"other"` lands - three arrivals, one honest answer, and deliberately one
// `return` rather than three copies of it.
//
// `recordingStart` is where the recording's own 0 lies on the axis `event.time`
// is on (see `placeOnDiveAxis`): "the dive's start" is the recording's, so a
// second computer placed 1:34 into the dive still opens on "Dive started".
export function describeEvent(
  event: DiveProfileEvent,
  cylinders: CylinderLabels = [],
  recordingStart = 0,
): string {
  switch (event.type) {
    case "gas_switch":
      if (event.time - recordingStart <= STARTING_GAS_MS) return "Dive started";
      // Named as the pressure curves name the cylinder - and `0` is a real
      // label, the first the reader gives, so this tests for null rather than
      // for falsiness.
      return event.gas_number == null
        ? "Gas switch"
        : `Gas switch to ${cylinderName(event.gas_number, cylinders)}`;
    case "deep_stop":
      return "Deep stop";
    case "safety_stop":
      return "Safety stop";
    case "bookmark":
      return "Bookmark";
    case "ascent_rate":
      return "Ascent rate";
    case "safety_stop_mandatory":
      return "Mandatory safety stop";
    case "safety_stop_violation":
      return "Safety stop broken";
    case "deep_stop_violation":
      return "Deep stop broken";
    case "ceiling_violation":
      return "Deco ceiling broken";
    case "ndl_reached":
      return "No-deco limit reached";
    case "ppo2_high":
      return "ppO₂ high";
    case "pressure_low":
      return "Tank pressure low";
    case "depth_alarm":
      return "Depth alarm";
    default:
      return event.label?.trim() || "Device event";
  }
}

// How far apart two samples have to be before the line between them is a lie,
// derived from the series' own cadence rather than fixed.
//
// It has to be derived: cadence varies from 1 s (Suunto Ocean temperature) to
// 10 s (every Suunto depth series), and server-side downsampling stretches it
// further and unevenly. A fixed threshold would either break every downsampled
// line into confetti or draw straight through a real ten-minute dropout. Three
// times the median delta is comfortably above normal jitter and comfortably
// below any dropout worth showing; the floor keeps a fast, regular series from
// breaking on the jitter in its own stamps.
const GAP_FACTOR = 3;
export const MIN_GAP_MS = 15 * MILLISECONDS_PER_SECOND;

export function gapThreshold(t: number[]): number {
  if (t.length < 3) return Number.POSITIVE_INFINITY;

  const deltas = t.slice(1).map((time, index) => time - t[index]);
  deltas.sort((a, b) => a - b);
  const median = deltas[Math.floor(deltas.length / 2)];

  return Math.max(MIN_GAP_MS, median * GAP_FACTOR);
}

// The same threshold, made safe to compare a *distance* against - which is what
// the crosshair does and the segmenter doesn't.
//
// `gapThreshold` answers `Infinity` for a series of one or two samples, and for
// segmenting that is exactly right: there is no cadence to derive, and no gap
// that could be judged a dropout, so the honest answer is "never break this".
// Fed to `sampleIndexAt` the same value means "no distance is too far", which
// silently turns the guard back into the clamping `nearestSampleIndex` it exists
// to replace.
//
// **That is not a hypothetical shape for the deco ceiling.** The API drops zero
// ceilings, so the channel carries samples only while an obligation existed - a
// dive that tips into deco for one or two 10-second samples produces a two-point
// series and nothing else. With an infinite tolerance the crosshair then reports
// that ceiling at *every instant of the dive*, which is precisely the invented
// obligation this guard was written to prevent, arriving on the shortest and
// least expected obligations rather than the long obvious ones. The one-sample
// case is worse still: `segmentByTimeGap` yields a single-point run that draws
// nothing, so the chart shows no ceiling at all while the tooltip insists on one.
//
// The floor rather than the median, because a series this short has no median
// worth having. It errs toward refusing: on two samples 70 s apart the line is
// drawn across the whole span while the readout only answers within 15 s of
// either end. Silence where a curve exists is a cosmetic loss; a number where no
// obligation existed is not.
//
// **Named for the readout, but on a channel whose gaps are meaningful it is the
// segmentation threshold too** - see `gapsAreMeaningful` and the `runs()` helper
// in `dive-profile-chart.tsx`. That is the point rather than an overload: on the
// ceiling the line and the crosshair have to be cut at one number, and this is
// the one that is finite. Anyone looking for "the threshold the line was cut at"
// on the ceiling should stop here rather than re-deriving it.
export function readoutTolerance(t: number[]): number {
  const threshold = gapThreshold(t);

  return Number.isFinite(threshold) ? threshold : MIN_GAP_MS;
}

// The index of the sample nearest the instant `at`, by binary search, clamped at
// both ends.
//
// Always a real sample, never an interpolated one: the crosshair readout says
// "21.6 °C at 12:30", and a value the sensor never recorded has no business
// being presented as one. Called once per channel, since the channels don't
// share a time axis.
export function nearestSampleIndex(t: number[], at: number): number {
  if (t.length === 0) return -1;
  if (at <= t[0]) return 0;
  if (at >= t[t.length - 1]) return t.length - 1;

  let low = 0;
  let high = t.length - 1;
  while (high - low > 1) {
    const middle = (low + high) >> 1;
    if (t[middle] <= at) {
      low = middle;
    } else {
      high = middle;
    }
  }

  // `low` and `high` now bracket `at`; pick whichever is closer, ties going to
  // the earlier sample.
  return at - t[low] <= t[high] - at ? low : high;
}

// The sample that can honestly be called this channel's reading at the instant
// `at`, or -1 when there isn't one.
//
// `nearestSampleIndex` clamps at both ends, which is right for finding a
// neighbour and wrong for captioning one: it answers "the closest sample" even
// when the closest sample is twenty minutes away. The chart already refuses to
// *draw* a line across a stretch a channel didn't record (`segmentByTimeGap`),
// and the crosshair has to refuse to quote a number there for the same reason -
// no line, no dot, no readout.
//
// The deco ceiling is what makes this unmistakable rather than merely untidy. A
// gap in that channel means the dive owed no decompression, so a clamped reading
// before the first sample reports a 3.0 m ceiling five minutes into a dive that
// was still well inside no-deco limits - inventing an obligation, on the one
// curve where that is a safety claim rather than a cosmetic slip. The same
// clamp was already quoting tank pressure through a transmitter dropout; that
// was a lie too, just a quieter one.
//
// `maxDeltaMs` is the channel's own `readoutTolerance` - deliberately not
// `gapThreshold`, which is the number this looks like it should take and is the
// wrong one: its `Infinity` below three samples reads here as "no distance is
// too far to quote". The caller passes the same value it cut the line at, so the
// two cannot disagree about the same stretch of dive.
//
// Being close enough is necessary and not sufficient. A sample can clear this
// and still have been dropped from the picture for sitting in a run too short to
// draw - which is what `drawnSampleIndexAt` below exists to handle, and what
// every caller in the chart actually wants.
export function sampleIndexAt(
  t: number[],
  at: number,
  maxDeltaMs: number,
): number {
  const index = nearestSampleIndex(t, at);
  if (index < 0) return -1;

  return Math.abs(t[index] - at) <= maxDeltaMs ? index : -1;
}

// The nearest sample that is close enough to quote **and** made it onto the
// chart, or -1.
//
// Not `sampleIndexAt` followed by a membership test, which fails in one specific
// way: the nearest sample overall may be one the chart dropped, and rejecting it
// outright then reports nothing even though a drawn sample sits just behind it,
// well inside the tolerance. On samples at 1000, 1010, 1020 and 1060 s with a
// 30 s tolerance and the last dropped as an isolated run, hovering at 1045 s
// resolves to 1060, fails the membership test, and goes silent - with 1020 only
// 25 s away and visibly drawn.
//
// So the search walks outward from the nearest sample and takes the first drawn
// one, stopping as soon as both frontiers are past the tolerance. That makes the
// rule sayable in one line - *the nearest drawn sample within tolerance* - rather
// than as two rules whose interaction has to be reasoned about.
export function drawnSampleIndexAt(
  t: number[],
  at: number,
  maxDeltaMs: number,
  drawn: ReadonlySet<number>,
): number {
  const nearest = nearestSampleIndex(t, at);
  if (nearest < 0) return -1;

  let low = nearest;
  let high = nearest + 1;

  while (low >= 0 || high < t.length) {
    const lowDelta =
      low >= 0 ? Math.abs(t[low] - at) : Number.POSITIVE_INFINITY;
    const highDelta =
      high < t.length ? Math.abs(t[high] - at) : Number.POSITIVE_INFINITY;

    // Both frontiers are out of reach, and they only get further away.
    if (Math.min(lowDelta, highDelta) > maxDeltaMs) return -1;

    // Ties to the earlier sample, matching `nearestSampleIndex`.
    if (lowDelta <= highDelta) {
      if (drawn.has(low)) return low;
      low--;
    } else {
      if (drawn.has(high)) return high;
      high++;
    }
  }

  return -1;
}

// Candidate x-axis steps, on the millisecond axis. Clock-shaped throughout:
// `axisTicks` would happily hand back a 250-second step, and nobody reads a dive
// profile in units of 4 minutes 10 seconds. The seconds are for a plot zoomed in
// to a minute or two.
const ELAPSED_STEPS_MS = [
  ...[10, 15, 30].map((seconds) => seconds * MILLISECONDS_PER_SECOND),
  ...[1, 2, 5, 10, 15, 30, 60, 120].map(
    (minutes) => minutes * 60 * MILLISECONDS_PER_SECOND,
  ),
];

// How many elapsed-time steps the axis aims for at the chart's design width.
export const ELAPSED_TICK_TARGET = 6;

// The elapsed-time gridlines for the stretch of a profile from `startMs` to
// `endMs`, in milliseconds: whole multiples of the step, never outside it.
export function elapsedTicks(
  startMs: number,
  endMs: number,
  targetTicks = ELAPSED_TICK_TARGET,
): number[] {
  const span = endMs - startMs;
  if (span <= 0) return [startMs];

  let step =
    ELAPSED_STEPS_MS.find((candidate) => span / candidate <= targetTicks) ??
    ELAPSED_STEPS_MS[ELAPSED_STEPS_MS.length - 1];
  // Past the last candidate, a recording long enough to outrun it - or a phone
  // asking for fewer ticks - keeps doubling it, so the target still holds.
  while (span / step > targetTicks) step *= 2;

  const ticks: number[] = [];
  for (
    let time = Math.ceil(startMs / step) * step;
    time <= endMs;
    time += step
  ) {
    ticks.push(time);
  }
  return ticks;
}

// Where the crosshair's readout card sits vertically, given the topmost of the
// dots it describes. Returns a y in the chart's own coordinate space plus the
// CSS `translateY` that pins the card to it.
//
// Deliberately anchored to the *plot's* top or bottom edge rather than to the
// hovered point, which is what an earlier version did and got wrong. Two
// reasons, and the second is the bug:
//
//   - With a full-height crosshair, "the hovered point" is three different
//     points (one per channel), so picking one to hang the card off was already
//     arbitrary - and it made the card jump between channels while scrubbing.
//   - Offsetting from a point can only be done safely if you know how tall the
//     card is, and here you don't: its height depends on how many channels the
//     dive recorded, and the SVG scales to its container while the card's text
//     does not. A fixed "flip above the point when it's in the top third" rule
//     put the card 11 px past the top edge of the chart, over whatever sits
//     above it. Anchoring to an edge is correct for *any* card height and any
//     scale.
//
// The card moves to the bottom when the topmost dot is high in the plot, so it
// doesn't cover the readings it is describing.
const TOOLTIP_FLIP_FRACTION = 0.45;

export function tooltipVerticalAnchor(
  topmostY: number,
  plotTop: number,
  plotBottom: number,
): { y: number; translateY: string } {
  const height = plotBottom - plotTop;
  const fraction = height > 0 ? (topmostY - plotTop) / height : 0;

  return fraction < TOOLTIP_FLIP_FRACTION
    ? // Card's bottom edge on the plot's bottom edge, nudged clear of the axis.
      { y: plotBottom, translateY: "calc(-100% - 4px)" }
    : // Card's top edge on the plot's top edge.
      { y: plotTop, translateY: "4px" };
}

// How far the readout card stands off the crosshair when it sits beside it, in
// CSS pixels.
export const TOOLTIP_GAP_PX = 12;

/**
 * Which side of the crosshair the readout card sits on, so it never covers the
 * line and the dots it is reading: the right where it fits inside the frame,
 * the left where only that fits, and null where neither does and the card has
 * to sit over the line. All three in CSS pixels, the crosshair measured from
 * the frame's left edge.
 */
export function tooltipSide(
  crosshairPx: number,
  cardPx: number,
  framePx: number,
): "right" | "left" | null {
  if (crosshairPx + TOOLTIP_GAP_PX + cardPx <= framePx) return "right";
  if (crosshairPx - TOOLTIP_GAP_PX - cardPx >= 0) return "left";
  return null;
}

// A reading formatted for a tooltip or an axis label, at the channel's own
// resolution.
export function formatChannelValue(
  value: number,
  channel: ProfileChannel,
): string {
  // `value` is already in the channel's own units - the series were converted when
  // they were built, so there is nothing to convert here and converting again would
  // double it. The separator comes from the same table the label does, which is
  // what makes a temperature read "21.6°C" here exactly as it does in the sidebar.
  return `${value.toFixed(channel.decimals)}${channelSeparator(channel.key)}${channel.unit}`;
}