diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx
index 929c50e..5d72929 100644
--- a/frontend/src/components/maps/LocationMapCanvas.jsx
+++ b/frontend/src/components/maps/LocationMapCanvas.jsx
@@ -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 (
-
- );
- })
- : null;
-
return (
-
- startDrag(event, object, "move")}
- onClick={(event) => handleObjectClick(event, object)}
- onKeyDown={(event) => handleObjectKeyDown(event, object)}
- />
- {filters.showLabels ? (
-
- {labelLines.map((line, lineIndex) => (
-
- {line}
-
- ))}
-
- ) : null}
- {filters.showCounts ? (
-
- {countLabel}
-
- ) : null}
- {pinDots}
- {canEditObjects && isSelected && !object.locked ? (
- startDrag(event, object, "resize")}
- onClick={(event) => event.stopPropagation()}
- />
- ) : null}
-
+ 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}
+ />
);
};
diff --git a/frontend/src/components/maps/LocationMapObject.jsx b/frontend/src/components/maps/LocationMapObject.jsx
new file mode 100644
index 0000000..ce276f1
--- /dev/null
+++ b/frontend/src/components/maps/LocationMapObject.jsx
@@ -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 (
+
+ );
+ })
+ : null;
+
+ return (
+
+ onStartDrag(event, object, "move")}
+ onClick={(event) => onObjectClick(event, object)}
+ onKeyDown={(event) => onObjectKeyDown(event, object)}
+ />
+ {filters.showLabels ? (
+
+ {labelLines.map((line, lineIndex) => (
+
+ {line}
+
+ ))}
+
+ ) : null}
+ {filters.showCounts ? (
+
+ {countLabel}
+
+ ) : null}
+ {pinDots}
+ {canEditObjects && isSelected && !object.locked ? (
+ onStartDrag(event, object, "resize")}
+ onClick={(event) => event.stopPropagation()}
+ />
+ ) : null}
+
+ );
+}