From 924dd7966ac035264d74fa198684f0dd2b3556a0 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Mon, 18 May 2026 00:00:31 +1000 Subject: [PATCH] fix(damage-flow): precomputed zone bboxes (avoid getBBox timing); inline SVG viewBox in HTML so first paint already zoomed --- .../frontend/public/data/zone_bboxes.json | 344 ++++++++++++++++++ .../components/damage-flow/ZoneDetailView.tsx | 133 ++++--- 2 files changed, 435 insertions(+), 42 deletions(-) create mode 100644 mechanic-pwa/frontend/public/data/zone_bboxes.json diff --git a/mechanic-pwa/frontend/public/data/zone_bboxes.json b/mechanic-pwa/frontend/public/data/zone_bboxes.json new file mode 100644 index 0000000..1d714da --- /dev/null +++ b/mechanic-pwa/frontend/public/data/zone_bboxes.json @@ -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 + } +} \ No newline at end of file diff --git a/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx index 6b38bae..c22244c 100644 --- a/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx +++ b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx @@ -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 | null = null; + +async function loadBBoxes(): Promise> { + 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(null); const [svgText, setSvgText] = useState(null); const [points, setPoints] = useState([]); - const [viewBox, setViewBox] = useState("0 0 827 1209"); + const [viewBox, setViewBox] = useState(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 + mutated = mutated.replace(//gi, ""); + // replace viewBox attribute on the root opening tag + mutated = mutated.replace(/(]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`); + // ensure preserveAspectRatio is present + if (!/preserveAspectRatio/i.test(mutated.slice(0, 400))) { + mutated = mutated.replace(/]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi, + "$1" + ); + mutated = mutated.replace(/ { 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) { - // 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 */} - setPoints((p) => p.slice(0, -1))} /> + {viewBox && ( + setPoints((p) => p.slice(0, -1))} + /> + )} + {!svgText && ( +
+ Загружаю схему… +
+ )}