import React, { useEffect, useRef, useState } from 'react'; import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext'; import { useAssetUrl } from '../useAssetImageUrl'; import styles from './MaterialOverlay.module.css'; type Corner = 'nw' | 'ne' | 'sw' | 'se'; type MaterialOverlayProps = { assetId: AssetId | null; layout: MaterialsOverlayLayout; editable?: boolean; zoomTool?: MaterialsZoomTool | NpcsZoomTool; showClose?: boolean; onClose?: () => void; closeLabel?: string; rotateLabel?: string; onLayoutChange?: (layout: MaterialsOverlayLayout) => void; onZoomAt?: (nx: number, ny: number) => void; /** Без собственного root/dim — внутри `SceneOverlayHost`. */ embedded?: boolean; /** Маркеры легенды (норм. координаты картинки). */ legendMarkers?: readonly { id: string; number: number; nx: number; ny: number }[]; }; function RotateIcon() { return ( ); } function nextBaseSize( viewW: number, viewH: number, naturalW: number, naturalH: number, ): { w: number; h: number } { if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 }; const maxW = viewW * 0.92; const maxH = viewH * 0.88; const fit = Math.min(maxW / naturalW, maxH / naturalH); return { w: naturalW * fit, h: naturalH * fit }; } function pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number { return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI; } function shortestAngleDelta(fromDeg: number, toDeg: number): number { let d = toDeg - fromDeg; while (d > 180) d -= 360; while (d < -180) d += 360; return d; } function screenToLocal( clientX: number, clientY: number, centerX: number, centerY: number, rotationDeg: number, ): { x: number; y: number } { const rad = (-rotationDeg * Math.PI) / 180; const dx = clientX - centerX; const dy = clientY - centerY; return { x: dx * Math.cos(rad) - dy * Math.sin(rad), y: dx * Math.sin(rad) + dy * Math.cos(rad), }; } function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } { const rad = (rotationDeg * Math.PI) / 180; return { x: localX * Math.cos(rad) - localY * Math.sin(rad), y: localX * Math.sin(rad) + localY * Math.cos(rad), }; } export function MaterialOverlay({ assetId, layout, editable = false, zoomTool = null, showClose = false, onClose, closeLabel = 'Close', rotateLabel = 'Rotate', onLayoutChange, onZoomAt, embedded = false, legendMarkers, }: MaterialOverlayProps) { const url = useAssetUrl(assetId); const host = useSceneOverlayView(); const localRootRef = useRef(null); const rootRef = embedded && host ? host.rootRef : localRootRef; const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 }); const [localView, setLocalView] = useState({ w: 1, h: 1 }); /** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */ const [draftLayout, setDraftLayout] = useState(null); const pendingLayoutRef = useRef(null); const draftRafRef = useRef(0); const onLayoutChangeRef = useRef(onLayoutChange); onLayoutChangeRef.current = onLayoutChange; const view = embedded && host ? host.view : localView; const dragRef = useRef< | { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout } | { mode: 'resize'; corner: Corner; origin: MaterialsOverlayLayout; baseW: number; baseH: number; viewW: number; viewH: number; centerClientX: number; centerClientY: number; } | { mode: 'rotate'; origin: MaterialsOverlayLayout; startPointerAngle: number; centerX: number; centerY: number; } | null >(null); useEffect(() => { if (embedded) return; const el = localRootRef.current; if (!el) return; let raf = 0; const sync = () => { if (raf !== 0) return; raf = window.requestAnimationFrame(() => { raf = 0; const w = Math.max(1, el.clientWidth); const h = Math.max(1, el.clientHeight); setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h })); }); }; sync(); const ro = new ResizeObserver(sync); ro.observe(el); return () => { ro.disconnect(); if (raf !== 0) window.cancelAnimationFrame(raf); }; }, [embedded, url]); useEffect(() => { if (dragRef.current) return; setDraftLayout(null); pendingLayoutRef.current = null; }, [layout]); if (!assetId || !url) return null; const interactive = editable || showClose || Boolean(zoomTool); const zoomCursor = zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : ''; const effectiveLayout = draftLayout ?? layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT; const rotationDeg = effectiveLayout.rotationDeg ?? 0; const base = nextBaseSize(view.w, view.h, natural.w, natural.h); const w = base.w * effectiveLayout.scale; const h = base.h * effectiveLayout.scale; const left = effectiveLayout.cx * view.w - w / 2; const top = effectiveLayout.cy * view.h - h / 2; const toNorm = (clientX: number, clientY: number) => { const root = rootRef.current; if (!root) return { nx: 0.5, ny: 0.5 }; const r = root.getBoundingClientRect(); return { nx: (clientX - r.left) / Math.max(1, r.width), ny: (clientY - r.top) / Math.max(1, r.height), }; }; const layoutCenterClient = () => { const root = rootRef.current; if (!root) return { x: 0, y: 0 }; const r = root.getBoundingClientRect(); return { x: r.left + effectiveLayout.cx * r.width, y: r.top + effectiveLayout.cy * r.height, }; }; const publishDraft = (next: MaterialsOverlayLayout): void => { pendingLayoutRef.current = next; if (draftRafRef.current !== 0) return; draftRafRef.current = window.requestAnimationFrame(() => { draftRafRef.current = 0; const pending = pendingLayoutRef.current; if (!pending) return; setDraftLayout(pending); onLayoutChangeRef.current?.(pending); }); }; const onPointerMove = (e: PointerEvent) => { const drag = dragRef.current; if (!drag || !onLayoutChange) return; if (drag.mode === 'rotate') { const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY); const delta = shortestAngleDelta(drag.startPointerAngle, angle); publishDraft({ ...drag.origin, rotationDeg: drag.origin.rotationDeg + delta, }); return; } const root = rootRef.current; if (!root) return; const r = root.getBoundingClientRect(); if (drag.mode === 'move') { const dx = (e.clientX - drag.startX) / Math.max(1, r.width); const dy = (e.clientY - drag.startY) / Math.max(1, r.height); publishDraft({ ...drag.origin, cx: drag.origin.cx + dx, cy: drag.origin.cy + dy, }); return; } const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag; const originW = baseW * origin.scale; const originH = baseH * origin.scale; const originLeft = -originW / 2; const originTop = -originH / 2; const originRight = originW / 2; const originBottom = originH / 2; let anchorX = originLeft; let anchorY = originTop; if (corner === 'nw') { anchorX = originRight; anchorY = originBottom; } else if (corner === 'ne') { anchorX = originLeft; anchorY = originBottom; } else if (corner === 'sw') { anchorX = originRight; anchorY = originTop; } const local = screenToLocal( e.clientX, e.clientY, centerClientX, centerClientY, origin.rotationDeg ?? 0, ); const newW = Math.max(8, Math.abs(local.x - anchorX)); const newH = Math.max(8, Math.abs(local.y - anchorY)); const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH)); const ww = baseW * nextScale; const hh = baseH * nextScale; let nextLeft = anchorX; let nextTop = anchorY; if (corner === 'nw') { nextLeft = anchorX - ww; nextTop = anchorY - hh; } else if (corner === 'ne') { nextLeft = anchorX; nextTop = anchorY - hh; } else if (corner === 'sw') { nextLeft = anchorX - ww; nextTop = anchorY; } const localCenterX = nextLeft + ww / 2; const localCenterY = nextTop + hh / 2; const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0); publishDraft({ ...origin, cx: origin.cx + screenOffset.x / Math.max(1, viewW), cy: origin.cy + screenOffset.y / Math.max(1, viewH), scale: nextScale, }); }; const endDrag = () => { dragRef.current = null; window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', endDrag); if (draftRafRef.current !== 0) { window.cancelAnimationFrame(draftRafRef.current); draftRafRef.current = 0; } const finalLayout = pendingLayoutRef.current; if (finalLayout) { setDraftLayout(finalLayout); onLayoutChangeRef.current?.(finalLayout); } }; const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => { if (!editable || !onLayoutChange || zoomTool) return; e.preventDefault(); e.stopPropagation(); if (mode === 'move') { dragRef.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...effectiveLayout }, }; } else { const center = layoutCenterClient(); dragRef.current = { mode: 'resize', corner: corner ?? 'se', origin: { ...effectiveLayout }, baseW: base.w, baseH: base.h, viewW: view.w, viewH: view.h, centerClientX: center.x, centerClientY: center.y, }; } window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', endDrag); }; const startRotate = (e: React.PointerEvent) => { if (!editable || !onLayoutChange || zoomTool) return; e.preventDefault(); e.stopPropagation(); const center = layoutCenterClient(); dragRef.current = { mode: 'rotate', origin: { ...effectiveLayout }, startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY), centerX: center.x, centerY: center.y, }; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', endDrag); }; const frame = (
{ if (zoomTool) return; startDrag(e, 'move'); }} > { const img = e.currentTarget; setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); }} /> {(legendMarkers ?? []).map((m) => (
{m.number}
))} {editable && !zoomTool ? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( ) : null}
); if (embedded) { return frame; } return (
{ if (!zoomTool || !onZoomAt) return; e.stopPropagation(); const { nx, ny } = toNorm(e.clientX, e.clientY); onZoomAt(nx, ny); }} >
{frame} {showClose ? ( ) : null}
); }