@honua/react 0.1.0-beta.0 → 0.1.2-beta.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/contract/discovery.d.ts +231 -0
- package/contract/discovery.d.ts.map +1 -0
- package/contract/discovery.js +554 -0
- package/contract/edit-sketch.d.ts +8 -0
- package/contract/edit-sketch.d.ts.map +1 -1
- package/contract/edit-sketch.js +15 -0
- package/contract/edit-snapping.d.ts +160 -0
- package/contract/edit-snapping.d.ts.map +1 -0
- package/contract/edit-snapping.js +675 -0
- package/contract/generated/projjson-v0.7-crs-validator.d.ts +35 -0
- package/contract/generated/projjson-v0.7-crs-validator.d.ts.map +1 -0
- package/contract/generated/projjson-v0.7-crs-validator.js +3941 -0
- package/contract/index.d.ts +11 -2
- package/contract/index.d.ts.map +1 -1
- package/contract/index.js +4 -1
- package/contract/pmtiles.d.ts +31 -0
- package/contract/pmtiles.d.ts.map +1 -1
- package/contract/pmtiles.js +60 -20
- package/contract/protocol-module.d.ts +70 -0
- package/contract/protocol-module.d.ts.map +1 -0
- package/contract/protocol-module.js +1 -0
- package/contract/schema-envelope.d.ts +26 -0
- package/contract/schema-envelope.d.ts.map +1 -0
- package/contract/schema-envelope.js +25 -0
- package/contract/schema.d.ts +582 -0
- package/contract/schema.d.ts.map +1 -0
- package/contract/schema.js +2483 -0
- package/contract/source-capability-support.d.ts +6 -0
- package/contract/source-capability-support.d.ts.map +1 -0
- package/contract/source-capability-support.js +154 -0
- package/contract/source.d.ts +35 -17
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +267 -71
- package/contract/types.d.ts +140 -4
- package/contract/types.d.ts.map +1 -1
- package/contract/types.js +6 -14
- package/core/bounded-json.d.ts +4 -0
- package/core/bounded-json.d.ts.map +1 -0
- package/core/bounded-json.js +38 -0
- package/core/cache-state.d.ts +9 -0
- package/core/cache-state.d.ts.map +1 -1
- package/core/cache-state.js +10 -0
- package/core/capabilities-xml.d.ts +33 -0
- package/core/capabilities-xml.d.ts.map +1 -0
- package/core/capabilities-xml.js +331 -0
- package/core/client.d.ts +4 -1
- package/core/client.d.ts.map +1 -1
- package/core/client.js +115 -29
- package/core/error-classifications.d.ts +153 -0
- package/core/error-classifications.d.ts.map +1 -0
- package/core/error-classifications.js +140 -0
- package/core/error-code-registry.d.ts +158 -0
- package/core/error-code-registry.d.ts.map +1 -0
- package/core/error-code-registry.js +143 -0
- package/core/error-envelope.d.ts +68 -0
- package/core/error-envelope.d.ts.map +1 -0
- package/core/error-envelope.js +357 -0
- package/core/errors.d.ts +45 -29
- package/core/errors.d.ts.map +1 -1
- package/core/errors.js +97 -50
- package/core/geoparquet-sql.d.ts +30 -2
- package/core/geoparquet-sql.d.ts.map +1 -1
- package/core/geoparquet-sql.js +38 -3
- package/core/odata-key-path.d.ts +12 -0
- package/core/odata-key-path.d.ts.map +1 -0
- package/core/odata-key-path.js +28 -0
- package/core/odata-write-codec.d.ts +66 -0
- package/core/odata-write-codec.d.ts.map +1 -0
- package/core/odata-write-codec.js +1083 -0
- package/core/odata.d.ts +59 -7
- package/core/odata.d.ts.map +1 -1
- package/core/odata.js +431 -99
- package/core/ogc-features.d.ts.map +1 -1
- package/core/ogc-features.js +3 -0
- package/core/ogc-maps.d.ts +7 -0
- package/core/ogc-maps.d.ts.map +1 -1
- package/core/ogc-maps.js +46 -7
- package/core/ogc-processes.d.ts +2 -1
- package/core/ogc-processes.d.ts.map +1 -1
- package/core/ogc-processes.js +33 -5
- package/core/ogc-records.d.ts +9 -0
- package/core/ogc-records.d.ts.map +1 -1
- package/core/ogc-records.js +78 -29
- package/core/ogc-tiles.d.ts +10 -1
- package/core/ogc-tiles.d.ts.map +1 -1
- package/core/ogc-tiles.js +56 -17
- package/core/path-utils.d.ts +9 -0
- package/core/path-utils.d.ts.map +1 -1
- package/core/path-utils.js +19 -0
- package/core/protocol-transport.d.ts +1 -0
- package/core/protocol-transport.d.ts.map +1 -1
- package/core/request-pipeline.d.ts.map +1 -1
- package/core/request-pipeline.js +1 -1
- package/core/spatial-filter.d.ts +8 -4
- package/core/spatial-filter.d.ts.map +1 -1
- package/core/spatial-filter.js +206 -16
- package/core/stac-static.d.ts +6 -2
- package/core/stac-static.d.ts.map +1 -1
- package/core/stac-static.js +260 -69
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +69 -5
- package/core/types.d.ts +125 -7
- package/core/types.d.ts.map +1 -1
- package/core/wfs-capabilities.d.ts +2 -0
- package/core/wfs-capabilities.d.ts.map +1 -1
- package/core/wfs-capabilities.js +8 -1
- package/core/wfs.d.ts.map +1 -1
- package/core/wfs.js +10 -0
- package/core/wms-capabilities.d.ts +56 -84
- package/core/wms-capabilities.d.ts.map +1 -1
- package/core/wms-capabilities.js +212 -509
- package/core/wmts-capabilities.d.ts +62 -62
- package/core/wmts-capabilities.d.ts.map +1 -1
- package/core/wmts-capabilities.js +224 -371
- package/esri-compat/editor.d.ts +8 -0
- package/esri-compat/editor.d.ts.map +1 -1
- package/esri-compat/editor.js +13 -0
- package/esri-compat/feature-layer.d.ts +2 -0
- package/esri-compat/feature-layer.d.ts.map +1 -1
- package/esri-compat/feature-layer.js +1 -0
- package/esri-compat/layer-list.d.ts +11 -0
- package/esri-compat/layer-list.d.ts.map +1 -1
- package/esri-compat/layer-list.js +49 -0
- package/esri-compat/legend.d.ts +10 -0
- package/esri-compat/legend.d.ts.map +1 -1
- package/esri-compat/legend.js +27 -0
- package/esri-compat/portal.d.ts +197 -0
- package/esri-compat/portal.d.ts.map +1 -0
- package/esri-compat/portal.js +251 -0
- package/esri-compat/renderer-objects.d.ts +40 -0
- package/esri-compat/renderer-objects.d.ts.map +1 -0
- package/esri-compat/renderer-objects.js +62 -0
- package/esri-compat/sketch.d.ts +43 -0
- package/esri-compat/sketch.d.ts.map +1 -1
- package/esri-compat/sketch.js +46 -0
- package/esri-compat/snapping.d.ts +43 -0
- package/esri-compat/snapping.d.ts.map +1 -0
- package/esri-compat/snapping.js +45 -0
- package/esri-compat/url.d.ts.map +1 -1
- package/esri-compat/url.js +1 -1
- package/esri-compat/widget-host.d.ts +84 -0
- package/esri-compat/widget-host.d.ts.map +1 -0
- package/esri-compat/widget-host.js +159 -0
- package/filter-registry/index.d.ts +145 -0
- package/filter-registry/index.d.ts.map +1 -0
- package/filter-registry/index.js +531 -0
- package/generated-app/errors.d.ts +18 -2
- package/generated-app/errors.d.ts.map +1 -1
- package/generated-app/errors.js +44 -11
- package/map/automatic-mount-integration.d.ts +134 -0
- package/map/automatic-mount-integration.d.ts.map +1 -0
- package/map/automatic-mount-integration.js +305 -0
- package/map/automatic-source-strategy.d.ts +125 -0
- package/map/automatic-source-strategy.d.ts.map +1 -0
- package/map/automatic-source-strategy.js +494 -0
- package/map/data-to-map-bridge.d.ts +293 -0
- package/map/data-to-map-bridge.d.ts.map +1 -0
- package/map/data-to-map-bridge.js +817 -0
- package/map/geojson-projection.d.ts +33 -0
- package/map/geojson-projection.d.ts.map +1 -0
- package/map/geojson-projection.js +118 -0
- package/map/index.d.ts +14 -0
- package/map/index.d.ts.map +1 -1
- package/map/index.js +8 -0
- package/map/raster-source-spec.d.ts +30 -0
- package/map/raster-source-spec.d.ts.map +1 -0
- package/map/raster-source-spec.js +190 -0
- package/map/raster-source-strategy.d.ts +65 -0
- package/map/raster-source-strategy.d.ts.map +1 -0
- package/map/raster-source-strategy.js +232 -0
- package/map/source-to-maplibre.d.ts +91 -0
- package/map/source-to-maplibre.d.ts.map +1 -0
- package/map/source-to-maplibre.js +454 -0
- package/map/temporal-playback.d.ts +139 -0
- package/map/temporal-playback.d.ts.map +1 -0
- package/map/temporal-playback.js +247 -0
- package/package.json +14 -1
- package/query-planner/canonical.d.ts +7 -0
- package/query-planner/canonical.d.ts.map +1 -0
- package/query-planner/canonical.js +145 -0
- package/query-planner/cql2-json.d.ts +17 -0
- package/query-planner/cql2-json.d.ts.map +1 -0
- package/query-planner/cql2-json.js +646 -0
- package/query-planner/diagnostics.d.ts +35 -0
- package/query-planner/diagnostics.d.ts.map +1 -0
- package/query-planner/diagnostics.js +373 -0
- package/query-planner/duckdb.d.ts +79 -0
- package/query-planner/duckdb.d.ts.map +1 -0
- package/query-planner/duckdb.js +758 -0
- package/query-planner/execution-context.d.ts +5 -0
- package/query-planner/execution-context.d.ts.map +1 -0
- package/query-planner/execution-context.js +50 -0
- package/query-planner/executor.d.ts +5 -0
- package/query-planner/executor.d.ts.map +1 -0
- package/query-planner/executor.js +223 -0
- package/query-planner/geoservices.d.ts +91 -0
- package/query-planner/geoservices.d.ts.map +1 -0
- package/query-planner/geoservices.js +667 -0
- package/query-planner/grpc.d.ts +100 -0
- package/query-planner/grpc.d.ts.map +1 -0
- package/query-planner/grpc.js +736 -0
- package/query-planner/index.d.ts +44 -0
- package/query-planner/index.d.ts.map +1 -0
- package/query-planner/index.js +30 -0
- package/query-planner/ir.d.ts +20 -0
- package/query-planner/ir.d.ts.map +1 -0
- package/query-planner/ir.js +417 -0
- package/query-planner/local-aggregate.d.ts +5 -0
- package/query-planner/local-aggregate.d.ts.map +1 -0
- package/query-planner/local-aggregate.js +126 -0
- package/query-planner/odata.d.ts +48 -0
- package/query-planner/odata.d.ts.map +1 -0
- package/query-planner/odata.js +662 -0
- package/query-planner/ogc-compiler.d.ts +55 -0
- package/query-planner/ogc-compiler.d.ts.map +1 -0
- package/query-planner/ogc-compiler.js +480 -0
- package/query-planner/ogc-features.d.ts +53 -0
- package/query-planner/ogc-features.d.ts.map +1 -0
- package/query-planner/ogc-features.js +617 -0
- package/query-planner/planner.d.ts +17 -0
- package/query-planner/planner.d.ts.map +1 -0
- package/query-planner/planner.js +818 -0
- package/query-planner/resource.d.ts +94 -0
- package/query-planner/resource.d.ts.map +1 -0
- package/query-planner/resource.js +526 -0
- package/query-planner/semantic-canonical.d.ts +15 -0
- package/query-planner/semantic-canonical.d.ts.map +1 -0
- package/query-planner/semantic-canonical.js +62 -0
- package/query-planner/semantic-compat.d.ts +10 -0
- package/query-planner/semantic-compat.d.ts.map +1 -0
- package/query-planner/semantic-compat.js +35 -0
- package/query-planner/semantic-compiler.d.ts +64 -0
- package/query-planner/semantic-compiler.d.ts.map +1 -0
- package/query-planner/semantic-compiler.js +81 -0
- package/query-planner/semantic-literals.d.ts +30 -0
- package/query-planner/semantic-literals.d.ts.map +1 -0
- package/query-planner/semantic-literals.js +133 -0
- package/query-planner/semantic-types.d.ts +265 -0
- package/query-planner/semantic-types.d.ts.map +1 -0
- package/query-planner/semantic-types.js +1 -0
- package/query-planner/semantic.d.ts +54 -0
- package/query-planner/semantic.d.ts.map +1 -0
- package/query-planner/semantic.js +1421 -0
- package/query-planner/types.d.ts +560 -0
- package/query-planner/types.d.ts.map +1 -0
- package/query-planner/types.js +49 -0
- package/query-planner/wfs-semantic.d.ts +58 -0
- package/query-planner/wfs-semantic.d.ts.map +1 -0
- package/query-planner/wfs-semantic.js +547 -0
- package/query-planner/wfs.d.ts +6 -0
- package/query-planner/wfs.d.ts.map +1 -0
- package/query-planner/wfs.js +71 -0
- package/react/external-map.d.ts +67 -0
- package/react/external-map.d.ts.map +1 -0
- package/react/external-map.js +63 -0
- package/react/honua-map.d.ts.map +1 -1
- package/react/honua-map.js +3 -2
- package/react/honua-source-layer.d.ts +110 -0
- package/react/honua-source-layer.d.ts.map +1 -0
- package/react/honua-source-layer.js +101 -0
- package/react/hooks.d.ts +28 -0
- package/react/hooks.d.ts.map +1 -1
- package/react/hooks.js +26 -4
- package/react/index.d.ts +8 -0
- package/react/index.d.ts.map +1 -1
- package/react/index.js +5 -0
- package/react/query-cache.d.ts +26 -4
- package/react/query-cache.d.ts.map +1 -1
- package/react/query-cache.js +51 -8
- package/react/selection.d.ts +177 -0
- package/react/selection.d.ts.map +1 -0
- package/react/selection.js +0 -0
- package/react/use-mounted-source.d.ts +69 -0
- package/react/use-mounted-source.d.ts.map +1 -0
- package/react/use-mounted-source.js +346 -0
- package/runtime/edit-snapping.d.ts +107 -0
- package/runtime/edit-snapping.d.ts.map +1 -0
- package/runtime/edit-snapping.js +190 -0
- package/runtime/errors.d.ts +3 -2
- package/runtime/errors.d.ts.map +1 -1
- package/runtime/errors.js +19 -2
- package/runtime/index.d.ts +6 -0
- package/runtime/index.d.ts.map +1 -1
- package/runtime/index.js +3 -0
- package/runtime/maplibre-renderer.d.ts +41 -0
- package/runtime/maplibre-renderer.d.ts.map +1 -0
- package/runtime/maplibre-renderer.js +322 -0
- package/runtime/query-tiles.d.ts +2 -1
- package/runtime/query-tiles.d.ts.map +1 -1
- package/runtime/query-tiles.js +6 -2
- package/runtime/source-bridge.d.ts +4 -4
- package/runtime/source-bridge.d.ts.map +1 -1
- package/runtime/source-bridge.js +9 -56
- package/runtime/style-interactions.d.ts +3 -2
- package/runtime/style-interactions.d.ts.map +1 -1
- package/runtime/style-interactions.js +11 -3
- package/runtime/terra-draw-sketch.d.ts +275 -0
- package/runtime/terra-draw-sketch.d.ts.map +1 -0
- package/runtime/terra-draw-sketch.js +404 -0
- package/source-capability-registry.d.ts +1 -0
- package/source-capability-registry.js +1 -0
- package/source-capability-types.d.ts +186 -0
- package/source-capability-types.js +7 -0
- package/style/index.d.ts +2 -0
- package/style/index.d.ts.map +1 -1
- package/style/index.js +2 -0
- package/style/renderers.d.ts +326 -0
- package/style/renderers.d.ts.map +1 -0
- package/style/renderers.js +517 -0
- package/webmap/convert-renderer.d.ts +24 -3
- package/webmap/convert-renderer.d.ts.map +1 -1
- package/webmap/convert-renderer.js +93 -112
- package/webmap/index.d.ts +1 -1
- package/webmap/index.d.ts.map +1 -1
- package/webmap/index.js +1 -1
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone data-to-map bridge: mount any contract `Source` as a live,
|
|
3
|
+
* styled MapLibre layer set — no MapPackage, no Honua server, no
|
|
4
|
+
* app-platform import. This is the `connection.mount(target)` slice of the
|
|
5
|
+
* kernel ADR (`docs/decisions/north-star-sdk-application-kernel.md`) scoped
|
|
6
|
+
* to a caller-owned MapLibre host.
|
|
7
|
+
*
|
|
8
|
+
* The module is peer-injected and safe to import in Node/SSR/worker code:
|
|
9
|
+
* it never imports `maplibre-gl`. The host passes a duck-typed map (any
|
|
10
|
+
* MapLibre GL `Map` instance satisfies {@link DataToMapLibreMap}).
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { HonuaSdkError, mergeHonuaErrorContext } from "../core/error-envelope.js";
|
|
15
|
+
import { HonuaCapabilityNotSupportedError } from "../core/errors.js";
|
|
16
|
+
import { createHoverHandler } from "../interactions/feature-state.js";
|
|
17
|
+
import { bindPopup } from "../runtime/popups.js";
|
|
18
|
+
import { buildMapLibreQueryTileSourceSpec, diagnoseQueryTileSourceSupport } from "../runtime/query-tiles.js";
|
|
19
|
+
import { canonicalFeaturesToGeoJson, defaultPaint, defaultPolygonOutlinePaint, geometryKinds, layerType, mapLibreGeometryType, removeUndefined, safeId, } from "./geojson-projection.js";
|
|
20
|
+
/** A stable, machine-readable bridge failure. @experimental */
|
|
21
|
+
export class HonuaDataToMapBridgeError extends HonuaSdkError {
|
|
22
|
+
code;
|
|
23
|
+
detail;
|
|
24
|
+
constructor(code, message, detail, options = {}) {
|
|
25
|
+
super(DATA_TO_MAP_ERROR_CODES[code], message, {
|
|
26
|
+
...options,
|
|
27
|
+
context: mergeHonuaErrorContext(detail, options.context),
|
|
28
|
+
});
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.detail = detail;
|
|
31
|
+
this.name = "HonuaDataToMapBridgeError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const DATA_TO_MAP_ERROR_CODES = {
|
|
35
|
+
"invalid-option": "map.data-bridge.invalid-option",
|
|
36
|
+
disposed: "map.data-bridge.disposed",
|
|
37
|
+
"source-conflict": "map.data-bridge.source-conflict",
|
|
38
|
+
"layer-conflict": "map.data-bridge.layer-conflict",
|
|
39
|
+
"map-mutation-failed": "map.data-bridge.map-mutation-failed",
|
|
40
|
+
"interaction-unsupported": "map.data-bridge.interaction-unsupported",
|
|
41
|
+
"filter-unsupported": "map.data-bridge.filter-unsupported",
|
|
42
|
+
};
|
|
43
|
+
const DEFAULT_MAX_GEOJSON_FEATURES = 10_000;
|
|
44
|
+
const GEOMETRY_SUFFIXES = ["point", "line", "polygon", "polygon-outline"];
|
|
45
|
+
/**
|
|
46
|
+
* Explain which materialization strategy {@link mountSource} would pick for
|
|
47
|
+
* a source, without touching a map. Runs the same capability checks and (when
|
|
48
|
+
* both strategies are viable) the same result-size probe.
|
|
49
|
+
*
|
|
50
|
+
* @experimental
|
|
51
|
+
*/
|
|
52
|
+
export async function explainDataToMapStrategy(source, options = {}) {
|
|
53
|
+
const limit = normalizedLimit(options.maxGeoJsonFeatures);
|
|
54
|
+
const reasons = [];
|
|
55
|
+
const canQuery = source.capabilities.has("query");
|
|
56
|
+
const tileDescriptor = options.queryTiles;
|
|
57
|
+
if (options.strategy === "geojson") {
|
|
58
|
+
if (!canQuery) {
|
|
59
|
+
throw new HonuaCapabilityNotSupportedError("query", source.descriptor.protocol, source.descriptor.id);
|
|
60
|
+
}
|
|
61
|
+
reasons.push(info("strategy-override", "Explicit geojson strategy override."));
|
|
62
|
+
return { strategy: "geojson", reasons };
|
|
63
|
+
}
|
|
64
|
+
if (options.strategy === "query-tiles") {
|
|
65
|
+
if (!tileDescriptor) {
|
|
66
|
+
throw new HonuaDataToMapBridgeError("invalid-option", "The query-tiles strategy requires options.queryTiles (a dynamic query-tile source descriptor).");
|
|
67
|
+
}
|
|
68
|
+
reasons.push(info("strategy-override", "Explicit query-tiles strategy override."));
|
|
69
|
+
return { strategy: "query-tiles", reasons };
|
|
70
|
+
}
|
|
71
|
+
if (options.strategy !== undefined) {
|
|
72
|
+
throw new HonuaDataToMapBridgeError("invalid-option", `Unknown strategy "${String(options.strategy)}".`, {
|
|
73
|
+
strategy: options.strategy,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!canQuery && !tileDescriptor) {
|
|
77
|
+
throw new HonuaCapabilityNotSupportedError("query", source.descriptor.protocol, source.descriptor.id);
|
|
78
|
+
}
|
|
79
|
+
if (!tileDescriptor) {
|
|
80
|
+
reasons.push(info("query-capability", "The source advertises query and no dynamic query-tile descriptor was supplied; using bounded GeoJSON materialization."));
|
|
81
|
+
if (source.capabilities.has("tiles")) {
|
|
82
|
+
reasons.push(info("tile-endpoint-missing", "The source declares the tiles capability but no options.queryTiles descriptor was supplied, so the dynamic query-tile strategy is unavailable."));
|
|
83
|
+
}
|
|
84
|
+
return { strategy: "geojson", reasons };
|
|
85
|
+
}
|
|
86
|
+
if (!canQuery) {
|
|
87
|
+
reasons.push(info("tiles-capability", "The source does not advertise query; using the dynamic query-tile strategy."));
|
|
88
|
+
return { strategy: "query-tiles", reasons };
|
|
89
|
+
}
|
|
90
|
+
// Both strategies are viable — apply the result-size heuristic. Probe with
|
|
91
|
+
// the same initial filter the mount path would apply, so a selective
|
|
92
|
+
// descriptor-level query counts what would actually be materialized.
|
|
93
|
+
const initialQuery = options.query ?? options.queryTiles?.query;
|
|
94
|
+
let count;
|
|
95
|
+
if (source.capabilities.has("queryExtent")) {
|
|
96
|
+
try {
|
|
97
|
+
const probe = await source.queryExtent({
|
|
98
|
+
...(initialQuery ?? {}),
|
|
99
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
100
|
+
});
|
|
101
|
+
count = probe.count;
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
if (options.signal?.aborted)
|
|
105
|
+
throw cause;
|
|
106
|
+
reasons.push(warning("count-probe-failed", "The result-size probe (queryExtent) failed; the count is unknown.", {
|
|
107
|
+
error: errorMessage(cause),
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
reasons.push(info("count-probe-unavailable", "The source does not advertise queryExtent; the result size is unknown."));
|
|
113
|
+
}
|
|
114
|
+
if (count !== undefined && count <= limit) {
|
|
115
|
+
reasons.push(info("result-size-within-limit", `${count} matching rows fit within the ${limit}-feature GeoJSON bound.`, {
|
|
116
|
+
count,
|
|
117
|
+
limit,
|
|
118
|
+
}));
|
|
119
|
+
return { strategy: "geojson", reasons, probedCount: count };
|
|
120
|
+
}
|
|
121
|
+
if (count !== undefined) {
|
|
122
|
+
reasons.push(info("result-size-exceeds-limit", `${count} matching rows exceed the ${limit}-feature GeoJSON bound; using viewport-bounded query tiles.`, { count, limit }));
|
|
123
|
+
return { strategy: "query-tiles", reasons, probedCount: count };
|
|
124
|
+
}
|
|
125
|
+
reasons.push(info("result-size-unknown", "The result size is unknown; preferring viewport-bounded query tiles over unbounded materialization."));
|
|
126
|
+
return { strategy: "query-tiles", reasons };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Mount a contract `Source` onto a caller-owned MapLibre map as a styled,
|
|
130
|
+
* interactive layer set and return a disposable handle that owns everything
|
|
131
|
+
* it created.
|
|
132
|
+
*
|
|
133
|
+
* The bridge selects GeoJSON materialization or the dynamic query-tile path
|
|
134
|
+
* from declared capabilities and a result-size heuristic (override with
|
|
135
|
+
* `options.strategy`), applies geometry-appropriate default styling, and can
|
|
136
|
+
* wire click popups and hover feature-state.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* import maplibregl from "maplibre-gl";
|
|
141
|
+
* import { connect } from "@honua/sdk-js";
|
|
142
|
+
* import { mountSource } from "@honua/sdk-js/map";
|
|
143
|
+
*
|
|
144
|
+
* const connection = await connect({
|
|
145
|
+
* endpoint: FEATURE_LAYER_URL,
|
|
146
|
+
* protocol: "auto",
|
|
147
|
+
* authorizationScopeFingerprint: "public",
|
|
148
|
+
* });
|
|
149
|
+
* const mounted = await mountSource(map, connection.source(), {
|
|
150
|
+
* popup: { factory: () => new maplibregl.Popup() },
|
|
151
|
+
* hover: true,
|
|
152
|
+
* fitBounds: true,
|
|
153
|
+
* });
|
|
154
|
+
* console.log(mounted.diagnostics.strategy, mounted.diagnostics.reasons);
|
|
155
|
+
* // Later: mounted.dispose(); // or `await using mounted = …`
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* @experimental
|
|
159
|
+
*/
|
|
160
|
+
export async function mountSource(map, source, options = {}) {
|
|
161
|
+
validateOptions(map, options);
|
|
162
|
+
const limit = normalizedLimit(options.maxGeoJsonFeatures);
|
|
163
|
+
const lifecycle = new AbortController();
|
|
164
|
+
const signal = combineSignals([lifecycle.signal, options.signal]);
|
|
165
|
+
signal.throwIfAborted();
|
|
166
|
+
const explanation = await explainDataToMapStrategy(source, options);
|
|
167
|
+
signal.throwIfAborted();
|
|
168
|
+
const strategy = explanation.strategy;
|
|
169
|
+
const reasons = [...explanation.reasons];
|
|
170
|
+
const updates = [];
|
|
171
|
+
if (options.renderer?.kind === "cluster" && strategy !== "geojson") {
|
|
172
|
+
throw new HonuaDataToMapBridgeError("invalid-option", 'A cluster renderer requires the "geojson" strategy (MapLibre clustering is a GeoJSON-source feature); pass strategy: "geojson".', { strategy });
|
|
173
|
+
}
|
|
174
|
+
const sourceId = options.sourceId ?? `honua-${safeId(source.descriptor.id)}`;
|
|
175
|
+
const layerBase = options.layerId ?? sourceId;
|
|
176
|
+
const sourceLayer = strategy === "query-tiles"
|
|
177
|
+
? (options.sourceLayer ?? options.queryTiles?.tilejson?.vector_layers?.[0]?.id ?? options.queryTiles?.sourceId)
|
|
178
|
+
: undefined;
|
|
179
|
+
let currentQuery = options.query ?? options.queryTiles?.query;
|
|
180
|
+
// ── Materialize the initial source spec ────────────────────────
|
|
181
|
+
let featureCount;
|
|
182
|
+
let totalCount;
|
|
183
|
+
let overflow;
|
|
184
|
+
let presentKinds;
|
|
185
|
+
let geojsonBounds;
|
|
186
|
+
let sourceSpec;
|
|
187
|
+
let currentTiles;
|
|
188
|
+
const materialize = async (query) => {
|
|
189
|
+
const callerLimit = query?.pagination?.limit;
|
|
190
|
+
const result = await source.queryAll({
|
|
191
|
+
...(query ?? {}),
|
|
192
|
+
returnGeometry: true,
|
|
193
|
+
// MapLibre GeoJSON sources are always WGS84; respect an explicit caller override.
|
|
194
|
+
outSr: query?.outSr ?? 4326,
|
|
195
|
+
pagination: { ...(query?.pagination ?? {}), limit: Math.min(callerLimit ?? limit, limit) },
|
|
196
|
+
signal,
|
|
197
|
+
});
|
|
198
|
+
signal.throwIfAborted();
|
|
199
|
+
return result;
|
|
200
|
+
};
|
|
201
|
+
if (strategy === "geojson") {
|
|
202
|
+
const result = await materialize(currentQuery);
|
|
203
|
+
const converted = canonicalFeaturesToGeoJson(result.features, source.descriptor.schema?.primaryKey);
|
|
204
|
+
featureCount = converted.data.features.length;
|
|
205
|
+
totalCount = resolveTotalCount(result);
|
|
206
|
+
overflow = computeOverflow(result, limit, reasons);
|
|
207
|
+
presentKinds = geometryKinds(converted.data.features);
|
|
208
|
+
geojsonBounds = computeBounds(converted.data);
|
|
209
|
+
if (converted.unsupported > 0) {
|
|
210
|
+
reasons.push(warning("geometry-unsupported", `${converted.unsupported} feature geometry value(s) could not be projected and remain attribute-only.`, { unsupportedGeometryCount: converted.unsupported }));
|
|
211
|
+
}
|
|
212
|
+
if (result.degraded && result.degraded.length > 0) {
|
|
213
|
+
reasons.push(warning("source-degraded", "The source reported degraded execution semantics.", {
|
|
214
|
+
reasons: result.degraded.map((entry) => entry.reason),
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
sourceSpec = {
|
|
218
|
+
type: "geojson",
|
|
219
|
+
data: converted.data,
|
|
220
|
+
promoteId: source.descriptor.schema?.primaryKey,
|
|
221
|
+
attribution: options.attribution ?? source.descriptor.attribution,
|
|
222
|
+
...(options.renderer?.kind === "cluster" ? options.renderer.toMapLibreSource() : {}),
|
|
223
|
+
};
|
|
224
|
+
removeUndefined(sourceSpec);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const descriptor = tileDescriptorWithQuery(requireTileDescriptor(options), currentQuery);
|
|
228
|
+
for (const diagnostic of diagnoseQueryTileSourceSupport(descriptor)) {
|
|
229
|
+
reasons.push({
|
|
230
|
+
code: "tile-support",
|
|
231
|
+
severity: diagnostic.severity === "info" ? "info" : "warning",
|
|
232
|
+
message: diagnostic.message,
|
|
233
|
+
detail: { queryTileDiagnostic: diagnostic.code },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const spec = buildMapLibreQueryTileSourceSpec(descriptor, { useTileJsonUrl: currentQuery === undefined });
|
|
237
|
+
currentTiles = spec.tiles ? [...spec.tiles] : undefined;
|
|
238
|
+
sourceSpec = { ...spec, attribution: options.attribution ?? spec.attribution };
|
|
239
|
+
removeUndefined(sourceSpec);
|
|
240
|
+
}
|
|
241
|
+
// ── Build the layer set ────────────────────────────────────────
|
|
242
|
+
let currentRenderer = options.renderer;
|
|
243
|
+
let layers = buildLayers(strategy, sourceId, layerBase, sourceLayer, options, currentRenderer);
|
|
244
|
+
let layerIds = layers.map((layer) => String(layer.id));
|
|
245
|
+
const interactiveIdsFor = (ids) => options.layers ? ids : ids.filter((id) => !id.endsWith("-polygon-outline") && !id.endsWith("-cluster-count"));
|
|
246
|
+
const interactiveLayerIds = interactiveIdsFor(layerIds);
|
|
247
|
+
// ── Mutate the map transactionally ─────────────────────────────
|
|
248
|
+
signal.throwIfAborted();
|
|
249
|
+
assertIdsAvailable(map, sourceId, layerIds);
|
|
250
|
+
const attempted = [];
|
|
251
|
+
try {
|
|
252
|
+
map.addSource(sourceId, sourceSpec);
|
|
253
|
+
for (const layer of layers) {
|
|
254
|
+
attempted.push(String(layer.id));
|
|
255
|
+
map.addLayer(layer, options.beforeId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (cause) {
|
|
259
|
+
rollback(map, sourceId, attempted);
|
|
260
|
+
throw new HonuaDataToMapBridgeError("map-mutation-failed", `Failed to mount source "${sourceId}" transactionally.`, { sourceId, attemptedLayerIds: attempted }, { cause });
|
|
261
|
+
}
|
|
262
|
+
// ── Interactions ───────────────────────────────────────────────
|
|
263
|
+
const popupHandles = [];
|
|
264
|
+
const hoverHandles = [];
|
|
265
|
+
const wireInteractions = (ids) => {
|
|
266
|
+
if (options.popup) {
|
|
267
|
+
const popup = options.popup;
|
|
268
|
+
for (const layerId of ids) {
|
|
269
|
+
popupHandles.push(bindPopup(map, {
|
|
270
|
+
binding: { sourceId, ...(popup.title !== undefined ? { title: popup.title } : {}) },
|
|
271
|
+
layerId,
|
|
272
|
+
popupFactory: popup.factory,
|
|
273
|
+
render: (context) => renderPopup(popup, context.features, context.lngLat),
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (options.hover) {
|
|
278
|
+
const stateKey = typeof options.hover === "object" ? (options.hover.stateKey ?? "hover") : "hover";
|
|
279
|
+
for (const layerId of ids) {
|
|
280
|
+
hoverHandles.push(createHoverHandler(map, {
|
|
281
|
+
source: sourceId,
|
|
282
|
+
layer: layerId,
|
|
283
|
+
stateKey,
|
|
284
|
+
...(sourceLayer !== undefined ? { sourceLayer } : {}),
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
try {
|
|
290
|
+
wireInteractions(interactiveLayerIds);
|
|
291
|
+
}
|
|
292
|
+
catch (cause) {
|
|
293
|
+
removeInteractions(popupHandles, hoverHandles);
|
|
294
|
+
rollback(map, sourceId, layerIds);
|
|
295
|
+
throw new HonuaDataToMapBridgeError("map-mutation-failed", `Failed to wire interactions for source "${sourceId}".`, { sourceId }, { cause });
|
|
296
|
+
}
|
|
297
|
+
// ── Optional camera fit ────────────────────────────────────────
|
|
298
|
+
if (options.fitBounds && typeof map.fitBounds === "function") {
|
|
299
|
+
const bounds = strategy === "geojson" ? geojsonBounds : (options.queryTiles?.bounds ?? options.queryTiles?.tilejson?.bounds);
|
|
300
|
+
if (bounds) {
|
|
301
|
+
const padding = typeof options.fitBounds === "object" ? (options.fitBounds.padding ?? 32) : 32;
|
|
302
|
+
map.fitBounds([bounds[0], bounds[1], bounds[2], bounds[3]], { padding });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// ── Handle ─────────────────────────────────────────────────────
|
|
306
|
+
let state = "ready";
|
|
307
|
+
let refreshTail = Promise.resolve();
|
|
308
|
+
const snapshotDiagnostics = () => Object.freeze({
|
|
309
|
+
strategy,
|
|
310
|
+
reasons: Object.freeze([...reasons]),
|
|
311
|
+
updates: Object.freeze([...updates]),
|
|
312
|
+
...(featureCount !== undefined ? { featureCount } : {}),
|
|
313
|
+
...(totalCount !== undefined ? { totalCount } : {}),
|
|
314
|
+
...(overflow !== undefined ? { overflow } : {}),
|
|
315
|
+
...(presentKinds !== undefined ? { geometryKinds: Object.freeze([...presentKinds]) } : {}),
|
|
316
|
+
});
|
|
317
|
+
const applyGeoJsonUpdate = async (query, code) => {
|
|
318
|
+
const result = await materialize(query);
|
|
319
|
+
if (state === "disposed") {
|
|
320
|
+
throw new HonuaDataToMapBridgeError("disposed", "The mounted source was disposed during an update.");
|
|
321
|
+
}
|
|
322
|
+
const converted = canonicalFeaturesToGeoJson(result.features, source.descriptor.schema?.primaryKey);
|
|
323
|
+
const handle = map.getSource(sourceId);
|
|
324
|
+
if (!handle || typeof handle.setData !== "function") {
|
|
325
|
+
throw new HonuaDataToMapBridgeError("map-mutation-failed", `Mounted source "${sourceId}" no longer exposes setData().`, { sourceId });
|
|
326
|
+
}
|
|
327
|
+
handle.setData(converted.data);
|
|
328
|
+
featureCount = converted.data.features.length;
|
|
329
|
+
totalCount = resolveTotalCount(result);
|
|
330
|
+
overflow = computeOverflow(result, limit, updates);
|
|
331
|
+
presentKinds = geometryKinds(converted.data.features);
|
|
332
|
+
updates.push(info(code, `Updated GeoJSON data in place with setData() (${featureCount} features).`));
|
|
333
|
+
};
|
|
334
|
+
const applyTileUpdate = async (query, code) => {
|
|
335
|
+
const descriptor = tileDescriptorWithQuery(requireTileDescriptor(options), query);
|
|
336
|
+
const spec = buildMapLibreQueryTileSourceSpec(descriptor, { useTileJsonUrl: false });
|
|
337
|
+
const nextTiles = spec.tiles ? [...spec.tiles] : undefined;
|
|
338
|
+
if (!nextTiles || nextTiles.length === 0) {
|
|
339
|
+
throw new HonuaDataToMapBridgeError("filter-unsupported", "The query-tile descriptor cannot produce a tile URL template for filter updates.", { sourceId });
|
|
340
|
+
}
|
|
341
|
+
if (code === "filter-applied" &&
|
|
342
|
+
currentTiles !== undefined &&
|
|
343
|
+
sameStringArray(nextTiles, currentTiles) &&
|
|
344
|
+
!sameQuery(query, currentQuery)) {
|
|
345
|
+
throw new HonuaDataToMapBridgeError("filter-unsupported", "The query-tile descriptor's fixed tile URLs cannot carry the new filter; supply an endpoint-based descriptor or use the geojson strategy.", { sourceId });
|
|
346
|
+
}
|
|
347
|
+
const handle = map.getSource(sourceId);
|
|
348
|
+
if (handle && typeof handle.setTiles === "function") {
|
|
349
|
+
handle.setTiles([...nextTiles]);
|
|
350
|
+
currentTiles = nextTiles;
|
|
351
|
+
updates.push(info(code, "Rewrote the tile URL template in place with setTiles()."));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Structural fallback for hosts without setTiles: recreate the source
|
|
355
|
+
// while keeping the layers' event bindings (they are keyed by layer id).
|
|
356
|
+
for (const layerId of [...layerIds].reverse())
|
|
357
|
+
if (map.getLayer(layerId))
|
|
358
|
+
map.removeLayer(layerId);
|
|
359
|
+
if (map.getSource(sourceId))
|
|
360
|
+
map.removeSource(sourceId);
|
|
361
|
+
const nextSpec = { ...spec, attribution: options.attribution ?? spec.attribution };
|
|
362
|
+
removeUndefined(nextSpec);
|
|
363
|
+
map.addSource(sourceId, nextSpec);
|
|
364
|
+
for (const layer of layers)
|
|
365
|
+
map.addLayer(layer, options.beforeId);
|
|
366
|
+
currentTiles = nextTiles;
|
|
367
|
+
updates.push(warning("filter-recreated-source", "The map host does not expose setTiles(); the tile source and layers were recreated in place."));
|
|
368
|
+
};
|
|
369
|
+
const clusterSourceOptionsOf = (renderer) => renderer?.kind === "cluster" ? renderer.toMapLibreSource() : undefined;
|
|
370
|
+
const applyRendererUpdate = async (next) => {
|
|
371
|
+
if (next?.kind === "cluster" && strategy !== "geojson") {
|
|
372
|
+
throw new HonuaDataToMapBridgeError("invalid-option", 'A cluster renderer requires the "geojson" strategy (MapLibre clustering is a GeoJSON-source feature).', { sourceId, strategy });
|
|
373
|
+
}
|
|
374
|
+
const nextLayers = buildLayers(strategy, sourceId, layerBase, sourceLayer, options, next);
|
|
375
|
+
const nextIds = nextLayers.map((layer) => String(layer.id));
|
|
376
|
+
const nextCluster = clusterSourceOptionsOf(next);
|
|
377
|
+
const clusterChanged = !sameJson(clusterSourceOptionsOf(currentRenderer), nextCluster);
|
|
378
|
+
const structureUnchanged = !clusterChanged &&
|
|
379
|
+
nextIds.length === layerIds.length &&
|
|
380
|
+
nextIds.every((id, index) => id === layerIds[index] && nextLayers[index].type === layers[index].type);
|
|
381
|
+
if (structureUnchanged && typeof map.setPaintProperty === "function") {
|
|
382
|
+
// Diff-update in place: no layer teardown, no source touch.
|
|
383
|
+
for (let index = 0; index < nextLayers.length; index++) {
|
|
384
|
+
const id = nextIds[index];
|
|
385
|
+
diffProperties(recordOf(layers[index].paint), recordOf(nextLayers[index].paint), (name, value) => map.setPaintProperty?.(id, name, value));
|
|
386
|
+
if (typeof map.setLayoutProperty === "function") {
|
|
387
|
+
diffProperties(recordOf(layers[index].layout), recordOf(nextLayers[index].layout), (name, value) => map.setLayoutProperty?.(id, name, value));
|
|
388
|
+
}
|
|
389
|
+
if (typeof map.setFilter === "function" && !sameJson(layers[index].filter, nextLayers[index].filter)) {
|
|
390
|
+
map.setFilter(id, nextLayers[index].filter);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
layers = nextLayers;
|
|
394
|
+
currentRenderer = next;
|
|
395
|
+
updates.push(info("renderer-applied", "Updated renderer paint/layout in place without layer teardown."));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// Structural path: replace the owned layers; recreate the source only
|
|
399
|
+
// when GeoJSON cluster options changed. Materialize the replacement data
|
|
400
|
+
// BEFORE any teardown so a failed or aborted query leaves the previously
|
|
401
|
+
// working layers and interactions on the map untouched.
|
|
402
|
+
const replacement = clusterChanged
|
|
403
|
+
? await (async () => {
|
|
404
|
+
const result = await materialize(currentQuery);
|
|
405
|
+
const converted = canonicalFeaturesToGeoJson(result.features, source.descriptor.schema?.primaryKey);
|
|
406
|
+
return { result, converted };
|
|
407
|
+
})()
|
|
408
|
+
: undefined;
|
|
409
|
+
removeInteractions(popupHandles, hoverHandles);
|
|
410
|
+
for (const layerId of [...layerIds].reverse())
|
|
411
|
+
if (map.getLayer(layerId))
|
|
412
|
+
map.removeLayer(layerId);
|
|
413
|
+
if (replacement) {
|
|
414
|
+
const { result, converted } = replacement;
|
|
415
|
+
if (map.getSource(sourceId))
|
|
416
|
+
map.removeSource(sourceId);
|
|
417
|
+
const nextSpec = {
|
|
418
|
+
type: "geojson",
|
|
419
|
+
data: converted.data,
|
|
420
|
+
promoteId: source.descriptor.schema?.primaryKey,
|
|
421
|
+
attribution: options.attribution ?? source.descriptor.attribution,
|
|
422
|
+
...(nextCluster ?? {}),
|
|
423
|
+
};
|
|
424
|
+
removeUndefined(nextSpec);
|
|
425
|
+
map.addSource(sourceId, nextSpec);
|
|
426
|
+
featureCount = converted.data.features.length;
|
|
427
|
+
totalCount = resolveTotalCount(result);
|
|
428
|
+
overflow = computeOverflow(result, limit, updates);
|
|
429
|
+
presentKinds = geometryKinds(converted.data.features);
|
|
430
|
+
}
|
|
431
|
+
for (const layer of nextLayers)
|
|
432
|
+
map.addLayer(layer, options.beforeId);
|
|
433
|
+
layers = nextLayers;
|
|
434
|
+
layerIds = nextIds;
|
|
435
|
+
currentRenderer = next;
|
|
436
|
+
wireInteractions(interactiveIdsFor(layerIds));
|
|
437
|
+
updates.push(info(clusterChanged ? "renderer-recreated-source" : "renderer-recreated-layers", clusterChanged
|
|
438
|
+
? "Renderer swap changed GeoJSON cluster options; the source and layers were recreated."
|
|
439
|
+
: "Renderer swap changed the layer structure; the owned layers were recreated in place."));
|
|
440
|
+
};
|
|
441
|
+
const enqueue = (work) => {
|
|
442
|
+
const run = refreshTail.then(async () => {
|
|
443
|
+
if (state === "disposed") {
|
|
444
|
+
throw new HonuaDataToMapBridgeError("disposed", "Cannot update a disposed mounted source.");
|
|
445
|
+
}
|
|
446
|
+
signal.throwIfAborted();
|
|
447
|
+
await work();
|
|
448
|
+
return snapshotDiagnostics();
|
|
449
|
+
});
|
|
450
|
+
refreshTail = run.then(() => undefined, () => undefined);
|
|
451
|
+
return run;
|
|
452
|
+
};
|
|
453
|
+
const dispose = () => {
|
|
454
|
+
if (state === "disposed")
|
|
455
|
+
return;
|
|
456
|
+
state = "disposed";
|
|
457
|
+
lifecycle.abort(new DOMException("Mounted source disposed", "AbortError"));
|
|
458
|
+
const failures = [];
|
|
459
|
+
try {
|
|
460
|
+
removeInteractions(popupHandles, hoverHandles);
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
failures.push(error);
|
|
464
|
+
}
|
|
465
|
+
for (const layerId of [...layerIds].reverse()) {
|
|
466
|
+
try {
|
|
467
|
+
if (map.getLayer(layerId))
|
|
468
|
+
map.removeLayer(layerId);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
failures.push(error);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
if (map.getSource(sourceId))
|
|
476
|
+
map.removeSource(sourceId);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
failures.push(error);
|
|
480
|
+
}
|
|
481
|
+
if (failures.length > 0) {
|
|
482
|
+
throw new HonuaDataToMapBridgeError("map-mutation-failed", `Disposal of source "${sourceId}" completed with ${failures.length} renderer failure(s).`, { sourceId, failureCount: failures.length }, { cause: failures[0] });
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
return {
|
|
486
|
+
strategy,
|
|
487
|
+
sourceId,
|
|
488
|
+
get layerIds() {
|
|
489
|
+
return [...layerIds];
|
|
490
|
+
},
|
|
491
|
+
sourceLayer,
|
|
492
|
+
get state() {
|
|
493
|
+
return state;
|
|
494
|
+
},
|
|
495
|
+
get diagnostics() {
|
|
496
|
+
return snapshotDiagnostics();
|
|
497
|
+
},
|
|
498
|
+
setFilter(query) {
|
|
499
|
+
return enqueue(async () => {
|
|
500
|
+
if (strategy === "geojson")
|
|
501
|
+
await applyGeoJsonUpdate(query, "filter-applied");
|
|
502
|
+
else
|
|
503
|
+
await applyTileUpdate(query, "filter-applied");
|
|
504
|
+
currentQuery = query;
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
setRenderer(renderer) {
|
|
508
|
+
return enqueue(() => applyRendererUpdate(renderer));
|
|
509
|
+
},
|
|
510
|
+
refresh() {
|
|
511
|
+
return enqueue(async () => {
|
|
512
|
+
if (strategy === "geojson")
|
|
513
|
+
await applyGeoJsonUpdate(currentQuery, "refresh-applied");
|
|
514
|
+
else
|
|
515
|
+
await applyTileUpdate(currentQuery, "refresh-applied");
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
dispose,
|
|
519
|
+
async [Symbol.asyncDispose]() {
|
|
520
|
+
await refreshTail;
|
|
521
|
+
dispose();
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
// ── Internals ────────────────────────────────────────────────────
|
|
526
|
+
function validateOptions(map, options) {
|
|
527
|
+
if (options.maxGeoJsonFeatures !== undefined)
|
|
528
|
+
normalizedLimit(options.maxGeoJsonFeatures);
|
|
529
|
+
if (options.geometry !== undefined &&
|
|
530
|
+
options.geometry !== "auto" &&
|
|
531
|
+
options.geometry !== "point" &&
|
|
532
|
+
options.geometry !== "line" &&
|
|
533
|
+
options.geometry !== "polygon") {
|
|
534
|
+
throw new HonuaDataToMapBridgeError("invalid-option", `Unknown geometry "${String(options.geometry)}".`, {
|
|
535
|
+
geometry: options.geometry,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
if (options.renderer !== undefined) {
|
|
539
|
+
if (options.layers !== undefined) {
|
|
540
|
+
throw new HonuaDataToMapBridgeError("invalid-option", "options.renderer and options.layers are mutually exclusive; renderer-derived layers own the layer set.");
|
|
541
|
+
}
|
|
542
|
+
const renderer = options.renderer;
|
|
543
|
+
if (typeof renderer.toMapLibre !== "function" || typeof renderer.kind !== "string") {
|
|
544
|
+
throw new HonuaDataToMapBridgeError("invalid-option", "options.renderer must be a renderer object from @honua/sdk-js/style (classBreaksRenderer, uniqueValueRenderer, heatmapRenderer, clusterRenderer).");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (options.layers !== undefined) {
|
|
548
|
+
if (options.layers.length === 0) {
|
|
549
|
+
throw new HonuaDataToMapBridgeError("invalid-option", "options.layers must contain at least one layer.");
|
|
550
|
+
}
|
|
551
|
+
const seen = new Set();
|
|
552
|
+
for (const layer of options.layers) {
|
|
553
|
+
const id = layer.id;
|
|
554
|
+
if (typeof id !== "string" || id.length === 0 || seen.has(id)) {
|
|
555
|
+
throw new HonuaDataToMapBridgeError("invalid-option", "Every options.layers entry needs a unique string id.", {
|
|
556
|
+
layerId: id,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
seen.add(id);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (options.popup && (typeof map.on !== "function" || typeof map.off !== "function")) {
|
|
563
|
+
throw new HonuaDataToMapBridgeError("interaction-unsupported", "Popups require a map with on()/off() event methods.");
|
|
564
|
+
}
|
|
565
|
+
if (options.hover &&
|
|
566
|
+
(typeof map.on !== "function" || typeof map.off !== "function" || typeof map.setFeatureState !== "function")) {
|
|
567
|
+
throw new HonuaDataToMapBridgeError("interaction-unsupported", "Hover feature-state requires a map with on()/off() and setFeatureState() methods.");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function normalizedLimit(value) {
|
|
571
|
+
if (value === undefined)
|
|
572
|
+
return DEFAULT_MAX_GEOJSON_FEATURES;
|
|
573
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
574
|
+
throw new HonuaDataToMapBridgeError("invalid-option", "maxGeoJsonFeatures must be a positive integer.", {
|
|
575
|
+
maxGeoJsonFeatures: value,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return value;
|
|
579
|
+
}
|
|
580
|
+
function requireTileDescriptor(options) {
|
|
581
|
+
const descriptor = options.queryTiles;
|
|
582
|
+
if (!descriptor) {
|
|
583
|
+
throw new HonuaDataToMapBridgeError("invalid-option", "The query-tiles strategy requires options.queryTiles (a dynamic query-tile source descriptor).");
|
|
584
|
+
}
|
|
585
|
+
return descriptor;
|
|
586
|
+
}
|
|
587
|
+
function tileDescriptorWithQuery(descriptor, query) {
|
|
588
|
+
if (query !== undefined)
|
|
589
|
+
return { ...descriptor, query: { ...query } };
|
|
590
|
+
// `setFilter(undefined)` clears the mounted filter: strip a baked
|
|
591
|
+
// descriptor-level query too, so the rebuilt tile URL is unfiltered.
|
|
592
|
+
if (descriptor.query === undefined)
|
|
593
|
+
return descriptor;
|
|
594
|
+
const { query: _cleared, ...rest } = descriptor;
|
|
595
|
+
return rest;
|
|
596
|
+
}
|
|
597
|
+
function buildLayers(strategy, sourceId, layerBase, sourceLayer, options, renderer) {
|
|
598
|
+
if (options.layers) {
|
|
599
|
+
return options.layers.map((layer) => Object.freeze({
|
|
600
|
+
source: sourceId,
|
|
601
|
+
...(strategy === "query-tiles" && sourceLayer !== undefined && layer["source-layer"] === undefined
|
|
602
|
+
? { "source-layer": sourceLayer }
|
|
603
|
+
: {}),
|
|
604
|
+
...layer,
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
if (renderer?.kind === "cluster") {
|
|
608
|
+
return renderer.toMapLibre("point").map((fragment) => Object.freeze({
|
|
609
|
+
id: `${layerBase}-${fragment.role}`,
|
|
610
|
+
type: fragment.type,
|
|
611
|
+
source: sourceId,
|
|
612
|
+
...(fragment.filter !== undefined ? { filter: fragment.filter } : {}),
|
|
613
|
+
paint: { ...fragment.paint },
|
|
614
|
+
...(Object.keys(fragment.layout).length > 0 ? { layout: { ...fragment.layout } } : {}),
|
|
615
|
+
metadata: { "honua:mount": "data-to-map-bridge", "honua:strategy": strategy, "honua:renderer": renderer.kind },
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
if (renderer?.kind === "heatmap") {
|
|
619
|
+
const [fragment] = renderer.toMapLibre("point");
|
|
620
|
+
return [
|
|
621
|
+
Object.freeze({
|
|
622
|
+
id: `${layerBase}-heatmap`,
|
|
623
|
+
type: fragment.type,
|
|
624
|
+
source: sourceId,
|
|
625
|
+
...(sourceLayer !== undefined ? { "source-layer": sourceLayer } : {}),
|
|
626
|
+
filter: ["==", ["geometry-type"], "Point"],
|
|
627
|
+
paint: { ...fragment.paint },
|
|
628
|
+
...(Object.keys(fragment.layout).length > 0 ? { layout: { ...fragment.layout } } : {}),
|
|
629
|
+
metadata: { "honua:mount": "data-to-map-bridge", "honua:strategy": strategy, "honua:renderer": renderer.kind },
|
|
630
|
+
}),
|
|
631
|
+
];
|
|
632
|
+
}
|
|
633
|
+
const requested = options.geometry === undefined || options.geometry === "auto" ? undefined : options.geometry;
|
|
634
|
+
const kinds = requested ? [requested] : ["point", "line", "polygon"];
|
|
635
|
+
const layers = [];
|
|
636
|
+
for (const kind of kinds) {
|
|
637
|
+
const fragment = renderer ? renderer.toMapLibre(kind)[0] : undefined;
|
|
638
|
+
const rendererLayout = fragment && Object.keys(fragment.layout).length > 0 ? fragment.layout : undefined;
|
|
639
|
+
layers.push(layerSpec(strategy, sourceId, `${layerBase}-${kind}`, layerType(kind), kind, sourceLayer, {
|
|
640
|
+
...defaultPaint(kind),
|
|
641
|
+
...(fragment?.paint ?? {}),
|
|
642
|
+
...(options.paint?.[kind] ?? {}),
|
|
643
|
+
}, rendererLayout || options.layout?.[kind]
|
|
644
|
+
? { ...(rendererLayout ?? {}), ...(options.layout?.[kind] ?? {}) }
|
|
645
|
+
: undefined));
|
|
646
|
+
if (kind === "polygon") {
|
|
647
|
+
layers.push(layerSpec(strategy, sourceId, `${layerBase}-polygon-outline`, "line", "polygon", sourceLayer, {
|
|
648
|
+
...defaultPolygonOutlinePaint(),
|
|
649
|
+
...(options.paint?.polygonOutline ?? {}),
|
|
650
|
+
}, options.layout?.polygonOutline));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return layers.map((layer) => Object.freeze(layer));
|
|
654
|
+
}
|
|
655
|
+
function layerSpec(strategy, sourceId, id, type, kind, sourceLayer, paint, layout) {
|
|
656
|
+
return {
|
|
657
|
+
id,
|
|
658
|
+
type,
|
|
659
|
+
source: sourceId,
|
|
660
|
+
...(sourceLayer !== undefined ? { "source-layer": sourceLayer } : {}),
|
|
661
|
+
filter: ["==", ["geometry-type"], mapLibreGeometryType(kind)],
|
|
662
|
+
paint,
|
|
663
|
+
...(layout ? { layout: { ...layout } } : {}),
|
|
664
|
+
metadata: { "honua:mount": "data-to-map-bridge", "honua:strategy": strategy },
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function assertIdsAvailable(map, sourceId, layerIds) {
|
|
668
|
+
if (map.getSource(sourceId)) {
|
|
669
|
+
throw new HonuaDataToMapBridgeError("source-conflict", `MapLibre source "${sourceId}" already exists.`, {
|
|
670
|
+
sourceId,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
for (const layerId of layerIds) {
|
|
674
|
+
if (map.getLayer(layerId)) {
|
|
675
|
+
throw new HonuaDataToMapBridgeError("layer-conflict", `MapLibre layer "${layerId}" already exists.`, {
|
|
676
|
+
layerId,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function rollback(map, sourceId, layerIds) {
|
|
682
|
+
for (const layerId of [...layerIds].reverse()) {
|
|
683
|
+
try {
|
|
684
|
+
if (map.getLayer(layerId))
|
|
685
|
+
map.removeLayer(layerId);
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
/* best-effort rollback */
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
if (map.getSource(sourceId))
|
|
693
|
+
map.removeSource(sourceId);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
/* best-effort rollback */
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function removeInteractions(popupHandles, hoverHandles) {
|
|
700
|
+
for (const handle of popupHandles.splice(0))
|
|
701
|
+
handle.remove();
|
|
702
|
+
for (const handle of hoverHandles.splice(0))
|
|
703
|
+
handle.remove();
|
|
704
|
+
}
|
|
705
|
+
function renderPopup(options, features, lngLat) {
|
|
706
|
+
if (options.render)
|
|
707
|
+
return options.render({ features, lngLat });
|
|
708
|
+
const doc = typeof document !== "undefined" ? document : undefined;
|
|
709
|
+
const feature = features[0];
|
|
710
|
+
if (!doc || !feature?.properties)
|
|
711
|
+
return undefined;
|
|
712
|
+
const root = doc.createElement("div");
|
|
713
|
+
if (options.title) {
|
|
714
|
+
const title = doc.createElement("h3");
|
|
715
|
+
title.textContent = options.title;
|
|
716
|
+
root.appendChild(title);
|
|
717
|
+
}
|
|
718
|
+
const fields = options.fields
|
|
719
|
+
? options.fields.filter((field) => Object.hasOwn(feature.properties ?? {}, field))
|
|
720
|
+
: Object.keys(feature.properties);
|
|
721
|
+
const dl = doc.createElement("dl");
|
|
722
|
+
for (const field of fields) {
|
|
723
|
+
const dt = doc.createElement("dt");
|
|
724
|
+
dt.textContent = field;
|
|
725
|
+
const dd = doc.createElement("dd");
|
|
726
|
+
dd.textContent = String(feature.properties[field]);
|
|
727
|
+
dl.appendChild(dt);
|
|
728
|
+
dl.appendChild(dd);
|
|
729
|
+
}
|
|
730
|
+
root.appendChild(dl);
|
|
731
|
+
return root;
|
|
732
|
+
}
|
|
733
|
+
function resolveTotalCount(result) {
|
|
734
|
+
// Some adapters report totalCount as the returned page size; drop it when
|
|
735
|
+
// the result was truncated and the count carries no extra information.
|
|
736
|
+
if (result.totalCount !== undefined && (result.totalCount > result.features.length || !result.exceededTransferLimit))
|
|
737
|
+
return result.totalCount;
|
|
738
|
+
return result.exceededTransferLimit ? undefined : (result.totalCount ?? result.features.length);
|
|
739
|
+
}
|
|
740
|
+
function computeOverflow(result, limit, sink) {
|
|
741
|
+
if (!result.exceededTransferLimit)
|
|
742
|
+
return undefined;
|
|
743
|
+
const overflow = Object.freeze({
|
|
744
|
+
truncated: true,
|
|
745
|
+
renderedFeatureCount: result.features.length,
|
|
746
|
+
limit,
|
|
747
|
+
...(result.totalCount !== undefined && result.totalCount > result.features.length
|
|
748
|
+
? { totalCount: result.totalCount }
|
|
749
|
+
: {}),
|
|
750
|
+
});
|
|
751
|
+
sink.push(warning("overflow-truncated", `GeoJSON materialization was truncated at ${result.features.length} of the matching rows (limit ${limit}); raise maxGeoJsonFeatures or use the query-tiles strategy.`, { renderedFeatureCount: result.features.length, limit }));
|
|
752
|
+
return overflow;
|
|
753
|
+
}
|
|
754
|
+
function computeBounds(data) {
|
|
755
|
+
let xmin = Number.POSITIVE_INFINITY;
|
|
756
|
+
let ymin = Number.POSITIVE_INFINITY;
|
|
757
|
+
let xmax = Number.NEGATIVE_INFINITY;
|
|
758
|
+
let ymax = Number.NEGATIVE_INFINITY;
|
|
759
|
+
const visit = (coordinates) => {
|
|
760
|
+
if (!Array.isArray(coordinates))
|
|
761
|
+
return;
|
|
762
|
+
if (coordinates.length >= 2 && typeof coordinates[0] === "number" && typeof coordinates[1] === "number") {
|
|
763
|
+
const [x, y] = coordinates;
|
|
764
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
765
|
+
xmin = Math.min(xmin, x);
|
|
766
|
+
ymin = Math.min(ymin, y);
|
|
767
|
+
xmax = Math.max(xmax, x);
|
|
768
|
+
ymax = Math.max(ymax, y);
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
for (const entry of coordinates)
|
|
773
|
+
visit(entry);
|
|
774
|
+
};
|
|
775
|
+
for (const feature of data.features)
|
|
776
|
+
visit(feature.geometry?.coordinates);
|
|
777
|
+
if (!Number.isFinite(xmin) || !Number.isFinite(ymin) || !Number.isFinite(xmax) || !Number.isFinite(ymax)) {
|
|
778
|
+
return undefined;
|
|
779
|
+
}
|
|
780
|
+
return [xmin, ymin, xmax, ymax];
|
|
781
|
+
}
|
|
782
|
+
function sameStringArray(left, right) {
|
|
783
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
784
|
+
}
|
|
785
|
+
function sameQuery(left, right) {
|
|
786
|
+
return sameJson(left, right);
|
|
787
|
+
}
|
|
788
|
+
function sameJson(left, right) {
|
|
789
|
+
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
|
790
|
+
}
|
|
791
|
+
function recordOf(value) {
|
|
792
|
+
return value && typeof value === "object" ? value : {};
|
|
793
|
+
}
|
|
794
|
+
/** Apply added/changed keys and unset removed keys through a MapLibre setter. */
|
|
795
|
+
function diffProperties(previous, next, set) {
|
|
796
|
+
for (const [key, value] of Object.entries(next)) {
|
|
797
|
+
if (!sameJson(previous[key], value))
|
|
798
|
+
set(key, value);
|
|
799
|
+
}
|
|
800
|
+
for (const key of Object.keys(previous)) {
|
|
801
|
+
if (!(key in next))
|
|
802
|
+
set(key, undefined);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
function combineSignals(signals) {
|
|
806
|
+
const available = signals.filter((signal) => signal !== undefined);
|
|
807
|
+
return available.length === 1 ? available[0] : AbortSignal.any(available);
|
|
808
|
+
}
|
|
809
|
+
function info(code, message, detail) {
|
|
810
|
+
return Object.freeze({ code, severity: "info", message, ...(detail ? { detail } : {}) });
|
|
811
|
+
}
|
|
812
|
+
function warning(code, message, detail) {
|
|
813
|
+
return Object.freeze({ code, severity: "warning", message, ...(detail ? { detail } : {}) });
|
|
814
|
+
}
|
|
815
|
+
function errorMessage(error) {
|
|
816
|
+
return error instanceof Error ? error.message : String(error);
|
|
817
|
+
}
|