// 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' } // --- Compact imperial display ---------------------------------------------- // The editor and the cards label sizes the way a gardener says them — 3′, 1′4″, // 8″ — rather than the always-both "4′ 0″" the entry fields round-trip through. // Display only; nothing here is parsed back. /** Centimeters → nearest inch, shown as feet/inches with zero parts dropped: * 91 → "3′", 40 → "1′4″", 20 → "8″". */ export function formatFtIn(cm: number): string { const inches = Math.round(Math.abs(cm) / CM_PER_INCH) const feet = Math.floor(inches / INCHES_PER_FOOT) const rest = inches - feet * INCHES_PER_FOOT const s = feet ? (rest ? `${feet}′${rest}″` : `${feet}′`) : `${rest}″` return cm < 0 && inches ? `-${s}` : s } /** One length in the garden's unit: compact feet/inches, or meters. */ export function formatLength(cm: number, unit: UnitPref): string { return unit === 'imperial' ? formatFtIn(cm) : formatCm(cm, 'metric') } /** "3′ × 6′" / "0.91 m × 1.83 m". */ export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string { return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}` } // --- Typed-length fields ---------------------------------------------------- // A dialog field that takes a length holds TWO things: the text the person sees // and the centimeters it means. The centimeters change only when the person // types; re-showing the field in another unit, or saving without touching it, // reuses them as they are. Parsing the displayed text back on save is how a // no-change Save once turned 900 cm into 899.922 — "29′ 6.3″" is the nearest // tenth of an inch, not the number that was loaded. /** A length as typed and as stored; `cm` is null while the text doesn't parse. */ export interface LengthField { text: string cm: number | null } /** A dimension field (meters, or feet and inches) showing `cm`. */ export function dimensionField(cm: number, unit: UnitPref): LengthField { return { text: formatDimensionInput(cm, unit), cm } } /** The person typed `text` into a dimension field. */ export function editDimensionField(text: string, unit: UnitPref): LengthField { return { text, cm: parseDimension(text, unit) } } /** Re-show a dimension field in another unit; the centimeters don't move. A * field that doesn't parse keeps its text, so the typo stays visible. */ export function convertDimensionField(field: LengthField, unit: UnitPref): LengthField { return field.cm === null ? field : dimensionField(field.cm, unit) } /** A spacing field (cm, or inches) showing `cm`. */ export function spacingField(cm: number, unit: UnitPref): LengthField { return { text: String(spacingFromCm(cm, unit)), cm } } /** The person typed `text` into a spacing field. */ export function editSpacingField(text: string, unit: UnitPref): LengthField { const trimmed = text.trim() const n = Number(trimmed) return { text, cm: trimmed !== '' && Number.isFinite(n) ? cmFromSpacing(n, unit) : null } }