@honua/app-platform 0.1.2-beta.0 → 0.1.7-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/app/index.d.ts.map +1 -1
- package/app/index.js +4 -0
- 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 +88 -19
- package/contract/types.d.ts.map +1 -1
- package/contract/types.js +4 -0
- 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 +51 -21
- package/core/client.d.ts.map +1 -1
- package/core/client.js +234 -54
- package/core/error-classifications.d.ts +23 -0
- package/core/error-classifications.d.ts.map +1 -1
- package/core/error-classifications.js +23 -0
- package/core/error-code-registry.d.ts +24 -1
- package/core/error-code-registry.d.ts.map +1 -1
- package/core/error-code-registry.js +23 -0
- package/core/error-envelope.js +2 -0
- package/core/errors.d.ts +2 -2
- package/core/errors.d.ts.map +1 -1
- package/core/errors.js +1 -1
- package/core/geoparquet-sql.d.ts +69 -5
- package/core/geoparquet-sql.d.ts.map +1 -1
- package/core/geoparquet-sql.js +327 -10
- 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 +32 -0
- package/core/stac.d.ts.map +1 -1
- package/core/stac.js +158 -23
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +273 -68
- package/core/types.d.ts +138 -11
- 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/interactions/declarative-contract.d.ts +149 -0
- package/interactions/declarative-contract.d.ts.map +1 -0
- package/interactions/declarative-contract.js +293 -0
- package/interactions/declarative.d.ts +198 -0
- package/interactions/declarative.d.ts.map +1 -0
- package/interactions/declarative.js +470 -0
- package/interactions/feature-state.d.ts +7 -0
- package/interactions/feature-state.d.ts.map +1 -1
- package/interactions/feature-state.js +10 -2
- 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/maplibre-target.js +1 -1
- 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/sse.js +7 -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/runtime/style-compose.d.ts.map +1 -1
- package/runtime/style-compose.js +78 -9
- 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,829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<honua-feature-editor>` — the production-tier capability-aware editing and
|
|
3
|
+
* sketch widget (issue #680, parent epic #678).
|
|
4
|
+
*
|
|
5
|
+
* The element is pure presentation over
|
|
6
|
+
* {@link HonuaFeatureEditorWorkflow}: it renders the schema/domain/subtype
|
|
7
|
+
* derived form, the geometry workflow, staged attachments, validation,
|
|
8
|
+
* per-operation availability, failure and conflict state, and turns user input
|
|
9
|
+
* back into workflow calls. It imports no protocol adapter, performs no
|
|
10
|
+
* transport of its own, and never reports a rejected edit as a success.
|
|
11
|
+
*
|
|
12
|
+
* ## Wiring
|
|
13
|
+
* ```ts
|
|
14
|
+
* const workflow = createFeatureEditorWorkflow({ source, subtypes });
|
|
15
|
+
* const editor = document.createElement("honua-feature-editor");
|
|
16
|
+
* editor.workflow = workflow; // element subscribes and renders
|
|
17
|
+
* document.body.append(editor);
|
|
18
|
+
* ```
|
|
19
|
+
* Geometry may additionally be driven from the map through a sketch adapter
|
|
20
|
+
* (`createTerraDrawEditorSketch` in `./feature-editor-sketch.js`) — the element
|
|
21
|
+
* itself never needs the map.
|
|
22
|
+
*
|
|
23
|
+
* ## Accessibility (NFR-001)
|
|
24
|
+
* The entire workflow is reachable without touching the map canvas:
|
|
25
|
+
*
|
|
26
|
+
* - operation, sketch-tool, undo/redo, conflict, and submit affordances are
|
|
27
|
+
* native `<button>`s in labelled `role="group"` toolbars, with `aria-pressed`
|
|
28
|
+
* for the active operation/tool and `aria-disabled` + a visible reason
|
|
29
|
+
* whenever an operation is unavailable;
|
|
30
|
+
* - every field is a labelled native control with `aria-required`,
|
|
31
|
+
* `aria-invalid`, and `aria-describedby` pointing at its own error text;
|
|
32
|
+
* - geometry can be entered, replaced, or cleared entirely from the keyboard
|
|
33
|
+
* through the GeoJSON geometry textarea plus **Apply geometry** / **Clear**;
|
|
34
|
+
* - validation problems and service failures render in `role="alert"` regions,
|
|
35
|
+
* the workflow message in a `role="status" aria-live="polite"` region;
|
|
36
|
+
* - `Escape` cancels the draft and `Ctrl`/`Cmd`+`Z` / `Shift`+`Ctrl`/`Cmd`+`Z`
|
|
37
|
+
* drive undo / redo from anywhere inside the panel;
|
|
38
|
+
* - focus and text selection survive re-renders, so typing is never
|
|
39
|
+
* interrupted by an unrelated realtime change.
|
|
40
|
+
*
|
|
41
|
+
* @module
|
|
42
|
+
*/
|
|
43
|
+
import { renderCspSafeShadowHtml } from "./csp-styles.js";
|
|
44
|
+
const globalDom = globalThis;
|
|
45
|
+
const HTMLElementBase = globalDom.HTMLElement ?? class {
|
|
46
|
+
};
|
|
47
|
+
const OPERATION_LABELS = {
|
|
48
|
+
create: "New",
|
|
49
|
+
update: "Edit",
|
|
50
|
+
delete: "Delete",
|
|
51
|
+
};
|
|
52
|
+
export class HonuaFeatureEditorElement extends HTMLElementBase {
|
|
53
|
+
static get observedAttributes() {
|
|
54
|
+
return ["label"];
|
|
55
|
+
}
|
|
56
|
+
#workflow;
|
|
57
|
+
#subscription;
|
|
58
|
+
#snapshot;
|
|
59
|
+
#connected = false;
|
|
60
|
+
#geometryDraft;
|
|
61
|
+
#geometryError;
|
|
62
|
+
/**
|
|
63
|
+
* The shadow-root keydown listener. Held as a stored reference because it is
|
|
64
|
+
* bound to the shadow root itself, which survives every `innerHTML`
|
|
65
|
+
* re-render — re-adding it per render would stack one handler per render and
|
|
66
|
+
* fire the shortcut N times per keystroke.
|
|
67
|
+
*/
|
|
68
|
+
#keydownListener;
|
|
69
|
+
#messages = {};
|
|
70
|
+
get messages() {
|
|
71
|
+
return this.#messages;
|
|
72
|
+
}
|
|
73
|
+
set messages(messages) {
|
|
74
|
+
this.#messages = messages ?? {};
|
|
75
|
+
this.render();
|
|
76
|
+
}
|
|
77
|
+
/** The workflow this editor renders. Setting it re-subscribes and re-renders. */
|
|
78
|
+
get workflow() {
|
|
79
|
+
return this.#workflow;
|
|
80
|
+
}
|
|
81
|
+
set workflow(workflow) {
|
|
82
|
+
if (this.#workflow === workflow)
|
|
83
|
+
return;
|
|
84
|
+
this.#subscription?.remove();
|
|
85
|
+
this.#subscription = undefined;
|
|
86
|
+
this.#workflow = workflow;
|
|
87
|
+
this.#geometryDraft = undefined;
|
|
88
|
+
this.#geometryError = undefined;
|
|
89
|
+
this.#snapshot = workflow?.snapshot();
|
|
90
|
+
// Only hold a subscription while connected; `connectedCallback` re-takes it
|
|
91
|
+
// so a detached-then-reinserted element resumes updating.
|
|
92
|
+
if (this.#connected)
|
|
93
|
+
this.#subscribe();
|
|
94
|
+
this.render();
|
|
95
|
+
}
|
|
96
|
+
/** Takes the workflow subscription, unless one is already held. */
|
|
97
|
+
#subscribe() {
|
|
98
|
+
const workflow = this.#workflow;
|
|
99
|
+
if (!workflow || this.#subscription)
|
|
100
|
+
return;
|
|
101
|
+
// State may have moved on while detached; re-read before listening.
|
|
102
|
+
this.#snapshot = workflow.snapshot();
|
|
103
|
+
this.#subscription = workflow.subscribe((snapshot) => {
|
|
104
|
+
this.#snapshot = snapshot;
|
|
105
|
+
this.#dispatch("honua-feature-edit-change", { snapshot });
|
|
106
|
+
this.render();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Equivalent to setting `.workflow`. */
|
|
110
|
+
connect(workflow) {
|
|
111
|
+
this.workflow = workflow;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The feature `update` / `delete` drafts open on. Hosts assign this from
|
|
115
|
+
* their own selection state (table row, map click, search result); it is
|
|
116
|
+
* forwarded to the workflow so per-operation availability reflects it
|
|
117
|
+
* immediately. The element never reads the map.
|
|
118
|
+
*/
|
|
119
|
+
get selectedFeature() {
|
|
120
|
+
return this.#workflow?.selection();
|
|
121
|
+
}
|
|
122
|
+
set selectedFeature(feature) {
|
|
123
|
+
this.#workflow?.setSelection(feature);
|
|
124
|
+
}
|
|
125
|
+
/** The last snapshot rendered. */
|
|
126
|
+
get snapshot() {
|
|
127
|
+
return this.#snapshot;
|
|
128
|
+
}
|
|
129
|
+
attributeChangedCallback() {
|
|
130
|
+
this.render();
|
|
131
|
+
}
|
|
132
|
+
connectedCallback() {
|
|
133
|
+
this.#connected = true;
|
|
134
|
+
if (!this.shadowRoot && typeof this.attachShadow === "function")
|
|
135
|
+
this.attachShadow({ mode: "open" });
|
|
136
|
+
this.#subscribe();
|
|
137
|
+
this.#armKeyboard();
|
|
138
|
+
this.render();
|
|
139
|
+
}
|
|
140
|
+
disconnectedCallback() {
|
|
141
|
+
this.#connected = false;
|
|
142
|
+
this.#subscription?.remove();
|
|
143
|
+
this.#subscription = undefined;
|
|
144
|
+
this.#releaseKeyboard();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Attaches the panel-wide keyboard shortcuts once per connection.
|
|
148
|
+
*
|
|
149
|
+
* Deliberately not part of `#bind()`: `#bind()` runs on every render and
|
|
150
|
+
* re-attaches listeners to freshly created children (which the `innerHTML`
|
|
151
|
+
* swap discarded), but the shadow root persists, so a listener added there
|
|
152
|
+
* per render would accumulate. The handler reads `this.#workflow` at event
|
|
153
|
+
* time rather than closing over it, so replacing the workflow never leaves a
|
|
154
|
+
* shortcut driving the previous one.
|
|
155
|
+
*/
|
|
156
|
+
#armKeyboard() {
|
|
157
|
+
const root = this.shadowRoot;
|
|
158
|
+
if (!root || this.#keydownListener)
|
|
159
|
+
return;
|
|
160
|
+
const listener = (event) => {
|
|
161
|
+
const workflow = this.#workflow;
|
|
162
|
+
if (!workflow)
|
|
163
|
+
return;
|
|
164
|
+
const keyboard = event;
|
|
165
|
+
if (keyboard.key === "Escape") {
|
|
166
|
+
workflow.cancel();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if ((keyboard.ctrlKey || keyboard.metaKey) && keyboard.key.toLowerCase() === "z") {
|
|
170
|
+
keyboard.preventDefault();
|
|
171
|
+
if (keyboard.shiftKey)
|
|
172
|
+
workflow.redo();
|
|
173
|
+
else
|
|
174
|
+
workflow.undo();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
root.addEventListener("keydown", listener);
|
|
178
|
+
this.#keydownListener = listener;
|
|
179
|
+
}
|
|
180
|
+
/** Releases the shadow-root keyboard listener. Re-armed by `connectedCallback`. */
|
|
181
|
+
#releaseKeyboard() {
|
|
182
|
+
const listener = this.#keydownListener;
|
|
183
|
+
if (!listener)
|
|
184
|
+
return;
|
|
185
|
+
this.shadowRoot?.removeEventListener("keydown", listener);
|
|
186
|
+
this.#keydownListener = undefined;
|
|
187
|
+
}
|
|
188
|
+
// ── rendering ──────────────────────────────────────────────────────────
|
|
189
|
+
render() {
|
|
190
|
+
const root = this.shadowRoot;
|
|
191
|
+
if (!root || !this.#connected)
|
|
192
|
+
return;
|
|
193
|
+
const focus = captureFocus(root);
|
|
194
|
+
renderCspSafeShadowHtml(root, this.#html());
|
|
195
|
+
this.#bind(root);
|
|
196
|
+
restoreFocus(root, focus);
|
|
197
|
+
}
|
|
198
|
+
#html() {
|
|
199
|
+
const label = this.#messages.label ?? this.#attr("label") ?? "Feature editor";
|
|
200
|
+
const snapshot = this.#snapshot;
|
|
201
|
+
if (!snapshot) {
|
|
202
|
+
return `
|
|
203
|
+
<style>${editorStyles()}</style>
|
|
204
|
+
<section class="panel" part="panel" role="region" aria-label="${escapeAttribute(label)}">
|
|
205
|
+
<div class="bar"><h2>${escapeHtml(label)}</h2></div>
|
|
206
|
+
<p class="empty" role="status">${escapeHtml(this.#messages.noWorkflow ?? "No edit workflow is attached. Set .workflow to an editable source's workflow.")}</p>
|
|
207
|
+
</section>
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
return `
|
|
211
|
+
<style>${editorStyles()}</style>
|
|
212
|
+
<section class="panel" part="panel" role="region" aria-label="${escapeAttribute(label)}">
|
|
213
|
+
<div class="bar">
|
|
214
|
+
<h2>${escapeHtml(label)}</h2>
|
|
215
|
+
<span class="state" data-state>${escapeHtml(this.#messages.status?.[snapshot.status] ?? snapshot.status)}${snapshot.dirty ? ` • ${escapeHtml(this.#messages.unsaved ?? "unsaved")}` : ""}</span>
|
|
216
|
+
</div>
|
|
217
|
+
${this.#operationsHtml(snapshot, label)}
|
|
218
|
+
${this.#conflictHtml(snapshot)}
|
|
219
|
+
${this.#formHtml(snapshot)}
|
|
220
|
+
${this.#geometryHtml(snapshot, label)}
|
|
221
|
+
${this.#attachmentsHtml(snapshot)}
|
|
222
|
+
${this.#problemsHtml(snapshot)}
|
|
223
|
+
<p class="status" part="status" role="status" aria-live="polite" data-status>${escapeHtml(snapshot.message)}</p>
|
|
224
|
+
${this.#actionsHtml(snapshot)}
|
|
225
|
+
</section>
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
#operationsHtml(snapshot, label) {
|
|
229
|
+
const buttons = snapshot.operations
|
|
230
|
+
.map((availability) => {
|
|
231
|
+
const active = snapshot.operation === availability.operation && snapshot.status !== "idle";
|
|
232
|
+
const disabled = availability.available ? "" : ' disabled aria-disabled="true"';
|
|
233
|
+
const title = availability.reason ? ` title="${escapeAttribute(availability.reason)}"` : "";
|
|
234
|
+
return `<button type="button" part="operation" data-operation="${availability.operation}" aria-pressed="${String(active)}"${disabled}${title}>${escapeHtml(this.#messages.operationLabels?.[availability.operation] ?? OPERATION_LABELS[availability.operation])}</button>`;
|
|
235
|
+
})
|
|
236
|
+
.join("");
|
|
237
|
+
const blocked = snapshot.operations.filter((availability) => !availability.available && availability.reason);
|
|
238
|
+
const reasons = blocked.length === 0
|
|
239
|
+
? ""
|
|
240
|
+
: `<ul class="reasons" data-operation-reasons>${blocked
|
|
241
|
+
.map((availability) => `<li data-operation-reason="${availability.operation}" data-code="${escapeAttribute(availability.code ?? "")}">${escapeHtml(`${this.#messages.operationLabels?.[availability.operation] ?? OPERATION_LABELS[availability.operation]}: ${availability.reason}`)}</li>`)
|
|
242
|
+
.join("")}</ul>`;
|
|
243
|
+
return `
|
|
244
|
+
<div class="segmented" role="group" aria-label="${escapeAttribute(this.#messages.operationsLabel?.(label) ?? `${label} operations`)}">${buttons}</div>
|
|
245
|
+
${reasons}
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
#conflictHtml(snapshot) {
|
|
249
|
+
const conflict = snapshot.conflict;
|
|
250
|
+
if (!conflict)
|
|
251
|
+
return "";
|
|
252
|
+
const detail = conflict.versionField && (conflict.mine !== undefined || conflict.theirs !== undefined)
|
|
253
|
+
? `<p class="muted" data-conflict-detail>${escapeHtml(`${conflict.versionField}: yours ${describeToken(conflict.mine)}, theirs ${describeToken(conflict.theirs)}`)}</p>`
|
|
254
|
+
: "";
|
|
255
|
+
const choices = conflict.choices
|
|
256
|
+
.map((choice) => `<button type="button" part="conflict" data-conflict="${choice}">${escapeHtml(this.#messages.conflictChoices?.[choice] ?? CONFLICT_LABELS[choice])}</button>`)
|
|
257
|
+
.join("");
|
|
258
|
+
return `
|
|
259
|
+
<div class="conflict" part="conflict-panel" role="group" aria-label="${escapeAttribute(this.#messages.conflictLabel ?? "Resolve conflicting change")}" data-conflict-panel>
|
|
260
|
+
<p role="alert" data-conflict-reason>${escapeHtml(conflict.reason)}</p>
|
|
261
|
+
${detail}
|
|
262
|
+
<div class="actions">${choices}</div>
|
|
263
|
+
</div>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
#formHtml(snapshot) {
|
|
267
|
+
const form = snapshot.form;
|
|
268
|
+
if (!form)
|
|
269
|
+
return "";
|
|
270
|
+
if (form.controls.length === 0) {
|
|
271
|
+
return `<p class="empty" role="status">${escapeHtml(this.#messages.noFields ?? "This source publishes no editable field metadata.")}</p>`;
|
|
272
|
+
}
|
|
273
|
+
return `
|
|
274
|
+
<fieldset class="fields" part="fields">
|
|
275
|
+
<legend>${escapeHtml(form.subtype?.name ? `Attributes — ${form.subtype.name}` : "Attributes")}</legend>
|
|
276
|
+
${form.controls.map((control) => this.#controlHtml(control)).join("")}
|
|
277
|
+
</fieldset>
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
#controlHtml(control) {
|
|
281
|
+
const id = `honua-field-${cssSafe(control.name)}`;
|
|
282
|
+
const errorId = `${id}-error`;
|
|
283
|
+
const invalid = control.errors.length > 0;
|
|
284
|
+
const described = invalid ? ` aria-describedby="${errorId}"` : "";
|
|
285
|
+
const common = `id="${id}" data-field="${escapeAttribute(control.name)}"` +
|
|
286
|
+
`${control.required ? ' aria-required="true" required' : ""}` +
|
|
287
|
+
`${invalid ? ' aria-invalid="true"' : ""}` +
|
|
288
|
+
`${control.readOnly ? " disabled" : ""}${described}`;
|
|
289
|
+
const value = stringValue(control.value);
|
|
290
|
+
let field;
|
|
291
|
+
if (control.kind === "select") {
|
|
292
|
+
const options = [
|
|
293
|
+
control.required && value === "" ? '<option value="">Select…</option>' : "",
|
|
294
|
+
...(control.choices ?? []).map((choice) => `<option value="${escapeAttribute(choice.value)}"${choice.value === value ? " selected" : ""}>${escapeHtml(choice.label)}</option>`),
|
|
295
|
+
...(control.required ? [] : ['<option value="">(none)</option>']),
|
|
296
|
+
].join("");
|
|
297
|
+
field = `<select ${common}>${options}</select>`;
|
|
298
|
+
}
|
|
299
|
+
else if (control.kind === "checkbox") {
|
|
300
|
+
field = `<input type="checkbox" ${common}${value === "true" ? " checked" : ""} />`;
|
|
301
|
+
}
|
|
302
|
+
else if (control.kind === "textarea") {
|
|
303
|
+
field = `<textarea ${common}${control.maxLength ? ` maxlength="${control.maxLength}"` : ""} rows="3">${escapeHtml(value)}</textarea>`;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const type = control.kind === "number" ? "number" : control.kind === "text" ? "text" : control.kind;
|
|
307
|
+
const bounds = control.kind === "number"
|
|
308
|
+
? `${control.min !== undefined ? ` min="${control.min}"` : ""}${control.max !== undefined ? ` max="${control.max}"` : ""}`
|
|
309
|
+
: "";
|
|
310
|
+
field = `<input type="${type}" ${common} value="${escapeAttribute(value)}"${control.maxLength ? ` maxlength="${control.maxLength}"` : ""}${bounds} />`;
|
|
311
|
+
}
|
|
312
|
+
const hints = [
|
|
313
|
+
control.readOnly ? "read-only" : "",
|
|
314
|
+
control.required ? "required" : control.nullable ? "" : "not nullable",
|
|
315
|
+
control.subtypeField ? "subtype" : "",
|
|
316
|
+
].filter(Boolean);
|
|
317
|
+
return `
|
|
318
|
+
<div class="field" part="field">
|
|
319
|
+
<label for="${id}">${escapeHtml(control.label)}${control.required ? '<span aria-hidden="true"> *</span>' : ""}</label>
|
|
320
|
+
${field}
|
|
321
|
+
${hints.length > 0 ? `<p class="hint">${escapeHtml(hints.join(" • "))}</p>` : ""}
|
|
322
|
+
${invalid ? `<p class="error" id="${errorId}" data-error-for="${escapeAttribute(control.name)}">${escapeHtml(control.errors.join(" "))}</p>` : ""}
|
|
323
|
+
</div>
|
|
324
|
+
`;
|
|
325
|
+
}
|
|
326
|
+
#geometryHtml(snapshot, label) {
|
|
327
|
+
const sketch = snapshot.sketch;
|
|
328
|
+
if (!sketch)
|
|
329
|
+
return "";
|
|
330
|
+
const tools = sketch.tools
|
|
331
|
+
.map((capability) => {
|
|
332
|
+
const disabled = capability.state === "supported" ? "" : ' disabled aria-disabled="true"';
|
|
333
|
+
const title = capability.reason ? ` title="${escapeAttribute(capability.reason)}"` : "";
|
|
334
|
+
return `<button type="button" part="sketch-tool" data-sketch-tool="${capability.tool}" aria-pressed="${String(sketch.activeTool === capability.tool)}"${disabled}${title}>${escapeHtml(this.#messages.toolLabels?.[capability.tool] ?? toolLabel(capability.tool))}</button>`;
|
|
335
|
+
})
|
|
336
|
+
.join("");
|
|
337
|
+
const text = this.#geometryDraft ?? (sketch.geometry ? JSON.stringify(sketch.geometry) : "");
|
|
338
|
+
return `
|
|
339
|
+
<fieldset class="geometry" part="geometry">
|
|
340
|
+
<legend>${escapeHtml(this.#messages.geometryLabel ?? "Geometry")}</legend>
|
|
341
|
+
<div class="segmented" role="group" aria-label="${escapeAttribute(this.#messages.sketchToolsLabel?.(label) ?? `${label} sketch tools`)}">${tools}</div>
|
|
342
|
+
<label for="honua-geometry-json">${escapeHtml(this.#messages.geometryJsonLabel ?? "Geometry (GeoJSON)")}</label>
|
|
343
|
+
<textarea id="honua-geometry-json" data-geometry-json rows="3" spellcheck="false" placeholder='{"type":"Point","coordinates":[0,0]}'>${escapeHtml(text)}</textarea>
|
|
344
|
+
<div class="actions">
|
|
345
|
+
<button type="button" data-action="apply-geometry">${escapeHtml(this.#messages.applyGeometry ?? "Apply geometry")}</button>
|
|
346
|
+
<button type="button" data-action="clear-geometry"${sketch.geometry ? "" : " disabled"}>${escapeHtml(this.#messages.clearGeometry ?? "Clear geometry")}</button>
|
|
347
|
+
</div>
|
|
348
|
+
<p class="hint" data-geometry-state>${escapeHtml(geometryStateText(sketch))}</p>
|
|
349
|
+
${this.#geometryError ? `<p class="error" role="alert" data-geometry-error>${escapeHtml(this.#geometryError)}</p>` : ""}
|
|
350
|
+
</fieldset>
|
|
351
|
+
`;
|
|
352
|
+
}
|
|
353
|
+
#attachmentsHtml(snapshot) {
|
|
354
|
+
if (!snapshot.form)
|
|
355
|
+
return "";
|
|
356
|
+
if (!snapshot.attachmentsSupported) {
|
|
357
|
+
return `
|
|
358
|
+
<fieldset class="attachments" part="attachments">
|
|
359
|
+
<legend>${escapeHtml(this.#messages.attachmentsLabel ?? "Attachments")}</legend>
|
|
360
|
+
<p class="empty" role="status" data-attachments-unsupported>${escapeHtml(this.#messages.attachmentsUnsupported ?? "This source does not support attachments.")}</p>
|
|
361
|
+
</fieldset>
|
|
362
|
+
`;
|
|
363
|
+
}
|
|
364
|
+
const staged = snapshot.attachments.length === 0
|
|
365
|
+
? `<p class="hint">${escapeHtml(this.#messages.noAttachments ?? "No attachments staged.")}</p>`
|
|
366
|
+
: `<ul class="staged" role="list">${snapshot.attachments
|
|
367
|
+
.map((draft) => `<li role="listitem" data-attachment-index="${draft.index}" data-attachment-status="${draft.status}">${escapeHtml(`${draft.name} — ${draft.status}${draft.error ? `: ${draft.error}` : ""}`)}</li>`)
|
|
368
|
+
.join("")}</ul>`;
|
|
369
|
+
return `
|
|
370
|
+
<fieldset class="attachments" part="attachments">
|
|
371
|
+
<legend>${escapeHtml(this.#messages.attachmentsLabel ?? "Attachments")}</legend>
|
|
372
|
+
<label for="honua-attachment-file">${escapeHtml(this.#messages.addAttachment ?? "Add an attachment")}</label>
|
|
373
|
+
<input type="file" id="honua-attachment-file" data-attachment-input />
|
|
374
|
+
${staged}
|
|
375
|
+
</fieldset>
|
|
376
|
+
`;
|
|
377
|
+
}
|
|
378
|
+
#problemsHtml(snapshot) {
|
|
379
|
+
const formErrors = snapshot.form?.formErrors ?? [];
|
|
380
|
+
const fieldErrorCount = (snapshot.form?.controls ?? []).filter((control) => control.errors.length > 0).length;
|
|
381
|
+
const validation = formErrors.length === 0 && fieldErrorCount === 0
|
|
382
|
+
? ""
|
|
383
|
+
: `<div class="problems" role="alert" data-validation>
|
|
384
|
+
<p>${escapeHtml(fieldErrorCount > 0
|
|
385
|
+
? (this.#messages.validation?.(fieldErrorCount) ??
|
|
386
|
+
`${fieldErrorCount} field${fieldErrorCount === 1 ? "" : "s"} need attention before this edit can be sent.`)
|
|
387
|
+
: (this.#messages.validationDraft ?? "This draft cannot be sent yet."))}</p>
|
|
388
|
+
${formErrors.length > 0 ? `<ul>${formErrors.map((message) => `<li>${escapeHtml(message)}</li>`).join("")}</ul>` : ""}
|
|
389
|
+
</div>`;
|
|
390
|
+
const failures = snapshot.failures.length === 0
|
|
391
|
+
? ""
|
|
392
|
+
: `<ul class="problems" role="alert" data-failures>${snapshot.failures
|
|
393
|
+
.map((failure) => `<li data-failure-kind="${failure.kind}">${escapeHtml(`${failure.kind}: ${failure.description}`)}</li>`)
|
|
394
|
+
.join("")}</ul>`;
|
|
395
|
+
return `${validation}${failures}`;
|
|
396
|
+
}
|
|
397
|
+
#actionsHtml(snapshot) {
|
|
398
|
+
const hasDraft = snapshot.form !== undefined;
|
|
399
|
+
const blockedByConflict = snapshot.conflict !== undefined;
|
|
400
|
+
const submitDisabled = !hasDraft || snapshot.busy || blockedByConflict || snapshot.form?.valid === false;
|
|
401
|
+
return `
|
|
402
|
+
<div class="actions" role="group" aria-label="${escapeAttribute(this.#messages.editActionsLabel ?? "Edit actions")}">
|
|
403
|
+
<button type="button" part="submit" data-action="submit"${submitDisabled ? " disabled" : ""}>${escapeHtml(snapshot.operation === "delete"
|
|
404
|
+
? (this.#messages.deleteFeature ?? "Delete feature")
|
|
405
|
+
: (this.#messages.submit ?? "Submit"))}</button>
|
|
406
|
+
<button type="button" data-action="retry"${snapshot.status === "rejected" && !snapshot.busy ? "" : " disabled"}>${escapeHtml(this.#messages.retry ?? "Retry")}</button>
|
|
407
|
+
<button type="button" data-action="undo"${snapshot.undo.canUndo ? "" : " disabled"}>${escapeHtml(this.#messages.undo ?? "Undo")}</button>
|
|
408
|
+
<button type="button" data-action="redo"${snapshot.undo.canRedo ? "" : " disabled"}>${escapeHtml(this.#messages.redo ?? "Redo")}</button>
|
|
409
|
+
<button type="button" data-action="discard"${hasDraft && snapshot.dirty ? "" : " disabled"}>${escapeHtml(this.#messages.revert ?? "Revert")}</button>
|
|
410
|
+
<button type="button" data-action="cancel"${hasDraft && !snapshot.busy ? "" : " disabled"}>${escapeHtml(this.#messages.cancel ?? "Cancel")}</button>
|
|
411
|
+
</div>
|
|
412
|
+
`;
|
|
413
|
+
}
|
|
414
|
+
// ── event binding ──────────────────────────────────────────────────────
|
|
415
|
+
#bind(root) {
|
|
416
|
+
const workflow = this.#workflow;
|
|
417
|
+
if (!workflow)
|
|
418
|
+
return;
|
|
419
|
+
root.querySelectorAll("button[data-operation]").forEach((button) => {
|
|
420
|
+
button.addEventListener("click", () => {
|
|
421
|
+
const operation = button.dataset.operation;
|
|
422
|
+
if (operation)
|
|
423
|
+
this.#dispatchOperation(operation);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
root.querySelectorAll("button[data-conflict]").forEach((button) => {
|
|
427
|
+
button.addEventListener("click", () => {
|
|
428
|
+
const choice = button.dataset.conflict;
|
|
429
|
+
if (choice)
|
|
430
|
+
workflow.resolveConflict(choice);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
root.querySelectorAll("button[data-sketch-tool]").forEach((button) => {
|
|
434
|
+
button.addEventListener("click", () => {
|
|
435
|
+
const tool = button.dataset.sketchTool;
|
|
436
|
+
if (tool)
|
|
437
|
+
workflow.startSketch(tool);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
root.querySelectorAll("[data-field]").forEach((input) => {
|
|
441
|
+
const name = input.dataset.field;
|
|
442
|
+
if (!name)
|
|
443
|
+
return;
|
|
444
|
+
input.addEventListener("change", () => {
|
|
445
|
+
workflow.setValue(name, controlValue(input));
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
root.querySelectorAll("button[data-action]").forEach((button) => {
|
|
449
|
+
button.addEventListener("click", () => {
|
|
450
|
+
void this.#runAction(button.dataset.action ?? "");
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
root.querySelector("[data-attachment-input]")?.addEventListener("change", (event) => {
|
|
454
|
+
const input = event.currentTarget;
|
|
455
|
+
const file = input?.files?.[0];
|
|
456
|
+
if (file)
|
|
457
|
+
workflow.stageAttachment(file, { name: file.name, contentType: file.type || undefined });
|
|
458
|
+
if (input)
|
|
459
|
+
input.value = "";
|
|
460
|
+
});
|
|
461
|
+
const geometry = root.querySelector("[data-geometry-json]");
|
|
462
|
+
geometry?.addEventListener("input", () => {
|
|
463
|
+
this.#geometryDraft = geometry.value;
|
|
464
|
+
});
|
|
465
|
+
// No shadow-root-level listener here: those survive the `innerHTML` swap
|
|
466
|
+
// and would stack per render. See `#armKeyboard()`.
|
|
467
|
+
}
|
|
468
|
+
#dispatchOperation(operation) {
|
|
469
|
+
const workflow = this.#workflow;
|
|
470
|
+
if (!workflow)
|
|
471
|
+
return;
|
|
472
|
+
this.#geometryDraft = undefined;
|
|
473
|
+
this.#geometryError = undefined;
|
|
474
|
+
// `update` / `delete` open on the workflow's recorded selection, which the
|
|
475
|
+
// host feeds through `.selectedFeature` — the element never reaches into
|
|
476
|
+
// the map itself.
|
|
477
|
+
workflow.begin(operation);
|
|
478
|
+
}
|
|
479
|
+
async #runAction(action) {
|
|
480
|
+
const workflow = this.#workflow;
|
|
481
|
+
if (!workflow)
|
|
482
|
+
return;
|
|
483
|
+
if (action === "submit" || action === "retry") {
|
|
484
|
+
const commit = action === "retry" ? await workflow.retry() : await workflow.submit();
|
|
485
|
+
this.#dispatch("honua-feature-edit-commit", { commit });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (action === "undo") {
|
|
489
|
+
workflow.undo();
|
|
490
|
+
this.#geometryDraft = undefined;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (action === "redo") {
|
|
494
|
+
workflow.redo();
|
|
495
|
+
this.#geometryDraft = undefined;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (action === "discard") {
|
|
499
|
+
workflow.discardChanges();
|
|
500
|
+
this.#geometryDraft = undefined;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (action === "cancel") {
|
|
504
|
+
workflow.cancel();
|
|
505
|
+
this.#geometryDraft = undefined;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (action === "clear-geometry") {
|
|
509
|
+
this.#geometryDraft = undefined;
|
|
510
|
+
this.#geometryError = undefined;
|
|
511
|
+
workflow.setGeometry(this.#activeTool(), null);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (action === "apply-geometry")
|
|
515
|
+
this.#applyGeometryFromText();
|
|
516
|
+
}
|
|
517
|
+
#applyGeometryFromText() {
|
|
518
|
+
const workflow = this.#workflow;
|
|
519
|
+
if (!workflow)
|
|
520
|
+
return;
|
|
521
|
+
const text = (this.#geometryDraft ?? "").trim();
|
|
522
|
+
if (text === "") {
|
|
523
|
+
this.#geometryError = "Enter a GeoJSON geometry, or use Clear geometry.";
|
|
524
|
+
this.render();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
let parsed;
|
|
528
|
+
try {
|
|
529
|
+
parsed = JSON.parse(text);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
this.#geometryError = "That is not valid JSON.";
|
|
533
|
+
this.render();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (!isEditableGeometry(parsed)) {
|
|
537
|
+
this.#geometryError =
|
|
538
|
+
'A geometry needs a GeoJSON "type" (for example {"type":"Point","coordinates":[0,0]}) or an Esri x/y, rings, or paths shape.';
|
|
539
|
+
this.render();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.#geometryError = undefined;
|
|
543
|
+
this.#geometryDraft = undefined;
|
|
544
|
+
workflow.setGeometry(this.#activeTool(parsed), parsed);
|
|
545
|
+
}
|
|
546
|
+
#activeTool(geometry) {
|
|
547
|
+
const snapshot = this.#snapshot;
|
|
548
|
+
const active = snapshot?.sketch?.activeTool;
|
|
549
|
+
if (active)
|
|
550
|
+
return active;
|
|
551
|
+
const inferred = geometry ? inferSketchTool(geometry) : undefined;
|
|
552
|
+
if (inferred)
|
|
553
|
+
return inferred;
|
|
554
|
+
const supported = snapshot?.sketch?.tools.find((capability) => capability.state === "supported");
|
|
555
|
+
return supported?.tool ?? "point";
|
|
556
|
+
}
|
|
557
|
+
#dispatch(name, detail) {
|
|
558
|
+
if (!globalDom.CustomEvent || typeof this.dispatchEvent !== "function")
|
|
559
|
+
return;
|
|
560
|
+
this.dispatchEvent(new globalDom.CustomEvent(name, { bubbles: true, composed: true, detail }));
|
|
561
|
+
}
|
|
562
|
+
#attr(name) {
|
|
563
|
+
return typeof this.getAttribute === "function" ? this.getAttribute(name) : null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** Registers `<honua-feature-editor>`; skipped when the tag is already defined. */
|
|
567
|
+
export function defineHonuaFeatureEditor(registry = globalDom.customElements) {
|
|
568
|
+
if (!registry)
|
|
569
|
+
return;
|
|
570
|
+
if (!registry.get("honua-feature-editor")) {
|
|
571
|
+
registry.define("honua-feature-editor", HonuaFeatureEditorElement);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
575
|
+
const CONFLICT_LABELS = {
|
|
576
|
+
"keep-mine": "Keep my changes",
|
|
577
|
+
"discard-mine": "Discard my changes",
|
|
578
|
+
reload: "Reload the feature",
|
|
579
|
+
};
|
|
580
|
+
function toolLabel(tool) {
|
|
581
|
+
return tool.charAt(0).toUpperCase() + tool.slice(1);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Whether a parsed geometry may be handed back to the workflow.
|
|
585
|
+
*
|
|
586
|
+
* The contract carries protocol-shaped geometry (`Record<string, unknown>`), so
|
|
587
|
+
* a GeoServices source round-trips Esri geometry, not GeoJSON. Requiring a
|
|
588
|
+
* GeoJSON `type` here would make the element render a geometry in the textarea
|
|
589
|
+
* that **Apply geometry** then refuses — which also strands the keyboard-only
|
|
590
|
+
* geometry path (NFR-001) on every Esri-backed source.
|
|
591
|
+
*/
|
|
592
|
+
function isEditableGeometry(value) {
|
|
593
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
594
|
+
return false;
|
|
595
|
+
const candidate = value;
|
|
596
|
+
if (typeof candidate.type === "string")
|
|
597
|
+
return true;
|
|
598
|
+
if (typeof candidate.x === "number" && typeof candidate.y === "number")
|
|
599
|
+
return true;
|
|
600
|
+
return Array.isArray(candidate.rings) || Array.isArray(candidate.paths) || Array.isArray(candidate.points);
|
|
601
|
+
}
|
|
602
|
+
/** Best-effort tool for a geometry, across GeoJSON and Esri shapes. */
|
|
603
|
+
function inferSketchTool(geometry) {
|
|
604
|
+
const type = typeof geometry.type === "string" ? geometry.type.toLowerCase() : "";
|
|
605
|
+
if (type.includes("polygon"))
|
|
606
|
+
return "polygon";
|
|
607
|
+
if (type.includes("line"))
|
|
608
|
+
return "line";
|
|
609
|
+
if (type.includes("point"))
|
|
610
|
+
return "point";
|
|
611
|
+
if (Array.isArray(geometry.rings))
|
|
612
|
+
return "polygon";
|
|
613
|
+
if (Array.isArray(geometry.paths))
|
|
614
|
+
return "line";
|
|
615
|
+
if (Array.isArray(geometry.points) || (typeof geometry.x === "number" && typeof geometry.y === "number")) {
|
|
616
|
+
return "point";
|
|
617
|
+
}
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
function geometryStateText(sketch) {
|
|
621
|
+
const snapping = sketch.snapping.enabled ? `snapping on (${sketch.snapping.tolerance}px)` : "snapping off";
|
|
622
|
+
if (sketch.status === "sketching")
|
|
623
|
+
return `Sketching with ${sketch.activeTool ?? "a tool"} — ${snapping}`;
|
|
624
|
+
if (sketch.geometry === null)
|
|
625
|
+
return `Geometry cleared — ${snapping}`;
|
|
626
|
+
if (sketch.geometry === undefined)
|
|
627
|
+
return `No geometry yet — ${snapping}`;
|
|
628
|
+
return `Geometry ready — ${snapping}`;
|
|
629
|
+
}
|
|
630
|
+
function describeToken(token) {
|
|
631
|
+
if (token === undefined)
|
|
632
|
+
return "unknown";
|
|
633
|
+
if (token === null)
|
|
634
|
+
return "none";
|
|
635
|
+
return String(token);
|
|
636
|
+
}
|
|
637
|
+
function stringValue(value) {
|
|
638
|
+
if (value === undefined || value === null)
|
|
639
|
+
return "";
|
|
640
|
+
if (typeof value === "boolean")
|
|
641
|
+
return String(value);
|
|
642
|
+
if (typeof value === "object")
|
|
643
|
+
return JSON.stringify(value);
|
|
644
|
+
return String(value);
|
|
645
|
+
}
|
|
646
|
+
function controlValue(input) {
|
|
647
|
+
const candidate = input;
|
|
648
|
+
if (candidate.type === "checkbox")
|
|
649
|
+
return candidate.checked === true;
|
|
650
|
+
return candidate.value ?? null;
|
|
651
|
+
}
|
|
652
|
+
function captureFocus(root) {
|
|
653
|
+
const active = root.activeElement;
|
|
654
|
+
if (!active?.id)
|
|
655
|
+
return undefined;
|
|
656
|
+
return {
|
|
657
|
+
id: active.id,
|
|
658
|
+
...(typeof active.selectionStart === "number" ? { selectionStart: active.selectionStart } : {}),
|
|
659
|
+
...(typeof active.selectionEnd === "number" ? { selectionEnd: active.selectionEnd } : {}),
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function restoreFocus(root, memento) {
|
|
663
|
+
if (!memento)
|
|
664
|
+
return;
|
|
665
|
+
const next = root.querySelector(`#${cssEscape(memento.id)}`);
|
|
666
|
+
if (!next || typeof next.focus !== "function")
|
|
667
|
+
return;
|
|
668
|
+
next.focus();
|
|
669
|
+
if (memento.selectionStart !== undefined && typeof next.setSelectionRange === "function") {
|
|
670
|
+
try {
|
|
671
|
+
next.setSelectionRange(memento.selectionStart, memento.selectionEnd ?? memento.selectionStart);
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
// Controls without a text selection model (checkbox, select) throw here.
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** Field names become element ids; keep them selector-safe without a regex backtrack. */
|
|
679
|
+
function cssSafe(value) {
|
|
680
|
+
let out = "";
|
|
681
|
+
for (const char of value) {
|
|
682
|
+
out += /[A-Za-z0-9_-]/.test(char) ? char : "_";
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
function cssEscape(value) {
|
|
687
|
+
return cssSafe(value);
|
|
688
|
+
}
|
|
689
|
+
function escapeHtml(value) {
|
|
690
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
691
|
+
}
|
|
692
|
+
function escapeAttribute(value) {
|
|
693
|
+
return escapeHtml(value).replace(/'/g, "'");
|
|
694
|
+
}
|
|
695
|
+
/** Structural styles, themable through the shared `--honua-ui-*` properties. */
|
|
696
|
+
function editorStyles() {
|
|
697
|
+
return `
|
|
698
|
+
:host {
|
|
699
|
+
--honua-ui-bg: #ffffff;
|
|
700
|
+
--honua-ui-fg: #172033;
|
|
701
|
+
--honua-ui-muted: #667085;
|
|
702
|
+
--honua-ui-border: #d0d5dd;
|
|
703
|
+
--honua-ui-accent: #1d4ed8;
|
|
704
|
+
--honua-ui-accent-fg: #ffffff;
|
|
705
|
+
--honua-ui-danger: #b42318;
|
|
706
|
+
box-sizing: border-box;
|
|
707
|
+
color: var(--honua-ui-fg);
|
|
708
|
+
display: block;
|
|
709
|
+
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
710
|
+
}
|
|
711
|
+
*, *::before, *::after { box-sizing: inherit; }
|
|
712
|
+
.panel {
|
|
713
|
+
background: var(--honua-ui-bg);
|
|
714
|
+
border: 1px solid var(--honua-ui-border);
|
|
715
|
+
border-radius: 8px;
|
|
716
|
+
display: grid;
|
|
717
|
+
gap: 10px;
|
|
718
|
+
min-width: 240px;
|
|
719
|
+
padding: 12px;
|
|
720
|
+
}
|
|
721
|
+
.bar { align-items: center; display: flex; gap: 8px; justify-content: space-between; }
|
|
722
|
+
h2 { font-size: 14px; margin: 0; }
|
|
723
|
+
.state { color: var(--honua-ui-muted); font-size: 12px; }
|
|
724
|
+
button {
|
|
725
|
+
background: var(--honua-ui-bg);
|
|
726
|
+
border: 1px solid var(--honua-ui-border);
|
|
727
|
+
border-radius: 6px;
|
|
728
|
+
color: inherit;
|
|
729
|
+
cursor: pointer;
|
|
730
|
+
font: inherit;
|
|
731
|
+
min-height: 32px;
|
|
732
|
+
padding: 0 10px;
|
|
733
|
+
}
|
|
734
|
+
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
|
735
|
+
button[aria-pressed="true"] {
|
|
736
|
+
background: var(--honua-ui-accent);
|
|
737
|
+
border-color: var(--honua-ui-accent);
|
|
738
|
+
color: var(--honua-ui-accent-fg);
|
|
739
|
+
}
|
|
740
|
+
.segmented { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
741
|
+
.actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
742
|
+
fieldset {
|
|
743
|
+
border: 1px solid var(--honua-ui-border);
|
|
744
|
+
border-radius: 6px;
|
|
745
|
+
display: grid;
|
|
746
|
+
gap: 8px;
|
|
747
|
+
margin: 0;
|
|
748
|
+
padding: 8px 10px 10px;
|
|
749
|
+
}
|
|
750
|
+
legend { color: var(--honua-ui-muted); font-size: 12px; padding: 0 4px; }
|
|
751
|
+
label { display: block; font-size: 12px; font-weight: 600; }
|
|
752
|
+
input, select, textarea {
|
|
753
|
+
border: 1px solid var(--honua-ui-border);
|
|
754
|
+
border-radius: 6px;
|
|
755
|
+
color: inherit;
|
|
756
|
+
font: inherit;
|
|
757
|
+
min-height: 32px;
|
|
758
|
+
padding: 4px 8px;
|
|
759
|
+
width: 100%;
|
|
760
|
+
}
|
|
761
|
+
input[type="checkbox"] { min-height: 0; width: auto; }
|
|
762
|
+
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; }
|
|
763
|
+
[aria-invalid="true"] { border-color: var(--honua-ui-danger); }
|
|
764
|
+
.field { display: grid; gap: 4px; }
|
|
765
|
+
.hint { color: var(--honua-ui-muted); font-size: 12px; margin: 0; }
|
|
766
|
+
.empty { color: var(--honua-ui-muted); margin: 0; }
|
|
767
|
+
.error { color: var(--honua-ui-danger); font-size: 12px; margin: 0; }
|
|
768
|
+
.status { margin: 0; }
|
|
769
|
+
.reasons, .staged { display: grid; gap: 4px; font-size: 12px; list-style: none; margin: 0; padding: 0; }
|
|
770
|
+
.reasons li { color: var(--honua-ui-muted); }
|
|
771
|
+
.problems {
|
|
772
|
+
background: rgba(180, 35, 24, 0.06);
|
|
773
|
+
border: 1px solid var(--honua-ui-danger);
|
|
774
|
+
border-radius: 6px;
|
|
775
|
+
color: var(--honua-ui-danger);
|
|
776
|
+
display: grid;
|
|
777
|
+
gap: 4px;
|
|
778
|
+
list-style: none;
|
|
779
|
+
margin: 0;
|
|
780
|
+
padding: 8px 10px;
|
|
781
|
+
}
|
|
782
|
+
.problems p, .problems ul { margin: 0; }
|
|
783
|
+
.conflict {
|
|
784
|
+
border: 1px solid var(--honua-ui-danger);
|
|
785
|
+
border-radius: 6px;
|
|
786
|
+
display: grid;
|
|
787
|
+
gap: 6px;
|
|
788
|
+
padding: 8px 10px;
|
|
789
|
+
}
|
|
790
|
+
.conflict p { margin: 0; }
|
|
791
|
+
@media (max-width: 320px) {
|
|
792
|
+
.panel { min-width: 0; }
|
|
793
|
+
.bar { align-items: flex-start; flex-direction: column; }
|
|
794
|
+
.segmented, .actions { width: 100%; }
|
|
795
|
+
.segmented button, .actions button { flex: 1 1 120px; min-width: 0; }
|
|
796
|
+
}
|
|
797
|
+
@media (forced-colors: active), (prefers-contrast: more) {
|
|
798
|
+
:host {
|
|
799
|
+
forced-color-adjust: auto;
|
|
800
|
+
}
|
|
801
|
+
button[aria-pressed="true"] {
|
|
802
|
+
background: Highlight;
|
|
803
|
+
border-color: Highlight;
|
|
804
|
+
color: HighlightText;
|
|
805
|
+
outline: 2px solid Highlight;
|
|
806
|
+
outline-offset: 1px;
|
|
807
|
+
}
|
|
808
|
+
button:disabled {
|
|
809
|
+
border-style: dashed;
|
|
810
|
+
color: GrayText;
|
|
811
|
+
opacity: 1;
|
|
812
|
+
}
|
|
813
|
+
input, select, textarea {
|
|
814
|
+
background: Canvas;
|
|
815
|
+
border-color: CanvasText;
|
|
816
|
+
color: CanvasText;
|
|
817
|
+
}
|
|
818
|
+
[aria-invalid="true"] {
|
|
819
|
+
border-color: Mark;
|
|
820
|
+
outline: 1px solid Mark;
|
|
821
|
+
}
|
|
822
|
+
.problems {
|
|
823
|
+
background: Canvas;
|
|
824
|
+
border-color: Mark;
|
|
825
|
+
color: Mark;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
`;
|
|
829
|
+
}
|