refactor: split map object rendering
This commit is contained in:
parent
505d9ce0f2
commit
19d345ac9a
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
148
frontend/src/components/maps/LocationMapObject.jsx
Normal file
148
frontend/src/components/maps/LocationMapObject.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user