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