hasari-api / packages /ui /src /components /ImageWithOverlay.tsx
erdoganpeker's picture
fix(wave9): 10 P0 prod bugs from 5-agent audit
41449fe
import { useEffect, useRef, useState } from 'react';
import type { Damage, Part } from '@arac-hasar/types';
import { cn } from '../utils/cn';
export type OverlayMode = 'damages' | 'parts' | 'both' | 'none';
interface Props {
imageUrl: string;
damages?: Damage[];
parts?: Part[];
mode?: OverlayMode;
highlightedDamageId?: number | null;
highlightedPart?: string | null;
/** Click handler for a damage overlay (hit-tested in container coords). */
onDamageClick?: (id: number) => void;
/** Click handler for a part overlay. */
onPartClick?: (name: string) => void;
/** Accessible alt text for the underlying image. */
alt?: string;
className?: string;
}
const SEVERITY_COLORS: Record<string, string> = {
hafif: '#f59e0b',
orta: '#f97316',
agir: '#ef4444',
};
const PART_COLOR = '#3b82f6';
export function ImageWithOverlay({
imageUrl,
damages = [],
parts = [],
mode = 'both',
highlightedDamageId = null,
highlightedPart = null,
onDamageClick,
onPartClick,
alt = 'İnceleme görüntüsü',
className,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [imgDim, setImgDim] = useState<{ w: number; h: number } | null>(null);
const [renderSize, setRenderSize] = useState<{ w: number; h: number } | null>(null);
// Resolve natural image dimensions (also fires via <img onLoad>).
useEffect(() => {
if (typeof window === 'undefined') return;
const img = new window.Image();
img.crossOrigin = 'anonymous';
img.onload = () => setImgDim({ w: img.naturalWidth, h: img.naturalHeight });
img.src = imageUrl;
}, [imageUrl]);
// Track rendered container size so the canvas re-paints on layout changes.
useEffect(() => {
const container = containerRef.current;
if (!container || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const cr = entry.contentRect;
setRenderSize({ w: cr.width, h: cr.height });
});
ro.observe(container);
return () => ro.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || !imgDim) return;
const size = renderSize ?? {
w: container.getBoundingClientRect().width,
h: container.getBoundingClientRect().height,
};
if (size.w <= 0 || size.h <= 0) return;
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
canvas.width = Math.round(size.w * dpr);
canvas.height = Math.round(size.h * dpr);
canvas.style.width = `${size.w}px`;
canvas.style.height = `${size.h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, size.w, size.h);
const sx = size.w;
const sy = size.h;
if (mode === 'parts' || mode === 'both') {
parts.forEach((p) => {
const isHi = highlightedPart === p.name;
ctx.strokeStyle = PART_COLOR;
ctx.lineWidth = isHi ? 3 : 1.5;
ctx.fillStyle = isHi ? 'rgba(59, 130, 246, 0.18)' : 'rgba(59, 130, 246, 0.05)';
drawPolygon(ctx, p.polygon_normalized, sx, sy, true);
});
}
if (mode === 'damages' || mode === 'both') {
damages.forEach((d) => {
const color = SEVERITY_COLORS[d.severity.level] || '#999';
const isHi = highlightedDamageId === d.id;
ctx.strokeStyle = color;
ctx.lineWidth = isHi ? 4 : 2;
ctx.fillStyle = isHi ? `${color}55` : `${color}22`;
if (d.polygon_normalized?.length) {
drawPolygon(ctx, d.polygon_normalized, sx, sy, true);
} else {
drawBbox(ctx, d.bbox, sx, sy, imgDim);
}
});
}
}, [imgDim, renderSize, damages, parts, mode, highlightedDamageId, highlightedPart]);
// Hit-test click → first damage/part whose polygon (or bbox) contains the point.
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onDamageClick && !onPartClick) return;
const container = containerRef.current;
if (!container || !imgDim) return;
const rect = container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const nx = (e.clientX - rect.left) / rect.width;
const ny = (e.clientY - rect.top) / rect.height;
if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return;
if (onDamageClick && (mode === 'damages' || mode === 'both')) {
for (const d of damages) {
if (d.polygon_normalized?.length) {
if (pointInPolygon(nx, ny, d.polygon_normalized)) {
onDamageClick(d.id);
return;
}
} else if (d.bbox && d.bbox.length >= 4) {
const [x1, y1, x2, y2] = d.bbox;
const px = nx * imgDim.w;
const py = ny * imgDim.h;
if (px >= x1! && px <= x2! && py >= y1! && py <= y2!) {
onDamageClick(d.id);
return;
}
}
}
}
if (onPartClick && (mode === 'parts' || mode === 'both')) {
for (const p of parts) {
if (p.polygon_normalized?.length && pointInPolygon(nx, ny, p.polygon_normalized)) {
onPartClick(p.name);
return;
}
}
}
};
const interactive = !!(onDamageClick || onPartClick);
// Reserve layout space ASAP to prevent CLS. Until we know the image's
// intrinsic aspect ratio, fall back to 4:3 (typical vehicle photo).
// packages/ui is framework-agnostic so we keep a plain <img> — consumer
// apps wrap routes in next/image where they own URLs directly.
const aspectRatio = imgDim ? `${imgDim.w} / ${imgDim.h}` : '4 / 3';
return (
<div
ref={containerRef}
onClick={interactive ? handleClick : undefined}
className={cn(
'relative overflow-hidden rounded-lg bg-slate-100',
interactive && 'cursor-pointer',
className,
)}
style={{ aspectRatio }}
>
{/* Guard: backend STORAGE_OPTIONAL'da `local://skipped/...` URL'i
dönebilir; browser bunu fetch edemez — placeholder göster. */}
{imageUrl && !imageUrl.startsWith('local://') ? (
<img
src={imageUrl}
alt={alt}
draggable={false}
loading="lazy"
decoding="async"
className="block h-full w-full select-none object-contain"
onLoad={(e) => {
const t = e.currentTarget;
setImgDim({ w: t.naturalWidth, h: t.naturalHeight });
}}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-slate-100 text-sm text-slate-500">
Görsel kaydedilmedi (storage opsiyonel mod)
</div>
)}
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden
/>
</div>
);
}
function pointInPolygon(x: number, y: number, poly: number[][]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i];
const b = poly[j];
if (!a || !b || a.length < 2 || b.length < 2) continue;
const xi = a[0]!;
const yi = a[1]!;
const xj = b[0]!;
const yj = b[1]!;
const intersect =
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + 1e-12) + xi;
if (intersect) inside = !inside;
}
return inside;
}
function drawPolygon(
ctx: CanvasRenderingContext2D,
poly: number[][],
sx: number,
sy: number,
fill = false,
) {
if (!poly || poly.length === 0) return;
ctx.beginPath();
poly.forEach((pt, i) => {
if (!pt || pt.length < 2) return;
const x = pt[0]!;
const y = pt[1]!;
const px = x * sx;
const py = y * sy;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.closePath();
if (fill) ctx.fill();
ctx.stroke();
}
function drawBbox(
ctx: CanvasRenderingContext2D,
bbox: number[],
sx: number,
sy: number,
imgDim: { w: number; h: number },
) {
if (bbox.length < 4) return;
const x1 = bbox[0]!;
const y1 = bbox[1]!;
const x2 = bbox[2]!;
const y2 = bbox[3]!;
const rx = (x1 / imgDim.w) * sx;
const ry = (y1 / imgDim.h) * sy;
const rw = ((x2 - x1) / imgDim.w) * sx;
const rh = ((y2 - y1) / imgDim.h) * sy;
ctx.beginPath();
ctx.rect(rx, ry, rw, rh);
ctx.fill();
ctx.stroke();
}