@reearth/core 0.0.7-alpha.67 → 0.0.7-alpha.69

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reearth/core",
3
- "version": "0.0.7-alpha.67",
3
+ "version": "0.0.7-alpha.69",
4
4
  "author": "Re:Earth contributors <community@reearth.io>",
5
5
  "license": "Apache-2.0",
6
6
  "description": "A library that abstracts a map engine as one common API.",
@@ -228,17 +228,132 @@ test("ImageryLayers should not re-render when tiles array reference changes but
228
228
  mockAdd.mockClear();
229
229
  mockRemove.mockClear();
230
230
 
231
- // Re-render with DIFFERENT content (changed opacity)
232
- const differentTiles: Tile[] = [
233
- { id: "1", type: "open_street_map", opacity: 0.5 }, // opacity changed
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
234
  ];
235
235
 
236
- rerender({ tiles: differentTiles });
236
+ rerender({ tiles: opacityChangedTiles });
237
237
 
238
238
  // Wait for effects to run
239
239
  await new Promise(resolve => setTimeout(resolve, 0));
240
240
 
241
- // Effect SHOULD run - old layers removed and new ones added
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)
242
357
  expect(mockRemove).toHaveBeenCalled();
243
358
  expect(mockAdd).toHaveBeenCalled();
244
359
  });
@@ -75,11 +75,34 @@ export default function ImageryLayers({
75
75
  presets: tilePresets,
76
76
  });
77
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
+
78
90
  useEffect(() => {
79
91
  if (!imageryLayerCollection || !scene) return;
80
92
 
81
93
  let cancelled = false;
82
- const addedLayers: CesiumImageryLayer[] = [];
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
+
83
106
  // Track layers by their intended index to maintain order with async loading
84
107
  const layersByIndex: (CesiumImageryLayer | null)[] = new Array(stableTiles?.length || 0).fill(
85
108
  null,
@@ -108,10 +131,50 @@ export default function ImageryLayers({
108
131
  scene.requestRender();
109
132
  };
110
133
 
111
- stableTiles?.forEach(({ id, zoomLevel, opacity, heatmap }, i) => {
134
+ stableTiles?.forEach((tile, i) => {
135
+ const { id, zoomLevel, opacity, heatmap } = tile;
136
+ const existing = layersRef.current.get(id);
112
137
  const providerOrPromise = providers[id]?.[3];
138
+
113
139
  if (!providerOrPromise) return;
114
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
+
115
178
  const doAdd = (provider: ImageryProvider) => {
116
179
  if (!provider || cancelled || scene.isDestroyed()) return;
117
180
  const layer = new CesiumImageryLayer(provider, {
@@ -127,7 +190,8 @@ export default function ImageryLayers({
127
190
  // Always append to avoid index out of bounds
128
191
  imageryLayerCollection.add(layer);
129
192
  layersByIndex[i] = layer;
130
- addedLayers.push(layer);
193
+ // Store the provider reference to detect when provider changes (e.g. token update)
194
+ layersRef.current.set(id, { layer, tile, provider: providerOrPromise });
131
195
 
132
196
  // Reorder all layers after each addition
133
197
  reorderLayers();
@@ -147,15 +211,24 @@ export default function ImageryLayers({
147
211
 
148
212
  return () => {
149
213
  cancelled = true;
150
- for (const layer of addedLayers) {
151
- if (!scene.isDestroyed() && imageryLayerCollection.contains(layer)) {
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)) {
152
226
  imageryLayerCollection.remove(layer);
153
227
  }
154
- }
228
+ });
229
+ layers.clear();
155
230
  };
156
- // Note: Using `stableTiles` to prevent re-renders when tiles reference changes but content is identical.
157
- // This also stabilizes `providers` since it depends on tiles in useImageryProviders.
158
- }, [providers, stableTiles, imageryLayerCollection, scene, onTilesChange]);
231
+ }, [imageryLayerCollection, scene]);
159
232
 
160
233
  return null;
161
234
  }
@@ -253,7 +253,9 @@ const Cesium: React.ForwardRefRenderFunction<EngineRef, EngineProps> = (
253
253
  useWebVR={!!property?.scene?.vr || undefined} // NOTE: useWebVR={false} will crash Cesium
254
254
  debugShowFramesPerSecond={!!property?.debug?.showFramesPerSecond}
255
255
  verticalExaggerationRelativeHeight={property?.scene?.verticalExaggerationRelativeHeight}
256
- verticalExaggeration={property?.scene?.verticalExaggeration}
256
+ verticalExaggeration={
257
+ property?.terrain?.enabled ? property?.scene?.verticalExaggeration : 1
258
+ }
257
259
  />
258
260
  <SkyBox show={property?.sky?.skyBox?.show ?? true} />
259
261
  <Fog enabled={property?.sky?.fog?.enabled ?? true} density={property?.sky?.fog?.density} />