Cross-widget actions
Widgets on a view can drive one another. A source widget emits a payload on a trigger; the runtime routes it to every wired target widget's inbox, keyed by the action's effect. Targets read their inbox and react.
Source: src/widgets/actions/
The wiring model
Action wiring is stored on the source widget instance as
WidgetInstance.actions (see
src/workspaces/types.ts):
interface WidgetAction {
id: string;
trigger: WidgetActionTrigger; // "map:extent-change" | "map:layer-click" | "chat:command" | "list:item-click" | "table:row-select"
effect: WidgetActionEffect; // "filter" | "show-feature" | "map-command" | "link-chart-command" | "zoom" | "pan" | "flash"
targetWidgetIds: string[];
enabled: boolean;
}
| Trigger | Emitted by | Typical effect |
|---|---|---|
map:extent-change | Map widget when the view settles after pan/zoom | filter a Table or List |
map:layer-click | Map widget when a feature is clicked | show-feature in a Details widget |
chat:command | AI Chat widget when an assistant turn carries commands | map-command on a Map or 3D Scene, or link-chart-command on a Link Chart |
list:item-click | List widget when an item is clicked | zoom or pan a Map or 3D Scene to the item's feature |
table:row-select | Table widget when a row is selected | zoom, pan, or flash a Map or 3D Scene for the row's feature |
Runtime flow
ActionsProvider wraps a view and holds the inbox:
interface ActionsProviderProps {
widgets: WidgetInstance[]; // the current view's widgets (holds the wiring)
children: React.ReactNode;
}
The inbox is shaped targetWidgetId -> effect -> latest payload. The provider
also prunes inbox entries whose target/effect is no longer wired (widget removed
or action disabled), so stale payloads never keep filtering a target.
Hooks
useActionEmitter()
Source widgets push payloads. Returns a stable emitter; it is a no-op when
used outside an ActionsProvider.
const emit = useActionEmitter();
// emit(sourceWidgetId, trigger, payload)
emit(widgetId, "map:extent-change", { extent, center });
useActionInbox(widgetId, effect)
Target widgets read the latest payload addressed to them for a given effect, or
null if none.
const filter = useActionInbox(widgetId, "filter") as MapExtentPayload | null;
Payload types
Payloads are plain JSON, decoupled from live ArcGIS instances (src/widgets/actions/types.ts):
interface MapExtentPayload {
extent: ExtentJson; // xmin/ymin/xmax/ymax (+ spatialReference)
center: CenterJson; // x/y (+ longitude/latitude when available)
}
interface LayerClickPayload {
layerId: string;
layerTitle?: string;
attributes: Record<string, unknown>;
}
The list:item-click and table:row-select triggers carry a
FeatureNavPayload: the feature's geometry reduced for cross-widget use, plus a
nonce that is unique per click/selection so each consumer applies its effect
exactly once (the inbox only keeps the latest payload):
interface FeatureNavPayload {
nonce: string;
point?: { x; y; spatialReference? }; // point features
extent?: ExtentJson; // line / polygon / multipoint bounding box (zoom/pan)
shape?: GeometryJson; // full geometry JSON for high-fidelity flash outlines
flash?: FlashStyle; // highlight color/size/count/speed for the flash effect
}
A single item click or row selection emits once; the runtime fans the same
payload out to whichever effects are wired, so a List or Table can zoom,
pan, and/or flash a map from the one interaction. The Map/Scene consumer
reads useActionInbox(widgetId, 'zoom' | 'pan' | 'flash'). For navigation it
calls view.goTo: zoom frames the feature (a point uses a fixed padding scale
so it never snaps to the deepest level), while pan recenters without changing
the scale; when both are wired to the same map, zoom wins for that click.
flash briefly pulses a highlight on the feature: a solid core on the feature
itself under a soft, borderless pulse that radiates off it (a point flashes from
its center; a polygon or line's pulse extends off its edge). It traces the true
geometry when shape is present, otherwise a point marker or bounding box,
respects the OS reduced-motion setting, and cancels any in-flight flash on the
same view. The highlight's color, size, pulse count, and per-pulse speed come
from the source widget's configured FlashStyle carried in payload.flash (the
Table exposes these under Flash appearance once a flash target is wired;
count is 1–5 pulses and speed is slow, normal, or fast, so the total
animation time is count × per-flash duration).
Navigating the live view also re-fires the map's own
map:extent-change, so any extent-wired filter targets update for free.
The chat:command trigger carries an array of ChatCommand objects (the
commands from a single assistant turn), validated from the backend's
ui.command events
(src/widgets/actions/chat-commands.ts):
type ChatCommand =
| {
id: string;
type: "zoom-to-location";
args: { latitude; longitude; scale? };
}
| {
id: string;
type: "zoom-to-extent";
args: { xmin; ymin; xmax; ymax; spatialReference? };
}
| {
id: string;
type: "apply-cypher";
// openCypher to re-seed a connected Link Chart, plus an optional
// label map (entity/relationship type -> property to display).
args: { query: string; labels?: Record<string, string> };
};
Each command's id is unique, so a target applies it exactly once even though
the inbox only keeps the latest payload and the array grows as commands stream
in. The Map/Scene consumer tracks applied ids and runs each new command via
view.goTo; because it navigates the live view, the map's own
map:extent-change re-fires once it settles, so any extent-wired filter
targets update for free.
When the AI Chat is connected to a Link Chart (the link-chart-command
effect), the backend runs the knowledge_graph workflow: it translates the
question into a subgraph openCypher query (entities + relationships) and, for
count/statistic questions, a separate aggregate query for an exact figure. It
summarizes the results in chat and emits an apply-cypher command carrying the
subgraph query. The Link Chart consumer reads it via
useActionInbox(widgetId, 'link-chart-command') and re-seeds its
base-cypher-query, regenerating the chart from the matching subgraph. When the
graph's authoring metadata names a display property for a type, the command also
carries a labels map (type → property) and the chart labels those nodes and
relationships by that property. These merge over the chart's own labelFields
starting labels (configured under Data) — the command's per-type values win,
while configured types it omits keep their label. (The
aggregate query never drives the chart — it would render nothing; and Lucene
searches are summarized in chat only, since only openCypher maps to the chart's
base query.)
The tile-search widget runs its AOI summary inline rather than emitting a
cross-widget action. After the user selects tiles, it posts the array of
TileResult objects to the backend area_summary workflow and opens the report
in a modal
(src/widgets/widgets/tile-search/summary-api.ts):
interface TileResult {
tile_key: string;
extent: number[]; // [xmin, ymin, xmax, ymax]
service_url: string; // imagery service the chip is exported from
wkid: number;
size_px: number;
score?: number | null; // CLIP similarity (query / combined modes)
}
Configuring wiring in the UI
The config drawer's Actions section renders a type-specific panel from src/widgets/actions/panels.tsx:
export function getWidgetActionsPanel(
type: string,
): WidgetActionsPanel | undefined;
Widgets flagged hideActions in the registry (such as Details) do not show this
section.