Rotation: circular angle dial
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user