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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue