@map-zero/cesium 0.3.2 → 0.4.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcos Pérez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -6,3 +6,13 @@ See the main repository for documentation, examples, and release notes:
6
6
 
7
7
  https://github.com/mploscos/map-zero
8
8
 
9
+
10
+ ## Cesium 1.145 native vectors
11
+
12
+ Version 0.4.0 renders native MVT with an XYZ `vectorTilesUrl`, using Cesium 1.145 `MVTDataProvider` and surface draping. The built-in server exposes PMTiles data at `/api/vector-tiles/{z}/{x}/{y}.mvt`.
13
+
14
+ Native labels share the OpenLayers theme’s selection and priority rules, with deduplication and screen decluttering. Set `labels: false` or `maxLabels` (default 150), and toggle them with `controller.setLabelsVisible()`.
15
+
16
+ The raster worker has been removed. Cesium no longer requires OpenLayers or PMTiles as peer dependencies. Provide an MVT endpoint for context, or `contextOverlay: false` for a static 3D Tiles-only scene. Re-export old PMTiles for label anchor metadata. See [integration details](https://github.com/mploscos/map-zero/blob/v0.4.0/docs/cesium.md).
17
+
18
+ Requires Cesium 1.145.0 and Node 22+ for package consumers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@map-zero/cesium",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Cesium integration helper for map-zero 3D Tiles packages.",
6
6
  "license": "MIT",
@@ -22,11 +22,12 @@
22
22
  "README.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@map-zero/raster": "0.3.2"
25
+ "@map-zero/core": "0.4.0"
26
26
  },
27
27
  "peerDependencies": {
28
- "cesium": "1.141.0",
29
- "ol": "10.10.0",
30
- "pmtiles": "4.4.1"
28
+ "cesium": "1.145.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
31
32
  }
32
33
  }
@@ -0,0 +1,150 @@
1
+ import {
2
+ Cartesian2, Cartesian3, Color, EllipsoidalOccluder, HeightReference,
3
+ HorizontalOrigin, LabelCollection, LabelStyle, SceneMode, SceneTransforms, VerticalOrigin
4
+ } from 'cesium';
5
+ import { LruCache } from '../../core/src/shared/cache.js';
6
+ import { describeLabel } from '../../core/src/labels.js';
7
+ import { getLayerRule, zoomMatchesRule } from '../../core/src/style.js';
8
+
9
+ /** Deterministic priority placement; bounds the visible label count and overlap checks. */
10
+ export function selectLabels(candidates, maxLabels) {
11
+ const selected = [];
12
+ const ids = new Set();
13
+ candidates.sort((a, b) => b.priority - a.priority || a.key.localeCompare(b.key));
14
+ for (const candidate of candidates) {
15
+ if (ids.has(candidate.key)) continue;
16
+ ids.add(candidate.key);
17
+ const a = candidate.box;
18
+ if (selected.some(({ box: b }) => a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1])) continue;
19
+ selected.push(candidate);
20
+ if (selected.length >= maxLabels) break;
21
+ }
22
+ return selected;
23
+ }
24
+
25
+ /** Labels share the native provider's loaded tiles; no extra requests or MVT decode. */
26
+ export function createCesiumLabels(viewer, tileset, options) {
27
+ const maxLabels = options.maxLabels ?? 150;
28
+ if (!Number.isInteger(maxLabels) || maxLabels < 1 || maxLabels > 1000) {
29
+ throw new Error('maxLabels must be an integer between 1 and 1000');
30
+ }
31
+ const scene = viewer.scene;
32
+ const collection = scene.primitives.add(new LabelCollection({ scene, show: options.labels !== false }));
33
+ const cache = new WeakMap();
34
+ const visible = new Set();
35
+ const active = new Map();
36
+ const widths = new LruCache(1024);
37
+ const measure = document.createElement('canvas').getContext('2d');
38
+ const occluder = new EllipsoidalOccluder(scene.globe?.ellipsoid);
39
+ let destroyed = false;
40
+
41
+ function candidatesFor(content) {
42
+ if (cache.has(content)) return cache.get(content);
43
+ const candidates = [];
44
+ // Cesium 1.145 vector content has one feature table per MVT source layer.
45
+ const tables = content.batchTables ?? [];
46
+ for (let table = 0; table < tables.length; table++) {
47
+ for (let i = 0; i < tables[table].featuresLength; i++) {
48
+ const feature = content.getFeature(i, table);
49
+ if (!feature) continue;
50
+ const lon = feature.getProperty('mapzero_label_lon');
51
+ const lat = feature.getProperty('mapzero_label_lat');
52
+ if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lon) > 180 || Math.abs(lat) > 90) continue;
53
+ const source = String(feature.getProperty('_layer') ?? feature.getProperty('layer') ?? '');
54
+ const layer = source === 'aviation' ? 'aip' : source;
55
+ const properties = Object.fromEntries(feature.getPropertyIds().map((key) => [key, feature.getProperty(key)]));
56
+ const adapter = { get: (key) => properties[key] };
57
+ // Keep data independent of the feature lifetime. Eligibility is evaluated
58
+ // again at the current zoom, so zoom limits in custom themes still apply.
59
+ const id = properties.id ?? properties.fid ?? `${lon},${lat}`;
60
+ candidates.push({ key: `${layer}:${id}`, layer, adapter, position: Cartesian3.fromDegrees(lon, lat) });
61
+ }
62
+ }
63
+ cache.set(content, candidates);
64
+ return candidates;
65
+ }
66
+
67
+ function update() {
68
+ if (destroyed || !collection.show) return;
69
+ const candidates = [];
70
+ const zoom = options.getZoom();
71
+ const width = scene.canvas.clientWidth, height = scene.canvas.clientHeight;
72
+ const layerRules = new Map();
73
+ occluder.cameraPosition = viewer.camera.positionWC;
74
+ for (const content of visible) {
75
+ for (const candidate of candidatesFor(content)) {
76
+ const { layer, adapter, position } = candidate;
77
+ if (!layerRules.has(layer)) layerRules.set(layer, getLayerRule(options.styleDocument, { id: layer }));
78
+ if (options.visibility.get(layer) === false || !zoomMatchesRule(zoom, layerRules.get(layer))) continue;
79
+ const opacity = options.opacities.get(layer) ?? options.opacity ?? 1;
80
+ if (opacity <= 0) continue;
81
+ if (candidate.zoom !== zoom) {
82
+ candidate.zoom = zoom;
83
+ candidate.descriptor = describeLabel(adapter, layer, zoom, options.styleDocument);
84
+ if (candidate.descriptor?.text.length > 64) {
85
+ candidate.descriptor.text = candidate.descriptor.text.slice(0, 61).trimEnd() + '…';
86
+ }
87
+ }
88
+ const descriptor = candidate.descriptor;
89
+ if (!descriptor) continue;
90
+ if (scene.mode === SceneMode.SCENE3D && !occluder.isPointVisible(position)) continue;
91
+ const point = SceneTransforms.worldToWindowCoordinates(scene, position);
92
+ if (!point || point.x < 0 || point.x > width || point.y < 0 || point.y > height) continue;
93
+ const measureKey = `${descriptor.font}:${descriptor.text}`;
94
+ let textWidth = widths.get(measureKey);
95
+ if (textWidth === undefined) {
96
+ measure.font = descriptor.font;
97
+ textWidth = measure.measureText(descriptor.text).width;
98
+ widths.set(measureKey, textWidth);
99
+ }
100
+ const halfWidth = textWidth / 2 + descriptor.haloWidth + 6;
101
+ const labelHeight = (Number(/([\d.]+)px/.exec(descriptor.font)?.[1]) || 12) + 2 * descriptor.haloWidth + 6;
102
+ candidates.push({ ...candidate, ...descriptor, opacity,
103
+ box: [point.x - halfWidth, point.y - labelHeight - 6, point.x + halfWidth, point.y - 6] });
104
+ }
105
+ }
106
+ const selected = selectLabels(candidates, maxLabels);
107
+ const keys = new Set(selected.map((label) => label.key));
108
+ let changed = false;
109
+ for (const [key, value] of active) {
110
+ if (!keys.has(key)) { collection.remove(value.label); active.delete(key); changed = true; }
111
+ }
112
+ for (const candidate of selected) {
113
+ const signature = JSON.stringify([candidate.text, candidate.font, candidate.fill, candidate.halo, candidate.haloWidth, candidate.opacity]);
114
+ const previous = active.get(candidate.key);
115
+ if (previous?.signature === signature) continue;
116
+ const fillColor = Color.fromCssColorString(candidate.fill);
117
+ const outlineColor = Color.fromCssColorString(candidate.halo);
118
+ fillColor.alpha *= candidate.opacity; outlineColor.alpha *= candidate.opacity;
119
+ const properties = {
120
+ id: { mapZeroLayer: candidate.layer, mapZeroLabel: candidate.key },
121
+ position: candidate.position, text: candidate.text, font: candidate.font,
122
+ fillColor, outlineColor, outlineWidth: candidate.haloWidth,
123
+ style: LabelStyle.FILL_AND_OUTLINE, horizontalOrigin: HorizontalOrigin.CENTER,
124
+ verticalOrigin: VerticalOrigin.BOTTOM, pixelOffset: new Cartesian2(0, -6),
125
+ heightReference: HeightReference.CLAMP_TO_TERRAIN, disableDepthTestDistance: Number.POSITIVE_INFINITY
126
+ };
127
+ const label = previous?.label ?? collection.add(properties);
128
+ if (previous) Object.assign(label, properties);
129
+ active.set(candidate.key, { signature, label }); changed = true;
130
+ }
131
+ if (changed) scene.requestRender();
132
+ }
133
+ const removers = [
134
+ scene.preRender.addEventListener(() => visible.clear()),
135
+ tileset.tileVisible.addEventListener((tile) => visible.add(tile.content)),
136
+ // Mutate primitives only after traversal; an unchanged layout requests no frame.
137
+ scene.postRender.addEventListener(() => queueMicrotask(update))
138
+ ];
139
+ return {
140
+ collection,
141
+ setVisible(visible) { collection.show = Boolean(visible); scene.requestRender(); },
142
+ destroy() {
143
+ if (destroyed) return;
144
+ destroyed = true;
145
+ removers.forEach((remove) => remove());
146
+ visible.clear(); active.clear(); widths.clear();
147
+ scene.primitives.remove(collection);
148
+ }
149
+ };
150
+ }
package/src/index.js CHANGED
@@ -1,16 +1,12 @@
1
+ import { createMapZeroVectorContext } from './vector.js';
1
2
  import {
2
3
  Cesium3DTileColorBlendMode,
3
4
  Cesium3DTileStyle,
4
- Cesium3DTileset,
5
- ImageryLayer
5
+ Cesium3DTileset
6
6
  } from 'cesium';
7
- import {
8
- contextOverlayConfig,
9
- hasMapZeroContextOverlay,
10
- MapZeroCesiumImageryProvider
11
- } from './imagery.js';
12
7
 
13
- export { MapZeroCesiumImageryProvider } from './imagery.js';
8
+
9
+ export { createNativeVectorStyle, vectorZoomRange } from './vector.js';
14
10
 
15
11
  let autoInstanceCounter = 0;
16
12
 
@@ -160,10 +156,12 @@ export async function createMapZeroCesiumTilesets(options) {
160
156
  * tilesetOpacity?: number,
161
157
  * buildingsOpacity?: number,
162
158
  * contextOverlay?: boolean,
159
+ * vectorTilesUrl?: string,
160
+ * vectorMaxZoom?: number,
161
+ * labels?: boolean,
162
+ * maxLabels?: number,
163
+ * vectorHeightReference?: import('cesium').HeightReference,
163
164
  * contextOpacity?: number,
164
- * contextOverzoomLevels?: number,
165
- * contextEdgeGuardPixels?: number,
166
- * workerUrl?: string | URL,
167
165
  * buildings3d?: boolean,
168
166
  * tilesetMaximumScreenSpaceError?: number,
169
167
  * tilesetCacheBytes?: number,
@@ -178,8 +176,9 @@ export async function createMapZeroCesiumTilesets(options) {
178
176
  * manifest: MapZeroManifest,
179
177
  * style: Record<string, unknown> | null,
180
178
  * tilesets: Record<string, Cesium3DTileset>,
181
- * imageryProvider?: MapZeroCesiumImageryProvider,
182
- * imageryLayer?: ImageryLayer,
179
+ * vectorProvider?: import('cesium').MVTDataProvider,
180
+ * labelCollection?: import('cesium').LabelCollection,
181
+ * setLabelsVisible: (visible: boolean) => void,
183
182
  * setVisible: (layerId: string, visible: boolean) => void,
184
183
  * setOpacity: (layerId: string, opacity: number) => void,
185
184
  * destroy: () => void
@@ -196,27 +195,18 @@ export async function addMapZeroToCesium(viewer, options) {
196
195
  const result = await createMapZeroCesiumTilesets(options);
197
196
  const uniqueTilesets = [...new Set(Object.values(result.tilesets))];
198
197
  const visibleLayers = new Set(Object.keys(result.tilesets));
199
- let opacity = options.tilesetOpacity ?? options.opacity ?? 1;
200
- const imageryProvider = shouldCreateContextOverlay(result.manifest, options)
201
- ? new MapZeroCesiumImageryProvider({
202
- manifest: result.manifest,
203
- manifestUrl: options.manifestUrl,
204
- styleDocument: result.style,
205
- layers: contextOverlayConfig(result.manifest)?.layers,
206
- overzoomLevels: options.contextOverzoomLevels,
207
- edgeGuardPixels: options.contextEdgeGuardPixels,
208
- workerUrl: options.workerUrl
209
- })
210
- : undefined;
211
- const imageryLayer = imageryProvider
212
- ? new ImageryLayer(imageryProvider, {
213
- alpha: clamp01(Number(options.contextOpacity ?? options.opacity ?? 1)),
214
- show: true
215
- })
216
- : undefined;
217
-
218
- if (imageryLayer) {
219
- viewer.imageryLayers?.add(imageryLayer);
198
+ let vectorContext;
199
+ try {
200
+ if (options.contextOverlay !== false) {
201
+ vectorContext = await createMapZeroVectorContext(viewer, {
202
+ ...options, manifest: result.manifest, styleDocument: result.style,
203
+ excludedLayers: Object.keys(result.tilesets)
204
+ });
205
+ viewer.scene.primitives.add(vectorContext.provider);
206
+ }
207
+ } catch (error) {
208
+ for (const tileset of uniqueTilesets) tileset.destroy();
209
+ throw error;
220
210
  }
221
211
  for (const tileset of uniqueTilesets) {
222
212
  viewer.scene.primitives.add(tileset);
@@ -233,40 +223,34 @@ export async function addMapZeroToCesium(viewer, options) {
233
223
  id: result.id,
234
224
  style: result.style,
235
225
  tilesets: result.tilesets,
236
- imageryProvider,
237
- imageryLayer,
226
+ vectorProvider: vectorContext?.provider,
227
+ vectorRange: vectorContext?.range,
228
+ labelCollection: vectorContext?.labels?.collection,
229
+ setLabelsVisible(visible) { vectorContext?.labels?.setVisible(visible); },
238
230
  setVisible(layerId, visible) {
231
+ vectorContext?.setVisible(layerId, visible);
239
232
  const tileset = result.tilesets[layerId];
240
- if (tileset) {
241
- if (visible) {
242
- visibleLayers.add(layerId);
243
- } else {
244
- visibleLayers.delete(layerId);
245
- }
246
- applyStyleToTilesetMap(result.tilesets, result.style, {
247
- opacity,
248
- visibleLayers
249
- });
250
- }
251
- imageryProvider?.setLayerVisible(layerId, visible);
252
- imageryProvider?.setLayerVisible(layerId === 'aviation' ? 'aip' : layerId, visible);
233
+ if (tileset) tileset.show = Boolean(visible);
253
234
  viewer.scene?.requestRender?.();
254
235
  },
255
236
  setOpacity(layerId, nextOpacity) {
256
- if (!result.tilesets[layerId]) return;
257
- opacity = clamp01(Number(nextOpacity));
258
- applyStyleToTilesetMap(result.tilesets, result.style, {
259
- opacity,
237
+ vectorContext?.setOpacity(layerId, nextOpacity);
238
+ const tileset = result.tilesets[layerId];
239
+ if (!tileset) return;
240
+ tileset.style = createMapZeroCesiumStyle(result.style, {
241
+ layerId,
242
+ opacity: clamp01(Number(nextOpacity)),
260
243
  visibleLayers
261
244
  });
245
+ viewer.scene?.requestRender?.();
262
246
  },
263
247
  destroy() {
264
- if (imageryLayer) {
265
- viewer.imageryLayers?.remove(imageryLayer, true);
266
- }
248
+ vectorContext?.destroy();
249
+ if (vectorContext) viewer.scene.primitives.remove(vectorContext.provider);
267
250
  for (const tileset of uniqueTilesets) {
268
251
  viewer.scene.primitives.remove(tileset);
269
252
  }
253
+ viewer.scene?.requestRender?.();
270
254
  }
271
255
  };
272
256
  }
@@ -352,21 +336,6 @@ function cesiumLayerMaterial(rule, layerId) {
352
336
  };
353
337
  }
354
338
 
355
- /**
356
- * @param {Record<string, Cesium3DTileset>} tilesets
357
- * @param {Record<string, unknown> | null} style
358
- * @param {{ opacity: number, visibleLayers: Set<string> }} options
359
- */
360
- function applyStyleToTilesetMap(tilesets, style, options) {
361
- for (const [layerId, tileset] of Object.entries(tilesets)) {
362
- tileset.style = createMapZeroCesiumStyle(style, {
363
- layerId,
364
- opacity: options.opacity,
365
- visibleLayers: options.visibleLayers
366
- });
367
- }
368
- }
369
-
370
339
  /**
371
340
  * @param {string} layerId
372
341
  * @param {{ opacity?: number, tilesetOpacity?: number, buildingsOpacity?: number }} options
@@ -398,6 +367,11 @@ function layerStyle(styleJson, layerId) {
398
367
  * @returns {Array<{ layerId: string, url: string }>}
399
368
  */
400
369
  function manifestTilesetEntries(manifest, options = {}) {
370
+ if (manifest.tiles3d?.format === '3dtiles' && manifest.tiles3d.tilesets) {
371
+ return Object.entries(manifest.tiles3d.tilesets)
372
+ .filter(([layerId, url]) => typeof url === 'string' && isAllowedCesiumTilesetLayer(layerId, options))
373
+ .map(([layerId, url]) => ({ layerId, url }));
374
+ }
401
375
  if (manifest.tiles3d?.format === '3dtiles' && typeof manifest.tiles3d.url === 'string') {
402
376
  const layers = Array.isArray(manifest.tiles3d.layers) && manifest.tiles3d.layers.length > 0
403
377
  ? manifest.tiles3d.layers.map(String)
@@ -422,17 +396,6 @@ function isAllowedCesiumTilesetLayer(layerId, options) {
422
396
  return layerId !== 'buildings' || options.buildings3d !== false;
423
397
  }
424
398
 
425
- /**
426
- * @param {MapZeroManifest} manifest
427
- * @param {{ contextOverlay?: boolean }} options
428
- * @returns {boolean}
429
- */
430
- function shouldCreateContextOverlay(manifest, options) {
431
- if (options.contextOverlay === false) {
432
- return false;
433
- }
434
- return hasMapZeroContextOverlay(manifest);
435
- }
436
399
 
437
400
  /**
438
401
  * @param {Cesium3DTileset} tileset
package/src/vector.js ADDED
@@ -0,0 +1,138 @@
1
+ import { createCesiumLabels } from './cesium-labels.js';
2
+ import { BoundingSphere, Cartesian3, Cesium3DTileStyle, Color, HeightReference, MVTDataProvider, Rectangle } from 'cesium';
3
+ import { getLayerRule, mergeFeatureRule, styleWidth, zoomMatchesRule } from '../../core/src/style.js';
4
+
5
+ const POLYGON_LAYERS = new Set(['landuse', 'terrain', 'water', 'buildings']);
6
+
7
+ /** Keep Cesium's eager runtime hierarchy bounded before allocating tile nodes. */
8
+ export function vectorZoomRange(bbox, minZoom = 8, maxZoom = 16, maxNodes = 20000) {
9
+ if (!Array.isArray(bbox) || bbox.length !== 4 || !bbox.every(Number.isFinite)
10
+ || bbox[0] >= bbox[2] || bbox[1] >= bbox[3] || bbox[0] < -180 || bbox[2] > 180
11
+ || bbox[1] < -85.05112878 || bbox[3] > 85.05112878) {
12
+ throw new Error('Native vector rendering requires a valid Web Mercator manifest bbox');
13
+ }
14
+ if (!Number.isInteger(minZoom) || !Number.isInteger(maxZoom) || minZoom < 0 || maxZoom > 22 || minZoom > maxZoom) {
15
+ throw new Error('Native vector zoom range must satisfy 0 <= minZoom <= maxZoom <= 22');
16
+ }
17
+ const tileY = (lat, n) => (1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * n;
18
+ let nodes = 0;
19
+ let effectiveMaxZoom = minZoom;
20
+ for (let z = minZoom; z <= maxZoom; z++) {
21
+ const n = 2 ** z;
22
+ const columns = Math.floor((bbox[2] + 180) / 360 * n) - Math.floor((bbox[0] + 180) / 360 * n) + 1;
23
+ const rows = Math.floor(tileY(bbox[1], n)) - Math.floor(tileY(bbox[3], n)) + 1;
24
+ if (nodes + columns * rows > maxNodes) {
25
+ if (z === minZoom) throw new Error('Native vector extent is too large at minZoom; use a smaller package or lower minZoom');
26
+ break;
27
+ }
28
+ nodes += columns * rows;
29
+ effectiveMaxZoom = z;
30
+ }
31
+ return { minZoom, maxZoom: effectiveMaxZoom, estimatedNodes: nodes };
32
+ }
33
+
34
+ /** Translate the shared map-zero theme into native per-feature Cesium styling.
35
+ * Labels, glow, dashes and multi-pass road casings are not supported by MVTDataProvider.
36
+ */
37
+ export function createNativeVectorStyle(styleDocument, options = {}) {
38
+ const zoom = options.zoom ?? 16;
39
+ const visibility = options.visibility ?? new Map();
40
+ const opacities = options.opacities ?? new Map();
41
+ const excludedLayers = options.excludedLayers ?? new Set();
42
+ const rules = new WeakMap();
43
+ const describe = (feature) => {
44
+ if (rules.has(feature)) return rules.get(feature);
45
+ const layer = String(feature.getProperty('_layer') ?? feature.getProperty('layer') ?? '');
46
+ const id = layer === 'aviation' ? 'aip' : layer;
47
+ const rule = mergeFeatureRule(getLayerRule(styleDocument, { id }), { get: (key) => feature.getProperty(key) });
48
+ const geometry = String(feature.getProperty('mapzero_geometry') ?? '');
49
+ const polygon = geometry ? geometry.includes('Polygon') : POLYGON_LAYERS.has(id) || id === 'boundaries';
50
+ const point = geometry ? geometry.includes('Point') : id === 'pois';
51
+ const info = { id, rule, polygon, point };
52
+ rules.set(feature, info);
53
+ return info;
54
+ };
55
+ const evaluateColor = (feature, result) => {
56
+ const { id, rule, polygon, point } = describe(feature);
57
+ const css = polygon || point ? rule.fill ?? rule.stroke : rule.stroke ?? rule.fill;
58
+ const color = Color.fromCssColorString(String(css ?? '#00ffff'), result) ?? Color.clone(Color.CYAN, result);
59
+ const alpha = polygon ? (rule.fill ? rule.fillOpacity ?? 1 : 0) : point ? rule.fillOpacity ?? 1 : rule.strokeOpacity ?? 1;
60
+ color.alpha *= Math.max(0, Math.min(1, Number(alpha) * (opacities.get(id) ?? options.opacity ?? 1)));
61
+ return color;
62
+ };
63
+ return new Cesium3DTileStyle({
64
+ show: { evaluate(feature) {
65
+ const { id, rule } = describe(feature);
66
+ return !excludedLayers.has(id) && visibility.get(id) !== false && zoomMatchesRule(zoom, rule);
67
+ } },
68
+ color: { evaluate: evaluateColor, evaluateColor },
69
+ lineWidth: { evaluate(feature) {
70
+ const { id, rule } = describe(feature);
71
+ return styleWidth(rule.strokeWidth, 1, id, zoom);
72
+ } },
73
+ pointSize: { evaluate(feature) {
74
+ const { id, rule } = describe(feature);
75
+ return 2 * styleWidth(rule.radius ?? rule.circleRadius, 3, id, zoom);
76
+ } }
77
+ });
78
+ }
79
+
80
+ /** Native MVT rendering, using the public Cesium 1.145 provider API. */
81
+ export async function createMapZeroVectorContext(viewer, options) {
82
+ if (!options.vectorTilesUrl) throw new Error('Native vector rendering requires vectorTilesUrl with /{z}/{x}/{y}.mvt');
83
+ const template = new URL(options.vectorTilesUrl, new URL(options.manifestUrl, globalThis.location?.href ?? 'http://localhost/')).href
84
+ .replaceAll('%7B', '{').replaceAll('%7D', '}');
85
+ if (!['{z}', '{x}', '{y}'].every((token) => template.includes(token))) throw new Error('vectorTilesUrl must include {z}, {x}, and {y}');
86
+ const range = vectorZoomRange(options.manifest.bbox,
87
+ options.manifest.tiles?.minZoom ?? 8, Math.min(options.vectorMaxZoom ?? options.manifest.tiles?.maxZoom ?? 16, options.manifest.tiles?.maxZoom ?? 16));
88
+ const provider = await MVTDataProvider.fromUrl(template, {
89
+ minZoom: range.minZoom,
90
+ maxZoom: range.maxZoom,
91
+ extent: Rectangle.fromDegrees(...options.manifest.bbox),
92
+ heightReference: options.vectorHeightReference ?? HeightReference.CLAMP_TO_TERRAIN,
93
+ scene: viewer.scene
94
+ });
95
+ const visibility = new Map();
96
+ const opacities = new Map();
97
+ const excludedLayers = new Set(options.excludedLayers ?? []);
98
+ let zoom;
99
+ const bbox = options.manifest.bbox;
100
+ const latitude = (bbox[1] + bbox[3]) / 2;
101
+ const center = new BoundingSphere(Cartesian3.fromDegrees((bbox[0] + bbox[2]) / 2, latitude), 1);
102
+ const getZoom = () => {
103
+ const resolution = viewer.camera.getPixelSize(center, viewer.scene.canvas.clientWidth, viewer.scene.canvas.clientHeight);
104
+ return Math.max(0, Math.min(22, Math.round(Math.log2(156543.033928 * Math.cos(latitude * Math.PI / 180) / Math.max(0.001, resolution)))));
105
+ };
106
+ const refresh = () => {
107
+ const nextZoom = getZoom();
108
+ zoom = nextZoom;
109
+ provider.tileset.style = createNativeVectorStyle(options.styleDocument ?? {}, {
110
+ zoom, visibility, opacities, excludedLayers, opacity: options.contextOpacity ?? options.opacity ?? 1
111
+ });
112
+ viewer.scene.requestRender?.();
113
+ };
114
+ provider.tileset.maximumScreenSpaceError = 8;
115
+ provider.tileset.cacheBytes = 128 * 1024 * 1024;
116
+ provider.tileset.maximumCacheOverflowBytes = 64 * 1024 * 1024;
117
+ provider.tileset.preloadWhenHidden = false;
118
+ let labels;
119
+ try {
120
+ labels = createCesiumLabels(viewer, provider.tileset, {
121
+ ...options, styleDocument: options.styleDocument ?? {}, visibility, opacities, getZoom,
122
+ opacity: options.contextOpacity ?? options.opacity ?? 1
123
+ });
124
+ } catch (error) {
125
+ provider.destroy();
126
+ throw error;
127
+ }
128
+ refresh();
129
+ const removeCameraListener = viewer.camera?.moveEnd?.addEventListener(() => refresh());
130
+ return {
131
+ provider,
132
+ range,
133
+ labels,
134
+ setVisible(id, visible) { visibility.set(id === 'aviation' ? 'aip' : id, Boolean(visible)); refresh(); },
135
+ setOpacity(id, opacity) { opacities.set(id === 'aviation' ? 'aip' : id, Math.max(0, Math.min(1, Number(opacity)))); refresh(); },
136
+ destroy() { removeCameraListener?.(); labels.destroy(); }
137
+ };
138
+ }
package/src/imagery.js DELETED
@@ -1,305 +0,0 @@
1
- import {
2
- Event,
3
- Rectangle,
4
- WebMercatorTilingScheme
5
- } from 'cesium';
6
-
7
- import { WEB_MERCATOR_MAX_LAT } from '../../raster/src/shared/geo.js';
8
- import {
9
- DEFAULT_CONTEXT_LAYERS,
10
- layerAlias,
11
- normalizeContextLayers,
12
- sourceLayerFor
13
- } from '../../raster/src/shared/layers.js';
14
- import { pmtilesInfo } from '../../raster/src/shared/manifest.js';
15
- import { clampInteger } from '../../raster/src/shared/math.js';
16
-
17
- const DEFAULT_WORKER_URL = new URL('@map-zero/raster/imagery-worker.js', import.meta.url);
18
- const TILE_SIZE = 512;
19
-
20
- /**
21
- * Cesium ImageryProvider that rasterizes map-zero PMTiles/MVT tiles in a
22
- * dedicated worker. OffscreenCanvas is required by design so MVT decoding and
23
- * canvas drawing never block Cesium's main render thread.
24
- */
25
- export class MapZeroCesiumImageryProvider {
26
- /**
27
- * @param {{
28
- * manifest: Record<string, unknown>,
29
- * manifestUrl: string,
30
- * styleDocument?: Record<string, unknown> | null,
31
- * layers?: string[],
32
- * tileSize?: number,
33
- * minimumLevel?: number,
34
- * maximumLevel?: number,
35
- * overzoomLevels?: number,
36
- * edgeGuardPixels?: number,
37
- * workerUrl?: string | URL
38
- * }} options
39
- */
40
- constructor(options) {
41
- assertWorkerRasterSupport();
42
- this.manifest = options.manifest;
43
- this.manifestUrl = resolveWorkerBaseUrl(options.manifestUrl);
44
- this.styleDocument = options.styleDocument ?? {};
45
- this.tileWidth = Number(options.tileSize ?? TILE_SIZE);
46
- this.tileHeight = Number(options.tileSize ?? TILE_SIZE);
47
- this.tilingScheme = new WebMercatorTilingScheme();
48
- this.rectangle = rectangleFromManifestBbox(
49
- this.manifest,
50
- /** @type {any} */ (this.manifest).bbox
51
- );
52
- this.minimumLevel = Number.isFinite(options.minimumLevel) ? Number(options.minimumLevel) : 0;
53
- this.sourceMaximumLevel = Number(pmtilesInfo(this.manifest).maxZoom ?? 18);
54
- const contextOverlay = contextOverlayConfig(this.manifest);
55
- this.overzoomLevels = clampInteger(options.overzoomLevels ?? contextOverlay?.overzoomLevels ?? 0, 0, 4);
56
- this.edgeGuardPixels = clampInteger(options.edgeGuardPixels ?? contextOverlay?.edgeGuardPixels ?? 0, 0, 8);
57
- this.maximumLevel = Number.isFinite(options.maximumLevel)
58
- ? Number(options.maximumLevel)
59
- : this.sourceMaximumLevel + this.overzoomLevels;
60
- this.ready = true;
61
- this.readyPromise = Promise.resolve(true);
62
- this.hasAlphaChannel = true;
63
- this.errorEvent = new Event();
64
- this.credit = undefined;
65
- this.proxy = undefined;
66
-
67
- this.layerIds = normalizeContextLayers(options.layers ?? contextOverlay?.layers ?? DEFAULT_CONTEXT_LAYERS);
68
- this.layerVisibility = new Map(this.layerIds.map((layerId) => [layerId, true]));
69
- this.cache = new Map();
70
- this.pending = new Map();
71
- this.nextRequestId = 1;
72
- this.metrics = createImageryMetrics();
73
- this.worker = new Worker(
74
- options.workerUrl ?? DEFAULT_WORKER_URL,
75
- { type: 'module' }
76
- );
77
- this.worker.addEventListener('message', (event) => this.#handleWorkerMessage(event.data));
78
- this.worker.addEventListener('error', (event) => {
79
- const error = new Error(event.message || 'map-zero imagery worker failed');
80
- this.errorEvent.raiseEvent(error);
81
- this.#rejectPending(error);
82
- });
83
- this.worker.postMessage({
84
- type: 'init',
85
- options: {
86
- manifest: this.manifest,
87
- manifestUrl: this.manifestUrl,
88
- styleDocument: this.styleDocument,
89
- layers: this.layerIds,
90
- tileSize: this.tileWidth,
91
- sourceMaximumLevel: this.sourceMaximumLevel,
92
- overzoomLevels: this.overzoomLevels,
93
- edgeGuardPixels: this.edgeGuardPixels,
94
- source: String(contextOverlay?.source ?? pmtilesInfo(this.manifest).url ?? 'tiles.pmtiles')
95
- }
96
- });
97
- }
98
-
99
- /**
100
- * @param {string} layerId
101
- * @param {boolean} visible
102
- */
103
- setLayerVisible(layerId, visible) {
104
- this.layerVisibility.set(sourceLayerFor(layerId), visible);
105
- this.layerVisibility.set(layerAlias(layerId), visible);
106
- this.cache.clear();
107
- this.worker.postMessage({
108
- type: 'visibility',
109
- layerId,
110
- visible
111
- });
112
- }
113
-
114
- /**
115
- * @param {number} x
116
- * @param {number} y
117
- * @param {number} level
118
- * @returns {Promise<HTMLCanvasElement>}
119
- */
120
- async requestImage(x, y, level) {
121
- const key = `${level}/${x}/${y}`;
122
- const cached = this.cache.get(key);
123
- if (cached) {
124
- this.metrics.cacheHits++;
125
- return cached;
126
- }
127
-
128
- const id = this.nextRequestId++;
129
- const promise = new Promise((resolve, reject) => {
130
- this.pending.set(id, { resolve, reject });
131
- this.worker.postMessage({
132
- type: 'render',
133
- id,
134
- x,
135
- y,
136
- z: level
137
- });
138
- }).catch((error) => {
139
- this.errorEvent.raiseEvent(error);
140
- return emptyCanvas(this.tileWidth, this.tileHeight);
141
- });
142
- this.cache.set(key, promise);
143
- return promise;
144
- }
145
-
146
- getTileCredits() {
147
- return undefined;
148
- }
149
-
150
- pickFeatures() {
151
- return undefined;
152
- }
153
-
154
- destroy() {
155
- this.#rejectPending(new Error('map-zero imagery provider destroyed'));
156
- this.worker.terminate();
157
- this.cache.clear();
158
- }
159
-
160
- #handleWorkerMessage(message) {
161
- if (message?.type === 'metrics') {
162
- mergeWorkerMetrics(this.metrics, message.metrics);
163
- return;
164
- }
165
-
166
- if (message?.type !== 'tile') {
167
- return;
168
- }
169
-
170
- if (message.metrics) {
171
- mergeWorkerMetrics(this.metrics, message.metrics);
172
- }
173
- const pending = this.pending.get(message.id);
174
- if (!pending) {
175
- message.image?.close?.();
176
- return;
177
- }
178
- this.pending.delete(message.id);
179
- if (message.error) {
180
- pending.reject(new Error(message.error));
181
- return;
182
- }
183
- pending.resolve(imageToCanvas(message.image, this.tileWidth, this.tileHeight));
184
- }
185
-
186
- #rejectPending(error) {
187
- for (const pending of this.pending.values()) {
188
- pending.reject(error);
189
- }
190
- this.pending.clear();
191
- }
192
- }
193
-
194
- function assertWorkerRasterSupport() {
195
- if (typeof Worker !== 'function' || typeof OffscreenCanvas !== 'function' || typeof createImageBitmap !== 'function') {
196
- throw new Error('map-zero Cesium context overlay requires Worker, OffscreenCanvas, and createImageBitmap');
197
- }
198
- }
199
-
200
- function createImageryMetrics() {
201
- const metrics = {
202
- requested: 0,
203
- cacheHits: 0,
204
- decoded: 0,
205
- features: 0,
206
- overzoomed: 0,
207
- requestLevels: {},
208
- sourceLevels: {},
209
- renderMs: { total: 0, max: 0 },
210
- decodeMs: { total: 0, max: 0 }
211
- };
212
- const root = globalThis.__mapZeroCesiumMetrics ??= { imageryProviders: [] };
213
- root.imageryProviders.push(metrics);
214
- return metrics;
215
- }
216
-
217
- function mergeWorkerMetrics(target, patch) {
218
- if (!patch) return;
219
- for (const key of ['requested', 'cacheHits', 'decoded', 'features', 'overzoomed']) {
220
- target[key] = Number(patch[key] ?? target[key] ?? 0);
221
- }
222
- target.requestLevels = { ...patch.requestLevels };
223
- target.sourceLevels = { ...patch.sourceLevels };
224
- target.renderMs = { ...patch.renderMs };
225
- target.decodeMs = { ...patch.decodeMs };
226
- }
227
-
228
- /**
229
- * @param {Record<string, unknown>} manifest
230
- * @returns {{ type?: string, source?: string, layers?: string[], backend?: string, overzoomLevels?: number, edgeGuardPixels?: number } | null}
231
- */
232
- export function contextOverlayConfig(manifest) {
233
- const pmtiles = pmtilesInfo(manifest);
234
- if (!pmtiles.url) return null;
235
- return {
236
- type: 'client-rasterized-pmtiles',
237
- source: pmtiles.url,
238
- layers: contextLayerIds(manifest)
239
- };
240
- }
241
-
242
- /**
243
- * @param {Record<string, unknown>} manifest
244
- * @returns {boolean}
245
- */
246
- export function hasMapZeroContextOverlay(manifest) {
247
- const overlay = contextOverlayConfig(manifest);
248
- return Boolean(overlay?.source || pmtilesInfo(manifest).url);
249
- }
250
-
251
- function contextLayerIds(manifest) {
252
- const layers = Array.isArray(manifest.layers)
253
- ? manifest.layers.map(String).filter((layerId) => layerId !== 'buildings')
254
- : [];
255
- return layers.length > 0 ? layers : DEFAULT_CONTEXT_LAYERS;
256
- }
257
-
258
- function rectangleFromManifestBbox(manifest, bbox) {
259
- const padded = expandBboxByTileMargin(bbox, pmtilesInfo(manifest).minZoom);
260
- return rectangleFromBbox(padded);
261
- }
262
-
263
- function rectangleFromBbox(bbox) {
264
- if (Array.isArray(bbox) && bbox.length === 4) {
265
- return Rectangle.fromDegrees(Number(bbox[0]), Number(bbox[1]), Number(bbox[2]), Number(bbox[3]));
266
- }
267
- return Rectangle.MAX_VALUE;
268
- }
269
-
270
- function expandBboxByTileMargin(bbox, minZoom) {
271
- if (!Array.isArray(bbox) || bbox.length !== 4) return bbox;
272
- const z = clampInteger(minZoom ?? 8, 0, 22);
273
- const margin = 360 / 2 ** z;
274
- return [
275
- Math.max(-180, Number(bbox[0]) - margin),
276
- Math.max(-WEB_MERCATOR_MAX_LAT, Number(bbox[1]) - margin),
277
- Math.min(180, Number(bbox[2]) + margin),
278
- Math.min(WEB_MERCATOR_MAX_LAT, Number(bbox[3]) + margin)
279
- ];
280
- }
281
-
282
- function emptyCanvas(width, height) {
283
- const canvas = document.createElement('canvas');
284
- canvas.width = width;
285
- canvas.height = height;
286
- return canvas;
287
- }
288
-
289
- function imageToCanvas(image, width, height) {
290
- const canvas = emptyCanvas(width, height);
291
- if (!image) {
292
- return canvas;
293
- }
294
-
295
- const ctx = canvas.getContext('2d');
296
- if (ctx) {
297
- ctx.drawImage(image, 0, 0, width, height);
298
- }
299
- image.close?.();
300
- return canvas;
301
- }
302
-
303
- function resolveWorkerBaseUrl(url) {
304
- return new URL(url, globalThis.location?.href ?? 'http://localhost/').toString();
305
- }