fix(damage-flow): rotation round-trip (store original-frame coords, display in rotated view)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user