fix(damage-flow): precomputed zone bboxes (avoid getBBox timing); inline SVG viewBox in HTML so first paint already zoomed
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user