// 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' }