Make entrance a draggable/resizable square with selection and size controls
This commit is contained in:
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>furniture-app</title>
|
||||
<script type="module" crossorigin src="/assets/index-j9ORS9Fz.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DK0NE8HO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B4HUQdL0.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+63
-31
@@ -12,6 +12,38 @@ const initialState: LayoutState = {
|
||||
objectCount: 0,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
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',
|
||||
lockedForOptimization: false,
|
||||
placed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<LayoutState>(initialState);
|
||||
const [mode, setMode] = useState<'layout' | 'optimize'>('layout');
|
||||
@@ -20,6 +52,32 @@ function App() {
|
||||
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<string>();
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!optimizing) return;
|
||||
if (timeLeft <= 0) {
|
||||
@@ -41,37 +99,11 @@ function App() {
|
||||
}, [optimizing, timeLeft]);
|
||||
|
||||
const handleOptimize = () => {
|
||||
// lock all objects that are on map; generate remaining up to objectCount with random positions
|
||||
const existing = state.furniture.map((f) => ({ ...f, lockedForOptimization: true, placed: true }));
|
||||
const target = state.objectCount ?? existing.length;
|
||||
const need = Math.max(0, target - existing.length);
|
||||
const generated: FurnitureConfig[] = [];
|
||||
for (let i = 0; i < need; i++) {
|
||||
const id = 'g_' + Date.now() + '_' + i;
|
||||
generated.push({
|
||||
id,
|
||||
name: 'Obj ' + (existing.length + i + 1),
|
||||
shape: {
|
||||
type: 'rectangle',
|
||||
x: Math.random() * Math.max(50, state.room.width - 60),
|
||||
y: Math.random() * Math.max(50, state.room.height - 60),
|
||||
width: 40 + Math.random() * 40,
|
||||
height: 30 + Math.random() * 30,
|
||||
},
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
{ kind: 'accessible', weight: 1 },
|
||||
],
|
||||
color: '#90caf9',
|
||||
lockedForOptimization: false,
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
const lockedState: LayoutState = {
|
||||
...state,
|
||||
furniture: [...existing, ...generated],
|
||||
};
|
||||
setState(lockedState);
|
||||
const existing = state.furniture.map((f) => ({
|
||||
...f,
|
||||
lockedForOptimization: f.placed ? true : false,
|
||||
}));
|
||||
setState({ ...state, furniture: existing });
|
||||
setOptimizing(true);
|
||||
setTimeLeft(15);
|
||||
};
|
||||
|
||||
+126
-76
@@ -1,4 +1,4 @@
|
||||
import type { ConditionKind, Entrance, FurnitureConfig, FurniturePreset, LayoutState, RoomConfig } from '../types';
|
||||
import type { ConditionKind, Entrance, LayoutState, RoomConfig } from '../types';
|
||||
|
||||
interface ControlPanelProps {
|
||||
state: LayoutState;
|
||||
@@ -23,61 +23,7 @@ export function ControlPanel({
|
||||
optimizing,
|
||||
timeLeft,
|
||||
}: ControlPanelProps) {
|
||||
const addPreset = (p: FurniturePreset) => {
|
||||
const id = 'f_' + Date.now();
|
||||
const newItem: FurnitureConfig = {
|
||||
id,
|
||||
name: p.name,
|
||||
shape: { ...p.shape, x: 50, y: 50 },
|
||||
conditions: p.conditions.map((c) => ({ ...c })),
|
||||
color: p.color,
|
||||
lockedForOptimization: false,
|
||||
placed: true,
|
||||
};
|
||||
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
const addFurniture = () => {
|
||||
const id = 'f_' + Date.now();
|
||||
const newItem: FurnitureConfig = {
|
||||
id,
|
||||
name: 'Table ' + state.furniture.length,
|
||||
shape: { type: 'rectangle', x: 50, y: 50, width: 60, height: 40 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
{ kind: 'accessible', weight: 1 },
|
||||
],
|
||||
color: '#a1887f',
|
||||
lockedForOptimization: false,
|
||||
placed: true,
|
||||
};
|
||||
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
const tablePreset: FurniturePreset = {
|
||||
id: 'table',
|
||||
name: 'Table',
|
||||
shape: { type: 'rectangle', width: 80, height: 60 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
{ kind: 'accessible', weight: 1 },
|
||||
],
|
||||
color: '#a1887f',
|
||||
};
|
||||
|
||||
const chairPreset: FurniturePreset = {
|
||||
id: 'chair',
|
||||
name: 'Chair',
|
||||
shape: { type: 'rectangle', width: 40, height: 40 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
{ kind: 'near_object', weight: 1, params: {} },
|
||||
{ kind: 'accessible', weight: 1 },
|
||||
],
|
||||
color: '#d7ccc5',
|
||||
};
|
||||
const setRoom = (room: Partial<RoomConfig>) => {
|
||||
onStateChange({ ...state, room: { ...state.room, ...room } });
|
||||
};
|
||||
@@ -88,7 +34,7 @@ export function ControlPanel({
|
||||
};
|
||||
|
||||
const placeEntrance = () => {
|
||||
onStateChange({ ...state, entrance: { x: 20, y: 20, width: 8, height: 8 } });
|
||||
onStateChange({ ...state, entrance: { x: 20, y: 20, width: 20, height: 20 } });
|
||||
};
|
||||
|
||||
const toggleLock = () => {
|
||||
@@ -128,6 +74,16 @@ export function ControlPanel({
|
||||
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 selected = state.furniture.find((f) => f.id === selectedId);
|
||||
|
||||
return (
|
||||
@@ -162,29 +118,10 @@ export function ControlPanel({
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label>
|
||||
Object Count:
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.objectCount ?? state.furniture.length}
|
||||
onChange={(e) =>
|
||||
onStateChange({ ...state, objectCount: Math.max(0, Number(e.target.value)) })
|
||||
}
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={addFurniture}>Add Furniture</button>
|
||||
<button onClick={placeEntrance} style={{ marginLeft: 8 }}>
|
||||
Place Entrance
|
||||
</button> <button onClick={() => addPreset(tablePreset)} style={{ marginLeft: 8 }}>
|
||||
Add Table
|
||||
</button>
|
||||
<button onClick={() => addPreset(chairPreset)} style={{ marginLeft: 8 }}>
|
||||
Add Chair
|
||||
</button>
|
||||
</div>
|
||||
{state.entrance && (
|
||||
@@ -209,8 +146,53 @@ export function ControlPanel({
|
||||
</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}
|
||||
@@ -225,6 +207,51 @@ export function ControlPanel({
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
{selected && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
@@ -243,6 +270,29 @@ export function ControlPanel({
|
||||
<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(
|
||||
|
||||
@@ -14,11 +14,8 @@ interface DragState {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
resizing?: boolean;
|
||||
edge?: 'se' | 'e' | 's' | 'nw' | 'ne' | 'sw' | 'n' | 'w';
|
||||
}
|
||||
|
||||
const MIN_SIZE = 10;
|
||||
|
||||
export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }: RoomCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
@@ -42,9 +39,20 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
ctx.strokeRect(0, 0, state.room.width, state.room.height);
|
||||
|
||||
if (state.entrance) {
|
||||
const e = state.entrance;
|
||||
const w = e.width ?? 20;
|
||||
const h = e.height ?? 20;
|
||||
ctx.fillStyle = '#4caf50';
|
||||
ctx.fillRect(state.entrance.x - 4, state.entrance.y - 4, 8, 8);
|
||||
ctx.fillText('ENTRANCE', state.entrance.x + 6, state.entrance.y + 4);
|
||||
ctx.fillRect(e.x, e.y, w, h);
|
||||
ctx.strokeStyle = selectedId === '__entrance__' ? '#ff5722' : '#333';
|
||||
ctx.lineWidth = selectedId === '__entrance__' ? 3 : 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);
|
||||
// resize handle (SE)
|
||||
ctx.fillStyle = selectedId === '__entrance__' ? '#ff5722' : '#666';
|
||||
ctx.fillRect(e.x + w - 4, e.y + h - 4, 8, 8);
|
||||
}
|
||||
|
||||
for (const f of state.furniture) {
|
||||
@@ -75,15 +83,21 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
if (selectedId) {
|
||||
const f = state.furniture.find((x) => x.id === selectedId);
|
||||
if (f) {
|
||||
const seX = f.shape.x + f.shape.width;
|
||||
const seY = f.shape.y + f.shape.height;
|
||||
if (Math.abs(pos.x - seX) < 8 && Math.abs(pos.y - seY) < 8) {
|
||||
setDrag({ id: selectedId, offsetX: 0, offsetY: 0, resizing: true, edge: 'se' });
|
||||
return;
|
||||
}
|
||||
if (state.entrance) {
|
||||
const en = state.entrance;
|
||||
const w = en.width ?? 20;
|
||||
const h = en.height ?? 20;
|
||||
const handleX = en.x + w - 4;
|
||||
const handleY = en.y + h - 4;
|
||||
if (Math.abs(pos.x - handleX) <= 6 && Math.abs(pos.y - handleY) <= 6) {
|
||||
onSelect('__entrance__');
|
||||
setDrag({ id: '__entrance__', offsetX: 0, offsetY: 0, resizing: true });
|
||||
return;
|
||||
}
|
||||
if (pointInRect(pos, { x: en.x, y: en.y, w, h })) {
|
||||
onSelect('__entrance__');
|
||||
setDrag({ id: '__entrance__', offsetX: pos.x - en.x, offsetY: pos.y - en.y });
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = state.furniture.length - 1; i >= 0; i--) {
|
||||
@@ -101,18 +115,22 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!drag) return;
|
||||
const pos = getMousePos(e);
|
||||
if (drag.id === '__entrance__' && state.entrance) {
|
||||
const en = state.entrance;
|
||||
if (drag.resizing) {
|
||||
const newW = Math.max(10, pos.x - en.x);
|
||||
const newH = Math.max(10, pos.y - en.y);
|
||||
onStateChange({ ...state, entrance: { ...en, width: newW, height: newH } });
|
||||
} else {
|
||||
onStateChange({
|
||||
...state,
|
||||
entrance: { ...en, x: pos.x - drag.offsetX, y: pos.y - drag.offsetY },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== drag.id) return f;
|
||||
if (drag.resizing && drag.edge === 'se') {
|
||||
return {
|
||||
...f,
|
||||
shape: {
|
||||
...f.shape,
|
||||
width: Math.max(MIN_SIZE, pos.x - f.shape.x),
|
||||
height: Math.max(MIN_SIZE, pos.y - f.shape.y),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
shape: {
|
||||
@@ -126,6 +144,10 @@ export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }:
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (drag && drag.id !== '__entrance__') {
|
||||
const updated = state.furniture.map((f) => (f.id === drag.id ? { ...f, placed: true } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ConditionSpec {
|
||||
export interface FurnitureConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
shape: ShapeData;
|
||||
conditions: ConditionSpec[];
|
||||
color?: string;
|
||||
@@ -102,4 +103,5 @@ export interface LayoutState {
|
||||
furniture: FurnitureConfig[];
|
||||
presets?: FurniturePreset[];
|
||||
objectCount?: number;
|
||||
objectAmounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user