Files
pansy/web/src/lib/units.ts
T
steve 3ec77a1099
Build image / build-and-push (push) Successful in 6s
Feet-and-inches entry: type 2' 7\" instead of 2.6 (#59) (#62)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:08:57 +00:00

205 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Unit conversion + display for garden dimensions. The API and DB are metric
// only (centimeters); imperial is purely a display/entry concern here. A minimal
// helper set for #8 — #9's geometry lib consolidates/extends it.
export type UnitPref = 'metric' | 'imperial'
const CM_PER_INCH = 2.54
const CM_PER_METER = 100
const INCHES_PER_FOOT = 12
// Mirrors the server's garden dimension bounds (service/gardens.go): [1cm, 100m].
export const MIN_DIMENSION_CM = 1
export const MAX_DIMENSION_CM = 10_000
// Below this, a garden-scale grid is fine enough to be worth questioning — it
// usually means someone reached for plant spacing, which belongs to the bed, not
// the garden. Soft: the form hints, it doesn't refuse.
export const MIN_GARDEN_GRID_CM = 10
/** Whether a centimeter dimension is within the server's accepted range. */
export function isValidDimensionCm(cm: number): boolean {
return Number.isFinite(cm) && cm >= MIN_DIMENSION_CM && cm <= MAX_DIMENSION_CM
}
/** Round away IEEE-754 dust without rounding away precision: 12 × 2.54 is
* 30.479999999999997 in binary floating point, and the 1e-6 cm (10 nm) grain
* here is far below anything a garden cares about. Every cm column is REAL, so
* entry stays exact — this only stops 30.48 being stored as 30.479999999999997. */
function roundCm(cm: number): number {
return Math.round(cm * 1e6) / 1e6
}
/** Feet (+ optional inches) → centimeters, exactly (1 ft → 30.48). */
export function cmFromFtIn(feet: number, inches = 0): number {
return roundCm((feet * INCHES_PER_FOOT + inches) * CM_PER_INCH)
}
/** Meters → centimeters, exactly. */
export function cmFromMeters(meters: number): number {
return roundCm(meters * CM_PER_METER)
}
/** Centimeters → a number in the given unit, for prefilling an input field.
* Rounded so cm-quantization doesn't show through (e.g. 244cm shows as 8 ft,
* not 8.01): meters to the cm (2 dp), feet to 0.1 ft. */
export function displayFromCm(cm: number, unit: UnitPref): number {
if (unit === 'imperial') {
const feet = cm / CM_PER_INCH / INCHES_PER_FOOT
return Math.round(feet * 10) / 10
}
return Math.round((cm / CM_PER_METER) * 100) / 100
}
// --- Dimension entry -------------------------------------------------------
// Imperial dimensions are typed the way people say them — 2' 7", not 2.6 — while
// a bare number still means feet so nothing anyone typed before this existed
// breaks. Metric entry is decimal meters, unchanged.
// Feet, then optionally inches. The inch mark is optional once feet are explicit,
// because "2' 7" is a natural thing to type and unambiguous.
const FT_IN_RE = /^(\d*\.?\d+)\s*(?:'|ft|feet|foot)(?:\s*(\d*\.?\d+)\s*(?:"|in|inch|inches)?)?$/
// Inches alone, where the mark is required — a bare number means feet.
const IN_ONLY_RE = /^(\d*\.?\d+)\s*(?:"|in|inch|inches)$/
// A bare decimal, which means feet (imperial) or meters (metric).
const BARE_RE = /^(\d*\.?\d+)$/
const METERS_RE = /^(\d*\.?\d+)\s*m?$/
/** Normalize the characters people actually paste: typographic quotes and the
* various unicode dashes, so text copied from anywhere parses like text typed
* here. Lowercased so "FT"/"In" work too. */
function normalizeEntry(s: string): string {
return s
.trim()
.replace(/[′’‵]/g, "'")
.replace(/[″”‶]/g, '"')
.replace(/[−–—]/g, '-')
.toLowerCase()
}
/** Split a leading sign off a normalized entry. The sign belongs to the WHOLE
* value, not just its first term: -2' 6" is (2ft + 6in) = 30in, not 2ft + 6in. */
function splitSign(s: string): { sign: number; rest: string } {
if (s.startsWith('-')) return { sign: -1, rest: s.slice(1).trim() }
if (s.startsWith('+')) return { sign: 1, rest: s.slice(1).trim() }
return { sign: 1, rest: s }
}
/**
* Parse a typed dimension into centimeters, or null if it isn't a dimension.
*
* Imperial accepts `2' 7"`, `2 7″`, `2ft 7in`, `2' 7`, `31"`, `2'`, and a bare
* `2.5` still meaning 2½ feet. Metric accepts decimal meters with an optional
* `m`. Null means "don't commit anything" — never a silent zero, which would
* turn a typo into a destructive edit.
*/
export function parseDimension(input: string, unit: UnitPref): number | null {
const { sign, rest } = splitSign(normalizeEntry(input))
if (rest === '') return null
if (unit !== 'imperial') {
const m = rest.match(METERS_RE)
return m ? sign * cmFromMeters(parseFloat(m[1])) : null
}
const bare = rest.match(BARE_RE)
if (bare) return sign * cmFromFtIn(parseFloat(bare[1]))
const ftIn = rest.match(FT_IN_RE)
if (ftIn) return sign * cmFromFtIn(parseFloat(ftIn[1]), ftIn[2] ? parseFloat(ftIn[2]) : 0)
const inOnly = rest.match(IN_ONLY_RE)
if (inOnly) return sign * cmFromFtIn(0, parseFloat(inOnly[1]))
return null
}
/**
* Centimeters → the string a dimension input shows, and the exact string
* parseDimension round-trips. Imperial reads `2 7″`; inches carry one decimal
* (`7 10.5″`) because 0.1″ is ~0.25 cm, so what's displayed stays honest about
* what's stored rather than rounding a dragged position to the nearest inch.
*/
export function formatDimensionInput(cm: number, unit: UnitPref): string {
if (unit !== 'imperial') return String(displayFromCm(cm, unit))
const { feet, inches } = feetAndInches(cm, 1)
// Only sign a value that actually rounded to something: a hair below zero is
// 0 0″, and "-0 0″" would be both wrong and un-round-trippable.
const sign = cm < 0 && (feet || inches) ? '-' : ''
return `${sign}${feet} ${inches}″`
}
/**
* The inputMode a dimension field should ask for. Imperial entry needs ' and ",
* which no numeric keypad offers, so those fields get the full keyboard; metric
* is pure decimals and keeps the keypad. A bare number still means feet, so the
* keypad path stays usable either way.
*/
export function dimensionInputMode(unit: UnitPref): 'text' | 'decimal' {
return unit === 'imperial' ? 'text' : 'decimal'
}
/**
* Decompose centimeters into feet and inches, rounding inches to `decimals` and
* carrying 12″ up to the next foot. Magnitude only — the sign is the caller's to
* apply, and only once it knows the rounded result isn't zero.
*/
function feetAndInches(cm: number, decimals: number): { feet: number; inches: number } {
const scale = 10 ** decimals
const totalInches = Math.abs(cm) / CM_PER_INCH
let feet = Math.floor(totalInches / INCHES_PER_FOOT)
let inches = Math.round((totalInches - feet * INCHES_PER_FOOT) * scale) / scale
if (inches >= INCHES_PER_FOOT) {
feet += 1
inches = 0
}
return { feet, inches }
}
/** Centimeters → a human string in the given unit (e.g. "1.22 m" or "4 0″"). */
export function formatCm(cm: number, unit: UnitPref): string {
if (unit === 'imperial') {
const { feet, inches } = feetAndInches(cm, 0)
return `${cm < 0 && (feet || inches) ? '-' : ''}${feet} ${inches}″`
}
const meters = Math.round((cm / CM_PER_METER) * 100) / 100
return `${meters} m`
}
/** Centimeters width × height → a human string in the given unit. */
export function formatDimensions(widthCm: number, heightCm: number, unit: UnitPref): string {
return `${formatCm(widthCm, unit)} × ${formatCm(heightCm, unit)}`
}
/** The input-field label for a single dimension in the given unit. */
export function dimensionUnitLabel(unit: UnitPref): string {
return unit === 'imperial' ? 'ft' : 'm'
}
// --- Plant spacing (small scale) -------------------------------------------
// Plant spacing is a handful of centimeters, so meters/feet read poorly here;
// metric shows cm and imperial shows whole inches.
/** Centimeters → a spacing string in the given unit (e.g. "15 cm" or "6″"). */
export function formatSpacing(cm: number, unit: UnitPref): string {
if (unit === 'imperial') return `${Math.round(cm / CM_PER_INCH)}″`
return `${Math.round(cm)} cm`
}
/** A spacing value typed in the given unit (cm, or inches) → centimeters,
* exactly (12 in → 30.48). */
export function cmFromSpacing(value: number, unit: UnitPref): number {
return roundCm(unit === 'imperial' ? value * CM_PER_INCH : value)
}
/** Centimeters → a spacing number in the given unit, for prefilling an input.
* Inches to 0.1 so cm-quantization doesn't show through. */
export function spacingFromCm(cm: number, unit: UnitPref): number {
return unit === 'imperial' ? Math.round((cm / CM_PER_INCH) * 10) / 10 : Math.round(cm)
}
/** The spacing input-field unit label. */
export function spacingUnitLabel(unit: UnitPref): string {
return unit === 'imperial' ? 'in' : 'cm'
}