fix(damage-flow): rotation round-trip (store original-frame coords, display in rotated view)

This commit is contained in:
2026-05-18 00:43:29 +10:00
parent 56f1a2774b
commit abdf0a2dea
@@ -29,6 +29,47 @@ const COMPOSITE_H = 1209;
// Module-level cache for the bbox map
let bboxCache: Record<string, ZoneBBox> | null = null;
/** Convert tap point from rotated/visible viewBox frame back to original composite frame. */
function inverseRotateAroundBboxCenter(
pt: { x: number; y: number },
b: ZoneBBox
): { x: number; y: number } {
const rot = b.rotation ?? 0;
if (rot === 0) return pt;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
const dx = pt.x - cx;
const dy = pt.y - cy;
// Inverse rotation (CCW by `rot` degrees, or CW by `-rot`)
const rad = (-rot * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: cx + dx * cos - dy * sin,
y: cy + dx * sin + dy * cos,
};
}
/** Apply forward rotation to a stored composite point so it appears at the correct visual position in the rotated detail view. */
function forwardRotateAroundBboxCenter(
pt: { x: number; y: number },
b: ZoneBBox
): { x: number; y: number } {
const rot = b.rotation ?? 0;
if (rot === 0) return pt;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
const dx = pt.x - cx;
const dy = pt.y - cy;
const rad = (rot * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: cx + dx * cos - dy * sin,
y: cy + dx * sin + dy * cos,
};
}
async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
if (bboxCache) return bboxCache;
const res = await fetch("/data/zone_bboxes.json");
@@ -68,6 +109,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const [svgText, setSvgText] = useState<string | null>(null);
const [points, setPoints] = useState<Point[]>([]);
const [viewBox, setViewBox] = useState<string | null>(null);
const [zoneBBox, setZoneBBox] = useState<ZoneBBox | null>(null);
// Load SVG + precomputed bboxes when opening, and pick this zone's viewBox up-front
useEffect(() => {
@@ -86,6 +128,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
const rotXform = b ? rotationTransform(b) : null;
setViewBox(finalVB);
setZoneBBox(b ?? null);
// Rewrite SVG BEFORE mounting so first paint is already zoomed + rotated correctly.
let mutated = text;
@@ -164,10 +207,17 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
pt.y = e.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return;
// svgPt is in the SVG root user-space (viewBox space), which is the rotated frame.
const svgPt = pt.matrixTransform(ctm.inverse());
const x = svgPt.x / COMPOSITE_W;
const y = svgPt.y / COMPOSITE_H;
// Convert back to original composite frame (un-rotate) so stored coord lines up
// with the position on the unrotated overview SVG.
const original = zoneBBox
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, zoneBBox)
: { x: svgPt.x, y: svgPt.y };
const x = original.x / COMPOSITE_W;
const y = original.y / COMPOSITE_H;
const cx = Math.max(0, Math.min(1, x));
const cy = Math.max(0, Math.min(1, y));
setPoints((prev) => [...prev, { x: cx, y: cy }]);
@@ -199,6 +249,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
<PointOverlay
points={points}
viewBox={viewBox}
zoneBBox={zoneBBox}
onRemoveLast={() => setPoints((p) => p.slice(0, -1))}
/>
)}
@@ -240,18 +291,24 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
interface PointOverlayProps {
points: Point[];
viewBox: string;
zoneBBox: ZoneBBox | null;
onRemoveLast: () => void;
}
function PointOverlay({ points, viewBox, onRemoveLast }: PointOverlayProps) {
function PointOverlay({ points, viewBox, zoneBBox, onRemoveLast }: PointOverlayProps) {
const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number);
return (
<>
{points.map((p, i) => {
const xVB = p.x * COMPOSITE_W;
const yVB = p.y * COMPOSITE_H;
const xPct = ((xVB - minX) / w) * 100;
const yPct = ((yVB - minY) / h) * 100;
// Stored point is in ORIGINAL composite frame (un-rotated).
// Apply forward rotation to map it back into the visible rotated frame so the dot
// appears at the position the user tapped.
const original = { x: p.x * COMPOSITE_W, y: p.y * COMPOSITE_H };
const rotated = zoneBBox
? forwardRotateAroundBboxCenter(original, zoneBBox)
: original;
const xPct = ((rotated.x - minX) / w) * 100;
const yPct = ((rotated.y - minY) / h) * 100;
if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null;
return (
<button