import { useRef, useState } from "react"; import { clientPointToMap, getObjectKey, itemsForZone, } from "../../lib/locationMapUtils"; const DRAG_CHANGE_THRESHOLD = 2; export default function LocationMapCanvas({ mode, editorTool, objects, filters, mapSize, zoom, selectedObjectKey, mapItems, username, svgRef, dragState, setDragState, setSelectedObjectKey, remember, updateObjects, }) { const canEditObjects = mode === "edit" && editorTool === "edit"; const canDragPan = mode === "view" || editorTool === "pan"; const scrollRef = useRef(null); const panDragRef = useRef(null); const objectDragHistoryCapturedRef = useRef(false); const suppressPanClickRef = useRef(false); const [isPanning, setIsPanning] = useState(false); const startDrag = (event, object, type) => { if (!canEditObjects || object.locked || !svgRef.current) return; event.stopPropagation(); event.preventDefault(); objectDragHistoryCapturedRef.current = false; const point = clientPointToMap(event, svgRef.current, mapSize); event.currentTarget.setPointerCapture?.(event.pointerId); setSelectedObjectKey(getObjectKey(object)); setDragState({ type, key: getObjectKey(object), startPoint: point, startObject: { ...object }, }); }; const handlePointerMove = (event) => { if (!dragState || !svgRef.current) return; event.preventDefault(); const point = clientPointToMap(event, svgRef.current, mapSize); const deltaX = point.x - dragState.startPoint.x; const deltaY = point.y - dragState.startPoint.y; const hasMeaningfulChange = Math.abs(deltaX) >= DRAG_CHANGE_THRESHOLD || Math.abs(deltaY) >= DRAG_CHANGE_THRESHOLD; if (!hasMeaningfulChange) return; if (!objectDragHistoryCapturedRef.current) { remember(); objectDragHistoryCapturedRef.current = true; } updateObjects((currentObjects) => currentObjects.map((object) => { if (getObjectKey(object) !== dragState.key) return object; if (dragState.type === "resize") { return { ...object, width: Math.max(40, dragState.startObject.width + deltaX), height: Math.max(40, dragState.startObject.height + deltaY), }; } return { ...object, x: dragState.startObject.x + deltaX, y: dragState.startObject.y + deltaY, }; }) ); }; const handlePointerUp = () => { objectDragHistoryCapturedRef.current = false; setDragState(null); }; const handleObjectClick = (event, object) => { event.stopPropagation(); if (suppressPanClickRef.current) { suppressPanClickRef.current = false; return; } if (mode === "view" || canEditObjects) { setSelectedObjectKey(getObjectKey(object)); } }; const clearSelection = () => { if (suppressPanClickRef.current) { suppressPanClickRef.current = false; return; } if (!dragState) { setSelectedObjectKey(null); } }; const startPan = (event) => { if (!canDragPan || !scrollRef.current || event.button !== 0) return; const objectElement = event.target.closest?.(".location-map-object"); event.currentTarget.setPointerCapture?.(event.pointerId); panDragRef.current = { startX: event.clientX, startY: event.clientY, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop, objectKey: objectElement?.getAttribute("data-object-key") || null, moved: false, }; setIsPanning(true); }; const movePan = (event) => { if (!panDragRef.current || !scrollRef.current) return; const deltaX = event.clientX - panDragRef.current.startX; const deltaY = event.clientY - panDragRef.current.startY; if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) { panDragRef.current.moved = true; event.preventDefault(); } scrollRef.current.scrollLeft = panDragRef.current.scrollLeft - deltaX; scrollRef.current.scrollTop = panDragRef.current.scrollTop - deltaY; }; const endPan = (event) => { if (!panDragRef.current) return; const wasMoved = panDragRef.current.moved; const objectKey = panDragRef.current.objectKey; if (!wasMoved && mode === "view" && objectKey) { setSelectedObjectKey(objectKey); suppressPanClickRef.current = true; } else { suppressPanClickRef.current = wasMoved; } panDragRef.current = null; setIsPanning(false); event.currentTarget.releasePointerCapture?.(event.pointerId); }; const truncateLabel = (value, maxCharacters) => { if (value.length <= maxCharacters) return value; return `${value.slice(0, Math.max(5, maxCharacters - 3)).trimEnd()}...`; }; const fittedLabelLines = (object, fallback) => { const rawLabel = String(object.label || object.zone_name || fallback).trim(); const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 13)); const canUseTwoLines = Number(object.height) >= 92 && rawLabel.length > maxCharacters; if (!canUseTwoLines) return [truncateLabel(rawLabel, maxCharacters)]; const words = rawLabel.split(/\s+/); let firstLine = ""; let splitIndex = 0; for (let index = 0; index < words.length; index += 1) { const candidate = firstLine ? `${firstLine} ${words[index]}` : words[index]; if (candidate.length > maxCharacters && firstLine) break; firstLine = candidate; splitIndex = index + 1; if (candidate.length >= maxCharacters) break; } const secondLine = words.slice(splitIndex).join(" "); if (!secondLine) return [truncateLabel(firstLine, maxCharacters)]; return [ truncateLabel(firstLine, maxCharacters), truncateLabel(secondLine, maxCharacters), ]; }; const renderMapObject = (object, index) => { if (!object.visible || !filters.showZones) return null; const key = getObjectKey(object); const isSelected = key === selectedObjectKey; const zoneItems = itemsForZone(mapItems, object.zone_id, filters, username); const count = zoneItems.length; const countLabel = `${count} item${count === 1 ? "" : "s"}`; const labelLines = fittedLabelLines(object, "Area"); const countY = filters.showLabels && labelLines.length > 1 ? object.y + 76 : object.y + 52; const resizeHandleSize = Math.min(96, Math.max(44, Math.ceil(48 / zoom))); const resizeHandleInset = 6; const resizeHandleX = Math.min( Math.max(0, mapSize.width - resizeHandleSize), Math.max( object.x + resizeHandleInset, object.x + object.width - resizeHandleSize - resizeHandleInset ) ); const resizeHandleY = Math.min( Math.max(0, mapSize.height - resizeHandleSize), Math.max( object.y + resizeHandleInset, object.y + object.height - resizeHandleSize - resizeHandleInset ) ); const pinDots = filters.showPins ? zoneItems.slice(0, 12).map((item, itemIndex) => { const column = itemIndex % 4; const row = Math.floor(itemIndex / 4); return ( ); }) : null; return ( startDrag(event, object, "move")} onClick={(event) => handleObjectClick(event, object)} /> {filters.showLabels ? ( {labelLines.map((line, lineIndex) => ( {line} ))} ) : null} {filters.showCounts ? ( {countLabel} ) : null} {pinDots} {canEditObjects && isSelected ? ( startDrag(event, object, "resize")} onClick={(event) => event.stopPropagation()} /> ) : null} ); }; return ( {mode === "edit" ? ( ) : null} {objects.map(renderMapObject)} {objects.length === 0 ? ( No map areas yet ) : null} ); }