-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathMapGrid.tsx
More file actions
268 lines (256 loc) · 10.9 KB
/
Copy pathMapGrid.tsx
File metadata and controls
268 lines (256 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
import { getCesiumIonToken, useAppStore } from "@geolibre/core";
import { CesiumCanvas, isCesiumSupportedLayerType, SecondaryMapCanvas } from "@geolibre/map";
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@geolibre/ui";
import { Globe, Layers, Map as MapIcon, X } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
/**
* The current Cesium Ion token, re-resolved whenever the runtime environment
* changes. It can come from the build (the `CESIUM_TOKEN` env var) or from
* Settings → Environment variables (`VITE_CESIUM_TOKEN`), so the 3D-globe view
* can be enabled at runtime in the web build with no rebuild. Cesium World
* Imagery + Terrain require a token, so without one the globe is not offered
* (the per-pane toggle is hidden).
*/
function useCesiumIonToken(): string | undefined {
const [token, setToken] = useState<string | undefined>(() => getCesiumIonToken());
useEffect(() => {
const refresh = () => setToken(getCesiumIonToken());
refresh();
window.addEventListener("geolibre:runtime-env-change", refresh);
return () => window.removeEventListener("geolibre:runtime-env-change", refresh);
}, []);
return token;
}
/**
* An editable label shown centered at the top of a map pane. Empty by default;
* users type a custom name (e.g. a date or scenario) to tell panes apart.
*/
function PaneLabel({
value,
onChange,
ariaLabel,
}: {
value: string;
onChange: (value: string) => void;
ariaLabel: string;
}) {
const { t } = useTranslation();
return (
// The wrapper is click-through so it never blocks map interaction; only the
// centered field itself is interactive. `max-w` keeps it clear of the
// top-left and top-right control clusters.
<div className="pointer-events-none absolute inset-x-0 top-2 z-10 flex justify-center">
<input
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={ariaLabel}
placeholder={t("mapGrid.labelPlaceholder")}
className="pointer-events-auto h-7 w-32 max-w-[40%] rounded-md border border-input map-glass px-2 text-center text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none"
/>
</div>
);
}
interface MapGridProps {
/** The primary map pane (MapCanvas plus its overlays), rendered in cell 0. */
children: ReactNode;
}
/**
* Lays out the workspace's map panes.
*
* With a single pane (the default) it renders the primary map untouched, so the
* normal single-map DOM and behavior are unchanged. With a larger grid it tiles
* the primary map plus one {@link SecondaryMapCanvas} per `secondaryMapViews`
* entry into a CSS grid. Every pane shares the primary's basemap and layers;
* each secondary pane carries a layer-visibility toggle so it can show a
* different subset of the shared layers, plus a button to drop the pane. Camera
* sync between panes is handled inside the canvases (via the shared global
* `mapView`); this component only owns layout and chrome.
*/
export function MapGrid({ children }: MapGridProps) {
const { t } = useTranslation();
const rows = useAppStore((s) => s.mapLayout.rows);
const cols = useAppStore((s) => s.mapLayout.cols);
const secondaryMapViews = useAppStore((s) => s.secondaryMapViews);
const primaryMapLabel = useAppStore((s) => s.primaryMapLabel);
const setPrimaryMapLabel = useAppStore((s) => s.setPrimaryMapLabel);
const cesiumToken = useCesiumIonToken();
if (rows * cols <= 1) {
return <>{children}</>;
}
return (
<div
className="grid h-full w-full gap-0.5 bg-border"
style={{
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
}}
data-testid="map-grid"
>
<div className="relative isolate min-h-0 min-w-0 overflow-hidden bg-background">
{children}
<PaneLabel
value={primaryMapLabel}
onChange={setPrimaryMapLabel}
ariaLabel={t("mapGrid.labelLabel", { number: 1 })}
/>
</div>
{secondaryMapViews.map((pane, index) => (
<SecondaryMapPane key={pane.id} viewId={pane.id} index={index} cesiumToken={cesiumToken} />
))}
</div>
);
}
interface SecondaryMapPaneProps {
viewId: string;
/** Zero-based index among secondary panes, shown in the pane label. */
index: number;
/** Current Cesium Ion token; when absent the 3D-globe view is not offered. */
cesiumToken?: string;
}
function SecondaryMapPane({ viewId, index, cesiumToken }: SecondaryMapPaneProps) {
const { t } = useTranslation();
const cesiumAvailable = Boolean(cesiumToken);
const removeSecondaryMapView = useAppStore((s) => s.removeSecondaryMapView);
const setSecondaryMapLabel = useAppStore((s) => s.setSecondaryMapLabel);
const setSecondaryViewKind = useAppStore((s) => s.setSecondaryViewKind);
const label = useAppStore((s) => s.secondaryMapViews.find((p) => p.id === viewId)?.label ?? "");
// Absent viewKind means the default 2D map (back-compat with older panes).
// Only honor a 3D pane when Cesium is actually available (a token is present);
// otherwise a project saved with a globe pane silently opens as the 2D map.
const wantsCesium = useAppStore(
(s) => s.secondaryMapViews.find((p) => p.id === viewId)?.viewKind === "cesium",
);
const is3d = cesiumAvailable && wantsCesium;
return (
<div className="relative isolate min-h-0 min-w-0 overflow-hidden bg-background">
{is3d ? (
// Key on the token so changing the Cesium Ion token in Settings remounts
// the globe: `Cesium.Ion.defaultAccessToken` is applied once at viewer
// creation, so without a remount a swapped (e.g. corrected) token would
// never take effect on an already-mounted pane.
<CesiumCanvas key={cesiumToken} viewId={viewId} ionToken={cesiumToken} />
) : (
<SecondaryMapCanvas viewId={viewId} />
)}
<PaneLabel
value={label}
onChange={(value) => setSecondaryMapLabel(viewId, value)}
ariaLabel={t("mapGrid.labelLabel", { number: index + 2 })}
/>
<div className="absolute left-2 top-2 z-10 flex items-center gap-1.5">
{/* Both the 2D map and the 3D globe render the shared layers, so the
per-pane layer-visibility toggle applies to either. */}
<PaneLayerToggle viewId={viewId} index={index} is3d={is3d} />
{/* The 2D/3D toggle only appears when Cesium is available (a token is
configured); otherwise the globe is not offered. */}
{cesiumAvailable ? (
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md border border-input map-glass text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
aria-label={
is3d
? t("mapGrid.show2d", { number: index + 2 })
: t("mapGrid.show3d", { number: index + 2 })
}
aria-pressed={is3d}
onClick={() => setSecondaryViewKind(viewId, is3d ? "maplibre" : "cesium")}
>
{is3d ? <MapIcon className="h-4 w-4" /> : <Globe className="h-4 w-4" />}
</button>
) : null}
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md border border-input map-glass text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
aria-label={t("mapGrid.removePane", { number: index + 2 })}
onClick={() => removeSecondaryMapView(viewId)}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
interface PaneLayerToggleProps {
viewId: string;
index: number;
/** When true this is a 3D-globe pane, so layers it can't render are flagged. */
is3d: boolean;
}
/**
* A dropdown of the shared layers with a checkbox each, controlling which layers
* are visible in this pane. A layer's checkbox reflects its effective visibility
* (the pane's override, or the primary map's visibility when not overridden). On
* a 3D-globe pane, layer kinds the globe cannot render are tagged "2D only".
*/
function PaneLayerToggle({ viewId, index, is3d }: PaneLayerToggleProps) {
const { t } = useTranslation();
const layers = useAppStore((s) => s.layers);
const layerVisibility = useAppStore(
(s) => s.secondaryMapViews.find((p) => p.id === viewId)?.layerVisibility,
);
const setSecondaryLayerVisibility = useAppStore((s) => s.setSecondaryLayerVisibility);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
// ghost rather than outline: outline carries its own bg-background,
// which sits in the same cascade layer as map-glass at equal
// specificity, so this button rendered opaque beside its glass
// siblings. ghost sets no base background, leaving map-glass to paint
// it, and still supplies hover:bg-accent. The border the outline
// variant would have given is restored explicitly to match the
// sibling controls.
variant="ghost"
size="sm"
className="h-7 gap-1.5 border border-input map-glass px-2 shadow-sm"
aria-label={t("mapGrid.layersLabel", { number: index + 2 })}
>
<Layers className="h-3.5 w-3.5" />
{t("mapGrid.layers")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-80 w-56 overflow-auto">
<DropdownMenuLabel>{t("mapGrid.layers")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{layers.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">{t("mapGrid.noLayers")}</div>
) : (
layers.map((layer) => {
const override = layerVisibility?.[layer.id];
const visible = override === undefined ? layer.visible : override;
const only2d = is3d && !isCesiumSupportedLayerType(layer);
return (
<DropdownMenuCheckboxItem
key={layer.id}
indicator="box"
checked={visible}
onCheckedChange={(checked: boolean) =>
setSecondaryLayerVisibility(viewId, layer.id, checked)
}
// Keep the menu open so several layers can be toggled at once.
onSelect={(event: Event) => event.preventDefault()}
>
<span className="truncate">{layer.name}</span>
{only2d ? (
<span className="ms-auto shrink-0 ps-2 text-xs text-muted-foreground">
{t("mapGrid.only2d")}
</span>
) : null}
</DropdownMenuCheckboxItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
);
}