Unify presets and objects into one Objects list; every object card has an amount field

This commit is contained in:
2026-10-17 21:35:55 -04:00
parent 5b2bc25ef6
commit 1ecac34502
8 changed files with 213 additions and 260 deletions
+135 -226
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import type { ConditionKind, Entrance, FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types';
import { useEffect, useState } from 'react';
import type { ConditionKind, Entrance, FurniturePreset, LayoutState, RoomConfig } from '../types';
import { NumberField } from './NumberField';
interface ControlPanelProps {
@@ -34,6 +34,8 @@ const CONDITION_LABELS: Record<ConditionKind, string> = {
open_circulation: 'Open circulation',
};
const SWATCHES = ['#a1887f', '#d7ccc5', '#90caf9', '#a5d6a7', '#ef9a9a', '#ce93d8', '#ffcc80'];
export function ControlPanel({
state,
onStateChange,
@@ -46,92 +48,66 @@ export function ControlPanel({
timeLeft,
}: ControlPanelProps) {
const [openId, setOpenId] = useState<string | null>(null);
const objects = state.presets || [];
const setRoom = (room: Partial<RoomConfig>) => {
onStateChange({ ...state, room: { ...state.room, ...room } });
};
const selectedTypeId = selectedId
? state.furniture.find((f) => f.id === selectedId)?.type ?? (selectedId === '__entrance__' ? '__entrance__' : null)
: null;
useEffect(() => {
if (selectedTypeId) setOpenId(selectedTypeId);
}, [selectedTypeId]);
const setRoom = (room: Partial<RoomConfig>) => onStateChange({ ...state, room: { ...state.room, ...room } });
const setEntrance = (entrance: Partial<Entrance>) => {
if (!state.entrance) return;
onStateChange({ ...state, entrance: { ...state.entrance, ...entrance } });
};
const placeEntrance = () => {
onStateChange({ ...state, entrance: { x: 20, y: 20, width: 20, height: 20 } });
const placeEntrance = () => onStateChange({ ...state, entrance: { x: 20, y: 20, width: 20, height: 20 } });
const updateObject = (id: string, patch: Partial<FurniturePreset>) => {
const presets = objects.map((p) => (p.id === id ? { ...p, ...patch } : p));
onStateChange({ ...state, presets });
};
const setAmount = (id: string, amount: number) => updateObject(id, { amount: Math.max(0, amount) });
const removeObject = (id: string) => {
onStateChange({
...state,
presets: objects.filter((p) => p.id !== id),
furniture: state.furniture.filter((f) => f.type !== id),
});
};
const createObject = () => {
const id = 'obj_' + Date.now();
const newItem: FurnitureConfig = {
const newObject: FurniturePreset = {
id,
name: 'Object ' + (state.furniture.length + 1),
type: 'custom',
shape: { type: 'rectangle', x: 100, y: 100, width: 50, height: 50 },
name: 'Object ' + (objects.filter((o) => !o.isPreset).length + 1),
color: SWATCHES[objects.length % SWATCHES.length],
amount: 1,
isPreset: false,
shape: { type: 'rectangle', width: 50, height: 50 },
conditions: [
{ kind: 'walk_to_and_sit', weight: 1 },
{ kind: 'accessible', weight: 1 },
],
color: '#90caf9',
lockedForOptimization: false,
placed: true,
};
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
onStateChange({ ...state, presets: [...objects, newObject] });
onSelect(id);
setOpenId(id);
};
const updatePreset = (id: string, patch: Partial<FurniturePreset>) => {
const presets = (state.presets || []).map((p) => (p.id === id ? { ...p, ...patch } : p));
onStateChange({ ...state, presets });
};
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) => {
const updated = state.furniture.map((f) =>
f.id === id ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
);
onStateChange({ ...state, furniture: updated });
};
const setNearObjectTarget = (id: string, targetId: string) => {
const updated = state.furniture.map((f) => {
if (f.id !== id) return f;
return {
...f,
conditions: f.conditions.map((c) =>
c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c,
),
};
});
onStateChange({ ...state, furniture: updated });
};
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 togglePresetCondition = (id: string, k: ConditionKind, enabled: boolean) => {
const presets = (state.presets || []).map((p) => {
const toggleObjectCondition = (id: string, k: ConditionKind, enabled: boolean) => {
const presets = objects.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: {} }] };
const params = k === 'near_object' ? { targetId: '' } : {};
return { ...p, conditions: [...p.conditions, { kind: k, weight: 1, params }] };
}
if (!enabled && has) {
return { ...p, conditions: p.conditions.filter((c) => c.kind !== k) };
@@ -141,64 +117,44 @@ export function ControlPanel({
onStateChange({ ...state, presets });
};
const setNearObjectTarget = (id: string, targetId: string) => {
const presets = objects.map((p) => {
if (p.id !== id) return p;
return {
...p,
conditions: p.conditions.map((c) =>
c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c,
),
};
});
onStateChange({ ...state, presets });
};
const toggleOpen = (id: string) => {
onSelect(id);
onSelect(id === '__entrance__' ? '__entrance__' : id);
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;
const renderObjectCard = (p: FurniturePreset) => {
const open = openId === p.id;
return (
<div className={`obj-card ${open ? 'open' : ''}`} key={p.id}>
<div className="obj-head" onClick={() => toggleOpen('preset_' + p.id)}>
<div key={p.id} className={`obj-card ${open ? 'open' : ''}`}>
<div className="obj-head" onClick={() => toggleOpen(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)}
/>
<NumberField value={p.amount ?? 0} min={0} width={44} onChange={(v) => setAmount(p.id, v)} />
</span>
</div>
{open && (
<div className="obj-body">
{!p.isPreset && (
<label className="field" style={{ marginBottom: 10 }}>
<span>Name</span>
<input value={p.name} onChange={(e) => updateObject(p.id, { name: e.target.value })} />
</label>
)}
<div className="grid-2">
<label className="field">
<span>Width</span>
@@ -206,7 +162,7 @@ export function ControlPanel({
value={p.shape.width}
min={10}
width="100%"
onChange={(v) => updatePreset(p.id, { shape: { ...p.shape, width: v } })}
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, width: v } })}
/>
</label>
<label className="field">
@@ -215,97 +171,76 @@ export function ControlPanel({
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 className="obj-head" onClick={() => toggleOpen(f.id)}>
<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">
<label className="field">
<span>Name</span>
<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>
<NumberField
value={f.shape.width}
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>
<NumberField
value={f.shape.height}
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 });
}}
onChange={(v) => updateObject(p.id, { shape: { ...p.shape, height: v } })}
/>
</label>
</div>
<div className="row" style={{ marginTop: 10 }}>
<label>
<input
type="checkbox"
checked={f.lockedForOptimization}
onChange={() => toggleLock(f.id)}
/>
Lock position
</label>
<span className="hint" style={{ margin: 0 }}>
Color
</span>
<div className="swatch-row">
{SWATCHES.map((c) => (
<button
key={c}
className={`swatch-btn ${p.color === c ? 'active' : ''}`}
style={{ background: c }}
onClick={(e) => {
e.stopPropagation();
updateObject(p.id, { color: c });
}}
/>
))}
</div>
</div>
<div className="section-title" style={{ marginTop: 12 }}>
Conditions
</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 className="cond-list">
{CONDITION_KINDS.map((k) => {
const active = p.conditions.some((c) => c.kind === k);
const targetId = (p.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) => toggleObjectCondition(p.id, k, e.target.checked)}
/>
{CONDITION_LABELS[k]}
</label>
{k === 'near_object' && active && (
<select
value={targetId || ''}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setNearObjectTarget(p.id, e.target.value)}
>
<option value="">Select object…</option>
{objects
.filter((o) => o.id !== p.id)
.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
)}
</div>
);
})}
</div>
{!p.isPreset && (
<button
style={{ marginTop: 12, width: '100%' }}
onClick={(e) => {
e.stopPropagation();
removeObject(p.id);
}}
>
Delete Object
</button>
)}
</div>
)}
@@ -338,21 +273,11 @@ export function ControlPanel({
<div className="grid-2">
<label className="field">
<span>Width</span>
<NumberField
value={state.room.width}
min={100}
width="100%"
onChange={(v) => setRoom({ width: v })}
/>
<NumberField value={state.room.width} min={100} width="100%" onChange={(v) => setRoom({ width: v })} />
</label>
<label className="field">
<span>Height</span>
<NumberField
value={state.room.height}
min={100}
width="100%"
onChange={(v) => setRoom({ height: v })}
/>
<NumberField value={state.room.height} min={100} width="100%" onChange={(v) => setRoom({ height: v })} />
</label>
</div>
</div>
@@ -369,17 +294,14 @@ export function ControlPanel({
</div>
</div>
<div className="section">
<div className="section-title">Presets</div>
<div className="obj-list">{(state.presets || []).map(renderPresetCard)}</div>
</div>
<div className="section">
<div className="section-title">Objects</div>
<div className="obj-list">{objects.map(renderObjectCard)}</div>
{state.entrance && (
<div
className={`obj-card ${selectedId === '__entrance__' ? 'selected' : ''}`}
style={{ marginBottom: 6 }}
style={{ marginTop: 6 }}
>
<div className="obj-head" onClick={() => onSelect('__entrance__')}>
<span className="obj-swatch" style={{ background: '#3fbf7f' }} />
@@ -391,19 +313,11 @@ export function ControlPanel({
<div className="grid-2">
<label className="field">
<span>X</span>
<NumberField
value={state.entrance.x}
width="100%"
onChange={(v) => setEntrance({ x: v })}
/>
<NumberField value={state.entrance.x} width="100%" onChange={(v) => setEntrance({ x: v })} />
</label>
<label className="field">
<span>Y</span>
<NumberField
value={state.entrance.y}
width="100%"
onChange={(v) => setEntrance({ y: v })}
/>
<NumberField value={state.entrance.y} width="100%" onChange={(v) => setEntrance({ y: v })} />
</label>
<label className="field">
<span>Width</span>
@@ -428,11 +342,6 @@ export function ControlPanel({
)}
</div>
)}
{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>
)}
</div>
</>
)}