@reearth/core 0.0.7-alpha.66 → 0.0.7-alpha.68
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/dist/core.js +777 -757
- package/dist/core.umd.cjs +42 -42
- package/package.json +1 -1
- package/src/engines/Cesium/core/Imagery.test.ts +206 -2
- package/src/engines/Cesium/core/Imagery.tsx +104 -10
package/package.json
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import { renderHook } from "@testing-library/react";
|
|
2
2
|
import { UrlTemplateImageryProvider } from "cesium";
|
|
3
|
-
import { expect, test, vi } from "vitest";
|
|
3
|
+
import { expect, test, vi, beforeEach } from "vitest";
|
|
4
4
|
|
|
5
5
|
import type { CustomProviderConfig } from "../../../Map/types/customProvider";
|
|
6
6
|
|
|
7
|
-
import { type Tile, useImageryProviders } from "./Imagery";
|
|
7
|
+
import ImageryLayers, { type Tile, useImageryProviders } from "./Imagery";
|
|
8
|
+
|
|
9
|
+
// Mock Cesium scene and imageryLayerCollection for ImageryLayers component tests
|
|
10
|
+
const mockAdd = vi.fn();
|
|
11
|
+
const mockRemove = vi.fn();
|
|
12
|
+
const mockContains = vi.fn(() => true);
|
|
13
|
+
const mockIndexOf = vi.fn(() => 0);
|
|
14
|
+
const mockRequestRender = vi.fn();
|
|
15
|
+
|
|
16
|
+
const mockImageryLayerCollection = {
|
|
17
|
+
add: mockAdd,
|
|
18
|
+
remove: mockRemove,
|
|
19
|
+
contains: mockContains,
|
|
20
|
+
indexOf: mockIndexOf,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const mockScene = {
|
|
24
|
+
requestRender: mockRequestRender,
|
|
25
|
+
isDestroyed: () => false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
vi.mock("resium", () => ({
|
|
29
|
+
useCesium: () => ({
|
|
30
|
+
imageryLayerCollection: mockImageryLayerCollection,
|
|
31
|
+
scene: mockScene,
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
mockAdd.mockClear();
|
|
37
|
+
mockRemove.mockClear();
|
|
38
|
+
mockContains.mockClear();
|
|
39
|
+
mockIndexOf.mockClear();
|
|
40
|
+
mockRequestRender.mockClear();
|
|
41
|
+
});
|
|
8
42
|
|
|
9
43
|
test("useImageryProviders", () => {
|
|
10
44
|
const provider = vi.fn(({ url }: { url?: string } = {}): any => ({ hoge: url }));
|
|
@@ -153,3 +187,173 @@ test("useImageryProviders", () => {
|
|
|
153
187
|
typedRerender({ tiles: [] });
|
|
154
188
|
expect(result.current.providers).toEqual({});
|
|
155
189
|
});
|
|
190
|
+
|
|
191
|
+
test("ImageryLayers should not re-render when tiles array reference changes but content is the same", async () => {
|
|
192
|
+
const tiles: Tile[] = [{ id: "1", type: "open_street_map", opacity: 0.8 }];
|
|
193
|
+
|
|
194
|
+
const { rerender } = renderHook(
|
|
195
|
+
({ tiles }: { tiles: Tile[] }) => {
|
|
196
|
+
return ImageryLayers({
|
|
197
|
+
tiles,
|
|
198
|
+
cesiumIonAccessToken: undefined,
|
|
199
|
+
customProvider: undefined,
|
|
200
|
+
onTilesChange: undefined,
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
initialProps: { tiles },
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Wait for initial render to complete
|
|
209
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
210
|
+
|
|
211
|
+
// Clear mock calls from initial render
|
|
212
|
+
mockAdd.mockClear();
|
|
213
|
+
mockRemove.mockClear();
|
|
214
|
+
|
|
215
|
+
// Re-render with a NEW tiles array reference but SAME content
|
|
216
|
+
const newTilesArraySameContent: Tile[] = [{ id: "1", type: "open_street_map", opacity: 0.8 }];
|
|
217
|
+
|
|
218
|
+
rerender({ tiles: newTilesArraySameContent });
|
|
219
|
+
|
|
220
|
+
// Wait for any effects to run
|
|
221
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
222
|
+
|
|
223
|
+
// Effect should NOT have run again - no layers should be removed or added
|
|
224
|
+
expect(mockRemove).not.toHaveBeenCalled();
|
|
225
|
+
expect(mockAdd).not.toHaveBeenCalled();
|
|
226
|
+
|
|
227
|
+
// Clear mocks
|
|
228
|
+
mockAdd.mockClear();
|
|
229
|
+
mockRemove.mockClear();
|
|
230
|
+
|
|
231
|
+
// Re-render with ONLY opacity changed
|
|
232
|
+
const opacityChangedTiles: Tile[] = [
|
|
233
|
+
{ id: "1", type: "open_street_map", opacity: 0.5 }, // only opacity changed
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
rerender({ tiles: opacityChangedTiles });
|
|
237
|
+
|
|
238
|
+
// Wait for effects to run
|
|
239
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
240
|
+
|
|
241
|
+
// With our optimization, when ONLY opacity changes, the layer is NOT recreated
|
|
242
|
+
// The layer.alpha property is updated directly without removing/adding the layer
|
|
243
|
+
expect(mockRemove).not.toHaveBeenCalled();
|
|
244
|
+
expect(mockAdd).not.toHaveBeenCalled();
|
|
245
|
+
|
|
246
|
+
// Clear mocks
|
|
247
|
+
mockAdd.mockClear();
|
|
248
|
+
mockRemove.mockClear();
|
|
249
|
+
|
|
250
|
+
// Re-render with DIFFERENT tile type (requires layer recreation)
|
|
251
|
+
const differentTypeTiles: Tile[] = [
|
|
252
|
+
{ id: "1", type: "stamen_watercolor", opacity: 0.5 }, // type changed
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
rerender({ tiles: differentTypeTiles });
|
|
256
|
+
|
|
257
|
+
// Wait for effects to run
|
|
258
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
259
|
+
|
|
260
|
+
// When provider changes (type, url, etc.), layer IS recreated
|
|
261
|
+
expect(mockRemove).toHaveBeenCalled();
|
|
262
|
+
expect(mockAdd).toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("ImageryLayers should optimize opacity changes without recreating layers", async () => {
|
|
266
|
+
const tiles: Tile[] = [{ id: "1", type: "open_street_map", opacity: 1.0 }];
|
|
267
|
+
|
|
268
|
+
const { rerender } = renderHook(
|
|
269
|
+
({ tiles }: { tiles: Tile[] }) => {
|
|
270
|
+
return ImageryLayers({
|
|
271
|
+
tiles,
|
|
272
|
+
cesiumIonAccessToken: undefined,
|
|
273
|
+
customProvider: undefined,
|
|
274
|
+
onTilesChange: undefined,
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
initialProps: { tiles },
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Wait for initial render
|
|
283
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
284
|
+
|
|
285
|
+
// Clear initial render calls
|
|
286
|
+
mockAdd.mockClear();
|
|
287
|
+
mockRemove.mockClear();
|
|
288
|
+
|
|
289
|
+
// Change opacity multiple times
|
|
290
|
+
for (const opacity of [0.8, 0.6, 0.4, 0.2]) {
|
|
291
|
+
rerender({ tiles: [{ id: "1", type: "open_street_map", opacity }] });
|
|
292
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// After multiple opacity changes, layers should NEVER be removed/added
|
|
296
|
+
// The optimization updates layer.alpha directly
|
|
297
|
+
expect(mockRemove).not.toHaveBeenCalled();
|
|
298
|
+
expect(mockAdd).not.toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("ImageryLayers should recreate layer when customProvider changes even if tile properties are same", async () => {
|
|
302
|
+
// Use a custom provider that we can change
|
|
303
|
+
const tiles: Tile[] = [{ id: "1", type: "my_custom", opacity: 0.8 }];
|
|
304
|
+
|
|
305
|
+
const customProvider1: CustomProviderConfig = {
|
|
306
|
+
imagery: {
|
|
307
|
+
providers: [
|
|
308
|
+
{
|
|
309
|
+
id: "my_custom",
|
|
310
|
+
url: "https://tiles1.example.com/{z}/{x}/{y}.png",
|
|
311
|
+
credit: "© Example 1",
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const customProvider2: CustomProviderConfig = {
|
|
318
|
+
imagery: {
|
|
319
|
+
providers: [
|
|
320
|
+
{
|
|
321
|
+
id: "my_custom",
|
|
322
|
+
url: "https://tiles2.example.com/{z}/{x}/{y}.png", // Different URL
|
|
323
|
+
credit: "© Example 2",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const { rerender } = renderHook(
|
|
330
|
+
({ tiles, customProvider }: { tiles: Tile[]; customProvider?: CustomProviderConfig }) => {
|
|
331
|
+
return ImageryLayers({
|
|
332
|
+
tiles,
|
|
333
|
+
cesiumIonAccessToken: undefined,
|
|
334
|
+
customProvider,
|
|
335
|
+
onTilesChange: undefined,
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
initialProps: { tiles, customProvider: customProvider1 },
|
|
340
|
+
},
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Wait for initial render
|
|
344
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
345
|
+
|
|
346
|
+
// Clear initial render calls
|
|
347
|
+
mockAdd.mockClear();
|
|
348
|
+
mockRemove.mockClear();
|
|
349
|
+
|
|
350
|
+
// Change the customProvider (this creates a new provider with different URL)
|
|
351
|
+
// Tile properties stay the same
|
|
352
|
+
rerender({ tiles, customProvider: customProvider2 });
|
|
353
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
354
|
+
|
|
355
|
+
// Layer SHOULD be recreated because the provider changed
|
|
356
|
+
// (even though tile properties didn't change)
|
|
357
|
+
expect(mockRemove).toHaveBeenCalled();
|
|
358
|
+
expect(mockAdd).toHaveBeenCalled();
|
|
359
|
+
});
|
|
@@ -53,20 +53,60 @@ export default function ImageryLayers({
|
|
|
53
53
|
}: Props) {
|
|
54
54
|
const { imageryLayerCollection, scene } = useCesium();
|
|
55
55
|
|
|
56
|
+
// Create a stable tiles reference that only changes when the content actually changes
|
|
57
|
+
// Normalize `undefined` to a stable empty array so `useImageryProviders` doesn't allocate a new default `[]` each render.
|
|
58
|
+
const emptyTiles = useMemo<Tile[]>(() => [], []);
|
|
59
|
+
const tilesValue = tiles ?? emptyTiles;
|
|
60
|
+
|
|
61
|
+
const prevTilesRef = useRef<Tile[]>(tilesValue);
|
|
62
|
+
const stableTiles = useMemo(() => {
|
|
63
|
+
if (!isEqual(prevTilesRef.current, tilesValue)) {
|
|
64
|
+
prevTilesRef.current = tilesValue;
|
|
65
|
+
}
|
|
66
|
+
return prevTilesRef.current;
|
|
67
|
+
}, [tilesValue]);
|
|
68
|
+
|
|
69
|
+
// Pass stableTiles to useImageryProviders to prevent providers from being recreated
|
|
70
|
+
// when tiles reference changes but content is the same
|
|
56
71
|
const { providers } = useImageryProviders({
|
|
57
|
-
tiles,
|
|
72
|
+
tiles: stableTiles,
|
|
58
73
|
cesiumIonAccessToken,
|
|
59
74
|
customProvider,
|
|
60
75
|
presets: tilePresets,
|
|
61
76
|
});
|
|
62
77
|
|
|
78
|
+
// Store layers keyed by tile ID to allow incremental updates
|
|
79
|
+
const layersRef = useRef<
|
|
80
|
+
Map<
|
|
81
|
+
string,
|
|
82
|
+
{
|
|
83
|
+
layer: CesiumImageryLayer;
|
|
84
|
+
tile: Tile;
|
|
85
|
+
provider: Promise<ImageryProvider> | ImageryProvider;
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
>(new Map());
|
|
89
|
+
|
|
63
90
|
useEffect(() => {
|
|
64
91
|
if (!imageryLayerCollection || !scene) return;
|
|
65
92
|
|
|
66
93
|
let cancelled = false;
|
|
67
|
-
const
|
|
94
|
+
const currentTileIds = new Set(stableTiles?.map(t => t.id) || []);
|
|
95
|
+
|
|
96
|
+
// Remove layers for tiles that no longer exist
|
|
97
|
+
layersRef.current.forEach(({ layer }, id) => {
|
|
98
|
+
if (!currentTileIds.has(id)) {
|
|
99
|
+
if (imageryLayerCollection.contains(layer)) {
|
|
100
|
+
imageryLayerCollection.remove(layer);
|
|
101
|
+
}
|
|
102
|
+
layersRef.current.delete(id);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
68
106
|
// Track layers by their intended index to maintain order with async loading
|
|
69
|
-
const layersByIndex: (CesiumImageryLayer | null)[] = new Array(
|
|
107
|
+
const layersByIndex: (CesiumImageryLayer | null)[] = new Array(stableTiles?.length || 0).fill(
|
|
108
|
+
null,
|
|
109
|
+
);
|
|
70
110
|
|
|
71
111
|
const reorderLayers = () => {
|
|
72
112
|
if (cancelled || scene.isDestroyed()) return;
|
|
@@ -91,10 +131,50 @@ export default function ImageryLayers({
|
|
|
91
131
|
scene.requestRender();
|
|
92
132
|
};
|
|
93
133
|
|
|
94
|
-
|
|
134
|
+
stableTiles?.forEach((tile, i) => {
|
|
135
|
+
const { id, zoomLevel, opacity, heatmap } = tile;
|
|
136
|
+
const existing = layersRef.current.get(id);
|
|
95
137
|
const providerOrPromise = providers[id]?.[3];
|
|
138
|
+
|
|
96
139
|
if (!providerOrPromise) return;
|
|
97
140
|
|
|
141
|
+
// Check if we can reuse the existing layer with just an opacity update
|
|
142
|
+
if (existing) {
|
|
143
|
+
const prevTile = existing.tile;
|
|
144
|
+
const prevProvider = existing.provider;
|
|
145
|
+
// Must check provider reference - if provider changed (e.g. cesiumIonAccessToken updated),
|
|
146
|
+
// the layer needs to be recreated even if tile properties are the same
|
|
147
|
+
const canReuseLayer =
|
|
148
|
+
prevProvider === providerOrPromise &&
|
|
149
|
+
prevTile.type === tile.type &&
|
|
150
|
+
prevTile.url === tile.url &&
|
|
151
|
+
prevTile.cesiumIonAssetId === tile.cesiumIonAssetId &&
|
|
152
|
+
prevTile.zoomLevel?.[0] === zoomLevel?.[0] &&
|
|
153
|
+
prevTile.zoomLevel?.[1] === zoomLevel?.[1] &&
|
|
154
|
+
prevTile.heatmap === heatmap;
|
|
155
|
+
|
|
156
|
+
if (canReuseLayer) {
|
|
157
|
+
// Only opacity might have changed - update it directly if needed
|
|
158
|
+
const nextAlpha = opacity ?? 1;
|
|
159
|
+
if (existing.layer.alpha !== nextAlpha) {
|
|
160
|
+
existing.layer.alpha = nextAlpha;
|
|
161
|
+
scene.requestRender();
|
|
162
|
+
}
|
|
163
|
+
// Update stored tile and provider for next comparison
|
|
164
|
+
existing.tile = tile;
|
|
165
|
+
existing.provider = providerOrPromise;
|
|
166
|
+
layersByIndex[i] = existing.layer;
|
|
167
|
+
reorderLayers();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Need to recreate the layer - remove the old one
|
|
172
|
+
if (imageryLayerCollection.contains(existing.layer)) {
|
|
173
|
+
imageryLayerCollection.remove(existing.layer);
|
|
174
|
+
}
|
|
175
|
+
layersRef.current.delete(id);
|
|
176
|
+
}
|
|
177
|
+
|
|
98
178
|
const doAdd = (provider: ImageryProvider) => {
|
|
99
179
|
if (!provider || cancelled || scene.isDestroyed()) return;
|
|
100
180
|
const layer = new CesiumImageryLayer(provider, {
|
|
@@ -110,14 +190,17 @@ export default function ImageryLayers({
|
|
|
110
190
|
// Always append to avoid index out of bounds
|
|
111
191
|
imageryLayerCollection.add(layer);
|
|
112
192
|
layersByIndex[i] = layer;
|
|
113
|
-
|
|
193
|
+
// Store the provider reference to detect when provider changes (e.g. token update)
|
|
194
|
+
layersRef.current.set(id, { layer, tile, provider: providerOrPromise });
|
|
114
195
|
|
|
115
196
|
// Reorder all layers after each addition
|
|
116
197
|
reorderLayers();
|
|
117
198
|
};
|
|
118
199
|
|
|
119
200
|
if (providerOrPromise instanceof Promise) {
|
|
120
|
-
providerOrPromise
|
|
201
|
+
providerOrPromise
|
|
202
|
+
.then(doAdd)
|
|
203
|
+
.catch(err => console.error("Failed to load imagery provider:", err));
|
|
121
204
|
} else {
|
|
122
205
|
doAdd(providerOrPromise);
|
|
123
206
|
}
|
|
@@ -128,13 +211,24 @@ export default function ImageryLayers({
|
|
|
128
211
|
|
|
129
212
|
return () => {
|
|
130
213
|
cancelled = true;
|
|
131
|
-
|
|
132
|
-
|
|
214
|
+
// Don't remove layers on cleanup - they'll be managed by the next render
|
|
215
|
+
// This prevents flickering when tiles change
|
|
216
|
+
};
|
|
217
|
+
}, [providers, stableTiles, imageryLayerCollection, scene, onTilesChange]);
|
|
218
|
+
|
|
219
|
+
// Cleanup all layers on unmount
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
const layers = layersRef.current;
|
|
222
|
+
return () => {
|
|
223
|
+
if (!imageryLayerCollection || !scene || scene.isDestroyed()) return;
|
|
224
|
+
layers.forEach(({ layer }) => {
|
|
225
|
+
if (imageryLayerCollection.contains(layer)) {
|
|
133
226
|
imageryLayerCollection.remove(layer);
|
|
134
227
|
}
|
|
135
|
-
}
|
|
228
|
+
});
|
|
229
|
+
layers.clear();
|
|
136
230
|
};
|
|
137
|
-
}, [
|
|
231
|
+
}, [imageryLayerCollection, scene]);
|
|
138
232
|
|
|
139
233
|
return null;
|
|
140
234
|
}
|