Linked-View Context Sync

Status: optional SDK/UI integration layer for issue #72, building on the ExplorationContext, @honua/sdk-js/interactions, and @honua/sdk-js/realtime public surfaces.

Linked-view context sync coordinates maps, tables, charts or graphs, filter controls, detail panels, and live operational layers without wiring those components directly to each other. Each component adapter dispatches typed exploration intents into one ExplorationContext and subscribes to the slice or selector it can render. A map does not call a table, a chart does not call a map, and a filter bar does not know which layers or protocols consume its clauses.

Core Pattern

import { createExplorationContext, sourceFeatureSelectionTarget } from "@honua/sdk-js/exploration";
import {
  bindChartToExploration,
  bindDetailToSelection,
  bindFilterControlsToExploration,
  bindMapExtentToExploration,
  bindQueryProjectionToExploration,
  bindTableSelectionToExploration,
  syncMapLayerFilterToExploration,
} from "@honua/sdk-js/interactions";
import { reconcileRealtimeSelection } from "@honua/sdk-js/realtime";

const ctx = createExplorationContext({
  datasetId: "ops",
  sourceIds: ["incidents"],
  preset: "mapDriven",
});

const mapView = ctx.connectView({ id: "map", role: "map" });
const tableView = ctx.connectView({ id: "table", role: "grid" });
const chartView = ctx.connectView({ id: "chart", role: "chart" });
const filterView = ctx.connectView({ id: "filters", role: "filter" });
const detailView = ctx.connectView({ id: "detail", role: "detail" });

connectView() returns an ExplorationViewController. The controller stamps the bound viewId onto every dispatch, suppresses self-origin callbacks by default, and releases subscriptions on unbind().

Component Adapters

Maps publish viewport and selection intents:

bindMapExtentToExploration(mapView, mapExtentSource, {
  publishSpatialFilter: false,
});

mapView.select([sourceFeatureSelectionTarget("incidents", 101)], { replace: true });

Tables and charts subscribe to a query projection rather than to the map:

bindQueryProjectionToExploration(tableView, async (projection) => {
  const rows = await source.query({
    spatialFilter: projection.spatialFilter,
    orderBy: projection.orderBy,
    pagination: projection.pagination,
    outFields: projection.outFields,
  });
  renderRows(rows, projection.selection);
});

bindChartToExploration(chartView).subscribeQuery((projection) => {
  renderChart({
    filters: projection.filters,
    spatialFilter: projection.spatialFilter,
    grouping: projection.grouping,
    aggregation: projection.aggregation,
  });
});

Filter controls publish structured FilterClause values. Map, table, and chart adapters translate the same projection at their protocol edge:

const filters = bindFilterControlsToExploration(filterView);

syncMapLayerFilterToExploration(maplibreMap, mapView, {
  layerId: "incident-points",
  sourceId: "incidents",
  translate: (projection) => compileMapLibreFilter(projection.filters),
});

filters.setFilter("status", {
  field: "STATUS",
  operator: "=",
  value: "open",
  appliesTo: ["incidents"],
});

Charts or graphs can drive grouped exploration by dispatching grouping, aggregation, selected feature targets, and bucket filters:

const chart = bindChartToExploration(chartView);

chart.setGrouping(["SEVERITY"]);
chart.setAggregation({
  groupBy: ["SEVERITY"],
  metrics: [{ fn: "count", field: "OBJECTID" }],
});
chart.selectBucket({
  targets: [sourceFeatureSelectionTarget("incidents", 101)],
  filters: {
    severity: { field: "SEVERITY", operator: "=", value: "high" },
  },
});

Detail panels subscribe to selection:

bindDetailToSelection(detailView, ([selected]) => {
  renderDetail(selected);
});

const table = bindTableSelectionToExploration(tableView);
table.clearSelection();

Presets

Presets decide which bound-view slice notifications propagate to peers. Central state still moves for every valid intent, and "all" subscribers still observe every reducer change for devtools, persistence, and snapshot writing.

Preset Use when Peer propagation
globalLinked Prototypes and fully coordinated apps. Every role can publish every slice.
mapDriven The map viewport is the primary browsing surface. Map publishes extent, spatialFilter, selection, and filters; filter controls publish filters and spatialFilter.
chartDriven A chart or graph bucket controls the working set. Chart publishes grouping, aggregation, filters, and selection; filter controls publish filters.
decoupled Components should keep local interaction state while sharing one serializable context. No peer slice notifications; snapshots still contain central state.
ctx.connectView({ id: "map", role: "map" }).applyPreset("chartDriven");

Use decoupled when an application wants the SDK state container and snapshot format but needs app-owned propagation rules. Because slice listeners stay quiet, components must subscribe to "all" or use app-specific routing if they need to observe local-only movement.

Realtime Deltas

Realtime feature stores are another producer of typed state. Transport adapters emit normalized deltas through @honua/sdk-js/realtime; UI adapters reconcile the resulting live set against ExplorationContext selection.

store.subscribe((state) => {
  reconcileRealtimeSelection(detailView, state, {
    sourceId: "incidents",
    requireLiveRecord: true,
  });
});

delete events create tombstones, and stale or replayed streams keep cursor and watermark metadata in the realtime store. Selection reconciliation removes tombstoned and, by default, missing live records from the shared selection so map highlights, table rows, charts, and detail panels do not point at archived features.

Adoption Paths

Issue #56 can adopt this layer for sample and application work by binding maps, grids, charts, filters, and details through one ExplorationContext instead of custom pairwise callbacks.

Issue #57 can reuse the realtime reconciliation path for the incident operations dashboard so live feature deltas, map highlights, table rows, charts, filters, and detail panels converge through the same context instead of pairwise synchronization callbacks.