diff --git a/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx index ae63a84..83a7955 100644 --- a/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx +++ b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx @@ -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 | 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> { if (bboxCache) return bboxCache; const res = await fetch("/data/zone_bboxes.json"); @@ -79,8 +39,6 @@ async function loadBBoxes(): Promise> { function paddedViewBox(b: ZoneBBox): string { const rotation = b.rotation ?? 0; - // For 90°/-90° rotation, after wrapping content in , - // 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(null); const [svgText, setSvgText] = useState(null); const [points, setPoints] = useState([]); - const [viewBox, setViewBox] = useState(null); - const [zoneBBox, setZoneBBox] = useState(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,38 +82,30 @@ 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 mutated = mutated.replace(//gi, ""); - // replace viewBox on root mutated = mutated.replace(/(]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`); - // strip existing width/height mutated = mutated.replace( /(]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi, "$1" ); - // inject width/height + preserveAspectRatio on the root tag mutated = mutated.replace( / in a 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 ; wrap everything between. - const openMatch = mutated.match(/]*?>/i); - const closeIdx = mutated.lastIndexOf(""); - if (openMatch && closeIdx > -1) { - const openEnd = (openMatch.index ?? 0) + openMatch[0].length; - const before = mutated.slice(0, openEnd); - const inner = mutated.slice(openEnd, closeIdx); - const after = mutated.slice(closeIdx); - mutated = `${before}${inner}${after}`; - } + // Wrap inner content in rotated so visible orientation is correct. + // We always wrap (even with identity rotation) so the point-overlay + // append target is consistent. + const openMatch = mutated.match(/]*?>/i); + const closeIdx = mutated.lastIndexOf(""); + if (openMatch && closeIdx > -1) { + const openEnd = (openMatch.index ?? 0) + openMatch[0].length; + const before = mutated.slice(0, openEnd); + const inner = mutated.slice(openEnd, closeIdx); + const after = mutated.slice(closeIdx); + const xform = rotXform ? ` transform="${rotXform}"` : ""; + mutated = `${before}${inner}${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 . + // 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) { - 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 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 }:
- {viewBox && ( - setPoints((p) => p.slice(0, -1))} - /> - )} {!svgText && ( -
+
Загружаю схему…
)} @@ -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 ( - - ); - })} - - ); +/** 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, + }; }