feat(damage-flow): rotate zoomed detail view to natural orientation (90/-90/180 per zone)

This commit is contained in:
2026-05-18 00:25:59 +10:00
parent 924dd7966a
commit 56f1a2774b
2 changed files with 164 additions and 76 deletions
@@ -3,342 +3,399 @@
"x": 278.11,
"y": 88.77,
"w": 262.74,
"h": 45.43
"h": 45.43,
"rotation": 0
},
"52": {
"x": 699.8,
"y": 296.93,
"w": 41.31,
"h": 70.61
"h": 70.61,
"rotation": -90
},
"51": {
"x": 81.04,
"y": 295.79,
"w": 41.4,
"h": 71.68
"h": 71.68,
"rotation": 90
},
"1": {
"x": 488.5,
"y": 132.88,
"w": 50.92,
"h": 20.77
"h": 20.77,
"rotation": 0
},
"2": {
"x": 279.61,
"y": 132.82,
"w": 50.93,
"h": 21.24
"h": 21.24,
"rotation": 0
},
"3": {
"x": 350.0,
"y": 130.0,
"w": 120.0,
"h": 22.0
"h": 22.0,
"rotation": 0
},
"30": {
"x": 509.22,
"y": 734.61,
"w": 130.89,
"h": 41.63
"h": 41.63,
"rotation": 0
},
"31": {
"x": 182.12,
"y": 734.5,
"w": 129.26,
"h": 41.56
"h": 41.56,
"rotation": 0
},
"28": {
"x": 170.41,
"y": 496.19,
"w": 6.35,
"h": 8.34
"h": 8.34,
"rotation": 90
},
"29": {
"x": 645.28,
"y": 496.19,
"w": 6.53,
"h": 8.58
"h": 8.58,
"rotation": -90
},
"11": {
"x": 278.5,
"y": 477.86,
"w": 465.53,
"h": 152.09
"h": 152.09,
"rotation": 0
},
"10": {
"x": 78.52,
"y": 478.98,
"w": 91.6,
"h": 150.99
"h": 150.99,
"rotation": 90
},
"4": {
"x": 300.72,
"y": 311.52,
"w": 219.3,
"h": 146.72
"h": 146.72,
"rotation": 0
},
"5": {
"x": 305.05,
"y": 442.76,
"w": 210.67,
"h": 127.13
"h": 127.13,
"rotation": 0
},
"22": {
"x": 281.48,
"y": 1063.81,
"w": 260.16,
"h": 59.34
"h": 59.34,
"rotation": 0
},
"49": {
"x": 87.78,
"y": 826.98,
"w": 50.54,
"h": 99.5
"h": 99.5,
"rotation": 90
},
"50": {
"x": 684.83,
"y": 827.25,
"w": 49.78,
"h": 99.71
"h": 99.71,
"rotation": -90
},
"33": {
"x": 284.24,
"y": 1038.88,
"w": 29.65,
"h": 22.7
"h": 22.7,
"rotation": 0
},
"32": {
"x": 507.74,
"y": 1038.78,
"w": 29.66,
"h": 22.69
"h": 22.69,
"rotation": 0
},
"21": {
"x": 303.29,
"y": 841.39,
"w": 215.88,
"h": 236.13
"h": 236.13,
"rotation": 0
},
"38": {
"x": 510.42,
"y": 195.83,
"w": 26.67,
"h": 821.6
"h": 821.6,
"rotation": -90
},
"39": {
"x": 281.9,
"y": 195.94,
"w": 26.67,
"h": 14.24
"h": 14.24,
"rotation": 0
},
"23": {
"x": 316.23,
"y": 766.98,
"w": 187.94,
"h": 89.25
"h": 89.25,
"rotation": 0
},
"6": {
"x": 78.13,
"y": 314.95,
"w": 91.98,
"h": 168.83
"h": 168.83,
"rotation": 90
},
"7": {
"x": 651.97,
"y": 314.84,
"w": 92.1,
"h": 168.93
"h": 168.93,
"rotation": -90
},
"8": {
"x": 81.05,
"y": 734.43,
"w": 104.0,
"h": 176.79
"h": 176.79,
"rotation": 90
},
"9": {
"x": 590.75,
"y": 666.97,
"w": 150.45,
"h": 244.25
"h": 244.25,
"rotation": -90
},
"12": {
"x": 81.53,
"y": 628.12,
"w": 99.38,
"h": 147.01
"h": 147.01,
"rotation": 90
},
"13": {
"x": 641.53,
"y": 627.6,
"w": 99.42,
"h": 147.14
"h": 147.14,
"rotation": -90
},
"16": {
"x": 325.94,
"y": 563.11,
"w": 168.93,
"h": 208.89
"h": 208.89,
"rotation": 90
},
"14": {
"x": 72.11,
"y": 471.33,
"w": 5.0,
"h": 161.47
"h": 161.47,
"rotation": 90
},
"47": {
"x": 72.83,
"y": 615.64,
"w": 5.6,
"h": 124.59
"h": 124.59,
"rotation": 90
},
"15": {
"x": 744.0,
"y": 461.0,
"w": 6.0,
"h": 171.5
"h": 171.5,
"rotation": -90
},
"48": {
"x": 744.0,
"y": 632.5,
"w": 6.0,
"h": 107.5
"h": 107.5,
"rotation": -90
},
"27": {
"x": 596.56,
"y": 643.76,
"w": 50.37,
"h": 84.35
"h": 84.35,
"rotation": -90
},
"26": {
"x": 175.44,
"y": 643.75,
"w": 50.57,
"h": 84.4
"h": 84.4,
"rotation": 90
},
"24": {
"x": 171.07,
"y": 526.12,
"w": 53.81,
"h": 105.33
"h": 105.33,
"rotation": 90
},
"25": {
"x": 597.45,
"y": 526.06,
"w": 53.57,
"h": 105.33
"h": 105.33,
"rotation": -90
},
"18": {
"x": 42.7,
"y": 369.77,
"w": 89.47,
"h": 88.82
"h": 88.82,
"rotation": 0
},
"43": {
"x": 56.05,
"y": 382.97,
"w": 63.97,
"h": 63.6
"h": 63.6,
"rotation": 0
},
"17": {
"x": 688.66,
"y": 369.75,
"w": 89.51,
"h": 88.83
"h": 88.83,
"rotation": 0
},
"42": {
"x": 702.04,
"y": 382.95,
"w": 63.97,
"h": 63.61
"h": 63.61,
"rotation": 0
},
"20": {
"x": 42.55,
"y": 751.33,
"w": 90.63,
"h": 88.87
"h": 88.87,
"rotation": 0
},
"40": {
"x": 56.71,
"y": 764.4,
"w": 61.8,
"h": 62.63
"h": 62.63,
"rotation": 0
},
"19": {
"x": 688.96,
"y": 751.25,
"w": 89.2,
"h": 88.92
"h": 88.92,
"rotation": 0
},
"41": {
"x": 701.6,
"y": 763.49,
"w": 64.65,
"h": 64.46
"h": 64.46,
"rotation": 0
},
"44": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0
"h": 512.0,
"rotation": 90
},
"45": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0
"h": 512.0,
"rotation": 90
},
"46": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0
"h": 512.0,
"rotation": 90
},
"34": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
"h": 360.0,
"rotation": 0
},
"35": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
"h": 360.0,
"rotation": 0
},
"36": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
"h": 360.0,
"rotation": 0
},
"37": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
"h": 360.0,
"rotation": 0
},
"53": {
"x": 185.83,
"y": 719.15,
"w": 42.97,
"h": 94.96
"h": 94.96,
"rotation": 90
},
"54": {
"x": 594.89,
"y": 723.2,
"w": 46.0,
"h": 101.37
"h": 101.37,
"rotation": -90
},
"55": {
"x": 170.0,
"y": 450.0,
"w": 60.0,
"h": 130.0
"h": 130.0,
"rotation": 90
},
"56": {
"x": 595.0,
"y": 450.0,
"w": 55.0,
"h": 130.0
"h": 130.0,
"rotation": -90
}
}
@@ -11,6 +11,7 @@ interface ZoneBBox {
y: number;
w: number;
h: number;
rotation?: number; // degrees, 0 / 90 / -90 / 180
}
interface Props {
@@ -36,13 +37,30 @@ async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
}
function paddedViewBox(b: ZoneBBox): string {
const padX = b.w * PADDING;
const padY = b.h * PADDING;
const x = b.x - padX;
const y = b.y - padY;
const w = b.w + 2 * padX;
const h = b.h + 2 * padY;
return `${x} ${y} ${w} ${h}`;
const rotation = b.rotation ?? 0;
// For 90°/-90° rotation, after wrapping content in <g transform="rotate(angle cx cy)">,
// the rotated bbox dims swap (w↔h). Center stays at (cx, cy).
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
let w = b.w;
let h = b.h;
if (rotation === 90 || rotation === -90) {
w = b.h;
h = b.w;
}
const padX = w * PADDING;
const padY = h * PADDING;
const minX = cx - w / 2 - padX;
const minY = cy - h / 2 - padY;
return `${minX} ${minY} ${w + 2 * padX} ${h + 2 * padY}`;
}
function rotationTransform(b: ZoneBBox): string | null {
const rotation = b.rotation ?? 0;
if (rotation === 0) return null;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
return `rotate(${rotation} ${cx} ${cy})`;
}
export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: Props) {
@@ -65,27 +83,40 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
]).then(([text, bboxes]) => {
if (cancelled) return;
const b = bboxes[String(zoneId)];
if (b) setViewBox(paddedViewBox(b));
else setViewBox(`0 0 ${COMPOSITE_W} ${COMPOSITE_H}`); // fallback to full view
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
const rotXform = b ? rotationTransform(b) : null;
setViewBox(finalVB);
// Rewrite the SVG root viewBox + strip script and set fills BEFORE mounting,
// so the SVG is correct from the very first paint (no flash of full unfolded view).
const finalVB = bboxes[String(zoneId)] ? paddedViewBox(bboxes[String(zoneId)]) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
// Rewrite SVG BEFORE mounting so first paint is already zoomed + rotated correctly.
let mutated = text;
// strip <script>...</script>
mutated = mutated.replace(/<script[\s\S]*?<\/script>/gi, "");
// replace viewBox attribute on the root <svg ...> opening tag
// replace viewBox on root <svg>
mutated = mutated.replace(/(<svg\b[^>]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`);
// ensure preserveAspectRatio is present
if (!/preserveAspectRatio/i.test(mutated.slice(0, 400))) {
mutated = mutated.replace(/<svg\b/i, `<svg preserveAspectRatio="xMidYMid meet"`);
}
// ensure width/height attributes are 100%
// strip existing width/height
mutated = mutated.replace(
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
"$1"
);
mutated = mutated.replace(/<svg\b/i, `<svg width="100%" height="100%"`);
// inject width/height + preserveAspectRatio on the root tag
mutated = mutated.replace(
/<svg\b/i,
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
);
// Wrap all root children of <svg> in a <g transform="rotate(...)"> if rotation needed.
// This makes rotation visible AND keeps getScreenCTM correct for tap conversion.
if (rotXform) {
// Find the end of the root opening tag and the closing </svg>; wrap everything between.
const openMatch = mutated.match(/<svg\b[^>]*?>/i);
const closeIdx = mutated.lastIndexOf("</svg>");
if (openMatch && closeIdx > -1) {
const openEnd = (openMatch.index ?? 0) + openMatch[0].length;
const before = mutated.slice(0, openEnd);
const inner = mutated.slice(openEnd, closeIdx);
const after = mutated.slice(closeIdx);
mutated = `${before}<g transform="${rotXform}">${inner}</g>${after}`;
}
}
setSvgText(mutated);
});