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:
@@ -22,54 +22,14 @@ interface Props {
|
|||||||
onCancel: () => void;
|
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_W = 827;
|
||||||
const COMPOSITE_H = 1209;
|
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;
|
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>> {
|
async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
|
||||||
if (bboxCache) return bboxCache;
|
if (bboxCache) return bboxCache;
|
||||||
const res = await fetch("/data/zone_bboxes.json");
|
const res = await fetch("/data/zone_bboxes.json");
|
||||||
@@ -79,8 +39,6 @@ async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
|
|||||||
|
|
||||||
function paddedViewBox(b: ZoneBBox): string {
|
function paddedViewBox(b: ZoneBBox): string {
|
||||||
const rotation = b.rotation ?? 0;
|
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 cx = b.x + b.w / 2;
|
||||||
const cy = b.y + b.h / 2;
|
const cy = b.y + b.h / 2;
|
||||||
let w = b.w;
|
let w = b.w;
|
||||||
@@ -108,15 +66,12 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
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 | 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setPoints([]);
|
setPoints([]);
|
||||||
setSvgText(null);
|
setSvgText(null);
|
||||||
setViewBox(null);
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -127,38 +82,30 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
const b = bboxes[String(zoneId)];
|
const b = bboxes[String(zoneId)];
|
||||||
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
|
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
|
||||||
const rotXform = b ? rotationTransform(b) : null;
|
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;
|
let mutated = text;
|
||||||
// strip <script>...</script>
|
|
||||||
mutated = mutated.replace(/<script[\s\S]*?<\/script>/gi, "");
|
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}"`);
|
mutated = mutated.replace(/(<svg\b[^>]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`);
|
||||||
// strip existing width/height
|
|
||||||
mutated = mutated.replace(
|
mutated = mutated.replace(
|
||||||
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
|
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
|
||||||
"$1"
|
"$1"
|
||||||
);
|
);
|
||||||
// inject width/height + preserveAspectRatio on the root tag
|
|
||||||
mutated = mutated.replace(
|
mutated = mutated.replace(
|
||||||
/<svg\b/i,
|
/<svg\b/i,
|
||||||
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
|
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
|
||||||
);
|
);
|
||||||
// Wrap all root children of <svg> in a <g transform="rotate(...)"> if rotation needed.
|
// Wrap inner content in rotated <g> so visible orientation is correct.
|
||||||
// This makes rotation visible AND keeps getScreenCTM correct for tap conversion.
|
// We always wrap (even with identity rotation) so the point-overlay <g>
|
||||||
if (rotXform) {
|
// append target is consistent.
|
||||||
// Find the end of the root opening tag and the closing </svg>; wrap everything between.
|
const openMatch = mutated.match(/<svg\b[^>]*?>/i);
|
||||||
const openMatch = mutated.match(/<svg\b[^>]*?>/i);
|
const closeIdx = mutated.lastIndexOf("</svg>");
|
||||||
const closeIdx = mutated.lastIndexOf("</svg>");
|
if (openMatch && closeIdx > -1) {
|
||||||
if (openMatch && closeIdx > -1) {
|
const openEnd = (openMatch.index ?? 0) + openMatch[0].length;
|
||||||
const openEnd = (openMatch.index ?? 0) + openMatch[0].length;
|
const before = mutated.slice(0, openEnd);
|
||||||
const before = mutated.slice(0, openEnd);
|
const inner = mutated.slice(openEnd, closeIdx);
|
||||||
const inner = mutated.slice(openEnd, closeIdx);
|
const after = mutated.slice(closeIdx);
|
||||||
const after = mutated.slice(closeIdx);
|
const xform = rotXform ? ` transform="${rotXform}"` : "";
|
||||||
mutated = `${before}<g transform="${rotXform}">${inner}</g>${after}`;
|
mutated = `${before}<g data-rot="1"${xform}>${inner}</g>${after}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSvgText(mutated);
|
setSvgText(mutated);
|
||||||
@@ -166,7 +113,7 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [open, zoneId]);
|
}, [open, zoneId]);
|
||||||
|
|
||||||
// After SVG mounts, apply fill highlights (DOM mutation; viewBox is already in the string)
|
// After SVG mounts, apply fill highlights
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!svgText) return;
|
if (!svgText) return;
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -174,8 +121,7 @@ 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;
|
||||||
|
|
||||||
const allPaths = svg.querySelectorAll("path[class]");
|
svg.querySelectorAll("path[class]").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.18)");
|
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";
|
(p as SVGElement).style.pointerEvents = "none";
|
||||||
});
|
});
|
||||||
// Also dim non-class decorative paths (body lines)
|
|
||||||
svg.querySelectorAll("path:not([class])").forEach((p) => {
|
svg.querySelectorAll("path:not([class])").forEach((p) => {
|
||||||
(p as SVGElement).style.opacity = "0.4";
|
(p as SVGElement).style.opacity = "0.4";
|
||||||
(p as SVGElement).style.pointerEvents = "none";
|
(p as SVGElement).style.pointerEvents = "none";
|
||||||
});
|
});
|
||||||
}, [svgText, zoneId]);
|
}, [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>) {
|
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;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||||
if (!svg) return;
|
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();
|
const pt = svg.createSVGPoint();
|
||||||
pt.x = e.clientX;
|
pt.x = e.clientX;
|
||||||
pt.y = e.clientY;
|
pt.y = e.clientY;
|
||||||
const ctm = svg.getScreenCTM();
|
const ctm = svg.getScreenCTM();
|
||||||
if (!ctm) return;
|
if (!ctm) return;
|
||||||
// svgPt is in the SVG root user-space (viewBox space), which is the rotated frame.
|
|
||||||
const svgPt = pt.matrixTransform(ctm.inverse());
|
const svgPt = pt.matrixTransform(ctm.inverse());
|
||||||
|
|
||||||
// Convert back to original composite frame (un-rotate) so stored coord lines up
|
// Convert from the visible (rotated) frame back to the original composite frame
|
||||||
// with the position on the unrotated overview SVG.
|
// so storage is in a canonical coord system.
|
||||||
const original = zoneBBox
|
const bboxes = bboxCache;
|
||||||
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, zoneBBox)
|
const b = bboxes?.[String(zoneId)] ?? null;
|
||||||
|
const original = b
|
||||||
|
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, b)
|
||||||
: { x: svgPt.x, y: svgPt.y };
|
: { x: svgPt.x, y: svgPt.y };
|
||||||
|
|
||||||
const x = original.x / COMPOSITE_W;
|
const xN = Math.max(0, Math.min(1, original.x / COMPOSITE_W));
|
||||||
const y = original.y / COMPOSITE_H;
|
const yN = Math.max(0, Math.min(1, original.y / COMPOSITE_H));
|
||||||
const cx = Math.max(0, Math.min(1, x));
|
setPoints((prev) => [...prev, { x: xN, y: yN }]);
|
||||||
const cy = Math.max(0, Math.min(1, y));
|
|
||||||
setPoints((prev) => [...prev, { x: cx, y: cy }]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
@@ -241,20 +247,12 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onClick={handleTap}
|
onClick={handleTap}
|
||||||
className="w-full h-full relative"
|
className="w-full h-full"
|
||||||
style={{ touchAction: "manipulation" }}
|
style={{ touchAction: "manipulation" }}
|
||||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||||
/>
|
/>
|
||||||
{viewBox && (
|
|
||||||
<PointOverlay
|
|
||||||
points={points}
|
|
||||||
viewBox={viewBox}
|
|
||||||
zoneBBox={zoneBBox}
|
|
||||||
onRemoveLast={() => setPoints((p) => p.slice(0, -1))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!svgText && (
|
{!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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -288,45 +286,22 @@ export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PointOverlayProps {
|
/** Convert tap point from rotated/visible viewBox frame back to original composite frame. */
|
||||||
points: Point[];
|
function inverseRotateAroundBboxCenter(
|
||||||
viewBox: string;
|
pt: { x: number; y: number },
|
||||||
zoneBBox: ZoneBBox | null;
|
b: ZoneBBox
|
||||||
onRemoveLast: () => void;
|
): { x: number; y: number } {
|
||||||
}
|
const rot = b.rotation ?? 0;
|
||||||
|
if (rot === 0) return pt;
|
||||||
function PointOverlay({ points, viewBox, zoneBBox, onRemoveLast }: PointOverlayProps) {
|
const cx = b.x + b.w / 2;
|
||||||
const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number);
|
const cy = b.y + b.h / 2;
|
||||||
return (
|
const dx = pt.x - cx;
|
||||||
<>
|
const dy = pt.y - cy;
|
||||||
{points.map((p, i) => {
|
const rad = (-rot * Math.PI) / 180;
|
||||||
// Stored point is in ORIGINAL composite frame (un-rotated).
|
const cos = Math.cos(rad);
|
||||||
// Apply forward rotation to map it back into the visible rotated frame so the dot
|
const sin = Math.sin(rad);
|
||||||
// appears at the position the user tapped.
|
return {
|
||||||
const original = { x: p.x * COMPOSITE_W, y: p.y * COMPOSITE_H };
|
x: cx + dx * cos - dy * sin,
|
||||||
const rotated = zoneBBox
|
y: cy + dx * sin + dy * cos,
|
||||||
? 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user