fix(damage-flow): render points as SVG circles inside rotated <g> for exact tap alignment (eliminate HTML overlay letterboxing offset)

This commit is contained in:
2026-05-18 00:53:06 +10:00
parent abdf0a2dea
commit e0083b894f
@@ -22,54 +22,14 @@ interface Props {
onCancel: () => void;
}
const PADDING = 0.1; // 10% of bbox as padding when zooming — gives breathing room
const PADDING = 0.1;
const COMPOSITE_W = 827;
const COMPOSITE_H = 1209;
const SVG_NS = "http://www.w3.org/2000/svg";
const POINT_RADIUS = 18; // user-space units; visible on zoomed views
// Module-level cache for the bbox map
let bboxCache: Record<string, ZoneBBox> | null = null;
/** Convert tap point from rotated/visible viewBox frame back to original composite frame. */
function inverseRotateAroundBboxCenter(
pt: { x: number; y: number },
b: ZoneBBox
): { x: number; y: number } {
const rot = b.rotation ?? 0;
if (rot === 0) return pt;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
const dx = pt.x - cx;
const dy = pt.y - cy;
// Inverse rotation (CCW by `rot` degrees, or CW by `-rot`)
const rad = (-rot * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: cx + dx * cos - dy * sin,
y: cy + dx * sin + dy * cos,
};
}
/** Apply forward rotation to a stored composite point so it appears at the correct visual position in the rotated detail view. */
function forwardRotateAroundBboxCenter(
pt: { x: number; y: number },
b: ZoneBBox
): { x: number; y: number } {
const rot = b.rotation ?? 0;
if (rot === 0) return pt;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
const dx = pt.x - cx;
const dy = pt.y - cy;
const rad = (rot * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: cx + dx * cos - dy * sin,
y: cy + dx * sin + dy * cos,
};
}
async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
if (bboxCache) return bboxCache;
const res = await fetch("/data/zone_bboxes.json");
@@ -79,8 +39,6 @@ async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
function paddedViewBox(b: ZoneBBox): string {
const rotation = b.rotation ?? 0;
// For 90°/-90° rotation, after wrapping content in <g transform="rotate(angle cx cy)">,
// the rotated bbox dims swap (w↔h). Center stays at (cx, cy).
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
let w = b.w;
@@ -108,15 +66,12 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const containerRef = useRef<HTMLDivElement>(null);
const [svgText, setSvgText] = useState<string | null>(null);
const [points, setPoints] = useState<Point[]>([]);
const [viewBox, setViewBox] = useState<string | null>(null);
const [zoneBBox, setZoneBBox] = useState<ZoneBBox | null>(null);
// Load SVG + precomputed bboxes when opening, and pick this zone's viewBox up-front
// Load SVG + bboxes
useEffect(() => {
if (!open) return;
setPoints([]);
setSvgText(null);
setViewBox(null);
let cancelled = false;
Promise.all([
@@ -127,29 +82,21 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const b = bboxes[String(zoneId)];
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
const rotXform = b ? rotationTransform(b) : null;
setViewBox(finalVB);
setZoneBBox(b ?? null);
// Rewrite SVG BEFORE mounting so first paint is already zoomed + rotated correctly.
let mutated = text;
// strip <script>...</script>
mutated = mutated.replace(/<script[\s\S]*?<\/script>/gi, "");
// replace viewBox on root <svg>
mutated = mutated.replace(/(<svg\b[^>]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`);
// strip existing width/height
mutated = mutated.replace(
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
"$1"
);
// 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.
// Wrap inner content in rotated <g> so visible orientation is correct.
// We always wrap (even with identity rotation) so the point-overlay <g>
// append target is consistent.
const openMatch = mutated.match(/<svg\b[^>]*?>/i);
const closeIdx = mutated.lastIndexOf("</svg>");
if (openMatch && closeIdx > -1) {
@@ -157,8 +104,8 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
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}`;
}
const xform = rotXform ? ` transform="${rotXform}"` : "";
mutated = `${before}<g data-rot="1"${xform}>${inner}</g>${after}`;
}
setSvgText(mutated);
@@ -166,7 +113,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
return () => { cancelled = true; };
}, [open, zoneId]);
// After SVG mounts, apply fill highlights (DOM mutation; viewBox is already in the string)
// After SVG mounts, apply fill highlights
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
@@ -174,8 +121,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
const allPaths = svg.querySelectorAll("path[class]");
allPaths.forEach((p) => {
svg.querySelectorAll("path[class]").forEach((p) => {
const cls = p.getAttribute("class") ?? "";
if (cls === String(zoneId)) {
p.setAttribute("fill", "rgba(239, 68, 68, 0.18)");
@@ -187,40 +133,100 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
}
(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";
});
}, [svgText, zoneId]);
// Imperatively render points as SVG circles inside the rotated <g>.
// This guarantees they sit at the exact same place as the SVG content,
// regardless of letterboxing / aspect-ratio.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
const rotGroup = svg.querySelector("g[data-rot]") as SVGGElement | null;
if (!rotGroup) return;
// Remove existing overlay group if any
const existing = rotGroup.querySelector("g[data-points]");
if (existing) existing.remove();
// Build new overlay group
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("data-points", "1");
points.forEach((p, i) => {
const cx = p.x * COMPOSITE_W;
const cy = p.y * COMPOSITE_H;
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", String(cx));
circle.setAttribute("cy", String(cy));
circle.setAttribute("r", String(POINT_RADIUS));
circle.setAttribute("fill", "rgb(239, 68, 68)");
circle.setAttribute("stroke", "white");
circle.setAttribute("stroke-width", "3");
circle.setAttribute("data-point-index", String(i));
(circle as SVGElement).style.cursor = "pointer";
group.appendChild(circle);
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("x", String(cx));
text.setAttribute("y", String(cy));
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "central");
text.setAttribute("fill", "white");
text.setAttribute("font-size", "20");
text.setAttribute("font-weight", "bold");
text.setAttribute("data-point-label", String(i));
(text as SVGElement).style.pointerEvents = "none";
(text as SVGElement).style.userSelect = "none";
text.textContent = String(i + 1);
group.appendChild(text);
});
rotGroup.appendChild(group);
}, [svgText, points]);
function handleTap(e: React.MouseEvent<HTMLDivElement>) {
if ((e.target as HTMLElement).dataset.pointDot) return;
// If user clicked an existing point circle, remove it (if it's the last one)
const target = e.target as Element;
const pointIdxAttr = target.getAttribute?.("data-point-index");
if (pointIdxAttr != null) {
const idx = Number(pointIdxAttr);
if (idx === points.length - 1) {
setPoints((p) => p.slice(0, -1));
}
return;
}
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
// getScreenCTM gives matrix from SVG root user-space to screen pixels.
// It does NOT include inner <g> transforms — so svgPt is in the rotated viewBox frame.
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return;
// svgPt is in the SVG root user-space (viewBox space), which is the rotated frame.
const svgPt = pt.matrixTransform(ctm.inverse());
// Convert back to original composite frame (un-rotate) so stored coord lines up
// with the position on the unrotated overview SVG.
const original = zoneBBox
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, zoneBBox)
// Convert from the visible (rotated) frame back to the original composite frame
// so storage is in a canonical coord system.
const bboxes = bboxCache;
const b = bboxes?.[String(zoneId)] ?? null;
const original = b
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, b)
: { x: svgPt.x, y: svgPt.y };
const x = original.x / COMPOSITE_W;
const y = original.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 }]);
const xN = Math.max(0, Math.min(1, original.x / COMPOSITE_W));
const yN = Math.max(0, Math.min(1, original.y / COMPOSITE_H));
setPoints((prev) => [...prev, { x: xN, y: yN }]);
}
if (!open) return null;
@@ -241,20 +247,12 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
<div
ref={containerRef}
onClick={handleTap}
className="w-full h-full relative"
className="w-full h-full"
style={{ touchAction: "manipulation" }}
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
/>
{viewBox && (
<PointOverlay
points={points}
viewBox={viewBox}
zoneBBox={zoneBBox}
onRemoveLast={() => setPoints((p) => p.slice(0, -1))}
/>
)}
{!svgText && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground pointer-events-none">
Загружаю схему
</div>
)}
@@ -288,45 +286,22 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
);
}
interface PointOverlayProps {
points: Point[];
viewBox: string;
zoneBBox: ZoneBBox | null;
onRemoveLast: () => void;
}
function PointOverlay({ points, viewBox, zoneBBox, onRemoveLast }: PointOverlayProps) {
const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number);
return (
<>
{points.map((p, i) => {
// Stored point is in ORIGINAL composite frame (un-rotated).
// Apply forward rotation to map it back into the visible rotated frame so the dot
// appears at the position the user tapped.
const original = { x: p.x * COMPOSITE_W, y: p.y * COMPOSITE_H };
const rotated = zoneBBox
? forwardRotateAroundBboxCenter(original, zoneBBox)
: original;
const xPct = ((rotated.x - minX) / w) * 100;
const yPct = ((rotated.y - minY) / h) * 100;
if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null;
return (
<button
key={i}
type="button"
data-point-dot="1"
onClick={(e) => {
e.stopPropagation();
if (i === points.length - 1) onRemoveLast();
}}
className="absolute w-8 h-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-destructive border-2 border-white shadow text-white text-sm font-bold flex items-center justify-center pointer-events-auto"
style={{ left: `${xPct}%`, top: `${yPct}%` }}
title="Тапни по последней точке, чтобы удалить"
>
{i + 1}
</button>
);
})}
</>
);
/** Convert tap point from rotated/visible viewBox frame back to original composite frame. */
function inverseRotateAroundBboxCenter(
pt: { x: number; y: number },
b: ZoneBBox
): { x: number; y: number } {
const rot = b.rotation ?? 0;
if (rot === 0) return pt;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
const dx = pt.x - cx;
const dy = pt.y - cy;
const rad = (-rot * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: cx + dx * cos - dy * sin,
y: cy + dx * sin + dy * cos,
};
}