diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index f1ee6a3..4329337 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -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; diff --git a/src/components/frontend/map/AuthoringToolbar.tsx b/src/components/frontend/map/AuthoringToolbar.tsx index 24d2022..9157e9f 100644 --- a/src/components/frontend/map/AuthoringToolbar.tsx +++ b/src/components/frontend/map/AuthoringToolbar.tsx @@ -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; removeFeature: (target: "road" | "zone" | "node", id: number) => Promise; @@ -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(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)"} + {kind != null && ( + + drag squares to move, right-click to delete, cyan squares insert + + )}