1
0
Fork 0

feat(map): zoom-tiered pins with info ribbons, vertex editing UI, attribution

- structure pins step 26px to 32px to 38px as you zoom in; at the close tier
  an info ribbon extends from the pin (name plus one status line: blueprint
  purpose, minute-bucketed build ETA, or first needed material)
- ribbons claim screen-space rectangles greedily by cluster priority so they
  never overlap other pins or ribbons; losers fall back to hover tooltips
- hovered structure jumps above all markers via a leaflet icon z-index rule
- authoring clicks and draft-vertex drags snap to existing geometry
  (vertices plus road/zone edges, closing segments included) with a live
  snap preview; draggable handles, right-click delete, midpoint insert
- distant roads render one straight midpoint label, close roads curve text
  along the path
- structure popover footer shows a live ETA countdown, status line shows a
  friendly estimated completion timestamp
- attribution control credited to the unit and game, restyled to match
This commit is contained in:
Jason Fraley 2026-09-17 16:28:05 -04:00
parent cc0a7facd0
commit 2bc61fe211
3 changed files with 667 additions and 53 deletions

View file

@ -3253,6 +3253,130 @@
border-radius: 1.5px; border-radius: 1.5px;
} }
.map-structure-pin.map-pin-md {
width: 32px;
height: 32px;
}
.map-structure-pin.map-pin-lg {
width: 38px;
height: 38px;
}
.map-structure-ribbon {
position: absolute;
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 1px;
padding: 2px 8px 3px;
border: 1px solid rgb(255 255 255 / 15%);
border-left-width: 2px;
border-radius: 1px;
background: rgb(8 10 14 / 88%);
box-shadow: 0 1px 4px rgb(0 0 0 / 50%);
white-space: nowrap;
}
.map-structure-ribbon .ribbon-name {
font-size: 11px;
font-weight: 600;
color: rgb(255 255 255 / 95%);
max-width: 190px;
overflow: hidden;
text-overflow: ellipsis;
}
.map-structure-ribbon .ribbon-meta {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(255 255 255 / 65%);
}
.map-tooltip-dossier .map-tooltip-name {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: rgb(255 255 255 / 95%);
}
/* Must out-rank leaflet.css, which styles the SAME selector later (it loads
in the MapClient chunk after styles.css) with a white background. */
.map-host .leaflet-container .leaflet-control-attribution {
background: rgb(8 10 14 / 92%);
color: rgb(255 255 255 / 55%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 1px 6px;
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 0;
}
.leaflet-container .leaflet-control-attribution a {
color: rgb(148 163 184 / 85%);
}
.map-tooltip-dossier .map-tooltip-meta {
margin-top: 1px;
font-size: 9px;
letter-spacing: 0.12em;
color: rgb(255 255 255 / 60%);
}
/* A hovered structure (pin or ribbon) jumps above every other marker. The
!important is required to beat Leaflet's inline z-index management. */
.map-structure-icon.leaflet-marker-icon:hover {
z-index: 1200 !important;
}
.map-vertex-handle {
width: 12px;
height: 12px;
border: 1.5px solid #f8fafc;
border-radius: 2px;
background: rgb(8 10 14 / 75%);
box-shadow: 0 0 0 1px rgb(0 0 0 / 60%);
}
.map-midpoint-handle {
width: 8px;
height: 8px;
border: 1.5px solid #22d3ee;
border-radius: 1px;
background: rgb(8 10 14 / 40%);
opacity: 0.85;
}
.map-snap-preview {
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
border-radius: 2px;
border: 1.5px solid;
pointer-events: none;
}
.map-snap-vertex {
border-color: #34d399;
background: rgb(52 211 153 / 25%);
}
.map-snap-edge {
border-color: #22d3ee;
background: rgb(34 211 238 / 25%);
}
.map-structure-progress { .map-structure-progress {
position: absolute; position: absolute;
left: 3px; left: 3px;
@ -3397,6 +3521,29 @@
overflow: visible; overflow: visible;
} }
.map-road-label {
color: rgb(255 255 255 / 94%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
white-space: nowrap;
/* Size to the text itself: inside the 0x0 divIcon container a block div
resolves to 0 width, which makes the percentage centering transform a
no-op and leaves the label hanging off the road. */
width: max-content;
background: rgb(8 10 14 / 62%);
padding: 1px 5px;
border-radius: 1px;
text-shadow:
0 0 3px rgb(0 0 0 / 95%),
0 0 7px rgb(0 0 0 / 90%),
0 0 2px rgb(0 0 0 / 90%);
pointer-events: none;
}
.map-road-label-path { .map-road-label-path {
fill: none; fill: none;
stroke: none; stroke: none;

View file

@ -56,14 +56,16 @@ export interface MapAuthoring {
setStrokeWeight: (value: string) => void; setStrokeWeight: (value: string) => void;
strokeStyle: "solid" | "dashed" | "dotted"; strokeStyle: "solid" | "dashed" | "dotted";
setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void; setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void;
labelVisible: boolean; roadLabelVisible: boolean | null;
setLabelVisible: (value: boolean) => void; setRoadLabelVisible: (value: boolean | null) => void;
zoneName: string; zoneName: string;
setZoneName: (value: string) => void; setZoneName: (value: string) => void;
zoneDescription: string; zoneDescription: string;
setZoneDescription: (value: string) => void; setZoneDescription: (value: string) => void;
zoneType: "water" | "land"; zoneType: "water" | "land" | "territory" | "border";
setZoneType: (value: "water" | "land") => void; setZoneType: (value: "water" | "land" | "territory" | "border") => void;
zoneLabelVisible: boolean;
setZoneLabelVisible: (value: boolean) => void;
fillColor: string; fillColor: string;
setFillColor: (value: string) => void; setFillColor: (value: string) => void;
zoneStrokeColor: string; zoneStrokeColor: string;
@ -85,6 +87,9 @@ export interface MapAuthoring {
}; };
setKind: (kind: AuthoringKind) => void; setKind: (kind: AuthoringKind) => void;
pushPoint: (point: MapPoint) => void; pushPoint: (point: MapPoint) => void;
updateDraftPoint: (index: number, point: MapPoint) => void;
insertDraftPoint: (index: number, point: MapPoint) => void;
removeDraftPoint: (index: number) => void;
clearDraft: () => void; clearDraft: () => void;
save: () => Promise<void>; save: () => Promise<void>;
removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>; removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>;
@ -104,10 +109,11 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const [strokeColor, setStrokeColor] = useState(""); const [strokeColor, setStrokeColor] = useState("");
const [strokeWeight, setStrokeWeight] = useState(""); const [strokeWeight, setStrokeWeight] = useState("");
const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid"); const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid");
const [labelVisible, setLabelVisible] = useState(true); const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
const [zoneName, setZoneName] = useState(""); const [zoneName, setZoneName] = useState("");
const [zoneDescription, setZoneDescription] = useState(""); const [zoneDescription, setZoneDescription] = useState("");
const [zoneType, setZoneType] = useState<"water" | "land">("water"); const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border">("water");
const [zoneLabelVisible, setZoneLabelVisible] = useState(true);
const [fillColor, setFillColor] = useState(""); const [fillColor, setFillColor] = useState("");
const [zoneStrokeColor, setZoneStrokeColor] = useState(""); const [zoneStrokeColor, setZoneStrokeColor] = useState("");
const [fillOpacity, setFillOpacity] = useState(""); const [fillOpacity, setFillOpacity] = useState("");
@ -136,6 +142,31 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setDraftPoints((current) => [...current, point]); setDraftPoints((current) => [...current, point]);
}, []); }, []);
const updateDraftPoint = useCallback((index: number, point: MapPoint) => {
setDraftPoints((current) => current.map((existing, i) => (i === index ? point : existing)));
}, []);
const insertDraftPoint = useCallback((index: number, point: MapPoint) => {
setDraftPoints((current) => {
if (index < 0 || index > current.length) return current;
return [...current.slice(0, index), point, ...current.slice(index)];
});
}, []);
const removeDraftPoint = useCallback(
(index: number) => {
setDraftPoints((current) => {
const minimum = kind === "zone" ? 3 : kind === "road" ? 2 : 1;
if (current.length <= minimum) {
toast.error(`A ${kind ?? "feature"} needs at least ${minimum} points.`);
return current;
}
return current.filter((_, i) => i !== index);
});
},
[kind],
);
const clearDraft = useCallback(() => setDraftPoints([]), []); const clearDraft = useCallback(() => setDraftPoints([]), []);
const setKind = useCallback((next: AuthoringKind) => { const setKind = useCallback((next: AuthoringKind) => {
@ -154,7 +185,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setStrokeColor(road.strokeColor ?? ""); setStrokeColor(road.strokeColor ?? "");
setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : ""); setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : "");
setStrokeStyle(road.strokeStyle ?? "solid"); setStrokeStyle(road.strokeStyle ?? "solid");
setLabelVisible(road.labelVisible !== false); setRoadLabelVisible(road.labelVisible);
setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` }); setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` });
}, []); }, []);
@ -169,7 +200,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setZoneStrokeColor(zone.strokeColor ?? ""); setZoneStrokeColor(zone.strokeColor ?? "");
setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : ""); setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : "");
setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : ""); setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : "");
setLabelVisible(zone.labelVisible !== false); setZoneLabelVisible(zone.labelVisible !== false);
setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` }); setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` });
}, []); }, []);
@ -212,7 +243,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor: strokeColor.trim() || null, strokeColor: strokeColor.trim() || null,
strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null, strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null,
strokeStyle, strokeStyle,
labelVisible, labelVisible: roadLabelVisible,
points: draftPoints, points: draftPoints,
}); });
if (!result.success) throw new Error(result.error ?? "Save failed."); if (!result.success) throw new Error(result.error ?? "Save failed.");
@ -229,7 +260,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor: zoneStrokeColor.trim() || null, strokeColor: zoneStrokeColor.trim() || null,
fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null, fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null,
strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null, strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null,
labelVisible, labelVisible: zoneLabelVisible,
points: draftPoints, points: draftPoints,
}); });
if (!result.success) throw new Error(result.error ?? "Save failed."); if (!result.success) throw new Error(result.error ?? "Save failed.");
@ -265,7 +296,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setZoneStrokeColor(""); setZoneStrokeColor("");
setFillOpacity(""); setFillOpacity("");
setZoneStrokeWeight(""); setZoneStrokeWeight("");
setLabelVisible(true); setRoadLabelVisible(null);
setZoneLabelVisible(true);
onSaved(); onSaved();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Save failed."); toast.error(error instanceof Error ? error.message : "Save failed.");
@ -283,11 +315,12 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor, strokeColor,
strokeWeight, strokeWeight,
strokeStyle, strokeStyle,
labelVisible, roadLabelVisible,
draftPoints, draftPoints,
zoneName, zoneName,
zoneDescription, zoneDescription,
zoneType, zoneType,
zoneLabelVisible,
fillColor, fillColor,
zoneStrokeColor, zoneStrokeColor,
fillOpacity, fillOpacity,
@ -328,14 +361,16 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setStrokeWeight, setStrokeWeight,
strokeStyle, strokeStyle,
setStrokeStyle, setStrokeStyle,
labelVisible, roadLabelVisible,
setLabelVisible, setRoadLabelVisible,
zoneName, zoneName,
setZoneName, setZoneName,
zoneDescription, zoneDescription,
setZoneDescription, setZoneDescription,
zoneType, zoneType,
setZoneType, setZoneType,
zoneLabelVisible,
setZoneLabelVisible,
fillColor, fillColor,
setFillColor, setFillColor,
zoneStrokeColor, zoneStrokeColor,
@ -357,6 +392,9 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
}, },
setKind, setKind,
pushPoint, pushPoint,
updateDraftPoint,
insertDraftPoint,
removeDraftPoint,
clearDraft, clearDraft,
save, save,
removeFeature, removeFeature,
@ -491,6 +529,11 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
{kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"} {kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"}
{kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"} {kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
</span> </span>
{kind != null && (
<span className="text-[10px] uppercase tracking-wide text-white/40">
drag squares to move, right-click to delete, cyan squares insert
</span>
)}
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -621,14 +664,32 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30" className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
/> />
</div> </div>
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60"> <div className="flex flex-col gap-1">
<input <Label className="text-[10px] uppercase text-white/60">Label</Label>
type="checkbox" <Select
checked={fields.labelVisible} value={
onChange={(event) => fields.setLabelVisible(event.target.checked)} fields.roadLabelVisible == null
/> ? "auto"
Label : fields.roadLabelVisible
</label> ? "shown"
: "hidden"
}
onValueChange={(value) =>
fields.setRoadLabelVisible(
value === "shown" ? true : value === "hidden" ? false : null,
)
}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="shown">Shown</SelectItem>
<SelectItem value="hidden">Hidden</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</> </>
)} )}
@ -649,7 +710,9 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<Label className="text-[10px] uppercase text-white/60">Type</Label> <Label className="text-[10px] uppercase text-white/60">Type</Label>
<Select <Select
value={fields.zoneType} value={fields.zoneType}
onValueChange={(value) => fields.setZoneType(value as "water" | "land")} onValueChange={(value) =>
fields.setZoneType(value as "water" | "land" | "territory" | "border")
}
> >
<SelectTrigger className="h-8 w-24 text-xs"> <SelectTrigger className="h-8 w-24 text-xs">
<SelectValue /> <SelectValue />
@ -657,6 +720,8 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<SelectContent> <SelectContent>
<SelectItem value="water">Water</SelectItem> <SelectItem value="water">Water</SelectItem>
<SelectItem value="land">Land</SelectItem> <SelectItem value="land">Land</SelectItem>
<SelectItem value="territory">Territory</SelectItem>
<SelectItem value="border">Country Border</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -706,8 +771,8 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60"> <label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
<input <input
type="checkbox" type="checkbox"
checked={fields.labelVisible} checked={fields.zoneLabelVisible}
onChange={(event) => fields.setLabelVisible(event.target.checked)} onChange={(event) => fields.setZoneLabelVisible(event.target.checked)}
/> />
Label Label
</label> </label>

View file

@ -30,11 +30,20 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useGameTick } from "@/hooks/useGameTick"; import { useGameTick } from "@/hooks/useGameTick";
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points"; import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
import {
findSnap,
segmentMidpoints,
type SnapKind,
type SnapMatch,
type SnapTarget,
} from "@/lib/map/snapping";
import { import {
dashArrayFor, dashArrayFor,
labelScreenAngle,
metersPerPx, metersPerPx,
polygonCentroid, polygonCentroid,
roadLabelPoints, roadLabelPoints,
roadLabelShown,
splinePolyline, splinePolyline,
} from "@/lib/map/style"; } from "@/lib/map/style";
import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/lib/map/types"; import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/lib/map/types";
@ -66,9 +75,11 @@ const STRUCTURE_STATUS: Record<string, { color: string; label: string }> = {
// Layer styling is hoisted to module constants: react-leaflet re-applies // Layer styling is hoisted to module constants: react-leaflet re-applies
// pathOptions by reference, and a fresh object literal every render forces a // pathOptions by reference, and a fresh object literal every render forces a
// canvas redraw that glitches mid-zoom-animation. // canvas redraw that glitches mid-zoom-animation.
const ZONE_PATH_OPTIONS: Record<"water" | "land", L.PathOptions> = { const ZONE_PATH_OPTIONS: Record<"water" | "land" | "territory" | "border", L.PathOptions> = {
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 }, water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 }, land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
}; };
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = { const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
@ -111,11 +122,22 @@ const BASEMAP_FALLBACK_PATH_OPTIONS: L.PathOptions = {
weight: 1, weight: 1,
}; };
type StructurePinTier = "sm" | "md" | "lg";
const STRUCTURE_PIN_SIZES: Record<StructurePinTier, number> = { sm: 26, md: 32, lg: 38 };
const STRUCTURE_PIN_SVG_SIZES: Record<StructurePinTier, number> = { sm: 13, md: 16, lg: 20 };
const ROAD_LABEL_SPACING_PX = 400; const ROAD_LABEL_SPACING_PX = 400;
const ROAD_LABEL_MIN_SCREEN_PX = 120; const ROAD_LABEL_MIN_SCREEN_PX = 120;
const ROAD_LABEL_CURVE_MIN_PX = 600;
const ROAD_LABELS_MAX = 12; const ROAD_LABELS_MAX = 12;
const ROAD_LABEL_CHAR_PX = 9; const ROAD_LABEL_CHAR_PX = 9;
const STRUCTURE_LABEL_CLUSTER_PX = 56; const STRUCTURE_LABEL_CLUSTER_PX = 56;
const STRUCTURE_PIN_MID_M_PER_PX = 8;
const STRUCTURE_RIBBON_M_PER_PX = 4;
const RIBBON_HEIGHT_PX = 32;
const RIBBON_GAP_PX = 6;
const RIBBON_PIN_HALF_PX = STRUCTURE_PIN_SIZES.lg / 2;
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
return text return text
@ -147,6 +169,24 @@ function polylinePathD(points: MapPoint[]): string {
.join(" "); .join(" ");
} }
const straightLabelIconCache = new Map<string, L.DivIcon>();
function straightLabelIcon(name: string, angle: number): L.DivIcon {
const bucket = Math.round(angle / 5) * 5;
const cacheKey = `${name}:${bucket}`;
let icon = straightLabelIconCache.get(cacheKey);
if (!icon) {
icon = L.divIcon({
className: "map-road-label-icon",
html: `<div class="map-road-label" style="transform: translate(-50%, -50%) rotate(${bucket}deg)">${escapeHtml(name.toUpperCase())}</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
straightLabelIconCache.set(cacheKey, icon);
}
return icon;
}
/** /**
* Catmull-Rom spline through the points, emitted as cubic Beziers, so label * Catmull-Rom spline through the points, emitted as cubic Beziers, so label
* glyphs turn gently through bends instead of snapping at each kink. The * glyphs turn gently through bends instead of snapping at each kink. The
@ -213,9 +253,9 @@ const STRUCTURE_SVG_PATHS: Record<string, string> = {
'<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/>', '<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/>',
}; };
function dossierSvg(paths: string, color: string, bearingDeg?: number): string { function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13): string {
const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : ""; const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
} }
const nodeIcon = L.divIcon({ const nodeIcon = L.divIcon({
@ -248,23 +288,41 @@ function shipmentIcon(bearing: number): L.DivIcon {
const structureIconCache = new Map<string, L.DivIcon>(); const structureIconCache = new Map<string, L.DivIcon>();
function getStructureIcon(statusKey: string, progressPct: number | null): L.DivIcon { interface StructureRibbon {
const cacheKey = `${statusKey}:${progressPct ?? "full"}`; tier: StructurePinTier;
name: string;
meta: string;
}
function getStructureIcon(
statusKey: string,
progressPct: number | null,
ribbon: StructureRibbon | null,
): L.DivIcon {
const tier = ribbon?.tier ?? "sm";
const pinSize = STRUCTURE_PIN_SIZES[tier];
const cacheKey = `${statusKey}:${progressPct ?? "full"}:${tier}:${ribbon ? `${ribbon.name}|${ribbon.meta}` : ""}`;
let icon = structureIconCache.get(cacheKey); let icon = structureIconCache.get(cacheKey);
if (!icon) { if (!icon) {
if (structureIconCache.size > 400) structureIconCache.clear();
const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete; const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete;
const bar = const bar =
progressPct != null progressPct != null
? `<div class="map-structure-progress"><div style="width:${progressPct}%"></div></div>` ? `<div class="map-structure-progress"><div style="width:${progressPct}%"></div></div>`
: ""; : "";
const ribbonHtml = ribbon
? `<div class="map-structure-ribbon" style="border-left-color:${meta.color}"><div class="ribbon-name">${escapeHtml(ribbon.name)}</div><div class="ribbon-meta">${escapeHtml(ribbon.meta)}</div></div>`
: "";
icon = L.divIcon({ icon = L.divIcon({
className: "map-structure-icon", className: "map-structure-icon",
html: `<div class="map-structure-pin" style="border-color:${meta.color}">${dossierSvg( html: `<div class="map-structure-pin ${tier === "sm" ? "" : `map-pin-${tier}`}" style="border-color:${meta.color}">${dossierSvg(
STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete, STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete,
meta.color, meta.color,
)}${bar}</div>`, undefined,
iconSize: [26, 26], STRUCTURE_PIN_SVG_SIZES[tier],
iconAnchor: [13, 13], )}${bar}${ribbonHtml}</div>`,
iconSize: [pinSize, pinSize],
iconAnchor: [pinSize / 2, pinSize / 2],
}); });
structureIconCache.set(cacheKey, icon); structureIconCache.set(cacheKey, icon);
} }
@ -357,6 +415,159 @@ function ClickHandler({ onPick }: { onPick: (point: MapPoint) => void }) {
return null; return null;
} }
/** Rewrites the bottom-right attribution prefix to credit the unit and the game. */
function MapAttribution({ prefix }: { prefix: string }) {
const map = useMap();
useEffect(() => {
map.attributionControl.setPrefix(prefix);
}, [map, prefix]);
return null;
}
const SNAP_RADIUS_PX = 12;
const snapPreviewIconCache = new Map<string, L.DivIcon>();
function snapPreviewIcon(kind: SnapKind): L.DivIcon {
let icon = snapPreviewIconCache.get(kind);
if (!icon) {
icon = L.divIcon({
className: "map-snap-icon",
html: `<div class="map-snap-preview map-snap-${kind}"></div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
snapPreviewIconCache.set(kind, icon);
}
return icon;
}
/**
* Authoring click layer: while a road/zone/node draft is active, every map
* click snaps to existing geometry (feature vertices, zone/road edges,
* structure/node positions, already-placed draft vertices) before landing,
* and a moving cursor shows where the next click would land.
*/
function AuthoringSnapLayer({
targets,
worldSize,
onPick,
}: {
targets: SnapTarget[];
worldSize: number;
onPick: (point: MapPoint) => void;
}) {
const map = useMap();
const [preview, setPreview] = useState<SnapMatch | null>(null);
const snapAt = (event: L.LeafletMouseEvent): SnapMatch | null =>
findSnap(
[event.latlng.lng, -event.latlng.lat],
targets,
SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize),
);
useMapEvents({
mousemove: (event) => setPreview(snapAt(event)),
click: (event) => {
const match = snapAt(event);
if (match) {
onPick(match.point);
} else {
onPick([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]);
}
},
});
if (!preview) return null;
return (
<Marker
position={toLatLng(preview.point)}
interactive={false}
icon={snapPreviewIcon(preview.kind)}
/>
);
}
const vertexHandleIcon = L.divIcon({
className: "map-vertex-handle-icon",
html: '<div class="map-vertex-handle"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6],
});
const midpointHandleIcon = L.divIcon({
className: "map-midpoint-handle-icon",
html: '<div class="map-midpoint-handle"></div>',
iconSize: [8, 8],
iconAnchor: [4, 4],
});
/**
* Draggable handles for the current draft's vertices: drag a square to move it
* (drag-end snaps to existing geometry), right-click deletes it, clicking the
* small hollow square on a segment inserts a vertex there.
*/
function DraftHandles({
points,
closed,
baseTargets,
worldSize,
onUpdate,
onInsert,
onRemove,
}: {
points: MapPoint[];
closed: boolean;
baseTargets: SnapTarget[];
worldSize: number;
onUpdate: (index: number, point: MapPoint) => void;
onInsert: (index: number, point: MapPoint) => void;
onRemove: (index: number) => void;
}) {
const map = useMap();
const midpoints = useMemo(() => segmentMidpoints(points, closed), [points, closed]);
const dragTargets = (index: number): SnapTarget[] => [
...baseTargets,
// Own vertices, minus the dragged one so a small drag cannot snap back.
{ key: "draft", vertices: points.filter((_, i) => i !== index), closed: false, edges: false },
];
return (
<>
{points.map((point, index) => (
<Marker
key={`draft-vertex-${index}`}
position={toLatLng(point)}
draggable
icon={vertexHandleIcon}
zIndexOffset={1000}
eventHandlers={{
dragend: (event) => {
const latlng = (event.target as L.Marker).getLatLng();
const raw: MapPoint = [latlng.lng, -latlng.lat];
const radius = SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize);
const match = findSnap(raw, dragTargets(index), radius);
onUpdate(index, match ? match.point : [Math.round(raw[0]), Math.round(raw[1])]);
},
contextmenu: () => onRemove(index),
}}
/>
))}
{midpoints.map((midpoint, index) => (
<Marker
key={`draft-midpoint-${index}-${midpoint.index}`}
position={toLatLng(midpoint.point)}
icon={midpointHandleIcon}
zIndexOffset={900}
eventHandlers={{ click: () => onInsert(midpoint.index, midpoint.point) }}
/>
))}
</>
);
}
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000)); const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600); const hours = Math.floor(total / 3600);
@ -365,6 +576,38 @@ function formatDuration(ms: number): string {
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`; return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
} }
/** Footer-cell countdown: fixed-width "04H 07M 12S". */
function paddedCountdown(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return `${String(hours).padStart(2, "0")}H ${String(minutes).padStart(2, "0")}M ${String(seconds).padStart(2, "0")}S`;
}
/**
* Friendly completion target: today (or already due) shows the time only,
* within a week the weekday, beyond that the calendar date.
*/
function friendlyCompletion(completesMs: number, nowMs: number): string {
const target = new Date(completesMs);
const time = target.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayDiff = Math.round(
(startOfDay(target) - startOfDay(new Date(nowMs))) / (24 * 60 * 60 * 1000),
);
if (dayDiff <= 0) return time;
if (dayDiff < 7) return `${target.toLocaleDateString([], { weekday: "long" })}, ${time}`;
return `${target.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
}
/** Ribbon ETA in whole minutes ("1H 05M") so the cached icon changes at most once per minute. */
function bucketedEta(ms: number): string {
const totalMinutes = Math.max(0, Math.floor(ms / 60000));
return `${Math.floor(totalMinutes / 60)}H ${totalMinutes % 60}M`;
}
function DossierStrip({ label, refLabel }: { label: string; refLabel: string }) { function DossierStrip({ label, refLabel }: { label: string; refLabel: string }) {
return ( return (
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1"> <div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
@ -433,7 +676,9 @@ function StructurePopup({
> >
{status.label} {status.label}
{pct != null ? ` // ${pct}%` : ""} {pct != null ? ` // ${pct}%` : ""}
{Number.isFinite(remaining) ? ` // T-${formatDuration(remaining)}` : ""} {building && Number.isFinite(completesMs)
? ` // Est. completion by ${friendlyCompletion(completesMs, nowMs)}`
: ""}
</div> </div>
{structure.constructionStatus === "awaiting_materials" && {structure.constructionStatus === "awaiting_materials" &&
structure.materials.length > 0 && ( structure.materials.length > 0 && (
@ -450,13 +695,8 @@ function StructurePopup({
{ label: "Grid", value: `${gridX}E / ${gridY}S` }, { label: "Grid", value: `${gridX}E / ${gridY}S` },
building building
? { ? {
label: "Completes", label: "ETA",
value: Number.isFinite(completesMs) value: Number.isFinite(remaining) ? paddedCountdown(remaining) : "pending",
? new Date(completesMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: "pending",
} }
: { : {
label: "Storage", label: "Storage",
@ -569,18 +809,99 @@ const StructureLayer = memo(function StructureLayer({
return winners; return winners;
}, [dots, zoom, worldSize]); }, [dots, zoom, worldSize]);
// Ribbon allocation at close zoom: same priority order as labels, greedy
// screen-space rectangles (pin + estimated ribbon width) so ribbons never
// cover another structure's pin or ribbon. Structures that lose the
// collision fall back to their hover tooltip.
const ribbonIds = useMemo(() => {
if (metersPerPx(zoom, worldSize) > STRUCTURE_RIBBON_M_PER_PX) return new Set<number>();
const placed: { left: number; right: number; top: number; bottom: number }[] = [];
const winners = new Set<number>();
const ordered = [...dots].sort(
(a, b) =>
(b.structure.labelPriority ?? 0) - (a.structure.labelPriority ?? 0) ||
b.structure.storageUsedKg - a.structure.storageUsedKg ||
a.structure.id - b.structure.id,
);
for (const dot of ordered) {
const point = map.latLngToLayerPoint(dot.position);
const width = Math.min(220, Math.max(110, Math.max(dot.structure.name.length, 24) * 6.2));
const left = point.x + RIBBON_PIN_HALF_PX + RIBBON_GAP_PX;
const rect = {
left,
right: left + width,
top: point.y - RIBBON_HEIGHT_PX / 2,
bottom: point.y + RIBBON_HEIGHT_PX / 2,
};
const collides = placed.some(
(other) =>
rect.left < other.right &&
rect.right > other.left &&
rect.top < other.bottom &&
rect.bottom > other.top,
);
if (collides) continue;
placed.push(rect);
winners.add(dot.structure.id);
}
return winners;
}, [dots, zoom, worldSize, map]);
return ( return (
<> <>
{dots.map(({ structure, statusKey, position }) => { {dots.map(({ structure, statusKey, position }) => {
const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null; const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null;
const showLabel = labeledIds.has(structure.id); const showLabel = labeledIds.has(structure.id);
const mPerPx = metersPerPx(zoom, worldSize);
const tier: StructurePinTier =
mPerPx <= STRUCTURE_RIBBON_M_PER_PX
? "lg"
: mPerPx <= STRUCTURE_PIN_MID_M_PER_PX
? "md"
: "sm";
let ribbonMeta: string;
if (statusKey === "building") {
const end = structure.constructionCompletesAt
? new Date(structure.constructionCompletesAt).getTime()
: NaN;
ribbonMeta = Number.isFinite(end)
? `ETA ${bucketedEta(Math.max(0, end - nowMs))}`
: "Building";
} else if (statusKey === "awaiting_materials") {
const first = structure.materials[0];
ribbonMeta = first
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
: "Awaiting materials";
} else {
ribbonMeta = structure.blueprintName ?? "Structure";
}
const ribbon: StructureRibbon | null =
tier === "lg" && ribbonIds.has(structure.id)
? {
tier,
name: structure.name ?? `Structure ${structure.id}`,
meta: ribbonMeta,
}
: null;
return ( return (
<Marker <Marker
key={`structure-${structure.id}`} key={`structure-${structure.id}`}
position={position} position={position}
icon={getStructureIcon(statusKey, pct)} icon={getStructureIcon(statusKey, pct, ribbon)}
> >
{showLabel ? ( {tier === "lg" ? (
ribbon ? null : (
<Tooltip
direction="top"
offset={[0, -16]}
opacity={1}
className="map-tooltip-dossier"
>
<div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
</Tooltip>
)
) : showLabel ? (
<Tooltip <Tooltip
permanent permanent
direction="right" direction="right"
@ -597,7 +918,8 @@ const StructureLayer = memo(function StructureLayer({
opacity={1} opacity={1}
className="map-tooltip-dossier" className="map-tooltip-dossier"
> >
{structure.name} <div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
</Tooltip> </Tooltip>
)} )}
<Popup className="map-popup-dossier"> <Popup className="map-popup-dossier">
@ -612,8 +934,16 @@ const StructureLayer = memo(function StructureLayer({
const RoadLabelsLayer = memo(function RoadLabelsLayer({ const RoadLabelsLayer = memo(function RoadLabelsLayer({
roads, roads,
worldSize,
}: { }: {
roads: { id: number; name: string; labelVisible: boolean | null; points: MapPoint[] }[]; roads: {
id: number;
name: string;
labelVisible: boolean | null;
surface: "paved" | "dirt" | "trail";
points: MapPoint[];
}[];
worldSize: number;
}) { }) {
const map = useMap(); const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom()); const [zoom, setZoom] = useState(() => map.getZoom());
@ -623,8 +953,9 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
}); });
const labeledRoads = useMemo(() => { const labeledRoads = useMemo(() => {
const scale = metersPerPx(zoom, worldSize);
return roads return roads
.filter((road) => road.name && road.labelVisible !== false) .filter((road) => road.name && roadLabelShown(road, scale))
.flatMap((road) => { .flatMap((road) => {
const anchor = map.latLngToLayerPoint(toLatLng(road.points[0])); const anchor = map.latLngToLayerPoint(toLatLng(road.points[0]));
const pxPoints = road.points.map((point) => { const pxPoints = road.points.map((point) => {
@ -634,6 +965,25 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
const screenLength = polylineLength(pxPoints); const screenLength = polylineLength(pxPoints);
if (screenLength < ROAD_LABEL_MIN_SCREEN_PX) return []; if (screenLength < ROAD_LABEL_MIN_SCREEN_PX) return [];
// Distant roads: a single straight label at the midpoint (following a
// tiny wiggly path folds the letters together). Close up: text follows
// the bends, character-aligned.
if (screenLength < ROAD_LABEL_CURVE_MIN_PX) {
const mid = interpolateAlong(road.points, 0.5);
if (!mid) return [];
return [
{
id: road.id,
name: road.name,
position: toLatLng(mid.point),
icon: straightLabelIcon(
road.name,
labelScreenAngle(road.points[0], road.points[road.points.length - 1]),
),
},
];
}
const halfText = (road.name.length * ROAD_LABEL_CHAR_PX) / 2; const halfText = (road.name.length * ROAD_LABEL_CHAR_PX) / 2;
// On very long roads a fixed interval would exhaust the label cap part // On very long roads a fixed interval would exhaust the label cap part
// way along the path; spread the capped labels over the whole road // way along the path; spread the capped labels over the whole road
@ -659,7 +1009,7 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
}, },
]; ];
}); });
}, [map, roads, zoom]); }, [map, roads, worldSize, zoom]);
return ( return (
<> <>
@ -845,11 +1195,11 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
const zonePolys = useMemo( const zonePolys = useMemo(
() => () =>
(features?.zones ?? []).map((zone) => { (features?.zones ?? []).map((zone) => {
const fallbackColor = zone.type === "water" ? "#38bdf8" : "#4ade80"; const defaults = ZONE_PATH_OPTIONS[zone.type];
const options: L.PathOptions = { const options: L.PathOptions = {
color: zone.strokeColor || fallbackColor, color: zone.strokeColor || defaults.color,
fillColor: zone.fillColor || fallbackColor, fillColor: zone.fillColor || defaults.fillColor,
fillOpacity: zone.fillOpacity ?? (zone.type === "water" ? 0.25 : 0.08), fillOpacity: zone.fillOpacity ?? defaults.fillOpacity,
weight: zone.strokeWeight ?? 1, weight: zone.strokeWeight ?? 1,
}; };
return { zone, positions: zone.points.map(toLatLng), options }; return { zone, positions: zone.points.map(toLatLng), options };
@ -903,6 +1253,37 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
[worldW, worldH], [worldW, worldH],
); );
const maxWorld = Math.max(worldW, worldH);
const snapTargets = useMemo<SnapTarget[]>(() => {
if (mode !== "edit" || authoring.kind == null) return [];
return [
...(features?.roads ?? []).map((road) => ({
key: `road-${road.id}`,
vertices: road.points,
closed: false,
edges: true,
})),
...(features?.zones ?? []).map((zone) => ({
key: `zone-${zone.id}`,
vertices: zone.points,
closed: true,
edges: true,
})),
...(features?.nodes ?? []).map((node) => ({
key: `node-${node.id}`,
vertices: [node.point],
closed: false,
edges: false,
})),
...(features?.structures ?? []).map((structure) => ({
key: `structure-${structure.id}`,
vertices: [structure.point],
closed: false,
edges: false,
})),
];
}, [mode, authoring.kind, features]);
if (maps.length === 0) { if (maps.length === 0) {
return ( return (
<div className="flex flex-1 items-center justify-center p-6"> <div className="flex flex-1 items-center justify-center p-6">
@ -1089,10 +1470,12 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
{features && toggles.roads && ( {features && toggles.roads && (
<RoadLabelsLayer <RoadLabelsLayer
worldSize={Math.max(worldW, worldH)}
roads={features.roads.map((road) => ({ roads={features.roads.map((road) => ({
id: road.id, id: road.id,
name: road.name ?? "", name: road.name ?? "",
labelVisible: road.labelVisible, labelVisible: road.labelVisible,
surface: road.surface,
points: road.points, points: road.points,
}))} }))}
/> />
@ -1202,12 +1585,31 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
)} )}
<MousePosition /> <MousePosition />
<MapAttribution prefix="Polaris Task Force // Arma 3 // Leaflet" />
<FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} /> <FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} />
{mapConfig && ( {mapConfig && (
<DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} /> <DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} />
)} )}
{mode === "place" && <ClickHandler onPick={setPendingPoint} />} {mode === "place" && <ClickHandler onPick={setPendingPoint} />}
{mode === "edit" && authoring.kind != null && <ClickHandler onPick={authoring.pushPoint} />} {mode === "edit" && authoring.kind != null && (
<AuthoringSnapLayer
targets={snapTargets}
worldSize={maxWorld}
onPick={authoring.pushPoint}
/>
)}
{mode === "edit" && authoring.kind != null && authoring.draftPoints.length > 0 && (
<DraftHandles
points={authoring.draftPoints}
closed={authoring.kind === "zone"}
baseTargets={snapTargets}
worldSize={maxWorld}
onUpdate={authoring.updateDraftPoint}
onInsert={authoring.insertDraftPoint}
onRemove={authoring.removeDraftPoint}
/>
)}
{mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && ( {mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && (
<Polyline <Polyline