Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s
Build image / build-and-push (push) Successful in 7s
Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #45.
This commit is contained in:
@@ -10,29 +10,37 @@ import (
|
||||
)
|
||||
|
||||
// gardenColumns lists the gardens columns in the fixed order scanGarden expects.
|
||||
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, version, created_at, updated_at`
|
||||
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid, version, created_at, updated_at`
|
||||
|
||||
func scanGarden(s scanner) (*domain.Garden, error) {
|
||||
var g domain.Garden
|
||||
var (
|
||||
g domain.Garden
|
||||
snap int64
|
||||
)
|
||||
if err := s.Scan(
|
||||
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
|
||||
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt,
|
||||
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SnapToGrid = snap != 0
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
// scanGardenWithRole reads a garden row plus a trailing my_role column into the
|
||||
// computed Garden.MyRole field (used by the actor-scoped list).
|
||||
func scanGardenWithRole(s scanner) (*domain.Garden, error) {
|
||||
var g domain.Garden
|
||||
var (
|
||||
g domain.Garden
|
||||
snap int64
|
||||
)
|
||||
if err := s.Scan(
|
||||
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
|
||||
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
|
||||
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SnapToGrid = snap != 0
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
@@ -45,10 +53,10 @@ const maxGardensListed = 1000
|
||||
// set and validated by the service) and returns the stored row.
|
||||
func (d *DB) CreateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden, error) {
|
||||
created, err := scanGarden(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+gardenColumns,
|
||||
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes,
|
||||
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert garden: %w", err)
|
||||
@@ -117,11 +125,12 @@ func (d *DB) UpdateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden
|
||||
updated, err := scanGarden(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE gardens
|
||||
SET name = ?, width_cm = ?, height_cm = ?, unit_pref = ?, notes = ?,
|
||||
grid_size_cm = ?, snap_to_grid = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ? AND version = ?
|
||||
RETURNING `+gardenColumns,
|
||||
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.ID, g.Version,
|
||||
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid), g.ID, g.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// No row matched: distinguish "gone" from "stale version".
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Grid + snap settings for the layout system.
|
||||
--
|
||||
-- Two independent grids:
|
||||
-- * gardens.grid_size_cm / snap_to_grid — the garden-scale grid the visual grid
|
||||
-- is drawn from and (when snap is on) objects snap to when placed/moved/resized.
|
||||
-- Defaults to 100 cm (1 m), matching the editor's previous fixed grid.
|
||||
-- * garden_objects.grid_size_cm / snap_to_grid — a per-object grid used inside a
|
||||
-- plantable bed: when snap is on, plants snap to it. Defaults to 30 cm (~1 ft),
|
||||
-- a practical bed cell.
|
||||
--
|
||||
-- snap defaults to off on both, so existing gardens keep free placement until the
|
||||
-- owner (garden grid) or an editor (bed grid) opts in.
|
||||
ALTER TABLE gardens ADD COLUMN grid_size_cm REAL NOT NULL DEFAULT 100;
|
||||
ALTER TABLE gardens ADD COLUMN snap_to_grid INTEGER NOT NULL DEFAULT 0 CHECK (snap_to_grid IN (0, 1));
|
||||
|
||||
ALTER TABLE garden_objects ADD COLUMN grid_size_cm REAL NOT NULL DEFAULT 30;
|
||||
ALTER TABLE garden_objects ADD COLUMN snap_to_grid INTEGER NOT NULL DEFAULT 0 CHECK (snap_to_grid IN (0, 1));
|
||||
@@ -11,21 +11,23 @@ import (
|
||||
|
||||
// objectColumns lists garden_objects columns in the order scanObject expects.
|
||||
const objectColumns = `id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, notes, version, created_at, updated_at`
|
||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes, version, created_at, updated_at`
|
||||
|
||||
func scanObject(s scanner) (*domain.GardenObject, error) {
|
||||
var (
|
||||
o domain.GardenObject
|
||||
plantable int64
|
||||
snap int64
|
||||
)
|
||||
if err := s.Scan(
|
||||
&o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points,
|
||||
&o.XCM, &o.YCM, &o.WidthCM, &o.HeightCM, &o.RotationDeg, &o.ZIndex,
|
||||
&plantable, &o.Color, &o.Props, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
|
||||
&plantable, &o.Color, &o.Props, &o.GridSizeCM, &snap, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Plantable = plantable != 0
|
||||
o.SnapToGrid = snap != 0
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
@@ -35,11 +37,12 @@ func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.
|
||||
created, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO garden_objects
|
||||
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+objectColumns,
|
||||
o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert object: %w", err)
|
||||
@@ -95,13 +98,14 @@ func (d *DB) UpdateObject(ctx context.Context, o *domain.GardenObject) (*domain.
|
||||
`UPDATE garden_objects
|
||||
SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?,
|
||||
width_cm = ?, height_cm = ?, rotation_deg = ?, z_index = ?,
|
||||
plantable = ?, color = ?, props = ?, notes = ?,
|
||||
plantable = ?, color = ?, props = ?, grid_size_cm = ?, snap_to_grid = ?, notes = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ? AND version = ?
|
||||
RETURNING `+objectColumns,
|
||||
o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
||||
o.ID, o.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
Reference in New Issue
Block a user