Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
205 lines
8.5 KiB
TypeScript
205 lines
8.5 KiB
TypeScript
// 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'
|
||
}
|