366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import type { ConditionKind, Entrance, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
|
import { NumberField } from './NumberField';
|
|
|
|
interface ControlPanelProps {
|
|
state: LayoutState;
|
|
onStateChange: (state: LayoutState) => void;
|
|
mode: 'layout' | 'optimize';
|
|
onModeChange: (mode: 'layout' | 'optimize') => void;
|
|
selectedId: string | null;
|
|
onSelect: (id: string | null) => void;
|
|
onOptimize: () => void;
|
|
optimizing: boolean;
|
|
timeLeft: number;
|
|
}
|
|
|
|
const CONDITION_KINDS: ConditionKind[] = [
|
|
'walk_to_and_sit',
|
|
'around_open_area',
|
|
'near_entrance',
|
|
'near_object',
|
|
'accessible',
|
|
'not_blocking_path',
|
|
'open_circulation',
|
|
];
|
|
|
|
const CONDITION_LABELS: Record<ConditionKind, string> = {
|
|
walk_to_and_sit: 'Walk to and sit',
|
|
around_open_area: 'Around open area',
|
|
near_entrance: 'Near entrance',
|
|
near_object: 'Near object',
|
|
accessible: 'Accessible',
|
|
not_blocking_path: 'Not blocking path',
|
|
open_circulation: 'Open circulation',
|
|
};
|
|
|
|
const SWATCHES = ['#a1887f', '#d7ccc5', '#90caf9', '#a5d6a7', '#ef9a9a', '#ce93d8', '#ffcc80'];
|
|
|
|
export function ControlPanel({
|
|
state,
|
|
onStateChange,
|
|
mode,
|
|
onModeChange,
|
|
selectedId,
|
|
onSelect,
|
|
onOptimize,
|
|
optimizing,
|
|
timeLeft,
|
|
}: ControlPanelProps) {
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
const objects = state.presets || [];
|
|
|
|
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 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 newObject: FurniturePreset = {
|
|
id,
|
|
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 },
|
|
],
|
|
};
|
|
onStateChange({ ...state, presets: [...objects, newObject] });
|
|
onSelect(id);
|
|
setOpenId(id);
|
|
};
|
|
|
|
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) {
|
|
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) };
|
|
}
|
|
return p;
|
|
});
|
|
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 === '__entrance__' ? '__entrance__' : id);
|
|
setOpenId((cur) => (cur === id ? null : id));
|
|
};
|
|
|
|
const renderObjectCard = (p: FurniturePreset) => {
|
|
const open = openId === p.id;
|
|
return (
|
|
<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) => 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>
|
|
<NumberField
|
|
value={p.shape.width}
|
|
min={10}
|
|
width="100%"
|
|
onChange={(v) => updateObject(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) => updateObject(p.id, { shape: { ...p.shape, height: v } })}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="row" style={{ marginTop: 10 }}>
|
|
<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>
|
|
<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>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<aside className="sidebar">
|
|
<div className="sidebar-header">
|
|
<div className="sidebar-title">
|
|
<span className="dot" />
|
|
Venue Planner
|
|
</div>
|
|
<div className="segmented" style={{ marginTop: 12 }}>
|
|
<button className={mode === 'layout' ? 'active' : ''} onClick={() => onModeChange('layout')}>
|
|
Layout
|
|
</button>
|
|
<button className={mode === 'optimize' ? 'active' : ''} onClick={() => onModeChange('optimize')}>
|
|
Optimize
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sidebar-body">
|
|
{mode === 'layout' && (
|
|
<>
|
|
<div className="section" style={{ marginTop: 0 }}>
|
|
<div className="section-title">Room Size</div>
|
|
<div className="grid-2">
|
|
<label className="field">
|
|
<span>Width</span>
|
|
<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 })} />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="section">
|
|
<div className="section-title">Scene</div>
|
|
<button onClick={createObject} style={{ width: '100%' }}>
|
|
Create Object
|
|
</button>
|
|
</div>
|
|
|
|
<div className="section">
|
|
<div className="section-title">Entrance</div>
|
|
{state.entrance ? (
|
|
<>
|
|
<div className="grid-2">
|
|
<label className="field">
|
|
<span>X</span>
|
|
<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 })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>Width</span>
|
|
<NumberField
|
|
value={state.entrance.width ?? 20}
|
|
min={10}
|
|
width="100%"
|
|
onChange={(v) => setEntrance({ width: v })}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>Height</span>
|
|
<NumberField
|
|
value={state.entrance.height ?? 20}
|
|
min={10}
|
|
width="100%"
|
|
onChange={(v) => setEntrance({ height: v })}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<button
|
|
style={{ marginTop: 8, width: '100%' }}
|
|
onClick={() => onStateChange({ ...state, entrance: null })}
|
|
>
|
|
Remove Entrance
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button onClick={placeEntrance} style={{ width: '100%' }}>
|
|
Place Entrance
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="section">
|
|
<div className="section-title">Objects</div>
|
|
<div className="obj-list">{objects.map(renderObjectCard)}</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{mode === 'optimize' && (
|
|
<div className="section" style={{ marginTop: 0 }}>
|
|
<div className="section-title">Optimization</div>
|
|
<p className="hint">
|
|
Placed objects are locked in position. Unplaced objects are randomized across the room for 15
|
|
seconds; the best-scoring layout is kept.
|
|
</p>
|
|
<button className="primary" onClick={onOptimize} disabled={optimizing} style={{ width: '100%' }}>
|
|
{optimizing ? `Optimizing… ${timeLeft}s` : 'Optimize Layout'}
|
|
</button>
|
|
{optimizing && (
|
|
<div className="progress">
|
|
<div style={{ width: `${((15 - timeLeft) / 15) * 100}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|