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;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ZoneBBox {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
zoneId: number;
|
zoneId: number;
|
||||||
@@ -14,25 +21,78 @@ interface Props {
|
|||||||
onCancel: () => void;
|
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) {
|
export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [svgText, setSvgText] = useState<string | null>(null);
|
const [svgText, setSvgText] = useState<string | null>(null);
|
||||||
const [points, setPoints] = useState<Point[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setPoints([]);
|
setPoints([]);
|
||||||
|
setSvgText(null);
|
||||||
|
setViewBox(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch("/scheme/exterior.svg")
|
|
||||||
.then((r) => r.text())
|
Promise.all([
|
||||||
.then((t) => { if (!cancelled) setSvgText(t); });
|
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; };
|
return () => { cancelled = true; };
|
||||||
}, [open, zoneId]);
|
}, [open, zoneId]);
|
||||||
|
|
||||||
// After SVG renders, focus on target zone
|
// After SVG mounts, apply fill highlights (DOM mutation; viewBox is already in the string)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!svgText) return;
|
if (!svgText) return;
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -40,40 +100,27 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||||
if (!svg) return;
|
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]");
|
const allPaths = svg.querySelectorAll("path[class]");
|
||||||
allPaths.forEach((p) => {
|
allPaths.forEach((p) => {
|
||||||
const cls = p.getAttribute("class") ?? "";
|
const cls = p.getAttribute("class") ?? "";
|
||||||
if (cls === String(zoneId)) {
|
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");
|
p.setAttribute("stroke-width", "2");
|
||||||
} else {
|
} else {
|
||||||
p.setAttribute("fill", "rgba(0,0,0,0.04)");
|
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]);
|
}, [svgText, zoneId]);
|
||||||
|
|
||||||
function handleTap(e: React.MouseEvent<HTMLDivElement>) {
|
function handleTap(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
// Don't react to taps on point dots
|
|
||||||
if ((e.target as HTMLElement).dataset.pointDot) return;
|
if ((e.target as HTMLElement).dataset.pointDot) return;
|
||||||
|
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -81,7 +128,6 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|
||||||
// Convert client coords -> SVG user space using the current viewBox
|
|
||||||
const pt = svg.createSVGPoint();
|
const pt = svg.createSVGPoint();
|
||||||
pt.x = e.clientX;
|
pt.x = e.clientX;
|
||||||
pt.y = e.clientY;
|
pt.y = e.clientY;
|
||||||
@@ -89,14 +135,10 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
if (!ctm) return;
|
if (!ctm) return;
|
||||||
const svgPt = pt.matrixTransform(ctm.inverse());
|
const svgPt = pt.matrixTransform(ctm.inverse());
|
||||||
|
|
||||||
// Normalize to 0..1 of the COMPOSITE SVG (827x1209) — for cross-render compatibility
|
const x = svgPt.x / COMPOSITE_W;
|
||||||
const x = svgPt.x / 827;
|
const y = svgPt.y / COMPOSITE_H;
|
||||||
const y = svgPt.y / 1209;
|
|
||||||
|
|
||||||
// Clamp to keep within image
|
|
||||||
const cx = Math.max(0, Math.min(1, x));
|
const cx = Math.max(0, Math.min(1, x));
|
||||||
const cy = Math.max(0, Math.min(1, y));
|
const cy = Math.max(0, Math.min(1, y));
|
||||||
|
|
||||||
setPoints((prev) => [...prev, { x: cx, y: cy }]);
|
setPoints((prev) => [...prev, { x: cx, y: cy }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +164,18 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
style={{ touchAction: "manipulation" }}
|
style={{ touchAction: "manipulation" }}
|
||||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||||
/>
|
/>
|
||||||
{/* Render points as absolute-positioned dots over the SVG using the COMPOSITE coords + viewBox transform */}
|
{viewBox && (
|
||||||
<PointOverlay points={points} viewBox={viewBox} onRemoveLast={() => setPoints((p) => p.slice(0, -1))} />
|
<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>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t p-3 flex items-center gap-3 bg-card">
|
<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) {
|
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);
|
const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{points.map((p, i) => {
|
{points.map((p, i) => {
|
||||||
const xVB = p.x * 827;
|
const xVB = p.x * COMPOSITE_W;
|
||||||
const yVB = p.y * 1209;
|
const yVB = p.y * COMPOSITE_H;
|
||||||
const xPct = ((xVB - minX) / w) * 100;
|
const xPct = ((xVB - minX) / w) * 100;
|
||||||
const yPct = ((yVB - minY) / h) * 100;
|
const yPct = ((yVB - minY) / h) * 100;
|
||||||
// Clamp visible
|
|
||||||
if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null;
|
if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user