From ab9ddf7289ca93405a763b797a2229cd5871a5ff Mon Sep 17 00:00:00 2001 From: vladtechno Date: Sun, 17 May 2026 20:18:31 +1000 Subject: [PATCH] feat(mechanic-pwa): use vendor SVG scheme with zone-based markers (1:1 vendor coords for future import) - Add public/scheme/exterior.svg (131 KB, viewBox 0 0 827 1209, 57 zones class="0"..class="56") - Add public/scheme/salon.svg (53 KB, Phase 2 reserve) - New VendorVehicleScheme: fetches SVG as separate asset (?url), strips vendor \ No newline at end of file diff --git a/mechanic-pwa/frontend/public/scheme/salon.svg b/mechanic-pwa/frontend/public/scheme/salon.svg new file mode 100644 index 0000000..d4e85d5 --- /dev/null +++ b/mechanic-pwa/frontend/public/scheme/salon.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx b/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx index cfa218e..95d734e 100644 --- a/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx +++ b/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx @@ -39,12 +39,20 @@ const SEVERITIES: { value: string; label: string }[] = [ { value: "severe", label: "Тяжёлое" }, ]; +function humanizeSide(side: string): string { + if (side === "tires") return "Резина"; + const m = /^zone-(\d+)$/.exec(side); + if (m) return `Зона ${m[1]}`; + // Legacy named sides + return side; +} + interface Props { open: boolean; inspectionId: number; - view: string; - x: number; - y: number; + side: string; // already-formatted side string ("zone-23", "tires", etc.) + x?: number | null; // optional — zone-only markers leave this null + y?: number | null; onClose: () => void; onCreated: (marker: MarkerSummary) => void; } @@ -52,7 +60,7 @@ interface Props { export function AddMarkerDialog({ open, inspectionId, - view, + side, x, y, onClose, @@ -114,7 +122,8 @@ export function AddMarkerDialog({

Новая метка

- {view} · {(x * 100).toFixed(1)}%, {(y * 100).toFixed(1)}% + {humanizeSide(side)} + {x != null && y != null && ` · ${(x * 100).toFixed(1)}%, ${(y * 100).toFixed(1)}%`}

- ))} - - {/* 5 PNG view tap targets */} - {VIEW_AREAS.map((v) => { - // Aspect ratio per view — chosen to approximate actual PNG dimensions - // top: 128×128 → square; front: 69×50 → ~4:3 landscape; back: 293×201 → ~3:2; - // left: 108×44 → ~5:2 strip; right: 109×44 → ~5:2 strip - const aspectStyle: React.CSSProperties = - v.key === "top" ? { aspectRatio: "1 / 1.25" } : // slightly taller — gives more tap surface - v.key === "front" ? { aspectRatio: "4 / 3" } : - v.key === "back" ? { aspectRatio: "4 / 3" } : - /* left / right */ { aspectRatio: "5 / 2" }; - - return ( -
{ - viewRefs.current[v.key] = el; - }} - style={{ gridArea: v.gridArea, ...aspectStyle }} - onClick={(e) => handleViewTap(v.key, e)} - className={cn( - "relative bg-muted/40 rounded-lg overflow-hidden w-full", - editable && "cursor-crosshair" - )} - role={editable ? "button" : undefined} - aria-label={editable ? `Добавить метку — ${v.label}` : v.label} - > - {v.label} - - {/* Marker dots for this view */} - {markers - .filter( - (m) => m.side === v.key && m.x != null && m.y != null - ) - .map((m) => ( - - ))} -
- ); - })} - - - {/* Global tire-wear control */} - - - {editable && ( -

- Тапните по части машины, чтобы добавить метку повреждения. -

- )} - - ); -} diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/VendorVehicleScheme.tsx b/mechanic-pwa/frontend/src/components/vehicle-scheme/VendorVehicleScheme.tsx new file mode 100644 index 0000000..d74e319 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/vehicle-scheme/VendorVehicleScheme.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import exteriorSvgUrl from "/scheme/exterior.svg?url"; +import { cn } from "@/lib/utils"; + +// Zone color states +const COLOR_DEFAULT_FILL = "rgba(0, 0, 0, 0)"; // transparent +const COLOR_HOVER_FILL = "rgba(59, 130, 246, 0.20)"; // soft blue +const COLOR_HAS_DAMAGE_FILL = "rgba(239, 68, 68, 0.30)"; // red tint for zones with active damage +const COLOR_RESOLVED_FILL = "rgba(156, 163, 175, 0.30)"; // gray for resolved-only zones + +export interface SchemeMarker { + id: number; + side?: string | null; + damage_type?: string | null; + severity?: string | null; + resolved: boolean; +} + +interface Props { + markers: SchemeMarker[]; + onZoneTap?: (zoneId: number) => void; + onTireWearClick?: () => void; + editable?: boolean; +} + +/** Parse "zone-23" -> 23, anything else -> null */ +function parseZoneId(side: string | null | undefined): number | null { + if (!side) return null; + const m = /^zone-(\d+)$/.exec(side); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) ? n : null; +} + +export function VendorVehicleScheme({ + markers, + onZoneTap, + onTireWearClick, + editable = true, +}: Props) { + const containerRef = useRef(null); + const [svgText, setSvgText] = useState(null); + const [hoverZone, setHoverZone] = useState(null); + + // Load SVG once from public/scheme/exterior.svg as a separate asset + useEffect(() => { + let cancelled = false; + fetch(exteriorSvgUrl) + .then((r) => r.text()) + .then((t) => { + if (!cancelled) setSvgText(t); + }) + .catch(() => { + if (!cancelled) setSvgText(""); + }); + return () => { + cancelled = true; + }; + }, []); + + // Count damage per zone, separately track has-any vs has-only-resolved + const zoneStats = useMemo(() => { + const stats = new Map(); + for (const m of markers) { + const z = parseZoneId(m.side); + if (z == null) continue; + const s = stats.get(z) ?? { count: 0, activeCount: 0 }; + s.count += 1; + if (!m.resolved) s.activeCount += 1; + stats.set(z, s); + } + return stats; + }, [markers]); + + const tireCount = markers.filter((m) => m.side === "tires").length; + + // Apply zone fills after SVG renders or when hover/damage state changes + useEffect(() => { + if (!svgText) return; + const el = containerRef.current; + if (!el) return; + const svg = el.querySelector("svg"); + if (!svg) return; + + // Strip any inline