368 lines
12 KiB
JavaScript
368 lines
12 KiB
JavaScript
import { useMemo, useRef, useState } from "react";
|
|
import {
|
|
clientPointToMap,
|
|
getObjectKey,
|
|
itemMatchesMapFilters,
|
|
} from "../../lib/locationMapUtils";
|
|
import LocationMapObject from "./LocationMapObject";
|
|
|
|
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" || canEditObjects;
|
|
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 zoneItemSummary = useMemo(() => {
|
|
const assignedCounts = new Map();
|
|
const visibleItemsByZone = new Map();
|
|
|
|
(mapItems || []).forEach((item) => {
|
|
const zoneKey = String(item.zone_id || "");
|
|
if (!zoneKey) return;
|
|
|
|
assignedCounts.set(zoneKey, (assignedCounts.get(zoneKey) || 0) + 1);
|
|
if (!itemMatchesMapFilters(item, filters, username)) return;
|
|
|
|
const existingItems = visibleItemsByZone.get(zoneKey) || [];
|
|
existingItems.push(item);
|
|
visibleItemsByZone.set(zoneKey, existingItems);
|
|
});
|
|
|
|
return { assignedCounts, visibleItemsByZone };
|
|
}, [filters, mapItems, username]);
|
|
|
|
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");
|
|
if (canEditObjects && objectElement) return;
|
|
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 renderMapObject = (object, index) => {
|
|
const key = getObjectKey(object);
|
|
const zoneKey = String(object.zone_id || "");
|
|
const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : [];
|
|
const assignedZoneItemCount = zoneKey ? zoneItemSummary.assignedCounts.get(zoneKey) || 0 : 0;
|
|
return (
|
|
<LocationMapObject
|
|
key={key}
|
|
object={object}
|
|
index={index}
|
|
filters={filters}
|
|
zoneItems={zoneItems}
|
|
assignedZoneItemCount={assignedZoneItemCount}
|
|
isSelected={key === selectedObjectKey}
|
|
canEditObjects={canEditObjects}
|
|
canSelect={mode === "view" || canEditObjects}
|
|
zoom={zoom}
|
|
mapSize={mapSize}
|
|
onStartDrag={startDrag}
|
|
onObjectClick={handleObjectClick}
|
|
onObjectKeyDown={handleObjectKeyDown}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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}
|
|
{(selectedObjectKey
|
|
? [
|
|
...objects.filter((object) => getObjectKey(object) !== selectedObjectKey),
|
|
...objects.filter((object) => getObjectKey(object) === selectedObjectKey),
|
|
]
|
|
: 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>
|
|
);
|
|
}
|