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:
parent
cc0a7facd0
commit
2bc61fe211
3 changed files with 667 additions and 53 deletions
|
|
@ -3253,6 +3253,130 @@
|
|||
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 {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
|
|
@ -3397,6 +3521,29 @@
|
|||
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 {
|
||||
fill: none;
|
||||
stroke: none;
|
||||
|
|
|
|||
|
|
@ -56,14 +56,16 @@ export interface MapAuthoring {
|
|||
setStrokeWeight: (value: string) => void;
|
||||
strokeStyle: "solid" | "dashed" | "dotted";
|
||||
setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void;
|
||||
labelVisible: boolean;
|
||||
setLabelVisible: (value: boolean) => void;
|
||||
roadLabelVisible: boolean | null;
|
||||
setRoadLabelVisible: (value: boolean | null) => void;
|
||||
zoneName: string;
|
||||
setZoneName: (value: string) => void;
|
||||
zoneDescription: string;
|
||||
setZoneDescription: (value: string) => void;
|
||||
zoneType: "water" | "land";
|
||||
setZoneType: (value: "water" | "land") => void;
|
||||
zoneType: "water" | "land" | "territory" | "border";
|
||||
setZoneType: (value: "water" | "land" | "territory" | "border") => void;
|
||||
zoneLabelVisible: boolean;
|
||||
setZoneLabelVisible: (value: boolean) => void;
|
||||
fillColor: string;
|
||||
setFillColor: (value: string) => void;
|
||||
zoneStrokeColor: string;
|
||||
|
|
@ -85,6 +87,9 @@ export interface MapAuthoring {
|
|||
};
|
||||
setKind: (kind: AuthoringKind) => void;
|
||||
pushPoint: (point: MapPoint) => void;
|
||||
updateDraftPoint: (index: number, point: MapPoint) => void;
|
||||
insertDraftPoint: (index: number, point: MapPoint) => void;
|
||||
removeDraftPoint: (index: number) => void;
|
||||
clearDraft: () => void;
|
||||
save: () => 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 [strokeWeight, setStrokeWeight] = useState("");
|
||||
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 [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 [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
||||
const [fillOpacity, setFillOpacity] = useState("");
|
||||
|
|
@ -136,6 +142,31 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
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 setKind = useCallback((next: AuthoringKind) => {
|
||||
|
|
@ -154,7 +185,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setStrokeColor(road.strokeColor ?? "");
|
||||
setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : "");
|
||||
setStrokeStyle(road.strokeStyle ?? "solid");
|
||||
setLabelVisible(road.labelVisible !== false);
|
||||
setRoadLabelVisible(road.labelVisible);
|
||||
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 ?? "");
|
||||
setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : "");
|
||||
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}` });
|
||||
}, []);
|
||||
|
||||
|
|
@ -212,7 +243,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
strokeColor: strokeColor.trim() || null,
|
||||
strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null,
|
||||
strokeStyle,
|
||||
labelVisible,
|
||||
labelVisible: roadLabelVisible,
|
||||
points: draftPoints,
|
||||
});
|
||||
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,
|
||||
fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null,
|
||||
strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null,
|
||||
labelVisible,
|
||||
labelVisible: zoneLabelVisible,
|
||||
points: draftPoints,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
||||
|
|
@ -265,7 +296,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setZoneStrokeColor("");
|
||||
setFillOpacity("");
|
||||
setZoneStrokeWeight("");
|
||||
setLabelVisible(true);
|
||||
setRoadLabelVisible(null);
|
||||
setZoneLabelVisible(true);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Save failed.");
|
||||
|
|
@ -283,11 +315,12 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
strokeColor,
|
||||
strokeWeight,
|
||||
strokeStyle,
|
||||
labelVisible,
|
||||
roadLabelVisible,
|
||||
draftPoints,
|
||||
zoneName,
|
||||
zoneDescription,
|
||||
zoneType,
|
||||
zoneLabelVisible,
|
||||
fillColor,
|
||||
zoneStrokeColor,
|
||||
fillOpacity,
|
||||
|
|
@ -328,14 +361,16 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setStrokeWeight,
|
||||
strokeStyle,
|
||||
setStrokeStyle,
|
||||
labelVisible,
|
||||
setLabelVisible,
|
||||
roadLabelVisible,
|
||||
setRoadLabelVisible,
|
||||
zoneName,
|
||||
setZoneName,
|
||||
zoneDescription,
|
||||
setZoneDescription,
|
||||
zoneType,
|
||||
setZoneType,
|
||||
zoneLabelVisible,
|
||||
setZoneLabelVisible,
|
||||
fillColor,
|
||||
setFillColor,
|
||||
zoneStrokeColor,
|
||||
|
|
@ -357,6 +392,9 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
},
|
||||
setKind,
|
||||
pushPoint,
|
||||
updateDraftPoint,
|
||||
insertDraftPoint,
|
||||
removeDraftPoint,
|
||||
clearDraft,
|
||||
save,
|
||||
removeFeature,
|
||||
|
|
@ -491,6 +529,11 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
{kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"}
|
||||
{kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
|
||||
</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
|
||||
size="sm"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.labelVisible}
|
||||
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
||||
/>
|
||||
Label
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Label</Label>
|
||||
<Select
|
||||
value={
|
||||
fields.roadLabelVisible == null
|
||||
? "auto"
|
||||
: fields.roadLabelVisible
|
||||
? "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>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -649,7 +710,9 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
<Label className="text-[10px] uppercase text-white/60">Type</Label>
|
||||
<Select
|
||||
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">
|
||||
<SelectValue />
|
||||
|
|
@ -657,6 +720,8 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
<SelectContent>
|
||||
<SelectItem value="water">Water</SelectItem>
|
||||
<SelectItem value="land">Land</SelectItem>
|
||||
<SelectItem value="territory">Territory</SelectItem>
|
||||
<SelectItem value="border">Country Border</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.labelVisible}
|
||||
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
||||
checked={fields.zoneLabelVisible}
|
||||
onChange={(event) => fields.setZoneLabelVisible(event.target.checked)}
|
||||
/>
|
||||
Label
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -30,11 +30,20 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
|
||||
import {
|
||||
findSnap,
|
||||
segmentMidpoints,
|
||||
type SnapKind,
|
||||
type SnapMatch,
|
||||
type SnapTarget,
|
||||
} from "@/lib/map/snapping";
|
||||
import {
|
||||
dashArrayFor,
|
||||
labelScreenAngle,
|
||||
metersPerPx,
|
||||
polygonCentroid,
|
||||
roadLabelPoints,
|
||||
roadLabelShown,
|
||||
splinePolyline,
|
||||
} from "@/lib/map/style";
|
||||
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
|
||||
// pathOptions by reference, and a fresh object literal every render forces a
|
||||
// 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 },
|
||||
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> = {
|
||||
|
|
@ -111,11 +122,22 @@ const BASEMAP_FALLBACK_PATH_OPTIONS: L.PathOptions = {
|
|||
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_MIN_SCREEN_PX = 120;
|
||||
const ROAD_LABEL_CURVE_MIN_PX = 600;
|
||||
const ROAD_LABELS_MAX = 12;
|
||||
const ROAD_LABEL_CHAR_PX = 9;
|
||||
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 {
|
||||
return text
|
||||
|
|
@ -147,6 +169,24 @@ function polylinePathD(points: MapPoint[]): string {
|
|||
.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
|
||||
* 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"/>',
|
||||
};
|
||||
|
||||
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)"` : "";
|
||||
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({
|
||||
|
|
@ -248,23 +288,41 @@ function shipmentIcon(bearing: number): L.DivIcon {
|
|||
|
||||
const structureIconCache = new Map<string, L.DivIcon>();
|
||||
|
||||
function getStructureIcon(statusKey: string, progressPct: number | null): L.DivIcon {
|
||||
const cacheKey = `${statusKey}:${progressPct ?? "full"}`;
|
||||
interface StructureRibbon {
|
||||
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);
|
||||
if (!icon) {
|
||||
if (structureIconCache.size > 400) structureIconCache.clear();
|
||||
const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete;
|
||||
const bar =
|
||||
progressPct != null
|
||||
? `<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({
|
||||
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,
|
||||
meta.color,
|
||||
)}${bar}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
undefined,
|
||||
STRUCTURE_PIN_SVG_SIZES[tier],
|
||||
)}${bar}${ribbonHtml}</div>`,
|
||||
iconSize: [pinSize, pinSize],
|
||||
iconAnchor: [pinSize / 2, pinSize / 2],
|
||||
});
|
||||
structureIconCache.set(cacheKey, icon);
|
||||
}
|
||||
|
|
@ -357,6 +415,159 @@ function ClickHandler({ onPick }: { onPick: (point: MapPoint) => void }) {
|
|||
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 {
|
||||
const total = Math.max(0, Math.ceil(ms / 1000));
|
||||
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`;
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
return (
|
||||
<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}
|
||||
{pct != null ? ` // ${pct}%` : ""}
|
||||
{Number.isFinite(remaining) ? ` // T-${formatDuration(remaining)}` : ""}
|
||||
{building && Number.isFinite(completesMs)
|
||||
? ` // Est. completion by ${friendlyCompletion(completesMs, nowMs)}`
|
||||
: ""}
|
||||
</div>
|
||||
{structure.constructionStatus === "awaiting_materials" &&
|
||||
structure.materials.length > 0 && (
|
||||
|
|
@ -450,13 +695,8 @@ function StructurePopup({
|
|||
{ label: "Grid", value: `${gridX}E / ${gridY}S` },
|
||||
building
|
||||
? {
|
||||
label: "Completes",
|
||||
value: Number.isFinite(completesMs)
|
||||
? new Date(completesMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "pending",
|
||||
label: "ETA",
|
||||
value: Number.isFinite(remaining) ? paddedCountdown(remaining) : "pending",
|
||||
}
|
||||
: {
|
||||
label: "Storage",
|
||||
|
|
@ -569,18 +809,99 @@ const StructureLayer = memo(function StructureLayer({
|
|||
return winners;
|
||||
}, [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 (
|
||||
<>
|
||||
{dots.map(({ structure, statusKey, position }) => {
|
||||
const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null;
|
||||
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 (
|
||||
<Marker
|
||||
key={`structure-${structure.id}`}
|
||||
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
|
||||
permanent
|
||||
direction="right"
|
||||
|
|
@ -597,7 +918,8 @@ const StructureLayer = memo(function StructureLayer({
|
|||
opacity={1}
|
||||
className="map-tooltip-dossier"
|
||||
>
|
||||
{structure.name}
|
||||
<div className="map-tooltip-name">{structure.name}</div>
|
||||
<div className="map-tooltip-meta">{ribbonMeta}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popup className="map-popup-dossier">
|
||||
|
|
@ -612,8 +934,16 @@ const StructureLayer = memo(function StructureLayer({
|
|||
|
||||
const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
||||
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 [zoom, setZoom] = useState(() => map.getZoom());
|
||||
|
|
@ -623,8 +953,9 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
|||
});
|
||||
|
||||
const labeledRoads = useMemo(() => {
|
||||
const scale = metersPerPx(zoom, worldSize);
|
||||
return roads
|
||||
.filter((road) => road.name && road.labelVisible !== false)
|
||||
.filter((road) => road.name && roadLabelShown(road, scale))
|
||||
.flatMap((road) => {
|
||||
const anchor = map.latLngToLayerPoint(toLatLng(road.points[0]));
|
||||
const pxPoints = road.points.map((point) => {
|
||||
|
|
@ -634,6 +965,25 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
|||
const screenLength = polylineLength(pxPoints);
|
||||
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;
|
||||
// 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
|
||||
|
|
@ -659,7 +1009,7 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
|||
},
|
||||
];
|
||||
});
|
||||
}, [map, roads, zoom]);
|
||||
}, [map, roads, worldSize, zoom]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -845,11 +1195,11 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
const zonePolys = useMemo(
|
||||
() =>
|
||||
(features?.zones ?? []).map((zone) => {
|
||||
const fallbackColor = zone.type === "water" ? "#38bdf8" : "#4ade80";
|
||||
const defaults = ZONE_PATH_OPTIONS[zone.type];
|
||||
const options: L.PathOptions = {
|
||||
color: zone.strokeColor || fallbackColor,
|
||||
fillColor: zone.fillColor || fallbackColor,
|
||||
fillOpacity: zone.fillOpacity ?? (zone.type === "water" ? 0.25 : 0.08),
|
||||
color: zone.strokeColor || defaults.color,
|
||||
fillColor: zone.fillColor || defaults.fillColor,
|
||||
fillOpacity: zone.fillOpacity ?? defaults.fillOpacity,
|
||||
weight: zone.strokeWeight ?? 1,
|
||||
};
|
||||
return { zone, positions: zone.points.map(toLatLng), options };
|
||||
|
|
@ -903,6 +1253,37 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
[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) {
|
||||
return (
|
||||
<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 && (
|
||||
<RoadLabelsLayer
|
||||
worldSize={Math.max(worldW, worldH)}
|
||||
roads={features.roads.map((road) => ({
|
||||
id: road.id,
|
||||
name: road.name ?? "",
|
||||
labelVisible: road.labelVisible,
|
||||
surface: road.surface,
|
||||
points: road.points,
|
||||
}))}
|
||||
/>
|
||||
|
|
@ -1202,12 +1585,31 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
)}
|
||||
|
||||
<MousePosition />
|
||||
<MapAttribution prefix="Polaris Task Force // Arma 3 // Leaflet" />
|
||||
<FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} />
|
||||
{mapConfig && (
|
||||
<DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} />
|
||||
)}
|
||||
{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 && (
|
||||
<Polyline
|
||||
|
|
|
|||
Loading…
Reference in a new issue