fix(damage-flow): precomputed zone bboxes (avoid getBBox timing); inline SVG viewBox in HTML so first paint already zoomed

This commit is contained in:
2026-05-18 00:00:31 +10:00
parent 102a2525db
commit 924dd7966a
2 changed files with 435 additions and 42 deletions
@@ -0,0 +1,344 @@
{
"0": {
"x": 278.11,
"y": 88.77,
"w": 262.74,
"h": 45.43
},
"52": {
"x": 699.8,
"y": 296.93,
"w": 41.31,
"h": 70.61
},
"51": {
"x": 81.04,
"y": 295.79,
"w": 41.4,
"h": 71.68
},
"1": {
"x": 488.5,
"y": 132.88,
"w": 50.92,
"h": 20.77
},
"2": {
"x": 279.61,
"y": 132.82,
"w": 50.93,
"h": 21.24
},
"3": {
"x": 350.0,
"y": 130.0,
"w": 120.0,
"h": 22.0
},
"30": {
"x": 509.22,
"y": 734.61,
"w": 130.89,
"h": 41.63
},
"31": {
"x": 182.12,
"y": 734.5,
"w": 129.26,
"h": 41.56
},
"28": {
"x": 170.41,
"y": 496.19,
"w": 6.35,
"h": 8.34
},
"29": {
"x": 645.28,
"y": 496.19,
"w": 6.53,
"h": 8.58
},
"11": {
"x": 278.5,
"y": 477.86,
"w": 465.53,
"h": 152.09
},
"10": {
"x": 78.52,
"y": 478.98,
"w": 91.6,
"h": 150.99
},
"4": {
"x": 300.72,
"y": 311.52,
"w": 219.3,
"h": 146.72
},
"5": {
"x": 305.05,
"y": 442.76,
"w": 210.67,
"h": 127.13
},
"22": {
"x": 281.48,
"y": 1063.81,
"w": 260.16,
"h": 59.34
},
"49": {
"x": 87.78,
"y": 826.98,
"w": 50.54,
"h": 99.5
},
"50": {
"x": 684.83,
"y": 827.25,
"w": 49.78,
"h": 99.71
},
"33": {
"x": 284.24,
"y": 1038.88,
"w": 29.65,
"h": 22.7
},
"32": {
"x": 507.74,
"y": 1038.78,
"w": 29.66,
"h": 22.69
},
"21": {
"x": 303.29,
"y": 841.39,
"w": 215.88,
"h": 236.13
},
"38": {
"x": 510.42,
"y": 195.83,
"w": 26.67,
"h": 821.6
},
"39": {
"x": 281.9,
"y": 195.94,
"w": 26.67,
"h": 14.24
},
"23": {
"x": 316.23,
"y": 766.98,
"w": 187.94,
"h": 89.25
},
"6": {
"x": 78.13,
"y": 314.95,
"w": 91.98,
"h": 168.83
},
"7": {
"x": 651.97,
"y": 314.84,
"w": 92.1,
"h": 168.93
},
"8": {
"x": 81.05,
"y": 734.43,
"w": 104.0,
"h": 176.79
},
"9": {
"x": 590.75,
"y": 666.97,
"w": 150.45,
"h": 244.25
},
"12": {
"x": 81.53,
"y": 628.12,
"w": 99.38,
"h": 147.01
},
"13": {
"x": 641.53,
"y": 627.6,
"w": 99.42,
"h": 147.14
},
"16": {
"x": 325.94,
"y": 563.11,
"w": 168.93,
"h": 208.89
},
"14": {
"x": 72.11,
"y": 471.33,
"w": 5.0,
"h": 161.47
},
"47": {
"x": 72.83,
"y": 615.64,
"w": 5.6,
"h": 124.59
},
"15": {
"x": 744.0,
"y": 461.0,
"w": 6.0,
"h": 171.5
},
"48": {
"x": 744.0,
"y": 632.5,
"w": 6.0,
"h": 107.5
},
"27": {
"x": 596.56,
"y": 643.76,
"w": 50.37,
"h": 84.35
},
"26": {
"x": 175.44,
"y": 643.75,
"w": 50.57,
"h": 84.4
},
"24": {
"x": 171.07,
"y": 526.12,
"w": 53.81,
"h": 105.33
},
"25": {
"x": 597.45,
"y": 526.06,
"w": 53.57,
"h": 105.33
},
"18": {
"x": 42.7,
"y": 369.77,
"w": 89.47,
"h": 88.82
},
"43": {
"x": 56.05,
"y": 382.97,
"w": 63.97,
"h": 63.6
},
"17": {
"x": 688.66,
"y": 369.75,
"w": 89.51,
"h": 88.83
},
"42": {
"x": 702.04,
"y": 382.95,
"w": 63.97,
"h": 63.61
},
"20": {
"x": 42.55,
"y": 751.33,
"w": 90.63,
"h": 88.87
},
"40": {
"x": 56.71,
"y": 764.4,
"w": 61.8,
"h": 62.63
},
"19": {
"x": 688.96,
"y": 751.25,
"w": 89.2,
"h": 88.92
},
"41": {
"x": 701.6,
"y": 763.49,
"w": 64.65,
"h": 64.46
},
"44": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0
},
"45": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0
},
"46": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0
},
"34": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
},
"35": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
},
"36": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
},
"37": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0
},
"53": {
"x": 185.83,
"y": 719.15,
"w": 42.97,
"h": 94.96
},
"54": {
"x": 594.89,
"y": 723.2,
"w": 46.0,
"h": 101.37
},
"55": {
"x": 170.0,
"y": 450.0,
"w": 60.0,
"h": 130.0
},
"56": {
"x": 595.0,
"y": 450.0,
"w": 55.0,
"h": 130.0
}
}
@@ -6,6 +6,13 @@ interface Point {
y: number;
}
interface ZoneBBox {
x: number;
y: number;
w: number;
h: number;
}
interface Props {
open: boolean;
zoneId: number;
@@ -14,25 +21,78 @@ interface Props {
onCancel: () => void;
}
const PADDING = 0.05; // 5% of bbox as padding when zooming
const PADDING = 0.1; // 10% of bbox as padding when zooming — gives breathing room
const COMPOSITE_W = 827;
const COMPOSITE_H = 1209;
// Module-level cache for the bbox map
let bboxCache: Record<string, ZoneBBox> | null = null;
async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
if (bboxCache) return bboxCache;
const res = await fetch("/data/zone_bboxes.json");
bboxCache = await res.json();
return bboxCache!;
}
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}`;
}
export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [svgText, setSvgText] = useState<string | null>(null);
const [points, setPoints] = useState<Point[]>([]);
const [viewBox, setViewBox] = useState<string>("0 0 827 1209");
const [viewBox, setViewBox] = useState<string | null>(null);
// Load SVG + precomputed bboxes when opening, and pick this zone's viewBox up-front
useEffect(() => {
if (!open) return;
setPoints([]);
setSvgText(null);
setViewBox(null);
let cancelled = false;
fetch("/scheme/exterior.svg")
.then((r) => r.text())
.then((t) => { if (!cancelled) setSvgText(t); });
Promise.all([
fetch("/scheme/exterior.svg").then((r) => r.text()),
loadBBoxes(),
]).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
// 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}`;
let mutated = text;
// strip <script>...</script>
mutated = mutated.replace(/<script[\s\S]*?<\/script>/gi, "");
// replace viewBox attribute on the root <svg ...> opening tag
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%
mutated = mutated.replace(
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
"$1"
);
mutated = mutated.replace(/<svg\b/i, `<svg width="100%" height="100%"`);
setSvgText(mutated);
});
return () => { cancelled = true; };
}, [open, zoneId]);
// After SVG renders, focus on target zone
// After SVG mounts, apply fill highlights (DOM mutation; viewBox is already in the string)
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
@@ -40,40 +100,27 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
// Strip vendor scripts
svg.querySelectorAll("script").forEach((s) => s.remove());
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
// Mute every zone except target; highlight target
const allPaths = svg.querySelectorAll("path[class]");
allPaths.forEach((p) => {
const cls = p.getAttribute("class") ?? "";
if (cls === String(zoneId)) {
p.setAttribute("fill", "rgba(239, 68, 68, 0.15)"); // soft red highlight
p.setAttribute("fill", "rgba(239, 68, 68, 0.18)");
p.setAttribute("stroke", "rgb(239, 68, 68)");
p.setAttribute("stroke-width", "2");
} else {
p.setAttribute("fill", "rgba(0,0,0,0.04)");
p.setAttribute("stroke", "rgba(0,0,0,0.3)");
p.setAttribute("stroke", "rgba(0,0,0,0.25)");
}
(p as SVGElement).style.pointerEvents = "none"; // we handle clicks at container level
(p as SVGElement).style.pointerEvents = "none";
});
// Also dim non-class decorative paths (body lines)
svg.querySelectorAll("path:not([class])").forEach((p) => {
(p as SVGElement).style.opacity = "0.4";
(p as SVGElement).style.pointerEvents = "none";
});
// Compute bounding box of target zone and set viewBox to zoom in
const target = svg.querySelector(`path[class="${zoneId}"]`) as SVGGraphicsElement | null;
if (target) {
const bbox = target.getBBox();
const padX = bbox.width * PADDING;
const padY = bbox.height * PADDING;
const vb = `${bbox.x - padX} ${bbox.y - padY} ${bbox.width + 2 * padX} ${bbox.height + 2 * padY}`;
svg.setAttribute("viewBox", vb);
setViewBox(vb);
}
}, [svgText, zoneId]);
function handleTap(e: React.MouseEvent<HTMLDivElement>) {
// Don't react to taps on point dots
if ((e.target as HTMLElement).dataset.pointDot) return;
const el = containerRef.current;
@@ -81,7 +128,6 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
// Convert client coords -> SVG user space using the current viewBox
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
@@ -89,14 +135,10 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
if (!ctm) return;
const svgPt = pt.matrixTransform(ctm.inverse());
// Normalize to 0..1 of the COMPOSITE SVG (827x1209) — for cross-render compatibility
const x = svgPt.x / 827;
const y = svgPt.y / 1209;
// Clamp to keep within image
const x = svgPt.x / COMPOSITE_W;
const y = svgPt.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 }]);
}
@@ -122,8 +164,18 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
style={{ touchAction: "manipulation" }}
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
/>
{/* Render points as absolute-positioned dots over the SVG using the COMPOSITE coords + viewBox transform */}
<PointOverlay points={points} viewBox={viewBox} onRemoveLast={() => setPoints((p) => p.slice(0, -1))} />
{viewBox && (
<PointOverlay
points={points}
viewBox={viewBox}
onRemoveLast={() => setPoints((p) => p.slice(0, -1))}
/>
)}
{!svgText && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
Загружаю схему
</div>
)}
</main>
<footer className="border-t p-3 flex items-center gap-3 bg-card">
@@ -161,17 +213,14 @@ interface PointOverlayProps {
}
function PointOverlay({ points, viewBox, onRemoveLast }: PointOverlayProps) {
// viewBox is "minX minY width height" in COMPOSITE SVG coords (which are themselves 0..827 x 0..1209)
// Our points are stored normalized to composite (0..1). Convert to viewBox-relative %:
const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number);
return (
<>
{points.map((p, i) => {
const xVB = p.x * 827;
const yVB = p.y * 1209;
const xVB = p.x * COMPOSITE_W;
const yVB = p.y * COMPOSITE_H;
const xPct = ((xVB - minX) / w) * 100;
const yPct = ((yVB - minY) / h) * 100;
// Clamp visible
if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null;
return (
<button