Ctrl K
ComunidadGuías…

src/components/dives/dive-profile-chart.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import {
  Fragment,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { Maximize, Minimize, SlidersHorizontal, ZoomOut } from "lucide-react";
import type {
  DiveProfile,
  DiveProfileEvent,
  DiveProfileEventType,
} from "@/lib/api/dives";
import {
  alignedDomain,
  axisTicks,
  labelCapacity,
  labelWidth,
  niceDomain,
  stepCount,
  type Domain,
} from "@/lib/chart-scale";
import { buildAreaPath, segmentByTimeGap } from "@/lib/chart-path";
import {
  type ChannelSeries,
  type ProfileAxisKey,
  type ProfileChannel,
  type ProfileChannelKey,
  type ProfileSeriesKey,
  type ProfileViewKey,
  ELAPSED_TICK_TARGET,
  EVENTS_LABEL,
  PANEL_AXES,
  PROFILE_CHANNELS,
  PROFILE_CHANNEL_KEYS,
  PROFILE_VIEW_KEYS,
  axisDomain,
  axisUnitSuffix,
  channelAxis,
  channelWord,
  channelsOnAxis,
  cylinderName,
  type CylinderLabels,
  depthDomain,
  displayChannel,
  describeEvent,
  elapsedTicks,
  formatChannelValue,
  formatElapsed,
  formatElapsedSpoken,
  cutAtDiveEnd,
  type DiveAxis,
  drawnSampleIndexAt,
  gapThreshold,
  nearestEvent,
  placeOnDiveAxis,
  profileScalePlacement,
  readoutTolerance,
  temperatureDomain,
  toChannelSeries,
  toPressureSeries,
  tooltipSide,
  tooltipVerticalAnchor,
  TOOLTIP_GAP_PX,
} from "@/lib/dive-profile";
import {
  DIVE_PROFILE_SERIES_KEY,
  parseSeriesVisibility,
  readStoredSeries,
  subscribeToNothing,
  toggleSeries,
  writeSeriesVisibility,
} from "@/lib/chart-series-view";
import { cn } from "@/lib/utils";
import { CHART_HOVER_CARD } from "@/components/charts/chart-hover-card";
import { Button } from "@/components/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { IconTooltip } from "@/components/ui/tooltip";
import { DiveProfileFullscreen } from "@/components/dives/dive-profile-fullscreen";
import { type ChartReadout, useChartReadout } from "@/hooks/useChartReadout";
import { useChartWidth } from "@/hooks/useChartWidth";
import { useChartZoom } from "@/hooks/useChartZoom";
import { type ElementSize, useElementSize } from "@/hooks/useElementSize";
import { useFullscreen } from "@/hooks/useFullscreen";
import { useUnits } from "@/hooks/useUnits";
import type { UnitSystem } from "@/lib/units";

// Hand-rolled SVG rather than a charting library. The app ships a strict
// nonce-based CSP (`src/proxy.ts`): inline style *attributes* are allowed, but a
// library that injects a `<style>` element - which the emotion/styled-components
// based ones do - would work in dev and break in production, which is a
// miserable bug to acquire for one chart. Stated here in full rather than
// cross-referenced, because this comment is what stops the next person reaching
// for Recharts.
//
// One colour per channel, every one a theme-stable token declared once in
// `globals.css` and never redeclared under `.dark` (see the note on `--pressure`
// there). `--primary` would not do: it is near-black in light mode and a mid
// grey in dark, which is barely visible against a dark card.

// The viewBox coordinate space. Not pixels: the SVG scales to its container, so
// these are only ever ratios to each other. This is the design width; a phone
// draws into a narrower one (see `useChartWidth`), and the depth plot's height
// follows it down - see `plotHeightFor`.
const WIDTH = 720;
const PADDING = { top: 14, bottom: 28 };
// Between an edge's numbers and the plot. Each edge's gutter is as wide as its
// widest number plus this, and no wider - none at all on an edge with no scale -
// so the plot fills the card out to the card's own padding. A label or a dot at
// either end overhangs into that padding, which is why the SVG is
// `overflow-visible`.
const AXIS_LABEL_GAP = 6;

const PLOT_HEIGHT = 238;
// The shortest the depth plot gets on a phone: enough for five labelled depth
// gridlines in 11-unit type with air between them.
const MIN_PLOT_HEIGHT = 150;

// The depth plot's height for a viewBox `width` wide. Held at the design height
// would make a phone's chart nearly as tall as it is wide, a screenful of plot
// before the cards below it; scaled with the width, it keeps the desktop's
// proportions until the floor.
const plotHeightFor = (width: number) =>
  Math.max(MIN_PLOT_HEIGHT, Math.round((PLOT_HEIGHT * width) / WIDTH));

// The room each elapsed-time label gets on the x axis: "999:00" is 37.5 units
// wide in 11-unit Inter, plus air. At the design width the axis is never short of
// it, so this only thins the labels on a phone.
const ELAPSED_LABEL_SPACING = 44;

// How far above and below its height each of two stacked edge numbers sits:
// 11-unit type is about 8 units of ink, so 12 between centres leaves air.
const STACKED_LABEL_OFFSET = 6;

// The panel: one short plot per scale the depth plot's two edges cannot
// carry, stacked under it and sharing its elapsed-time axis. See
// `profileScalePlacement` for why the split is forced rather than chosen.
//
// Each row is its own plot with its own left-hand scale, so the depth plot's
// edges are untouched by anything the diver switches on down here, and every
// curve on the chart is drawn against numbers that belong to it.
const PANEL_HEIGHT = 46;
// Its two numbers a size under the depth plot's, as quieter scales.
const PANEL_LABEL_SIZE = 10;
// The labels either side of a gap each sit centred on their own rule, so the gap
// has to hold half of each: 11- and 10-unit type is about 1.2 em tall, 12.6 units
// between them, and 16 leaves a few units of air.
const PANEL_GAP = 16;
// A channel line's stroke. The panel clip reaches half of it past each rule, so
// a reading sitting exactly on a row's top or bottom value keeps its full width.
const LINE_WIDTH = 1.5;

// The vertical layout for a depth plot `plotHeight` tall: where it ends, where
// each panel row starts, and where the drawing and the viewBox stop. The
// last two grow with the panel count - with no panel the viewBox is the depth
// plot and its padding.
function verticalLayout(plotHeight: number) {
  const plotBottom = PADDING.top + plotHeight;
  const panelTop = (index: number) =>
    plotBottom + PANEL_GAP + index * (PANEL_HEIGHT + PANEL_GAP);
  const chartBottom = (panelCount: number) =>
    panelCount === 0 ? plotBottom : panelTop(panelCount - 1) + PANEL_HEIGHT;
  return {
    plotHeight,
    plotBottom,
    panelTop,
    chartBottom,
    chartHeight: (panelCount: number) =>
      chartBottom(panelCount) + PADDING.bottom,
  };
}

// The depth plot's height in full screen, where the box is fixed and the plot
// takes whatever the padding and the panel rows leave of it. `box` is in CSS
// pixels and the viewBox `width` units span its width. Held at the phone floor,
// below which the chart is letterboxed into the box instead.
function fullscreenPlotHeight(
  box: ElementSize,
  width: number,
  panelCount: number,
) {
  const boxHeight = (box.height * width) / box.width;
  return Math.max(
    MIN_PLOT_HEIGHT,
    Math.floor(boxHeight - verticalLayout(0).chartHeight(panelCount)),
  );
}

// Display value -> y, in one rect. The `inverted` flag is depth's: it grows
// downward from the surface, so its axis is upside down relative to every other
// scale on the chart.
const scaleY =
  (domain: Domain, top: number, height: number, inverted: boolean) =>
  (value: number) => {
    const fraction = (value - domain.min) / (domain.max - domain.min);
    return top + (inverted ? fraction : 1 - fraction) * height;
  };

// How close the crosshair has to be to an event marker before the readout names
// it, in viewBox units - converted to the axis's milliseconds per dive, so it
// stays the same distance on screen whether the dive lasted 20 minutes or three
// hours.
// Comfortably wider than the glyph itself (4 units), because the diver is
// aiming at a marker with a crosshair that has no snap.
const EVENT_HOVER_UNITS = 8;

// The marker's own geometry: a tick standing on the x-axis with a glyph on top
// of it. Anchored to the axis rather than to the depth curve, which is where a
// dive computer's own display puts them and the only place that still works with
// depth toggled off.
const EVENT_TICK_HEIGHT = 9;
const EVENT_GLYPH_RADIUS = 4;
// Between the top of the tick and the centre of the glyph, so the two read as
// one mark rather than as a shape resting on a line.
const EVENT_GLYPH_GAP = 2;

// The shortest stretch of a dive the plot zooms in to, in milliseconds.
const MIN_VIEW_MS = 60_000;

// How near the crosshair, in CSS px, a finger on a zoomed plot has to land to
// drag the crosshair rather than the plot: about a fingertip either side.
const CROSSHAIR_GRAB_PX = 24;

// The plot's hit target, from the `<svg>` it is in. Found from the element under
// the finger rather than held in a ref, because the plot is drawn twice for a
// moment as full screen closes, and a shared ref ends on the copy that went.
const plotBox = (svg: Element) =>
  svg.querySelector("[data-hit]")?.getBoundingClientRect() ?? null;

// How many markers the chart's accessible summary names before it starts
// counting - see `describeProfile`.
const MAX_DESCRIBED_EVENTS = 8;

export interface DiveProfileChartProps {
  profile: DiveProfile;
  // The dive's cylinders, which a pressure curve and a gas switch are named by -
  // see `cylinderName`. Absent, every label is named as one no cylinder carries.
  mixtures?: CylinderLabels;
  // Where the channel menu and the full-screen button go: a slot on the card's
  // title row, which the card owns while the selection stays here with
  // everything derived from it. Null until that slot has mounted, and both
  // controls wait for it.
  menuContainer: HTMLElement | null;
  // The dive's other recordings, whose readings widen every vertical axis this
  // chart draws, so switching computers keeps one grid and the difference
  // between them is in the curves rather than in the numbers along the edge.
  // Every one of them, placed on the time axis or not.
  scaleWith?: readonly DiveProfile[];
  // Where this recording sits on the time axis the dive's recordings share - see
  // `DiveAxis`. Absent, it is drawn on its own axis, from its own 0 to its own
  // end.
  axis?: DiveAxis | null;
}

const NO_CYLINDERS: CylinderLabels = [];
const NO_PEERS: readonly DiveProfile[] = [];

interface PlottedChannel {
  key: string;
  // Which of the legend's toggles this line belongs to. Not the same as `key`:
  // two cylinders are two lines and one channel.
  channelKey: ProfileChannelKey;
  // Which scale it is drawn against, which also decides *where* it is drawn -
  // the depth plot for the first three, a row of the panel for the rest.
  axis: ProfileAxisKey;
  label: string;
  series: ChannelSeries;
  // The domain this line is scaled against, kept alongside it so the axis labels
  // and gridlines can read the same one the curve was drawn with rather than
  // recomputing one and hoping it matches. It wouldn't, for pressure: every
  // cylinder shares one domain across all of them.
  //
  // **Null on a panel channel, where it cannot be known yet**: a panel row's scale
  // is taken over the channels on that row that are *shown*, and nothing here has
  // seen the selection. See the positioning step for why that differs from
  // `depthDomain`'s rule.
  domain: Domain | null;
  // Runs of consecutive samples, as index arrays - never one polyline across a
  // sensor dropout, and never a run too short to draw.
  segments: number[][];
  // The threshold those runs were cut at, kept so the crosshair can refuse to
  // quote a reading from a stretch the line refuses to cross - see
  // `drawnSampleIndexAt`.
  gapMs: number;
  // Which sample indices actually reached the picture.
  //
  // The tolerance alone is not enough to keep the readout honest, which is the
  // second time this invariant has had to be tightened. `gapMs` says "is
  // there a sample near enough to quote", and a sample dropped for being an
  // undrawable run of one is near enough to itself - so the crosshair went on
  // naming a 3.0 m ceiling, with a red dot on it, over a chart that had drawn no
  // ceiling at all. Membership here is the question that cannot be answered
  // "yes" by a sample nothing was drawn from.
  drawn: Set<number>;
}

// The same channel once the selection is known, and therefore once there is a
// scale and a rect to map it into. Neither `y` nor a concrete `domain` can sit on
// `PlottedChannel`: a panel channel's row, and the numbers down the side of it,
// both depend on which *other* panel channels are switched on, and `channels` is
// built before any of that is decided.
type PositionedChannel = Omit<PlottedChannel, "domain"> & {
  domain: Domain;
  y: (value: number) => number;
  // Which row of the panel this line is drawn in, or -1 for the depth plot.
  // Kept rather than recomputed at each of the three places that need it, one of
  // which is the clip that holds a curve inside its row - see `panelClip`.
  panelIndex: number;
};

// Module-level so the reference is stable across renders - see
// `subscribeToNothing`. `useSyncExternalStore` re-reads whenever this identity
// changes, which an inline arrow would make every render.
const readStoredChannels = () => readStoredSeries(DIVE_PROFILE_SERIES_KEY);

// A channel's readings that reached the picture, in display units.
//
// Everything that reports a channel's *extremes* - the shared vertical axis and
// the accessible summary - has to read these rather than `series.values`. A
// sample dropped for sitting in an undrawable run is still in the raw array, and
// both of those consumers take a max over it: on a ceiling of
// `t = [600, 610, 620, 2000]` where only the first run draws, the summary
// announced "deco ceiling to 9.0 meters" over a chart whose deepest drawn
// ceiling was 3.0 m, and the axis stretched to fit a curve that isn't there.
//
// The same disagreement `drawn` and the channel filter already close for the
// crosshair and the legend, at a granularity neither of them reached: those
// two ask "is this channel on the chart", and this asks "is this *sample*".
function drawnValues(
  series: ChannelSeries | null,
  drawn: ReadonlySet<number> | undefined,
): number[] {
  if (!series || !drawn) return [];

  return [...drawn].map((index) => series.values[index]);
}

// The two things every channel derives from its own cadence, from *one*
// threshold - which is the whole point of `PlottedChannel` carrying
// `gapMs`, and which an earlier version of this quietly gave up by
// passing `gapThreshold` to the segmenter and `readoutTolerance` to the
// readout.
//
// The readout is always cut at `readoutTolerance`, whose floor is what stops
// the crosshair quoting a reading from the far side of the dive. The *line*
// is cut at that same number only where a gap means something - see
// `gapsAreMeaningful` on `ProfileChannel`.
//
// On the ceiling, `gapThreshold`'s `Infinity` below three samples reads as
// "join these however far apart they are", which draws a twenty-minute
// shaded forbidden zone between two isolated moments in deco. Two samples ten
// seconds apart still join, which is the case worth drawing; two twenty
// minutes apart become single-point runs, dropped here because a one-sample
// `<polyline>` has no line and `buildAreaPath` turns one point into a
// degenerate zero-width shape - markup that renders nothing. Dropping them
// makes "no ceiling is drawn here" true of the DOM as well as the pixels.
//
// On a measured channel that same strictness buys nothing and costs a lot: a
// gap there means "not recorded", two samples are still two readings, and
// filtering them out would leave a depth-only dive rendering "this
// recording's file recorded no samples to plot" over a profile that has two.
function channelRuns(t: number[], channel: ProfileChannel) {
  const tolerance = readoutTolerance(t);
  const cut = channel.gapsAreMeaningful ? tolerance : gapThreshold(t);
  const segments = segmentByTimeGap(t, cut).filter(
    (segment) => !channel.gapsAreMeaningful || segment.length > 1,
  );

  return {
    gapMs: tolerance,
    segments,
    drawn: new Set(segments.flat()),
  };
}

// One channel's readings across the dive's other recordings, in the diver's
// units: those each would draw where `drawn`, every one otherwise - the same
// choice the shown recording's own domain makes for that channel.
function peerValues(
  peers: readonly DiveProfile[],
  key: ProfileChannelKey,
  units: UnitSystem,
  drawn: boolean,
): number[] {
  return peers.flatMap((peer) => {
    const series =
      key === "pressure"
        ? toPressureSeries(peer, units)
        : [toChannelSeries(peer, key, units)].filter(
            (entry): entry is ChannelSeries => entry !== null,
          );
    return series.flatMap((entry) =>
      drawn
        ? drawnValues(entry, channelRuns(entry.t, PROFILE_CHANNELS[key]).drawn)
        : entry.values,
    );
  });
}

export function DiveProfileChart({
  profile: recorded,
  mixtures = NO_CYLINDERS,
  menuContainer,
  scaleWith = NO_PEERS,
  axis,
}: DiveProfileChartProps) {
  const units = useUnits();
  // The recording cut at the dive's end, with `duration` moved to that instant,
  // on its own clock. Cutting the samples before anything derives from them,
  // rather than ending only the x scale, is what keeps the surface stretch out
  // of the vertical scales, the crosshair and the summary as well as off the
  // plot.
  const own = useMemo(() => cutAtDiveEnd(recorded), [recorded]);
  // Where its own 0 lies on the axis the chart draws, 0 on its own axis.
  const start = axis?.start ?? 0;
  // Everything below reads this rather than the prop: the cut recording moved
  // to its place on the axis, so every time read off it - a sample's, a
  // marker's, `duration` - is already a position on the plot. Only the rules
  // about the recording's own clock read `start` back out: `describeEvent`'s
  // starting gas and the summary's span.
  const profile = useMemo(() => placeOnDiveAxis(own, start), [own, start]);
  // The axis: the shared one where the recording is placed - never short of
  // this recording's own end - and its own visible span otherwise.
  const extent = Math.max(axis?.end ?? 0, profile.duration);
  // Cut the same way, so another computer's surface stretch cannot widen an axis
  // this one's curves never reach either.
  const peers = useMemo(() => scaleWith.map(cutAtDiveEnd), [scaleWith]);
  // Names the clip paths below, so two charts on one page cannot clip each other
  // - the trap `components/icons/google-icon.tsx` already records for the mask and
  // filter ids it namespaces the same way.
  //
  // Reduced to letters, digits and dashes rather than used as `useId` hands it
  // over. React 19 spells an id `«r0»`, and 18 spelled it `:r0:`; both are legal
  // in an `id` attribute and neither is legal unescaped in the `url(#...)`
  // fragment that has to resolve it. What survives the strip is still the part
  // that differs between two ids on one page.
  const clipPrefix = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const { fullscreen, enter, exit } = useFullscreen();
  const [chartRef, fittedWidth] = useChartWidth(WIDTH);
  // The box the plot fills in full screen. Measured only there.
  const [fullscreenRef, fullscreenBox] = useElementSize();
  const box = fullscreen ? fullscreenBox : null;
  // A unit per pixel in full screen wherever that is wider than the design
  // width, so a desktop screen gets a longer plot rather than outsized labels.
  const width = box ? Math.max(fittedWidth, box.width) : fittedWidth;
  // The inline plot's height as full screen opened, which an empty box keeps on
  // the card meanwhile so the page behind does not reflow.
  const inlineRef = useRef<HTMLDivElement>(null);
  const [inlineHeight, setInlineHeight] = useState<number | null>(null);
  const enterButtonRef = useRef<HTMLButtonElement>(null);
  const enterFullscreen = () => {
    setInlineHeight(inlineRef.current?.offsetHeight ?? null);
    enter();
  };
  // One hovered *time*, not one hovered sample, and one piece of state for the
  // whole chart - the same call `GasUseChart` makes, for the same reason. It
  // can't be an index here: the channels are independently sampled and don't
  // share a time axis, so "the sample under the cursor" is a different index per
  // channel. The cursor's x maps to an axis instant once, and each channel
  // resolves its own nearest sample from that (`drawnSampleIndexAt`), or none at all
  // where it recorded nothing near enough to be quoted.
  // Milliseconds on the profile's axis, like every time below.
  //
  // A finger's x goes through the same hit target as a mouse's, so the two land
  // on the same instant - of the stretch on screen, once zoomed.
  const zoom = useChartZoom(extent, MIN_VIEW_MS, plotBox);
  const { view } = zoom;
  const span = view.end - view.start;
  const timeAt = (box: DOMRect, clientX: number) =>
    Math.min(
      view.end,
      Math.max(
        view.start,
        view.start + ((clientX - box.left) / box.width) * span,
      ),
    );
  const readout: ChartReadout<number> = useChartReadout<number>(
    (event) => {
      const box = plotBox(event.currentTarget);
      return box ? timeAt(box, event.clientX) : null;
    },
    // Zoomed, a finger drags the plot unless it lands on the crosshair.
    {
      from: (event): boolean => {
        if (!zoom.zoomed) return false;
        const box = plotBox(event.currentTarget);
        if (!box || hoveredMs === null) return true;
        const crosshairX =
          box.left + ((hoveredMs - view.start) / span) * box.width;
        return Math.abs(event.clientX - crosshairX) > CROSSHAIR_GRAB_PX;
      },
      by: (dx, event) => {
        const box = plotBox(event.currentTarget);
        if (box && box.width > 0) zoom.panBy((-dx / box.width) * span);
      },
    },
  );
  // The crosshair while it is on screen. Panned off the plot it is still the
  // readout's, and comes back with the stretch it is in.
  const hoveredMs: number | null =
    readout.value !== null &&
    readout.value >= view.start &&
    readout.value <= view.end
      ? readout.value
      : null;
  const holdPage = readout.scrubProps.ref;
  const zoomRef = zoom.ref;
  const svgRef = useCallback(
    (element: Element | null) => {
      const releaseHold = holdPage(element);
      const releaseZoom = zoomRef(element);
      return () => {
        releaseHold?.();
        releaseZoom?.();
      };
    },
    [holdPage, zoomRef],
  );

  // What the diver picked in *this* visit, and null until they pick - which is
  // what leaves room for the remembered selection underneath. Channels and the
  // event markers together, because they are one legend and one stored entry.
  const [chosen, setChosen] = useState<ProfileViewKey[] | null>(null);

  // The selection remembered from last time.
  //
  // Through `useSyncExternalStore` rather than a `useState` + effect pair, which
  // is what this wants to be and can't: `localStorage` doesn't exist on the
  // server, so a first client render that read it would disagree with the HTML
  // Next rendered and be a hydration mismatch. The three arguments are exactly
  // that problem's shape - a server snapshot of `null` to hydrate against, a
  // client snapshot read straight from storage, and no subscription. The same
  // call `useChartPeriodView` makes for its remembered period, for the same
  // reasons.
  const storedChannels = useSyncExternalStore(
    subscribeToNothing,
    readStoredChannels,
    () => null,
  );
  const remembered = useMemo(
    () => parseSeriesVisibility(storedChannels, PROFILE_VIEW_KEYS),
    [storedChannels],
  );

  // Remembered for next time, in an effect rather than in the click handler
  // below, which is what keeps that handler's updater pure - React is entitled
  // to call an updater twice.
  useEffect(() => {
    if (chosen) writeSeriesVisibility(DIVE_PROFILE_SERIES_KEY, chosen);
  }, [chosen]);

  // Converted here and nowhere after: every domain, tick, readout and spoken
  // extreme below reads `values`, which is already in the diver's own units.
  const depth = toChannelSeries(profile, "depth", units);
  const ceiling = toChannelSeries(profile, "ceiling", units);
  const temperature = toChannelSeries(profile, "temperature", units);
  const pressure = toPressureSeries(profile, units);

  // Where this recording's curves end on the axis: the dive's end, or the
  // recording's where there is none. The axis itself can run on past it, to
  // where another placed recording ends.
  const end = profile.duration;

  // Markers that land inside the plot, which is this chart's job rather than the
  // API's: its `shape_events` clamps the low side at zero and leaves the high side
  // alone - a device goes on recording after the dive, and after its last sample,
  // so a FIT `user_marker` pressed after surfacing happened when the file says it
  // did. The DiveJSON spec blesses the same arrangement (§6.4). This is the clip,
  // at `end`, which is the dive's end where the response carries one (see
  // `cutAtDiveEnd`) and the recording's last sample otherwise, and at `start`,
  // before which this recording had not begun.
  //
  // Dropped rather than clamped to the plot's last instant, which would invent a
  // time to keep a marker on screen, and rather than left to the SVG's edge, which
  // clips nothing: `x(time)` past the axis's end lands in the right-hand axis-label
  // gutter (aligned with no time on the axis), or in the card's padding, which the
  // `overflow-visible` SVG draws into.
  //
  // One filtered list feeds all three consumers - the glyphs, the crosshair and
  // `describeProfile` - so the picture and the accessible summary cannot disagree
  // about what the chart contains. That disagreement is the actual bug here: a
  // marker invisible to the eye but named to a screen reader.
  const events = (profile.events ?? []).filter(
    (event) => event.time >= start && event.time <= end,
  );

  // Rebuilt every render rather than memoized: the series are already capped at
  // 1 200 points per channel server-side, so this is a few thousand arithmetic
  // operations - and every input to it is a fresh object derived from `profile`,
  // which would make a `useMemo` dependency list either a lie or a no-op.
  const channels: PlottedChannel[] = (() => {
    const plotted: PlottedChannel[] = [];

    const depthRuns = depth
      ? channelRuns(depth.t, PROFILE_CHANNELS.depth)
      : null;
    const ceilingRuns = ceiling
      ? channelRuns(ceiling.t, PROFILE_CHANNELS.ceiling)
      : null;

    // Anchored at the surface and inverted: `0` at the top, deeper further
    // down. One domain for depth and the ceiling both - see `depthDomain`, which
    // is what keeps a 3 m ceiling drawn above a 40 m depth instead of on an axis
    // of its own where it could land below it.
    //
    // From the values that were *drawn*, not from the raw series: a sample
    // dropped for sitting in an undrawable run would otherwise stretch this axis
    // to accommodate a reading no curve ever reaches.
    const vertical = depthDomain(
      [
        ...drawnValues(depth, depthRuns?.drawn),
        ...peerValues(peers, "depth", units, true),
      ],
      [
        ...drawnValues(ceiling, ceilingRuns?.drawn),
        ...peerValues(peers, "ceiling", units, true),
      ],
    );

    if (depth && depthRuns) {
      plotted.push({
        key: "depth",
        channelKey: "depth",
        axis: "depth",
        label: PROFILE_CHANNELS.depth.label,
        series: depth,
        domain: vertical,
        ...depthRuns,
      });
    }

    if (ceiling && ceilingRuns) {
      plotted.push({
        key: "ceiling",
        channelKey: "ceiling",
        axis: "depth",
        label: PROFILE_CHANNELS.ceiling.label,
        series: ceiling,
        domain: vertical,
        // Segmented like every other channel, and here the gaps carry the most
        // meaning of any on the chart: a break in this series is a stretch of
        // the dive with *no* decompression obligation, not a sensor dropping
        // out. Drawing through one - or quoting a ceiling into one, which is
        // what `gapMs` stops the crosshair doing - would claim the diver
        // was held to a ceiling they were free of.
        ...ceilingRuns,
      });
    }

    if (temperature) {
      // Its own domain, not shared with depth: a 21.6-21.9 °C range - which is
      // what a whole dive's temperature usually spans - would be a flat line on
      // any axis wide enough for depth. Nor fitted tight to it, though - see
      // `temperatureDomain`.
      plotted.push({
        key: "temperature",
        channelKey: "temperature",
        axis: "temperature",
        label: PROFILE_CHANNELS.temperature.label,
        series: temperature,
        domain: temperatureDomain(
          [
            ...temperature.values,
            ...peerValues(peers, "temperature", units, false),
          ],
          units,
        ),
        ...channelRuns(temperature.t, PROFILE_CHANNELS.temperature),
      });
    }

    if (pressure.length > 0) {
      // One domain across every cylinder, unlike the channels above: two tanks
      // on one dive are directly comparable, and giving each its own axis would
      // make a 50-bar stage look like the 200-bar back gas.
      const domain = niceDomain([
        ...pressure.flatMap((cylinder) => cylinder.values),
        ...peerValues(peers, "pressure", units, false),
      ]);
      for (const cylinder of pressure) {
        plotted.push({
          key: `pressure-${cylinder.gasNumber}`,
          channelKey: "pressure",
          axis: "pressure",
          label:
            pressure.length > 1
              ? `${PROFILE_CHANNELS.pressure.label} (${cylinderName(cylinder.gasNumber, mixtures)})`
              : PROFILE_CHANNELS.pressure.label,
          series: cylinder,
          domain,
          ...channelRuns(cylinder.t, PROFILE_CHANNELS.pressure),
        });
      }
    }

    // The panel's channels, one axis at a time. Nothing here is special-
    // cased per channel: they differ from the four above only in sharing an axis
    // with whichever siblings are scaled against the same quantity, so the loop
    // is over the axes and the channels fall out of `channelsOnAxis`.
    for (const axis of PANEL_AXES) {
      const built = channelsOnAxis(PROFILE_CHANNEL_KEYS, axis)
        // Tank pressure is the one channel the API serves as a list rather than
        // a series, and it never lands in a panel - this is what says so to the
        // type system rather than to a reader only.
        .filter((key): key is ProfileSeriesKey => key !== "pressure")
        .map((key) => {
          const series = toChannelSeries(profile, key, units);
          return series
            ? { key, series, ...channelRuns(series.t, PROFILE_CHANNELS[key]) }
            : null;
        })
        .filter((entry): entry is NonNullable<typeof entry> => entry !== null);
      for (const entry of built) {
        plotted.push({
          key: entry.key,
          channelKey: entry.key,
          axis,
          label: PROFILE_CHANNELS[entry.key].label,
          series: entry.series,
          // Decided in the positioning step, from the channels on this row that
          // are actually shown.
          domain: null,
          segments: entry.segments,
          gapMs: entry.gapMs,
          drawn: entry.drawn,
        });
      }
    }

    // A channel with nothing drawable left is not a plotted channel, and
    // dropping it here is what makes that true everywhere at once - the legend,
    // both axes, the crosshair and the accessible summary all derive from this
    // list. Without it a series too sparse to draw still claimed a toggle
    // reading "on", a fully labelled axis in its own colour, and a line in the
    // `aria-label`, over a plot with no curve on it.
    //
    // "Too sparse to draw" is not a hypothetical: it is a two-sample channel,
    // where `readoutTolerance`'s floor splits the pair into two runs of one. On
    // the ceiling that is a brief obligation, the shape this whole guard exists
    // for; on the others it is a device that recorded twice, which is degenerate
    // but not impossible.
    return plotted.filter((channel) => channel.segments.length > 0);
  })();

  // Which channels this dive both recorded *and* can draw - the only ones worth
  // offering a toggle for. A remembered selection is intersected with it below
  // rather than used as stored: dives differ in what they carry, and
  // "temperature only" is a perfectly reasonable thing to have chosen on a dive
  // that had temperature.
  //
  // The markers ride along on the same test: they are offered when the dive has
  // any that land inside the plot, and a dive with none gets no switch for them,
  // exactly as a dive with no ceiling gets no ceiling switch. Kept as two lists
  // rather than one filter over `PROFILE_VIEW_KEYS`, because "which channels can
  // this dive draw" is a question the fallback below has to ask on its own.
  const availableChannels = PROFILE_CHANNEL_KEYS.filter((key) =>
    channels.some((channel) => channel.channelKey === key),
  );
  const available: ProfileViewKey[] =
    events.length > 0 ? [...availableChannels, "events"] : availableChannels;

  // On the *channels*, not on `available`, which now also counts the markers. A
  // profile carrying events and no drawable series would otherwise render a plot
  // box with a time axis, a row of ticks and a legend reading only "Markers",
  // with nothing to say why it is bare - and the sentence below is still true of
  // it, since markers are annotations rather than samples.
  if (channels.length === 0) {
    return (
      <p className="text-sm text-muted-foreground">
        This recording&apos;s file recorded no samples to plot.
      </p>
    );
  }

  // The selection, over **every key the legend can switch** rather than over the
  // ones this dive happens to offer. Three sources, most specific first: what the
  // diver toggled in this visit, then what they were reading last visit, then
  // everything.
  //
  // Held at full width because `available` is per-dive while the stored entry is
  // one entry for the whole app, so anything that narrows the selection to this
  // dive's keys erases the diver's opinion about the keys it lacks. Hide the
  // temperature curve on a dive whose computer logged no markers, and a selection
  // narrowed to that dive would be written back without `events` - so the next
  // dive that *has* markers opens with them hidden and a legend entry claiming
  // the diver turned them off. That is precisely the failure `DIVE_PROFILE_SERIES_KEY`
  // bumps a version to fix once, at migration, reopened on every marker-less dive.
  //
  // The same leak was already live for the ceiling before the markers existed - a
  // no-deco dive offers no ceiling toggle - which is why this is written over the
  // key list rather than patched for `events`.
  const selectedKeys = chosen ?? remembered ?? PROFILE_VIEW_KEYS;

  // What that selection plots *here*.
  const selectedHere = selectedKeys.filter((key) => available.includes(key));

  // Whether that selection draws a **curve** here. The markers deliberately do not
  // count: a plot with nothing on it but marker ticks is the blank chart the
  // fallback below exists to avoid, not a selection that plots something. Asking
  // `selectedHere.length > 0` instead let a stored `["temperature", "events"]`
  // opened on a depth-and-markers dive survive as `["events"]` - no curve, no
  // vertical axis, no gridlines, and no message either, because the overlay stands
  // down while the markers are up.
  const plotsACurveHere = selectedHere.some((key) => key !== "events");

  // A *remembered* selection that draws no curve here is skipped - a dive that
  // recorded only what you'd hidden should open showing what it does have, not
  // blank. An empty `chosen` is honored, by contrast: that is a choice just made,
  // on this dive, and the `!== null` is what tells the two apart.
  //
  // The fallback restores the **curves only**, carrying the markers choice through
  // untouched. Falling back to `available` wholesale would switch the markers back
  // on, and under this key their absence from a selection is a real "off" rather
  // than a gap - which is the whole reason the selection is held at full width.
  // Nothing about the marker preference caused the blank plot, so nothing about it
  // needs overriding to fix one.
  const visible =
    chosen !== null || plotsACurveHere
      ? selectedHere
      : [
          ...availableChannels,
          ...selectedHere.filter((key) => key === "events"),
        ];

  // What's on screen, lifted back over the full key list by re-attaching the keys
  // this dive can't show. Toggling from this rather than from `visible` is the
  // whole of the fix above; taking it from `visible` rather than from
  // `selectedKeys` is what keeps a click honest in the fallback case, where the
  // remembered selection plots nothing here and the chart is showing everything -
  // there, flipping a key against the stored selection would *add* the curve the
  // diver just asked to hide.
  const selection = [
    ...visible,
    ...selectedKeys.filter((key) => !available.includes(key)),
  ];

  // The updater form, not `toggleSeries(selection, ...)`. `selection` is this
  // render's value, and two toggles clicked inside one batch would both compute
  // from it - so the second would silently undo the first.
  const toggle = (key: ProfileViewKey) =>
    setChosen((current) =>
      toggleSeries(current ?? selection, key, PROFILE_VIEW_KEYS),
    );

  // Where this selection's scales land: which channel labels each edge of the
  // depth plot, and which rows the panel under it grows. Derived from the keys
  // alone (`profileScalePlacement`) so the rule can be swept over every
  // selection in a unit test rather than sampled by rendering.
  const placement = profileScalePlacement(
    availableChannels.filter((key) => visible.includes(key)),
  );

  const { plotHeight, plotBottom, panelTop, chartBottom, chartHeight } =
    verticalLayout(
      box
        ? fullscreenPlotHeight(box, width, placement.panels.length)
        : plotHeightFor(width),
    );

  // One scale per panel row, over the channels on that row that are **shown** -
  // which is deliberately not the rule `depthDomain` follows for depth and the
  // ceiling.
  //
  // There the hidden channel's values go in whether or not it is plotted, so the
  // axis does not shift under the diver's eyes when the ceiling is toggled; it
  // costs nothing, because a ceiling is always shallower than the depth it was
  // computed at. Here it would cost the row: a Suunto's `gf99` reaches five
  // figures on a decompression ascent, and letting a hidden gradient factor set
  // the percent row's scale would draw a CNS clock of 23 % as a flat line on the
  // baseline. A gradient factor is not a bound on a CNS clock the way a ceiling
  // is a bound on depth, so there is nothing to buy the stillness with.
  //
  // `axisDomain` rather than `niceDomain`, which is what stops a *shown* one
  // doing the same damage: the percent axis declares a ceiling of 200 % and will
  // not scale past it whatever the readings say. The rule above still earns its
  // place under that ceiling - a hidden gradient factor could still take a CNS
  // row from 12 % to 200 % - so both hold at once.
  const panelDomains = new Map<ProfileAxisKey, Domain>(
    placement.panels.map((axis) => [
      axis,
      axisDomain(axis, [
        ...channels
          .filter(
            (channel) =>
              channel.axis === axis && visible.includes(channel.channelKey),
          )
          .flatMap((channel) => drawnValues(channel.series, channel.drawn)),
        ...channelsOnAxis(
          availableChannels.filter((key) => visible.includes(key)),
          axis,
        ).flatMap((key) => peerValues(peers, key, units, true)),
      ]),
    ]),
  );

  // The shown channels, now that there is a scale and a rect for each. A deco
  // channel's row is its axis's position among the panels, which is exactly the
  // thing that could not be known while `channels` was being built.
  // The domain of the scale numbered under the right edge's, when there is one:
  // fitted to the right edge's gridline count, so each pair of numbers labels
  // one height. Across every cylinder, as the pressure domain always is.
  const rightDomain = channels.find(
    (channel) => channel.channelKey === placement.right,
  )?.domain;
  const underRightDomain =
    placement.underRight && rightDomain
      ? alignedDomain(
          [
            ...channels
              .filter((channel) => channel.channelKey === placement.underRight)
              .flatMap((channel) => channel.series.values),
            ...peerValues(peers, placement.underRight, units, false),
          ],
          stepCount(rightDomain),
        )
      : null;

  const shown: PositionedChannel[] = channels
    .filter((channel) => visible.includes(channel.channelKey))
    .flatMap((channel) => {
      const panelIndex = placement.panels.indexOf(channel.axis);
      const domain =
        (channel.channelKey === placement.underRight
          ? underRightDomain
          : null) ??
        channel.domain ??
        panelDomains.get(channel.axis);
      // Unreachable: a channel is either on the depth plot with a domain of its
      // own, or on a panel row that `placement` grew *because* it is shown.
      if (!domain) return [];

      const { inverted } = PROFILE_CHANNELS[channel.channelKey];
      return [
        {
          ...channel,
          domain,
          panelIndex,
          y:
            panelIndex >= 0
              ? scaleY(domain, panelTop(panelIndex), PANEL_HEIGHT, inverted)
              : scaleY(domain, PADDING.top, plotHeight, inverted),
        },
      ];
    });

  const chartFoot = chartBottom(placement.panels.length);
  const height = chartHeight(placement.panels.length);

  // Whether the markers are on the plot right now. Read by everything that says
  // anything about them - the glyphs, the crosshair and the accessible summary -
  // so a marker the diver has switched off can't go on being named by the card
  // or read out to a screen reader. The same rule `shownValues` enforces for the
  // channels, and for the same reason: the chart has three ways of describing
  // itself and they have to agree.
  const eventsShown = visible.includes("events");

  // Whether a channel is on screen right now: recorded, drawable, and not
  // toggled off. What `describeProfile` is built from, so the sentence a screen
  // reader hears describes the same chart the eye is looking at - `available`
  // is the half that was missing, and a channel too sparse to draw was being
  // named to a screen reader over a plot with no curve on it.
  // The readings a channel has on screen right now: visible, and drawn. Built
  // from `channels`, so a channel that was filtered out contributes nothing and
  // a sample that was filtered out of a run contributes nothing either - which
  // is what keeps the sentence a screen reader hears describing the same chart
  // the eye is looking at, down to the extremes it quotes.
  const shownValues = (key: ProfileChannelKey): number[] =>
    visible.includes(key)
      ? channels
          .filter((channel) => channel.channelKey === key)
          .flatMap((channel) => drawnValues(channel.series, channel.drawn))
      : [];

  // The channel drawing a given toggle right now, or null. `find` rather than a
  // filter for pressure on purpose: every cylinder shares one domain and one y,
  // so any of them answers for the axis.
  const shownChannel = (key: ProfileChannelKey): PositionedChannel | null =>
    shown.find((channel) => channel.channelKey === key) ?? null;

  const depthChannel = shownChannel("depth");
  const ceilingChannel = shownChannel("ceiling");

  // Which channel labels each edge of the depth plot - the rule lives in
  // `profileScalePlacement`, and what is left here is looking the channel up.
  const leftChannel = placement.left ? shownChannel(placement.left) : null;
  const rightChannel = placement.right ? shownChannel(placement.right) : null;
  const underRightChannel = placement.underRight
    ? shownChannel(placement.underRight)
    : null;

  // The panel's rows, with the two numbers each is labelled with: the
  // domain's own ends, taken through `axisTicks` rather than read off `domain`
  // directly, since that is where the fractional-step rounding lives - without
  // it a ppO₂ row is labelled `1.4000000000000001`. The upper one carries the
  // unit.
  const panelRows = placement.panels.flatMap((axis) => {
    const row = shown.find((channel) => channel.axis === axis);
    if (!row) return [];
    const ticks = axisTicks(row.domain);
    const bounds = [ticks[ticks.length - 1], ticks[0]];
    const suffix = axisUnitSuffix(axis, units);
    const labels = bounds.map((tick, position) =>
      position === 0 ? `${tick}${suffix}` : String(tick),
    );
    return [{ axis, row, bounds, labels }];
  });

  // Each edge's gutter, from the numbers drawn in it - see `AXIS_LABEL_GAP`.
  const gutter = (widths: number[]) =>
    widths.length ? Math.max(...widths) + AXIS_LABEL_GAP : 0;
  const tickWidths = (channel: PositionedChannel | null) =>
    channel
      ? axisTicks(channel.domain).map((tick) => labelWidth(String(tick)))
      : [];
  const left = gutter([
    ...tickWidths(leftChannel),
    ...panelRows.flatMap(({ labels }) =>
      labels.map((label) => labelWidth(label, PANEL_LABEL_SIZE)),
    ),
  ]);
  const right = gutter([
    ...tickWidths(rightChannel),
    ...tickWidths(underRightChannel),
  ]);
  const plotWidth = width - left - right;
  const x = (at: number) =>
    left + (span > 0 ? (at - view.start) / span : 0) * plotWidth;
  // Zoomed in, the curves and markers run on past both ends of the plot.
  const plotClip = zoom.zoomed ? `url(#${clipPrefix}-plot)` : undefined;

  // The water column: one filled region per run of consecutive depth samples,
  // from the surface down to the curve.
  //
  // Per segment, and built from the same `segments` the polylines are, which is
  // the whole of the fix. It was one path over `series.t` entire - every sample
  // the channel carried, dropouts included - so on a dive whose depth series has
  // a real hole in it the line broke where the recording stopped and the teal
  // fill went straight on across the gap underneath it. That is the one thing
  // `segmentByTimeGap` exists to prevent, stated on the line and contradicted by
  // the shape under it: a filled region is a claim that the diver was in that
  // water, and here it spanned the stretch where nothing was recorded at all.
  //
  // Real, not hypothetical - `Dive_2025-03-08-1440.xml` has a 1 341-second hole,
  // and the same class of bug was already found and fixed on the ceiling, which
  // has shaded per segment since it was added.
  const depthAreas =
    depthChannel?.segments.map((segment) =>
      buildAreaPath(
        segment.map((position) => ({
          x: x(depthChannel.series.t[position]),
          y: depthChannel.y(depthChannel.series.values[position]),
        })),
        PADDING.top,
      ),
    ) ?? [];

  // The forbidden zone: one filled region per run of consecutive ceiling
  // samples, from the surface down to the ceiling. That is the water the diver
  // may *not* ascend into, which is the thing worth shading - the ceiling line
  // alone says where the limit is without saying which side of it is the
  // problem. Per segment rather than one path over the whole channel, so a
  // stretch with no obligation is left unshaded instead of being spanned.
  const ceilingAreas =
    ceilingChannel?.segments.map((segment) =>
      buildAreaPath(
        segment.map((position) => ({
          x: x(ceilingChannel.series.t[position]),
          y: ceilingChannel.y(ceilingChannel.series.values[position]),
        })),
        PADDING.top,
      ),
    ) ?? [];

  // The marker the crosshair is close enough to be naming, if any. In
  // milliseconds, from a distance in viewBox units - see `EVENT_HOVER_UNITS`.
  const hoveredEvent =
    hoveredMs === null || !eventsShown
      ? null
      : nearestEvent(events, hoveredMs, (span / plotWidth) * EVENT_HOVER_UNITS);

  // One binary search per channel, not one shared index lookup: the channels are
  // independently sampled, so each has its own nearest sample to the cursor's
  // instant. Every reported value is a real reading, never an interpolation -
  // and `drawnSampleIndexAt` is what keeps that true across a stretch a channel
  // didn't record, where the nearest reading is not a reading *here* at all.
  // There the channel keeps its row with no sample in it, as its line keeps its
  // place on the plot with nothing drawn.
  const readouts: Readout[] = shown.map((channel) => {
    // The nearest sample that is both close enough to quote and actually on the
    // chart - one question, not two, so a nearer *undrawn* sample can't mask a
    // drawn one just behind it. See `drawnSampleIndexAt`.
    const index =
      hoveredMs === null
        ? -1
        : drawnSampleIndexAt(
            channel.series.t,
            hoveredMs,
            channel.gapMs,
            channel.drawn,
          );
    const value = channel.series.values[index];
    return {
      key: channel.key,
      label: channel.label,
      channel: channel.series.channel,
      panelIndex: channel.panelIndex,
      sample:
        index < 0
          ? null
          : {
              at: channel.series.t[index],
              value,
              cy: channel.y(value),
              // Whether the reading is one this row's axis can hold - see
              // `axisDomain`. Only ever true on a bounded axis, and the value
              // is quoted either way: the card is the readout, and bounding an
              // axis is not licence to stop telling the diver what the device
              // wrote.
              offScale:
                value < channel.domain.min || value > channel.domain.max,
            },
    };
  });

  // The readings with a dot on the chart. A reading the row's axis cannot hold
  // has no honest place to be marked: at its own `cy` the dot lands over the
  // depth plot, and pulled back to the row's top edge it would claim the curve is
  // up there, which is exactly the reading the bound exists to avoid making. The
  // curve says it left, and the card says what it left with.
  // Nor does one from just past the end of a zoomed view.
  const dots = readouts.flatMap(({ key, channel, sample }) =>
    sample &&
    !sample.offScale &&
    sample.at >= view.start &&
    sample.at <= view.end
      ? [{ key, channel, ...sample }]
      : [],
  );

  // Sized to exactly the chart, and what the tooltip's percentage offsets and
  // the empty-plot message are resolved against. It never scrolls:
  // `useChartWidth` narrows the viewBox to fit a phone instead. In full screen
  // it fills the box `fullscreenPlotHeight` fitted the viewBox to.
  const plot = (
    <div ref={chartRef} className={cn("relative", fullscreen && "h-full")}>
      <svg
        viewBox={`0 0 ${width} ${height}`}
        className={cn(
          "overflow-visible",
          fullscreen ? "h-full w-full" : "w-full h-auto",
        )}
        role="img"
        {...readout.scrubProps}
        ref={svgRef}
        // Only what's on screen. A summary naming a temperature range the
        // diver has hidden describes a chart nobody is looking at.
        aria-label={describeProfile({
          readings: shownValues,
          // Emptied rather than filtered, since the toggle is
          // all-or-nothing - and this is the same "only what's on screen"
          // rule the channels go through `shownValues` for.
          events: eventsShown ? events : [],
          cylinders: mixtures,
          // What this computer recorded, on its own clock, rather than the
          // shared axis it is drawn on.
          duration: own.duration,
          recordingStart: start,
          units,
          temperatureDecimals: temperature?.channel.decimals ?? 0,
        })}
      >
        {/* One clip per panel row, because a panel row's axis can declare a
            ceiling its readings overrun - see `axisDomain`, and DECISIONS.md's
            *"The percent axis stops at 200 %"*.

            A gradient factor of 14 060 % has to be drawn **leaving** a 200 %
            row. Held inside it instead, flattened along the top edge, it would
            read as a measurement at 200 %, which is the one thing it is not;
            left unclipped it would run up through the panel gap and across the
            depth plot. So the row clips, and the curve crosses its top rule at
            whatever angle it was climbing at and is gone.

            That is also what tells the two kinds of absence apart, which is
            worth saying because this chart cares elsewhere: a stretch the
            device never recorded leaves the line stopping *inside* the row,
            while a stretch the axis cannot hold leaves it stopping *on the top
            rule*. Different pictures, and neither invents a reading.

            Applied to every panel row, not only a bounded one: on an unbounded
            row the domain covers its own drawn values by construction, so there
            is nothing there for a clip to remove. The depth plot is left alone
            for the same reason and an extra one - its readout dots sit on its
            edges, and a clip would cut them in half.

            Not left to the SVG's own edge, which is not clipping at all: the
            SVG is `overflow-visible`, and draws whatever leaves a plot. That
            distinction is already recorded, under *"Markers are
            clipped to the plot"*. */}
        <defs aria-hidden>
          <clipPath id={`${clipPrefix}-plot`}>
            <rect x={left} y={0} width={plotWidth} height={height} />
          </clipPath>
          {placement.panels.map((axis, index) => (
            <clipPath key={axis} id={`${clipPrefix}-panel-${index}`}>
              <rect
                x={left}
                y={panelTop(index) - LINE_WIDTH / 2}
                width={plotWidth}
                height={PANEL_HEIGHT + LINE_WIDTH}
              />
            </clipPath>
          ))}
        </defs>

        {/* Horizontal gridlines, from the left-hand axis, so every rule
            lines up with a labelled value rather than floating between two.
            `currentColor` throughout, so light/dark is inherited from the
            surrounding text colors rather than hardcoded per theme.

            `aria-hidden`, here and on the two axes below: an axis is a
            reading aid for the eye, and `role="img"` on the svg does not
            reliably keep bare `<text>` out of the accessibility tree - it
            surfaces as a run of unlabelled numbers ahead of anything useful.
            `describeProfile` on the svg says what they say, in a sentence. */}
        {leftChannel &&
          axisTicks(leftChannel.domain).map((tick) => (
            <line
              key={tick}
              aria-hidden
              className="text-border"
              x1={left}
              x2={width - right}
              y1={leftChannel.y(tick)}
              y2={leftChannel.y(tick)}
              stroke="currentColor"
              strokeWidth={1}
            />
          ))}

        {/* The primary scale, labelled in the colour of whichever channel
            is holding it - see `leftChannel` for which one that is and why
            it is never nothing while a curve is on the plot. */}
        {leftChannel &&
          axisTicks(leftChannel.domain).map((tick) => (
            <text
              key={tick}
              aria-hidden
              x={left - AXIS_LABEL_GAP}
              y={leftChannel.y(tick)}
              textAnchor="end"
              dominantBaseline="middle"
              fontSize={11}
              fill="currentColor"
              className={leftChannel.series.channel.colorClass}
            >
              {tick}
            </text>
          ))}

        {/* The second scale, where the selection holds one - see
            `rightChannel`. */}
        {rightChannel &&
          axisTicks(rightChannel.domain).map((tick) => (
            <text
              key={tick}
              aria-hidden
              x={width - right + AXIS_LABEL_GAP}
              y={
                rightChannel.y(tick) -
                (underRightChannel ? STACKED_LABEL_OFFSET : 0)
              }
              textAnchor="start"
              dominantBaseline="middle"
              fontSize={11}
              fill="currentColor"
              className={rightChannel.series.channel.colorClass}
            >
              {tick}
            </text>
          ))}

        {/* The third scale, a number under each of the second's at the same
            height - see `underRight`. */}
        {underRightChannel &&
          axisTicks(underRightChannel.domain).map((tick) => (
            <text
              key={tick}
              aria-hidden
              data-under-right
              x={width - right + AXIS_LABEL_GAP}
              y={underRightChannel.y(tick) + STACKED_LABEL_OFFSET}
              textAnchor="start"
              dominantBaseline="middle"
              fontSize={11}
              fill="currentColor"
              className={underRightChannel.series.channel.colorClass}
            >
              {tick}
            </text>
          ))}

        {/* The panel's rows: two rules and two numbers each, the
            upper one carrying the unit. Deliberately thinner than the depth
            plot's axis - `axisTicks` would put four or five labels in 46
            units, where 11-unit type collides with itself - and deliberately
            **not** coloured after a channel: three curves in different colours
            can share one of these rows, so numbers in any one of them would be
            claiming the scale for that curve. The depth plot's coloured-edge
            rule holds where it was written, on an edge one channel owns. */}
        {panelRows.map(({ axis, row, bounds, labels }) => (
          <g key={axis} data-panel={axis} aria-hidden>
            {bounds.map((tick) => (
              <line
                key={tick}
                className="text-border"
                x1={left}
                x2={width - right}
                y1={row.y(tick)}
                y2={row.y(tick)}
                stroke="currentColor"
                strokeWidth={1}
              />
            ))}
            {bounds.map((tick, position) => (
              <text
                key={tick}
                x={left - AXIS_LABEL_GAP}
                y={row.y(tick)}
                textAnchor="end"
                dominantBaseline="middle"
                fontSize={PANEL_LABEL_SIZE}
                fill="currentColor"
                className="text-muted-foreground"
              >
                {labels[position]}
              </text>
            ))}
          </g>
        ))}

        {elapsedTicks(
          view.start,
          view.end,
          Math.min(
            ELAPSED_TICK_TARGET,
            labelCapacity(plotWidth, ELAPSED_LABEL_SPACING),
          ),
        ).map((tick) => (
          <text
            key={tick}
            aria-hidden
            x={x(tick)}
            y={height - 8}
            textAnchor="middle"
            fontSize={11}
            fill="currentColor"
            className="text-muted-foreground"
          >
            {formatElapsed(tick)}
          </text>
        ))}

        {/* Depth is the chart's subject, so it is drawn as the water column
            itself - which also makes "which side is the water" unambiguous on
            an inverted axis - with no line along its edge, and everything
            else is a bare line on top - below the 3:1 the lines clear, by
            choice (see DECISIONS.md, the profile chart's section). One per
            run, so the fill breaks wherever the recording does. */}
        <g clipPath={plotClip}>
          {depthAreas.map((area, index) => (
            <path
              key={index}
              d={area}
              fill="currentColor"
              className={`${PROFILE_CHANNELS.depth.colorClass} opacity-15`}
            />
          ))}

          {/* Over the depth fill, not under it: the ceiling zone is a subset
            of the water column by construction - a ceiling is always
            shallower than the depth it was computed at - so underneath it
            would be invisible. Denser than depth's 15% for the same reason it
            is red: this is the one region on the chart that is a rule rather
            than a reading. */}
          {ceilingAreas.map((area, index) => (
            <path
              key={index}
              d={area}
              fill="currentColor"
              className={`${PROFILE_CHANNELS.ceiling.colorClass} opacity-25`}
            />
          ))}

          {shown
            .filter((channel) => channel.channelKey !== "depth")
            .map((channel) => (
              <g
                key={channel.key}
                className={channel.series.channel.colorClass}
                clipPath={
                  channel.panelIndex >= 0
                    ? `url(#${clipPrefix}-panel-${channel.panelIndex})`
                    : undefined
                }
              >
                {channel.segments.map((segment, index) => (
                  <polyline
                    key={index}
                    points={segment
                      .map(
                        (position) =>
                          `${x(channel.series.t[position])},${channel.y(channel.series.values[position])}`,
                      )
                      .join(" ")}
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={LINE_WIDTH}
                    // From the channel rather than from this line's key, so the
                    // legend swatch below can read the same flag - see `dashed`
                    // on `ProfileChannel` for why the dash is load-bearing.
                    strokeDasharray={
                      channel.series.channel.dashed ? "5 3" : undefined
                    }
                    strokeLinejoin="round"
                    strokeLinecap="round"
                  />
                ))}
              </g>
            ))}

          {/* Event markers, on the axis rather than on the depth curve, and
            behind a switch of their own in the legend. They are still not a
            channel - no axis, no unit, nothing to scale - and the switch does
            not make them one; what it grants is that a dive with a dozen of
            them can be read without them, which "annotations are cheap enough
            to always draw" was the wrong answer to. See `PROFILE_VIEW_KEYS`
            for why they are a separate list rather than one more channel. */}
          {eventsShown &&
            events.map((event, index) => (
              <EventMarker
                key={`${event.time}-${event.type}-${index}`}
                event={event}
                cx={x(event.time)}
                baseline={plotBottom}
                hovered={event === hoveredEvent}
              />
            ))}
        </g>

        {/* Through the panel as well as the depth plot: it is one
            instant of one dive, and a crosshair that stopped at the depth
            plot's baseline would leave the diver reading a panel dot with no
            line to place it on. */}
        {hoveredMs !== null && (
          <line
            x1={x(hoveredMs)}
            x2={x(hoveredMs)}
            y1={PADDING.top}
            y2={chartFoot}
            stroke="currentColor"
            strokeWidth={1}
            className="text-muted-foreground"
          />
        )}

        {dots.map((dot) => (
          <circle
            key={dot.key}
            cx={x(dot.at)}
            cy={dot.cy}
            r={3.5}
            fill="currentColor"
            className={dot.channel.colorClass}
          />
        ))}

        {/* One transparent hit target over the whole plot. The gas chart hangs
            its hover off per-dot `<a>` elements; a continuous line has no dots
            to hang anything off, so the analogue is a rect that turns the
            cursor's x into a time. `transparent` rather than `none` -
            `fill="none"` takes no pointer events at all, which is the opposite
            of the point.

            Keyboard scrubbing is deliberately out of scope: the gas chart gets
            focus for free from the links its dots already are, and there is no
            equivalent here without inventing a focus model for a polyline. The
            `aria-label` above carries the summary instead. */}
        <rect
          data-hit
          x={left}
          y={PADDING.top}
          width={plotWidth}
          height={chartFoot - PADDING.top}
          fill="transparent"
          onMouseMove={(event) =>
            readout.hover(
              timeAt(
                event.currentTarget.getBoundingClientRect(),
                event.clientX,
              ),
            )
          }
          onMouseLeave={() => readout.hover(null)}
        />
      </svg>

      {/* In full screen the readout has a panel of its own beside the plot. */}
      {!fullscreen &&
        hoveredMs !== null &&
        (readouts.length > 0 || hoveredEvent) && (
          <ProfileTooltip
            at={hoveredMs}
            cylinders={mixtures}
            readouts={readouts}
            event={hoveredEvent}
            recordingStart={start}
            cx={x(hoveredMs)}
            chartWidth={width}
            // The box the card's percentage offsets are resolved against,
            // which grows with the panel: a card positioned as a fraction
            // of a height it no longer has lands somewhere else entirely.
            chartHeight={height}
            // The topmost of the dots being described, which is only used to
            // decide which end of the plot the card sits at - see
            // `tooltipVerticalAnchor`. `plotBottom` is the degenerate
            // fallback for a card with no dot to hang off - an event on its
            // own, or readings that are all off their rows' axes - which
            // puts it at the top, clear of the marker on the baseline.
            topmostY={
              dots.length > 0
                ? Math.min(...dots.map((dot) => dot.cy))
                : plotBottom
            }
            plotBottom={plotBottom}
          />
        )}

      {/* With every channel off the plot stays - same box, same elapsed-time
          axis, with the sentence over the middle of it - rather than a bare
          sentence in its place, which collapses the card to two lines.

          Not shown while the markers are up, even with every curve hidden: the
          plot has content then, and "pick one to plot it" printed across a row
          of markers describes a chart nobody is looking at.

          `pointer-events-none` so the hit target underneath still tracks the
          crosshair, which markers are still worth hovering for. */}
      {shown.length === 0 && !eventsShown && (
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <p className="text-sm text-muted-foreground">
            Every channel is hidden. Pick one from the menu to plot it.
          </p>
        </div>
      )}
    </div>
  );

  // The channel menu, and beside it the way into full screen or back out.
  const controls = (inFullscreen: boolean) => (
    <div className="flex items-center">
      {zoom.zoomed && (
        <IconTooltip label="Zoom out">
          <Button
            type="button"
            variant="ghost"
            size="icon"
            className="h-9 w-9"
            onClick={zoom.reset}
          >
            <ZoomOut className="h-4 w-4" aria-hidden="true" />
          </Button>
        </IconTooltip>
      )}
      <LegendMenu
        available={available}
        visible={visible}
        onToggle={toggle}
        units={units}
      />
      <IconTooltip label={inFullscreen ? "Exit full screen" : "Full screen"}>
        <Button
          ref={inFullscreen ? undefined : enterButtonRef}
          type="button"
          variant="ghost"
          size="icon"
          className="h-9 w-9"
          onClick={inFullscreen ? exit : enterFullscreen}
        >
          {inFullscreen ? (
            <Minimize className="h-4 w-4" aria-hidden="true" />
          ) : (
            <Maximize className="h-4 w-4" aria-hidden="true" />
          )}
        </Button>
      </IconTooltip>
    </div>
  );

  return (
    <div>
      <div
        ref={inlineRef}
        style={
          fullscreen && inlineHeight ? { height: inlineHeight } : undefined
        }
      >
        {!fullscreen && plot}
      </div>

      {menuContainer && createPortal(controls(false), menuContainer)}

      <DiveProfileFullscreen
        open={fullscreen}
        onClose={exit}
        returnFocusRef={enterButtonRef}
        controls={controls(true)}
        readout={
          <div className="rounded-md border px-2 py-2">
            <ReadoutGrid
              at={hoveredMs}
              readouts={readouts}
              event={hoveredEvent}
              recordingStart={start}
              markers={eventsShown}
              cylinders={mixtures}
              tone="panel"
            />
          </div>
        }
        plotRef={fullscreenRef}
      >
        {plot}
      </DiveProfileFullscreen>
    </div>
  );
}

interface Readout {
  key: string;
  label: string;
  channel: (typeof PROFILE_CHANNELS)[keyof typeof PROFILE_CHANNELS];
  // The graph the reading is drawn on: the panel row, or -1 for the depth
  // plot. The card groups its readings by it.
  panelIndex: number;
  // What the line reads at the crosshair, or null where it recorded nothing
  // near enough to quote - or where there is no crosshair. The row stays, with
  // a dash, so the card keeps its shape while the crosshair crosses a gap.
  sample: {
    // The sample's own instant on the axis, which is where its dot goes.
    at: number;
    value: number;
    cy: number;
    // The reading is outside its row's axis, so the curve carrying it has left
    // the row and there is nowhere on the chart to put a dot for it. The card
    // still names it.
    offScale: boolean;
  } | null;
}

// How a marker is drawn, by what it means. Three families rather than five
// glyphs: at this size a shape is worth about one bit of information, and
// spending it on "is this the gas plan, an obligation, or the computer talking"
// is worth more than five outlines nobody can tell apart. The readout says which
// one it is in words.
//
// Colours are borrowed, not new, and they answer a narrower question than the
// shapes do: **does this marker join to something else on the chart?** A gas
// switch is `--pressure` because that is the cylinders' colour here and the marker
// carries the `gas_number` that joins it to one. Nothing else joins to anything,
// so nothing else is coloured.
//
// A stop is deliberately *not* drawn in the ceiling's red, which is what an
// earlier version did on the grounds that "a stop is the obligation the ceiling
// describes". That is not true of either type this build can receive. Both are
// the Suunto app's `Notify` values as the `divejson` package's reader maps them
// (`STOP_TYPES`) - the computer *recommending* a pause, not a ceiling forbidding
// an ascent. A safety stop is the clearest case: it is
// precisely the stop that is not an obligation, and a red triangle in the
// forbidden zone's exact colour would put an obligation on a recreational
// no-deco profile that never had one. Red means the ceiling, and only the
// ceiling.
interface EventGlyph {
  colorClass: string;
  shape: "diamond" | "triangle" | "circle";
}

// **Three shapes for thirteen types, and the family is the whole point.** A
// stop is a triangle whether the computer prescribed it or recorded it broken,
// an alarm is the computer talking and draws the general circle, and the gas
// plan is the diamond. Nine more outlines would be nine more things to tell
// apart at four units across; the crosshair says which one it is in words.
const EVENT_GLYPHS: Record<DiveProfileEventType, EventGlyph> = {
  gas_switch: { colorClass: "text-pressure", shape: "diamond" },
  deep_stop: { colorClass: "text-muted-foreground", shape: "triangle" },
  safety_stop: { colorClass: "text-muted-foreground", shape: "triangle" },
  bookmark: { colorClass: "text-muted-foreground", shape: "circle" },
  // The alarm classes. A violated stop keeps the stop's triangle rather than
  // taking the ceiling's red: red means the ceiling on this chart and only the
  // ceiling, and a safety stop is precisely the stop that is not an obligation.
  safety_stop_mandatory: {
    colorClass: "text-muted-foreground",
    shape: "triangle",
  },
  safety_stop_violation: {
    colorClass: "text-muted-foreground",
    shape: "triangle",
  },
  deep_stop_violation: {
    colorClass: "text-muted-foreground",
    shape: "triangle",
  },
  ascent_rate: { colorClass: "text-muted-foreground", shape: "circle" },
  ceiling_violation: { colorClass: "text-muted-foreground", shape: "circle" },
  ndl_reached: { colorClass: "text-muted-foreground", shape: "circle" },
  ppo2_high: { colorClass: "text-muted-foreground", shape: "circle" },
  pressure_low: { colorClass: "text-muted-foreground", shape: "circle" },
  depth_alarm: { colorClass: "text-muted-foreground", shape: "circle" },
};

// What a marker with no type, or a type this build has never heard of, is drawn
// as.
//
// Both are real and they are not the same thing. An **absent** type is the
// format's own way of saying the device recorded something this vocabulary has
// no word for, and arrives with the device's wording in `label`. An
// **unrecognized** type is the deployment gap: `event.type` is a `string` on the
// wire that TypeScript has been told is one of thirteen, the two repos deploy
// independently, and an API that grows a fourteenth reaches browsers still
// running this bundle. Indexing `EVENT_GLYPHS` with either yields `undefined`,
// and destructuring that is a `TypeError` inside render - which trades the whole
// dive page for `app/error.tsx` rather than losing one tick. A neutral grey
// circle is the honest degradation for
// both, and `describeEvent` falls back to the device's own wording beside it.
const UNKNOWN_EVENT_GLYPH: EventGlyph = {
  colorClass: "text-muted-foreground",
  shape: "circle",
};

function glyphFor(type: string | null | undefined): EventGlyph {
  if (type == null) return UNKNOWN_EVENT_GLYPH;

  // The `as` is doing the opposite of its usual job here: it exists so the `??`
  // is reachable, not to silence it.
  return EVENT_GLYPHS[type as DiveProfileEventType] ?? UNKNOWN_EVENT_GLYPH;
}

// One marker: a tick standing on the x-axis with its glyph on top.
//
// `aria-hidden` like the axes - `role="img"` on the parent svg doesn't reliably
// keep shapes out of the accessibility tree, and `describeProfile` names the
// events in a sentence, which a run of unlabelled paths never could.
function EventMarker({
  event,
  cx,
  baseline,
  hovered,
}: {
  event: DiveProfileEvent;
  cx: number;
  // The depth plot's x-axis, which the tick stands on.
  baseline: number;
  hovered: boolean;
}) {
  const { colorClass, shape } = glyphFor(event.type);

  return (
    <g
      aria-hidden
      className={colorClass}
      // Dimmed until the crosshair is near one, and then only *this* one comes
      // up. Markers are the chart's background layer of annotation: at full
      // strength a dive with a dozen of them reads as a picket fence in front of
      // the curves it is annotating.
      //
      // 0.9 rather than the 0.55 this started at, because opacity composites
      // away exactly the contrast the tokens were chosen for, and a resting
      // marker is the only affordance there is - you cannot hover what you
      // cannot see, so this is the one place on the chart where a faint mark
      // fails the person it matters most to.
      //
      // Composited against the card the markers actually sit on, 0.55 puts
      // `--ceiling` at **1.94:1** in dark, `--pressure` at **1.94:1** and
      // `--muted-foreground` at **2.37:1** in light - all under the 3:1 WCAG
      // asks of a graphical object, despite every one of those tokens clearing
      // it on its own. 0.9 brings the worst case, `--pressure` on white, to
      // **3.26:1**; 0.85 would clear at 3.01:1, close enough to the line that a
      // rounding difference in compositing could put it under.
      //
      // The picket-fence worry is answered by the marks being thin ticks on the
      // baseline rather than by making them faint, and the hover still reads: it
      // takes the tick to full strength *and* thickens it, which was always
      // doing more of that work than the opacity was.
      opacity={hovered ? 1 : 0.9}
    >
      <line
        x1={cx}
        x2={cx}
        y1={baseline}
        y2={baseline - EVENT_TICK_HEIGHT}
        stroke="currentColor"
        strokeWidth={hovered ? 1.5 : 1}
      />
      <GlyphShape
        shape={shape}
        cx={cx}
        cy={baseline - EVENT_TICK_HEIGHT - EVENT_GLYPH_GAP}
      />
    </g>
  );
}

// A marker's glyph, centred on (cx, cy) and filled in the current colour.
function GlyphShape({
  shape,
  cx,
  cy,
}: {
  shape: EventGlyph["shape"];
  cx: number;
  cy: number;
}) {
  const r = EVENT_GLYPH_RADIUS;
  switch (shape) {
    case "diamond":
      return (
        <path
          d={`M${cx},${cy - r} L${cx + r},${cy} L${cx},${cy + r} L${cx - r},${cy} Z`}
          fill="currentColor"
        />
      );
    case "triangle":
      return (
        <path
          d={`M${cx},${cy - r} L${cx + r},${cy + r - 1} L${cx - r},${cy + r - 1} Z`}
          fill="currentColor"
        />
      );
    case "circle":
      return <circle cx={cx} cy={cy} r={r - 1} fill="currentColor" />;
  }
}

// The hover card: HTML rather than SVG `<text>`, so it gets the app's tooltip
// tokens, a border, a shadow and crisp text at any chart width - the same call,
// and the same tokens, as `GasUseTooltip`.
//
// Positioned in percentages of the chart box, which works because the SVG scales
// uniformly inside a wrapper of exactly its size, so viewBox units map straight
// onto percentages without measuring anything in the DOM. The `style` prop is an
// inline style *attribute*, which the CSP allows (`style-src-attr
// 'unsafe-inline'`); an injected `<style>` element would not be.
function ProfileTooltip({
  at,
  cylinders,
  readouts,
  event,
  recordingStart,
  cx,
  chartWidth,
  chartHeight,
  topmostY,
  plotBottom,
}: {
  at: number;
  cylinders: CylinderLabels;
  readouts: Readout[];
  event: DiveProfileEvent | null;
  recordingStart: number;
  cx: number;
  chartWidth: number;
  chartHeight: number;
  topmostY: number;
  plotBottom: number;
}) {
  // The card always lands inside the chart box, in both axes: past an edge it
  // would cover whatever sits beside the chart, or on a phone scroll the page
  // sideways.
  //
  // Vertically that is guaranteed by anchoring to the plot's own top or bottom
  // edge rather than offsetting from a data point - see `tooltipVerticalAnchor`
  // for why offsetting from a point cannot be made safe here.
  //
  // Horizontally the card stands beside the crosshair wherever the chart is
  // wide enough for it (`tooltipSide`), so it never covers the line and the
  // dots it is reading. Where it is not, it is centred on the crosshair and held
  // inside the frame, aligned to its left edge if it is wider than the frame.
  // Measured and written after every render and before paint, since the side
  // depends on how wide the readings came out - to the `translate` property,
  // which composes with the vertical `transform` below and which React leaves
  // alone because no `style` prop names it.
  const cardRef = useRef<HTMLDivElement>(null);
  useLayoutEffect(() => {
    const card = cardRef.current;
    const frame = card?.offsetParent;
    if (!card || !frame) return;

    const line = card.offsetLeft;
    const width = card.offsetWidth;
    const room = frame.clientWidth;
    const side = tooltipSide(line, width, room);
    const left =
      side === "right"
        ? line + TOOLTIP_GAP_PX
        : side === "left"
          ? line - TOOLTIP_GAP_PX - width
          : Math.max(0, Math.min(line - width / 2, room - width));
    card.style.translate = `${left - line}px`;
  });
  const { y, translateY } = tooltipVerticalAnchor(
    topmostY,
    PADDING.top,
    plotBottom,
  );

  return (
    <div
      ref={cardRef}
      // `w-max` so its width is its text's wherever it is placed: a box this
      // far right in its frame would otherwise shrink to the room left of it,
      // and the measurement above would answer for a narrower card.
      className={cn(CHART_HOVER_CARD, "w-max")}
      style={{
        left: `${(cx / chartWidth) * 100}%`,
        top: `${(y / chartHeight) * 100}%`,
        transform: `translateY(${translateY})`,
      }}
      data-chart-readout=""
      role="presentation"
    >
      <ReadoutGrid
        at={at}
        readouts={readouts}
        event={event}
        recordingStart={recordingStart}
        markers={false}
        cylinders={cylinders}
        tone="tooltip"
      />
    </div>
  );
}

// The readings at one instant, a row per line on the plot: the value
// right-aligned in its channel's colour, then the label, in two columns so every
// label starts at one edge. One grid across the groups, so the columns hold
// through the rules between them.
//
// The hover card's tones, or the full-screen panel's on the page background.
function ReadoutGrid({
  at,
  readouts,
  event,
  recordingStart,
  markers,
  cylinders,
  tone,
}: {
  at: number | null;
  readouts: Readout[];
  event: DiveProfileEvent | null;
  // Where the recording's own 0 lies on the axis - see `describeEvent`.
  recordingStart: number;
  // Whether the marker line holds its place with no marker under the
  // crosshair, as the panel's does: the panel sits above the plot on an upright
  // screen, and a line coming and going would move the plot under the finger.
  markers: boolean;
  cylinders: CylinderLabels;
  tone: "tooltip" | "panel";
}) {
  const quiet =
    tone === "tooltip" ? "text-tooltip-foreground/70" : "text-muted-foreground";
  const absent =
    tone === "tooltip"
      ? "text-tooltip-foreground/40"
      : "text-muted-foreground/60";
  const rule = cn(
    "col-span-2 my-1 border-t",
    tone === "tooltip" ? "border-white/10" : "border-border",
  );

  return (
    <div
      className={cn(
        "grid items-baseline gap-x-2 gap-y-0.5 text-sm",
        // A floor under the values in the panel, which stays put while the
        // crosshair moves: without it the labels shift as a reading gains a
        // digit. As wide as the widest reading set, "3000 psi".
        tone === "panel"
          ? "grid-cols-[minmax(3.75rem,auto)_1fr]"
          : "grid-cols-[auto_auto]",
      )}
    >
      <div className={cn("col-start-2 text-xs", quiet)}>
        {at === null ? "—" : formatElapsed(at)} elapsed
      </div>
      {/* One group per graph the readings come from, ruled off from each
          other, so a ppO₂ reads as belonging to its row of the panel rather than
          as one more line under the depth. `readouts` arrives in drawing order,
          which already runs graph by graph. */}
      {groupByPanel(readouts).map((group, index) => (
        <Fragment key={group[0].panelIndex}>
          {index > 0 && <div aria-hidden className={rule} />}
          {group.map(({ key, label, channel, sample }) => (
            <Fragment key={key}>
              <span
                className={cn(
                  "text-right font-semibold tabular-nums",
                  sample ? channel.colorClass : absent,
                )}
              >
                {sample ? formatChannelValue(sample.value, channel) : "—"}
              </span>
              {/* Takes no cell - a grid drops white space between its items -
                  and keeps the reading and its label apart when the card's text
                  is read or copied. */}{" "}
              <span className={sample ? quiet : absent}>{label}</span>
            </Fragment>
          ))}
        </Fragment>
      ))}
      {/* Under the readings, with a rule above it: a marker is a different
          kind of statement from a sensor value, and the card is showing both at
          an instant where they merely coincide. */}
      {(event || markers) && <div aria-hidden className={rule} />}
      {event ? (
        <>
          {/* The glyph the marker is drawn with on the plot, where a value
              would be, so the row says which mark it is naming. */}
          <span className="text-right">
            <svg
              aria-hidden
              viewBox={`${-EVENT_GLYPH_RADIUS} ${-EVENT_GLYPH_RADIUS} ${2 * EVENT_GLYPH_RADIUS} ${2 * EVENT_GLYPH_RADIUS}`}
              className={cn(
                "inline-block h-2.5 w-2.5",
                glyphFor(event.type).colorClass,
              )}
            >
              <GlyphShape shape={glyphFor(event.type).shape} cx={0} cy={0} />
            </svg>
          </span>{" "}
          {/* `whitespace-normal` against the card's own `nowrap`, and a width
              to wrap inside. An `other`'s text comes off the uploaded file -
              the only free text on this chart - and while the API caps it at
              120 characters, 120 characters on one line is several times the
              plot's width. In the card it is capped; in the panel it takes no
              width of its own and wraps inside the column the labels set. */}
          <span
            className={cn(
              "whitespace-normal font-semibold",
              tone === "tooltip" ? "max-w-56" : "w-0 min-w-full",
              glyphFor(event.type).colorClass,
            )}
          >
            {describeEvent(event, cylinders, recordingStart)}
          </span>
        </>
      ) : (
        markers && (
          <>
            <span className={cn("text-right font-semibold", absent)}>—</span>{" "}
            <span className={absent}>{EVENTS_LABEL}</span>
          </>
        )
      )}
    </div>
  );
}

// Consecutive readouts on the same graph, as one list each.
function groupByPanel(readouts: Readout[]): Readout[][] {
  const groups: Readout[][] = [];
  for (const readout of readouts) {
    const last = groups[groups.length - 1];
    if (last && last[0].panelIndex === readout.panelIndex) last.push(readout);
    else groups.push([readout]);
  }
  return groups;
}

// The legend, and the control for what's plotted.
//
// One and the same thing deliberately: the legend already names every curve and
// carries its color, so it is where you look to ask "which line is which" - and
// "hide that one" is the next thought. A separate row of checkboxes above the
// chart would say the same words twice.
//
// One toggle per *channel*, not per plotted line, which is only a distinction on
// a dive with two cylinders. Both pressure lines draw in the same `--pressure`
// grey, so listing them separately never distinguished them by eye anyway, and
// the crosshair readout still names each cylinder ("Tank pressure (tank 2)").
// Toggling by channel is also what makes the choice worth remembering: "tank 2"
// means a different cylinder on the next dive, while "tank pressure" doesn't.
//
// The markers get an entry too, last, and it is the one that is not a curve -
// see `PROFILE_VIEW_KEYS`. Putting it here rather than anywhere else on the card
// follows from the same pact as the rest: *"Both charts' legends are the control
// for what they plot"*, and a marker switch sitting somewhere else would be the
// first thing on this chart you could turn off from outside its legend.
//
// Folded behind an icon button on the card's title row rather than laid out
// under the plot: the channels and the markers wrap to three lines on a phone,
// and the plot's coloured axes and the crosshair's coloured readings already say
// which curve is which.
function LegendMenu({
  available,
  visible,
  onToggle,
  units,
}: {
  available: readonly ProfileViewKey[];
  visible: readonly ProfileViewKey[];
  onToggle: (key: ProfileViewKey) => void;
  units: UnitSystem;
}) {
  // Named for what this dive's legend actually holds. Most dives carry no
  // markers and get no switch for them, and a group announcing a control it
  // does not contain is the same disagreement the chart polices everywhere
  // else, one level up in the accessibility tree.
  const label = available.includes("events")
    ? "Channels and markers"
    : "Channels";

  return (
    <Popover>
      <IconTooltip label={label}>
        <PopoverTrigger
          render={
            <Button
              type="button"
              variant="ghost"
              size="icon"
              className="h-9 w-9"
            />
          }
        >
          <SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
        </PopoverTrigger>
      </IconTooltip>
      <PopoverContent align="end" className="w-auto p-1">
        <LegendToggles
          label={label}
          available={available}
          visible={visible}
          onToggle={onToggle}
          units={units}
        />
      </PopoverContent>
    </Popover>
  );
}

function LegendToggles({
  label,
  available,
  visible,
  onToggle,
  units,
}: {
  label: string;
  available: readonly ProfileViewKey[];
  visible: readonly ProfileViewKey[];
  onToggle: (key: ProfileViewKey) => void;
  units: UnitSystem;
}) {
  // One group per graph, as the crosshair's readout is grouped: the depth plot
  // with the markers that stand on its baseline, then each panel row.
  const onGraph = (key: ProfileViewKey) =>
    key === "events" ? null : channelAxis(key);
  const groups = [
    available.filter((key) => {
      const axis = onGraph(key);
      return axis === null || !PANEL_AXES.includes(axis);
    }),
    ...PANEL_AXES.map((axis) =>
      available.filter((key) => onGraph(key) === axis),
    ),
  ].filter((group) => group.length > 0);

  return (
    <div className="flex flex-col text-sm" role="group" aria-label={label}>
      {groups.map((group, index) => (
        <div key={group[0]} className={cn(index > 0 && "mt-1 border-t pt-1")}>
          {group.map((key) => {
            const channel =
              key === "events"
                ? null
                : displayChannel(PROFILE_CHANNELS[key], units);
            const on = visible.includes(key);

            return (
              <button
                key={key}
                type="button"
                // `aria-pressed` rather than a checkbox: these are buttons that
                // change the picture in place, and the pressed state is what a
                // screen reader needs to hear. The label stays the channel's
                // name in both states - "Show Depth" on a control that is
                // currently showing depth reads as a description of what it
                // does, not of what it is.
                aria-pressed={on}
                onClick={() => onToggle(key)}
                className={cn(
                  "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring touch:min-h-11",
                  on ? "text-foreground" : "text-muted-foreground",
                )}
              >
                {channel ? (
                  <span
                    aria-hidden
                    className={cn(
                      "inline-block w-4",
                      // A dashed curve gets a dashed swatch. The legend is the
                      // only thing that says the red dashed line is the
                      // ceiling, so a solid swatch beside it would be
                      // describing a curve that isn't on the chart - and the
                      // dash is what separates the ceiling from temperature
                      // when hue alone is close (see `--ceiling`).
                      //
                      // A top border rather than a background, because CSS
                      // has no way to dash a fill: `border-current` picks up
                      // the same `currentColor` `bg-current` does, so both
                      // branches inherit the colour the same way.
                      key === "depth"
                        ? // The water column is a fill, so its swatch is one.
                          "h-2.5 rounded-sm bg-current opacity-15"
                        : channel.dashed
                          ? "border-t-2 border-dashed border-current"
                          : "h-0.5 rounded-full bg-current",
                      // Hidden channels keep their swatch, in the button's
                      // own muted colour rather than the channel's: a grey
                      // line where the teal one was is the whole of "this is
                      // off, and this is what it would be".
                      on && channel.colorClass,
                    )}
                  />
                ) : (
                  // The markers' swatch is a mark, not a line, because that
                  // is what they are on the plot - a swatch of the same width
                  // so the labels stay in one column, with the glyph centred
                  // in it.
                  //
                  // A circle rather than the diamond or the triangle: those
                  // two mean "gas switch" and "a stop" specifically, and one
                  // entry standing for every type in the vocabulary has no
                  // business claiming to be one of them. The circle is
                  // already what the general types draw, and the crosshair
                  // names the particular one in words.
                  //
                  // Uncoloured, unlike every swatch above. Marker colour
                  // answers "does this join to something else on the chart" -
                  // only a gas switch does, in the cylinders' grey - so a
                  // coloured legend swatch would be making that claim on
                  // behalf of every other type, for which it is false.
                  <span aria-hidden className="inline-flex w-4 justify-center">
                    <span className="h-1.5 w-1.5 rounded-full bg-current" />
                  </span>
                )}
                {channel ? `${channel.label} (${channel.unit})` : EVENTS_LABEL}
              </button>
            );
          })}
        </div>
      ))}
    </div>
  );
}

// The chart's accessible name. The visual tooltip says nothing to a screen
// reader, so this has to carry the shape of the dive on its own.
function describeProfile({
  readings,
  events,
  cylinders,
  duration,
  recordingStart,
  units,
  temperatureDecimals,
}: {
  // A channel's readings that are on screen, in display units - not its raw
  // series. Every one of these comes through `drawnValues`, because this
  // function's whole output is extremes and an extreme taken over samples the
  // chart declined to draw describes a curve nobody can see. An empty array is
  // "this channel is not on screen", which covers hidden, absent and too-sparse
  // with one check.
  readings: (key: ProfileChannelKey) => number[];
  events: readonly DiveProfileEvent[];
  cylinders: CylinderLabels;
  // The recording's own visible span, which is what the sentence opens on.
  duration: number;
  // Where the recording's own 0 lies on the axis its markers' times are on: the
  // times are spoken as the crosshair prints them, and the starting gas is
  // judged on the recording's own clock - see `describeEvent`.
  recordingStart: number;
  // The system those readings are already in, so this can name it. Spelled out
  // rather than abbreviated throughout - "ft" is read aloud as a word and "°C"
  // not at all, which is the whole reason `unitWord` exists.
  units: UnitSystem;
  // The grain the recording's temperatures carry - see `toChannelSeries`.
  temperatureDecimals: number;
}): string {
  // `formatElapsedSpoken` here rather than the axis's `MM:SS`: read aloud,
  // "84:36" is not a length of time, whereas "1h 25min" is. The axis keeps
  // `MM:SS`, which is what a dive profile's elapsed scale conventionally shows.
  const parts = [`Dive profile over ${formatElapsedSpoken(duration)}`];

  // At the channel's own resolution, which is the same rule the crosshair follows
  // and the same one the numbers were stored under.
  const say = (key: ProfileChannelKey, value: number) =>
    value.toFixed(
      key === "temperature"
        ? temperatureDecimals
        : displayChannel(PROFILE_CHANNELS[key], units).decimals,
    );
  const word = (key: ProfileChannelKey) => channelWord(key, units);

  // **Which extreme says something is per quantity**, and the NDL is the one
  // that reads against the grain. A maximum NDL is the device's display cap on
  // almost every recreational dive and says nothing, while the minimum is the
  // moment the dive came closest to an obligation - the same call the API makes
  // in choosing which extreme to store. Every other single-extreme channel -
  // depth, the ceiling, time to surface, ppO₂ and the three percentages - names
  // its maximum, and temperature, tank pressure and heart rate are the ranges,
  // because there both ends are a fact about the dive.
  //
  // Driven by `PROFILE_CHANNEL_KEYS` rather than by an `if` per channel, so the sentence
  // keeps the legend's order and a channel added to that list cannot be silently
  // left out of what a screen reader hears.
  const phrase = (key: ProfileChannelKey, values: number[]): string | null => {
    if (values.length === 0) return null;
    const low = say(key, Math.min(...values));
    const high = say(key, Math.max(...values));

    switch (key) {
      case "depth":
        return `maximum depth ${high} ${word(key)}`;
      case "ceiling":
        // The deepest ceiling, which is the one number that says how much
        // decompression this dive owed at its worst.
        return `deco ceiling to ${high} ${word(key)}`;
      case "temperature":
        return `temperature ${low} to ${high} ${word(key)}`;
      case "pressure":
        return `tank pressure ${high} down to ${low} ${word(key)}`;
      case "heart_rate":
        return `heart rate ${low} to ${high} ${word(key)}`;
      case "ndl":
        return `no-decompression time down to ${low} ${word(key)}`;
      case "tts":
        return `time to surface up to ${high} ${word(key)}`;
      case "ppo2":
        return `oxygen partial pressure up to ${high} ${word(key)}`;
      case "cns":
        return `CNS to ${high} ${word(key)}`;
      case "gradient_factor":
        return `gradient factor at depth to ${high} ${word(key)}`;
      case "surface_gradient_factor":
        return `surface gradient factor to ${high} ${word(key)}`;
    }
  };

  for (const key of PROFILE_CHANNEL_KEYS) {
    const said = phrase(key, readings(key));
    if (said) parts.push(said);
  }

  // Spelled out rather than counted, unlike the channels above, because this is
  // the one thing on the chart a sighted reader gets by hovering - and hovering
  // is exactly what this label exists in place of. A curve's shape genuinely
  // can't be read aloud, so its extremes are the honest summary; a list of five
  // markers can be, and "5 markers" would be withholding it.
  //
  // Capped all the same: the API allows 200, and a label that long is not a
  // summary of anything. The real dives in the corpus produce a handful.
  //
  // Sorted before slicing, because "the first eight" only means "the first eight
  // of the dive" on a list that arrived in time order. `nearestEvent` goes out of
  // its way not to assume that (it is a scan for exactly that reason), and two
  // functions in one file taking opposite stances on the same input is how the
  // weaker assumption eventually wins. The API does sort in `shape_events`, so
  // this reorders nothing today; at n <= 200 it costs nothing to not depend on it.
  if (events.length > 0) {
    const named = [...events]
      .sort((first, second) => first.time - second.time)
      .slice(0, MAX_DESCRIBED_EVENTS)
      .map(
        (event) =>
          `${describeEvent(event, cylinders, recordingStart)} at ${formatElapsedSpoken(event.time)}`,
      );
    if (events.length > named.length) {
      named.push(`and ${events.length - named.length} more`);
    }
    parts.push(`markers: ${named.join(", ")}`);
  }

  return `${parts.join(", ")}.`;
}