Unify preset/object GUI as collapsible cards; add NumberField for reliable numeric editing
This commit is contained in:
+189
-150
@@ -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<FurnitureConfig>) => {
|
||||
const updated = state.furniture.map((f) => (f.id === id ? { ...f, ...patch } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
const updatePreset = (id: string, patch: Partial<FurniturePreset>) => {
|
||||
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 }[] },
|
||||
) => (
|
||||
<div className="cond-list">
|
||||
{CONDITION_KINDS.map((k) => {
|
||||
const active = conditions.some((c) => c.kind === k);
|
||||
return (
|
||||
<div className="cond" key={k}>
|
||||
<label>
|
||||
<input type="checkbox" checked={active} onChange={(e) => onToggle(k, e.target.checked)} />
|
||||
{CONDITION_LABELS[k]}
|
||||
</label>
|
||||
{k === 'near_object' && active && nearSelect && (
|
||||
<select
|
||||
value={nearSelect.value}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => nearSelect.onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select object…</option>
|
||||
{nearSelect.options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPresetCard = (p: FurniturePreset) => {
|
||||
const open = openId === 'preset_' + p.id;
|
||||
return (
|
||||
<div className={`obj-card ${open ? 'open' : ''}`} key={p.id}>
|
||||
<div className="obj-head" onClick={() => toggleOpen('preset_' + p.id)}>
|
||||
<span className={`obj-caret ${open ? 'open' : ''}`}>▶</span>
|
||||
<span className="obj-swatch" style={{ background: p.color }} />
|
||||
<span className="obj-name">{p.name}</span>
|
||||
<span className="obj-count" onClick={(e) => e.stopPropagation()}>
|
||||
<NumberField
|
||||
value={p.amount ?? 0}
|
||||
min={0}
|
||||
width={44}
|
||||
onChange={(v) => setPresetAmount(p.id, v)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="obj-body">
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<NumberField
|
||||
value={p.shape.width}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => updatePreset(p.id, { shape: { ...p.shape, width: v } })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<NumberField
|
||||
value={p.shape.height}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => updatePreset(p.id, { shape: { ...p.shape, height: v } })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="section-title" style={{ marginTop: 12 }}>
|
||||
Default Conditions
|
||||
</div>
|
||||
{conditionsEditor(p.conditions, (k, enabled) => togglePresetCondition(p.id, k, enabled))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderObjectCard = (f: FurnitureConfig) => {
|
||||
const open = openId === f.id;
|
||||
const selected = selectedId === f.id;
|
||||
return (
|
||||
<div key={f.id} className={`obj-card${selected ? ' selected' : ''}`}>
|
||||
<div key={f.id} className={`obj-card ${selected ? 'selected' : ''}`}>
|
||||
<div className="obj-head" onClick={() => toggleOpen(f.id)}>
|
||||
<span className={`obj-caret ${open ? 'open' : ''}`}>{'\u25B6'}</span>
|
||||
<span className={`obj-caret ${open ? 'open' : ''}`}>▶</span>
|
||||
<span className="obj-swatch" style={{ background: f.color || '#9aa0a6' }} />
|
||||
<span className="obj-name">{f.name}</span>
|
||||
{f.lockedForOptimization && <span className="obj-badge locked">locked</span>}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="obj-body">
|
||||
<div className="field" style={{ marginBottom: 8 }}>
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input value={f.name} onChange={(e) => updateObject(f.id, { name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<input
|
||||
value={f.name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
const updated = state.furniture.map((x) => (x.id === f.id ? { ...x, name: e.target.value } : x));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid-2" style={{ marginTop: 8 }}>
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
<NumberField
|
||||
value={f.shape.width}
|
||||
onChange={(e) => updateSize(f.id, Number(e.target.value), f.shape.height)}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => {
|
||||
const updated = state.furniture.map((x) =>
|
||||
x.id === f.id ? { ...x, shape: { ...x.shape, width: v } } : x,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
<NumberField
|
||||
value={f.shape.height}
|
||||
onChange={(e) => updateSize(f.id, f.shape.width, Number(e.target.value))}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => {
|
||||
const updated = state.furniture.map((x) =>
|
||||
x.id === f.id ? { ...x, shape: { ...x.shape, height: v } } : x,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginTop: 10, justifyContent: 'space-between' }}>
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -180,55 +293,20 @@ export function ControlPanel({
|
||||
Lock position
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="section-title" style={{ marginTop: 12 }}>
|
||||
Conditions
|
||||
</div>
|
||||
<div className="cond-list">
|
||||
{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 (
|
||||
<div className="cond" key={k}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => {
|
||||
const updated = state.furniture.map((x) => {
|
||||
if (x.id !== f.id) return x;
|
||||
const has = x.conditions.some((c) => c.kind === k);
|
||||
if (e.target.checked && !has) {
|
||||
const params = k === 'near_object' ? { targetId: '' } : {};
|
||||
return { ...x, conditions: [...x.conditions, { kind: k, weight: 1, params }] };
|
||||
}
|
||||
if (!e.target.checked && has) {
|
||||
return { ...x, conditions: x.conditions.filter((c) => c.kind !== k) };
|
||||
}
|
||||
return x;
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
/>
|
||||
{CONDITION_LABELS[k]}
|
||||
</label>
|
||||
{k === 'near_object' && active && (
|
||||
<select value={targetId || ''} onChange={(e) => setNearObjectTarget(f.id, e.target.value)}>
|
||||
<option value="">Select object…</option>
|
||||
{state.furniture
|
||||
.filter((other) => other.id !== f.id)
|
||||
.map((other) => (
|
||||
<option key={other.id} value={other.id}>
|
||||
{other.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{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 })),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -243,16 +321,10 @@ export function ControlPanel({
|
||||
Venue Planner
|
||||
</div>
|
||||
<div className="segmented" style={{ marginTop: 12 }}>
|
||||
<button
|
||||
className={mode === 'layout' ? 'active' : ''}
|
||||
onClick={() => onModeChange('layout')}
|
||||
>
|
||||
<button className={mode === 'layout' ? 'active' : ''} onClick={() => onModeChange('layout')}>
|
||||
Layout
|
||||
</button>
|
||||
<button
|
||||
className={mode === 'optimize' ? 'active' : ''}
|
||||
onClick={() => onModeChange('optimize')}
|
||||
>
|
||||
<button className={mode === 'optimize' ? 'active' : ''} onClick={() => onModeChange('optimize')}>
|
||||
Optimize
|
||||
</button>
|
||||
</div>
|
||||
@@ -266,18 +338,20 @@ export function ControlPanel({
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={state.room.width}
|
||||
onChange={(e) => setRoom({ width: Number(e.target.value) })}
|
||||
min={100}
|
||||
width="100%"
|
||||
onChange={(v) => setRoom({ width: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={state.room.height}
|
||||
onChange={(e) => setRoom({ height: Number(e.target.value) })}
|
||||
min={100}
|
||||
width="100%"
|
||||
onChange={(v) => setRoom({ height: v })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -296,41 +370,8 @@ export function ControlPanel({
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<div className="section-title">Furniture Presets</div>
|
||||
<div className="obj-list">
|
||||
<div className="counter">
|
||||
<span className="counter-label">
|
||||
<span className="swatch" style={{ background: '#a1887f' }} />
|
||||
Tables
|
||||
</span>
|
||||
<span className="stepper">
|
||||
<button onClick={() => adjustAmount('table', -1)}>−</button>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.objectAmounts?.table ?? 0}
|
||||
onChange={(e) => setAmount('table', Number(e.target.value))}
|
||||
/>
|
||||
<button onClick={() => adjustAmount('table', 1)}>+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="counter">
|
||||
<span className="counter-label">
|
||||
<span className="swatch" style={{ background: '#d7ccc5' }} />
|
||||
Chairs
|
||||
</span>
|
||||
<span className="stepper">
|
||||
<button onClick={() => adjustAmount('chair', -1)}>−</button>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.objectAmounts?.chair ?? 0}
|
||||
onChange={(e) => setAmount('chair', Number(e.target.value))}
|
||||
/>
|
||||
<button onClick={() => adjustAmount('chair', 1)}>+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-title">Presets</div>
|
||||
<div className="obj-list">{(state.presets || []).map(renderPresetCard)}</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
@@ -350,36 +391,36 @@ export function ControlPanel({
|
||||
<div className="grid-2">
|
||||
<label className="field">
|
||||
<span>X</span>
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={state.entrance.x}
|
||||
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ x: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Y</span>
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={state.entrance.y}
|
||||
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ y: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Width</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
<NumberField
|
||||
value={state.entrance.width ?? 20}
|
||||
onChange={(e) => setEntrance({ width: Math.max(10, Number(e.target.value)) })}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ width: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Height</span>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
<NumberField
|
||||
value={state.entrance.height ?? 20}
|
||||
onChange={(e) => setEntrance({ height: Math.max(10, Number(e.target.value)) })}
|
||||
min={10}
|
||||
width="100%"
|
||||
onChange={(v) => setEntrance({ height: v })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -387,7 +428,7 @@ export function ControlPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.furniture.length === 0 && !state.entrance ? (
|
||||
{state.furniture.length === 0 ? (
|
||||
<div className="empty">No objects yet — add a preset or create one.</div>
|
||||
) : (
|
||||
<div className="obj-list">{state.furniture.map(renderObjectCard)}</div>
|
||||
@@ -407,11 +448,9 @@ export function ControlPanel({
|
||||
{optimizing ? `Optimizing… ${timeLeft}s` : 'Optimize Layout'}
|
||||
</button>
|
||||
{optimizing && (
|
||||
<>
|
||||
<div className="progress">
|
||||
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} />
|
||||
</div>
|
||||
</>
|
||||
<div className="progress">
|
||||
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>(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 (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={className}
|
||||
title={title}
|
||||
style={width ? { width } : undefined}
|
||||
value={text}
|
||||
onClick={(e) => 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));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user