grocery-app/frontend/src/components/maps/LocationMapCanvas.jsx
2026-06-03 23:04:53 -07:00

453 lines
15 KiB
JavaScript

import { useRef, useState } from "react";
import {
clientPointToMap,
getMapObjectDisplayLabel,
getObjectKey,
itemsForZone,
} from "../../lib/locationMapUtils";
const DRAG_CHANGE_THRESHOLD = 2;
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
export default function LocationMapCanvas({
mode,
editorTool,
objects,
filters,
mapSize,
zoom,
selectedObjectKey,
mapItems,
username,
svgRef,
dragState,
editingLocked = false,
hiddenAreaCount = 0,
onAddObject,
onShowZones,
onShowHiddenAreas,
setDragState,
setSelectedObjectKey,
remember,
updateObjects,
}) {
const canEditObjects = !editingLocked && mode === "edit" && editorTool === "edit";
const canDragPan = mode === "view" || editorTool === "pan";
const hiddenByZonesLayer = objects.length > 0 && !filters.showZones;
const visibleObjectCount = filters.showZones
? objects.filter((object) => object.visible).length
: 0;
const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0;
const canAddFirstArea =
mode === "edit" && objects.length === 0 && typeof onAddObject === "function";
const canRecoverHiddenAreas =
mode === "edit" && hasNoVisibleObjects && hiddenAreaCount > 0 && typeof onShowHiddenAreas === "function";
const hasEmptyCanvasAction = canAddFirstArea || hiddenByZonesLayer || canRecoverHiddenAreas;
const emptyCanvasMessage = objects.length === 0
? "No map areas yet"
: hiddenByZonesLayer
? "Zones hidden in Layers"
: hasNoVisibleObjects
? "No visible map areas"
: "";
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 selectObject = (object) => {
if (mode === "view" || canEditObjects) {
setSelectedObjectKey(getObjectKey(object));
}
};
const handleObjectClick = (event, object) => {
event.stopPropagation();
if (suppressPanClickRef.current) {
suppressPanClickRef.current = false;
return;
}
selectObject(object);
};
const handleObjectKeyDown = (event, object) => {
if (["Enter", " ", "Spacebar"].includes(event.key)) {
event.stopPropagation();
event.preventDefault();
selectObject(object);
return;
}
if (!canEditObjects || object.locked || !NUDGE_KEYS.has(event.key)) return;
event.stopPropagation();
event.preventDefault();
const nudgeAmount = event.shiftKey ? 25 : 10;
const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0;
const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0;
remember();
setSelectedObjectKey(getObjectKey(object));
updateObjects((currentObjects) =>
currentObjects.map((candidate) =>
getObjectKey(candidate) === getObjectKey(object)
? {
...candidate,
x: candidate.x + deltaX,
y: candidate.y + deltaY,
}
: candidate
)
);
};
const handleShowZones = (event) => {
event.stopPropagation();
onShowZones?.();
};
const handleAddObject = (event) => {
event.stopPropagation();
if (editingLocked) return;
onAddObject?.();
};
const handleShowHiddenAreas = (event) => {
event.stopPropagation();
if (editingLocked) return;
onShowHiddenAreas?.();
};
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 = getMapObjectDisplayLabel(object, fallback);
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 assignedZoneItemCount = object.zone_id
? mapItems.filter(
(item) => String(item.zone_id || "") === String(object.zone_id || "")
).length
: 0;
const count = zoneItems.length;
const hasHiddenZoneItems = assignedZoneItemCount > count;
const countLabel = hasHiddenZoneItems
? `${count} shown`
: `${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 (
<circle
key={item.id}
className="location-map-pin"
cx={object.x + 24 + column * 18}
cy={object.y + 30 + row * 18}
r="5"
/>
);
})
: null;
return (
<g
key={key}
data-object-key={key}
className={[
"location-map-object",
isSelected ? "is-selected" : "",
canEditObjects ? "is-editable" : "",
].filter(Boolean).join(" ")}
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
>
<rect
x={object.x}
y={object.y}
width={object.width}
height={object.height}
rx="12"
role="button"
aria-label={`Map area ${getMapObjectDisplayLabel(object, index + 1)}`}
aria-pressed={isSelected}
tabIndex={mode === "view" || canEditObjects ? 0 : -1}
onPointerDown={(event) => startDrag(event, object, "move")}
onClick={(event) => handleObjectClick(event, object)}
onKeyDown={(event) => handleObjectKeyDown(event, object)}
/>
{filters.showLabels ? (
<text x={object.x + 16} y={object.y + 28} className="location-map-label">
{labelLines.map((line, lineIndex) => (
<tspan key={`${line}-${lineIndex}`} x={object.x + 16} dy={lineIndex === 0 ? 0 : 24}>
{line}
</tspan>
))}
</text>
) : null}
{filters.showCounts ? (
<text x={object.x + 16} y={countY} className="location-map-count">
{countLabel}
</text>
) : null}
{pinDots}
{canEditObjects && isSelected && !object.locked ? (
<rect
className="location-map-resize-handle"
x={resizeHandleX}
y={resizeHandleY}
width={resizeHandleSize}
height={resizeHandleSize}
rx="8"
onPointerDown={(event) => startDrag(event, object, "resize")}
onClick={(event) => event.stopPropagation()}
/>
) : null}
</g>
);
};
return (
<section
className={[
"location-map-canvas-shell",
mode === "edit" ? "is-edit-mode" : "is-view-mode",
editorTool === "edit" ? "is-object-tool" : "is-pan-tool",
isPanning ? "is-panning" : "",
].filter(Boolean).join(" ")}
>
<div
ref={scrollRef}
className="location-map-scroll"
onPointerDown={startPan}
onPointerMove={movePan}
onPointerUp={endPan}
onPointerCancel={endPan}
onPointerLeave={endPan}
>
<svg
ref={svgRef}
className={[
"location-map-svg",
canEditObjects ? "is-editable" : "",
].filter(Boolean).join(" ")}
style={{
width: `${mapSize.width * zoom}px`,
height: `${mapSize.height * zoom}px`,
}}
viewBox={`0 0 ${mapSize.width} ${mapSize.height}`}
role="img"
aria-label="Store location map"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onClick={clearSelection}
>
<defs>
<pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" />
</pattern>
</defs>
<rect className="location-map-background" width={mapSize.width} height={mapSize.height} />
{mode === "edit" ? (
<rect className="location-map-grid" width={mapSize.width} height={mapSize.height} />
) : null}
{objects.map(renderMapObject)}
</svg>
{emptyCanvasMessage ? (
<div
className={[
"location-map-empty-canvas",
hasEmptyCanvasAction ? "has-action" : "",
].filter(Boolean).join(" ")}
>
<span>{emptyCanvasMessage}</span>
{canAddFirstArea ? (
<button
type="button"
disabled={editingLocked}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleAddObject}
>
Add Area
</button>
) : hiddenByZonesLayer ? (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShowZones}
>
Show Zones
</button>
) : canRecoverHiddenAreas ? (
<button
type="button"
disabled={editingLocked}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleShowHiddenAreas}
>
Show Hidden
</button>
) : null}
</div>
) : null}
</div>
</section>
);
}