@tpzdsp/next-toolkit 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/assets/styles/ol.css +11 -5
- package/src/map/LegendPanel.tsx +85 -0
- package/src/map/MapComponent.tsx +2 -10
- package/src/map/MapControlsOverlay.tsx +231 -0
- package/src/map/geocoder/Geocoder.tsx +2 -2
- package/src/map/index.ts +2 -3
- package/src/map/useKeyboardDrawing.ts +2 -0
- package/src/map/useVirtualCursor.ts +2 -0
- package/src/map/FullScreenControl.ts +0 -126
- package/src/map/LayerSwitcherControl.ts +0 -173
- package/src/map/createControlButton.ts +0 -72
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpzdsp/next-toolkit",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "A reusable React component library for Next.js applications",
|
|
5
|
+
"packageManager": "pnpm@11.5.3",
|
|
5
6
|
"engines": {
|
|
6
|
-
"node": "
|
|
7
|
-
"pnpm": "
|
|
7
|
+
"node": "24.12.0",
|
|
8
|
+
"pnpm": "11.5.3"
|
|
8
9
|
},
|
|
9
10
|
"type": "module",
|
|
10
11
|
"private": false,
|
package/src/assets/styles/ol.css
CHANGED
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
.ol-btn:focus-visible {
|
|
34
34
|
width: 40px !important;
|
|
35
35
|
height: 40px !important;
|
|
36
|
-
border: none !important;
|
|
37
36
|
outline: none !important;
|
|
37
|
+
border: 2px solid #0b0c0c !important;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
.ol-btn {
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
cursor: pointer;
|
|
48
48
|
transition:
|
|
49
49
|
box-shadow 0.2s,
|
|
50
|
-
background 0.2s
|
|
50
|
+
background 0.2s,
|
|
51
|
+
border-color 0.2s;
|
|
51
52
|
margin: 0;
|
|
52
53
|
padding: 0;
|
|
53
54
|
display: flex;
|
|
@@ -71,11 +72,16 @@
|
|
|
71
72
|
|
|
72
73
|
.ol-btn:focus,
|
|
73
74
|
.ol-btn:focus-visible {
|
|
74
|
-
|
|
75
|
+
border-color: var(--focus-outline-color) !important;
|
|
75
76
|
background: var(--focus-color) !important;
|
|
76
77
|
z-index: 2;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
.ol-btn:disabled {
|
|
81
|
+
opacity: 0.4;
|
|
82
|
+
cursor: not-allowed;
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
/* Universal OpenLayers control focus style for non-button elements */
|
|
80
86
|
#map:focus,
|
|
81
87
|
.ol-viewport:focus,
|
|
@@ -106,8 +112,8 @@
|
|
|
106
112
|
.ol-zoom button:focus-visible {
|
|
107
113
|
width: 40px !important;
|
|
108
114
|
height: 40px !important;
|
|
109
|
-
border: none !important;
|
|
110
115
|
outline: none !important;
|
|
116
|
+
border: 2px solid #0b0c0c !important;
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
.ol-zoom button {
|
|
@@ -136,7 +142,7 @@
|
|
|
136
142
|
|
|
137
143
|
.ol-zoom button:focus,
|
|
138
144
|
.ol-zoom button:focus-visible {
|
|
139
|
-
|
|
145
|
+
border-color: var(--focus-outline-color) !important;
|
|
140
146
|
background: var(--focus-color) !important;
|
|
141
147
|
z-index: 2;
|
|
142
148
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { createPortal } from 'react-dom';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../utils';
|
|
8
|
+
|
|
9
|
+
type LegendPanelProps = {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
buttonRect: DOMRect | null;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const LegendPanel = ({ isOpen, onClose, buttonRect, children }: LegendPanelProps) => {
|
|
17
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
18
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setIsMounted(true);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isOpen) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
30
|
+
if (e.key === 'Escape') {
|
|
31
|
+
onClose();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
36
|
+
|
|
37
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
38
|
+
}, [isOpen, onClose]);
|
|
39
|
+
|
|
40
|
+
if (!isMounted || !isOpen) {
|
|
41
|
+
return <></>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const top = buttonRect ? buttonRect.top : 200;
|
|
45
|
+
const right = buttonRect ? window.innerWidth - buttonRect.left + 8 : 96;
|
|
46
|
+
const maxHeight = buttonRect
|
|
47
|
+
? `${window.innerHeight - buttonRect.top - 24}px`
|
|
48
|
+
: 'calc(100vh - 224px)';
|
|
49
|
+
|
|
50
|
+
return createPortal(
|
|
51
|
+
<div
|
|
52
|
+
ref={panelRef}
|
|
53
|
+
role="dialog"
|
|
54
|
+
aria-modal="true"
|
|
55
|
+
aria-label="Map legend"
|
|
56
|
+
style={{ top, right, maxHeight }}
|
|
57
|
+
className={cn(
|
|
58
|
+
'fixed bg-white rounded-lg shadow-lg border border-gray-200',
|
|
59
|
+
'flex flex-col',
|
|
60
|
+
'p-4 gap-3',
|
|
61
|
+
'w-[220px]',
|
|
62
|
+
'z-[9999]',
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex items-center justify-between flex-shrink-0">
|
|
66
|
+
<h2 className="text-sm font-semibold text-gray-800">Legend</h2>
|
|
67
|
+
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={onClose}
|
|
71
|
+
aria-label="Close legend"
|
|
72
|
+
className="w-8 h-8 flex items-center justify-center rounded text-gray-600 hover:bg-focus
|
|
73
|
+
focus:outline focus:outline-2 focus:outline-focus focus:bg-focus transition-colors"
|
|
74
|
+
>
|
|
75
|
+
<span className="text-xl leading-none" aria-hidden="true">
|
|
76
|
+
×
|
|
77
|
+
</span>
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="overflow-y-auto">{children}</div>
|
|
82
|
+
</div>,
|
|
83
|
+
document.body,
|
|
84
|
+
);
|
|
85
|
+
};
|
package/src/map/MapComponent.tsx
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import { Feature, Map, Overlay, View } from 'ol';
|
|
6
|
-
import { Attribution, ScaleLine
|
|
6
|
+
import { Attribution, ScaleLine } from 'ol/control';
|
|
7
7
|
import { fromLonLat } from 'ol/proj';
|
|
8
8
|
|
|
9
9
|
import { initializeBasemapLayers } from './basemaps';
|
|
10
|
-
import { FullScreenControl } from './FullScreenControl';
|
|
11
|
-
import { LayerSwitcherControl } from './LayerSwitcherControl';
|
|
12
10
|
import { useMap } from './MapContext';
|
|
13
11
|
import { Popup } from './Popup';
|
|
14
12
|
import { getPopupPositionClass, LAYER_NAMES } from './utils';
|
|
@@ -81,15 +79,9 @@ const MapComponentBase = ({ osMapsApiKey, basePath, children }: MapComponentProp
|
|
|
81
79
|
return;
|
|
82
80
|
}
|
|
83
81
|
|
|
84
|
-
// Create controls individually
|
|
85
|
-
const mapZoom = new Zoom();
|
|
86
82
|
const scaleLine = new ScaleLine({ units: 'metric' });
|
|
87
83
|
const attribution = new Attribution();
|
|
88
|
-
const
|
|
89
|
-
const fullScreenControl = new FullScreenControl();
|
|
90
|
-
|
|
91
|
-
// Add controls in the desired order
|
|
92
|
-
const controls = [mapZoom, layerSwitcher, fullScreenControl, scaleLine, attribution];
|
|
84
|
+
const controls = [scaleLine, attribution];
|
|
93
85
|
|
|
94
86
|
const newMap = new Map({
|
|
95
87
|
target,
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
import BaseLayer from 'ol/layer/Base';
|
|
6
|
+
import { LuLayers, LuList, LuMaximize2, LuMinus, LuMinimize2, LuPlus } from 'react-icons/lu';
|
|
7
|
+
|
|
8
|
+
import { LayerSwitcherPanel } from './LayerSwitcherPanel';
|
|
9
|
+
import { LegendPanel } from './LegendPanel';
|
|
10
|
+
import { useMap } from './MapContext';
|
|
11
|
+
|
|
12
|
+
type MapControlsOverlayProps = {
|
|
13
|
+
mapContainerRef: RefObject<HTMLDivElement | null>;
|
|
14
|
+
isLegendVisible?: boolean;
|
|
15
|
+
isLegendEnabled?: boolean;
|
|
16
|
+
legendContent?: ReactNode;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ControlButtonProps = {
|
|
20
|
+
ariaLabel: string;
|
|
21
|
+
title: string;
|
|
22
|
+
icon: ReactNode;
|
|
23
|
+
onClick: () => void;
|
|
24
|
+
isPressed?: boolean;
|
|
25
|
+
isDisabled?: boolean;
|
|
26
|
+
hasPopup?: boolean;
|
|
27
|
+
isExpanded?: boolean;
|
|
28
|
+
buttonRef?: RefObject<HTMLButtonElement | null>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ControlButton = ({
|
|
32
|
+
ariaLabel,
|
|
33
|
+
title,
|
|
34
|
+
icon,
|
|
35
|
+
onClick,
|
|
36
|
+
isPressed,
|
|
37
|
+
isDisabled = false,
|
|
38
|
+
hasPopup,
|
|
39
|
+
isExpanded,
|
|
40
|
+
buttonRef,
|
|
41
|
+
}: ControlButtonProps) => (
|
|
42
|
+
<button
|
|
43
|
+
ref={buttonRef}
|
|
44
|
+
type="button"
|
|
45
|
+
className="ol-btn"
|
|
46
|
+
aria-label={ariaLabel}
|
|
47
|
+
title={title}
|
|
48
|
+
onClick={onClick}
|
|
49
|
+
disabled={isDisabled}
|
|
50
|
+
aria-pressed={isPressed}
|
|
51
|
+
aria-haspopup={hasPopup ? 'dialog' : undefined}
|
|
52
|
+
aria-expanded={isExpanded}
|
|
53
|
+
>
|
|
54
|
+
<span aria-hidden="true">{icon}</span>
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const MapControlsOverlay = ({
|
|
59
|
+
mapContainerRef,
|
|
60
|
+
isLegendVisible = false,
|
|
61
|
+
isLegendEnabled = false,
|
|
62
|
+
legendContent,
|
|
63
|
+
}: MapControlsOverlayProps) => {
|
|
64
|
+
const { map, getLayers } = useMap();
|
|
65
|
+
const [isFullScreen, setIsFullScreen] = useState(false);
|
|
66
|
+
const [isLayerSwitcherOpen, setIsLayerSwitcherOpen] = useState(false);
|
|
67
|
+
const [layerSwitcherButtonRect, setLayerSwitcherButtonRect] = useState<DOMRect | null>(null);
|
|
68
|
+
const layerSwitcherButtonRef = useRef<HTMLButtonElement>(null);
|
|
69
|
+
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
|
70
|
+
const [legendButtonRect, setLegendButtonRect] = useState<DOMRect | null>(null);
|
|
71
|
+
const legendButtonRef = useRef<HTMLButtonElement>(null);
|
|
72
|
+
|
|
73
|
+
const basemapLayers = useMemo(
|
|
74
|
+
() => (getLayers()?.filter((l: BaseLayer) => l.get('basemap') === true) ?? []) as BaseLayer[],
|
|
75
|
+
[getLayers],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const activeLayerName =
|
|
79
|
+
(basemapLayers.find((l: BaseLayer) => l.getVisible())?.get('name') as string | undefined) ??
|
|
80
|
+
null;
|
|
81
|
+
|
|
82
|
+
const handleFullScreen = useCallback(() => {
|
|
83
|
+
const container = mapContainerRef.current;
|
|
84
|
+
|
|
85
|
+
if (!container) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const entering = !isFullScreen;
|
|
90
|
+
|
|
91
|
+
setIsFullScreen(entering);
|
|
92
|
+
container.classList.toggle('map-fullscreen', entering);
|
|
93
|
+
|
|
94
|
+
if (map) {
|
|
95
|
+
setTimeout(() => map.updateSize(), 300);
|
|
96
|
+
}
|
|
97
|
+
}, [isFullScreen, map, mapContainerRef]);
|
|
98
|
+
|
|
99
|
+
const handleLayerSwitcherToggle = useCallback(() => {
|
|
100
|
+
const rect = layerSwitcherButtonRef.current?.getBoundingClientRect() ?? null;
|
|
101
|
+
|
|
102
|
+
setLayerSwitcherButtonRect(rect);
|
|
103
|
+
setIsLayerSwitcherOpen((prev) => !prev);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const handleLayerSwitcherClose = useCallback(() => {
|
|
107
|
+
setIsLayerSwitcherOpen(false);
|
|
108
|
+
layerSwitcherButtonRef.current?.focus();
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const handleLegendToggle = useCallback(() => {
|
|
112
|
+
const rect = legendButtonRef.current?.getBoundingClientRect() ?? null;
|
|
113
|
+
|
|
114
|
+
setLegendButtonRect(rect);
|
|
115
|
+
setIsLegendOpen((prev) => !prev);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const handleLegendClose = useCallback(() => {
|
|
119
|
+
setIsLegendOpen(false);
|
|
120
|
+
legendButtonRef.current?.focus();
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const handleSelectLayer = useCallback(
|
|
124
|
+
(layerName: string) => {
|
|
125
|
+
const current = basemapLayers.find((l: BaseLayer) => l.getVisible());
|
|
126
|
+
const next = basemapLayers.find((l: BaseLayer) => l.get('name') === layerName);
|
|
127
|
+
|
|
128
|
+
current?.setVisible(false);
|
|
129
|
+
next?.setVisible(true);
|
|
130
|
+
},
|
|
131
|
+
[basemapLayers],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const handleZoomIn = useCallback(() => {
|
|
135
|
+
if (!map) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const view = map.getView();
|
|
140
|
+
const zoom = view.getZoom();
|
|
141
|
+
|
|
142
|
+
if (zoom !== undefined) {
|
|
143
|
+
view.animate({ zoom: zoom + 1, duration: 200 });
|
|
144
|
+
}
|
|
145
|
+
}, [map]);
|
|
146
|
+
|
|
147
|
+
const handleZoomOut = useCallback(() => {
|
|
148
|
+
if (!map) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const view = map.getView();
|
|
153
|
+
const zoom = view.getZoom();
|
|
154
|
+
|
|
155
|
+
if (zoom !== undefined) {
|
|
156
|
+
view.animate({ zoom: zoom - 1, duration: 200 });
|
|
157
|
+
}
|
|
158
|
+
}, [map]);
|
|
159
|
+
|
|
160
|
+
const fullScreenLabel = isFullScreen ? 'Exit full screen' : 'Toggle full screen';
|
|
161
|
+
const legendLabel = isLegendOpen ? 'Hide legend' : 'Show legend';
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<>
|
|
165
|
+
<div className="flex flex-col" role="group" aria-label="Map controls">
|
|
166
|
+
<ControlButton
|
|
167
|
+
ariaLabel={fullScreenLabel}
|
|
168
|
+
title={fullScreenLabel}
|
|
169
|
+
icon={isFullScreen ? <LuMinimize2 size={20} /> : <LuMaximize2 size={20} />}
|
|
170
|
+
onClick={handleFullScreen}
|
|
171
|
+
/>
|
|
172
|
+
|
|
173
|
+
<ControlButton
|
|
174
|
+
buttonRef={layerSwitcherButtonRef}
|
|
175
|
+
ariaLabel="Toggle basemap selector"
|
|
176
|
+
title="Basemap selector"
|
|
177
|
+
icon={<LuLayers size={20} />}
|
|
178
|
+
onClick={handleLayerSwitcherToggle}
|
|
179
|
+
hasPopup
|
|
180
|
+
isExpanded={isLayerSwitcherOpen}
|
|
181
|
+
/>
|
|
182
|
+
|
|
183
|
+
<ControlButton
|
|
184
|
+
ariaLabel="Zoom in"
|
|
185
|
+
title="Zoom in"
|
|
186
|
+
icon={<LuPlus size={20} />}
|
|
187
|
+
onClick={handleZoomIn}
|
|
188
|
+
/>
|
|
189
|
+
|
|
190
|
+
<ControlButton
|
|
191
|
+
ariaLabel="Zoom out"
|
|
192
|
+
title="Zoom out"
|
|
193
|
+
icon={<LuMinus size={20} />}
|
|
194
|
+
onClick={handleZoomOut}
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
{isLegendVisible ? (
|
|
198
|
+
<ControlButton
|
|
199
|
+
buttonRef={legendButtonRef}
|
|
200
|
+
ariaLabel={legendLabel}
|
|
201
|
+
title={legendLabel}
|
|
202
|
+
icon={<LuList size={20} />}
|
|
203
|
+
onClick={handleLegendToggle}
|
|
204
|
+
hasPopup
|
|
205
|
+
isExpanded={isLegendOpen}
|
|
206
|
+
isDisabled={!isLegendEnabled}
|
|
207
|
+
/>
|
|
208
|
+
) : null}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<LayerSwitcherPanel
|
|
212
|
+
isOpen={isLayerSwitcherOpen}
|
|
213
|
+
onClose={handleLayerSwitcherClose}
|
|
214
|
+
layers={basemapLayers}
|
|
215
|
+
activeLayerName={activeLayerName}
|
|
216
|
+
onSelectLayer={handleSelectLayer}
|
|
217
|
+
buttonRect={layerSwitcherButtonRect}
|
|
218
|
+
/>
|
|
219
|
+
|
|
220
|
+
{isLegendVisible ? (
|
|
221
|
+
<LegendPanel
|
|
222
|
+
isOpen={isLegendOpen}
|
|
223
|
+
onClose={handleLegendClose}
|
|
224
|
+
buttonRect={legendButtonRect}
|
|
225
|
+
>
|
|
226
|
+
{legendContent}
|
|
227
|
+
</LegendPanel>
|
|
228
|
+
) : null}
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
};
|
|
@@ -211,8 +211,8 @@ export const Geocoder = ({
|
|
|
211
211
|
</label>
|
|
212
212
|
|
|
213
213
|
<div
|
|
214
|
-
className="relative bg-white rounded shadow-lg transition-all
|
|
215
|
-
focus-within:ring-2 focus-within:ring-focus"
|
|
214
|
+
className="relative bg-white rounded shadow-lg border-2 border-[#0b0c0c] transition-all
|
|
215
|
+
duration-200 focus-within:ring-2 focus-within:ring-focus"
|
|
216
216
|
>
|
|
217
217
|
<IoSearch
|
|
218
218
|
size={20}
|
package/src/map/index.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export * from './basemaps';
|
|
2
2
|
export * from './geocoder/index';
|
|
3
3
|
export * from './geometries';
|
|
4
|
-
export * from './createControlButton';
|
|
5
|
-
export * from './LayerSwitcherControl';
|
|
6
4
|
export * from './LayerSwitcherPanel';
|
|
7
|
-
export * from './
|
|
5
|
+
export * from './LegendPanel';
|
|
6
|
+
export * from './MapControlsOverlay';
|
|
8
7
|
export * from './utils';
|
|
9
8
|
export * from './MapComponent';
|
|
10
9
|
export * from './MapContext';
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
/* eslint-disable no-restricted-syntax */
|
|
2
|
-
import { Map } from 'ol';
|
|
3
|
-
import { Control } from 'ol/control';
|
|
4
|
-
import type { Options as ControlOptions } from 'ol/control/Control';
|
|
5
|
-
|
|
6
|
-
import { CONTROL_ICONS, createControlButton } from './createControlButton';
|
|
7
|
-
|
|
8
|
-
const FULL_SCREEN_CLASS = 'map-fullscreen';
|
|
9
|
-
const ARIA_LABEL_TOGGLE = 'Toggle full screen';
|
|
10
|
-
const ARIA_LABEL_EXIT = 'Exit full screen';
|
|
11
|
-
|
|
12
|
-
export class FullScreenControl extends Control {
|
|
13
|
-
private isFullScreen = false;
|
|
14
|
-
private readonly button!: HTMLButtonElement;
|
|
15
|
-
liveRegion!: HTMLElement;
|
|
16
|
-
private mapContainer: HTMLElement | null = null;
|
|
17
|
-
|
|
18
|
-
constructor(options?: ControlOptions) {
|
|
19
|
-
const button = createControlButton({
|
|
20
|
-
ariaLabel: ARIA_LABEL_TOGGLE,
|
|
21
|
-
title: ARIA_LABEL_TOGGLE,
|
|
22
|
-
iconSvg: CONTROL_ICONS.EXPAND,
|
|
23
|
-
className: 'ol-fullscreen-toggle',
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
const element = document.createElement('div');
|
|
27
|
-
|
|
28
|
-
element.className = 'ol-fullscreen ol-unselectable ol-control';
|
|
29
|
-
element.appendChild(button);
|
|
30
|
-
|
|
31
|
-
// Create a live region for screen reader announcements
|
|
32
|
-
const liveRegion = document.createElement('div');
|
|
33
|
-
|
|
34
|
-
liveRegion.setAttribute('aria-live', 'polite');
|
|
35
|
-
liveRegion.setAttribute('role', 'status');
|
|
36
|
-
liveRegion.className = 'ol-fullscreen-live-region';
|
|
37
|
-
liveRegion.style.position = 'absolute';
|
|
38
|
-
liveRegion.style.width = '1px';
|
|
39
|
-
liveRegion.style.height = '1px';
|
|
40
|
-
liveRegion.style.margin = '-1px';
|
|
41
|
-
liveRegion.style.border = '0';
|
|
42
|
-
liveRegion.style.padding = '0';
|
|
43
|
-
liveRegion.style.overflow = 'hidden';
|
|
44
|
-
liveRegion.style.clipPath = 'inset(50%)';
|
|
45
|
-
element.appendChild(liveRegion);
|
|
46
|
-
|
|
47
|
-
super({
|
|
48
|
-
element,
|
|
49
|
-
target: options?.target,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
this.button = button;
|
|
53
|
-
this.liveRegion = liveRegion;
|
|
54
|
-
|
|
55
|
-
button.addEventListener('click', this.toggleFullScreen, false);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
setMap(map: Map | null) {
|
|
59
|
-
super.setMap(map);
|
|
60
|
-
|
|
61
|
-
if (map) {
|
|
62
|
-
// Find the map container (the target element)
|
|
63
|
-
this.mapContainer = map.getTargetElement();
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
private readonly updateButtonIcon = (isFullScreen: boolean): void => {
|
|
68
|
-
// Update the SVG icon content
|
|
69
|
-
const svg = this.button.querySelector('svg');
|
|
70
|
-
|
|
71
|
-
if (svg) {
|
|
72
|
-
svg.innerHTML = isFullScreen ? CONTROL_ICONS.COLLAPSE : CONTROL_ICONS.EXPAND;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
toggleFullScreen = (): void => {
|
|
77
|
-
if (!this.mapContainer) {
|
|
78
|
-
console.warn('Map container not found');
|
|
79
|
-
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
this.isFullScreen = !this.isFullScreen;
|
|
84
|
-
|
|
85
|
-
if (this.isFullScreen) {
|
|
86
|
-
// Enter full screen mode
|
|
87
|
-
this.mapContainer.classList.add(FULL_SCREEN_CLASS);
|
|
88
|
-
this.updateButtonIcon(true);
|
|
89
|
-
this.button.setAttribute('aria-label', ARIA_LABEL_EXIT);
|
|
90
|
-
this.button.setAttribute('title', ARIA_LABEL_EXIT);
|
|
91
|
-
this.liveRegion.textContent = 'Full screen mode enabled';
|
|
92
|
-
} else {
|
|
93
|
-
// Exit full screen mode
|
|
94
|
-
this.mapContainer.classList.remove(FULL_SCREEN_CLASS);
|
|
95
|
-
this.updateButtonIcon(false);
|
|
96
|
-
this.button.setAttribute('aria-label', ARIA_LABEL_TOGGLE);
|
|
97
|
-
this.button.setAttribute('title', ARIA_LABEL_TOGGLE);
|
|
98
|
-
this.liveRegion.textContent = 'Full screen mode disabled';
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Force map to update its size after the transition
|
|
102
|
-
const map = this.getMap();
|
|
103
|
-
|
|
104
|
-
if (map) {
|
|
105
|
-
// Wait for CSS transition to complete before updating size
|
|
106
|
-
setTimeout(() => {
|
|
107
|
-
map.updateSize();
|
|
108
|
-
}, 300);
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
// Public method to exit full screen programmatically
|
|
113
|
-
exitFullScreen(): void {
|
|
114
|
-
if (this.isFullScreen) {
|
|
115
|
-
this.toggleFullScreen();
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
dispose(): void {
|
|
120
|
-
if (this.button) {
|
|
121
|
-
this.button.removeEventListener('click', this.toggleFullScreen);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
super.dispose?.();
|
|
125
|
-
}
|
|
126
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/* eslint-disable no-restricted-syntax */
|
|
2
|
-
import { createElement } from 'react';
|
|
3
|
-
|
|
4
|
-
import { Map } from 'ol';
|
|
5
|
-
import { Control } from 'ol/control';
|
|
6
|
-
import type { Options as ControlOptions } from 'ol/control/Control';
|
|
7
|
-
import BaseLayer from 'ol/layer/Base';
|
|
8
|
-
import { createRoot } from 'react-dom/client';
|
|
9
|
-
import type { Root } from 'react-dom/client';
|
|
10
|
-
|
|
11
|
-
import { createControlButton } from './createControlButton';
|
|
12
|
-
import { LayerSwitcherPanel } from './LayerSwitcherPanel';
|
|
13
|
-
|
|
14
|
-
const MAP_LOGO =
|
|
15
|
-
'<path d="M20.5 3l-5.7 2.1-6-2L3.5 5c-.3.1-.5.5-.5.8v15.2c0 .3.2.6.5.7l5.7-2.1 6 2 5.3-1.9c.3-.1.5-.4.5-.7V3.8c0-.3-.2-.6-.5-.8zM10 5.2l4 1.3v12.3l-4-1.3V5.2zm-6 1.1l4-1.4v12.3l-4 1.4V6.3zm16 12.4l-4 1.4V7.8l4-1.4v12.3z"/>';
|
|
16
|
-
|
|
17
|
-
export class LayerSwitcherControl extends Control {
|
|
18
|
-
map!: Map;
|
|
19
|
-
liveRegion!: HTMLElement;
|
|
20
|
-
isOpen = false;
|
|
21
|
-
private readonly layers: BaseLayer[];
|
|
22
|
-
private reactRoot: Root | null = null;
|
|
23
|
-
private reactContainer: HTMLDivElement | null = null;
|
|
24
|
-
private readonly button!: HTMLButtonElement;
|
|
25
|
-
private isClosingFromPanel = false;
|
|
26
|
-
|
|
27
|
-
constructor(layers: BaseLayer[], options?: ControlOptions) {
|
|
28
|
-
const button = createControlButton({
|
|
29
|
-
ariaLabel: 'Toggle basemap selector',
|
|
30
|
-
title: 'Basemap selector',
|
|
31
|
-
iconSvg: MAP_LOGO,
|
|
32
|
-
hasPopup: true,
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const element = document.createElement('div');
|
|
36
|
-
|
|
37
|
-
element.className = 'ol-layer-switcher ol-unselectable ol-control';
|
|
38
|
-
element.appendChild(button);
|
|
39
|
-
|
|
40
|
-
// Create a live region for screen reader announcements
|
|
41
|
-
const liveRegion = document.createElement('div');
|
|
42
|
-
|
|
43
|
-
liveRegion.setAttribute('aria-live', 'polite');
|
|
44
|
-
liveRegion.setAttribute('role', 'status');
|
|
45
|
-
liveRegion.className = 'ol-layer-switcher-live-region';
|
|
46
|
-
liveRegion.style.position = 'absolute';
|
|
47
|
-
liveRegion.style.width = '1px';
|
|
48
|
-
liveRegion.style.height = '1px';
|
|
49
|
-
liveRegion.style.margin = '-1px';
|
|
50
|
-
liveRegion.style.border = '0';
|
|
51
|
-
liveRegion.style.padding = '0';
|
|
52
|
-
liveRegion.style.overflow = 'hidden';
|
|
53
|
-
liveRegion.style.clipPath = 'inset(50%)';
|
|
54
|
-
element.appendChild(liveRegion);
|
|
55
|
-
|
|
56
|
-
super({
|
|
57
|
-
element,
|
|
58
|
-
target: options?.target,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
this.layers = layers;
|
|
62
|
-
this.button = button;
|
|
63
|
-
this.liveRegion = liveRegion;
|
|
64
|
-
|
|
65
|
-
// Create React container
|
|
66
|
-
this.reactContainer = document.createElement('div');
|
|
67
|
-
this.reactRoot = createRoot(this.reactContainer);
|
|
68
|
-
|
|
69
|
-
button.addEventListener('click', this.toggleLayerSwitcher, false);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
private getCurrentBasemap(): BaseLayer | undefined {
|
|
73
|
-
return this.layers.find((layer: BaseLayer) => layer.getVisible() === true);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private getBasemapByName(layerName: string): BaseLayer | undefined {
|
|
77
|
-
return this.layers.find((layer: BaseLayer) => layer.get('name') === layerName);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
setMap(map: Map | null) {
|
|
81
|
-
super.setMap(map);
|
|
82
|
-
|
|
83
|
-
if (map) {
|
|
84
|
-
this.map = map;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
toggleLayerSwitcher = (): void => {
|
|
89
|
-
// Prevent double-toggle when panel closes via focus trap
|
|
90
|
-
if (this.isClosingFromPanel) {
|
|
91
|
-
this.isClosingFromPanel = false;
|
|
92
|
-
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
this.isOpen = !this.isOpen;
|
|
97
|
-
this.button.setAttribute('aria-expanded', this.isOpen.toString());
|
|
98
|
-
|
|
99
|
-
if (this.isOpen) {
|
|
100
|
-
this.liveRegion.textContent = 'Basemap switcher opened';
|
|
101
|
-
this.renderPanel();
|
|
102
|
-
} else {
|
|
103
|
-
this.liveRegion.textContent = 'Basemap switcher closed';
|
|
104
|
-
this.closePanel();
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
private readonly handlePanelClose = (): void => {
|
|
109
|
-
if (this.isOpen) {
|
|
110
|
-
this.isClosingFromPanel = true;
|
|
111
|
-
this.isOpen = false;
|
|
112
|
-
this.button.setAttribute('aria-expanded', 'false');
|
|
113
|
-
this.liveRegion.textContent = 'Basemap switcher closed';
|
|
114
|
-
this.closePanel();
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
private readonly renderPanel = (): void => {
|
|
119
|
-
if (!this.reactRoot || !this.reactContainer) {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const buttonRect = this.button.getBoundingClientRect();
|
|
124
|
-
const activeLayer = this.getCurrentBasemap();
|
|
125
|
-
const activeLayerName = activeLayer ? (activeLayer.get('name') as string) : '';
|
|
126
|
-
|
|
127
|
-
this.reactRoot.render(
|
|
128
|
-
createElement(LayerSwitcherPanel, {
|
|
129
|
-
isOpen: this.isOpen,
|
|
130
|
-
onClose: this.handlePanelClose,
|
|
131
|
-
layers: this.layers,
|
|
132
|
-
activeLayerName,
|
|
133
|
-
onSelectLayer: this.selectBasemap,
|
|
134
|
-
buttonRect,
|
|
135
|
-
}),
|
|
136
|
-
);
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
private readonly closePanel = (): void => {
|
|
140
|
-
if (!this.reactRoot) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
this.reactRoot.render(null);
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
selectBasemap = (layerName: string): void => {
|
|
148
|
-
const currentBasemap = this.getCurrentBasemap();
|
|
149
|
-
const newBasemap = this.getBasemapByName(layerName);
|
|
150
|
-
|
|
151
|
-
currentBasemap?.setVisible(false);
|
|
152
|
-
newBasemap?.setVisible(true);
|
|
153
|
-
|
|
154
|
-
this.liveRegion.textContent = `${layerName} basemap selected`;
|
|
155
|
-
|
|
156
|
-
// Re-render to update active state
|
|
157
|
-
if (this.isOpen) {
|
|
158
|
-
this.renderPanel();
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
dispose(): void {
|
|
163
|
-
// Clean up React root
|
|
164
|
-
if (this.reactRoot) {
|
|
165
|
-
this.reactRoot.unmount();
|
|
166
|
-
this.reactRoot = null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
this.reactContainer = null;
|
|
170
|
-
|
|
171
|
-
super.dispose?.();
|
|
172
|
-
}
|
|
173
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Utility function to create a standardized OpenLayers control button
|
|
3
|
-
* with consistent styling and accessibility attributes.
|
|
4
|
-
*
|
|
5
|
-
* @example
|
|
6
|
-
* const button = createControlButton({
|
|
7
|
-
* ariaLabel: 'Toggle feature',
|
|
8
|
-
* title: 'Feature control',
|
|
9
|
-
* iconSvg: '<path d="M..." />',
|
|
10
|
-
* });
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
// Common SVG icons for map controls
|
|
14
|
-
export const CONTROL_ICONS = {
|
|
15
|
-
PLUS: '<path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
|
16
|
-
MINUS: '<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
|
17
|
-
MAP_LAYERS:
|
|
18
|
-
'<path d="M20.5 3l-5.7 2.1-6-2L3.5 5c-.3.1-.5.5-.5.8v15.2c0 .3.2.6.5.7l5.7-2.1 6 2 5.3-1.9c.3-.1.5-.4.5-.7V3.8c0-.3-.2-.6-.5-.8zM10 5.2l4 1.3v12.3l-4-1.3V5.2zm-6 1.1l4-1.4v12.3l-4 1.4V6.3zm16 12.4l-4 1.4V7.8l4-1.4v12.3z"/>',
|
|
19
|
-
EXPAND:
|
|
20
|
-
'<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
21
|
-
COLLAPSE:
|
|
22
|
-
'<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export type ControlButtonOptions = {
|
|
26
|
-
/** Accessible label for screen readers */
|
|
27
|
-
ariaLabel: string;
|
|
28
|
-
/** Tooltip text shown on hover */
|
|
29
|
-
title: string;
|
|
30
|
-
/** SVG path data or element for the button icon */
|
|
31
|
-
iconSvg: string;
|
|
32
|
-
/** Additional CSS classes to apply */
|
|
33
|
-
className?: string;
|
|
34
|
-
/** Button type, defaults to 'button' */
|
|
35
|
-
type?: 'button' | 'submit' | 'reset';
|
|
36
|
-
/** Whether this button controls a popup/dialog */
|
|
37
|
-
hasPopup?: boolean;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Creates a standardized control button with proper accessibility attributes
|
|
42
|
-
* and consistent styling via the `ol-btn` base class.
|
|
43
|
-
*/
|
|
44
|
-
export const createControlButton = (options: ControlButtonOptions): HTMLButtonElement => {
|
|
45
|
-
const { ariaLabel, title, iconSvg, className = '', type = 'button', hasPopup = false } = options;
|
|
46
|
-
|
|
47
|
-
const button = document.createElement('button');
|
|
48
|
-
|
|
49
|
-
button.setAttribute('aria-label', ariaLabel);
|
|
50
|
-
button.setAttribute('title', title);
|
|
51
|
-
button.type = type;
|
|
52
|
-
button.className = `ol-btn ${className}`.trim();
|
|
53
|
-
|
|
54
|
-
if (hasPopup) {
|
|
55
|
-
button.setAttribute('aria-haspopup', 'dialog');
|
|
56
|
-
button.setAttribute('aria-expanded', 'false');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Create SVG icon
|
|
60
|
-
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
61
|
-
|
|
62
|
-
icon.setAttribute('width', '20');
|
|
63
|
-
icon.setAttribute('height', '20');
|
|
64
|
-
icon.setAttribute('viewBox', '0 0 24 24');
|
|
65
|
-
icon.setAttribute('fill', 'currentColor');
|
|
66
|
-
icon.setAttribute('aria-hidden', 'true');
|
|
67
|
-
icon.innerHTML = iconSvg;
|
|
68
|
-
|
|
69
|
-
button.appendChild(icon);
|
|
70
|
-
|
|
71
|
-
return button;
|
|
72
|
-
};
|