@honua/app-platform 0.1.2-beta.0 → 0.1.4-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/collaboration/client.d.ts.map +1 -1
- package/connect-url-safety.d.ts +53 -0
- package/connect-url-safety.js +189 -0
- package/contract/discovery.d.ts +34 -0
- package/contract/discovery.d.ts.map +1 -1
- package/contract/index.d.ts +7 -5
- package/contract/index.d.ts.map +1 -1
- package/contract/index.js +3 -2
- package/contract/pmtiles.d.ts +10 -1
- package/contract/pmtiles.d.ts.map +1 -1
- package/contract/pmtiles.js +10 -2
- package/contract/protocol-module.d.ts +84 -9
- package/contract/protocol-module.d.ts.map +1 -1
- package/contract/query-filter-fes.d.ts +18 -0
- package/contract/query-filter-fes.d.ts.map +1 -0
- package/contract/query-filter-fes.js +122 -0
- package/contract/query-filter.d.ts +300 -0
- package/contract/query-filter.d.ts.map +1 -0
- package/contract/query-filter.js +883 -0
- package/contract/schema.d.ts +12 -0
- package/contract/schema.d.ts.map +1 -1
- package/contract/schema.js +34 -0
- package/contract/source-capability-support.d.ts.map +1 -1
- package/contract/source-capability-support.js +15 -3
- package/contract/source.d.ts +45 -25
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +719 -479
- package/contract/types.d.ts +87 -18
- package/contract/types.d.ts.map +1 -1
- package/contract/widget-source.d.ts.map +1 -1
- package/controls/basemap-switcher.d.ts +19 -11
- package/controls/basemap-switcher.d.ts.map +1 -1
- package/controls/basemap-switcher.js +58 -25
- package/controls/catalog.d.ts +105 -0
- package/controls/catalog.d.ts.map +1 -0
- package/controls/catalog.js +341 -0
- package/controls/element-utils.d.ts +9 -0
- package/controls/element-utils.d.ts.map +1 -1
- package/controls/element-utils.js +17 -0
- package/controls/index.d.ts +29 -8
- package/controls/index.d.ts.map +1 -1
- package/controls/index.js +26 -8
- package/controls/layer-list.d.ts +13 -5
- package/controls/layer-list.d.ts.map +1 -1
- package/controls/layer-list.js +72 -15
- package/controls/legend.d.ts +32 -5
- package/controls/legend.d.ts.map +1 -1
- package/controls/legend.js +95 -25
- package/controls/qualification.d.ts +152 -0
- package/controls/qualification.d.ts.map +1 -0
- package/controls/qualification.js +1265 -0
- package/controls/registry.d.ts +104 -0
- package/controls/registry.d.ts.map +1 -0
- package/controls/registry.js +150 -0
- package/controls/swipe-control.d.ts +6 -1
- package/controls/swipe-control.d.ts.map +1 -1
- package/controls/swipe-control.js +82 -20
- package/core/client.d.ts +45 -21
- package/core/client.d.ts.map +1 -1
- package/core/client.js +224 -46
- package/core/error-classifications.d.ts +6 -0
- package/core/error-classifications.d.ts.map +1 -1
- package/core/error-classifications.js +6 -0
- package/core/error-code-registry.d.ts +6 -0
- package/core/error-code-registry.d.ts.map +1 -1
- package/core/error-code-registry.js +6 -0
- package/core/error-envelope.js +2 -0
- package/core/errors.d.ts +1 -1
- package/core/errors.js +1 -1
- package/core/geoparquet-sql.d.ts +68 -4
- package/core/geoparquet-sql.d.ts.map +1 -1
- package/core/geoparquet-sql.js +325 -8
- package/core/odata-write-codec.d.ts.map +1 -1
- package/core/odata-write-codec.js +0 -1
- package/core/odata.d.ts +6 -0
- package/core/odata.d.ts.map +1 -1
- package/core/odata.js +6 -1
- package/core/ogc-processes.d.ts +182 -25
- package/core/ogc-processes.d.ts.map +1 -1
- package/core/ogc-processes.js +602 -30
- package/core/process-runner.d.ts +8 -1
- package/core/process-runner.d.ts.map +1 -1
- package/core/process-runner.js +1 -0
- package/core/protocol-transport.d.ts +2 -0
- package/core/protocol-transport.d.ts.map +1 -1
- package/core/stac.d.ts +31 -0
- package/core/stac.d.ts.map +1 -1
- package/core/stac.js +136 -19
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +273 -68
- package/core/types.d.ts +123 -9
- package/core/types.d.ts.map +1 -1
- package/core/wfs-axis-order.d.ts +18 -0
- package/core/wfs-axis-order.d.ts.map +1 -0
- package/core/wfs-axis-order.js +108 -0
- package/core/wfs-capabilities.d.ts +65 -24
- package/core/wfs-capabilities.d.ts.map +1 -1
- package/core/wfs-capabilities.js +237 -27
- package/core/wfs-filter.d.ts +26 -3
- package/core/wfs-filter.d.ts.map +1 -1
- package/core/wfs-filter.js +48 -53
- package/core/wfs-protocol-error.d.ts +12 -0
- package/core/wfs-protocol-error.d.ts.map +1 -0
- package/core/wfs-protocol-error.js +13 -0
- package/core/wfs.d.ts +53 -1
- package/core/wfs.d.ts.map +1 -1
- package/core/wfs.js +251 -29
- package/core/wms-feature-info.d.ts +92 -0
- package/core/wms-feature-info.d.ts.map +1 -0
- package/core/wms-feature-info.js +285 -0
- package/core/wms.d.ts +9 -0
- package/core/wms.d.ts.map +1 -1
- package/core/wms.js +17 -5
- package/esri-compat/event-bus.d.ts +20 -0
- package/esri-compat/event-bus.d.ts.map +1 -1
- package/esri-compat/feature-filter.d.ts +46 -1
- package/esri-compat/feature-filter.d.ts.map +1 -1
- package/esri-compat/feature-filter.js +49 -0
- package/esri-compat/geojson-layer.d.ts +4 -2
- package/esri-compat/geojson-layer.d.ts.map +1 -1
- package/esri-compat/geojson-layer.js +4 -2
- package/esri-compat/identity-manager.js +2 -2
- package/esri-compat/imagery-layer.d.ts +2 -1
- package/esri-compat/imagery-layer.d.ts.map +1 -1
- package/esri-compat/imagery-layer.js +2 -1
- package/esri-compat/layer-list.d.ts +8 -1
- package/esri-compat/layer-list.d.ts.map +1 -1
- package/esri-compat/layer-list.js +11 -4
- package/esri-compat/legend.d.ts.map +1 -1
- package/esri-compat/legend.js +1 -1
- package/esri-compat/locator.d.ts +297 -0
- package/esri-compat/locator.d.ts.map +1 -0
- package/esri-compat/locator.js +579 -0
- package/esri-compat/map-image-layer.d.ts.map +1 -1
- package/esri-compat/map-view.d.ts.map +1 -1
- package/esri-compat/map-view.js +1 -1
- package/esri-compat/scene-view.d.ts.map +1 -1
- package/esri-compat/time-slider.d.ts +29 -0
- package/esri-compat/time-slider.d.ts.map +1 -1
- package/esri-compat/time-slider.js +117 -0
- package/esri-compat/wfs-layer.d.ts +2 -1
- package/esri-compat/wfs-layer.d.ts.map +1 -1
- package/esri-compat/wfs-layer.js +2 -1
- package/esri-compat/widget-host.d.ts +42 -6
- package/esri-compat/widget-host.d.ts.map +1 -1
- package/esri-compat/widget-host.js +99 -10
- package/generated-app/projection.d.ts.map +1 -1
- package/generated-app/runtime.d.ts.map +1 -1
- package/map/data-to-map-bridge.d.ts +1 -1
- package/map/data-to-map-bridge.d.ts.map +1 -1
- package/map/data-to-map-bridge.js +1 -2
- package/map/honua-map.d.ts.map +1 -1
- package/map/honua-map.js +1 -1
- package/map/index.d.ts +2 -0
- package/map/index.d.ts.map +1 -1
- package/map/index.js +2 -0
- package/map/realtime-reconciliation-adapter.d.ts +98 -0
- package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
- package/map/realtime-reconciliation-adapter.js +84 -0
- package/map/temporal-playback.d.ts +25 -0
- package/map/temporal-playback.d.ts.map +1 -1
- package/map/temporal-playback.js +29 -1
- package/operator/workspace/workspace.d.ts.map +1 -1
- package/operator/workspace/workspace.js +0 -2
- package/package.json +4 -3
- package/query-planner/canonical-filter.d.ts +30 -0
- package/query-planner/canonical-filter.d.ts.map +1 -0
- package/query-planner/canonical-filter.js +30 -0
- package/query-planner/columnar-plan.d.ts +63 -0
- package/query-planner/columnar-plan.d.ts.map +1 -0
- package/query-planner/columnar-plan.js +94 -0
- package/query-planner/diagnostics.d.ts +3 -3
- package/query-planner/diagnostics.d.ts.map +1 -1
- package/query-planner/diagnostics.js +17 -4
- package/query-planner/duckdb.d.ts.map +1 -1
- package/query-planner/duckdb.js +16 -2
- package/query-planner/execution-context.d.ts.map +1 -1
- package/query-planner/execution-context.js +3 -1
- package/query-planner/executor.d.ts.map +1 -1
- package/query-planner/executor.js +7 -1
- package/query-planner/geoservices.d.ts.map +1 -1
- package/query-planner/geoservices.js +24 -5
- package/query-planner/grpc.d.ts.map +1 -1
- package/query-planner/grpc.js +29 -5
- package/query-planner/index.d.ts +20 -5
- package/query-planner/index.d.ts.map +1 -1
- package/query-planner/index.js +16 -3
- package/query-planner/ir.d.ts +6 -1
- package/query-planner/ir.d.ts.map +1 -1
- package/query-planner/ir.js +63 -1
- package/query-planner/odata-protocol-module.d.ts +54 -0
- package/query-planner/odata-protocol-module.d.ts.map +1 -0
- package/query-planner/odata-protocol-module.js +199 -0
- package/query-planner/odata-v1.d.ts +23 -0
- package/query-planner/odata-v1.d.ts.map +1 -0
- package/query-planner/odata-v1.js +202 -0
- package/query-planner/odata.d.ts +0 -3
- package/query-planner/odata.d.ts.map +1 -1
- package/query-planner/odata.js +0 -143
- package/query-planner/ogc-features.d.ts.map +1 -1
- package/query-planner/ogc-features.js +23 -1
- package/query-planner/planner.d.ts +2 -2
- package/query-planner/planner.d.ts.map +1 -1
- package/query-planner/planner.js +146 -27
- package/query-planner/representation.d.ts +59 -0
- package/query-planner/representation.d.ts.map +1 -0
- package/query-planner/representation.js +140 -0
- package/query-planner/types.d.ts +153 -3
- package/query-planner/types.d.ts.map +1 -1
- package/query-planner/wfs-protocol-module.d.ts +57 -0
- package/query-planner/wfs-protocol-module.d.ts.map +1 -0
- package/query-planner/wfs-protocol-module.js +731 -0
- package/query-planner/wfs-v1.d.ts +41 -0
- package/query-planner/wfs-v1.d.ts.map +1 -0
- package/query-planner/wfs-v1.js +366 -0
- package/query-planner/wfs.d.ts +1 -3
- package/query-planner/wfs.d.ts.map +1 -1
- package/query-planner/wfs.js +1 -70
- package/realtime/checkpoint-store.d.ts +206 -0
- package/realtime/checkpoint-store.d.ts.map +1 -0
- package/realtime/checkpoint-store.js +504 -0
- package/realtime/honua-server.d.ts +8 -0
- package/realtime/honua-server.d.ts.map +1 -1
- package/realtime/honua-server.js +123 -14
- package/realtime/index.d.ts +4 -0
- package/realtime/index.d.ts.map +1 -1
- package/realtime/index.js +2 -0
- package/realtime/reconciliation.d.ts +245 -0
- package/realtime/reconciliation.d.ts.map +1 -0
- package/realtime/reconciliation.js +377 -0
- package/realtime/resumable-transport.d.ts.map +1 -1
- package/realtime/sse.d.ts.map +1 -1
- package/realtime/websocket.d.ts.map +1 -1
- package/realtime/wire.d.ts +2 -1
- package/realtime/wire.d.ts.map +1 -1
- package/realtime/wire.js +24 -8
- package/replica-sync/errors.d.ts +19 -1
- package/replica-sync/errors.d.ts.map +1 -1
- package/replica-sync/errors.js +16 -0
- package/replica-sync/geoservices.d.ts +260 -0
- package/replica-sync/geoservices.d.ts.map +1 -0
- package/replica-sync/geoservices.js +1093 -0
- package/replica-sync/index.d.ts +5 -1
- package/replica-sync/index.d.ts.map +1 -1
- package/replica-sync/index.js +3 -1
- package/replica-sync/transport-conformance.d.ts +58 -0
- package/replica-sync/transport-conformance.d.ts.map +1 -0
- package/replica-sync/transport-conformance.js +265 -0
- package/replica-sync/types.d.ts +22 -0
- package/replica-sync/types.d.ts.map +1 -1
- package/runtime/index.d.ts +6 -2
- package/runtime/index.d.ts.map +1 -1
- package/runtime/index.js +3 -1
- package/runtime/load-package.d.ts +24 -0
- package/runtime/load-package.d.ts.map +1 -1
- package/runtime/load-package.js +45 -1
- package/runtime/maplibre-renderer.d.ts +11 -0
- package/runtime/maplibre-renderer.d.ts.map +1 -1
- package/runtime/maplibre-renderer.js +32 -7
- package/runtime/offline-region-protocol.d.ts +73 -0
- package/runtime/offline-region-protocol.d.ts.map +1 -0
- package/runtime/offline-region-protocol.js +63 -0
- package/runtime/offline-region-style.d.ts +78 -0
- package/runtime/offline-region-style.d.ts.map +1 -0
- package/runtime/offline-region-style.js +147 -0
- package/runtime/pmtiles-protocol.d.ts +7 -8
- package/runtime/pmtiles-protocol.d.ts.map +1 -1
- package/runtime/pmtiles-protocol.js +19 -66
- package/runtime/protocol-registry.d.ts +55 -0
- package/runtime/protocol-registry.d.ts.map +1 -0
- package/runtime/protocol-registry.js +103 -0
- package/runtime/runtime.d.ts +2 -0
- package/runtime/runtime.d.ts.map +1 -1
- package/runtime/runtime.js +6 -1
- package/scene-workspace/analysis-widgets.d.ts +4 -2
- package/scene-workspace/analysis-widgets.d.ts.map +1 -1
- package/scene-workspace/analysis-widgets.js +4 -2
- package/scene-workspace/camera-correspondence.d.ts +151 -0
- package/scene-workspace/camera-correspondence.d.ts.map +1 -0
- package/scene-workspace/camera-correspondence.js +220 -0
- package/scene-workspace/cesium-adapter.d.ts +230 -29
- package/scene-workspace/cesium-adapter.d.ts.map +1 -1
- package/scene-workspace/cesium-adapter.js +741 -56
- package/scene-workspace/cesium-mount.d.ts +205 -0
- package/scene-workspace/cesium-mount.d.ts.map +1 -0
- package/scene-workspace/cesium-mount.js +392 -0
- package/scene-workspace/cesium-scene-owner.d.ts +160 -0
- package/scene-workspace/cesium-scene-owner.d.ts.map +1 -0
- package/scene-workspace/cesium-scene-owner.js +205 -0
- package/scene-workspace/cesium-state-sync.d.ts +101 -0
- package/scene-workspace/cesium-state-sync.d.ts.map +1 -0
- package/scene-workspace/cesium-state-sync.js +432 -0
- package/scene-workspace/cesium-time.d.ts +346 -0
- package/scene-workspace/cesium-time.d.ts.map +1 -0
- package/scene-workspace/cesium-time.js +403 -0
- package/scene-workspace/index.d.ts +42 -10
- package/scene-workspace/index.d.ts.map +1 -1
- package/scene-workspace/index.js +32 -7
- package/scene-workspace/maplibre-state-sync.d.ts +112 -0
- package/scene-workspace/maplibre-state-sync.d.ts.map +1 -0
- package/scene-workspace/maplibre-state-sync.js +400 -0
- package/scene-workspace/primitives.d.ts +237 -3
- package/scene-workspace/primitives.d.ts.map +1 -1
- package/scene-workspace/primitives.js +1087 -17
- package/scene-workspace/scene-discovery.d.ts +5 -2
- package/scene-workspace/scene-discovery.d.ts.map +1 -1
- package/scene-workspace/scene-discovery.js +5 -2
- package/scene-workspace/scene-view.d.ts +5 -2
- package/scene-workspace/scene-view.d.ts.map +1 -1
- package/scene-workspace/scene-view.js +5 -2
- package/scene-workspace/source-to-cesium.d.ts +141 -8
- package/scene-workspace/source-to-cesium.d.ts.map +1 -1
- package/scene-workspace/source-to-cesium.js +497 -72
- package/scene-workspace/state-sync-port-kit.d.ts +156 -0
- package/scene-workspace/state-sync-port-kit.d.ts.map +1 -0
- package/scene-workspace/state-sync-port-kit.js +319 -0
- package/scene-workspace/state-sync.d.ts +34 -1
- package/scene-workspace/state-sync.d.ts.map +1 -1
- package/scene-workspace/state-sync.js +48 -0
- package/scene-workspace/types.d.ts +8 -0
- package/scene-workspace/types.d.ts.map +1 -1
- package/scene-workspace/types.js +2 -0
- package/scene-workspace/workspace.d.ts +12 -1
- package/scene-workspace/workspace.d.ts.map +1 -1
- package/scene-workspace/workspace.js +13 -0
- package/studio/index.d.ts +24 -7
- package/studio/index.d.ts.map +1 -1
- package/studio/index.js +21 -7
- package/studio/lifecycle-client.d.ts +164 -0
- package/studio/lifecycle-client.d.ts.map +1 -0
- package/studio/lifecycle-client.js +299 -0
- package/studio/lifecycle-errors.d.ts +91 -0
- package/studio/lifecycle-errors.d.ts.map +1 -0
- package/studio/lifecycle-errors.js +77 -0
- package/studio/lifecycle-types.d.ts +321 -0
- package/studio/lifecycle-types.d.ts.map +1 -0
- package/studio/lifecycle-types.js +22 -0
- package/web-components/controller.d.ts.map +1 -1
- package/web-components/csp-styles.d.ts +6 -0
- package/web-components/csp-styles.d.ts.map +1 -0
- package/web-components/csp-styles.js +53 -0
- package/web-components/elements.d.ts +158 -2
- package/web-components/elements.d.ts.map +1 -1
- package/web-components/elements.js +1302 -215
- package/web-components/export-redaction.d.ts +252 -0
- package/web-components/export-redaction.d.ts.map +1 -0
- package/web-components/export-redaction.js +588 -0
- package/web-components/export.d.ts +417 -0
- package/web-components/export.d.ts.map +1 -0
- package/web-components/export.js +719 -0
- package/web-components/feature-editor-model.d.ts +265 -0
- package/web-components/feature-editor-model.d.ts.map +1 -0
- package/web-components/feature-editor-model.js +438 -0
- package/web-components/feature-editor-sketch.d.ts +125 -0
- package/web-components/feature-editor-sketch.d.ts.map +1 -0
- package/web-components/feature-editor-sketch.js +194 -0
- package/web-components/feature-editor-workflow.d.ts +286 -0
- package/web-components/feature-editor-workflow.d.ts.map +1 -0
- package/web-components/feature-editor-workflow.js +889 -0
- package/web-components/feature-editor.d.ts +83 -0
- package/web-components/feature-editor.d.ts.map +1 -0
- package/web-components/feature-editor.js +829 -0
- package/web-components/feature-table-engine.d.ts +465 -0
- package/web-components/feature-table-engine.d.ts.map +1 -0
- package/web-components/feature-table-engine.js +1360 -0
- package/web-components/feature-table-view.d.ts +95 -0
- package/web-components/feature-table-view.d.ts.map +1 -0
- package/web-components/feature-table-view.js +343 -0
- package/web-components/index.d.ts +34 -3
- package/web-components/index.d.ts.map +1 -1
- package/web-components/index.js +35 -2
- package/web-components/maplibre-renderer.d.ts.map +1 -1
- package/web-components/maplibre-renderer.js +13 -4
- package/web-components/measurement.d.ts +3 -1
- package/web-components/measurement.d.ts.map +1 -1
- package/web-components/measurement.js +62 -18
- package/web-components/time-slider.d.ts +122 -0
- package/web-components/time-slider.d.ts.map +1 -0
- package/web-components/time-slider.js +740 -0
- package/web-components/types.d.ts +310 -0
- package/web-components/types.d.ts.map +1 -1
- package/webmap/convert-label.js +0 -1
- package/webmap/convert-symbol.d.ts.map +1 -1
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded feature-table engine — the headless kernel behind
|
|
3
|
+
* `<honua-feature-table>` (issue #681).
|
|
4
|
+
*
|
|
5
|
+
* The engine is deliberately DOM-free so the whole production contract
|
|
6
|
+
* (paging, sort, typed filters, column semantics, budgets, virtualization,
|
|
7
|
+
* linked exploration state, realtime reconciliation, bounded export, and the
|
|
8
|
+
* keyboard focus model) is testable without a browser and reusable by any
|
|
9
|
+
* renderer.
|
|
10
|
+
*
|
|
11
|
+
* Design rules this module holds itself to:
|
|
12
|
+
*
|
|
13
|
+
* - **Public seams only.** Data arrives through the canonical `Source`-shaped
|
|
14
|
+
* query surface ({@link HonuaFeatureTableQuerySource}), typed filters are
|
|
15
|
+
* compiled by `@honua/sdk-js/filter-registry`, linked state flows through an
|
|
16
|
+
* `@honua/sdk-js/exploration` view controller, and realtime deltas arrive as
|
|
17
|
+
* the `@honua/sdk-js/realtime` reconciliation diff. No protocol adapter,
|
|
18
|
+
* renderer, or SDK-private path is imported.
|
|
19
|
+
* - **Never manufacture a count.** {@link HonuaFeatureTableCount} always names
|
|
20
|
+
* the evidence a number came from, and reports `"unknown"` rather than
|
|
21
|
+
* guessing (REQ-004).
|
|
22
|
+
* - **Never materialize a layer.** Every fetch is bounded by
|
|
23
|
+
* {@link HonuaFeatureTableBudgets}; the page cache evicts to stay under the
|
|
24
|
+
* row/byte ceilings and stops issuing work once the request ceiling is
|
|
25
|
+
* reached (REQ-002).
|
|
26
|
+
* - **Identity before linkage.** A table with no resolvable stable row identity
|
|
27
|
+
* enters the `"unsupported"` state instead of keying rows by their position
|
|
28
|
+
* in a page, which would corrupt selection across paging.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
*/
|
|
32
|
+
import { isSourceQualifiedSelectionTarget, sourceFeatureSelectionTarget } from "../exploration/selection.js";
|
|
33
|
+
import { projectFilterRegistryToQuery } from "../filter-registry/index.js";
|
|
34
|
+
import { reconcileRealtimeKeyedState, reconcileRealtimeViewport } from "../realtime/reconciliation.js";
|
|
35
|
+
/** Build the engine's row key. Shares the realtime key space (`sourceId:id`). */
|
|
36
|
+
export function featureTableRowKey(sourceId, id) {
|
|
37
|
+
return `${sourceId}:${String(id)}`;
|
|
38
|
+
}
|
|
39
|
+
/** Conservative defaults sized for an operational browser panel. */
|
|
40
|
+
export const DEFAULT_FEATURE_TABLE_BUDGETS = Object.freeze({
|
|
41
|
+
pageSize: 200,
|
|
42
|
+
maxCachedRows: 2_000,
|
|
43
|
+
maxCachedBytes: 4 * 1024 * 1024,
|
|
44
|
+
maxRequests: 64,
|
|
45
|
+
maxExportRows: 10_000,
|
|
46
|
+
windowOverscan: 20,
|
|
47
|
+
});
|
|
48
|
+
/** Select the work items attributed to one execution tier. */
|
|
49
|
+
export function featureTableWorkByTier(evidence, tier) {
|
|
50
|
+
return Object.freeze(evidence.work.filter((item) => item.tier === tier));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pure window computation: which absolute row indices a renderer must paint
|
|
54
|
+
* for the given scroll geometry, widened by `overscan` and clamped to
|
|
55
|
+
* `totalRows` when a total is known.
|
|
56
|
+
*/
|
|
57
|
+
export function featureTableWindow(metrics, options = {}) {
|
|
58
|
+
const rowHeight = metrics.rowHeight > 0 ? metrics.rowHeight : 1;
|
|
59
|
+
const overscan = Math.max(0, options.overscan ?? 0);
|
|
60
|
+
const first = Math.max(0, Math.floor(Math.max(0, metrics.scrollTop) / rowHeight) - overscan);
|
|
61
|
+
const visible = Math.max(1, Math.ceil(Math.max(0, metrics.viewportHeight) / rowHeight));
|
|
62
|
+
const last = first + visible + overscan * 2;
|
|
63
|
+
const end = options.totalRows === undefined ? last : Math.min(last, Math.max(0, options.totalRows));
|
|
64
|
+
return Object.freeze({ startIndex: Math.min(first, end), endIndex: end });
|
|
65
|
+
}
|
|
66
|
+
/** Deterministic page-cache key. Stable across property insertion order. */
|
|
67
|
+
export function featureTablePageCacheKey(identity, page = {}) {
|
|
68
|
+
return [
|
|
69
|
+
`source=${identity.sourceId}`,
|
|
70
|
+
`sourceVersion=${identity.sourceVersion ?? ""}`,
|
|
71
|
+
`schemaVersion=${identity.schemaVersion ?? ""}`,
|
|
72
|
+
`plan=${identity.planFingerprint ?? ""}`,
|
|
73
|
+
`filter=${identity.filterKey}`,
|
|
74
|
+
`sort=${identity.sortKey}`,
|
|
75
|
+
`projection=${identity.projectionKey}`,
|
|
76
|
+
`scope=${[...identity.authorizationScope].sort().join("|")}`,
|
|
77
|
+
`freshness=${identity.freshness}`,
|
|
78
|
+
`offset=${page.offset ?? ""}`,
|
|
79
|
+
`limit=${page.limit ?? ""}`,
|
|
80
|
+
`cursor=${page.cursor ?? ""}`,
|
|
81
|
+
].join(" ");
|
|
82
|
+
}
|
|
83
|
+
const EMPTY_EVIDENCE = Object.freeze({
|
|
84
|
+
work: Object.freeze([]),
|
|
85
|
+
degraded: Object.freeze([]),
|
|
86
|
+
cacheKey: "",
|
|
87
|
+
});
|
|
88
|
+
const CANCELLED_MESSAGE = "The table refresh was cancelled.";
|
|
89
|
+
const NO_IDENTITY_FIELD_MESSAGE = "A stable row identity is required. Set `identityField` (or the source descriptor's `schema.primaryKey`) so selection, focus, and realtime reconciliation survive paging.";
|
|
90
|
+
const QUERY_CAPABILITY_MESSAGE = "The source does not advertise the canonical `query` capability, so a feature table cannot claim that bounded paging is supported.";
|
|
91
|
+
const IDENTITY_REQUIRED_HINT = "Every row must carry a string or number identity value, or selection and realtime reconciliation cannot survive paging.";
|
|
92
|
+
const PAGE_SIZE_HINT = "no page could ever be held within the memory budget. Lower `pageSize` or raise `maxCachedRows`.";
|
|
93
|
+
const CACHE_BUDGET_HINT = "Lower `pageSize` or raise the cache budget.";
|
|
94
|
+
/**
|
|
95
|
+
* Create a bounded feature table.
|
|
96
|
+
*
|
|
97
|
+
* The engine starts `"idle"` and performs no I/O until {@link
|
|
98
|
+
* HonuaFeatureTable.refresh} or {@link HonuaFeatureTable.setScroll} is called,
|
|
99
|
+
* so mounting a grid never triggers an unbounded drain.
|
|
100
|
+
*/
|
|
101
|
+
export function createHonuaFeatureTable(options) {
|
|
102
|
+
const budgets = Object.freeze({ ...DEFAULT_FEATURE_TABLE_BUDGETS, ...options.budgets });
|
|
103
|
+
const pagingMode = options.pagingMode ?? "offset";
|
|
104
|
+
const residualTier = options.residualExecution === "worker" ? "worker" : "client";
|
|
105
|
+
const authorizationScope = Object.freeze([...(options.authorizationScope ?? [])]);
|
|
106
|
+
const returnGeometry = options.returnGeometry ?? false;
|
|
107
|
+
const identityField = options.identityField ?? options.source.descriptor?.schema?.primaryKey;
|
|
108
|
+
const queryCapabilityAdvertised = options.source.descriptor?.capabilities?.has("query");
|
|
109
|
+
const listeners = new Set();
|
|
110
|
+
const pages = new Map();
|
|
111
|
+
/**
|
|
112
|
+
* Exploration selection targets for the keys currently selected. Selection
|
|
113
|
+
* outlives cache residency, so a row scrolled out of the bounded window must
|
|
114
|
+
* still round-trip to a target. Pruned to the live selection on every change,
|
|
115
|
+
* so this never becomes a second unbounded row cache.
|
|
116
|
+
*/
|
|
117
|
+
const selectionTargetIndex = new Map();
|
|
118
|
+
const misconfiguredPageSize = budgets.pageSize > budgets.maxCachedRows;
|
|
119
|
+
let columns = resolveColumns(options.columns ?? []);
|
|
120
|
+
let sort = Object.freeze([...(options.sort ?? [])]);
|
|
121
|
+
let filters = normalizeFilters(options.filters);
|
|
122
|
+
let selection = Object.freeze([]);
|
|
123
|
+
let focus;
|
|
124
|
+
let window = Object.freeze({ startIndex: 0, endIndex: 0 });
|
|
125
|
+
let state = identityField && !misconfiguredPageSize && queryCapabilityAdvertised !== false ? "idle" : "unsupported";
|
|
126
|
+
let stale = false;
|
|
127
|
+
let message = !identityField
|
|
128
|
+
? NO_IDENTITY_FIELD_MESSAGE
|
|
129
|
+
: queryCapabilityAdvertised === false
|
|
130
|
+
? QUERY_CAPABILITY_MESSAGE
|
|
131
|
+
: misconfiguredPageSize
|
|
132
|
+
? `\`budgets.pageSize\` (${budgets.pageSize}) exceeds \`budgets.maxCachedRows\` (${budgets.maxCachedRows}), so ${PAGE_SIZE_HINT}`
|
|
133
|
+
: undefined;
|
|
134
|
+
/** Set when a bounded operation had to give something up; survives a successful run. */
|
|
135
|
+
let budgetNotice;
|
|
136
|
+
let error;
|
|
137
|
+
let conflicts = Object.freeze([]);
|
|
138
|
+
let evidence = EMPTY_EVIDENCE;
|
|
139
|
+
let count = Object.freeze({ kind: "unknown", loaded: 0, evidence: "none" });
|
|
140
|
+
let ledger = Object.freeze({
|
|
141
|
+
requests: 0,
|
|
142
|
+
lifetimeRequests: 0,
|
|
143
|
+
rows: 0,
|
|
144
|
+
bytes: 0,
|
|
145
|
+
evictedRows: 0,
|
|
146
|
+
exhausted: Object.freeze([]),
|
|
147
|
+
});
|
|
148
|
+
let totalKnown;
|
|
149
|
+
/** Which evidence produced `totalKnown`. Never inferred after the fact. */
|
|
150
|
+
let totalEvidence = "result-total-count";
|
|
151
|
+
let totalEstimated;
|
|
152
|
+
let exhausted = false;
|
|
153
|
+
let sequence = 0;
|
|
154
|
+
let revision = 0;
|
|
155
|
+
let disposed = false;
|
|
156
|
+
let generation = 0;
|
|
157
|
+
let cursorPages = 0;
|
|
158
|
+
let cursorIterator;
|
|
159
|
+
let inFlight;
|
|
160
|
+
// ── Snapshot assembly ───────────────────────────────────────
|
|
161
|
+
function buildSnapshot() {
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
sourceId: options.sourceId,
|
|
164
|
+
state,
|
|
165
|
+
columns,
|
|
166
|
+
visibleColumns: Object.freeze(columns.filter((column) => column.visible)),
|
|
167
|
+
rows: Object.freeze(rowsInWindow()),
|
|
168
|
+
window,
|
|
169
|
+
count,
|
|
170
|
+
sort,
|
|
171
|
+
filters,
|
|
172
|
+
selection,
|
|
173
|
+
...(focus ? { focus } : {}),
|
|
174
|
+
paging: Object.freeze({ mode: pagingMode, pageSize: budgets.pageSize, loadedPages: pages.size, exhausted }),
|
|
175
|
+
budgets,
|
|
176
|
+
ledger,
|
|
177
|
+
evidence,
|
|
178
|
+
conflicts,
|
|
179
|
+
stale,
|
|
180
|
+
...(message !== undefined ? { message } : {}),
|
|
181
|
+
...(error !== undefined ? { error } : {}),
|
|
182
|
+
revision,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
let snapshot = buildSnapshot();
|
|
186
|
+
function publish() {
|
|
187
|
+
revision += 1;
|
|
188
|
+
snapshot = buildSnapshot();
|
|
189
|
+
for (const listener of [...listeners])
|
|
190
|
+
listener(snapshot);
|
|
191
|
+
return snapshot;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Read `state` opaquely. `loadWindow`/`nextCursorPage` can move the table to
|
|
195
|
+
* `"unsupported"` mid-flight, which a direct comparison would have narrowed
|
|
196
|
+
* away at the call site.
|
|
197
|
+
*/
|
|
198
|
+
function currentState() {
|
|
199
|
+
return state;
|
|
200
|
+
}
|
|
201
|
+
function rowsInWindow() {
|
|
202
|
+
const out = [];
|
|
203
|
+
for (let index = window.startIndex; index < window.endIndex; index += 1)
|
|
204
|
+
out.push(rowAt(index));
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function rowAt(index) {
|
|
208
|
+
const page = pages.get(pageOffsetFor(index));
|
|
209
|
+
if (!page)
|
|
210
|
+
return undefined;
|
|
211
|
+
return page.rows.find((row) => row.index === index);
|
|
212
|
+
}
|
|
213
|
+
function pageOffsetFor(index) {
|
|
214
|
+
return Math.floor(index / budgets.pageSize) * budgets.pageSize;
|
|
215
|
+
}
|
|
216
|
+
function loadedRows() {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const page of [...pages.values()].sort((left, right) => left.offset - right.offset))
|
|
219
|
+
out.push(...page.rows);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
function findRow(key) {
|
|
223
|
+
for (const page of pages.values()) {
|
|
224
|
+
const hit = page.rows.find((row) => row.key === key);
|
|
225
|
+
if (hit)
|
|
226
|
+
return hit;
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
// ── Query composition ───────────────────────────────────────
|
|
231
|
+
function projection() {
|
|
232
|
+
const fields = columns.filter((column) => column.visible).map((column) => column.field);
|
|
233
|
+
if (fields.length === 0)
|
|
234
|
+
return undefined;
|
|
235
|
+
return identityField && !fields.includes(identityField) ? [...fields, identityField] : fields;
|
|
236
|
+
}
|
|
237
|
+
function orderedFields() {
|
|
238
|
+
const visible = columns.filter((column) => column.visible).map((column) => column.field);
|
|
239
|
+
return visible.length > 0 ? visible : columns.map((column) => column.field);
|
|
240
|
+
}
|
|
241
|
+
function composeQuery(page) {
|
|
242
|
+
const descriptor = options.source.descriptor;
|
|
243
|
+
const projected = projectFilterRegistryToQuery({ version: 1, clauses: filters }, {
|
|
244
|
+
sourceId: options.sourceId,
|
|
245
|
+
...(descriptor
|
|
246
|
+
? { source: { id: descriptor.id, protocol: descriptor.protocol, capabilities: descriptor.capabilities } }
|
|
247
|
+
: {}),
|
|
248
|
+
});
|
|
249
|
+
const outFields = projection();
|
|
250
|
+
const query = {
|
|
251
|
+
...(projected.where !== undefined ? { where: projected.where } : {}),
|
|
252
|
+
...(projected.query.spatialFilter ? { spatialFilter: projected.query.spatialFilter } : {}),
|
|
253
|
+
...(outFields ? { outFields } : {}),
|
|
254
|
+
...(sort.length > 0 ? { orderBy: [...sort] } : {}),
|
|
255
|
+
pagination: { ...(page.offset === undefined ? {} : { offset: page.offset }), limit: page.limit },
|
|
256
|
+
returnGeometry,
|
|
257
|
+
};
|
|
258
|
+
return { query, degraded: projected.degraded ?? [], filterKey: projected.cacheKey };
|
|
259
|
+
}
|
|
260
|
+
function identity(filterKey, planFingerprint) {
|
|
261
|
+
const descriptor = options.source.descriptor;
|
|
262
|
+
const freshness = descriptor?.analytics?.freshness;
|
|
263
|
+
return {
|
|
264
|
+
sourceId: options.sourceId,
|
|
265
|
+
...(options.sourceVersion ? { sourceVersion: options.sourceVersion } : {}),
|
|
266
|
+
...(descriptor?.schemaV2?.fingerprint ? { schemaVersion: descriptor.schemaV2.fingerprint } : {}),
|
|
267
|
+
...(planFingerprint ? { planFingerprint } : {}),
|
|
268
|
+
filterKey,
|
|
269
|
+
sortKey: sort.map((entry) => `${entry.field}:${entry.direction ?? "asc"}`).join(","),
|
|
270
|
+
projectionKey: (projection() ?? ["*"]).join(","),
|
|
271
|
+
authorizationScope,
|
|
272
|
+
freshness: freshness ? `${freshness.mode}:${freshness.ttlMs ?? ""}:${generation}` : `generation:${generation}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function buildEvidence(plan, degraded, cacheKey) {
|
|
276
|
+
const work = [];
|
|
277
|
+
for (const step of plan?.steps ?? []) {
|
|
278
|
+
if (step.engine === "remote") {
|
|
279
|
+
work.push({
|
|
280
|
+
tier: "server",
|
|
281
|
+
concern: step.pushdown === "full" ? "filter" : "paging",
|
|
282
|
+
detail: `${step.operation} pushdown=${step.pushdown} fidelity=${step.fidelity}: ${step.reason}`,
|
|
283
|
+
planStepId: step.id,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
work.push({
|
|
288
|
+
tier: residualTier,
|
|
289
|
+
concern: "filter",
|
|
290
|
+
detail: `residual ${step.operation} bounded to ${step.maxRows} rows: ${step.reason}`,
|
|
291
|
+
planStepId: step.id,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const reason of degraded) {
|
|
296
|
+
work.push({
|
|
297
|
+
tier: residualTier,
|
|
298
|
+
concern: "filter",
|
|
299
|
+
detail: `residual for ${reason.capability}: ${reason.reason}`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (sort.length > 0) {
|
|
303
|
+
work.push({
|
|
304
|
+
tier: plan === undefined || plan.pushdown === "full" ? "server" : residualTier,
|
|
305
|
+
concern: "sort",
|
|
306
|
+
detail: `orderBy ${sort.map((entry) => `${entry.field} ${entry.direction ?? "asc"}`).join(", ")}`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
work.push({
|
|
310
|
+
tier: "client",
|
|
311
|
+
concern: "virtualization",
|
|
312
|
+
detail: `window ${window.startIndex}-${window.endIndex} over ${pages.size} resident page(s)`,
|
|
313
|
+
});
|
|
314
|
+
const formatted = columns.filter((column) => column.format).length;
|
|
315
|
+
if (formatted > 0) {
|
|
316
|
+
work.push({
|
|
317
|
+
tier: "client",
|
|
318
|
+
concern: "format",
|
|
319
|
+
detail: `${formatted} column formatter(s) applied at render`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return Object.freeze({
|
|
323
|
+
...(plan ? { planId: plan.id, planFingerprint: plan.fingerprint, pushdown: plan.pushdown } : {}),
|
|
324
|
+
...(plan ? { fidelity: plan.fidelity } : {}),
|
|
325
|
+
work: Object.freeze(work.map((item) => Object.freeze(item))),
|
|
326
|
+
degraded: Object.freeze([...degraded]),
|
|
327
|
+
cacheKey,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
// ── Budget accounting ───────────────────────────────────────
|
|
331
|
+
function noteBudget(patch) {
|
|
332
|
+
ledger = Object.freeze({
|
|
333
|
+
requests: ledger.requests + (patch.requests ?? 0),
|
|
334
|
+
lifetimeRequests: ledger.lifetimeRequests + (patch.requests ?? 0),
|
|
335
|
+
rows: ledger.rows + (patch.rows ?? 0),
|
|
336
|
+
bytes: ledger.bytes + (patch.bytes ?? 0),
|
|
337
|
+
evictedRows: ledger.evictedRows + (patch.evictedRows ?? 0),
|
|
338
|
+
exhausted: ledger.exhausted,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function markExhausted(kind) {
|
|
342
|
+
if (ledger.exhausted.includes(kind))
|
|
343
|
+
return;
|
|
344
|
+
ledger = Object.freeze({ ...ledger, exhausted: Object.freeze([...ledger.exhausted, kind]) });
|
|
345
|
+
}
|
|
346
|
+
function residentRows() {
|
|
347
|
+
let total = 0;
|
|
348
|
+
for (const page of pages.values())
|
|
349
|
+
total += page.rows.length;
|
|
350
|
+
return total;
|
|
351
|
+
}
|
|
352
|
+
function residentBytes() {
|
|
353
|
+
let total = 0;
|
|
354
|
+
for (const page of pages.values())
|
|
355
|
+
total += page.bytes;
|
|
356
|
+
return total;
|
|
357
|
+
}
|
|
358
|
+
function withinMemoryBudgets() {
|
|
359
|
+
return residentRows() <= budgets.maxCachedRows && residentBytes() <= budgets.maxCachedBytes;
|
|
360
|
+
}
|
|
361
|
+
function evictPage(victim) {
|
|
362
|
+
markExhausted(residentBytes() > budgets.maxCachedBytes ? "bytes" : "rows");
|
|
363
|
+
pages.delete(victim.offset);
|
|
364
|
+
noteBudget({ evictedRows: victim.rows.length });
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Evict least-recently-used pages until both memory ceilings hold.
|
|
368
|
+
*
|
|
369
|
+
* `protectedOffsets` names the pages the current window just fetched, which
|
|
370
|
+
* are evicted last — but they are *not* exempt. A single page heavier than
|
|
371
|
+
* `maxCachedBytes` would otherwise sit above a ceiling documented as hard,
|
|
372
|
+
* so once every other page is gone the oversized page is dropped too and the
|
|
373
|
+
* caller is told why. Its window rows then render as placeholders, which is
|
|
374
|
+
* the honest outcome: the budget forbids holding that page.
|
|
375
|
+
*/
|
|
376
|
+
function enforceMemoryBudgets(protectedOffsets) {
|
|
377
|
+
const byAge = (left, right) => left.sequence - right.sequence;
|
|
378
|
+
const notice = `A page exceeded the table's memory budget (${budgets.maxCachedRows} rows / ${budgets.maxCachedBytes} bytes) and was dropped. ${CACHE_BUDGET_HINT}`;
|
|
379
|
+
const evictable = [...pages.values()].filter((page) => !protectedOffsets.has(page.offset)).sort(byAge);
|
|
380
|
+
for (const victim of evictable) {
|
|
381
|
+
if (withinMemoryBudgets())
|
|
382
|
+
return;
|
|
383
|
+
evictPage(victim);
|
|
384
|
+
budgetNotice = notice;
|
|
385
|
+
}
|
|
386
|
+
if (withinMemoryBudgets())
|
|
387
|
+
return;
|
|
388
|
+
for (const victim of [...pages.values()].sort(byAge)) {
|
|
389
|
+
if (withinMemoryBudgets())
|
|
390
|
+
break;
|
|
391
|
+
evictPage(victim);
|
|
392
|
+
budgetNotice = notice;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ── Fetching ────────────────────────────────────────────────
|
|
396
|
+
async function fetchPage(offset, signal) {
|
|
397
|
+
const composed = composeQuery({ offset: pagingMode === "cursor" ? undefined : offset, limit: budgets.pageSize });
|
|
398
|
+
const plan = options.planner?.(composed.query);
|
|
399
|
+
const cacheKey = featureTablePageCacheKey(identity(composed.filterKey, plan?.fingerprint), {
|
|
400
|
+
offset,
|
|
401
|
+
limit: budgets.pageSize,
|
|
402
|
+
...(pagingMode === "cursor" ? { cursor: offset / budgets.pageSize } : {}),
|
|
403
|
+
});
|
|
404
|
+
const cached = pages.get(offset);
|
|
405
|
+
if (cached && cached.cacheKey === cacheKey) {
|
|
406
|
+
sequence += 1;
|
|
407
|
+
cached.sequence = sequence;
|
|
408
|
+
evidence = buildEvidence(plan, composed.degraded, cacheKey);
|
|
409
|
+
return cached;
|
|
410
|
+
}
|
|
411
|
+
if (ledger.requests >= budgets.maxRequests) {
|
|
412
|
+
markExhausted("requests");
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
noteBudget({ requests: 1 });
|
|
416
|
+
const request = { ...composed.query, ...(signal ? { signal } : {}) };
|
|
417
|
+
// `Source.query` is not required to reject when its signal aborts, so a
|
|
418
|
+
// superseded generation's response can still resolve normally and land here
|
|
419
|
+
// long after the filter/sort/projection moved on. Cancellation alone cannot
|
|
420
|
+
// prevent that; only refusing to write results whose generation no longer
|
|
421
|
+
// matches can.
|
|
422
|
+
const requestGeneration = generation;
|
|
423
|
+
const result = pagingMode === "cursor" ? await nextCursorPage(offset, request) : await options.source.query(request);
|
|
424
|
+
if (generation !== requestGeneration || disposed)
|
|
425
|
+
return undefined;
|
|
426
|
+
if (!result)
|
|
427
|
+
return undefined;
|
|
428
|
+
const rows = [];
|
|
429
|
+
for (const [index, feature] of result.features.entries()) {
|
|
430
|
+
const row = toRow(feature, offset + index);
|
|
431
|
+
if (!row)
|
|
432
|
+
return undefined; // `toRow` already moved the table to `"unsupported"`.
|
|
433
|
+
rows.push(row);
|
|
434
|
+
}
|
|
435
|
+
const bytes = estimateBytes(result.features);
|
|
436
|
+
sequence += 1;
|
|
437
|
+
const page = { offset, rows: Object.freeze([...rows]), bytes, cacheKey, sequence };
|
|
438
|
+
pages.set(offset, page);
|
|
439
|
+
noteBudget({ rows: rows.length, bytes });
|
|
440
|
+
if (rows.length < budgets.pageSize && !result.exceededTransferLimit) {
|
|
441
|
+
exhausted = true;
|
|
442
|
+
if (totalKnown === undefined) {
|
|
443
|
+
totalKnown = offset + rows.length;
|
|
444
|
+
totalEvidence = "exhausted-pages";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (typeof result.totalCount === "number") {
|
|
448
|
+
totalKnown = result.totalCount;
|
|
449
|
+
totalEvidence = "result-total-count";
|
|
450
|
+
}
|
|
451
|
+
else if (typeof plan?.estimates?.rows === "number")
|
|
452
|
+
totalEstimated = plan.estimates.rows;
|
|
453
|
+
evidence = buildEvidence(plan, [...composed.degraded, ...(result.degraded ?? [])], cacheKey);
|
|
454
|
+
enforceMemoryBudgets(new Set([offset]));
|
|
455
|
+
return page;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Forward-only cursor paging over `Source.stream()` — the protocol-neutral
|
|
459
|
+
* cursor seam. Random access is impossible by construction, so a jump past
|
|
460
|
+
* the drained frontier is reported as `"unsupported"` rather than silently
|
|
461
|
+
* served from the wrong page.
|
|
462
|
+
*/
|
|
463
|
+
async function nextCursorPage(offset, request) {
|
|
464
|
+
const stream = options.source.stream;
|
|
465
|
+
if (!stream) {
|
|
466
|
+
state = "unsupported";
|
|
467
|
+
message = 'Cursor paging requires the source\'s `stream` capability. Use `pagingMode: "offset"` instead.';
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
const wantedPage = offset / budgets.pageSize;
|
|
471
|
+
if (wantedPage > cursorPages) {
|
|
472
|
+
state = "unsupported";
|
|
473
|
+
message = `Cursor paging is forward-only: page ${wantedPage} is past the drained frontier (${cursorPages}).`;
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
cursorIterator ??= stream.call(options.source, request);
|
|
477
|
+
const next = await cursorIterator.next();
|
|
478
|
+
if (next.done) {
|
|
479
|
+
exhausted = true;
|
|
480
|
+
return { features: [], exceededTransferLimit: false };
|
|
481
|
+
}
|
|
482
|
+
cursorPages += 1;
|
|
483
|
+
return next.value;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Build one row, or move the table to `"unsupported"` and return `undefined`.
|
|
487
|
+
*
|
|
488
|
+
* A row whose identity attribute is missing, `null`, or not a string/number
|
|
489
|
+
* has no stable identity. Substituting its position in the page would key the
|
|
490
|
+
* row by an offset that changes with every sort, filter, and page boundary —
|
|
491
|
+
* exactly the selection corruption the unsupported-identity state exists to
|
|
492
|
+
* prevent — so this refuses instead of inventing one.
|
|
493
|
+
*/
|
|
494
|
+
function toRow(feature, index) {
|
|
495
|
+
const attributes = feature.attributes ?? {};
|
|
496
|
+
const raw = identityField ? attributes[identityField] : undefined;
|
|
497
|
+
if (typeof raw !== "string" && typeof raw !== "number") {
|
|
498
|
+
state = "unsupported";
|
|
499
|
+
message = `Row ${index} has no stable identity: \`${identityField}\` is ${describeIdentityValue(raw)}. ${IDENTITY_REQUIRED_HINT}`;
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
const id = raw;
|
|
503
|
+
return Object.freeze({
|
|
504
|
+
key: featureTableRowKey(options.sourceId, id),
|
|
505
|
+
id,
|
|
506
|
+
sourceId: options.sourceId,
|
|
507
|
+
index,
|
|
508
|
+
attributes,
|
|
509
|
+
...(feature.geometry !== undefined ? { geometry: feature.geometry } : {}),
|
|
510
|
+
target: sourceFeatureSelectionTarget(options.sourceId, id),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
function recomputeCount() {
|
|
514
|
+
const loaded = residentRows();
|
|
515
|
+
if (typeof totalKnown === "number") {
|
|
516
|
+
count = Object.freeze({ kind: "known", value: totalKnown, loaded, evidence: totalEvidence });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (exhausted) {
|
|
520
|
+
count = Object.freeze({ kind: "known", value: loaded, loaded, evidence: "exhausted-pages" });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (typeof totalEstimated === "number") {
|
|
524
|
+
count = Object.freeze({ kind: "estimated", value: totalEstimated, loaded, evidence: "plan-estimate" });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
count =
|
|
528
|
+
loaded > 0
|
|
529
|
+
? Object.freeze({ kind: "partial", loaded, evidence: "loaded-rows" })
|
|
530
|
+
: Object.freeze({ kind: "unknown", loaded, evidence: "none" });
|
|
531
|
+
}
|
|
532
|
+
async function loadWindow(signal) {
|
|
533
|
+
const wanted = new Set();
|
|
534
|
+
const end = Math.max(window.endIndex, window.startIndex + 1);
|
|
535
|
+
for (let index = window.startIndex; index < end; index += 1)
|
|
536
|
+
wanted.add(pageOffsetFor(index));
|
|
537
|
+
for (const offset of [...wanted].sort((left, right) => left - right)) {
|
|
538
|
+
if (currentState() === "unsupported")
|
|
539
|
+
return;
|
|
540
|
+
if (exhausted && typeof totalKnown === "number" && offset >= totalKnown)
|
|
541
|
+
continue;
|
|
542
|
+
await fetchPage(offset, signal);
|
|
543
|
+
if (signal?.aborted)
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
enforceMemoryBudgets(wanted);
|
|
547
|
+
}
|
|
548
|
+
async function run(signal) {
|
|
549
|
+
if (disposed || state === "unsupported")
|
|
550
|
+
return snapshot;
|
|
551
|
+
inFlight?.abort();
|
|
552
|
+
const controller = new AbortController();
|
|
553
|
+
inFlight = controller;
|
|
554
|
+
if (signal) {
|
|
555
|
+
if (signal.aborted)
|
|
556
|
+
controller.abort();
|
|
557
|
+
else
|
|
558
|
+
signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
559
|
+
}
|
|
560
|
+
// A superseded run must not write state either: its `cancelled` verdict
|
|
561
|
+
// would otherwise land after the newer run already published `ready`.
|
|
562
|
+
const runGeneration = generation;
|
|
563
|
+
state = "loading";
|
|
564
|
+
error = undefined;
|
|
565
|
+
publish();
|
|
566
|
+
try {
|
|
567
|
+
await loadWindow(controller.signal);
|
|
568
|
+
if (generation !== runGeneration || disposed)
|
|
569
|
+
return snapshot;
|
|
570
|
+
if (controller.signal.aborted) {
|
|
571
|
+
state = "cancelled";
|
|
572
|
+
message = CANCELLED_MESSAGE;
|
|
573
|
+
}
|
|
574
|
+
else if (currentState() !== "unsupported") {
|
|
575
|
+
const complete = exhausted || (typeof totalKnown === "number" && residentRows() >= totalKnown);
|
|
576
|
+
state = complete ? "ready" : "partial";
|
|
577
|
+
// A budget the run had to give something up for is not an error, but it
|
|
578
|
+
// must not be silently cleared either.
|
|
579
|
+
message = budgetNotice;
|
|
580
|
+
stale = false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch (caught) {
|
|
584
|
+
if (generation !== runGeneration || disposed)
|
|
585
|
+
return snapshot;
|
|
586
|
+
if (isAbort(caught)) {
|
|
587
|
+
state = "cancelled";
|
|
588
|
+
message = CANCELLED_MESSAGE;
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
state = "error";
|
|
592
|
+
error = caught;
|
|
593
|
+
message = caught instanceof Error ? caught.message : "The table query failed.";
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
if (inFlight === controller)
|
|
598
|
+
inFlight = undefined;
|
|
599
|
+
}
|
|
600
|
+
recomputeCount();
|
|
601
|
+
return publish();
|
|
602
|
+
}
|
|
603
|
+
function invalidate() {
|
|
604
|
+
generation += 1;
|
|
605
|
+
pages.clear();
|
|
606
|
+
exhausted = false;
|
|
607
|
+
totalKnown = undefined;
|
|
608
|
+
totalEvidence = "result-total-count";
|
|
609
|
+
totalEstimated = undefined;
|
|
610
|
+
cursorPages = 0;
|
|
611
|
+
void cursorIterator?.return(undefined);
|
|
612
|
+
cursorIterator = undefined;
|
|
613
|
+
// `budgets.maxRequests` bounds one filter/sort/projection identity. Carrying
|
|
614
|
+
// the previous identity's consumption forward would let an exhausted table
|
|
615
|
+
// refuse to load a brand-new question forever, so the per-identity counter
|
|
616
|
+
// and its exhaustion flags reset while the lifetime totals stay intact.
|
|
617
|
+
ledger = Object.freeze({ ...ledger, requests: 0, exhausted: Object.freeze([]) });
|
|
618
|
+
budgetNotice = undefined;
|
|
619
|
+
conflicts = Object.freeze([]);
|
|
620
|
+
if (state !== "unsupported")
|
|
621
|
+
state = "idle";
|
|
622
|
+
}
|
|
623
|
+
function ensureWindow() {
|
|
624
|
+
if (window.endIndex > window.startIndex)
|
|
625
|
+
return;
|
|
626
|
+
window = Object.freeze({ startIndex: 0, endIndex: budgets.pageSize });
|
|
627
|
+
}
|
|
628
|
+
// ── Selection + focus ───────────────────────────────────────
|
|
629
|
+
function setSelection(next) {
|
|
630
|
+
const deduped = [...new Set(next)];
|
|
631
|
+
if (deduped.length === selection.length && deduped.every((key, index) => selection[index] === key))
|
|
632
|
+
return;
|
|
633
|
+
selection = Object.freeze(deduped);
|
|
634
|
+
rememberSelectionTargets();
|
|
635
|
+
publish();
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Keep an exploration target for every selected key, and only for those keys.
|
|
639
|
+
*
|
|
640
|
+
* Resident rows supply their own target; keys learned from
|
|
641
|
+
* {@link HonuaFeatureTable.keysForTargets} keep the target they were resolved
|
|
642
|
+
* from. Pruning to the live selection bounds the index by selection size.
|
|
643
|
+
*/
|
|
644
|
+
function rememberSelectionTargets() {
|
|
645
|
+
for (const key of selection) {
|
|
646
|
+
const row = findRow(key);
|
|
647
|
+
if (row)
|
|
648
|
+
selectionTargetIndex.set(key, row.target);
|
|
649
|
+
}
|
|
650
|
+
for (const key of [...selectionTargetIndex.keys()]) {
|
|
651
|
+
if (!selection.includes(key))
|
|
652
|
+
selectionTargetIndex.delete(key);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* The row key a selection target maps to, or `undefined` when the target
|
|
657
|
+
* belongs to another source.
|
|
658
|
+
*
|
|
659
|
+
* Resolution is arithmetic on identity, not a cache lookup: a target for this
|
|
660
|
+
* source resolves whether or not its page is resident. A bare `FeatureId`
|
|
661
|
+
* target (the single-source exploration form) is treated as this source's.
|
|
662
|
+
*/
|
|
663
|
+
function keyForTarget(target) {
|
|
664
|
+
if (!isSourceQualifiedSelectionTarget(target))
|
|
665
|
+
return featureTableRowKey(options.sourceId, target);
|
|
666
|
+
if (target.sourceId !== options.sourceId)
|
|
667
|
+
return undefined;
|
|
668
|
+
return featureTableRowKey(options.sourceId, target.id);
|
|
669
|
+
}
|
|
670
|
+
async function applyFocusMove(move) {
|
|
671
|
+
const fields = orderedFields();
|
|
672
|
+
if (fields.length === 0)
|
|
673
|
+
return snapshot;
|
|
674
|
+
const current = focus ? findRow(focus.rowKey) : undefined;
|
|
675
|
+
const pageStep = Math.max(1, window.endIndex - window.startIndex);
|
|
676
|
+
const lastIndex = typeof totalKnown === "number" ? Math.max(0, totalKnown - 1) : maxLoadedIndex();
|
|
677
|
+
let absolute = current?.index ?? window.startIndex;
|
|
678
|
+
let field = focus ? Math.max(0, fields.indexOf(focus.field)) : 0;
|
|
679
|
+
switch (move) {
|
|
680
|
+
case "up":
|
|
681
|
+
absolute -= 1;
|
|
682
|
+
break;
|
|
683
|
+
case "down":
|
|
684
|
+
absolute += 1;
|
|
685
|
+
break;
|
|
686
|
+
case "left":
|
|
687
|
+
field -= 1;
|
|
688
|
+
break;
|
|
689
|
+
case "right":
|
|
690
|
+
field += 1;
|
|
691
|
+
break;
|
|
692
|
+
case "row-start":
|
|
693
|
+
field = 0;
|
|
694
|
+
break;
|
|
695
|
+
case "row-end":
|
|
696
|
+
field = fields.length - 1;
|
|
697
|
+
break;
|
|
698
|
+
case "page-up":
|
|
699
|
+
absolute -= pageStep;
|
|
700
|
+
break;
|
|
701
|
+
case "page-down":
|
|
702
|
+
absolute += pageStep;
|
|
703
|
+
break;
|
|
704
|
+
case "grid-start":
|
|
705
|
+
absolute = 0;
|
|
706
|
+
field = 0;
|
|
707
|
+
break;
|
|
708
|
+
case "grid-end":
|
|
709
|
+
absolute = lastIndex;
|
|
710
|
+
field = fields.length - 1;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
absolute = Math.max(0, Math.min(absolute, lastIndex));
|
|
714
|
+
field = Math.max(0, Math.min(field, fields.length - 1));
|
|
715
|
+
if (absolute < window.startIndex || absolute >= window.endIndex) {
|
|
716
|
+
const span = Math.max(1, window.endIndex - window.startIndex);
|
|
717
|
+
const start = Math.max(0, absolute - Math.floor(span / 2));
|
|
718
|
+
window = Object.freeze({ startIndex: start, endIndex: start + span });
|
|
719
|
+
await run(undefined);
|
|
720
|
+
}
|
|
721
|
+
const row = rowAt(absolute);
|
|
722
|
+
const nextField = fields[field];
|
|
723
|
+
if (!row || nextField === undefined)
|
|
724
|
+
return publish();
|
|
725
|
+
focus = Object.freeze({ rowKey: row.key, field: nextField });
|
|
726
|
+
return publish();
|
|
727
|
+
}
|
|
728
|
+
function maxLoadedIndex() {
|
|
729
|
+
let max = 0;
|
|
730
|
+
for (const page of pages.values()) {
|
|
731
|
+
for (const row of page.rows)
|
|
732
|
+
max = Math.max(max, row.index);
|
|
733
|
+
}
|
|
734
|
+
return max;
|
|
735
|
+
}
|
|
736
|
+
// ── Realtime reconciliation (REQ-005) ───────────────────────
|
|
737
|
+
function reconcile(diff) {
|
|
738
|
+
const keyed = {
|
|
739
|
+
changes: diff.changes.map((change) => ({ kind: change.kind, key: change.key, id: change.id })),
|
|
740
|
+
reset: diff.reset,
|
|
741
|
+
...(diff.resetReason ? { resetReason: diff.resetReason } : {}),
|
|
742
|
+
};
|
|
743
|
+
const nextConflicts = [];
|
|
744
|
+
const patched = [];
|
|
745
|
+
const sortFields = new Set(sort.map((entry) => entry.field));
|
|
746
|
+
const selectionResult = reconcileRealtimeKeyedState(new Set(selection), keyed);
|
|
747
|
+
const focusResult = reconcileRealtimeKeyedState(new Set(focus ? [focus.rowKey] : []), keyed);
|
|
748
|
+
const viewportResult = reconcileRealtimeViewport(window, keyed);
|
|
749
|
+
if (diff.reset) {
|
|
750
|
+
// A replacement snapshot / schema change replaces identity wholesale: the
|
|
751
|
+
// cache is dropped and the conflict announced. Sort, filters, and column
|
|
752
|
+
// state are user intent, so they are deliberately preserved.
|
|
753
|
+
const preservedWindow = window;
|
|
754
|
+
invalidate();
|
|
755
|
+
window = preservedWindow;
|
|
756
|
+
selection = Object.freeze([]);
|
|
757
|
+
focus = undefined;
|
|
758
|
+
stale = true;
|
|
759
|
+
if (state !== "unsupported")
|
|
760
|
+
state = "stale";
|
|
761
|
+
const code = diff.resetReason === "schema-changed" ? "schema-changed" : "snapshot-reset";
|
|
762
|
+
message =
|
|
763
|
+
code === "schema-changed"
|
|
764
|
+
? "The source schema changed; refresh the table to rebuild rows."
|
|
765
|
+
: "A replacement snapshot arrived; refresh the table to rebuild rows.";
|
|
766
|
+
nextConflicts.push({ code, message, rowKeys: Object.freeze([]) });
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
for (const change of diff.changes) {
|
|
770
|
+
if (change.kind === "delete") {
|
|
771
|
+
removeRow(change.key);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const existing = findRow(change.key);
|
|
775
|
+
// A create landing outside the resident window is picked up by the next
|
|
776
|
+
// fetch; patching it in would fabricate an ordering position.
|
|
777
|
+
if (!existing)
|
|
778
|
+
continue;
|
|
779
|
+
const attributes = change.record?.feature.attributes;
|
|
780
|
+
if (!attributes)
|
|
781
|
+
continue;
|
|
782
|
+
if (changesSortKey(existing.attributes, attributes, sortFields)) {
|
|
783
|
+
nextConflicts.push({
|
|
784
|
+
code: "sort-key-changed",
|
|
785
|
+
message: "A live update changed a sorted column. The row was patched in place; refresh to re-order the table.",
|
|
786
|
+
rowKeys: Object.freeze([change.key]),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
patchRow(existing, attributes, change.record?.feature.geometry);
|
|
790
|
+
patched.push(change.key);
|
|
791
|
+
}
|
|
792
|
+
if (selectionResult.invalidations.length > 0) {
|
|
793
|
+
selection = Object.freeze([...selectionResult.next]);
|
|
794
|
+
nextConflicts.push({
|
|
795
|
+
code: "selection-invalidated",
|
|
796
|
+
message: "Selected rows were deleted upstream and were dropped from the selection.",
|
|
797
|
+
rowKeys: Object.freeze(selectionResult.invalidations.map((entry) => entry.key)),
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
if (focus && focusResult.invalidations.length > 0) {
|
|
801
|
+
const deleted = focus.rowKey;
|
|
802
|
+
focus = nextFocusAfterDeletion(focus);
|
|
803
|
+
nextConflicts.push({
|
|
804
|
+
code: "focused-row-deleted",
|
|
805
|
+
message: "The focused row was deleted upstream. Focus moved to the next available row.",
|
|
806
|
+
rowKeys: Object.freeze([deleted]),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// Realtime updates can change the serialized size of a resident page.
|
|
811
|
+
// Re-apply the hard cache ceilings after reconciliation so a large delta
|
|
812
|
+
// cannot bypass the same bounded-memory contract as a fetched page.
|
|
813
|
+
enforceMemoryBudgets(new Set());
|
|
814
|
+
if (budgetNotice !== undefined)
|
|
815
|
+
message = budgetNotice;
|
|
816
|
+
recomputeCount();
|
|
817
|
+
conflicts = Object.freeze(nextConflicts.map((conflict) => Object.freeze(conflict)));
|
|
818
|
+
return Object.freeze({
|
|
819
|
+
preserved: nextConflicts.length === 0,
|
|
820
|
+
conflicts,
|
|
821
|
+
...(focus ? { focus } : {}),
|
|
822
|
+
selection,
|
|
823
|
+
sort,
|
|
824
|
+
window: viewportResult.viewport,
|
|
825
|
+
invalidations: Object.freeze([...selectionResult.invalidations, ...focusResult.invalidations]),
|
|
826
|
+
patchedRowKeys: Object.freeze(patched),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function removeRow(key) {
|
|
830
|
+
for (const page of [...pages.values()]) {
|
|
831
|
+
if (!page.rows.some((row) => row.key === key))
|
|
832
|
+
continue;
|
|
833
|
+
const rows = Object.freeze(page.rows.filter((row) => row.key !== key));
|
|
834
|
+
pages.set(page.offset, {
|
|
835
|
+
...page,
|
|
836
|
+
rows,
|
|
837
|
+
bytes: estimateBytes(rows.map((row) => ({ attributes: row.attributes }))),
|
|
838
|
+
});
|
|
839
|
+
if (typeof totalKnown === "number")
|
|
840
|
+
totalKnown = Math.max(0, totalKnown - 1);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function patchRow(row, attributes, geometry) {
|
|
845
|
+
const page = pages.get(pageOffsetFor(row.index));
|
|
846
|
+
if (!page)
|
|
847
|
+
return;
|
|
848
|
+
const next = Object.freeze({
|
|
849
|
+
...row,
|
|
850
|
+
attributes: { ...row.attributes, ...attributes },
|
|
851
|
+
...(geometry !== undefined ? { geometry } : {}),
|
|
852
|
+
});
|
|
853
|
+
const rows = Object.freeze(page.rows.map((candidate) => (candidate.key === row.key ? next : candidate)));
|
|
854
|
+
pages.set(page.offset, {
|
|
855
|
+
...page,
|
|
856
|
+
rows,
|
|
857
|
+
bytes: estimateBytes(rows.map((candidate) => ({ attributes: candidate.attributes }))),
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
function nextFocusAfterDeletion(current) {
|
|
861
|
+
const fallback = loadedRows().find((row) => row.key !== current.rowKey);
|
|
862
|
+
return fallback ? Object.freeze({ rowKey: fallback.key, field: current.field }) : undefined;
|
|
863
|
+
}
|
|
864
|
+
// ── Mutators ────────────────────────────────────────────────
|
|
865
|
+
async function refresh(refreshOptions = {}) {
|
|
866
|
+
invalidate();
|
|
867
|
+
ensureWindow();
|
|
868
|
+
return run(refreshOptions.signal);
|
|
869
|
+
}
|
|
870
|
+
async function setSort(nextSort) {
|
|
871
|
+
sort = Object.freeze([...nextSort]);
|
|
872
|
+
invalidate();
|
|
873
|
+
ensureWindow();
|
|
874
|
+
return run(undefined);
|
|
875
|
+
}
|
|
876
|
+
async function setColumns(nextColumns) {
|
|
877
|
+
const before = (projection() ?? []).join(",");
|
|
878
|
+
columns = resolveColumns(nextColumns);
|
|
879
|
+
if ((projection() ?? []).join(",") === before)
|
|
880
|
+
return publish();
|
|
881
|
+
invalidate();
|
|
882
|
+
ensureWindow();
|
|
883
|
+
return run(undefined);
|
|
884
|
+
}
|
|
885
|
+
async function runExport(request) {
|
|
886
|
+
const fields = orderedFields();
|
|
887
|
+
if (currentState() === "unsupported") {
|
|
888
|
+
return Object.freeze({
|
|
889
|
+
format: request.format,
|
|
890
|
+
content: request.format === "csv" ? toCsv([], fields, columns) : toJson([], fields, columns),
|
|
891
|
+
rowCount: 0,
|
|
892
|
+
truncated: false,
|
|
893
|
+
columns: Object.freeze([...fields]),
|
|
894
|
+
evidence,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
const ceiling = Math.max(0, Math.min(request.maxRows ?? budgets.maxExportRows, budgets.maxExportRows));
|
|
898
|
+
const rows = [];
|
|
899
|
+
let limit = (request.maxRows ?? 0) > budgets.maxExportRows ? "export-rows" : undefined;
|
|
900
|
+
if (request.selectionOnly) {
|
|
901
|
+
const keys = new Set(selection);
|
|
902
|
+
for (const row of loadedRows()) {
|
|
903
|
+
if (keys.has(row.key) && rows.length < ceiling)
|
|
904
|
+
rows.push(row);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
let offset = 0;
|
|
909
|
+
while (rows.length < ceiling) {
|
|
910
|
+
if (ledger.requests >= budgets.maxRequests) {
|
|
911
|
+
markExhausted("requests");
|
|
912
|
+
limit = "requests";
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
const page = await fetchPage(offset, request.signal);
|
|
916
|
+
if (!page || page.rows.length === 0)
|
|
917
|
+
break;
|
|
918
|
+
for (const row of page.rows) {
|
|
919
|
+
if (rows.length < ceiling)
|
|
920
|
+
rows.push(row);
|
|
921
|
+
}
|
|
922
|
+
if (page.rows.length < budgets.pageSize)
|
|
923
|
+
break;
|
|
924
|
+
offset += budgets.pageSize;
|
|
925
|
+
}
|
|
926
|
+
if (rows.length >= ceiling && !exhausted)
|
|
927
|
+
limit ??= "export-rows";
|
|
928
|
+
}
|
|
929
|
+
recomputeCount();
|
|
930
|
+
publish();
|
|
931
|
+
return Object.freeze({
|
|
932
|
+
format: request.format,
|
|
933
|
+
content: request.format === "csv" ? toCsv(rows, fields, columns) : toJson(rows, fields, columns),
|
|
934
|
+
rowCount: rows.length,
|
|
935
|
+
truncated: limit !== undefined,
|
|
936
|
+
...(limit ? { limit } : {}),
|
|
937
|
+
columns: Object.freeze([...fields]),
|
|
938
|
+
evidence,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
get snapshot() {
|
|
943
|
+
return snapshot;
|
|
944
|
+
},
|
|
945
|
+
subscribe(listener) {
|
|
946
|
+
listeners.add(listener);
|
|
947
|
+
return () => {
|
|
948
|
+
listeners.delete(listener);
|
|
949
|
+
};
|
|
950
|
+
},
|
|
951
|
+
refresh,
|
|
952
|
+
async setScroll(metrics) {
|
|
953
|
+
const next = featureTableWindow(metrics, {
|
|
954
|
+
overscan: budgets.windowOverscan,
|
|
955
|
+
...(typeof totalKnown === "number" ? { totalRows: totalKnown } : {}),
|
|
956
|
+
});
|
|
957
|
+
if (next.startIndex === window.startIndex && next.endIndex === window.endIndex && state !== "idle") {
|
|
958
|
+
return snapshot;
|
|
959
|
+
}
|
|
960
|
+
window = next;
|
|
961
|
+
return run(undefined);
|
|
962
|
+
},
|
|
963
|
+
setSort,
|
|
964
|
+
async toggleSort(field, toggleOptions = {}) {
|
|
965
|
+
const existing = sort.find((entry) => entry.field === field);
|
|
966
|
+
const others = toggleOptions.additive ? sort.filter((entry) => entry.field !== field) : [];
|
|
967
|
+
if (existing === undefined)
|
|
968
|
+
return setSort([...others, { field, direction: "asc" }]);
|
|
969
|
+
if ((existing.direction ?? "asc") === "asc")
|
|
970
|
+
return setSort([...others, { field, direction: "desc" }]);
|
|
971
|
+
return setSort(others);
|
|
972
|
+
},
|
|
973
|
+
async setFilters(nextFilters) {
|
|
974
|
+
filters = normalizeFilters(nextFilters);
|
|
975
|
+
invalidate();
|
|
976
|
+
ensureWindow();
|
|
977
|
+
return run(undefined);
|
|
978
|
+
},
|
|
979
|
+
setColumns,
|
|
980
|
+
async setColumnVisibility(field, visible) {
|
|
981
|
+
return setColumns(columns.map((column) => (column.field === field ? { ...column, visible } : { ...column })));
|
|
982
|
+
},
|
|
983
|
+
select(keys, selectOptions = {}) {
|
|
984
|
+
setSelection(selectOptions.replace === false ? [...selection, ...keys] : keys);
|
|
985
|
+
},
|
|
986
|
+
deselect(keys) {
|
|
987
|
+
if (!keys) {
|
|
988
|
+
setSelection([]);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const drop = new Set(keys);
|
|
992
|
+
setSelection(selection.filter((key) => !drop.has(key)));
|
|
993
|
+
},
|
|
994
|
+
keysForTargets(targets) {
|
|
995
|
+
const out = [];
|
|
996
|
+
for (const target of targets) {
|
|
997
|
+
const key = keyForTarget(target);
|
|
998
|
+
if (key === undefined || out.includes(key))
|
|
999
|
+
continue;
|
|
1000
|
+
// Remember the target so a selection made from an off-window key can
|
|
1001
|
+
// still be published back as a target.
|
|
1002
|
+
selectionTargetIndex.set(key, target);
|
|
1003
|
+
out.push(key);
|
|
1004
|
+
}
|
|
1005
|
+
return Object.freeze(out);
|
|
1006
|
+
},
|
|
1007
|
+
selectionTargets() {
|
|
1008
|
+
const out = [];
|
|
1009
|
+
for (const key of selection) {
|
|
1010
|
+
const target = findRow(key)?.target ?? selectionTargetIndex.get(key);
|
|
1011
|
+
if (target !== undefined)
|
|
1012
|
+
out.push(target);
|
|
1013
|
+
}
|
|
1014
|
+
return Object.freeze(out);
|
|
1015
|
+
},
|
|
1016
|
+
setFocus(nextFocus) {
|
|
1017
|
+
focus = nextFocus ? Object.freeze({ ...nextFocus }) : undefined;
|
|
1018
|
+
publish();
|
|
1019
|
+
},
|
|
1020
|
+
moveFocus: applyFocusMove,
|
|
1021
|
+
applyRealtimeDiff(diff) {
|
|
1022
|
+
const outcome = reconcile(diff);
|
|
1023
|
+
publish();
|
|
1024
|
+
return outcome;
|
|
1025
|
+
},
|
|
1026
|
+
markStale(nextMessage) {
|
|
1027
|
+
stale = true;
|
|
1028
|
+
if (state === "ready" || state === "partial")
|
|
1029
|
+
state = "stale";
|
|
1030
|
+
message = nextMessage;
|
|
1031
|
+
publish();
|
|
1032
|
+
},
|
|
1033
|
+
export: runExport,
|
|
1034
|
+
cancel() {
|
|
1035
|
+
const controller = inFlight;
|
|
1036
|
+
inFlight = undefined;
|
|
1037
|
+
controller?.abort();
|
|
1038
|
+
if (state === "loading") {
|
|
1039
|
+
state = "cancelled";
|
|
1040
|
+
message = CANCELLED_MESSAGE;
|
|
1041
|
+
publish();
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
dispose() {
|
|
1045
|
+
disposed = true;
|
|
1046
|
+
inFlight?.abort();
|
|
1047
|
+
inFlight = undefined;
|
|
1048
|
+
void cursorIterator?.return(undefined);
|
|
1049
|
+
cursorIterator = undefined;
|
|
1050
|
+
pages.clear();
|
|
1051
|
+
listeners.clear();
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Wire a bounded table to a shared exploration view controller in **both**
|
|
1057
|
+
* directions (REQ-003).
|
|
1058
|
+
*
|
|
1059
|
+
* Table → context: selection, sort, filters, and the virtualization window
|
|
1060
|
+
* (as the shared `page` slice) are published as intents from the bound view,
|
|
1061
|
+
* so the linked-view preset decides whether peers accept them.
|
|
1062
|
+
*
|
|
1063
|
+
* Context → table: peer changes to selection, sort, visible fields, and filters
|
|
1064
|
+
* are applied to the table. The view controller suppresses self-origin
|
|
1065
|
+
* notifications, and this binding additionally guards re-entry, so no feedback
|
|
1066
|
+
* loop is possible.
|
|
1067
|
+
*
|
|
1068
|
+
* Returns an unsubscribe function; call it on component teardown.
|
|
1069
|
+
*/
|
|
1070
|
+
export function linkFeatureTableToExploration(table, view, options = {}) {
|
|
1071
|
+
const syncSort = options.sort ?? true;
|
|
1072
|
+
const syncFields = options.visibleFields ?? true;
|
|
1073
|
+
const syncFilters = options.filters ?? true;
|
|
1074
|
+
const syncSelection = options.selection ?? true;
|
|
1075
|
+
const syncPage = options.page ?? true;
|
|
1076
|
+
let applying = false;
|
|
1077
|
+
let suppressFilterPublish = 0;
|
|
1078
|
+
/** Filter snapshots requested by inbound assignments; these are suppressed. */
|
|
1079
|
+
const inboundFilterSnapshots = new Set();
|
|
1080
|
+
let queuedFilterPublish = false;
|
|
1081
|
+
const publishedFilterIds = new Set();
|
|
1082
|
+
function publishFilters(snapshot) {
|
|
1083
|
+
const currentFilterIds = new Set();
|
|
1084
|
+
for (const clause of snapshot.filters) {
|
|
1085
|
+
const explorationClause = filterClauseToExplorationClause(clause);
|
|
1086
|
+
if (explorationClause === undefined || clause.enabled === false)
|
|
1087
|
+
continue;
|
|
1088
|
+
currentFilterIds.add(clause.id);
|
|
1089
|
+
publishedFilterIds.add(clause.id);
|
|
1090
|
+
view.setFilter(clause.id, explorationClause);
|
|
1091
|
+
}
|
|
1092
|
+
for (const id of publishedFilterIds) {
|
|
1093
|
+
if (!currentFilterIds.has(id)) {
|
|
1094
|
+
view.clearFilter(id);
|
|
1095
|
+
publishedFilterIds.delete(id);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const unsubscribeContext = view.subscribe(["selection", "sort", "visibleFields", "filters"], (event) => {
|
|
1100
|
+
if (applying)
|
|
1101
|
+
return;
|
|
1102
|
+
applying = true;
|
|
1103
|
+
try {
|
|
1104
|
+
if (syncSelection && event.changedSlices.has("selection")) {
|
|
1105
|
+
table.select(table.keysForTargets(event.state.selection), { replace: true });
|
|
1106
|
+
}
|
|
1107
|
+
if (syncSort && event.changedSlices.has("sort"))
|
|
1108
|
+
void table.setSort(event.state.sort);
|
|
1109
|
+
if (syncFields && event.changedSlices.has("visibleFields")) {
|
|
1110
|
+
const fields = new Set(event.state.visibleFields);
|
|
1111
|
+
if (fields.size > 0) {
|
|
1112
|
+
void table.setColumns(table.snapshot.columns.map((column) => ({ ...column, visible: fields.has(column.field) })));
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (syncFilters && event.changedSlices.has("filters")) {
|
|
1116
|
+
suppressFilterPublish += 1;
|
|
1117
|
+
const inboundRefresh = table.setFilters(Object.entries(event.state.filters).map(([id, clause]) => explorationClauseToFilterClause(id, clause)));
|
|
1118
|
+
// setFilters publishes its loading snapshot synchronously before it
|
|
1119
|
+
// returns. Remember that exact snapshot so completion of this inbound
|
|
1120
|
+
// assignment is not mistaken for a local table edit.
|
|
1121
|
+
const inboundSnapshot = table.snapshot.filters;
|
|
1122
|
+
inboundFilterSnapshots.add(inboundSnapshot);
|
|
1123
|
+
void inboundRefresh.finally(() => {
|
|
1124
|
+
inboundFilterSnapshots.delete(inboundSnapshot);
|
|
1125
|
+
suppressFilterPublish -= 1;
|
|
1126
|
+
if (suppressFilterPublish === 0 && queuedFilterPublish) {
|
|
1127
|
+
queuedFilterPublish = false;
|
|
1128
|
+
if (!inboundFilterSnapshots.has(table.snapshot.filters))
|
|
1129
|
+
publishFilters(table.snapshot);
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
finally {
|
|
1135
|
+
applying = false;
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
let lastSelection = table.snapshot.selection;
|
|
1139
|
+
let lastSort = table.snapshot.sort;
|
|
1140
|
+
let lastWindow = table.snapshot.window;
|
|
1141
|
+
let lastFilters = table.snapshot.filters;
|
|
1142
|
+
const unsubscribeTable = table.subscribe((snapshot) => {
|
|
1143
|
+
const filtersChanged = snapshot.filters !== lastFilters;
|
|
1144
|
+
lastFilters = snapshot.filters;
|
|
1145
|
+
if (applying)
|
|
1146
|
+
return;
|
|
1147
|
+
applying = true;
|
|
1148
|
+
try {
|
|
1149
|
+
if (syncSelection && snapshot.selection !== lastSelection) {
|
|
1150
|
+
lastSelection = snapshot.selection;
|
|
1151
|
+
// Replace only this table's own source. A multi-source workspace's
|
|
1152
|
+
// selections for peer sources are not the table's to drop.
|
|
1153
|
+
const sourceId = snapshot.sourceId;
|
|
1154
|
+
const peers = view.state.selection.filter((target) => isSourceQualifiedSelectionTarget(target) && target.sourceId !== sourceId);
|
|
1155
|
+
view.select([...peers, ...table.selectionTargets()], { replace: true });
|
|
1156
|
+
}
|
|
1157
|
+
if (syncSort && snapshot.sort !== lastSort) {
|
|
1158
|
+
lastSort = snapshot.sort;
|
|
1159
|
+
view.setSort(snapshot.sort);
|
|
1160
|
+
}
|
|
1161
|
+
if (syncPage && snapshot.window !== lastWindow) {
|
|
1162
|
+
lastWindow = snapshot.window;
|
|
1163
|
+
view.setPage({
|
|
1164
|
+
offset: snapshot.window.startIndex,
|
|
1165
|
+
limit: Math.max(0, snapshot.window.endIndex - snapshot.window.startIndex),
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
if (syncFilters && filtersChanged && suppressFilterPublish === 0) {
|
|
1169
|
+
publishFilters(snapshot);
|
|
1170
|
+
}
|
|
1171
|
+
else if (syncFilters && filtersChanged && suppressFilterPublish > 0) {
|
|
1172
|
+
// A local setFilters can start while an inbound refresh is awaiting
|
|
1173
|
+
// I/O. Do not lose that edit when the inbound suppression settles.
|
|
1174
|
+
queuedFilterPublish = true;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
finally {
|
|
1178
|
+
applying = false;
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
return () => {
|
|
1182
|
+
unsubscribeContext();
|
|
1183
|
+
unsubscribeTable();
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Project one exploration filter clause onto a filter-registry clause so the
|
|
1188
|
+
* shared `filters` slice compiles through the same typed pushdown path as
|
|
1189
|
+
* table-owned filters.
|
|
1190
|
+
*/
|
|
1191
|
+
export function explorationClauseToFilterClause(id, clause) {
|
|
1192
|
+
return {
|
|
1193
|
+
id,
|
|
1194
|
+
owner: { kind: "table", id: "honua-feature-table" },
|
|
1195
|
+
field: clause.field,
|
|
1196
|
+
operator: clause.operator,
|
|
1197
|
+
...(clause.value === undefined ? {} : { value: clause.value }),
|
|
1198
|
+
...(clause.appliesTo && clause.appliesTo.length > 0 ? { sourceScope: [...clause.appliesTo] } : {}),
|
|
1199
|
+
effect: "filter",
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Project a table-owned filter back onto the shared exploration filter shape.
|
|
1204
|
+
* Registry-only metadata is intentionally omitted; unsupported clauses (for
|
|
1205
|
+
* example spatial masks without a field/operator pair) remain table-local.
|
|
1206
|
+
*/
|
|
1207
|
+
export function filterClauseToExplorationClause(clause) {
|
|
1208
|
+
if (clause.field === undefined || clause.operator === undefined)
|
|
1209
|
+
return undefined;
|
|
1210
|
+
const policy = clause.valuePolicy;
|
|
1211
|
+
if (policy?.secret === true)
|
|
1212
|
+
return undefined;
|
|
1213
|
+
if (policy?.maxSerializedBytes !== undefined && clause.value !== undefined) {
|
|
1214
|
+
let serialized;
|
|
1215
|
+
try {
|
|
1216
|
+
serialized = JSON.stringify(clause.value);
|
|
1217
|
+
}
|
|
1218
|
+
catch {
|
|
1219
|
+
return undefined;
|
|
1220
|
+
}
|
|
1221
|
+
if (serialized.length > policy.maxSerializedBytes)
|
|
1222
|
+
return undefined;
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
field: clause.field,
|
|
1226
|
+
operator: clause.operator,
|
|
1227
|
+
...(clause.value === undefined ? {} : { value: clause.value }),
|
|
1228
|
+
...(Array.isArray(clause.sourceScope) && clause.sourceScope.length > 0
|
|
1229
|
+
? { appliesTo: [...clause.sourceScope] }
|
|
1230
|
+
: {}),
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
// ── Presentation helpers ──────────────────────────────────────
|
|
1234
|
+
/**
|
|
1235
|
+
* Default cell formatting. Pure presentation (always client work): `null` and
|
|
1236
|
+
* `undefined` render as an empty string rather than the words
|
|
1237
|
+
* `"null"`/`"undefined"`.
|
|
1238
|
+
*/
|
|
1239
|
+
export function formatFeatureTableCell(value, column) {
|
|
1240
|
+
if (column?.format)
|
|
1241
|
+
return column.format(value);
|
|
1242
|
+
if (value === null || value === undefined)
|
|
1243
|
+
return "";
|
|
1244
|
+
if (typeof value === "number")
|
|
1245
|
+
return Number.isFinite(value) ? String(value) : "";
|
|
1246
|
+
if (typeof value === "boolean")
|
|
1247
|
+
return value ? "true" : "false";
|
|
1248
|
+
if (typeof value === "string")
|
|
1249
|
+
return value;
|
|
1250
|
+
return JSON.stringify(value) ?? "";
|
|
1251
|
+
}
|
|
1252
|
+
/** `aria-sort` token for one column, per the WAI-ARIA grid pattern. */
|
|
1253
|
+
export function featureTableAriaSort(field, sort) {
|
|
1254
|
+
const entry = sort.find((candidate) => candidate.field === field);
|
|
1255
|
+
if (!entry)
|
|
1256
|
+
return "none";
|
|
1257
|
+
if (sort.length > 1 && sort[0]?.field !== field)
|
|
1258
|
+
return "other";
|
|
1259
|
+
return (entry.direction ?? "asc") === "asc" ? "ascending" : "descending";
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Screen-reader announcement for the current result truth. Deliberately says
|
|
1263
|
+
* "at least" for partial results instead of presenting a loaded-row count as a
|
|
1264
|
+
* total (REQ-004).
|
|
1265
|
+
*/
|
|
1266
|
+
export function describeFeatureTableCount(count) {
|
|
1267
|
+
switch (count.kind) {
|
|
1268
|
+
case "known":
|
|
1269
|
+
return `${count.value ?? 0} rows`;
|
|
1270
|
+
case "estimated":
|
|
1271
|
+
return `about ${count.value ?? 0} rows (estimated)`;
|
|
1272
|
+
case "partial":
|
|
1273
|
+
return `at least ${count.loaded} rows loaded; total unknown`;
|
|
1274
|
+
default:
|
|
1275
|
+
return "row count unknown";
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* `aria-rowcount` for the grid. Returns `-1` — the ARIA value for "total
|
|
1280
|
+
* unknown" — instead of substituting the loaded-row count (REQ-004).
|
|
1281
|
+
*/
|
|
1282
|
+
export function featureTableAriaRowCount(count) {
|
|
1283
|
+
return count.kind === "known" || count.kind === "estimated" ? (count.value ?? -1) + 1 : -1;
|
|
1284
|
+
}
|
|
1285
|
+
// ── Internals ─────────────────────────────────────────────────
|
|
1286
|
+
function resolveColumns(columns) {
|
|
1287
|
+
return Object.freeze(columns.map((column) => Object.freeze({
|
|
1288
|
+
field: column.field,
|
|
1289
|
+
label: column.label ?? column.field,
|
|
1290
|
+
type: column.type ?? "unknown",
|
|
1291
|
+
visible: column.visible ?? true,
|
|
1292
|
+
sortable: column.sortable ?? true,
|
|
1293
|
+
...(column.format ? { format: column.format } : {}),
|
|
1294
|
+
})));
|
|
1295
|
+
}
|
|
1296
|
+
/** Name an unusable identity value without leaking the attribute payload. */
|
|
1297
|
+
function describeIdentityValue(value) {
|
|
1298
|
+
if (value === undefined)
|
|
1299
|
+
return "absent";
|
|
1300
|
+
if (value === null)
|
|
1301
|
+
return "null";
|
|
1302
|
+
return `a ${Array.isArray(value) ? "array" : typeof value}`;
|
|
1303
|
+
}
|
|
1304
|
+
function normalizeFilters(filters) {
|
|
1305
|
+
if (!filters)
|
|
1306
|
+
return Object.freeze([]);
|
|
1307
|
+
if (Array.isArray(filters))
|
|
1308
|
+
return Object.freeze([...filters]);
|
|
1309
|
+
return Object.freeze([...filters.clauses]);
|
|
1310
|
+
}
|
|
1311
|
+
function estimateBytes(features) {
|
|
1312
|
+
let total = 0;
|
|
1313
|
+
for (const feature of features) {
|
|
1314
|
+
try {
|
|
1315
|
+
total += JSON.stringify(feature.attributes ?? {})?.length ?? 0;
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
// A non-serializable attribute bag contributes no measurable ceiling cost.
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return total * 2; // UTF-16 code units
|
|
1322
|
+
}
|
|
1323
|
+
function changesSortKey(previous, next, sortFields) {
|
|
1324
|
+
if (sortFields.size === 0)
|
|
1325
|
+
return false;
|
|
1326
|
+
const before = previous;
|
|
1327
|
+
const after = next;
|
|
1328
|
+
for (const field of sortFields) {
|
|
1329
|
+
if (field in after && before[field] !== after[field])
|
|
1330
|
+
return true;
|
|
1331
|
+
}
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
function isAbort(error) {
|
|
1335
|
+
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
1336
|
+
}
|
|
1337
|
+
function toCsv(rows, fields, columns) {
|
|
1338
|
+
const byField = new Map(columns.map((column) => [column.field, column]));
|
|
1339
|
+
const lines = [fields.map(csvCell).join(",")];
|
|
1340
|
+
for (const row of rows) {
|
|
1341
|
+
const attributes = row.attributes;
|
|
1342
|
+
lines.push(fields.map((field) => csvCell(formatFeatureTableCell(attributes[field], byField.get(field)))).join(","));
|
|
1343
|
+
}
|
|
1344
|
+
return lines.join("\n");
|
|
1345
|
+
}
|
|
1346
|
+
function csvCell(value) {
|
|
1347
|
+
if (!value.includes(",") && !value.includes('"') && !value.includes("\n"))
|
|
1348
|
+
return value;
|
|
1349
|
+
return `"${value.split('"').join('""')}"`;
|
|
1350
|
+
}
|
|
1351
|
+
function toJson(rows, fields, columns) {
|
|
1352
|
+
const byField = new Map(columns.map((column) => [column.field, column]));
|
|
1353
|
+
return JSON.stringify(rows.map((row) => {
|
|
1354
|
+
const attributes = row.attributes;
|
|
1355
|
+
const out = {};
|
|
1356
|
+
for (const field of fields)
|
|
1357
|
+
out[field] = formatFeatureTableCell(attributes[field], byField.get(field));
|
|
1358
|
+
return { id: row.id, attributes: out };
|
|
1359
|
+
}));
|
|
1360
|
+
}
|