src/lib/dive-profile.ts
Rama main · réplica de lectura del repositorio original.
import type {
DiveMixture,
DiveProfile,
DiveProfileEvent,
DiveProfilePressureSeries,
DiveProfileSeries,
} from "@/lib/api/dives";
import { niceDomain, type Domain } from "@/lib/chart-scale";
import {
formatDurationForForm,
formatDurationHoursMinutes,
} from "@/lib/date-time";
import {
toDisplayUnits,
unitLabel,
unitSeparator,
unitWord,
type Dimension,
type UnitSystem,
} from "@/lib/units";
// All of the dive profile chart's arithmetic, kept out of the component per the
// repo convention: pure functions in `lib/` get Vitest coverage, components
// aren't rendered in tests. If a number on that chart could be wrong, its
// derivation belongs here.
// The profile's axis is milliseconds (DiveJSON §5.1): a series' `times`, a
// profile's `duration` and an event's `time`, counted from the recording's own
// start - until `placeOnDiveAxis` moves them onto the axis a dive's recordings
// share, which is the axis the chart draws on. Every helper here takes and
// returns that unit, and a second exists only
// where a person reads one - `formatElapsed` and `formatElapsedSpoken` convert,
// once. The `ndl` and `tts` *values* are seconds still: readings, not positions
// on the axis, and `PROFILE_CHANNELS` scales them on their own terms.
export const MILLISECONDS_PER_SECOND = 1000;
/** An axis instant as the `M:SS` the x-axis labels and the crosshair print. */
export function formatElapsed(milliseconds: number): string {
return formatDurationForForm(
Math.round(milliseconds / MILLISECONDS_PER_SECOND),
);
}
/**
* An axis span read aloud - "1h 25min" rather than "85:00", which is not a
* length of time to a screen reader.
*/
export function formatElapsedSpoken(milliseconds: number): string {
return formatDurationHoursMinutes(milliseconds / MILLISECONDS_PER_SECOND);
}
export type ProfileChannelKey =
| "depth"
| "ceiling"
| "temperature"
| "pressure"
// What the diver's wrist sensor or strap read, in whole beats per minute: a
// measurement like the four above, drawn under the depth plot only because
// that plot's edges are taken.
| "heart_rate"
// The six the device's own decompression arithmetic produces. They are
// channels on exactly the terms the first four are - a domain, a scale, a
// unit and a toggle - and they are drawn away from the depth plot only
// because the plot has two edges and these carry four scales of their own
// between them. See `CHANNEL_AXIS`.
| "ndl"
| "tts"
| "ppo2"
| "cns"
| "gradient_factor"
| "surface_gradient_factor";
export interface ProfileChannel {
key: ProfileChannelKey;
label: string;
unit: string;
// What the API's integer `values` are divided by to reach display units.
//
// For the depth plot's four channels this is exactly the API's own
// constant - `DEPTH_SCALE`/`CEILING_SCALE`/`TEMPERATURE_SCALE`/`PRESSURE_SCALE` in
// `schemas/dive_profile.py`, and those two lists are a pair. For `ndl` and
// `tts` it is not: the format's scale is 1 because the wire carries whole
// seconds, and a diver reads a no-decompression limit in minutes, so the
// divisor is 60 and the axis is numbered in the unit the wrist showed.
scale: number;
// How many decimals a reading of this channel is worth showing. Same
// resolution as the stored scale: showing more digits than were stored is a
// false claim about the sensor. Temperature's is the most it is ever quoted
// to: the format fills hundredths with zeros for a source that states tenths
// or whole degrees, so `toChannelSeries` narrows it to the grain each
// recording's readings actually carry.
decimals: number;
// The Tailwind text color class the curve, its axis and its readout all use.
colorClass: string;
// Depth grows downward from the surface, so its axis is upside down relative
// to every other chart in the app. The flag lives here rather than as an `if`
// in the component, so nothing else has to remember which channel is special.
inverted: boolean;
// Drawn as a dashed line rather than a solid one - in the plot *and* in the
// legend swatch, which is the reason this is a property of the channel rather
// than a check at the one place that draws a polyline. A solid swatch next to
// a dashed curve is the one thing a legend must not say, and the two live far
// enough apart in the component to drift if each decided for itself.
dashed: boolean;
// What a gap in this channel's samples means, and so how hard the chart works
// to avoid drawing across one.
//
// For a measured channel a gap means "not recorded", and with fewer than three
// samples there is no cadence to judge one by - so joining them is the only
// honest thing left, and `gapThreshold`'s `Infinity` says exactly that. For
// the deco ceiling a gap means **no obligation existed**, which is a fact
// about the dive rather than about the sensor, and joining across it draws a
// forbidden zone over water the diver was free to be in.
//
// Only the second kind is worth refusing to draw for, and refusing has a real
// cost: a two-sample channel splits into two runs of one, which draw nothing,
// and a channel with nothing drawable is not plotted at all. Paid for the
// ceiling; not paid for depth, where it would replace a two-point line with
// "this recording's file recorded no samples to plot" over a dive that
// recorded two.
//
// Coincides with `dashed` today and says something different: that one is how
// the curve is drawn, this is what its absence means.
gapsAreMeaningful: boolean;
}
export const PROFILE_CHANNELS: Record<ProfileChannelKey, ProfileChannel> = {
depth: {
key: "depth",
label: "Depth",
unit: "m",
scale: 100, // centimeters
decimals: 1,
colorClass: "text-teal",
inverted: true,
dashed: false,
gapsAreMeaningful: false,
},
ceiling: {
key: "ceiling",
label: "Deco ceiling",
unit: "m",
// Depth's scale, not one of its own - see `CEILING_SCALE` in the API's
// `schemas/dive_profile.py`. A ceiling is a depth, and it is drawn against
// the depth axis, so the two have to divide by the same number or the
// shaded region would not line up with the curve it bounds.
scale: 100, // centimeters
decimals: 1,
colorClass: "text-ceiling",
inverted: true,
// The only dashed curve on the chart, and the dash is load-bearing: it is
// the one line **on the depth plot** that was never measured. Depth,
// temperature and pressure are readings; a ceiling is a computed limit that
// moved as the diver's tissues loaded, and a solid line would present the
// two as the same kind of fact.
//
// The rows under the depth plot are drawn solid, which is not an exception
// to that rule but the absence of the case it answers: no row there puts a
// measured curve beside a computed one - heart rate has a row of its own,
// and every curve on the others is the device's own arithmetic - so a dash
// would distinguish nothing from nothing.
dashed: true,
// A break in this series is a stretch of dive with no decompression
// obligation, not a sensor dropping out.
gapsAreMeaningful: true,
},
temperature: {
key: "temperature",
label: "Temperature",
unit: "°C",
scale: 100, // hundredths of a degree
decimals: 2,
colorClass: "text-coral",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
pressure: {
key: "pressure",
label: "Tank pressure",
unit: "bar",
scale: 10, // tenths of a bar
decimals: 0,
colorClass: "text-pressure",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
heart_rate: {
key: "heart_rate",
label: "Heart rate",
unit: "bpm",
scale: 1, // whole beats per minute
decimals: 0,
colorClass: "text-heart-rate",
inverted: false,
dashed: false,
// A dropout is a stretch the sensor lost contact, not a fact about the dive.
gapsAreMeaningful: false,
},
// The six below are the device's own decompression arithmetic. None of them is
// inverted (more is up, on all six), none is dashed - see the note on the
// ceiling's dash - and a gap in any of them means "not recorded here", the
// measured channels' meaning rather than the ceiling's.
ndl: {
key: "ndl",
label: "No-deco time",
unit: "min",
// Seconds on the wire. See `scale` above for why this one is not the API's
// own constant.
scale: 60,
decimals: 0,
colorClass: "text-ndl",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
tts: {
key: "tts",
label: "Time to surface",
unit: "min",
scale: 60,
decimals: 0,
colorClass: "text-tts",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
ppo2: {
key: "ppo2",
label: "ppO₂",
unit: "bar",
scale: 100, // hundredths of a bar
// Two, because tenths cannot tell 1.30 from 1.32 and the limits divers set
// themselves live in that last digit.
decimals: 2,
colorClass: "text-ppo2",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
cns: {
key: "cns",
label: "CNS",
unit: "%",
scale: 10, // tenths of a percent
decimals: 1,
colorClass: "text-cns",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
gradient_factor: {
key: "gradient_factor",
// Says where it was read, beside the surface reading and the "GF 30/85"
// settings the recordings card names.
label: "Gradient factor at depth",
unit: "%",
scale: 1, // whole percent
decimals: 0,
colorClass: "text-gradient-factor",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
surface_gradient_factor: {
key: "surface_gradient_factor",
label: "Surface gradient factor",
unit: "%",
scale: 1, // whole percent
decimals: 0,
colorClass: "text-surface-gradient-factor",
inverted: false,
dashed: false,
gapsAreMeaningful: false,
},
};
// The channels in the order they're plotted, listed in the legend and read out.
// That is the order the API's `PROFILE_CHANNEL_ORDER` sorts a profile's
// `channels` by and DiveJSON §6.4 declares its members in, but for the surface
// gradient factor ahead of the gradient factor. Nothing here reads the API's
// order, so the two can differ. A separate list rather than `Object.keys` on
// the record above, which gives an order only by accident of how the object
// happens to be written.
export const PROFILE_CHANNEL_KEYS: readonly ProfileChannelKey[] = [
"depth",
"ceiling",
"temperature",
"pressure",
"heart_rate",
"ndl",
"tts",
"ppo2",
"cns",
"surface_gradient_factor",
"gradient_factor",
];
// Everything the legend switches, which is every channel plus the event
// markers - and the markers are emphatically *not* another channel. They have no
// scale, no unit and nothing to invert, so a `PROFILE_CHANNELS` entry would have
// had to invent all three; what they share with a channel is only that the
// legend names them and the diver can turn them off.
//
// A second list rather than a widened `ProfileChannelKey`, so that stays the type
// of "a thing with a domain" - which is what every axis, readout and segmenter in
// this module is written against, and none of them can say anything about a
// marker.
export type ProfileViewKey = ProfileChannelKey | "events";
export const PROFILE_VIEW_KEYS: readonly ProfileViewKey[] = [
...PROFILE_CHANNEL_KEYS,
"events",
];
// What the legend calls the markers. No unit follows it, unlike every channel
// beside it - "Markers" is already the word the profile card's description and
// the chart's accessible summary use for them.
export const EVENTS_LABEL = "Markers";
// Which channels share one domain, and therefore one labelled scale. Depth and
// the ceiling are one axis by construction - the same meters, so a 3 m ceiling
// cannot be drawn below a 40 m depth - and the two gradient factors and the CNS
// clock are one because they are all whole percent.
//
// Named after the quantity rather than after the unit: `ppo2` and `pressure` are
// both bar and are emphatically **not** one axis, because 1.3 bar of oxygen on a
// 230-bar tank scale is a flat line along the baseline.
export type ProfileAxisKey =
| "depth"
| "temperature"
| "pressure"
| "heart_rate"
| "no_deco"
| "time_to_surface"
| "ppo2"
| "percent";
const CHANNEL_AXIS: Record<ProfileChannelKey, ProfileAxisKey> = {
depth: "depth",
ceiling: "depth",
temperature: "temperature",
pressure: "pressure",
heart_rate: "heart_rate",
// Minutes both, and still two rows: a no-deco limit runs to 99 while time to
// surface is a few minutes on most dives, so on one scale it is a flat line.
ndl: "no_deco",
tts: "time_to_surface",
ppo2: "ppo2",
cns: "percent",
gradient_factor: "percent",
surface_gradient_factor: "percent",
};
// The axes that go into the panel underneath the depth plot, in the order their
// rows stack - every axis but the three the plot itself carries.
//
// **The split is forced, not stylistic.** The depth plot has two edges and holds
// at most three scales, and they are taken. So every other axis is housed where a
// labelled scale can be given it, and the panel is one row per axis so that every
// curve on the chart is drawn against numbers that belong to it.
//
// Heart rate first: it is the one measurement among them, and the rows below it
// are the computer's arithmetic.
export const PANEL_AXES: readonly ProfileAxisKey[] = [
"heart_rate",
"no_deco",
"time_to_surface",
"ppo2",
"percent",
];
// The value an axis will not scale past however high a reading on it goes, or
// null where the readings are the only bound there is.
//
// Only the percent axis has one, and only because one of its channels can be
// **wrong**. A Suunto Ocean's `gf99` reaches 14 060 % on a deep decompression
// ascent - not a reading on some other scale, a fault: the same device's saved
// tissue tensions put the real figure near 60, and across a 79-dive corpus the
// inequality GF99 <= surface GF, which no decompression model can violate, holds
// on every sample of the 75 clean dives and fails on two samples in five of the
// four broken ones.
//
// Fitted to that reading the row runs to 15 000, and the surface gradient factor
// - the channel that is correct, and the whole readable story of the ascent - is
// drawn across 1.1 % of it. **One channel's fault erases another channel's
// data**, which is what the bound exists to stop.
//
// 200 rather than 100, and the number comes from the quantity rather than from
// this dive's readings. 100 % is the M-value, and a gradient factor past twice it
// is not telling a diver anything they can act on; below that every real reading
// still fits, since the surface gradient factor peaks at 121 across the corpus's
// 75 clean dives and at 173 across all 79. A ceiling of 100 would draw a healthy
// 121 leaving the panel on a dive where nothing was wrong, which is the same
// failure this fixes, one order of magnitude down.
//
// See DECISIONS.md, *"The percent axis stops at 200 %, and the curve that
// overruns it is drawn leaving the row"*, for the alternatives and why a
// percentile is not one of them.
const AXIS_BOUND: Record<ProfileAxisKey, number | null> = {
depth: null,
temperature: null,
pressure: null,
// A sensor reads a heart rate in a band an axis fits well, and cannot be wrong
// by the orders of magnitude the percent bound exists for.
heart_rate: null,
no_deco: null,
time_to_surface: null,
ppo2: null,
percent: 200,
};
/**
* The domain one axis is drawn against, fitted to `values` up to whatever
* ceiling that axis declares.
*
* Below the ceiling this is `niceDomain` and nothing else, so every dive whose
* readings are in range is scaled exactly as it always was. Above it the axis is
* the declared band, which is deliberately not derived from the readings at all:
* a rule that moved with the data - a percentile, say - would draw the same
* channel against a different scale on adjacent dives with nothing on screen to
* say so. On the worst dive in the corpus the 99th percentile still yields 2 000
* and the 95th yields 400.
*
* **No reading is altered by any of this.** The series, the crosshair readout,
* the accessible summary and everything downstream of the API still carry the
* figure the device wrote; what is bounded is the axis, and a curve that leaves
* the band is drawn leaving it.
*
* Zero-anchored in the bounded branch, which is right for the one axis that has
* a bound: DiveJSON §6.4 floors all three percent channels at zero, so there is
* no reading below it for the band to cut off.
*/
export function axisDomain(
axis: ProfileAxisKey,
values: readonly number[],
): Domain {
const fitted = niceDomain([...values]);
const bound = AXIS_BOUND[axis];
return bound === null || fitted.max <= bound
? fitted
: niceDomain([0, bound]);
}
// The least a temperature axis spans, in degrees Celsius. Many sources state
// tenths of a degree, and fitted tight a dive whose water moved 0.4 °C draws each
// tenth as a quarter of the plot - a staircase that reads as a swing. Twenty
// tenths keeps one step to a twentieth of the height.
const MIN_TEMPERATURE_SPAN_C = 2;
/**
* The temperature axis: `niceDomain` over the readings, widened about their
* middle to at least `MIN_TEMPERATURE_SPAN_C` in the diver's units. A dive whose
* readings span more is fitted exactly as `niceDomain` fits it.
*/
export function temperatureDomain(
values: readonly number[],
units: UnitSystem,
): Domain {
if (values.length === 0) return niceDomain([]);
const span =
toChannelDisplay(MIN_TEMPERATURE_SPAN_C, "temperature", units) -
toChannelDisplay(0, "temperature", units);
const middle = (Math.min(...values) + Math.max(...values)) / 2;
return niceDomain([...values, middle - span / 2, middle + span / 2]);
}
// What follows the number on a panel row's top tick - the separator and the unit
// both, so "100%" is written the way that quantity is. Read off the channels
// themselves rather than written out again, so a row cannot come to disagree with
// the curves in it.
//
// Minutes, beats per minute and bar get none. The legend already names a no-deco
// limit, a time to surface, a heart rate and a ppO2, and the widest label in the
// left gutter sets where the whole plot starts, so " min", " bpm" or " bar" would
// cost every row its width.
export function axisUnitSuffix(
axis: ProfileAxisKey,
units: UnitSystem,
): string {
const key = PROFILE_CHANNEL_KEYS.find(
(candidate) => CHANNEL_AXIS[candidate] === axis,
);
if (!key) return "";
const { unit } = displayChannel(PROFILE_CHANNELS[key], units);
return unit === "min" || unit === "bpm" || unit === "bar"
? ""
: `${channelSeparator(key)}${unit}`;
}
/** The axis a channel is scaled against. */
export const channelAxis = (key: ProfileChannelKey): ProfileAxisKey =>
CHANNEL_AXIS[key];
/** Which channels of a selection belong to one axis, in the legend's order. */
export function channelsOnAxis(
shown: readonly ProfileChannelKey[],
axis: ProfileAxisKey,
): ProfileChannelKey[] {
return PROFILE_CHANNEL_KEYS.filter(
(key) => CHANNEL_AXIS[key] === axis && shown.includes(key),
);
}
/**
* Where a selection's scales go: which channel labels the depth plot's left edge,
* which labels its right, and which rows the panel under it grows.
*
* A pure function over channel keys rather than logic inside the component,
* because the rule has one input per channel and the combination that breaks it
* is never the obvious one - `lib/dive-profile.test.ts` sweeps every selection,
* which no sequence of renders could afford to.
*
* **Sides are fixed while there are two scales to tell apart**: meters on the
* left, temperature on the right, and pressure taking whichever of the two the
* others left free. A diver reads this chart across a logbook of dives, and an
* axis that changed edges with the channel mix would make them re-read the colour
* of the numbers every time. A single scale goes on the left and the right edge
* stays empty — there is no side to protect when nothing shares the plot with it.
*
* The panel rows are independent of all that: they are their own plots with their
* own left edges, so nothing about the depth plot's edges changes when one
* appears.
*/
export interface ProfileScalePlacement {
/** Labels the depth plot's left edge; null only when nothing is plotted on it. */
left: ProfileChannelKey | null;
/** Labels its right edge, exactly when the plot holds a second scale. */
right: ProfileChannelKey | null;
/**
* Labels the right edge too, a number under each of `right`'s, when the plot
* holds a third scale - pressure under temperature, its domain fitted to
* temperature's ticks.
*/
underRight: ProfileChannelKey | null;
/** One row per axis the selection puts below the depth plot, in `PANEL_AXES` order. */
panels: ProfileAxisKey[];
}
export function profileScalePlacement(
shown: readonly ProfileChannelKey[],
): ProfileScalePlacement {
const has = (key: ProfileChannelKey) => shown.includes(key);
// "Depth, or the ceiling if depth is off" - one scale, labelled in whichever
// of the two is drawing it.
const vertical: ProfileChannelKey | null = has("depth")
? "depth"
: has("ceiling")
? "ceiling"
: null;
const temperature: ProfileChannelKey | null = has("temperature")
? "temperature"
: null;
const pressure: ProfileChannelKey | null = has("pressure")
? "pressure"
: null;
const scales = [vertical, temperature, pressure].filter(
(key) => key !== null,
);
const hasTwoScales = scales.length > 1;
return {
left: hasTwoScales ? (vertical ?? pressure) : (scales[0] ?? null),
// Reachable only with meters on the left, since two scales without
// temperature means depth (or the ceiling) and pressure - so this can never
// hand pressure to both edges at once.
right: hasTwoScales ? (temperature ?? pressure) : null,
underRight: scales.length === 3 ? pressure : null,
panels: PANEL_AXES.filter((axis) =>
shown.some((key) => CHANNEL_AXIS[key] === axis),
),
};
}
export interface ChannelSeries {
channel: ProfileChannel;
// Elapsed milliseconds, as served. Kept as `t` rather than following the
// wire's `times`: this is the chart's own shape, and every consumer of it below
// reads a *converted* series, which is the difference worth keeping visible.
t: number[];
// Display units - the wire's integer `values` divided by the channel's scale.
values: number[];
}
// What `lib/units.ts` supplies for a channel it has no dimension for: the two
// things a `Dimension` would have answered that the channel's own `unit` does
// not - how it is spoken aloud, and what sits between the value and the label.
interface InvariantUnit {
/** Spoken form, for the accessible summary: "%" is read aloud as nothing. */
word: string;
/** What sits between value and label - "" where the unit attaches. */
separator: string;
}
// Which of `lib/units.ts`'s dimensions each channel is a reading of - or, for a
// channel that reads the same number in both systems, what that module would
// have supplied if it had one.
//
// The ceiling is a **depth**, and sharing depth's dimension is what keeps the two
// converting identically - the same binding `scale` already has for the same
// reason. A shaded deco region drawn against a curve converted by any other factor
// would drift off the water it bounds.
//
// **Heart rate and the six deco channels have no dimension, and inventing one
// would have been the wrong half of the choice.** `lib/units.ts` names ppO₂, CNS
// and duration as deliberately absent from `Dimension` - a bar is a bar and a
// percent is a percent in both systems, and a minute is not a length - the
// gradient factors are percentages too, and a beat per minute reads the same in
// either system. Adding unit-invariant dimensions there would put
// identity conversions in the one module whose entire job is that a conversion
// exists, so the widening happens here instead, where the exception is local to
// the chart that has it.
const CHANNEL_DIMENSION: Record<ProfileChannelKey, Dimension | InvariantUnit> =
{
depth: "depth",
ceiling: "depth",
temperature: "temperature",
pressure: "pressure",
heart_rate: { word: "beats per minute", separator: " " },
ndl: { word: "minutes", separator: " " },
tts: { word: "minutes", separator: " " },
ppo2: { word: "bar", separator: " " },
// Attached, the way a degree is: "23.4%" is how a percentage is written
// everywhere, including on the computers these readings come off.
cns: { word: "percent", separator: "" },
gradient_factor: { word: "percent", separator: "" },
surface_gradient_factor: { word: "percent", separator: "" },
};
// A `Dimension` is a string and the invariant spelling is an object, so `typeof`
// is the discriminant and each of the four readers below narrows on it directly.
// Deliberately not routed through a `dimensionOrNull` helper: three of the four
// want the *other* branch's value, so such a helper would be followed by a cast
// or a `!` at every call site to recover what it had just discarded.
/**
* A channel as it is labelled and quoted for one system.
*
* The metric channel unchanged, or one carrying the imperial unit and no decimals
* at all: a foot and a psi are each finer than the tenth of a metric unit the
* stored scale resolves to, so a decimal there would be inventing precision
* rather than preserving it. A degree Fahrenheit is coarser than a tenth of a
* degree Celsius, and is quoted whole all the same, which is how a diver reads
* one.
*
* A channel with no dimension is returned unchanged in **both** systems - there is
* no imperial spelling of a percent, and dropping its decimals would round every
* ppO₂ to a whole bar.
*/
export function displayChannel(
channel: ProfileChannel,
units: UnitSystem,
): ProfileChannel {
const entry = CHANNEL_DIMENSION[channel.key];
if (units === "metric" || typeof entry !== "string") return channel;
return {
...channel,
unit: unitLabel(entry, units),
decimals: 0,
};
}
/**
* One reading, converted - **after** the wire scale has been divided out.
*
* That order is the whole rule: `scale` is a pair with `schemas/dive_profile.py`
* and describes how the API encodes an integer, not how a diver reads one. Folding
* a unit conversion into it would make this app's idea of a centimetre disagree
* with the API's.
*
* A channel with no dimension passes through: the number a diver reads is the
* same in either system.
*/
function toChannelDisplay(
scaledValue: number,
key: ProfileChannelKey,
units: UnitSystem,
): number {
const entry = CHANNEL_DIMENSION[key];
return typeof entry === "string"
? toDisplayUnits(scaledValue, entry, units)
: scaledValue;
}
/** The spoken unit for a channel, for the chart's accessible description. */
export function channelWord(key: ProfileChannelKey, units: UnitSystem): string {
const entry = CHANNEL_DIMENSION[key];
return typeof entry === "string" ? unitWord(entry, units) : entry.word;
}
/** What sits between a reading and its unit - see `DimensionSpec.separator`. */
function channelSeparator(key: ProfileChannelKey): string {
const entry = CHANNEL_DIMENSION[key];
return typeof entry === "string" ? unitSeparator(entry) : entry.separator;
}
// The profile as the chart draws it: every channel ends at `dive_end_time` and
// `duration` becomes that instant, so the axis, every scale fitted from the
// series, the crosshair and the accessible summary all stop where the dive did.
// A computer goes on recording at the surface for minutes after a dive, in case
// the diver descends again; those samples stay in the data and off the chart.
//
// A sample at the end is kept, so depth ends on the reading that closed the
// dive, and nothing is invented to reach the edge. Events are left alone: the
// chart clips its markers to `duration`. With no end in the response - null, or
// a body from a build that did not send one - the profile comes back whole.
export function cutAtDiveEnd(profile: DiveProfile): DiveProfile {
const end = profile.dive_end_time;
if (end == null || end >= profile.duration) return profile;
const cut = <S extends DiveProfileSeries>(series: S): S => {
const past = series.times.findIndex((time) => time > end);
return past === -1
? series
: {
...series,
times: series.times.slice(0, past),
values: series.values.slice(0, past),
};
};
const visible: DiveProfile = {
...profile,
duration: end,
pressures: (profile.pressures ?? []).map(cut),
};
for (const key of PROFILE_SERIES_KEYS) {
const series = profile[key];
if (series) visible[key] = cut(series);
}
return visible;
}
// Where one recording sits on the time axis a dive's recordings share: its own 0
// at `start`, the axis running from 0 to `end` (`diveAxisEnd`). `start` is the
// API's `start_on_dive_axis`, which places every recording it can by the
// computers' clocks; the web never compares clocks itself.
export interface DiveAxis {
start: number;
end: number;
}
// The profile moved `start` milliseconds along the axis: every series' `times`,
// every event's `time`, `duration` and `dive_end_time`. Run after
// `cutAtDiveEnd`, so the cut is made on the recording's own clock, and the
// readings themselves are left alone - nothing derived from the spacing of the
// samples changes.
export function placeOnDiveAxis(
profile: DiveProfile,
start: number,
): DiveProfile {
if (start === 0) return profile;
const shift = <S extends DiveProfileSeries>(series: S): S => ({
...series,
times: series.times.map((time) => time + start),
});
const placed: DiveProfile = {
...profile,
duration: profile.duration + start,
dive_end_time:
profile.dive_end_time == null
? profile.dive_end_time
: profile.dive_end_time + start,
pressures: (profile.pressures ?? []).map(shift),
events: profile.events?.map((event) => ({
...event,
time: event.time + start,
})),
};
for (const key of PROFILE_SERIES_KEYS) {
const series = profile[key];
if (series) placed[key] = shift(series);
}
return placed;
}
// Where the shared axis ends: the latest visible end - what `cutAtDiveEnd`
// leaves - over every placed recording whose samples are in hand. One extent
// for all of them, so switching between two placed recordings keeps the axis.
export function diveAxisEnd(
placed: readonly { start: number; profile: DiveProfile }[],
): number {
return Math.max(
0,
...placed.map(
({ start, profile }) => start + cutAtDiveEnd(profile).duration,
),
);
}
// A channel's stored integers as display units, or `null` when the profile
// doesn't carry that channel.
//
// Divides, never multiplies by a reciprocal: `1234 / 100` is the correctly
// rounded `12.34`, whereas `1234 * 0.01` is `12.340000000000002` - exactly the
// noise the integer encoding was chosen to remove.
// Every channel the API serves as a single series, which is all of them but tank
// pressure - that one is a list, one entry per cylinder, and has
// `toPressureSeries` below. Derived rather than spelled out, so a channel added
// to `ProfileChannelKey` is one this accepts without a second edit.
export type ProfileSeriesKey = Exclude<ProfileChannelKey, "pressure">;
const PROFILE_SERIES_KEYS = PROFILE_CHANNEL_KEYS.filter(
(key): key is ProfileSeriesKey => key !== "pressure",
);
export function toChannelSeries(
profile: DiveProfile,
key: ProfileSeriesKey,
units: UnitSystem,
): ChannelSeries | null {
const recorded: DiveProfileSeries | null | undefined = profile[key];
const after = SPURIOUS_ZERO_AFTER[key];
const series =
recorded && after !== undefined
? withoutSpuriousZeros(recorded, after)
: recorded;
if (!series || series.times.length === 0) return null;
const channel = PROFILE_CHANNELS[key];
const display = displayChannel(channel, units);
// Converted here, once, rather than at each of the dozen places downstream that
// read `values`. Everything past this point - the domains, the axis ticks, the
// crosshair, the accessible extremes - is then already in the units it renders
// in, so `niceDomain` picks round numbers in the system the diver is reading
// and there is nowhere left for a conversion to be forgotten.
return {
channel:
key === "temperature"
? {
...display,
decimals: Math.min(
display.decimals,
grainDecimals(series.values, channel.scale),
),
}
: display,
t: series.times,
values: series.values.map((value) =>
toChannelDisplay(value / channel.scale, key, units),
),
};
}
// The channels a computer writes a zero into when it has stopped computing them
// - at the end of the dive, on a surface swim, or for a glitched sample - and the
// reading, in the wire's units, from which a fall to zero in one sample cannot
// be real. Neither quantity can drop that far that fast, so the zero is not a
// reading.
const SPURIOUS_ZERO_AFTER: Partial<Record<ProfileSeriesKey, number>> = {
// The 100-minute cap a no-deco limit shows while it has nothing to count
// down. A zero after it would plot as an obligation the diver never had.
ndl: 100 * 60,
// A surface gradient factor falls only as fast as the fastest tissue
// off-gases, a few percent of itself in a sample. From 1 % a zero is a
// reading rounding down; across the corpus nothing falls to zero from 2-5 %,
// and everything from 6 % up is a device writing nothing.
surface_gradient_factor: 5,
};
// A series without its spurious zeros: a run of zeros that starts straight after
// a reading of at least `after` is dropped whole, up to the next reading, while
// one reached by counting down - a minute, then none - is real and stays.
export function withoutSpuriousZeros(
series: DiveProfileSeries,
after: number,
): DiveProfileSeries {
const kept: number[] = [];
let spurious = false;
series.values.forEach((value, index) => {
if (value !== 0) spurious = false;
else if (index > 0 && series.values[index - 1] >= after) spurious = true;
if (!spurious) kept.push(index);
});
if (kept.length === series.values.length) return series;
return {
...series,
times: kept.map((index) => series.times[index]),
values: kept.map((index) => series.values[index]),
};
}
// The fewest decimals that state every one of a series' stored integers exactly
// once divided by `scale`, a power of ten: 2 for hundredths with any odd
// hundredth among them, 1 where every reading is a whole tenth, 0 where every
// one is a whole unit. A source coarser than the format's grain arrives with the
// grain filled by zeros, and this is how the readout tells those apart from
// digits a sensor measured.
export function grainDecimals(
values: readonly number[],
scale: number,
): number {
let decimals = 0;
for (let step = scale; step > 1; step /= 10, decimals += 1) {
if (values.every((value) => value % step === 0)) return decimals;
}
return decimals;
}
// Same, per cylinder. Each cylinder is its own line on the chart, keyed by its
// `gas_number` and named through `cylinderName`.
export function toPressureSeries(
profile: DiveProfile,
units: UnitSystem,
): (ChannelSeries & { gasNumber: number })[] {
const channel = PROFILE_CHANNELS.pressure;
return (profile.pressures ?? [])
.filter((cylinder) => cylinder.times.length > 0)
.map((cylinder: DiveProfilePressureSeries) => ({
channel: displayChannel(channel, units),
gasNumber: cylinder.gas_number,
t: cylinder.times,
values: cylinder.values.map((value) =>
toChannelDisplay(value / channel.scale, "pressure", units),
),
}));
}
// The dive's cylinders as far as naming one goes: only their labels, in the
// order the Tanks card lists them.
export type CylinderLabels = readonly Pick<DiveMixture, "gas_number">[];
// What to call the cylinder a pressure series or a gas switch names by its
// label: the tank's position in the Tanks card's own 1-based numbering where
// exactly one of the dive's cylinders carries that label, and otherwise the
// device's label with the word kept - "gas 3", never a bare 3 that reads as a
// row of the card. That is the rule `tankGasUseRows` names a tank by, including
// a label two cylinders share joining neither. The label itself counts from 0,
// so showing it bare would name the card's next row.
export function cylinderName(
gasNumber: number,
cylinders: CylinderLabels,
): string {
const positions = cylinders.flatMap((cylinder, index) =>
cylinder.gas_number === gasNumber ? [index] : [],
);
return positions.length === 1
? `tank ${positions[0] + 1}`
: `gas ${gasNumber}`;
}
// The one vertical axis depth and the deco ceiling are both scaled against.
//
// One domain across both, exactly as every cylinder shares one pressure domain
// and for a sharper version of the same reason: a ceiling is a bound *on* the
// depth curve, so on an axis of its own a 3 m ceiling could be drawn below a
// 40 m depth. Anchored at the surface by feeding `niceDomain` the `0` that makes
// `Math.floor(0 / step) * step` land on it.
//
// Both channels' values go in whether or not they're currently plotted, so the
// axis doesn't shift under the curves when the ceiling is toggled. On real data
// that costs nothing - a ceiling is always shallower than the depth it was
// computed at - but a domain that depends on what's visible is a needless way
// for the picture to move.
export function depthDomain(
depthValues: readonly number[],
ceilingValues: readonly number[],
): Domain {
return niceDomain([0, ...depthValues, ...ceilingValues]);
}
// The event nearest the instant `at`, or null when the closest is further away
// than `maxDeltaMs`.
//
// A tolerance rather than a nearest-always, because unlike a channel readout an
// event marker is a discrete thing at a discrete instant: naming the dive's only
// gas switch while the cursor sits twenty minutes away from it would be a
// caption for something that isn't under the crosshair. The caller sets the
// tolerance from the chart's own geometry, so it stays a fixed distance in
// pixels rather than a fixed span of time - a tolerance that reads well on a
// 20-minute dive is invisible on a three-hour one.
//
// A linear scan over a list the API caps at 200, and deliberately not a binary
// search: the saving is unmeasurable and the scan doesn't care whether the list
// arrived sorted.
//
// **Ties go to the earlier event**, and that is compared on `time` rather than left
// to iteration order. `delta < bestDelta` alone would mean "first in the array",
// which is only the same thing on a sorted list - and not assuming sorted input
// is the whole reason this is a scan. The API does sort in `shape_events`, so this
// decides nothing today; a comment claiming one rule while the code follows
// another is the part that would eventually cost someone an afternoon.
export function nearestEvent(
events: readonly DiveProfileEvent[],
at: number,
maxDeltaMs: number,
): DiveProfileEvent | null {
let best: DiveProfileEvent | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const event of events) {
const delta = Math.abs(event.time - at);
if (delta > maxDeltaMs) continue;
if (
best === null ||
delta < bestDelta ||
(delta === bestDelta && event.time < best.time)
) {
best = event;
bestDelta = delta;
}
}
return best;
}
// How far into the recording a gas switch is the computer logging the gas the
// dive starts on rather than a switch. Every first switch in the local corpus
// sits inside the opening 10 s, and every later one minutes in.
const STARTING_GAS_MS = 30 * MILLISECONDS_PER_SECOND;
// What a marker says, in words - for the crosshair readout and for the chart's
// accessible summary.
//
// An event with **no type** is the device's own wording and is passed through as
// it stands: that is the entire point of the absent type, and rephrasing
// "Mandatory Safety Stop Broken" into something tidier would be inventing a
// claim about a dive. The API guarantees a label wherever it sends no type (its
// `shape_events` drops one without), so the fallback noun here is only for
// a payload that broke that promise - an unlabelled tick with no words is still
// better than the string "undefined".
//
// **One value per distinct meaning, not one per vendor string**, which is why
// there is no case for "Mandatory Safety Stop Broken" beside
// `safety_stop_violation`: the spelling travels in `label` and the chart says
// what happened.
//
// The `default` is not dead code, however much the exhaustive `case` list makes
// it look like one. `ProfileEventType` is closed *today*, and the two repos
// deploy independently: an API that grows a fourteenth type reaches a browser
// still running this bundle, where `event.type` is a string TypeScript merely
// believes is one of thirteen. Without the branch the switch falls off the end
// and returns `undefined` from a function typed `: string`, which renders as an
// empty tooltip line and puts the literal word "undefined" in the chart's
// `aria-label`. It is also where a null type lands, and where an older build's
// `"other"` lands - three arrivals, one honest answer, and deliberately one
// `return` rather than three copies of it.
//
// `recordingStart` is where the recording's own 0 lies on the axis `event.time`
// is on (see `placeOnDiveAxis`): "the dive's start" is the recording's, so a
// second computer placed 1:34 into the dive still opens on "Dive started".
export function describeEvent(
event: DiveProfileEvent,
cylinders: CylinderLabels = [],
recordingStart = 0,
): string {
switch (event.type) {
case "gas_switch":
if (event.time - recordingStart <= STARTING_GAS_MS) return "Dive started";
// Named as the pressure curves name the cylinder - and `0` is a real
// label, the first the reader gives, so this tests for null rather than
// for falsiness.
return event.gas_number == null
? "Gas switch"
: `Gas switch to ${cylinderName(event.gas_number, cylinders)}`;
case "deep_stop":
return "Deep stop";
case "safety_stop":
return "Safety stop";
case "bookmark":
return "Bookmark";
case "ascent_rate":
return "Ascent rate";
case "safety_stop_mandatory":
return "Mandatory safety stop";
case "safety_stop_violation":
return "Safety stop broken";
case "deep_stop_violation":
return "Deep stop broken";
case "ceiling_violation":
return "Deco ceiling broken";
case "ndl_reached":
return "No-deco limit reached";
case "ppo2_high":
return "ppO₂ high";
case "pressure_low":
return "Tank pressure low";
case "depth_alarm":
return "Depth alarm";
default:
return event.label?.trim() || "Device event";
}
}
// How far apart two samples have to be before the line between them is a lie,
// derived from the series' own cadence rather than fixed.
//
// It has to be derived: cadence varies from 1 s (Suunto Ocean temperature) to
// 10 s (every Suunto depth series), and server-side downsampling stretches it
// further and unevenly. A fixed threshold would either break every downsampled
// line into confetti or draw straight through a real ten-minute dropout. Three
// times the median delta is comfortably above normal jitter and comfortably
// below any dropout worth showing; the floor keeps a fast, regular series from
// breaking on the jitter in its own stamps.
const GAP_FACTOR = 3;
export const MIN_GAP_MS = 15 * MILLISECONDS_PER_SECOND;
export function gapThreshold(t: number[]): number {
if (t.length < 3) return Number.POSITIVE_INFINITY;
const deltas = t.slice(1).map((time, index) => time - t[index]);
deltas.sort((a, b) => a - b);
const median = deltas[Math.floor(deltas.length / 2)];
return Math.max(MIN_GAP_MS, median * GAP_FACTOR);
}
// The same threshold, made safe to compare a *distance* against - which is what
// the crosshair does and the segmenter doesn't.
//
// `gapThreshold` answers `Infinity` for a series of one or two samples, and for
// segmenting that is exactly right: there is no cadence to derive, and no gap
// that could be judged a dropout, so the honest answer is "never break this".
// Fed to `sampleIndexAt` the same value means "no distance is too far", which
// silently turns the guard back into the clamping `nearestSampleIndex` it exists
// to replace.
//
// **That is not a hypothetical shape for the deco ceiling.** The API drops zero
// ceilings, so the channel carries samples only while an obligation existed - a
// dive that tips into deco for one or two 10-second samples produces a two-point
// series and nothing else. With an infinite tolerance the crosshair then reports
// that ceiling at *every instant of the dive*, which is precisely the invented
// obligation this guard was written to prevent, arriving on the shortest and
// least expected obligations rather than the long obvious ones. The one-sample
// case is worse still: `segmentByTimeGap` yields a single-point run that draws
// nothing, so the chart shows no ceiling at all while the tooltip insists on one.
//
// The floor rather than the median, because a series this short has no median
// worth having. It errs toward refusing: on two samples 70 s apart the line is
// drawn across the whole span while the readout only answers within 15 s of
// either end. Silence where a curve exists is a cosmetic loss; a number where no
// obligation existed is not.
//
// **Named for the readout, but on a channel whose gaps are meaningful it is the
// segmentation threshold too** - see `gapsAreMeaningful` and the `runs()` helper
// in `dive-profile-chart.tsx`. That is the point rather than an overload: on the
// ceiling the line and the crosshair have to be cut at one number, and this is
// the one that is finite. Anyone looking for "the threshold the line was cut at"
// on the ceiling should stop here rather than re-deriving it.
export function readoutTolerance(t: number[]): number {
const threshold = gapThreshold(t);
return Number.isFinite(threshold) ? threshold : MIN_GAP_MS;
}
// The index of the sample nearest the instant `at`, by binary search, clamped at
// both ends.
//
// Always a real sample, never an interpolated one: the crosshair readout says
// "21.6 °C at 12:30", and a value the sensor never recorded has no business
// being presented as one. Called once per channel, since the channels don't
// share a time axis.
export function nearestSampleIndex(t: number[], at: number): number {
if (t.length === 0) return -1;
if (at <= t[0]) return 0;
if (at >= t[t.length - 1]) return t.length - 1;
let low = 0;
let high = t.length - 1;
while (high - low > 1) {
const middle = (low + high) >> 1;
if (t[middle] <= at) {
low = middle;
} else {
high = middle;
}
}
// `low` and `high` now bracket `at`; pick whichever is closer, ties going to
// the earlier sample.
return at - t[low] <= t[high] - at ? low : high;
}
// The sample that can honestly be called this channel's reading at the instant
// `at`, or -1 when there isn't one.
//
// `nearestSampleIndex` clamps at both ends, which is right for finding a
// neighbour and wrong for captioning one: it answers "the closest sample" even
// when the closest sample is twenty minutes away. The chart already refuses to
// *draw* a line across a stretch a channel didn't record (`segmentByTimeGap`),
// and the crosshair has to refuse to quote a number there for the same reason -
// no line, no dot, no readout.
//
// The deco ceiling is what makes this unmistakable rather than merely untidy. A
// gap in that channel means the dive owed no decompression, so a clamped reading
// before the first sample reports a 3.0 m ceiling five minutes into a dive that
// was still well inside no-deco limits - inventing an obligation, on the one
// curve where that is a safety claim rather than a cosmetic slip. The same
// clamp was already quoting tank pressure through a transmitter dropout; that
// was a lie too, just a quieter one.
//
// `maxDeltaMs` is the channel's own `readoutTolerance` - deliberately not
// `gapThreshold`, which is the number this looks like it should take and is the
// wrong one: its `Infinity` below three samples reads here as "no distance is
// too far to quote". The caller passes the same value it cut the line at, so the
// two cannot disagree about the same stretch of dive.
//
// Being close enough is necessary and not sufficient. A sample can clear this
// and still have been dropped from the picture for sitting in a run too short to
// draw - which is what `drawnSampleIndexAt` below exists to handle, and what
// every caller in the chart actually wants.
export function sampleIndexAt(
t: number[],
at: number,
maxDeltaMs: number,
): number {
const index = nearestSampleIndex(t, at);
if (index < 0) return -1;
return Math.abs(t[index] - at) <= maxDeltaMs ? index : -1;
}
// The nearest sample that is close enough to quote **and** made it onto the
// chart, or -1.
//
// Not `sampleIndexAt` followed by a membership test, which fails in one specific
// way: the nearest sample overall may be one the chart dropped, and rejecting it
// outright then reports nothing even though a drawn sample sits just behind it,
// well inside the tolerance. On samples at 1000, 1010, 1020 and 1060 s with a
// 30 s tolerance and the last dropped as an isolated run, hovering at 1045 s
// resolves to 1060, fails the membership test, and goes silent - with 1020 only
// 25 s away and visibly drawn.
//
// So the search walks outward from the nearest sample and takes the first drawn
// one, stopping as soon as both frontiers are past the tolerance. That makes the
// rule sayable in one line - *the nearest drawn sample within tolerance* - rather
// than as two rules whose interaction has to be reasoned about.
export function drawnSampleIndexAt(
t: number[],
at: number,
maxDeltaMs: number,
drawn: ReadonlySet<number>,
): number {
const nearest = nearestSampleIndex(t, at);
if (nearest < 0) return -1;
let low = nearest;
let high = nearest + 1;
while (low >= 0 || high < t.length) {
const lowDelta =
low >= 0 ? Math.abs(t[low] - at) : Number.POSITIVE_INFINITY;
const highDelta =
high < t.length ? Math.abs(t[high] - at) : Number.POSITIVE_INFINITY;
// Both frontiers are out of reach, and they only get further away.
if (Math.min(lowDelta, highDelta) > maxDeltaMs) return -1;
// Ties to the earlier sample, matching `nearestSampleIndex`.
if (lowDelta <= highDelta) {
if (drawn.has(low)) return low;
low--;
} else {
if (drawn.has(high)) return high;
high++;
}
}
return -1;
}
// Candidate x-axis steps, on the millisecond axis. Clock-shaped throughout:
// `axisTicks` would happily hand back a 250-second step, and nobody reads a dive
// profile in units of 4 minutes 10 seconds. The seconds are for a plot zoomed in
// to a minute or two.
const ELAPSED_STEPS_MS = [
...[10, 15, 30].map((seconds) => seconds * MILLISECONDS_PER_SECOND),
...[1, 2, 5, 10, 15, 30, 60, 120].map(
(minutes) => minutes * 60 * MILLISECONDS_PER_SECOND,
),
];
// How many elapsed-time steps the axis aims for at the chart's design width.
export const ELAPSED_TICK_TARGET = 6;
// The elapsed-time gridlines for the stretch of a profile from `startMs` to
// `endMs`, in milliseconds: whole multiples of the step, never outside it.
export function elapsedTicks(
startMs: number,
endMs: number,
targetTicks = ELAPSED_TICK_TARGET,
): number[] {
const span = endMs - startMs;
if (span <= 0) return [startMs];
let step =
ELAPSED_STEPS_MS.find((candidate) => span / candidate <= targetTicks) ??
ELAPSED_STEPS_MS[ELAPSED_STEPS_MS.length - 1];
// Past the last candidate, a recording long enough to outrun it - or a phone
// asking for fewer ticks - keeps doubling it, so the target still holds.
while (span / step > targetTicks) step *= 2;
const ticks: number[] = [];
for (
let time = Math.ceil(startMs / step) * step;
time <= endMs;
time += step
) {
ticks.push(time);
}
return ticks;
}
// Where the crosshair's readout card sits vertically, given the topmost of the
// dots it describes. Returns a y in the chart's own coordinate space plus the
// CSS `translateY` that pins the card to it.
//
// Deliberately anchored to the *plot's* top or bottom edge rather than to the
// hovered point, which is what an earlier version did and got wrong. Two
// reasons, and the second is the bug:
//
// - With a full-height crosshair, "the hovered point" is three different
// points (one per channel), so picking one to hang the card off was already
// arbitrary - and it made the card jump between channels while scrubbing.
// - Offsetting from a point can only be done safely if you know how tall the
// card is, and here you don't: its height depends on how many channels the
// dive recorded, and the SVG scales to its container while the card's text
// does not. A fixed "flip above the point when it's in the top third" rule
// put the card 11 px past the top edge of the chart, over whatever sits
// above it. Anchoring to an edge is correct for *any* card height and any
// scale.
//
// The card moves to the bottom when the topmost dot is high in the plot, so it
// doesn't cover the readings it is describing.
const TOOLTIP_FLIP_FRACTION = 0.45;
export function tooltipVerticalAnchor(
topmostY: number,
plotTop: number,
plotBottom: number,
): { y: number; translateY: string } {
const height = plotBottom - plotTop;
const fraction = height > 0 ? (topmostY - plotTop) / height : 0;
return fraction < TOOLTIP_FLIP_FRACTION
? // Card's bottom edge on the plot's bottom edge, nudged clear of the axis.
{ y: plotBottom, translateY: "calc(-100% - 4px)" }
: // Card's top edge on the plot's top edge.
{ y: plotTop, translateY: "4px" };
}
// How far the readout card stands off the crosshair when it sits beside it, in
// CSS pixels.
export const TOOLTIP_GAP_PX = 12;
/**
* Which side of the crosshair the readout card sits on, so it never covers the
* line and the dots it is reading: the right where it fits inside the frame,
* the left where only that fits, and null where neither does and the card has
* to sit over the line. All three in CSS pixels, the crosshair measured from
* the frame's left edge.
*/
export function tooltipSide(
crosshairPx: number,
cardPx: number,
framePx: number,
): "right" | "left" | null {
if (crosshairPx + TOOLTIP_GAP_PX + cardPx <= framePx) return "right";
if (crosshairPx - TOOLTIP_GAP_PX - cardPx >= 0) return "left";
return null;
}
// A reading formatted for a tooltip or an axis label, at the channel's own
// resolution.
export function formatChannelValue(
value: number,
channel: ProfileChannel,
): string {
// `value` is already in the channel's own units - the series were converted when
// they were built, so there is nothing to convert here and converting again would
// double it. The separator comes from the same table the label does, which is
// what makes a temperature read "21.6°C" here exactly as it does in the sidebar.
return `${value.toFixed(channel.decimals)}${channelSeparator(channel.key)}${channel.unit}`;
}