Rotation: circular angle dial

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 12:55:43 -04:00
parent 7ec02ed9ed
commit b6eaeda028
3 changed files with 86 additions and 11 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ Layout and editing UI. No optimizer yet.
**Editing (Phase 2)**
- Click an item to select it (blue outline); click empty floor to deselect (Escape/Done too). Dragging still moves. Selecting opens the item panel in the sidebar (replaces tiles / type menu).
- Item panel (chairs have no rotation controls; every other type does): Rotate 90 button + free angle wheel (0-359, wraps); Lock/Unlock; Delete (also Delete/Backspace key, ignored while typing in an input); "Seats at this table" wheel for tables (per-item override, "Reset to default" link when overridden); Size wheels (0.3-6 m, 0.1 steps; screens show only Length).
- Item panel (chairs have no rotation controls; every other type does): Rotate 90 button + a circular angle dial (`AngleDial`: drag the knob around the ring, Shift snaps to 15°, arrows/scroll nudge 1°); Lock/Unlock; Delete (also Delete/Backspace key, ignored while typing in an input); "Seats at this table" wheel for tables (per-item override, "Reset to default" link when overridden); Size wheels (0.3-6 m, 0.1 steps; screens show only Length).
- Rotation turns around the item's center and collision/wall clamping use the rotated bounding box (`boundsOf`). A rotate/size change is pushed back inside the walls, then rejected if it would overlap (`tryUpdate`).
- Locked items can't be dragged or rotated and show a padlock. (Size, seats and delete are still allowed on locked items.)
- Delete frees inventory automatically (remaining is derived from `items`).
+83
View File
@@ -0,0 +1,83 @@
import { useRef } from "react";
interface Props {
value: number; // degrees, 0 = knob at the top, clockwise
disabled?: boolean;
onChange: (deg: number) => void;
}
const SIZE = 120;
const R = 46; // knob orbit radius
/** Circular dial: drag the knob around the ring. Shift snaps to 15°. Arrow keys / scroll nudge by 1°. */
export function AngleDial({ value, disabled = false, onChange }: Props) {
const box = useRef<SVGSVGElement>(null);
const dragging = useRef(false);
const norm = (d: number) => ((Math.round(d) % 360) + 360) % 360;
const angleAt = (e: React.PointerEvent) => {
const r = box.current!.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
let deg = (Math.atan2(dx, -dy) * 180) / Math.PI; // 0 at top, clockwise
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
return norm(deg);
};
const down = (e: React.PointerEvent) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragging.current = true;
onChange(angleAt(e));
};
const move = (e: React.PointerEvent) => {
if (dragging.current) onChange(angleAt(e));
};
const up = () => {
dragging.current = false;
};
const rad = (value * Math.PI) / 180;
const c = SIZE / 2;
const kx = c + R * Math.sin(rad);
const ky = c - R * Math.cos(rad);
return (
<svg
ref={box}
width="100%"
viewBox={`0 0 ${SIZE} ${SIZE}`}
style={{ maxWidth: 160, display: "block", margin: "0 auto", touchAction: "none", userSelect: "none", opacity: disabled ? 0.45 : 1, cursor: disabled ? "not-allowed" : "pointer", outline: "none" }}
tabIndex={disabled ? -1 : 0}
role="slider"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={359}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onWheel={(e) => !disabled && onChange(norm(value + Math.sign(e.deltaY)))}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "ArrowRight" || e.key === "ArrowUp") onChange(norm(value + 1));
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") onChange(norm(value - 1));
else return;
e.preventDefault();
}}
>
<circle cx={c} cy={c} r={R + 10} fill="#f4f4f4" />
<circle cx={c} cy={c} r={R} fill="#fff" stroke="#bbb" strokeWidth={2} />
{[0, 90, 180, 270].map((t) => {
const a = (t * Math.PI) / 180;
return <line key={t} x1={c + (R - 6) * Math.sin(a)} y1={c - (R - 6) * Math.cos(a)} x2={c + R * Math.sin(a)} y2={c - R * Math.cos(a)} stroke="#bbb" strokeWidth={2} />;
})}
<line x1={c} y1={c} x2={kx} y2={ky} stroke="#1c7ed6" strokeWidth={2} opacity={0.5} />
<circle cx={kx} cy={ky} r={9} fill="#1c7ed6" stroke="#fff" strokeWidth={2} />
<text x={c} y={c + 6} textAnchor="middle" style={{ font: "600 18px system-ui, sans-serif", fill: "#222" }}>
{value}°
</text>
</svg>
);
}
+2 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
import { effectiveSeats } from "../inventory";
import { AngleDial } from "./AngleDial";
import { Wheel } from "./Wheel";
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
@@ -49,16 +50,7 @@ export function ItemPanel({ item, config, onPatch, onDelete, onClose }: Props) {
{config.rotatable && (
<div>
<div style={label}>Rotation</div>
<Wheel
value={item.rotation}
min={0}
max={359}
wrap
rows={3}
disabled={item.locked}
format={(v) => `${v}°`}
onChange={(v) => patch({ rotation: v })}
/>
<AngleDial value={item.rotation} disabled={item.locked} onChange={(v) => patch({ rotation: v })} />
<button
style={{ ...btn, marginTop: 8, width: "100%", opacity: item.locked ? 0.45 : 1 }}
disabled={item.locked}