@honua/react 0.1.4-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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `@honua/sdk-js/interactions/declarative` — compiles a standard
3
+ * `interactions[]` block (geospatial-mcp ADR-0030) onto this SDK's existing
4
+ * imperative binding primitives.
5
+ *
6
+ * The standard constrains the *document*, not the dispatcher: "client
7
+ * runtimes implement dispatch by compiling bindings onto whatever imperative
8
+ * primitives they already have". That is exactly what this module does — it
9
+ * owns no event plumbing of its own, only a compile table from the closed
10
+ * (`on.event`, `do.verb`) vocabulary onto `createSelectionHandler`,
11
+ * `createHoverHandler`, `bindTableSelectionToExploration`,
12
+ * `bindFilterControlsToExploration`, and `subscribeExplorationSelector`.
13
+ *
14
+ * Three properties are load-bearing and asserted by the suite:
15
+ *
16
+ * 1. **Validate before binding.** `compileHonuaInteractions` runs the whole
17
+ * block through {@link validateHonuaInteractions} plus reference
18
+ * resolution against the components it was handed. A block with issues
19
+ * binds *nothing* — the standard rejects over-cap and malformed documents
20
+ * rather than truncating them.
21
+ * 2. **Actions never emit events.** A verb-driven state change must not
22
+ * re-enter the dispatcher. Two mechanisms enforce it: every compiled
23
+ * handler is skipped while an action is in flight (`#dispatching`), and
24
+ * exploration-backed subscriptions run with the view controller's default
25
+ * `includeSelf: false`, so a change this compiler originated is never
26
+ * delivered back to this compiler. There is no cycle detection because
27
+ * there can be no cycle.
28
+ * 3. **Unsupported pairs are named, never dropped.** When a binding's
29
+ * (`on.event`, `do.verb`) pair — or the component behind a ref — has no
30
+ * primitive to compile onto, it lands in
31
+ * {@link HonuaCompiledInteractions.unsupported} with the pair spelled out,
32
+ * so a host can surface a real "this runtime cannot honor that binding"
33
+ * message instead of silently rendering an inert app.
34
+ *
35
+ * @experimental The compiler surface tracks geospatial-mcp ADR-0030 while
36
+ * that ADR is Proposed; the component-adapter shapes may gain fields.
37
+ *
38
+ * @module
39
+ */
40
+ import type { SourceId } from "../contract/types.js";
41
+ import type { ExplorationViewController, FilterClause } from "../exploration/types.js";
42
+ import type { InteractiveMap } from "./feature-state.js";
43
+ import { type HonuaInteractionEvent, type HonuaInteractionIssue, type HonuaInteractionVerb } from "./declarative-contract.js";
44
+ export * from "./declarative-contract.js";
45
+ /**
46
+ * A layer addressed as `layer:{id}`. `map` is what the
47
+ * `featureSelect`/`featureHover` primitives bind to; the optional methods are
48
+ * the layer-scoped action targets.
49
+ */
50
+ export interface HonuaInteractionLayerComponent {
51
+ /** MapLibre-style interactive map this layer renders on — the event source for `featureSelect`/`featureHover`. */
52
+ readonly map?: InteractiveMap;
53
+ /** Source id feature-state is written to. Defaults to the ref's id. */
54
+ readonly sourceId?: SourceId;
55
+ /** Rendered layer id clicks/hovers are observed on. Defaults to the ref's id. */
56
+ readonly layerId?: string;
57
+ /** Source layer, for vector-tile sources. */
58
+ readonly sourceLayer?: string;
59
+ /** Select multiple features rather than replacing on each click. @default false */
60
+ readonly multiSelect?: boolean;
61
+ /** `setVisibility` target. */
62
+ setVisibility?(visible: boolean): void;
63
+ /** `setFilter` target. When absent, `setFilter` falls back to the shared exploration filter keyed by this ref's id. */
64
+ setFilter?(clause: FilterClause | undefined): void;
65
+ }
66
+ export interface HonuaInteractionViewport {
67
+ readonly bbox?: readonly [number, number, number, number];
68
+ readonly center?: readonly [number, number];
69
+ readonly zoom?: number;
70
+ readonly pitch?: number;
71
+ readonly bearing?: number;
72
+ }
73
+ /** The `map` component. */
74
+ export interface HonuaInteractionMapComponent {
75
+ /**
76
+ * `setViewport` target. When absent, `setViewport` falls back to the
77
+ * exploration view's `setExtent` for `bbox`-shaped arguments.
78
+ */
79
+ setViewport?(viewport: HonuaInteractionViewport): void;
80
+ }
81
+ /** A widget addressed as `widget:{id}`. */
82
+ export interface HonuaInteractionWidgetComponent {
83
+ /** `runWidgetQuery` target. There is no exploration-level fallback for this verb. */
84
+ runQuery?(request: Readonly<Record<string, unknown>>): void;
85
+ /** `setFilter` target. When absent, falls back to the shared exploration filter keyed by this ref's id. */
86
+ setFilter?(clause: FilterClause | undefined): void;
87
+ /** `setVisibility` target. */
88
+ setVisibility?(visible: boolean): void;
89
+ }
90
+ /**
91
+ * A control addressed as `control:{id}`. A control's `change` event is read
92
+ * off the shared exploration filter slice keyed by the control's id — that is
93
+ * the same seam `bindFilterControlsToExploration` writes through, so a
94
+ * filter bar wired with that primitive needs no extra adapter here.
95
+ */
96
+ export interface HonuaInteractionControlComponent {
97
+ /** `setVisibility` target. */
98
+ setVisibility?(visible: boolean): void;
99
+ /** `setFilter` target. When absent, falls back to the shared exploration filter keyed by this ref's id. */
100
+ setFilter?(clause: FilterClause | undefined): void;
101
+ }
102
+ /**
103
+ * The components an `interactions[]` block's refs resolve against. A ref that
104
+ * resolves to no component is a validation failure (`unresolved-ref`), matching
105
+ * the standard's "an implementation MUST reject a binding whose `ref` does not
106
+ * resolve within the document".
107
+ *
108
+ * `control:` and `widget:` refs may be declared with an empty object when the
109
+ * component needs no adapter — their events and their `setFilter` action both
110
+ * run through the shared exploration view.
111
+ */
112
+ export interface HonuaInteractionComponents {
113
+ readonly map?: HonuaInteractionMapComponent;
114
+ readonly layers?: Readonly<Record<string, HonuaInteractionLayerComponent>>;
115
+ readonly widgets?: Readonly<Record<string, HonuaInteractionWidgetComponent>>;
116
+ readonly controls?: Readonly<Record<string, HonuaInteractionControlComponent>>;
117
+ }
118
+ /** One binding this runtime cannot honor, with the pair named. */
119
+ export interface HonuaUnsupportedInteraction {
120
+ readonly interactionId: string;
121
+ readonly on: {
122
+ readonly ref: string;
123
+ readonly event: HonuaInteractionEvent;
124
+ };
125
+ readonly do: {
126
+ readonly ref: string;
127
+ readonly verb: HonuaInteractionVerb;
128
+ };
129
+ /** `"{event} -> {verb}"`, the pair as the standard spells it. */
130
+ readonly pair: string;
131
+ readonly reason: string;
132
+ }
133
+ /** One binding that compiled onto a primitive. */
134
+ export interface HonuaCompiledInteractionBinding {
135
+ readonly interactionId: string;
136
+ readonly on: {
137
+ readonly ref: string;
138
+ readonly event: HonuaInteractionEvent;
139
+ };
140
+ readonly do: {
141
+ readonly ref: string;
142
+ readonly verb: HonuaInteractionVerb;
143
+ };
144
+ readonly pair: string;
145
+ /** Disposes just this binding. Idempotent. */
146
+ dispose(): void;
147
+ }
148
+ export interface HonuaCompiledInteractions {
149
+ /** True when the block validated and every non-disabled binding compiled. */
150
+ readonly ok: boolean;
151
+ /** Validation failures. When non-empty, nothing was bound. */
152
+ readonly issues: ReadonlyArray<HonuaInteractionIssue>;
153
+ readonly bindings: ReadonlyArray<HonuaCompiledInteractionBinding>;
154
+ readonly unsupported: ReadonlyArray<HonuaUnsupportedInteraction>;
155
+ /** Interaction ids skipped because `disabled: true`. */
156
+ readonly disabled: ReadonlyArray<string>;
157
+ /** Disposes every compiled binding. Idempotent. */
158
+ dispose(): void;
159
+ }
160
+ export interface CompileHonuaInteractionsOptions {
161
+ /**
162
+ * The exploration view this compiler owns — every action verb writes
163
+ * through it, and every exploration-backed event source subscribes on it.
164
+ *
165
+ * **Pass a view bound for the compiler alone** (`ctx.connectView({ id:
166
+ * "interactions", role: "custom" })`), not a view a widget also dispatches
167
+ * through. That is what makes "actions never emit events" structural rather
168
+ * than best-effort: an `ExplorationViewController` delivers a bound view its
169
+ * own notifications only under `includeSelf`, so a state change this
170
+ * compiler originated is never routed back into this compiler, while a real
171
+ * user gesture published by any *other* view is.
172
+ */
173
+ readonly view: ExplorationViewController;
174
+ readonly components?: HonuaInteractionComponents;
175
+ /** Per-(`on.ref`, `on.event`) fan-out cap. @default 8 */
176
+ readonly fanOutCap?: number;
177
+ /**
178
+ * Observes each dispatched action. Called after the verb ran; never called
179
+ * for a suppressed (action-originated) event, which is the whole point of
180
+ * "actions never emit events".
181
+ */
182
+ readonly onDispatch?: (dispatch: HonuaInteractionDispatch) => void;
183
+ }
184
+ export interface HonuaInteractionDispatch {
185
+ readonly interactionId: string;
186
+ readonly event: HonuaInteractionEvent;
187
+ readonly verb: HonuaInteractionVerb;
188
+ readonly ref: string;
189
+ /** Arguments after `$event.*` substitution. */
190
+ readonly args: Readonly<Record<string, unknown>>;
191
+ }
192
+ /**
193
+ * Compiles an `interactions[]` block onto live components. Returns disposers;
194
+ * never throws for a bad document — a malformed or over-cap block comes back
195
+ * as `{ ok: false, issues }` with nothing bound.
196
+ */
197
+ export declare function compileHonuaInteractions(interactions: unknown, options: CompileHonuaInteractionsOptions): HonuaCompiledInteractions;
198
+ //# sourceMappingURL=declarative.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"declarative.d.ts","sourceRoot":"","sources":["../../../src/interactions/declarative.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,470 @@
1
+ /**
2
+ * `@honua/sdk-js/interactions/declarative` — compiles a standard
3
+ * `interactions[]` block (geospatial-mcp ADR-0030) onto this SDK's existing
4
+ * imperative binding primitives.
5
+ *
6
+ * The standard constrains the *document*, not the dispatcher: "client
7
+ * runtimes implement dispatch by compiling bindings onto whatever imperative
8
+ * primitives they already have". That is exactly what this module does — it
9
+ * owns no event plumbing of its own, only a compile table from the closed
10
+ * (`on.event`, `do.verb`) vocabulary onto `createSelectionHandler`,
11
+ * `createHoverHandler`, `bindTableSelectionToExploration`,
12
+ * `bindFilterControlsToExploration`, and `subscribeExplorationSelector`.
13
+ *
14
+ * Three properties are load-bearing and asserted by the suite:
15
+ *
16
+ * 1. **Validate before binding.** `compileHonuaInteractions` runs the whole
17
+ * block through {@link validateHonuaInteractions} plus reference
18
+ * resolution against the components it was handed. A block with issues
19
+ * binds *nothing* — the standard rejects over-cap and malformed documents
20
+ * rather than truncating them.
21
+ * 2. **Actions never emit events.** A verb-driven state change must not
22
+ * re-enter the dispatcher. Two mechanisms enforce it: every compiled
23
+ * handler is skipped while an action is in flight (`#dispatching`), and
24
+ * exploration-backed subscriptions run with the view controller's default
25
+ * `includeSelf: false`, so a change this compiler originated is never
26
+ * delivered back to this compiler. There is no cycle detection because
27
+ * there can be no cycle.
28
+ * 3. **Unsupported pairs are named, never dropped.** When a binding's
29
+ * (`on.event`, `do.verb`) pair — or the component behind a ref — has no
30
+ * primitive to compile onto, it lands in
31
+ * {@link HonuaCompiledInteractions.unsupported} with the pair spelled out,
32
+ * so a host can surface a real "this runtime cannot honor that binding"
33
+ * message instead of silently rendering an inert app.
34
+ *
35
+ * @experimental The compiler surface tracks geospatial-mcp ADR-0030 while
36
+ * that ADR is Proposed; the component-adapter shapes may gain fields.
37
+ *
38
+ * @module
39
+ */
40
+ import { sourceFeatureSelectionTarget } from "../exploration/selection.js";
41
+ import { subscribeExplorationSelector } from "../exploration/selectors.js";
42
+ import { bindFilterControlsToExploration, bindTableSelectionToExploration } from "./exploration-bindings.js";
43
+ import { createHoverHandler, createSelectionHandler } from "./feature-state.js";
44
+ import { parseHonuaInteractionRef, resolveHonuaInteractionArgs, validateHonuaInteractions, } from "./declarative-contract.js";
45
+ export * from "./declarative-contract.js";
46
+ // ── Compiler ──────────────────────────────────────────────────
47
+ const EMPTY_RESULT_BASE = { bindings: [], disabled: [] };
48
+ /**
49
+ * Compiles an `interactions[]` block onto live components. Returns disposers;
50
+ * never throws for a bad document — a malformed or over-cap block comes back
51
+ * as `{ ok: false, issues }` with nothing bound.
52
+ */
53
+ export function compileHonuaInteractions(interactions, options) {
54
+ const validation = validateHonuaInteractions(interactions, {
55
+ ...(options.fanOutCap !== undefined ? { fanOutCap: options.fanOutCap } : {}),
56
+ });
57
+ const components = options.components ?? {};
58
+ const declared = Array.isArray(interactions) ? interactions : [];
59
+ const refIssues = validation.ok ? resolveReferenceIssues(declared, components) : [];
60
+ const issues = [...validation.issues, ...refIssues];
61
+ if (issues.length > 0) {
62
+ return {
63
+ ok: false,
64
+ issues,
65
+ ...EMPTY_RESULT_BASE,
66
+ unsupported: [],
67
+ dispose: () => undefined,
68
+ };
69
+ }
70
+ const compiler = new InteractionCompiler(options, components);
71
+ for (const interaction of declared)
72
+ compiler.compile(interaction);
73
+ return compiler.result();
74
+ }
75
+ function resolveReferenceIssues(interactions, components) {
76
+ const issues = [];
77
+ interactions.forEach((interaction, index) => {
78
+ for (const side of ["on", "do"]) {
79
+ const ref = interaction[side].ref;
80
+ if (resolveComponent(ref, components) !== undefined)
81
+ continue;
82
+ issues.push({
83
+ code: "invalid-ref",
84
+ interactionId: interaction.id,
85
+ path: `interactions[${index}].${side}.ref`,
86
+ message: `\`${ref}\` does not resolve to a declared component.`,
87
+ });
88
+ }
89
+ });
90
+ return issues;
91
+ }
92
+ function resolveComponent(ref, components) {
93
+ const parsed = parseHonuaInteractionRef(ref);
94
+ if (!parsed)
95
+ return undefined;
96
+ if (parsed.kind === "map") {
97
+ return components.map ? { kind: "map", id: "map", component: components.map } : undefined;
98
+ }
99
+ const id = parsed.id;
100
+ const table = {
101
+ layer: components.layers,
102
+ widget: components.widgets,
103
+ control: components.controls,
104
+ };
105
+ const component = table[parsed.kind]?.[id];
106
+ if (!component)
107
+ return undefined;
108
+ return { kind: parsed.kind, id, component };
109
+ }
110
+ class InteractionCompiler {
111
+ #view;
112
+ #components;
113
+ #onDispatch;
114
+ #bindings = [];
115
+ #unsupported = [];
116
+ #disabled = [];
117
+ /** Re-entrancy guard: an event delivered while an action runs is dropped (actions never emit events). */
118
+ #dispatching = false;
119
+ #disposed = false;
120
+ constructor(options, components) {
121
+ this.#view = options.view;
122
+ this.#components = components;
123
+ this.#onDispatch = options.onDispatch;
124
+ }
125
+ compile(interaction) {
126
+ if (interaction.disabled === true) {
127
+ this.#disabled.push(interaction.id);
128
+ return;
129
+ }
130
+ const source = resolveComponent(interaction.on.ref, this.#components);
131
+ const target = resolveComponent(interaction.do.ref, this.#components);
132
+ const action = this.#compileAction(interaction, target);
133
+ if (typeof action === "string") {
134
+ this.#reject(interaction, action);
135
+ return;
136
+ }
137
+ const dispose = this.#compileEvent(interaction, source, action);
138
+ if (typeof dispose === "string") {
139
+ this.#reject(interaction, dispose);
140
+ return;
141
+ }
142
+ let released = false;
143
+ this.#bindings.push({
144
+ interactionId: interaction.id,
145
+ on: { ref: interaction.on.ref, event: interaction.on.event },
146
+ do: { ref: interaction.do.ref, verb: interaction.do.verb },
147
+ pair: pairLabel(interaction),
148
+ dispose: () => {
149
+ if (released)
150
+ return;
151
+ released = true;
152
+ dispose();
153
+ },
154
+ });
155
+ }
156
+ result() {
157
+ const bindings = [...this.#bindings];
158
+ return {
159
+ ok: this.#unsupported.length === 0,
160
+ issues: [],
161
+ bindings,
162
+ unsupported: [...this.#unsupported],
163
+ disabled: [...this.#disabled],
164
+ dispose: () => {
165
+ if (this.#disposed)
166
+ return;
167
+ this.#disposed = true;
168
+ for (const binding of bindings)
169
+ binding.dispose();
170
+ },
171
+ };
172
+ }
173
+ #reject(interaction, reason) {
174
+ this.#unsupported.push({
175
+ interactionId: interaction.id,
176
+ on: { ref: interaction.on.ref, event: interaction.on.event },
177
+ do: { ref: interaction.do.ref, verb: interaction.do.verb },
178
+ pair: pairLabel(interaction),
179
+ reason,
180
+ });
181
+ }
182
+ // ── Event side ──────────────────────────────────────────────
183
+ /** Returns a disposer, or a string reason the pair is unsupported. */
184
+ #compileEvent(interaction, source, action) {
185
+ const emit = (payload) => {
186
+ if (this.#disposed || this.#dispatching)
187
+ return;
188
+ this.#dispatching = true;
189
+ try {
190
+ action(payload);
191
+ }
192
+ finally {
193
+ this.#dispatching = false;
194
+ }
195
+ };
196
+ switch (interaction.on.event) {
197
+ case "featureSelect": {
198
+ if (source.kind !== "layer")
199
+ return `\`featureSelect\` is a layer event; \`${interaction.on.ref}\` is not a layer.`;
200
+ const map = source.component.map;
201
+ if (!map)
202
+ return `Layer "${source.id}" declares no \`map\`, so \`featureSelect\` has no primitive to bind to.`;
203
+ const sourceId = source.component.sourceId ?? source.id;
204
+ const handle = createSelectionHandler(map, {
205
+ source: sourceId,
206
+ layer: source.component.layerId ?? source.id,
207
+ ...(source.component.sourceLayer ? { sourceLayer: source.component.sourceLayer } : {}),
208
+ ...(source.component.multiSelect !== undefined ? { multiSelect: source.component.multiSelect } : {}),
209
+ onSelectionTargetsChange: (targets) => emit(featureSelectPayload(sourceId, targets)),
210
+ });
211
+ return () => handle.remove();
212
+ }
213
+ case "featureHover": {
214
+ if (source.kind !== "layer")
215
+ return `\`featureHover\` is a layer event; \`${interaction.on.ref}\` is not a layer.`;
216
+ const map = source.component.map;
217
+ if (!map)
218
+ return `Layer "${source.id}" declares no \`map\`, so \`featureHover\` has no primitive to bind to.`;
219
+ const sourceId = source.component.sourceId ?? source.id;
220
+ const handle = createHoverHandler(map, {
221
+ source: sourceId,
222
+ layer: source.component.layerId ?? source.id,
223
+ ...(source.component.sourceLayer ? { sourceLayer: source.component.sourceLayer } : {}),
224
+ onHoverTargetChange: (hovered) => emit(featureHoverPayload(sourceId, hovered)),
225
+ });
226
+ return () => handle.remove();
227
+ }
228
+ case "selection": {
229
+ if (source.kind !== "widget")
230
+ return `\`selection\` is a widget event; \`${interaction.on.ref}\` is not a widget.`;
231
+ // Compiled onto the table/grid selection primitive: a widget publishes
232
+ // and observes selection through the shared exploration slice.
233
+ const unsubscribe = bindTableSelectionToExploration(this.#view).subscribe((selection) => emit(selectionPayload(selection)));
234
+ return unsubscribe;
235
+ }
236
+ case "change": {
237
+ if (source.kind !== "control")
238
+ return `\`change\` is a control event; \`${interaction.on.ref}\` is not a control.`;
239
+ // Compiled onto the filter-control primitive, keyed by the control's
240
+ // own id — the same key `bindFilterControlsToExploration().setFilter`
241
+ // writes, so a filter bar built on that primitive needs no adapter.
242
+ const controlId = source.id;
243
+ let previous = this.#view.state.filters[controlId];
244
+ const unsubscribe = bindFilterControlsToExploration(this.#view).subscribe((filters) => {
245
+ const clause = filters[controlId];
246
+ if (clause === previous)
247
+ return;
248
+ previous = clause;
249
+ emit(changePayload(controlId, clause, filters));
250
+ });
251
+ return unsubscribe;
252
+ }
253
+ case "viewportChange": {
254
+ if (source.kind !== "map")
255
+ return "`viewportChange` is a map event; only `map` publishes it.";
256
+ // Compiled onto the shared extent slice — the slice
257
+ // `bindMapExtentToExploration` publishes into.
258
+ const unsubscribe = subscribeExplorationSelector(this.#view, "extent", (state) => state.extent, (extent) => emit(viewportPayload(extent)), { fireImmediately: false });
259
+ return unsubscribe;
260
+ }
261
+ }
262
+ }
263
+ // ── Action side ─────────────────────────────────────────────
264
+ /** Returns an action, or a string reason the pair is unsupported. */
265
+ #compileAction(interaction, target) {
266
+ const verb = interaction.do.verb;
267
+ const declaredArgs = interaction.do.args;
268
+ const observe = (args) => {
269
+ this.#onDispatch?.({
270
+ interactionId: interaction.id,
271
+ event: interaction.on.event,
272
+ verb,
273
+ ref: interaction.do.ref,
274
+ args,
275
+ });
276
+ };
277
+ switch (verb) {
278
+ case "setFilter": {
279
+ const setFilter = componentSetFilter(target);
280
+ const filterId = target.kind === "map" ? "map" : target.id;
281
+ return (payload) => {
282
+ const args = resolveHonuaInteractionArgs(declaredArgs, payload);
283
+ const clause = filterClauseFromArgs(args);
284
+ if (setFilter)
285
+ setFilter(clause);
286
+ else if (clause)
287
+ this.#view.setFilter(filterId, clause);
288
+ else
289
+ this.#view.clearFilter(filterId);
290
+ observe(args);
291
+ };
292
+ }
293
+ case "setViewport": {
294
+ if (target.kind !== "map")
295
+ return "`setViewport` targets `map`; no other component accepts it.";
296
+ const component = target.component;
297
+ return (payload) => {
298
+ const args = resolveHonuaInteractionArgs(declaredArgs, payload);
299
+ const viewport = viewportFromArgs(args);
300
+ if (component.setViewport)
301
+ component.setViewport(viewport);
302
+ else
303
+ this.#view.setExtent(extentFromBbox(viewport.bbox));
304
+ observe(args);
305
+ };
306
+ }
307
+ case "selectFeature": {
308
+ if (target.kind === "control")
309
+ return "`selectFeature` has no control-side primitive.";
310
+ const fallbackSourceId = target.kind === "layer" ? (target.component.sourceId ?? target.id) : target.id;
311
+ const selection = bindTableSelectionToExploration(this.#view);
312
+ return (payload) => {
313
+ const args = resolveHonuaInteractionArgs(declaredArgs, payload);
314
+ const featureId = args.featureId ?? args.id;
315
+ if (featureId === undefined || featureId === null) {
316
+ selection.clearSelection();
317
+ observe(args);
318
+ return;
319
+ }
320
+ const sourceId = typeof args.sourceId === "string" ? args.sourceId : fallbackSourceId;
321
+ const target_ = sourceFeatureSelectionTarget(sourceId, featureId);
322
+ selection.select([target_], { replace: args.replace !== false });
323
+ observe(args);
324
+ };
325
+ }
326
+ case "runWidgetQuery": {
327
+ if (target.kind !== "widget")
328
+ return "`runWidgetQuery` targets `widget:{id}`; no other component accepts it.";
329
+ const runQuery = target.component.runQuery;
330
+ if (!runQuery) {
331
+ return `Widget "${target.id}" declares no \`runQuery\`, and there is no exploration-level fallback for \`runWidgetQuery\`.`;
332
+ }
333
+ return (payload) => {
334
+ const args = resolveHonuaInteractionArgs(declaredArgs, payload);
335
+ runQuery(args);
336
+ observe(args);
337
+ };
338
+ }
339
+ case "setVisibility": {
340
+ if (target.kind === "map")
341
+ return "`setVisibility` targets a layer, widget, or control, never `map`.";
342
+ const setVisibility = target.component.setVisibility;
343
+ if (!setVisibility) {
344
+ return `${target.kind === "layer" ? "Layer" : target.kind === "widget" ? "Widget" : "Control"} "${target.id}" declares no \`setVisibility\`.`;
345
+ }
346
+ return (payload) => {
347
+ const args = resolveHonuaInteractionArgs(declaredArgs, payload);
348
+ setVisibility(args.visible !== false);
349
+ observe(args);
350
+ };
351
+ }
352
+ }
353
+ }
354
+ }
355
+ // ── Payload builders ──────────────────────────────────────────
356
+ function featureSelectPayload(sourceId, targets) {
357
+ const first = targets[0];
358
+ return {
359
+ sourceId: first?.sourceId ?? sourceId,
360
+ ...(first ? { featureId: first.id } : {}),
361
+ ...(first?.sourceLayer ? { sourceLayer: first.sourceLayer } : {}),
362
+ targets,
363
+ count: targets.length,
364
+ };
365
+ }
366
+ function featureHoverPayload(sourceId, hovered) {
367
+ return {
368
+ sourceId: hovered?.sourceId ?? sourceId,
369
+ ...(hovered ? { featureId: hovered.id } : {}),
370
+ ...(hovered?.sourceLayer ? { sourceLayer: hovered.sourceLayer } : {}),
371
+ ...(hovered ? { target: hovered } : {}),
372
+ hovering: hovered !== undefined,
373
+ };
374
+ }
375
+ function selectionPayload(selection) {
376
+ const first = selection[0];
377
+ const qualified = typeof first === "object" && first !== null ? first : undefined;
378
+ return {
379
+ targets: selection,
380
+ count: selection.length,
381
+ ...(qualified ? { sourceId: qualified.sourceId, featureId: qualified.id } : {}),
382
+ ...(qualified === undefined && first !== undefined ? { featureId: first } : {}),
383
+ };
384
+ }
385
+ function changePayload(id, clause, filters) {
386
+ return {
387
+ id,
388
+ ...(clause ? { field: clause.field, operator: clause.operator, value: clause.value, clause } : {}),
389
+ filters,
390
+ };
391
+ }
392
+ function viewportPayload(extent) {
393
+ return {
394
+ ...(extent ? { bbox: [extent.xmin, extent.ymin, extent.xmax, extent.ymax], extent } : {}),
395
+ };
396
+ }
397
+ // ── Argument projection ───────────────────────────────────────
398
+ function componentSetFilter(target) {
399
+ if (target.kind === "map")
400
+ return undefined;
401
+ const setFilter = target.component.setFilter;
402
+ return setFilter ? (clause) => setFilter(clause) : undefined;
403
+ }
404
+ const FILTER_OPERATORS = new Set([
405
+ "=",
406
+ "!=",
407
+ "<",
408
+ "<=",
409
+ ">",
410
+ ">=",
411
+ "in",
412
+ "not-in",
413
+ "between",
414
+ "like",
415
+ "is-null",
416
+ "is-not-null",
417
+ ]);
418
+ function filterClauseFromArgs(args) {
419
+ const declared = args.clause;
420
+ if (declared && typeof declared === "object" && !Array.isArray(declared)) {
421
+ const clause = declared;
422
+ if (typeof clause.field === "string") {
423
+ return {
424
+ field: clause.field,
425
+ operator: FILTER_OPERATORS.has(clause.operator) ? clause.operator : "=",
426
+ value: clause.value,
427
+ ...(Array.isArray(clause.appliesTo) ? { appliesTo: clause.appliesTo } : {}),
428
+ };
429
+ }
430
+ }
431
+ if (typeof args.field !== "string")
432
+ return undefined;
433
+ // A `$event.*` path that resolved to nothing clears the filter rather than
434
+ // installing an `= undefined` clause nothing can translate.
435
+ if (args.value === undefined && !isValuelessOperator(args.operator))
436
+ return undefined;
437
+ return {
438
+ field: args.field,
439
+ operator: FILTER_OPERATORS.has(args.operator) ? args.operator : "=",
440
+ value: args.value,
441
+ ...(Array.isArray(args.appliesTo) ? { appliesTo: args.appliesTo } : {}),
442
+ };
443
+ }
444
+ function isValuelessOperator(operator) {
445
+ return operator === "is-null" || operator === "is-not-null";
446
+ }
447
+ function viewportFromArgs(args) {
448
+ const bbox = numberTuple(args.bbox, 4);
449
+ const center = numberTuple(args.center, 2);
450
+ return {
451
+ ...(bbox ? { bbox } : {}),
452
+ ...(center ? { center } : {}),
453
+ ...(typeof args.zoom === "number" ? { zoom: args.zoom } : {}),
454
+ ...(typeof args.pitch === "number" ? { pitch: args.pitch } : {}),
455
+ ...(typeof args.bearing === "number" ? { bearing: args.bearing } : {}),
456
+ };
457
+ }
458
+ function numberTuple(value, length) {
459
+ if (!Array.isArray(value) || value.length !== length)
460
+ return undefined;
461
+ return value.every((entry) => typeof entry === "number" && Number.isFinite(entry))
462
+ ? value
463
+ : undefined;
464
+ }
465
+ function extentFromBbox(bbox) {
466
+ return bbox ? { xmin: bbox[0], ymin: bbox[1], xmax: bbox[2], ymax: bbox[3] } : undefined;
467
+ }
468
+ function pairLabel(interaction) {
469
+ return `${interaction.on.event} -> ${interaction.do.verb}`;
470
+ }