@cveo/dsh-digital-earth 0.0.0-stage → 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 (359) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +13 -0
  4. package/README.en.md +124 -0
  5. package/README.md +124 -3
  6. package/client.js +2297 -0
  7. package/cordis.patch.yml +3 -0
  8. package/icon.svg +7 -0
  9. package/index.js +590 -0
  10. package/locale/en.json +6 -0
  11. package/locale/zh.json +6 -0
  12. package/package.json +74 -6
  13. package/preview.png +0 -0
  14. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_0.json +1 -0
  15. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_1.json +1 -0
  16. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_10.json +1 -0
  17. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_11.json +1 -0
  18. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_12.json +1 -0
  19. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_13.json +1 -0
  20. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_14.json +1 -0
  21. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_15.json +1 -0
  22. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_16.json +1 -0
  23. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_17.json +1 -0
  24. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_18.json +1 -0
  25. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_19.json +1 -0
  26. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_2.json +1 -0
  27. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_20.json +1 -0
  28. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_21.json +1 -0
  29. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_22.json +1 -0
  30. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_23.json +1 -0
  31. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_24.json +1 -0
  32. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_25.json +1 -0
  33. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_26.json +1 -0
  34. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_27.json +1 -0
  35. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_3.json +1 -0
  36. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_4.json +1 -0
  37. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_5.json +1 -0
  38. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_6.json +1 -0
  39. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_7.json +1 -0
  40. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_8.json +1 -0
  41. package/vendor/cesium/Assets/IAU2006_XYS/IAU2006_XYS_9.json +1 -0
  42. package/vendor/cesium/Assets/Images/bing_maps_credit.png +0 -0
  43. package/vendor/cesium/Assets/Images/cesium_credit.png +0 -0
  44. package/vendor/cesium/Assets/Images/google_earth_credit.png +0 -0
  45. package/vendor/cesium/Assets/Images/ion-credit.png +0 -0
  46. package/vendor/cesium/Assets/Textures/LensFlare/DirtMask.jpg +0 -0
  47. package/vendor/cesium/Assets/Textures/LensFlare/StarBurst.jpg +0 -0
  48. package/vendor/cesium/Assets/Textures/NaturalEarthII/0/0/0.jpg +0 -0
  49. package/vendor/cesium/Assets/Textures/NaturalEarthII/0/1/0.jpg +0 -0
  50. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/0/0.jpg +0 -0
  51. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/0/1.jpg +0 -0
  52. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/1/0.jpg +0 -0
  53. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/1/1.jpg +0 -0
  54. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/2/0.jpg +0 -0
  55. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/2/1.jpg +0 -0
  56. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/3/0.jpg +0 -0
  57. package/vendor/cesium/Assets/Textures/NaturalEarthII/1/3/1.jpg +0 -0
  58. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/0/0.jpg +0 -0
  59. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/0/1.jpg +0 -0
  60. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/0/2.jpg +0 -0
  61. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/0/3.jpg +0 -0
  62. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/1/0.jpg +0 -0
  63. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/1/1.jpg +0 -0
  64. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/1/2.jpg +0 -0
  65. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/1/3.jpg +0 -0
  66. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/2/0.jpg +0 -0
  67. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/2/1.jpg +0 -0
  68. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/2/2.jpg +0 -0
  69. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/2/3.jpg +0 -0
  70. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/3/0.jpg +0 -0
  71. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/3/1.jpg +0 -0
  72. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/3/2.jpg +0 -0
  73. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/3/3.jpg +0 -0
  74. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/4/0.jpg +0 -0
  75. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/4/1.jpg +0 -0
  76. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/4/2.jpg +0 -0
  77. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/4/3.jpg +0 -0
  78. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/5/0.jpg +0 -0
  79. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/5/1.jpg +0 -0
  80. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/5/2.jpg +0 -0
  81. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/5/3.jpg +0 -0
  82. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/6/0.jpg +0 -0
  83. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/6/1.jpg +0 -0
  84. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/6/2.jpg +0 -0
  85. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/6/3.jpg +0 -0
  86. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/7/0.jpg +0 -0
  87. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/7/1.jpg +0 -0
  88. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/7/2.jpg +0 -0
  89. package/vendor/cesium/Assets/Textures/NaturalEarthII/2/7/3.jpg +0 -0
  90. package/vendor/cesium/Assets/Textures/NaturalEarthII/tilemapresource.xml +14 -0
  91. package/vendor/cesium/Assets/Textures/SkyBox/tycho2t3_80_mx.jpg +0 -0
  92. package/vendor/cesium/Assets/Textures/SkyBox/tycho2t3_80_my.jpg +0 -0
  93. package/vendor/cesium/Assets/Textures/SkyBox/tycho2t3_80_mz.jpg +0 -0
  94. package/vendor/cesium/Assets/Textures/SkyBox/tycho2t3_80_px.jpg +0 -0
  95. package/vendor/cesium/Assets/Textures/SkyBox/tycho2t3_80_py.jpg +0 -0
  96. package/vendor/cesium/Assets/Textures/SkyBox/tycho2t3_80_pz.jpg +0 -0
  97. package/vendor/cesium/Assets/Textures/maki/airfield.png +0 -0
  98. package/vendor/cesium/Assets/Textures/maki/airport.png +0 -0
  99. package/vendor/cesium/Assets/Textures/maki/alcohol-shop.png +0 -0
  100. package/vendor/cesium/Assets/Textures/maki/america-football.png +0 -0
  101. package/vendor/cesium/Assets/Textures/maki/art-gallery.png +0 -0
  102. package/vendor/cesium/Assets/Textures/maki/bakery.png +0 -0
  103. package/vendor/cesium/Assets/Textures/maki/bank.png +0 -0
  104. package/vendor/cesium/Assets/Textures/maki/bar.png +0 -0
  105. package/vendor/cesium/Assets/Textures/maki/baseball.png +0 -0
  106. package/vendor/cesium/Assets/Textures/maki/basketball.png +0 -0
  107. package/vendor/cesium/Assets/Textures/maki/beer.png +0 -0
  108. package/vendor/cesium/Assets/Textures/maki/bicycle.png +0 -0
  109. package/vendor/cesium/Assets/Textures/maki/building.png +0 -0
  110. package/vendor/cesium/Assets/Textures/maki/bus.png +0 -0
  111. package/vendor/cesium/Assets/Textures/maki/cafe.png +0 -0
  112. package/vendor/cesium/Assets/Textures/maki/camera.png +0 -0
  113. package/vendor/cesium/Assets/Textures/maki/campsite.png +0 -0
  114. package/vendor/cesium/Assets/Textures/maki/car.png +0 -0
  115. package/vendor/cesium/Assets/Textures/maki/cemetery.png +0 -0
  116. package/vendor/cesium/Assets/Textures/maki/cesium.png +0 -0
  117. package/vendor/cesium/Assets/Textures/maki/chemist.png +0 -0
  118. package/vendor/cesium/Assets/Textures/maki/cinema.png +0 -0
  119. package/vendor/cesium/Assets/Textures/maki/circle-stroked.png +0 -0
  120. package/vendor/cesium/Assets/Textures/maki/circle.png +0 -0
  121. package/vendor/cesium/Assets/Textures/maki/city.png +0 -0
  122. package/vendor/cesium/Assets/Textures/maki/clothing-store.png +0 -0
  123. package/vendor/cesium/Assets/Textures/maki/college.png +0 -0
  124. package/vendor/cesium/Assets/Textures/maki/commercial.png +0 -0
  125. package/vendor/cesium/Assets/Textures/maki/cricket.png +0 -0
  126. package/vendor/cesium/Assets/Textures/maki/cross.png +0 -0
  127. package/vendor/cesium/Assets/Textures/maki/dam.png +0 -0
  128. package/vendor/cesium/Assets/Textures/maki/danger.png +0 -0
  129. package/vendor/cesium/Assets/Textures/maki/disability.png +0 -0
  130. package/vendor/cesium/Assets/Textures/maki/dog-park.png +0 -0
  131. package/vendor/cesium/Assets/Textures/maki/embassy.png +0 -0
  132. package/vendor/cesium/Assets/Textures/maki/emergency-telephone.png +0 -0
  133. package/vendor/cesium/Assets/Textures/maki/entrance.png +0 -0
  134. package/vendor/cesium/Assets/Textures/maki/farm.png +0 -0
  135. package/vendor/cesium/Assets/Textures/maki/fast-food.png +0 -0
  136. package/vendor/cesium/Assets/Textures/maki/ferry.png +0 -0
  137. package/vendor/cesium/Assets/Textures/maki/fire-station.png +0 -0
  138. package/vendor/cesium/Assets/Textures/maki/fuel.png +0 -0
  139. package/vendor/cesium/Assets/Textures/maki/garden.png +0 -0
  140. package/vendor/cesium/Assets/Textures/maki/gift.png +0 -0
  141. package/vendor/cesium/Assets/Textures/maki/golf.png +0 -0
  142. package/vendor/cesium/Assets/Textures/maki/grocery.png +0 -0
  143. package/vendor/cesium/Assets/Textures/maki/hairdresser.png +0 -0
  144. package/vendor/cesium/Assets/Textures/maki/harbor.png +0 -0
  145. package/vendor/cesium/Assets/Textures/maki/heart.png +0 -0
  146. package/vendor/cesium/Assets/Textures/maki/heliport.png +0 -0
  147. package/vendor/cesium/Assets/Textures/maki/hospital.png +0 -0
  148. package/vendor/cesium/Assets/Textures/maki/ice-cream.png +0 -0
  149. package/vendor/cesium/Assets/Textures/maki/industrial.png +0 -0
  150. package/vendor/cesium/Assets/Textures/maki/land-use.png +0 -0
  151. package/vendor/cesium/Assets/Textures/maki/laundry.png +0 -0
  152. package/vendor/cesium/Assets/Textures/maki/library.png +0 -0
  153. package/vendor/cesium/Assets/Textures/maki/lighthouse.png +0 -0
  154. package/vendor/cesium/Assets/Textures/maki/lodging.png +0 -0
  155. package/vendor/cesium/Assets/Textures/maki/logging.png +0 -0
  156. package/vendor/cesium/Assets/Textures/maki/london-underground.png +0 -0
  157. package/vendor/cesium/Assets/Textures/maki/marker-stroked.png +0 -0
  158. package/vendor/cesium/Assets/Textures/maki/marker.png +0 -0
  159. package/vendor/cesium/Assets/Textures/maki/minefield.png +0 -0
  160. package/vendor/cesium/Assets/Textures/maki/mobilephone.png +0 -0
  161. package/vendor/cesium/Assets/Textures/maki/monument.png +0 -0
  162. package/vendor/cesium/Assets/Textures/maki/museum.png +0 -0
  163. package/vendor/cesium/Assets/Textures/maki/music.png +0 -0
  164. package/vendor/cesium/Assets/Textures/maki/oil-well.png +0 -0
  165. package/vendor/cesium/Assets/Textures/maki/park.png +0 -0
  166. package/vendor/cesium/Assets/Textures/maki/park2.png +0 -0
  167. package/vendor/cesium/Assets/Textures/maki/parking-garage.png +0 -0
  168. package/vendor/cesium/Assets/Textures/maki/parking.png +0 -0
  169. package/vendor/cesium/Assets/Textures/maki/pharmacy.png +0 -0
  170. package/vendor/cesium/Assets/Textures/maki/pitch.png +0 -0
  171. package/vendor/cesium/Assets/Textures/maki/place-of-worship.png +0 -0
  172. package/vendor/cesium/Assets/Textures/maki/playground.png +0 -0
  173. package/vendor/cesium/Assets/Textures/maki/police.png +0 -0
  174. package/vendor/cesium/Assets/Textures/maki/polling-place.png +0 -0
  175. package/vendor/cesium/Assets/Textures/maki/post.png +0 -0
  176. package/vendor/cesium/Assets/Textures/maki/prison.png +0 -0
  177. package/vendor/cesium/Assets/Textures/maki/rail-above.png +0 -0
  178. package/vendor/cesium/Assets/Textures/maki/rail-light.png +0 -0
  179. package/vendor/cesium/Assets/Textures/maki/rail-metro.png +0 -0
  180. package/vendor/cesium/Assets/Textures/maki/rail-underground.png +0 -0
  181. package/vendor/cesium/Assets/Textures/maki/rail.png +0 -0
  182. package/vendor/cesium/Assets/Textures/maki/religious-christian.png +0 -0
  183. package/vendor/cesium/Assets/Textures/maki/religious-jewish.png +0 -0
  184. package/vendor/cesium/Assets/Textures/maki/religious-muslim.png +0 -0
  185. package/vendor/cesium/Assets/Textures/maki/restaurant.png +0 -0
  186. package/vendor/cesium/Assets/Textures/maki/roadblock.png +0 -0
  187. package/vendor/cesium/Assets/Textures/maki/rocket.png +0 -0
  188. package/vendor/cesium/Assets/Textures/maki/school.png +0 -0
  189. package/vendor/cesium/Assets/Textures/maki/scooter.png +0 -0
  190. package/vendor/cesium/Assets/Textures/maki/shop.png +0 -0
  191. package/vendor/cesium/Assets/Textures/maki/skiing.png +0 -0
  192. package/vendor/cesium/Assets/Textures/maki/slaughterhouse.png +0 -0
  193. package/vendor/cesium/Assets/Textures/maki/soccer.png +0 -0
  194. package/vendor/cesium/Assets/Textures/maki/square-stroked.png +0 -0
  195. package/vendor/cesium/Assets/Textures/maki/square.png +0 -0
  196. package/vendor/cesium/Assets/Textures/maki/star-stroked.png +0 -0
  197. package/vendor/cesium/Assets/Textures/maki/star.png +0 -0
  198. package/vendor/cesium/Assets/Textures/maki/suitcase.png +0 -0
  199. package/vendor/cesium/Assets/Textures/maki/swimming.png +0 -0
  200. package/vendor/cesium/Assets/Textures/maki/telephone.png +0 -0
  201. package/vendor/cesium/Assets/Textures/maki/tennis.png +0 -0
  202. package/vendor/cesium/Assets/Textures/maki/theatre.png +0 -0
  203. package/vendor/cesium/Assets/Textures/maki/toilets.png +0 -0
  204. package/vendor/cesium/Assets/Textures/maki/town-hall.png +0 -0
  205. package/vendor/cesium/Assets/Textures/maki/town.png +0 -0
  206. package/vendor/cesium/Assets/Textures/maki/triangle-stroked.png +0 -0
  207. package/vendor/cesium/Assets/Textures/maki/triangle.png +0 -0
  208. package/vendor/cesium/Assets/Textures/maki/village.png +0 -0
  209. package/vendor/cesium/Assets/Textures/maki/warehouse.png +0 -0
  210. package/vendor/cesium/Assets/Textures/maki/waste-basket.png +0 -0
  211. package/vendor/cesium/Assets/Textures/maki/water.png +0 -0
  212. package/vendor/cesium/Assets/Textures/maki/wetland.png +0 -0
  213. package/vendor/cesium/Assets/Textures/maki/zoo.png +0 -0
  214. package/vendor/cesium/Assets/Textures/moonSmall.jpg +0 -0
  215. package/vendor/cesium/Assets/Textures/pin.svg +1 -0
  216. package/vendor/cesium/Assets/Textures/waterNormals.jpg +0 -0
  217. package/vendor/cesium/Assets/Textures/waterNormalsSmall.jpg +0 -0
  218. package/vendor/cesium/Assets/approximateTerrainHeights.json +1 -0
  219. package/vendor/cesium/Cesium.js +18348 -0
  220. package/vendor/cesium/LICENSE.md +1068 -0
  221. package/vendor/cesium/ThirdParty/Workers/package.json +1 -0
  222. package/vendor/cesium/ThirdParty/Workers/zip-web-worker.js +1 -0
  223. package/vendor/cesium/ThirdParty/basis_transcoder.wasm +0 -0
  224. package/vendor/cesium/ThirdParty/draco_decoder.wasm +0 -0
  225. package/vendor/cesium/ThirdParty/google-earth-dbroot-parser.js +1 -0
  226. package/vendor/cesium/ThirdParty/wasm_splats_bg.wasm +0 -0
  227. package/vendor/cesium/ThirdParty/zip-module.wasm +0 -0
  228. package/vendor/cesium/Widgets/Animation/Animation.css +127 -0
  229. package/vendor/cesium/Widgets/Animation/lighter.css +70 -0
  230. package/vendor/cesium/Widgets/BaseLayerPicker/BaseLayerPicker.css +108 -0
  231. package/vendor/cesium/Widgets/BaseLayerPicker/lighter.css +22 -0
  232. package/vendor/cesium/Widgets/Cesium3DTilesInspector/Cesium3DTilesInspector.css +102 -0
  233. package/vendor/cesium/Widgets/CesiumInspector/CesiumInspector.css +113 -0
  234. package/vendor/cesium/Widgets/CesiumWidget/CesiumWidget.css +119 -0
  235. package/vendor/cesium/Widgets/CesiumWidget/lighter.css +14 -0
  236. package/vendor/cesium/Widgets/FullscreenButton/FullscreenButton.css +8 -0
  237. package/vendor/cesium/Widgets/Geocoder/Geocoder.css +70 -0
  238. package/vendor/cesium/Widgets/Geocoder/lighter.css +17 -0
  239. package/vendor/cesium/Widgets/I3SBuildingSceneLayerExplorer/I3SBuildingSceneLayerExplorer.css +27 -0
  240. package/vendor/cesium/Widgets/Images/ImageryProviders/ArcGisMapServiceWorldHillshade.png +0 -0
  241. package/vendor/cesium/Widgets/Images/ImageryProviders/ArcGisMapServiceWorldImagery.png +0 -0
  242. package/vendor/cesium/Widgets/Images/ImageryProviders/ArcGisMapServiceWorldOcean.png +0 -0
  243. package/vendor/cesium/Widgets/Images/ImageryProviders/azureAerial.png +0 -0
  244. package/vendor/cesium/Widgets/Images/ImageryProviders/azureRoads.png +0 -0
  245. package/vendor/cesium/Widgets/Images/ImageryProviders/bingAerial.png +0 -0
  246. package/vendor/cesium/Widgets/Images/ImageryProviders/bingAerialLabels.png +0 -0
  247. package/vendor/cesium/Widgets/Images/ImageryProviders/bingRoads.png +0 -0
  248. package/vendor/cesium/Widgets/Images/ImageryProviders/blueMarble.png +0 -0
  249. package/vendor/cesium/Widgets/Images/ImageryProviders/earthAtNight.png +0 -0
  250. package/vendor/cesium/Widgets/Images/ImageryProviders/googleContour.png +0 -0
  251. package/vendor/cesium/Widgets/Images/ImageryProviders/googleRoadmap.png +0 -0
  252. package/vendor/cesium/Widgets/Images/ImageryProviders/googleSatellite.png +0 -0
  253. package/vendor/cesium/Widgets/Images/ImageryProviders/googleSatelliteLabels.png +0 -0
  254. package/vendor/cesium/Widgets/Images/ImageryProviders/mapQuestOpenStreetMap.png +0 -0
  255. package/vendor/cesium/Widgets/Images/ImageryProviders/mapboxSatellite.png +0 -0
  256. package/vendor/cesium/Widgets/Images/ImageryProviders/mapboxStreets.png +0 -0
  257. package/vendor/cesium/Widgets/Images/ImageryProviders/mapboxTerrain.png +0 -0
  258. package/vendor/cesium/Widgets/Images/ImageryProviders/naturalEarthII.png +0 -0
  259. package/vendor/cesium/Widgets/Images/ImageryProviders/openStreetMap.png +0 -0
  260. package/vendor/cesium/Widgets/Images/ImageryProviders/sentinel-2.png +0 -0
  261. package/vendor/cesium/Widgets/Images/ImageryProviders/stadiaAlidadeSmooth.png +0 -0
  262. package/vendor/cesium/Widgets/Images/ImageryProviders/stadiaAlidadeSmoothDark.png +0 -0
  263. package/vendor/cesium/Widgets/Images/ImageryProviders/stamenToner.png +0 -0
  264. package/vendor/cesium/Widgets/Images/ImageryProviders/stamenWatercolor.png +0 -0
  265. package/vendor/cesium/Widgets/Images/NavigationHelp/Mouse.svg +84 -0
  266. package/vendor/cesium/Widgets/Images/NavigationHelp/MouseLeft.svg +76 -0
  267. package/vendor/cesium/Widgets/Images/NavigationHelp/MouseMiddle.svg +76 -0
  268. package/vendor/cesium/Widgets/Images/NavigationHelp/MouseRight.svg +76 -0
  269. package/vendor/cesium/Widgets/Images/NavigationHelp/Touch.svg +120 -0
  270. package/vendor/cesium/Widgets/Images/NavigationHelp/TouchDrag.svg +129 -0
  271. package/vendor/cesium/Widgets/Images/NavigationHelp/TouchRotate.svg +76 -0
  272. package/vendor/cesium/Widgets/Images/NavigationHelp/TouchTilt.svg +135 -0
  273. package/vendor/cesium/Widgets/Images/NavigationHelp/TouchZoom.svg +74 -0
  274. package/vendor/cesium/Widgets/Images/TerrainProviders/CesiumWorldTerrain.png +0 -0
  275. package/vendor/cesium/Widgets/Images/TerrainProviders/Ellipsoid.png +0 -0
  276. package/vendor/cesium/Widgets/Images/TimelineIcons.png +0 -0
  277. package/vendor/cesium/Widgets/Images/info-loading.gif +0 -0
  278. package/vendor/cesium/Widgets/InfoBox/InfoBox.css +92 -0
  279. package/vendor/cesium/Widgets/InfoBox/InfoBoxDescription.css +178 -0
  280. package/vendor/cesium/Widgets/NavigationHelpButton/NavigationHelpButton.css +93 -0
  281. package/vendor/cesium/Widgets/NavigationHelpButton/lighter.css +38 -0
  282. package/vendor/cesium/Widgets/PerformanceWatchdog/PerformanceWatchdog.css +15 -0
  283. package/vendor/cesium/Widgets/ProjectionPicker/ProjectionPicker.css +38 -0
  284. package/vendor/cesium/Widgets/SceneModePicker/SceneModePicker.css +56 -0
  285. package/vendor/cesium/Widgets/SelectionIndicator/SelectionIndicator.css +20 -0
  286. package/vendor/cesium/Widgets/Timeline/Timeline.css +103 -0
  287. package/vendor/cesium/Widgets/Timeline/lighter.css +23 -0
  288. package/vendor/cesium/Widgets/VRButton/VRButton.css +8 -0
  289. package/vendor/cesium/Widgets/Viewer/Viewer.css +107 -0
  290. package/vendor/cesium/Widgets/VoxelInspector/VoxelInspector.css +16 -0
  291. package/vendor/cesium/Widgets/lighter.css +237 -0
  292. package/vendor/cesium/Widgets/lighterShared.css +46 -0
  293. package/vendor/cesium/Widgets/shared.css +103 -0
  294. package/vendor/cesium/Widgets/widgets.css +1342 -0
  295. package/vendor/cesium/Workers/chunk-3XZ3CL24.js +26 -0
  296. package/vendor/cesium/Workers/chunk-674HC7OA.js +26 -0
  297. package/vendor/cesium/Workers/chunk-6LO6MWK3.js +26 -0
  298. package/vendor/cesium/Workers/chunk-F6M5ZWO4.js +29 -0
  299. package/vendor/cesium/Workers/chunk-FZYUOFLY.js +70 -0
  300. package/vendor/cesium/Workers/chunk-GACOCTFW.js +26 -0
  301. package/vendor/cesium/Workers/chunk-MDI2T7LJ.js +27 -0
  302. package/vendor/cesium/Workers/chunk-P6X455E3.js +27 -0
  303. package/vendor/cesium/Workers/chunk-SOIWLLGP.js +26 -0
  304. package/vendor/cesium/Workers/chunk-UBKFZE5C.js +26 -0
  305. package/vendor/cesium/Workers/combineGeometry.js +26 -0
  306. package/vendor/cesium/Workers/createBoxGeometry.js +26 -0
  307. package/vendor/cesium/Workers/createBoxOutlineGeometry.js +26 -0
  308. package/vendor/cesium/Workers/createCircleGeometry.js +26 -0
  309. package/vendor/cesium/Workers/createCircleOutlineGeometry.js +26 -0
  310. package/vendor/cesium/Workers/createCoplanarPolygonGeometry.js +26 -0
  311. package/vendor/cesium/Workers/createCoplanarPolygonOutlineGeometry.js +26 -0
  312. package/vendor/cesium/Workers/createCorridorGeometry.js +26 -0
  313. package/vendor/cesium/Workers/createCorridorOutlineGeometry.js +26 -0
  314. package/vendor/cesium/Workers/createCylinderGeometry.js +26 -0
  315. package/vendor/cesium/Workers/createCylinderOutlineGeometry.js +26 -0
  316. package/vendor/cesium/Workers/createEllipseGeometry.js +26 -0
  317. package/vendor/cesium/Workers/createEllipseOutlineGeometry.js +26 -0
  318. package/vendor/cesium/Workers/createEllipsoidGeometry.js +26 -0
  319. package/vendor/cesium/Workers/createEllipsoidOutlineGeometry.js +26 -0
  320. package/vendor/cesium/Workers/createFrustumGeometry.js +26 -0
  321. package/vendor/cesium/Workers/createFrustumOutlineGeometry.js +26 -0
  322. package/vendor/cesium/Workers/createGeometry.js +26 -0
  323. package/vendor/cesium/Workers/createGroundPolylineGeometry.js +26 -0
  324. package/vendor/cesium/Workers/createPlaneGeometry.js +26 -0
  325. package/vendor/cesium/Workers/createPlaneOutlineGeometry.js +26 -0
  326. package/vendor/cesium/Workers/createPolygonGeometry.js +26 -0
  327. package/vendor/cesium/Workers/createPolygonOutlineGeometry.js +26 -0
  328. package/vendor/cesium/Workers/createPolylineGeometry.js +26 -0
  329. package/vendor/cesium/Workers/createPolylineVolumeGeometry.js +26 -0
  330. package/vendor/cesium/Workers/createPolylineVolumeOutlineGeometry.js +26 -0
  331. package/vendor/cesium/Workers/createRectangleGeometry.js +26 -0
  332. package/vendor/cesium/Workers/createRectangleOutlineGeometry.js +26 -0
  333. package/vendor/cesium/Workers/createSimplePolylineGeometry.js +26 -0
  334. package/vendor/cesium/Workers/createSphereGeometry.js +26 -0
  335. package/vendor/cesium/Workers/createSphereOutlineGeometry.js +26 -0
  336. package/vendor/cesium/Workers/createTaskProcessorWorker.js +26 -0
  337. package/vendor/cesium/Workers/createVectorTileClampedPolylines.js +26 -0
  338. package/vendor/cesium/Workers/createVectorTileGeometries.js +26 -0
  339. package/vendor/cesium/Workers/createVectorTilePoints.js +26 -0
  340. package/vendor/cesium/Workers/createVectorTilePolygons.js +26 -0
  341. package/vendor/cesium/Workers/createVectorTilePolylines.js +26 -0
  342. package/vendor/cesium/Workers/createVerticesFromCesium3DTilesTerrain.js +26 -0
  343. package/vendor/cesium/Workers/createVerticesFromGoogleEarthEnterpriseBuffer.js +26 -0
  344. package/vendor/cesium/Workers/createVerticesFromHeightmap.js +26 -0
  345. package/vendor/cesium/Workers/createVerticesFromQuantizedTerrainMesh.js +26 -0
  346. package/vendor/cesium/Workers/createWallGeometry.js +26 -0
  347. package/vendor/cesium/Workers/createWallOutlineGeometry.js +26 -0
  348. package/vendor/cesium/Workers/decodeDraco.js +26 -0
  349. package/vendor/cesium/Workers/decodeGoogleEarthEnterprisePacket.js +26 -0
  350. package/vendor/cesium/Workers/decodeI3S.js +26 -0
  351. package/vendor/cesium/Workers/gaussianSplatSorter.js +26 -0
  352. package/vendor/cesium/Workers/gaussianSplatTextureGenerator.js +26 -0
  353. package/vendor/cesium/Workers/incrementallyBuildTerrainPicker.js +26 -0
  354. package/vendor/cesium/Workers/transcodeKTX2.js +56 -0
  355. package/vendor/cesium/Workers/transferTypedArrayTest.js +26 -0
  356. package/vendor/cesium/Workers/upsampleQuantizedTerrainMesh.js +26 -0
  357. package/vendor/cesium/Workers/upsampleVerticesFromCesium3DTilesTerrain.js +26 -0
  358. package/vendor/cesium/index.cjs +18348 -0
  359. package/vendor/cesium/index.js +18348 -0
package/client.js ADDED
@@ -0,0 +1,2297 @@
1
+ /**
2
+ * Client half of the Digital Earth plugin: a full-page globe panel that renders
3
+ * imagery basemaps, terrain and any number of local/online 3D Tiles layers on
4
+ * top of CesiumJS.
5
+ *
6
+ * The CesiumJS build is vendored in the package and served by the Host half,
7
+ * so the panel is fully offline-capable and never depends on a CDN.
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: '@cveo/dsh-digital-earth',
11
+ factory(require) {
12
+ const React = require('react')
13
+ const h = React.createElement
14
+
15
+ let appCtx = null
16
+ let metaPromise = null
17
+ let cesiumPromise = null
18
+ let cesiumStylePromise = null
19
+ let saveChain = Promise.resolve()
20
+
21
+ /** Read a Cordis client service without breaking when it is not mounted. */
22
+ const service = (key) => {
23
+ try {
24
+ return appCtx?.get?.(key) ?? undefined
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ //#region localization -----------------------------------------------------
31
+
32
+ const MESSAGES = {
33
+ zh: {
34
+ 'panel.title': '数字地球',
35
+ 'panel.status.loading': '正在加载三维引擎…',
36
+ 'panel.status.ready': '就绪',
37
+ 'panel.status.layers.one': '1 个图层',
38
+ 'panel.status.layers.other': '{n} 个图层',
39
+ 'panel.empty.title': '在地球上叠加你的数据',
40
+ 'panel.empty.body':
41
+ '打开本地目录或在线地址,把 3D Tiles(b3dm / glb / 3DGS)叠加到 CesiumJS 地球上;底图、地形与量测工具已就绪。',
42
+ 'panel.empty.local': '打开本地目录',
43
+ 'panel.empty.url': '加载在线地址',
44
+ 'btn.data': '数据',
45
+ 'btn.openLocal': '打开本地目录…',
46
+ 'btn.openUrl': '加载在线地址…',
47
+ 'btn.layers': '图层',
48
+ 'btn.basemap': '底图',
49
+ 'btn.terrain': '地形',
50
+ 'btn.measure': '量测',
51
+ 'btn.screenshot': '截图',
52
+ 'btn.reset': '复位',
53
+ 'btn.settings': '数据源与外观',
54
+ 'btn.appearance': '外观',
55
+ 'btn.close': '关闭',
56
+ 'btn.cancel': '取消',
57
+ 'btn.retry': '重试',
58
+ 'btn.save': '保存',
59
+ 'btn.add': '添加',
60
+ 'btn.edit': '编辑',
61
+ 'btn.delete': '删除',
62
+ 'btn.restore': '恢复默认',
63
+ 'btn.flyTo': '定位',
64
+ 'btn.visible': '显示',
65
+ 'btn.hidden': '隐藏',
66
+ 'menu.none': '无底图',
67
+ 'menu.terrainOff': '关闭地形',
68
+ 'tool.distance': '测距',
69
+ 'tool.area': '测面',
70
+ 'tool.height': '测高',
71
+ 'tool.pick': '拾取属性',
72
+ 'tool.clear': '清除量测结果',
73
+ 'hud.pointer': '光标',
74
+ 'hud.camera': '相机高度',
75
+ 'hud.heading': '朝向',
76
+ 'hud.tiles': '瓦片',
77
+ 'hud.pending': '待加载',
78
+ 'hud.triangles': '三角面',
79
+ 'hud.cache': '缓存',
80
+ 'hud.elevation': '高程 {value}',
81
+ 'hud.empty': '—',
82
+ 'layers.title': '图层',
83
+ 'layers.empty': '暂无图层',
84
+ 'urlbar.placeholder': 'tileset.json 的 http(s) 地址,或本地绝对路径',
85
+ 'urlbar.submit': '加载',
86
+ 'toast.scanEmpty': '该目录下没有找到 tileset.json',
87
+ 'toast.scanFailed': '打开目录失败:{message}',
88
+ 'toast.dialogUnavailable': '目录选择服务不可用',
89
+ 'toast.invalidPath': '本地路径无效',
90
+ 'toast.invalidUrl': '请输入 http(s) 地址或本地 tileset.json 路径',
91
+ 'toast.proxyFallback': '直连失败,已通过本地代理加载',
92
+ 'toast.loadFailed': '加载失败:{message}',
93
+ 'toast.loadingData': '正在加载数据…',
94
+ 'toast.busyDirectory': '正在打开目录…',
95
+ 'toast.terrainLoading': '正在加载地形…',
96
+ 'toast.terrainOn': '已开启地形:{name}',
97
+ 'toast.terrainOff': '已关闭地形,恢复椭球',
98
+ 'toast.terrainFailed': '地形加载失败,继续使用椭球:{message}',
99
+ 'toast.basemap': '已切换底图:{name}',
100
+ 'toast.basemapNone': '已关闭影像底图',
101
+ 'toast.basemapFailed': '底图加载失败:{message}',
102
+ 'toast.screenshotSaved': '截图已保存',
103
+ 'toast.screenshotFailed': '截图失败:{message}',
104
+ 'toast.sourcesSaved': '数据源已保存',
105
+ 'toast.saveFailed': '保存失败:{message}',
106
+ 'toast.defaultsRestored': '已恢复默认数据源',
107
+ 'toast.noSurface': '该位置没有可拾取的表面',
108
+ 'toast.pickNone': '未拾取到对象',
109
+ 'measure.start': '左键点击取点,双击结束',
110
+ 'measure.distance': '总长 {value} · 双击结束',
111
+ 'measure.area': '面积 {value} · 双击结束',
112
+ 'measure.height': '高差 {value} · 水平距 {distance}',
113
+ 'measure.total': '总长 {value}',
114
+ 'measure.areaLabel': '面积 {value}',
115
+ 'pick.title': '拾取对象',
116
+ 'pick.type': '类型',
117
+ 'pick.layer': '图层',
118
+ 'pick.name': '名称',
119
+ 'pick.entity': '实体',
120
+ 'pick.scene': '场景对象',
121
+ 'pick.tiles': '3D Tiles 模型',
122
+ 'pick.noProperties': '说明',
123
+ 'pick.noPropertiesValue': '该瓦片没有属性表',
124
+ 'model.unnamed': '3D Tiles 模型',
125
+ 'credit.imagery': '影像',
126
+ 'settings.title': '数据源与外观',
127
+ 'settings.team': '武汉大学 CVEO 课题组',
128
+ 'settings.tab.imagery': '影像底图',
129
+ 'settings.tab.terrain': '地形',
130
+ 'settings.tab.appearance': '外观',
131
+ 'settings.imageryHint': '此处维护「底图」菜单中的影像源;单选按钮切换当前底图。',
132
+ 'settings.terrainHint': '此处维护可选的地形源;单选按钮用于开启或切换地形。',
133
+ 'settings.empty': '还没有数据源,点击「添加」新建一个。',
134
+ 'settings.current': '当前',
135
+ 'settings.field.name': '名称',
136
+ 'settings.field.type': '类型',
137
+ 'settings.field.url': '地址',
138
+ 'settings.field.credit': '版权署名',
139
+ 'settings.field.maxLevel': '最大层级',
140
+ 'settings.field.subdomains': '子域名(逗号分隔)',
141
+ 'settings.field.tms': 'TMS(Y 轴南起)',
142
+ 'settings.field.localTiles': '选择本地瓦片目录…',
143
+ 'settings.field.layers': 'WMS 图层名',
144
+ 'settings.field.tilingScheme': '投影方式',
145
+ 'settings.field.normals': '请求顶点法线',
146
+ 'settings.field.waterMask': '请求水面掩膜',
147
+ 'settings.type.xyz': 'XYZ 瓦片模板',
148
+ 'settings.type.arcgis': 'ArcGIS 服务',
149
+ 'settings.type.wms': 'WMS',
150
+ 'settings.type.cesium': 'Cesium 地形',
151
+ 'settings.tiling.webMercator': 'Web 墨卡托',
152
+ 'settings.tiling.geographic': '经纬度',
153
+ 'settings.required': '请填写名称与地址',
154
+ 'settings.deleteConfirm': '确定删除数据源「{name}」?',
155
+ 'settings.restoreConfirm': '恢复默认数据源?当前配置将被覆盖。',
156
+ 'theme.auto': '跟随系统',
157
+ 'theme.light': '浅色',
158
+ 'theme.dark': '深色',
159
+ 'theme.appearanceHint': '默认跟随 DSH / 系统的浅深色;选择具体模式可覆盖本面板的外观。',
160
+ 'unit.hectare': '公顷',
161
+ },
162
+ en: {
163
+ 'panel.title': 'Digital Earth',
164
+ 'panel.status.loading': 'Loading 3D engine…',
165
+ 'panel.status.ready': 'Ready',
166
+ 'panel.status.layers.one': '1 layer',
167
+ 'panel.status.layers.other': '{n} layers',
168
+ 'panel.empty.title': 'Lay your data over the globe',
169
+ 'panel.empty.body':
170
+ 'Open a local folder or an online URL to add 3D Tiles (b3dm / glb / 3DGS) to this CesiumJS globe. Imagery, terrain and measurement tools are ready.',
171
+ 'panel.empty.local': 'Open local folder',
172
+ 'panel.empty.url': 'Load from URL',
173
+ 'btn.data': 'Data',
174
+ 'btn.openLocal': 'Open local folder…',
175
+ 'btn.openUrl': 'Load from URL…',
176
+ 'btn.layers': 'Layers',
177
+ 'btn.basemap': 'Basemap',
178
+ 'btn.terrain': 'Terrain',
179
+ 'btn.measure': 'Measure',
180
+ 'btn.screenshot': 'Snapshot',
181
+ 'btn.reset': 'Reset',
182
+ 'btn.settings': 'Sources & appearance',
183
+ 'btn.appearance': 'Appearance',
184
+ 'btn.close': 'Close',
185
+ 'btn.cancel': 'Cancel',
186
+ 'btn.retry': 'Retry',
187
+ 'btn.save': 'Save',
188
+ 'btn.add': 'Add',
189
+ 'btn.edit': 'Edit',
190
+ 'btn.delete': 'Delete',
191
+ 'btn.restore': 'Restore defaults',
192
+ 'btn.flyTo': 'Fly to',
193
+ 'btn.visible': 'Show',
194
+ 'btn.hidden': 'Hide',
195
+ 'menu.none': 'No basemap',
196
+ 'menu.terrainOff': 'Terrain off',
197
+ 'tool.distance': 'Distance',
198
+ 'tool.area': 'Area',
199
+ 'tool.height': 'Height',
200
+ 'tool.pick': 'Pick properties',
201
+ 'tool.clear': 'Clear measurements',
202
+ 'hud.pointer': 'Cursor',
203
+ 'hud.camera': 'Camera',
204
+ 'hud.heading': 'Heading',
205
+ 'hud.tiles': 'Tiles',
206
+ 'hud.pending': 'Pending',
207
+ 'hud.triangles': 'Triangles',
208
+ 'hud.cache': 'Cache',
209
+ 'hud.elevation': 'Elev {value}',
210
+ 'hud.empty': '—',
211
+ 'layers.title': 'Layers',
212
+ 'layers.empty': 'No layers yet',
213
+ 'urlbar.placeholder': 'http(s) URL of tileset.json, or an absolute local path',
214
+ 'urlbar.submit': 'Load',
215
+ 'toast.scanEmpty': 'No tileset.json found in that folder',
216
+ 'toast.scanFailed': 'Failed to open folder: {message}',
217
+ 'toast.dialogUnavailable': 'Directory picker is unavailable',
218
+ 'toast.invalidPath': 'Invalid local path',
219
+ 'toast.invalidUrl': 'Enter an http(s) URL or a local tileset.json path',
220
+ 'toast.proxyFallback': 'Direct request failed; loaded through the local proxy',
221
+ 'toast.loadFailed': 'Load failed: {message}',
222
+ 'toast.loadingData': 'Loading dataset…',
223
+ 'toast.busyDirectory': 'Opening folder…',
224
+ 'toast.terrainLoading': 'Loading terrain…',
225
+ 'toast.terrainOn': 'Terrain enabled: {name}',
226
+ 'toast.terrainOff': 'Terrain off; ellipsoid restored',
227
+ 'toast.terrainFailed': 'Terrain failed; keeping the ellipsoid: {message}',
228
+ 'toast.basemap': 'Basemap: {name}',
229
+ 'toast.basemapNone': 'Imagery basemap off',
230
+ 'toast.basemapFailed': 'Basemap failed: {message}',
231
+ 'toast.screenshotSaved': 'Snapshot saved',
232
+ 'toast.screenshotFailed': 'Snapshot failed: {message}',
233
+ 'toast.sourcesSaved': 'Sources saved',
234
+ 'toast.saveFailed': 'Save failed: {message}',
235
+ 'toast.defaultsRestored': 'Default sources restored',
236
+ 'toast.noSurface': 'No pickable surface there',
237
+ 'toast.pickNone': 'Nothing picked',
238
+ 'measure.start': 'Left-click to add points, double-click to finish',
239
+ 'measure.distance': 'Total {value} · double-click to finish',
240
+ 'measure.area': 'Area {value} · double-click to finish',
241
+ 'measure.height': 'Δh {value} · horizontal {distance}',
242
+ 'measure.total': 'Total {value}',
243
+ 'measure.areaLabel': 'Area {value}',
244
+ 'pick.title': 'Picked object',
245
+ 'pick.type': 'Type',
246
+ 'pick.layer': 'Layer',
247
+ 'pick.name': 'Name',
248
+ 'pick.entity': 'Entity',
249
+ 'pick.scene': 'Scene object',
250
+ 'pick.tiles': '3D Tiles model',
251
+ 'pick.noProperties': 'Info',
252
+ 'pick.noPropertiesValue': 'This tile has no property table',
253
+ 'model.unnamed': '3D Tiles model',
254
+ 'credit.imagery': 'Imagery',
255
+ 'settings.title': 'Sources & appearance',
256
+ 'settings.team': 'Wuhan University CVEO Group',
257
+ 'settings.tab.imagery': 'Imagery',
258
+ 'settings.tab.terrain': 'Terrain',
259
+ 'settings.tab.appearance': 'Appearance',
260
+ 'settings.imageryHint': 'Manage the imagery sources offered in the Basemap menu; the radio button picks the active one.',
261
+ 'settings.terrainHint': 'Manage the available terrain sources; the radio button enables or switches terrain.',
262
+ 'settings.empty': 'No sources yet — click Add to create one.',
263
+ 'settings.current': 'Current',
264
+ 'settings.field.name': 'Name',
265
+ 'settings.field.type': 'Type',
266
+ 'settings.field.url': 'URL',
267
+ 'settings.field.credit': 'Credit',
268
+ 'settings.field.maxLevel': 'Max zoom level',
269
+ 'settings.field.subdomains': 'Subdomains (comma separated)',
270
+ 'settings.field.tms': 'TMS (south-origin Y)',
271
+ 'settings.field.localTiles': 'Pick local tiles…',
272
+ 'settings.field.layers': 'WMS layers',
273
+ 'settings.field.tilingScheme': 'Projection',
274
+ 'settings.field.normals': 'Request vertex normals',
275
+ 'settings.field.waterMask': 'Request water mask',
276
+ 'settings.type.xyz': 'XYZ tile template',
277
+ 'settings.type.arcgis': 'ArcGIS service',
278
+ 'settings.type.wms': 'WMS',
279
+ 'settings.type.cesium': 'Cesium terrain',
280
+ 'settings.tiling.webMercator': 'Web Mercator',
281
+ 'settings.tiling.geographic': 'Geographic',
282
+ 'settings.required': 'Name and URL are required',
283
+ 'settings.deleteConfirm': 'Delete source "{name}"?',
284
+ 'settings.restoreConfirm': 'Restore default sources? The current configuration is replaced.',
285
+ 'theme.auto': 'Follow system',
286
+ 'theme.light': 'Light',
287
+ 'theme.dark': 'Dark',
288
+ 'theme.appearanceHint': 'Follows the DSH / system light-dark theme by default; pick a mode to override this panel only.',
289
+ 'unit.hectare': 'ha',
290
+ },
291
+ }
292
+
293
+ const localeId = () => {
294
+ const active = service('locale')?.getSnapshot?.()?.active
295
+ if (typeof active === 'string' && active !== '') return active
296
+ return typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en'
297
+ }
298
+ const isChinese = () => localeId().toLowerCase().startsWith('zh')
299
+
300
+ /** Translate a copy key; `{name}` placeholders interpolate from `params`. */
301
+ const t = (key, params) => {
302
+ const table = isChinese() ? MESSAGES.zh : MESSAGES.en
303
+ let text = table[key] ?? MESSAGES.en[key] ?? key
304
+ if (params !== undefined) {
305
+ for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
306
+ }
307
+ return text
308
+ }
309
+
310
+ /** Re-render the panel when the DSH locale changes. */
311
+ function useTranslation() {
312
+ React.useSyncExternalStore(
313
+ React.useCallback((callback) => service('locale')?.subscribe?.(callback) ?? (() => {}), []),
314
+ localeId,
315
+ )
316
+ return t
317
+ }
318
+ //#endregion localization
319
+
320
+ //#region theme ------------------------------------------------------------
321
+
322
+ /** Whether DSH (or the OS, when DSH follows it) is currently dark. */
323
+ const darkFromEnvironment = () => {
324
+ const theme = service('theme')
325
+ const snapshot = theme?.getTheme?.()
326
+ const scheme = snapshot?.active?.colorScheme
327
+ if (scheme === 'dark') return true
328
+ if (scheme === 'light') return false
329
+ if (typeof document !== 'undefined' && document.body) {
330
+ if (document.body.hasAttribute('data-ds-dark-theme')) return true
331
+ const source = document.documentElement?.dataset?.dsThemeSource
332
+ if (source === 'dark') return true
333
+ if (source === 'light') return false
334
+ }
335
+ return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches
336
+ }
337
+
338
+ /**
339
+ * Track the resolved DSH colour scheme. `theme/change` is the documented
340
+ * channel; the media query covers a host without the theme service.
341
+ */
342
+ function useEnvironmentDark() {
343
+ const subscribe = React.useCallback((callback) => {
344
+ const theme = service('theme')
345
+ const disposers = []
346
+ if (theme && typeof appCtx?.on === 'function') disposers.push(appCtx.on('theme/change', callback))
347
+ if (typeof matchMedia === 'function') {
348
+ const media = matchMedia('(prefers-color-scheme: dark)')
349
+ media.addEventListener('change', callback)
350
+ disposers.push(() => media.removeEventListener('change', callback))
351
+ }
352
+ return () => {
353
+ for (const dispose of disposers) dispose()
354
+ }
355
+ }, [])
356
+ return React.useSyncExternalStore(subscribe, darkFromEnvironment)
357
+ }
358
+ //#endregion theme
359
+
360
+ //#region helpers ----------------------------------------------------------
361
+
362
+ const message = (error) => (error instanceof Error ? error.message : String(error))
363
+ const b64url = (value) => {
364
+ const bytes = new TextEncoder().encode(value)
365
+ let binary = ''
366
+ for (const byte of bytes) binary += String.fromCharCode(byte)
367
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
368
+ }
369
+ const formatValue = (value) => {
370
+ const text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value)
371
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text
372
+ }
373
+ const formatBytes = (value) => {
374
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '–'
375
+ if (value >= 1 << 30) return `${(value / (1 << 30)).toFixed(2)} GB`
376
+ if (value >= 1 << 20) return `${(value / (1 << 20)).toFixed(1)} MB`
377
+ if (value >= 1 << 10) return `${(value / 1024).toFixed(0)} KB`
378
+ return `${value} B`
379
+ }
380
+ const formatCount = (value) => {
381
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '–'
382
+ if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`
383
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)}k`
384
+ return String(value)
385
+ }
386
+ const formatDistance = (meters) =>
387
+ meters >= 1000 ? `${(meters / 1000).toFixed(3)} km` : `${meters.toFixed(2)} m`
388
+ const formatArea = (squareMeters) =>
389
+ squareMeters >= 1e6
390
+ ? `${(squareMeters / 1e6).toFixed(3)} km²`
391
+ : squareMeters >= 1e4
392
+ ? `${(squareMeters / 1e4).toFixed(2)} ${t('unit.hectare')}`
393
+ : `${squareMeters.toFixed(1)} m²`
394
+ const formatHeight = (meters) =>
395
+ Math.abs(meters) >= 1000 ? `${(meters / 1000).toFixed(2)} km` : `${meters.toFixed(1)} m`
396
+
397
+ function geodesicLength(Cesium, points) {
398
+ let total = 0
399
+ for (let index = 1; index < points.length; index += 1) {
400
+ const left = Cesium.Cartographic.fromCartesian(points[index - 1])
401
+ const right = Cesium.Cartographic.fromCartesian(points[index])
402
+ total += new Cesium.EllipsoidGeodesic(left, right).surfaceDistance
403
+ }
404
+ return total
405
+ }
406
+
407
+ function planarArea(Cesium, points) {
408
+ if (points.length < 3) return 0
409
+ const enu = Cesium.Transforms.eastNorthUpToFixedFrame(points[0])
410
+ const inverse = Cesium.Matrix4.inverseTransformation(enu, new Cesium.Matrix4())
411
+ const local = points.map((point) => Cesium.Matrix4.multiplyByPoint(inverse, point, new Cesium.Cartesian3()))
412
+ let sum = 0
413
+ for (let index = 0; index < local.length; index += 1) {
414
+ const current = local[index]
415
+ const next = local[(index + 1) % local.length]
416
+ sum += current.x * next.y - next.x * current.y
417
+ }
418
+ return Math.abs(sum) / 2
419
+ }
420
+
421
+ const clone = (value) => JSON.parse(JSON.stringify(value))
422
+ //#endregion helpers
423
+
424
+ //#region data access ------------------------------------------------------
425
+
426
+ const loadMeta = () => {
427
+ metaPromise ??= fetch('/api/3dviewer/meta', { credentials: 'same-origin' }).then((response) => {
428
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
429
+ return response.json()
430
+ })
431
+ return metaPromise
432
+ }
433
+
434
+ const loadCesium = () => {
435
+ cesiumPromise ??= (async () => {
436
+ const meta = await loadMeta()
437
+ window.CESIUM_BASE_URL = meta.cesiumBase
438
+ return import(meta.cesiumBase + 'index.js')
439
+ })()
440
+ return cesiumPromise
441
+ }
442
+
443
+ const loadCesiumStyle = () => {
444
+ cesiumStylePromise ??= (async () => {
445
+ if (document.getElementById('mv-cesium-style')) return
446
+ const meta = await loadMeta()
447
+ await new Promise((resolve) => {
448
+ const link = document.createElement('link')
449
+ link.id = 'mv-cesium-style'
450
+ link.rel = 'stylesheet'
451
+ link.href = meta.cesiumBase + 'Widgets/widgets.css'
452
+ link.onload = resolve
453
+ link.onerror = resolve
454
+ document.head.appendChild(link)
455
+ })
456
+ })()
457
+ return cesiumStylePromise
458
+ }
459
+
460
+ /** Read the persisted source catalog (falls back to the built-in catalog). */
461
+ const loadSources = async (meta) => {
462
+ try {
463
+ const response = await fetch(meta.sourcesUrl, { credentials: 'same-origin' })
464
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
465
+ return await response.json()
466
+ } catch {
467
+ const config = {
468
+ imagery: [
469
+ {
470
+ id: 'esri-world-imagery',
471
+ name: 'Esri World Imagery',
472
+ type: 'xyz',
473
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
474
+ maximumLevel: 19,
475
+ credit: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community',
476
+ },
477
+ ],
478
+ terrain: [
479
+ {
480
+ id: 'arcgis-world-elevation',
481
+ name: 'ArcGIS WorldElevation3D',
482
+ type: 'arcgis',
483
+ url: 'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer',
484
+ credit: 'Esri, CGIAR, USGS',
485
+ },
486
+ ],
487
+ settings: { theme: 'auto', imagery: 'esri-world-imagery', terrain: null },
488
+ }
489
+ return { config, defaults: clone(config) }
490
+ }
491
+ }
492
+
493
+ /** Persist the catalog; calls are serialised so concurrent edits cannot race. */
494
+ const persistSources = (meta, config) => {
495
+ saveChain = saveChain.then(async () => {
496
+ const response = await fetch(meta.sourcesUrl, {
497
+ method: 'PUT',
498
+ credentials: 'same-origin',
499
+ headers: { 'content-type': 'application/json' },
500
+ body: JSON.stringify(config),
501
+ })
502
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
503
+ return (await response.json()).config
504
+ })
505
+ return saveChain
506
+ }
507
+
508
+ /** Map a stored imagery source onto a Cesium imagery provider. */
509
+ async function imageryProvider(Cesium, source) {
510
+ const options = {}
511
+ if (source.credit) options.credit = source.credit
512
+ if (source.type === 'arcgis') return Cesium.ArcGisMapServerImageryProvider.fromUrl(source.url, options)
513
+ if (source.type === 'wms') {
514
+ return new Cesium.WebMapServiceImageryProvider({
515
+ url: source.url,
516
+ layers: source.layers ?? '',
517
+ parameters: { service: 'WMS', version: '1.1.1', transparent: true, format: 'image/png' },
518
+ ...options,
519
+ })
520
+ }
521
+ if (source.maximumLevel !== undefined) options.maximumLevel = source.maximumLevel
522
+ if (source.subdomains) options.subdomains = source.subdomains
523
+ if (source.tilingScheme === 'geographic') options.tilingScheme = new Cesium.GeographicTilingScheme()
524
+ if (source.tilingScheme === 'web-mercator') options.tilingScheme = new Cesium.WebMercatorTilingScheme()
525
+ // Cesium reads south-origin tile rows from `{reverseY}`, so rewrite the template
526
+ // instead of asking users to remember which placeholder their tileset needs.
527
+ const template = source.reverseY ? source.url.replace(/\{y\}/g, '{reverseY}') : source.url
528
+ return new Cesium.UrlTemplateImageryProvider({ url: template, ...options })
529
+ }
530
+
531
+ /** Map a stored terrain source onto a Cesium terrain provider. */
532
+ async function terrainProvider(Cesium, source) {
533
+ const options = {}
534
+ if (source.credit) options.credit = source.credit
535
+ if (source.requestVertexNormals) options.requestVertexNormals = true
536
+ if (source.requestWaterMask) options.requestWaterMask = true
537
+ if (source.type === 'cesium') return Cesium.CesiumTerrainProvider.fromUrl(source.url, options)
538
+ return Cesium.ArcGISTiledElevationTerrainProvider.fromUrl(source.url, options)
539
+ }
540
+ //#endregion data access
541
+
542
+ //#region cursor sampling --------------------------------------------------
543
+
544
+ /** True when a pick result describes geometry above the globe (tiles, models). */
545
+ function isModelPick(Cesium, picked) {
546
+ if (!Cesium.defined(picked)) return false
547
+ if (picked instanceof Cesium.Cesium3DTileFeature) return true
548
+ const primitive = picked.primitive ?? picked
549
+ if (primitive instanceof Cesium.Cesium3DTileset) return true
550
+ if (primitive instanceof Cesium.Model) return true
551
+ return false
552
+ }
553
+
554
+ /**
555
+ * Sample the point under the pointer.
556
+ *
557
+ * The globe surface always has an answer (ray → ellipsoid), which keeps the
558
+ * readout alive on the bare globe with terrain disabled. When a 3D Tiles or
559
+ * glTF surface is under the cursor its depth is preferred, at any camera
560
+ * distance, so the reported elevation is the model surface instead of sea
561
+ * level. Terrain elevation is filled in from the globe when a terrain
562
+ * provider is attached and the depth buffer is too coarse to trust.
563
+ */
564
+ function sampleCursor(viewer, Cesium, position) {
565
+ const scene = viewer.scene
566
+ const ellipsoid = scene.globe.ellipsoid
567
+ const ray = viewer.camera.getPickRay(position)
568
+ const interval = ray ? Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid) : undefined
569
+ let carto = interval ? ellipsoid.cartesianToCartographic(Cesium.Ray.getPoint(ray, interval.start)) : null
570
+ if (!carto) return null
571
+ carto.height = 0
572
+
573
+ const picked = scene.pick(position)
574
+ const overModel = isModelPick(Cesium, picked)
575
+ let sampled = false
576
+ if (scene.pickPositionSupported && (overModel || viewer.camera.positionCartographic.height < 100000)) {
577
+ try {
578
+ const point = scene.pickPosition(position)
579
+ if (Cesium.defined(point)) {
580
+ const depthCarto = Cesium.Cartographic.fromCartesian(point)
581
+ if (depthCarto && Math.abs(depthCarto.height) < 30000) {
582
+ carto = depthCarto
583
+ sampled = true
584
+ }
585
+ }
586
+ } catch {
587
+ /* keep the ray hit */
588
+ }
589
+ }
590
+ if (!sampled) {
591
+ const provider = scene.globe.terrainProvider
592
+ const flat = provider instanceof Cesium.EllipsoidTerrainProvider
593
+ if (!flat && typeof scene.globe.getHeight === 'function') {
594
+ const terrainHeight = scene.globe.getHeight(carto)
595
+ if (typeof terrainHeight === 'number' && Number.isFinite(terrainHeight)) carto.height = terrainHeight
596
+ } else {
597
+ carto.height = 0
598
+ }
599
+ } else if (!overModel && scene.globe.terrainProvider instanceof Cesium.EllipsoidTerrainProvider) {
600
+ // The depth buffer biases the faceted ellipsoid by up to a few
601
+ // kilometres; report sea level instead of a fake elevation.
602
+ if (Math.abs(carto.height) < 100) carto.height = 0
603
+ }
604
+ return carto
605
+ }
606
+
607
+ const formatCursor = (Cesium, carto) => {
608
+ const lon = Cesium.Math.toDegrees(carto.longitude).toFixed(5)
609
+ const lat = Cesium.Math.toDegrees(carto.latitude).toFixed(5)
610
+ const height = Math.abs(carto.height) < 0.05 ? 0 : carto.height
611
+ return `${lon}, ${lat} · ${t('hud.elevation', { value: formatHeight(height) })}`
612
+ }
613
+ //#endregion cursor sampling
614
+
615
+ function ViewerIcon({ size = 18 }) {
616
+ return h(
617
+ 'svg',
618
+ { viewBox: '0 0 64 64', width: size, height: size, 'aria-hidden': true, style: { display: 'block' } },
619
+ h('circle', { cx: 30, cy: 30, r: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 3 }),
620
+ h('ellipse', { cx: 30, cy: 30, rx: 8.5, ry: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, opacity: 0.85 }),
621
+ h('path', { d: 'M10.5 30h39M13.5 20.5h33M13.5 39.5h33', stroke: 'currentColor', strokeWidth: 2, opacity: 0.55 }),
622
+ h('path', { d: 'M48 34l9 5v10l-9 5-9-5V39z', fill: 'currentColor', opacity: 0.9 }),
623
+ )
624
+ }
625
+
626
+ const ThemeIcon = ({ mode, size = 16 }) =>
627
+ h(
628
+ 'svg',
629
+ { viewBox: '0 0 24 24', width: size, height: size, 'aria-hidden': true, style: { display: 'block' } },
630
+ mode === 'dark'
631
+ ? h('path', { d: 'M20.5 14.4A8.6 8.6 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4z', fill: 'currentColor' })
632
+ : mode === 'light'
633
+ ? h(
634
+ 'g',
635
+ { stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', fill: 'none' },
636
+ h('circle', { cx: 12, cy: 12, r: 4.1 }),
637
+ h('path', { d: 'M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.4 5.4l1.6 1.6M17 17l1.6 1.6M18.6 5.4L17 7M7 17l-1.6 1.6' }),
638
+ )
639
+ : h(
640
+ 'g',
641
+ { stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', fill: 'none' },
642
+ h('circle', { cx: 12, cy: 12, r: 8.4 }),
643
+ h('path', { d: 'M12 3.6a8.4 8.4 0 0 0 0 16.8z', fill: 'currentColor', stroke: 'none' }),
644
+ ),
645
+ )
646
+
647
+ /** Bottom-left HUD: FPS, pointer position/elevation, camera and per-layer statistics. */
648
+ function StatusHud({ fps, stats, viewerRef, cesiumRef, labels }) {
649
+ // The cursor readout is written straight into the DOM: it updates up to eight
650
+ // times a second, so keeping it out of React avoids re-renders while busy.
651
+ const cursorRef = React.useRef(null)
652
+
653
+ React.useEffect(() => {
654
+ const viewer = viewerRef.current
655
+ const Cesium = cesiumRef.current
656
+ if (!viewer || !Cesium) return undefined
657
+ const canvas = viewer.scene.canvas
658
+ const pointer = { x: 0, y: 0, inside: false, dirty: false }
659
+ const onMove = (event) => {
660
+ pointer.x = event.offsetX
661
+ pointer.y = event.offsetY
662
+ pointer.inside = true
663
+ pointer.dirty = true
664
+ }
665
+ const onLeave = () => {
666
+ pointer.inside = false
667
+ pointer.dirty = true
668
+ }
669
+ canvas.addEventListener('pointermove', onMove, { passive: true })
670
+ canvas.addEventListener('pointerleave', onLeave, { passive: true })
671
+ let shown = null
672
+ const paint = (value) => {
673
+ if (value === shown) return
674
+ shown = value
675
+ if (cursorRef.current) cursorRef.current.textContent = value ?? t('hud.empty')
676
+ }
677
+ // Pick at ~8 Hz and only while the pointer keeps moving: one depth read per
678
+ // update keeps this cheap even on multi-million-triangle tilesets.
679
+ const timer = setInterval(() => {
680
+ if (!pointer.dirty) return
681
+ pointer.dirty = false
682
+ let next = null
683
+ if (pointer.inside) {
684
+ const position = new Cesium.Cartesian2(pointer.x, pointer.y)
685
+ const carto = sampleCursor(viewer, Cesium, position)
686
+ if (carto) next = formatCursor(Cesium, carto)
687
+ }
688
+ paint(next)
689
+ }, 120)
690
+ return () => {
691
+ canvas.removeEventListener('pointermove', onMove)
692
+ canvas.removeEventListener('pointerleave', onLeave)
693
+ clearInterval(timer)
694
+ }
695
+ }, [])
696
+
697
+ return h(
698
+ 'div',
699
+ { className: 'mv-hud mv-glass' },
700
+ h(
701
+ 'div',
702
+ { className: 'row' },
703
+ h('span', null, 'FPS ', h('b', null, fps)),
704
+ h('span', null, `${labels.pointer} `, h('b', { ref: cursorRef }, t('hud.empty'))),
705
+ h('span', null, `${labels.camera} `, h('b', null, stats.height)),
706
+ h('span', null, `${labels.heading} `, h('b', null, `${stats.heading} / ${stats.pitch}`)),
707
+ ),
708
+ stats.layers.map((row) =>
709
+ h(
710
+ 'div',
711
+ { key: row.id, className: 'row' },
712
+ h('b', null, row.name),
713
+ h('span', null, `${labels.tiles} `, h('b', null, formatCount(row.tiles))),
714
+ h('span', null, `${labels.pending} `, h('b', null, formatCount(row.pending))),
715
+ h('span', null, `${labels.triangles} `, h('b', null, formatCount(row.triangles))),
716
+ h('span', null, `${labels.cache} `, h('b', null, formatBytes(row.memory))),
717
+ ),
718
+ ),
719
+ )
720
+ }
721
+
722
+ function MenuItem({ onClick, children, active }) {
723
+ return h(
724
+ 'button',
725
+ { type: 'button', className: `mv-menu-item${active ? ' on' : ''}`, onClick },
726
+ children,
727
+ active ? h('span', { className: 'check', 'aria-hidden': true }, '✓') : null,
728
+ )
729
+ }
730
+
731
+ const CSS = `
732
+ .mv-root{position:relative;flex:1 1 0;min-height:0;overflow:hidden;background:var(--mv-bg);color:var(--mv-text);font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;--mv-bg:var(--dsw-alias-bg-base,#fff);--mv-text:var(--dsw-alias-label-primary,#0f1115);--mv-dim:var(--dsw-alias-label-secondary,#5b6068);--mv-line:var(--dsw-alias-border-l2,#0000001a);--mv-accent:var(--dsw-alias-state-business-primary,#4176e6);--mv-lift:var(--dsw-alias-interactive-bg-hover,#2631480f);--mv-glass:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 88%,transparent);--mv-glass-strong:var(--dsw-alias-bg-layer-1,#fff);--mv-input:var(--dsw-alias-interactive-bg-hover,#2631480f);--mv-shadow:0 8px 28px rgba(15,23,42,.18);--mv-attrib:rgba(255,255,255,.62);--mv-attrib-shadow:0 1px 2px rgba(0,0,0,.6);--mv-overlay:transparent;--mv-scrim:rgba(24,34,50,.28)}
733
+ .mv-root[data-mv-scheme="dark"]{--mv-attrib:rgba(255,255,255,.62);--mv-attrib-shadow:0 1px 2px rgba(0,0,0,.6);--mv-overlay:transparent;--mv-scrim:rgba(3,7,13,.62)}
734
+ .mv-root[data-mv-theme="dark"]{--mv-bg:#151517;--mv-text:#ebeef2;--mv-dim:#adb2b8;--mv-line:#ffffff1f;--mv-accent:#7aaaff;--mv-lift:#ffffff14;--mv-glass:rgba(21,21,23,.82);--mv-glass-strong:#232324;--mv-input:#ffffff14;--mv-shadow:0 8px 28px rgba(0,0,0,.34);color-scheme:dark}
735
+ .mv-root[data-mv-theme="light"]{--mv-bg:#fff;--mv-text:#0f1115;--mv-dim:#5b6068;--mv-line:#0000001a;--mv-accent:#4176e6;--mv-lift:#2631480f;--mv-glass:rgba(255,255,255,.86);--mv-glass-strong:#fff;--mv-input:#2631480f;--mv-shadow:0 8px 28px rgba(15,23,42,.16);color-scheme:light}
736
+ .mv-globe{position:absolute;inset:0;background:#05080f}
737
+ .mv-globe .cesium-viewer,.mv-globe .cesium-widget,.mv-globe .cesium-widget canvas{width:100%;height:100%}
738
+ .mv-attr{position:absolute;right:10px;bottom:18px;z-index:6;font-size:10px;line-height:1.4;color:var(--mv-attrib);pointer-events:none;text-shadow:var(--mv-attrib-shadow)}
739
+ .mv-credits{position:absolute;right:10px;bottom:2px;max-width:46%;z-index:6;font-size:10px;color:var(--mv-attrib);text-align:right;line-height:1.4;text-shadow:var(--mv-attrib-shadow)}
740
+ .mv-credits .cesium-credit-textContainer,.mv-credits .cesium-credit-logoContainer{display:inline-block}
741
+ .mv-credits a{color:inherit}
742
+ .mv-glass{background:var(--mv-glass);border:.5px solid var(--mv-line);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);box-shadow:var(--mv-shadow)}
743
+ .mv-top{position:absolute;top:10px;left:10px;right:10px;display:flex;align-items:flex-start;justify-content:space-between;gap:10px;z-index:10;pointer-events:none}
744
+ .mv-brand{display:flex;align-items:center;gap:8px;padding:7px 12px;border-radius:11px;pointer-events:auto}
745
+ .mv-brand b{font-size:13px;font-weight:600;letter-spacing:.01em}
746
+ .mv-brand span{font-size:11px;color:var(--mv-dim)}
747
+ .mv-actions{display:flex;align-items:center;gap:2px;padding:4px;border-radius:11px;pointer-events:auto;flex-wrap:wrap;justify-content:flex-end}
748
+ .mv-btn{appearance:none;border:0;background:transparent;color:var(--mv-dim);font:inherit;font-size:12px;line-height:1;padding:7px 9px;border-radius:7px;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:6px}
749
+ .mv-btn:hover{background:color-mix(in srgb,var(--mv-lift) 72%,transparent);color:var(--mv-text)}
750
+ .mv-btn.on{background:color-mix(in srgb,var(--mv-accent) 26%,transparent);color:var(--mv-text)}
751
+ .mv-btn.icon{padding:7px 8px}
752
+ .mv-sep{width:1px;height:17px;background:var(--mv-line);margin:0 3px}
753
+ .mv-menu{position:relative;display:inline-flex}
754
+ .mv-menu-panel{position:absolute;top:calc(100% + 6px);right:0;min-width:206px;padding:4px;border-radius:11px;z-index:20;display:flex;flex-direction:column;gap:1px}
755
+ .mv-menu-item{appearance:none;border:0;background:transparent;color:var(--mv-text);font:inherit;font-size:12px;text-align:left;padding:8px 10px;border-radius:7px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:8px;white-space:nowrap}
756
+ .mv-menu-item:hover{background:color-mix(in srgb,var(--mv-lift) 80%,transparent)}
757
+ .mv-menu-item.on{color:var(--mv-accent)}
758
+ .mv-menu-item .check{font-size:11px}
759
+ .mv-urlbar{position:absolute;top:58px;left:50%;transform:translateX(-50%);display:flex;gap:6px;padding:6px;border-radius:11px;z-index:12;width:min(640px,82%)}
760
+ .mv-urlbar input{flex:1;min-width:0;background:var(--mv-input);border:.5px solid var(--mv-line);color:var(--mv-text);border-radius:7px;padding:7px 9px;font:inherit;font-size:12px;outline:none}
761
+ .mv-urlbar input:focus{border-color:var(--mv-accent)}
762
+ .mv-urlbar button{appearance:none;border:.5px solid var(--mv-line);background:var(--mv-lift);color:var(--mv-text);font:inherit;font-size:12px;padding:7px 13px;border-radius:7px;cursor:pointer}
763
+ .mv-urlbar button:hover{border-color:var(--mv-accent)}
764
+ .mv-layers{position:absolute;top:60px;right:10px;width:284px;max-height:calc(100% - 214px);display:flex;flex-direction:column;border-radius:13px;z-index:9;overflow:hidden}
765
+ .mv-layers>header{display:flex;align-items:center;justify-content:space-between;padding:9px 11px;font-size:11px;letter-spacing:.06em;color:var(--mv-dim);border-bottom:.5px solid var(--mv-line)}
766
+ .mv-layer-list{overflow:auto;padding:4px}
767
+ .mv-layer{display:flex;align-items:center;gap:2px;padding:5px 6px;border-radius:8px}
768
+ .mv-layer:hover{background:color-mix(in srgb,var(--mv-lift) 64%,transparent)}
769
+ .mv-layer .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;cursor:pointer}
770
+ .mv-layer.off .name{color:var(--mv-dim);text-decoration:line-through}
771
+ .mv-icon-btn{appearance:none;border:0;background:transparent;color:var(--mv-dim);width:22px;height:22px;border-radius:6px;cursor:pointer;font:inherit;font-size:11px;line-height:1;display:inline-flex;align-items:center;justify-content:center;flex:none}
772
+ .mv-icon-btn:hover{background:color-mix(in srgb,var(--mv-lift) 82%,transparent);color:var(--mv-text)}
773
+ .mv-icon-btn.on{color:var(--mv-accent)}
774
+ .mv-hud{position:absolute;left:10px;bottom:10px;z-index:9;padding:8px 11px;border-radius:11px;display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--mv-dim);max-width:calc(100% - 330px);pointer-events:none}
775
+ .mv-hud .row{display:flex;gap:10px;flex-wrap:wrap}
776
+ .mv-hud b{color:var(--mv-text);font-weight:600;font-variant-numeric:tabular-nums}
777
+ .mv-pick{position:absolute;right:10px;bottom:10px;width:322px;max-height:44%;display:flex;flex-direction:column;z-index:13;border-radius:12px;overflow:hidden}
778
+ .mv-pick>header{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;font-size:11px;color:var(--mv-dim);border-bottom:.5px solid var(--mv-line)}
779
+ .mv-pick table{width:100%;border-collapse:collapse;font-size:11px;overflow:auto;display:block}
780
+ .mv-pick td{padding:3px 9px;vertical-align:top;border-top:.5px solid var(--mv-line);word-break:break-all}
781
+ .mv-pick td:first-child{color:var(--mv-dim);width:38%}
782
+ .mv-measure{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);z-index:11;padding:6px 13px;border-radius:999px;font-size:11px;color:var(--mv-dim);white-space:nowrap;pointer-events:none}
783
+ .mv-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:40;background:var(--mv-glass-strong);border:.5px solid var(--mv-line);color:var(--mv-text);padding:8px 14px;border-radius:9px;font-size:12px;max-width:72%;word-break:break-all;box-shadow:var(--mv-shadow)}
784
+ .mv-overlay{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--mv-overlay);pointer-events:none}
785
+ .mv-overlay.dim{background:var(--mv-scrim)}
786
+ .mv-empty{display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;max-width:470px;pointer-events:auto;padding:24px 28px;border-radius:16px;background:var(--mv-glass-strong);border:.5px solid var(--mv-line);box-shadow:0 24px 64px var(--mv-shadow)}
787
+ .mv-empty h2{margin:0;font-size:17px;font-weight:600;letter-spacing:.01em;color:var(--mv-text)}
788
+ .mv-empty p{margin:0;color:var(--mv-dim);font-size:12px;line-height:1.75}
789
+ .mv-empty .buttons{display:flex;gap:8px;margin-top:2px}
790
+ .mv-cta{appearance:none;border:.5px solid var(--mv-line);background:var(--mv-lift);color:var(--mv-text);font:inherit;font-size:12px;padding:8px 16px;border-radius:9px;cursor:pointer;transition:background .15s ease,border-color .15s ease}
791
+ .mv-cta.primary{background:color-mix(in srgb,var(--mv-accent) 20%,transparent);border-color:color-mix(in srgb,var(--mv-accent) 45%,transparent)}
792
+ .mv-cta:hover{border-color:var(--mv-accent);background:color-mix(in srgb,var(--mv-accent) 26%,transparent)}
793
+ .mv-spinner{width:24px;height:24px;border-radius:50%;border:2px solid rgba(120,160,255,.22);border-top-color:var(--mv-accent);animation:mv-spin .9s linear infinite}
794
+ @keyframes mv-spin{to{transform:rotate(360deg)}}
795
+ .mv-modal{position:absolute;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;background:var(--mv-scrim);padding:20px}
796
+ .mv-dialog{width:min(740px,94%);max-height:86%;display:flex;flex-direction:column;border-radius:14px;overflow:hidden;background:var(--mv-glass-strong);border:.5px solid var(--mv-line);box-shadow:0 24px 64px var(--mv-shadow)}
797
+ .mv-dialog>header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px;border-bottom:.5px solid var(--mv-line);font-size:13px;font-weight:600}
798
+ .mv-tabs{display:flex;gap:4px;padding:8px 12px 0;border-bottom:.5px solid var(--mv-line)}
799
+ .mv-tab{appearance:none;border:0;background:transparent;color:var(--mv-dim);font:inherit;font-size:12px;padding:8px 12px;border-radius:8px 8px 0 0;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
800
+ .mv-tab:hover{color:var(--mv-text)}
801
+ .mv-tab.on{color:var(--mv-text);border-bottom-color:var(--mv-accent)}
802
+ .mv-dialog .body{padding:12px 14px;overflow:auto;display:flex;flex-direction:column;gap:8px}
803
+ .mv-note{margin:0 0 2px;color:var(--mv-dim);font-size:11px;line-height:1.65}
804
+ .mv-src{display:flex;align-items:center;gap:8px;padding:8px 10px;border:.5px solid var(--mv-line);border-radius:9px;background:color-mix(in srgb,var(--mv-bg) 55%,transparent)}
805
+ .mv-src .radio{width:16px;height:16px;flex:none;border-radius:50%;border:1.5px solid var(--mv-line);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
806
+ .mv-src .radio.on{border-color:var(--mv-accent)}
807
+ .mv-src .radio.on::after{content:"";width:8px;height:8px;border-radius:50%;background:var(--mv-accent)}
808
+ .mv-src .meta{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
809
+ .mv-src .meta b{font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
810
+ .mv-src .meta span{font-size:10.5px;color:var(--mv-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
811
+ .mv-src .badge{font-size:10px;padding:1px 6px;border-radius:999px;border:.5px solid var(--mv-line);color:var(--mv-dim);flex:none}
812
+ .mv-form{border:.5px solid var(--mv-line);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:8px;background:color-mix(in srgb,var(--mv-bg) 42%,transparent)}
813
+ .mv-form .grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
814
+ .mv-field{display:flex;flex-direction:column;gap:3px;min-width:0}
815
+ .mv-field label{font-size:10.5px;color:var(--mv-dim)}
816
+ .mv-field input,.mv-field select{background:var(--mv-input);border:.5px solid var(--mv-line);color:var(--mv-text);border-radius:6px;padding:6px 8px;font:inherit;font-size:12px;outline:none;min-width:0}
817
+ .mv-field input:focus,.mv-field select:focus{border-color:var(--mv-accent)}
818
+ .mv-field.check{flex-direction:row;align-items:center;gap:7px}
819
+ .mv-field.check label{font-size:12px;color:var(--mv-text);cursor:pointer}
820
+ .mv-field.check input{appearance:auto;width:13px;height:13px;padding:0;margin:0}
821
+ .mv-dialog .footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;border-top:.5px solid var(--mv-line)}
822
+ .mv-dialog .footer .right{display:flex;gap:8px}
823
+ .mv-team{margin-left:8px;color:var(--mv-dim);font-size:10.5px;letter-spacing:.2px;text-decoration:none;opacity:.72;transition:opacity .15s,color .15s}
824
+ .mv-team:hover,.mv-team:focus-visible{opacity:1;color:var(--mv-accent)}
825
+ .mv-seg{display:flex;gap:2px;padding:3px;border:.5px solid var(--mv-line);border-radius:9px;background:var(--mv-input)}
826
+ .mv-seg button{appearance:none;border:0;background:transparent;color:var(--mv-dim);font:inherit;font-size:11.5px;padding:6px 12px;border-radius:6px;cursor:pointer;flex:1}
827
+ .mv-seg button.on{background:color-mix(in srgb,var(--mv-accent) 22%,transparent);color:var(--mv-text)}
828
+ `
829
+
830
+ //#region sources dialog ---------------------------------------------------
831
+
832
+ const SOURCE_TYPES = {
833
+ imagery: ['xyz', 'arcgis', 'wms'],
834
+ terrain: ['arcgis', 'cesium'],
835
+ }
836
+ const SOURCE_TYPE_KEY = {
837
+ xyz: 'settings.type.xyz',
838
+ arcgis: 'settings.type.arcgis',
839
+ wms: 'settings.type.wms',
840
+ cesium: 'settings.type.cesium',
841
+ }
842
+
843
+ const emptySource = (kind) => ({
844
+ id: `${kind}-${Date.now().toString(36)}`,
845
+ name: '',
846
+ type: SOURCE_TYPES[kind][0],
847
+ url: '',
848
+ credit: '',
849
+ })
850
+
851
+ function SourceForm({ kind, source, onPickLocal, onChange, onCancel, t }) {
852
+ const set = (patch) => onChange({ ...source, ...patch })
853
+ const field = (label, input) => h('div', { className: 'mv-field' }, h('label', null, label), input)
854
+ const pickTiles = async () => {
855
+ if (typeof onPickLocal !== 'function') return
856
+ const url = await onPickLocal()
857
+ if (url) set({ type: 'xyz', url, maximumLevel: source.maximumLevel ?? 18 })
858
+ }
859
+ const text = (key, value, placeholder) =>
860
+ h('input', {
861
+ key,
862
+ value: value ?? '',
863
+ placeholder: placeholder ?? '',
864
+ onChange: (event) => set({ [key]: event.target.value }),
865
+ })
866
+ return h(
867
+ 'div',
868
+ { className: 'mv-form' },
869
+ h(
870
+ 'div',
871
+ { className: 'grid' },
872
+ field(t('settings.field.name'), text('name')),
873
+ field(
874
+ t('settings.field.type'),
875
+ h(
876
+ 'select',
877
+ { value: source.type, onChange: (event) => set({ type: event.target.value }) },
878
+ SOURCE_TYPES[kind].map((type) => h('option', { key: type, value: type }, t(SOURCE_TYPE_KEY[type]))),
879
+ ),
880
+ ),
881
+ ),
882
+ field(t('settings.field.url'), text('url', source.url, kind === 'imagery' ? 'https://…/{z}/{x}/{y}.png' : 'https://…')),
883
+ field(t('settings.field.credit'), text('credit', source.credit)),
884
+ kind === 'imagery' && source.type === 'xyz'
885
+ ? h(
886
+ 'div',
887
+ { className: 'grid' },
888
+ field(
889
+ t('settings.field.maxLevel'),
890
+ h('input', {
891
+ type: 'number',
892
+ min: 0,
893
+ max: 25,
894
+ value: source.maximumLevel ?? '',
895
+ onChange: (event) =>
896
+ set({ maximumLevel: event.target.value === '' ? undefined : Number(event.target.value) }),
897
+ }),
898
+ ),
899
+ field(
900
+ t('settings.field.tilingScheme'),
901
+ h(
902
+ 'select',
903
+ {
904
+ value: source.tilingScheme ?? 'web-mercator',
905
+ onChange: (event) => set({ tilingScheme: event.target.value }),
906
+ },
907
+ h('option', { value: 'web-mercator' }, t('settings.tiling.webMercator')),
908
+ h('option', { value: 'geographic' }, t('settings.tiling.geographic')),
909
+ ),
910
+ ),
911
+ field(
912
+ t('settings.field.subdomains'),
913
+ h('input', {
914
+ value: (source.subdomains ?? []).join(','),
915
+ onChange: (event) =>
916
+ set({
917
+ subdomains: event.target.value
918
+ .split(',')
919
+ .map((item) => item.trim())
920
+ .filter((item) => item !== ''),
921
+ }),
922
+ }),
923
+ ),
924
+ )
925
+ : null,
926
+ kind === 'imagery' && source.type === 'xyz'
927
+ ? h(
928
+ 'div',
929
+ { className: 'grid' },
930
+ h(
931
+ 'div',
932
+ { className: 'mv-field check' },
933
+ h('input', {
934
+ id: 'mv-reverse-y',
935
+ type: 'checkbox',
936
+ checked: source.reverseY === true,
937
+ onChange: (event) => set({ reverseY: event.target.checked }),
938
+ }),
939
+ h('label', { htmlFor: 'mv-reverse-y' }, t('settings.field.tms')),
940
+ ),
941
+ h(
942
+ 'div',
943
+ { className: 'mv-field' },
944
+ h(
945
+ 'button',
946
+ { type: 'button', className: 'mv-cta', onClick: pickTiles },
947
+ t('settings.field.localTiles'),
948
+ ),
949
+ ),
950
+ )
951
+ : null,
952
+ kind === 'imagery' && source.type === 'wms'
953
+ ? field(t('settings.field.layers'), text('layers', source.layers))
954
+ : null,
955
+ kind === 'terrain'
956
+ ? h(
957
+ 'div',
958
+ { className: 'grid' },
959
+ h(
960
+ 'div',
961
+ { className: 'mv-field check' },
962
+ h('input', {
963
+ id: 'mv-normals',
964
+ type: 'checkbox',
965
+ checked: source.requestVertexNormals === true,
966
+ onChange: (event) => set({ requestVertexNormals: event.target.checked }),
967
+ }),
968
+ h('label', { htmlFor: 'mv-normals' }, t('settings.field.normals')),
969
+ ),
970
+ h(
971
+ 'div',
972
+ { className: 'mv-field check' },
973
+ h('input', {
974
+ id: 'mv-water',
975
+ type: 'checkbox',
976
+ checked: source.requestWaterMask === true,
977
+ onChange: (event) => set({ requestWaterMask: event.target.checked }),
978
+ }),
979
+ h('label', { htmlFor: 'mv-water' }, t('settings.field.waterMask')),
980
+ ),
981
+ )
982
+ : null,
983
+ h(
984
+ 'div',
985
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px' } },
986
+ h('button', { type: 'button', className: 'mv-cta', onClick: onCancel }, t('btn.cancel')),
987
+ ),
988
+ )
989
+ }
990
+
991
+ function SourcesDialog({ config, defaults, environmentDark, themeMode, onPickLocal, onSave, onClose, messages }) {
992
+ const t = messages
993
+ const [draft, setDraft] = React.useState(() => clone(config))
994
+ const [tab, setTab] = React.useState('imagery')
995
+ const [editing, setEditing] = React.useState(null)
996
+ const activeId = (kind) => (kind === 'imagery' ? draft.settings.imagery : draft.settings.terrain)
997
+
998
+ React.useEffect(() => {
999
+ const onKey = (event) => {
1000
+ if (event.key !== 'Escape') return
1001
+ event.stopPropagation()
1002
+ onClose()
1003
+ }
1004
+ document.addEventListener('keydown', onKey, true)
1005
+ return () => document.removeEventListener('keydown', onKey, true)
1006
+ }, [onClose])
1007
+
1008
+ const setSources = (kind, list) => setDraft((current) => ({ ...current, [kind]: list }))
1009
+ const selectSource = (kind, id) =>
1010
+ setDraft((current) => ({
1011
+ ...current,
1012
+ settings: { ...current.settings, [kind]: id },
1013
+ }))
1014
+ const addSource = (kind) => {
1015
+ const source = emptySource(kind)
1016
+ setSources(kind, [...draft[kind], source])
1017
+ setEditing({ kind, id: source.id })
1018
+ }
1019
+ const removeSource = (kind, id) => {
1020
+ setSources(
1021
+ kind,
1022
+ draft[kind].filter((item) => item.id !== id),
1023
+ )
1024
+ if (draft.settings[kind] === id) selectSource(kind, null)
1025
+ if (editing?.id === id) setEditing(null)
1026
+ }
1027
+ const updateSource = (kind, source) =>
1028
+ setSources(
1029
+ kind,
1030
+ draft[kind].map((item) => (item.id === source.id ? source : item)),
1031
+ )
1032
+ const save = () => {
1033
+ for (const kind of ['imagery', 'terrain']) {
1034
+ if (draft[kind].some((source) => source.name.trim() === '' || source.url.trim() === '')) {
1035
+ window.alert(t('settings.required'))
1036
+ return
1037
+ }
1038
+ }
1039
+ onSave(draft)
1040
+ }
1041
+ const restore = () => {
1042
+ if (!window.confirm(t('settings.restoreConfirm'))) return
1043
+ setDraft(clone(defaults))
1044
+ setEditing(null)
1045
+ }
1046
+ const segments = [
1047
+ ['auto', 'theme.auto'],
1048
+ ['light', 'theme.light'],
1049
+ ['dark', 'theme.dark'],
1050
+ ]
1051
+ const rowsFor = (kind) =>
1052
+ draft[kind].map((source) =>
1053
+ editing?.kind === kind && editing.id === source.id
1054
+ ? h(SourceForm, {
1055
+ key: source.id,
1056
+ kind,
1057
+ source,
1058
+ t,
1059
+ onPickLocal,
1060
+ onChange: (next) => updateSource(kind, next),
1061
+ onCancel: () => setEditing(null),
1062
+ })
1063
+ : h(
1064
+ 'div',
1065
+ { key: source.id, className: 'mv-src' },
1066
+ h('span', {
1067
+ className: `radio${activeId(kind) === source.id ? ' on' : ''}`,
1068
+ role: 'radio',
1069
+ 'aria-checked': activeId(kind) === source.id,
1070
+ tabIndex: 0,
1071
+ onClick: () => selectSource(kind, activeId(kind) === source.id ? null : source.id),
1072
+ onKeyDown: (event) => {
1073
+ if (event.key === 'Enter' || event.key === ' ') selectSource(kind, source.id)
1074
+ },
1075
+ }),
1076
+ h(
1077
+ 'div',
1078
+ { className: 'meta' },
1079
+ h('b', null, source.name),
1080
+ h('span', null, source.url),
1081
+ ),
1082
+ h('span', { className: 'badge' }, t(SOURCE_TYPE_KEY[source.type] ?? source.type)),
1083
+ h(
1084
+ 'button',
1085
+ {
1086
+ type: 'button',
1087
+ className: 'mv-icon-btn',
1088
+ title: t('btn.edit'),
1089
+ onClick: () => setEditing({ kind, id: source.id }),
1090
+ },
1091
+ '✎',
1092
+ ),
1093
+ h(
1094
+ 'button',
1095
+ {
1096
+ type: 'button',
1097
+ className: 'mv-icon-btn',
1098
+ title: t('btn.delete'),
1099
+ onClick: () => {
1100
+ if (window.confirm(t('settings.deleteConfirm', { name: source.name }))) removeSource(kind, source.id)
1101
+ },
1102
+ },
1103
+ '✕',
1104
+ ),
1105
+ ),
1106
+ )
1107
+
1108
+ const appearance = h(
1109
+ 'div',
1110
+ { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
1111
+ h('p', { className: 'mv-note' }, t('theme.appearanceHint')),
1112
+ h(
1113
+ 'div',
1114
+ { className: 'mv-seg', role: 'radiogroup' },
1115
+ segments.map(([value, key]) =>
1116
+ h(
1117
+ 'button',
1118
+ {
1119
+ key: value,
1120
+ type: 'button',
1121
+ className: themeMode === value ? 'on' : '',
1122
+ role: 'radio',
1123
+ 'aria-checked': themeMode === value,
1124
+ onClick: () => setDraft((current) => ({ ...current, settings: { ...current.settings, theme: value } })),
1125
+ },
1126
+ t(key),
1127
+ ),
1128
+ ),
1129
+ ),
1130
+ h('p', { className: 'mv-note' }, `${t('theme.auto')} · ${environmentDark ? t('theme.dark') : t('theme.light')}`),
1131
+ )
1132
+
1133
+ return h(
1134
+ 'div',
1135
+ { className: 'mv-modal', onPointerDown: (event) => event.target === event.currentTarget && onClose() },
1136
+ h(
1137
+ 'div',
1138
+ { className: 'mv-dialog', role: 'dialog', 'aria-modal': true },
1139
+ h(
1140
+ 'header',
1141
+ null,
1142
+ h('b', null, t('settings.title')),
1143
+ h('button', { type: 'button', className: 'mv-icon-btn', onClick: onClose, title: t('btn.close') }, '✕'),
1144
+ ),
1145
+ h(
1146
+ 'div',
1147
+ { className: 'mv-tabs' },
1148
+ [['imagery', 'settings.tab.imagery'], ['terrain', 'settings.tab.terrain'], ['appearance', 'settings.tab.appearance']].map(
1149
+ ([value, key]) =>
1150
+ h(
1151
+ 'button',
1152
+ {
1153
+ key: value,
1154
+ type: 'button',
1155
+ className: `mv-tab${tab === value ? ' on' : ''}`,
1156
+ onClick: () => setTab(value),
1157
+ },
1158
+ t(key),
1159
+ ),
1160
+ ),
1161
+ ),
1162
+ h(
1163
+ 'div',
1164
+ { className: 'body' },
1165
+ tab === 'appearance'
1166
+ ? appearance
1167
+ : h(
1168
+ React.Fragment,
1169
+ null,
1170
+ h('p', { className: 'mv-note' }, t(tab === 'imagery' ? 'settings.imageryHint' : 'settings.terrainHint')),
1171
+ rowsFor(tab),
1172
+ draft[tab].length === 0 && editing === null
1173
+ ? h('p', { className: 'mv-note' }, t('settings.empty'))
1174
+ : null,
1175
+ h(
1176
+ 'div',
1177
+ { style: { display: 'flex', justifyContent: 'flex-start' } },
1178
+ h('button', { type: 'button', className: 'mv-cta', onClick: () => addSource(tab) }, `+ ${t('btn.add')}`),
1179
+ ),
1180
+ ),
1181
+ ),
1182
+ h(
1183
+ 'div',
1184
+ { className: 'footer' },
1185
+ h(
1186
+ 'div',
1187
+ { style: { display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 } },
1188
+ h('button', { type: 'button', className: 'mv-cta', onClick: restore }, t('btn.restore')),
1189
+ h(
1190
+ 'a',
1191
+ {
1192
+ className: 'mv-team',
1193
+ href: 'https://www.whu-cveo.com/',
1194
+ target: '_blank',
1195
+ rel: 'noopener noreferrer',
1196
+ title: 'https://www.whu-cveo.com/',
1197
+ },
1198
+ t('settings.team'),
1199
+ ),
1200
+ ),
1201
+ h(
1202
+ 'div',
1203
+ { className: 'right' },
1204
+ h('button', { type: 'button', className: 'mv-cta', onClick: onClose }, t('btn.cancel')),
1205
+ h('button', { type: 'button', className: 'mv-cta primary', onClick: save }, t('btn.save')),
1206
+ ),
1207
+ ),
1208
+ ),
1209
+ )
1210
+ }
1211
+ //#endregion sources dialog
1212
+
1213
+ function ViewerPanel({ t: injectedT }) {
1214
+ const fallbackT = useTranslation()
1215
+ const t = typeof injectedT === 'function' ? injectedT : fallbackT
1216
+ const environmentDark = useEnvironmentDark()
1217
+ const globeRef = React.useRef(null)
1218
+ const creditRef = React.useRef(null)
1219
+ const menuRef = React.useRef(null)
1220
+ const viewerRef = React.useRef(null)
1221
+ const cesiumRef = React.useRef(null)
1222
+ const metaRef = React.useRef(null)
1223
+ const layersRef = React.useRef([])
1224
+ const toolRef = React.useRef(null)
1225
+ const measureRef = React.useRef({ points: [], entities: [] })
1226
+ const handlerRef = React.useRef(null)
1227
+ const imageryRef = React.useRef(null)
1228
+ const sourcesRef = React.useRef(null)
1229
+ const toastTimer = React.useRef(0)
1230
+
1231
+ const [phase, setPhase] = React.useState('loading')
1232
+ const [error, setError] = React.useState('')
1233
+ const [layers, setLayers] = React.useState([])
1234
+ const [sources, setSources] = React.useState(null)
1235
+ const [defaults, setDefaults] = React.useState(null)
1236
+ const [tool, setTool] = React.useState(null)
1237
+ const [fps, setFps] = React.useState(0)
1238
+ const [stats, setStats] = React.useState(null)
1239
+ const [pickInfo, setPickInfo] = React.useState(null)
1240
+ const [measureInfo, setMeasureInfo] = React.useState(null)
1241
+ const [scan, setScan] = React.useState(null)
1242
+ const [urlBar, setUrlBar] = React.useState(false)
1243
+ const [urlInput, setUrlInput] = React.useState('')
1244
+ const [busy, setBusy] = React.useState('')
1245
+ const [toast, setToast] = React.useState('')
1246
+ const [openMenu, setOpenMenu] = React.useState(null)
1247
+ const [layersOpen, setLayersOpen] = React.useState(true)
1248
+ const [settingsOpen, setSettingsOpen] = React.useState(false)
1249
+ const [terrainBusy, setTerrainBusy] = React.useState(false)
1250
+ const [imageryBusy, setImageryBusy] = React.useState(false)
1251
+
1252
+ const themeMode = sources?.settings?.theme ?? 'auto'
1253
+ const resolvedDark = themeMode === 'auto' ? environmentDark : themeMode === 'dark'
1254
+ const activeImageryId = sources?.settings?.imagery ?? null
1255
+ const activeTerrainId = sources?.settings?.terrain ?? null
1256
+ const activeImagery = sources?.imagery?.find((source) => source.id === activeImageryId) ?? null
1257
+
1258
+ const showToast = (text) => {
1259
+ setToast(text)
1260
+ clearTimeout(toastTimer.current)
1261
+ toastTimer.current = setTimeout(() => setToast(''), 6000)
1262
+ }
1263
+
1264
+ const syncLayers = () => {
1265
+ setLayers(layersRef.current.map((layer) => ({ id: layer.id, name: layer.name, visible: layer.visible })))
1266
+ }
1267
+
1268
+ const commitSources = (next, notify) => {
1269
+ const previous = sourcesRef.current
1270
+ sourcesRef.current = next
1271
+ setSources(next)
1272
+ const meta = metaRef.current
1273
+ if (!meta) return
1274
+ persistSources(meta, next)
1275
+ .then((saved) => {
1276
+ if (!saved) return
1277
+ sourcesRef.current = saved
1278
+ setSources(saved)
1279
+ if (notify) showToast(t('toast.sourcesSaved'))
1280
+ })
1281
+ .catch((error) => {
1282
+ sourcesRef.current = previous
1283
+ setSources(previous)
1284
+ showToast(t('toast.saveFailed', { message: message(error) }))
1285
+ })
1286
+ }
1287
+
1288
+ const updateSettings = (patch, notify) => {
1289
+ const current = sourcesRef.current
1290
+ if (!current) return
1291
+ commitSources({ ...current, settings: { ...current.settings, ...patch } }, notify)
1292
+ }
1293
+
1294
+ const pickSurface = (position) => {
1295
+ const viewer = viewerRef.current
1296
+ if (!viewer) return null
1297
+ if (viewer.scene.pickPositionSupported) {
1298
+ try {
1299
+ const picked = viewer.scene.pickPosition(position)
1300
+ if (picked) return picked
1301
+ } catch {
1302
+ /* fall through to the globe ray pick */
1303
+ }
1304
+ }
1305
+ const ray = viewer.camera.getPickRay(position)
1306
+ return ray ? viewer.scene.globe.pick(ray, viewer.scene) ?? null : null
1307
+ }
1308
+
1309
+ const proxyUrlFor = (url) => {
1310
+ const meta = metaRef.current
1311
+ const parsed = new URL(url)
1312
+ const path = parsed.pathname
1313
+ .replace(/^\//, '')
1314
+ .split('/')
1315
+ .map((part) => encodeURIComponent(part))
1316
+ .join('/')
1317
+ return `${meta.remoteBase}${b64url(parsed.origin)}/${path}`
1318
+ }
1319
+
1320
+ const localPathToUrl = async (filePath) => {
1321
+ const meta = metaRef.current
1322
+ const normalized = filePath.replace(/\\/g, '/')
1323
+ const index = normalized.lastIndexOf('/')
1324
+ if (index <= 0) throw new Error(t('toast.invalidPath'))
1325
+ const dir = normalized.slice(0, index)
1326
+ const rel = normalized.slice(index + 1)
1327
+ // The host only serves tile files from directories opened through /scan.
1328
+ await fetch(`${meta.scanUrl}?token=${encodeURIComponent(meta.token)}&dir=${encodeURIComponent(dir)}`, {
1329
+ credentials: 'same-origin',
1330
+ })
1331
+ return `${meta.tilesBase}${encodeURIComponent(b64url(dir))}/${encodeURIComponent(rel)}`
1332
+ }
1333
+
1334
+ async function addTileset(url, name) {
1335
+ const Cesium = cesiumRef.current
1336
+ const viewer = viewerRef.current
1337
+ if (!Cesium || !viewer) return
1338
+ const label = name || decodeURIComponent(url.split('/').filter(Boolean).slice(-2).join('/'))
1339
+ setBusy(t('toast.loadingData'))
1340
+ try {
1341
+ let tileset
1342
+ try {
1343
+ tileset = await Cesium.Cesium3DTileset.fromUrl(url)
1344
+ } catch (directError) {
1345
+ if (!/^https?:/i.test(url)) throw directError
1346
+ tileset = await Cesium.Cesium3DTileset.fromUrl(proxyUrlFor(url))
1347
+ showToast(t('toast.proxyFallback'))
1348
+ }
1349
+ viewer.scene.primitives.add(tileset)
1350
+ layersRef.current.push({
1351
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
1352
+ name: label,
1353
+ url,
1354
+ tileset,
1355
+ visible: true,
1356
+ })
1357
+ syncLayers()
1358
+ setScan(null)
1359
+ await viewer.flyTo(tileset, { duration: 1.2 })
1360
+ } catch (error) {
1361
+ showToast(t('toast.loadFailed', { message: message(error) }))
1362
+ } finally {
1363
+ setBusy('')
1364
+ }
1365
+ }
1366
+
1367
+ async function pickLocal() {
1368
+ setOpenMenu(null)
1369
+ const workspace = service('uiWorkspace')
1370
+ if (!workspace) {
1371
+ showToast(t('toast.dialogUnavailable'))
1372
+ return
1373
+ }
1374
+ setBusy(t('toast.busyDirectory'))
1375
+ try {
1376
+ const dir = await workspace.pickDirectory()
1377
+ if (!dir) return
1378
+ const meta = metaRef.current
1379
+ const response = await fetch(
1380
+ `${meta.scanUrl}?token=${encodeURIComponent(meta.token)}&dir=${encodeURIComponent(dir)}`,
1381
+ { credentials: 'same-origin' },
1382
+ )
1383
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
1384
+ const data = await response.json()
1385
+ if (!data.items || data.items.length === 0) {
1386
+ showToast(t('toast.scanEmpty'))
1387
+ return
1388
+ }
1389
+ setScan({ dir, items: data.items })
1390
+ } catch (error) {
1391
+ showToast(t('toast.scanFailed', { message: message(error) }))
1392
+ } finally {
1393
+ setBusy('')
1394
+ }
1395
+ }
1396
+
1397
+ /**
1398
+ * Pick a local tile pyramid folder and turn it into a `/tiles` template.
1399
+ * Opening the folder through /scan is what whitelists it on the host, so a
1400
+ * local XYZ pyramid needs no extra permission plumbing.
1401
+ */
1402
+ async function pickLocalTiles() {
1403
+ const workspace = service('uiWorkspace')
1404
+ if (!workspace) {
1405
+ showToast(t('toast.dialogUnavailable'))
1406
+ return null
1407
+ }
1408
+ try {
1409
+ const dir = await workspace.pickDirectory()
1410
+ if (!dir) return null
1411
+ const meta = metaRef.current
1412
+ const response = await fetch(
1413
+ `${meta.scanUrl}?token=${encodeURIComponent(meta.token)}&dir=${encodeURIComponent(dir)}`,
1414
+ { credentials: 'same-origin' },
1415
+ )
1416
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
1417
+ const folder = dir.replace(/\\/g, '/').replace(/\/+$/, '')
1418
+ return `${meta.tilesBase}${encodeURIComponent(b64url(folder))}/{z}/{x}/{y}.png`
1419
+ } catch (error) {
1420
+ showToast(t('toast.scanFailed', { message: message(error) }))
1421
+ return null
1422
+ }
1423
+ }
1424
+
1425
+ async function applyImagery(id, notify) {
1426
+ const Cesium = cesiumRef.current
1427
+ const viewer = viewerRef.current
1428
+ if (!Cesium || !viewer) return
1429
+ const config = sourcesRef.current
1430
+ const source = config?.imagery.find((item) => item.id === id) ?? null
1431
+ setImageryBusy(true)
1432
+ try {
1433
+ if (imageryRef.current) {
1434
+ viewer.scene.imageryLayers.remove(imageryRef.current, true)
1435
+ imageryRef.current = null
1436
+ }
1437
+ if (source) {
1438
+ const provider = await imageryProvider(Cesium, source)
1439
+ imageryRef.current = viewer.scene.imageryLayers.addImageryProvider(provider)
1440
+ if (notify) showToast(t('toast.basemap', { name: source.name }))
1441
+ } else if (notify) {
1442
+ showToast(t('toast.basemapNone'))
1443
+ }
1444
+ } catch (error) {
1445
+ showToast(t('toast.basemapFailed', { message: message(error) }))
1446
+ } finally {
1447
+ setImageryBusy(false)
1448
+ }
1449
+ }
1450
+
1451
+ async function applyTerrain(id, notify) {
1452
+ const Cesium = cesiumRef.current
1453
+ const viewer = viewerRef.current
1454
+ if (!Cesium || !viewer) return
1455
+ const config = sourcesRef.current
1456
+ const source = config?.terrain.find((item) => item.id === id) ?? null
1457
+ setTerrainBusy(true)
1458
+ if (notify) setBusy(t('toast.terrainLoading'))
1459
+ try {
1460
+ if (source) {
1461
+ viewer.terrainProvider = await terrainProvider(Cesium, source)
1462
+ showToast(t('toast.terrainOn', { name: source.name }))
1463
+ } else {
1464
+ viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
1465
+ showToast(t('toast.terrainOff'))
1466
+ }
1467
+ } catch (error) {
1468
+ viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
1469
+ showToast(t('toast.terrainFailed', { message: message(error) }))
1470
+ } finally {
1471
+ setTerrainBusy(false)
1472
+ if (notify) setBusy('')
1473
+ }
1474
+ }
1475
+
1476
+ const changeImagery = (id) => {
1477
+ updateSettings({ imagery: id })
1478
+ applyImagery(id, true)
1479
+ setOpenMenu(null)
1480
+ }
1481
+
1482
+ const changeTerrain = (id) => {
1483
+ updateSettings({ terrain: id })
1484
+ applyTerrain(id, true)
1485
+ setOpenMenu(null)
1486
+ }
1487
+
1488
+ const setTheme = (mode) => {
1489
+ updateSettings({ theme: mode })
1490
+ setOpenMenu(null)
1491
+ }
1492
+
1493
+ const saveSources = (draft) => {
1494
+ const current = sourcesRef.current
1495
+ const meta = metaRef.current
1496
+ if (!meta) return
1497
+ const next = { ...draft }
1498
+ commitSources(next, true)
1499
+ setSettingsOpen(false)
1500
+ if (current?.settings?.imagery !== next.settings.imagery) applyImagery(next.settings.imagery, false)
1501
+ if (current?.settings?.terrain !== next.settings.terrain) applyTerrain(next.settings.terrain, false)
1502
+ }
1503
+
1504
+ function setToolMode(mode) {
1505
+ toolRef.current = mode
1506
+ setTool(mode)
1507
+ clearMeasure()
1508
+ if (mode) setPickInfo(null)
1509
+ setOpenMenu(null)
1510
+ }
1511
+
1512
+ function clearMeasure() {
1513
+ const viewer = viewerRef.current
1514
+ const state = measureRef.current
1515
+ if (viewer) {
1516
+ for (const entity of state.entities) viewer.entities.remove(entity)
1517
+ }
1518
+ state.entities = []
1519
+ state.points = []
1520
+ setMeasureInfo(null)
1521
+ }
1522
+
1523
+ function redrawMeasure(mode) {
1524
+ const Cesium = cesiumRef.current
1525
+ const viewer = viewerRef.current
1526
+ const state = measureRef.current
1527
+ if (!Cesium || !viewer) return
1528
+ for (const entity of state.entities) viewer.entities.remove(entity)
1529
+ state.entities = []
1530
+ const points = state.points
1531
+ const add = (options) => {
1532
+ const entity = viewer.entities.add(options)
1533
+ state.entities.push(entity)
1534
+ return entity
1535
+ }
1536
+ const accent = Cesium.Color.fromCssColorString('#5b8cff')
1537
+ const label = (position, text) =>
1538
+ add({
1539
+ position,
1540
+ label: {
1541
+ text,
1542
+ font: '12px -apple-system,"Segoe UI","PingFang SC",sans-serif',
1543
+ style: Cesium.LabelStyle.FILL,
1544
+ fillColor: Cesium.Color.WHITE,
1545
+ showBackground: true,
1546
+ backgroundColor: new Cesium.Color(0.06, 0.09, 0.15, 0.85),
1547
+ backgroundPadding: new Cesium.Cartesian2(8, 5),
1548
+ pixelOffset: new Cesium.Cartesian2(0, -18),
1549
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
1550
+ },
1551
+ })
1552
+ for (const position of points) {
1553
+ add({
1554
+ position,
1555
+ point: {
1556
+ pixelSize: 6,
1557
+ color: accent,
1558
+ outlineColor: Cesium.Color.WHITE,
1559
+ outlineWidth: 1,
1560
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
1561
+ },
1562
+ })
1563
+ }
1564
+ if (mode === 'distance' && points.length >= 2) {
1565
+ add({ polyline: { positions: points, width: 2, material: accent, depthFailMaterial: accent } })
1566
+ const total = geodesicLength(Cesium, points)
1567
+ label(points[points.length - 1], t('measure.total', { value: formatDistance(total) }))
1568
+ setMeasureInfo({ text: t('measure.distance', { value: formatDistance(total) }) })
1569
+ } else if (mode === 'area' && points.length >= 3) {
1570
+ add({
1571
+ polygon: {
1572
+ hierarchy: new Cesium.PolygonHierarchy(points),
1573
+ perPositionHeight: true,
1574
+ material: accent.withAlpha(0.28),
1575
+ outline: true,
1576
+ outlineColor: accent,
1577
+ },
1578
+ })
1579
+ const area = planarArea(Cesium, points)
1580
+ const center = Cesium.BoundingSphere.fromPoints(points).center
1581
+ label(center, t('measure.areaLabel', { value: formatArea(area) }))
1582
+ setMeasureInfo({ text: t('measure.area', { value: formatArea(area) }) })
1583
+ } else if (mode === 'height' && points.length === 2) {
1584
+ add({
1585
+ polyline: {
1586
+ positions: points,
1587
+ width: 2,
1588
+ material: Cesium.Color.fromCssColorString('#f0b84b'),
1589
+ depthFailMaterial: Cesium.Color.fromCssColorString('#f0b84b'),
1590
+ },
1591
+ })
1592
+ const left = Cesium.Cartographic.fromCartesian(points[0])
1593
+ const right = Cesium.Cartographic.fromCartesian(points[1])
1594
+ const delta = right.height - left.height
1595
+ const horizontal = new Cesium.EllipsoidGeodesic(left, right).surfaceDistance
1596
+ const text = t('measure.height', {
1597
+ value: `${delta >= 0 ? '+' : ''}${delta.toFixed(2)} m`,
1598
+ distance: formatDistance(horizontal),
1599
+ })
1600
+ label(points[1], text)
1601
+ setMeasureInfo({ text })
1602
+ } else {
1603
+ setMeasureInfo({ text: t('measure.start') })
1604
+ }
1605
+ }
1606
+
1607
+ function layerLabel(tileset) {
1608
+ const found = layersRef.current.find((entry) => entry.tileset === tileset)
1609
+ return found ? found.name : t('model.unnamed')
1610
+ }
1611
+
1612
+ function pickAt(position) {
1613
+ const Cesium = cesiumRef.current
1614
+ const viewer = viewerRef.current
1615
+ if (!Cesium || !viewer) return
1616
+ const picked = viewer.scene.pick(position)
1617
+ if (!Cesium.defined(picked)) {
1618
+ setPickInfo({ title: t('toast.pickNone'), rows: [] })
1619
+ return
1620
+ }
1621
+ if (picked instanceof Cesium.Cesium3DTileFeature) {
1622
+ const rows = []
1623
+ for (const id of picked.getPropertyIds()) {
1624
+ if (rows.length >= 60) break
1625
+ rows.push([id, formatValue(picked.getProperty(id))])
1626
+ }
1627
+ if (rows.length === 0) rows.push([t('pick.noProperties'), t('pick.noPropertiesValue')])
1628
+ setPickInfo({ title: layerLabel(picked.primitive ?? picked.tileset), rows })
1629
+ return
1630
+ }
1631
+ const primitive = picked?.primitive ?? picked
1632
+ let rows
1633
+ if (primitive instanceof Cesium.Cesium3DTileset) {
1634
+ rows = [
1635
+ [t('pick.type'), t('pick.tiles')],
1636
+ [t('pick.layer'), layerLabel(primitive)],
1637
+ ]
1638
+ } else if (picked?.id instanceof Cesium.Entity) {
1639
+ rows = [
1640
+ [t('pick.type'), t('pick.entity')],
1641
+ [t('pick.name'), picked.id.name ?? picked.id.id],
1642
+ ]
1643
+ } else {
1644
+ rows = [[t('pick.type'), t('pick.scene')]]
1645
+ }
1646
+ setPickInfo({ title: t('pick.title'), rows })
1647
+ }
1648
+
1649
+ function onCanvasClick(position) {
1650
+ const mode = toolRef.current
1651
+ if (mode === null) return
1652
+ if (mode === 'pick') {
1653
+ pickAt(position)
1654
+ return
1655
+ }
1656
+ const surface = pickSurface(position)
1657
+ if (!surface) {
1658
+ showToast(t('toast.noSurface'))
1659
+ return
1660
+ }
1661
+ measureRef.current.points.push(surface)
1662
+ if (mode === 'height' && measureRef.current.points.length > 2) {
1663
+ measureRef.current.points.splice(0, measureRef.current.points.length - 2)
1664
+ }
1665
+ redrawMeasure(mode)
1666
+ }
1667
+
1668
+ function finishMeasure() {
1669
+ if (toolRef.current === 'distance' || toolRef.current === 'area') {
1670
+ toolRef.current = null
1671
+ setTool(null)
1672
+ }
1673
+ }
1674
+
1675
+ function resetView() {
1676
+ const Cesium = cesiumRef.current
1677
+ const viewer = viewerRef.current
1678
+ if (!Cesium || !viewer) return
1679
+ viewer.camera.flyTo({
1680
+ destination: Cesium.Cartesian3.fromDegrees(104, 31, 14000000),
1681
+ duration: 1.2,
1682
+ })
1683
+ }
1684
+
1685
+ async function screenshot() {
1686
+ const viewer = viewerRef.current
1687
+ if (!viewer) return
1688
+ try {
1689
+ viewer.render()
1690
+ const blob = await new Promise((resolve) => viewer.canvas.toBlob(resolve, 'image/png'))
1691
+ if (!blob) throw new Error('empty canvas')
1692
+ const link = document.createElement('a')
1693
+ link.href = URL.createObjectURL(blob)
1694
+ link.download = `digital-earth-${new Date().toISOString().replace(/[:.]/g, '-')}.png`
1695
+ link.click()
1696
+ setTimeout(() => URL.revokeObjectURL(link.href), 5000)
1697
+ showToast(t('toast.screenshotSaved'))
1698
+ } catch (error) {
1699
+ showToast(t('toast.screenshotFailed', { message: message(error) }))
1700
+ }
1701
+ }
1702
+
1703
+ function toggleLayer(id) {
1704
+ const layer = layersRef.current.find((item) => item.id === id)
1705
+ if (!layer) return
1706
+ layer.visible = !layer.visible
1707
+ layer.tileset.show = layer.visible
1708
+ syncLayers()
1709
+ }
1710
+
1711
+ function flyToLayer(id) {
1712
+ const layer = layersRef.current.find((item) => item.id === id)
1713
+ const viewer = viewerRef.current
1714
+ if (layer && viewer) viewer.flyTo(layer.tileset, { duration: 1.0 })
1715
+ }
1716
+
1717
+ function removeLayer(id) {
1718
+ const viewer = viewerRef.current
1719
+ const index = layersRef.current.findIndex((item) => item.id === id)
1720
+ if (index < 0 || !viewer) return
1721
+ const [layer] = layersRef.current.splice(index, 1)
1722
+ try {
1723
+ viewer.scene.primitives.remove(layer.tileset)
1724
+ } catch {
1725
+ /* already detached */
1726
+ }
1727
+ syncLayers()
1728
+ }
1729
+
1730
+ function collectStats() {
1731
+ const Cesium = cesiumRef.current
1732
+ const viewer = viewerRef.current
1733
+ if (!Cesium || !viewer) return null
1734
+ const layerRows = layersRef.current.map((layer) => {
1735
+ const statistics = layer.tileset.statistics ?? {}
1736
+ return {
1737
+ id: layer.id,
1738
+ name: layer.name,
1739
+ tiles: statistics.numberOfTilesWithContentReady ?? statistics.numberOfTilesLoaded,
1740
+ pending: statistics.numberOfPendingRequests,
1741
+ triangles: statistics.numberOfTrianglesSelected,
1742
+ memory: layer.tileset.cacheBytes ?? layer.tileset.totalMemoryUsageInBytes,
1743
+ }
1744
+ })
1745
+ const heading = Cesium.Math.toDegrees(viewer.camera.heading ?? 0)
1746
+ const pitch = Cesium.Math.toDegrees(viewer.camera.pitch ?? 0)
1747
+ return {
1748
+ height: formatHeight(viewer.camera.positionCartographic.height),
1749
+ heading: `${heading.toFixed(0)}°`,
1750
+ pitch: `${pitch.toFixed(0)}°`,
1751
+ layers: layerRows,
1752
+ }
1753
+ }
1754
+
1755
+ async function submitUrl() {
1756
+ const value = urlInput.trim()
1757
+ if (!value) return
1758
+ let url = value
1759
+ if (/^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\')) {
1760
+ try {
1761
+ url = await localPathToUrl(value)
1762
+ } catch (error) {
1763
+ showToast(message(error))
1764
+ return
1765
+ }
1766
+ } else if (!/^https?:\/\//i.test(value)) {
1767
+ showToast(t('toast.invalidUrl'))
1768
+ return
1769
+ }
1770
+ setUrlBar(false)
1771
+ setUrlInput('')
1772
+ await addTileset(url, value.split(/[\\/]/).filter(Boolean).slice(-2).join('/'))
1773
+ }
1774
+
1775
+ React.useEffect(() => {
1776
+ let cancelled = false
1777
+ ;(async () => {
1778
+ try {
1779
+ const meta = await loadMeta()
1780
+ if (cancelled) return
1781
+ metaRef.current = meta
1782
+ const [Cesium, catalog] = await Promise.all([loadCesium(), loadSources(meta)])
1783
+ await loadCesiumStyle()
1784
+ if (cancelled || !globeRef.current) return
1785
+ cesiumRef.current = Cesium
1786
+ sourcesRef.current = catalog.config
1787
+ setSources(catalog.config)
1788
+ setDefaults(catalog.defaults)
1789
+ const viewer = new Cesium.Viewer(globeRef.current, {
1790
+ baseLayer: false,
1791
+ animation: false,
1792
+ timeline: false,
1793
+ baseLayerPicker: false,
1794
+ geocoder: false,
1795
+ homeButton: false,
1796
+ sceneModePicker: false,
1797
+ navigationHelpButton: false,
1798
+ infoBox: false,
1799
+ selectionIndicator: false,
1800
+ fullscreenButton: false,
1801
+ vrButton: false,
1802
+ creditContainer: creditRef.current,
1803
+ contextOptions: { webgl: { preserveDrawingBuffer: true } },
1804
+ })
1805
+ viewerRef.current = viewer
1806
+ viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString('#060a12')
1807
+ viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(104, 31, 14000000) })
1808
+ await applyImagery(catalog.config.settings.imagery, false)
1809
+ if (catalog.config.settings.terrain) await applyTerrain(catalog.config.settings.terrain, false)
1810
+ const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
1811
+ handler.setInputAction((event) => onCanvasClick(event.position), Cesium.ScreenSpaceEventType.LEFT_CLICK)
1812
+ handler.setInputAction(() => finishMeasure(), Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK)
1813
+ handlerRef.current = handler
1814
+ setPhase('ready')
1815
+ } catch (error) {
1816
+ if (!cancelled) {
1817
+ setError(message(error))
1818
+ setPhase('error')
1819
+ }
1820
+ }
1821
+ })()
1822
+ return () => {
1823
+ cancelled = true
1824
+ clearTimeout(toastTimer.current)
1825
+ try {
1826
+ handlerRef.current?.destroy()
1827
+ } catch {
1828
+ /* ignore */
1829
+ }
1830
+ handlerRef.current = null
1831
+ try {
1832
+ viewerRef.current?.destroy()
1833
+ } catch {
1834
+ /* ignore */
1835
+ }
1836
+ viewerRef.current = null
1837
+ layersRef.current = []
1838
+ }
1839
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1840
+ }, [])
1841
+
1842
+ React.useEffect(() => {
1843
+ if (!openMenu) return undefined
1844
+ const close = (event) => {
1845
+ if (!menuRef.current?.contains(event.target)) setOpenMenu(null)
1846
+ }
1847
+ document.addEventListener('pointerdown', close, true)
1848
+ return () => document.removeEventListener('pointerdown', close, true)
1849
+ }, [openMenu])
1850
+
1851
+ React.useEffect(() => {
1852
+ if (phase !== 'ready') return undefined
1853
+ const element = globeRef.current
1854
+ if (!element || typeof ResizeObserver === 'undefined') return undefined
1855
+ const observer = new ResizeObserver(() => {
1856
+ try {
1857
+ viewerRef.current?.resize()
1858
+ } catch {
1859
+ /* ignore */
1860
+ }
1861
+ })
1862
+ observer.observe(element)
1863
+ return () => observer.disconnect()
1864
+ }, [phase])
1865
+
1866
+ React.useEffect(() => {
1867
+ if (phase !== 'ready') return undefined
1868
+ let raf = 0
1869
+ let frames = 0
1870
+ let last = performance.now()
1871
+ const tick = (now) => {
1872
+ frames += 1
1873
+ if (now - last >= 1000) {
1874
+ setFps(Math.round((frames * 1000) / (now - last)))
1875
+ frames = 0
1876
+ last = now
1877
+ }
1878
+ raf = requestAnimationFrame(tick)
1879
+ }
1880
+ raf = requestAnimationFrame(tick)
1881
+ return () => cancelAnimationFrame(raf)
1882
+ }, [phase])
1883
+
1884
+ React.useEffect(() => {
1885
+ if (!scan) return undefined
1886
+ const onKey = (event) => {
1887
+ if (event.key === 'Escape') setScan(null)
1888
+ }
1889
+ document.addEventListener('keydown', onKey)
1890
+ return () => document.removeEventListener('keydown', onKey)
1891
+ }, [scan])
1892
+
1893
+ React.useEffect(() => {
1894
+ if (phase !== 'ready') return undefined
1895
+ const timer = setInterval(() => setStats(collectStats()), 500)
1896
+ return () => clearInterval(timer)
1897
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1898
+ }, [phase])
1899
+
1900
+ // Keep the bare globe (no imagery) and its atmosphere in step with the
1901
+ // resolved light/dark scheme, so an explicit override tints the whole panel.
1902
+ React.useEffect(() => {
1903
+ const viewer = viewerRef.current
1904
+ const Cesium = cesiumRef.current
1905
+ if (phase !== 'ready' || !viewer || !Cesium) return
1906
+ viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString(resolvedDark ? '#05080f' : '#dde6f2')
1907
+ viewer.scene.globe.showGroundAtmosphere = resolvedDark
1908
+ if (viewer.scene.skyAtmosphere) viewer.scene.skyAtmosphere.show = resolvedDark
1909
+ viewer.scene.requestRender?.()
1910
+ }, [phase, resolvedDark])
1911
+
1912
+ const button = (label, onClick, active, extra) =>
1913
+ h(
1914
+ 'button',
1915
+ { type: 'button', className: `mv-btn${active ? ' on' : ''}${extra ? ` ${extra}` : ''}`, onClick },
1916
+ label,
1917
+ )
1918
+ const menu = (id, label, items, active) =>
1919
+ h(
1920
+ 'div',
1921
+ { key: id, className: 'mv-menu' },
1922
+ button(`${label} ▾`, () => setOpenMenu(openMenu === id ? null : id), active),
1923
+ openMenu === id ? h('div', { className: 'mv-menu-panel mv-glass' }, items) : null,
1924
+ )
1925
+
1926
+ const status = busy || (phase === 'loading' ? t('panel.status.loading') : layers.length ? t(layers.length === 1 ? 'panel.status.layers.one' : 'panel.status.layers.other', { n: layers.length }) : t('panel.status.ready'))
1927
+ const hudLabels = {
1928
+ pointer: t('hud.pointer'),
1929
+ camera: t('hud.camera'),
1930
+ heading: t('hud.heading'),
1931
+ tiles: t('hud.tiles'),
1932
+ pending: t('hud.pending'),
1933
+ triangles: t('hud.triangles'),
1934
+ cache: t('hud.cache'),
1935
+ }
1936
+
1937
+ return h(
1938
+ 'div',
1939
+ { className: 'mv-root', 'data-mv-theme': themeMode === 'auto' ? undefined : themeMode, 'data-mv-scheme': resolvedDark ? 'dark' : 'light' },
1940
+ h('style', { key: 'css' }, CSS),
1941
+ h('div', { key: 'globe', ref: globeRef, className: 'mv-globe' }),
1942
+ activeImagery?.credit
1943
+ ? h('div', { key: 'attr', className: 'mv-attr' }, `${t('credit.imagery')} © ${activeImagery.credit}`)
1944
+ : null,
1945
+ h('div', { key: 'credits', ref: creditRef, className: 'mv-credits' }),
1946
+ h(
1947
+ 'div',
1948
+ { key: 'top', className: 'mv-top' },
1949
+ h(
1950
+ 'div',
1951
+ { className: 'mv-brand mv-glass' },
1952
+ h(ViewerIcon, { size: 18 }),
1953
+ h('b', null, t('panel.title')),
1954
+ h('span', null, status),
1955
+ ),
1956
+ h(
1957
+ 'div',
1958
+ { ref: menuRef, className: 'mv-actions mv-glass' },
1959
+ menu(
1960
+ 'data',
1961
+ t('btn.data'),
1962
+ [
1963
+ h(MenuItem, { key: 'local', onClick: pickLocal }, t('btn.openLocal')),
1964
+ h(
1965
+ MenuItem,
1966
+ {
1967
+ key: 'url',
1968
+ onClick: () => {
1969
+ setOpenMenu(null)
1970
+ setUrlBar((value) => !value)
1971
+ },
1972
+ },
1973
+ t('btn.openUrl'),
1974
+ ),
1975
+ h('i', { key: 'sep', className: 'mv-sep', style: { width: 'auto', height: '.5px', margin: '4px 8px' } }),
1976
+ h(
1977
+ MenuItem,
1978
+ {
1979
+ key: 'clear',
1980
+ onClick: () => {
1981
+ setOpenMenu(null)
1982
+ setToolMode(null)
1983
+ setPickInfo(null)
1984
+ setMeasureInfo(null)
1985
+ },
1986
+ },
1987
+ t('tool.clear'),
1988
+ ),
1989
+ ],
1990
+ ),
1991
+ menu(
1992
+ 'basemap',
1993
+ t('btn.basemap'),
1994
+ [
1995
+ h(
1996
+ MenuItem,
1997
+ { key: 'none', active: activeImageryId === null, onClick: () => changeImagery(null) },
1998
+ t('menu.none'),
1999
+ ),
2000
+ ...(sources?.imagery ?? []).map((source) =>
2001
+ h(
2002
+ MenuItem,
2003
+ { key: source.id, active: activeImageryId === source.id, onClick: () => changeImagery(source.id) },
2004
+ source.name,
2005
+ ),
2006
+ ),
2007
+ ],
2008
+ Boolean(activeImageryId),
2009
+ ),
2010
+ menu(
2011
+ 'terrain',
2012
+ t('btn.terrain'),
2013
+ [
2014
+ h(MenuItem, { key: 'off', active: activeTerrainId === null, onClick: () => changeTerrain(null) }, t('menu.terrainOff')),
2015
+ ...(sources?.terrain ?? []).map((source) =>
2016
+ h(
2017
+ MenuItem,
2018
+ { key: source.id, active: activeTerrainId === source.id, onClick: () => changeTerrain(source.id) },
2019
+ source.name,
2020
+ ),
2021
+ ),
2022
+ ],
2023
+ activeTerrainId !== null,
2024
+ ),
2025
+ h('i', { key: 's1', className: 'mv-sep' }),
2026
+ menu(
2027
+ 'measure',
2028
+ t('btn.measure'),
2029
+ [
2030
+ h(MenuItem, { key: 'distance', active: tool === 'distance', onClick: () => setToolMode(tool === 'distance' ? null : 'distance') }, t('tool.distance')),
2031
+ h(MenuItem, { key: 'area', active: tool === 'area', onClick: () => setToolMode(tool === 'area' ? null : 'area') }, t('tool.area')),
2032
+ h(MenuItem, { key: 'height', active: tool === 'height', onClick: () => setToolMode(tool === 'height' ? null : 'height') }, t('tool.height')),
2033
+ h(MenuItem, { key: 'pick', active: tool === 'pick', onClick: () => setToolMode(tool === 'pick' ? null : 'pick') }, t('tool.pick')),
2034
+ ],
2035
+ tool !== null,
2036
+ ),
2037
+ h('i', { key: 's2', className: 'mv-sep' }),
2038
+ button(`${t('btn.layers')}${layers.length ? ` ${layers.length}` : ''}`, () => setLayersOpen((value) => !value), layersOpen && layers.length > 0),
2039
+ button(t('btn.screenshot'), screenshot),
2040
+ button(t('btn.reset'), resetView),
2041
+ h('i', { key: 's3', className: 'mv-sep' }),
2042
+ h(
2043
+ 'div',
2044
+ { key: 'appearance', className: 'mv-menu' },
2045
+ h(
2046
+ 'button',
2047
+ {
2048
+ type: 'button',
2049
+ className: 'mv-btn icon',
2050
+ title: `${t('btn.appearance')} · ${t(themeMode === 'auto' ? 'theme.auto' : themeMode === 'light' ? 'theme.light' : 'theme.dark')}`,
2051
+ 'aria-haspopup': 'menu',
2052
+ onClick: () => setOpenMenu(openMenu === 'appearance' ? null : 'appearance'),
2053
+ },
2054
+ h(ThemeIcon, { mode: resolvedDark ? 'dark' : 'light' }),
2055
+ ),
2056
+ openMenu === 'appearance'
2057
+ ? h(
2058
+ 'div',
2059
+ { className: 'mv-menu-panel mv-glass' },
2060
+ [
2061
+ ['auto', 'theme.auto'],
2062
+ ['light', 'theme.light'],
2063
+ ['dark', 'theme.dark'],
2064
+ ].map(([mode, label]) =>
2065
+ h(
2066
+ MenuItem,
2067
+ {
2068
+ key: mode,
2069
+ active: themeMode === mode,
2070
+ onClick: () => setTheme(mode),
2071
+ },
2072
+ t(label),
2073
+ ),
2074
+ ),
2075
+ )
2076
+ : null,
2077
+ ),
2078
+ h(
2079
+ 'button',
2080
+ {
2081
+ key: 'settings',
2082
+ type: 'button',
2083
+ className: 'mv-btn icon',
2084
+ title: t('btn.settings'),
2085
+ onClick: () => setSettingsOpen(true),
2086
+ },
2087
+ h(
2088
+ 'svg',
2089
+ { viewBox: '0 0 24 24', width: 16, height: 16, 'aria-hidden': true, style: { display: 'block' } },
2090
+ h('g', { stroke: 'currentColor', strokeWidth: 1.7, fill: 'none', strokeLinecap: 'round' },
2091
+ h('circle', { cx: 12, cy: 12, r: 3.2 }),
2092
+ h('path', { d: 'M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.2a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.2a1.6 1.6 0 0 0-1.4 1z' }),
2093
+ ),
2094
+ ),
2095
+ ),
2096
+ ),
2097
+ ),
2098
+ urlBar
2099
+ ? h(
2100
+ 'div',
2101
+ { key: 'urlbar', className: 'mv-urlbar mv-glass' },
2102
+ h('input', {
2103
+ autoFocus: true,
2104
+ value: urlInput,
2105
+ placeholder: t('urlbar.placeholder'),
2106
+ onChange: (event) => setUrlInput(event.target.value),
2107
+ onKeyDown: (event) => {
2108
+ if (event.key === 'Enter') submitUrl()
2109
+ if (event.key === 'Escape') setUrlBar(false)
2110
+ },
2111
+ }),
2112
+ h('button', { type: 'button', onClick: submitUrl }, t('urlbar.submit')),
2113
+ )
2114
+ : null,
2115
+ layersOpen && layers.length > 0
2116
+ ? h(
2117
+ 'div',
2118
+ { key: 'layers', className: 'mv-layers mv-glass' },
2119
+ h(
2120
+ 'header',
2121
+ null,
2122
+ h('span', null, `${t('layers.title')} · ${layers.length}`),
2123
+ ),
2124
+ h(
2125
+ 'div',
2126
+ { className: 'mv-layer-list' },
2127
+ layers.map((layer) =>
2128
+ h(
2129
+ 'div',
2130
+ { key: layer.id, className: `mv-layer${layer.visible ? '' : ' off'}` },
2131
+ h('span', { className: 'name', title: layer.name, onClick: () => flyToLayer(layer.id) }, layer.name),
2132
+ h(
2133
+ 'button',
2134
+ {
2135
+ type: 'button',
2136
+ className: `mv-icon-btn${layer.visible ? '' : ' on'}`,
2137
+ title: layer.visible ? t('btn.visible') : t('btn.hidden'),
2138
+ onClick: () => toggleLayer(layer.id),
2139
+ },
2140
+ layer.visible ? '◉' : '○',
2141
+ ),
2142
+ h('button', { type: 'button', className: 'mv-icon-btn', title: t('btn.flyTo'), onClick: () => flyToLayer(layer.id) }, '⌖'),
2143
+ h('button', { type: 'button', className: 'mv-icon-btn', title: t('btn.delete'), onClick: () => removeLayer(layer.id) }, '✕'),
2144
+ ),
2145
+ ),
2146
+ ),
2147
+ )
2148
+ : null,
2149
+ phase === 'ready' && layers.length === 0
2150
+ ? h(
2151
+ 'div',
2152
+ { key: 'empty', className: 'mv-overlay' },
2153
+ h(
2154
+ 'div',
2155
+ { className: 'mv-empty' },
2156
+ h(ViewerIcon, { size: 30 }),
2157
+ h('h2', null, t('panel.empty.title')),
2158
+ h('p', null, t('panel.empty.body')),
2159
+ h(
2160
+ 'div',
2161
+ { className: 'buttons' },
2162
+ h('button', { type: 'button', className: 'mv-cta primary', onClick: pickLocal }, t('panel.empty.local')),
2163
+ h('button', { type: 'button', className: 'mv-cta', onClick: () => setUrlBar(true) }, t('panel.empty.url')),
2164
+ ),
2165
+ ),
2166
+ )
2167
+ : null,
2168
+ phase === 'loading'
2169
+ ? h('div', { key: 'loading', className: 'mv-overlay dim' }, h('div', { className: 'mv-empty' }, h('div', { className: 'mv-spinner' }), h('p', null, t('panel.status.loading'))))
2170
+ : null,
2171
+ phase === 'error'
2172
+ ? h(
2173
+ 'div',
2174
+ { key: 'error', className: 'mv-overlay dim' },
2175
+ h(
2176
+ 'div',
2177
+ { className: 'mv-empty' },
2178
+ h('h2', null, '⚠'),
2179
+ h('p', null, error),
2180
+ h('div', { className: 'buttons' }, h('button', { type: 'button', className: 'mv-cta', onClick: () => location.reload() }, t('btn.retry'))),
2181
+ ),
2182
+ )
2183
+ : null,
2184
+ stats
2185
+ ? h(StatusHud, { key: 'hud', fps, stats, viewerRef, cesiumRef, labels: hudLabels })
2186
+ : null,
2187
+ measureInfo ? h('div', { key: 'measure', className: 'mv-measure mv-glass' }, measureInfo.text) : null,
2188
+ pickInfo
2189
+ ? h(
2190
+ 'div',
2191
+ { key: 'pick', className: 'mv-pick mv-glass' },
2192
+ h(
2193
+ 'header',
2194
+ null,
2195
+ h('span', null, pickInfo.title),
2196
+ h(
2197
+ 'button',
2198
+ { type: 'button', className: 'mv-icon-btn', onClick: () => setPickInfo(null), title: t('btn.close') },
2199
+ '✕',
2200
+ ),
2201
+ ),
2202
+ h(
2203
+ 'table',
2204
+ null,
2205
+ h(
2206
+ 'tbody',
2207
+ null,
2208
+ pickInfo.rows.map(([name, value]) => h('tr', { key: name }, h('td', null, name), h('td', null, value))),
2209
+ ),
2210
+ ),
2211
+ )
2212
+ : null,
2213
+ toast ? h('div', { key: 'toast', className: 'mv-toast' }, toast) : null,
2214
+ scan
2215
+ ? h(
2216
+ 'div',
2217
+ { key: 'scan', className: 'mv-modal', onPointerDown: (event) => event.target === event.currentTarget && setScan(null) },
2218
+ h(
2219
+ 'div',
2220
+ { className: 'mv-dialog' },
2221
+ h(
2222
+ 'header',
2223
+ null,
2224
+ h('b', null, t('layers.title')),
2225
+ h('span', { className: 'path', style: { fontWeight: 400, color: 'var(--mv-dim)' } }, scan.dir),
2226
+ h('button', { type: 'button', className: 'mv-icon-btn', onClick: () => setScan(null), title: t('btn.close') }, '✕'),
2227
+ ),
2228
+ h(
2229
+ 'div',
2230
+ { className: 'body' },
2231
+ scan.items.map((item) =>
2232
+ h(
2233
+ 'button',
2234
+ {
2235
+ key: item.url,
2236
+ type: 'button',
2237
+ className: 'mv-cta',
2238
+ style: { textAlign: 'left', display: 'flex', flexDirection: 'column', gap: '2px' },
2239
+ onClick: () => addTileset(item.url, item.name.split(/[\\/]/).filter(Boolean).slice(-2).join('/')),
2240
+ },
2241
+ h('span', null, item.name),
2242
+ ),
2243
+ ),
2244
+ ),
2245
+ ),
2246
+ )
2247
+ : null,
2248
+ settingsOpen && sources && defaults
2249
+ ? h(SourcesDialog, {
2250
+ key: 'settings',
2251
+ config: sources,
2252
+ defaults,
2253
+ environmentDark,
2254
+ themeMode,
2255
+ messages: t,
2256
+ onPickLocal: pickLocalTiles,
2257
+ onSave: saveSources,
2258
+ onClose: () => setSettingsOpen(false),
2259
+ })
2260
+ : null,
2261
+ imageryBusy || terrainBusy
2262
+ ? h('div', { key: 'busy', className: 'mv-measure mv-glass', style: { bottom: '44px' } }, t('toast.loadingData'))
2263
+ : null,
2264
+ )
2265
+ }
2266
+
2267
+ return {
2268
+ inject: ['slots', 'uiWorkspace', 'locale', 'theme'],
2269
+ apply(ctx) {
2270
+ appCtx = ctx
2271
+ const NS = 'dsh-digital-earth'
2272
+ // Registered through ctx.effect so a hot reload disposes the old dictionaries
2273
+ // before the new ones land (the registry rejects duplicate namespaces).
2274
+ ctx.effect(
2275
+ () => ctx.locale.register(NS, { zh: MESSAGES.zh, en: MESSAGES.en }),
2276
+ 'dsh-digital-earth: dictionaries',
2277
+ )
2278
+ const translate = ctx.locale.bind(NS)
2279
+ ctx.slots.inject('sidebar.panellist', () =>
2280
+ ctx.slots.register(
2281
+ {
2282
+ name: 'sidebar.panellist',
2283
+ id: 'digital-earth',
2284
+ order: 30,
2285
+ label: () => translate('panel.title'),
2286
+ locale: NS,
2287
+ },
2288
+ ViewerIcon,
2289
+ ),
2290
+ )
2291
+ ctx.slots.inject('main', () =>
2292
+ ctx.slots.register({ name: 'main', key: 'digital-earth', locale: NS }, ViewerPanel),
2293
+ )
2294
+ },
2295
+ }
2296
+ },
2297
+ })