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 = { 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 = { 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 = { 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 = { 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 = (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 = (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; 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> = { // 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[]; // 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 { 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}`; }