import { displayNumber, toCommittedMetric, type UnitSystem } from "@/lib/units"; import type { CylinderMaterial, VesselFacts } from "@/lib/api/gear"; /** The vessel facts a cylinder's name pins down beyond its litres. */ export interface PresetVessel extends Pick< VesselFacts, "working_pressure" | "material" > { working_pressure: number; material: CylinderMaterial; } // A rating in bar exactly as the working-pressure box stores the stamped psi typed // in imperial, so an imperial reader reads the stamp back and a metric reader sees // what that same entry shows. function ratedAt(psi: number, material: CylinderMaterial): PresetVessel { return { working_pressure: toCommittedMetric(psi, "pressure", "imperial"), material, }; } const AL_3000 = ratedAt(3000, "aluminium"); const HP_3442 = ratedAt(3442, "steel"); // LP cylinders are named at their "+" rating, 10% over the stamped 2400 psi: at // 2400 an LP85 holds 75 cu ft, so only 2640 reads the name back. const LP_2640 = ratedAt(2640, "steel"); export interface VolumeOption { value: number; label: string; /** * The cu-ft name this cylinder is known by, where it has one. * * Only the US sizes carry one, and it always states the material: `AL` for * aluminium, `HP` and `LP` for the two steel working-pressure families. * `24 L (2x12 L)`'s parenthetical looks like the same thing and is not - it is * a metric composition, so it is part of `label` and never leads. */ imperialName?: string; /** * The working pressure and material the name pins down, on every row with an * `imperialName` and no other: a 12 L is made at 200, 232 and 300 bar in steel * and in aluminium, so litres alone say neither. */ vessel?: PresetVessel; } export interface VolumeOptionGroup { /** Heading shown above the group in the dropdown. */ label: string; /** * Whose divers this group is *for*, which decides only where it sits. * * Never whether it is shown: every group is offered to every diver, and * `volumeGroupsFor` moves the reader's own to the front rather than dropping * the rest. See DECISIONS.md - a list that hides the unfamiliar half is a list * that fails the traveller, who is the one person a named preset is for. * * Absent where a group belongs to neither family, which sinks it below both. */ system?: UnitSystem; options: VolumeOption[]; } const TWIN_SETS: VolumeOption[] = [ { value: 14, label: "14 L (2x7 L)" }, { value: 22.2, label: "22.2 L (2x AL80)", imperialName: "2x AL80", vessel: AL_3000, }, { value: 24, label: "24 L (2x12 L)" }, { value: 30, label: "30 L (2x15 L)" }, ]; /** * Whether a cylinder's litres are one of the twin-set presets: the only way a * cylinder says it is a twin set, since a mixture records nothing but its volume. * No single in the presets shares a total with them. */ export function isTwinSetVolume(volume: number | null | undefined): boolean { return TWIN_SETS.some((option) => option.value === volume); } // Common cylinder water capacities (litres), in four groups. // // The named entries are the US sizes, carrying the name divers actually say // alongside the litre water capacity the mixture stores; the unnamed ones are the // European sizes, which have no name beyond their capacity. // // A US name states the material because the litres depend on it: an AL80 at // 3000 psi is 11.1 L and a steel HP80 at 3442 psi is 10.2 L, and both are "80 // cubic feet". `AL` is aluminium - Luxfer and Catalina model-number that line // `S80`, an `S` divers read as steel, which is why the model number is not what // is shown here (see DECISIONS.md). `HP` is the 3442 psi steel family and `LP` // the 2400/2640 psi one, from Faber and Worthington. // // A named row also carries the working pressure and material its name pins down, // from the makers' rating families: 3000 psi aluminium (Luxfer, Catalina), 3300 // psi for the AL100 (Luxfer's S100), 3442 psi HP steel and 2400+ LP steel (Faber, // Worthington). An unnamed row carries neither. // // Every litre figure is a manufacturer's stated water capacity - Luxfer's and // Catalina's scuba sheets via XS Scuba, XS Scuba's Metal Impact sheet, Faber's // and Worthington's own - never a number derived from the cu-ft name. Where the // sheets disagree, that row's comment gives the figures and says which one the // preset takes, so the next person to touch it can see it was a choice. export const VOLUME_GROUPS: VolumeOptionGroup[] = [ { label: "Metric singles", system: "metric", // Ascending by litres, which is the quantity stored and the only thing these // are known by. options: [ { value: 3, label: "3 L" }, { value: 5, label: "5 L" }, { value: 7, label: "7 L" }, { value: 10, label: "10 L" }, { value: 12, label: "12 L" }, { value: 15, label: "15 L" }, { value: 18, label: "18 L" }, { value: 20, label: "20 L" }, ], }, { label: "Twin sets", // No `system`, so this sits under both families whichever way the diver // reads: a twin set is asked for by its total either way, and it is the // rarer pick, so the singles a diver reaches for most are the rows nearest // the box. The 2x AL80 rides along rather than splitting the group by // material. options: TWIN_SETS, }, { label: "US aluminium", system: "imperial", // Nominal cu ft ascending, which for this family is also litres ascending. options: [ // The standard stage/deco/pony bottle. Luxfer 5.7, Catalina 5.8; the preset // takes Luxfer's, theirs being the AL40 most often sold under the name. { value: 5.7, label: "5.7 L (AL40)", imperialName: "AL40", vessel: AL_3000, }, // Luxfer 6.9, Metal Impact 7.1, Catalina 7.2 - a 0.3 L spread with no maker // dominant enough to settle it the way Faber settles the HP100. The preset // takes the middle figure; a diver whose own sheet says 6.9 types it. { value: 7.1, label: "7.1 L (AL50)", imperialName: "AL50", vessel: AL_3000, }, { value: 9, label: "9 L (AL63)", imperialName: "AL63", vessel: AL_3000, }, { value: 10, label: "10 L (AL72)", imperialName: "AL72", vessel: AL_3000, }, { value: 11.1, label: "11.1 L (AL80)", imperialName: "AL80", vessel: AL_3000, }, // Rated 3300 psi, unlike the rest of the family. { value: 13.2, label: "13.2 L (AL100)", imperialName: "AL100", vessel: ratedAt(3300, "aluminium"), }, ], }, { label: "US steel", system: "imperial", // Two families, each in nominal cu ft order, HP before LP - so the group is // deliberately not ascending by litres across the join (HP130 is 16 L and the // LP85 under it is 13 L). A diver reaches for one family or the other. options: [ // Faber 10.2, Worthington 10.1. The preset takes Faber's, for the reason the // HP100 below does - one maker across the family beats a row-by-row pick, // and a tenth of a litre is well inside what a diver would retype anyway. { value: 10.2, label: "10.2 L (HP80)", imperialName: "HP80", vessel: HP_3442, }, // The one size the makers genuinely disagree on: Faber 12.9, Worthington's // own sheet 11.6, PST 12.7. Faber's figure is the preset because it is the // one most often sold under the name; a diver holding either types theirs. { value: 12.9, label: "12.9 L (HP100)", imperialName: "HP100", vessel: HP_3442, }, // The HP117 and the LP95 below are both 15.0 L - two cylinders that differ // by a working pressure the mixture does not record. Hence the list is keyed // by label rather than value: the value is not unique. { value: 15, label: "15 L (HP117)", imperialName: "HP117", vessel: HP_3442, }, { value: 15.3, label: "15.3 L (HP120)", imperialName: "HP120", vessel: HP_3442, }, // Worthington's X8-130. Faber has no 130; its nearest is the HP133 at 17 L, // which is a different cylinder rather than a second figure for this one. { value: 16, label: "16 L (HP130)", imperialName: "HP130", vessel: HP_3442, }, { value: 13, label: "13 L (LP85)", imperialName: "LP85", vessel: LP_2640, }, { value: 15, label: "15 L (LP95)", imperialName: "LP95", vessel: LP_2640, }, { value: 17, label: "17 L (LP108)", imperialName: "LP108", vessel: LP_2640, }, { value: 19, label: "19 L (LP121)", imperialName: "LP121", vessel: LP_2640, }, ], }, ]; const NAMED_PRESETS = VOLUME_GROUPS.flatMap((group) => group.options).filter( (option): option is VolumeOption & Required> => option.vessel !== undefined, ); // Whether two pressures in bar read as the same whole figure in either unit: a // stamp typed as 3000 psi stores 206.84 bar and the same cylinder typed as 207 bar // reads 3002 psi, so neither unit alone recognises both. function samePressureReading(a: number, b: number): boolean { return (["metric", "imperial"] as const).some( (units) => displayNumber(a, "pressure", units, { decimals: 0 }) === displayNumber(b, "pressure", units, { decimals: 0 }), ); } /** * The name of the preset a vessel's recorded facts are, or `undefined`. * * All three facts must be recorded and agree with the row's, which is what tells * the HP117 from the LP95: both are 15 L. */ export function presetNameFor({ volume, working_pressure: pressure, material, }: VesselFacts): string | undefined { if (volume == null || pressure == null || !material) return undefined; return NAMED_PRESETS.find( ({ value, vessel }) => value === volume && vessel.material === material && samePressureReading(vessel.working_pressure, pressure), )?.imperialName; }