Units correctness: exact imperial entry, garden grid at dimension scale (#47) #60
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"frontend-design@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,14 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
|||||||
setUnit(next)
|
setUnit(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Converted once per render and used by both the submit handler and the
|
||||||
|
// too-fine hint, so the two can never disagree about what was entered.
|
||||||
|
const gridSizeCm = cmFromDisplay(parseFloat(gridSize), unit)
|
||||||
|
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
|
||||||
|
// someone reaching for plant spacing, which lives on the bed instead — but it
|
||||||
|
// is a legitimate choice for a very small garden, so the save still goes through.
|
||||||
|
const gridTooFine = Number.isFinite(gridSizeCm) && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
@@ -86,7 +94,6 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
|||||||
setFormError('Width and height must be between 1 cm and 100 m.')
|
setFormError('Width and height must be between 1 cm and 100 m.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const gridSizeCm = cmFromDisplay(parseFloat(gridSize), unit)
|
|
||||||
if (!isValidDimensionCm(gridSizeCm)) {
|
if (!isValidDimensionCm(gridSizeCm)) {
|
||||||
setFormError('Grid size must be between 1 cm and 100 m.')
|
setFormError('Grid size must be between 1 cm and 100 m.')
|
||||||
return
|
return
|
||||||
@@ -130,10 +137,6 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unitLabel = dimensionUnitLabel(unit)
|
const unitLabel = dimensionUnitLabel(unit)
|
||||||
// Soft floor: warn, don't refuse. A sub-4″ garden grid is almost always "1"
|
|
||||||
// typed meaning one foot, but it's a legitimate choice for a tiny garden.
|
|
||||||
const gridEntered = cmFromDisplay(parseFloat(gridSize), unit)
|
|
||||||
const gridTooFine = Number.isFinite(gridEntered) && gridEntered > 0 && gridEntered < MIN_GARDEN_GRID_CM
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
|
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
|
||||||
@@ -201,7 +204,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
|||||||
</div>
|
</div>
|
||||||
|
|
|||||||
{gridTooFine && (
|
{gridTooFine && (
|
||||||
<p className="mt-1 text-xs text-muted">
|
<p className="mt-1 text-xs text-muted">
|
||||||
That's a {formatCm(gridEntered, unit)} grid for the whole garden — did you mean {unitLabel}?
|
{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed
|
||||||
|
(Bed grid in the inspector), not here.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type Rect,
|
type Rect,
|
||||||
type Size,
|
type Size,
|
||||||
} from '@/lib/geometry'
|
} from '@/lib/geometry'
|
||||||
import { formatCm } from '@/lib/units'
|
import { formatCm, type UnitPref } from '@/lib/units'
|
||||||
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
|
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
import type { EditorPlanting } from '@/lib/plantings'
|
import type { EditorPlanting } from '@/lib/plantings'
|
||||||
@@ -24,8 +24,28 @@ import type { EditorGarden, EditorObject } from './types'
|
|||||||
|
|
||||||
const GRID_MIN_CELL_PX = 6
|
const GRID_MIN_CELL_PX = 6
|
||||||
const GRID_OPACITY = 0.18
|
const GRID_OPACITY = 0.18
|
||||||
|
const OVERLAY_BADGE_CLASS = 'rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur'
|
||||||
const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed
|
const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to tell the user about the grid they're looking at, or null when the
|
||||||
|
* drawn lines are simply the garden's own grid and need no explanation. Two
|
||||||
|
* states are worth naming rather than leaving them to infer: lines drawn every
|
||||||
|
* Nth grid cell (the grid was too fine to draw at this zoom), and — degenerate —
|
||||||
|
* a grid so fine no multiple of it is drawable, which matters most when snapping
|
||||||
|
* is on and would otherwise be invisibly active (#47).
|
||||||
|
*/
|
||||||
|
function gridNoteFor(
|
||||||
|
drawnCm: number | null,
|
||||||
|
gridCm: number,
|
||||||
|
snapToGrid: boolean,
|
||||||
|
unit: UnitPref,
|
||||||
|
): string | null {
|
||||||
|
if (drawnCm == null) return snapToGrid ? 'grid too fine to draw — zoom in' : null
|
||||||
|
if (drawnCm === gridCm) return null
|
||||||
|
return `grid every ${formatCm(drawnCm, unit)}`
|
||||||
|
}
|
||||||
|
|
||||||
/** The world-space bounds rect of an object (center + size → top-left rect). */
|
/** The world-space bounds rect of an object (center + size → top-left rect). */
|
||||||
function objectRect(o: EditorObject): Rect {
|
function objectRect(o: EditorObject): Rect {
|
||||||
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
|
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
|
||||||
@@ -113,16 +133,7 @@ export function GardenCanvas({
|
|||||||
// the drawn lines are a subset of the snap positions, never a different grid.
|
// the drawn lines are a subset of the snap positions, never a different grid.
|
||||||
const gridCm = garden.gridSizeCm
|
const gridCm = garden.gridSizeCm
|
||||||
const drawnGridCm = visibleGridStepCm(gridCm, viewport.scale, GRID_MIN_CELL_PX)
|
const drawnGridCm = visibleGridStepCm(gridCm, viewport.scale, GRID_MIN_CELL_PX)
|
||||||
// Two states worth explaining rather than leaving the user to infer: lines that
|
const gridNote = gridNoteFor(drawnGridCm, gridCm, garden.snapToGrid, garden.unitPref)
|
||||||
// are every Nth grid cell, and (degenerate) a grid too fine to draw at all.
|
|
||||||
const gridNote =
|
|
||||||
drawnGridCm == null
|
|
||||||
? garden.snapToGrid
|
|
||||||
? 'grid too fine to draw — zoom in'
|
|
||||||
: null
|
|
||||||
: drawnGridCm !== gridCm
|
|
||||||
? `grid every ${formatCm(drawnGridCm, garden.unitPref)}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
|
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
|
||||||
const rendered = useMemo(
|
const rendered = useMemo(
|
||||||
@@ -300,12 +311,8 @@ export function GardenCanvas({
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div className="pointer-events-none absolute left-3 top-3 flex flex-col items-start gap-1">
|
<div className="pointer-events-none absolute left-3 top-3 flex flex-col items-start gap-1">
|
||||||
<span className="rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">
|
<span className={OVERLAY_BADGE_CLASS}>{viewport.scale.toFixed(2)} px/cm</span>
|
||||||
{viewport.scale.toFixed(2)} px/cm
|
{gridNote && <span className={OVERLAY_BADGE_CLASS}>{gridNote}</span>}
|
||||||
</span>
|
|
||||||
{gridNote && (
|
|
||||||
<span className="rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">{gridNote}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const INCHES_PER_FOOT = 12
|
|||||||
export const MIN_DIMENSION_CM = 1
|
export const MIN_DIMENSION_CM = 1
|
||||||
export const MAX_DIMENSION_CM = 10_000
|
export const MAX_DIMENSION_CM = 10_000
|
||||||
|
|
||||||
// A garden grid finer than this at *dimension* scale is a mis-entry, not a
|
// Below this, a garden-scale grid is fine enough to be worth questioning — it
|
||||||
// preference (typing "1" in a feet field meaning one foot, and landing on a 1 cm
|
// usually means someone reached for plant spacing, which belongs to the bed, not
|
||||||
// grid). Soft: the form warns, it doesn't refuse.
|
// the garden. Soft: the form hints, it doesn't refuse.
|
||||||
export const MIN_GARDEN_GRID_CM = 10
|
export const MIN_GARDEN_GRID_CM = 10
|
||||||
|
|
||||||
/** Whether a centimeter dimension is within the server's accepted range. */
|
/** Whether a centimeter dimension is within the server's accepted range. */
|
||||||
@@ -23,9 +23,9 @@ export function isValidDimensionCm(cm: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Round away IEEE-754 dust without rounding away precision: 12 × 2.54 is
|
/** Round away IEEE-754 dust without rounding away precision: 12 × 2.54 is
|
||||||
* 30.479999999999997 in binary floating point, and 1 nanometer is far below
|
* 30.479999999999997 in binary floating point, and the 1e-6 cm (10 nm) grain
|
||||||
|
gitea-actions
commented
⚪ roundCm's doc comment misstates the rounding grain as 1 nm when it's actually 10 nm (1e-6 cm = 10 nm) correctness · flagged by 1 model 🪰 Gadfly · advisory ⚪ **roundCm's doc comment misstates the rounding grain as 1 nm when it's actually 10 nm (1e-6 cm = 10 nm)**
_correctness · flagged by 1 model_
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
* anything a garden cares about. Every cm column is REAL, so entry stays exact
|
* here is far below anything a garden cares about. Every cm column is REAL, so
|
||||||
* — this only stops 30.48 from being stored as 30.479999999999997. */
|
* entry stays exact — this only stops 30.48 being stored as 30.479999999999997. */
|
||||||
function roundCm(cm: number): number {
|
function roundCm(cm: number): number {
|
||||||
return Math.round(cm * 1e6) / 1e6
|
return Math.round(cm * 1e6) / 1e6
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
🟠 Soft-warning 'did you mean {unitLabel}?' is self-contradictory now that the grid field is already in that unit; the '1 typed meaning one foot' comments are stale under the new dimension scale.
maintainability · flagged by 1 model
web/src/components/gardens/GardenFormModal.tsx:204— The soft-warningdid you mean {unitLabel}?is self-contradictory after the scale migration. The grid field is now labeledGarden grid (ft)/(m)(i.e.unitLabel), so when the warning fires it asks "did you mean ft?" while the user is already typing in a ft field. The warning only made sense when the grid was at spacing scale (in/cm) and the suggestion was to switch to the dimension unit. Under the new dimension scale, the "typed '1…🪰 Gadfly · advisory