diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx
index 05a969c..b509164 100644
--- a/frontend/src/components/maps/LocationMapCanvas.jsx
+++ b/frontend/src/components/maps/LocationMapCanvas.jsx
@@ -138,11 +138,34 @@ export default function LocationMapCanvas({
event.currentTarget.releasePointerCapture?.(event.pointerId);
};
- const fittedLabel = (object, fallback) => {
- const rawLabel = object.label || object.zone_name || fallback;
- const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 14));
- if (rawLabel.length <= maxCharacters) return rawLabel;
- return `${rawLabel.slice(0, Math.max(5, maxCharacters - 3))}...`;
+ 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 = String(object.label || object.zone_name || fallback).trim();
+ 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) => {
@@ -152,6 +175,8 @@ export default function LocationMapCanvas({
const zoneItems = itemsForZone(mapItems, object.zone_id, filters, username);
const count = zoneItems.length;
const countLabel = `${count} item${count === 1 ? "" : "s"}`;
+ const labelLines = fittedLabelLines(object, "Area");
+ const countY = labelLines.length > 1 ? object.y + 76 : object.y + 52;
const pinDots = filters.showPins
? zoneItems.slice(0, 12).map((item, itemIndex) => {
const column = itemIndex % 4;
@@ -192,11 +217,15 @@ export default function LocationMapCanvas({
/>
{filters.showLabels ? (
- {fittedLabel(object, "Area")}
+ {labelLines.map((line, lineIndex) => (
+
+ {line}
+
+ ))}
) : null}
{filters.showCounts ? (
-
+
{countLabel}
) : null}
diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts
index e91dccb..050e0a7 100644
--- a/frontend/tests/location-map-manager.spec.ts
+++ b/frontend/tests/location-map-manager.spec.ts
@@ -423,6 +423,46 @@ test("admin status follows the visible map when a saved draft exists", async ({
await expect(page.locator(".location-map-object", { hasText: "Live Bakery" })).toBeVisible();
});
+test("viewer wraps long zone labels and keeps item counts visible", async ({ page }) => {
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1501,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Produce & Fresh Vegetables",
+ type: "zone",
+ label: "Produce & Fresh Vegetables",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 120,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ const produceArea = page.locator('[data-object-key="1501"]');
+ await expect(produceArea.locator(".location-map-label tspan")).toHaveCount(2);
+ await expect(produceArea.locator(".location-map-label tspan").nth(0)).toHaveText("Produce & Fresh");
+ await expect(produceArea.locator(".location-map-label tspan").nth(1)).toHaveText("Vegetables");
+ await expect(produceArea.locator(".location-map-count")).toHaveText("2 items");
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+});
+
test("members can view a published map but cannot edit it", async ({ page }) => {
await mockMapShell(page, memberHousehold);