gistda-sphere-react 1.0.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.
Files changed (41) hide show
  1. package/README.md +827 -0
  2. package/dist/index.d.mts +1081 -0
  3. package/dist/index.d.ts +1081 -0
  4. package/dist/index.js +2057 -0
  5. package/dist/index.mjs +2013 -0
  6. package/package.json +70 -0
  7. package/src/__tests__/Layer.test.tsx +133 -0
  8. package/src/__tests__/Marker.test.tsx +183 -0
  9. package/src/__tests__/SphereContext.test.tsx +120 -0
  10. package/src/__tests__/SphereMap.test.tsx +240 -0
  11. package/src/__tests__/geometry.test.tsx +454 -0
  12. package/src/__tests__/hooks.test.tsx +173 -0
  13. package/src/__tests__/setup.ts +204 -0
  14. package/src/__tests__/useMapControls.test.tsx +168 -0
  15. package/src/__tests__/useOverlays.test.tsx +265 -0
  16. package/src/__tests__/useRoute.test.tsx +219 -0
  17. package/src/__tests__/useSearch.test.tsx +205 -0
  18. package/src/__tests__/useTags.test.tsx +179 -0
  19. package/src/components/Circle.tsx +189 -0
  20. package/src/components/Dot.tsx +150 -0
  21. package/src/components/Layer.tsx +177 -0
  22. package/src/components/Marker.tsx +204 -0
  23. package/src/components/Polygon.tsx +223 -0
  24. package/src/components/Polyline.tsx +211 -0
  25. package/src/components/Popup.tsx +130 -0
  26. package/src/components/Rectangle.tsx +194 -0
  27. package/src/components/SphereMap.tsx +315 -0
  28. package/src/components/index.ts +18 -0
  29. package/src/context/MapContext.tsx +41 -0
  30. package/src/context/SphereContext.tsx +348 -0
  31. package/src/context/index.ts +15 -0
  32. package/src/hooks/index.ts +42 -0
  33. package/src/hooks/useMapEvent.ts +66 -0
  34. package/src/hooks/useOverlays.ts +278 -0
  35. package/src/hooks/useRoute.ts +232 -0
  36. package/src/hooks/useSearch.ts +143 -0
  37. package/src/hooks/useSphere.ts +18 -0
  38. package/src/hooks/useTags.ts +129 -0
  39. package/src/index.ts +124 -0
  40. package/src/types/index.ts +1 -0
  41. package/src/types/sphere.ts +671 -0
@@ -0,0 +1,348 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import type {
12
+ Bound,
13
+ FilterType,
14
+ FlyToOptions,
15
+ Location,
16
+ SphereMap,
17
+ SphereNamespace,
18
+ } from "../types";
19
+
20
+ type BuiltInLayer =
21
+ | "SIMPLE"
22
+ | "STREETS"
23
+ | "STREETS_NIGHT"
24
+ | "HYBRID"
25
+ | "TRAFFIC"
26
+ | "IMAGES"
27
+ | "PM25"
28
+ | "HOTSPOT"
29
+ | "FLOOD"
30
+ | "DROUGHT";
31
+
32
+ interface MapControls {
33
+ isReady: boolean;
34
+ goTo: (options: FlyToOptions, animate?: boolean) => void;
35
+ setCenter: (location: Location, animate?: boolean) => void;
36
+ setZoom: (zoom: number, animate?: boolean) => void;
37
+ setBound: (bound: Bound, options?: object) => void;
38
+ setRotate: (angle: number, animate?: boolean) => void;
39
+ setPitch: (angle: number) => void;
40
+ setFilter: (filter: FilterType | false) => void;
41
+ setLanguage: (language: string) => void;
42
+ setBaseLayer: (layer: BuiltInLayer) => void;
43
+ addLayer: (layer: BuiltInLayer) => void;
44
+ removeLayer: (layer: BuiltInLayer) => void;
45
+ resize: () => void;
46
+ repaint: () => void;
47
+ }
48
+
49
+ interface SphereContextValue {
50
+ isLoaded: boolean;
51
+ error: Error | null;
52
+ sphere: SphereNamespace | null;
53
+ apiKey: string;
54
+ map: SphereMap | null;
55
+ isMapReady: boolean;
56
+ controls: MapControls;
57
+ registerMap: (map: SphereMap) => void;
58
+ unregisterMap: () => void;
59
+ }
60
+
61
+ interface SphereProviderProps {
62
+ apiKey: string;
63
+ children: ReactNode;
64
+ scriptUrl?: string;
65
+ onLoad?: () => void;
66
+ onError?: (error: Error) => void;
67
+ }
68
+
69
+ const SphereContext = createContext<SphereContextValue | null>(null);
70
+
71
+ const SCRIPT_ID = "gistda-sphere-api-script";
72
+ let scriptLoadingPromise: Promise<void> | null = null;
73
+
74
+ function loadSphereScript(apiKey: string, customUrl?: string): Promise<void> {
75
+ if (scriptLoadingPromise) {
76
+ return scriptLoadingPromise;
77
+ }
78
+
79
+ const existingScript = document.getElementById(SCRIPT_ID);
80
+ if (existingScript) {
81
+ if (window.sphere) {
82
+ return Promise.resolve();
83
+ }
84
+ // Stale script element without a pending promise — remove and retry fresh
85
+ existingScript.remove();
86
+ }
87
+
88
+ scriptLoadingPromise = new Promise((resolve, reject) => {
89
+ const script = document.createElement("script");
90
+ script.id = SCRIPT_ID;
91
+ script.type = "text/javascript";
92
+ script.src =
93
+ customUrl || `https://api.sphere.gistda.or.th/map/?key=${apiKey}`;
94
+ script.async = true;
95
+
96
+ // setTimeout(0) defers the check so the browser has time to execute the loaded script
97
+ script.onload = () => {
98
+ setTimeout(() => {
99
+ if (window.sphere) {
100
+ resolve();
101
+ } else {
102
+ reject(
103
+ new Error("Sphere API loaded but window.sphere is not available")
104
+ );
105
+ }
106
+ }, 0);
107
+ };
108
+
109
+ script.onerror = () => {
110
+ script.remove();
111
+ scriptLoadingPromise = null;
112
+ reject(
113
+ new Error(
114
+ "Failed to load Sphere API script. This may be due to domain restrictions. " +
115
+ "Ensure your API key is registered for your domain at https://sphere.gistda.or.th/"
116
+ )
117
+ );
118
+ };
119
+
120
+ document.head.appendChild(script);
121
+ });
122
+
123
+ return scriptLoadingPromise;
124
+ }
125
+
126
+ export function SphereProvider({
127
+ apiKey,
128
+ children,
129
+ scriptUrl,
130
+ onLoad,
131
+ onError,
132
+ }: SphereProviderProps): ReactNode {
133
+ const [isLoaded, setIsLoaded] = useState(false);
134
+ const [error, setError] = useState<Error | null>(null);
135
+ const [sphere, setSphere] = useState<SphereNamespace | null>(null);
136
+ const [map, setMap] = useState<SphereMap | null>(null);
137
+ const [isMapReady, setIsMapReady] = useState(false);
138
+
139
+ const mapRef = useRef<SphereMap | null>(null);
140
+
141
+ const handleLoad = useCallback(() => {
142
+ setIsLoaded(true);
143
+ setSphere(window.sphere ?? null);
144
+ onLoad?.();
145
+ }, [onLoad]);
146
+
147
+ const handleError = useCallback(
148
+ (err: Error) => {
149
+ setError(err);
150
+ onError?.(err);
151
+ },
152
+ [onError]
153
+ );
154
+
155
+ useEffect(() => {
156
+ if (window.sphere) {
157
+ handleLoad();
158
+ return;
159
+ }
160
+
161
+ loadSphereScript(apiKey, scriptUrl).then(handleLoad).catch(handleError);
162
+ }, [apiKey, scriptUrl, handleLoad, handleError]);
163
+
164
+ const registerMap = useCallback((newMap: SphereMap) => {
165
+ mapRef.current = newMap;
166
+ setMap(newMap);
167
+ setIsMapReady(true);
168
+ }, []);
169
+
170
+ const unregisterMap = useCallback(() => {
171
+ mapRef.current = null;
172
+ setMap(null);
173
+ setIsMapReady(false);
174
+ }, []);
175
+
176
+ const goTo = useCallback((options: FlyToOptions, animate = true) => {
177
+ mapRef.current?.goTo(options, animate);
178
+ }, []);
179
+
180
+ const setCenter = useCallback((location: Location, animate = true) => {
181
+ mapRef.current?.location(location, animate);
182
+ }, []);
183
+
184
+ const setZoom = useCallback((zoom: number, animate = true) => {
185
+ mapRef.current?.zoom(zoom, animate);
186
+ }, []);
187
+
188
+ const setBound = useCallback((bound: Bound, options?: object) => {
189
+ mapRef.current?.bound(bound, options);
190
+ }, []);
191
+
192
+ const setRotate = useCallback((angle: number, animate = true) => {
193
+ mapRef.current?.rotate(angle, animate);
194
+ }, []);
195
+
196
+ const setPitch = useCallback((angle: number) => {
197
+ mapRef.current?.pitch(angle);
198
+ }, []);
199
+
200
+ const setFilter = useCallback((filter: FilterType | false) => {
201
+ if (mapRef.current && window.sphere) {
202
+ const filterValue =
203
+ filter === false ? false : window.sphere.Filter[filter];
204
+ mapRef.current.enableFilter(filterValue);
205
+ }
206
+ }, []);
207
+
208
+ const setLanguage = useCallback((language: string) => {
209
+ mapRef.current?.language(language);
210
+ }, []);
211
+
212
+ const resize = useCallback(() => {
213
+ mapRef.current?.resize();
214
+ }, []);
215
+
216
+ const repaint = useCallback(() => {
217
+ mapRef.current?.repaint();
218
+ }, []);
219
+
220
+ const setBaseLayer = useCallback((layer: BuiltInLayer) => {
221
+ const layerValue = window.sphere?.Layers?.[layer];
222
+ if (mapRef.current && layerValue) {
223
+ mapRef.current.Layers.setBase(layerValue);
224
+ }
225
+ }, []);
226
+
227
+ const addLayer = useCallback((layer: BuiltInLayer) => {
228
+ const layerValue = window.sphere?.Layers?.[layer];
229
+ if (mapRef.current && layerValue) {
230
+ mapRef.current.Layers.add(layerValue);
231
+ }
232
+ }, []);
233
+
234
+ const removeLayer = useCallback((layer: BuiltInLayer) => {
235
+ const layerValue = window.sphere?.Layers?.[layer];
236
+ if (mapRef.current && layerValue) {
237
+ mapRef.current.Layers.remove(layerValue);
238
+ }
239
+ }, []);
240
+
241
+ const controls = useMemo<MapControls>(
242
+ () => ({
243
+ isReady: isMapReady && sphere !== null,
244
+ goTo,
245
+ setCenter,
246
+ setZoom,
247
+ setBound,
248
+ setRotate,
249
+ setPitch,
250
+ setFilter,
251
+ setLanguage,
252
+ setBaseLayer,
253
+ addLayer,
254
+ removeLayer,
255
+ resize,
256
+ repaint,
257
+ }),
258
+ [
259
+ isMapReady,
260
+ sphere,
261
+ goTo,
262
+ setCenter,
263
+ setZoom,
264
+ setBound,
265
+ setRotate,
266
+ setPitch,
267
+ setFilter,
268
+ setLanguage,
269
+ setBaseLayer,
270
+ addLayer,
271
+ removeLayer,
272
+ resize,
273
+ repaint,
274
+ ]
275
+ );
276
+
277
+ const contextValue = useMemo<SphereContextValue>(
278
+ () => ({
279
+ isLoaded,
280
+ error,
281
+ sphere,
282
+ apiKey,
283
+ map,
284
+ isMapReady,
285
+ controls,
286
+ registerMap,
287
+ unregisterMap,
288
+ }),
289
+ [
290
+ isLoaded,
291
+ error,
292
+ sphere,
293
+ apiKey,
294
+ map,
295
+ isMapReady,
296
+ controls,
297
+ registerMap,
298
+ unregisterMap,
299
+ ]
300
+ );
301
+
302
+ return (
303
+ <SphereContext.Provider value={contextValue}>
304
+ {children}
305
+ </SphereContext.Provider>
306
+ );
307
+ }
308
+
309
+ export function useSphereContext(): SphereContextValue {
310
+ const context = useContext(SphereContext);
311
+
312
+ if (!context) {
313
+ throw new Error("useSphereContext must be used within a SphereProvider");
314
+ }
315
+
316
+ return context;
317
+ }
318
+
319
+ export function useSphere(): {
320
+ sphere: SphereNamespace | null;
321
+ isLoaded: boolean;
322
+ error: Error | null;
323
+ } {
324
+ const { sphere, isLoaded, error } = useSphereContext();
325
+ return { sphere, isLoaded, error };
326
+ }
327
+
328
+ export function useMap(): {
329
+ map: SphereMap | null;
330
+ sphere: SphereNamespace | null;
331
+ isReady: boolean;
332
+ } {
333
+ const { map, isMapReady, sphere } = useSphereContext();
334
+ return { map, sphere, isReady: isMapReady && sphere !== null };
335
+ }
336
+
337
+ export function useMapControls(): MapControls {
338
+ const { controls } = useSphereContext();
339
+ return controls;
340
+ }
341
+
342
+ export { SphereContext };
343
+ export type {
344
+ BuiltInLayer,
345
+ MapControls,
346
+ SphereContextValue,
347
+ SphereProviderProps,
348
+ };
@@ -0,0 +1,15 @@
1
+ export type { MapContextValue } from "./MapContext";
2
+ export { MapContext, MapProvider, useMapContext } from "./MapContext";
3
+ export type {
4
+ BuiltInLayer,
5
+ MapControls,
6
+ SphereContextValue,
7
+ SphereProviderProps,
8
+ } from "./SphereContext";
9
+ export {
10
+ SphereContext,
11
+ SphereProvider,
12
+ useMap,
13
+ useMapControls,
14
+ useSphereContext,
15
+ } from "./SphereContext";
@@ -0,0 +1,42 @@
1
+ export { useMap, useMapControls } from "../context/SphereContext";
2
+ export {
3
+ useMapClick,
4
+ useMapEvent,
5
+ useMapLocation,
6
+ useMapReady,
7
+ useMapZoom,
8
+ useOverlayClick,
9
+ } from "./useMapEvent";
10
+ export type {
11
+ BaseOverlay,
12
+ CircleData,
13
+ MarkerData,
14
+ OverlayInput,
15
+ PolygonData,
16
+ PolylineData,
17
+ UseOverlaysResult,
18
+ } from "./useOverlays";
19
+ export {
20
+ useCircles,
21
+ useMarkers,
22
+ useOverlays,
23
+ usePolygons,
24
+ usePolylines,
25
+ } from "./useOverlays";
26
+ export type { RouteGuideStep, UseRouteReturn } from "./useRoute";
27
+ export { useRoute } from "./useRoute";
28
+ export type {
29
+ AddressResult,
30
+ PoiResult,
31
+ SearchResult,
32
+ UseSearchReturn,
33
+ } from "./useSearch";
34
+ export { useSearch } from "./useSearch";
35
+ export { useSphere } from "./useSphere";
36
+ export type {
37
+ TagCategory,
38
+ TagDataFunction,
39
+ TagDefinition,
40
+ UseTagsReturn,
41
+ } from "./useTags";
42
+ export { TAG_CATEGORIES, useTags } from "./useTags";
@@ -0,0 +1,66 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useMapContext } from "../context/MapContext";
3
+ import { useSphereContext } from "../context/SphereContext";
4
+ import type {
5
+ EventHandler,
6
+ EventName,
7
+ Location,
8
+ SphereOverlay,
9
+ } from "../types";
10
+
11
+ export function useMapEvent<T = unknown>(
12
+ eventName: EventName,
13
+ handler: EventHandler<T>
14
+ ): void {
15
+ const { map, isReady } = useMapContext();
16
+ const { sphere } = useSphereContext();
17
+ const handlerRef = useRef(handler);
18
+
19
+ useEffect(() => {
20
+ handlerRef.current = handler;
21
+ }, [handler]);
22
+
23
+ useEffect(() => {
24
+ if (!(isReady && map && sphere)) {
25
+ return;
26
+ }
27
+
28
+ // Stable reference avoids re-binding on every handler change
29
+ const stableHandler: EventHandler<T> = (data) => {
30
+ return handlerRef.current(data);
31
+ };
32
+
33
+ map.Event.bind(eventName, stableHandler);
34
+
35
+ return () => {
36
+ map.Event.unbind(eventName, stableHandler);
37
+ };
38
+ }, [map, isReady, sphere, eventName]);
39
+ }
40
+
41
+ export function useMapReady(handler: () => void): void {
42
+ useMapEvent("ready", handler);
43
+ }
44
+
45
+ export function useMapClick(
46
+ handler: EventHandler<{ lon: number; lat: number }>
47
+ ): void {
48
+ useMapEvent("click", handler);
49
+ }
50
+
51
+ export function useMapZoom(handler: EventHandler<void>): void {
52
+ useMapEvent("zoom", handler);
53
+ }
54
+
55
+ export function useMapLocation(handler: EventHandler<void>): void {
56
+ useMapEvent("location", handler);
57
+ }
58
+
59
+ export function useOverlayClick(
60
+ handler: EventHandler<{
61
+ overlay: SphereOverlay;
62
+ location: Location;
63
+ }>
64
+ ): void {
65
+ useMapEvent("overlayClick", handler);
66
+ }