117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
|
|
|
|
/**
|
|
* Natural-language measurement parsing for editor property fields, backed by
|
|
* `@pascal-app/lingo`. Lets a user type `6ft`, `180cm`, `1m80`, `5'11"`, `45°`
|
|
* or `1.57rad` into any measurement field and have it canonicalized to the
|
|
* unit the field stores its value in — independent of the metric/imperial
|
|
* display toggle.
|
|
*
|
|
* The editor stores linear values in meters and angular values in radians (a
|
|
* few fields store inches or degrees); lingo's `length` base is meters and its
|
|
* `angle` base is radians, so a field's `unit` prop already names the stored /
|
|
* canonical unit and doubles as the parse target.
|
|
*/
|
|
|
|
export interface LingoUnitSpec {
|
|
kind: Kind
|
|
/** Unit id the field's stored `value` is expressed in — the parse target. */
|
|
unitId: string
|
|
}
|
|
|
|
/**
|
|
* Map an editor field `unit` prop to a lingo kind + canonical unit. Returns
|
|
* `null` for units we deliberately do NOT natural-language-parse (plain
|
|
* numbers, percentages, angular rates, counts) so those keep exact
|
|
* `Number.parseFloat` behavior.
|
|
*/
|
|
export function lingoUnitSpec(unit: string | undefined): LingoUnitSpec | null {
|
|
switch (unit) {
|
|
case 'm':
|
|
case 'cm':
|
|
case 'mm':
|
|
case 'in':
|
|
case 'ft':
|
|
return { kind: 'length', unitId: unit }
|
|
case '°':
|
|
case 'deg':
|
|
case 'degrees':
|
|
return { kind: 'angle', unitId: 'deg' }
|
|
case 'rad':
|
|
case 'radians':
|
|
return { kind: 'angle', unitId: 'rad' }
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
export interface ParseMeasurementOptions {
|
|
/**
|
|
* Implied unit for a BARE number (e.g. `6` → `6 ft`). Defaults to the field's
|
|
* own `unitId`. A typed unit (`180cm`) is always honored regardless.
|
|
*/
|
|
bareUnit?: string
|
|
/** Disambiguates gal/ton/cup families; harmless for length/angle. */
|
|
system?: 'metric' | 'us' | 'imperial'
|
|
}
|
|
|
|
/**
|
|
* Parse typed field text into the field's stored numeric unit. Returns `null`
|
|
* when the text can't be read as a quantity, so the caller can fall back to a
|
|
* plain number parse or revert to the previous value.
|
|
*/
|
|
export function parseMeasurement(
|
|
raw: string,
|
|
spec: LingoUnitSpec,
|
|
options: ParseMeasurementOptions = {},
|
|
): number | null {
|
|
const result = parseQuantity(raw, {
|
|
kind: spec.kind,
|
|
unit: options.bareUnit ?? spec.unitId,
|
|
system: options.system,
|
|
strictness: 'forgiving',
|
|
})
|
|
if (!result.ok) return null
|
|
const value = result.quantity.to(spec.unitId).value
|
|
return Number.isFinite(value) ? value : null
|
|
}
|
|
|
|
export interface MeasurementHintOptions extends ParseMeasurementOptions {
|
|
/** Unit id the preview is rendered in (the field's displayed unit). */
|
|
displayUnit?: string
|
|
/** Max fraction digits in the preview. */
|
|
precision?: number
|
|
/**
|
|
* Clamp applied to the parsed stored value before previewing, so the hint
|
|
* reflects what a commit would actually store on a bounded field.
|
|
*/
|
|
clamp?: (stored: number) => number
|
|
}
|
|
|
|
const PLAIN_NUMBER = /^[+-]?\d*\.?\d*$/
|
|
|
|
/**
|
|
* A faint "= 1.83 m" preview of the value currently being typed, shown only
|
|
* when the user typed something beyond a plain decimal in the field's own unit
|
|
* (an explicit unit, a compound like `1m80`, or a number word) and it parses.
|
|
* Returns `null` when there's nothing useful to preview.
|
|
*/
|
|
export function measurementHint(
|
|
raw: string,
|
|
spec: LingoUnitSpec,
|
|
options: MeasurementHintOptions = {},
|
|
): string | null {
|
|
const trimmed = raw.trim()
|
|
if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null
|
|
|
|
const parsed = parseMeasurement(trimmed, spec, options)
|
|
if (parsed === null) return null
|
|
const stored = options.clamp ? options.clamp(parsed) : parsed
|
|
|
|
const displayUnit = options.displayUnit ?? spec.unitId
|
|
const precision = options.precision ?? 2
|
|
const q = quantity(stored, spec.unitId)
|
|
const shown = displayUnit === spec.unitId ? q : q.to(displayUnit)
|
|
return `= ${shown.format({ precision })}`
|
|
}
|