Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
2 changed files with 164 additions and 123 deletions
Showing only changes of commit 19d345ac9a - Show all commits

View File

@ -1,10 +1,10 @@
import { useMemo, useRef, useState } from "react";
import {
clientPointToMap,
getMapObjectDisplayLabel,
getObjectKey,
itemMatchesMapFilters,
} from "../../lib/locationMapUtils";
import LocationMapObject from "./LocationMapObject";
const DRAG_CHANGE_THRESHOLD = 2;
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
@ -246,135 +246,28 @@ export default function LocationMapCanvas({
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 zoneKey = String(object.zone_id || "");
const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : [];
const assignedZoneItemCount = zoneKey ? zoneItemSummary.assignedCounts.get(zoneKey) || 0 : 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
<LocationMapObject
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>
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}
/>
);
};

View File

@ -0,0 +1,148 @@
import {
getMapObjectDisplayLabel,
getObjectKey,
} from "../../lib/locationMapUtils";
const PIN_PREVIEW_LIMIT = 12;
function truncateLabel(value, maxCharacters) {
if (value.length <= maxCharacters) return value;
return `${value.slice(0, Math.max(5, maxCharacters - 3)).trimEnd()}...`;
}
function 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),
];
}
export default function LocationMapObject({
object,
index,
filters,
zoneItems,
assignedZoneItemCount,
isSelected,
canEditObjects,
canSelect,
zoom,
mapSize,
onStartDrag,
onObjectClick,
onObjectKeyDown,
}) {
if (!object.visible || !filters.showZones) return null;
const key = getObjectKey(object);
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, PIN_PREVIEW_LIMIT).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
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={canSelect ? 0 : -1}
onPointerDown={(event) => onStartDrag(event, object, "move")}
onClick={(event) => onObjectClick(event, object)}
onKeyDown={(event) => onObjectKeyDown(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) => onStartDrag(event, object, "resize")}
onClick={(event) => event.stopPropagation()}
/>
) : null}
</g>
);
}