Compare commits
2 Commits
505d9ce0f2
...
ea05691a65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea05691a65 | ||
|
|
19d345ac9a |
@ -1,10 +1,10 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
clientPointToMap,
|
clientPointToMap,
|
||||||
getMapObjectDisplayLabel,
|
|
||||||
getObjectKey,
|
getObjectKey,
|
||||||
itemMatchesMapFilters,
|
itemMatchesMapFilters,
|
||||||
} from "../../lib/locationMapUtils";
|
} from "../../lib/locationMapUtils";
|
||||||
|
import LocationMapObject from "./LocationMapObject";
|
||||||
|
|
||||||
const DRAG_CHANGE_THRESHOLD = 2;
|
const DRAG_CHANGE_THRESHOLD = 2;
|
||||||
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
|
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
|
||||||
@ -246,135 +246,28 @@ export default function LocationMapCanvas({
|
|||||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
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) => {
|
const renderMapObject = (object, index) => {
|
||||||
if (!object.visible || !filters.showZones) return null;
|
|
||||||
const key = getObjectKey(object);
|
const key = getObjectKey(object);
|
||||||
const isSelected = key === selectedObjectKey;
|
|
||||||
const zoneKey = String(object.zone_id || "");
|
const zoneKey = String(object.zone_id || "");
|
||||||
const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : [];
|
const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : [];
|
||||||
const assignedZoneItemCount = zoneKey ? zoneItemSummary.assignedCounts.get(zoneKey) || 0 : 0;
|
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 (
|
return (
|
||||||
<g
|
<LocationMapObject
|
||||||
key={key}
|
key={key}
|
||||||
data-object-key={key}
|
object={object}
|
||||||
className={[
|
index={index}
|
||||||
"location-map-object",
|
filters={filters}
|
||||||
isSelected ? "is-selected" : "",
|
zoneItems={zoneItems}
|
||||||
canEditObjects ? "is-editable" : "",
|
assignedZoneItemCount={assignedZoneItemCount}
|
||||||
].filter(Boolean).join(" ")}
|
isSelected={key === selectedObjectKey}
|
||||||
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
|
canEditObjects={canEditObjects}
|
||||||
>
|
canSelect={mode === "view" || canEditObjects}
|
||||||
<rect
|
zoom={zoom}
|
||||||
x={object.x}
|
mapSize={mapSize}
|
||||||
y={object.y}
|
onStartDrag={startDrag}
|
||||||
width={object.width}
|
onObjectClick={handleObjectClick}
|
||||||
height={object.height}
|
onObjectKeyDown={handleObjectKeyDown}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -851,16 +851,16 @@
|
|||||||
|
|
||||||
.location-map-toolbar {
|
.location-map-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 8.25rem) auto;
|
grid-template-columns: minmax(0, 8.25rem) minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: stretch;
|
||||||
column-gap: 0.15rem;
|
column-gap: 0.15rem;
|
||||||
row-gap: 0.35rem;
|
row-gap: 0.35rem;
|
||||||
padding: 0.35rem 0.25rem;
|
padding: 0.35rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-map-toolbar.has-history-slot {
|
.location-map-toolbar.has-history-slot {
|
||||||
grid-template-columns: minmax(0, 8.25rem) 5rem auto;
|
grid-template-columns: minmax(0, 8.25rem) 5rem minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-map-mode-group {
|
.location-map-mode-group {
|
||||||
|
|||||||
@ -787,7 +787,9 @@ test("mobile keeps draft preview status compact in the topbar", async ({ page })
|
|||||||
expect(toolbarBox.height).toBeLessThanOrEqual(64);
|
expect(toolbarBox.height).toBeLessThanOrEqual(64);
|
||||||
expect(modeBox.width).toBeLessThanOrEqual(150);
|
expect(modeBox.width).toBeLessThanOrEqual(150);
|
||||||
expect(reservedHistoryBox.width).toBeGreaterThanOrEqual(70);
|
expect(reservedHistoryBox.width).toBeGreaterThanOrEqual(70);
|
||||||
|
expect(reservedHistoryBox.x).toBeGreaterThan(modeBox.x + modeBox.width - 1);
|
||||||
expect(zoomBox.y).toBeLessThan(modeBox.y + modeBox.height);
|
expect(zoomBox.y).toBeLessThan(modeBox.y + modeBox.height);
|
||||||
|
expect(zoomBox.x).toBeGreaterThan(reservedHistoryBox.x + reservedHistoryBox.width - 1);
|
||||||
expect(zoomBox.x + zoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1);
|
expect(zoomBox.x + zoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Edit Draft" }).click();
|
await page.getByRole("button", { name: "Edit Draft" }).click();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user