diff --git a/dist/index.html b/dist/index.html index c21279b..56ed4a3 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,8 +5,8 @@ furniture-app - - + +
diff --git a/src/App.tsx b/src/App.tsx index be86321..7a4121e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -109,7 +109,7 @@ function App() { }; return ( -
+
-
- +
+
+ +
); diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index bdefe77..7934bd7 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -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 = { + 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(null); const setRoom = (room: Partial) => { 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) => { + 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 ( +
+
toggleOpen(f.id)}> + {'\u25B6'} + + {f.name} + {f.lockedForOptimization && locked} +
+ {open && ( +
+
+ Name + updateObject(f.id, { name: e.target.value })} /> +
+ +
+ + +
+ +
+ +
+ +
+ Conditions +
+
+ {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 ( +
+ + {k === 'near_object' && active && ( + + )} +
+ ); + })} +
+
+ )} +
+ ); + }; return ( -
-
- - + ); } diff --git a/src/components/RoomCanvas.tsx b/src/components/RoomCanvas.tsx index 9192a1c..e02338f 100644 --- a/src/components/RoomCanvas.tsx +++ b/src/components/RoomCanvas.tsx @@ -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(); +} diff --git a/src/index.css b/src/index.css index 45df9f9..3e1b681 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,476 @@ -html, body, #root { +:root { + --bg: #0f1115; + --bg-elev: #171a21; + --bg-panel: #1b1f27; + --bg-input: #232833; + --bg-hover: #262c38; + --border: #2c3342; + --border-strong: #3a4356; + --text: #e6e9ef; + --text-dim: #9aa3b2; + --text-mute: #6b7486; + --accent: #4f8cff; + --accent-soft: rgba(79, 140, 255, 0.15); + --accent-strong: #3b78e8; + --green: #3fbf7f; + --amber: #e0a83e; + --red: #e05555; + --radius: 10px; + --radius-sm: 7px; + --shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { margin: 0; padding: 0; height: 100%; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-size: 13px; + -webkit-font-smoothing: antialiased; +} + +button { + font: inherit; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 12px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, transform 0.05s ease; +} + +button:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +button:active:not(:disabled) { + transform: translateY(1px); +} + +button:disabled { + opacity: 0.45; + cursor: default; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + font-weight: 600; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-strong); + border-color: var(--accent-strong); +} + +input, +select { + font: inherit; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 8px; + outline: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +input:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +input[type='number'] { + width: 68px; +} + +input[type='checkbox'] { + accent-color: var(--accent); + width: 15px; + height: 15px; + vertical-align: middle; +} + +label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-dim); +} + +/* ---------- App shell ---------- */ +.app { + display: flex; + height: 100vh; + overflow: hidden; +} + +.sidebar { + width: 320px; + min-width: 320px; + height: 100%; + background: var(--bg-panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: 16px 16px 12px; + border-bottom: 1px solid var(--border); +} + +.sidebar-title { + font-size: 15px; + font-weight: 700; + letter-spacing: 0.2px; + display: flex; + align-items: center; + gap: 8px; +} + +.sidebar-title .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 10px var(--accent); +} + +.sidebar-body { + flex: 1; + overflow-y: auto; + padding: 14px 16px 24px; +} + +.sidebar-body::-webkit-scrollbar { + width: 9px; +} + +.sidebar-body::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 6px; + border: 2px solid var(--bg-panel); +} + +/* ---------- Segmented tabs ---------- */ +.segmented { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 3px; +} + +.segmented button { + border: none; + background: transparent; + padding: 7px 10px; + border-radius: 5px; + color: var(--text-dim); + font-weight: 600; +} + +.segmented button:hover:not(:disabled) { + background: var(--bg-hover); +} + +.segmented button.active { + background: var(--accent); + color: #fff; +} + +.segmented button.active:hover { + background: var(--accent); +} + +/* ---------- Sections ---------- */ +.section { + margin-top: 18px; +} + +.section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.9px; + color: var(--text-mute); + margin: 0 0 10px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.row.between { + justify-content: space-between; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field > span { + font-size: 11px; + color: var(--text-mute); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.field input, +.field select { + width: 100%; +} + +.counter { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 5px 6px 5px 12px; +} + +.counter .counter-label { + display: flex; + align-items: center; + gap: 8px; + color: var(--text); + font-weight: 500; +} + +.counter .swatch { + width: 12px; + height: 12px; + border-radius: 3px; +} + +.stepper { + display: flex; + align-items: center; + gap: 2px; +} + +.stepper button { + width: 26px; + height: 26px; + padding: 0; + display: grid; + place-items: center; + font-size: 15px; + line-height: 1; +} + +.stepper input { + width: 40px; + text-align: center; + border: none; + background: transparent; + padding: 2px; +} + +.stepper input:focus { + box-shadow: none; +} + +/* ---------- Accordion object cards ---------- */ +.obj-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.obj-card { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-input); + overflow: hidden; + transition: border-color 0.12s ease; +} + +.obj-card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +.obj-head { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 10px; + cursor: pointer; + user-select: none; +} + +.obj-head:hover { + background: var(--bg-hover); +} + +.obj-caret { + width: 12px; + color: var(--text-mute); + transition: transform 0.15s ease; + flex: none; +} + +.obj-caret.open { + transform: rotate(90deg); +} + +.obj-swatch { + width: 14px; + height: 14px; + border-radius: 4px; + flex: none; + border: 1px solid rgba(0, 0, 0, 0.3); +} + +.obj-name { + flex: 1; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.obj-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 20px; + background: var(--bg-hover); + color: var(--text-mute); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.obj-badge.locked { + color: var(--amber); + background: rgba(224, 168, 62, 0.14); +} + +.obj-body { + padding: 4px 10px 12px; + border-top: 1px solid var(--border); + animation: reveal 0.15s ease; +} + +@keyframes reveal { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.cond-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cond { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cond label { + color: var(--text); + font-size: 12px; +} + +.cond select { + width: 100%; +} + +.hint { + color: var(--text-mute); + font-size: 12px; + line-height: 1.5; + margin: 0 0 10px; +} + +/* ---------- Canvas area ---------- */ +.stage { + flex: 1; + overflow: auto; + padding: 24px; + background: + radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.04) 1px, transparent 0) 0 0 / 22px 22px, + var(--bg); + display: flex; + align-items: flex-start; + justify-content: center; +} + +.canvas-frame { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 14px; + display: inline-block; +} + +.canvas-frame canvas { + display: block; + border-radius: 6px; + cursor: crosshair; +} + +.progress { + height: 6px; + border-radius: 20px; + background: var(--bg-input); + overflow: hidden; + margin-top: 10px; +} + +.progress > div { + height: 100%; + background: linear-gradient(90deg, var(--accent), #7db0ff); + transition: width 0.9s linear; +} + +.empty { + color: var(--text-mute); + font-size: 12px; + text-align: center; + padding: 14px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); +}