@map-zero/cesium 0.1.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 (2) hide show
  1. package/package.json +13 -0
  2. package/src/index.js +405 -0
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@map-zero/cesium",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Cesium integration helper for map-zero 3D Tiles packages.",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "peerDependencies": {
11
+ "cesium": "^1.141.0"
12
+ }
13
+ }
package/src/index.js ADDED
@@ -0,0 +1,405 @@
1
+ import {
2
+ Cesium3DTileStyle,
3
+ Cesium3DTileset
4
+ } from 'cesium';
5
+
6
+ let autoInstanceCounter = 0;
7
+
8
+ /**
9
+ * @typedef {{
10
+ * id?: string,
11
+ * format?: string,
12
+ * version?: number,
13
+ * name?: string,
14
+ * bbox?: [number, number, number, number],
15
+ * styles?: Record<string, string>,
16
+ * tiles3d?: { format?: string, url?: string, layers?: string[] },
17
+ * cesium?: { tilesets?: Record<string, string>, bbox?: [number, number, number, number], focusBbox?: [number, number, number, number] },
18
+ * layers?: Array<{ id: string, table?: string, style?: string }>
19
+ * }} MapZeroManifest
20
+ */
21
+
22
+ /**
23
+ * Load a map-zero manifest.
24
+ *
25
+ * @param {string} manifestUrl
26
+ * @returns {Promise<MapZeroManifest>}
27
+ */
28
+ export async function loadMapZeroManifest(manifestUrl) {
29
+ const response = await fetch(manifestUrl);
30
+ if (!response.ok) {
31
+ throw new Error(`failed to load map-zero manifest: ${response.status}`);
32
+ }
33
+
34
+ return response.json();
35
+ }
36
+
37
+ /**
38
+ * Load a style document.
39
+ *
40
+ * Supported forms:
41
+ * - loadMapZeroStyle('./styles/neon-dark.json')
42
+ * - loadMapZeroStyle(manifest, { manifestUrl, style: 'default' })
43
+ *
44
+ * @param {string | MapZeroManifest} input
45
+ * @param {{ manifestUrl?: string, style?: string }} [options]
46
+ * @returns {Promise<Record<string, unknown> | null>}
47
+ */
48
+ export async function loadMapZeroStyle(input, options = {}) {
49
+ if (typeof input === 'string') {
50
+ const response = await fetch(resolveRelativeUrl(input, globalThis.location?.href ?? 'http://localhost/'));
51
+ if (!response.ok) {
52
+ throw new Error(`failed to load map-zero style: ${response.status}`);
53
+ }
54
+
55
+ return response.json();
56
+ }
57
+
58
+ const manifest = input;
59
+ const key = options.style ?? 'default';
60
+ const stylePath = manifest.styles?.[key] ?? manifest.styles?.default;
61
+ if (!stylePath) {
62
+ return null;
63
+ }
64
+
65
+ const response = await fetch(resolveRelativeUrl(stylePath, options.manifestUrl ?? globalThis.location?.href ?? 'http://localhost/'));
66
+ if (!response.ok) {
67
+ throw new Error(`failed to load map-zero style: ${response.status}`);
68
+ }
69
+
70
+ return response.json();
71
+ }
72
+
73
+ /**
74
+ * Create Cesium 3D Tiles primitives for a map-zero package.
75
+ *
76
+ * @param {{
77
+ * id?: string,
78
+ * manifestUrl: string,
79
+ * manifest?: MapZeroManifest,
80
+ * style?: string | Record<string, unknown>,
81
+ * styleJson?: Record<string, unknown> | null,
82
+ * opacity?: number
83
+ * }} options
84
+ * @returns {Promise<{ id: string, manifest: MapZeroManifest, style: Record<string, unknown> | null, tilesets: Record<string, Cesium3DTileset> }>}
85
+ */
86
+ export async function createMapZeroCesiumTilesets(options) {
87
+ const manifest = options.manifest ?? await loadMapZeroManifest(options.manifestUrl);
88
+ const instanceId = createInstanceId(options.id, manifest, options.manifestUrl);
89
+ const styleJson = options.styleJson ?? (
90
+ options.style && typeof options.style === 'object'
91
+ ? options.style
92
+ : await loadMapZeroStyle(manifest, {
93
+ manifestUrl: options.manifestUrl,
94
+ style: typeof options.style === 'string' ? options.style : undefined
95
+ })
96
+ );
97
+
98
+ const tilesetEntries = manifestTilesetEntries(manifest);
99
+ if (tilesetEntries.length === 0) {
100
+ return {
101
+ id: instanceId,
102
+ manifest,
103
+ style: styleJson,
104
+ tilesets: {}
105
+ };
106
+ }
107
+
108
+ /** @type {Record<string, Cesium3DTileset>} */
109
+ const tilesets = {};
110
+ for (const entry of tilesetEntries) {
111
+ const url = resolveRelativeUrl(entry.url, options.manifestUrl);
112
+ const tileset = await Cesium3DTileset.fromUrl(url);
113
+ tagCesiumTileset(tileset, instanceId, entry.layerId);
114
+ tileset.style = createMapZeroCesiumStyle(styleJson, {
115
+ layerId: entry.layerId,
116
+ visibleLayers: new Set([entry.layerId]),
117
+ opacity: options.opacity ?? 1
118
+ });
119
+ tilesets[entry.layerId] = tileset;
120
+ }
121
+
122
+ return {
123
+ id: instanceId,
124
+ manifest,
125
+ style: styleJson,
126
+ tilesets
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Add map-zero 3D Tiles to an existing Cesium Viewer.
132
+ *
133
+ * The helper does not create or own the viewer. It only adds map-zero
134
+ * primitives and returns a small controller.
135
+ *
136
+ * @param {{ scene: { primitives: { add: (primitive: unknown) => unknown, remove: (primitive: unknown) => boolean } } }} viewer
137
+ * @param {{
138
+ * id?: string,
139
+ * manifestUrl: string,
140
+ * style?: string | Record<string, unknown>,
141
+ * opacity?: number,
142
+ * zoomTo?: boolean,
143
+ * applyDefaultSceneStyle?: boolean,
144
+ * configureScene?: (viewer: unknown) => void
145
+ * }} options
146
+ * @returns {Promise<{
147
+ * id: string,
148
+ * manifest: MapZeroManifest,
149
+ * style: Record<string, unknown> | null,
150
+ * tilesets: Record<string, Cesium3DTileset>,
151
+ * setVisible: (layerId: string, visible: boolean) => void,
152
+ * setOpacity: (layerId: string, opacity: number) => void,
153
+ * destroy: () => void
154
+ * }>}
155
+ */
156
+ export async function addMapZeroToCesium(viewer, options) {
157
+ if (options.applyDefaultSceneStyle) {
158
+ applyMapZeroCesiumSceneStyle(viewer);
159
+ }
160
+ if (typeof options.configureScene === 'function') {
161
+ options.configureScene(viewer);
162
+ }
163
+
164
+ const result = await createMapZeroCesiumTilesets(options);
165
+ const uniqueTilesets = [...new Set(Object.values(result.tilesets))];
166
+ const visibleLayers = new Set(Object.keys(result.tilesets));
167
+ let opacity = options.opacity ?? 1;
168
+
169
+ for (const tileset of uniqueTilesets) {
170
+ viewer.scene.primitives.add(tileset);
171
+ }
172
+ if (options.zoomTo !== false && typeof viewer.zoomTo === 'function') {
173
+ const firstTileset = uniqueTilesets[0];
174
+ if (firstTileset) {
175
+ await viewer.zoomTo(firstTileset);
176
+ }
177
+ }
178
+
179
+ return {
180
+ manifest: result.manifest,
181
+ id: result.id,
182
+ style: result.style,
183
+ tilesets: result.tilesets,
184
+ setVisible(layerId, visible) {
185
+ const tileset = result.tilesets[layerId];
186
+ if (tileset) {
187
+ if (visible) {
188
+ visibleLayers.add(layerId);
189
+ } else {
190
+ visibleLayers.delete(layerId);
191
+ }
192
+ applyStyleToTilesetMap(result.tilesets, result.style, {
193
+ opacity,
194
+ visibleLayers
195
+ });
196
+ }
197
+ },
198
+ setOpacity(layerId, nextOpacity) {
199
+ if (!result.tilesets[layerId]) return;
200
+ opacity = clamp01(Number(nextOpacity));
201
+ applyStyleToTilesetMap(result.tilesets, result.style, {
202
+ opacity,
203
+ visibleLayers
204
+ });
205
+ },
206
+ destroy() {
207
+ for (const tileset of uniqueTilesets) {
208
+ viewer.scene.primitives.remove(tileset);
209
+ }
210
+ }
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Apply optional tactical scene defaults for the built-in map-zero viewer.
216
+ *
217
+ * This is intentionally opt-in. External applications should keep full control
218
+ * of their Cesium Viewer and call this helper only when they want the map-zero
219
+ * black-background tactical look.
220
+ *
221
+ * @param {any} viewer
222
+ */
223
+ export function applyMapZeroCesiumSceneStyle(viewer) {
224
+ const Cesium = globalThis.Cesium;
225
+ const scene = viewer?.scene;
226
+ if (!scene || !Cesium) {
227
+ return;
228
+ }
229
+
230
+ scene.backgroundColor = Cesium.Color.BLACK;
231
+ if (scene.globe) {
232
+ scene.globe.baseColor = Cesium.Color.BLACK;
233
+ scene.globe.enableLighting = false;
234
+ scene.globe.depthTestAgainstTerrain = false;
235
+ }
236
+ if (scene.fog) scene.fog.enabled = false;
237
+ if (scene.skyBox) scene.skyBox.show = false;
238
+ if (scene.sun) scene.sun.show = false;
239
+ if (scene.moon) scene.moon.show = false;
240
+ if (scene.skyAtmosphere) scene.skyAtmosphere.show = false;
241
+ }
242
+
243
+ /**
244
+ * Convert a map-zero layer style to a Cesium 3D Tiles style.
245
+ *
246
+ * @param {Record<string, unknown> | null} styleJson
247
+ * @param {{ layerId: string, opacity?: number, visibleLayers?: Set<string> }} options
248
+ * @returns {Cesium3DTileStyle}
249
+ */
250
+ export function createMapZeroCesiumStyle(styleJson, options) {
251
+ const visibleLayers = options.visibleLayers ?? new Set([options.layerId]);
252
+ if (!visibleLayers.has(options.layerId)) {
253
+ return new Cesium3DTileStyle({
254
+ show: false
255
+ });
256
+ }
257
+
258
+ const rule = layerStyle(styleJson, options.layerId);
259
+ const { color, opacity } = cesiumLayerMaterial(rule, options.layerId);
260
+ return new Cesium3DTileStyle({
261
+ color: `color('${safeCssColor(color)}', ${clamp01(Number(options.opacity ?? 1) * opacity).toFixed(3)})`,
262
+ show: true
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Pick a single material color from a map-zero style rule.
268
+ *
269
+ * In 2D, buildings commonly use a dark fill plus a bright stroke. A single
270
+ * Cesium material cannot show that outline, so building solids use the body or
271
+ * stroke color instead of the dark fill.
272
+ *
273
+ * @param {Record<string, any> | null} rule
274
+ * @param {string} layerId
275
+ * @returns {{ color: string, opacity: number }}
276
+ */
277
+ function cesiumLayerMaterial(rule, layerId) {
278
+ if (layerId === 'buildings') {
279
+ return {
280
+ color: String(rule?.body?.color ?? rule?.stroke ?? rule?.fill ?? '#ff00ff'),
281
+ opacity: clamp01(Number(rule?.body?.opacity ?? rule?.strokeOpacity ?? rule?.fillOpacity ?? 0.8))
282
+ };
283
+ }
284
+
285
+ return {
286
+ color: String(rule?.fill ?? rule?.body?.color ?? rule?.stroke ?? '#00ffff'),
287
+ opacity: clamp01(Number(rule?.fillOpacity ?? rule?.body?.opacity ?? rule?.strokeOpacity ?? 0.8))
288
+ };
289
+ }
290
+
291
+ /**
292
+ * @param {Record<string, Cesium3DTileset>} tilesets
293
+ * @param {Record<string, unknown> | null} style
294
+ * @param {{ opacity: number, visibleLayers: Set<string> }} options
295
+ */
296
+ function applyStyleToTilesetMap(tilesets, style, options) {
297
+ for (const [layerId, tileset] of Object.entries(tilesets)) {
298
+ tileset.style = createMapZeroCesiumStyle(style, {
299
+ layerId,
300
+ opacity: options.opacity,
301
+ visibleLayers: options.visibleLayers
302
+ });
303
+ }
304
+ }
305
+
306
+ /**
307
+ * @param {Record<string, unknown> | null} styleJson
308
+ * @param {string} layerId
309
+ * @returns {Record<string, any> | null}
310
+ */
311
+ function layerStyle(styleJson, layerId) {
312
+ const layers = /** @type {{ layers?: Record<string, unknown> } | null} */ (styleJson)?.layers;
313
+ return /** @type {Record<string, any> | null} */ (layers?.[layerId] ?? null);
314
+ }
315
+
316
+ /**
317
+ * @param {MapZeroManifest} manifest
318
+ * @returns {Array<{ layerId: string, url: string }>}
319
+ */
320
+ function manifestTilesetEntries(manifest) {
321
+ const cesiumTilesets = manifest.cesium?.tilesets;
322
+ if (cesiumTilesets && typeof cesiumTilesets === 'object') {
323
+ return Object.entries(cesiumTilesets)
324
+ .filter(([, url]) => typeof url === 'string' && url.length > 0)
325
+ .map(([layerId, url]) => ({ layerId, url }));
326
+ }
327
+
328
+ if (manifest.tiles3d?.format === '3dtiles' && typeof manifest.tiles3d.url === 'string') {
329
+ const layers = Array.isArray(manifest.tiles3d.layers) && manifest.tiles3d.layers.length > 0
330
+ ? manifest.tiles3d.layers.map(String)
331
+ : ['buildings'];
332
+ return layers.map((layerId) => ({
333
+ layerId,
334
+ url: /** @type {string} */ (manifest.tiles3d?.url)
335
+ }));
336
+ }
337
+
338
+ return [];
339
+ }
340
+
341
+ /**
342
+ * @param {string | undefined} id
343
+ * @param {MapZeroManifest} manifest
344
+ * @param {string} manifestUrl
345
+ * @returns {string}
346
+ */
347
+ function createInstanceId(id, manifest, manifestUrl) {
348
+ if (id) {
349
+ return safeInstanceId(id);
350
+ }
351
+
352
+ const name = typeof manifest.name === 'string' && manifest.name.trim()
353
+ ? manifest.name
354
+ : new URL(manifestUrl, globalThis.location?.href ?? 'http://localhost/').pathname.split('/').filter(Boolean).at(-2) ?? 'mapzero';
355
+ autoInstanceCounter += 1;
356
+ return `${safeInstanceId(name)}-${autoInstanceCounter}`;
357
+ }
358
+
359
+ /**
360
+ * @param {string} id
361
+ * @returns {string}
362
+ */
363
+ function safeInstanceId(id) {
364
+ return id.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'mapzero';
365
+ }
366
+
367
+ /**
368
+ * @param {Cesium3DTileset} tileset
369
+ * @param {string} instanceId
370
+ * @param {string} layerId
371
+ */
372
+ function tagCesiumTileset(tileset, instanceId, layerId) {
373
+ tileset.mapZero = {
374
+ id: instanceId,
375
+ layerId,
376
+ namespacedLayerId: `${instanceId}:${layerId}`
377
+ };
378
+ }
379
+
380
+ /**
381
+ * @param {string} path
382
+ * @param {string} baseUrl
383
+ * @returns {string}
384
+ */
385
+ function resolveRelativeUrl(path, baseUrl) {
386
+ const absoluteBase = new URL(baseUrl, globalThis.location?.href ?? 'http://localhost/').href;
387
+ return new URL(path, absoluteBase).toString();
388
+ }
389
+
390
+ /**
391
+ * @param {string} color
392
+ * @returns {string}
393
+ */
394
+ function safeCssColor(color) {
395
+ return /^#[0-9a-f]{6}$/i.test(color) ? color : '#ff00ff';
396
+ }
397
+
398
+
399
+ /**
400
+ * @param {number} value
401
+ * @returns {number}
402
+ */
403
+ function clamp01(value) {
404
+ return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1));
405
+ }