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

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.65",
3
+ "version": "0.0.7-alpha.67",
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.",
@@ -23,7 +23,15 @@ import {
23
23
  createGooglePhotorealistic3DTileset,
24
24
  } from "cesium";
25
25
  import { pick } from "lodash-es";
26
- import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
26
+ import {
27
+ MutableRefObject,
28
+ useCallback,
29
+ useEffect,
30
+ useLayoutEffect,
31
+ useMemo,
32
+ useRef,
33
+ useState,
34
+ } from "react";
27
35
  import { CesiumComponentRef, useCesium } from "resium";
28
36
 
29
37
  import type {
@@ -490,12 +498,53 @@ export const useHooks = ({
490
498
 
491
499
  const [style, setStyle] = useState<Cesium3DTileStyle>();
492
500
  const { url, type, idProperty, googleMapApiKey, provider } = useData(layer);
501
+ const cesiumIonAccessToken =
502
+ typeof meta?.cesiumIonAccessToken === "string" ? meta.cesiumIonAccessToken : undefined;
493
503
  const shouldUseFeatureIndex = !disableIndexingFeature && !!idProperty;
494
504
 
495
505
  const [isTilesetReady, setIsTilesetReady] = useState(false);
496
506
  const [isTilesetCompReady, setIsTilesetCompReady] = useState(false);
497
507
  const [isTilesetRefReady, setIsTilesetRefReady] = useState(false);
498
508
 
509
+ const reearthGooglePhotorealisticUrl = useMemo(
510
+ () =>
511
+ customProvider?.layers?.providers?.find(
512
+ p => p.id === "reearth_google_photorealistic_3d_tiles",
513
+ )?.url,
514
+ [customProvider],
515
+ );
516
+
517
+ const tilesetKey = useMemo(
518
+ () =>
519
+ generateIDWithMD5(
520
+ JSON.stringify({
521
+ type,
522
+ url,
523
+ tileset,
524
+ provider,
525
+ googleMapApiKey,
526
+ reearthGooglePhotorealisticUrl,
527
+ cesiumIonAccessToken:
528
+ type === "osm-buildings" ||
529
+ (type === "google-photorealistic" && (provider === "cesium-ion" || !googleMapApiKey))
530
+ ? cesiumIonAccessToken
531
+ : undefined,
532
+ }),
533
+ ),
534
+ [
535
+ type,
536
+ url,
537
+ tileset,
538
+ provider,
539
+ googleMapApiKey,
540
+ reearthGooglePhotorealisticUrl,
541
+ cesiumIonAccessToken,
542
+ ],
543
+ );
544
+ const currentTilesetKeyRef = useRef(tilesetKey);
545
+ currentTilesetKeyRef.current = tilesetKey;
546
+ const tilesetRefKeyRef = useRef<string | undefined>(undefined);
547
+
499
548
  const prevPlanes = useRef(_planes);
500
549
  const planes = useMemo(() => {
501
550
  if (
@@ -563,20 +612,25 @@ export const useHooks = ({
563
612
 
564
613
  const ref = useCallback(
565
614
  (tileset: CesiumComponentRef<Cesium3DTilesetType> | null) => {
566
- if (tileset?.cesiumElement) {
567
- attachTag(tileset.cesiumElement, {
615
+ const cesiumElement = tileset?.cesiumElement;
616
+ if (tilesetKey !== currentTilesetKeyRef.current) {
617
+ return;
618
+ }
619
+ if (cesiumElement) {
620
+ attachTag(cesiumElement, {
568
621
  layerId: layer?.id || id,
569
622
  featureIndex: shouldUseFeatureIndex ? featureIndex : undefined,
570
623
  appearanceType: "3dtiles",
571
624
  });
572
625
  }
573
- if (layer?.id && tileset?.cesiumElement) {
574
- (tileset?.cesiumElement as any)[layerIdField] = layer.id;
626
+ if (layer?.id && cesiumElement) {
627
+ (cesiumElement as any)[layerIdField] = layer.id;
575
628
  }
576
- tilesetRef.current = tileset?.cesiumElement;
577
- setIsTilesetRefReady(!!tileset?.cesiumElement);
629
+ tilesetRef.current = cesiumElement;
630
+ tilesetRefKeyRef.current = cesiumElement ? tilesetKey : undefined;
631
+ setIsTilesetRefReady(!!cesiumElement);
578
632
  },
579
- [id, layer?.id, featureIndex, shouldUseFeatureIndex],
633
+ [id, layer?.id, featureIndex, shouldUseFeatureIndex, tilesetKey],
580
634
  );
581
635
 
582
636
  const selectedFeatureIdsRef = useRef<string[]>([]);
@@ -584,6 +638,20 @@ export const useHooks = ({
584
638
  selectedFeatureColorRef.current = selectedFeatureColor;
585
639
  const [selectedFeatureColorMap] = useState(() => new Map<string, Color>());
586
640
 
641
+ // The Cesium3DTileset child remounts when tilesetKey changes, while this hook stays mounted.
642
+ useLayoutEffect(() => {
643
+ setIsTilesetCompReady(false);
644
+ setIsTilesetReady(false);
645
+ if (tilesetRefKeyRef.current !== tilesetKey) {
646
+ tilesetRef.current = undefined;
647
+ tilesetRefKeyRef.current = undefined;
648
+ setIsTilesetRefReady(false);
649
+ }
650
+ featureIndex.records.clear();
651
+ selectedFeatureIdsRef.current = [];
652
+ selectedFeatureColorMap.clear();
653
+ }, [tilesetKey, featureIndex, selectedFeatureColorMap]);
654
+
587
655
  useEffect(() => {
588
656
  if (!tilesetRef.current || !shouldUseFeatureIndex || !isTilesetReady) return;
589
657
  Object.assign(tilesetRef.current, {
@@ -759,12 +827,7 @@ export const useHooks = ({
759
827
 
760
828
  // For Re:Earth provider, use the custom URL from customProvider or layer data
761
829
  if (provider === "reearth") {
762
- // Try to get URL from customProvider first, then fall back to layer URL
763
- const customUrl = customProvider?.layers?.providers?.find(
764
- p => p.id === "reearth_google_photorealistic_3d_tiles",
765
- )?.url;
766
-
767
- return customUrl || url || null;
830
+ return reearthGooglePhotorealisticUrl || url || null;
768
831
  }
769
832
 
770
833
  // Otherwise load via Google API key or Cesium Ion.
@@ -772,11 +835,13 @@ export const useHooks = ({
772
835
  try {
773
836
  if (provider === "cesium-ion" || !googleMapApiKey) {
774
837
  const resource = await IonResource.fromAssetId(2275207, {
775
- accessToken: meta?.cesiumIonAccessToken as string | undefined,
838
+ accessToken: cesiumIonAccessToken,
776
839
  });
777
840
  return resource;
778
841
  }
779
- const tileset = await createGooglePhotorealistic3DTileset({ key: googleMapApiKey });
842
+ const tileset = await createGooglePhotorealistic3DTileset({
843
+ key: googleMapApiKey,
844
+ });
780
845
  return tileset.resource;
781
846
  } catch (error) {
782
847
  console.error(`Error loading Photorealistic 3D Tiles tileset: ${error}`);
@@ -785,7 +850,15 @@ export const useHooks = ({
785
850
  };
786
851
 
787
852
  return loadTileset();
788
- }, [type, isVisible, googleMapApiKey, meta?.cesiumIonAccessToken, provider, customProvider, url]);
853
+ }, [
854
+ type,
855
+ isVisible,
856
+ googleMapApiKey,
857
+ cesiumIonAccessToken,
858
+ provider,
859
+ reearthGooglePhotorealisticUrl,
860
+ url,
861
+ ]);
789
862
 
790
863
  const tilesetUrl = useMemo((): string | Resource | Promise<Resource> | null => {
791
864
  if (!isVisible) return null;
@@ -803,7 +876,7 @@ export const useHooks = ({
803
876
  // OSM Buildings — only available via Cesium Ion.
804
877
  if (type === "osm-buildings") {
805
878
  return IonResource.fromAssetId(96188, {
806
- accessToken: meta?.cesiumIonAccessToken as string | undefined,
879
+ accessToken: cesiumIonAccessToken,
807
880
  }); // https://github.com/CesiumGS/cesium/blob/main/packages/engine/Source/Scene/createOsmBuildings.js#L53
808
881
  }
809
882
 
@@ -813,7 +886,7 @@ export const useHooks = ({
813
886
  }
814
887
 
815
888
  return null;
816
- }, [type, isVisible, googleMapPhotorealisticResource, url, tileset, meta?.cesiumIonAccessToken]);
889
+ }, [type, isVisible, googleMapPhotorealisticResource, url, tileset, cesiumIonAccessToken]);
817
890
 
818
891
  const imageBasedLighting = useMemo(() => {
819
892
  if (
@@ -854,11 +927,29 @@ export const useHooks = ({
854
927
 
855
928
  const handleReady = useCallback(
856
929
  (tileset: Cesium3DTileset) => {
930
+ if (tilesetKey !== currentTilesetKeyRef.current) {
931
+ return;
932
+ }
857
933
  setIsTilesetCompReady(true);
858
934
  onLayerFetch?.({ properties: tileset.properties });
859
935
  onLayerLoad?.({ layerId: layerIdRef.current });
860
936
  },
861
- [onLayerFetch, onLayerLoad],
937
+ [onLayerFetch, onLayerLoad, tilesetKey],
938
+ );
939
+
940
+ const handleError = useCallback(
941
+ (error: unknown) => {
942
+ if (tilesetKey !== currentTilesetKeyRef.current) {
943
+ return;
944
+ }
945
+ tilesetRef.current = undefined;
946
+ tilesetRefKeyRef.current = undefined;
947
+ setIsTilesetCompReady(false);
948
+ setIsTilesetRefReady(false);
949
+ setIsTilesetReady(false);
950
+ console.error("Error loading Cesium 3D Tileset:", error);
951
+ },
952
+ [tilesetKey],
862
953
  );
863
954
 
864
955
  useEffect(() => {
@@ -874,6 +965,7 @@ export const useHooks = ({
874
965
  }, [type, updateCredits]);
875
966
 
876
967
  return {
968
+ tilesetKey,
877
969
  tilesetUrl,
878
970
  ref,
879
971
  style,
@@ -883,5 +975,6 @@ export const useHooks = ({
883
975
  builtinBoxProps,
884
976
  imageBasedLighting,
885
977
  handleReady,
978
+ handleError,
886
979
  };
887
980
  };
@@ -1,4 +1,4 @@
1
- import { memo, useMemo, type JSX } from "react";
1
+ import { useMemo, type JSX } from "react";
2
2
  import { Cesium3DTileset } from "resium";
3
3
 
4
4
  import type { Cesium3DTilesAppearance, ComputedLayer } from "../../..";
@@ -37,6 +37,7 @@ function Tileset({
37
37
  property ?? {};
38
38
  const boxId = `${layer?.id}_box`;
39
39
  const {
40
+ tilesetKey,
40
41
  tilesetUrl,
41
42
  ref,
42
43
  style,
@@ -46,6 +47,7 @@ function Tileset({
46
47
  builtinBoxProps,
47
48
  imageBasedLighting,
48
49
  handleReady,
50
+ handleError,
49
51
  } = useHooks({
50
52
  id,
51
53
  boxId,
@@ -72,6 +74,7 @@ function Tileset({
72
74
  return !isVisible || !tilesetUrl ? null : (
73
75
  <>
74
76
  <Cesium3DTileset
77
+ key={tilesetKey}
75
78
  ref={ref}
76
79
  url={tilesetUrl}
77
80
  customShader={
@@ -88,6 +91,7 @@ function Tileset({
88
91
  colorBlendMode={colorBlendModeFor3DTile(colorBlendMode)}
89
92
  imageBasedLighting={imageBasedLighting}
90
93
  onReady={handleReady}
94
+ onError={handleError}
91
95
  debugWireframe={showWireframe}
92
96
  debugShowBoundingVolume={showBoundingVolume}
93
97
  showCreditsOnScreen
@@ -111,19 +115,7 @@ function Tileset({
111
115
  );
112
116
  }
113
117
 
114
- export default memo(
115
- Tileset,
116
- (prev, next) =>
117
- prev.id === next.id &&
118
- prev.isVisible === next.isVisible &&
119
- prev.property === next.property &&
120
- prev.layer?.layer === next.layer?.layer &&
121
- prev.viewerProperty === next.viewerProperty &&
122
- prev.meta === next.meta &&
123
- prev.evalFeature === next.evalFeature &&
124
- prev.onComputedFeatureFetch === next.onComputedFeatureFetch &&
125
- prev.onFeatureDelete === next.onFeatureDelete,
126
- );
118
+ export default Tileset;
127
119
 
128
120
  export const config: FeatureComponentConfig = {
129
121
  noFeature: true,
@@ -112,7 +112,19 @@ export default function Feature({
112
112
  displayType.every(k => components[k][1].noFeature && !components[k][1].noLayer);
113
113
  const useTransition = !!layer?.transition?.useTransition;
114
114
  const cacheable = !data?.updateInterval && !useTransition;
115
- const urlMD5 = useMemo(() => (data?.url ? generateIDWithMD5(data.url) : ""), [data?.url]);
115
+ const dataSourceKey = useMemo(
116
+ () =>
117
+ generateIDWithMD5(
118
+ JSON.stringify({
119
+ type: data?.type,
120
+ url: data?.url,
121
+ layers: data?.layers,
122
+ provider: data?.provider,
123
+ googleMapApiKey: data?.serviceTokens?.googleMapApiKey,
124
+ }),
125
+ ),
126
+ [data?.type, data?.url, data?.layers, data?.provider, data?.serviceTokens?.googleMapApiKey],
127
+ );
116
128
 
117
129
  const { requestRender } = useContext();
118
130
  // TODO: Find a way to wait updating the entity
@@ -139,7 +151,7 @@ export default function Feature({
139
151
  !(sketchEditingFeature?.layerId === layer.id && sketchEditingFeature?.feature?.id === f?.id);
140
152
 
141
153
  const componentId =
142
- urlMD5 +
154
+ dataSourceKey +
143
155
  generateIDWithMD5(
144
156
  `${layer.id}_${
145
157
  f?.id ?? ""
@@ -221,9 +233,9 @@ export default function Feature({
221
233
  layer?.layer?.type === "simple" && !!layer?.layer?.["3dtiles"]?.specularEnvironmentMaps;
222
234
 
223
235
  // "noFeature" component should be recreated when the following value is changed.
224
- // data.url, isVisible
236
+ // data source, Cesium Ion token, isVisible
225
237
  const key =
226
- urlMD5 +
238
+ dataSourceKey +
227
239
  generateIDWithMD5(
228
240
  `${
229
241
  layer?.id || ""
@@ -243,7 +255,7 @@ export default function Feature({
243
255
  })}
244
256
  </>
245
257
  );
246
- }, [areAllDisplayTypeNoFeature, displayType, layer, isHidden, urlMD5, props]);
258
+ }, [areAllDisplayTypeNoFeature, displayType, layer, isHidden, dataSourceKey, props]);
247
259
 
248
260
  return (
249
261
  <>
@@ -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,58 @@ 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 DIFFERENT content (changed opacity)
232
+ const differentTiles: Tile[] = [
233
+ { id: "1", type: "open_street_map", opacity: 0.5 }, // opacity changed
234
+ ];
235
+
236
+ rerender({ tiles: differentTiles });
237
+
238
+ // Wait for effects to run
239
+ await new Promise(resolve => setTimeout(resolve, 0));
240
+
241
+ // Effect SHOULD run - old layers removed and new ones added
242
+ expect(mockRemove).toHaveBeenCalled();
243
+ expect(mockAdd).toHaveBeenCalled();
244
+ });
@@ -53,8 +53,23 @@ 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,
@@ -66,7 +81,9 @@ export default function ImageryLayers({
66
81
  let cancelled = false;
67
82
  const addedLayers: CesiumImageryLayer[] = [];
68
83
  // Track layers by their intended index to maintain order with async loading
69
- const layersByIndex: (CesiumImageryLayer | null)[] = new Array(tiles?.length || 0).fill(null);
84
+ const layersByIndex: (CesiumImageryLayer | null)[] = new Array(stableTiles?.length || 0).fill(
85
+ null,
86
+ );
70
87
 
71
88
  const reorderLayers = () => {
72
89
  if (cancelled || scene.isDestroyed()) return;
@@ -91,7 +108,7 @@ export default function ImageryLayers({
91
108
  scene.requestRender();
92
109
  };
93
110
 
94
- tiles?.forEach(({ id, zoomLevel, opacity, heatmap }, i) => {
111
+ stableTiles?.forEach(({ id, zoomLevel, opacity, heatmap }, i) => {
95
112
  const providerOrPromise = providers[id]?.[3];
96
113
  if (!providerOrPromise) return;
97
114
 
@@ -117,7 +134,9 @@ export default function ImageryLayers({
117
134
  };
118
135
 
119
136
  if (providerOrPromise instanceof Promise) {
120
- providerOrPromise.then(doAdd).catch(err => console.error("Failed to load imagery provider:", err));
137
+ providerOrPromise
138
+ .then(doAdd)
139
+ .catch(err => console.error("Failed to load imagery provider:", err));
121
140
  } else {
122
141
  doAdd(providerOrPromise);
123
142
  }
@@ -134,7 +153,9 @@ export default function ImageryLayers({
134
153
  }
135
154
  }
136
155
  };
137
- }, [providers, tiles, imageryLayerCollection, scene, onTilesChange]);
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]);
138
159
 
139
160
  return null;
140
161
  }