Redesign UI: dark theme, collapsible object dropdowns, steppers, improved canvas rendering

This commit is contained in:
2026-10-17 20:33:43 -04:00
parent 478b037e6a
commit e314623c10
5 changed files with 899 additions and 309 deletions
+332 -279
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import type { ConditionKind, Entrance, FurnitureConfig, LayoutState, RoomConfig } from '../types';
interface ControlPanelProps {
@@ -12,6 +13,26 @@ interface ControlPanelProps {
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',
};
export function ControlPanel({
state,
onStateChange,
@@ -23,6 +44,7 @@ export function ControlPanel({
optimizing,
timeLeft,
}: ControlPanelProps) {
const [openId, setOpenId] = useState<string | null>(null);
const setRoom = (room: Partial<RoomConfig>) => {
onStateChange({ ...state, room: { ...state.room, ...room } });
@@ -54,315 +76,346 @@ export function ControlPanel({
};
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
onSelect(id);
setOpenId(id);
};
const toggleLock = () => {
if (!selectedId) return;
const updateObject = (id: string, patch: Partial<FurnitureConfig>) => {
const updated = state.furniture.map((f) => (f.id === id ? { ...f, ...patch } : f));
onStateChange({ ...state, furniture: updated });
};
const updateSize = (id: string, width: number, height: number) => {
const updated = state.furniture.map((f) =>
f.id === selectedId ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
f.id === id ? { ...f, shape: { ...f.shape, width: Math.max(10, width), height: Math.max(10, height) } } : f,
);
onStateChange({ ...state, furniture: updated });
};
const updateSelectedCondition = (kind: ConditionKind, enabled: boolean) => {
if (!selectedId) return;
const updated = state.furniture.map((f) => {
if (f.id !== selectedId) return f;
const has = f.conditions.some((c) => c.kind === kind);
if (enabled && !has) {
const params = kind === 'near_object' ? { targetId: '' } : {};
return { ...f, conditions: [...f.conditions, { kind, weight: 1, params }] };
}
if (!enabled && has) {
return { ...f, conditions: f.conditions.filter((c) => c.kind !== kind) };
}
return f;
});
const toggleLock = (id: string) => {
const updated = state.furniture.map((f) =>
f.id === id ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
);
onStateChange({ ...state, furniture: updated });
};
const setNearObjectTarget = (targetId: string) => {
if (!selectedId) return;
const setNearObjectTarget = (id: string, targetId: string) => {
const updated = state.furniture.map((f) => {
if (f.id !== selectedId) return f;
if (f.id !== id) return f;
return {
...f,
conditions: f.conditions.map((c) => (c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c)),
conditions: f.conditions.map((c) =>
c.kind === 'near_object' ? { ...c, params: { ...c.params, targetId } } : c,
),
};
});
onStateChange({ ...state, furniture: updated });
};
const updateSelectedSize = (width: number, height: number) => {
if (!selectedId) return;
const updated = state.furniture.map((f) =>
f.id === selectedId
? { ...f, shape: { ...f.shape, width: Math.max(10, width), height: Math.max(10, height) } }
: f,
);
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 selected = state.furniture.find((f) => f.id === selectedId);
const setAmount = (type: string, value: number) => {
onStateChange({
...state,
objectAmounts: { ...(state.objectAmounts || {}), [type]: Math.max(0, value) },
});
};
const toggleOpen = (id: string) => {
onSelect(id);
setOpenId((cur) => (cur === id ? null : id));
};
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' : ''}`}>{'\u25B6'}</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 }}>
<span>Name</span>
<input value={f.name} onChange={(e) => updateObject(f.id, { name: e.target.value })} />
</div>
<div className="grid-2">
<label className="field">
<span>Width</span>
<input
type="number"
min={10}
value={f.shape.width}
onChange={(e) => updateSize(f.id, Number(e.target.value), f.shape.height)}
/>
</label>
<label className="field">
<span>Height</span>
<input
type="number"
min={10}
value={f.shape.height}
onChange={(e) => updateSize(f.id, f.shape.width, Number(e.target.value))}
/>
</label>
</div>
<div className="row" style={{ marginTop: 10, justifyContent: 'space-between' }}>
<label>
<input
type="checkbox"
checked={f.lockedForOptimization}
onChange={() => toggleLock(f.id)}
/>
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>
</div>
)}
</div>
);
};
return (
<div className="panel" style={{ padding: 12, borderRight: '1px solid #ccc', minWidth: 260 }}>
<div style={{ marginBottom: 12 }}>
<button onClick={() => onModeChange('layout')} disabled={mode === 'layout'}>
Layout
</button>
<button onClick={() => onModeChange('optimize')} disabled={mode === 'optimize'} style={{ marginLeft: 8 }}>
Optimize
</button>
<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>
{mode === 'layout' && (
<div>
<h3>Room</h3>
<label>
Width:
<input
type="number"
value={state.room.width}
onChange={(e) => setRoom({ width: Number(e.target.value) })}
style={{ width: 80, marginLeft: 4 }}
/>
</label>
<label style={{ marginLeft: 8 }}>
Height:
<input
type="number"
value={state.room.height}
onChange={(e) => setRoom({ height: Number(e.target.value) })}
style={{ width: 80, marginLeft: 4 }}
/>
</label>
<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>
<input
type="number"
value={state.room.width}
onChange={(e) => setRoom({ width: Number(e.target.value) })}
/>
</label>
<label className="field">
<span>Height</span>
<input
type="number"
value={state.room.height}
onChange={(e) => setRoom({ height: Number(e.target.value) })}
/>
</label>
</div>
</div>
<div style={{ marginTop: 8 }}>
<button onClick={placeEntrance} style={{ marginLeft: 8 }}>
Place Entrance
</button>
<button onClick={createObject} style={{ marginLeft: 8 }}>
Create Object
</button>
</div>
{state.entrance && (
<div style={{ marginTop: 8 }}>
<label>
Ex:
<input
type="number"
value={state.entrance.x}
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
style={{ width: 60 }}
/>
</label>
<label style={{ marginLeft: 4 }}>
Ey:
<input
type="number"
value={state.entrance.y}
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
style={{ width: 60 }}
/>
</label>
</div>
)}
<h3 style={{ marginTop: 12 }}>Premade Objects</h3>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>Tables:</span>
<input
type="number"
min={0}
value={state.objectAmounts?.table ?? 0}
onChange={(e) =>
onStateChange({
...state,
objectAmounts: { ...(state.objectAmounts || {}), table: Math.max(0, Number(e.target.value)) },
})
}
style={{ width: 60 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<span>Chairs:</span>
<input
type="number"
min={0}
value={state.objectAmounts?.chair ?? 0}
onChange={(e) =>
onStateChange({
...state,
objectAmounts: { ...(state.objectAmounts || {}), chair: Math.max(0, Number(e.target.value)) },
})
}
style={{ width: 60 }}
/>
</div>
</div>
<h3 style={{ marginTop: 12 }}>Objects</h3>
<div>
{state.entrance && (
<div
onClick={() => onSelect('__entrance__')}
style={{
padding: 4,
background: selectedId === '__entrance__' ? '#eee' : 'transparent',
cursor: 'pointer',
}}
>
Entrance
</div>
)}
{state.furniture.map((f) => (
<div
key={f.id}
onClick={() => onSelect(f.id)}
style={{
padding: 4,
background: selectedId === f.id ? '#eee' : 'transparent',
cursor: 'pointer',
}}
>
{f.name} {f.lockedForOptimization ? '(locked)' : ''}
</div>
))}
</div>
{selectedId === '__entrance__' && state.entrance && (
<div style={{ marginTop: 8 }}>
<div>Entrance Settings</div>
<label>
X:
<input
type="number"
value={state.entrance.x}
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
style={{ width: 60, marginLeft: 4 }}
/>
</label>
<label style={{ marginLeft: 8 }}>
Y:
<input
type="number"
value={state.entrance.y}
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
style={{ width: 60, marginLeft: 4 }}
/>
</label>
<div style={{ marginTop: 4 }}>
<label>
W:
<input
type="number"
min={10}
value={state.entrance.width ?? 20}
onChange={(e) => setEntrance({ width: Math.max(10, Number(e.target.value)) })}
style={{ width: 60, marginLeft: 4 }}
/>
</label>
<label style={{ marginLeft: 8 }}>
H:
<input
type="number"
min={10}
value={state.entrance.height ?? 20}
onChange={(e) => setEntrance({ height: Math.max(10, Number(e.target.value)) })}
style={{ width: 60, marginLeft: 4 }}
/>
</label>
<div className="section">
<div className="section-title">Scene</div>
<div className="row">
<button onClick={placeEntrance} style={{ flex: 1 }}>
Place Entrance
</button>
<button onClick={createObject} style={{ flex: 1 }}>
Create Object
</button>
</div>
</div>
)}
{selected && (
<div style={{ marginTop: 8 }}>
<div>
<label>
Name:
<input
value={selected.name}
onChange={(e) => {
const updated = state.furniture.map((f) => (f.id === selectedId ? { ...f, name: e.target.value } : f));
onStateChange({ ...state, furniture: updated });
}}
style={{ width: 120, marginLeft: 4 }}
/>
</label>
<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 style={{ marginTop: 4 }}>
<button onClick={toggleLock}>{selected.lockedForOptimization ? 'Unlock' : 'Lock'} for opt</button>
</div>
<div style={{ marginTop: 8 }}>
<div>Size:</div>
<label>
W:
<input
type="number"
min={10}
value={selected.shape.width}
onChange={(e) => updateSelectedSize(Number(e.target.value), selected.shape.height)}
style={{ width: 60, marginLeft: 4 }}
/>
</label>
<label style={{ marginLeft: 8 }}>
H:
<input
type="number"
min={10}
value={selected.shape.height}
onChange={(e) => updateSelectedSize(selected.shape.width, Number(e.target.value))}
style={{ width: 60, marginLeft: 4 }}
/>
</label>
</div>
<div style={{ marginTop: 8 }}>
<div>Conditions:</div>
{(['walk_to_and_sit', 'around_open_area', 'near_entrance', 'near_object', 'accessible', 'not_blocking_path', 'open_circulation'] as ConditionKind[]).map(
(k) => (
<div key={k} style={{ display: 'block' }}>
<label>
<input
type="checkbox"
checked={selected.conditions.some((c) => c.kind === k)}
onChange={(e) => updateSelectedCondition(k, e.target.checked)}
/>
{k}
</label>
{k === 'near_object' && selected.conditions.some((c) => c.kind === 'near_object') && (
<select
style={{ marginLeft: 8 }}
value={(selected.conditions.find((c) => c.kind === 'near_object')?.params as any)?.targetId || ''}
onChange={(e) => setNearObjectTarget(e.target.value)}
>
<option value="">Select object</option>
{state.furniture
.filter((f) => f.id !== selected.id)
.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
</div>
<div className="section">
<div className="section-title">Objects</div>
{state.entrance && (
<div
className={`obj-card ${selectedId === '__entrance__' ? 'selected' : ''}`}
style={{ marginBottom: 6 }}
>
<div className="obj-head" onClick={() => onSelect('__entrance__')}>
<span className="obj-swatch" style={{ background: '#3fbf7f' }} />
<span className="obj-name">Entrance</span>
<span className="obj-badge">fixed</span>
</div>
{selectedId === '__entrance__' && (
<div className="obj-body">
<div className="grid-2">
<label className="field">
<span>X</span>
<input
type="number"
value={state.entrance.x}
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
/>
</label>
<label className="field">
<span>Y</span>
<input
type="number"
value={state.entrance.y}
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
/>
</label>
<label className="field">
<span>Width</span>
<input
type="number"
min={10}
value={state.entrance.width ?? 20}
onChange={(e) => setEntrance({ width: Math.max(10, Number(e.target.value)) })}
/>
</label>
<label className="field">
<span>Height</span>
<input
type="number"
min={10}
value={state.entrance.height ?? 20}
onChange={(e) => setEntrance({ height: Math.max(10, Number(e.target.value)) })}
/>
</label>
</div>
</div>
),
)}
</div>
)}
</div>
)}
{state.furniture.length === 0 && !state.entrance ? (
<div className="empty">No objects yet — add a preset or create one.</div>
) : (
<div className="obj-list">{state.furniture.map(renderObjectCard)}</div>
)}
</div>
)}
</div>
)}
</>
)}
{mode === 'optimize' && (
<div>
<h3>Optimize</h3>
<p>Runs random search (15s) then NN-informed placement heuristic.</p>
<button onClick={onOptimize} disabled={optimizing}>
{optimizing ? `Optimizing... ${timeLeft}s` : 'Optimize'}
</button>
{optimizing && <div style={{ marginTop: 8 }}>Randomizing layouts, keeping best...</div>}
</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>
);
}
+82 -18
View File
@@ -34,7 +34,27 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
ctx.save();
ctx.scale(zoom, zoom);
ctx.strokeStyle = '#666';
ctx.fillStyle = '#12151b';
ctx.fillRect(0, 0, state.room.width, state.room.height);
// subtle grid
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
ctx.lineWidth = 1;
const step = 25;
for (let x = 0; x <= state.room.width; x += step) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, state.room.height);
ctx.stroke();
}
for (let y = 0; y <= state.room.height; y += step) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(state.room.width, y);
ctx.stroke();
}
ctx.strokeStyle = '#2c3342';
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, state.room.width, state.room.height);
@@ -42,30 +62,58 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
const e = state.entrance;
const w = e.width ?? 20;
const h = e.height ?? 20;
ctx.fillStyle = '#4caf50';
const selected = selectedId === '__entrance__';
ctx.fillStyle = '#3fbf7f';
ctx.fillRect(e.x, e.y, w, h);
ctx.strokeStyle = selectedId === '__entrance__' ? '#ff5722' : '#333';
ctx.lineWidth = selectedId === '__entrance__' ? 3 : 1;
ctx.strokeStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.4)';
ctx.lineWidth = selected ? 2.5 : 1;
ctx.strokeRect(e.x, e.y, w, h);
ctx.fillStyle = '#000';
ctx.font = '10px sans-serif';
ctx.fillText('ENTRANCE', e.x + 2, e.y + 12);
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.font = '600 10px Inter, sans-serif';
ctx.textBaseline = 'middle';
if (w > 50) ctx.fillText('ENTRANCE', e.x + 6, e.y + h / 2);
// resize handle (SE)
ctx.fillStyle = selectedId === '__entrance__' ? '#ff5722' : '#666';
ctx.fillStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(e.x + w - 4, e.y + h - 4, 8, 8);
}
for (const f of state.furniture) {
ctx.fillStyle = f.color || '#9aa0a6';
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.5 : 0.9;
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeStyle = selectedId === f.id ? '#ff5722' : '#333';
ctx.lineWidth = selectedId === f.id ? 3 : 1;
ctx.strokeRect(b.x, b.y, b.w, b.h);
ctx.fillStyle = '#000';
ctx.font = '10px sans-serif';
ctx.fillText(f.name.substring(0, 12), b.x + 2, b.y + 12);
const r = Math.min(8, b.w / 4, b.h / 4);
const selected = selectedId === f.id;
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.55 : 1;
ctx.beginPath();
roundRect(ctx, b.x, b.y, b.w, b.h, r);
ctx.fillStyle = f.color || '#9aa0a6';
ctx.fill();
if (selected) {
ctx.shadowColor = '#4f8cff';
ctx.shadowBlur = 14;
ctx.strokeStyle = '#4f8cff';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.shadowBlur = 0;
} else {
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
ctx.font = '600 11px Inter, sans-serif';
ctx.textBaseline = 'middle';
const label = b.w > 44 ? f.name.substring(0, 14) : '';
if (label) ctx.fillText(label, b.x + 7, b.y + b.h / 2);
if (f.lockedForOptimization) {
ctx.font = '10px Inter, sans-serif';
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
ctx.fillText('🔒', b.x + b.w - 15, b.y + 9);
}
ctx.globalAlpha = 1;
}
@@ -156,7 +204,7 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
ref={canvasRef}
width={Math.max(state.room.width, 400)}
height={Math.max(state.room.height, 300)}
style={{ border: '1px solid #ccc', maxWidth: '100%', height: 'auto' }}
style={{ maxWidth: '100%', height: 'auto' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -167,3 +215,19 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
function pointInRect(p: Point, r: { x: number; y: number; w: number; h: number }): boolean {
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}