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)**
|
**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).
|
- 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`).
|
- 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.)
|
- 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`).
|
- 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 { useEffect, useState } from "react";
|
||||||
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
|
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
|
||||||
import { effectiveSeats } from "../inventory";
|
import { effectiveSeats } from "../inventory";
|
||||||
|
import { AngleDial } from "./AngleDial";
|
||||||
import { Wheel } from "./Wheel";
|
import { Wheel } from "./Wheel";
|
||||||
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
|
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 && (
|
{config.rotatable && (
|
||||||
<div>
|
<div>
|
||||||
<div style={label}>Rotation</div>
|
<div style={label}>Rotation</div>
|
||||||
<Wheel
|
<AngleDial value={item.rotation} disabled={item.locked} onChange={(v) => patch({ rotation: v })} />
|
||||||
value={item.rotation}
|
|
||||||
min={0}
|
|
||||||
max={359}
|
|
||||||
wrap
|
|
||||||
rows={3}
|
|
||||||
disabled={item.locked}
|
|
||||||
format={(v) => `${v}°`}
|
|
||||||
onChange={(v) => patch({ rotation: v })}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
style={{ ...btn, marginTop: 8, width: "100%", opacity: item.locked ? 0.45 : 1 }}
|
style={{ ...btn, marginTop: 8, width: "100%", opacity: item.locked ? 0.45 : 1 }}
|
||||||
disabled={item.locked}
|
disabled={item.locked}
|
||||||
|
|||||||
Reference in New Issue
Block a user