453 lines
15 KiB
JavaScript
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>
|
|
);
|
|
}
|