From 5b2bc25ef6f5fe5a6b67a4a757bf2ce6e4c84f8b Mon Sep 17 00:00:00 2001 From: plus_one Date: Sat, 17 Oct 2026 20:38:37 -0400 Subject: [PATCH] Unify preset/object GUI as collapsible cards; add NumberField for reliable numeric editing --- dist/index.html | 4 +- src/App.tsx | 99 ++++------ src/components/ControlPanel.tsx | 339 ++++++++++++++++++-------------- src/components/NumberField.tsx | 73 +++++++ src/furniture/presets.ts | 27 +++ src/index.css | 19 ++ src/types/index.ts | 1 + 7 files changed, 353 insertions(+), 209 deletions(-) create mode 100644 src/components/NumberField.tsx create mode 100644 src/furniture/presets.ts diff --git a/dist/index.html b/dist/index.html index 56ed4a3..4dba354 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,8 +5,8 @@ furniture-app - - + +
diff --git a/src/App.tsx b/src/App.tsx index 7a4121e..b45110b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,44 +1,53 @@ import { useEffect, useState } from 'react'; import { RoomCanvas } from './components/RoomCanvas'; import { ControlPanel } from './components/ControlPanel'; -import type { FurnitureConfig, LayoutState } from './types'; +import type { FurnitureConfig, FurniturePreset, LayoutState } from './types'; import { runRandomSearch } from './optimization/randomSearch'; import { optimizeWithNN } from './ai/nn'; +import { DEFAULT_PRESETS } from './furniture/presets'; const initialState: LayoutState = { room: { width: 600, height: 400, gridSize: 10 }, - entrance: { x: 30, y: 30 }, + entrance: { x: 30, y: 30, width: 20, height: 20 }, furniture: [], - objectCount: 0, + presets: DEFAULT_PRESETS, }; -function makePreset(type: string, index: number): FurnitureConfig { - if (type === 'table') { - return { - id: `${type}_${Date.now()}_${index}`, - name: `Table ${index + 1}`, - type: 'table', - shape: { type: 'rectangle', x: 80 + index * 20, y: 80, width: 80, height: 60 }, - conditions: [ - { kind: 'walk_to_and_sit', weight: 1 }, - { kind: 'accessible', weight: 1 }, - ], - color: '#a1887f', - lockedForOptimization: false, - placed: false, - }; +function syncFurniture(state: LayoutState): LayoutState { + const presets = state.presets || []; + let furniture = [...state.furniture]; + for (const preset of presets) { + const desired = preset.amount ?? 0; + const existing = furniture.filter((f) => f.type === preset.id); + if (existing.length > desired) { + const removeIds = new Set( + [...existing] + .sort((a, b) => Number(a.placed) - Number(b.placed)) + .slice(0, existing.length - desired) + .map((f) => f.id), + ); + furniture = furniture.filter((f) => !removeIds.has(f.id)); + } else if (existing.length < desired) { + for (let i = existing.length; i < desired; i++) { + furniture.push(makeFurniture(preset, i)); + } + } } + return { ...state, furniture }; +} + +function makeFurniture(preset: FurniturePreset, index: number): FurnitureConfig { return { - id: `${type}_${Date.now()}_${index}`, - name: `Chair ${index + 1}`, - type: 'chair', - shape: { type: 'rectangle', x: 120 + index * 20, y: 160, width: 40, height: 40 }, - conditions: [ - { kind: 'walk_to_and_sit', weight: 1 }, - { kind: 'near_object', weight: 1, params: { targetId: '' } }, - { kind: 'accessible', weight: 1 }, - ], - color: '#d7ccc5', + id: `${preset.id}_${Date.now()}_${index}`, + name: `${preset.name} ${index + 1}`, + type: preset.id, + shape: { + ...preset.shape, + x: 80 + (index % 5) * 60, + y: 80 + Math.floor(index / 5) * 60, + }, + conditions: preset.conditions.map((c) => ({ ...c, params: { ...(c.params || {}) } })), + color: preset.color, lockedForOptimization: false, placed: false, }; @@ -51,32 +60,9 @@ function App() { const [optimizing, setOptimizing] = useState(false); const [timeLeft, setTimeLeft] = useState(15); - useEffect(() => { - const amounts = state.objectAmounts || { table: 0, chair: 0 }; - const types = ['table', 'chair']; - let furniture = [...state.furniture]; - for (const type of types) { - const desired = amounts[type] ?? 0; - let existing = furniture.filter((f) => f.type === type); - if (existing.length > desired) { - // remove extras (unplaced first) - const toRemove = new Set(); - const sorted = [...existing].sort((a, b) => Number(a.placed) - Number(b.placed)); - for (let i = 0; i < existing.length - desired; i++) { - toRemove.add(sorted[i].id); - } - furniture = furniture.filter((f) => !toRemove.has(f.id)); - } else if (existing.length < desired) { - for (let i = existing.length; i < desired; i++) { - furniture.push(makePreset(type, i)); - } - } - } - if (furniture.length !== state.furniture.length) { - setState({ ...state, furniture }); - } - }, [state.objectAmounts]); + setState((s) => syncFurniture(s)); + }, [state.presets]); useEffect(() => { if (!optimizing) return; @@ -99,11 +85,10 @@ function App() { }, [optimizing, timeLeft]); const handleOptimize = () => { - const existing = state.furniture.map((f) => ({ - ...f, - lockedForOptimization: f.placed ? true : false, + setState((s) => ({ + ...s, + furniture: s.furniture.map((f) => ({ ...f, lockedForOptimization: f.placed ? true : false })), })); - setState({ ...state, furniture: existing }); setOptimizing(true); setTimeLeft(15); }; diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index 7934bd7..0ce552b 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import type { ConditionKind, Entrance, FurnitureConfig, LayoutState, RoomConfig } from '../types'; +import type { ConditionKind, Entrance, FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types'; +import { NumberField } from './NumberField'; interface ControlPanelProps { state: LayoutState; @@ -79,16 +80,14 @@ export function ControlPanel({ setOpenId(id); }; - const updateObject = (id: string, patch: Partial) => { - const updated = state.furniture.map((f) => (f.id === id ? { ...f, ...patch } : f)); - onStateChange({ ...state, furniture: updated }); + const updatePreset = (id: string, patch: Partial) => { + const presets = (state.presets || []).map((p) => (p.id === id ? { ...p, ...patch } : p)); + onStateChange({ ...state, presets }); }; - const updateSize = (id: string, width: number, height: number) => { - const updated = state.furniture.map((f) => - f.id === id ? { ...f, shape: { ...f.shape, width: Math.max(10, width), height: Math.max(10, height) } } : f, - ); - onStateChange({ ...state, furniture: updated }); + const setPresetAmount = (id: string, amount: number) => { + const presets = (state.presets || []).map((p) => (p.id === id ? { ...p, amount: Math.max(0, amount) } : p)); + onStateChange({ ...state, presets }); }; const toggleLock = (id: string) => { @@ -111,19 +110,35 @@ export function ControlPanel({ onStateChange({ ...state, furniture: updated }); }; - const adjustAmount = (type: string, delta: number) => { - const current = state.objectAmounts?.[type] ?? 0; - onStateChange({ - ...state, - objectAmounts: { ...(state.objectAmounts || {}), [type]: Math.max(0, current + delta) }, + const toggleFurnitureCondition = (id: string, k: ConditionKind, enabled: boolean) => { + const updated = state.furniture.map((x) => { + if (x.id !== id) return x; + const has = x.conditions.some((c) => c.kind === k); + if (enabled && !has) { + const params = k === 'near_object' ? { targetId: '' } : {}; + return { ...x, conditions: [...x.conditions, { kind: k, weight: 1, params }] }; + } + if (!enabled && has) { + return { ...x, conditions: x.conditions.filter((c) => c.kind !== k) }; + } + return x; }); + onStateChange({ ...state, furniture: updated }); }; - const setAmount = (type: string, value: number) => { - onStateChange({ - ...state, - objectAmounts: { ...(state.objectAmounts || {}), [type]: Math.max(0, value) }, + const togglePresetCondition = (id: string, k: ConditionKind, enabled: boolean) => { + const presets = (state.presets || []).map((p) => { + if (p.id !== id) return p; + const has = p.conditions.some((c) => c.kind === k); + if (enabled && !has) { + return { ...p, conditions: [...p.conditions, { kind: k, weight: 1, params: {} }] }; + } + if (!enabled && has) { + return { ...p, conditions: p.conditions.filter((c) => c.kind !== k) }; + } + return p; }); + onStateChange({ ...state, presets }); }; const toggleOpen = (id: string) => { @@ -131,46 +146,144 @@ export function ControlPanel({ setOpenId((cur) => (cur === id ? null : id)); }; + const conditionsEditor = ( + conditions: FurnitureConfig['conditions'], + onToggle: (k: ConditionKind, enabled: boolean) => void, + nearSelect?: { value: string; onChange: (v: string) => void; options: { id: string; name: string }[] }, + ) => ( +
+ {CONDITION_KINDS.map((k) => { + const active = conditions.some((c) => c.kind === k); + return ( +
+ + {k === 'near_object' && active && nearSelect && ( + + )} +
+ ); + })} +
+ ); + + const renderPresetCard = (p: FurniturePreset) => { + const open = openId === 'preset_' + p.id; + return ( +
+
toggleOpen('preset_' + p.id)}> + ▶ + + {p.name} + e.stopPropagation()}> + setPresetAmount(p.id, v)} + /> + +
+ {open && ( +
+
+ + +
+
+ Default Conditions +
+ {conditionsEditor(p.conditions, (k, enabled) => togglePresetCondition(p.id, k, enabled))} +
+ )} +
+ ); + }; + const renderObjectCard = (f: FurnitureConfig) => { const open = openId === f.id; const selected = selectedId === f.id; return ( -
+
toggleOpen(f.id)}> - {'\u25B6'} + ▶ {f.name} {f.lockedForOptimization && locked}
{open && (
-
+
- -
+ e.stopPropagation()} + onChange={(e) => { + const updated = state.furniture.map((x) => (x.id === f.id ? { ...x, name: e.target.value } : x)); + onStateChange({ ...state, furniture: updated }); + }} + /> + +
- -
+
-
Conditions
-
- {CONDITION_KINDS.map((k) => { - const active = f.conditions.some((c) => c.kind === k); - const targetId = (f.conditions.find((c) => c.kind === k)?.params as { targetId?: string } | undefined) - ?.targetId; - return ( -
- - {k === 'near_object' && active && ( - - )} -
- ); - })} -
+ {conditionsEditor( + f.conditions, + (k, enabled) => toggleFurnitureCondition(f.id, k, enabled), + { + value: + (f.conditions.find((c) => c.kind === 'near_object')?.params as { targetId?: string } | undefined) + ?.targetId || '', + onChange: (v) => setNearObjectTarget(f.id, v), + options: state.furniture.filter((o) => o.id !== f.id).map((o) => ({ id: o.id, name: o.name })), + }, + )}
)}
@@ -243,16 +321,10 @@ export function ControlPanel({ Venue Planner
- -
@@ -266,18 +338,20 @@ export function ControlPanel({
@@ -296,41 +370,8 @@ export function ControlPanel({
-
Furniture Presets
-
-
- - - Tables - - - - setAmount('table', Number(e.target.value))} - /> - - -
-
- - - Chairs - - - - setAmount('chair', Number(e.target.value))} - /> - - -
-
+
Presets
+
{(state.presets || []).map(renderPresetCard)}
@@ -350,36 +391,36 @@ export function ControlPanel({
@@ -387,7 +428,7 @@ export function ControlPanel({ )}
)} - {state.furniture.length === 0 && !state.entrance ? ( + {state.furniture.length === 0 ? (
No objects yet — add a preset or create one.
) : (
{state.furniture.map(renderObjectCard)}
@@ -407,11 +448,9 @@ export function ControlPanel({ {optimizing ? `Optimizing… ${timeLeft}s` : 'Optimize Layout'} {optimizing && ( - <> -
-
-
- +
+
+
)}
)} diff --git a/src/components/NumberField.tsx b/src/components/NumberField.tsx new file mode 100644 index 0000000..67c7589 --- /dev/null +++ b/src/components/NumberField.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; + +interface NumberFieldProps { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + width?: number | string; + className?: string; + title?: string; +} + +export function NumberField({ value, onChange, min, max, step, width, className, title }: NumberFieldProps) { + const [text, setText] = useState(String(value)); + + useEffect(() => { + setText(String(value)); + }, [value]); + + const clamp = (n: number) => { + if (min !== undefined) n = Math.max(min, n); + if (max !== undefined) n = Math.min(max, n); + return n; + }; + + return ( + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onChange={(e) => { + const raw = e.target.value; + if (!/^-?\d*$/.test(raw)) return; + setText(raw); + if (raw === '' || raw === '-') return; + const parsed = Number(raw); + if (!Number.isNaN(parsed)) { + const next = clamp(parsed); + if (next !== value) onChange(next); + } + }} + onBlur={() => { + if (text === '' || text === '-') { + const fallback = clamp(value); + onChange(fallback); + setText(String(fallback)); + } else { + setText(String(value)); + } + }} + onKeyDown={(e) => { + if (e.key === 'ArrowUp' && step) { + e.preventDefault(); + const next = clamp(value + step); + onChange(next); + setText(String(next)); + } + if (e.key === 'ArrowDown' && step) { + e.preventDefault(); + const next = clamp(value - step); + onChange(next); + setText(String(next)); + } + }} + /> + ); +} diff --git a/src/furniture/presets.ts b/src/furniture/presets.ts new file mode 100644 index 0000000..450207d --- /dev/null +++ b/src/furniture/presets.ts @@ -0,0 +1,27 @@ +import type { FurniturePreset } from '../types'; + +export const DEFAULT_PRESETS: FurniturePreset[] = [ + { + id: 'table', + name: 'Table', + color: '#a1887f', + amount: 0, + shape: { type: 'rectangle', width: 80, height: 60 }, + conditions: [ + { kind: 'walk_to_and_sit', weight: 1 }, + { kind: 'accessible', weight: 1 }, + ], + }, + { + id: 'chair', + name: 'Chair', + color: '#d7ccc5', + amount: 0, + shape: { type: 'rectangle', width: 40, height: 40 }, + conditions: [ + { kind: 'walk_to_and_sit', weight: 1 }, + { kind: 'near_object', weight: 1, params: { targetId: '' } }, + { kind: 'accessible', weight: 1 }, + ], + }, +]; diff --git a/src/index.css b/src/index.css index 3e1b681..4729efe 100644 --- a/src/index.css +++ b/src/index.css @@ -378,6 +378,25 @@ label { background: rgba(224, 168, 62, 0.14); } +.obj-count { + display: flex; + align-items: center; +} + +.obj-count input { + text-align: center; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 6px; + font-weight: 600; +} + +.obj-count input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + .obj-body { padding: 4px 10px 12px; border-top: 1px solid var(--border); diff --git a/src/types/index.ts b/src/types/index.ts index 63d1a06..c6f9aa4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -95,6 +95,7 @@ export interface FurniturePreset { shape: Omit; conditions: ConditionSpec[]; color?: string; + amount?: number; } export interface LayoutState {