From 4fba2b19b4267e290372b036b2a801e88d490f98 Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Sun, 8 Mar 2026 19:16:30 -0500 Subject: [PATCH 01/21] provenance base --- Makefile | 96 ++++- autk-provenance/package.json | 40 ++ autk-provenance/src/adapters/db-adapter.ts | 175 ++++++++ autk-provenance/src/adapters/index.ts | 13 + autk-provenance/src/adapters/map-adapter.ts | 85 ++++ autk-provenance/src/adapters/plot-adapter.ts | 82 ++++ autk-provenance/src/core.ts | 398 ++++++++++++++++++ .../src/create-autark-provenance.ts | 106 +++++ autk-provenance/src/create-provenance.ts | 60 +++ autk-provenance/src/index.ts | 10 + autk-provenance/src/provenance-trail-ui.ts | 124 ++++++ autk-provenance/src/types.ts | 107 +++++ autk-provenance/tsconfig.json | 23 + autk-provenance/vite.config.ts | 16 + .../autk-provenance/map-plot-provenance.html | 63 +++ .../autk-provenance/map-plot-provenance.ts | 57 +++ gallery/package.json | 3 +- test.mmd | 103 +++++ usecases/vite.config.ts | 4 +- 19 files changed, 1544 insertions(+), 21 deletions(-) create mode 100644 autk-provenance/package.json create mode 100644 autk-provenance/src/adapters/db-adapter.ts create mode 100644 autk-provenance/src/adapters/index.ts create mode 100644 autk-provenance/src/adapters/map-adapter.ts create mode 100644 autk-provenance/src/adapters/plot-adapter.ts create mode 100644 autk-provenance/src/core.ts create mode 100644 autk-provenance/src/create-autark-provenance.ts create mode 100644 autk-provenance/src/create-provenance.ts create mode 100644 autk-provenance/src/index.ts create mode 100644 autk-provenance/src/provenance-trail-ui.ts create mode 100644 autk-provenance/src/types.ts create mode 100644 autk-provenance/tsconfig.json create mode 100644 autk-provenance/vite.config.ts create mode 100644 examples/src/autk-provenance/map-plot-provenance.html create mode 100644 examples/src/autk-provenance/map-plot-provenance.ts create mode 100644 test.mmd diff --git a/Makefile b/Makefile index 7ebc27b0..2226f5bd 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,28 @@ -.PHONY: lint typecheck build docs verify test test-update dev clean +.PHONY: install lint typecheck build build-all docs verify test test-update test-ui test-codegen dev map db plot compute clean publish CONCURRENTLY := npx concurrently RIMRAF := npx rimraf APP ?= gallery -TEST ?= tests/gallery/autk-map/colormap-categorical.test.ts -UPDATE ?= cache images + +LIB_PACKAGES := autk-map autk-db autk-plot autk-compute autk-provenance +DOC_PACKAGES := $(LIB_PACKAGES) lint: npm run lint -typecheck: build + +install: + $(CONCURRENTLY) $(foreach package,$(LIB_PACKAGES),cd $(package) && npm install) + +typecheck: $(CONCURRENTLY) \ "cd autk-core && npx tsc --noEmit --skipLibCheck" \ "cd autk-map && npx tsc --noEmit --skipLibCheck" \ "cd autk-db && npx tsc --noEmit --skipLibCheck" \ "cd autk-plot && npx tsc --noEmit --skipLibCheck" \ "cd autk-compute && npx tsc --noEmit --skipLibCheck" \ - "cd autk && npx tsc --noEmit --skipLibCheck" \ + "cd autk-provenance && npx tsc --noEmit --skipLibCheck" \ "cd gallery && npx tsc --noEmit --skipLibCheck" \ "cd usecases && npx tsc --noEmit --skipLibCheck" @@ -26,30 +31,61 @@ build: "cd autk-map && npm run build" \ "cd autk-db && npm run build" \ "cd autk-plot && npm run build" \ + "cd autk-provenance && npm run build" \ + "cd autk-compute && npm run build" + +build-all: + $(CONCURRENTLY) \ + "cd autk-map && npm run build" \ + "cd autk-db && npm run build" \ + "cd autk-plot && npm run build" \ + "cd autk-provenance && npm run build" \ "cd autk-compute && npm run build" - cd autk && npm run build docs: $(CONCURRENTLY) \ "cd autk-map && npm run doc" \ "cd autk-db && npm run doc" \ "cd autk-plot && npm run doc" \ + "cd autk-provenance && npm run doc" \ "cd autk-compute && npm run doc" -verify: lint typecheck +verify: lint typecheck build-all docs + +ifdef OPEN +TEST_TARGET = tests/$(APP)/$(shell echo '$(OPEN)' | sed 's|^/||' | sed 's|^src/||' | sed 's|/$$||' | sed 's|\.[^./]*$$||') +else +TEST_TARGET = tests/$(APP) +endif + +CODEGEN_TARGET = src/$(shell echo '$(OPEN)' | sed 's|^/||' | sed 's|^src/||' | sed 's|/$$||' | sed 's|\.[^./]*$$||') test: - APP=$(APP) npx playwright test $(TEST) + APP=$(APP) OPEN=$(OPEN) npx playwright test $(if $(OPEN),$(TEST_TARGET).test.ts,$(TEST_TARGET)) + +# make test-update → update both cache (HAR) and images (snapshots) +# make test-update cache → update HAR files only +# make test-update images → update snapshots only +# make test-update cache images → update both explicitly +_CACHE := $(filter cache,$(MAKECMDGOALS)) +_IMAGES := $(filter images,$(MAKECMDGOALS)) +_BOTH := $(if $(or $(_CACHE),$(_IMAGES)),,1) -# Update committed test baselines locally. -# Examples: -# make test-update TEST=tests/gallery/autk-map/colormap-categorical.test.ts UPDATE=images -# make test-update TEST=tests/gallery/autk-map/osm-layers-api.test.ts UPDATE="cache images" test-update: - APP=$(APP) \ - $(if $(findstring cache,$(UPDATE)),HAR_UPDATE=1) \ - npx playwright test $(TEST) \ - $(if $(findstring images,$(UPDATE)),--update-snapshots) + APP=$(APP) OPEN=$(OPEN) \ + $(if $(or $(_CACHE),$(_BOTH)),HAR_UPDATE=1) \ + npx playwright test $(if $(OPEN),$(TEST_TARGET).test.ts,$(TEST_TARGET)) \ + $(if $(or $(_IMAGES),$(_BOTH)),--update-snapshots) + +cache images: + @true + +test-ui: + APP=$(APP) OPEN=$(OPEN) npx playwright test --ui $(if $(OPEN),$(TEST_TARGET).test.ts,$(TEST_TARGET)) + +test-codegen: + node playwright.codegen.mjs http://localhost:5173$(OPEN) $(if $(OPEN),$(TEST_TARGET).test.ts) + dev: npm install @@ -59,9 +95,22 @@ dev: "cd autk-db && npm run dev-build" \ "cd autk-plot && npm run dev-build" \ "cd autk-compute && npm run dev-build" \ - "cd autk && npm run dev-build" \ + "cd autk-provenance && npm run dev-build" \ "cd $(APP) && VITE_OPEN=\"$(OPEN)\" npm run dev" +map: + cd autk-map && npm run build + +db: + cd autk-db && npm run build + +plot: + cd autk-plot && npm run build + +compute: + cd autk-compute && npm run build + + clean: $(RIMRAF) node_modules $(CONCURRENTLY) \ @@ -69,6 +118,17 @@ clean: "cd autk-db && $(RIMRAF) dist build" \ "cd autk-plot && $(RIMRAF) dist build" \ "cd autk-compute && $(RIMRAF) dist build" \ - "cd autk && $(RIMRAF) dist build" \ "cd gallery && $(RIMRAF) dist build" \ "cd usecases && $(RIMRAF) dist build" + +publish: + @if [ -z "$(LIB)" ]; then \ + echo "Error: Please specify a library to publish using LIB="; \ + echo "Usage: make publish LIB=autk-map|autk-db|autk-plot|autk-compute"; \ + exit 1; \ + fi + @if [ "$(LIB)" != "autk-map" ] && [ "$(LIB)" != "autk-db" ] && [ "$(LIB)" != "autk-plot" ] && [ "$(LIB)" != "autk-compute" ]; then \ + echo "Error: LIB must be one of: autk-map, autk-db, autk-plot, autk-compute"; \ + exit 1; \ + fi + cd $(LIB) && npm pack && npm publish *.tgz diff --git a/autk-provenance/package.json b/autk-provenance/package.json new file mode 100644 index 00000000..cd0e9c04 --- /dev/null +++ b/autk-provenance/package.json @@ -0,0 +1,40 @@ +{ + "name": "autk-provenance", + "version": "0.0.0", + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/autk-provenance.umd.cjs", + "module": "./dist/autk-provenance.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/autk-provenance.js", + "require": "./dist/autk-provenance.umd.cjs" + } + }, + "scripts": { + "dev": "vite", + "dev-build": "tsc && vite build --watch", + "build": "tsc && vite build", + "preview": "vite preview", + "format": "prettier --write ." + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.46.2", + "@typescript-eslint/parser": "^8.46.2", + "eslint": "^9.38.0", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.6.2", + "typescript": "^5.9.3", + "vite": "^7.1.12", + "vite-plugin-dts": "^4.5.4" + }, + "dependencies": { + "autk-map": "file:../autk-map", + "autk-plot": "file:../autk-plot", + "autk-db": "file:../autk-db" + } +} diff --git a/autk-provenance/src/adapters/db-adapter.ts b/autk-provenance/src/adapters/db-adapter.ts new file mode 100644 index 00000000..024ea6ca --- /dev/null +++ b/autk-provenance/src/adapters/db-adapter.ts @@ -0,0 +1,175 @@ +import type { AutarkProvenanceState } from '../types'; +import { ProvenanceAction } from '../types'; + +export type DbRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; + +export interface IDbForProvenance { + getCurrentWorkspace(): string; + getWorkspaces(): string[]; + setWorkspace(name: string): Promise; + tables: Array<{ name: string }>; +} + +export interface DbAdapterApi { + recordWorkspace(name: string): void; + recordLoadOsm(outputTableName: string): void; + recordLoadCsv(outputTableName: string): void; + recordLoadJson(outputTableName: string): void; + recordLoadCustomLayer(outputTableName: string): void; + recordLoadGridLayer(outputTableName: string): void; + recordSpatialJoin(params: { tableRootName?: string; tableJoinName?: string }): void; + recordUpdateTable(tableName: string): void; + recordDropTable(tableName: string): void; + applyState(state: AutarkProvenanceState): Promise; +} + +export function createDbAdapter( + db: IDbForProvenance, + onRecord: DbRecordCallback +): DbAdapterApi { + function getCurrentLayerNames(): string[] { + return (db.tables || []).map((t) => t.name); + } + + function recordWorkspace(name: string): void { + onRecord(ProvenanceAction.DB_WORKSPACE, `Workspace: ${name}`, { + data: { + workspace: name, + layerTableNames: getCurrentLayerNames(), + }, + }); + } + + function recordLoadOsm(outputTableName: string): void { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + onRecord(ProvenanceAction.DB_LOAD_OSM, `Load OSM: ${outputTableName}`, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: names, + }, + }); + } + + function recordLoadCsv(outputTableName: string): void { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + onRecord(ProvenanceAction.DB_LOAD_CSV, `Load CSV: ${outputTableName}`, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: names, + }, + }); + } + + function recordLoadJson(outputTableName: string): void { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + onRecord(ProvenanceAction.DB_LOAD_JSON, `Load JSON: ${outputTableName}`, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: names, + }, + }); + } + + function recordLoadCustomLayer(outputTableName: string): void { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + onRecord(ProvenanceAction.DB_LOAD_CUSTOM_LAYER, `Load custom layer: ${outputTableName}`, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: names, + }, + }); + } + + function recordLoadGridLayer(outputTableName: string): void { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + onRecord(ProvenanceAction.DB_LOAD_GRID_LAYER, `Load grid layer: ${outputTableName}`, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: names, + }, + }); + } + + function recordSpatialJoin(params: { tableRootName?: string; tableJoinName?: string }): void { + const label = `Spatial join: ${params.tableRootName ?? '?'} + ${params.tableJoinName ?? '?'}`; + onRecord(ProvenanceAction.DB_SPATIAL_JOIN, label, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: getCurrentLayerNames(), + }, + }); + } + + function recordUpdateTable(tableName: string): void { + onRecord(ProvenanceAction.DB_UPDATE_TABLE, `Update table: ${tableName}`, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: getCurrentLayerNames(), + }, + }); + } + + function recordDropTable(tableName: string): void { + const names = getCurrentLayerNames().filter((n) => n !== tableName); + onRecord(ProvenanceAction.DB_DROP_TABLE, `Drop table: ${tableName}`, { + data: { + workspace: db.getCurrentWorkspace(), + layerTableNames: names, + }, + }); + } + + async function applyState(state: AutarkProvenanceState): Promise { + const data = state.data; + if (!data?.workspace) return; + const current = db.getCurrentWorkspace(); + if (current !== data.workspace) { + await db.setWorkspace(data.workspace); + } + } + + return { + recordWorkspace, + recordLoadOsm, + recordLoadCsv, + recordLoadJson, + recordLoadCustomLayer, + recordLoadGridLayer, + recordSpatialJoin, + recordUpdateTable, + recordDropTable, + applyState, + }; +} + +/** + * Wraps a SpatialDb-like instance so that mutating methods automatically record provenance. + * Use this when you want DB actions to be tracked without calling record* manually. + * The wrapped db has the same interface as the original; pass it to createAutarkProvenance as db. + */ +export function createDbProvenanceWrapper( + db: T, + onRecord: DbRecordCallback +): T { + const adapter = createDbAdapter(db, onRecord); + return new Proxy(db, { + get(target, prop, receiver) { + if (prop === 'setWorkspace') { + return async function (name: string) { + await (target as IDbForProvenance).setWorkspace(name); + adapter.recordWorkspace(name); + }; + } + return Reflect.get(target, prop, receiver); + }, + }) as T; +} diff --git a/autk-provenance/src/adapters/index.ts b/autk-provenance/src/adapters/index.ts new file mode 100644 index 00000000..0fd57a82 --- /dev/null +++ b/autk-provenance/src/adapters/index.ts @@ -0,0 +1,13 @@ +export { createMapAdapter } from './map-adapter'; +export type { MapAdapterApi, MapRecordCallback } from './map-adapter'; +export { createPlotAdapter } from './plot-adapter'; +export type { PlotAdapterApi, PlotRecordCallback } from './plot-adapter'; +export { + createDbAdapter, + createDbProvenanceWrapper, +} from './db-adapter'; +export type { + DbAdapterApi, + DbRecordCallback, + IDbForProvenance, +} from './db-adapter'; diff --git a/autk-provenance/src/adapters/map-adapter.ts b/autk-provenance/src/adapters/map-adapter.ts new file mode 100644 index 00000000..dfe0e378 --- /dev/null +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -0,0 +1,85 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../types'; +import { ProvenanceAction } from '../types'; + +const MAP_PICK_EVENT = 'pick'; + +export type MapRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; + +export interface MapAdapterApi { + startRecording(): void; + stopRecording(): void; + applyState(state: AutarkProvenanceState): void; +} + +export function createMapAdapter( + map: IMapForProvenance, + onRecord: MapRecordCallback +): MapAdapterApi { + let pickListener: ((selection: number[], layerId: string) => void) | null = null; + + function startRecording(): void { + if (pickListener) return; + pickListener = (selection: number[], layerId: string) => { + const label = + selection.length === 0 + ? `Cleared selection on ${layerId}` + : `Picked ${selection.length} feature(s) on ${layerId}`; + onRecord(ProvenanceAction.MAP_PICK, label, { + selection: { + map: { layerId, ids: selection }, + }, + } as Partial); + }; + map.mapEvents.addEventListener(MAP_PICK_EVENT, pickListener); + } + + function stopRecording(): void { + if (pickListener && map.mapEvents.removeEventListener) { + map.mapEvents.removeEventListener(MAP_PICK_EVENT, pickListener); + pickListener = null; + } + } + + function applyState(state: AutarkProvenanceState): void { + const { selection } = state; + if (!selection) return; + + const layers = map.layerManager.vectorLayers; + if (layers) { + for (const layer of layers) { + const id = layer.layerInfo?.id; + if (id === selection.map?.layerId) { + if (selection.map && selection.map.ids.length > 0) { + layer.setHighlightedIds(selection.map.ids); + } else { + layer.clearHighlightedIds(); + } + } else { + layer.clearHighlightedIds(); + } + } + } else { + const layerId = selection.map?.layerId; + if (layerId) { + const layer = map.layerManager.searchByLayerId(layerId); + if (layer) { + if (selection.map && selection.map.ids.length > 0) { + layer.setHighlightedIds(selection.map.ids); + } else { + layer.clearHighlightedIds(); + } + } + } + } + } + + return { + startRecording, + stopRecording, + applyState, + }; +} diff --git a/autk-provenance/src/adapters/plot-adapter.ts b/autk-provenance/src/adapters/plot-adapter.ts new file mode 100644 index 00000000..ade5ceb6 --- /dev/null +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -0,0 +1,82 @@ +import type { AutarkProvenanceState, IPlotForProvenance } from '../types'; +import { ProvenanceAction } from '../types'; + +const PLOT_CLICK = 'click'; +const PLOT_BRUSH = 'brush'; +const PLOT_BRUSH_X = 'brushX'; +const PLOT_BRUSH_Y = 'brushY'; + +export type PlotRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; + +export interface PlotAdapterApi { + startRecording(): void; + stopRecording(): void; + applyState(state: AutarkProvenanceState): void; +} + +function makeListener( + eventName: string, + actionType: ProvenanceAction, + onRecord: PlotRecordCallback +): (selection: number[]) => void { + return (selection: number[]) => { + const label = + selection.length === 0 + ? `Cleared plot selection` + : `${eventName}: ${selection.length} point(s) selected`; + onRecord(actionType, label, { + selection: { + map: null, + plot: selection, + }, + }); + }; +} + +export function createPlotAdapter( + plot: IPlotForProvenance, + onRecord: PlotRecordCallback +): PlotAdapterApi { + const listeners: Array<{ event: string; fn: (selection: number[]) => void }> = []; + const events: Array<{ event: string; actionType: ProvenanceAction }> = [ + { event: PLOT_CLICK, actionType: ProvenanceAction.PLOT_CLICK }, + { event: PLOT_BRUSH, actionType: ProvenanceAction.PLOT_BRUSH }, + { event: PLOT_BRUSH_X, actionType: ProvenanceAction.PLOT_BRUSH_X }, + { event: PLOT_BRUSH_Y, actionType: ProvenanceAction.PLOT_BRUSH_Y }, + ]; + + function startRecording(): void { + if (listeners.length > 0) return; + for (const { event, actionType } of events) { + const fn = makeListener(event, actionType, onRecord); + listeners.push({ event, fn }); + plot.plotEvents.addEventListener(event, fn); + } + } + + function stopRecording(): void { + for (const { event, fn } of listeners) { + if (plot.plotEvents.removeEventListener) { + plot.plotEvents.removeEventListener(event, fn); + } + } + listeners.length = 0; + } + + function applyState(state: AutarkProvenanceState): void { + const plotSelection = state.selection?.plot; + if (Array.isArray(plotSelection)) { + plot.setHighlightedIds(plotSelection); + } + } + + return { + startRecording, + stopRecording, + applyState, + }; +} diff --git a/autk-provenance/src/core.ts b/autk-provenance/src/core.ts new file mode 100644 index 00000000..f0cadeff --- /dev/null +++ b/autk-provenance/src/core.ts @@ -0,0 +1,398 @@ +import type { + AutarkProvenanceState, + PathNode, + ProvenanceGraph, + ProvenanceNode, +} from './types'; +import { ProvenanceAction } from './types'; + +function generateId(): string { + return `n_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +function deepMergeState( + base: AutarkProvenanceState, + delta: Partial +): AutarkProvenanceState { + const next: AutarkProvenanceState = { + selection: { + map: delta.selection?.map ?? base.selection.map, + plot: delta.selection?.plot ?? base.selection.plot, + }, + }; + if (base.view || delta.view) next.view = delta.view ?? base.view; + if (base.data || delta.data) next.data = delta.data ?? base.data; + return next; +} + +export interface ProvenanceCoreOptions { + initialState: AutarkProvenanceState; +} + +export interface ProvenanceCoreApi { + applyAction( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial + ): void; + goToNode(nodeId: string): boolean; + goBackOneStep(): boolean; + goForwardOneStep(): boolean; + canGoBack(): boolean; + canGoForward(): boolean; + getPathFromRoot(): PathNode[]; + getGraph(): ProvenanceGraph; + getCurrentNode(): ProvenanceNode | null; + getCurrentState(): T | null; + exportGraph(): string; + importGraph(json: string): void; + addObserver(callback: (node: ProvenanceNode) => void): () => void; +} + +export function createProvenanceCore( + options: ProvenanceCoreOptions +): ProvenanceCoreApi { + const { initialState } = options; + const nodes = new Map>(); + const rootId = generateId(); + let currentId = rootId; + const observers: Array<(node: ProvenanceNode) => void> = []; + + const rootNode: ProvenanceNode = { + id: rootId, + parentId: null, + childrenIds: [], + state: initialState, + actionLabel: 'Start', + actionType: ProvenanceAction.ROOT, + timestamp: Date.now(), + }; + nodes.set(rootId, rootNode); + + function getCurrent(): ProvenanceNode | null { + return nodes.get(currentId) ?? null; + } + + function notify(): void { + const node = getCurrent(); + if (node) observers.forEach((cb) => cb(node)); + } + + function applyAction( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial + ): void { + const current = getCurrent(); + if (!current) return; + const newState = deepMergeState(current.state, stateDelta); + const newId = generateId(); + const newNode: ProvenanceNode = { + id: newId, + parentId: currentId, + childrenIds: [], + state: newState, + actionLabel, + actionType, + timestamp: Date.now(), + }; + nodes.set(newId, newNode); + current.childrenIds.push(newId); + currentId = newId; + notify(); + } + + function goToNode(nodeId: string): boolean { + if (!nodes.has(nodeId)) return false; + currentId = nodeId; + notify(); + return true; + } + + function goBackOneStep(): boolean { + const current = getCurrent(); + if (!current || !current.parentId) return false; + currentId = current.parentId; + notify(); + return true; + } + + function goForwardOneStep(): boolean { + const current = getCurrent(); + if (!current || current.childrenIds.length === 0) return false; + currentId = current.childrenIds[current.childrenIds.length - 1]; + notify(); + return true; + } + + function canGoBack(): boolean { + const current = getCurrent(); + return !!current?.parentId; + } + + function canGoForward(): boolean { + const current = getCurrent(); + return !!current && current.childrenIds.length > 0; + } + + function getPathFromRoot(): PathNode[] { + const path: PathNode[] = []; + let id: string | null = currentId; + const ordered: ProvenanceNode[] = []; + while (id) { + const node = nodes.get(id); + if (!node) break; + ordered.push(node); + id = node.parentId; + } + ordered.reverse(); + for (const node of ordered) { + path.push({ + id: node.id, + actionLabel: node.actionLabel, + actionType: node.actionType, + timestamp: node.timestamp, + state: node.state, + }); + } + return path; + } + + function getGraph(): ProvenanceGraph { + return { + nodes: new Map(nodes), + rootId, + currentId, + }; + } + + function getCurrentState(): AutarkProvenanceState | null { + return getCurrent()?.state ?? null; + } + + function exportGraph(): string { + const graph = getGraph(); + const serializable = { + rootId: graph.rootId, + currentId: graph.currentId, + nodes: Array.from(graph.nodes.entries()), + }; + return JSON.stringify(serializable); + } + + function importGraph(json: string): void { + try { + const parsed = JSON.parse(json) as { + rootId: string; + currentId: string; + nodes: [string, ProvenanceNode][]; + }; + nodes.clear(); + for (const [id, node] of parsed.nodes) { + nodes.set(id, node); + } + currentId = parsed.currentId; + notify(); + } catch { + // no-op on invalid JSON + } + } + + function addObserver(callback: (node: ProvenanceNode) => void): () => void { + observers.push(callback); + return () => { + const i = observers.indexOf(callback); + if (i !== -1) observers.splice(i, 1); + }; + } + + return { + applyAction, + goToNode, + goBackOneStep, + goForwardOneStep, + canGoBack, + canGoForward, + getPathFromRoot, + getGraph, + getCurrentNode: getCurrent, + getCurrentState, + exportGraph, + importGraph, + addObserver, + }; +} + +export interface ProvenanceCoreGenericOptions { + initialState: T; + mergeState: (base: T, delta: Partial) => T; +} + +export function createProvenanceCoreGeneric>( + options: ProvenanceCoreGenericOptions +): ProvenanceCoreApi { + const { initialState, mergeState } = options; + const nodes = new Map>(); + const rootId = generateId(); + let currentId = rootId; + const observers: Array<(node: ProvenanceNode) => void> = []; + + const rootNode: ProvenanceNode = { + id: rootId, + parentId: null, + childrenIds: [], + state: initialState, + actionLabel: 'Start', + actionType: 'root', + timestamp: Date.now(), + }; + nodes.set(rootId, rootNode); + + function getCurrent(): ProvenanceNode | null { + return nodes.get(currentId) ?? null; + } + + function notify(): void { + const node = getCurrent(); + if (node) observers.forEach((cb) => cb(node)); + } + + function applyAction( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial + ): void { + const current = getCurrent(); + if (!current) return; + const newState = mergeState(current.state, stateDelta); + const newId = generateId(); + const newNode: ProvenanceNode = { + id: newId, + parentId: currentId, + childrenIds: [], + state: newState, + actionLabel, + actionType, + timestamp: Date.now(), + }; + nodes.set(newId, newNode); + current.childrenIds.push(newId); + currentId = newId; + notify(); + } + + function goToNode(nodeId: string): boolean { + if (!nodes.has(nodeId)) return false; + currentId = nodeId; + notify(); + return true; + } + + function goBackOneStep(): boolean { + const current = getCurrent(); + if (!current || !current.parentId) return false; + currentId = current.parentId; + notify(); + return true; + } + + function goForwardOneStep(): boolean { + const current = getCurrent(); + if (!current || current.childrenIds.length === 0) return false; + currentId = current.childrenIds[current.childrenIds.length - 1]; + notify(); + return true; + } + + function canGoBack(): boolean { + const current = getCurrent(); + return !!current?.parentId; + } + + function canGoForward(): boolean { + const current = getCurrent(); + return !!current && current.childrenIds.length > 0; + } + + function getPathFromRoot(): PathNode[] { + const ordered: ProvenanceNode[] = []; + let id: string | null = currentId; + while (id) { + const node = nodes.get(id); + if (!node) break; + ordered.push(node); + id = node.parentId; + } + ordered.reverse(); + return ordered.map((node) => ({ + id: node.id, + actionLabel: node.actionLabel, + actionType: node.actionType, + timestamp: node.timestamp, + state: node.state, + })); + } + + function getGraph(): ProvenanceGraph { + return { + nodes: new Map(nodes), + rootId, + currentId, + }; + } + + function getCurrentState(): T | null { + return getCurrent()?.state ?? null; + } + + function exportGraph(): string { + const graph = getGraph(); + return JSON.stringify({ + rootId: graph.rootId, + currentId: graph.currentId, + nodes: Array.from(graph.nodes.entries()), + }); + } + + function importGraph(json: string): void { + try { + const parsed = JSON.parse(json) as { + rootId: string; + currentId: string; + nodes: [string, ProvenanceNode][]; + }; + nodes.clear(); + for (const [id, node] of parsed.nodes) { + nodes.set(id, node); + } + currentId = parsed.currentId; + notify(); + } catch { + // no-op + } + } + + function addObserver(callback: (node: ProvenanceNode) => void): () => void { + observers.push(callback); + return () => { + const i = observers.indexOf(callback); + if (i !== -1) observers.splice(i, 1); + }; + } + + return { + applyAction, + goToNode, + goBackOneStep, + goForwardOneStep, + canGoBack, + canGoForward, + getPathFromRoot, + getGraph, + getCurrentNode: getCurrent, + getCurrentState, + exportGraph, + importGraph, + addObserver, + }; +} diff --git a/autk-provenance/src/create-autark-provenance.ts b/autk-provenance/src/create-autark-provenance.ts new file mode 100644 index 00000000..4b3af0b0 --- /dev/null +++ b/autk-provenance/src/create-autark-provenance.ts @@ -0,0 +1,106 @@ +import type { AutarkProvenanceState } from './types'; +import { createProvenanceCore } from './core'; +import { createMapAdapter } from './adapters/map-adapter'; +import { createPlotAdapter } from './adapters/plot-adapter'; +import { createDbAdapter } from './adapters/db-adapter'; +import type { IMapForProvenance, IPlotForProvenance } from './types'; +import type { IDbForProvenance } from './adapters/db-adapter'; + +const DEFAULT_STATE: AutarkProvenanceState = { + selection: { + map: null, + plot: [], + }, +}; + +export interface CreateAutarkProvenanceOptions { + map?: IMapForProvenance; + plot?: IPlotForProvenance; + db?: IDbForProvenance; + initialState?: Partial; +} + +export interface AutarkProvenanceApi { + goToNode(nodeId: string): boolean; + goBackOneStep(): boolean; + goForwardOneStep(): boolean; + canGoBack(): boolean; + canGoForward(): boolean; + getPathFromRoot(): import('./types').PathNode[]; + getGraph(): import('./types').ProvenanceGraph; + getCurrentNode(): import('./types').ProvenanceNode | null; + getCurrentState(): AutarkProvenanceState | null; + exportGraph(): string; + importGraph(json: string): void; + addObserver(callback: (node: import('./types').ProvenanceNode) => void): () => void; + stopRecording(): void; + startRecording(): void; + db?: import('./adapters/db-adapter').DbAdapterApi; +} + +export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkProvenanceApi { + const { map, plot, db, initialState: initialPartial } = options; + const initialState: AutarkProvenanceState = { + ...DEFAULT_STATE, + ...initialPartial, + }; + + const core = createProvenanceCore({ initialState }); + + const mapAdapter = map + ? createMapAdapter(map, (actionType, label, delta) => { + core.applyAction(actionType, label, delta); + }) + : null; + + const plotAdapter = plot + ? createPlotAdapter(plot, (actionType, label, delta) => { + core.applyAction(actionType, label, delta); + }) + : null; + + const dbAdapter = db + ? createDbAdapter(db, (actionType, label, delta) => { + core.applyAction(actionType, label, delta); + }) + : null; + + core.addObserver((node) => { + const state = node.state; + mapAdapter?.applyState(state); + plotAdapter?.applyState(state); + if (dbAdapter) { + dbAdapter.applyState(state).catch(() => {}); + } + }); + + function stopRecording(): void { + mapAdapter?.stopRecording(); + plotAdapter?.stopRecording(); + } + + function startRecording(): void { + mapAdapter?.startRecording(); + plotAdapter?.startRecording(); + } + + startRecording(); + + return { + goToNode: core.goToNode.bind(core), + goBackOneStep: core.goBackOneStep.bind(core), + goForwardOneStep: core.goForwardOneStep.bind(core), + canGoBack: core.canGoBack.bind(core), + canGoForward: core.canGoForward.bind(core), + getPathFromRoot: core.getPathFromRoot.bind(core), + getGraph: core.getGraph.bind(core), + getCurrentNode: core.getCurrentNode.bind(core), + getCurrentState: core.getCurrentState.bind(core), + exportGraph: core.exportGraph.bind(core), + importGraph: core.importGraph.bind(core), + addObserver: core.addObserver.bind(core), + stopRecording, + startRecording, + db: dbAdapter ?? undefined, + }; +} diff --git a/autk-provenance/src/create-provenance.ts b/autk-provenance/src/create-provenance.ts new file mode 100644 index 00000000..ddd407fa --- /dev/null +++ b/autk-provenance/src/create-provenance.ts @@ -0,0 +1,60 @@ +import type { ProvenanceAdapter } from './types'; +import { createProvenanceCoreGeneric } from './core'; +import type { ProvenanceNode } from './types'; + +export interface CreateProvenanceOptions { + initialState: T; + adapter: ProvenanceAdapter; + mergeState?: (base: T, delta: Partial) => T; +} + +export interface ProvenanceApi { + applyAction(actionType: string, actionLabel: string, stateDelta: Partial): void; + goToNode(nodeId: string): boolean; + goBackOneStep(): boolean; + goForwardOneStep(): boolean; + canGoBack(): boolean; + canGoForward(): boolean; + getPathFromRoot(): Array<{ id: string; actionLabel: string; actionType: string; timestamp: number; state: T }>; + getGraph(): { nodes: Map>; rootId: string; currentId: string }; + getCurrentNode(): ProvenanceNode | null; + getCurrentState(): T | null; + exportGraph(): string; + importGraph(json: string): void; + addObserver(callback: (node: ProvenanceNode) => void): () => void; +} + +function defaultMerge(base: T, delta: Partial): T { + return { ...base, ...delta }; +} + +export function createProvenance>( + options: CreateProvenanceOptions +): ProvenanceApi { + const { initialState, adapter, mergeState = defaultMerge } = options; + + const core = createProvenanceCoreGeneric({ + initialState, + mergeState, + }); + + core.addObserver((node) => { + adapter.applyState(node.state); + }); + + return { + applyAction: core.applyAction.bind(core), + goToNode: core.goToNode.bind(core), + goBackOneStep: core.goBackOneStep.bind(core), + goForwardOneStep: core.goForwardOneStep.bind(core), + canGoBack: core.canGoBack.bind(core), + canGoForward: core.canGoForward.bind(core), + getPathFromRoot: core.getPathFromRoot.bind(core), + getGraph: core.getGraph.bind(core), + getCurrentNode: core.getCurrentNode.bind(core), + getCurrentState: core.getCurrentState.bind(core), + exportGraph: core.exportGraph.bind(core), + importGraph: core.importGraph.bind(core), + addObserver: core.addObserver.bind(core), + }; +} diff --git a/autk-provenance/src/index.ts b/autk-provenance/src/index.ts new file mode 100644 index 00000000..79a9fd1b --- /dev/null +++ b/autk-provenance/src/index.ts @@ -0,0 +1,10 @@ +export * from './types'; +export { createProvenanceCore, createProvenanceCoreGeneric } from './core'; +export type { ProvenanceCoreApi, ProvenanceCoreOptions, ProvenanceCoreGenericOptions } from './core'; +export { createAutarkProvenance } from './create-autark-provenance'; +export type { AutarkProvenanceApi, CreateAutarkProvenanceOptions } from './create-autark-provenance'; +export { createProvenance } from './create-provenance'; +export type { ProvenanceApi, CreateProvenanceOptions } from './create-provenance'; +export { renderProvenanceTrailUI } from './provenance-trail-ui'; +export type { ProvenanceTrailUIOptions } from './provenance-trail-ui'; +export * from './adapters'; diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts new file mode 100644 index 00000000..e0b5e121 --- /dev/null +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -0,0 +1,124 @@ +import type { AutarkProvenanceState } from './types'; +import type { PathNode } from './types'; +import type { AutarkProvenanceApi } from './create-autark-provenance'; + +export interface ProvenanceTrailUIOptions { + provenance: AutarkProvenanceApi; + container: HTMLElement; + showBackForward?: boolean; + showTimestamps?: boolean; +} + +function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void { + const { + provenance, + container, + showBackForward = true, + showTimestamps = true, + } = options; + + if (typeof document !== 'undefined' && document.head) { + const styleId = 'autk-provenance-trail-styles'; + if (!document.getElementById(styleId)) { + const el = document.createElement('style'); + el.id = styleId; + el.textContent = '.autk-provenance-trail{font-family:system-ui,sans-serif;font-size:13px;padding:8px}.autk-provenance-trail-path{display:flex;flex-direction:column;gap:2px;max-height:280px;overflow-y:auto}.autk-provenance-trail-item{display:flex;align-items:center;gap:8px;padding:6px 8px;cursor:pointer;border-radius:4px;border:1px solid transparent}.autk-provenance-trail-item:hover{background:rgba(0,0,0,.06)}.autk-provenance-trail-item.autk-provenance-trail-current{background:rgba(0,0,0,.08);border-color:rgba(0,0,0,.15);font-weight:600}.autk-provenance-trail-label{flex:1}.autk-provenance-trail-time{color:#666;font-size:11px}.autk-provenance-trail-buttons{display:flex;gap:6px;margin-top:8px}.autk-provenance-trail-buttons button{padding:6px 12px;cursor:pointer}.autk-provenance-trail-buttons button:disabled{opacity:.5;cursor:not-allowed}'; + document.head.appendChild(el); + } + } + container.classList.add('autk-provenance-trail'); + container.innerHTML = ''; + + const pathContainer = document.createElement('div'); + pathContainer.className = 'autk-provenance-trail-path'; + pathContainer.setAttribute('role', 'list'); + container.appendChild(pathContainer); + + let backBtn: HTMLButtonElement | null = null; + let fwdBtn: HTMLButtonElement | null = null; + + if (showBackForward) { + const btnRow = document.createElement('div'); + btnRow.className = 'autk-provenance-trail-buttons'; + backBtn = document.createElement('button'); + backBtn.textContent = '← Back'; + backBtn.setAttribute('aria-label', 'Go back one step'); + fwdBtn = document.createElement('button'); + fwdBtn.textContent = 'Forward →'; + fwdBtn.setAttribute('aria-label', 'Go forward one step'); + btnRow.appendChild(backBtn); + btnRow.appendChild(fwdBtn); + container.appendChild(btnRow); + + backBtn.addEventListener('click', () => { + provenance.goBackOneStep(); + }); + fwdBtn.addEventListener('click', () => { + provenance.goForwardOneStep(); + }); + } + + function updateButtons(): void { + if (backBtn) backBtn.disabled = !provenance.canGoBack(); + if (fwdBtn) fwdBtn.disabled = !provenance.canGoForward(); + } + + function renderPath(path: PathNode[]): void { + pathContainer.innerHTML = ''; + const currentId = provenance.getCurrentNode()?.id ?? null; + + for (let i = 0; i < path.length; i++) { + const node = path[i]; + const isCurrent = node.id === currentId; + const item = document.createElement('div'); + item.className = 'autk-provenance-trail-item' + (isCurrent ? ' autk-provenance-trail-current' : ''); + item.setAttribute('role', 'listitem'); + item.setAttribute('data-node-id', node.id); + + const labelSpan = document.createElement('span'); + labelSpan.className = 'autk-provenance-trail-label'; + labelSpan.textContent = node.actionLabel; + if (isCurrent) { + labelSpan.setAttribute('aria-current', 'step'); + } + item.appendChild(labelSpan); + + if (showTimestamps) { + const timeSpan = document.createElement('span'); + timeSpan.className = 'autk-provenance-trail-time'; + timeSpan.textContent = formatTime(node.timestamp); + item.appendChild(timeSpan); + } + + item.addEventListener('click', () => { + provenance.goToNode(node.id); + }); + + pathContainer.appendChild(item); + } + + updateButtons(); + } + + function refresh(): void { + const path = provenance.getPathFromRoot(); + renderPath(path); + } + + const unsub = provenance.addObserver(() => { + refresh(); + }); + + refresh(); + + return () => { + unsub(); + container.innerHTML = ''; + container.classList.remove('autk-provenance-trail'); + }; +} diff --git a/autk-provenance/src/types.ts b/autk-provenance/src/types.ts new file mode 100644 index 00000000..ade6df53 --- /dev/null +++ b/autk-provenance/src/types.ts @@ -0,0 +1,107 @@ +/** + * Canonical state shape for autark provenance. + * Captures everything needed to restore the current analysis state. + */ +export interface AutarkProvenanceState { + selection: { + map: { layerId: string; ids: number[] } | null; + plot: number[]; + }; + view?: { + center: [number, number]; + zoom?: number; + pitch?: number; + }; + data?: { + workspace: string; + layerTableNames: string[]; + activeLayerIds?: string[]; + }; +} + +/** + * Action types for every trackable interaction. + * Each creates one node in the provenance graph. + */ +export enum ProvenanceAction { + ROOT = 'root', + MAP_PICK = 'MAP_PICK', + MAP_LAYER_LOAD = 'MAP_LAYER_LOAD', + MAP_VIEW = 'MAP_VIEW', + PLOT_CLICK = 'PLOT_CLICK', + PLOT_BRUSH = 'PLOT_BRUSH', + PLOT_BRUSH_X = 'PLOT_BRUSH_X', + PLOT_BRUSH_Y = 'PLOT_BRUSH_Y', + PLOT_DATA = 'PLOT_DATA', + DB_WORKSPACE = 'DB_WORKSPACE', + DB_LOAD_OSM = 'DB_LOAD_OSM', + DB_LOAD_CSV = 'DB_LOAD_CSV', + DB_LOAD_JSON = 'DB_LOAD_JSON', + DB_LOAD_CUSTOM_LAYER = 'DB_LOAD_CUSTOM_LAYER', + DB_LOAD_GRID_LAYER = 'DB_LOAD_GRID_LAYER', + DB_SPATIAL_JOIN = 'DB_SPATIAL_JOIN', + DB_UPDATE_TABLE = 'DB_UPDATE_TABLE', + DB_DROP_TABLE = 'DB_DROP_TABLE', + DB_OTHER = 'DB_OTHER', +} + +export interface ProvenanceNode { + id: string; + parentId: string | null; + childrenIds: string[]; + state: T; + actionLabel: string; + actionType: ProvenanceAction | string; + timestamp: number; + metadata?: Record; +} + +export interface ProvenanceGraph { + nodes: Map>; + rootId: string; + currentId: string; +} + +/** + * Adapter contract for applying state to a system (e.g. map, plot, db). + * Used when navigating to a node: core calls applyState with that node's state. + */ +export interface ProvenanceAdapter { + applyState(state: T): void; +} + +/** + * Minimal map-like interface needed by MapAdapter (avoids tight coupling to AutkMap). + */ +export interface IMapForProvenance { + mapEvents: { + addEventListener(event: string, listener: (selection: number[], layerId: string) => void): void; + removeEventListener?(event: string, listener: (selection: number[], layerId: string) => void): void; + }; + layerManager: { + searchByLayerId(layerId: string): { setHighlightedIds(ids: number[]): void; clearHighlightedIds(): void; layerInfo?: { id: string } } | null; + vectorLayers?: Array<{ layerInfo?: { id: string }; setHighlightedIds(ids: number[]): void; clearHighlightedIds(): void }>; + }; +} + +/** + * Minimal plot-like interface needed by PlotAdapter. + */ +export interface IPlotForProvenance { + plotEvents: { + addEventListener(event: string, listener: (selection: number[]) => void): void; + removeEventListener?(event: string, listener: (selection: number[]) => void): void; + }; + setHighlightedIds(selection: number[]): void; +} + +/** + * Path entry for getPathFromRoot(). + */ +export interface PathNode { + id: string; + actionLabel: string; + actionType: ProvenanceAction | string; + timestamp: number; + state: T; +} diff --git a/autk-provenance/tsconfig.json b/autk-provenance/tsconfig.json new file mode 100644 index 00000000..645ee4dc --- /dev/null +++ b/autk-provenance/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "declaration": true, + "declarationDir": "./dist", + "emitDeclarationOnly": true, + "outDir": "./dist", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/autk-provenance/vite.config.ts b/autk-provenance/vite.config.ts new file mode 100644 index 00000000..e209e460 --- /dev/null +++ b/autk-provenance/vite.config.ts @@ -0,0 +1,16 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + plugins: [dts()], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'autk-provenance', + }, + copyPublicDir: false, + emptyOutDir: false, + sourcemap: true, + }, +}); diff --git a/examples/src/autk-provenance/map-plot-provenance.html b/examples/src/autk-provenance/map-plot-provenance.html new file mode 100644 index 00000000..72a614f6 --- /dev/null +++ b/examples/src/autk-provenance/map-plot-provenance.html @@ -0,0 +1,63 @@ + + + + + Autark provenance – map + plot + + + + +
+

Map + Plot with provenance

+
+
+ +
+
+
+
+
+ +
+
+ + + + diff --git a/examples/src/autk-provenance/map-plot-provenance.ts b/examples/src/autk-provenance/map-plot-provenance.ts new file mode 100644 index 00000000..8ab27b04 --- /dev/null +++ b/examples/src/autk-provenance/map-plot-provenance.ts @@ -0,0 +1,57 @@ +import { FeatureCollection } from 'geojson'; +import { PlotEvent, Scatterplot } from 'autk-plot'; +import { AutkMap, MapEvent, VectorLayer } from 'autk-map'; +import { + createAutarkProvenance, + renderProvenanceTrailUI, +} from 'autk-provenance'; + +async function main() { + const geojson = await fetch('http://localhost:5173/data/mnt_neighs_proj.geojson').then( + (res) => res.json() as Promise + ); + + const canvas = document.querySelector('canvas') as HTMLCanvasElement; + const plotBody = document.querySelector('#plotBody') as HTMLElement; + const trailContainer = document.querySelector('#provenanceTrail') as HTMLElement; + + if (!canvas || !plotBody) { + console.error('Canvas or plot body not found'); + return; + } + + const map = new AutkMap(canvas); + await map.init(); + map.loadGeoJsonLayer('neighborhoods', geojson); + map.updateRenderInfoProperty('neighborhoods', 'isPick', true); + map.draw(); + + const plot = new Scatterplot({ + div: plotBody, + data: geojson, + labels: { axis: ['shape_area', 'shape_leng'], title: 'Plot with provenance' }, + width: 790, + events: [PlotEvent.BRUSH], + }); + + map.mapEvents.addEventListener(MapEvent.PICK, (selection: number[]) => { + plot.setHighlightedIds(selection); + }); + plot.plotEvents.addEventListener(PlotEvent.BRUSH, (selection: number[]) => { + const layer = map.layerManager.searchByLayerId('neighborhoods') as VectorLayer | null; + if (layer) layer.setHighlightedIds(selection); + }); + + const provenance = createAutarkProvenance({ map, plot }); + + if (trailContainer) { + renderProvenanceTrailUI({ + provenance, + container: trailContainer, + showBackForward: true, + showTimestamps: true, + }); + } +} + +main(); diff --git a/gallery/package.json b/gallery/package.json index 15712c06..1f19cdcb 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -25,6 +25,7 @@ "autk-db": "file:../autk-db", "autk-map": "file:../autk-map", "autk-plot": "file:../autk-plot", - "autk-compute": "file:../autk-compute" + "autk-compute": "file:../autk-compute", + "autk-provenance": "file:../autk-provenance" } } diff --git a/test.mmd b/test.mmd new file mode 100644 index 00000000..7733f25d --- /dev/null +++ b/test.mmd @@ -0,0 +1,103 @@ +flowchart TB + %% ========================= + %% VA Systems + %% ========================= + subgraph VA["VA Systems (Producers)"] + AUTARK["Autark (autk-db, autk-compute, autk-map, autk-plot)"] + EXT1["Other VA System A"] + EXT2["Other VA System B"] + end + + %% ========================= + %% Instrumentation + %% ========================= + subgraph INSTR["Instrumentation Layer"] + HOOKS["provkit/hooks\n(wrapAsync, lifecycle, context propagation)"] + SINK["ProvenanceSink.emit(event)"] + end + + AUTARK --> HOOKS + AUTARK --> SINK + EXT1 --> HOOKS + EXT1 --> SINK + EXT2 --> HOOKS + EXT2 --> SINK + + %% ========================= + %% Adapters + %% ========================= + subgraph ADP["provkit/adapters"] + AUTK_ADP["adapters/autark\n(autark event mapping)"] + GEN_ADP["adapters/generic\n(config-driven fallback)"] + FUT_ADP["adapters/\n(pluggable)"] + end + + HOOKS --> AUTK_ADP + HOOKS --> GEN_ADP + SINK --> AUTK_ADP + SINK --> GEN_ADP + EXT1 -. new support .-> FUT_ADP + EXT2 -. new support .-> FUT_ADP + + %% ========================= + %% Core + %% ========================= + subgraph CORE["provkit/core"] + TRACKER["Tracker API\n(startActivity/endActivity,\nrecordEntity, recordRelation, ingest)"] + MODEL["Canonical Model\nEntity, Activity, Agent, Relation"] + VALID["Validation + Schema Versioning"] + end + + AUTK_ADP --> TRACKER + GEN_ADP --> TRACKER + FUT_ADP --> TRACKER + TRACKER --> MODEL + TRACKER --> VALID + + %% ========================= + %% Storage + %% ========================= + subgraph STORE["provkit/stores"] + EVENTLOG["Append-only Event Log"] + INDEX["Read Model / Graph Index\n(adjacency, run/session/time indexes)"] + MEM["Memory Store"] + IDB["IndexedDB Store"] + SQL["SQL/Remote Store (future)"] + end + + TRACKER --> EVENTLOG + EVENTLOG --> INDEX + MEM --> EVENTLOG + IDB --> EVENTLOG + SQL --> EVENTLOG + + %% ========================= + %% Query + Export + %% ========================= + subgraph QX["provkit/query + provkit/export"] + LINEAGE["Lineage Query (upstream)"] + IMPACT["Impact Query (downstream)"] + HISTORY["History Query (run/session/time)"] + EXPORT["Export: JSON / PROV-JSON"] + end + + INDEX --> LINEAGE + INDEX --> IMPACT + INDEX --> HISTORY + MODEL --> EXPORT + INDEX --> EXPORT + + %% ========================= + %% Consumers + %% ========================= + subgraph CONS["Consumers"] + UI["VA App UI / Debug Panel"] + AUDIT["Audit / Reproducibility Reports"] + API["External APIs / Data Products"] + end + + LINEAGE --> UI + IMPACT --> UI + HISTORY --> UI + EXPORT --> AUDIT + EXPORT --> API diff --git a/usecases/vite.config.ts b/usecases/vite.config.ts index 4fe35c51..058e3537 100644 --- a/usecases/vite.config.ts +++ b/usecases/vite.config.ts @@ -14,9 +14,9 @@ export function pluginWatchNodeModules(modules: string[]) { } export default defineConfig({ - plugins: [pluginWatchNodeModules(['autk-core', 'autk-map', 'autk-db', 'autk-plot', 'autk-compute'])], + plugins: [pluginWatchNodeModules(['autk-map', 'autk-db', 'autk-plot', 'autk-compute', 'autk-provenance'])], optimizeDeps: { - exclude: ['autk-core', 'autk-map', 'autk-db', 'autk-plot', 'autk-compute'], + exclude: ['autk-map', 'autk-db', 'autk-plot', 'autk-compute', 'autk-provenance'], }, server: { From 3b43caf3588d550df23a5addfc8ada1c352f1ee5 Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Mon, 9 Mar 2026 00:14:53 -0500 Subject: [PATCH 02/21] provenance implementation --- README.md | 8 + autk-provenance/README.md | 178 +++++ autk-provenance/src/adapters/db-adapter.ts | 313 +++++++-- autk-provenance/src/adapters/map-adapter.ts | 311 ++++++++- autk-provenance/src/adapters/plot-adapter.ts | 38 +- autk-provenance/src/core.ts | 6 + .../src/create-autark-provenance.ts | 19 +- autk-provenance/src/provenance-trail-ui.ts | 637 +++++++++++++++++- autk-provenance/src/types.ts | 46 +- .../autk-provenance/map-plot-provenance.css | 228 +++++++ .../autk-provenance/map-plot-provenance.html | 79 +-- .../autk-provenance/map-plot-provenance.ts | 28 +- examples/src/shared/example-shell.css | 193 ++++++ .../src/autk-map/colormap-categorical.html | 1 + gallery/src/autk-map/colormap-diverging.html | 1 + gallery/src/autk-map/compute-function.html | 1 + .../src/autk-map/compute-osm-function.html | 1 + .../src/autk-map/geojson-boundaries-vis.html | 1 + gallery/src/autk-map/geojson-lines-vis.html | 1 + gallery/src/autk-map/geojson-vis.html | 1 + gallery/src/autk-map/heatmap-vis.html | 1 + gallery/src/autk-map/layer-opacity.html | 1 + .../src/autk-map/osm-layers-api-chicago.html | 1 + .../src/autk-map/osm-layers-api-multi.html | 1 + .../src/autk-map/osm-layers-api-niteroi.html | 1 + gallery/src/autk-map/osm-layers-api.html | 1 + .../src/autk-map/spatial-join-buildings.html | 1 + gallery/src/autk-map/spatial-join-multi.html | 1 + gallery/src/autk-map/spatial-join-near.html | 1 + gallery/src/autk-map/spatial-join.html | 1 + .../src/autk-map/standalone-geojson-vis.html | 1 + .../standalone-points-geojson-vis.html | 1 + gallery/src/autk-plot/barchart-click.css | 198 ++++-- gallery/src/autk-plot/barchart-click.html | 13 +- gallery/src/autk-plot/heatmatrix-click.html | 43 -- gallery/src/autk-plot/histogram-brush.html | 43 -- gallery/src/autk-plot/table-click.html | 3 +- 37 files changed, 2080 insertions(+), 323 deletions(-) create mode 100644 autk-provenance/README.md create mode 100644 examples/src/autk-provenance/map-plot-provenance.css create mode 100644 examples/src/shared/example-shell.css diff --git a/README.md b/README.md index 70a8d658..978f8e54 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ Autark is available as an umbrella package plus individual modules: * `autk-map`: A map visualization library that allows the exploration of 2D and 3D physical and thematical layers. * `autk-plot`: A d3.js based plot library designed to consume urban data in standard formats and create linked views. +## Provenance Module + +Autark also includes a provenance module (`autk-provenance`) for recording and replaying user analysis history across map, plot, and database interactions. + +For detailed provenance startup, verification, and testing instructions, see: + +- `autk-provenance/README.md` + For demonstration purposes and to facilitate the adoption of Autark, we created a large collection of simple examples illustrating the core functionalities of each module. We also provide several examples on how to combine several modules to build complex applications. All examples are organized in the `example/` directory. ## Installation diff --git a/autk-provenance/README.md b/autk-provenance/README.md new file mode 100644 index 00000000..5fa86b69 --- /dev/null +++ b/autk-provenance/README.md @@ -0,0 +1,178 @@ +# Autark Provenance: Startup, Verification, and Testing Guide + +This guide explains how to run the provenance example, verify that provenance is working in the UI, and test core provenance behavior (trail, graph navigation, and state replay). + +## What This Covers + +- Start the project with provenance enabled +- Open the provenance example page +- Verify that the provenance model is recording events +- Validate that going to older nodes restores full state +- Run quick build-level checks for `autk-provenance` + +## Prerequisites + +- Node.js installed +- Dependencies installed in workspace +- WebGPU enabled browser (Chrome/Edge recommended) + +From project root: + +```bash +make install +``` + +## Startup Options + +### Option A: Full workspace (recommended) + +From project root: + +```bash +make dev +``` + +This builds and watches all modules (`autk-map`, `autk-db`, `autk-plot`, `autk-compute`, `autk-provenance`) and starts the examples Vite server. + +### Option B: Faster provenance-focused workflow + +Terminal 1: + +```bash +cd autk-provenance +npm run dev-build +``` + +Terminal 2: + +```bash +cd examples +npm run dev +``` + +## Open the Provenance UI + +After the dev server starts, open: + +```text +http://localhost:5173/src/autk-provenance/map-plot-provenance.html +``` + +Expected page sections: + +- `Map View` +- `Scatterplot` +- `Provenance Trail` (right panel) +- Graph preview inside the provenance panel + +## How to Check Provenance Is Working + +1. Interact with map and plot: +- Click map features (pick selection) +- Brush in scatterplot +- Clear brush + +2. Interact with map UI controls: +- Open/close hamburger menu +- Toggle visible layers +- Change active layer +- Toggle thematic checkbox + +3. Confirm provenance updates: +- Trail list should append new entries with timestamps +- Back/Forward buttons should enable when navigation is possible +- Graph preview should show branching nodes + +4. Open graph modal: +- Click graph preview (or `Open Graph`) +- Use `+`, `−`, `Fit`, wheel zoom, and drag-pan +- Click a node in modal: app should restore to that node state + +## What Should Be Replayed on Node Navigation + +When you click a node (trail or graph), the app should restore: + +- Map selection + plot selection consistency +- Active layer +- Visible layers +- Thematic checkbox state +- Thematic legend visibility +- Menu open/closed state + +If you jump to a node before thematic was enabled, thematic and legend should both be off. + +## Manual Test Checklist (Recommended) + +### 1. Startup + data load tracking + +1. Reload page +2. Confirm early trail entries for DB/map initialization and layer loading +3. Confirm `Start` root node exists + +### 2. Branch navigation + +1. Do flow A: pick map feature(s) +2. Go back to earlier node +3. Do flow B: brush different scatterplot points +4. Open graph modal and confirm visible branch structure +5. Click nodes from both branches and verify state replay + +### 3. Redundant clear dedupe + +1. Clear plot once -> should log clear event +2. Clear again immediately (no state change) -> should NOT log extra clear +3. Make new selection, then clear -> should log clear again + +### 4. Cross-view consistency + +1. Select via map -> plot highlights should sync +2. Select via plot -> map highlights should sync +3. Clear plot -> map highlights should also clear + +### 5. Thematic rollback + +1. Enable thematic and choose properties +2. Create additional provenance steps +3. Jump to node created before thematic enable +4. Verify thematic checkbox + legend + rendering are reverted + +## Build / Smoke Checks + +Run provenance package build: + +```bash +cd autk-provenance +npm run build +``` + +This validates TypeScript + bundling for `autk-provenance`. + +## Optional Debug Hook (for console inspection) + +If you want to inspect graph/state in browser console, temporarily expose the API in: + +`examples/src/autk-provenance/map-plot-provenance.ts` + +```ts +// after createAutarkProvenance(...) +(window as any).__autkProvenance = provenance; +``` + +Then in DevTools: + +```js +__autkProvenance.getCurrentState(); +__autkProvenance.getGraph(); +__autkProvenance.exportGraph(); +``` + +## Troubleshooting + +- Provenance panel empty: + - Confirm you opened `/src/autk-provenance/map-plot-provenance.html`, not another example. +- UI interactions not recorded: + - Ensure page is using latest `autk-provenance` build/watch output. +- Node click does not restore as expected: + - Check browser console for runtime errors. + - Reproduce with a fresh reload and minimal sequence, then compare trail entries. + diff --git a/autk-provenance/src/adapters/db-adapter.ts b/autk-provenance/src/adapters/db-adapter.ts index 024ea6ca..13c428a7 100644 --- a/autk-provenance/src/adapters/db-adapter.ts +++ b/autk-provenance/src/adapters/db-adapter.ts @@ -7,34 +7,103 @@ export type DbRecordCallback = ( stateDelta: Partial ) => void; +type DbTableLike = { + name: string; + source?: string; + type?: string; +}; + export interface IDbForProvenance { getCurrentWorkspace(): string; - getWorkspaces(): string[]; + getWorkspaces?(): string[]; setWorkspace(name: string): Promise; - tables: Array<{ name: string }>; + tables: DbTableLike[]; + init?(): Promise; + loadOsmFromOverpassApi?(params: { outputTableName?: string }): Promise; + loadCsv?(params: { outputTableName?: string }): Promise; + loadJson?(params: { outputTableName?: string }): Promise; + loadLayer?(params: { layer?: string; outputTableName?: string }): Promise; + loadCustomLayer?(params: { outputTableName?: string }): Promise; + loadGridLayer?(params: { outputTableName?: string }): Promise; + getLayer?(layerTableName: string): Promise; + spatialJoin?(params: { tableRootName?: string; tableJoinName?: string }): Promise; + updateTable?(params: { tableName?: string }): Promise; + rawQuery?(params: { output?: { type?: string } }): Promise; + buildHeatmap?(params: { outputTableName?: string }): Promise; } export interface DbAdapterApi { + startRecording(): void; + stopRecording(): void; + bootstrapFromCurrentState(): void; + recordInit(): void; recordWorkspace(name: string): void; recordLoadOsm(outputTableName: string): void; recordLoadCsv(outputTableName: string): void; recordLoadJson(outputTableName: string): void; + recordLoadLayer(outputTableName: string): void; recordLoadCustomLayer(outputTableName: string): void; recordLoadGridLayer(outputTableName: string): void; + recordGetLayer(outputTableName: string): void; recordSpatialJoin(params: { tableRootName?: string; tableJoinName?: string }): void; recordUpdateTable(tableName: string): void; recordDropTable(tableName: string): void; + recordRawQuery(label?: string): void; + recordBuildHeatmap(outputTableName: string): void; applyState(state: AutarkProvenanceState): Promise; } +function inferName(result: unknown, fallback = 'table'): string { + if (result && typeof result === 'object' && 'name' in result) { + const maybeName = (result as { name?: unknown }).name; + if (typeof maybeName === 'string' && maybeName.trim().length > 0) return maybeName; + } + return fallback; +} + +function isFn(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === 'function'; +} + export function createDbAdapter( db: IDbForProvenance, onRecord: DbRecordCallback ): DbAdapterApi { + const dbObj = db as unknown as Record; + const originalMethods = new Map(); + let isRecording = false; + let isApplyingState = false; + let didBootstrap = false; + function getCurrentLayerNames(): string[] { return (db.tables || []).map((t) => t.name); } + function getWorkspaceSafe(): string { + try { + return db.getCurrentWorkspace(); + } catch { + return 'main'; + } + } + + function record( + actionType: ProvenanceAction | string, + actionLabel: string, + layerTableNames: string[] = getCurrentLayerNames() + ): void { + onRecord(actionType, actionLabel, { + data: { + workspace: getWorkspaceSafe(), + layerTableNames, + }, + }); + } + + function recordInit(): void { + record(ProvenanceAction.DB_INIT, 'Database initialized'); + } + function recordWorkspace(name: string): void { onRecord(ProvenanceAction.DB_WORKSPACE, `Workspace: ${name}`, { data: { @@ -47,129 +116,243 @@ export function createDbAdapter( function recordLoadOsm(outputTableName: string): void { const names = getCurrentLayerNames(); if (!names.includes(outputTableName)) names.push(outputTableName); - onRecord(ProvenanceAction.DB_LOAD_OSM, `Load OSM: ${outputTableName}`, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: names, - }, - }); + record(ProvenanceAction.DB_LOAD_OSM, `Load OSM: ${outputTableName}`, names); } function recordLoadCsv(outputTableName: string): void { const names = getCurrentLayerNames(); if (!names.includes(outputTableName)) names.push(outputTableName); - onRecord(ProvenanceAction.DB_LOAD_CSV, `Load CSV: ${outputTableName}`, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: names, - }, - }); + record(ProvenanceAction.DB_LOAD_CSV, `Load CSV: ${outputTableName}`, names); } function recordLoadJson(outputTableName: string): void { const names = getCurrentLayerNames(); if (!names.includes(outputTableName)) names.push(outputTableName); - onRecord(ProvenanceAction.DB_LOAD_JSON, `Load JSON: ${outputTableName}`, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: names, - }, - }); + record(ProvenanceAction.DB_LOAD_JSON, `Load JSON: ${outputTableName}`, names); + } + + function recordLoadLayer(outputTableName: string): void { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + record(ProvenanceAction.DB_LOAD_LAYER, `Load layer: ${outputTableName}`, names); } function recordLoadCustomLayer(outputTableName: string): void { const names = getCurrentLayerNames(); if (!names.includes(outputTableName)) names.push(outputTableName); - onRecord(ProvenanceAction.DB_LOAD_CUSTOM_LAYER, `Load custom layer: ${outputTableName}`, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: names, - }, - }); + record(ProvenanceAction.DB_LOAD_CUSTOM_LAYER, `Load custom layer: ${outputTableName}`, names); } function recordLoadGridLayer(outputTableName: string): void { const names = getCurrentLayerNames(); if (!names.includes(outputTableName)) names.push(outputTableName); - onRecord(ProvenanceAction.DB_LOAD_GRID_LAYER, `Load grid layer: ${outputTableName}`, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: names, - }, - }); + record(ProvenanceAction.DB_LOAD_GRID_LAYER, `Load grid layer: ${outputTableName}`, names); + } + + function recordGetLayer(outputTableName: string): void { + record(ProvenanceAction.DB_GET_LAYER, `Get layer: ${outputTableName}`); } function recordSpatialJoin(params: { tableRootName?: string; tableJoinName?: string }): void { const label = `Spatial join: ${params.tableRootName ?? '?'} + ${params.tableJoinName ?? '?'}`; - onRecord(ProvenanceAction.DB_SPATIAL_JOIN, label, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: getCurrentLayerNames(), - }, - }); + record(ProvenanceAction.DB_SPATIAL_JOIN, label); } function recordUpdateTable(tableName: string): void { - onRecord(ProvenanceAction.DB_UPDATE_TABLE, `Update table: ${tableName}`, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: getCurrentLayerNames(), - }, - }); + record(ProvenanceAction.DB_UPDATE_TABLE, `Update table: ${tableName}`); } function recordDropTable(tableName: string): void { const names = getCurrentLayerNames().filter((n) => n !== tableName); - onRecord(ProvenanceAction.DB_DROP_TABLE, `Drop table: ${tableName}`, { - data: { - workspace: db.getCurrentWorkspace(), - layerTableNames: names, - }, + record(ProvenanceAction.DB_DROP_TABLE, `Drop table: ${tableName}`, names); + } + + function recordRawQuery(label = 'Raw query executed'): void { + record(ProvenanceAction.DB_RAW_QUERY, label); + } + + function recordBuildHeatmap(outputTableName: string): void { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + record(ProvenanceAction.DB_BUILD_HEATMAP, `Build heatmap: ${outputTableName}`, names); + } + + function recordTableBySource(table: DbTableLike): void { + switch (table.source) { + case 'csv': + recordLoadCsv(table.name); + return; + case 'json': + recordLoadJson(table.name); + return; + case 'osm': + if (table.type === 'pointset') recordLoadOsm(table.name); + else recordLoadLayer(table.name); + return; + case 'geojson': + recordLoadCustomLayer(table.name); + return; + case 'user': + if (table.type === 'pointset') { + record(ProvenanceAction.DB_OTHER, `User table: ${table.name}`); + } else { + recordLoadGridLayer(table.name); + } + return; + default: + record(ProvenanceAction.DB_OTHER, `Table available: ${table.name}`); + } + } + + function bootstrapFromCurrentState(): void { + if (didBootstrap) return; + didBootstrap = true; + + recordInit(); + recordWorkspace(getWorkspaceSafe()); + for (const table of db.tables || []) { + recordTableBySource(table); + } + } + + function wrapAsyncMethod( + methodName: string, + onAfter: (args: unknown[], result: unknown) => void + ): void { + const current = dbObj[methodName]; + if (!isFn(current) || originalMethods.has(methodName)) return; + const original = current; + originalMethods.set(methodName, original); + + dbObj[methodName] = async function (...args: unknown[]) { + const result = await (original as (...a: unknown[]) => unknown).apply(this, args); + if (isRecording && !isApplyingState) { + onAfter(args, result); + } + return result; + }; + } + + function restoreWrappedMethods(): void { + for (const [methodName, original] of originalMethods.entries()) { + dbObj[methodName] = original; + } + originalMethods.clear(); + } + + function startRecording(): void { + if (isRecording) return; + isRecording = true; + + bootstrapFromCurrentState(); + + wrapAsyncMethod('init', () => { + recordInit(); + }); + wrapAsyncMethod('setWorkspace', (args) => { + const [name] = args; + recordWorkspace(typeof name === 'string' ? name : getWorkspaceSafe()); + }); + wrapAsyncMethod('loadOsmFromOverpassApi', (args) => { + const params = (args[0] ?? {}) as { outputTableName?: string }; + recordLoadOsm(params.outputTableName ?? 'osm'); + }); + wrapAsyncMethod('loadCsv', (args, result) => { + const params = (args[0] ?? {}) as { outputTableName?: string }; + recordLoadCsv(params.outputTableName ?? inferName(result, 'csv')); + }); + wrapAsyncMethod('loadJson', (args, result) => { + const params = (args[0] ?? {}) as { outputTableName?: string }; + recordLoadJson(params.outputTableName ?? inferName(result, 'json')); + }); + wrapAsyncMethod('loadLayer', (args, result) => { + const params = (args[0] ?? {}) as { layer?: string; outputTableName?: string }; + recordLoadLayer(params.outputTableName ?? params.layer ?? inferName(result, 'layer')); }); + wrapAsyncMethod('loadCustomLayer', (args, result) => { + const params = (args[0] ?? {}) as { outputTableName?: string }; + recordLoadCustomLayer(params.outputTableName ?? inferName(result, 'custom-layer')); + }); + wrapAsyncMethod('loadGridLayer', (args, result) => { + const params = (args[0] ?? {}) as { outputTableName?: string }; + recordLoadGridLayer(params.outputTableName ?? inferName(result, 'grid-layer')); + }); + wrapAsyncMethod('getLayer', (args) => { + const [tableName] = args; + recordGetLayer(typeof tableName === 'string' ? tableName : 'layer'); + }); + wrapAsyncMethod('spatialJoin', (args) => { + const params = (args[0] ?? {}) as { tableRootName?: string; tableJoinName?: string }; + recordSpatialJoin(params); + }); + wrapAsyncMethod('updateTable', (args, result) => { + const params = (args[0] ?? {}) as { tableName?: string }; + recordUpdateTable(params.tableName ?? inferName(result, 'table')); + }); + wrapAsyncMethod('rawQuery', (args) => { + const params = (args[0] ?? {}) as { output?: { type?: string } }; + const label = params.output?.type === 'CREATE_TABLE' + ? 'Raw query created table' + : 'Raw query executed'; + recordRawQuery(label); + }); + wrapAsyncMethod('buildHeatmap', (args, result) => { + const params = (args[0] ?? {}) as { outputTableName?: string }; + recordBuildHeatmap(params.outputTableName ?? inferName(result, 'heatmap')); + }); + } + + function stopRecording(): void { + if (!isRecording) return; + isRecording = false; + restoreWrappedMethods(); } async function applyState(state: AutarkProvenanceState): Promise { const data = state.data; if (!data?.workspace) return; - const current = db.getCurrentWorkspace(); + const current = getWorkspaceSafe(); if (current !== data.workspace) { - await db.setWorkspace(data.workspace); + isApplyingState = true; + try { + await db.setWorkspace(data.workspace); + } finally { + isApplyingState = false; + } } } return { + startRecording, + stopRecording, + bootstrapFromCurrentState, + recordInit, recordWorkspace, recordLoadOsm, recordLoadCsv, recordLoadJson, + recordLoadLayer, recordLoadCustomLayer, recordLoadGridLayer, + recordGetLayer, recordSpatialJoin, recordUpdateTable, recordDropTable, + recordRawQuery, + recordBuildHeatmap, applyState, }; } /** - * Wraps a SpatialDb-like instance so that mutating methods automatically record provenance. - * Use this when you want DB actions to be tracked without calling record* manually. - * The wrapped db has the same interface as the original; pass it to createAutarkProvenance as db. + * Starts automatic provenance tracking for a SpatialDb-like instance. + * The same db instance is returned for convenience. */ export function createDbProvenanceWrapper( db: T, onRecord: DbRecordCallback ): T { const adapter = createDbAdapter(db, onRecord); - return new Proxy(db, { - get(target, prop, receiver) { - if (prop === 'setWorkspace') { - return async function (name: string) { - await (target as IDbForProvenance).setWorkspace(name); - adapter.recordWorkspace(name); - }; - } - return Reflect.get(target, prop, receiver); - }, - }) as T; + adapter.startRecording(); + return db; } diff --git a/autk-provenance/src/adapters/map-adapter.ts b/autk-provenance/src/adapters/map-adapter.ts index dfe0e378..183c90d5 100644 --- a/autk-provenance/src/adapters/map-adapter.ts +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -3,6 +3,20 @@ import { ProvenanceAction } from '../types'; const MAP_PICK_EVENT = 'pick'; +type LayerLike = { + layerInfo?: { id: string }; + setHighlightedIds?(ids: number[]): void; + clearHighlightedIds?(): void; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; +}; + +type ResolvedUiState = { + mapMenuOpen: boolean; + activeLayerId: string | null; + visibleLayerIds: string[]; + thematicEnabled: boolean; +}; + export type MapRecordCallback = ( actionType: ProvenanceAction | string, actionLabel: string, @@ -15,14 +29,174 @@ export interface MapAdapterApi { applyState(state: AutarkProvenanceState): void; } +function isElement(value: unknown): value is Element { + return !!value && typeof value === 'object' && 'nodeType' in value; +} + export function createMapAdapter( map: IMapForProvenance, onRecord: MapRecordCallback ): MapAdapterApi { + const mapObj = map as unknown as Record; let pickListener: ((selection: number[], layerId: string) => void) | null = null; + let clickListener: ((event: Event) => void) | null = null; + let changeListener: ((event: Event) => void) | null = null; + let isApplyingState = false; + + const wrappedMapMethods = new Map(); + + function getAllLayers(): LayerLike[] { + return [...(map.layerManager.vectorLayers ?? []), ...(map.layerManager.rasterLayers ?? [])] as LayerLike[]; + } + + function getVisibleLayerIds(): string[] { + return getAllLayers() + .filter((layer) => !layer.layerRenderInfo?.isSkip) + .map((layer) => layer.layerInfo?.id) + .filter((id): id is string => typeof id === 'string'); + } + + function getActiveLayerId(): string | null { + return map.ui?.activeLayer?.layerInfo?.id ?? null; + } + + function getThematicEnabled(): boolean { + return !!map.ui?.activeLayer?.layerRenderInfo?.isColorMap; + } + + function getMenuOpen(): boolean { + const parent = map.canvas.parentElement; + if (!parent) return false; + const submenu = parent.querySelector('#autkMapSubMenu') as HTMLElement | null; + return submenu ? submenu.style.visibility === 'visible' : false; + } + + function getAllLayerIds(): string[] { + return getAllLayers() + .map((layer) => layer.layerInfo?.id) + .filter((id): id is string => typeof id === 'string'); + } + + function getDefaultActiveLayerId(): string | null { + return getActiveLayerId() ?? map.layerManager.vectorLayers?.[0]?.layerInfo?.id ?? null; + } + + function resolveUiState(ui: AutarkProvenanceState['ui']): ResolvedUiState { + const allLayerIds = getAllLayerIds(); + return { + mapMenuOpen: ui?.mapMenuOpen ?? false, + activeLayerId: ui?.activeLayerId ?? getDefaultActiveLayerId(), + visibleLayerIds: Array.isArray(ui?.visibleLayerIds) ? ui.visibleLayerIds : allLayerIds, + thematicEnabled: ui?.thematicEnabled ?? false, + }; + } + + function setLayerRenderFlag(layerId: string, property: 'isSkip' | 'isColorMap', value: boolean): void { + if (map.updateRenderInfoProperty) { + map.updateRenderInfoProperty(layerId, property, value); + return; + } + const layer = map.layerManager.searchByLayerId(layerId); + if (layer?.layerRenderInfo) { + layer.layerRenderInfo[property] = value; + } + } + + function syncUiDom(ui: ResolvedUiState): void { + const parent = map.canvas.parentElement; + if (!parent) return; + + const submenu = parent.querySelector('#autkMapSubMenu') as HTMLElement | null; + if (submenu) submenu.style.visibility = ui.mapMenuOpen ? 'visible' : 'hidden'; + + const thematicCheckbox = parent.querySelector('#showThematicCheckbox') as HTMLInputElement | null; + if (thematicCheckbox) thematicCheckbox.checked = ui.thematicEnabled; + + const legend = parent.querySelector('#autkMapLegend') as HTMLElement | null; + if (legend) legend.style.visibility = ui.thematicEnabled ? 'visible' : 'hidden'; + + const visibleSet = new Set(ui.visibleLayerIds); + const visibleCheckboxes = parent.querySelectorAll( + '#visibleLayerDropdownList input[type="checkbox"]' + ) as NodeListOf; + visibleCheckboxes.forEach((checkbox) => { + checkbox.checked = visibleSet.has(checkbox.value); + }); + + const activeRadios = parent.querySelectorAll('.active-layer-radio') as NodeListOf; + activeRadios.forEach((radio) => { + radio.checked = !!ui.activeLayerId && radio.value === ui.activeLayerId; + }); + } + + function inMapContainer(target: EventTarget | null): boolean { + if (!isElement(target)) return false; + const parent = map.canvas.parentElement; + return !!parent && parent.contains(target); + } + + function buildUiDelta(overrides: Partial> = {}): Partial { + return { + ui: { + mapMenuOpen: getMenuOpen(), + activeLayerId: getActiveLayerId(), + visibleLayerIds: getVisibleLayerIds(), + thematicEnabled: getThematicEnabled(), + ...overrides, + }, + }; + } + + function recordUiEvent( + actionType: ProvenanceAction, + actionLabel: string, + overrides: Partial> = {} + ): void { + onRecord(actionType, actionLabel, buildUiDelta(overrides)); + } + + function wrapMapMethod( + methodName: string, + onAfter: (args: unknown[]) => void + ): void { + const current = mapObj[methodName]; + if (typeof current !== 'function' || wrappedMapMethods.has(methodName)) return; + const original = current; + wrappedMapMethods.set(methodName, original); + + mapObj[methodName] = function (...args: unknown[]) { + const result = (original as (...a: unknown[]) => unknown).apply(this, args); + if (!isApplyingState) { + onAfter(args); + } + return result; + }; + } + + function restoreWrappedMapMethods(): void { + for (const [methodName, original] of wrappedMapMethods.entries()) { + mapObj[methodName] = original; + } + wrappedMapMethods.clear(); + } function startRecording(): void { - if (pickListener) return; + if (pickListener || clickListener || changeListener) return; + + wrapMapMethod('init', () => { + onRecord(ProvenanceAction.MAP_INIT, 'Map initialized', buildUiDelta()); + }); + wrapMapMethod('loadGeoJsonLayer', (args) => { + const [layerName] = args; + const name = typeof layerName === 'string' ? layerName : 'layer'; + onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Map layer loaded: ${name}`, buildUiDelta()); + }); + wrapMapMethod('loadGeoTiffLayer', (args) => { + const [layerName] = args; + const name = typeof layerName === 'string' ? layerName : 'raster'; + onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${name}`, buildUiDelta()); + }); + pickListener = (selection: number[], layerId: string) => { const label = selection.length === 0 @@ -31,10 +205,63 @@ export function createMapAdapter( onRecord(ProvenanceAction.MAP_PICK, label, { selection: { map: { layerId, ids: selection }, + plot: selection, }, - } as Partial); + }); }; map.mapEvents.addEventListener(MAP_PICK_EVENT, pickListener); + + if (typeof document !== 'undefined') { + clickListener = (event: Event) => { + if (isApplyingState || !inMapContainer(event.target)) return; + const target = event.target; + if (!isElement(target)) return; + + if (target.closest('#menuIcon')) { + recordUiEvent( + ProvenanceAction.MAP_UI_MENU_TOGGLE, + getMenuOpen() ? 'Opened map menu' : 'Closed map menu', + { mapMenuOpen: getMenuOpen() } + ); + } + }; + document.addEventListener('click', clickListener); + + changeListener = (event: Event) => { + if (isApplyingState || !inMapContainer(event.target)) return; + const target = event.target; + if (!(target instanceof HTMLInputElement)) return; + + if (target.id === 'showThematicCheckbox') { + recordUiEvent( + ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, + target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', + { thematicEnabled: target.checked } + ); + return; + } + + if (target.classList.contains('active-layer-radio')) { + const activeLayerId = getActiveLayerId() ?? target.value; + recordUiEvent( + ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, + `Active layer: ${activeLayerId}`, + { activeLayerId } + ); + return; + } + + if (target.type === 'checkbox' && target.closest('#visibleLayerDropdownList')) { + const layerId = target.value || 'layer'; + recordUiEvent( + ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, + target.checked ? `Show layer: ${layerId}` : `Hide layer: ${layerId}`, + { visibleLayerIds: getVisibleLayerIds() } + ); + } + }; + document.addEventListener('change', changeListener); + } } function stopRecording(): void { @@ -42,38 +269,72 @@ export function createMapAdapter( map.mapEvents.removeEventListener(MAP_PICK_EVENT, pickListener); pickListener = null; } + if (clickListener && typeof document !== 'undefined') { + document.removeEventListener('click', clickListener); + clickListener = null; + } + if (changeListener && typeof document !== 'undefined') { + document.removeEventListener('change', changeListener); + changeListener = null; + } + restoreWrappedMapMethods(); } function applyState(state: AutarkProvenanceState): void { - const { selection } = state; - if (!selection) return; - - const layers = map.layerManager.vectorLayers; - if (layers) { - for (const layer of layers) { - const id = layer.layerInfo?.id; - if (id === selection.map?.layerId) { - if (selection.map && selection.map.ids.length > 0) { - layer.setHighlightedIds(selection.map.ids); - } else { - layer.clearHighlightedIds(); - } - } else { - layer.clearHighlightedIds(); + isApplyingState = true; + try { + const { selection } = state; + const ui = resolveUiState(state.ui); + + const visibleLayerIds = new Set(ui.visibleLayerIds); + for (const layer of getAllLayers()) { + const layerId = layer.layerInfo?.id; + if (!layerId) continue; + setLayerRenderFlag(layerId, 'isSkip', !visibleLayerIds.has(layerId)); + } + + const parent = map.canvas.parentElement; + const thematicCheckbox = parent?.querySelector('#showThematicCheckbox') as HTMLInputElement | null; + if (thematicCheckbox) thematicCheckbox.checked = ui.thematicEnabled; + + if (ui.activeLayerId) { + const activeLayer = map.layerManager.searchByLayerId(ui.activeLayerId); + if (activeLayer && map.ui?.changeActiveLayer) { + map.ui.changeActiveLayer(activeLayer); } } - } else { - const layerId = selection.map?.layerId; - if (layerId) { - const layer = map.layerManager.searchByLayerId(layerId); - if (layer) { - if (selection.map && selection.map.ids.length > 0) { - layer.setHighlightedIds(selection.map.ids); + + for (const layer of getAllLayers()) { + const layerId = layer.layerInfo?.id; + if (!layerId) continue; + const shouldUseThematic = ui.thematicEnabled && !!ui.activeLayerId && layerId === ui.activeLayerId; + setLayerRenderFlag(layerId, 'isColorMap', shouldUseThematic); + } + + syncUiDom(ui); + + if (selection) { + const targetLayerId = selection.map?.layerId; + const targetIds = selection.map?.ids ?? []; + const fallbackPlotIds = selection.plot ?? []; + const fallbackLayerId = ui.activeLayerId; + + for (const layer of map.layerManager.vectorLayers ?? []) { + const layerId = layer.layerInfo?.id; + if (!layerId) continue; + const isMapTarget = !!targetLayerId && layerId === targetLayerId; + const isPlotTarget = !targetLayerId && !!fallbackLayerId && layerId === fallbackLayerId; + if (isMapTarget && targetIds.length > 0) { + layer.setHighlightedIds?.(targetIds); + } else if (isPlotTarget && fallbackPlotIds.length > 0) { + layer.setHighlightedIds?.(fallbackPlotIds); } else { - layer.clearHighlightedIds(); + layer.clearHighlightedIds?.(); } } } + } finally { + isApplyingState = false; } } diff --git a/autk-provenance/src/adapters/plot-adapter.ts b/autk-provenance/src/adapters/plot-adapter.ts index ade5ceb6..481906d5 100644 --- a/autk-provenance/src/adapters/plot-adapter.ts +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -18,23 +18,8 @@ export interface PlotAdapterApi { applyState(state: AutarkProvenanceState): void; } -function makeListener( - eventName: string, - actionType: ProvenanceAction, - onRecord: PlotRecordCallback -): (selection: number[]) => void { - return (selection: number[]) => { - const label = - selection.length === 0 - ? `Cleared plot selection` - : `${eventName}: ${selection.length} point(s) selected`; - onRecord(actionType, label, { - selection: { - map: null, - plot: selection, - }, - }); - }; +function selectionSignature(selection: number[]): string { + return selection.join(','); } export function createPlotAdapter( @@ -42,6 +27,7 @@ export function createPlotAdapter( onRecord: PlotRecordCallback ): PlotAdapterApi { const listeners: Array<{ event: string; fn: (selection: number[]) => void }> = []; + let lastSelectionSig: string | null = null; const events: Array<{ event: string; actionType: ProvenanceAction }> = [ { event: PLOT_CLICK, actionType: ProvenanceAction.PLOT_CLICK }, { event: PLOT_BRUSH, actionType: ProvenanceAction.PLOT_BRUSH }, @@ -52,7 +38,22 @@ export function createPlotAdapter( function startRecording(): void { if (listeners.length > 0) return; for (const { event, actionType } of events) { - const fn = makeListener(event, actionType, onRecord); + const fn = (selection: number[]) => { + const sig = selectionSignature(selection); + if (sig === lastSelectionSig) return; + lastSelectionSig = sig; + + const label = + selection.length === 0 + ? `Cleared plot selection` + : `${event}: ${selection.length} point(s) selected`; + onRecord(actionType, label, { + selection: { + map: null, + plot: selection, + }, + }); + }; listeners.push({ event, fn }); plot.plotEvents.addEventListener(event, fn); } @@ -70,6 +71,7 @@ export function createPlotAdapter( function applyState(state: AutarkProvenanceState): void { const plotSelection = state.selection?.plot; if (Array.isArray(plotSelection)) { + lastSelectionSig = selectionSignature(plotSelection); plot.setHighlightedIds(plotSelection); } } diff --git a/autk-provenance/src/core.ts b/autk-provenance/src/core.ts index f0cadeff..9b758778 100644 --- a/autk-provenance/src/core.ts +++ b/autk-provenance/src/core.ts @@ -20,6 +20,12 @@ function deepMergeState( plot: delta.selection?.plot ?? base.selection.plot, }, }; + if (base.ui || delta.ui) { + next.ui = { + ...(base.ui ?? {}), + ...(delta.ui ?? {}), + }; + } if (base.view || delta.view) next.view = delta.view ?? base.view; if (base.data || delta.data) next.data = delta.data ?? base.data; return next; diff --git a/autk-provenance/src/create-autark-provenance.ts b/autk-provenance/src/create-autark-provenance.ts index 4b3af0b0..55d02669 100644 --- a/autk-provenance/src/create-autark-provenance.ts +++ b/autk-provenance/src/create-autark-provenance.ts @@ -11,6 +11,11 @@ const DEFAULT_STATE: AutarkProvenanceState = { map: null, plot: [], }, + ui: { + mapMenuOpen: false, + activeLayerId: null, + thematicEnabled: false, + }, }; export interface CreateAutarkProvenanceOptions { @@ -41,9 +46,17 @@ export interface AutarkProvenanceApi { export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkProvenanceApi { const { map, plot, db, initialState: initialPartial } = options; const initialState: AutarkProvenanceState = { - ...DEFAULT_STATE, - ...initialPartial, + selection: { + map: initialPartial?.selection?.map ?? DEFAULT_STATE.selection.map, + plot: initialPartial?.selection?.plot ?? [...DEFAULT_STATE.selection.plot], + }, + ui: { + ...(DEFAULT_STATE.ui ?? {}), + ...(initialPartial?.ui ?? {}), + }, }; + if (initialPartial?.view) initialState.view = initialPartial.view; + if (initialPartial?.data) initialState.data = initialPartial.data; const core = createProvenanceCore({ initialState }); @@ -77,11 +90,13 @@ export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): function stopRecording(): void { mapAdapter?.stopRecording(); plotAdapter?.stopRecording(); + dbAdapter?.stopRecording(); } function startRecording(): void { mapAdapter?.startRecording(); plotAdapter?.startRecording(); + dbAdapter?.startRecording(); } startRecording(); diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts index e0b5e121..76d6e32a 100644 --- a/autk-provenance/src/provenance-trail-ui.ts +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -1,5 +1,4 @@ -import type { AutarkProvenanceState } from './types'; -import type { PathNode } from './types'; +import type { AutarkProvenanceState, PathNode, ProvenanceNode } from './types'; import type { AutarkProvenanceApi } from './create-autark-provenance'; export interface ProvenanceTrailUIOptions { @@ -7,19 +6,214 @@ export interface ProvenanceTrailUIOptions { container: HTMLElement; showBackForward?: boolean; showTimestamps?: boolean; + showGraph?: boolean; + showPathList?: boolean; } +type LayoutNode = { + node: ProvenanceNode; + x: number; + y: number; + depth: number; + row: number; +}; + +type LayoutEdge = { + from: string; + to: string; +}; + +type GraphLayout = { + nodes: LayoutNode[]; + edges: LayoutEdge[]; + width: number; + height: number; +}; + function formatTime(ts: number): string { const d = new Date(ts); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } +function truncate(text: string, max = 30): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}\u2026`; +} + +function buildGraphLayout( + nodesMap: Map>, + rootId: string +): GraphLayout { + const depth = new Map(); + const row = new Map(); + const visited = new Set(); + const edges: LayoutEdge[] = []; + + function assignDepth(nodeId: string, d: number): void { + if (visited.has(nodeId)) { + const prev = depth.get(nodeId); + if (prev === undefined || d < prev) depth.set(nodeId, d); + return; + } + visited.add(nodeId); + depth.set(nodeId, d); + const node = nodesMap.get(nodeId); + if (!node) return; + for (const childId of node.childrenIds) { + if (!nodesMap.has(childId)) continue; + edges.push({ from: nodeId, to: childId }); + assignDepth(childId, d + 1); + } + } + + assignDepth(rootId, 0); + + let nextLeafRow = 0; + function assignRow(nodeId: string): number { + if (row.has(nodeId)) return row.get(nodeId) ?? 0; + const node = nodesMap.get(nodeId); + if (!node) { + row.set(nodeId, nextLeafRow); + nextLeafRow += 1; + return row.get(nodeId) ?? 0; + } + + const validChildren = node.childrenIds.filter((id) => nodesMap.has(id)); + if (validChildren.length === 0) { + row.set(nodeId, nextLeafRow); + nextLeafRow += 1; + return row.get(nodeId) ?? 0; + } + + const childRows = validChildren.map(assignRow); + const avg = childRows.reduce((acc, n) => acc + n, 0) / childRows.length; + row.set(nodeId, avg); + return avg; + } + + assignRow(rootId); + + for (const nodeId of nodesMap.keys()) { + if (!depth.has(nodeId)) depth.set(nodeId, 0); + if (!row.has(nodeId)) { + row.set(nodeId, nextLeafRow); + nextLeafRow += 1; + } + } + + const xGap = 160; + const yGap = 64; + const marginX = 28; + const marginY = 24; + + const layoutNodes: LayoutNode[] = []; + let maxDepth = 0; + let maxRow = 0; + + for (const [id, node] of nodesMap.entries()) { + const d = depth.get(id) ?? 0; + const r = row.get(id) ?? 0; + maxDepth = Math.max(maxDepth, d); + maxRow = Math.max(maxRow, r); + layoutNodes.push({ + node, + depth: d, + row: r, + x: marginX + d * xGap, + y: marginY + r * yGap, + }); + } + + layoutNodes.sort((a, b) => (a.depth === b.depth ? a.row - b.row : a.depth - b.depth)); + + const width = marginX * 2 + maxDepth * xGap + 280; + const height = marginY * 2 + Math.max(1, maxRow) * yGap + 44; + + return { nodes: layoutNodes, edges, width, height }; +} + +function createSvgElement(tag: T): SVGElementTagNameMap[T] { + return document.createElementNS('http://www.w3.org/2000/svg', tag); +} + +function createGraphScene( + layout: GraphLayout, + currentId: string | null, + showTimestamps: boolean, + onNodeClick: (nodeId: string) => void +): SVGGElement { + const root = createSvgElement('g'); + const positionById = new Map(layout.nodes.map((n) => [n.node.id, n])); + + for (const edge of layout.edges) { + const from = positionById.get(edge.from); + const to = positionById.get(edge.to); + if (!from || !to) continue; + + const path = createSvgElement('path'); + const ctrlDx = Math.max(32, (to.x - from.x) * 0.55); + path.setAttribute( + 'd', + `M ${from.x} ${from.y} C ${from.x + ctrlDx} ${from.y}, ${to.x - ctrlDx} ${to.y}, ${to.x} ${to.y}` + ); + path.setAttribute('class', 'autk-provenance-edge'); + root.appendChild(path); + } + + for (const item of layout.nodes) { + const g = createSvgElement('g'); + const isCurrent = item.node.id === currentId; + g.setAttribute('class', `autk-provenance-node${isCurrent ? ' autk-provenance-node-current' : ''}`); + g.setAttribute('transform', `translate(${item.x}, ${item.y})`); + + const circle = createSvgElement('circle'); + circle.setAttribute('class', 'autk-provenance-node-circle'); + circle.setAttribute('r', isCurrent ? '9' : '7'); + g.appendChild(circle); + + const title = createSvgElement('title'); + title.textContent = `${item.node.actionLabel} (${item.node.id})`; + g.appendChild(title); + + const label = createSvgElement('text'); + label.setAttribute('class', 'autk-provenance-node-label'); + label.setAttribute('x', '14'); + label.setAttribute('y', '-2'); + label.textContent = truncate(item.node.actionLabel); + g.appendChild(label); + + if (showTimestamps) { + const time = createSvgElement('text'); + time.setAttribute('class', 'autk-provenance-node-time'); + time.setAttribute('x', '14'); + time.setAttribute('y', '12'); + time.textContent = formatTime(item.node.timestamp); + g.appendChild(time); + } + + g.addEventListener('click', (event) => { + event.stopPropagation(); + onNodeClick(item.node.id); + }); + + root.appendChild(g); + } + + return root; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void { const { provenance, container, showBackForward = true, showTimestamps = true, + showGraph = true, + showPathList = true, } = options; if (typeof document !== 'undefined' && document.head) { @@ -27,24 +221,335 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = if (!document.getElementById(styleId)) { const el = document.createElement('style'); el.id = styleId; - el.textContent = '.autk-provenance-trail{font-family:system-ui,sans-serif;font-size:13px;padding:8px}.autk-provenance-trail-path{display:flex;flex-direction:column;gap:2px;max-height:280px;overflow-y:auto}.autk-provenance-trail-item{display:flex;align-items:center;gap:8px;padding:6px 8px;cursor:pointer;border-radius:4px;border:1px solid transparent}.autk-provenance-trail-item:hover{background:rgba(0,0,0,.06)}.autk-provenance-trail-item.autk-provenance-trail-current{background:rgba(0,0,0,.08);border-color:rgba(0,0,0,.15);font-weight:600}.autk-provenance-trail-label{flex:1}.autk-provenance-trail-time{color:#666;font-size:11px}.autk-provenance-trail-buttons{display:flex;gap:6px;margin-top:8px}.autk-provenance-trail-buttons button{padding:6px 12px;cursor:pointer}.autk-provenance-trail-buttons button:disabled{opacity:.5;cursor:not-allowed}'; + el.textContent = [ + '.autk-provenance-root{display:flex;flex-direction:column;height:100%;font-family:system-ui,sans-serif;font-size:13px;gap:10px}', + '.autk-provenance-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}', + '.autk-provenance-toolbar-main{display:flex;align-items:center;gap:6px;flex-wrap:wrap}', + '.autk-provenance-toolbar-hint{font-size:11px;color:#5c7389}', + '.autk-provenance-toggle{padding:6px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', + '.autk-provenance-toggle:hover{background:#f2f7fd}', + '.autk-provenance-toggle:disabled{opacity:.55;cursor:not-allowed}', + '.autk-provenance-graph-wrap{position:relative;border:1px solid #dbe5f0;border-radius:8px;background:#fff;overflow:auto;min-height:280px;height:280px;max-height:360px;cursor:zoom-in}', + '.autk-provenance-graph-svg{display:block;min-width:100%;max-width:none}', + '.autk-provenance-edge{stroke:#b9c8d8;stroke-width:1.4;fill:none}', + '.autk-provenance-node{cursor:pointer}', + '.autk-provenance-node-circle{fill:#f8fbff;stroke:#6f8baa;stroke-width:1.5}', + '.autk-provenance-node-current .autk-provenance-node-circle{fill:#dcecff;stroke:#2f5f96;stroke-width:2}', + '.autk-provenance-node-label{font-size:11px;fill:#1f344b}', + '.autk-provenance-node-time{font-size:10px;fill:#5c7389}', + '.autk-provenance-modal-backdrop{position:fixed;inset:0;background:rgba(15,27,44,.48);z-index:11000;display:flex;align-items:center;justify-content:center;padding:24px}', + '.autk-provenance-modal{width:min(1200px,95vw);height:min(860px,90vh);display:flex;flex-direction:column;border-radius:12px;overflow:hidden;background:#f8fbff;box-shadow:0 28px 80px rgba(17,31,47,.45)}', + '.autk-provenance-modal-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px;background:#fff;border-bottom:1px solid #dbe5f0}', + '.autk-provenance-modal-title{font-size:14px;font-weight:700;color:#1f344b}', + '.autk-provenance-modal-controls{display:flex;align-items:center;gap:6px}', + '.autk-provenance-modal-controls button{padding:6px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;color:#1f344b;cursor:pointer;font-size:12px}', + '.autk-provenance-modal-controls button:hover{background:#edf5ff}', + '.autk-provenance-modal-body{padding:12px;display:flex;flex:1;min-height:0}', + '.autk-provenance-modal-canvas{position:relative;flex:1;min-height:0;border:1px solid #dbe5f0;border-radius:8px;background:#fff;overflow:hidden;cursor:grab;touch-action:none}', + '.autk-provenance-modal-canvas.autk-provenance-panning{cursor:grabbing}', + '.autk-provenance-modal-svg{display:block;width:100%;height:100%;min-width:0}', + '.autk-provenance-path{display:flex;flex-direction:column;gap:2px;border:1px solid #dbe5f0;border-radius:8px;background:#fbfdff;max-height:220px;overflow-y:auto}', + '.autk-provenance-path-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #e7eef6;cursor:pointer}', + '.autk-provenance-path-item:last-child{border-bottom:0}', + '.autk-provenance-path-item:hover{background:#f1f7ff}', + '.autk-provenance-path-item-current{background:#e8f2ff;font-weight:600}', + '.autk-provenance-path-label{flex:1;min-width:0}', + '.autk-provenance-path-time{color:#5c7389;font-size:11px;white-space:nowrap}', + '.autk-provenance-buttons{display:flex;gap:6px}', + '.autk-provenance-buttons button{padding:6px 12px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer}', + '.autk-provenance-buttons button:hover:not(:disabled){background:#f2f7fd}', + '.autk-provenance-buttons button:disabled{opacity:.55;cursor:not-allowed}', + ].join(''); document.head.appendChild(el); } } - container.classList.add('autk-provenance-trail'); - container.innerHTML = ''; - const pathContainer = document.createElement('div'); - pathContainer.className = 'autk-provenance-trail-path'; - pathContainer.setAttribute('role', 'list'); - container.appendChild(pathContainer); + container.innerHTML = ''; + container.classList.add('autk-provenance-root'); + let graphVisible = showGraph; + let graphModalOpen = false; + let graphToggleBtn: HTMLButtonElement | null = null; + let graphExpandBtn: HTMLButtonElement | null = null; + let graphHint: HTMLSpanElement | null = null; + let graphWrap: HTMLDivElement | null = null; + let pathContainer: HTMLDivElement | null = null; let backBtn: HTMLButtonElement | null = null; let fwdBtn: HTMLButtonElement | null = null; + let graphModalBackdrop: HTMLDivElement | null = null; + let graphModalCanvas: HTMLDivElement | null = null; + let graphModalScene: SVGGElement | null = null; + let graphModalLayout: GraphLayout | null = null; + let graphModalScale = 1; + let graphModalTx = 0; + let graphModalTy = 0; + let graphModalTransformInitialized = false; + + function applyGraphModalTransform(): void { + if (!graphModalScene) return; + graphModalScene.setAttribute( + 'transform', + `translate(${graphModalTx} ${graphModalTy}) scale(${graphModalScale})` + ); + } + + function fitGraphModalView(): void { + if (!graphModalCanvas || !graphModalLayout || !graphModalScene) return; + const viewportWidth = Math.max(1, graphModalCanvas.clientWidth); + const viewportHeight = Math.max(1, graphModalCanvas.clientHeight); + const padding = 52; + const scaleX = (viewportWidth - padding * 2) / Math.max(1, graphModalLayout.width); + const scaleY = (viewportHeight - padding * 2) / Math.max(1, graphModalLayout.height); + const fitScale = clamp(Math.min(scaleX, scaleY), 0.25, 6); + graphModalScale = Number.isFinite(fitScale) && fitScale > 0 ? fitScale : 1; + graphModalTx = (viewportWidth - graphModalLayout.width * graphModalScale) / 2; + graphModalTy = (viewportHeight - graphModalLayout.height * graphModalScale) / 2; + graphModalTransformInitialized = true; + applyGraphModalTransform(); + } + + function zoomGraphModal(factor: number, clientX?: number, clientY?: number): void { + if (!graphModalCanvas || !graphModalScene) return; + const rect = graphModalCanvas.getBoundingClientRect(); + const x = clientX === undefined ? rect.left + rect.width / 2 : clientX; + const y = clientY === undefined ? rect.top + rect.height / 2 : clientY; + const localX = x - rect.left; + const localY = y - rect.top; + const nextScale = clamp(graphModalScale * factor, 0.25, 6); + const ratio = nextScale / graphModalScale; + graphModalTx = localX - (localX - graphModalTx) * ratio; + graphModalTy = localY - (localY - graphModalTy) * ratio; + graphModalScale = nextScale; + graphModalTransformInitialized = true; + applyGraphModalTransform(); + } + + function teardownGraphModal(): void { + graphModalBackdrop?.remove(); + graphModalBackdrop = null; + graphModalCanvas = null; + graphModalScene = null; + graphModalLayout = null; + graphModalTransformInitialized = false; + } + + function closeGraphModal(): void { + graphModalOpen = false; + teardownGraphModal(); + } + + function ensureGraphModal(): void { + if (graphModalBackdrop) return; + + const backdrop = document.createElement('div'); + backdrop.className = 'autk-provenance-modal-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'autk-provenance-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', 'Provenance graph'); + + const header = document.createElement('div'); + header.className = 'autk-provenance-modal-header'; + + const title = document.createElement('div'); + title.className = 'autk-provenance-modal-title'; + title.textContent = 'Provenance Graph'; + + const controls = document.createElement('div'); + controls.className = 'autk-provenance-modal-controls'; + + const zoomOutBtn = document.createElement('button'); + zoomOutBtn.type = 'button'; + zoomOutBtn.textContent = '−'; + zoomOutBtn.setAttribute('aria-label', 'Zoom out'); + + const zoomInBtn = document.createElement('button'); + zoomInBtn.type = 'button'; + zoomInBtn.textContent = '+'; + zoomInBtn.setAttribute('aria-label', 'Zoom in'); + + const fitBtn = document.createElement('button'); + fitBtn.type = 'button'; + fitBtn.textContent = 'Fit'; + fitBtn.setAttribute('aria-label', 'Fit graph to view'); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.textContent = 'Close'; + closeBtn.setAttribute('aria-label', 'Close graph modal'); + + controls.appendChild(zoomOutBtn); + controls.appendChild(zoomInBtn); + controls.appendChild(fitBtn); + controls.appendChild(closeBtn); + header.appendChild(title); + header.appendChild(controls); + + const body = document.createElement('div'); + body.className = 'autk-provenance-modal-body'; + + const canvas = document.createElement('div'); + canvas.className = 'autk-provenance-modal-canvas'; + body.appendChild(canvas); + + modal.appendChild(header); + modal.appendChild(body); + backdrop.appendChild(modal); + + backdrop.addEventListener('click', (event) => { + if (event.target !== backdrop) return; + closeGraphModal(); + refresh(); + }); + + closeBtn.addEventListener('click', () => { + closeGraphModal(); + refresh(); + }); + zoomInBtn.addEventListener('click', () => zoomGraphModal(1.2)); + zoomOutBtn.addEventListener('click', () => zoomGraphModal(0.84)); + fitBtn.addEventListener('click', () => fitGraphModalView()); + + canvas.addEventListener( + 'wheel', + (event) => { + event.preventDefault(); + zoomGraphModal(event.deltaY < 0 ? 1.12 : 0.89, event.clientX, event.clientY); + }, + { passive: false } + ); + + let panning = false; + let lastX = 0; + let lastY = 0; + + const stopPanning = (event: PointerEvent) => { + if (!panning) return; + panning = false; + canvas.classList.remove('autk-provenance-panning'); + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + }; + + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + const target = event.target as HTMLElement | null; + if (target?.closest('.autk-provenance-node')) return; + panning = true; + lastX = event.clientX; + lastY = event.clientY; + canvas.classList.add('autk-provenance-panning'); + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + + canvas.addEventListener('pointermove', (event) => { + if (!panning) return; + graphModalTx += event.clientX - lastX; + graphModalTy += event.clientY - lastY; + lastX = event.clientX; + lastY = event.clientY; + graphModalTransformInitialized = true; + applyGraphModalTransform(); + }); + + canvas.addEventListener('pointerup', stopPanning); + canvas.addEventListener('pointercancel', stopPanning); + + document.body?.appendChild(backdrop); + graphModalBackdrop = backdrop; + graphModalCanvas = canvas; + } + + function renderGraphModal(): void { + if (!graphModalOpen) return; + ensureGraphModal(); + if (!graphModalCanvas) return; + + graphModalCanvas.innerHTML = ''; + + const graph = provenance.getGraph(); + const rootNode = graph.nodes.get(graph.rootId); + if (!rootNode) return; + + const currentId = provenance.getCurrentNode()?.id ?? null; + const layout = buildGraphLayout(graph.nodes, graph.rootId); + graphModalLayout = layout; + + const viewportWidth = Math.max(320, Math.floor(graphModalCanvas.clientWidth)); + const viewportHeight = Math.max(260, Math.floor(graphModalCanvas.clientHeight)); + + const svg = createSvgElement('svg'); + svg.classList.add('autk-provenance-modal-svg'); + svg.setAttribute('viewBox', `0 0 ${viewportWidth} ${viewportHeight}`); + svg.setAttribute('width', String(viewportWidth)); + svg.setAttribute('height', String(viewportHeight)); + + const scene = createGraphScene(layout, currentId, showTimestamps, (nodeId) => { + provenance.goToNode(nodeId); + }); + graphModalScene = scene; + svg.appendChild(scene); + graphModalCanvas.appendChild(svg); + + if (!graphModalTransformInitialized) { + fitGraphModalView(); + } else { + applyGraphModalTransform(); + } + } + + function openGraphModal(): void { + if (!showGraph || !graphVisible) return; + graphModalOpen = true; + graphModalTransformInitialized = false; + renderGraphModal(); + } + + if (showGraph) { + const toolbar = document.createElement('div'); + toolbar.className = 'autk-provenance-toolbar'; + const toolbarMain = document.createElement('div'); + toolbarMain.className = 'autk-provenance-toolbar-main'; + + graphToggleBtn = document.createElement('button'); + graphToggleBtn.className = 'autk-provenance-toggle'; + graphToggleBtn.textContent = 'Hide Graph'; + toolbarMain.appendChild(graphToggleBtn); + + graphExpandBtn = document.createElement('button'); + graphExpandBtn.className = 'autk-provenance-toggle'; + graphExpandBtn.textContent = 'Open Graph'; + toolbarMain.appendChild(graphExpandBtn); + + toolbar.appendChild(toolbarMain); + + graphHint = document.createElement('span'); + graphHint.className = 'autk-provenance-toolbar-hint'; + graphHint.textContent = 'Click graph preview to open modal'; + toolbar.appendChild(graphHint); + + container.appendChild(toolbar); + + graphWrap = document.createElement('div'); + graphWrap.className = 'autk-provenance-graph-wrap'; + container.appendChild(graphWrap); + } + + if (showPathList) { + pathContainer = document.createElement('div'); + pathContainer.className = 'autk-provenance-path'; + pathContainer.setAttribute('role', 'list'); + container.appendChild(pathContainer); + } + if (showBackForward) { const btnRow = document.createElement('div'); - btnRow.className = 'autk-provenance-trail-buttons'; + btnRow.className = 'autk-provenance-buttons'; backBtn = document.createElement('button'); backBtn.textContent = '← Back'; backBtn.setAttribute('aria-label', 'Go back one step'); @@ -68,29 +573,56 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = if (fwdBtn) fwdBtn.disabled = !provenance.canGoForward(); } + function renderGraph(): void { + if (!graphWrap) return; + if (!graphVisible) { + graphWrap.style.display = 'none'; + return; + } + graphWrap.style.display = 'block'; + graphWrap.innerHTML = ''; + + const graph = provenance.getGraph(); + const currentId = provenance.getCurrentNode()?.id ?? null; + const rootNode = graph.nodes.get(graph.rootId); + if (!rootNode) return; + + const layout = buildGraphLayout(graph.nodes, graph.rootId); + + const svg = createSvgElement('svg'); + svg.classList.add('autk-provenance-graph-svg'); + svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`); + svg.setAttribute('width', String(layout.width)); + svg.setAttribute('height', String(layout.height)); + svg.appendChild( + createGraphScene(layout, currentId, showTimestamps, (nodeId) => { + provenance.goToNode(nodeId); + }) + ); + + graphWrap.appendChild(svg); + } + function renderPath(path: PathNode[]): void { + if (!pathContainer) return; pathContainer.innerHTML = ''; const currentId = provenance.getCurrentNode()?.id ?? null; - for (let i = 0; i < path.length; i++) { - const node = path[i]; + for (const node of path) { const isCurrent = node.id === currentId; const item = document.createElement('div'); - item.className = 'autk-provenance-trail-item' + (isCurrent ? ' autk-provenance-trail-current' : ''); + item.className = `autk-provenance-path-item${isCurrent ? ' autk-provenance-path-item-current' : ''}`; item.setAttribute('role', 'listitem'); item.setAttribute('data-node-id', node.id); const labelSpan = document.createElement('span'); - labelSpan.className = 'autk-provenance-trail-label'; + labelSpan.className = 'autk-provenance-path-label'; labelSpan.textContent = node.actionLabel; - if (isCurrent) { - labelSpan.setAttribute('aria-current', 'step'); - } item.appendChild(labelSpan); if (showTimestamps) { const timeSpan = document.createElement('span'); - timeSpan.className = 'autk-provenance-trail-time'; + timeSpan.className = 'autk-provenance-path-time'; timeSpan.textContent = formatTime(node.timestamp); item.appendChild(timeSpan); } @@ -101,24 +633,83 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = pathContainer.appendChild(item); } - - updateButtons(); } function refresh(): void { - const path = provenance.getPathFromRoot(); - renderPath(path); + if (graphToggleBtn) { + graphToggleBtn.textContent = graphVisible ? 'Hide Graph' : 'Show Graph'; + } + if (graphExpandBtn) { + graphExpandBtn.textContent = graphModalOpen ? 'Close Graph' : 'Open Graph'; + graphExpandBtn.disabled = !graphVisible; + } + if (graphHint) { + graphHint.textContent = graphModalOpen + ? 'Modal open: click nodes to jump state' + : 'Click graph preview to open modal'; + graphHint.style.visibility = graphVisible ? 'visible' : 'hidden'; + } + if (showGraph) renderGraph(); + if (graphModalOpen) renderGraphModal(); + if (showPathList) { + const path = provenance.getPathFromRoot(); + renderPath(path); + } + updateButtons(); } const unsub = provenance.addObserver(() => { refresh(); }); + if (graphToggleBtn) { + graphToggleBtn.addEventListener('click', () => { + graphVisible = !graphVisible; + if (!graphVisible) closeGraphModal(); + refresh(); + }); + } + + if (graphExpandBtn) { + graphExpandBtn.addEventListener('click', () => { + if (graphModalOpen) { + closeGraphModal(); + } else { + openGraphModal(); + } + refresh(); + }); + } + + if (graphWrap) { + graphWrap.addEventListener('click', () => { + if (!graphVisible || graphModalOpen) return; + openGraphModal(); + refresh(); + }); + } + + const handleDocKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || !graphModalOpen) return; + closeGraphModal(); + refresh(); + }; + document.addEventListener('keydown', handleDocKeyDown); + + const handleWindowResize = () => { + if (!graphModalOpen) return; + renderGraphModal(); + }; + window.addEventListener('resize', handleWindowResize); + refresh(); return () => { unsub(); + document.removeEventListener('keydown', handleDocKeyDown); + window.removeEventListener('resize', handleWindowResize); + closeGraphModal(); container.innerHTML = ''; - container.classList.remove('autk-provenance-trail'); + container.classList.remove('autk-provenance-root'); }; } diff --git a/autk-provenance/src/types.ts b/autk-provenance/src/types.ts index ade6df53..12a769f8 100644 --- a/autk-provenance/src/types.ts +++ b/autk-provenance/src/types.ts @@ -7,6 +7,12 @@ export interface AutarkProvenanceState { map: { layerId: string; ids: number[] } | null; plot: number[]; }; + ui?: { + mapMenuOpen?: boolean; + activeLayerId?: string | null; + visibleLayerIds?: string[]; + thematicEnabled?: boolean; + }; view?: { center: [number, number]; zoom?: number; @@ -25,23 +31,33 @@ export interface AutarkProvenanceState { */ export enum ProvenanceAction { ROOT = 'root', + MAP_INIT = 'MAP_INIT', MAP_PICK = 'MAP_PICK', MAP_LAYER_LOAD = 'MAP_LAYER_LOAD', MAP_VIEW = 'MAP_VIEW', + MAP_UI_MENU_TOGGLE = 'MAP_UI_MENU_TOGGLE', + MAP_UI_VISIBLE_LAYER_TOGGLE = 'MAP_UI_VISIBLE_LAYER_TOGGLE', + MAP_UI_ACTIVE_LAYER_CHANGE = 'MAP_UI_ACTIVE_LAYER_CHANGE', + MAP_UI_THEMATIC_TOGGLE = 'MAP_UI_THEMATIC_TOGGLE', PLOT_CLICK = 'PLOT_CLICK', PLOT_BRUSH = 'PLOT_BRUSH', PLOT_BRUSH_X = 'PLOT_BRUSH_X', PLOT_BRUSH_Y = 'PLOT_BRUSH_Y', PLOT_DATA = 'PLOT_DATA', + DB_INIT = 'DB_INIT', DB_WORKSPACE = 'DB_WORKSPACE', DB_LOAD_OSM = 'DB_LOAD_OSM', DB_LOAD_CSV = 'DB_LOAD_CSV', DB_LOAD_JSON = 'DB_LOAD_JSON', + DB_LOAD_LAYER = 'DB_LOAD_LAYER', DB_LOAD_CUSTOM_LAYER = 'DB_LOAD_CUSTOM_LAYER', DB_LOAD_GRID_LAYER = 'DB_LOAD_GRID_LAYER', + DB_GET_LAYER = 'DB_GET_LAYER', DB_SPATIAL_JOIN = 'DB_SPATIAL_JOIN', DB_UPDATE_TABLE = 'DB_UPDATE_TABLE', DB_DROP_TABLE = 'DB_DROP_TABLE', + DB_RAW_QUERY = 'DB_RAW_QUERY', + DB_BUILD_HEATMAP = 'DB_BUILD_HEATMAP', DB_OTHER = 'DB_OTHER', } @@ -78,9 +94,35 @@ export interface IMapForProvenance { addEventListener(event: string, listener: (selection: number[], layerId: string) => void): void; removeEventListener?(event: string, listener: (selection: number[], layerId: string) => void): void; }; + canvas: { + parentElement: HTMLElement | null; + }; + ui?: { + activeLayer?: { layerInfo?: { id: string }; layerRenderInfo?: { isColorMap?: boolean } } | null; + changeActiveLayer?(layer: unknown): void; + }; + updateRenderInfoProperty?( + layerName: string, + property: string, + value: unknown + ): void; layerManager: { - searchByLayerId(layerId: string): { setHighlightedIds(ids: number[]): void; clearHighlightedIds(): void; layerInfo?: { id: string } } | null; - vectorLayers?: Array<{ layerInfo?: { id: string }; setHighlightedIds(ids: number[]): void; clearHighlightedIds(): void }>; + searchByLayerId(layerId: string): { + setHighlightedIds?(ids: number[]): void; + clearHighlightedIds?(): void; + layerInfo?: { id: string }; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; + } | null; + vectorLayers?: Array<{ + layerInfo?: { id: string }; + setHighlightedIds?(ids: number[]): void; + clearHighlightedIds?(): void; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; + }>; + rasterLayers?: Array<{ + layerInfo?: { id: string }; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; + }>; }; } diff --git a/examples/src/autk-provenance/map-plot-provenance.css b/examples/src/autk-provenance/map-plot-provenance.css new file mode 100644 index 00000000..bcbce6d3 --- /dev/null +++ b/examples/src/autk-provenance/map-plot-provenance.css @@ -0,0 +1,228 @@ +:root { + --bg: #eef3f8; + --surface: #ffffff; + --surface-muted: #f6f9fc; + --border: #d4dde7; + --text: #1b3148; + --text-muted: #546b80; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + padding: 20px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: var(--text); + background: radial-gradient(circle at top left, #f8fbff, var(--bg) 62%); +} + +#app { + max-width: 1500px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.page-header { + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 20px; + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + box-shadow: 0 10px 26px rgba(28, 53, 76, 0.08); +} + +.page-header h1 { + margin: 0; + font-size: 28px; + line-height: 1.2; +} + +.page-header p { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 14px; +} + +#layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 420px; + gap: 16px; + align-items: start; +} + +#analysisColumn { + min-width: 0; + display: grid; + grid-template-rows: minmax(420px, 56vh) minmax(560px, 44vh); + gap: 16px; +} + +.panel { + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + overflow: hidden; + box-shadow: 0 10px 26px rgba(28, 53, 76, 0.08); +} + +.panel h2 { + margin: 0; + padding: 12px 14px; + font-size: 16px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); +} + +.panel-body { + flex: 1; + padding: 12px; + min-height: 0; +} + +.map-panel .panel-body { + padding: 0; +} + +.plot-panel .panel-body { + display: flex; + overflow: hidden; +} + +canvas { + display: block; + width: 100%; + height: 100%; + background: #c8dae6; +} + +#plot { + display: flex; + flex: 1; + position: relative; + z-index: 1; + width: 100%; + height: 100%; + min-height: 540px; + border: 1px solid #d8e1eb; + border-radius: 10px; + background: #fff; + overflow: hidden; +} + +#plotBody { + flex: 1; + width: 100%; + height: 100%; + min-height: 0; +} + +svg { + width: 100%; + height: 100%; + border: none !important; + background: #fff; +} + +#sidebar { + position: sticky; + top: 20px; + height: calc(100vh - 40px); + max-height: 920px; +} + +#sidebar .panel-body { + height: calc(100% - 46px); + padding: 10px; +} + +#provenanceTrail { + height: 100%; +} + +#provenanceTrail.autk-provenance-root { + min-height: 0; +} + +#provenanceTrail .autk-provenance-graph-wrap { + height: 340px; + max-height: 420px; +} + +#provenanceTrail .autk-provenance-path { + flex: 1; + max-height: none; +} + +.tick line { + stroke-width: 1px; + stroke: #d7dde6; +} + +.title { + font-size: 14px; + fill: #24374a; + font-weight: 600; +} + +@media (max-width: 1260px) { + #layout { + grid-template-columns: 1fr; + } + + #analysisColumn { + grid-template-rows: minmax(420px, 54vh) 600px; + } + + #sidebar { + position: static; + height: 420px; + max-height: none; + } + + #sidebar .panel-body { + height: calc(100% - 46px); + } +} + +@media (max-width: 700px) { + body { + padding: 12px; + } + + .page-header { + padding: 14px; + } + + .page-header h1 { + font-size: 22px; + } + + .page-header p { + font-size: 13px; + } + + .panel h2 { + padding: 10px 12px; + font-size: 14px; + } + + .panel-body { + padding: 10px; + } + + #analysisColumn { + grid-template-rows: 420px 560px; + } + + #sidebar { + height: 360px; + } +} diff --git a/examples/src/autk-provenance/map-plot-provenance.html b/examples/src/autk-provenance/map-plot-provenance.html index 72a614f6..24638631 100644 --- a/examples/src/autk-provenance/map-plot-provenance.html +++ b/examples/src/autk-provenance/map-plot-provenance.html @@ -2,62 +2,43 @@ - Autark provenance – map + plot - - + Autark - Provenance (Map + Plot) +
-

Map + Plot with provenance

+ +
-
- -
-
-
-
-
-
- diff --git a/examples/src/autk-provenance/map-plot-provenance.ts b/examples/src/autk-provenance/map-plot-provenance.ts index 8ab27b04..f10c054d 100644 --- a/examples/src/autk-provenance/map-plot-provenance.ts +++ b/examples/src/autk-provenance/map-plot-provenance.ts @@ -1,4 +1,5 @@ import { FeatureCollection } from 'geojson'; +import { SpatialDb } from 'autk-db'; import { PlotEvent, Scatterplot } from 'autk-plot'; import { AutkMap, MapEvent, VectorLayer } from 'autk-map'; import { @@ -7,9 +8,13 @@ import { } from 'autk-provenance'; async function main() { - const geojson = await fetch('http://localhost:5173/data/mnt_neighs_proj.geojson').then( - (res) => res.json() as Promise - ); + const db = new SpatialDb(); + await db.init(); + await db.loadCustomLayer({ + geojsonFileUrl: '/data/mnt_neighs_proj.geojson', + outputTableName: 'neighborhoods', + }); + const geojson = await db.getLayer('neighborhoods') as FeatureCollection; const canvas = document.querySelector('canvas') as HTMLCanvasElement; const plotBody = document.querySelector('#plotBody') as HTMLElement; @@ -26,11 +31,15 @@ async function main() { map.updateRenderInfoProperty('neighborhoods', 'isPick', true); map.draw(); + const plotWidth = Math.max(320, Math.floor(plotBody.getBoundingClientRect().width)); + const plotHeight = Math.max(500, Math.floor(plotBody.getBoundingClientRect().height || 500)); + const plot = new Scatterplot({ div: plotBody, data: geojson, labels: { axis: ['shape_area', 'shape_leng'], title: 'Plot with provenance' }, - width: 790, + width: plotWidth, + height: plotHeight, events: [PlotEvent.BRUSH], }); @@ -39,10 +48,15 @@ async function main() { }); plot.plotEvents.addEventListener(PlotEvent.BRUSH, (selection: number[]) => { const layer = map.layerManager.searchByLayerId('neighborhoods') as VectorLayer | null; - if (layer) layer.setHighlightedIds(selection); + if (!layer) return; + if (selection.length === 0) { + layer.clearHighlightedIds(); + } else { + layer.setHighlightedIds(selection); + } }); - const provenance = createAutarkProvenance({ map, plot }); + const provenance = createAutarkProvenance({ map, plot, db }); if (trailContainer) { renderProvenanceTrailUI({ @@ -50,6 +64,8 @@ async function main() { container: trailContainer, showBackForward: true, showTimestamps: true, + showGraph: true, + showPathList: true, }); } } diff --git a/examples/src/shared/example-shell.css b/examples/src/shared/example-shell.css new file mode 100644 index 00000000..6b626357 --- /dev/null +++ b/examples/src/shared/example-shell.css @@ -0,0 +1,193 @@ +:root { + --example-bg: #eef3f8; + --example-surface: #ffffff; + --example-surface-muted: #f6f9fc; + --example-border: #d4dde7; + --example-text: #1b3148; + --example-text-muted: #546b80; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + padding: 20px; + display: block; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: var(--example-text); + background: radial-gradient(circle at top left, #f8fbff, var(--example-bg) 62%); +} + +#app { + width: min(96vw, 1200px); + max-width: 1200px; + margin: 0 auto; + min-height: auto; + border: 1px solid var(--example-border); + border-radius: 14px; + background: var(--example-surface); + box-shadow: 0 10px 26px rgba(28, 53, 76, 0.08); + display: flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; +} + +#app > h2 { + margin: 0; + padding: 12px 16px; + font-size: 18px; + border-bottom: 1px solid var(--example-border); + background: linear-gradient(180deg, var(--example-surface), var(--example-surface-muted)); +} + +#app > p { + margin: 12px 16px 0; + color: var(--example-text-muted); + line-height: 1.5; +} + +canvas { + display: block; + width: calc(100% - 24px) !important; + max-width: calc(100% - 24px); + height: min(72vh, 780px) !important; + min-height: 320px; + margin: 12px; + border: 1px solid #d8e1eb; + border-radius: 10px; + background: #c8dae6; +} + +#output { + width: calc(100% - 24px) !important; + min-height: 160px; + margin: 12px; + padding: 12px; + border: 1px solid #d8e1eb; + border-radius: 10px; + background: #f8fbff; + overflow: auto; + align-self: stretch; +} + +#output pre { + max-width: 100%; + overflow: auto; + word-break: break-word; +} + +#output table { + width: 100%; + border-collapse: collapse; +} + +#output th, +#output td { + padding: 6px 8px; + border-bottom: 1px solid #e1e7ef; + text-align: left; + vertical-align: top; +} + +#plot { + position: relative !important; + z-index: 1; + width: calc(100% - 24px) !important; + margin: 0 12px 12px; + border: 1px solid #d8e1eb; + border-radius: 10px; + background: #fff; + visibility: visible !important; + opacity: 1 !important; + overflow: hidden; +} + +#plotBar { + display: none !important; +} + +#plotBody { + width: 100% !important; + height: min(52vh, 520px) !important; + min-height: 320px; + margin: 0 !important; +} + +svg { + width: 100%; + height: 100%; + border: none !important; + background: #fff; +} + +select { + margin: 12px; + max-width: calc(100% - 24px); +} + +.map-container { + width: 100%; + padding: 0 12px 12px; + display: grid !important; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; +} + +.map-container canvas { + width: 100% !important; + max-width: none; + margin: 0 !important; + height: min(62vh, 700px) !important; +} + +@media (max-width: 700px) { + body { + padding: 12px; + } + + #app { + width: 100%; + } + + #app > h2 { + padding: 10px 12px; + font-size: 16px; + } + + #app > p { + margin: 10px 12px 0; + font-size: 14px; + } + + canvas { + width: calc(100% - 20px) !important; + max-width: calc(100% - 20px); + height: min(56vh, 520px) !important; + min-height: 260px; + margin: 10px; + } + + #output { + width: calc(100% - 20px) !important; + margin: 10px; + } + + #plot { + width: calc(100% - 20px) !important; + margin: 0 10px 10px; + } + + #plotBody { + height: min(52vh, 420px) !important; + min-height: 280px; + } + + .map-container { + grid-template-columns: 1fr; + padding: 0 10px 10px; + } +} diff --git a/gallery/src/autk-map/colormap-categorical.html b/gallery/src/autk-map/colormap-categorical.html index 20129419..6a707a5b 100644 --- a/gallery/src/autk-map/colormap-categorical.html +++ b/gallery/src/autk-map/colormap-categorical.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/colormap-diverging.html b/gallery/src/autk-map/colormap-diverging.html index 22bfca4c..a60034c9 100644 --- a/gallery/src/autk-map/colormap-diverging.html +++ b/gallery/src/autk-map/colormap-diverging.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/compute-function.html b/gallery/src/autk-map/compute-function.html index ba5b248b..7cfe2a5c 100644 --- a/gallery/src/autk-map/compute-function.html +++ b/gallery/src/autk-map/compute-function.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/compute-osm-function.html b/gallery/src/autk-map/compute-osm-function.html index 64b40d02..a659ba92 100644 --- a/gallery/src/autk-map/compute-osm-function.html +++ b/gallery/src/autk-map/compute-osm-function.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/geojson-boundaries-vis.html b/gallery/src/autk-map/geojson-boundaries-vis.html index 8f6bcc5d..0cda1f34 100644 --- a/gallery/src/autk-map/geojson-boundaries-vis.html +++ b/gallery/src/autk-map/geojson-boundaries-vis.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/geojson-lines-vis.html b/gallery/src/autk-map/geojson-lines-vis.html index 55aa197d..56c66c8b 100644 --- a/gallery/src/autk-map/geojson-lines-vis.html +++ b/gallery/src/autk-map/geojson-lines-vis.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/geojson-vis.html b/gallery/src/autk-map/geojson-vis.html index dec9834c..4898ef21 100644 --- a/gallery/src/autk-map/geojson-vis.html +++ b/gallery/src/autk-map/geojson-vis.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/heatmap-vis.html b/gallery/src/autk-map/heatmap-vis.html index 069ce261..d70326d3 100644 --- a/gallery/src/autk-map/heatmap-vis.html +++ b/gallery/src/autk-map/heatmap-vis.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/layer-opacity.html b/gallery/src/autk-map/layer-opacity.html index 9bc33a06..0097227d 100644 --- a/gallery/src/autk-map/layer-opacity.html +++ b/gallery/src/autk-map/layer-opacity.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/osm-layers-api-chicago.html b/gallery/src/autk-map/osm-layers-api-chicago.html index 79c7329f..eaf64442 100644 --- a/gallery/src/autk-map/osm-layers-api-chicago.html +++ b/gallery/src/autk-map/osm-layers-api-chicago.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/osm-layers-api-multi.html b/gallery/src/autk-map/osm-layers-api-multi.html index 4dfc3aa3..220e3509 100644 --- a/gallery/src/autk-map/osm-layers-api-multi.html +++ b/gallery/src/autk-map/osm-layers-api-multi.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/osm-layers-api-niteroi.html b/gallery/src/autk-map/osm-layers-api-niteroi.html index b1f0cceb..1f352736 100644 --- a/gallery/src/autk-map/osm-layers-api-niteroi.html +++ b/gallery/src/autk-map/osm-layers-api-niteroi.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/osm-layers-api.html b/gallery/src/autk-map/osm-layers-api.html index 2fb60bfc..10c1f5a5 100644 --- a/gallery/src/autk-map/osm-layers-api.html +++ b/gallery/src/autk-map/osm-layers-api.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/spatial-join-buildings.html b/gallery/src/autk-map/spatial-join-buildings.html index fac168fe..d9add3fd 100644 --- a/gallery/src/autk-map/spatial-join-buildings.html +++ b/gallery/src/autk-map/spatial-join-buildings.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/spatial-join-multi.html b/gallery/src/autk-map/spatial-join-multi.html index cc73e8cc..d54a37cf 100644 --- a/gallery/src/autk-map/spatial-join-multi.html +++ b/gallery/src/autk-map/spatial-join-multi.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/spatial-join-near.html b/gallery/src/autk-map/spatial-join-near.html index 7d4a04bb..50c346a7 100644 --- a/gallery/src/autk-map/spatial-join-near.html +++ b/gallery/src/autk-map/spatial-join-near.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/spatial-join.html b/gallery/src/autk-map/spatial-join.html index 1cd0c7ab..8e9e4d8d 100644 --- a/gallery/src/autk-map/spatial-join.html +++ b/gallery/src/autk-map/spatial-join.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/standalone-geojson-vis.html b/gallery/src/autk-map/standalone-geojson-vis.html index 760d46a9..e64bcd6f 100644 --- a/gallery/src/autk-map/standalone-geojson-vis.html +++ b/gallery/src/autk-map/standalone-geojson-vis.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-map/standalone-points-geojson-vis.html b/gallery/src/autk-map/standalone-points-geojson-vis.html index 08648c01..6bd3365e 100644 --- a/gallery/src/autk-map/standalone-points-geojson-vis.html +++ b/gallery/src/autk-map/standalone-points-geojson-vis.html @@ -5,6 +5,7 @@ Autark (Examples) + diff --git a/gallery/src/autk-plot/barchart-click.css b/gallery/src/autk-plot/barchart-click.css index c3b57560..85253b18 100644 --- a/gallery/src/autk-plot/barchart-click.css +++ b/gallery/src/autk-plot/barchart-click.css @@ -1,69 +1,181 @@ -body { - margin: 20px; +:root { + --bg: #eef3f8; + --surface: #ffffff; + --surface-muted: #f6f9fc; + --border: #d4dde7; + --text: #1b3148; + --text-muted: #546b80; +} + +* { + box-sizing: border-box; +} - display: flex; - flex-direction: row; - justify-content: center; - align-items: flex-start; +body { + margin: 0; + min-height: 100vh; + padding: 20px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: var(--text); + background: radial-gradient(circle at top left, #f8fbff, var(--bg) 62%); } #app { - margin: 10px; + max-width: 1400px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.page-header { + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 20px; + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + box-shadow: 0 10px 26px rgba(28, 53, 76, 0.08); +} + +.page-header h1 { + margin: 0; + font-size: 28px; + line-height: 1.2; +} + +.page-header p { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 14px; +} + +#workspace { + display: grid; + grid-template-columns: minmax(520px, 1fr) minmax(360px, 460px); + gap: 16px; + align-items: start; +} + +.panel { + min-width: 0; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + overflow: hidden; + box-shadow: 0 10px 26px rgba(28, 53, 76, 0.08); +} - width: 800px; - min-height: 185px; - border: 1px solid #bfbfbf; +.panel h2 { + margin: 0; + padding: 12px 14px; + font-size: 16px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); +} + +.panel-body { + padding: 12px; +} + +.map-panel .panel-body { + padding: 0; + height: min(74vh, 760px); + min-height: 460px; +} - display: flex; - flex-direction: column; - align-items: center; +.plot-panel .panel-body { + height: min(74vh, 760px); + min-height: 460px; } canvas { - margin: 10px; - width: 780px; - height: 780px; + display: block; + width: 100%; + height: 100%; + background: #c8dae6; } #plot { - position: fixed; - z-index: 100; - background-color: #ffffff; - opacity: 0.85; - width: 800px; - border: 1px solid #bfbfbf; - border-radius: 10px 10px 0px 0px; - visibility: hidden; + position: relative; + z-index: 1; + width: 100%; + height: 100%; + visibility: visible; + border: 1px solid #d8e1eb; + border-radius: 10px; + background: #fff; + overflow: hidden; } #plotBar { - width: 100%; - height: 30px; - cursor: move; - background-color: #bfbfbf; - border-radius: 10px 10px 0px 0px; - border-bottom: 1px solid #bbb; - box-shadow: 0px 1px 3px #666; + display: none; } #plotBody { - margin: 5px; - width: calc(100% - 10px); - height: 500px; + margin: 0; + width: 100%; + height: 100%; + min-height: 260px; } svg { - width: 100%; - height: 100%; - border: none !important; + width: 100%; + height: 100%; + border: none !important; + background: #fff; +} + +.tick line { + stroke-width: 1px; + stroke: #d7dde6; +} + +.title { + font-size: 14px; + fill: #24374a; + font-weight: 600; } -.tick line{ - stroke-width: 1px; - stroke: #dfdfdf; +@media (max-width: 1180px) { + #workspace { + grid-template-columns: 1fr; + } + + .map-panel .panel-body, + .plot-panel .panel-body { + height: min(62vh, 560px); + min-height: 360px; + } } -.title { - font-size: 12px; - fill: #333; +@media (max-width: 700px) { + body { + padding: 12px; + } + + .page-header { + padding: 14px; + } + + .page-header h1 { + font-size: 22px; + } + + .page-header p { + font-size: 13px; + } + + .panel h2 { + padding: 10px 12px; + font-size: 14px; + } + + .panel-body { + padding: 10px; + } + + .map-panel .panel-body, + .plot-panel .panel-body { + height: 420px; + min-height: 320px; + } } diff --git a/gallery/src/autk-plot/barchart-click.html b/gallery/src/autk-plot/barchart-click.html index c80d7180..cde7742e 100644 --- a/gallery/src/autk-plot/barchart-click.html +++ b/gallery/src/autk-plot/barchart-click.html @@ -1,12 +1,10 @@ - Autark (Examples) -

barchart-click.ts

@@ -15,7 +13,18 @@

barchart-click.ts

+ + +
+

Scatterplot

+
+
+
+
+
+
+ diff --git a/gallery/src/autk-plot/heatmatrix-click.html b/gallery/src/autk-plot/heatmatrix-click.html index 83d91f4e..6ab72599 100644 --- a/gallery/src/autk-plot/heatmatrix-click.html +++ b/gallery/src/autk-plot/heatmatrix-click.html @@ -12,53 +12,10 @@

heatmatrix-click.ts

-
- - diff --git a/gallery/src/autk-plot/histogram-brush.html b/gallery/src/autk-plot/histogram-brush.html index 834f0e43..a70969d3 100644 --- a/gallery/src/autk-plot/histogram-brush.html +++ b/gallery/src/autk-plot/histogram-brush.html @@ -12,53 +12,10 @@

histogram-brush.ts

-
- - diff --git a/gallery/src/autk-plot/table-click.html b/gallery/src/autk-plot/table-click.html index e89ce917..858be667 100644 --- a/gallery/src/autk-plot/table-click.html +++ b/gallery/src/autk-plot/table-click.html @@ -12,7 +12,6 @@

table-click.ts

-
@@ -61,4 +60,4 @@

table-click.ts

floatingPlot(); - \ No newline at end of file + From 0d434993244a4b63bb86b30d3e36b21ea8061761 Mon Sep 17 00:00:00 2001 From: JP109 Date: Sat, 14 Mar 2026 00:23:49 -0500 Subject: [PATCH 03/21] feat: track map and pan interactions in provenance graph --- autk-provenance/src/adapters/map-adapter.ts | 20 ++++++++++++++++++- autk-provenance/src/types.ts | 22 ++++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/autk-provenance/src/adapters/map-adapter.ts b/autk-provenance/src/adapters/map-adapter.ts index 183c90d5..1050f115 100644 --- a/autk-provenance/src/adapters/map-adapter.ts +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -1,4 +1,4 @@ -import type { AutarkProvenanceState, IMapForProvenance } from '../types'; +import type { AutarkProvenanceState, IMapForProvenance, MapViewState } from '../types'; import { ProvenanceAction } from '../types'; const MAP_PICK_EVENT = 'pick'; @@ -39,6 +39,7 @@ export function createMapAdapter( ): MapAdapterApi { const mapObj = map as unknown as Record; let pickListener: ((selection: number[], layerId: string) => void) | null = null; + let viewListener: ((state: MapViewState) => void) | null = null; let clickListener: ((event: Event) => void) | null = null; let changeListener: ((event: Event) => void) | null = null; let isApplyingState = false; @@ -211,6 +212,15 @@ export function createMapAdapter( }; map.mapEvents.addEventListener(MAP_PICK_EVENT, pickListener); + if (map.addViewListener) { + viewListener = (viewState: MapViewState) => { + if (isApplyingState) return; + const alt = viewState.eye[2].toFixed(0); + onRecord(ProvenanceAction.MAP_VIEW, `View changed (alt: ${alt})`, { view: viewState }); + }; + map.addViewListener(viewListener); + } + if (typeof document !== 'undefined') { clickListener = (event: Event) => { if (isApplyingState || !inMapContainer(event.target)) return; @@ -269,6 +279,10 @@ export function createMapAdapter( map.mapEvents.removeEventListener(MAP_PICK_EVENT, pickListener); pickListener = null; } + if (viewListener && map.removeViewListener) { + map.removeViewListener(viewListener); + viewListener = null; + } if (clickListener && typeof document !== 'undefined') { document.removeEventListener('click', clickListener); clickListener = null; @@ -313,6 +327,10 @@ export function createMapAdapter( syncUiDom(ui); + if (state.view && map.setViewState) { + map.setViewState(state.view); + } + if (selection) { const targetLayerId = selection.map?.layerId; const targetIds = selection.map?.ids ?? []; diff --git a/autk-provenance/src/types.ts b/autk-provenance/src/types.ts index 12a769f8..311fe8da 100644 --- a/autk-provenance/src/types.ts +++ b/autk-provenance/src/types.ts @@ -1,3 +1,13 @@ +/** + * Serialisable snapshot of the map camera viewport. + * Sufficient to fully restore the camera via resetCamera(state.up, state.lookAt, state.eye). + */ +export interface MapViewState { + eye: [number, number, number]; + lookAt: [number, number, number]; + up: [number, number, number]; +} + /** * Canonical state shape for autark provenance. * Captures everything needed to restore the current analysis state. @@ -13,11 +23,7 @@ export interface AutarkProvenanceState { visibleLayerIds?: string[]; thematicEnabled?: boolean; }; - view?: { - center: [number, number]; - zoom?: number; - pitch?: number; - }; + view?: MapViewState; data?: { workspace: string; layerTableNames: string[]; @@ -94,6 +100,12 @@ export interface IMapForProvenance { addEventListener(event: string, listener: (selection: number[], layerId: string) => void): void; removeEventListener?(event: string, listener: (selection: number[], layerId: string) => void): void; }; + /** Register a callback to be notified whenever the camera viewport changes (zoom or pan). */ + addViewListener?(callback: (state: MapViewState) => void): void; + /** Unregister a viewport change callback. */ + removeViewListener?(callback: (state: MapViewState) => void): void; + /** Restore the camera to a previously captured viewport state. */ + setViewState?(state: MapViewState): void; canvas: { parentElement: HTMLElement | null; }; From 5d19b02995147589fa6d970d76737981c1bd63a9 Mon Sep 17 00:00:00 2001 From: JP109 Date: Mon, 16 Mar 2026 23:39:06 -0500 Subject: [PATCH 04/21] feat: export and import provenance history --- autk-provenance/src/core.ts | 6 ++-- .../autk-provenance/map-plot-provenance.css | 24 +++++++++++++++ .../autk-provenance/map-plot-provenance.html | 5 ++++ .../autk-provenance/map-plot-provenance.ts | 29 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/autk-provenance/src/core.ts b/autk-provenance/src/core.ts index 9b758778..a66471de 100644 --- a/autk-provenance/src/core.ts +++ b/autk-provenance/src/core.ts @@ -60,7 +60,7 @@ export function createProvenanceCore( ): ProvenanceCoreApi { const { initialState } = options; const nodes = new Map>(); - const rootId = generateId(); + let rootId = generateId(); let currentId = rootId; const observers: Array<(node: ProvenanceNode) => void> = []; @@ -197,6 +197,7 @@ export function createProvenanceCore( for (const [id, node] of parsed.nodes) { nodes.set(id, node); } + rootId = parsed.rootId; currentId = parsed.currentId; notify(); } catch { @@ -239,7 +240,7 @@ export function createProvenanceCoreGeneric>( ): ProvenanceCoreApi { const { initialState, mergeState } = options; const nodes = new Map>(); - const rootId = generateId(); + let rootId = generateId(); let currentId = rootId; const observers: Array<(node: ProvenanceNode) => void> = []; @@ -371,6 +372,7 @@ export function createProvenanceCoreGeneric>( for (const [id, node] of parsed.nodes) { nodes.set(id, node); } + rootId = parsed.rootId; currentId = parsed.currentId; notify(); } catch { diff --git a/examples/src/autk-provenance/map-plot-provenance.css b/examples/src/autk-provenance/map-plot-provenance.css index bcbce6d3..984e87d9 100644 --- a/examples/src/autk-provenance/map-plot-provenance.css +++ b/examples/src/autk-provenance/map-plot-provenance.css @@ -138,6 +138,30 @@ svg { max-height: 920px; } +.session-actions { + display: flex; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); +} + +.session-actions button { + flex: 1; + padding: 6px 10px; + font-size: 13px; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + cursor: pointer; +} + +.session-actions button:hover { + background: var(--bg); +} + #sidebar .panel-body { height: calc(100% - 46px); padding: 10px; diff --git a/examples/src/autk-provenance/map-plot-provenance.html b/examples/src/autk-provenance/map-plot-provenance.html index 24638631..71118b8f 100644 --- a/examples/src/autk-provenance/map-plot-provenance.html +++ b/examples/src/autk-provenance/map-plot-provenance.html @@ -33,6 +33,11 @@

Scatterplot

+ + +
+

Session Insights

+
+
+
Analysis Strategy
+
Waiting for interactions…
+
+
+
Annotate This Step
+
Waiting for interactions…
+
+
+
Selection Focus
+
Waiting for interactions…
+
+
+
Analysis Summary
+
Waiting for interactions…
+
+
+
diff --git a/examples/src/autk-provenance/map-plot-provenance.ts b/examples/src/autk-provenance/map-plot-provenance.ts index 115708b9..30c824c8 100644 --- a/examples/src/autk-provenance/map-plot-provenance.ts +++ b/examples/src/autk-provenance/map-plot-provenance.ts @@ -5,8 +5,211 @@ import { AutkMap, MapEvent, VectorLayer } from 'autk-map'; import { createAutarkProvenance, renderProvenanceTrailUI, + computeGraphMetrics, + computeSelectionFrequency, + getInsightAnnotations, + generateSessionNarrative, + type StrategyLabel, } from 'autk-provenance'; +// --------------------------------------------------------------------------- +// Strategy colours (mirrors insight-engine classification) +// --------------------------------------------------------------------------- +const STRATEGY_COLORS: Record = { + Confirmatory: '#2e7d32', + Exploratory: '#1565c0', + 'Iterative Refinement': '#6a1b9a', +}; + +const STRATEGY_DESC: Record = { + Confirmatory: 'Focused, linear exploration — you appear to know what you are looking for.', + Exploratory: 'Broad, open-ended investigation with multiple diverging paths.', + 'Iterative Refinement': 'Hypothesis-driven: repeated backtracking and revision of prior selections.', +}; + +// --------------------------------------------------------------------------- +// Insight card renderers +// --------------------------------------------------------------------------- + +function renderMetricsCard(el: HTMLElement, provenance: ReturnType): void { + const graph = provenance.getGraph(); + const m = computeGraphMetrics(graph); + + el.innerHTML = ''; + + const badge = document.createElement('span'); + badge.className = 'strategy-badge'; + badge.textContent = m.strategyLabel; + badge.style.background = STRATEGY_COLORS[m.strategyLabel]; + el.appendChild(badge); + + const desc = document.createElement('p'); + desc.className = 'strategy-desc'; + desc.textContent = STRATEGY_DESC[m.strategyLabel]; + el.appendChild(desc); + + const chips: [string, string][] = [ + ['States', String(m.totalNodes)], + ['Branch points', String(m.branchPoints)], + ['Backtracks', String(m.backtracks)], + ['Max depth', String(m.maxDepth)], + ['Insights noted', String(m.insightCount)], + ]; + + if (m.sessionDurationMs > 0) { + const s = Math.round(m.sessionDurationMs / 1000); + const dur = s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; + chips.unshift(['Duration', dur]); + } + + const row = document.createElement('div'); + row.className = 'metrics-row'; + for (const [label, value] of chips) { + const chip = document.createElement('div'); + chip.className = 'metric-chip'; + chip.innerHTML = `${value} ${label}`; + row.appendChild(chip); + } + el.appendChild(row); +} + +// Keep annotation textarea stable across re-renders — only recreate when node changes +let _annotationNodeId: string | null = null; +let _annotationTextarea: HTMLTextAreaElement | null = null; + +function renderAnnotateCard(el: HTMLElement, provenance: ReturnType): void { + const currentNode = provenance.getCurrentNode(); + + if (!currentNode) { + el.innerHTML = 'No active state.'; + _annotationNodeId = null; + _annotationTextarea = null; + return; + } + + // If same node, just sync the textarea value (don't wipe the DOM) + if (currentNode.id === _annotationNodeId && _annotationTextarea) { + if (!_annotationTextarea.matches(':focus')) { + const existing = currentNode.metadata?.insight; + _annotationTextarea.value = typeof existing === 'string' ? existing : ''; + } + return; + } + + // New node — rebuild the card + _annotationNodeId = currentNode.id; + el.innerHTML = ''; + + const label = document.createElement('p'); + label.className = 'annotation-label'; + label.textContent = `Current step: "${currentNode.actionLabel}"`; + el.appendChild(label); + + const textarea = document.createElement('textarea'); + textarea.className = 'annotation-textarea'; + textarea.placeholder = 'What did you notice or conclude at this step?'; + const existing = currentNode.metadata?.insight; + textarea.value = typeof existing === 'string' ? existing : ''; + el.appendChild(textarea); + _annotationTextarea = textarea; + + const saveBtn = document.createElement('button'); + saveBtn.className = 'annotation-save-btn'; + saveBtn.textContent = 'Save Insight'; + saveBtn.addEventListener('click', () => { + provenance.annotateNode(currentNode.id, textarea.value.trim()); + saveBtn.textContent = 'Saved!'; + setTimeout(() => { saveBtn.textContent = 'Save Insight'; }, 1500); + }); + el.appendChild(saveBtn); +} + +function renderFreqCard(el: HTMLElement, provenance: ReturnType): void { + const graph = provenance.getGraph(); + const freq = computeSelectionFrequency(graph); + + const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); + const topPlot = [...freq.plot.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); + + el.innerHTML = ''; + + if (topMap.length === 0 && topPlot.length === 0) { + const empty = document.createElement('span'); + empty.className = 'freq-empty'; + empty.textContent = 'No selections yet. Click features on the map or brush the scatterplot.'; + el.appendChild(empty); + return; + } + + const maxCount = Math.max(...[...topMap, ...topPlot].map(([, c]) => c), 1); + + const renderGroup = (label: string, items: [number, number][]): void => { + if (items.length === 0) return; + + const groupLabel = document.createElement('div'); + groupLabel.className = 'freq-group-label'; + groupLabel.textContent = label; + el.appendChild(groupLabel); + + for (const [id, count] of items) { + const row = document.createElement('div'); + row.className = 'freq-row'; + + const idSpan = document.createElement('span'); + idSpan.className = 'freq-id'; + idSpan.textContent = `#${id}`; + row.appendChild(idSpan); + + const barWrap = document.createElement('div'); + barWrap.className = 'freq-bar-wrap'; + const fill = document.createElement('div'); + fill.className = 'freq-bar-fill'; + fill.style.width = `${Math.round((count / maxCount) * 100)}%`; + barWrap.appendChild(fill); + row.appendChild(barWrap); + + const countSpan = document.createElement('span'); + countSpan.className = 'freq-count'; + countSpan.textContent = `${count}×`; + row.appendChild(countSpan); + + el.appendChild(row); + } + }; + + renderGroup('Map features', topMap); + renderGroup('Plot features', topPlot); +} + +function renderSummaryCard(el: HTMLElement, provenance: ReturnType): void { + const graph = provenance.getGraph(); + const metrics = computeGraphMetrics(graph); + const annotations = getInsightAnnotations(graph); + const narrative = generateSessionNarrative(graph, metrics, annotations); + + el.innerHTML = ''; + + const pre = document.createElement('pre'); + pre.className = 'summary-pre'; + pre.textContent = narrative; + el.appendChild(pre); + + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-btn'; + copyBtn.textContent = 'Copy to clipboard'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(narrative).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy to clipboard'; }, 1800); + }); + }); + el.appendChild(copyBtn); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + async function main() { const db = new SpatialDb(); await db.init(); @@ -58,6 +261,7 @@ async function main() { const provenance = createAutarkProvenance({ map, plot, db }); + // ── Export / Import ────────────────────────────────────────────────────── const exportBtn = document.querySelector('#exportBtn') as HTMLButtonElement; const importBtn = document.querySelector('#importBtn') as HTMLButtonElement; const importFile = document.querySelector('#importFile') as HTMLInputElement; @@ -74,7 +278,6 @@ async function main() { }); importBtn.addEventListener('click', () => importFile.click()); - importFile.addEventListener('change', () => { const file = importFile.files?.[0]; if (!file) return; @@ -87,6 +290,7 @@ async function main() { importFile.value = ''; }); + // ── Provenance trail sidebar ────────────────────────────────────────────── if (trailContainer) { renderProvenanceTrailUI({ provenance, @@ -97,6 +301,22 @@ async function main() { showPathList: true, }); } + + // ── Session Insights section ────────────────────────────────────────────── + const cardMetrics = document.querySelector('#insightsMetrics .insights-card-body') as HTMLElement; + const cardAnnotate = document.querySelector('#insightsAnnotate .insights-card-body') as HTMLElement; + const cardFreq = document.querySelector('#insightsFreq .insights-card-body') as HTMLElement; + const cardSummary = document.querySelector('#insightsSummary .insights-card-body') as HTMLElement; + + function refreshInsights(): void { + if (cardMetrics) renderMetricsCard(cardMetrics, provenance); + if (cardAnnotate) renderAnnotateCard(cardAnnotate, provenance); + if (cardFreq) renderFreqCard(cardFreq, provenance); + if (cardSummary) renderSummaryCard(cardSummary, provenance); + } + + provenance.addObserver(() => refreshInsights()); + refreshInsights(); } main(); From b45044c01fbe46c49b99a21252ff14cddf459c0c Mon Sep 17 00:00:00 2001 From: JP109 Date: Fri, 17 Apr 2026 11:24:59 -0500 Subject: [PATCH 06/21] fix: Show map sction names instead of id's in insights tab --- .../autk-provenance/map-plot-provenance.css | 6 +++-- .../autk-provenance/map-plot-provenance.ts | 24 ++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/examples/src/autk-provenance/map-plot-provenance.css b/examples/src/autk-provenance/map-plot-provenance.css index fe129e72..95b5149d 100644 --- a/examples/src/autk-provenance/map-plot-provenance.css +++ b/examples/src/autk-provenance/map-plot-provenance.css @@ -362,9 +362,11 @@ svg { .freq-id { font-size: 11px; color: var(--text-muted); - width: 52px; + width: 140px; flex-shrink: 0; - font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .freq-bar-wrap { diff --git a/examples/src/autk-provenance/map-plot-provenance.ts b/examples/src/autk-provenance/map-plot-provenance.ts index 30c824c8..4a6c348a 100644 --- a/examples/src/autk-provenance/map-plot-provenance.ts +++ b/examples/src/autk-provenance/map-plot-provenance.ts @@ -124,7 +124,11 @@ function renderAnnotateCard(el: HTMLElement, provenance: ReturnType): void { +function renderFreqCard( + el: HTMLElement, + provenance: ReturnType, + featureName: (id: number) => string, +): void { const graph = provenance.getGraph(); const freq = computeSelectionFrequency(graph); @@ -155,10 +159,11 @@ function renderFreqCard(el: HTMLElement, provenance: ReturnType { + const feat = geojson.features[id]; + const name = feat?.properties?.['ntaname'] as string | undefined; + return name ?? `#${id}`; + }; + function refreshInsights(): void { if (cardMetrics) renderMetricsCard(cardMetrics, provenance); if (cardAnnotate) renderAnnotateCard(cardAnnotate, provenance); - if (cardFreq) renderFreqCard(cardFreq, provenance); + if (cardFreq) renderFreqCard(cardFreq, provenance, featureName); if (cardSummary) renderSummaryCard(cardSummary, provenance); } From 0e4672a7088d38948ac3443c9fa9ee5f754bd23e Mon Sep 17 00:00:00 2001 From: JP109 Date: Mon, 27 Apr 2026 18:58:25 -0500 Subject: [PATCH 07/21] feat: add insight generation from tracked provenance. support provenance for all kinds of charts and maps. --- autk-plot/src/plot-types/histogram.ts | 299 +++++++++++++ .../src/adapters/compute-adapter.ts | 107 +++++ autk-provenance/src/adapters/index.ts | 8 +- autk-provenance/src/adapters/map-adapter.ts | 142 +++++- autk-provenance/src/adapters/plot-adapter.ts | 145 ++++-- autk-provenance/src/core.ts | 8 +- .../src/create-autark-provenance.ts | 34 +- autk-provenance/src/insight-engine.ts | 27 +- autk-provenance/src/provenance-trail-ui.ts | 20 +- autk-provenance/src/types.ts | 29 +- .../autk-provenance/all-plots-provenance.css | 422 ++++++++++++++++++ .../autk-provenance/all-plots-provenance.html | 149 +++++++ .../autk-provenance/all-plots-provenance.ts | 410 +++++++++++++++++ .../autk-provenance/map-plot-provenance.ts | 21 +- 14 files changed, 1745 insertions(+), 76 deletions(-) create mode 100644 autk-plot/src/plot-types/histogram.ts create mode 100644 autk-provenance/src/adapters/compute-adapter.ts create mode 100644 examples/src/autk-provenance/all-plots-provenance.css create mode 100644 examples/src/autk-provenance/all-plots-provenance.html create mode 100644 examples/src/autk-provenance/all-plots-provenance.ts diff --git a/autk-plot/src/plot-types/histogram.ts b/autk-plot/src/plot-types/histogram.ts new file mode 100644 index 00000000..5724650c --- /dev/null +++ b/autk-plot/src/plot-types/histogram.ts @@ -0,0 +1,299 @@ +import * as d3 from 'd3'; + +import { PlotD3 } from '../plot-d3'; +import { PlotConfig } from '../types'; +import { PlotStyle } from '../plot-style'; +import { PlotEvent } from '../constants'; + +type BinDatum = { value: number; index: number }; + +/** + * D3 histogram chart. + * + * Each bar represents one bin computed by d3.bin() on the numeric property + * specified by config.labels.axis[0]. Selection emits and receives *feature + * row indices* (not bin indices), so it is directly compatible with the + * coordinated-view highlighting used by the map and other plots. + * + * config.labels.axis[1] is used as the Y-axis label (defaults to 'Count'). + */ +export class Histogram extends PlotD3 { + private binData: d3.Bin[] = []; + + constructor(config: PlotConfig) { + if (config.events === undefined) { config.events = [PlotEvent.CLICK]; } + super(config); + this.draw(); + } + + public async draw(): Promise { + const svg = d3 + .select(this._div) + .selectAll('#plot') + .data([0]) + .join('svg') + .attr('id', 'plot') + .style('width', `${this._width}px`) + .style('height', `${this._height}px`) + .style('visibility', 'visible'); + + const node = svg.node(); + if (!svg || !node) throw new Error('SVG element could not be created.'); + + const width = this._width - this._margins.left - this._margins.right; + const height = this._height - this._margins.top - this._margins.bottom; + + if (this._title && this._title.length > 0) { + svg + .selectAll('#plotTitle') + .data([this._title]) + .join('text') + .attr('id', 'plotTitle') + .attr('class', 'plot-title') + .attr('x', this._margins.left + width / 2) + .attr('y', Math.max(this._margins.top * 0.5, 10)) + .attr('text-anchor', 'middle') + .style('font-weight', '600') + .style('visibility', 'visible') + .text((d) => d); + } + + const values: BinDatum[] = this.data.map((d, i) => ({ + value: d ? +d[this._axis[0]] || 0 : 0, + index: i, + })); + + const extent = d3.extent(values, (d) => d.value) as [number, number]; + this.binData = d3 + .bin() + .value((d) => d.value) + .domain(extent)(values); + + const mapX = d3.scaleLinear().domain(extent).range([0, width]); + const maxCount = d3.max(this.binData, (b) => b.length) ?? 0; + const mapY = d3.scaleLinear().domain([0, maxCount]).range([height, 0]); + + const xAxis = d3.axisBottom(mapX).tickSizeOuter(0).tickFormat(d3.format('.2s')); + svg + .selectAll('#axisX') + .data([0]) + .join('g') + .attr('id', 'axisX') + .attr('class', 'x axis') + .attr('transform', `translate(${this._margins.left}, ${this._height - this._margins.bottom})`) + .style('visibility', 'visible') + .call(xAxis); + + const yAxis = d3.axisLeft(mapY).tickSizeInner(-width).tickFormat(d3.format('.2s')); + const yAxisSel = svg + .selectAll('#axisY') + .data([0]) + .join('g') + .attr('id', 'axisY') + .attr('class', 'y axis') + .attr('transform', `translate(${this._margins.left}, ${this._margins.top})`) + .style('visibility', 'visible') + .call(yAxis); + + const yLabel = this._axis[1] ?? 'Count'; + yAxisSel + .selectAll('.hist-ylabel') + .data([yLabel]) + .join('text') + .attr('class', 'hist-ylabel title') + .attr('text-anchor', 'end') + .attr('transform', 'rotate(-90)') + .attr('y', -this._margins.left / 2 - 7) + .attr('x', -this._margins.top) + .style('visibility', 'visible') + .text((d) => d); + + const cGroup = svg + .selectAll('.autkBrushable') + .data([0]) + .join('g') + .attr('class', 'autkBrushable autkMarksGroup') + .attr('transform', `translate(${this._margins.left}, ${this._margins.top})`); + + cGroup + .selectAll('.autkClear') + .data([0]) + .join('rect') + .attr('class', 'autkClear') + .attr('width', width) + .attr('height', height) + .style('fill', 'white') + .style('opacity', 0) + .style('visibility', 'visible'); + + const barPad = 1; + cGroup + .selectAll('.autkMark') + .data(this.binData) + .join('rect') + .attr('class', 'autkMark') + .attr('x', (b) => mapX(b.x0!) + barPad) + .attr('y', (b) => mapY(b.length)) + .attr('width', (b) => Math.max(0, mapX(b.x1!) - mapX(b.x0!) - barPad * 2)) + .attr('height', (b) => height - mapY(b.length)) + .style('fill', PlotStyle.default) + .style('stroke', '#2f2f2f') + .style('visibility', 'visible'); + + this.configureSignalListeners(); + } + + // Expand a set of bin indices into the feature row indices within those bins. + private binIndicesToFeatureIds(binIndices: Set): number[] { + const ids: number[] = []; + for (const binIdx of binIndices) { + const bin = this.binData[binIdx]; + if (bin) ids.push(...bin.map((d) => d.index)); + } + return [...new Set(ids)]; + } + + // Override: emit feature row indices (not bin indices) so coordinated-view works correctly. + clickEvent(): void { + const svgs = d3.select(this._div).selectAll('.autkMark'); + const cls = d3.select(this._div).selectAll('.autkClear'); + const plot = this; + + svgs.each(function (_d, binIdx: number) { + d3.select(this).on('click', function () { + const binFeatureIds = plot.binData[binIdx]?.map((d) => d.index) ?? []; + const allSelected = + binFeatureIds.length > 0 && + binFeatureIds.every((id) => plot.selection.includes(id)); + if (allSelected) { + plot.selection = plot.selection.filter((id) => !binFeatureIds.includes(id)); + } else { + plot.selection = [...new Set([...plot.selection, ...binFeatureIds])]; + } + plot.plotEvents.emit(PlotEvent.CLICK, plot.selection); + plot.updatePlotSelection(); + }); + }); + + cls.on('click', function () { + plot.selection = []; + plot.plotEvents.emit(PlotEvent.CLICK, plot.selection); + plot.updatePlotSelection(); + }); + } + + brushEvent(): void { + const brushable = d3.select(this._div).selectAll('.autkBrushable'); + const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); + const plot = this; + + brushable.each(function () { + const cBrush = d3.select(this); + const brush = d3.brush() + .extent([[0, 0], [ + plot._width - plot._margins.left - plot._margins.right, + plot._height - plot._margins.top - plot._margins.bottom, + ]]) + .on('start end', function (event: d3.D3BrushEvent) { + if (event.selection) { + const [[x0, y0], [x1, y1]] = event.selection as [[number, number], [number, number]]; + const brushedBins = new Set(); + marksGroup.selectAll('.autkMark') + .each(function (_d, binIdx: number) { + const bbox = this.getBBox(); + if (!(bbox.x + bbox.width < x0 || bbox.x > x1 || bbox.y + bbox.height < y0 || bbox.y > y1)) { + brushedBins.add(binIdx); + } + }); + plot.selection = plot.binIndicesToFeatureIds(brushedBins); + } else { + plot.selection = []; + } + plot.plotEvents.emit(PlotEvent.BRUSH, plot.selection); + plot.updatePlotSelection(); + }); + cBrush.call(brush); + }); + } + + brushXEvent(): void { + const brushable = d3.select(this._div).selectAll('.autkBrushable'); + const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); + const innerHeight = this._height - this._margins.top - this._margins.bottom; + const plot = this; + + brushable.each(function () { + const cBrush = d3.select(this); + const brush = d3.brushX() + .extent([[0, 0], [ + plot._width - plot._margins.left - plot._margins.right, + innerHeight, + ]]) + .on('start end', function (event: d3.D3BrushEvent) { + if (event.selection) { + const [x0, x1] = event.selection as [number, number]; + const brushedBins = new Set(); + marksGroup.selectAll('.autkMark') + .each(function (_d, binIdx: number) { + const bbox = this.getBBox(); + if (!(bbox.x + bbox.width < x0 || bbox.x > x1)) { + brushedBins.add(binIdx); + } + }); + plot.selection = plot.binIndicesToFeatureIds(brushedBins); + } else { + plot.selection = []; + } + plot.plotEvents.emit(PlotEvent.BRUSH_X, plot.selection); + plot.updatePlotSelection(); + }); + cBrush.call(brush); + }); + } + + brushYEvent(): void { + const brushable = d3.select(this._div).selectAll('.autkBrushable'); + const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); + const innerHeight = this._height - this._margins.top - this._margins.bottom; + const plot = this; + + brushable.each(function () { + const cBrush = d3.select(this); + const brush = d3.brushY() + .extent([[0, 0], [ + plot._width - plot._margins.left - plot._margins.right, + innerHeight, + ]]) + .on('start end', function (event: d3.D3BrushEvent) { + if (event.selection) { + const [y0, y1] = event.selection as [number, number]; + const brushedBins = new Set(); + marksGroup.selectAll('.autkMark') + .each(function (_d, binIdx: number) { + const bbox = this.getBBox(); + if (!(bbox.y + bbox.height < y0 || bbox.y > y1)) { + brushedBins.add(binIdx); + } + }); + plot.selection = plot.binIndicesToFeatureIds(brushedBins); + } else { + plot.selection = []; + } + plot.plotEvents.emit(PlotEvent.BRUSH_Y, plot.selection); + plot.updatePlotSelection(); + }); + cBrush.call(brush); + }); + } + + // Highlight bars that contain any of the given feature row indices. + updatePlotSelection(): void { + const selectedSet = new Set(this._selection); + const plot = this; + d3.select(this._div).selectAll('.autkMark').style('fill', function (_d: unknown, binIdx: number) { + const bin = plot.binData[binIdx]; + const hasSelected = bin ? bin.some((d) => selectedSet.has(d.index)) : false; + return hasSelected ? PlotStyle.highlight : PlotStyle.default; + }); + } +} diff --git a/autk-provenance/src/adapters/compute-adapter.ts b/autk-provenance/src/adapters/compute-adapter.ts new file mode 100644 index 00000000..6048e811 --- /dev/null +++ b/autk-provenance/src/adapters/compute-adapter.ts @@ -0,0 +1,107 @@ +import type { AutarkProvenanceState } from '../types'; +import { ProvenanceAction } from '../types'; + +export type ComputeRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; + +/** + * Minimal interface for a compute object compatible with autk-compute's GeojsonCompute. + * Only the methods that exist on the instance are wrapped — missing methods are ignored. + */ +export interface IComputeForProvenance { + computeFunctionIntoProperties?(...args: unknown[]): Promise; + [key: string]: unknown; +} + +export interface ComputeAdapterApi { + startRecording(): void; + stopRecording(): void; +} + +function isFn(value: unknown): value is (...args: unknown[]) => Promise { + return typeof value === 'function'; +} + +function extractOutputColumnName(args: unknown[]): string { + const params = args[0]; + if (params && typeof params === 'object' && 'outputColumnName' in params) { + const name = (params as { outputColumnName?: unknown }).outputColumnName; + if (typeof name === 'string' && name.trim().length > 0) return name; + } + return 'result'; +} + +function extractFeatureCount(args: unknown[]): number { + const params = args[0]; + if (params && typeof params === 'object' && 'geojson' in params) { + const geojson = (params as { geojson?: unknown }).geojson; + if (geojson && typeof geojson === 'object' && 'features' in geojson) { + const features = (geojson as { features?: unknown[] }).features; + if (Array.isArray(features)) return features.length; + } + } + return 0; +} + +export function createComputeAdapter( + compute: IComputeForProvenance, + onRecord: ComputeRecordCallback +): ComputeAdapterApi { + const computeObj = compute as Record; + const originalMethods = new Map(); + let isRecording = false; + + function wrapAsyncMethod( + methodName: string, + buildRecord: (args: unknown[]) => { label: string; delta: Partial } + ): void { + const current = computeObj[methodName]; + if (!isFn(current) || originalMethods.has(methodName)) return; + originalMethods.set(methodName, current); + + computeObj[methodName] = async function (...args: unknown[]) { + const result = await (current as (...a: unknown[]) => Promise).apply(this, args); + if (isRecording) { + const { label, delta } = buildRecord(args); + onRecord(ProvenanceAction.COMPUTE_RUN, label, delta); + } + return result; + }; + } + + function restoreWrappedMethods(): void { + for (const [methodName, original] of originalMethods.entries()) { + computeObj[methodName] = original; + } + originalMethods.clear(); + } + + function startRecording(): void { + if (isRecording) return; + isRecording = true; + + wrapAsyncMethod('computeFunctionIntoProperties', (args) => { + const col = extractOutputColumnName(args); + const n = extractFeatureCount(args); + return { + label: `Compute: "${col}" on ${n} feature${n !== 1 ? 's' : ''}`, + delta: { + filters: { + lastCompute: { outputColumnName: col, featureCount: n, timestamp: Date.now() }, + }, + }, + }; + }); + } + + function stopRecording(): void { + if (!isRecording) return; + isRecording = false; + restoreWrappedMethods(); + } + + return { startRecording, stopRecording }; +} diff --git a/autk-provenance/src/adapters/index.ts b/autk-provenance/src/adapters/index.ts index 0fd57a82..074abb44 100644 --- a/autk-provenance/src/adapters/index.ts +++ b/autk-provenance/src/adapters/index.ts @@ -1,5 +1,5 @@ export { createMapAdapter } from './map-adapter'; -export type { MapAdapterApi, MapRecordCallback } from './map-adapter'; +export type { MapAdapterApi, MapRecordCallback, MapSelectorConfig, CustomControlConfig } from './map-adapter'; export { createPlotAdapter } from './plot-adapter'; export type { PlotAdapterApi, PlotRecordCallback } from './plot-adapter'; export { @@ -11,3 +11,9 @@ export type { DbRecordCallback, IDbForProvenance, } from './db-adapter'; +export { createComputeAdapter } from './compute-adapter'; +export type { + ComputeAdapterApi, + ComputeRecordCallback, + IComputeForProvenance, +} from './compute-adapter'; diff --git a/autk-provenance/src/adapters/map-adapter.ts b/autk-provenance/src/adapters/map-adapter.ts index 1050f115..fb2b0d04 100644 --- a/autk-provenance/src/adapters/map-adapter.ts +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -23,6 +23,59 @@ export type MapRecordCallback = ( stateDelta: Partial ) => void; +/** + * Config for a single custom DOM control to track in provenance. + * Use this to track app-specific UI such as temporal dropdowns, + * dataset pickers, or custom layer toggles. + * + * Example — month dropdown in a shadows analysis app: + * ```ts + * { + * selector: '#monthSelect', + * event: 'change', + * actionType: 'SHADOW_MONTH_CHANGE', + * getLabel: (el) => `Month: ${(el as HTMLSelectElement).value}`, + * getStateDelta: (el) => ({ filters: { month: (el as HTMLSelectElement).value } }), + * applyState: (el, state) => { (el as HTMLSelectElement).value = state.filters?.month as string ?? ''; }, + * } + * ``` + */ +export interface CustomControlConfig { + /** CSS selector used to find the element inside the map container. */ + selector: string; + /** DOM event type that triggers recording. */ + event: 'click' | 'change'; + /** ProvenanceAction type (or any string) to store on the node. */ + actionType: ProvenanceAction | string; + /** Derive the human-readable action label from the element at event time. */ + getLabel(el: Element): string; + /** Derive the state delta from the element at event time. */ + getStateDelta(el: Element): Partial; + /** Restore this control's visual state during provenance replay. Called in applyState. */ + applyState?(el: Element, state: AutarkProvenanceState): void; +} + +/** + * Override the CSS selectors the map adapter uses to locate standard map UI. + * All fields are optional — unset fields fall back to the built-in defaults. + */ +export interface MapSelectorConfig { + /** Default: '#menuIcon' */ + menuIcon?: string; + /** Default: '#autkMapSubMenu' */ + subMenu?: string; + /** Default: '#showThematicCheckbox' */ + thematicCheckbox?: string; + /** Default: '#autkMapLegend' */ + legend?: string; + /** Default: '.active-layer-radio' */ + activeLayerRadioClass?: string; + /** Default: '#visibleLayerDropdownList' */ + visibleLayerList?: string; + /** Extra controls to track (e.g. temporal dropdowns, custom toggles). */ + customControls?: CustomControlConfig[]; +} + export interface MapAdapterApi { startRecording(): void; stopRecording(): void; @@ -35,8 +88,20 @@ function isElement(value: unknown): value is Element { export function createMapAdapter( map: IMapForProvenance, - onRecord: MapRecordCallback + onRecord: MapRecordCallback, + selectorConfig?: MapSelectorConfig ): MapAdapterApi { + // Resolved selectors (config overrides fall back to defaults) + const SEL = { + menuIcon: selectorConfig?.menuIcon ?? '#menuIcon', + subMenu: selectorConfig?.subMenu ?? '#autkMapSubMenu', + thematicCheckbox: selectorConfig?.thematicCheckbox ?? '#showThematicCheckbox', + legend: selectorConfig?.legend ?? '#autkMapLegend', + activeLayerRadioClass: selectorConfig?.activeLayerRadioClass ?? '.active-layer-radio', + visibleLayerList: selectorConfig?.visibleLayerList ?? '#visibleLayerDropdownList', + }; + const customControls: CustomControlConfig[] = selectorConfig?.customControls ?? []; + const mapObj = map as unknown as Record; let pickListener: ((selection: number[], layerId: string) => void) | null = null; let viewListener: ((state: MapViewState) => void) | null = null; @@ -68,7 +133,7 @@ export function createMapAdapter( function getMenuOpen(): boolean { const parent = map.canvas.parentElement; if (!parent) return false; - const submenu = parent.querySelector('#autkMapSubMenu') as HTMLElement | null; + const submenu = parent.querySelector(SEL.subMenu) as HTMLElement | null; return submenu ? submenu.style.visibility === 'visible' : false; } @@ -107,29 +172,39 @@ export function createMapAdapter( const parent = map.canvas.parentElement; if (!parent) return; - const submenu = parent.querySelector('#autkMapSubMenu') as HTMLElement | null; + const submenu = parent.querySelector(SEL.subMenu) as HTMLElement | null; if (submenu) submenu.style.visibility = ui.mapMenuOpen ? 'visible' : 'hidden'; - const thematicCheckbox = parent.querySelector('#showThematicCheckbox') as HTMLInputElement | null; + const thematicCheckbox = parent.querySelector(SEL.thematicCheckbox) as HTMLInputElement | null; if (thematicCheckbox) thematicCheckbox.checked = ui.thematicEnabled; - const legend = parent.querySelector('#autkMapLegend') as HTMLElement | null; + const legend = parent.querySelector(SEL.legend) as HTMLElement | null; if (legend) legend.style.visibility = ui.thematicEnabled ? 'visible' : 'hidden'; const visibleSet = new Set(ui.visibleLayerIds); const visibleCheckboxes = parent.querySelectorAll( - '#visibleLayerDropdownList input[type="checkbox"]' + `${SEL.visibleLayerList} input[type="checkbox"]` ) as NodeListOf; visibleCheckboxes.forEach((checkbox) => { checkbox.checked = visibleSet.has(checkbox.value); }); - const activeRadios = parent.querySelectorAll('.active-layer-radio') as NodeListOf; + const activeRadios = parent.querySelectorAll(SEL.activeLayerRadioClass) as NodeListOf; activeRadios.forEach((radio) => { radio.checked = !!ui.activeLayerId && radio.value === ui.activeLayerId; }); } + function syncCustomControlsDom(state: AutarkProvenanceState): void { + const parent = map.canvas.parentElement; + if (!parent || customControls.length === 0) return; + for (const ctrl of customControls) { + if (!ctrl.applyState) continue; + const el = parent.querySelector(ctrl.selector); + if (el) ctrl.applyState(el, state); + } + } + function inMapContainer(target: EventTarget | null): boolean { if (!isElement(target)) return false; const parent = map.canvas.parentElement; @@ -206,7 +281,7 @@ export function createMapAdapter( onRecord(ProvenanceAction.MAP_PICK, label, { selection: { map: { layerId, ids: selection }, - plot: selection, + plots: {}, }, }); }; @@ -223,11 +298,27 @@ export function createMapAdapter( if (typeof document !== 'undefined') { clickListener = (event: Event) => { - if (isApplyingState || !inMapContainer(event.target)) return; + if (isApplyingState) return; const target = event.target; if (!isElement(target)) return; - if (target.closest('#menuIcon')) { + // Custom click controls are checked first — no container restriction needed + // because their selectors are specific enough to avoid false matches. + for (const ctrl of customControls) { + if (ctrl.event !== 'click') continue; + const matchEl = target.matches(ctrl.selector) + ? target + : target.closest(ctrl.selector); + if (matchEl) { + onRecord(ctrl.actionType, ctrl.getLabel(matchEl), ctrl.getStateDelta(matchEl)); + return; + } + } + + // Standard map controls require the target to be inside the map container. + if (!inMapContainer(target)) return; + + if (target.closest(SEL.menuIcon)) { recordUiEvent( ProvenanceAction.MAP_UI_MENU_TOGGLE, getMenuOpen() ? 'Opened map menu' : 'Closed map menu', @@ -238,11 +329,27 @@ export function createMapAdapter( document.addEventListener('click', clickListener); changeListener = (event: Event) => { - if (isApplyingState || !inMapContainer(event.target)) return; + if (isApplyingState) return; const target = event.target; + if (!isElement(target)) return; + + // Custom change controls are checked first — no container restriction. + for (const ctrl of customControls) { + if (ctrl.event !== 'change') continue; + const matchEl = target.matches(ctrl.selector) + ? target + : target.closest(ctrl.selector); + if (matchEl) { + onRecord(ctrl.actionType, ctrl.getLabel(matchEl), ctrl.getStateDelta(matchEl)); + return; + } + } + + // Standard map controls require the target to be inside the map container. + if (!inMapContainer(target)) return; if (!(target instanceof HTMLInputElement)) return; - if (target.id === 'showThematicCheckbox') { + if (target.id === SEL.thematicCheckbox.replace(/^#/, '')) { recordUiEvent( ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', @@ -251,7 +358,7 @@ export function createMapAdapter( return; } - if (target.classList.contains('active-layer-radio')) { + if (target.classList.contains(SEL.activeLayerRadioClass.replace(/^\./, ''))) { const activeLayerId = getActiveLayerId() ?? target.value; recordUiEvent( ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, @@ -261,7 +368,8 @@ export function createMapAdapter( return; } - if (target.type === 'checkbox' && target.closest('#visibleLayerDropdownList')) { + const listSel = SEL.visibleLayerList.replace(/^#/, ''); + if (target.type === 'checkbox' && target.closest(`#${listSel}`)) { const layerId = target.value || 'layer'; recordUiEvent( ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, @@ -308,7 +416,7 @@ export function createMapAdapter( } const parent = map.canvas.parentElement; - const thematicCheckbox = parent?.querySelector('#showThematicCheckbox') as HTMLInputElement | null; + const thematicCheckbox = parent?.querySelector(SEL.thematicCheckbox) as HTMLInputElement | null; if (thematicCheckbox) thematicCheckbox.checked = ui.thematicEnabled; if (ui.activeLayerId) { @@ -326,6 +434,7 @@ export function createMapAdapter( } syncUiDom(ui); + syncCustomControlsDom(state); if (state.view && map.setViewState) { map.setViewState(state.view); @@ -334,7 +443,8 @@ export function createMapAdapter( if (selection) { const targetLayerId = selection.map?.layerId; const targetIds = selection.map?.ids ?? []; - const fallbackPlotIds = selection.plot ?? []; + const allPlotIds = Object.values(selection.plots ?? {}).flatMap((p) => p.ids); + const fallbackPlotIds = [...new Set(allPlotIds)]; const fallbackLayerId = ui.activeLayerId; for (const layer of map.layerManager.vectorLayers ?? []) { diff --git a/autk-provenance/src/adapters/plot-adapter.ts b/autk-provenance/src/adapters/plot-adapter.ts index 481906d5..a653c41d 100644 --- a/autk-provenance/src/adapters/plot-adapter.ts +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -18,61 +18,152 @@ export interface PlotAdapterApi { applyState(state: AutarkProvenanceState): void; } +const EVENT_ACTION_MAP: Record = { + [PLOT_CLICK]: ProvenanceAction.PLOT_CLICK, + [PLOT_BRUSH]: ProvenanceAction.PLOT_BRUSH, + [PLOT_BRUSH_X]: ProvenanceAction.PLOT_BRUSH_X, + [PLOT_BRUSH_Y]: ProvenanceAction.PLOT_BRUSH_Y, +}; + function selectionSignature(selection: number[]): string { return selection.join(','); } +function plotTypeLabel(plotType: string): string { + return plotType.replace(/_/g, ' '); +} + +interface PlotEntry { + plot: IPlotForProvenance; + listeners: Array<{ event: string; fn: (selection: number[]) => void }>; + lastSelectionSig: string | null; + dataDescriptorRestorer: (() => void) | null; +} + export function createPlotAdapter( - plot: IPlotForProvenance, + plots: IPlotForProvenance[], onRecord: PlotRecordCallback ): PlotAdapterApi { - const listeners: Array<{ event: string; fn: (selection: number[]) => void }> = []; - let lastSelectionSig: string | null = null; - const events: Array<{ event: string; actionType: ProvenanceAction }> = [ - { event: PLOT_CLICK, actionType: ProvenanceAction.PLOT_CLICK }, - { event: PLOT_BRUSH, actionType: ProvenanceAction.PLOT_BRUSH }, - { event: PLOT_BRUSH_X, actionType: ProvenanceAction.PLOT_BRUSH_X }, - { event: PLOT_BRUSH_Y, actionType: ProvenanceAction.PLOT_BRUSH_Y }, - ]; + let isApplyingState = false; - function startRecording(): void { - if (listeners.length > 0) return; - for (const { event, actionType } of events) { + const entries: PlotEntry[] = plots.map((plot) => ({ + plot, + listeners: [], + lastSelectionSig: null, + dataDescriptorRestorer: null, + })); + + function attachListeners(entry: PlotEntry): void { + if (entry.listeners.length > 0) return; + const { plot } = entry; + + for (const [event, actionType] of Object.entries(EVENT_ACTION_MAP)) { const fn = (selection: number[]) => { const sig = selectionSignature(selection); - if (sig === lastSelectionSig) return; - lastSelectionSig = sig; + if (sig === entry.lastSelectionSig) return; + entry.lastSelectionSig = sig; + const typeLabel = plotTypeLabel(plot.plotType); const label = selection.length === 0 - ? `Cleared plot selection` - : `${event}: ${selection.length} point(s) selected`; + ? `Cleared selection on ${typeLabel} (${plot.plotId})` + : `${event}: ${selection.length} point(s) on ${typeLabel} (${plot.plotId})`; + onRecord(actionType, label, { selection: { map: null, - plot: selection, + plots: { + [plot.plotId]: { ids: selection, plotType: plot.plotType }, + }, }, }); }; - listeners.push({ event, fn }); + entry.listeners.push({ event, fn }); plot.plotEvents.addEventListener(event, fn); } } - function stopRecording(): void { - for (const { event, fn } of listeners) { - if (plot.plotEvents.removeEventListener) { - plot.plotEvents.removeEventListener(event, fn); + function detachListeners(entry: PlotEntry): void { + const { plot } = entry; + for (const { event, fn } of entry.listeners) { + plot.plotEvents.removeEventListener?.(event, fn); + } + entry.listeners.length = 0; + } + + function wrapDataSetter(entry: PlotEntry): void { + if (entry.dataDescriptorRestorer) return; + const plotObj = entry.plot as unknown as object; + const proto = Object.getPrototypeOf(plotObj); + const descriptor = Object.getOwnPropertyDescriptor(proto, 'data') + ?? Object.getOwnPropertyDescriptor(plotObj, 'data'); + if (!descriptor?.set) return; + + const { plot } = entry; + + // Override on the instance to shadow the prototype property. + // The original setter still runs so the plot's internal state stays correct. + Object.defineProperty(plotObj, 'data', { + get: descriptor.get ? () => descriptor.get!.call(plotObj) : undefined, + set(value: unknown) { + descriptor.set!.call(plotObj, value); + if (!isApplyingState) { + const dataLen = Array.isArray(value) ? value.length : 0; + const label = `Data updated: ${dataLen} row(s) on ${plotTypeLabel(plot.plotType)} (${plot.plotId})`; + // Reset selection for this plot — indices are no longer valid after a data swap. + onRecord(ProvenanceAction.PLOT_DATA, label, { + selection: { + map: null, + plots: { [plot.plotId]: { ids: [], plotType: plot.plotType } }, + }, + }); + } + }, + configurable: true, + enumerable: descriptor.enumerable ?? false, + }); + + entry.dataDescriptorRestorer = () => { + // Remove the own-property override so the prototype setter is visible again. + try { + delete (plotObj as Record)['data']; + } catch { + // non-configurable — leave it } + }; + } + + function unwrapDataSetter(entry: PlotEntry): void { + entry.dataDescriptorRestorer?.(); + entry.dataDescriptorRestorer = null; + } + + function startRecording(): void { + for (const entry of entries) { + attachListeners(entry); + wrapDataSetter(entry); + } + } + + function stopRecording(): void { + for (const entry of entries) { + detachListeners(entry); + unwrapDataSetter(entry); } - listeners.length = 0; } function applyState(state: AutarkProvenanceState): void { - const plotSelection = state.selection?.plot; - if (Array.isArray(plotSelection)) { - lastSelectionSig = selectionSignature(plotSelection); - plot.setHighlightedIds(plotSelection); + isApplyingState = true; + try { + for (const entry of entries) { + const { plot } = entry; + const plotState = state.selection?.plots?.[plot.plotId]; + const ids = plotState?.ids ?? state.selection?.map?.ids ?? []; + entry.lastSelectionSig = selectionSignature(ids); + plot.setHighlightedIds(ids); + } + } finally { + isApplyingState = false; } } diff --git a/autk-provenance/src/core.ts b/autk-provenance/src/core.ts index 92e4dc34..057f8a3d 100644 --- a/autk-provenance/src/core.ts +++ b/autk-provenance/src/core.ts @@ -17,7 +17,10 @@ function deepMergeState( const next: AutarkProvenanceState = { selection: { map: delta.selection?.map ?? base.selection.map, - plot: delta.selection?.plot ?? base.selection.plot, + // Spread-merge so a delta for one plot doesn't wipe the others. + plots: delta.selection?.plots !== undefined + ? { ...base.selection.plots, ...delta.selection.plots } + : base.selection.plots, }, }; if (base.ui || delta.ui) { @@ -28,6 +31,9 @@ function deepMergeState( } if (base.view || delta.view) next.view = delta.view ?? base.view; if (base.data || delta.data) next.data = delta.data ?? base.data; + if (base.filters || delta.filters) { + next.filters = { ...(base.filters ?? {}), ...(delta.filters ?? {}) }; + } return next; } diff --git a/autk-provenance/src/create-autark-provenance.ts b/autk-provenance/src/create-autark-provenance.ts index e298339a..6c8d8729 100644 --- a/autk-provenance/src/create-autark-provenance.ts +++ b/autk-provenance/src/create-autark-provenance.ts @@ -3,13 +3,16 @@ import { createProvenanceCore } from './core'; import { createMapAdapter } from './adapters/map-adapter'; import { createPlotAdapter } from './adapters/plot-adapter'; import { createDbAdapter } from './adapters/db-adapter'; +import { createComputeAdapter } from './adapters/compute-adapter'; import type { IMapForProvenance, IPlotForProvenance } from './types'; import type { IDbForProvenance } from './adapters/db-adapter'; +import type { IComputeForProvenance } from './adapters/compute-adapter'; +import type { MapSelectorConfig } from './adapters/map-adapter'; const DEFAULT_STATE: AutarkProvenanceState = { selection: { map: null, - plot: [], + plots: {}, }, ui: { mapMenuOpen: false, @@ -20,8 +23,16 @@ const DEFAULT_STATE: AutarkProvenanceState = { export interface CreateAutarkProvenanceOptions { map?: IMapForProvenance; - plot?: IPlotForProvenance; + /** All plot instances to track. Each must have a unique plotId and a plotType. */ + plots?: IPlotForProvenance[]; db?: IDbForProvenance; + /** autk-compute GeojsonCompute instance (or compatible object) to track. */ + compute?: IComputeForProvenance; + /** + * Override the CSS selectors the map adapter uses to locate standard map UI, + * and/or register custom DOM controls (dropdowns, buttons, sliders) for tracking. + */ + mapConfig?: MapSelectorConfig; initialState?: Partial; } @@ -46,11 +57,11 @@ export interface AutarkProvenanceApi { } export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkProvenanceApi { - const { map, plot, db, initialState: initialPartial } = options; + const { map, plots, db, compute, mapConfig, initialState: initialPartial } = options; const initialState: AutarkProvenanceState = { selection: { map: initialPartial?.selection?.map ?? DEFAULT_STATE.selection.map, - plot: initialPartial?.selection?.plot ?? [...DEFAULT_STATE.selection.plot], + plots: initialPartial?.selection?.plots ?? { ...DEFAULT_STATE.selection.plots }, }, ui: { ...(DEFAULT_STATE.ui ?? {}), @@ -59,17 +70,18 @@ export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): }; if (initialPartial?.view) initialState.view = initialPartial.view; if (initialPartial?.data) initialState.data = initialPartial.data; + if (initialPartial?.filters) initialState.filters = { ...initialPartial.filters }; const core = createProvenanceCore({ initialState }); const mapAdapter = map ? createMapAdapter(map, (actionType, label, delta) => { core.applyAction(actionType, label, delta); - }) + }, mapConfig) : null; - const plotAdapter = plot - ? createPlotAdapter(plot, (actionType, label, delta) => { + const plotAdapter = plots && plots.length > 0 + ? createPlotAdapter(plots, (actionType, label, delta) => { core.applyAction(actionType, label, delta); }) : null; @@ -80,6 +92,12 @@ export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): }) : null; + const computeAdapter = compute + ? createComputeAdapter(compute, (actionType, label, delta) => { + core.applyAction(actionType, label, delta); + }) + : null; + core.addObserver((node) => { const state = node.state; mapAdapter?.applyState(state); @@ -93,12 +111,14 @@ export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): mapAdapter?.stopRecording(); plotAdapter?.stopRecording(); dbAdapter?.stopRecording(); + computeAdapter?.stopRecording(); } function startRecording(): void { mapAdapter?.startRecording(); plotAdapter?.startRecording(); dbAdapter?.startRecording(); + computeAdapter?.startRecording(); } startRecording(); diff --git a/autk-provenance/src/insight-engine.ts b/autk-provenance/src/insight-engine.ts index dea47955..473f7209 100644 --- a/autk-provenance/src/insight-engine.ts +++ b/autk-provenance/src/insight-engine.ts @@ -19,8 +19,8 @@ import type { AutarkProvenanceState, ProvenanceGraph } from './types'; export interface SelectionFrequency { /** Map feature IDs → count of provenance states where that feature was selected */ map: Map; - /** Plot feature IDs → count of provenance states where that feature was selected */ - plot: Map; + /** Per-plot feature frequency: plotId → (featureId → count) */ + plots: Map>; } /** @@ -32,7 +32,7 @@ export function computeSelectionFrequency( graph: ProvenanceGraph ): SelectionFrequency { const mapFreq = new Map(); - const plotFreq = new Map(); + const plotsFreq = new Map>(); for (const node of graph.nodes.values()) { const sel = node.state.selection; @@ -41,14 +41,17 @@ export function computeSelectionFrequency( mapFreq.set(id, (mapFreq.get(id) ?? 0) + 1); } } - if (sel?.plot && sel.plot.length > 0) { - for (const id of sel.plot) { - plotFreq.set(id, (plotFreq.get(id) ?? 0) + 1); + for (const [plotId, plotSel] of Object.entries(sel?.plots ?? {})) { + if (!plotSel?.ids?.length) continue; + if (!plotsFreq.has(plotId)) plotsFreq.set(plotId, new Map()); + const freq = plotsFreq.get(plotId)!; + for (const id of plotSel.ids) { + freq.set(id, (freq.get(id) ?? 0) + 1); } } } - return { map: mapFreq, plot: plotFreq }; + return { map: mapFreq, plots: plotsFreq }; } // --------------------------------------------------------------------------- @@ -243,15 +246,17 @@ export function generateSessionNarrative( // Selection focus (ProWis-inspired: aggregate across all paths) const freq = computeSelectionFrequency(graph); const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - const topPlot = [...freq.plot.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); if (topMap.length > 0) { const items = topMap.map(([id, c]) => `feature #${id} (${c} state${c !== 1 ? 's' : ''})`).join(', '); lines.push(`\nMost revisited map features across all branches: ${items}.`); } - if (topPlot.length > 0 && topPlot[0][1] > 1) { - const items = topPlot.map(([id, c]) => `#${id} (${c}×)`).join(', '); - lines.push(`Most revisited plot features: ${items}.`); + for (const [plotId, plotFreq] of freq.plots.entries()) { + const top = [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); + if (top.length > 0 && top[0][1] > 1) { + const items = top.map(([id, c]) => `#${id} (${c}×)`).join(', '); + lines.push(`Most revisited features in plot "${plotId}": ${items}.`); + } } if (annotations.length > 0) { diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts index 0a76d181..14c48cc9 100644 --- a/autk-provenance/src/provenance-trail-ui.ts +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -322,8 +322,11 @@ function renderInsightsPanel( // ── Selection focus (Feature 2: aggregate selection frequency) ──────────── const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - const topPlot = [...freq.plot.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - const hasFreqData = topMap.length > 0 || topPlot.length > 0; + const topPlotsByPlot = [...freq.plots.entries()].map(([plotId, plotFreq]) => ({ + plotId, + top: [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), + })).filter(({ top }) => top.length > 0 && top[0][1] > 1); + const hasFreqData = topMap.length > 0 || topPlotsByPlot.length > 0; if (hasFreqData) { const freqSection = document.createElement('div'); @@ -334,10 +337,11 @@ function renderInsightsPanel( freqTitle.textContent = 'Selection focus (all branches)'; freqSection.appendChild(freqTitle); - const maxCount = Math.max( - ...[...topMap, ...topPlot].map(([, c]) => c), - 1 - ); + const allCounts = [ + ...topMap.map(([, c]) => c), + ...topPlotsByPlot.flatMap(({ top }) => top.map(([, c]) => c)), + ]; + const maxCount = Math.max(...allCounts, 1); const renderFreqGroup = (label: string, items: [number, number][]): void => { if (items.length === 0) return; @@ -373,7 +377,9 @@ function renderInsightsPanel( }; renderFreqGroup('Map', topMap); - renderFreqGroup('Plot', topPlot); + for (const { plotId, top } of topPlotsByPlot) { + renderFreqGroup(`Plot: ${plotId}`, top); + } container.appendChild(freqSection); } diff --git a/autk-provenance/src/types.ts b/autk-provenance/src/types.ts index 311fe8da..89fc12d4 100644 --- a/autk-provenance/src/types.ts +++ b/autk-provenance/src/types.ts @@ -8,6 +8,18 @@ export interface MapViewState { up: [number, number, number]; } +export enum PlotType { + SCATTERPLOT = 'scatterplot', + BARCHART = 'barchart', + PARALLEL_COORDINATES = 'parallel_coordinates', + HISTOGRAM = 'histogram', +} + +export interface PlotSelectionState { + ids: number[]; + plotType: PlotType; +} + /** * Canonical state shape for autark provenance. * Captures everything needed to restore the current analysis state. @@ -15,7 +27,8 @@ export interface MapViewState { export interface AutarkProvenanceState { selection: { map: { layerId: string; ids: number[] } | null; - plot: number[]; + /** Per-plot selection state, keyed by plotId. */ + plots: Record; }; ui?: { mapMenuOpen?: boolean; @@ -29,6 +42,12 @@ export interface AutarkProvenanceState { layerTableNames: string[]; activeLayerIds?: string[]; }; + /** + * Arbitrary filter / temporal state contributed by custom UI controls + * (e.g. month dropdowns, range sliders, dataset pickers). + * Keys are defined by the app via CustomControlConfig.getStateDelta. + */ + filters?: Record; } /** @@ -45,11 +64,15 @@ export enum ProvenanceAction { MAP_UI_VISIBLE_LAYER_TOGGLE = 'MAP_UI_VISIBLE_LAYER_TOGGLE', MAP_UI_ACTIVE_LAYER_CHANGE = 'MAP_UI_ACTIVE_LAYER_CHANGE', MAP_UI_THEMATIC_TOGGLE = 'MAP_UI_THEMATIC_TOGGLE', + PLOT_ADD = 'PLOT_ADD', + PLOT_REMOVE = 'PLOT_REMOVE', PLOT_CLICK = 'PLOT_CLICK', PLOT_BRUSH = 'PLOT_BRUSH', PLOT_BRUSH_X = 'PLOT_BRUSH_X', PLOT_BRUSH_Y = 'PLOT_BRUSH_Y', PLOT_DATA = 'PLOT_DATA', + MAP_UI_CUSTOM_CONTROL = 'MAP_UI_CUSTOM_CONTROL', + COMPUTE_RUN = 'COMPUTE_RUN', DB_INIT = 'DB_INIT', DB_WORKSPACE = 'DB_WORKSPACE', DB_LOAD_OSM = 'DB_LOAD_OSM', @@ -140,8 +163,12 @@ export interface IMapForProvenance { /** * Minimal plot-like interface needed by PlotAdapter. + * Each plot must supply a stable plotId and its PlotType so that provenance + * state is keyed correctly and interaction labels are meaningful. */ export interface IPlotForProvenance { + plotId: string; + plotType: PlotType; plotEvents: { addEventListener(event: string, listener: (selection: number[]) => void): void; removeEventListener?(event: string, listener: (selection: number[]) => void): void; diff --git a/examples/src/autk-provenance/all-plots-provenance.css b/examples/src/autk-provenance/all-plots-provenance.css new file mode 100644 index 00000000..fd447d30 --- /dev/null +++ b/examples/src/autk-provenance/all-plots-provenance.css @@ -0,0 +1,422 @@ +:root { + --bg: #eef3f8; + --surface: #ffffff; + --surface-muted: #f6f9fc; + --border: #d4dde7; + --text: #1b3148; + --text-muted: #546b80; + --accent: #2f5f96; + --radius: 12px; + --shadow: 0 8px 24px rgba(28,53,76,.09); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + padding: 16px; + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + color: var(--text); + background: radial-gradient(circle at top left, #f4f8ff, var(--bg) 60%); +} + +#app { + max-width: 1700px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.header-text h1 { margin: 0; font-size: 22px; line-height: 1.2; } +.header-text p { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; } + +.header-actions { display: flex; gap: 8px; flex-shrink: 0; } + +.hdr-btn { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} +.hdr-btn:hover { background: #edf5ff; border-color: var(--accent); color: var(--accent); } + +/* ── Main layout: two equal columns ─────────────────────────────────────── */ + +#layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + align-items: stretch; +} + +/* ── Left column: map ────────────────────────────────────────────────────── */ + +#mapColumn { + display: flex; + flex-direction: column; +} + +.map-panel { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; + box-shadow: var(--shadow); + min-height: 600px; +} + +.map-body { + flex: 1; + min-height: 0; + position: relative; +} + +canvas { + display: block; + width: 100%; + height: 100%; + background: #c8dae6; +} + +.map-header-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.ctrl-label { font-size: 11px; color: var(--text-muted); } + +.thematic-select { + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 12px; + cursor: pointer; +} +.thematic-select:focus { outline: none; border-color: var(--accent); } + +/* ── Right column: tabbed panel ──────────────────────────────────────────── */ + +#rightColumn { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; + box-shadow: var(--shadow); +} + +/* Tab bar */ +.tab-bar { + display: flex; + border-bottom: 2px solid var(--border); + background: var(--surface-muted); + flex-shrink: 0; +} + +.tab-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 10px 12px; + border: none; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + background: transparent; + cursor: pointer; + font-family: inherit; + color: var(--text-muted); + transition: background .15s; +} +.tab-btn:hover:not(.active) { background: #f0f5fb; } +.tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: var(--surface); +} + +.tab-label { font-size: 13px; font-weight: 600; } +.tab-hint { font-size: 11px; opacity: .75; } + +/* Tab content */ +.tab-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.tab-content.hidden { display: none; } + +/* ── Charts tab: 2×2 plot grid ───────────────────────────────────────────── */ + +#plotGrid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 10px; + padding: 10px; +} + +.plot-panel { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + overflow: hidden; + min-height: 0; +} + +.plot-body { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +#scatterBody, #barBody, #pcBody, #histBody { + position: absolute; + inset: 0; +} + +svg { display: block; overflow: visible; } + +/* ── Shared panel header ─────────────────────────────────────────────────── */ + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 12px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + flex-shrink: 0; +} +.panel-title { font-size: 13px; font-weight: 600; } +.panel-hint { font-size: 11px; color: var(--text-muted); } + +/* ── Provenance tab ──────────────────────────────────────────────────────── */ + +#provenanceTab { overflow: hidden; } + +#provenanceTrail { + height: 100%; + padding: 10px; + box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: 0; +} + +#provenanceTrail.autk-provenance-root { gap: 8px; min-height: 0; } +#provenanceTrail .autk-provenance-graph-wrap { height: 260px; max-height: 320px; } +#provenanceTrail .autk-provenance-path { max-height: 160px; flex-shrink: 0; } +#provenanceTrail .autk-prov-insights-wrap { flex: 1; min-height: 0; max-height: none; } + +/* ── Insights section ────────────────────────────────────────────────────── */ + +.insights-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; + box-shadow: var(--shadow); +} + +.insights-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); +} + +.insights-card { + padding: 14px 16px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 10px; + min-height: 200px; +} +.insights-card:last-child { border-right: none; } + +.insights-card-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); +} + +.insights-card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.insights-loading { + font-size: 12px; + color: var(--text-muted); + justify-content: center; + align-items: center; + text-align: center; +} + +/* ── Insight card content ────────────────────────────────────────────────── */ + +.strategy-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 700; + color: #fff; + align-self: flex-start; +} + +.strategy-desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; } + +.metrics-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } +.metric-chip { + padding: 3px 8px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 11px; +} + +.annotation-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.annotation-textarea { + width: 100%; + padding: 7px 8px; + border: 1px solid var(--border); + border-radius: 7px; + font-family: inherit; + font-size: 12px; + color: var(--text); + background: var(--surface); + resize: vertical; + box-sizing: border-box; + flex: 1; + min-height: 70px; +} +.annotation-textarea:focus { outline: none; border-color: var(--accent); } + +.annotation-save-btn { + align-self: flex-start; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + font-size: 12px; + font-family: inherit; + color: var(--text); + cursor: pointer; +} +.annotation-save-btn:hover:not(:disabled) { background: #edf5ff; border-color: var(--accent); color: var(--accent); } +.annotation-save-btn:disabled { opacity: .45; cursor: not-allowed; } + +.freq-empty { font-size: 12px; color: var(--text-muted); } +.freq-group-label { font-size: 11px; font-weight: 600; color: var(--text); margin-top: 4px; } +.freq-row { display: flex; align-items: center; gap: 6px; min-height: 18px; } +.freq-id { font-size: 11px; color: var(--text-muted); width: 110px; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.freq-bar-wrap { flex: 1; height: 7px; background: #e7eef6; border-radius: 4px; overflow: hidden; } +.freq-bar-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width .3s; } +.freq-count { font-size: 11px; width: 24px; text-align: right; flex-shrink: 0; } + +.summary-pre { + flex: 1; + margin: 0; + padding: 8px 10px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 7px; + font-size: 11px; + font-family: inherit; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6; + overflow-y: auto; + max-height: 140px; +} + +.copy-btn { + align-self: flex-start; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + font-size: 12px; + font-family: inherit; + color: var(--text); + cursor: pointer; +} +.copy-btn:hover { background: #edf5ff; } + +/* ── D3 chart chrome ─────────────────────────────────────────────────────── */ + +.tick line { stroke: #dde4ec; stroke-width: 1; } +.domain { stroke: #cfd8e3; } +.title { font-size: 12px; fill: var(--text); font-weight: 600; } + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 1200px) { + #layout { grid-template-columns: 1fr; } + .map-panel { min-height: 420px; max-height: 500px; } + .insights-grid { grid-template-columns: 1fr 1fr; } + .insights-card:nth-child(2) { border-right: none; } + .insights-card:nth-child(1), + .insights-card:nth-child(2) { border-bottom: 1px solid var(--border); } +} + +@media (max-width: 700px) { + body { padding: 8px; } + #plotGrid { grid-template-columns: 1fr; grid-template-rows: repeat(4, 280px); } + .insights-grid { grid-template-columns: 1fr; } + .insights-card { border-right: none; border-bottom: 1px solid var(--border); } + .insights-card:last-child { border-bottom: none; } +} diff --git a/examples/src/autk-provenance/all-plots-provenance.html b/examples/src/autk-provenance/all-plots-provenance.html new file mode 100644 index 00000000..925e8b64 --- /dev/null +++ b/examples/src/autk-provenance/all-plots-provenance.html @@ -0,0 +1,149 @@ + + + + + Autark – All Visualizations with Provenance + + + +
+ + + + + +
+ + +
+
+
+ Map +
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+ + +
+
+ +
+
+ Scatterplot + Area vs Perimeter · drag to brush +
+
+
+
+
+ +
+
+ Bar Chart + Neighborhoods per district · click a bar +
+
+
+
+
+ +
+
+ Parallel Coordinates + Area · Perimeter · Compactness · brush any axis +
+
+
+
+
+ +
+
+ Histogram + Neighborhood area distribution · click a bar +
+
+
+
+
+ +
+
+ + + +
+
+ + +
+
+ Session Insights + Updates as you interact with any visualization +
+
+ +
+
Analysis Strategy
+
Interact with any visualization to begin…
+
+ +
+
Annotate This Step
+
Interact with any visualization to begin…
+
+ +
+
Selection Focus
+
Interact with any visualization to begin…
+
+ +
+
Analysis Summary
+
Interact with any visualization to begin…
+
+ +
+
+ +
+ + + diff --git a/examples/src/autk-provenance/all-plots-provenance.ts b/examples/src/autk-provenance/all-plots-provenance.ts new file mode 100644 index 00000000..42a22bde --- /dev/null +++ b/examples/src/autk-provenance/all-plots-provenance.ts @@ -0,0 +1,410 @@ +import type { FeatureCollection } from 'geojson'; +import { SpatialDb } from 'autk-db'; +import { Scatterplot, Barchart, ParallelCoordinates, Histogram, PlotEvent } from 'autk-plot'; +import { AutkMap } from 'autk-map'; +import { + createAutarkProvenance, + renderProvenanceTrailUI, + computeGraphMetrics, + computeSelectionFrequency, + getInsightAnnotations, + generateSessionNarrative, + PlotType, + type StrategyLabel, + type CustomControlConfig, +} from 'autk-provenance'; + +// --------------------------------------------------------------------------- +// Data helpers +// --------------------------------------------------------------------------- + +// Add a compactness metric (how circular the polygon is; 1 = perfect circle) +// to every feature so Parallel Coordinates has a meaningful third axis. +function enrichFeatures(geojson: FeatureCollection): void { + for (const f of geojson.features) { + const area = f.properties?.shape_area as number ?? 0; + const perim = f.properties?.shape_leng as number ?? 1; + if (f.properties) { + f.properties.compactness = Math.round((4 * Math.PI * area / (perim * perim)) * 1000) / 1000; + } + } +} + +// Bar chart: one row per community district (cdta2020), height = neighborhood count. +// Indices here (0–N) don't align 1:1 with the 38-feature map dataset — that's expected. +// The bar chart's provenance tracking is correct; map cross-highlight uses the district +// count index as a fallback, which is an acceptable approximation for this demo. +function buildDistrictData(geojson: FeatureCollection): FeatureCollection { + const groups = new Map(); + for (const f of geojson.features) { + const cd = f.properties?.cdta2020 as string ?? 'Unknown'; + const area = f.properties?.shape_area as number ?? 0; + const g = groups.get(cd) ?? { count: 0, totalArea: 0 }; + g.count++; + g.totalArea += area; + groups.set(cd, g); + } + return { + type: 'FeatureCollection', + features: [...groups.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([cdta2020, { count, totalArea }]) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: { + cdta2020, + count, + avg_area_km2: Math.round(totalArea / count / 1e6 * 100) / 100, + }, + })), + }; +} + +// --------------------------------------------------------------------------- +// Session insight card renderers +// --------------------------------------------------------------------------- + +type ProvenanceApi = ReturnType; + +const STRATEGY_COLORS: Record = { + Confirmatory: '#2e7d32', + Exploratory: '#1565c0', + 'Iterative Refinement': '#6a1b9a', +}; + +const STRATEGY_DESC: Record = { + Confirmatory: 'Focused, linear exploration — you appear to know what you are looking for.', + Exploratory: 'Broad, open-ended investigation with multiple diverging paths.', + 'Iterative Refinement': 'Hypothesis-driven: repeated backtracking and revision of prior selections.', +}; + +let _annotationNodeId: string | null = null; +let _annotationTextarea: HTMLTextAreaElement | null = null; + +function renderMetricsCard(el: HTMLElement, provenance: ProvenanceApi): void { + const m = computeGraphMetrics(provenance.getGraph()); + el.innerHTML = ''; + + const badge = document.createElement('span'); + badge.className = 'strategy-badge'; + badge.textContent = m.strategyLabel; + badge.style.background = STRATEGY_COLORS[m.strategyLabel]; + el.appendChild(badge); + + const desc = document.createElement('p'); + desc.className = 'strategy-desc'; + desc.textContent = STRATEGY_DESC[m.strategyLabel]; + el.appendChild(desc); + + const row = document.createElement('div'); + row.className = 'metrics-row'; + const chips: [string, string | number][] = [ + ['States', m.totalNodes], ['Branches', m.branchPoints], + ['Backtracks', m.backtracks], ['Insights', m.insightCount], + ]; + if (m.sessionDurationMs > 0) { + const s = Math.round(m.sessionDurationMs / 1000); + chips.unshift(['Duration', s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`]); + } + for (const [label, val] of chips) { + const chip = document.createElement('span'); + chip.className = 'metric-chip'; + chip.innerHTML = `${val} ${label}`; + row.appendChild(chip); + } + el.appendChild(row); +} + +function renderAnnotateCard(el: HTMLElement, provenance: ProvenanceApi): void { + const current = provenance.getCurrentNode(); + if (!current) { + el.innerHTML = 'No active node.'; + _annotationNodeId = null; _annotationTextarea = null; + return; + } + if (current.id === _annotationNodeId && _annotationTextarea) { + if (!_annotationTextarea.matches(':focus')) { + const ex = current.metadata?.insight; + _annotationTextarea.value = typeof ex === 'string' ? ex : ''; + } + return; + } + _annotationNodeId = current.id; + el.innerHTML = ''; + + const lbl = document.createElement('div'); + lbl.className = 'annotation-label'; + lbl.textContent = `Step: ${current.actionLabel}`; + el.appendChild(lbl); + + const ta = document.createElement('textarea'); + ta.className = 'annotation-textarea'; + ta.placeholder = 'What did you notice or conclude at this step?'; + const ex = current.metadata?.insight; + ta.value = typeof ex === 'string' ? ex : ''; + el.appendChild(ta); + _annotationTextarea = ta; + + const btn = document.createElement('button'); + btn.className = 'annotation-save-btn'; + btn.textContent = 'Save Insight'; + btn.addEventListener('click', () => { + provenance.annotateNode(current.id, ta.value.trim()); + btn.textContent = 'Saved ✓'; + setTimeout(() => { btn.textContent = 'Save Insight'; }, 1500); + }); + el.appendChild(btn); +} + +function renderFreqCard( + el: HTMLElement, provenance: ProvenanceApi, featureName: (id: number) => string, +): void { + const freq = computeSelectionFrequency(provenance.getGraph()); + el.innerHTML = ''; + + const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); + const topPlots = [...freq.plots.entries()].map(([plotId, m]) => ({ + plotId, top: [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), + })).filter(p => p.top.length > 0); + + if (!topMap.length && !topPlots.length) { + const s = document.createElement('span'); + s.className = 'freq-empty'; + s.textContent = 'No selections yet. Click a neighborhood on the map or interact with any chart.'; + el.appendChild(s); return; + } + + const maxCount = Math.max(...topMap.map(([,c])=>c), ...topPlots.flatMap(p=>p.top.map(([,c])=>c)), 1); + + const renderGroup = (label: string, items: [number, number][]): void => { + if (!items.length) return; + const gl = document.createElement('div'); + gl.className = 'freq-group-label'; + gl.textContent = label; + el.appendChild(gl); + + for (const [id, count] of items) { + const row = document.createElement('div'); row.className = 'freq-row'; + const nm = document.createElement('span'); nm.className = 'freq-id'; + nm.textContent = featureName(id); nm.title = `Feature index #${id}`; row.appendChild(nm); + const wrap = document.createElement('div'); wrap.className = 'freq-bar-wrap'; + const fill = document.createElement('div'); fill.className = 'freq-bar-fill'; + fill.style.width = `${Math.round((count / maxCount) * 100)}%`; wrap.appendChild(fill); row.appendChild(wrap); + const cnt = document.createElement('span'); cnt.className = 'freq-count'; + cnt.textContent = `${count}×`; row.appendChild(cnt); + el.appendChild(row); + } + }; + + renderGroup('Map', topMap); + for (const { plotId, top } of topPlots) renderGroup(plotId, top); +} + +function renderSummaryCard(el: HTMLElement, provenance: ProvenanceApi): void { + const graph = provenance.getGraph(); + const narrative = generateSessionNarrative(graph, computeGraphMetrics(graph), getInsightAnnotations(graph)); + el.innerHTML = ''; + const pre = document.createElement('pre'); pre.className = 'summary-pre'; pre.textContent = narrative; + el.appendChild(pre); + const btn = document.createElement('button'); btn.className = 'copy-btn'; btn.textContent = 'Copy to clipboard'; + btn.addEventListener('click', () => navigator.clipboard.writeText(narrative).then(() => { + btn.textContent = 'Copied ✓'; setTimeout(() => { btn.textContent = 'Copy to clipboard'; }, 1800); + })); + el.appendChild(btn); +} + +// --------------------------------------------------------------------------- +// Tab switching +// --------------------------------------------------------------------------- + +function setupTabs(provenance: ProvenanceApi): void { + const chartsTab = document.getElementById('chartsTab')!; + const provTab = document.getElementById('provenanceTab')!; + const badge = document.getElementById('nodeCountBadge')!; + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab as 'charts' | 'provenance'; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn)); + chartsTab.classList.toggle('hidden', tab !== 'charts'); + provTab.classList.toggle('hidden', tab !== 'provenance'); + }); + }); + + provenance.addObserver(() => { + const n = provenance.getGraph().nodes.size; + badge.textContent = `${n} step${n !== 1 ? 's' : ''} recorded`; + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + // Loading overlay + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(238,243,248,.92);display:flex;align-items:center;justify-content:center;font-size:15px;color:#1b3148;z-index:9999;font-family:system-ui,sans-serif;'; + overlay.textContent = 'Loading Manhattan neighborhood data…'; + document.body.appendChild(overlay); + + // Load real Manhattan data via SpatialDb (handles Mercator projection automatically) + const db = new SpatialDb(); + await db.init(); + await db.loadCustomLayer({ + geojsonFileUrl: '/data/mnt_neighs_proj.geojson', + outputTableName: 'neighborhoods', + }); + const geojson = await db.getLayer('neighborhoods') as FeatureCollection; + + enrichFeatures(geojson); + + overlay.remove(); + + const featureName = (id: number): string => + (geojson.features[id]?.properties?.ntaname as string | undefined) ?? `#${id}`; + + // ── Map ────────────────────────────────────────────────────────────────── + const canvas = document.querySelector('canvas') as HTMLCanvasElement; + const map = new AutkMap(canvas); + await map.init(); + map.loadGeoJsonLayer('neighborhoods', geojson); + map.updateRenderInfoProperty('neighborhoods', 'isPick', true); + map.draw(); + + // ── Plots ───────────────────────────────────────────────────────────────── + const districtData = buildDistrictData(geojson); + + function plotDims(el: HTMLElement, fw: number, fh: number) { + const r = el.getBoundingClientRect(); + return { width: r.width > 20 ? Math.floor(r.width) : fw, height: r.height > 20 ? Math.floor(r.height) : fh }; + } + + const scatterEl = document.getElementById('scatterBody')!; + const barEl = document.getElementById('barBody')!; + const pcEl = document.getElementById('pcBody')!; + const histEl = document.getElementById('histBody')!; + + const sd = plotDims(scatterEl, 340, 260); + const bd = plotDims(barEl, 340, 260); + const pd = plotDims(pcEl, 340, 260); + const hd = plotDims(histEl, 340, 260); + + const scatter = new Scatterplot({ + div: scatterEl, data: geojson, + labels: { axis: ['shape_area', 'shape_leng'], title: 'Area vs Perimeter' }, + width: sd.width, height: sd.height, + margins: { left: 62, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.BRUSH], + }); + + const bar = new Barchart({ + div: barEl, data: districtData, + labels: { axis: ['cdta2020', 'count'], title: 'Neighborhoods per District' }, + width: bd.width, height: bd.height, + margins: { left: 50, right: 16, top: 36, bottom: 68 }, + events: [PlotEvent.CLICK], + }); + + const pc = new ParallelCoordinates({ + div: pcEl, data: geojson, + labels: { axis: ['shape_area', 'shape_leng', 'compactness'], title: 'Neighborhood Metrics' }, + width: pd.width, height: pd.height, + margins: { left: 28, right: 28, top: 36, bottom: 36 }, + events: [PlotEvent.BRUSH_Y], + }); + + const hist = new Histogram({ + div: histEl, data: geojson, + labels: { axis: ['shape_area', 'Count'], title: 'Area Distribution' }, + width: hd.width, height: hd.height, + margins: { left: 55, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.CLICK, PlotEvent.BRUSH_X], + }); + + // ── Thematic dropdown (custom control tracked by provenance) ────────────── + function applyThematic(prop: string): void { + if (prop) { + map.updateGeoJsonLayerThematic( + 'neighborhoods', geojson, + (f) => +(f.properties?.[prop] ?? 0) + ); + map.updateRenderInfoProperty('neighborhoods', 'isColorMap', true); + } else { + map.updateRenderInfoProperty('neighborhoods', 'isColorMap', false); + } + } + + const thematicControl: CustomControlConfig = { + selector: '#thematicSelect', + event: 'change', + actionType: 'MAP_THEMATIC_PROPERTY', + getLabel: (el) => { + const v = (el as HTMLSelectElement).value; + return v ? `Color by: ${v}` : 'Thematic off'; + }, + getStateDelta: (el) => { + const v = (el as HTMLSelectElement).value; + return { filters: { thematicProperty: v || null }, ui: { thematicEnabled: !!v } }; + }, + applyState: (el, state) => { + const prop = (state.filters?.thematicProperty as string) || ''; + (el as HTMLSelectElement).value = prop; + applyThematic(prop); + }, + }; + + // ── Provenance ──────────────────────────────────────────────────────────── + const scatterProv = Object.assign(scatter, { plotId: 'Scatterplot', plotType: PlotType.SCATTERPLOT }); + const barProv = Object.assign(bar, { plotId: 'Bar Chart', plotType: PlotType.BARCHART }); + const pcProv = Object.assign(pc, { plotId: 'Parallel Coordinates', plotType: PlotType.PARALLEL_COORDINATES }); + const histProv = Object.assign(hist, { plotId: 'Histogram', plotType: PlotType.HISTOGRAM }); + + const provenance = createAutarkProvenance({ + map, + plots: [scatterProv, barProv, pcProv, histProv], + db, + mapConfig: { customControls: [thematicControl] }, + }); + + // ── Provenance trail UI ─────────────────────────────────────────────────── + renderProvenanceTrailUI({ + provenance, container: document.getElementById('provenanceTrail')!, + showTimestamps: true, showGraph: true, showPathList: true, showBackForward: true, + }); + + setupTabs(provenance); + + // ── Session insight cards ───────────────────────────────────────────────── + const metricsEl = document.querySelector('#insightsMetrics .insights-card-body') as HTMLElement; + const annotateEl = document.querySelector('#insightsAnnotate .insights-card-body') as HTMLElement; + const freqEl = document.querySelector('#insightsFreq .insights-card-body') as HTMLElement; + const summaryEl = document.querySelector('#insightsSummary .insights-card-body') as HTMLElement; + + function refreshInsights(): void { + renderMetricsCard(metricsEl, provenance); + renderAnnotateCard(annotateEl, provenance); + renderFreqCard(freqEl, provenance, featureName); + renderSummaryCard(summaryEl, provenance); + } + provenance.addObserver(refreshInsights); + refreshInsights(); + + // ── Export / Import ─────────────────────────────────────────────────────── + document.getElementById('exportBtn')!.addEventListener('click', () => { + const url = URL.createObjectURL(new Blob([provenance.exportGraph()], { type: 'application/json' })); + Object.assign(document.createElement('a'), { href: url, download: `provenance-${Date.now()}.json` }).click(); + URL.revokeObjectURL(url); + }); + document.getElementById('importBtn')!.addEventListener('click', () => + (document.getElementById('importFile') as HTMLInputElement).click() + ); + (document.getElementById('importFile') as HTMLInputElement).addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { if (typeof ev.target?.result === 'string') provenance.importGraph(ev.target.result); }; + reader.readAsText(file); + }); +} + +main(); diff --git a/examples/src/autk-provenance/map-plot-provenance.ts b/examples/src/autk-provenance/map-plot-provenance.ts index 4a6c348a..156a582d 100644 --- a/examples/src/autk-provenance/map-plot-provenance.ts +++ b/examples/src/autk-provenance/map-plot-provenance.ts @@ -4,6 +4,7 @@ import { PlotEvent, Scatterplot } from 'autk-plot'; import { AutkMap, MapEvent, VectorLayer } from 'autk-map'; import { createAutarkProvenance, + PlotType, renderProvenanceTrailUI, computeGraphMetrics, computeSelectionFrequency, @@ -133,11 +134,15 @@ function renderFreqCard( const freq = computeSelectionFrequency(graph); const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); - const topPlot = [...freq.plot.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); + const topPlotsByPlot = [...freq.plots.entries()].map(([plotId, plotFreq]) => ({ + plotId, + top: [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6), + })).filter(({ top }) => top.length > 0); + const allTopPlot = topPlotsByPlot.flatMap(({ top }) => top); el.innerHTML = ''; - if (topMap.length === 0 && topPlot.length === 0) { + if (topMap.length === 0 && allTopPlot.length === 0) { const empty = document.createElement('span'); empty.className = 'freq-empty'; empty.textContent = 'No selections yet. Click features on the map or brush the scatterplot.'; @@ -145,7 +150,7 @@ function renderFreqCard( return; } - const maxCount = Math.max(...[...topMap, ...topPlot].map(([, c]) => c), 1); + const maxCount = Math.max(...[...topMap, ...allTopPlot].map(([, c]) => c), 1); const renderGroup = (label: string, items: [number, number][]): void => { if (items.length === 0) return; @@ -183,7 +188,9 @@ function renderFreqCard( }; renderGroup('Map features', topMap); - renderGroup('Plot features', topPlot); + for (const { plotId, top } of topPlotsByPlot) { + renderGroup(`Plot: ${plotId}`, top); + } } function renderSummaryCard(el: HTMLElement, provenance: ReturnType): void { @@ -264,7 +271,11 @@ async function main() { } }); - const provenance = createAutarkProvenance({ map, plot, db }); + const scatterplotForProvenance = Object.assign(plot, { + plotId: 'scatter-neighborhoods', + plotType: PlotType.SCATTERPLOT, + }); + const provenance = createAutarkProvenance({ map, plots: [scatterplotForProvenance], db }); // ── Export / Import ────────────────────────────────────────────────────── const exportBtn = document.querySelector('#exportBtn') as HTMLButtonElement; From b90d85447e4e52b84db9dda3a4a29fc5e2ccc0c2 Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Mon, 27 Apr 2026 23:08:27 -0500 Subject: [PATCH 08/21] bug fix --- autk-provenance/src/provenance-trail-ui.ts | 71 ++----------------- .../autk-provenance/all-plots-provenance.css | 46 +++++++++--- .../autk-provenance/all-plots-provenance.html | 13 +--- .../autk-provenance/all-plots-provenance.ts | 17 +++-- 4 files changed, 50 insertions(+), 97 deletions(-) diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts index 14c48cc9..c0dac98c 100644 --- a/autk-provenance/src/provenance-trail-ui.ts +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -2,7 +2,6 @@ import type { AutarkProvenanceState, PathNode, ProvenanceNode } from './types'; import type { AutarkProvenanceApi } from './create-autark-provenance'; import { computeGraphMetrics, - computeSelectionFrequency, generateSessionNarrative, getInsightAnnotations, type StrategyLabel, @@ -11,6 +10,7 @@ import { export interface ProvenanceTrailUIOptions { provenance: AutarkProvenanceApi; container: HTMLElement; + insightsContainer?: HTMLElement; showBackForward?: boolean; showTimestamps?: boolean; showGraph?: boolean; @@ -254,7 +254,6 @@ function renderInsightsPanel( const graph = provenance.getGraph(); const currentNode = provenance.getCurrentNode(); const metrics = computeGraphMetrics(graph); - const freq = computeSelectionFrequency(graph); const annotations = getInsightAnnotations(graph); // ── Strategy + metrics row ──────────────────────────────────────────────── @@ -320,70 +319,6 @@ function renderInsightsPanel( container.appendChild(annotateSection); - // ── Selection focus (Feature 2: aggregate selection frequency) ──────────── - const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - const topPlotsByPlot = [...freq.plots.entries()].map(([plotId, plotFreq]) => ({ - plotId, - top: [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), - })).filter(({ top }) => top.length > 0 && top[0][1] > 1); - const hasFreqData = topMap.length > 0 || topPlotsByPlot.length > 0; - - if (hasFreqData) { - const freqSection = document.createElement('div'); - freqSection.className = 'autk-prov-insights-section'; - - const freqTitle = document.createElement('div'); - freqTitle.className = 'autk-prov-insights-section-title'; - freqTitle.textContent = 'Selection focus (all branches)'; - freqSection.appendChild(freqTitle); - - const allCounts = [ - ...topMap.map(([, c]) => c), - ...topPlotsByPlot.flatMap(({ top }) => top.map(([, c]) => c)), - ]; - const maxCount = Math.max(...allCounts, 1); - - const renderFreqGroup = (label: string, items: [number, number][]): void => { - if (items.length === 0) return; - const groupLabel = document.createElement('div'); - groupLabel.className = 'autk-prov-freq-group-label'; - groupLabel.textContent = label; - freqSection.appendChild(groupLabel); - - for (const [id, count] of items) { - const row = document.createElement('div'); - row.className = 'autk-prov-freq-row'; - - const idSpan = document.createElement('span'); - idSpan.className = 'autk-prov-freq-id'; - idSpan.textContent = `#${id}`; - row.appendChild(idSpan); - - const bar = document.createElement('div'); - bar.className = 'autk-prov-freq-bar-wrap'; - const fill = document.createElement('div'); - fill.className = 'autk-prov-freq-bar-fill'; - fill.style.width = `${Math.round((count / maxCount) * 100)}%`; - bar.appendChild(fill); - row.appendChild(bar); - - const countSpan = document.createElement('span'); - countSpan.className = 'autk-prov-freq-count'; - countSpan.textContent = `${count}×`; - row.appendChild(countSpan); - - freqSection.appendChild(row); - } - }; - - renderFreqGroup('Map', topMap); - for (const { plotId, top } of topPlotsByPlot) { - renderFreqGroup(`Plot: ${plotId}`, top); - } - - container.appendChild(freqSection); - } - // ── Recorded insight annotations list ───────────────────────────────────── if (annotations.length > 0) { const annoSection = document.createElement('div'); @@ -459,6 +394,7 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = const { provenance, container, + insightsContainer, showBackForward = true, showTimestamps = true, showGraph = true, @@ -888,7 +824,7 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = insightsChevron.textContent = insightsOpen ? '\u25b4' : '\u25be'; }); - container.appendChild(insightsWrap); + (insightsContainer ?? container).appendChild(insightsWrap); // ── Helpers ─────────────────────────────────────────────────────────────── @@ -1051,5 +987,6 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = closeGraphModal(); container.innerHTML = ''; container.classList.remove('autk-provenance-root'); + if (insightsContainer) insightsContainer.innerHTML = ''; }; } diff --git a/examples/src/autk-provenance/all-plots-provenance.css b/examples/src/autk-provenance/all-plots-provenance.css index fd447d30..171c70f7 100644 --- a/examples/src/autk-provenance/all-plots-provenance.css +++ b/examples/src/autk-provenance/all-plots-provenance.css @@ -8,6 +8,7 @@ --accent: #2f5f96; --radius: 12px; --shadow: 0 8px 24px rgba(28,53,76,.09); + --viz-height: 640px; } * { box-sizing: border-box; } @@ -79,7 +80,7 @@ body { } .map-panel { - flex: 1; + height: var(--viz-height); display: flex; flex-direction: column; border: 1px solid var(--border); @@ -87,19 +88,22 @@ body { background: var(--surface); overflow: hidden; box-shadow: var(--shadow); - min-height: 600px; } .map-body { flex: 1; min-height: 0; - position: relative; + display: flex; + justify-content: center; + align-items: stretch; + overflow: hidden; } canvas { display: block; - width: 100%; height: 100%; + width: auto; + max-width: 100%; background: #c8dae6; } @@ -126,6 +130,7 @@ canvas { /* ── Right column: tabbed panel ──────────────────────────────────────────── */ #rightColumn { + height: var(--viz-height); display: flex; flex-direction: column; border: 1px solid var(--border); @@ -246,7 +251,30 @@ svg { display: block; overflow: visible; } #provenanceTrail.autk-provenance-root { gap: 8px; min-height: 0; } #provenanceTrail .autk-provenance-graph-wrap { height: 260px; max-height: 320px; } #provenanceTrail .autk-provenance-path { max-height: 160px; flex-shrink: 0; } -#provenanceTrail .autk-prov-insights-wrap { flex: 1; min-height: 0; max-height: none; } + +/* ── Provenance session insights (row 2, shown only on provenance tab) ────── */ +#provTrailInsights { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + overflow: hidden; +} +#provTrailInsights.hidden { display: none; } + +/* Strip the inner wrap's own border/background so it merges with the row panel */ +#provTrailInsights .autk-prov-insights-wrap { + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + max-height: none; + overflow-y: visible; +} +#provTrailInsights .autk-prov-insights-body { + max-height: none; + overflow-y: visible; +} /* ── Insights section ────────────────────────────────────────────────────── */ @@ -260,7 +288,7 @@ svg { display: block; overflow: visible; } .insights-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(2, 1fr); } .insights-card { @@ -406,11 +434,7 @@ svg { display: block; overflow: visible; } @media (max-width: 1200px) { #layout { grid-template-columns: 1fr; } - .map-panel { min-height: 420px; max-height: 500px; } - .insights-grid { grid-template-columns: 1fr 1fr; } - .insights-card:nth-child(2) { border-right: none; } - .insights-card:nth-child(1), - .insights-card:nth-child(2) { border-bottom: 1px solid var(--border); } + :root { --viz-height: 480px; } } @media (max-width: 700px) { diff --git a/examples/src/autk-provenance/all-plots-provenance.html b/examples/src/autk-provenance/all-plots-provenance.html index 925e8b64..c8deb07b 100644 --- a/examples/src/autk-provenance/all-plots-provenance.html +++ b/examples/src/autk-provenance/all-plots-provenance.html @@ -112,6 +112,9 @@

Autark Provenance

+ + +
@@ -120,21 +123,11 @@

Autark Provenance

-
-
Analysis Strategy
-
Interact with any visualization to begin…
-
-
Annotate This Step
Interact with any visualization to begin…
-
-
Selection Focus
-
Interact with any visualization to begin…
-
-
Analysis Summary
Interact with any visualization to begin…
diff --git a/examples/src/autk-provenance/all-plots-provenance.ts b/examples/src/autk-provenance/all-plots-provenance.ts index 42a22bde..672724c1 100644 --- a/examples/src/autk-provenance/all-plots-provenance.ts +++ b/examples/src/autk-provenance/all-plots-provenance.ts @@ -218,16 +218,18 @@ function renderSummaryCard(el: HTMLElement, provenance: ProvenanceApi): void { // --------------------------------------------------------------------------- function setupTabs(provenance: ProvenanceApi): void { - const chartsTab = document.getElementById('chartsTab')!; - const provTab = document.getElementById('provenanceTab')!; - const badge = document.getElementById('nodeCountBadge')!; + const chartsTab = document.getElementById('chartsTab')!; + const provTab = document.getElementById('provenanceTab')!; + const provInsights = document.getElementById('provTrailInsights')!; + const badge = document.getElementById('nodeCountBadge')!; document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab as 'charts' | 'provenance'; document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn)); - chartsTab.classList.toggle('hidden', tab !== 'charts'); - provTab.classList.toggle('hidden', tab !== 'provenance'); + chartsTab.classList.toggle('hidden', tab !== 'charts'); + provTab.classList.toggle('hidden', tab !== 'provenance'); + provInsights.classList.toggle('hidden', tab !== 'provenance'); }); }); @@ -370,21 +372,18 @@ async function main(): Promise { // ── Provenance trail UI ─────────────────────────────────────────────────── renderProvenanceTrailUI({ provenance, container: document.getElementById('provenanceTrail')!, + insightsContainer: document.getElementById('provTrailInsights')!, showTimestamps: true, showGraph: true, showPathList: true, showBackForward: true, }); setupTabs(provenance); // ── Session insight cards ───────────────────────────────────────────────── - const metricsEl = document.querySelector('#insightsMetrics .insights-card-body') as HTMLElement; const annotateEl = document.querySelector('#insightsAnnotate .insights-card-body') as HTMLElement; - const freqEl = document.querySelector('#insightsFreq .insights-card-body') as HTMLElement; const summaryEl = document.querySelector('#insightsSummary .insights-card-body') as HTMLElement; function refreshInsights(): void { - renderMetricsCard(metricsEl, provenance); renderAnnotateCard(annotateEl, provenance); - renderFreqCard(freqEl, provenance, featureName); renderSummaryCard(summaryEl, provenance); } provenance.addObserver(refreshInsights); From 2d7652bcd094a596d8784438902faa266379d6b4 Mon Sep 17 00:00:00 2001 From: JP109 Date: Mon, 27 Apr 2026 23:36:16 -0500 Subject: [PATCH 09/21] fix: histogram selection issue --- examples/src/autk-provenance/all-plots-provenance.html | 2 +- examples/src/autk-provenance/all-plots-provenance.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/src/autk-provenance/all-plots-provenance.html b/examples/src/autk-provenance/all-plots-provenance.html index c8deb07b..93d7d492 100644 --- a/examples/src/autk-provenance/all-plots-provenance.html +++ b/examples/src/autk-provenance/all-plots-provenance.html @@ -95,7 +95,7 @@

Autark Provenance

Histogram - Neighborhood area distribution · click a bar + Neighborhood area distribution · click bins to select
diff --git a/examples/src/autk-provenance/all-plots-provenance.ts b/examples/src/autk-provenance/all-plots-provenance.ts index 672724c1..44e94757 100644 --- a/examples/src/autk-provenance/all-plots-provenance.ts +++ b/examples/src/autk-provenance/all-plots-provenance.ts @@ -321,7 +321,7 @@ async function main(): Promise { labels: { axis: ['shape_area', 'Count'], title: 'Area Distribution' }, width: hd.width, height: hd.height, margins: { left: 55, right: 16, top: 36, bottom: 48 }, - events: [PlotEvent.CLICK, PlotEvent.BRUSH_X], + events: [PlotEvent.CLICK], }); // ── Thematic dropdown (custom control tracked by provenance) ────────────── From 0c0727b90af8e883cb47dd3e891c799d592a8700 Mon Sep 17 00:00:00 2001 From: JP109 Date: Mon, 27 Apr 2026 23:42:35 -0500 Subject: [PATCH 10/21] fix: hide duplicated insights tab --- examples/src/autk-provenance/all-plots-provenance.css | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/src/autk-provenance/all-plots-provenance.css b/examples/src/autk-provenance/all-plots-provenance.css index 171c70f7..b6871c90 100644 --- a/examples/src/autk-provenance/all-plots-provenance.css +++ b/examples/src/autk-provenance/all-plots-provenance.css @@ -251,6 +251,7 @@ svg { display: block; overflow: visible; } #provenanceTrail.autk-provenance-root { gap: 8px; min-height: 0; } #provenanceTrail .autk-provenance-graph-wrap { height: 260px; max-height: 320px; } #provenanceTrail .autk-provenance-path { max-height: 160px; flex-shrink: 0; } +#provenanceTrail .autk-prov-insights-wrap { display: none; } /* ── Provenance session insights (row 2, shown only on provenance tab) ────── */ #provTrailInsights { From b723cbf9ff04a76ce0c13712c96d94ddedb3a0fe Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Tue, 28 Apr 2026 01:41:59 -0500 Subject: [PATCH 11/21] fixes: insight chart fixes --- autk-compute/tsconfig.json | 3 +- autk-plot/src/plot-types/histogram.ts | 11 +- autk-provenance/src/adapters/map-adapter.ts | 30 +- autk-provenance/src/adapters/plot-adapter.ts | 18 +- autk-provenance/src/core.ts | 5 +- autk-provenance/tsconfig.json | 1 + .../autk-provenance/all-plots-provenance.css | 177 ++++++ .../autk-provenance/all-plots-provenance.html | 44 +- .../autk-provenance/all-plots-provenance.ts | 502 ++++++++++++++---- 9 files changed, 651 insertions(+), 140 deletions(-) diff --git a/autk-compute/tsconfig.json b/autk-compute/tsconfig.json index f8b75faa..75abdef2 100644 --- a/autk-compute/tsconfig.json +++ b/autk-compute/tsconfig.json @@ -17,8 +17,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "types": ["@webgpu/types"] + "noFallthroughCasesInSwitch": true }, "include": ["src"] } diff --git a/autk-plot/src/plot-types/histogram.ts b/autk-plot/src/plot-types/histogram.ts index 5724650c..3c8d0441 100644 --- a/autk-plot/src/plot-types/histogram.ts +++ b/autk-plot/src/plot-types/histogram.ts @@ -59,7 +59,7 @@ export class Histogram extends PlotD3 { } const values: BinDatum[] = this.data.map((d, i) => ({ - value: d ? +d[this._axis[0]] || 0 : 0, + value: Number(d?.[this._axis[0]] ?? 0), index: i, })); @@ -286,14 +286,17 @@ export class Histogram extends PlotD3 { }); } - // Highlight bars that contain any of the given feature row indices. + // Highlight a bin only when every feature mapped to that bin is selected. updatePlotSelection(): void { const selectedSet = new Set(this._selection); const plot = this; d3.select(this._div).selectAll('.autkMark').style('fill', function (_d: unknown, binIdx: number) { const bin = plot.binData[binIdx]; - const hasSelected = bin ? bin.some((d) => selectedSet.has(d.index)) : false; - return hasSelected ? PlotStyle.highlight : PlotStyle.default; + const fullySelected = + !!bin && + bin.length > 0 && + bin.every((d) => selectedSet.has(d.index)); + return fullySelected ? PlotStyle.highlight : PlotStyle.default; }); } } diff --git a/autk-provenance/src/adapters/map-adapter.ts b/autk-provenance/src/adapters/map-adapter.ts index fb2b0d04..a8761e41 100644 --- a/autk-provenance/src/adapters/map-adapter.ts +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -108,6 +108,9 @@ export function createMapAdapter( let clickListener: ((event: Event) => void) | null = null; let changeListener: ((event: Event) => void) | null = null; let isApplyingState = false; + let currentState: AutarkProvenanceState = { + selection: { map: null, plots: {} }, + }; const wrappedMapMethods = new Map(); @@ -274,13 +277,17 @@ export function createMapAdapter( }); pickListener = (selection: number[], layerId: string) => { + const activePlotIds = new Set( + Object.values(currentState.selection?.plots ?? {}).flatMap((plotState) => plotState.ids) + ); + const mapOwnedSelection = selection.filter((id) => !activePlotIds.has(id)); const label = - selection.length === 0 + mapOwnedSelection.length === 0 ? `Cleared selection on ${layerId}` - : `Picked ${selection.length} feature(s) on ${layerId}`; + : `Picked ${mapOwnedSelection.length} feature(s) on ${layerId}`; onRecord(ProvenanceAction.MAP_PICK, label, { selection: { - map: { layerId, ids: selection }, + map: { layerId, ids: mapOwnedSelection }, plots: {}, }, }); @@ -405,6 +412,7 @@ export function createMapAdapter( function applyState(state: AutarkProvenanceState): void { isApplyingState = true; try { + currentState = state; const { selection } = state; const ui = resolveUiState(state.ui); @@ -450,12 +458,16 @@ export function createMapAdapter( for (const layer of map.layerManager.vectorLayers ?? []) { const layerId = layer.layerInfo?.id; if (!layerId) continue; - const isMapTarget = !!targetLayerId && layerId === targetLayerId; - const isPlotTarget = !targetLayerId && !!fallbackLayerId && layerId === fallbackLayerId; - if (isMapTarget && targetIds.length > 0) { - layer.setHighlightedIds?.(targetIds); - } else if (isPlotTarget && fallbackPlotIds.length > 0) { - layer.setHighlightedIds?.(fallbackPlotIds); + const combinedIds = new Set(); + if (targetLayerId && layerId === targetLayerId) { + targetIds.forEach((id) => combinedIds.add(id)); + } + if (fallbackLayerId && layerId === fallbackLayerId) { + fallbackPlotIds.forEach((id) => combinedIds.add(id)); + } + + if (combinedIds.size > 0) { + layer.setHighlightedIds?.([...combinedIds]); } else { layer.clearHighlightedIds?.(); } diff --git a/autk-provenance/src/adapters/plot-adapter.ts b/autk-provenance/src/adapters/plot-adapter.ts index a653c41d..1255ecfa 100644 --- a/autk-provenance/src/adapters/plot-adapter.ts +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -71,11 +71,10 @@ export function createPlotAdapter( onRecord(actionType, label, { selection: { - map: null, plots: { [plot.plotId]: { ids: selection, plotType: plot.plotType }, }, - }, + } as unknown as AutarkProvenanceState['selection'], }); }; entry.listeners.push({ event, fn }); @@ -113,9 +112,8 @@ export function createPlotAdapter( // Reset selection for this plot — indices are no longer valid after a data swap. onRecord(ProvenanceAction.PLOT_DATA, label, { selection: { - map: null, plots: { [plot.plotId]: { ids: [], plotType: plot.plotType } }, - }, + } as unknown as AutarkProvenanceState['selection'], }); } }, @@ -155,12 +153,14 @@ export function createPlotAdapter( function applyState(state: AutarkProvenanceState): void { isApplyingState = true; try { + const coordinatedIds = [...new Set([ + ...(state.selection?.map?.ids ?? []), + ...Object.values(state.selection?.plots ?? {}).flatMap((plotState) => plotState.ids), + ])]; + for (const entry of entries) { - const { plot } = entry; - const plotState = state.selection?.plots?.[plot.plotId]; - const ids = plotState?.ids ?? state.selection?.map?.ids ?? []; - entry.lastSelectionSig = selectionSignature(ids); - plot.setHighlightedIds(ids); + entry.lastSelectionSig = selectionSignature(coordinatedIds); + entry.plot.setHighlightedIds(coordinatedIds); } } finally { isApplyingState = false; diff --git a/autk-provenance/src/core.ts b/autk-provenance/src/core.ts index 057f8a3d..56f25ef4 100644 --- a/autk-provenance/src/core.ts +++ b/autk-provenance/src/core.ts @@ -14,9 +14,12 @@ function deepMergeState( base: AutarkProvenanceState, delta: Partial ): AutarkProvenanceState { + const hasSelectionMap = + delta.selection !== undefined && + Object.prototype.hasOwnProperty.call(delta.selection, 'map'); const next: AutarkProvenanceState = { selection: { - map: delta.selection?.map ?? base.selection.map, + map: hasSelectionMap ? (delta.selection?.map ?? null) : base.selection.map, // Spread-merge so a delta for one plot doesn't wipe the others. plots: delta.selection?.plots !== undefined ? { ...base.selection.plots, ...delta.selection.plots } diff --git a/autk-provenance/tsconfig.json b/autk-provenance/tsconfig.json index 645ee4dc..ae2a5cd1 100644 --- a/autk-provenance/tsconfig.json +++ b/autk-provenance/tsconfig.json @@ -11,6 +11,7 @@ "isolatedModules": true, "moduleDetection": "force", "declaration": true, + "rootDir": "./src", "declarationDir": "./dist", "emitDeclarationOnly": true, "outDir": "./dist", diff --git a/examples/src/autk-provenance/all-plots-provenance.css b/examples/src/autk-provenance/all-plots-provenance.css index b6871c90..58ac9668 100644 --- a/examples/src/autk-provenance/all-plots-provenance.css +++ b/examples/src/autk-provenance/all-plots-provenance.css @@ -207,6 +207,49 @@ canvas { min-height: 0; } +.plot-panel:hover { + border-color: #bfd0e3; + box-shadow: 0 10px 26px rgba(30, 55, 80, .08); +} + +.plot-panel .plot-panel-header { + cursor: zoom-in; + gap: 12px; +} + +.plot-panel .panel-header-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.plot-panel .panel-header-main .panel-hint { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.panel-expand-btn { + flex-shrink: 0; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--accent); + font-family: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.panel-expand-btn:hover, +.panel-expand-btn:focus-visible { + background: #edf5ff; + border-color: var(--accent); + outline: none; +} + .plot-body { flex: 1; min-height: 0; @@ -221,6 +264,136 @@ canvas { svg { display: block; overflow: visible; } +.chart-modal-backdrop { + position: fixed; + inset: 0; + z-index: 12000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(14, 24, 36, .56); +} + +.chart-modal { + width: min(1320px, 96vw); + height: min(900px, 92vh); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(212, 221, 231, .85); + background: linear-gradient(180deg, #f9fcff, #f3f8fd); + box-shadow: 0 32px 90px rgba(13, 26, 40, .45); +} + +.chart-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, .92); +} + +.chart-modal-heading { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.chart-modal-title { + font-size: 15px; + font-weight: 700; + color: var(--text); +} + +.chart-modal-subtitle { + font-size: 12px; + color: var(--text-muted); +} + +.chart-modal-controls { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.chart-modal-btn { + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 12px; + cursor: pointer; +} + +.chart-modal-btn:hover, +.chart-modal-btn:focus-visible { + background: #edf5ff; + border-color: var(--accent); + outline: none; +} + +.chart-modal-body { + display: flex; + flex: 1; + min-height: 0; + padding: 14px; +} + +.chart-modal-canvas { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: + linear-gradient(90deg, rgba(207, 220, 232, .35) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(rgba(207, 220, 232, .35) 1px, transparent 1px) 0 0 / 32px 32px, + #ffffff; +} + +.chart-modal-stage { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + will-change: transform; +} + +.chart-modal-stage svg { + display: block; + overflow: visible; +} + +.chart-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px 14px; + color: var(--text-muted); + font-size: 12px; +} + +.chart-modal-hint { + min-width: 0; +} + +.chart-modal-scale { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + /* ── Shared panel header ─────────────────────────────────────────────────── */ .panel-header { @@ -444,4 +617,8 @@ svg { display: block; overflow: visible; } .insights-grid { grid-template-columns: 1fr; } .insights-card { border-right: none; border-bottom: 1px solid var(--border); } .insights-card:last-child { border-bottom: none; } + .chart-modal { width: 96vw; height: 92vh; } + .chart-modal-header, + .chart-modal-footer { flex-direction: column; align-items: flex-start; } + .chart-modal-controls { justify-content: flex-start; } } diff --git a/examples/src/autk-provenance/all-plots-provenance.html b/examples/src/autk-provenance/all-plots-provenance.html index 93d7d492..c03ff1a8 100644 --- a/examples/src/autk-provenance/all-plots-provenance.html +++ b/examples/src/autk-provenance/all-plots-provenance.html @@ -62,40 +62,52 @@

Autark Provenance

-
-
- Scatterplot - Area vs Perimeter · drag to brush +
+
+
+ Scatterplot + Area vs Perimeter · drag to brush +
+
-
-
- Bar Chart - Neighborhoods per district · click a bar +
+
+
+ Bar Chart + Neighborhoods per district · click a bar +
+
-
-
- Parallel Coordinates - Area · Perimeter · Compactness · brush any axis +
+
+
+ Parallel Coordinates + Area · Perimeter · Compactness · brush any axis +
+
-
-
- Histogram - Neighborhood area distribution · click bins to select +
+
+
+ Histogram + Neighborhood area distribution · click bins to select +
+
diff --git a/examples/src/autk-provenance/all-plots-provenance.ts b/examples/src/autk-provenance/all-plots-provenance.ts index 44e94757..63010e4e 100644 --- a/examples/src/autk-provenance/all-plots-provenance.ts +++ b/examples/src/autk-provenance/all-plots-provenance.ts @@ -6,11 +6,9 @@ import { createAutarkProvenance, renderProvenanceTrailUI, computeGraphMetrics, - computeSelectionFrequency, getInsightAnnotations, generateSessionNarrative, PlotType, - type StrategyLabel, type CustomControlConfig, } from 'autk-provenance'; @@ -35,26 +33,28 @@ function enrichFeatures(geojson: FeatureCollection): void { // The bar chart's provenance tracking is correct; map cross-highlight uses the district // count index as a fallback, which is an acceptable approximation for this demo. function buildDistrictData(geojson: FeatureCollection): FeatureCollection { - const groups = new Map(); - for (const f of geojson.features) { + const groups = new Map(); + geojson.features.forEach((f, index) => { const cd = f.properties?.cdta2020 as string ?? 'Unknown'; const area = f.properties?.shape_area as number ?? 0; - const g = groups.get(cd) ?? { count: 0, totalArea: 0 }; + const g = groups.get(cd) ?? { count: 0, totalArea: 0, memberIds: [] }; g.count++; g.totalArea += area; + g.memberIds.push(index); groups.set(cd, g); - } + }); return { type: 'FeatureCollection', features: [...groups.entries()] .sort((a, b) => a[0].localeCompare(b[0])) - .map(([cdta2020, { count, totalArea }]) => ({ + .map(([cdta2020, { count, totalArea, memberIds }]) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: { cdta2020, count, avg_area_km2: Math.round(totalArea / count / 1e6 * 100) / 100, + memberIds, }, })), }; @@ -65,56 +65,32 @@ function buildDistrictData(geojson: FeatureCollection): FeatureCollection { // --------------------------------------------------------------------------- type ProvenanceApi = ReturnType; - -const STRATEGY_COLORS: Record = { - Confirmatory: '#2e7d32', - Exploratory: '#1565c0', - 'Iterative Refinement': '#6a1b9a', +type ChartKey = 'scatter' | 'bar' | 'pc' | 'hist'; +type InteractivePlot = { + selection: number[]; + plotEvents: { + addEventListener(event: string, listener: (selection: number[]) => void): void; + removeEventListener?(event: string, listener: (selection: number[]) => void): void; + emit(event: string, selection: number[]): void; + }; + setHighlightedIds(selection: number[]): void; + updatePlotSelection(): void; }; - -const STRATEGY_DESC: Record = { - Confirmatory: 'Focused, linear exploration — you appear to know what you are looking for.', - Exploratory: 'Broad, open-ended investigation with multiple diverging paths.', - 'Iterative Refinement': 'Hypothesis-driven: repeated backtracking and revision of prior selections.', +type ChartModalDescriptor = { + key: ChartKey; + title: string; + subtitle: string; + originalPlot: InteractivePlot; + events: PlotEvent[]; + trigger: HTMLElement; + button: HTMLButtonElement | null; + createModalPlot(container: HTMLElement, width: number, height: number): InteractivePlot; }; +type ChartModalApi = { syncSelection(): void }; let _annotationNodeId: string | null = null; let _annotationTextarea: HTMLTextAreaElement | null = null; -function renderMetricsCard(el: HTMLElement, provenance: ProvenanceApi): void { - const m = computeGraphMetrics(provenance.getGraph()); - el.innerHTML = ''; - - const badge = document.createElement('span'); - badge.className = 'strategy-badge'; - badge.textContent = m.strategyLabel; - badge.style.background = STRATEGY_COLORS[m.strategyLabel]; - el.appendChild(badge); - - const desc = document.createElement('p'); - desc.className = 'strategy-desc'; - desc.textContent = STRATEGY_DESC[m.strategyLabel]; - el.appendChild(desc); - - const row = document.createElement('div'); - row.className = 'metrics-row'; - const chips: [string, string | number][] = [ - ['States', m.totalNodes], ['Branches', m.branchPoints], - ['Backtracks', m.backtracks], ['Insights', m.insightCount], - ]; - if (m.sessionDurationMs > 0) { - const s = Math.round(m.sessionDurationMs / 1000); - chips.unshift(['Duration', s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`]); - } - for (const [label, val] of chips) { - const chip = document.createElement('span'); - chip.className = 'metric-chip'; - chip.innerHTML = `${val} ${label}`; - row.appendChild(chip); - } - el.appendChild(row); -} - function renderAnnotateCard(el: HTMLElement, provenance: ProvenanceApi): void { const current = provenance.getCurrentNode(); if (!current) { @@ -156,50 +132,6 @@ function renderAnnotateCard(el: HTMLElement, provenance: ProvenanceApi): void { el.appendChild(btn); } -function renderFreqCard( - el: HTMLElement, provenance: ProvenanceApi, featureName: (id: number) => string, -): void { - const freq = computeSelectionFrequency(provenance.getGraph()); - el.innerHTML = ''; - - const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); - const topPlots = [...freq.plots.entries()].map(([plotId, m]) => ({ - plotId, top: [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), - })).filter(p => p.top.length > 0); - - if (!topMap.length && !topPlots.length) { - const s = document.createElement('span'); - s.className = 'freq-empty'; - s.textContent = 'No selections yet. Click a neighborhood on the map or interact with any chart.'; - el.appendChild(s); return; - } - - const maxCount = Math.max(...topMap.map(([,c])=>c), ...topPlots.flatMap(p=>p.top.map(([,c])=>c)), 1); - - const renderGroup = (label: string, items: [number, number][]): void => { - if (!items.length) return; - const gl = document.createElement('div'); - gl.className = 'freq-group-label'; - gl.textContent = label; - el.appendChild(gl); - - for (const [id, count] of items) { - const row = document.createElement('div'); row.className = 'freq-row'; - const nm = document.createElement('span'); nm.className = 'freq-id'; - nm.textContent = featureName(id); nm.title = `Feature index #${id}`; row.appendChild(nm); - const wrap = document.createElement('div'); wrap.className = 'freq-bar-wrap'; - const fill = document.createElement('div'); fill.className = 'freq-bar-fill'; - fill.style.width = `${Math.round((count / maxCount) * 100)}%`; wrap.appendChild(fill); row.appendChild(wrap); - const cnt = document.createElement('span'); cnt.className = 'freq-count'; - cnt.textContent = `${count}×`; row.appendChild(cnt); - el.appendChild(row); - } - }; - - renderGroup('Map', topMap); - for (const { plotId, top } of topPlots) renderGroup(plotId, top); -} - function renderSummaryCard(el: HTMLElement, provenance: ProvenanceApi): void { const graph = provenance.getGraph(); const narrative = generateSessionNarrative(graph, computeGraphMetrics(graph), getInsightAnnotations(graph)); @@ -239,6 +171,293 @@ function setupTabs(provenance: ProvenanceApi): void { }); } +function setupChartModal(descriptors: ChartModalDescriptor[]): ChartModalApi { + let active: ChartModalDescriptor | null = null; + let backdrop: HTMLDivElement | null = null; + let canvas: HTMLDivElement | null = null; + let stage: HTMLDivElement | null = null; + let titleEl: HTMLDivElement | null = null; + let subtitleEl: HTMLDivElement | null = null; + let scaleEl: HTMLSpanElement | null = null; + let modalPlot: InteractivePlot | null = null; + let modalPlotCleanup: Array<() => void> = []; + let contentWidth = 1; + let contentHeight = 1; + let scale = 1; + let tx = 0; + let ty = 0; + let spacePressed = false; + let dragActive = false; + let dragX = 0; + let dragY = 0; + + const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + + function applyTransform(): void { + if (!stage) return; + stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; + if (scaleEl) scaleEl.textContent = `${Math.round(scale * 100)}%`; + } + + function contentSize(): { width: number; height: number } { + return { width: Math.max(1, contentWidth), height: Math.max(1, contentHeight) }; + } + + function fit(): void { + if (!canvas || !stage) return; + const rect = canvas.getBoundingClientRect(); + const { width, height } = contentSize(); + const padding = 28; + const fitScale = Math.min( + (rect.width - padding * 2) / Math.max(1, width), + (rect.height - padding * 2) / Math.max(1, height) + ); + scale = clamp(Number.isFinite(fitScale) ? fitScale : 1, 0.3, 4); + tx = (rect.width - width * scale) / 2; + ty = (rect.height - height * scale) / 2; + applyTransform(); + } + + function zoomAt(clientX: number, clientY: number, factor: number): void { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const localX = clientX - rect.left; + const localY = clientY - rect.top; + const nextScale = clamp(scale * factor, 0.25, 6); + const ratio = nextScale / scale; + tx = localX - (localX - tx) * ratio; + ty = localY - (localY - ty) * ratio; + scale = nextScale; + applyTransform(); + } + + function clearModalPlot(): void { + modalPlotCleanup.forEach((cleanup) => cleanup()); + modalPlotCleanup = []; + modalPlot = null; + if (stage) stage.innerHTML = ''; + } + + function syncSelection(): void { + if (!active || !modalPlot) return; + modalPlot.setHighlightedIds([...active.originalPlot.selection]); + } + + function renderInteractivePlot(): void { + if (!active || !stage || !titleEl || !subtitleEl || !canvas) return; + titleEl.textContent = active.title; + subtitleEl.textContent = active.subtitle; + clearModalPlot(); + + const host = document.createElement('div'); + host.style.width = '100%'; + host.style.height = '100%'; + stage.appendChild(host); + + contentWidth = Math.max(920, canvas.clientWidth - 48); + contentHeight = Math.max(560, canvas.clientHeight - 48); + stage.style.width = `${contentWidth}px`; + stage.style.height = `${contentHeight}px`; + host.style.width = `${contentWidth}px`; + host.style.height = `${contentHeight}px`; + + modalPlot = active.createModalPlot(host, contentWidth, contentHeight); + syncSelection(); + + for (const eventName of active.events) { + const listener = (selection: number[]) => { + if (!active) return; + active.originalPlot.selection = [...selection]; + active.originalPlot.updatePlotSelection(); + active.originalPlot.plotEvents.emit(eventName, [...selection]); + requestAnimationFrame(syncSelection); + }; + modalPlot.plotEvents.addEventListener(eventName, listener); + modalPlotCleanup.push(() => { + modalPlot?.plotEvents.removeEventListener?.(eventName, listener); + }); + } + + requestAnimationFrame(() => fit()); + } + + function closeModal(): void { + clearModalPlot(); + backdrop?.remove(); + backdrop = null; + canvas = null; + stage = null; + titleEl = null; + subtitleEl = null; + scaleEl = null; + active = null; + document.body.style.overflow = ''; + } + + function ensureModal(): void { + if (backdrop) return; + + backdrop = document.createElement('div'); + backdrop.className = 'chart-modal-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'chart-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', 'Expanded chart view'); + + const header = document.createElement('div'); + header.className = 'chart-modal-header'; + + const heading = document.createElement('div'); + heading.className = 'chart-modal-heading'; + titleEl = document.createElement('div'); + titleEl.className = 'chart-modal-title'; + subtitleEl = document.createElement('div'); + subtitleEl.className = 'chart-modal-subtitle'; + heading.appendChild(titleEl); + heading.appendChild(subtitleEl); + + const controls = document.createElement('div'); + controls.className = 'chart-modal-controls'; + + const makeBtn = (label: string, onClick: () => void): HTMLButtonElement => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chart-modal-btn'; + btn.textContent = label; + btn.addEventListener('click', onClick); + return btn; + }; + + controls.appendChild(makeBtn('−', () => { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 0.85); + })); + controls.appendChild(makeBtn('+', () => { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.15); + })); + controls.appendChild(makeBtn('Fit', fit)); + controls.appendChild(makeBtn('100%', () => { + scale = 1; + tx = 24; + ty = 24; + applyTransform(); + })); + controls.appendChild(makeBtn('Close', closeModal)); + + header.appendChild(heading); + header.appendChild(controls); + + const body = document.createElement('div'); + body.className = 'chart-modal-body'; + + canvas = document.createElement('div'); + canvas.className = 'chart-modal-canvas'; + + stage = document.createElement('div'); + stage.className = 'chart-modal-stage'; + canvas.appendChild(stage); + body.appendChild(canvas); + + const footer = document.createElement('div'); + footer.className = 'chart-modal-footer'; + const hint = document.createElement('div'); + hint.className = 'chart-modal-hint'; + hint.textContent = 'Mouse wheel to zoom. Hold Space and drag to pan. Press Esc to close.'; + scaleEl = document.createElement('span'); + scaleEl.className = 'chart-modal-scale'; + scaleEl.textContent = '100%'; + footer.appendChild(hint); + footer.appendChild(scaleEl); + + modal.appendChild(header); + modal.appendChild(body); + modal.appendChild(footer); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + backdrop.addEventListener('click', (event) => { + if (event.target === backdrop) closeModal(); + }); + + canvas.addEventListener('wheel', (event) => { + event.preventDefault(); + zoomAt(event.clientX, event.clientY, event.deltaY < 0 ? 1.12 : 0.9); + }, { passive: false }); + + canvas.addEventListener('pointerdown', (event) => { + if (!(event.button === 0 && spacePressed)) return; + dragActive = true; + dragX = event.clientX; + dragY = event.clientY; + canvas!.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + + canvas.addEventListener('pointermove', (event) => { + if (!dragActive) return; + tx += event.clientX - dragX; + ty += event.clientY - dragY; + dragX = event.clientX; + dragY = event.clientY; + applyTransform(); + }); + + const endDrag = (event: PointerEvent) => { + if (!dragActive || !canvas) return; + dragActive = false; + try { + canvas.releasePointerCapture(event.pointerId); + } catch { + // ignore + } + }; + canvas.addEventListener('pointerup', endDrag); + canvas.addEventListener('pointercancel', endDrag); + } + + function openModal(descriptor: ChartModalDescriptor): void { + active = descriptor; + ensureModal(); + document.body.style.overflow = 'hidden'; + renderInteractivePlot(); + } + + document.addEventListener('keydown', (event) => { + if (event.key === ' ') spacePressed = true; + if (event.key === 'Escape' && backdrop) closeModal(); + }); + + document.addEventListener('keyup', (event) => { + if (event.key === ' ') spacePressed = false; + }); + + window.addEventListener('resize', () => { + if (!backdrop || !active) return; + renderInteractivePlot(); + }); + + for (const descriptor of descriptors) { + const open = () => openModal(descriptor); + descriptor.trigger.addEventListener('click', open); + descriptor.trigger.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + open(); + }); + descriptor.button?.addEventListener('click', (event) => { + event.stopPropagation(); + open(); + }); + } + + return { syncSelection }; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -263,9 +482,6 @@ async function main(): Promise { overlay.remove(); - const featureName = (id: number): string => - (geojson.features[id]?.properties?.ntaname as string | undefined) ?? `#${id}`; - // ── Map ────────────────────────────────────────────────────────────────── const canvas = document.querySelector('canvas') as HTMLCanvasElement; const map = new AutkMap(canvas); @@ -297,7 +513,7 @@ async function main(): Promise { labels: { axis: ['shape_area', 'shape_leng'], title: 'Area vs Perimeter' }, width: sd.width, height: sd.height, margins: { left: 62, right: 16, top: 36, bottom: 48 }, - events: [PlotEvent.BRUSH], + events: [PlotEvent.CLICK, PlotEvent.BRUSH], }); const bar = new Barchart({ @@ -313,6 +529,12 @@ async function main(): Promise { labels: { axis: ['shape_area', 'shape_leng', 'compactness'], title: 'Neighborhood Metrics' }, width: pd.width, height: pd.height, margins: { left: 28, right: 28, top: 36, bottom: 36 }, + parallelCoordinates: { + normalization: { + mode: 'robust', + quantileClamp: [0.05, 0.95], + }, + }, events: [PlotEvent.BRUSH_Y], }); @@ -324,6 +546,87 @@ async function main(): Promise { events: [PlotEvent.CLICK], }); + const chartModal = setupChartModal([ + { + key: 'scatter', + title: 'Scatterplot', + subtitle: 'Area vs Perimeter · drag to brush', + originalPlot: scatter, + events: [PlotEvent.CLICK, PlotEvent.BRUSH], + trigger: document.querySelector('[data-chart-key="scatter"] .plot-panel-header') as HTMLElement, + button: document.querySelector('[data-chart-key="scatter"] .panel-expand-btn') as HTMLButtonElement | null, + createModalPlot: (container, width, height) => new Scatterplot({ + div: container, + data: geojson, + labels: { axis: ['shape_area', 'shape_leng'], title: 'Area vs Perimeter' }, + width, + height, + margins: { left: 62, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.CLICK, PlotEvent.BRUSH], + }), + }, + { + key: 'bar', + title: 'Bar Chart', + subtitle: 'Neighborhoods per district · click a bar', + originalPlot: bar, + events: [PlotEvent.CLICK], + trigger: document.querySelector('[data-chart-key="bar"] .plot-panel-header') as HTMLElement, + button: document.querySelector('[data-chart-key="bar"] .panel-expand-btn') as HTMLButtonElement | null, + createModalPlot: (container, width, height) => new Barchart({ + div: container, + data: districtData, + labels: { axis: ['cdta2020', 'count'], title: 'Neighborhoods per District' }, + width, + height, + margins: { left: 50, right: 16, top: 36, bottom: 68 }, + events: [PlotEvent.CLICK], + }), + }, + { + key: 'pc', + title: 'Parallel Coordinates', + subtitle: 'Area · Perimeter · Compactness · brush any axis', + originalPlot: pc, + events: [PlotEvent.BRUSH_Y], + trigger: document.querySelector('[data-chart-key="pc"] .plot-panel-header') as HTMLElement, + button: document.querySelector('[data-chart-key="pc"] .panel-expand-btn') as HTMLButtonElement | null, + createModalPlot: (container, width, height) => new ParallelCoordinates({ + div: container, + data: geojson, + labels: { axis: ['shape_area', 'shape_leng', 'compactness'], title: 'Neighborhood Metrics' }, + width, + height, + margins: { left: 28, right: 28, top: 36, bottom: 36 }, + parallelCoordinates: { + normalization: { + mode: 'robust', + quantileClamp: [0.05, 0.95], + }, + }, + events: [PlotEvent.BRUSH_Y], + }), + }, + { + key: 'hist', + title: 'Histogram', + subtitle: 'Neighborhood area distribution · click bins to select', + originalPlot: hist, + events: [PlotEvent.CLICK], + trigger: document.querySelector('[data-chart-key="hist"] .plot-panel-header') as HTMLElement, + button: document.querySelector('[data-chart-key="hist"] .panel-expand-btn') as HTMLButtonElement | null, + createModalPlot: (container, width, height) => new Histogram({ + div: container, + data: geojson, + labels: { axis: ['shape_area', 'Count'], title: 'Area Distribution' }, + width, + height, + margins: { left: 55, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.CLICK], + }), + }, + ]); + // ── Thematic dropdown (custom control tracked by provenance) ────────────── function applyThematic(prop: string): void { if (prop) { @@ -385,6 +688,7 @@ async function main(): Promise { function refreshInsights(): void { renderAnnotateCard(annotateEl, provenance); renderSummaryCard(summaryEl, provenance); + chartModal.syncSelection(); } provenance.addObserver(refreshInsights); refreshInsights(); From 14474a563ee999b41c3d99751ce325c3e398ff14 Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Tue, 5 May 2026 15:49:02 -0500 Subject: [PATCH 12/21] fixes: insights changes --- Makefile | 2 +- autk-db/package.json | 8 +- .../src/adapters/db-adapter-bootstrap.ts | 39 + .../src/adapters/db-adapter-recorder.ts | 49 + .../src/adapters/db-adapter-shared.ts | 3 + .../src/adapters/db-adapter-types.ts | 39 + .../src/adapters/db-adapter-utils.ts | 11 + autk-provenance/src/adapters/db-adapter.ts | 248 +---- .../src/adapters/db-provenance-wrapper.ts | 11 + .../src/adapters/map-adapter-recording.ts | 22 + .../src/adapters/map-adapter-shared.ts | 32 + .../src/adapters/map-adapter-state.ts | 74 ++ .../src/adapters/map-adapter-types.ts | 56 + autk-provenance/src/adapters/map-adapter.ts | 427 ++------ .../src/adapters/map-ui-helpers.ts | 108 ++ autk-provenance/src/core-engine.ts | 196 ++++ autk-provenance/src/core.ts | 386 +------ autk-provenance/src/insight-engine.ts | 282 +----- autk-provenance/src/insights/annotations.ts | 20 + autk-provenance/src/insights/graph-metrics.ts | 63 ++ autk-provenance/src/insights/narrative.ts | 59 ++ .../src/insights/selection-frequency.ts | 28 + autk-provenance/src/insights/types.ts | 25 + autk-provenance/src/insights/utils.ts | 7 + autk-provenance/src/provenance-trail-ui.ts | 957 ++---------------- autk-provenance/src/ui/graph-layout.ts | 113 +++ autk-provenance/src/ui/graph-modal-dom.ts | 92 ++ autk-provenance/src/ui/graph-modal-utils.ts | 78 ++ autk-provenance/src/ui/graph-modal.ts | 109 ++ autk-provenance/src/ui/graph-preview.ts | 29 + autk-provenance/src/ui/graph-scene.ts | 89 ++ autk-provenance/src/ui/insights-panel.ts | 150 +++ autk-provenance/src/ui/styles.ts | 78 ++ autk-provenance/src/ui/trail-path.ts | 52 + autk-provenance/src/ui/trail-shell.ts | 94 ++ autk-provenance/src/ui/utils.ts | 20 + gallery/package.json | 6 +- 37 files changed, 1968 insertions(+), 2094 deletions(-) create mode 100644 autk-provenance/src/adapters/db-adapter-bootstrap.ts create mode 100644 autk-provenance/src/adapters/db-adapter-recorder.ts create mode 100644 autk-provenance/src/adapters/db-adapter-shared.ts create mode 100644 autk-provenance/src/adapters/db-adapter-types.ts create mode 100644 autk-provenance/src/adapters/db-adapter-utils.ts create mode 100644 autk-provenance/src/adapters/db-provenance-wrapper.ts create mode 100644 autk-provenance/src/adapters/map-adapter-recording.ts create mode 100644 autk-provenance/src/adapters/map-adapter-shared.ts create mode 100644 autk-provenance/src/adapters/map-adapter-state.ts create mode 100644 autk-provenance/src/adapters/map-adapter-types.ts create mode 100644 autk-provenance/src/adapters/map-ui-helpers.ts create mode 100644 autk-provenance/src/core-engine.ts create mode 100644 autk-provenance/src/insights/annotations.ts create mode 100644 autk-provenance/src/insights/graph-metrics.ts create mode 100644 autk-provenance/src/insights/narrative.ts create mode 100644 autk-provenance/src/insights/selection-frequency.ts create mode 100644 autk-provenance/src/insights/types.ts create mode 100644 autk-provenance/src/insights/utils.ts create mode 100644 autk-provenance/src/ui/graph-layout.ts create mode 100644 autk-provenance/src/ui/graph-modal-dom.ts create mode 100644 autk-provenance/src/ui/graph-modal-utils.ts create mode 100644 autk-provenance/src/ui/graph-modal.ts create mode 100644 autk-provenance/src/ui/graph-preview.ts create mode 100644 autk-provenance/src/ui/graph-scene.ts create mode 100644 autk-provenance/src/ui/insights-panel.ts create mode 100644 autk-provenance/src/ui/styles.ts create mode 100644 autk-provenance/src/ui/trail-path.ts create mode 100644 autk-provenance/src/ui/trail-shell.ts create mode 100644 autk-provenance/src/ui/utils.ts diff --git a/Makefile b/Makefile index 2226f5bd..a38c956b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: install lint typecheck build build-all docs verify test test-update test-ui test-codegen dev map db plot compute clean publish CONCURRENTLY := npx concurrently -RIMRAF := npx rimraf +RIMRAF := rm -rf APP ?= gallery diff --git a/autk-db/package.json b/autk-db/package.json index 40334a61..78a1f452 100644 --- a/autk-db/package.json +++ b/autk-db/package.json @@ -23,10 +23,10 @@ } }, "scripts": { - "dev": "vite", - "dev-build": "tsc && vite build --watch", - "build": "tsc && vite build", - "preview": "vite preview", + "dev": "node ./node_modules/vite/bin/vite.js", + "dev-build": "tsc && node ./node_modules/vite/bin/vite.js build --watch", + "build": "tsc && node ./node_modules/vite/bin/vite.js build", + "preview": "node ./node_modules/vite/bin/vite.js preview", "format": "prettier --write .", "doc": "typedoc --options typedoc.json" }, diff --git a/autk-provenance/src/adapters/db-adapter-bootstrap.ts b/autk-provenance/src/adapters/db-adapter-bootstrap.ts new file mode 100644 index 00000000..9c3a83c9 --- /dev/null +++ b/autk-provenance/src/adapters/db-adapter-bootstrap.ts @@ -0,0 +1,39 @@ +import type { DbRecorderSet, DbTableLike } from './db-adapter-types'; + +function recordTableBySource(recorder: DbRecorderSet, table: DbTableLike): void { + switch (table.source) { + case 'csv': + recorder.recordLoadCsv(table.name); + return; + case 'json': + recorder.recordLoadJson(table.name); + return; + case 'osm': + if (table.type === 'pointset') recorder.recordLoadOsm(table.name); + else recorder.recordLoadLayer(table.name); + return; + case 'geojson': + recorder.recordLoadCustomLayer(table.name); + return; + case 'user': + if (table.type === 'pointset') recorder.recordRawQuery(`User table: ${table.name}`); + else recorder.recordLoadGridLayer(table.name); + return; + default: + recorder.recordRawQuery(`Table available: ${table.name}`); + } +} + +export function bootstrapDbRecorder( + didBootstrap: { value: boolean }, + tables: DbTableLike[], + recorder: DbRecorderSet +): void { + if (didBootstrap.value) return; + didBootstrap.value = true; + recorder.recordInit(); + recorder.recordWorkspace(recorder.getWorkspaceSafe()); + for (const table of tables || []) { + recordTableBySource(recorder, table); + } +} diff --git a/autk-provenance/src/adapters/db-adapter-recorder.ts b/autk-provenance/src/adapters/db-adapter-recorder.ts new file mode 100644 index 00000000..dd0c6189 --- /dev/null +++ b/autk-provenance/src/adapters/db-adapter-recorder.ts @@ -0,0 +1,49 @@ +import { ProvenanceAction } from '../types'; +import { bootstrapDbRecorder } from './db-adapter-bootstrap'; +import type { DbRecordCallback, DbRecorderSet, DbStateSource } from './db-adapter-types'; + +export function createDbRecorderSet(db: DbStateSource, onRecord: DbRecordCallback): DbRecorderSet { + const didBootstrap = { value: false }; + const getCurrentLayerNames = (): string[] => (db.tables || []).map((t) => t.name); + const getWorkspaceSafe = (): string => { + try { + return db.getCurrentWorkspace(); + } catch { + return 'main'; + } + }; + const record = (actionType: string, actionLabel: string, layerTableNames: string[] = getCurrentLayerNames()) => + onRecord(actionType, actionLabel, { data: { workspace: getWorkspaceSafe(), layerTableNames } }); + const recordWithAddedTable = (actionType: string, prefix: string, outputTableName: string): void => { + const names = getCurrentLayerNames(); + if (!names.includes(outputTableName)) names.push(outputTableName); + record(actionType, `${prefix}: ${outputTableName}`, names); + }; + + const recorder: DbRecorderSet = { + getCurrentLayerNames, + getWorkspaceSafe, + bootstrapFromCurrentState: () => bootstrapDbRecorder(didBootstrap, db.tables || [], recorder), + recordInit: () => record(ProvenanceAction.DB_INIT, 'Database initialized'), + recordWorkspace: (name) => + onRecord(ProvenanceAction.DB_WORKSPACE, `Workspace: ${name}`, { + data: { workspace: name, layerTableNames: getCurrentLayerNames() }, + }), + recordLoadOsm: (name) => recordWithAddedTable(ProvenanceAction.DB_LOAD_OSM, 'Load OSM', name), + recordLoadCsv: (name) => recordWithAddedTable(ProvenanceAction.DB_LOAD_CSV, 'Load CSV', name), + recordLoadJson: (name) => recordWithAddedTable(ProvenanceAction.DB_LOAD_JSON, 'Load JSON', name), + recordLoadLayer: (name) => recordWithAddedTable(ProvenanceAction.DB_LOAD_LAYER, 'Load layer', name), + recordLoadCustomLayer: (name) => recordWithAddedTable(ProvenanceAction.DB_LOAD_CUSTOM_LAYER, 'Load custom layer', name), + recordLoadGridLayer: (name) => recordWithAddedTable(ProvenanceAction.DB_LOAD_GRID_LAYER, 'Load grid layer', name), + recordGetLayer: (name) => record(ProvenanceAction.DB_GET_LAYER, `Get layer: ${name}`), + recordSpatialJoin: (params) => + record(ProvenanceAction.DB_SPATIAL_JOIN, `Spatial join: ${params.tableRootName ?? '?'} + ${params.tableJoinName ?? '?'}`), + recordUpdateTable: (name) => record(ProvenanceAction.DB_UPDATE_TABLE, `Update table: ${name}`), + recordDropTable: (name) => + record(ProvenanceAction.DB_DROP_TABLE, `Drop table: ${name}`, getCurrentLayerNames().filter((n) => n !== name)), + recordRawQuery: (label = 'Raw query executed') => record(ProvenanceAction.DB_RAW_QUERY, label), + recordBuildHeatmap: (name) => recordWithAddedTable(ProvenanceAction.DB_BUILD_HEATMAP, 'Build heatmap', name), + }; + + return recorder; +} diff --git a/autk-provenance/src/adapters/db-adapter-shared.ts b/autk-provenance/src/adapters/db-adapter-shared.ts new file mode 100644 index 00000000..ff0788fe --- /dev/null +++ b/autk-provenance/src/adapters/db-adapter-shared.ts @@ -0,0 +1,3 @@ +export { createDbRecorderSet } from './db-adapter-recorder'; +export { inferName, isFn } from './db-adapter-utils'; +export type { DbRecordCallback, DbRecorderSet, DbStateSource, DbTableLike } from './db-adapter-types'; diff --git a/autk-provenance/src/adapters/db-adapter-types.ts b/autk-provenance/src/adapters/db-adapter-types.ts new file mode 100644 index 00000000..d70af758 --- /dev/null +++ b/autk-provenance/src/adapters/db-adapter-types.ts @@ -0,0 +1,39 @@ +import type { AutarkProvenanceState } from '../types'; +import { ProvenanceAction } from '../types'; + +export type DbRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; + +export type DbTableLike = { + name: string; + source?: string; + type?: string; +}; + +export interface DbStateSource { + getCurrentWorkspace(): string; + tables: DbTableLike[]; +} + +export interface DbRecorderSet { + getCurrentLayerNames(): string[]; + getWorkspaceSafe(): string; + bootstrapFromCurrentState(): void; + recordInit(): void; + recordWorkspace(name: string): void; + recordLoadOsm(outputTableName: string): void; + recordLoadCsv(outputTableName: string): void; + recordLoadJson(outputTableName: string): void; + recordLoadLayer(outputTableName: string): void; + recordLoadCustomLayer(outputTableName: string): void; + recordLoadGridLayer(outputTableName: string): void; + recordGetLayer(outputTableName: string): void; + recordSpatialJoin(params: { tableRootName?: string; tableJoinName?: string }): void; + recordUpdateTable(tableName: string): void; + recordDropTable(tableName: string): void; + recordRawQuery(label?: string): void; + recordBuildHeatmap(outputTableName: string): void; +} diff --git a/autk-provenance/src/adapters/db-adapter-utils.ts b/autk-provenance/src/adapters/db-adapter-utils.ts new file mode 100644 index 00000000..68c6465c --- /dev/null +++ b/autk-provenance/src/adapters/db-adapter-utils.ts @@ -0,0 +1,11 @@ +export function inferName(result: unknown, fallback = 'table'): string { + if (result && typeof result === 'object' && 'name' in result) { + const maybeName = (result as { name?: unknown }).name; + if (typeof maybeName === 'string' && maybeName.trim().length > 0) return maybeName; + } + return fallback; +} + +export function isFn(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === 'function'; +} diff --git a/autk-provenance/src/adapters/db-adapter.ts b/autk-provenance/src/adapters/db-adapter.ts index 13c428a7..a8b91c27 100644 --- a/autk-provenance/src/adapters/db-adapter.ts +++ b/autk-provenance/src/adapters/db-adapter.ts @@ -1,17 +1,14 @@ import type { AutarkProvenanceState } from '../types'; -import { ProvenanceAction } from '../types'; +import { + createDbRecorderSet, + inferName, + isFn, + type DbRecordCallback, + type DbTableLike, +} from './db-adapter-shared'; +export { createDbProvenanceWrapper } from './db-provenance-wrapper'; -export type DbRecordCallback = ( - actionType: ProvenanceAction | string, - actionLabel: string, - stateDelta: Partial -) => void; - -type DbTableLike = { - name: string; - source?: string; - type?: string; -}; +export type { DbRecordCallback }; export interface IDbForProvenance { getCurrentWorkspace(): string; @@ -53,167 +50,15 @@ export interface DbAdapterApi { applyState(state: AutarkProvenanceState): Promise; } -function inferName(result: unknown, fallback = 'table'): string { - if (result && typeof result === 'object' && 'name' in result) { - const maybeName = (result as { name?: unknown }).name; - if (typeof maybeName === 'string' && maybeName.trim().length > 0) return maybeName; - } - return fallback; -} - -function isFn(value: unknown): value is (...args: unknown[]) => unknown { - return typeof value === 'function'; -} - export function createDbAdapter( db: IDbForProvenance, onRecord: DbRecordCallback ): DbAdapterApi { const dbObj = db as unknown as Record; + const recorder = createDbRecorderSet(db, onRecord); const originalMethods = new Map(); let isRecording = false; let isApplyingState = false; - let didBootstrap = false; - - function getCurrentLayerNames(): string[] { - return (db.tables || []).map((t) => t.name); - } - - function getWorkspaceSafe(): string { - try { - return db.getCurrentWorkspace(); - } catch { - return 'main'; - } - } - - function record( - actionType: ProvenanceAction | string, - actionLabel: string, - layerTableNames: string[] = getCurrentLayerNames() - ): void { - onRecord(actionType, actionLabel, { - data: { - workspace: getWorkspaceSafe(), - layerTableNames, - }, - }); - } - - function recordInit(): void { - record(ProvenanceAction.DB_INIT, 'Database initialized'); - } - - function recordWorkspace(name: string): void { - onRecord(ProvenanceAction.DB_WORKSPACE, `Workspace: ${name}`, { - data: { - workspace: name, - layerTableNames: getCurrentLayerNames(), - }, - }); - } - - function recordLoadOsm(outputTableName: string): void { - const names = getCurrentLayerNames(); - if (!names.includes(outputTableName)) names.push(outputTableName); - record(ProvenanceAction.DB_LOAD_OSM, `Load OSM: ${outputTableName}`, names); - } - - function recordLoadCsv(outputTableName: string): void { - const names = getCurrentLayerNames(); - if (!names.includes(outputTableName)) names.push(outputTableName); - record(ProvenanceAction.DB_LOAD_CSV, `Load CSV: ${outputTableName}`, names); - } - - function recordLoadJson(outputTableName: string): void { - const names = getCurrentLayerNames(); - if (!names.includes(outputTableName)) names.push(outputTableName); - record(ProvenanceAction.DB_LOAD_JSON, `Load JSON: ${outputTableName}`, names); - } - - function recordLoadLayer(outputTableName: string): void { - const names = getCurrentLayerNames(); - if (!names.includes(outputTableName)) names.push(outputTableName); - record(ProvenanceAction.DB_LOAD_LAYER, `Load layer: ${outputTableName}`, names); - } - - function recordLoadCustomLayer(outputTableName: string): void { - const names = getCurrentLayerNames(); - if (!names.includes(outputTableName)) names.push(outputTableName); - record(ProvenanceAction.DB_LOAD_CUSTOM_LAYER, `Load custom layer: ${outputTableName}`, names); - } - - function recordLoadGridLayer(outputTableName: string): void { - const names = getCurrentLayerNames(); - if (!names.includes(outputTableName)) names.push(outputTableName); - record(ProvenanceAction.DB_LOAD_GRID_LAYER, `Load grid layer: ${outputTableName}`, names); - } - - function recordGetLayer(outputTableName: string): void { - record(ProvenanceAction.DB_GET_LAYER, `Get layer: ${outputTableName}`); - } - - function recordSpatialJoin(params: { tableRootName?: string; tableJoinName?: string }): void { - const label = `Spatial join: ${params.tableRootName ?? '?'} + ${params.tableJoinName ?? '?'}`; - record(ProvenanceAction.DB_SPATIAL_JOIN, label); - } - - function recordUpdateTable(tableName: string): void { - record(ProvenanceAction.DB_UPDATE_TABLE, `Update table: ${tableName}`); - } - - function recordDropTable(tableName: string): void { - const names = getCurrentLayerNames().filter((n) => n !== tableName); - record(ProvenanceAction.DB_DROP_TABLE, `Drop table: ${tableName}`, names); - } - - function recordRawQuery(label = 'Raw query executed'): void { - record(ProvenanceAction.DB_RAW_QUERY, label); - } - - function recordBuildHeatmap(outputTableName: string): void { - const names = getCurrentLayerNames(); - if (!names.includes(outputTableName)) names.push(outputTableName); - record(ProvenanceAction.DB_BUILD_HEATMAP, `Build heatmap: ${outputTableName}`, names); - } - - function recordTableBySource(table: DbTableLike): void { - switch (table.source) { - case 'csv': - recordLoadCsv(table.name); - return; - case 'json': - recordLoadJson(table.name); - return; - case 'osm': - if (table.type === 'pointset') recordLoadOsm(table.name); - else recordLoadLayer(table.name); - return; - case 'geojson': - recordLoadCustomLayer(table.name); - return; - case 'user': - if (table.type === 'pointset') { - record(ProvenanceAction.DB_OTHER, `User table: ${table.name}`); - } else { - recordLoadGridLayer(table.name); - } - return; - default: - record(ProvenanceAction.DB_OTHER, `Table available: ${table.name}`); - } - } - - function bootstrapFromCurrentState(): void { - if (didBootstrap) return; - didBootstrap = true; - - recordInit(); - recordWorkspace(getWorkspaceSafe()); - for (const table of db.tables || []) { - recordTableBySource(table); - } - } function wrapAsyncMethod( methodName: string, @@ -244,61 +89,61 @@ export function createDbAdapter( if (isRecording) return; isRecording = true; - bootstrapFromCurrentState(); + recorder.bootstrapFromCurrentState(); wrapAsyncMethod('init', () => { - recordInit(); + recorder.recordInit(); }); wrapAsyncMethod('setWorkspace', (args) => { const [name] = args; - recordWorkspace(typeof name === 'string' ? name : getWorkspaceSafe()); + recorder.recordWorkspace(typeof name === 'string' ? name : recorder.getWorkspaceSafe()); }); wrapAsyncMethod('loadOsmFromOverpassApi', (args) => { const params = (args[0] ?? {}) as { outputTableName?: string }; - recordLoadOsm(params.outputTableName ?? 'osm'); + recorder.recordLoadOsm(params.outputTableName ?? 'osm'); }); wrapAsyncMethod('loadCsv', (args, result) => { const params = (args[0] ?? {}) as { outputTableName?: string }; - recordLoadCsv(params.outputTableName ?? inferName(result, 'csv')); + recorder.recordLoadCsv(params.outputTableName ?? inferName(result, 'csv')); }); wrapAsyncMethod('loadJson', (args, result) => { const params = (args[0] ?? {}) as { outputTableName?: string }; - recordLoadJson(params.outputTableName ?? inferName(result, 'json')); + recorder.recordLoadJson(params.outputTableName ?? inferName(result, 'json')); }); wrapAsyncMethod('loadLayer', (args, result) => { const params = (args[0] ?? {}) as { layer?: string; outputTableName?: string }; - recordLoadLayer(params.outputTableName ?? params.layer ?? inferName(result, 'layer')); + recorder.recordLoadLayer(params.outputTableName ?? params.layer ?? inferName(result, 'layer')); }); wrapAsyncMethod('loadCustomLayer', (args, result) => { const params = (args[0] ?? {}) as { outputTableName?: string }; - recordLoadCustomLayer(params.outputTableName ?? inferName(result, 'custom-layer')); + recorder.recordLoadCustomLayer(params.outputTableName ?? inferName(result, 'custom-layer')); }); wrapAsyncMethod('loadGridLayer', (args, result) => { const params = (args[0] ?? {}) as { outputTableName?: string }; - recordLoadGridLayer(params.outputTableName ?? inferName(result, 'grid-layer')); + recorder.recordLoadGridLayer(params.outputTableName ?? inferName(result, 'grid-layer')); }); wrapAsyncMethod('getLayer', (args) => { const [tableName] = args; - recordGetLayer(typeof tableName === 'string' ? tableName : 'layer'); + recorder.recordGetLayer(typeof tableName === 'string' ? tableName : 'layer'); }); wrapAsyncMethod('spatialJoin', (args) => { const params = (args[0] ?? {}) as { tableRootName?: string; tableJoinName?: string }; - recordSpatialJoin(params); + recorder.recordSpatialJoin(params); }); wrapAsyncMethod('updateTable', (args, result) => { const params = (args[0] ?? {}) as { tableName?: string }; - recordUpdateTable(params.tableName ?? inferName(result, 'table')); + recorder.recordUpdateTable(params.tableName ?? inferName(result, 'table')); }); wrapAsyncMethod('rawQuery', (args) => { const params = (args[0] ?? {}) as { output?: { type?: string } }; const label = params.output?.type === 'CREATE_TABLE' ? 'Raw query created table' : 'Raw query executed'; - recordRawQuery(label); + recorder.recordRawQuery(label); }); wrapAsyncMethod('buildHeatmap', (args, result) => { const params = (args[0] ?? {}) as { outputTableName?: string }; - recordBuildHeatmap(params.outputTableName ?? inferName(result, 'heatmap')); + recorder.recordBuildHeatmap(params.outputTableName ?? inferName(result, 'heatmap')); }); } @@ -311,7 +156,7 @@ export function createDbAdapter( async function applyState(state: AutarkProvenanceState): Promise { const data = state.data; if (!data?.workspace) return; - const current = getWorkspaceSafe(); + const current = recorder.getWorkspaceSafe(); if (current !== data.workspace) { isApplyingState = true; try { @@ -325,34 +170,21 @@ export function createDbAdapter( return { startRecording, stopRecording, - bootstrapFromCurrentState, - recordInit, - recordWorkspace, - recordLoadOsm, - recordLoadCsv, - recordLoadJson, - recordLoadLayer, - recordLoadCustomLayer, - recordLoadGridLayer, - recordGetLayer, - recordSpatialJoin, - recordUpdateTable, - recordDropTable, - recordRawQuery, - recordBuildHeatmap, + bootstrapFromCurrentState: recorder.bootstrapFromCurrentState, + recordInit: recorder.recordInit, + recordWorkspace: recorder.recordWorkspace, + recordLoadOsm: recorder.recordLoadOsm, + recordLoadCsv: recorder.recordLoadCsv, + recordLoadJson: recorder.recordLoadJson, + recordLoadLayer: recorder.recordLoadLayer, + recordLoadCustomLayer: recorder.recordLoadCustomLayer, + recordLoadGridLayer: recorder.recordLoadGridLayer, + recordGetLayer: recorder.recordGetLayer, + recordSpatialJoin: recorder.recordSpatialJoin, + recordUpdateTable: recorder.recordUpdateTable, + recordDropTable: recorder.recordDropTable, + recordRawQuery: recorder.recordRawQuery, + recordBuildHeatmap: recorder.recordBuildHeatmap, applyState, }; } - -/** - * Starts automatic provenance tracking for a SpatialDb-like instance. - * The same db instance is returned for convenience. - */ -export function createDbProvenanceWrapper( - db: T, - onRecord: DbRecordCallback -): T { - const adapter = createDbAdapter(db, onRecord); - adapter.startRecording(); - return db; -} diff --git a/autk-provenance/src/adapters/db-provenance-wrapper.ts b/autk-provenance/src/adapters/db-provenance-wrapper.ts new file mode 100644 index 00000000..6d39fa15 --- /dev/null +++ b/autk-provenance/src/adapters/db-provenance-wrapper.ts @@ -0,0 +1,11 @@ +import type { DbRecordCallback } from './db-adapter-types'; +import { createDbAdapter, type IDbForProvenance } from './db-adapter'; + +export function createDbProvenanceWrapper( + db: T, + onRecord: DbRecordCallback +): T { + const adapter = createDbAdapter(db, onRecord); + adapter.startRecording(); + return db; +} diff --git a/autk-provenance/src/adapters/map-adapter-recording.ts b/autk-provenance/src/adapters/map-adapter-recording.ts new file mode 100644 index 00000000..aced542e --- /dev/null +++ b/autk-provenance/src/adapters/map-adapter-recording.ts @@ -0,0 +1,22 @@ +import type { AutarkProvenanceState } from '../types'; +import type { CustomControlConfig } from './map-adapter-shared'; + +export function bindCustomControlEvent( + customControls: CustomControlConfig[], + eventName: CustomControlConfig['event'], + target: Element, + onRecord: ( + actionType: string, + actionLabel: string, + stateDelta: Partial + ) => void +): boolean { + for (const ctrl of customControls) { + if (ctrl.event !== eventName) continue; + const matchEl = target.matches(ctrl.selector) ? target : target.closest(ctrl.selector); + if (!matchEl) continue; + onRecord(ctrl.actionType, ctrl.getLabel(matchEl), ctrl.getStateDelta(matchEl)); + return true; + } + return false; +} diff --git a/autk-provenance/src/adapters/map-adapter-shared.ts b/autk-provenance/src/adapters/map-adapter-shared.ts new file mode 100644 index 00000000..198af959 --- /dev/null +++ b/autk-provenance/src/adapters/map-adapter-shared.ts @@ -0,0 +1,32 @@ +import type { CustomControlConfig, MapSelectorConfig, ResolvedMapSelectors } from './map-adapter-types'; + +export type { + CustomControlConfig, + LayerLike, + MapAdapterApi, + MapRecordCallback, + MapSelectorConfig, + ResolvedMapSelectors, + ResolvedUiState, +} from './map-adapter-types'; + +export function isElement(value: unknown): value is Element { + return !!value && typeof value === 'object' && 'nodeType' in value; +} + +export function resolveMapSelectors(selectorConfig?: MapSelectorConfig): { + selectors: ResolvedMapSelectors; + customControls: CustomControlConfig[]; +} { + return { + selectors: { + menuIcon: selectorConfig?.menuIcon ?? '#menuIcon', + subMenu: selectorConfig?.subMenu ?? '#autkMapSubMenu', + thematicCheckbox: selectorConfig?.thematicCheckbox ?? '#showThematicCheckbox', + legend: selectorConfig?.legend ?? '#autkMapLegend', + activeLayerRadioClass: selectorConfig?.activeLayerRadioClass ?? '.active-layer-radio', + visibleLayerList: selectorConfig?.visibleLayerList ?? '#visibleLayerDropdownList', + }, + customControls: selectorConfig?.customControls ?? [], + }; +} diff --git a/autk-provenance/src/adapters/map-adapter-state.ts b/autk-provenance/src/adapters/map-adapter-state.ts new file mode 100644 index 00000000..d15d565a --- /dev/null +++ b/autk-provenance/src/adapters/map-adapter-state.ts @@ -0,0 +1,74 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../types'; +import type { ResolvedMapSelectors } from './map-adapter-shared'; +import type { createMapUiHelpers } from './map-ui-helpers'; + +type MapUiHelpers = ReturnType; + +export function applyMapProvenanceState( + map: IMapForProvenance, + ui: MapUiHelpers, + selectors: ResolvedMapSelectors, + state: AutarkProvenanceState +): void { + const { selection } = state; + const resolvedUi = ui.resolveUiState(state.ui); + + const visibleLayerIds = new Set(resolvedUi.visibleLayerIds); + for (const layer of ui.getAllLayers()) { + const layerId = layer.layerInfo?.id; + if (!layerId) continue; + ui.setLayerRenderFlag(layerId, 'isSkip', !visibleLayerIds.has(layerId)); + } + + const parent = map.canvas.parentElement; + const thematicCheckbox = parent?.querySelector(selectors.thematicCheckbox) as HTMLInputElement | null; + if (thematicCheckbox) thematicCheckbox.checked = resolvedUi.thematicEnabled; + + if (resolvedUi.activeLayerId) { + const activeLayer = map.layerManager.searchByLayerId(resolvedUi.activeLayerId); + if (activeLayer && map.ui?.changeActiveLayer) { + map.ui.changeActiveLayer(activeLayer); + } + } + + for (const layer of ui.getAllLayers()) { + const layerId = layer.layerInfo?.id; + if (!layerId) continue; + const shouldUseThematic = + resolvedUi.thematicEnabled && !!resolvedUi.activeLayerId && layerId === resolvedUi.activeLayerId; + ui.setLayerRenderFlag(layerId, 'isColorMap', shouldUseThematic); + } + + ui.syncUiDom(resolvedUi); + ui.syncCustomControlsDom(state); + + if (state.view && map.setViewState) { + map.setViewState(state.view); + } + + if (!selection) return; + + const targetLayerId = selection.map?.layerId; + const targetIds = selection.map?.ids ?? []; + const allPlotIds = Object.values(selection.plots ?? {}).flatMap((plotState) => plotState.ids); + const fallbackPlotIds = [...new Set(allPlotIds)]; + const fallbackLayerId = resolvedUi.activeLayerId; + + for (const layer of map.layerManager.vectorLayers ?? []) { + const layerId = layer.layerInfo?.id; + if (!layerId) continue; + const combinedIds = new Set(); + if (targetLayerId && layerId === targetLayerId) { + targetIds.forEach((id) => combinedIds.add(id)); + } + if (fallbackLayerId && layerId === fallbackLayerId) { + fallbackPlotIds.forEach((id) => combinedIds.add(id)); + } + + if (combinedIds.size > 0) { + layer.setHighlightedIds?.([...combinedIds]); + } else { + layer.clearHighlightedIds?.(); + } + } +} diff --git a/autk-provenance/src/adapters/map-adapter-types.ts b/autk-provenance/src/adapters/map-adapter-types.ts new file mode 100644 index 00000000..59a011d2 --- /dev/null +++ b/autk-provenance/src/adapters/map-adapter-types.ts @@ -0,0 +1,56 @@ +import type { AutarkProvenanceState } from '../types'; +import { ProvenanceAction } from '../types'; + +export type LayerLike = { + layerInfo?: { id: string }; + setHighlightedIds?(ids: number[]): void; + clearHighlightedIds?(): void; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; +}; + +export type ResolvedUiState = { + mapMenuOpen: boolean; + activeLayerId: string | null; + visibleLayerIds: string[]; + thematicEnabled: boolean; +}; + +export type MapRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; + +export interface CustomControlConfig { + selector: string; + event: 'click' | 'change'; + actionType: ProvenanceAction | string; + getLabel(el: Element): string; + getStateDelta(el: Element): Partial; + applyState?(el: Element, state: AutarkProvenanceState): void; +} + +export interface MapSelectorConfig { + menuIcon?: string; + subMenu?: string; + thematicCheckbox?: string; + legend?: string; + activeLayerRadioClass?: string; + visibleLayerList?: string; + customControls?: CustomControlConfig[]; +} + +export interface MapAdapterApi { + startRecording(): void; + stopRecording(): void; + applyState(state: AutarkProvenanceState): void; +} + +export interface ResolvedMapSelectors { + menuIcon: string; + subMenu: string; + thematicCheckbox: string; + legend: string; + activeLayerRadioClass: string; + visibleLayerList: string; +} diff --git a/autk-provenance/src/adapters/map-adapter.ts b/autk-provenance/src/adapters/map-adapter.ts index a8761e41..37519be8 100644 --- a/autk-provenance/src/adapters/map-adapter.ts +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -1,243 +1,45 @@ import type { AutarkProvenanceState, IMapForProvenance, MapViewState } from '../types'; import { ProvenanceAction } from '../types'; - +import { + resolveMapSelectors, + type CustomControlConfig, + type MapAdapterApi, + type MapRecordCallback, + type MapSelectorConfig, +} from './map-adapter-shared'; +import { createMapUiHelpers } from './map-ui-helpers'; +import { applyMapProvenanceState } from './map-adapter-state'; +import { bindCustomControlEvent } from './map-adapter-recording'; +import { isElement } from './map-adapter-shared'; const MAP_PICK_EVENT = 'pick'; - -type LayerLike = { - layerInfo?: { id: string }; - setHighlightedIds?(ids: number[]): void; - clearHighlightedIds?(): void; - layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; -}; - -type ResolvedUiState = { - mapMenuOpen: boolean; - activeLayerId: string | null; - visibleLayerIds: string[]; - thematicEnabled: boolean; -}; - -export type MapRecordCallback = ( - actionType: ProvenanceAction | string, - actionLabel: string, - stateDelta: Partial -) => void; - -/** - * Config for a single custom DOM control to track in provenance. - * Use this to track app-specific UI such as temporal dropdowns, - * dataset pickers, or custom layer toggles. - * - * Example — month dropdown in a shadows analysis app: - * ```ts - * { - * selector: '#monthSelect', - * event: 'change', - * actionType: 'SHADOW_MONTH_CHANGE', - * getLabel: (el) => `Month: ${(el as HTMLSelectElement).value}`, - * getStateDelta: (el) => ({ filters: { month: (el as HTMLSelectElement).value } }), - * applyState: (el, state) => { (el as HTMLSelectElement).value = state.filters?.month as string ?? ''; }, - * } - * ``` - */ -export interface CustomControlConfig { - /** CSS selector used to find the element inside the map container. */ - selector: string; - /** DOM event type that triggers recording. */ - event: 'click' | 'change'; - /** ProvenanceAction type (or any string) to store on the node. */ - actionType: ProvenanceAction | string; - /** Derive the human-readable action label from the element at event time. */ - getLabel(el: Element): string; - /** Derive the state delta from the element at event time. */ - getStateDelta(el: Element): Partial; - /** Restore this control's visual state during provenance replay. Called in applyState. */ - applyState?(el: Element, state: AutarkProvenanceState): void; -} - -/** - * Override the CSS selectors the map adapter uses to locate standard map UI. - * All fields are optional — unset fields fall back to the built-in defaults. - */ -export interface MapSelectorConfig { - /** Default: '#menuIcon' */ - menuIcon?: string; - /** Default: '#autkMapSubMenu' */ - subMenu?: string; - /** Default: '#showThematicCheckbox' */ - thematicCheckbox?: string; - /** Default: '#autkMapLegend' */ - legend?: string; - /** Default: '.active-layer-radio' */ - activeLayerRadioClass?: string; - /** Default: '#visibleLayerDropdownList' */ - visibleLayerList?: string; - /** Extra controls to track (e.g. temporal dropdowns, custom toggles). */ - customControls?: CustomControlConfig[]; -} - -export interface MapAdapterApi { - startRecording(): void; - stopRecording(): void; - applyState(state: AutarkProvenanceState): void; -} - -function isElement(value: unknown): value is Element { - return !!value && typeof value === 'object' && 'nodeType' in value; -} +export type { CustomControlConfig, MapAdapterApi, MapRecordCallback, MapSelectorConfig }; export function createMapAdapter( map: IMapForProvenance, onRecord: MapRecordCallback, selectorConfig?: MapSelectorConfig ): MapAdapterApi { - // Resolved selectors (config overrides fall back to defaults) - const SEL = { - menuIcon: selectorConfig?.menuIcon ?? '#menuIcon', - subMenu: selectorConfig?.subMenu ?? '#autkMapSubMenu', - thematicCheckbox: selectorConfig?.thematicCheckbox ?? '#showThematicCheckbox', - legend: selectorConfig?.legend ?? '#autkMapLegend', - activeLayerRadioClass: selectorConfig?.activeLayerRadioClass ?? '.active-layer-radio', - visibleLayerList: selectorConfig?.visibleLayerList ?? '#visibleLayerDropdownList', - }; - const customControls: CustomControlConfig[] = selectorConfig?.customControls ?? []; - + const { selectors, customControls } = resolveMapSelectors(selectorConfig); const mapObj = map as unknown as Record; + const ui = createMapUiHelpers(map, selectors, customControls); + let pickListener: ((selection: number[], layerId: string) => void) | null = null; let viewListener: ((state: MapViewState) => void) | null = null; let clickListener: ((event: Event) => void) | null = null; let changeListener: ((event: Event) => void) | null = null; let isApplyingState = false; - let currentState: AutarkProvenanceState = { - selection: { map: null, plots: {} }, - }; - + let currentState: AutarkProvenanceState = { selection: { map: null, plots: {} } }; const wrappedMapMethods = new Map(); - function getAllLayers(): LayerLike[] { - return [...(map.layerManager.vectorLayers ?? []), ...(map.layerManager.rasterLayers ?? [])] as LayerLike[]; - } - - function getVisibleLayerIds(): string[] { - return getAllLayers() - .filter((layer) => !layer.layerRenderInfo?.isSkip) - .map((layer) => layer.layerInfo?.id) - .filter((id): id is string => typeof id === 'string'); - } - - function getActiveLayerId(): string | null { - return map.ui?.activeLayer?.layerInfo?.id ?? null; - } - - function getThematicEnabled(): boolean { - return !!map.ui?.activeLayer?.layerRenderInfo?.isColorMap; - } - - function getMenuOpen(): boolean { - const parent = map.canvas.parentElement; - if (!parent) return false; - const submenu = parent.querySelector(SEL.subMenu) as HTMLElement | null; - return submenu ? submenu.style.visibility === 'visible' : false; - } - - function getAllLayerIds(): string[] { - return getAllLayers() - .map((layer) => layer.layerInfo?.id) - .filter((id): id is string => typeof id === 'string'); - } - - function getDefaultActiveLayerId(): string | null { - return getActiveLayerId() ?? map.layerManager.vectorLayers?.[0]?.layerInfo?.id ?? null; - } - - function resolveUiState(ui: AutarkProvenanceState['ui']): ResolvedUiState { - const allLayerIds = getAllLayerIds(); - return { - mapMenuOpen: ui?.mapMenuOpen ?? false, - activeLayerId: ui?.activeLayerId ?? getDefaultActiveLayerId(), - visibleLayerIds: Array.isArray(ui?.visibleLayerIds) ? ui.visibleLayerIds : allLayerIds, - thematicEnabled: ui?.thematicEnabled ?? false, - }; - } - - function setLayerRenderFlag(layerId: string, property: 'isSkip' | 'isColorMap', value: boolean): void { - if (map.updateRenderInfoProperty) { - map.updateRenderInfoProperty(layerId, property, value); - return; - } - const layer = map.layerManager.searchByLayerId(layerId); - if (layer?.layerRenderInfo) { - layer.layerRenderInfo[property] = value; - } - } - - function syncUiDom(ui: ResolvedUiState): void { - const parent = map.canvas.parentElement; - if (!parent) return; - - const submenu = parent.querySelector(SEL.subMenu) as HTMLElement | null; - if (submenu) submenu.style.visibility = ui.mapMenuOpen ? 'visible' : 'hidden'; - - const thematicCheckbox = parent.querySelector(SEL.thematicCheckbox) as HTMLInputElement | null; - if (thematicCheckbox) thematicCheckbox.checked = ui.thematicEnabled; - - const legend = parent.querySelector(SEL.legend) as HTMLElement | null; - if (legend) legend.style.visibility = ui.thematicEnabled ? 'visible' : 'hidden'; - - const visibleSet = new Set(ui.visibleLayerIds); - const visibleCheckboxes = parent.querySelectorAll( - `${SEL.visibleLayerList} input[type="checkbox"]` - ) as NodeListOf; - visibleCheckboxes.forEach((checkbox) => { - checkbox.checked = visibleSet.has(checkbox.value); - }); - - const activeRadios = parent.querySelectorAll(SEL.activeLayerRadioClass) as NodeListOf; - activeRadios.forEach((radio) => { - radio.checked = !!ui.activeLayerId && radio.value === ui.activeLayerId; - }); - } - - function syncCustomControlsDom(state: AutarkProvenanceState): void { - const parent = map.canvas.parentElement; - if (!parent || customControls.length === 0) return; - for (const ctrl of customControls) { - if (!ctrl.applyState) continue; - const el = parent.querySelector(ctrl.selector); - if (el) ctrl.applyState(el, state); - } - } - - function inMapContainer(target: EventTarget | null): boolean { - if (!isElement(target)) return false; - const parent = map.canvas.parentElement; - return !!parent && parent.contains(target); - } - - function buildUiDelta(overrides: Partial> = {}): Partial { - return { - ui: { - mapMenuOpen: getMenuOpen(), - activeLayerId: getActiveLayerId(), - visibleLayerIds: getVisibleLayerIds(), - thematicEnabled: getThematicEnabled(), - ...overrides, - }, - }; - } - function recordUiEvent( actionType: ProvenanceAction, actionLabel: string, overrides: Partial> = {} ): void { - onRecord(actionType, actionLabel, buildUiDelta(overrides)); + onRecord(actionType, actionLabel, ui.buildUiDelta(overrides)); } - function wrapMapMethod( - methodName: string, - onAfter: (args: unknown[]) => void - ): void { + function wrapMapMethod(methodName: string, onAfter: (args: unknown[]) => void): void { const current = mapObj[methodName]; if (typeof current !== 'function' || wrappedMapMethods.has(methodName)) return; const original = current; @@ -263,17 +65,17 @@ export function createMapAdapter( if (pickListener || clickListener || changeListener) return; wrapMapMethod('init', () => { - onRecord(ProvenanceAction.MAP_INIT, 'Map initialized', buildUiDelta()); + onRecord(ProvenanceAction.MAP_INIT, 'Map initialized', ui.buildUiDelta()); }); wrapMapMethod('loadGeoJsonLayer', (args) => { const [layerName] = args; const name = typeof layerName === 'string' ? layerName : 'layer'; - onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Map layer loaded: ${name}`, buildUiDelta()); + onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Map layer loaded: ${name}`, ui.buildUiDelta()); }); wrapMapMethod('loadGeoTiffLayer', (args) => { const [layerName] = args; const name = typeof layerName === 'string' ? layerName : 'raster'; - onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${name}`, buildUiDelta()); + onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${name}`, ui.buildUiDelta()); }); pickListener = (selection: number[], layerId: string) => { @@ -303,90 +105,56 @@ export function createMapAdapter( map.addViewListener(viewListener); } - if (typeof document !== 'undefined') { - clickListener = (event: Event) => { - if (isApplyingState) return; - const target = event.target; - if (!isElement(target)) return; - - // Custom click controls are checked first — no container restriction needed - // because their selectors are specific enough to avoid false matches. - for (const ctrl of customControls) { - if (ctrl.event !== 'click') continue; - const matchEl = target.matches(ctrl.selector) - ? target - : target.closest(ctrl.selector); - if (matchEl) { - onRecord(ctrl.actionType, ctrl.getLabel(matchEl), ctrl.getStateDelta(matchEl)); - return; - } - } - - // Standard map controls require the target to be inside the map container. - if (!inMapContainer(target)) return; + if (typeof document === 'undefined') return; - if (target.closest(SEL.menuIcon)) { - recordUiEvent( - ProvenanceAction.MAP_UI_MENU_TOGGLE, - getMenuOpen() ? 'Opened map menu' : 'Closed map menu', - { mapMenuOpen: getMenuOpen() } - ); - } - }; - document.addEventListener('click', clickListener); + clickListener = (event: Event) => { + if (isApplyingState) return; + const target = event.target; + if (!isElement(target)) return; + if (bindCustomControlEvent(customControls, 'click', target, onRecord)) return; + if (!ui.inMapContainer(target)) return; - changeListener = (event: Event) => { - if (isApplyingState) return; - const target = event.target; - if (!isElement(target)) return; - - // Custom change controls are checked first — no container restriction. - for (const ctrl of customControls) { - if (ctrl.event !== 'change') continue; - const matchEl = target.matches(ctrl.selector) - ? target - : target.closest(ctrl.selector); - if (matchEl) { - onRecord(ctrl.actionType, ctrl.getLabel(matchEl), ctrl.getStateDelta(matchEl)); - return; - } - } - - // Standard map controls require the target to be inside the map container. - if (!inMapContainer(target)) return; - if (!(target instanceof HTMLInputElement)) return; - - if (target.id === SEL.thematicCheckbox.replace(/^#/, '')) { - recordUiEvent( - ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, - target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', - { thematicEnabled: target.checked } - ); - return; - } + if (target.closest(selectors.menuIcon)) { + const isOpen = !!ui.buildUiDelta().ui?.mapMenuOpen; + recordUiEvent(ProvenanceAction.MAP_UI_MENU_TOGGLE, isOpen ? 'Opened map menu' : 'Closed map menu', { + mapMenuOpen: isOpen, + }); + } + }; + document.addEventListener('click', clickListener); + + changeListener = (event: Event) => { + if (isApplyingState) return; + const target = event.target; + if (!isElement(target)) return; + if (bindCustomControlEvent(customControls, 'change', target, onRecord)) return; + if (!ui.inMapContainer(target)) return; + if (!(target instanceof HTMLInputElement)) return; + + if (target.id === selectors.thematicCheckbox.replace(/^#/, '')) { + recordUiEvent(ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', { + thematicEnabled: target.checked, + }); + return; + } - if (target.classList.contains(SEL.activeLayerRadioClass.replace(/^\./, ''))) { - const activeLayerId = getActiveLayerId() ?? target.value; - recordUiEvent( - ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, - `Active layer: ${activeLayerId}`, - { activeLayerId } - ); - return; - } + if (target.classList.contains(selectors.activeLayerRadioClass.replace(/^\./, ''))) { + const activeLayerId = ui.getActiveLayerId() ?? target.value; + recordUiEvent(ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, `Active layer: ${activeLayerId}`, { + activeLayerId, + }); + return; + } - const listSel = SEL.visibleLayerList.replace(/^#/, ''); - if (target.type === 'checkbox' && target.closest(`#${listSel}`)) { - const layerId = target.value || 'layer'; - recordUiEvent( - ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, - target.checked ? `Show layer: ${layerId}` : `Hide layer: ${layerId}`, - { visibleLayerIds: getVisibleLayerIds() } - ); - } - }; - document.addEventListener('change', changeListener); - } + const listSel = selectors.visibleLayerList.replace(/^#/, ''); + if (target.type === 'checkbox' && target.closest(`#${listSel}`)) { + const layerId = target.value || 'layer'; + recordUiEvent(ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, target.checked ? `Show layer: ${layerId}` : `Hide layer: ${layerId}`, { + visibleLayerIds: ui.getVisibleLayerIds(), + }); + } + }; + document.addEventListener('change', changeListener); } function stopRecording(): void { @@ -413,66 +181,7 @@ export function createMapAdapter( isApplyingState = true; try { currentState = state; - const { selection } = state; - const ui = resolveUiState(state.ui); - - const visibleLayerIds = new Set(ui.visibleLayerIds); - for (const layer of getAllLayers()) { - const layerId = layer.layerInfo?.id; - if (!layerId) continue; - setLayerRenderFlag(layerId, 'isSkip', !visibleLayerIds.has(layerId)); - } - - const parent = map.canvas.parentElement; - const thematicCheckbox = parent?.querySelector(SEL.thematicCheckbox) as HTMLInputElement | null; - if (thematicCheckbox) thematicCheckbox.checked = ui.thematicEnabled; - - if (ui.activeLayerId) { - const activeLayer = map.layerManager.searchByLayerId(ui.activeLayerId); - if (activeLayer && map.ui?.changeActiveLayer) { - map.ui.changeActiveLayer(activeLayer); - } - } - - for (const layer of getAllLayers()) { - const layerId = layer.layerInfo?.id; - if (!layerId) continue; - const shouldUseThematic = ui.thematicEnabled && !!ui.activeLayerId && layerId === ui.activeLayerId; - setLayerRenderFlag(layerId, 'isColorMap', shouldUseThematic); - } - - syncUiDom(ui); - syncCustomControlsDom(state); - - if (state.view && map.setViewState) { - map.setViewState(state.view); - } - - if (selection) { - const targetLayerId = selection.map?.layerId; - const targetIds = selection.map?.ids ?? []; - const allPlotIds = Object.values(selection.plots ?? {}).flatMap((p) => p.ids); - const fallbackPlotIds = [...new Set(allPlotIds)]; - const fallbackLayerId = ui.activeLayerId; - - for (const layer of map.layerManager.vectorLayers ?? []) { - const layerId = layer.layerInfo?.id; - if (!layerId) continue; - const combinedIds = new Set(); - if (targetLayerId && layerId === targetLayerId) { - targetIds.forEach((id) => combinedIds.add(id)); - } - if (fallbackLayerId && layerId === fallbackLayerId) { - fallbackPlotIds.forEach((id) => combinedIds.add(id)); - } - - if (combinedIds.size > 0) { - layer.setHighlightedIds?.([...combinedIds]); - } else { - layer.clearHighlightedIds?.(); - } - } - } + applyMapProvenanceState(map, ui, selectors, state); } finally { isApplyingState = false; } diff --git a/autk-provenance/src/adapters/map-ui-helpers.ts b/autk-provenance/src/adapters/map-ui-helpers.ts new file mode 100644 index 00000000..32ea690c --- /dev/null +++ b/autk-provenance/src/adapters/map-ui-helpers.ts @@ -0,0 +1,108 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../types'; +import type { CustomControlConfig, LayerLike, ResolvedMapSelectors, ResolvedUiState } from './map-adapter-types'; +import { isElement } from './map-adapter-shared'; + +export function createMapUiHelpers( + map: IMapForProvenance, + selectors: ResolvedMapSelectors, + customControls: CustomControlConfig[] +) { + const getAllLayers = (): LayerLike[] => + [...(map.layerManager.vectorLayers ?? []), ...(map.layerManager.rasterLayers ?? [])] as LayerLike[]; + + const getVisibleLayerIds = (): string[] => + getAllLayers() + .filter((layer) => !layer.layerRenderInfo?.isSkip) + .map((layer) => layer.layerInfo?.id) + .filter((id): id is string => typeof id === 'string'); + + const getActiveLayerId = (): string | null => map.ui?.activeLayer?.layerInfo?.id ?? null; + const getThematicEnabled = (): boolean => !!map.ui?.activeLayer?.layerRenderInfo?.isColorMap; + + function getMenuOpen(): boolean { + const parent = map.canvas.parentElement; + if (!parent) return false; + const submenu = parent.querySelector(selectors.subMenu) as HTMLElement | null; + return submenu ? submenu.style.visibility === 'visible' : false; + } + + function resolveUiState(ui: AutarkProvenanceState['ui']): ResolvedUiState { + const allLayerIds = getAllLayers() + .map((layer) => layer.layerInfo?.id) + .filter((id): id is string => typeof id === 'string'); + return { + mapMenuOpen: ui?.mapMenuOpen ?? false, + activeLayerId: ui?.activeLayerId ?? getActiveLayerId() ?? map.layerManager.vectorLayers?.[0]?.layerInfo?.id ?? null, + visibleLayerIds: Array.isArray(ui?.visibleLayerIds) ? ui.visibleLayerIds : allLayerIds, + thematicEnabled: ui?.thematicEnabled ?? false, + }; + } + + function setLayerRenderFlag(layerId: string, property: 'isSkip' | 'isColorMap', value: boolean): void { + if (map.updateRenderInfoProperty) { + map.updateRenderInfoProperty(layerId, property, value); + return; + } + const layer = map.layerManager.searchByLayerId(layerId); + if (layer?.layerRenderInfo) layer.layerRenderInfo[property] = value; + } + + function syncUiDom(ui: ResolvedUiState): void { + const parent = map.canvas.parentElement; + if (!parent) return; + + const submenu = parent.querySelector(selectors.subMenu) as HTMLElement | null; + if (submenu) submenu.style.visibility = ui.mapMenuOpen ? 'visible' : 'hidden'; + const thematicCheckbox = parent.querySelector(selectors.thematicCheckbox) as HTMLInputElement | null; + if (thematicCheckbox) thematicCheckbox.checked = ui.thematicEnabled; + const legend = parent.querySelector(selectors.legend) as HTMLElement | null; + if (legend) legend.style.visibility = ui.thematicEnabled ? 'visible' : 'hidden'; + + const visibleSet = new Set(ui.visibleLayerIds); + parent.querySelectorAll(`${selectors.visibleLayerList} input[type="checkbox"]`).forEach((el) => { + (el as HTMLInputElement).checked = visibleSet.has((el as HTMLInputElement).value); + }); + parent.querySelectorAll(selectors.activeLayerRadioClass).forEach((el) => { + const radio = el as HTMLInputElement; + radio.checked = !!ui.activeLayerId && radio.value === ui.activeLayerId; + }); + } + + function syncCustomControlsDom(state: AutarkProvenanceState): void { + const parent = map.canvas.parentElement; + if (!parent || customControls.length === 0) return; + for (const ctrl of customControls) { + if (!ctrl.applyState) continue; + const el = parent.querySelector(ctrl.selector); + if (el) ctrl.applyState(el, state); + } + } + + const inMapContainer = (target: EventTarget | null): boolean => { + const parent = map.canvas.parentElement; + return isElement(target) && !!parent && parent.contains(target); + }; + + const buildUiDelta = (overrides: Partial> = {}): Partial => ({ + ui: { + mapMenuOpen: getMenuOpen(), + activeLayerId: getActiveLayerId(), + visibleLayerIds: getVisibleLayerIds(), + thematicEnabled: getThematicEnabled(), + ...overrides, + }, + }); + + return { + getAllLayers, + getVisibleLayerIds, + getActiveLayerId, + getThematicEnabled, + resolveUiState, + setLayerRenderFlag, + syncUiDom, + syncCustomControlsDom, + inMapContainer, + buildUiDelta, + }; +} diff --git a/autk-provenance/src/core-engine.ts b/autk-provenance/src/core-engine.ts new file mode 100644 index 00000000..dad52f7c --- /dev/null +++ b/autk-provenance/src/core-engine.ts @@ -0,0 +1,196 @@ +import type { PathNode, ProvenanceGraph, ProvenanceNode } from './types'; + +export interface GraphEngineOptions { + initialState: T; + rootActionType: string; + mergeState: (base: T, delta: Partial) => T; + generateId: () => string; +} + +export interface GraphEngineApi { + applyAction(actionType: string, actionLabel: string, stateDelta: Partial): void; + goToNode(nodeId: string): boolean; + goBackOneStep(): boolean; + goForwardOneStep(): boolean; + canGoBack(): boolean; + canGoForward(): boolean; + getPathFromRoot(): PathNode[]; + getGraph(): ProvenanceGraph; + getCurrentNode(): ProvenanceNode | null; + getCurrentState(): T | null; + exportGraph(): string; + importGraph(json: string): void; + addObserver(callback: (node: ProvenanceNode) => void): () => void; + annotateNode(nodeId: string, text: string): boolean; +} + +export function createGraphEngine(options: GraphEngineOptions): GraphEngineApi { + const { initialState, rootActionType, mergeState, generateId } = options; + const nodes = new Map>(); + let rootId = generateId(); + let currentId = rootId; + const observers: Array<(node: ProvenanceNode) => void> = []; + + const rootNode: ProvenanceNode = { + id: rootId, + parentId: null, + childrenIds: [], + state: initialState, + actionLabel: 'Start', + actionType: rootActionType, + timestamp: Date.now(), + }; + nodes.set(rootId, rootNode); + + function getCurrent(): ProvenanceNode | null { + return nodes.get(currentId) ?? null; + } + + function notify(): void { + const node = getCurrent(); + if (node) observers.forEach((cb) => cb(node)); + } + + function applyAction(actionType: string, actionLabel: string, stateDelta: Partial): void { + const current = getCurrent(); + if (!current) return; + const newState = mergeState(current.state, stateDelta); + const newId = generateId(); + const newNode: ProvenanceNode = { + id: newId, + parentId: currentId, + childrenIds: [], + state: newState, + actionLabel, + actionType, + timestamp: Date.now(), + }; + nodes.set(newId, newNode); + current.childrenIds.push(newId); + currentId = newId; + notify(); + } + + function goToNode(nodeId: string): boolean { + if (!nodes.has(nodeId)) return false; + currentId = nodeId; + notify(); + return true; + } + + function goBackOneStep(): boolean { + const current = getCurrent(); + if (!current?.parentId) return false; + currentId = current.parentId; + notify(); + return true; + } + + function goForwardOneStep(): boolean { + const current = getCurrent(); + if (!current || current.childrenIds.length === 0) return false; + currentId = current.childrenIds[current.childrenIds.length - 1]; + notify(); + return true; + } + + function canGoBack(): boolean { + return !!getCurrent()?.parentId; + } + + function canGoForward(): boolean { + const current = getCurrent(); + return !!current && current.childrenIds.length > 0; + } + + function getPathFromRoot(): PathNode[] { + const ordered: ProvenanceNode[] = []; + let id: string | null = currentId; + while (id) { + const node = nodes.get(id); + if (!node) break; + ordered.push(node); + id = node.parentId; + } + ordered.reverse(); + return ordered.map((node) => ({ + id: node.id, + actionLabel: node.actionLabel, + actionType: node.actionType, + timestamp: node.timestamp, + state: node.state, + })); + } + + function getGraph(): ProvenanceGraph { + return { + nodes: new Map(nodes), + rootId, + currentId, + }; + } + + function getCurrentState(): T | null { + return getCurrent()?.state ?? null; + } + + function exportGraph(): string { + const graph = getGraph(); + return JSON.stringify({ + rootId: graph.rootId, + currentId: graph.currentId, + nodes: Array.from(graph.nodes.entries()), + }); + } + + function importGraph(json: string): void { + try { + const parsed = JSON.parse(json) as { + rootId: string; + currentId: string; + nodes: [string, ProvenanceNode][]; + }; + nodes.clear(); + for (const [id, node] of parsed.nodes) { + nodes.set(id, node); + } + rootId = parsed.rootId; + currentId = parsed.currentId; + notify(); + } catch { + // no-op on invalid JSON + } + } + + function addObserver(callback: (node: ProvenanceNode) => void): () => void { + observers.push(callback); + return () => { + const index = observers.indexOf(callback); + if (index !== -1) observers.splice(index, 1); + }; + } + + function annotateNode(nodeId: string, text: string): boolean { + const node = nodes.get(nodeId); + if (!node) return false; + node.metadata = { ...(node.metadata ?? {}), insight: text }; + return true; + } + + return { + applyAction, + goToNode, + goBackOneStep, + goForwardOneStep, + canGoBack, + canGoForward, + getPathFromRoot, + getGraph, + getCurrentNode: getCurrent, + getCurrentState, + exportGraph, + importGraph, + addObserver, + annotateNode, + }; +} diff --git a/autk-provenance/src/core.ts b/autk-provenance/src/core.ts index 56f25ef4..71bd31ae 100644 --- a/autk-provenance/src/core.ts +++ b/autk-provenance/src/core.ts @@ -1,10 +1,6 @@ -import type { - AutarkProvenanceState, - PathNode, - ProvenanceGraph, - ProvenanceNode, -} from './types'; +import type { AutarkProvenanceState, PathNode, ProvenanceGraph, ProvenanceNode } from './types'; import { ProvenanceAction } from './types'; +import { createGraphEngine } from './core-engine'; function generateId(): string { return `n_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; @@ -17,15 +13,17 @@ function deepMergeState( const hasSelectionMap = delta.selection !== undefined && Object.prototype.hasOwnProperty.call(delta.selection, 'map'); + const next: AutarkProvenanceState = { selection: { map: hasSelectionMap ? (delta.selection?.map ?? null) : base.selection.map, - // Spread-merge so a delta for one plot doesn't wipe the others. - plots: delta.selection?.plots !== undefined - ? { ...base.selection.plots, ...delta.selection.plots } - : base.selection.plots, + plots: + delta.selection?.plots !== undefined + ? { ...base.selection.plots, ...delta.selection.plots } + : base.selection.plots, }, }; + if (base.ui || delta.ui) { next.ui = { ...(base.ui ?? {}), @@ -62,195 +60,18 @@ export interface ProvenanceCoreApi { exportGraph(): string; importGraph(json: string): void; addObserver(callback: (node: ProvenanceNode) => void): () => void; - /** - * Attaches an insight annotation to any node's metadata without creating a - * new provenance step. Implements Ragan §4.1.4 (insight provenance): insights - * cannot be captured automatically — the analyst must record them explicitly. - */ annotateNode(nodeId: string, text: string): boolean; } export function createProvenanceCore( options: ProvenanceCoreOptions ): ProvenanceCoreApi { - const { initialState } = options; - const nodes = new Map>(); - let rootId = generateId(); - let currentId = rootId; - const observers: Array<(node: ProvenanceNode) => void> = []; - - const rootNode: ProvenanceNode = { - id: rootId, - parentId: null, - childrenIds: [], - state: initialState, - actionLabel: 'Start', - actionType: ProvenanceAction.ROOT, - timestamp: Date.now(), - }; - nodes.set(rootId, rootNode); - - function getCurrent(): ProvenanceNode | null { - return nodes.get(currentId) ?? null; - } - - function notify(): void { - const node = getCurrent(); - if (node) observers.forEach((cb) => cb(node)); - } - - function applyAction( - actionType: ProvenanceAction | string, - actionLabel: string, - stateDelta: Partial - ): void { - const current = getCurrent(); - if (!current) return; - const newState = deepMergeState(current.state, stateDelta); - const newId = generateId(); - const newNode: ProvenanceNode = { - id: newId, - parentId: currentId, - childrenIds: [], - state: newState, - actionLabel, - actionType, - timestamp: Date.now(), - }; - nodes.set(newId, newNode); - current.childrenIds.push(newId); - currentId = newId; - notify(); - } - - function goToNode(nodeId: string): boolean { - if (!nodes.has(nodeId)) return false; - currentId = nodeId; - notify(); - return true; - } - - function goBackOneStep(): boolean { - const current = getCurrent(); - if (!current || !current.parentId) return false; - currentId = current.parentId; - notify(); - return true; - } - - function goForwardOneStep(): boolean { - const current = getCurrent(); - if (!current || current.childrenIds.length === 0) return false; - currentId = current.childrenIds[current.childrenIds.length - 1]; - notify(); - return true; - } - - function canGoBack(): boolean { - const current = getCurrent(); - return !!current?.parentId; - } - - function canGoForward(): boolean { - const current = getCurrent(); - return !!current && current.childrenIds.length > 0; - } - - function getPathFromRoot(): PathNode[] { - const path: PathNode[] = []; - let id: string | null = currentId; - const ordered: ProvenanceNode[] = []; - while (id) { - const node = nodes.get(id); - if (!node) break; - ordered.push(node); - id = node.parentId; - } - ordered.reverse(); - for (const node of ordered) { - path.push({ - id: node.id, - actionLabel: node.actionLabel, - actionType: node.actionType, - timestamp: node.timestamp, - state: node.state, - }); - } - return path; - } - - function getGraph(): ProvenanceGraph { - return { - nodes: new Map(nodes), - rootId, - currentId, - }; - } - - function getCurrentState(): AutarkProvenanceState | null { - return getCurrent()?.state ?? null; - } - - function exportGraph(): string { - const graph = getGraph(); - const serializable = { - rootId: graph.rootId, - currentId: graph.currentId, - nodes: Array.from(graph.nodes.entries()), - }; - return JSON.stringify(serializable); - } - - function importGraph(json: string): void { - try { - const parsed = JSON.parse(json) as { - rootId: string; - currentId: string; - nodes: [string, ProvenanceNode][]; - }; - nodes.clear(); - for (const [id, node] of parsed.nodes) { - nodes.set(id, node); - } - rootId = parsed.rootId; - currentId = parsed.currentId; - notify(); - } catch { - // no-op on invalid JSON - } - } - - function addObserver(callback: (node: ProvenanceNode) => void): () => void { - observers.push(callback); - return () => { - const i = observers.indexOf(callback); - if (i !== -1) observers.splice(i, 1); - }; - } - - function annotateNode(nodeId: string, text: string): boolean { - const node = nodes.get(nodeId); - if (!node) return false; - node.metadata = { ...(node.metadata ?? {}), insight: text }; - return true; - } - - return { - applyAction, - goToNode, - goBackOneStep, - goForwardOneStep, - canGoBack, - canGoForward, - getPathFromRoot, - getGraph, - getCurrentNode: getCurrent, - getCurrentState, - exportGraph, - importGraph, - addObserver, - annotateNode, - }; + return createGraphEngine({ + initialState: options.initialState, + rootActionType: ProvenanceAction.ROOT, + mergeState: deepMergeState, + generateId, + }); } export interface ProvenanceCoreGenericOptions { @@ -261,177 +82,10 @@ export interface ProvenanceCoreGenericOptions { export function createProvenanceCoreGeneric>( options: ProvenanceCoreGenericOptions ): ProvenanceCoreApi { - const { initialState, mergeState } = options; - const nodes = new Map>(); - let rootId = generateId(); - let currentId = rootId; - const observers: Array<(node: ProvenanceNode) => void> = []; - - const rootNode: ProvenanceNode = { - id: rootId, - parentId: null, - childrenIds: [], - state: initialState, - actionLabel: 'Start', - actionType: 'root', - timestamp: Date.now(), - }; - nodes.set(rootId, rootNode); - - function getCurrent(): ProvenanceNode | null { - return nodes.get(currentId) ?? null; - } - - function notify(): void { - const node = getCurrent(); - if (node) observers.forEach((cb) => cb(node)); - } - - function applyAction( - actionType: ProvenanceAction | string, - actionLabel: string, - stateDelta: Partial - ): void { - const current = getCurrent(); - if (!current) return; - const newState = mergeState(current.state, stateDelta); - const newId = generateId(); - const newNode: ProvenanceNode = { - id: newId, - parentId: currentId, - childrenIds: [], - state: newState, - actionLabel, - actionType, - timestamp: Date.now(), - }; - nodes.set(newId, newNode); - current.childrenIds.push(newId); - currentId = newId; - notify(); - } - - function goToNode(nodeId: string): boolean { - if (!nodes.has(nodeId)) return false; - currentId = nodeId; - notify(); - return true; - } - - function goBackOneStep(): boolean { - const current = getCurrent(); - if (!current || !current.parentId) return false; - currentId = current.parentId; - notify(); - return true; - } - - function goForwardOneStep(): boolean { - const current = getCurrent(); - if (!current || current.childrenIds.length === 0) return false; - currentId = current.childrenIds[current.childrenIds.length - 1]; - notify(); - return true; - } - - function canGoBack(): boolean { - const current = getCurrent(); - return !!current?.parentId; - } - - function canGoForward(): boolean { - const current = getCurrent(); - return !!current && current.childrenIds.length > 0; - } - - function getPathFromRoot(): PathNode[] { - const ordered: ProvenanceNode[] = []; - let id: string | null = currentId; - while (id) { - const node = nodes.get(id); - if (!node) break; - ordered.push(node); - id = node.parentId; - } - ordered.reverse(); - return ordered.map((node) => ({ - id: node.id, - actionLabel: node.actionLabel, - actionType: node.actionType, - timestamp: node.timestamp, - state: node.state, - })); - } - - function getGraph(): ProvenanceGraph { - return { - nodes: new Map(nodes), - rootId, - currentId, - }; - } - - function getCurrentState(): T | null { - return getCurrent()?.state ?? null; - } - - function exportGraph(): string { - const graph = getGraph(); - return JSON.stringify({ - rootId: graph.rootId, - currentId: graph.currentId, - nodes: Array.from(graph.nodes.entries()), - }); - } - - function importGraph(json: string): void { - try { - const parsed = JSON.parse(json) as { - rootId: string; - currentId: string; - nodes: [string, ProvenanceNode][]; - }; - nodes.clear(); - for (const [id, node] of parsed.nodes) { - nodes.set(id, node); - } - rootId = parsed.rootId; - currentId = parsed.currentId; - notify(); - } catch { - // no-op - } - } - - function addObserver(callback: (node: ProvenanceNode) => void): () => void { - observers.push(callback); - return () => { - const i = observers.indexOf(callback); - if (i !== -1) observers.splice(i, 1); - }; - } - - function annotateNode(nodeId: string, text: string): boolean { - const node = nodes.get(nodeId); - if (!node) return false; - node.metadata = { ...(node.metadata ?? {}), insight: text }; - return true; - } - - return { - applyAction, - goToNode, - goBackOneStep, - goForwardOneStep, - canGoBack, - canGoForward, - getPathFromRoot, - getGraph, - getCurrentNode: getCurrent, - getCurrentState, - exportGraph, - importGraph, - addObserver, - annotateNode, - }; + return createGraphEngine({ + initialState: options.initialState, + rootActionType: 'root', + mergeState: options.mergeState, + generateId, + }); } diff --git a/autk-provenance/src/insight-engine.ts b/autk-provenance/src/insight-engine.ts index 473f7209..f951189f 100644 --- a/autk-provenance/src/insight-engine.ts +++ b/autk-provenance/src/insight-engine.ts @@ -1,272 +1,10 @@ -/** - * Insight Engine — "not just logging" - * - * Derives new information from the provenance graph that does not exist in any - * single node. Implements three capabilities grounded in the Week 12 readings: - * - * 1. Selection frequency (ProWis: aggregate statistics across ensemble members) - * 2. Graph metrics + strategy classification (Ragan: rationale & meta-analysis) - * 3. Session narrative generator (Ragan: presentation purpose; Elicitation paper: - * users form "stories beyond the data" — the system should surface that story) - */ - -import type { AutarkProvenanceState, ProvenanceGraph } from './types'; - -// --------------------------------------------------------------------------- -// Feature 2: Aggregate Selection Frequency -// --------------------------------------------------------------------------- - -export interface SelectionFrequency { - /** Map feature IDs → count of provenance states where that feature was selected */ - map: Map; - /** Per-plot feature frequency: plotId → (featureId → count) */ - plots: Map>; -} - -/** - * Iterates every node in the graph (not just the current path) and tallies - * how many states each feature appears in. Analogous to ProWis computing - * aggregate statistics across all ensemble members. - */ -export function computeSelectionFrequency( - graph: ProvenanceGraph -): SelectionFrequency { - const mapFreq = new Map(); - const plotsFreq = new Map>(); - - for (const node of graph.nodes.values()) { - const sel = node.state.selection; - if (sel?.map?.ids) { - for (const id of sel.map.ids) { - mapFreq.set(id, (mapFreq.get(id) ?? 0) + 1); - } - } - for (const [plotId, plotSel] of Object.entries(sel?.plots ?? {})) { - if (!plotSel?.ids?.length) continue; - if (!plotsFreq.has(plotId)) plotsFreq.set(plotId, new Map()); - const freq = plotsFreq.get(plotId)!; - for (const id of plotSel.ids) { - freq.set(id, (freq.get(id) ?? 0) + 1); - } - } - } - - return { map: mapFreq, plots: plotsFreq }; -} - -// --------------------------------------------------------------------------- -// Feature 3: Graph Metrics + Rationale Inference -// --------------------------------------------------------------------------- - -export type StrategyLabel = 'Confirmatory' | 'Exploratory' | 'Iterative Refinement'; - -export interface GraphMetrics { - totalNodes: number; - /** Nodes with more than one child — each is a decision/revision point */ - branchPoints: number; - /** Total extra children beyond first across all branch points */ - backtracks: number; - maxDepth: number; - sessionDurationMs: number; - avgTimePerStateMs: number; - branchRatio: number; - /** Inferred from graph topology (Ragan §4.1.5: rationale can be inferred from logs) */ - strategyLabel: StrategyLabel; - /** Nodes that carry an insight annotation */ - insightCount: number; -} - -function computeMaxDepth(graph: ProvenanceGraph): number { - let max = 0; - const visited = new Set(); - function dfs(id: string, depth: number): void { - if (visited.has(id)) return; - visited.add(id); - max = Math.max(max, depth); - const node = graph.nodes.get(id); - if (!node) return; - for (const child of node.childrenIds) dfs(child, depth + 1); - } - dfs(graph.rootId, 0); - return max; -} - -/** - * Derives structural metrics from the provenance DAG. - * Ragan §4.1.5: "a significant amount of reasoning information can be inferred - * by analyzing system logs of events and interactions." - */ -export function computeGraphMetrics( - graph: ProvenanceGraph -): GraphMetrics { - const nodes = Array.from(graph.nodes.values()); - const total = nodes.length; - - if (total <= 1) { - return { - totalNodes: total, - branchPoints: 0, - backtracks: 0, - maxDepth: 0, - sessionDurationMs: 0, - avgTimePerStateMs: 0, - branchRatio: 0, - strategyLabel: 'Confirmatory', - insightCount: 0, - }; - } - - const branchPoints = nodes.filter((n) => n.childrenIds.length > 1).length; - const backtracks = nodes.reduce( - (acc, n) => acc + Math.max(0, n.childrenIds.length - 1), - 0 - ); - const branchRatio = branchPoints / total; - const maxDepth = computeMaxDepth(graph); - - const timestamps = nodes.map((n) => n.timestamp).sort((a, b) => a - b); - const sessionDurationMs = timestamps[timestamps.length - 1] - timestamps[0]; - const avgTimePerStateMs = total > 1 ? Math.round(sessionDurationMs / (total - 1)) : 0; - - const insightCount = nodes.filter( - (n) => typeof n.metadata?.insight === 'string' && (n.metadata.insight as string).trim().length > 0 - ).length; - - // Strategy classification based on topology (Ragan §4.1.5) - let strategyLabel: StrategyLabel; - if (backtracks >= 3 && branchRatio >= 0.15) { - strategyLabel = 'Iterative Refinement'; - } else if (branchRatio >= 0.15 || backtracks >= 2) { - strategyLabel = 'Exploratory'; - } else { - strategyLabel = 'Confirmatory'; - } - - return { - totalNodes: total, - branchPoints, - backtracks, - maxDepth, - sessionDurationMs, - avgTimePerStateMs, - branchRatio, - strategyLabel, - insightCount, - }; -} - -// --------------------------------------------------------------------------- -// Feature 1: Insight Annotations -// --------------------------------------------------------------------------- - -export interface InsightAnnotation { - nodeId: string; - actionLabel: string; - text: string; - timestamp: number; -} - -/** - * Collects all insight annotations stored in node metadata, ordered by time. - * Annotations are written by the user via the UI (Ragan §4.1.4 — insight - * provenance cannot be captured automatically; the analyst must record it). - */ -export function getInsightAnnotations( - graph: ProvenanceGraph -): InsightAnnotation[] { - const out: InsightAnnotation[] = []; - for (const node of graph.nodes.values()) { - const text = node.metadata?.insight; - if (typeof text === 'string' && text.trim().length > 0) { - out.push({ - nodeId: node.id, - actionLabel: node.actionLabel, - text: text.trim(), - timestamp: node.timestamp, - }); - } - } - return out.sort((a, b) => a.timestamp - b.timestamp); -} - -// --------------------------------------------------------------------------- -// Feature 4: Auto-Generated Session Narrative -// --------------------------------------------------------------------------- - -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const s = Math.round(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rs = s % 60; - return `${m}m ${rs}s`; -} - -/** - * Generates a human-readable summary of the analysis session. - * - * Ragan §4.2.5 (Presentation): among the most under-built purposes in - * provenance research. The narrative makes the analysis history shareable - * without requiring the recipient to replay the graph. - * - * Elicitation paper: participants naturally form "a story I would try to - * retell" from their visualization experience. This function makes that - * story explicit from the provenance record. - */ -export function generateSessionNarrative( - graph: ProvenanceGraph, - metrics: GraphMetrics, - annotations: InsightAnnotation[] -): string { - const lines: string[] = []; - const root = graph.nodes.get(graph.rootId); - const startTime = root ? new Date(root.timestamp).toLocaleTimeString() : '—'; - - lines.push(`Session started at ${startTime}.`); - lines.push( - `Duration: ${formatDuration(metrics.sessionDurationMs)} across ${metrics.totalNodes} states ` + - `(avg ${formatDuration(metrics.avgTimePerStateMs)} per state).` - ); - - const strategyDesc: Record = { - Confirmatory: 'A focused, linear exploration — the analyst appeared to know what they were looking for.', - Exploratory: 'A broad, open-ended investigation with multiple diverging paths.', - 'Iterative Refinement': 'A hypothesis-driven approach with repeated backtracking and revision.', - }; - lines.push(`\nAnalysis strategy: ${metrics.strategyLabel}`); - lines.push(strategyDesc[metrics.strategyLabel]); - - if (metrics.branchPoints > 0) { - lines.push( - `The analysis diverged at ${metrics.branchPoints} branch point${metrics.branchPoints > 1 ? 's' : ''}, ` + - `with ${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''} before settling on the current path.` - ); - } - - // Selection focus (ProWis-inspired: aggregate across all paths) - const freq = computeSelectionFrequency(graph); - const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - - if (topMap.length > 0) { - const items = topMap.map(([id, c]) => `feature #${id} (${c} state${c !== 1 ? 's' : ''})`).join(', '); - lines.push(`\nMost revisited map features across all branches: ${items}.`); - } - for (const [plotId, plotFreq] of freq.plots.entries()) { - const top = [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - if (top.length > 0 && top[0][1] > 1) { - const items = top.map(([id, c]) => `#${id} (${c}×)`).join(', '); - lines.push(`Most revisited features in plot "${plotId}": ${items}.`); - } - } - - if (annotations.length > 0) { - lines.push(`\nRecorded insights (${annotations.length}):`); - for (const a of annotations) { - lines.push(` • [${a.actionLabel}] ${a.text}`); - } - } else { - lines.push('\nNo insight annotations were recorded during this session.'); - } - - return lines.join('\n'); -} +export { getInsightAnnotations } from './insights/annotations'; +export { computeGraphMetrics } from './insights/graph-metrics'; +export { generateSessionNarrative } from './insights/narrative'; +export { computeSelectionFrequency } from './insights/selection-frequency'; +export type { + GraphMetrics, + InsightAnnotation, + SelectionFrequency, + StrategyLabel, +} from './insights/types'; diff --git a/autk-provenance/src/insights/annotations.ts b/autk-provenance/src/insights/annotations.ts new file mode 100644 index 00000000..fbbae0ab --- /dev/null +++ b/autk-provenance/src/insights/annotations.ts @@ -0,0 +1,20 @@ +import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import type { InsightAnnotation } from './types'; + +export function getInsightAnnotations( + graph: ProvenanceGraph +): InsightAnnotation[] { + const out: InsightAnnotation[] = []; + for (const node of graph.nodes.values()) { + const text = node.metadata?.insight; + if (typeof text === 'string' && text.trim().length > 0) { + out.push({ + nodeId: node.id, + actionLabel: node.actionLabel, + text: text.trim(), + timestamp: node.timestamp, + }); + } + } + return out.sort((a, b) => a.timestamp - b.timestamp); +} diff --git a/autk-provenance/src/insights/graph-metrics.ts b/autk-provenance/src/insights/graph-metrics.ts new file mode 100644 index 00000000..20ce6c73 --- /dev/null +++ b/autk-provenance/src/insights/graph-metrics.ts @@ -0,0 +1,63 @@ +import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import type { GraphMetrics, StrategyLabel } from './types'; + +function computeMaxDepth(graph: ProvenanceGraph): number { + let max = 0; + const visited = new Set(); + const dfs = (id: string, depth: number): void => { + if (visited.has(id)) return; + visited.add(id); + max = Math.max(max, depth); + const node = graph.nodes.get(id); + if (!node) return; + for (const child of node.childrenIds) dfs(child, depth + 1); + }; + dfs(graph.rootId, 0); + return max; +} + +export function computeGraphMetrics( + graph: ProvenanceGraph +): GraphMetrics { + const nodes = Array.from(graph.nodes.values()); + const total = nodes.length; + if (total <= 1) { + return { + totalNodes: total, + branchPoints: 0, + backtracks: 0, + maxDepth: 0, + sessionDurationMs: 0, + avgTimePerStateMs: 0, + branchRatio: 0, + strategyLabel: 'Confirmatory', + insightCount: 0, + }; + } + + const branchPoints = nodes.filter((n) => n.childrenIds.length > 1).length; + const backtracks = nodes.reduce((acc, n) => acc + Math.max(0, n.childrenIds.length - 1), 0); + const branchRatio = branchPoints / total; + const timestamps = nodes.map((n) => n.timestamp).sort((a, b) => a - b); + const sessionDurationMs = timestamps[timestamps.length - 1] - timestamps[0]; + const strategyLabel: StrategyLabel = + backtracks >= 3 && branchRatio >= 0.15 + ? 'Iterative Refinement' + : branchRatio >= 0.15 || backtracks >= 2 + ? 'Exploratory' + : 'Confirmatory'; + + return { + totalNodes: total, + branchPoints, + backtracks, + maxDepth: computeMaxDepth(graph), + sessionDurationMs, + avgTimePerStateMs: total > 1 ? Math.round(sessionDurationMs / (total - 1)) : 0, + branchRatio, + strategyLabel, + insightCount: nodes.filter( + (n) => typeof n.metadata?.insight === 'string' && (n.metadata.insight as string).trim().length > 0 + ).length, + }; +} diff --git a/autk-provenance/src/insights/narrative.ts b/autk-provenance/src/insights/narrative.ts new file mode 100644 index 00000000..a176bd3b --- /dev/null +++ b/autk-provenance/src/insights/narrative.ts @@ -0,0 +1,59 @@ +import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import { computeSelectionFrequency } from './selection-frequency'; +import type { GraphMetrics, InsightAnnotation, StrategyLabel } from './types'; +import { formatDuration } from './utils'; + +export function generateSessionNarrative( + graph: ProvenanceGraph, + metrics: GraphMetrics, + annotations: InsightAnnotation[] +): string { + const lines: string[] = []; + const root = graph.nodes.get(graph.rootId); + const startTime = root ? new Date(root.timestamp).toLocaleTimeString() : '—'; + const strategyDesc: Record = { + Confirmatory: 'A focused, linear exploration — the analyst appeared to know what they were looking for.', + Exploratory: 'A broad, open-ended investigation with multiple diverging paths.', + 'Iterative Refinement': 'A hypothesis-driven approach with repeated backtracking and revision.', + }; + + lines.push(`Session started at ${startTime}.`); + lines.push( + `Duration: ${formatDuration(metrics.sessionDurationMs)} across ${metrics.totalNodes} states ` + + `(avg ${formatDuration(metrics.avgTimePerStateMs)} per state).` + ); + lines.push(`\nAnalysis strategy: ${metrics.strategyLabel}`); + lines.push(strategyDesc[metrics.strategyLabel]); + + if (metrics.branchPoints > 0) { + lines.push( + `The analysis diverged at ${metrics.branchPoints} branch point${metrics.branchPoints > 1 ? 's' : ''}, ` + + `with ${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''} before settling on the current path.` + ); + } + + const freq = computeSelectionFrequency(graph); + const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); + if (topMap.length > 0) { + lines.push( + `\nMost revisited map features across all branches: ` + + topMap.map(([id, c]) => `feature #${id} (${c} state${c !== 1 ? 's' : ''})`).join(', ') + + '.' + ); + } + for (const [plotId, plotFreq] of freq.plots.entries()) { + const top = [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); + if (top.length > 0 && top[0][1] > 1) { + lines.push(`Most revisited features in plot "${plotId}": ${top.map(([id, c]) => `#${id} (${c}×)`).join(', ')}.`); + } + } + + if (annotations.length > 0) { + lines.push(`\nRecorded insights (${annotations.length}):`); + for (const a of annotations) lines.push(` • [${a.actionLabel}] ${a.text}`); + } else { + lines.push('\nNo insight annotations were recorded during this session.'); + } + + return lines.join('\n'); +} diff --git a/autk-provenance/src/insights/selection-frequency.ts b/autk-provenance/src/insights/selection-frequency.ts new file mode 100644 index 00000000..1b4b927d --- /dev/null +++ b/autk-provenance/src/insights/selection-frequency.ts @@ -0,0 +1,28 @@ +import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import type { SelectionFrequency } from './types'; + +export function computeSelectionFrequency( + graph: ProvenanceGraph +): SelectionFrequency { + const mapFreq = new Map(); + const plotsFreq = new Map>(); + + for (const node of graph.nodes.values()) { + const sel = node.state.selection; + if (sel?.map?.ids) { + for (const id of sel.map.ids) { + mapFreq.set(id, (mapFreq.get(id) ?? 0) + 1); + } + } + for (const [plotId, plotSel] of Object.entries(sel?.plots ?? {})) { + if (!plotSel?.ids?.length) continue; + if (!plotsFreq.has(plotId)) plotsFreq.set(plotId, new Map()); + const freq = plotsFreq.get(plotId)!; + for (const id of plotSel.ids) { + freq.set(id, (freq.get(id) ?? 0) + 1); + } + } + } + + return { map: mapFreq, plots: plotsFreq }; +} diff --git a/autk-provenance/src/insights/types.ts b/autk-provenance/src/insights/types.ts new file mode 100644 index 00000000..aab65c45 --- /dev/null +++ b/autk-provenance/src/insights/types.ts @@ -0,0 +1,25 @@ +export interface SelectionFrequency { + map: Map; + plots: Map>; +} + +export type StrategyLabel = 'Confirmatory' | 'Exploratory' | 'Iterative Refinement'; + +export interface GraphMetrics { + totalNodes: number; + branchPoints: number; + backtracks: number; + maxDepth: number; + sessionDurationMs: number; + avgTimePerStateMs: number; + branchRatio: number; + strategyLabel: StrategyLabel; + insightCount: number; +} + +export interface InsightAnnotation { + nodeId: string; + actionLabel: string; + text: string; + timestamp: number; +} diff --git a/autk-provenance/src/insights/utils.ts b/autk-provenance/src/insights/utils.ts new file mode 100644 index 00000000..eb4efdf2 --- /dev/null +++ b/autk-provenance/src/insights/utils.ts @@ -0,0 +1,7 @@ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + return `${m}m ${s % 60}s`; +} diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts index c0dac98c..7e356c2c 100644 --- a/autk-provenance/src/provenance-trail-ui.ts +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -1,11 +1,15 @@ -import type { AutarkProvenanceState, PathNode, ProvenanceNode } from './types'; import type { AutarkProvenanceApi } from './create-autark-provenance'; +import { createGraphModalController } from './ui/graph-modal'; +import { renderGraphPreview } from './ui/graph-preview'; +import { renderInsightsPanel } from './ui/insights-panel'; +import { ensureProvenanceTrailStyles } from './ui/styles'; import { - computeGraphMetrics, - generateSessionNarrative, - getInsightAnnotations, - type StrategyLabel, -} from './insight-engine'; + createGraphSection, + createInsightsSection, + createNavButtons, + createPathSection, +} from './ui/trail-shell'; +import { renderPathList } from './ui/trail-path'; export interface ProvenanceTrailUIOptions { provenance: AutarkProvenanceApi; @@ -17,379 +21,6 @@ export interface ProvenanceTrailUIOptions { showPathList?: boolean; } -type LayoutNode = { - node: ProvenanceNode; - x: number; - y: number; - depth: number; - row: number; -}; - -type LayoutEdge = { - from: string; - to: string; -}; - -type GraphLayout = { - nodes: LayoutNode[]; - edges: LayoutEdge[]; - width: number; - height: number; -}; - -function formatTime(ts: number): string { - const d = new Date(ts); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); -} - -function truncate(text: string, max = 30): string { - if (text.length <= max) return text; - return `${text.slice(0, max - 1)}\u2026`; -} - -function buildGraphLayout( - nodesMap: Map>, - rootId: string -): GraphLayout { - const depth = new Map(); - const row = new Map(); - const visited = new Set(); - const edges: LayoutEdge[] = []; - - function assignDepth(nodeId: string, d: number): void { - if (visited.has(nodeId)) { - const prev = depth.get(nodeId); - if (prev === undefined || d < prev) depth.set(nodeId, d); - return; - } - visited.add(nodeId); - depth.set(nodeId, d); - const node = nodesMap.get(nodeId); - if (!node) return; - for (const childId of node.childrenIds) { - if (!nodesMap.has(childId)) continue; - edges.push({ from: nodeId, to: childId }); - assignDepth(childId, d + 1); - } - } - - assignDepth(rootId, 0); - - let nextLeafRow = 0; - function assignRow(nodeId: string): number { - if (row.has(nodeId)) return row.get(nodeId) ?? 0; - const node = nodesMap.get(nodeId); - if (!node) { - row.set(nodeId, nextLeafRow); - nextLeafRow += 1; - return row.get(nodeId) ?? 0; - } - - const validChildren = node.childrenIds.filter((id) => nodesMap.has(id)); - if (validChildren.length === 0) { - row.set(nodeId, nextLeafRow); - nextLeafRow += 1; - return row.get(nodeId) ?? 0; - } - - const childRows = validChildren.map(assignRow); - const avg = childRows.reduce((acc, n) => acc + n, 0) / childRows.length; - row.set(nodeId, avg); - return avg; - } - - assignRow(rootId); - - for (const nodeId of nodesMap.keys()) { - if (!depth.has(nodeId)) depth.set(nodeId, 0); - if (!row.has(nodeId)) { - row.set(nodeId, nextLeafRow); - nextLeafRow += 1; - } - } - - const xGap = 160; - const yGap = 64; - const marginX = 28; - const marginY = 24; - - const layoutNodes: LayoutNode[] = []; - let maxDepth = 0; - let maxRow = 0; - - for (const [id, node] of nodesMap.entries()) { - const d = depth.get(id) ?? 0; - const r = row.get(id) ?? 0; - maxDepth = Math.max(maxDepth, d); - maxRow = Math.max(maxRow, r); - layoutNodes.push({ - node, - depth: d, - row: r, - x: marginX + d * xGap, - y: marginY + r * yGap, - }); - } - - layoutNodes.sort((a, b) => (a.depth === b.depth ? a.row - b.row : a.depth - b.depth)); - - const width = marginX * 2 + maxDepth * xGap + 280; - const height = marginY * 2 + Math.max(1, maxRow) * yGap + 44; - - return { nodes: layoutNodes, edges, width, height }; -} - -function createSvgElement(tag: T): SVGElementTagNameMap[T] { - return document.createElementNS('http://www.w3.org/2000/svg', tag); -} - -function createGraphScene( - layout: GraphLayout, - currentId: string | null, - showTimestamps: boolean, - onNodeClick: (nodeId: string) => void -): SVGGElement { - const root = createSvgElement('g'); - const positionById = new Map(layout.nodes.map((n) => [n.node.id, n])); - - for (const edge of layout.edges) { - const from = positionById.get(edge.from); - const to = positionById.get(edge.to); - if (!from || !to) continue; - - const path = createSvgElement('path'); - const ctrlDx = Math.max(32, (to.x - from.x) * 0.55); - path.setAttribute( - 'd', - `M ${from.x} ${from.y} C ${from.x + ctrlDx} ${from.y}, ${to.x - ctrlDx} ${to.y}, ${to.x} ${to.y}` - ); - path.setAttribute('class', 'autk-provenance-edge'); - root.appendChild(path); - } - - for (const item of layout.nodes) { - const g = createSvgElement('g'); - const isCurrent = item.node.id === currentId; - const hasAnnotation = - typeof item.node.metadata?.insight === 'string' && - (item.node.metadata.insight as string).trim().length > 0; - - g.setAttribute('class', `autk-provenance-node${isCurrent ? ' autk-provenance-node-current' : ''}`); - g.setAttribute('transform', `translate(${item.x}, ${item.y})`); - - const circle = createSvgElement('circle'); - circle.setAttribute('class', 'autk-provenance-node-circle'); - circle.setAttribute('r', isCurrent ? '9' : '7'); - g.appendChild(circle); - - // Insight annotation indicator dot (Feature 1) - if (hasAnnotation) { - const dot = createSvgElement('circle'); - dot.setAttribute('class', 'autk-provenance-node-insight-dot'); - dot.setAttribute('cx', isCurrent ? '7' : '5'); - dot.setAttribute('cy', isCurrent ? '-7' : '-5'); - dot.setAttribute('r', '4'); - g.appendChild(dot); - } - - const annotationText = hasAnnotation ? `\n"${(item.node.metadata!.insight as string).trim()}"` : ''; - const title = createSvgElement('title'); - title.textContent = `${item.node.actionLabel} (${item.node.id})${annotationText}`; - g.appendChild(title); - - const label = createSvgElement('text'); - label.setAttribute('class', 'autk-provenance-node-label'); - label.setAttribute('x', '14'); - label.setAttribute('y', '-2'); - label.textContent = truncate(item.node.actionLabel); - g.appendChild(label); - - if (showTimestamps) { - const time = createSvgElement('text'); - time.setAttribute('class', 'autk-provenance-node-time'); - time.setAttribute('x', '14'); - time.setAttribute('y', '12'); - time.textContent = formatTime(item.node.timestamp); - g.appendChild(time); - } - - g.addEventListener('click', (event) => { - event.stopPropagation(); - onNodeClick(item.node.id); - }); - - root.appendChild(g); - } - - return root; -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -// --------------------------------------------------------------------------- -// Insights panel helpers -// --------------------------------------------------------------------------- - -const STRATEGY_COLORS: Record = { - Confirmatory: '#2e7d32', - Exploratory: '#1565c0', - 'Iterative Refinement': '#6a1b9a', -}; - -function formatDurationShort(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const s = Math.round(ms / 1000); - if (s < 60) return `${s}s`; - return `${Math.floor(s / 60)}m ${s % 60}s`; -} - -function renderInsightsPanel( - container: HTMLElement, - provenance: AutarkProvenanceApi -): void { - container.innerHTML = ''; - - const graph = provenance.getGraph(); - const currentNode = provenance.getCurrentNode(); - const metrics = computeGraphMetrics(graph); - const annotations = getInsightAnnotations(graph); - - // ── Strategy + metrics row ──────────────────────────────────────────────── - const metricsRow = document.createElement('div'); - metricsRow.className = 'autk-prov-insights-metrics'; - - const badge = document.createElement('span'); - badge.className = 'autk-prov-strategy-badge'; - badge.textContent = metrics.strategyLabel; - badge.style.background = STRATEGY_COLORS[metrics.strategyLabel]; - metricsRow.appendChild(badge); - - const metaItems: string[] = []; - if (metrics.sessionDurationMs > 0) { - metaItems.push(formatDurationShort(metrics.sessionDurationMs)); - } - metaItems.push(`${metrics.totalNodes} state${metrics.totalNodes !== 1 ? 's' : ''}`); - if (metrics.branchPoints > 0) { - metaItems.push(`${metrics.branchPoints} branch${metrics.branchPoints !== 1 ? 'es' : ''}`); - } - if (metrics.backtracks > 0) { - metaItems.push(`${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''}`); - } - - const metaSpan = document.createElement('span'); - metaSpan.className = 'autk-prov-metrics-meta'; - metaSpan.textContent = metaItems.join(' • '); - metricsRow.appendChild(metaSpan); - - container.appendChild(metricsRow); - - // ── Annotate current node (Feature 1: insight provenance) ───────────────── - const annotateSection = document.createElement('div'); - annotateSection.className = 'autk-prov-insights-section'; - - const annotateLabel = document.createElement('div'); - annotateLabel.className = 'autk-prov-insights-section-title'; - annotateLabel.textContent = 'Insight at this step'; - annotateSection.appendChild(annotateLabel); - - const textarea = document.createElement('textarea'); - textarea.className = 'autk-prov-annotation-textarea'; - textarea.placeholder = 'What did you notice or conclude here?'; - textarea.rows = 3; - if (currentNode) { - const existing = currentNode.metadata?.insight; - textarea.value = typeof existing === 'string' ? existing : ''; - } else { - textarea.disabled = true; - } - annotateSection.appendChild(textarea); - - const saveBtn = document.createElement('button'); - saveBtn.className = 'autk-prov-annotation-save'; - saveBtn.textContent = 'Save Insight'; - saveBtn.disabled = !currentNode; - saveBtn.addEventListener('click', () => { - if (!currentNode) return; - provenance.annotateNode(currentNode.id, textarea.value); - renderInsightsPanel(container, provenance); - }); - annotateSection.appendChild(saveBtn); - - container.appendChild(annotateSection); - - // ── Recorded insight annotations list ───────────────────────────────────── - if (annotations.length > 0) { - const annoSection = document.createElement('div'); - annoSection.className = 'autk-prov-insights-section'; - - const annoTitle = document.createElement('div'); - annoTitle.className = 'autk-prov-insights-section-title'; - annoTitle.textContent = `Recorded insights (${annotations.length})`; - annoSection.appendChild(annoTitle); - - for (const a of annotations) { - const item = document.createElement('div'); - item.className = 'autk-prov-anno-item'; - - const step = document.createElement('div'); - step.className = 'autk-prov-anno-step'; - step.textContent = truncate(a.actionLabel, 24); - item.appendChild(step); - - const text = document.createElement('div'); - text.className = 'autk-prov-anno-text'; - text.textContent = a.text; - item.appendChild(text); - - // Click to navigate to that node - item.addEventListener('click', () => { - provenance.goToNode(a.nodeId); - }); - - annoSection.appendChild(item); - } - - container.appendChild(annoSection); - } - - // ── Auto-generated session narrative (Feature 4: presentation) ───────────── - const summarySection = document.createElement('div'); - summarySection.className = 'autk-prov-insights-section'; - - const summaryTitle = document.createElement('div'); - summaryTitle.className = 'autk-prov-insights-section-title'; - summaryTitle.textContent = 'Analysis summary'; - summarySection.appendChild(summaryTitle); - - const narrative = generateSessionNarrative(graph, metrics, annotations); - - const summaryPre = document.createElement('pre'); - summaryPre.className = 'autk-prov-summary-text'; - summaryPre.textContent = narrative; - summarySection.appendChild(summaryPre); - - const copyBtn = document.createElement('button'); - copyBtn.className = 'autk-prov-copy-btn'; - copyBtn.textContent = 'Copy to clipboard'; - copyBtn.addEventListener('click', () => { - navigator.clipboard.writeText(narrative).then(() => { - copyBtn.textContent = 'Copied!'; - setTimeout(() => { - copyBtn.textContent = 'Copy to clipboard'; - }, 1800); - }); - }); - summarySection.appendChild(copyBtn); - - container.appendChild(summarySection); -} - -// --------------------------------------------------------------------------- -// Main UI renderer -// --------------------------------------------------------------------------- - export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void { const { provenance, @@ -401,433 +32,31 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = showPathList = true, } = options; - if (typeof document !== 'undefined' && document.head) { - const styleId = 'autk-provenance-trail-styles'; - if (!document.getElementById(styleId)) { - const el = document.createElement('style'); - el.id = styleId; - el.textContent = [ - // Core layout - '.autk-provenance-root{display:flex;flex-direction:column;height:100%;font-family:system-ui,sans-serif;font-size:13px;gap:10px;min-height:0}', - '.autk-provenance-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}', - '.autk-provenance-toolbar-main{display:flex;align-items:center;gap:6px;flex-wrap:wrap}', - '.autk-provenance-toolbar-hint{font-size:11px;color:#5c7389}', - '.autk-provenance-toggle{padding:6px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', - '.autk-provenance-toggle:hover{background:#f2f7fd}', - '.autk-provenance-toggle:disabled{opacity:.55;cursor:not-allowed}', - // Graph - '.autk-provenance-graph-wrap{position:relative;border:1px solid #dbe5f0;border-radius:8px;background:#fff;overflow:auto;min-height:280px;height:280px;max-height:360px;cursor:zoom-in}', - '.autk-provenance-graph-svg{display:block;min-width:100%;max-width:none}', - '.autk-provenance-edge{stroke:#b9c8d8;stroke-width:1.4;fill:none}', - '.autk-provenance-node{cursor:pointer}', - '.autk-provenance-node-circle{fill:#f8fbff;stroke:#6f8baa;stroke-width:1.5}', - '.autk-provenance-node-current .autk-provenance-node-circle{fill:#dcecff;stroke:#2f5f96;stroke-width:2}', - '.autk-provenance-node-label{font-size:11px;fill:#1f344b}', - '.autk-provenance-node-time{font-size:10px;fill:#5c7389}', - // Insight annotation dot on graph nodes - '.autk-provenance-node-insight-dot{fill:#f59e0b;stroke:#fff;stroke-width:1.5}', - // Modal - '.autk-provenance-modal-backdrop{position:fixed;inset:0;background:rgba(15,27,44,.48);z-index:11000;display:flex;align-items:center;justify-content:center;padding:24px}', - '.autk-provenance-modal{width:min(1200px,95vw);height:min(860px,90vh);display:flex;flex-direction:column;border-radius:12px;overflow:hidden;background:#f8fbff;box-shadow:0 28px 80px rgba(17,31,47,.45)}', - '.autk-provenance-modal-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px;background:#fff;border-bottom:1px solid #dbe5f0}', - '.autk-provenance-modal-title{font-size:14px;font-weight:700;color:#1f344b}', - '.autk-provenance-modal-controls{display:flex;align-items:center;gap:6px}', - '.autk-provenance-modal-controls button{padding:6px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;color:#1f344b;cursor:pointer;font-size:12px}', - '.autk-provenance-modal-controls button:hover{background:#edf5ff}', - '.autk-provenance-modal-body{padding:12px;display:flex;flex:1;min-height:0}', - '.autk-provenance-modal-canvas{position:relative;flex:1;min-height:0;border:1px solid #dbe5f0;border-radius:8px;background:#fff;overflow:hidden;cursor:grab;touch-action:none}', - '.autk-provenance-modal-canvas.autk-provenance-panning{cursor:grabbing}', - '.autk-provenance-modal-svg{display:block;width:100%;height:100%;min-width:0}', - // Path list - '.autk-provenance-path{display:flex;flex-direction:column;gap:2px;border:1px solid #dbe5f0;border-radius:8px;background:#fbfdff;max-height:200px;overflow-y:auto}', - '.autk-provenance-path-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #e7eef6;cursor:pointer}', - '.autk-provenance-path-item:last-child{border-bottom:0}', - '.autk-provenance-path-item:hover{background:#f1f7ff}', - '.autk-provenance-path-item-current{background:#e8f2ff;font-weight:600}', - '.autk-provenance-path-label{flex:1;min-width:0}', - '.autk-provenance-path-time{color:#5c7389;font-size:11px;white-space:nowrap}', - // Back/forward - '.autk-provenance-buttons{display:flex;gap:6px}', - '.autk-provenance-buttons button{padding:6px 12px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer}', - '.autk-provenance-buttons button:hover:not(:disabled){background:#f2f7fd}', - '.autk-provenance-buttons button:disabled{opacity:.55;cursor:not-allowed}', - // ── Insights panel ────────────────────────────────────────────────── - '.autk-prov-insights-wrap{border:1px solid #dbe5f0;border-radius:8px;background:#fbfdff;overflow-y:auto;max-height:420px;min-height:0}', - '.autk-prov-insights-header{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-bottom:1px solid #e7eef6;cursor:pointer;user-select:none}', - '.autk-prov-insights-header-label{font-size:12px;font-weight:700;color:#1f344b;letter-spacing:.02em}', - '.autk-prov-insights-chevron{font-size:11px;color:#5c7389}', - '.autk-prov-insights-body{padding:8px 10px;display:flex;flex-direction:column;gap:10px}', - // Metrics row - '.autk-prov-insights-metrics{display:flex;align-items:center;gap:8px;flex-wrap:wrap}', - '.autk-prov-strategy-badge{display:inline-block;padding:3px 8px;border-radius:20px;font-size:11px;font-weight:700;color:#fff;white-space:nowrap}', - '.autk-prov-metrics-meta{font-size:11px;color:#5c7389}', - // Section - '.autk-prov-insights-section{display:flex;flex-direction:column;gap:5px}', - '.autk-prov-insights-section-title{font-size:11px;font-weight:700;color:#5c7389;text-transform:uppercase;letter-spacing:.05em}', - // Annotation textarea - '.autk-prov-annotation-textarea{width:100%;padding:6px 8px;border:1px solid #c3cfdb;border-radius:6px;font-family:inherit;font-size:12px;resize:vertical;color:#1f344b;background:#fff;box-sizing:border-box}', - '.autk-prov-annotation-textarea:focus{outline:none;border-color:#2f5f96}', - '.autk-prov-annotation-save{align-self:flex-start;padding:5px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', - '.autk-prov-annotation-save:hover:not(:disabled){background:#edf5ff;border-color:#2f5f96;color:#2f5f96}', - '.autk-prov-annotation-save:disabled{opacity:.5;cursor:not-allowed}', - // Selection frequency bars - '.autk-prov-freq-group-label{font-size:11px;color:#1f344b;font-weight:600;margin-top:2px}', - '.autk-prov-freq-row{display:flex;align-items:center;gap:6px;min-height:18px}', - '.autk-prov-freq-id{font-size:11px;color:#5c7389;width:48px;flex-shrink:0;font-variant-numeric:tabular-nums}', - '.autk-prov-freq-bar-wrap{flex:1;height:8px;background:#e7eef6;border-radius:4px;overflow:hidden}', - '.autk-prov-freq-bar-fill{height:100%;background:#2f5f96;border-radius:4px;transition:width .3s}', - '.autk-prov-freq-count{font-size:11px;color:#1f344b;width:26px;text-align:right;flex-shrink:0}', - // Annotation list - '.autk-prov-anno-item{padding:6px 8px;border-radius:6px;border:1px solid #e7eef6;background:#fff;cursor:pointer}', - '.autk-prov-anno-item:hover{background:#f1f7ff}', - '.autk-prov-anno-step{font-size:10px;color:#5c7389;margin-bottom:2px}', - '.autk-prov-anno-text{font-size:12px;color:#1f344b;line-height:1.4}', - // Summary narrative - '.autk-prov-summary-text{margin:0;padding:8px;background:#f0f5fb;border:1px solid #dbe5f0;border-radius:6px;font-size:11px;color:#1f344b;white-space:pre-wrap;word-break:break-word;line-height:1.55;max-height:180px;overflow-y:auto}', - '.autk-prov-copy-btn{align-self:flex-start;padding:5px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', - '.autk-prov-copy-btn:hover{background:#edf5ff}', - ].join(''); - document.head.appendChild(el); - } - } + ensureProvenanceTrailStyles(); container.innerHTML = ''; container.classList.add('autk-provenance-root'); let graphVisible = showGraph; - let graphModalOpen = false; - let graphToggleBtn: HTMLButtonElement | null = null; - let graphExpandBtn: HTMLButtonElement | null = null; - let graphHint: HTMLSpanElement | null = null; let graphWrap: HTMLDivElement | null = null; let pathContainer: HTMLDivElement | null = null; - let backBtn: HTMLButtonElement | null = null; - let fwdBtn: HTMLButtonElement | null = null; let insightsBodyEl: HTMLDivElement | null = null; let insightsOpen = true; - let graphModalBackdrop: HTMLDivElement | null = null; - let graphModalCanvas: HTMLDivElement | null = null; - let graphModalScene: SVGGElement | null = null; - let graphModalLayout: GraphLayout | null = null; - let graphModalScale = 1; - let graphModalTx = 0; - let graphModalTy = 0; - let graphModalTransformInitialized = false; - - function applyGraphModalTransform(): void { - if (!graphModalScene) return; - graphModalScene.setAttribute( - 'transform', - `translate(${graphModalTx} ${graphModalTy}) scale(${graphModalScale})` - ); - } - - function fitGraphModalView(): void { - if (!graphModalCanvas || !graphModalLayout || !graphModalScene) return; - const viewportWidth = Math.max(1, graphModalCanvas.clientWidth); - const viewportHeight = Math.max(1, graphModalCanvas.clientHeight); - const padding = 52; - const scaleX = (viewportWidth - padding * 2) / Math.max(1, graphModalLayout.width); - const scaleY = (viewportHeight - padding * 2) / Math.max(1, graphModalLayout.height); - const fitScale = clamp(Math.min(scaleX, scaleY), 0.25, 6); - graphModalScale = Number.isFinite(fitScale) && fitScale > 0 ? fitScale : 1; - graphModalTx = (viewportWidth - graphModalLayout.width * graphModalScale) / 2; - graphModalTy = (viewportHeight - graphModalLayout.height * graphModalScale) / 2; - graphModalTransformInitialized = true; - applyGraphModalTransform(); - } - - function zoomGraphModal(factor: number, clientX?: number, clientY?: number): void { - if (!graphModalCanvas || !graphModalScene) return; - const rect = graphModalCanvas.getBoundingClientRect(); - const x = clientX === undefined ? rect.left + rect.width / 2 : clientX; - const y = clientY === undefined ? rect.top + rect.height / 2 : clientY; - const localX = x - rect.left; - const localY = y - rect.top; - const nextScale = clamp(graphModalScale * factor, 0.25, 6); - const ratio = nextScale / graphModalScale; - graphModalTx = localX - (localX - graphModalTx) * ratio; - graphModalTy = localY - (localY - graphModalTy) * ratio; - graphModalScale = nextScale; - graphModalTransformInitialized = true; - applyGraphModalTransform(); - } - - function teardownGraphModal(): void { - graphModalBackdrop?.remove(); - graphModalBackdrop = null; - graphModalCanvas = null; - graphModalScene = null; - graphModalLayout = null; - graphModalTransformInitialized = false; - } - - function closeGraphModal(): void { - graphModalOpen = false; - teardownGraphModal(); - } - - function ensureGraphModal(): void { - if (graphModalBackdrop) return; - - const backdrop = document.createElement('div'); - backdrop.className = 'autk-provenance-modal-backdrop'; - - const modal = document.createElement('div'); - modal.className = 'autk-provenance-modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - modal.setAttribute('aria-label', 'Provenance graph'); - - const header = document.createElement('div'); - header.className = 'autk-provenance-modal-header'; - - const title = document.createElement('div'); - title.className = 'autk-provenance-modal-title'; - title.textContent = 'Provenance Graph'; - - const controls = document.createElement('div'); - controls.className = 'autk-provenance-modal-controls'; - - const zoomOutBtn = document.createElement('button'); - zoomOutBtn.type = 'button'; - zoomOutBtn.textContent = '\u2212'; - zoomOutBtn.setAttribute('aria-label', 'Zoom out'); - - const zoomInBtn = document.createElement('button'); - zoomInBtn.type = 'button'; - zoomInBtn.textContent = '+'; - zoomInBtn.setAttribute('aria-label', 'Zoom in'); - - const fitBtn = document.createElement('button'); - fitBtn.type = 'button'; - fitBtn.textContent = 'Fit'; - fitBtn.setAttribute('aria-label', 'Fit graph to view'); - - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.textContent = 'Close'; - closeBtn.setAttribute('aria-label', 'Close graph modal'); - - controls.appendChild(zoomOutBtn); - controls.appendChild(zoomInBtn); - controls.appendChild(fitBtn); - controls.appendChild(closeBtn); - header.appendChild(title); - header.appendChild(controls); - - const body = document.createElement('div'); - body.className = 'autk-provenance-modal-body'; - - const canvas = document.createElement('div'); - canvas.className = 'autk-provenance-modal-canvas'; - body.appendChild(canvas); - - modal.appendChild(header); - modal.appendChild(body); - backdrop.appendChild(modal); - - backdrop.addEventListener('click', (event) => { - if (event.target !== backdrop) return; - closeGraphModal(); - refresh(); - }); - - closeBtn.addEventListener('click', () => { - closeGraphModal(); - refresh(); - }); - zoomInBtn.addEventListener('click', () => zoomGraphModal(1.2)); - zoomOutBtn.addEventListener('click', () => zoomGraphModal(0.84)); - fitBtn.addEventListener('click', () => fitGraphModalView()); - - canvas.addEventListener( - 'wheel', - (event) => { - event.preventDefault(); - zoomGraphModal(event.deltaY < 0 ? 1.12 : 0.89, event.clientX, event.clientY); - }, - { passive: false } - ); - - let panning = false; - let lastX = 0; - let lastY = 0; - - const stopPanning = (event: PointerEvent) => { - if (!panning) return; - panning = false; - canvas.classList.remove('autk-provenance-panning'); - if (canvas.hasPointerCapture(event.pointerId)) { - canvas.releasePointerCapture(event.pointerId); - } - }; - - canvas.addEventListener('pointerdown', (event) => { - if (event.button !== 0) return; - const target = event.target as HTMLElement | null; - if (target?.closest('.autk-provenance-node')) return; - panning = true; - lastX = event.clientX; - lastY = event.clientY; - canvas.classList.add('autk-provenance-panning'); - canvas.setPointerCapture(event.pointerId); - event.preventDefault(); - }); - - canvas.addEventListener('pointermove', (event) => { - if (!panning) return; - graphModalTx += event.clientX - lastX; - graphModalTy += event.clientY - lastY; - lastX = event.clientX; - lastY = event.clientY; - graphModalTransformInitialized = true; - applyGraphModalTransform(); - }); - - canvas.addEventListener('pointerup', stopPanning); - canvas.addEventListener('pointercancel', stopPanning); - - document.body?.appendChild(backdrop); - graphModalBackdrop = backdrop; - graphModalCanvas = canvas; - } - - function renderGraphModal(): void { - if (!graphModalOpen) return; - ensureGraphModal(); - if (!graphModalCanvas) return; - - graphModalCanvas.innerHTML = ''; - - const graph = provenance.getGraph(); - const rootNode = graph.nodes.get(graph.rootId); - if (!rootNode) return; - - const currentId = provenance.getCurrentNode()?.id ?? null; - const layout = buildGraphLayout(graph.nodes, graph.rootId); - graphModalLayout = layout; - - const viewportWidth = Math.max(320, Math.floor(graphModalCanvas.clientWidth)); - const viewportHeight = Math.max(260, Math.floor(graphModalCanvas.clientHeight)); - - const svg = createSvgElement('svg'); - svg.classList.add('autk-provenance-modal-svg'); - svg.setAttribute('viewBox', `0 0 ${viewportWidth} ${viewportHeight}`); - svg.setAttribute('width', String(viewportWidth)); - svg.setAttribute('height', String(viewportHeight)); - - const scene = createGraphScene(layout, currentId, showTimestamps, (nodeId) => { - provenance.goToNode(nodeId); - }); - graphModalScene = scene; - svg.appendChild(scene); - graphModalCanvas.appendChild(svg); - - if (!graphModalTransformInitialized) { - fitGraphModalView(); - } else { - applyGraphModalTransform(); - } - } - - function openGraphModal(): void { - if (!showGraph || !graphVisible) return; - graphModalOpen = true; - graphModalTransformInitialized = false; - renderGraphModal(); - } - - if (showGraph) { - const toolbar = document.createElement('div'); - toolbar.className = 'autk-provenance-toolbar'; - const toolbarMain = document.createElement('div'); - toolbarMain.className = 'autk-provenance-toolbar-main'; - - graphToggleBtn = document.createElement('button'); - graphToggleBtn.className = 'autk-provenance-toggle'; - graphToggleBtn.textContent = 'Hide Graph'; - toolbarMain.appendChild(graphToggleBtn); - - graphExpandBtn = document.createElement('button'); - graphExpandBtn.className = 'autk-provenance-toggle'; - graphExpandBtn.textContent = 'Open Graph'; - toolbarMain.appendChild(graphExpandBtn); - - toolbar.appendChild(toolbarMain); - - graphHint = document.createElement('span'); - graphHint.className = 'autk-provenance-toolbar-hint'; - graphHint.textContent = 'Click graph preview to open modal'; - toolbar.appendChild(graphHint); - - container.appendChild(toolbar); - - graphWrap = document.createElement('div'); - graphWrap.className = 'autk-provenance-graph-wrap'; - container.appendChild(graphWrap); - } - - if (showPathList) { - pathContainer = document.createElement('div'); - pathContainer.className = 'autk-provenance-path'; - pathContainer.setAttribute('role', 'list'); - container.appendChild(pathContainer); - } - - if (showBackForward) { - const btnRow = document.createElement('div'); - btnRow.className = 'autk-provenance-buttons'; - backBtn = document.createElement('button'); - backBtn.textContent = '\u2190 Back'; - backBtn.setAttribute('aria-label', 'Go back one step'); - fwdBtn = document.createElement('button'); - fwdBtn.textContent = 'Forward \u2192'; - fwdBtn.setAttribute('aria-label', 'Go forward one step'); - btnRow.appendChild(backBtn); - btnRow.appendChild(fwdBtn); - container.appendChild(btnRow); - - backBtn.addEventListener('click', () => { - provenance.goBackOneStep(); - }); - fwdBtn.addEventListener('click', () => { - provenance.goForwardOneStep(); - }); - } - - // ── Insights panel (collapsible) ────────────────────────────────────────── - const insightsWrap = document.createElement('div'); - insightsWrap.className = 'autk-prov-insights-wrap'; - - const insightsHeader = document.createElement('div'); - insightsHeader.className = 'autk-prov-insights-header'; - const insightsHeaderLabel = document.createElement('span'); - insightsHeaderLabel.className = 'autk-prov-insights-header-label'; - insightsHeaderLabel.textContent = 'Session Insights'; - const insightsChevron = document.createElement('span'); - insightsChevron.className = 'autk-prov-insights-chevron'; - insightsChevron.textContent = '\u25b4'; - insightsHeader.appendChild(insightsHeaderLabel); - insightsHeader.appendChild(insightsChevron); - insightsWrap.appendChild(insightsHeader); - - const insightsBody = document.createElement('div'); - insightsBody.className = 'autk-prov-insights-body'; - insightsWrap.appendChild(insightsBody); - insightsBodyEl = insightsBody; + let graphToggleBtn: HTMLButtonElement | null = null; + let graphExpandBtn: HTMLButtonElement | null = null; + let graphHint: HTMLSpanElement | null = null; + let backBtn: HTMLButtonElement | null = null; + let fwdBtn: HTMLButtonElement | null = null; + let refreshRef = () => {}; - insightsHeader.addEventListener('click', () => { - insightsOpen = !insightsOpen; - insightsBody.style.display = insightsOpen ? 'flex' : 'none'; - insightsChevron.textContent = insightsOpen ? '\u25b4' : '\u25be'; + const graphModal = createGraphModalController({ + provenance, + showTimestamps, + getEnabled: () => showGraph && graphVisible, + onRefresh: () => refreshRef(), }); - (insightsContainer ?? container).appendChild(insightsWrap); - - // ── Helpers ─────────────────────────────────────────────────────────────── - function updateButtons(): void { if (backBtn) backBtn.disabled = !provenance.canGoBack(); if (fwdBtn) fwdBtn.disabled = !provenance.canGoForward(); @@ -840,72 +69,7 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = return; } graphWrap.style.display = 'block'; - graphWrap.innerHTML = ''; - - const graph = provenance.getGraph(); - const currentId = provenance.getCurrentNode()?.id ?? null; - const rootNode = graph.nodes.get(graph.rootId); - if (!rootNode) return; - - const layout = buildGraphLayout(graph.nodes, graph.rootId); - - const svg = createSvgElement('svg'); - svg.classList.add('autk-provenance-graph-svg'); - svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`); - svg.setAttribute('width', String(layout.width)); - svg.setAttribute('height', String(layout.height)); - svg.appendChild( - createGraphScene(layout, currentId, showTimestamps, (nodeId) => { - provenance.goToNode(nodeId); - }) - ); - - graphWrap.appendChild(svg); - } - - function renderPath(path: PathNode[]): void { - if (!pathContainer) return; - pathContainer.innerHTML = ''; - const currentId = provenance.getCurrentNode()?.id ?? null; - - for (const node of path) { - const isCurrent = node.id === currentId; - const item = document.createElement('div'); - item.className = `autk-provenance-path-item${isCurrent ? ' autk-provenance-path-item-current' : ''}`; - item.setAttribute('role', 'listitem'); - item.setAttribute('data-node-id', node.id); - - const labelSpan = document.createElement('span'); - labelSpan.className = 'autk-provenance-path-label'; - labelSpan.textContent = node.actionLabel; - item.appendChild(labelSpan); - - // Insight indicator in path list - const graphNode = provenance.getGraph().nodes.get(node.id); - const hasAnnotation = - typeof graphNode?.metadata?.insight === 'string' && - (graphNode.metadata.insight as string).trim().length > 0; - if (hasAnnotation) { - const dot = document.createElement('span'); - dot.title = graphNode!.metadata!.insight as string; - dot.style.cssText = - 'display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0'; - item.appendChild(dot); - } - - if (showTimestamps) { - const timeSpan = document.createElement('span'); - timeSpan.className = 'autk-provenance-path-time'; - timeSpan.textContent = formatTime(node.timestamp); - item.appendChild(timeSpan); - } - - item.addEventListener('click', () => { - provenance.goToNode(node.id); - }); - - pathContainer.appendChild(item); - } + renderGraphPreview(graphWrap, provenance, showTimestamps); } function refresh(): void { @@ -913,20 +77,20 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = graphToggleBtn.textContent = graphVisible ? 'Hide Graph' : 'Show Graph'; } if (graphExpandBtn) { - graphExpandBtn.textContent = graphModalOpen ? 'Close Graph' : 'Open Graph'; + graphExpandBtn.textContent = graphModal.isOpen() ? 'Close Graph' : 'Open Graph'; graphExpandBtn.disabled = !graphVisible; } if (graphHint) { - graphHint.textContent = graphModalOpen + graphHint.textContent = graphModal.isOpen() ? 'Modal open: click nodes to jump state' : 'Click graph preview to open modal'; graphHint.style.visibility = graphVisible ? 'visible' : 'hidden'; } + if (showGraph) renderGraph(); - if (graphModalOpen) renderGraphModal(); - if (showPathList) { - const path = provenance.getPathFromRoot(); - renderPath(path); + if (graphModal.isOpen()) graphModal.render(); + if (showPathList && pathContainer) { + renderPathList(pathContainer, provenance.getPathFromRoot(), provenance, showTimestamps); } updateButtons(); if (insightsBodyEl && insightsOpen) { @@ -934,24 +98,56 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = } } - const unsub = provenance.addObserver(() => { - refresh(); + if (showGraph) { + const graphSection = createGraphSection(container); + graphWrap = graphSection.graphWrap; + graphToggleBtn = graphSection.graphToggleBtn; + graphExpandBtn = graphSection.graphExpandBtn; + graphHint = graphSection.graphHint; + } + + if (showPathList) { + pathContainer = createPathSection(container); + } + + if (showBackForward) { + const nav = createNavButtons( + container, + () => provenance.goBackOneStep(), + () => provenance.goForwardOneStep() + ); + backBtn = nav.backBtn; + fwdBtn = nav.fwdBtn; + } + + const insightsSection = createInsightsSection(insightsContainer ?? container); + const insightsWrap = insightsSection.wrap; + const insightsBody = insightsSection.body; + const insightsChevron = insightsSection.chevron; + insightsBodyEl = insightsBody; + + insightsWrap.firstElementChild?.addEventListener('click', () => { + insightsOpen = !insightsOpen; + insightsBody.style.display = insightsOpen ? 'flex' : 'none'; + insightsChevron.textContent = insightsOpen ? '\u25b4' : '\u25be'; }); + const unsub = provenance.addObserver(() => refresh()); + if (graphToggleBtn) { graphToggleBtn.addEventListener('click', () => { graphVisible = !graphVisible; - if (!graphVisible) closeGraphModal(); + if (!graphVisible) graphModal.close(); refresh(); }); } if (graphExpandBtn) { graphExpandBtn.addEventListener('click', () => { - if (graphModalOpen) { - closeGraphModal(); + if (graphModal.isOpen()) { + graphModal.close(); } else { - openGraphModal(); + graphModal.open(); } refresh(); }); @@ -959,34 +155,33 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = if (graphWrap) { graphWrap.addEventListener('click', () => { - if (!graphVisible || graphModalOpen) return; - openGraphModal(); + if (!graphVisible || graphModal.isOpen()) return; + graphModal.open(); refresh(); }); } const handleDocKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || !graphModalOpen) return; - closeGraphModal(); + if (event.key !== 'Escape' || !graphModal.isOpen()) return; + graphModal.close(); refresh(); }; document.addEventListener('keydown', handleDocKeyDown); const handleWindowResize = () => { - if (!graphModalOpen) return; - renderGraphModal(); + if (!graphModal.isOpen()) return; + graphModal.render(); }; window.addEventListener('resize', handleWindowResize); + refreshRef = refresh; refresh(); return () => { unsub(); document.removeEventListener('keydown', handleDocKeyDown); window.removeEventListener('resize', handleWindowResize); - closeGraphModal(); - container.innerHTML = ''; - container.classList.remove('autk-provenance-root'); - if (insightsContainer) insightsContainer.innerHTML = ''; + graphModal.close(); + graphModal.destroy(); }; } diff --git a/autk-provenance/src/ui/graph-layout.ts b/autk-provenance/src/ui/graph-layout.ts new file mode 100644 index 00000000..c4abd0da --- /dev/null +++ b/autk-provenance/src/ui/graph-layout.ts @@ -0,0 +1,113 @@ +import type { AutarkProvenanceState, ProvenanceNode } from '../types'; + +export type LayoutNode = { + node: ProvenanceNode; + x: number; + y: number; + depth: number; + row: number; +}; + +export type LayoutEdge = { + from: string; + to: string; +}; + +export type GraphLayout = { + nodes: LayoutNode[]; + edges: LayoutEdge[]; + width: number; + height: number; +}; + +export function buildGraphLayout( + nodesMap: Map>, + rootId: string +): GraphLayout { + const depth = new Map(); + const row = new Map(); + const visited = new Set(); + const edges: LayoutEdge[] = []; + + function assignDepth(nodeId: string, d: number): void { + if (visited.has(nodeId)) { + const prev = depth.get(nodeId); + if (prev === undefined || d < prev) depth.set(nodeId, d); + return; + } + visited.add(nodeId); + depth.set(nodeId, d); + const node = nodesMap.get(nodeId); + if (!node) return; + for (const childId of node.childrenIds) { + if (!nodesMap.has(childId)) continue; + edges.push({ from: nodeId, to: childId }); + assignDepth(childId, d + 1); + } + } + + assignDepth(rootId, 0); + + let nextLeafRow = 0; + function assignRow(nodeId: string): number { + if (row.has(nodeId)) return row.get(nodeId) ?? 0; + const node = nodesMap.get(nodeId); + if (!node) { + row.set(nodeId, nextLeafRow); + nextLeafRow += 1; + return row.get(nodeId) ?? 0; + } + + const validChildren = node.childrenIds.filter((id) => nodesMap.has(id)); + if (validChildren.length === 0) { + row.set(nodeId, nextLeafRow); + nextLeafRow += 1; + return row.get(nodeId) ?? 0; + } + + const childRows = validChildren.map(assignRow); + const avg = childRows.reduce((acc, n) => acc + n, 0) / childRows.length; + row.set(nodeId, avg); + return avg; + } + + assignRow(rootId); + + for (const nodeId of nodesMap.keys()) { + if (!depth.has(nodeId)) depth.set(nodeId, 0); + if (!row.has(nodeId)) { + row.set(nodeId, nextLeafRow); + nextLeafRow += 1; + } + } + + const xGap = 160; + const yGap = 64; + const marginX = 28; + const marginY = 24; + + const layoutNodes: LayoutNode[] = []; + let maxDepth = 0; + let maxRow = 0; + + for (const [id, node] of nodesMap.entries()) { + const d = depth.get(id) ?? 0; + const r = row.get(id) ?? 0; + maxDepth = Math.max(maxDepth, d); + maxRow = Math.max(maxRow, r); + layoutNodes.push({ + node, + depth: d, + row: r, + x: marginX + d * xGap, + y: marginY + r * yGap, + }); + } + + layoutNodes.sort((a, b) => (a.depth === b.depth ? a.row - b.row : a.depth - b.depth)); + + const width = marginX * 2 + maxDepth * xGap + 280; + const height = marginY * 2 + Math.max(1, maxRow) * yGap + 44; + + return { nodes: layoutNodes, edges, width, height }; +} diff --git a/autk-provenance/src/ui/graph-modal-dom.ts b/autk-provenance/src/ui/graph-modal-dom.ts new file mode 100644 index 00000000..bd032415 --- /dev/null +++ b/autk-provenance/src/ui/graph-modal-dom.ts @@ -0,0 +1,92 @@ +import type { GraphModalState } from './graph-modal-utils'; + +interface EnsureGraphModalDomOptions { + state: GraphModalState; + onClose(): void; + onZoomIn(): void; + onZoomOut(): void; + onFit(): void; + onWheelZoom(event: WheelEvent): void; + onPanMove(dx: number, dy: number): void; +} + +export function ensureGraphModalDom(options: EnsureGraphModalDomOptions): void { + const { state, onClose, onZoomIn, onZoomOut, onFit, onWheelZoom, onPanMove } = options; + if (state.backdrop) return; + + const backdrop = document.createElement('div'); + backdrop.className = 'autk-provenance-modal-backdrop'; + const modal = document.createElement('div'); + modal.className = 'autk-provenance-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', 'Provenance graph'); + + const header = document.createElement('div'); + header.className = 'autk-provenance-modal-header'; + header.innerHTML = + '
Provenance Graph
' + + '
' + + '' + + '' + + '' + + '' + + '
'; + + const body = document.createElement('div'); + body.className = 'autk-provenance-modal-body'; + const canvas = document.createElement('div'); + canvas.className = 'autk-provenance-modal-canvas'; + body.appendChild(canvas); + modal.appendChild(header); + modal.appendChild(body); + backdrop.appendChild(modal); + + const [zoomOutBtn, zoomInBtn, fitBtn, closeBtn] = Array.from(header.querySelectorAll('button')); + backdrop.addEventListener('click', (event) => { + if (event.target === backdrop) onClose(); + }); + closeBtn?.addEventListener('click', onClose); + zoomInBtn?.addEventListener('click', onZoomIn); + zoomOutBtn?.addEventListener('click', onZoomOut); + fitBtn?.addEventListener('click', onFit); + canvas.addEventListener('wheel', onWheelZoom, { passive: false }); + + let panning = false; + let lastX = 0; + let lastY = 0; + + const stopPanning = (event: PointerEvent) => { + if (!panning) return; + panning = false; + canvas.classList.remove('autk-provenance-panning'); + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + }; + + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + const target = event.target as HTMLElement | null; + if (target?.closest('.autk-provenance-node')) return; + panning = true; + lastX = event.clientX; + lastY = event.clientY; + canvas.classList.add('autk-provenance-panning'); + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + + canvas.addEventListener('pointermove', (event) => { + if (!panning) return; + onPanMove(event.clientX - lastX, event.clientY - lastY); + lastX = event.clientX; + lastY = event.clientY; + }); + canvas.addEventListener('pointerup', stopPanning); + canvas.addEventListener('pointercancel', stopPanning); + + document.body?.appendChild(backdrop); + state.backdrop = backdrop; + state.canvas = canvas; +} diff --git a/autk-provenance/src/ui/graph-modal-utils.ts b/autk-provenance/src/ui/graph-modal-utils.ts new file mode 100644 index 00000000..eeadcbf9 --- /dev/null +++ b/autk-provenance/src/ui/graph-modal-utils.ts @@ -0,0 +1,78 @@ +import type { GraphLayout } from './graph-layout'; +import { clamp } from './utils'; + +export type GraphModalState = { + backdrop: HTMLDivElement | null; + canvas: HTMLDivElement | null; + scene: SVGGElement | null; + layout: GraphLayout | null; + scale: number; + tx: number; + ty: number; + initialized: boolean; + open: boolean; +}; + +export function createGraphModalState(): GraphModalState { + return { + backdrop: null, + canvas: null, + scene: null, + layout: null, + scale: 1, + tx: 0, + ty: 0, + initialized: false, + open: false, + }; +} + +export function applySceneTransform(state: GraphModalState): void { + if (!state.scene) return; + state.scene.setAttribute('transform', `translate(${state.tx} ${state.ty}) scale(${state.scale})`); +} + +export function fitGraphToView(state: GraphModalState): void { + if (!state.canvas || !state.layout || !state.scene) return; + const viewportWidth = Math.max(1, state.canvas.clientWidth); + const viewportHeight = Math.max(1, state.canvas.clientHeight); + const padding = 52; + const scaleX = (viewportWidth - padding * 2) / Math.max(1, state.layout.width); + const scaleY = (viewportHeight - padding * 2) / Math.max(1, state.layout.height); + const fitScale = clamp(Math.min(scaleX, scaleY), 0.25, 6); + state.scale = Number.isFinite(fitScale) && fitScale > 0 ? fitScale : 1; + state.tx = (viewportWidth - state.layout.width * state.scale) / 2; + state.ty = (viewportHeight - state.layout.height * state.scale) / 2; + state.initialized = true; + applySceneTransform(state); +} + +export function zoomGraph( + state: GraphModalState, + factor: number, + clientX?: number, + clientY?: number +): void { + if (!state.canvas || !state.scene) return; + const rect = state.canvas.getBoundingClientRect(); + const x = clientX === undefined ? rect.left + rect.width / 2 : clientX; + const y = clientY === undefined ? rect.top + rect.height / 2 : clientY; + const localX = x - rect.left; + const localY = y - rect.top; + const nextScale = clamp(state.scale * factor, 0.25, 6); + const ratio = nextScale / state.scale; + state.tx = localX - (localX - state.tx) * ratio; + state.ty = localY - (localY - state.ty) * ratio; + state.scale = nextScale; + state.initialized = true; + applySceneTransform(state); +} + +export function resetGraphModalState(state: GraphModalState): void { + state.backdrop?.remove(); + state.backdrop = null; + state.canvas = null; + state.scene = null; + state.layout = null; + state.initialized = false; +} diff --git a/autk-provenance/src/ui/graph-modal.ts b/autk-provenance/src/ui/graph-modal.ts new file mode 100644 index 00000000..cd33d477 --- /dev/null +++ b/autk-provenance/src/ui/graph-modal.ts @@ -0,0 +1,109 @@ +import type { AutarkProvenanceApi } from '../create-autark-provenance'; +import { buildGraphLayout, type GraphLayout } from './graph-layout'; +import { ensureGraphModalDom } from './graph-modal-dom'; +import { + applySceneTransform, + createGraphModalState, + fitGraphToView, + resetGraphModalState, + zoomGraph, + type GraphModalState, +} from './graph-modal-utils'; +import { createGraphScene, createSvgElement } from './graph-scene'; + +export interface GraphModalController { + isOpen(): boolean; + open(): void; + close(): void; + render(): void; + destroy(): void; +} + +interface CreateGraphModalControllerOptions { + provenance: AutarkProvenanceApi; + showTimestamps: boolean; + getEnabled(): boolean; + onRefresh(): void; +} + +export function createGraphModalController( + options: CreateGraphModalControllerOptions +): GraphModalController { + const { provenance, showTimestamps, getEnabled, onRefresh } = options; + const state: GraphModalState = createGraphModalState(); + + function close(): void { + state.open = false; + resetGraphModalState(state); + } + + function ensureModal(): void { + ensureGraphModalDom({ + state, + onClose: () => { + close(); + onRefresh(); + }, + onZoomIn: () => zoomGraph(state, 1.2), + onZoomOut: () => zoomGraph(state, 0.84), + onFit: () => fitGraphToView(state), + onWheelZoom: (event) => { + event.preventDefault(); + zoomGraph(state, event.deltaY < 0 ? 1.12 : 0.89, event.clientX, event.clientY); + }, + onPanMove: (dx, dy) => { + state.tx += dx; + state.ty += dy; + state.initialized = true; + applySceneTransform(state); + }, + }); + } + + function render(): void { + if (!state.open || !getEnabled()) return; + ensureModal(); + if (!state.canvas) return; + + state.canvas.innerHTML = ''; + const graph = provenance.getGraph(); + if (!graph.nodes.get(graph.rootId)) return; + + const currentId = provenance.getCurrentNode()?.id ?? null; + state.layout = buildGraphLayout(graph.nodes, graph.rootId); + + const viewportWidth = Math.max(320, Math.floor(state.canvas.clientWidth)); + const viewportHeight = Math.max(260, Math.floor(state.canvas.clientHeight)); + + const svg = createSvgElement('svg'); + svg.classList.add('autk-provenance-modal-svg'); + svg.setAttribute('viewBox', `0 0 ${viewportWidth} ${viewportHeight}`); + svg.setAttribute('width', String(viewportWidth)); + svg.setAttribute('height', String(viewportHeight)); + + state.scene = createGraphScene(state.layout, currentId, showTimestamps, (nodeId) => { + provenance.goToNode(nodeId); + }); + svg.appendChild(state.scene); + state.canvas.appendChild(svg); + + if (!state.initialized) { + fitGraphToView(state); + } else { + applySceneTransform(state); + } + } + + return { + isOpen: () => state.open, + open: () => { + if (!getEnabled()) return; + state.open = true; + state.initialized = false; + render(); + }, + close, + render, + destroy: () => resetGraphModalState(state), + }; +} diff --git a/autk-provenance/src/ui/graph-preview.ts b/autk-provenance/src/ui/graph-preview.ts new file mode 100644 index 00000000..9a1745b1 --- /dev/null +++ b/autk-provenance/src/ui/graph-preview.ts @@ -0,0 +1,29 @@ +import type { AutarkProvenanceApi } from '../create-autark-provenance'; +import { buildGraphLayout } from './graph-layout'; +import { createGraphScene, createSvgElement } from './graph-scene'; + +export function renderGraphPreview( + container: HTMLElement, + provenance: AutarkProvenanceApi, + showTimestamps: boolean +): void { + container.innerHTML = ''; + + const graph = provenance.getGraph(); + const currentId = provenance.getCurrentNode()?.id ?? null; + if (!graph.nodes.get(graph.rootId)) return; + + const layout = buildGraphLayout(graph.nodes, graph.rootId); + const svg = createSvgElement('svg'); + svg.classList.add('autk-provenance-graph-svg'); + svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`); + svg.setAttribute('width', String(layout.width)); + svg.setAttribute('height', String(layout.height)); + svg.appendChild( + createGraphScene(layout, currentId, showTimestamps, (nodeId) => { + provenance.goToNode(nodeId); + }) + ); + + container.appendChild(svg); +} diff --git a/autk-provenance/src/ui/graph-scene.ts b/autk-provenance/src/ui/graph-scene.ts new file mode 100644 index 00000000..7a2525d8 --- /dev/null +++ b/autk-provenance/src/ui/graph-scene.ts @@ -0,0 +1,89 @@ +import type { AutarkProvenanceState } from '../types'; +import type { GraphLayout } from './graph-layout'; +import { formatTime, truncate } from './utils'; + +export function createSvgElement(tag: T): SVGElementTagNameMap[T] { + return document.createElementNS('http://www.w3.org/2000/svg', tag); +} + +export function createGraphScene( + layout: GraphLayout, + currentId: string | null, + showTimestamps: boolean, + onNodeClick: (nodeId: string) => void +): SVGGElement { + const root = createSvgElement('g'); + const positionById = new Map(layout.nodes.map((n) => [n.node.id, n])); + + for (const edge of layout.edges) { + const from = positionById.get(edge.from); + const to = positionById.get(edge.to); + if (!from || !to) continue; + + const path = createSvgElement('path'); + const ctrlDx = Math.max(32, (to.x - from.x) * 0.55); + path.setAttribute( + 'd', + `M ${from.x} ${from.y} C ${from.x + ctrlDx} ${from.y}, ${to.x - ctrlDx} ${to.y}, ${to.x} ${to.y}` + ); + path.setAttribute('class', 'autk-provenance-edge'); + root.appendChild(path); + } + + for (const item of layout.nodes) { + const g = createSvgElement('g'); + const isCurrent = item.node.id === currentId; + const hasAnnotation = + typeof item.node.metadata?.insight === 'string' && + (item.node.metadata.insight as string).trim().length > 0; + + g.setAttribute('class', `autk-provenance-node${isCurrent ? ' autk-provenance-node-current' : ''}`); + g.setAttribute('transform', `translate(${item.x}, ${item.y})`); + + const circle = createSvgElement('circle'); + circle.setAttribute('class', 'autk-provenance-node-circle'); + circle.setAttribute('r', isCurrent ? '9' : '7'); + g.appendChild(circle); + + if (hasAnnotation) { + const dot = createSvgElement('circle'); + dot.setAttribute('class', 'autk-provenance-node-insight-dot'); + dot.setAttribute('cx', isCurrent ? '7' : '5'); + dot.setAttribute('cy', isCurrent ? '-7' : '-5'); + dot.setAttribute('r', '4'); + g.appendChild(dot); + } + + const annotationText = hasAnnotation + ? `\n"${(item.node.metadata!.insight as string).trim()}"` + : ''; + const title = createSvgElement('title'); + title.textContent = `${item.node.actionLabel} (${item.node.id})${annotationText}`; + g.appendChild(title); + + const label = createSvgElement('text'); + label.setAttribute('class', 'autk-provenance-node-label'); + label.setAttribute('x', '14'); + label.setAttribute('y', '-2'); + label.textContent = truncate(item.node.actionLabel); + g.appendChild(label); + + if (showTimestamps) { + const time = createSvgElement('text'); + time.setAttribute('class', 'autk-provenance-node-time'); + time.setAttribute('x', '14'); + time.setAttribute('y', '12'); + time.textContent = formatTime(item.node.timestamp); + g.appendChild(time); + } + + g.addEventListener('click', (event) => { + event.stopPropagation(); + onNodeClick(item.node.id); + }); + + root.appendChild(g); + } + + return root; +} diff --git a/autk-provenance/src/ui/insights-panel.ts b/autk-provenance/src/ui/insights-panel.ts new file mode 100644 index 00000000..d286c6e4 --- /dev/null +++ b/autk-provenance/src/ui/insights-panel.ts @@ -0,0 +1,150 @@ +import type { AutarkProvenanceApi } from '../create-autark-provenance'; +import { + computeGraphMetrics, + generateSessionNarrative, + getInsightAnnotations, + type StrategyLabel, +} from '../insight-engine'; +import { formatDurationShort, truncate } from './utils'; + +const STRATEGY_COLORS: Record = { + Confirmatory: '#2e7d32', + Exploratory: '#1565c0', + 'Iterative Refinement': '#6a1b9a', +}; + +export function renderInsightsPanel( + container: HTMLElement, + provenance: AutarkProvenanceApi +): void { + container.innerHTML = ''; + + const graph = provenance.getGraph(); + const currentNode = provenance.getCurrentNode(); + const metrics = computeGraphMetrics(graph); + const annotations = getInsightAnnotations(graph); + + const metricsRow = document.createElement('div'); + metricsRow.className = 'autk-prov-insights-metrics'; + + const badge = document.createElement('span'); + badge.className = 'autk-prov-strategy-badge'; + badge.textContent = metrics.strategyLabel; + badge.style.background = STRATEGY_COLORS[metrics.strategyLabel]; + metricsRow.appendChild(badge); + + const metaItems: string[] = []; + if (metrics.sessionDurationMs > 0) { + metaItems.push(formatDurationShort(metrics.sessionDurationMs)); + } + metaItems.push(`${metrics.totalNodes} state${metrics.totalNodes !== 1 ? 's' : ''}`); + if (metrics.branchPoints > 0) { + metaItems.push(`${metrics.branchPoints} branch${metrics.branchPoints !== 1 ? 'es' : ''}`); + } + if (metrics.backtracks > 0) { + metaItems.push(`${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''}`); + } + + const metaSpan = document.createElement('span'); + metaSpan.className = 'autk-prov-metrics-meta'; + metaSpan.textContent = metaItems.join(' • '); + metricsRow.appendChild(metaSpan); + + container.appendChild(metricsRow); + + const annotateSection = document.createElement('div'); + annotateSection.className = 'autk-prov-insights-section'; + + const annotateLabel = document.createElement('div'); + annotateLabel.className = 'autk-prov-insights-section-title'; + annotateLabel.textContent = 'Insight at this step'; + annotateSection.appendChild(annotateLabel); + + const textarea = document.createElement('textarea'); + textarea.className = 'autk-prov-annotation-textarea'; + textarea.placeholder = 'What did you notice or conclude here?'; + textarea.rows = 3; + if (currentNode) { + const existing = currentNode.metadata?.insight; + textarea.value = typeof existing === 'string' ? existing : ''; + } else { + textarea.disabled = true; + } + annotateSection.appendChild(textarea); + + const saveBtn = document.createElement('button'); + saveBtn.className = 'autk-prov-annotation-save'; + saveBtn.textContent = 'Save Insight'; + saveBtn.disabled = !currentNode; + saveBtn.addEventListener('click', () => { + if (!currentNode) return; + provenance.annotateNode(currentNode.id, textarea.value); + renderInsightsPanel(container, provenance); + }); + annotateSection.appendChild(saveBtn); + + container.appendChild(annotateSection); + + if (annotations.length > 0) { + const annoSection = document.createElement('div'); + annoSection.className = 'autk-prov-insights-section'; + + const annoTitle = document.createElement('div'); + annoTitle.className = 'autk-prov-insights-section-title'; + annoTitle.textContent = `Recorded insights (${annotations.length})`; + annoSection.appendChild(annoTitle); + + for (const a of annotations) { + const item = document.createElement('div'); + item.className = 'autk-prov-anno-item'; + + const step = document.createElement('div'); + step.className = 'autk-prov-anno-step'; + step.textContent = truncate(a.actionLabel, 24); + item.appendChild(step); + + const text = document.createElement('div'); + text.className = 'autk-prov-anno-text'; + text.textContent = a.text; + item.appendChild(text); + + item.addEventListener('click', () => { + provenance.goToNode(a.nodeId); + }); + + annoSection.appendChild(item); + } + + container.appendChild(annoSection); + } + + const summarySection = document.createElement('div'); + summarySection.className = 'autk-prov-insights-section'; + + const summaryTitle = document.createElement('div'); + summaryTitle.className = 'autk-prov-insights-section-title'; + summaryTitle.textContent = 'Analysis summary'; + summarySection.appendChild(summaryTitle); + + const narrative = generateSessionNarrative(graph, metrics, annotations); + + const summaryPre = document.createElement('pre'); + summaryPre.className = 'autk-prov-summary-text'; + summaryPre.textContent = narrative; + summarySection.appendChild(summaryPre); + + const copyBtn = document.createElement('button'); + copyBtn.className = 'autk-prov-copy-btn'; + copyBtn.textContent = 'Copy to clipboard'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(narrative).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy to clipboard'; + }, 1800); + }); + }); + summarySection.appendChild(copyBtn); + + container.appendChild(summarySection); +} diff --git a/autk-provenance/src/ui/styles.ts b/autk-provenance/src/ui/styles.ts new file mode 100644 index 00000000..a435b893 --- /dev/null +++ b/autk-provenance/src/ui/styles.ts @@ -0,0 +1,78 @@ +export function ensureProvenanceTrailStyles(): void { + if (typeof document === 'undefined' || !document.head) return; + + const styleId = 'autk-provenance-trail-styles'; + if (document.getElementById(styleId)) return; + + const el = document.createElement('style'); + el.id = styleId; + el.textContent = [ + '.autk-provenance-root{display:flex;flex-direction:column;height:100%;font-family:system-ui,sans-serif;font-size:13px;gap:10px;min-height:0}', + '.autk-provenance-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}', + '.autk-provenance-toolbar-main{display:flex;align-items:center;gap:6px;flex-wrap:wrap}', + '.autk-provenance-toolbar-hint{font-size:11px;color:#5c7389}', + '.autk-provenance-toggle{padding:6px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', + '.autk-provenance-toggle:hover{background:#f2f7fd}', + '.autk-provenance-toggle:disabled{opacity:.55;cursor:not-allowed}', + '.autk-provenance-graph-wrap{position:relative;border:1px solid #dbe5f0;border-radius:8px;background:#fff;overflow:auto;min-height:280px;height:280px;max-height:360px;cursor:zoom-in}', + '.autk-provenance-graph-svg{display:block;min-width:100%;max-width:none}', + '.autk-provenance-edge{stroke:#b9c8d8;stroke-width:1.4;fill:none}', + '.autk-provenance-node{cursor:pointer}', + '.autk-provenance-node-circle{fill:#f8fbff;stroke:#6f8baa;stroke-width:1.5}', + '.autk-provenance-node-current .autk-provenance-node-circle{fill:#dcecff;stroke:#2f5f96;stroke-width:2}', + '.autk-provenance-node-label{font-size:11px;fill:#1f344b}', + '.autk-provenance-node-time{font-size:10px;fill:#5c7389}', + '.autk-provenance-node-insight-dot{fill:#f59e0b;stroke:#fff;stroke-width:1.5}', + '.autk-provenance-modal-backdrop{position:fixed;inset:0;background:rgba(15,27,44,.48);z-index:11000;display:flex;align-items:center;justify-content:center;padding:24px}', + '.autk-provenance-modal{width:min(1200px,95vw);height:min(860px,90vh);display:flex;flex-direction:column;border-radius:12px;overflow:hidden;background:#f8fbff;box-shadow:0 28px 80px rgba(17,31,47,.45)}', + '.autk-provenance-modal-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px;background:#fff;border-bottom:1px solid #dbe5f0}', + '.autk-provenance-modal-title{font-size:14px;font-weight:700;color:#1f344b}', + '.autk-provenance-modal-controls{display:flex;align-items:center;gap:6px}', + '.autk-provenance-modal-controls button{padding:6px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;color:#1f344b;cursor:pointer;font-size:12px}', + '.autk-provenance-modal-controls button:hover{background:#edf5ff}', + '.autk-provenance-modal-body{padding:12px;display:flex;flex:1;min-height:0}', + '.autk-provenance-modal-canvas{position:relative;flex:1;min-height:0;border:1px solid #dbe5f0;border-radius:8px;background:#fff;overflow:hidden;cursor:grab;touch-action:none}', + '.autk-provenance-modal-canvas.autk-provenance-panning{cursor:grabbing}', + '.autk-provenance-modal-svg{display:block;width:100%;height:100%;min-width:0}', + '.autk-provenance-path{display:flex;flex-direction:column;gap:2px;border:1px solid #dbe5f0;border-radius:8px;background:#fbfdff;max-height:200px;overflow-y:auto}', + '.autk-provenance-path-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #e7eef6;cursor:pointer}', + '.autk-provenance-path-item:last-child{border-bottom:0}', + '.autk-provenance-path-item:hover{background:#f1f7ff}', + '.autk-provenance-path-item-current{background:#e8f2ff;font-weight:600}', + '.autk-provenance-path-label{flex:1;min-width:0}', + '.autk-provenance-path-time{color:#5c7389;font-size:11px;white-space:nowrap}', + '.autk-provenance-buttons{display:flex;gap:6px}', + '.autk-provenance-buttons button{padding:6px 12px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer}', + '.autk-provenance-buttons button:hover:not(:disabled){background:#f2f7fd}', + '.autk-provenance-buttons button:disabled{opacity:.55;cursor:not-allowed}', + '.autk-prov-insights-wrap{border:1px solid #dbe5f0;border-radius:8px;background:#fbfdff;overflow-y:auto;max-height:420px;min-height:0}', + '.autk-prov-insights-header{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-bottom:1px solid #e7eef6;cursor:pointer;user-select:none}', + '.autk-prov-insights-header-label{font-size:12px;font-weight:700;color:#1f344b;letter-spacing:.02em}', + '.autk-prov-insights-chevron{font-size:11px;color:#5c7389}', + '.autk-prov-insights-body{padding:8px 10px;display:flex;flex-direction:column;gap:10px}', + '.autk-prov-insights-metrics{display:flex;align-items:center;gap:8px;flex-wrap:wrap}', + '.autk-prov-strategy-badge{display:inline-block;padding:3px 8px;border-radius:20px;font-size:11px;font-weight:700;color:#fff;white-space:nowrap}', + '.autk-prov-metrics-meta{font-size:11px;color:#5c7389}', + '.autk-prov-insights-section{display:flex;flex-direction:column;gap:5px}', + '.autk-prov-insights-section-title{font-size:11px;font-weight:700;color:#5c7389;text-transform:uppercase;letter-spacing:.05em}', + '.autk-prov-annotation-textarea{width:100%;padding:6px 8px;border:1px solid #c3cfdb;border-radius:6px;font-family:inherit;font-size:12px;resize:vertical;color:#1f344b;background:#fff;box-sizing:border-box}', + '.autk-prov-annotation-textarea:focus{outline:none;border-color:#2f5f96}', + '.autk-prov-annotation-save{align-self:flex-start;padding:5px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', + '.autk-prov-annotation-save:hover:not(:disabled){background:#edf5ff;border-color:#2f5f96;color:#2f5f96}', + '.autk-prov-annotation-save:disabled{opacity:.5;cursor:not-allowed}', + '.autk-prov-freq-group-label{font-size:11px;color:#1f344b;font-weight:600;margin-top:2px}', + '.autk-prov-freq-row{display:flex;align-items:center;gap:6px;min-height:18px}', + '.autk-prov-freq-id{font-size:11px;color:#5c7389;width:48px;flex-shrink:0;font-variant-numeric:tabular-nums}', + '.autk-prov-freq-bar-wrap{flex:1;height:8px;background:#e7eef6;border-radius:4px;overflow:hidden}', + '.autk-prov-freq-bar-fill{height:100%;background:#2f5f96;border-radius:4px;transition:width .3s}', + '.autk-prov-freq-count{font-size:11px;color:#1f344b;width:26px;text-align:right;flex-shrink:0}', + '.autk-prov-anno-item{padding:6px 8px;border-radius:6px;border:1px solid #e7eef6;background:#fff;cursor:pointer}', + '.autk-prov-anno-item:hover{background:#f1f7ff}', + '.autk-prov-anno-step{font-size:10px;color:#5c7389;margin-bottom:2px}', + '.autk-prov-anno-text{font-size:12px;color:#1f344b;line-height:1.4}', + '.autk-prov-summary-text{margin:0;padding:8px;background:#f0f5fb;border:1px solid #dbe5f0;border-radius:6px;font-size:11px;color:#1f344b;white-space:pre-wrap;word-break:break-word;line-height:1.55;max-height:180px;overflow-y:auto}', + '.autk-prov-copy-btn{align-self:flex-start;padding:5px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', + '.autk-prov-copy-btn:hover{background:#edf5ff}', + ].join(''); + document.head.appendChild(el); +} diff --git a/autk-provenance/src/ui/trail-path.ts b/autk-provenance/src/ui/trail-path.ts new file mode 100644 index 00000000..86889169 --- /dev/null +++ b/autk-provenance/src/ui/trail-path.ts @@ -0,0 +1,52 @@ +import type { AutarkProvenanceState, PathNode } from '../types'; +import type { AutarkProvenanceApi } from '../create-autark-provenance'; +import { formatTime } from './utils'; + +export function renderPathList( + container: HTMLElement, + path: PathNode[], + provenance: AutarkProvenanceApi, + showTimestamps: boolean +): void { + container.innerHTML = ''; + const currentId = provenance.getCurrentNode()?.id ?? null; + const graph = provenance.getGraph(); + + for (const node of path) { + const isCurrent = node.id === currentId; + const item = document.createElement('div'); + item.className = `autk-provenance-path-item${isCurrent ? ' autk-provenance-path-item-current' : ''}`; + item.setAttribute('role', 'listitem'); + item.setAttribute('data-node-id', node.id); + + const labelSpan = document.createElement('span'); + labelSpan.className = 'autk-provenance-path-label'; + labelSpan.textContent = node.actionLabel; + item.appendChild(labelSpan); + + const graphNode = graph.nodes.get(node.id); + const hasAnnotation = + typeof graphNode?.metadata?.insight === 'string' && + (graphNode.metadata.insight as string).trim().length > 0; + if (hasAnnotation) { + const dot = document.createElement('span'); + dot.title = graphNode!.metadata!.insight as string; + dot.style.cssText = + 'display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0'; + item.appendChild(dot); + } + + if (showTimestamps) { + const timeSpan = document.createElement('span'); + timeSpan.className = 'autk-provenance-path-time'; + timeSpan.textContent = formatTime(node.timestamp); + item.appendChild(timeSpan); + } + + item.addEventListener('click', () => { + provenance.goToNode(node.id); + }); + + container.appendChild(item); + } +} diff --git a/autk-provenance/src/ui/trail-shell.ts b/autk-provenance/src/ui/trail-shell.ts new file mode 100644 index 00000000..def05c45 --- /dev/null +++ b/autk-provenance/src/ui/trail-shell.ts @@ -0,0 +1,94 @@ +export interface GraphSectionElements { + graphWrap: HTMLDivElement; + graphToggleBtn: HTMLButtonElement; + graphExpandBtn: HTMLButtonElement; + graphHint: HTMLSpanElement; +} + +export interface InsightsSectionElements { + wrap: HTMLDivElement; + body: HTMLDivElement; + chevron: HTMLSpanElement; +} + +export function createGraphSection(container: HTMLElement): GraphSectionElements { + const toolbar = document.createElement('div'); + toolbar.className = 'autk-provenance-toolbar'; + const toolbarMain = document.createElement('div'); + toolbarMain.className = 'autk-provenance-toolbar-main'; + + const graphToggleBtn = document.createElement('button'); + graphToggleBtn.className = 'autk-provenance-toggle'; + toolbarMain.appendChild(graphToggleBtn); + + const graphExpandBtn = document.createElement('button'); + graphExpandBtn.className = 'autk-provenance-toggle'; + toolbarMain.appendChild(graphExpandBtn); + + const graphHint = document.createElement('span'); + graphHint.className = 'autk-provenance-toolbar-hint'; + + toolbar.appendChild(toolbarMain); + toolbar.appendChild(graphHint); + container.appendChild(toolbar); + + const graphWrap = document.createElement('div'); + graphWrap.className = 'autk-provenance-graph-wrap'; + container.appendChild(graphWrap); + + return { graphWrap, graphToggleBtn, graphExpandBtn, graphHint }; +} + +export function createPathSection(container: HTMLElement): HTMLDivElement { + const pathContainer = document.createElement('div'); + pathContainer.className = 'autk-provenance-path'; + pathContainer.setAttribute('role', 'list'); + container.appendChild(pathContainer); + return pathContainer; +} + +export function createNavButtons( + container: HTMLElement, + onBack: () => void, + onForward: () => void +): { backBtn: HTMLButtonElement; fwdBtn: HTMLButtonElement } { + const btnRow = document.createElement('div'); + btnRow.className = 'autk-provenance-buttons'; + const backBtn = document.createElement('button'); + backBtn.textContent = '\u2190 Back'; + backBtn.setAttribute('aria-label', 'Go back one step'); + const fwdBtn = document.createElement('button'); + fwdBtn.textContent = 'Forward \u2192'; + fwdBtn.setAttribute('aria-label', 'Go forward one step'); + backBtn.addEventListener('click', onBack); + fwdBtn.addEventListener('click', onForward); + btnRow.appendChild(backBtn); + btnRow.appendChild(fwdBtn); + container.appendChild(btnRow); + return { backBtn, fwdBtn }; +} + +export function createInsightsSection(container: HTMLElement): InsightsSectionElements { + const wrap = document.createElement('div'); + wrap.className = 'autk-prov-insights-wrap'; + + const header = document.createElement('div'); + header.className = 'autk-prov-insights-header'; + const headerLabel = document.createElement('span'); + headerLabel.className = 'autk-prov-insights-header-label'; + headerLabel.textContent = 'Session Insights'; + const chevron = document.createElement('span'); + chevron.className = 'autk-prov-insights-chevron'; + chevron.textContent = '\u25b4'; + header.appendChild(headerLabel); + header.appendChild(chevron); + + const body = document.createElement('div'); + body.className = 'autk-prov-insights-body'; + + wrap.appendChild(header); + wrap.appendChild(body); + container.appendChild(wrap); + + return { wrap, body, chevron }; +} diff --git a/autk-provenance/src/ui/utils.ts b/autk-provenance/src/ui/utils.ts new file mode 100644 index 00000000..aec6129a --- /dev/null +++ b/autk-provenance/src/ui/utils.ts @@ -0,0 +1,20 @@ +export function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +export function truncate(text: string, max = 30): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}\u2026`; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function formatDurationShort(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + return `${Math.floor(s / 60)}m ${s % 60}s`; +} diff --git a/gallery/package.json b/gallery/package.json index 1f19cdcb..a5f66cb8 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", + "dev": "node ./node_modules/vite/bin/vite.js", + "build": "tsc && node ./node_modules/vite/bin/vite.js build", + "preview": "node ./node_modules/vite/bin/vite.js preview", "format": "prettier --write ." }, "devDependencies": { From ac2c67df1de161507c0cf7dc8d6e8b784773b8c6 Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Tue, 5 May 2026 16:13:40 -0500 Subject: [PATCH 13/21] fixes: Modularizing files --- autk-provenance/src/adapters/db-adapter.ts | 194 +++------------ autk-provenance/src/adapters/db/recorder.ts | 72 ++++++ autk-provenance/src/adapters/db/state.ts | 17 ++ autk-provenance/src/adapters/db/types.ts | 33 +++ autk-provenance/src/adapters/db/utils.ts | 29 +++ autk-provenance/src/adapters/db/wrappers.ts | 52 ++++ autk-provenance/src/adapters/map-adapter.ts | 222 +++--------------- autk-provenance/src/adapters/map/dom.ts | 53 +++++ autk-provenance/src/adapters/map/recording.ts | 111 +++++++++ autk-provenance/src/adapters/map/state.ts | 56 +++++ autk-provenance/src/adapters/map/types.ts | 50 ++++ autk-provenance/src/adapters/map/utils.ts | 70 ++++++ autk-provenance/src/core.ts | 81 +------ autk-provenance/src/core/api.ts | 24 ++ autk-provenance/src/core/state.ts | 33 +++ autk-provenance/src/core/store.ts | 135 +++++++++++ autk-provenance/src/insight-engine.ts | 9 +- autk-provenance/src/insights/annotations.ts | 16 +- autk-provenance/src/insights/graph-metrics.ts | 72 +++--- autk-provenance/src/insights/narrative.ts | 68 +++--- .../src/insights/selection-frequency.ts | 22 +- autk-provenance/src/provenance-trail-ui.ts | 207 +++++----------- autk-provenance/src/ui/graph-layout.ts | 103 +++----- autk-provenance/src/ui/graph-modal-shell.ts | 90 +++++++ autk-provenance/src/ui/graph-modal.ts | 167 ++++++------- autk-provenance/src/ui/graph-preview.ts | 34 +-- autk-provenance/src/ui/graph-scene.ts | 60 ++--- autk-provenance/src/ui/insights-panel.ts | 194 +++++++-------- autk-provenance/src/ui/path-list.ts | 45 ++++ autk-provenance/src/ui/styles.ts | 9 +- autk-provenance/src/ui/toolbar.ts | 89 +++++++ autk-provenance/src/ui/utils.ts | 15 +- 32 files changed, 1437 insertions(+), 995 deletions(-) create mode 100644 autk-provenance/src/adapters/db/recorder.ts create mode 100644 autk-provenance/src/adapters/db/state.ts create mode 100644 autk-provenance/src/adapters/db/types.ts create mode 100644 autk-provenance/src/adapters/db/utils.ts create mode 100644 autk-provenance/src/adapters/db/wrappers.ts create mode 100644 autk-provenance/src/adapters/map/dom.ts create mode 100644 autk-provenance/src/adapters/map/recording.ts create mode 100644 autk-provenance/src/adapters/map/state.ts create mode 100644 autk-provenance/src/adapters/map/types.ts create mode 100644 autk-provenance/src/adapters/map/utils.ts create mode 100644 autk-provenance/src/core/api.ts create mode 100644 autk-provenance/src/core/state.ts create mode 100644 autk-provenance/src/core/store.ts create mode 100644 autk-provenance/src/ui/graph-modal-shell.ts create mode 100644 autk-provenance/src/ui/path-list.ts create mode 100644 autk-provenance/src/ui/toolbar.ts diff --git a/autk-provenance/src/adapters/db-adapter.ts b/autk-provenance/src/adapters/db-adapter.ts index a8b91c27..c40ce731 100644 --- a/autk-provenance/src/adapters/db-adapter.ts +++ b/autk-provenance/src/adapters/db-adapter.ts @@ -1,33 +1,10 @@ import type { AutarkProvenanceState } from '../types'; -import { - createDbRecorderSet, - inferName, - isFn, - type DbRecordCallback, - type DbTableLike, -} from './db-adapter-shared'; -export { createDbProvenanceWrapper } from './db-provenance-wrapper'; +import { createDbRecorder } from './db/recorder'; +import { applyDbState } from './db/state'; +import type { DbRecordCallback, IDbForProvenance } from './db/types'; +import { createDbMethodRecordingController } from './db/wrappers'; -export type { DbRecordCallback }; - -export interface IDbForProvenance { - getCurrentWorkspace(): string; - getWorkspaces?(): string[]; - setWorkspace(name: string): Promise; - tables: DbTableLike[]; - init?(): Promise; - loadOsmFromOverpassApi?(params: { outputTableName?: string }): Promise; - loadCsv?(params: { outputTableName?: string }): Promise; - loadJson?(params: { outputTableName?: string }): Promise; - loadLayer?(params: { layer?: string; outputTableName?: string }): Promise; - loadCustomLayer?(params: { outputTableName?: string }): Promise; - loadGridLayer?(params: { outputTableName?: string }): Promise; - getLayer?(layerTableName: string): Promise; - spatialJoin?(params: { tableRootName?: string; tableJoinName?: string }): Promise; - updateTable?(params: { tableName?: string }): Promise; - rawQuery?(params: { output?: { type?: string } }): Promise; - buildHeatmap?(params: { outputTableName?: string }): Promise; -} +export type { DbRecordCallback, IDbForProvenance } from './db/types'; export interface DbAdapterApi { startRecording(): void; @@ -50,141 +27,38 @@ export interface DbAdapterApi { applyState(state: AutarkProvenanceState): Promise; } -export function createDbAdapter( - db: IDbForProvenance, - onRecord: DbRecordCallback -): DbAdapterApi { - const dbObj = db as unknown as Record; - const recorder = createDbRecorderSet(db, onRecord); - const originalMethods = new Map(); +export function createDbAdapter(db: IDbForProvenance, onRecord: DbRecordCallback): DbAdapterApi { let isRecording = false; let isApplyingState = false; - - function wrapAsyncMethod( - methodName: string, - onAfter: (args: unknown[], result: unknown) => void - ): void { - const current = dbObj[methodName]; - if (!isFn(current) || originalMethods.has(methodName)) return; - const original = current; - originalMethods.set(methodName, original); - - dbObj[methodName] = async function (...args: unknown[]) { - const result = await (original as (...a: unknown[]) => unknown).apply(this, args); - if (isRecording && !isApplyingState) { - onAfter(args, result); - } - return result; - }; - } - - function restoreWrappedMethods(): void { - for (const [methodName, original] of originalMethods.entries()) { - dbObj[methodName] = original; - } - originalMethods.clear(); - } - - function startRecording(): void { - if (isRecording) return; - isRecording = true; - - recorder.bootstrapFromCurrentState(); - - wrapAsyncMethod('init', () => { - recorder.recordInit(); - }); - wrapAsyncMethod('setWorkspace', (args) => { - const [name] = args; - recorder.recordWorkspace(typeof name === 'string' ? name : recorder.getWorkspaceSafe()); - }); - wrapAsyncMethod('loadOsmFromOverpassApi', (args) => { - const params = (args[0] ?? {}) as { outputTableName?: string }; - recorder.recordLoadOsm(params.outputTableName ?? 'osm'); - }); - wrapAsyncMethod('loadCsv', (args, result) => { - const params = (args[0] ?? {}) as { outputTableName?: string }; - recorder.recordLoadCsv(params.outputTableName ?? inferName(result, 'csv')); - }); - wrapAsyncMethod('loadJson', (args, result) => { - const params = (args[0] ?? {}) as { outputTableName?: string }; - recorder.recordLoadJson(params.outputTableName ?? inferName(result, 'json')); - }); - wrapAsyncMethod('loadLayer', (args, result) => { - const params = (args[0] ?? {}) as { layer?: string; outputTableName?: string }; - recorder.recordLoadLayer(params.outputTableName ?? params.layer ?? inferName(result, 'layer')); - }); - wrapAsyncMethod('loadCustomLayer', (args, result) => { - const params = (args[0] ?? {}) as { outputTableName?: string }; - recorder.recordLoadCustomLayer(params.outputTableName ?? inferName(result, 'custom-layer')); - }); - wrapAsyncMethod('loadGridLayer', (args, result) => { - const params = (args[0] ?? {}) as { outputTableName?: string }; - recorder.recordLoadGridLayer(params.outputTableName ?? inferName(result, 'grid-layer')); - }); - wrapAsyncMethod('getLayer', (args) => { - const [tableName] = args; - recorder.recordGetLayer(typeof tableName === 'string' ? tableName : 'layer'); - }); - wrapAsyncMethod('spatialJoin', (args) => { - const params = (args[0] ?? {}) as { tableRootName?: string; tableJoinName?: string }; - recorder.recordSpatialJoin(params); - }); - wrapAsyncMethod('updateTable', (args, result) => { - const params = (args[0] ?? {}) as { tableName?: string }; - recorder.recordUpdateTable(params.tableName ?? inferName(result, 'table')); - }); - wrapAsyncMethod('rawQuery', (args) => { - const params = (args[0] ?? {}) as { output?: { type?: string } }; - const label = params.output?.type === 'CREATE_TABLE' - ? 'Raw query created table' - : 'Raw query executed'; - recorder.recordRawQuery(label); - }); - wrapAsyncMethod('buildHeatmap', (args, result) => { - const params = (args[0] ?? {}) as { outputTableName?: string }; - recorder.recordBuildHeatmap(params.outputTableName ?? inferName(result, 'heatmap')); - }); - } - - function stopRecording(): void { - if (!isRecording) return; - isRecording = false; - restoreWrappedMethods(); - } - - async function applyState(state: AutarkProvenanceState): Promise { - const data = state.data; - if (!data?.workspace) return; - const current = recorder.getWorkspaceSafe(); - if (current !== data.workspace) { - isApplyingState = true; - try { - await db.setWorkspace(data.workspace); - } finally { - isApplyingState = false; - } - } - } + const recorder = createDbRecorder(db, onRecord); + const methodRecording = createDbMethodRecordingController({ + db, + recorder, + isRecording: () => isRecording, + isApplyingState: () => isApplyingState, + }); return { - startRecording, - stopRecording, - bootstrapFromCurrentState: recorder.bootstrapFromCurrentState, - recordInit: recorder.recordInit, - recordWorkspace: recorder.recordWorkspace, - recordLoadOsm: recorder.recordLoadOsm, - recordLoadCsv: recorder.recordLoadCsv, - recordLoadJson: recorder.recordLoadJson, - recordLoadLayer: recorder.recordLoadLayer, - recordLoadCustomLayer: recorder.recordLoadCustomLayer, - recordLoadGridLayer: recorder.recordLoadGridLayer, - recordGetLayer: recorder.recordGetLayer, - recordSpatialJoin: recorder.recordSpatialJoin, - recordUpdateTable: recorder.recordUpdateTable, - recordDropTable: recorder.recordDropTable, - recordRawQuery: recorder.recordRawQuery, - recordBuildHeatmap: recorder.recordBuildHeatmap, - applyState, + startRecording: () => { + if (isRecording) return; + isRecording = true; + recorder.bootstrapFromCurrentState(); + methodRecording.start(); + }, + stopRecording: () => { + if (!isRecording) return; + isRecording = false; + methodRecording.stop(); + }, + ...recorder, + applyState: (state) => applyDbState(db, state, (value) => { isApplyingState = value; }), }; } + +export function createDbProvenanceWrapper( + db: T, + onRecord: DbRecordCallback +): T { + createDbAdapter(db, onRecord).startRecording(); + return db; +} diff --git a/autk-provenance/src/adapters/db/recorder.ts b/autk-provenance/src/adapters/db/recorder.ts new file mode 100644 index 00000000..aa8ecb07 --- /dev/null +++ b/autk-provenance/src/adapters/db/recorder.ts @@ -0,0 +1,72 @@ +import { ProvenanceAction } from '../../types'; +import type { DbRecordCallback, DbTableLike, IDbForProvenance } from './types'; +import { appendLayerName, getCurrentLayerNames, getWorkspaceSafe } from './utils'; + +export interface DbRecorder { + bootstrapFromCurrentState(): void; + recordInit(): void; + recordWorkspace(name: string): void; + recordLoadOsm(name: string): void; + recordLoadCsv(name: string): void; + recordLoadJson(name: string): void; + recordLoadLayer(name: string): void; + recordLoadCustomLayer(name: string): void; + recordLoadGridLayer(name: string): void; + recordGetLayer(name: string): void; + recordSpatialJoin(params: { tableRootName?: string; tableJoinName?: string }): void; + recordUpdateTable(name: string): void; + recordDropTable(name: string): void; + recordRawQuery(label?: string): void; + recordBuildHeatmap(name: string): void; +} + +export function createDbRecorder(db: IDbForProvenance, onRecord: DbRecordCallback): DbRecorder { + let didBootstrap = false; + const record = (actionType: ProvenanceAction, label: string, names = getCurrentLayerNames(db)) => { + onRecord(actionType, label, { data: { workspace: getWorkspaceSafe(db), layerTableNames: names } }); + }; + const recordLoad = (actionType: ProvenanceAction, prefix: string, name: string) => { + record(actionType, `${prefix}: ${name}`, appendLayerName(getCurrentLayerNames(db), name)); + }; + + const recorder: DbRecorder = { + bootstrapFromCurrentState: () => { + if (didBootstrap) return; + didBootstrap = true; + recorder.recordInit(); + recorder.recordWorkspace(getWorkspaceSafe(db)); + (db.tables || []).forEach((table) => recordTableBySource(table, recorder, record)); + }, + recordInit: () => record(ProvenanceAction.DB_INIT, 'Database initialized'), + recordWorkspace: (name) => onRecord(ProvenanceAction.DB_WORKSPACE, `Workspace: ${name}`, { data: { workspace: name, layerTableNames: getCurrentLayerNames(db) } }), + recordLoadOsm: (name) => recordLoad(ProvenanceAction.DB_LOAD_OSM, 'Load OSM', name), + recordLoadCsv: (name) => recordLoad(ProvenanceAction.DB_LOAD_CSV, 'Load CSV', name), + recordLoadJson: (name) => recordLoad(ProvenanceAction.DB_LOAD_JSON, 'Load JSON', name), + recordLoadLayer: (name) => recordLoad(ProvenanceAction.DB_LOAD_LAYER, 'Load layer', name), + recordLoadCustomLayer: (name) => recordLoad(ProvenanceAction.DB_LOAD_CUSTOM_LAYER, 'Load custom layer', name), + recordLoadGridLayer: (name) => recordLoad(ProvenanceAction.DB_LOAD_GRID_LAYER, 'Load grid layer', name), + recordGetLayer: (name) => record(ProvenanceAction.DB_GET_LAYER, `Get layer: ${name}`), + recordSpatialJoin: (params) => record(ProvenanceAction.DB_SPATIAL_JOIN, `Spatial join: ${params.tableRootName ?? '?'} + ${params.tableJoinName ?? '?'}`), + recordUpdateTable: (name) => record(ProvenanceAction.DB_UPDATE_TABLE, `Update table: ${name}`), + recordDropTable: (name) => record(ProvenanceAction.DB_DROP_TABLE, `Drop table: ${name}`, getCurrentLayerNames(db).filter((table) => table !== name)), + recordRawQuery: (label = 'Raw query executed') => record(ProvenanceAction.DB_RAW_QUERY, label), + recordBuildHeatmap: (name) => recordLoad(ProvenanceAction.DB_BUILD_HEATMAP, 'Build heatmap', name), + }; + + return recorder; +} + +function recordTableBySource( + table: DbTableLike, + recorder: DbRecorder, + record: (actionType: ProvenanceAction, label: string) => void +): void { + switch (table.source) { + case 'csv': return recorder.recordLoadCsv(table.name); + case 'json': return recorder.recordLoadJson(table.name); + case 'osm': return table.type === 'pointset' ? recorder.recordLoadOsm(table.name) : recorder.recordLoadLayer(table.name); + case 'geojson': return recorder.recordLoadCustomLayer(table.name); + case 'user': return table.type === 'pointset' ? record(ProvenanceAction.DB_OTHER, `User table: ${table.name}`) : recorder.recordLoadGridLayer(table.name); + default: return record(ProvenanceAction.DB_OTHER, `Table available: ${table.name}`); + } +} diff --git a/autk-provenance/src/adapters/db/state.ts b/autk-provenance/src/adapters/db/state.ts new file mode 100644 index 00000000..fe455716 --- /dev/null +++ b/autk-provenance/src/adapters/db/state.ts @@ -0,0 +1,17 @@ +import type { AutarkProvenanceState } from '../../types'; +import type { IDbForProvenance } from './types'; +import { getWorkspaceSafe } from './utils'; + +export async function applyDbState( + db: IDbForProvenance, + state: AutarkProvenanceState, + setApplyingState: (value: boolean) => void +): Promise { + if (!state.data?.workspace || getWorkspaceSafe(db) === state.data.workspace) return; + setApplyingState(true); + try { + await db.setWorkspace(state.data.workspace); + } finally { + setApplyingState(false); + } +} diff --git a/autk-provenance/src/adapters/db/types.ts b/autk-provenance/src/adapters/db/types.ts new file mode 100644 index 00000000..5e79e44b --- /dev/null +++ b/autk-provenance/src/adapters/db/types.ts @@ -0,0 +1,33 @@ +import type { AutarkProvenanceState } from '../../types'; +import { ProvenanceAction } from '../../types'; + +export type DbTableLike = { + name: string; + source?: string; + type?: string; +}; + +export interface IDbForProvenance { + getCurrentWorkspace(): string; + getWorkspaces?(): string[]; + setWorkspace(name: string): Promise; + tables: DbTableLike[]; + init?(): Promise; + loadOsmFromOverpassApi?(params: { outputTableName?: string }): Promise; + loadCsv?(params: { outputTableName?: string }): Promise; + loadJson?(params: { outputTableName?: string }): Promise; + loadLayer?(params: { layer?: string; outputTableName?: string }): Promise; + loadCustomLayer?(params: { outputTableName?: string }): Promise; + loadGridLayer?(params: { outputTableName?: string }): Promise; + getLayer?(layerTableName: string): Promise; + spatialJoin?(params: { tableRootName?: string; tableJoinName?: string }): Promise; + updateTable?(params: { tableName?: string }): Promise; + rawQuery?(params: { output?: { type?: string } }): Promise; + buildHeatmap?(params: { outputTableName?: string }): Promise; +} + +export type DbRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; diff --git a/autk-provenance/src/adapters/db/utils.ts b/autk-provenance/src/adapters/db/utils.ts new file mode 100644 index 00000000..d64f74b9 --- /dev/null +++ b/autk-provenance/src/adapters/db/utils.ts @@ -0,0 +1,29 @@ +import type { IDbForProvenance } from './types'; + +export function inferName(result: unknown, fallback = 'table'): string { + if (result && typeof result === 'object' && 'name' in result) { + const maybeName = (result as { name?: unknown }).name; + if (typeof maybeName === 'string' && maybeName.trim().length > 0) return maybeName; + } + return fallback; +} + +export function isFunction(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === 'function'; +} + +export function getCurrentLayerNames(db: IDbForProvenance): string[] { + return (db.tables || []).map((table) => table.name); +} + +export function getWorkspaceSafe(db: IDbForProvenance): string { + try { + return db.getCurrentWorkspace(); + } catch { + return 'main'; + } +} + +export function appendLayerName(names: string[], tableName: string): string[] { + return names.includes(tableName) ? names : [...names, tableName]; +} diff --git a/autk-provenance/src/adapters/db/wrappers.ts b/autk-provenance/src/adapters/db/wrappers.ts new file mode 100644 index 00000000..ac77978c --- /dev/null +++ b/autk-provenance/src/adapters/db/wrappers.ts @@ -0,0 +1,52 @@ +import type { DbRecorder } from './recorder'; +import type { IDbForProvenance } from './types'; +import { inferName, isFunction } from './utils'; + +export function createDbMethodRecordingController(options: { + db: IDbForProvenance; + recorder: DbRecorder; + isRecording: () => boolean; + isApplyingState: () => boolean; +}): { start(): void; stop(): void } { + const { db, recorder, isRecording, isApplyingState } = options; + const dbObj = db as unknown as Record; + const originalMethods = new Map(); + + function wrapAsyncMethod(methodName: string, onAfter: (args: unknown[], result: unknown) => void): void { + const current = dbObj[methodName]; + if (!isFunction(current) || originalMethods.has(methodName)) return; + originalMethods.set(methodName, current); + dbObj[methodName] = async function (...args: unknown[]) { + const result = await current.apply(this, args); + if (isRecording() && !isApplyingState()) onAfter(args, result); + return result; + }; + } + + return { + start: () => { + wrapAsyncMethod('init', () => recorder.recordInit()); + wrapAsyncMethod('setWorkspace', ([name]) => recorder.recordWorkspace(typeof name === 'string' ? name : db.getCurrentWorkspace())); + wrapAsyncMethod('loadOsmFromOverpassApi', ([params]) => recorder.recordLoadOsm(((params ?? {}) as { outputTableName?: string }).outputTableName ?? 'osm')); + wrapAsyncMethod('loadCsv', ([params], result) => recorder.recordLoadCsv(((params ?? {}) as { outputTableName?: string }).outputTableName ?? inferName(result, 'csv'))); + wrapAsyncMethod('loadJson', ([params], result) => recorder.recordLoadJson(((params ?? {}) as { outputTableName?: string }).outputTableName ?? inferName(result, 'json'))); + wrapAsyncMethod('loadLayer', ([params], result) => { + const resolved = (params ?? {}) as { layer?: string; outputTableName?: string }; + recorder.recordLoadLayer(resolved.outputTableName ?? resolved.layer ?? inferName(result, 'layer')); + }); + wrapAsyncMethod('loadCustomLayer', ([params], result) => recorder.recordLoadCustomLayer(((params ?? {}) as { outputTableName?: string }).outputTableName ?? inferName(result, 'custom-layer'))); + wrapAsyncMethod('loadGridLayer', ([params], result) => recorder.recordLoadGridLayer(((params ?? {}) as { outputTableName?: string }).outputTableName ?? inferName(result, 'grid-layer'))); + wrapAsyncMethod('getLayer', ([tableName]) => recorder.recordGetLayer(typeof tableName === 'string' ? tableName : 'layer')); + wrapAsyncMethod('spatialJoin', ([params]) => recorder.recordSpatialJoin((params ?? {}) as { tableRootName?: string; tableJoinName?: string })); + wrapAsyncMethod('updateTable', ([params], result) => recorder.recordUpdateTable(((params ?? {}) as { tableName?: string }).tableName ?? inferName(result, 'table'))); + wrapAsyncMethod('rawQuery', ([params]) => recorder.recordRawQuery(((params ?? {}) as { output?: { type?: string } }).output?.type === 'CREATE_TABLE' ? 'Raw query created table' : 'Raw query executed')); + wrapAsyncMethod('buildHeatmap', ([params], result) => recorder.recordBuildHeatmap(((params ?? {}) as { outputTableName?: string }).outputTableName ?? inferName(result, 'heatmap'))); + }, + stop: () => { + originalMethods.forEach((original, methodName) => { + dbObj[methodName] = original; + }); + originalMethods.clear(); + }, + }; +} diff --git a/autk-provenance/src/adapters/map-adapter.ts b/autk-provenance/src/adapters/map-adapter.ts index 37519be8..b1933e55 100644 --- a/autk-provenance/src/adapters/map-adapter.ts +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -1,195 +1,51 @@ -import type { AutarkProvenanceState, IMapForProvenance, MapViewState } from '../types'; -import { ProvenanceAction } from '../types'; -import { - resolveMapSelectors, - type CustomControlConfig, - type MapAdapterApi, - type MapRecordCallback, - type MapSelectorConfig, -} from './map-adapter-shared'; -import { createMapUiHelpers } from './map-ui-helpers'; -import { applyMapProvenanceState } from './map-adapter-state'; -import { bindCustomControlEvent } from './map-adapter-recording'; -import { isElement } from './map-adapter-shared'; -const MAP_PICK_EVENT = 'pick'; -export type { CustomControlConfig, MapAdapterApi, MapRecordCallback, MapSelectorConfig }; +import type { AutarkProvenanceState, IMapForProvenance } from '../types'; +import { createMapRecordingController } from './map/recording'; +import { applyMapState } from './map/state'; +import type { CustomControlConfig, MapRecordCallback, MapSelectorConfig } from './map/types'; +import { getActiveLayerId, getMenuOpen, getThematicEnabled, getVisibleLayerIds, resolveMapSelectors } from './map/utils'; + +export type { CustomControlConfig, MapRecordCallback, MapSelectorConfig } from './map/types'; + +export interface MapAdapterApi { + startRecording(): void; + stopRecording(): void; + applyState(state: AutarkProvenanceState): void; +} export function createMapAdapter( map: IMapForProvenance, onRecord: MapRecordCallback, selectorConfig?: MapSelectorConfig ): MapAdapterApi { - const { selectors, customControls } = resolveMapSelectors(selectorConfig); - const mapObj = map as unknown as Record; - const ui = createMapUiHelpers(map, selectors, customControls); - - let pickListener: ((selection: number[], layerId: string) => void) | null = null; - let viewListener: ((state: MapViewState) => void) | null = null; - let clickListener: ((event: Event) => void) | null = null; - let changeListener: ((event: Event) => void) | null = null; + const selectors = resolveMapSelectors(selectorConfig); + const customControls: CustomControlConfig[] = selectorConfig?.customControls ?? []; let isApplyingState = false; let currentState: AutarkProvenanceState = { selection: { map: null, plots: {} } }; - const wrappedMapMethods = new Map(); - - function recordUiEvent( - actionType: ProvenanceAction, - actionLabel: string, - overrides: Partial> = {} - ): void { - onRecord(actionType, actionLabel, ui.buildUiDelta(overrides)); - } - - function wrapMapMethod(methodName: string, onAfter: (args: unknown[]) => void): void { - const current = mapObj[methodName]; - if (typeof current !== 'function' || wrappedMapMethods.has(methodName)) return; - const original = current; - wrappedMapMethods.set(methodName, original); - - mapObj[methodName] = function (...args: unknown[]) { - const result = (original as (...a: unknown[]) => unknown).apply(this, args); - if (!isApplyingState) { - onAfter(args); - } - return result; - }; - } - - function restoreWrappedMapMethods(): void { - for (const [methodName, original] of wrappedMapMethods.entries()) { - mapObj[methodName] = original; - } - wrappedMapMethods.clear(); - } - - function startRecording(): void { - if (pickListener || clickListener || changeListener) return; - - wrapMapMethod('init', () => { - onRecord(ProvenanceAction.MAP_INIT, 'Map initialized', ui.buildUiDelta()); - }); - wrapMapMethod('loadGeoJsonLayer', (args) => { - const [layerName] = args; - const name = typeof layerName === 'string' ? layerName : 'layer'; - onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Map layer loaded: ${name}`, ui.buildUiDelta()); - }); - wrapMapMethod('loadGeoTiffLayer', (args) => { - const [layerName] = args; - const name = typeof layerName === 'string' ? layerName : 'raster'; - onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${name}`, ui.buildUiDelta()); - }); - - pickListener = (selection: number[], layerId: string) => { - const activePlotIds = new Set( - Object.values(currentState.selection?.plots ?? {}).flatMap((plotState) => plotState.ids) - ); - const mapOwnedSelection = selection.filter((id) => !activePlotIds.has(id)); - const label = - mapOwnedSelection.length === 0 - ? `Cleared selection on ${layerId}` - : `Picked ${mapOwnedSelection.length} feature(s) on ${layerId}`; - onRecord(ProvenanceAction.MAP_PICK, label, { - selection: { - map: { layerId, ids: mapOwnedSelection }, - plots: {}, - }, - }); - }; - map.mapEvents.addEventListener(MAP_PICK_EVENT, pickListener); - - if (map.addViewListener) { - viewListener = (viewState: MapViewState) => { - if (isApplyingState) return; - const alt = viewState.eye[2].toFixed(0); - onRecord(ProvenanceAction.MAP_VIEW, `View changed (alt: ${alt})`, { view: viewState }); - }; - map.addViewListener(viewListener); - } - - if (typeof document === 'undefined') return; - - clickListener = (event: Event) => { - if (isApplyingState) return; - const target = event.target; - if (!isElement(target)) return; - if (bindCustomControlEvent(customControls, 'click', target, onRecord)) return; - if (!ui.inMapContainer(target)) return; - - if (target.closest(selectors.menuIcon)) { - const isOpen = !!ui.buildUiDelta().ui?.mapMenuOpen; - recordUiEvent(ProvenanceAction.MAP_UI_MENU_TOGGLE, isOpen ? 'Opened map menu' : 'Closed map menu', { - mapMenuOpen: isOpen, - }); - } - }; - document.addEventListener('click', clickListener); - - changeListener = (event: Event) => { - if (isApplyingState) return; - const target = event.target; - if (!isElement(target)) return; - if (bindCustomControlEvent(customControls, 'change', target, onRecord)) return; - if (!ui.inMapContainer(target)) return; - if (!(target instanceof HTMLInputElement)) return; - - if (target.id === selectors.thematicCheckbox.replace(/^#/, '')) { - recordUiEvent(ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', { - thematicEnabled: target.checked, - }); - return; - } - - if (target.classList.contains(selectors.activeLayerRadioClass.replace(/^\./, ''))) { - const activeLayerId = ui.getActiveLayerId() ?? target.value; - recordUiEvent(ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, `Active layer: ${activeLayerId}`, { - activeLayerId, - }); - return; - } - - const listSel = selectors.visibleLayerList.replace(/^#/, ''); - if (target.type === 'checkbox' && target.closest(`#${listSel}`)) { - const layerId = target.value || 'layer'; - recordUiEvent(ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, target.checked ? `Show layer: ${layerId}` : `Hide layer: ${layerId}`, { - visibleLayerIds: ui.getVisibleLayerIds(), - }); - } - }; - document.addEventListener('change', changeListener); - } - - function stopRecording(): void { - if (pickListener && map.mapEvents.removeEventListener) { - map.mapEvents.removeEventListener(MAP_PICK_EVENT, pickListener); - pickListener = null; - } - if (viewListener && map.removeViewListener) { - map.removeViewListener(viewListener); - viewListener = null; - } - if (clickListener && typeof document !== 'undefined') { - document.removeEventListener('click', clickListener); - clickListener = null; - } - if (changeListener && typeof document !== 'undefined') { - document.removeEventListener('change', changeListener); - changeListener = null; - } - restoreWrappedMapMethods(); - } - - function applyState(state: AutarkProvenanceState): void { - isApplyingState = true; - try { - currentState = state; - applyMapProvenanceState(map, ui, selectors, state); - } finally { - isApplyingState = false; - } - } + const buildUiDelta = (overrides: Partial> = {}) => ({ + ui: { + mapMenuOpen: getMenuOpen(map, selectors), + activeLayerId: getActiveLayerId(map), + visibleLayerIds: getVisibleLayerIds(map), + thematicEnabled: getThematicEnabled(map), + ...overrides, + }, + }); + const recording = createMapRecordingController({ + map, + selectors, + customControls, + onRecord, + getCurrentState: () => currentState, + isApplyingState: () => isApplyingState, + buildUiDelta, + }); return { - startRecording, - stopRecording, - applyState, + startRecording: recording.start, + stopRecording: recording.stop, + applyState: (state) => { + currentState = state; + applyMapState({ map, selectors, customControls, state, setApplyingState: (value) => { isApplyingState = value; } }); + }, }; } diff --git a/autk-provenance/src/adapters/map/dom.ts b/autk-provenance/src/adapters/map/dom.ts new file mode 100644 index 00000000..a39b9424 --- /dev/null +++ b/autk-provenance/src/adapters/map/dom.ts @@ -0,0 +1,53 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../../types'; +import type { CustomControlConfig, ResolvedMapSelectors, ResolvedUiState } from './types'; + +export function syncUiDom( + map: IMapForProvenance, + selectors: ResolvedMapSelectors, + ui: ResolvedUiState +): void { + const parent = map.canvas.parentElement; + if (!parent) return; + + const submenu = parent.querySelector(selectors.subMenu) as HTMLElement | null; + if (submenu) submenu.style.visibility = ui.mapMenuOpen ? 'visible' : 'hidden'; + + const thematic = parent.querySelector(selectors.thematicCheckbox) as HTMLInputElement | null; + if (thematic) thematic.checked = ui.thematicEnabled; + + const legend = parent.querySelector(selectors.legend) as HTMLElement | null; + if (legend) legend.style.visibility = ui.thematicEnabled ? 'visible' : 'hidden'; + + const visibleSet = new Set(ui.visibleLayerIds); + parent + .querySelectorAll(`${selectors.visibleLayerList} input[type="checkbox"]`) + .forEach((input) => { + const checkbox = input as HTMLInputElement; + checkbox.checked = visibleSet.has(checkbox.value); + }); + + parent.querySelectorAll(selectors.activeLayerRadioClass).forEach((input) => { + const radio = input as HTMLInputElement; + radio.checked = !!ui.activeLayerId && radio.value === ui.activeLayerId; + }); +} + +export function syncCustomControlsDom( + map: IMapForProvenance, + customControls: CustomControlConfig[], + state: AutarkProvenanceState +): void { + const parent = map.canvas.parentElement; + if (!parent) return; + customControls.forEach((control) => { + const element = control.applyState ? parent.querySelector(control.selector) : null; + if (element && control.applyState) control.applyState(element, state); + }); +} + +export function isTargetInMapContainer( + map: IMapForProvenance, + target: Element +): boolean { + return !!map.canvas.parentElement?.contains(target); +} diff --git a/autk-provenance/src/adapters/map/recording.ts b/autk-provenance/src/adapters/map/recording.ts new file mode 100644 index 00000000..046674db --- /dev/null +++ b/autk-provenance/src/adapters/map/recording.ts @@ -0,0 +1,111 @@ +import type { AutarkProvenanceState, IMapForProvenance, MapViewState } from '../../types'; +import { ProvenanceAction } from '../../types'; +import { isTargetInMapContainer } from './dom'; +import type { CustomControlConfig, MapRecordCallback, ResolvedMapSelectors } from './types'; +import { getActiveLayerId, getMenuOpen, getVisibleLayerIds, isElement } from './utils'; + +const MAP_PICK_EVENT = 'pick'; + +export function createMapRecordingController(options: { + map: IMapForProvenance; + selectors: ResolvedMapSelectors; + customControls: CustomControlConfig[]; + onRecord: MapRecordCallback; + getCurrentState: () => AutarkProvenanceState; + isApplyingState: () => boolean; + buildUiDelta: (overrides?: Partial>) => Partial; +}): { start(): void; stop(): void } { + const { map, selectors, customControls, onRecord, getCurrentState, isApplyingState, buildUiDelta } = options; + const mapObj = map as unknown as Record; + const wrappedMethods = new Map(); + const cleanups: Array<() => void> = []; + + function wrapMapMethod(methodName: string, onAfter: (args: unknown[]) => void): void { + const current = mapObj[methodName]; + if (typeof current !== 'function' || wrappedMethods.has(methodName)) return; + wrappedMethods.set(methodName, current); + mapObj[methodName] = function (...args: unknown[]) { + const result = (current as (...values: unknown[]) => unknown).apply(this, args); + if (!isApplyingState()) onAfter(args); + return result; + }; + } + + function recordCustomControl(target: Element, eventType: 'click' | 'change'): boolean { + for (const control of customControls) { + if (control.event !== eventType) continue; + const match = target.matches(control.selector) ? target : target.closest(control.selector); + if (match) { + onRecord(control.actionType, control.getLabel(match), control.getStateDelta(match)); + return true; + } + } + return false; + } + + return { + start: () => { + if (cleanups.length > 0) return; + wrapMapMethod('init', () => onRecord(ProvenanceAction.MAP_INIT, 'Map initialized', buildUiDelta())); + wrapMapMethod('loadGeoJsonLayer', ([layerName]) => { + onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Map layer loaded: ${typeof layerName === 'string' ? layerName : 'layer'}`, buildUiDelta()); + }); + wrapMapMethod('loadGeoTiffLayer', ([layerName]) => { + onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${typeof layerName === 'string' ? layerName : 'raster'}`, buildUiDelta()); + }); + + const pickListener = (selection: number[], layerId: string) => { + const activePlotIds = new Set(Object.values(getCurrentState().selection.plots ?? {}).flatMap((plot) => plot.ids)); + const mapOwnedSelection = selection.filter((id) => !activePlotIds.has(id)); + const label = mapOwnedSelection.length === 0 ? `Cleared selection on ${layerId}` : `Picked ${mapOwnedSelection.length} feature(s) on ${layerId}`; + onRecord(ProvenanceAction.MAP_PICK, label, { selection: { map: { layerId, ids: mapOwnedSelection }, plots: {} } }); + }; + map.mapEvents.addEventListener(MAP_PICK_EVENT, pickListener); + cleanups.push(() => map.mapEvents.removeEventListener?.(MAP_PICK_EVENT, pickListener)); + + if (map.addViewListener) { + const viewListener = (viewState: MapViewState) => { + if (!isApplyingState()) onRecord(ProvenanceAction.MAP_VIEW, `View changed (alt: ${viewState.eye[2].toFixed(0)})`, { view: viewState }); + }; + map.addViewListener(viewListener); + cleanups.push(() => map.removeViewListener?.(viewListener)); + } + + if (typeof document === 'undefined') return; + const clickListener = (event: Event) => { + const target = event.target; + if (isApplyingState() || !isElement(target)) return; + if (recordCustomControl(target, 'click')) return; + if (!isTargetInMapContainer(map, target)) return; + if (target.closest(selectors.menuIcon)) { + const mapMenuOpen = getMenuOpen(map, selectors); + onRecord(ProvenanceAction.MAP_UI_MENU_TOGGLE, mapMenuOpen ? 'Opened map menu' : 'Closed map menu', buildUiDelta({ mapMenuOpen })); + } + }; + const changeListener = (event: Event) => { + const target = event.target; + if (isApplyingState() || !isElement(target)) return; + if (recordCustomControl(target, 'change')) return; + if (!isTargetInMapContainer(map, target) || !(target instanceof HTMLInputElement)) return; + if (target.id === selectors.thematicCheckbox.replace(/^#/, '')) { + onRecord(ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', buildUiDelta({ thematicEnabled: target.checked })); + } else if (target.classList.contains(selectors.activeLayerRadioClass.replace(/^\./, ''))) { + onRecord(ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, `Active layer: ${getActiveLayerId(map) ?? target.value}`, buildUiDelta({ activeLayerId: getActiveLayerId(map) ?? target.value })); + } else if (target.type === 'checkbox' && target.closest(`#${selectors.visibleLayerList.replace(/^#/, '')}`)) { + onRecord(ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, target.checked ? `Show layer: ${target.value || 'layer'}` : `Hide layer: ${target.value || 'layer'}`, buildUiDelta({ visibleLayerIds: getVisibleLayerIds(map) })); + } + }; + document.addEventListener('click', clickListener); + document.addEventListener('change', changeListener); + cleanups.push(() => document.removeEventListener('click', clickListener)); + cleanups.push(() => document.removeEventListener('change', changeListener)); + }, + stop: () => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + wrappedMethods.forEach((original, methodName) => { + mapObj[methodName] = original; + }); + wrappedMethods.clear(); + }, + }; +} diff --git a/autk-provenance/src/adapters/map/state.ts b/autk-provenance/src/adapters/map/state.ts new file mode 100644 index 00000000..4ce82ac8 --- /dev/null +++ b/autk-provenance/src/adapters/map/state.ts @@ -0,0 +1,56 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../../types'; +import type { CustomControlConfig, ResolvedMapSelectors } from './types'; +import { syncCustomControlsDom, syncUiDom } from './dom'; +import { getAllLayers, resolveUiState, setLayerRenderFlag } from './utils'; + +export function applyMapState(options: { + map: IMapForProvenance; + selectors: ResolvedMapSelectors; + customControls: CustomControlConfig[]; + state: AutarkProvenanceState; + setApplyingState: (value: boolean) => void; +}): void { + const { map, selectors, customControls, state, setApplyingState } = options; + setApplyingState(true); + + try { + const ui = resolveUiState(map, state.ui); + const visibleLayerIds = new Set(ui.visibleLayerIds); + + getAllLayers(map).forEach((layer) => { + const layerId = layer.layerInfo?.id; + if (layerId) setLayerRenderFlag(map, layerId, 'isSkip', !visibleLayerIds.has(layerId)); + }); + + if (ui.activeLayerId) { + const activeLayer = map.layerManager.searchByLayerId(ui.activeLayerId); + if (activeLayer && map.ui?.changeActiveLayer) map.ui.changeActiveLayer(activeLayer); + } + + getAllLayers(map).forEach((layer) => { + const layerId = layer.layerInfo?.id; + if (!layerId) return; + setLayerRenderFlag(map, layerId, 'isColorMap', ui.thematicEnabled && layerId === ui.activeLayerId); + }); + + syncUiDom(map, selectors, ui); + syncCustomControlsDom(map, customControls, state); + if (state.view && map.setViewState) map.setViewState(state.view); + + const targetLayerId = state.selection.map?.layerId; + const targetIds = state.selection.map?.ids ?? []; + const fallbackLayerId = ui.activeLayerId; + const fallbackPlotIds = [...new Set(Object.values(state.selection.plots ?? {}).flatMap((plot) => plot.ids))]; + + (map.layerManager.vectorLayers ?? []).forEach((layer) => { + const layerId = layer.layerInfo?.id; + if (!layerId) return; + const combinedIds = new Set(); + if (targetLayerId === layerId) targetIds.forEach((id) => combinedIds.add(id)); + if (fallbackLayerId === layerId) fallbackPlotIds.forEach((id) => combinedIds.add(id)); + combinedIds.size > 0 ? layer.setHighlightedIds?.([...combinedIds]) : layer.clearHighlightedIds?.(); + }); + } finally { + setApplyingState(false); + } +} diff --git a/autk-provenance/src/adapters/map/types.ts b/autk-provenance/src/adapters/map/types.ts new file mode 100644 index 00000000..0401a103 --- /dev/null +++ b/autk-provenance/src/adapters/map/types.ts @@ -0,0 +1,50 @@ +import type { AutarkProvenanceState } from '../../types'; +import { ProvenanceAction } from '../../types'; + +export type LayerLike = { + layerInfo?: { id: string }; + setHighlightedIds?(ids: number[]): void; + clearHighlightedIds?(): void; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; +}; + +export type ResolvedUiState = { + mapMenuOpen: boolean; + activeLayerId: string | null; + visibleLayerIds: string[]; + thematicEnabled: boolean; +}; + +export type MapRecordCallback = ( + actionType: ProvenanceAction | string, + actionLabel: string, + stateDelta: Partial +) => void; + +export interface CustomControlConfig { + selector: string; + event: 'click' | 'change'; + actionType: ProvenanceAction | string; + getLabel(el: Element): string; + getStateDelta(el: Element): Partial; + applyState?(el: Element, state: AutarkProvenanceState): void; +} + +export interface MapSelectorConfig { + menuIcon?: string; + subMenu?: string; + thematicCheckbox?: string; + legend?: string; + activeLayerRadioClass?: string; + visibleLayerList?: string; + customControls?: CustomControlConfig[]; +} + +export interface ResolvedMapSelectors { + menuIcon: string; + subMenu: string; + thematicCheckbox: string; + legend: string; + activeLayerRadioClass: string; + visibleLayerList: string; +} diff --git a/autk-provenance/src/adapters/map/utils.ts b/autk-provenance/src/adapters/map/utils.ts new file mode 100644 index 00000000..ed805791 --- /dev/null +++ b/autk-provenance/src/adapters/map/utils.ts @@ -0,0 +1,70 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../../types'; +import type { LayerLike, MapSelectorConfig, ResolvedMapSelectors, ResolvedUiState } from './types'; + +export function resolveMapSelectors(config?: MapSelectorConfig): ResolvedMapSelectors { + return { + menuIcon: config?.menuIcon ?? '#menuIcon', + subMenu: config?.subMenu ?? '#autkMapSubMenu', + thematicCheckbox: config?.thematicCheckbox ?? '#showThematicCheckbox', + legend: config?.legend ?? '#autkMapLegend', + activeLayerRadioClass: config?.activeLayerRadioClass ?? '.active-layer-radio', + visibleLayerList: config?.visibleLayerList ?? '#visibleLayerDropdownList', + }; +} + +export function isElement(value: unknown): value is Element { + return !!value && typeof value === 'object' && 'nodeType' in value; +} + +export function getAllLayers(map: IMapForProvenance): LayerLike[] { + return [...(map.layerManager.vectorLayers ?? []), ...(map.layerManager.rasterLayers ?? [])] as LayerLike[]; +} + +export function getLayerIds(map: IMapForProvenance): string[] { + return getAllLayers(map) + .map((layer) => layer.layerInfo?.id) + .filter((id): id is string => typeof id === 'string'); +} + +export function getVisibleLayerIds(map: IMapForProvenance): string[] { + return getAllLayers(map) + .filter((layer) => !layer.layerRenderInfo?.isSkip) + .map((layer) => layer.layerInfo?.id) + .filter((id): id is string => typeof id === 'string'); +} + +export function getActiveLayerId(map: IMapForProvenance): string | null { + return map.ui?.activeLayer?.layerInfo?.id ?? null; +} + +export function getThematicEnabled(map: IMapForProvenance): boolean { + return !!map.ui?.activeLayer?.layerRenderInfo?.isColorMap; +} + +export function getMenuOpen(map: IMapForProvenance, selectors: ResolvedMapSelectors): boolean { + const submenu = map.canvas.parentElement?.querySelector(selectors.subMenu) as HTMLElement | null; + return submenu?.style.visibility === 'visible'; +} + +export function resolveUiState( + map: IMapForProvenance, + ui: AutarkProvenanceState['ui'] +): ResolvedUiState { + return { + mapMenuOpen: ui?.mapMenuOpen ?? false, + activeLayerId: ui?.activeLayerId ?? getActiveLayerId(map) ?? getLayerIds(map)[0] ?? null, + visibleLayerIds: Array.isArray(ui?.visibleLayerIds) ? ui.visibleLayerIds : getLayerIds(map), + thematicEnabled: ui?.thematicEnabled ?? false, + }; +} + +export function setLayerRenderFlag( + map: IMapForProvenance, + layerId: string, + property: 'isSkip' | 'isColorMap', + value: boolean +): void { + if (map.updateRenderInfoProperty) return void map.updateRenderInfoProperty(layerId, property, value); + const layer = map.layerManager.searchByLayerId(layerId); + if (layer?.layerRenderInfo) layer.layerRenderInfo[property] = value; +} diff --git a/autk-provenance/src/core.ts b/autk-provenance/src/core.ts index 71bd31ae..412bd0e7 100644 --- a/autk-provenance/src/core.ts +++ b/autk-provenance/src/core.ts @@ -1,91 +1,32 @@ -import type { AutarkProvenanceState, PathNode, ProvenanceGraph, ProvenanceNode } from './types'; +import type { AutarkProvenanceState } from './types'; import { ProvenanceAction } from './types'; -import { createGraphEngine } from './core-engine'; +import type { ProvenanceCoreApi } from './core/api'; +import { createProvenanceCoreStore } from './core/store'; +import { mergeAutarkProvenanceState } from './core/state'; -function generateId(): string { - return `n_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; -} - -function deepMergeState( - base: AutarkProvenanceState, - delta: Partial -): AutarkProvenanceState { - const hasSelectionMap = - delta.selection !== undefined && - Object.prototype.hasOwnProperty.call(delta.selection, 'map'); - - const next: AutarkProvenanceState = { - selection: { - map: hasSelectionMap ? (delta.selection?.map ?? null) : base.selection.map, - plots: - delta.selection?.plots !== undefined - ? { ...base.selection.plots, ...delta.selection.plots } - : base.selection.plots, - }, - }; - - if (base.ui || delta.ui) { - next.ui = { - ...(base.ui ?? {}), - ...(delta.ui ?? {}), - }; - } - if (base.view || delta.view) next.view = delta.view ?? base.view; - if (base.data || delta.data) next.data = delta.data ?? base.data; - if (base.filters || delta.filters) { - next.filters = { ...(base.filters ?? {}), ...(delta.filters ?? {}) }; - } - return next; -} +export type { ProvenanceCoreApi } from './core/api'; export interface ProvenanceCoreOptions { initialState: AutarkProvenanceState; } -export interface ProvenanceCoreApi { - applyAction( - actionType: ProvenanceAction | string, - actionLabel: string, - stateDelta: Partial - ): void; - goToNode(nodeId: string): boolean; - goBackOneStep(): boolean; - goForwardOneStep(): boolean; - canGoBack(): boolean; - canGoForward(): boolean; - getPathFromRoot(): PathNode[]; - getGraph(): ProvenanceGraph; - getCurrentNode(): ProvenanceNode | null; - getCurrentState(): T | null; - exportGraph(): string; - importGraph(json: string): void; - addObserver(callback: (node: ProvenanceNode) => void): () => void; - annotateNode(nodeId: string, text: string): boolean; +export interface ProvenanceCoreGenericOptions { + initialState: T; + mergeState: (base: T, delta: Partial) => T; } export function createProvenanceCore( options: ProvenanceCoreOptions ): ProvenanceCoreApi { - return createGraphEngine({ + return createProvenanceCoreStore({ initialState: options.initialState, + mergeState: mergeAutarkProvenanceState, rootActionType: ProvenanceAction.ROOT, - mergeState: deepMergeState, - generateId, }); } -export interface ProvenanceCoreGenericOptions { - initialState: T; - mergeState: (base: T, delta: Partial) => T; -} - export function createProvenanceCoreGeneric>( options: ProvenanceCoreGenericOptions ): ProvenanceCoreApi { - return createGraphEngine({ - initialState: options.initialState, - rootActionType: 'root', - mergeState: options.mergeState, - generateId, - }); + return createProvenanceCoreStore({ ...options, rootActionType: ProvenanceAction.ROOT }); } diff --git a/autk-provenance/src/core/api.ts b/autk-provenance/src/core/api.ts new file mode 100644 index 00000000..5b921f29 --- /dev/null +++ b/autk-provenance/src/core/api.ts @@ -0,0 +1,24 @@ +import type { PathNode, ProvenanceGraph, ProvenanceNode } from '../types'; + +export interface ProvenanceCoreOptions { + initialState: T; + mergeState: (base: T, delta: Partial) => T; + rootActionType: string; +} + +export interface ProvenanceCoreApi { + applyAction(actionType: string, actionLabel: string, stateDelta: Partial): void; + goToNode(nodeId: string): boolean; + goBackOneStep(): boolean; + goForwardOneStep(): boolean; + canGoBack(): boolean; + canGoForward(): boolean; + getPathFromRoot(): PathNode[]; + getGraph(): ProvenanceGraph; + getCurrentNode(): ProvenanceNode | null; + getCurrentState(): T | null; + exportGraph(): string; + importGraph(json: string): void; + addObserver(callback: (node: ProvenanceNode) => void): () => void; + annotateNode(nodeId: string, text: string): boolean; +} diff --git a/autk-provenance/src/core/state.ts b/autk-provenance/src/core/state.ts new file mode 100644 index 00000000..1df40f80 --- /dev/null +++ b/autk-provenance/src/core/state.ts @@ -0,0 +1,33 @@ +import type { AutarkProvenanceState } from '../types'; + +export function generateNodeId(): string { + return `n_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + +export function mergeAutarkProvenanceState( + base: AutarkProvenanceState, + delta: Partial +): AutarkProvenanceState { + const hasSelectionMap = + delta.selection !== undefined && + Object.prototype.hasOwnProperty.call(delta.selection, 'map'); + + const next: AutarkProvenanceState = { + selection: { + map: hasSelectionMap ? (delta.selection?.map ?? null) : base.selection.map, + plots: + delta.selection?.plots !== undefined + ? { ...base.selection.plots, ...delta.selection.plots } + : base.selection.plots, + }, + }; + + if (base.ui || delta.ui) next.ui = { ...(base.ui ?? {}), ...(delta.ui ?? {}) }; + if (base.view || delta.view) next.view = delta.view ?? base.view; + if (base.data || delta.data) next.data = delta.data ?? base.data; + if (base.filters || delta.filters) { + next.filters = { ...(base.filters ?? {}), ...(delta.filters ?? {}) }; + } + + return next; +} diff --git a/autk-provenance/src/core/store.ts b/autk-provenance/src/core/store.ts new file mode 100644 index 00000000..628dd50e --- /dev/null +++ b/autk-provenance/src/core/store.ts @@ -0,0 +1,135 @@ +import type { PathNode, ProvenanceNode } from '../types'; +import type { ProvenanceCoreApi, ProvenanceCoreOptions } from './api'; +import { generateNodeId } from './state'; + +type SerializableGraph = { + rootId: string; + currentId: string; + nodes: [string, ProvenanceNode][]; +}; + +export function createProvenanceCoreStore( + options: ProvenanceCoreOptions +): ProvenanceCoreApi { + const { initialState, mergeState, rootActionType } = options; + const nodes = new Map>(); + let rootId = generateNodeId(); + let currentId = rootId; + const observers: Array<(node: ProvenanceNode) => void> = []; + + nodes.set(rootId, { + id: rootId, + parentId: null, + childrenIds: [], + state: initialState, + actionLabel: 'Start', + actionType: rootActionType, + timestamp: Date.now(), + }); + + function getCurrentNode(): ProvenanceNode | null { + return nodes.get(currentId) ?? null; + } + + function notifyObservers(): void { + const node = getCurrentNode(); + if (node) observers.forEach((callback) => callback(node)); + } + + function applyAction(actionType: string, actionLabel: string, stateDelta: Partial): void { + const current = getCurrentNode(); + if (!current) return; + + const nextId = generateNodeId(); + nodes.set(nextId, { + id: nextId, + parentId: currentId, + childrenIds: [], + state: mergeState(current.state, stateDelta), + actionLabel, + actionType, + timestamp: Date.now(), + }); + current.childrenIds.push(nextId); + currentId = nextId; + notifyObservers(); + } + + function getPathFromRoot(): PathNode[] { + const ordered: ProvenanceNode[] = []; + let nodeId: string | null = currentId; + while (nodeId) { + const node = nodes.get(nodeId); + if (!node) break; + ordered.push(node); + nodeId = node.parentId; + } + + return ordered.reverse().map((node) => ({ + id: node.id, + actionLabel: node.actionLabel, + actionType: node.actionType, + timestamp: node.timestamp, + state: node.state, + })); + } + + function importGraph(json: string): void { + try { + const parsed = JSON.parse(json) as SerializableGraph; + nodes.clear(); + parsed.nodes.forEach(([id, node]) => nodes.set(id, node)); + rootId = parsed.rootId; + currentId = parsed.currentId; + notifyObservers(); + } catch { + // Preserve the existing graph when the import payload is invalid. + } + } + + return { + applyAction, + goToNode: (nodeId) => { + if (!nodes.has(nodeId)) return false; + currentId = nodeId; + notifyObservers(); + return true; + }, + goBackOneStep: () => { + const parentId = getCurrentNode()?.parentId; + if (!parentId) return false; + currentId = parentId; + notifyObservers(); + return true; + }, + goForwardOneStep: () => { + const children = getCurrentNode()?.childrenIds ?? []; + const nextId = children[children.length - 1]; + if (!nextId) return false; + currentId = nextId; + notifyObservers(); + return true; + }, + canGoBack: () => !!getCurrentNode()?.parentId, + canGoForward: () => !!getCurrentNode()?.childrenIds.length, + getPathFromRoot, + getGraph: () => ({ nodes: new Map(nodes), rootId, currentId }), + getCurrentNode, + getCurrentState: () => getCurrentNode()?.state ?? null, + exportGraph: () => JSON.stringify({ rootId, currentId, nodes: Array.from(nodes.entries()) }), + importGraph, + addObserver: (callback) => { + observers.push(callback); + return () => { + const index = observers.indexOf(callback); + if (index !== -1) observers.splice(index, 1); + }; + }, + annotateNode: (nodeId, text) => { + const node = nodes.get(nodeId); + if (!node) return false; + node.metadata = { ...(node.metadata ?? {}), insight: text }; + return true; + }, + }; +} diff --git a/autk-provenance/src/insight-engine.ts b/autk-provenance/src/insight-engine.ts index f951189f..98273c9a 100644 --- a/autk-provenance/src/insight-engine.ts +++ b/autk-provenance/src/insight-engine.ts @@ -1,10 +1,7 @@ export { getInsightAnnotations } from './insights/annotations'; +export type { InsightAnnotation } from './insights/annotations'; export { computeGraphMetrics } from './insights/graph-metrics'; +export type { GraphMetrics, StrategyLabel } from './insights/graph-metrics'; export { generateSessionNarrative } from './insights/narrative'; export { computeSelectionFrequency } from './insights/selection-frequency'; -export type { - GraphMetrics, - InsightAnnotation, - SelectionFrequency, - StrategyLabel, -} from './insights/types'; +export type { SelectionFrequency } from './insights/selection-frequency'; diff --git a/autk-provenance/src/insights/annotations.ts b/autk-provenance/src/insights/annotations.ts index fbbae0ab..d66fd7b7 100644 --- a/autk-provenance/src/insights/annotations.ts +++ b/autk-provenance/src/insights/annotations.ts @@ -1,14 +1,21 @@ import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; -import type { InsightAnnotation } from './types'; + +export interface InsightAnnotation { + nodeId: string; + actionLabel: string; + text: string; + timestamp: number; +} export function getInsightAnnotations( graph: ProvenanceGraph ): InsightAnnotation[] { - const out: InsightAnnotation[] = []; + const annotations: InsightAnnotation[] = []; + for (const node of graph.nodes.values()) { const text = node.metadata?.insight; if (typeof text === 'string' && text.trim().length > 0) { - out.push({ + annotations.push({ nodeId: node.id, actionLabel: node.actionLabel, text: text.trim(), @@ -16,5 +23,6 @@ export function getInsightAnnotations( }); } } - return out.sort((a, b) => a.timestamp - b.timestamp); + + return annotations.sort((a, b) => a.timestamp - b.timestamp); } diff --git a/autk-provenance/src/insights/graph-metrics.ts b/autk-provenance/src/insights/graph-metrics.ts index 20ce6c73..295d96ce 100644 --- a/autk-provenance/src/insights/graph-metrics.ts +++ b/autk-provenance/src/insights/graph-metrics.ts @@ -1,29 +1,41 @@ import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; -import type { GraphMetrics, StrategyLabel } from './types'; + +export type StrategyLabel = 'Confirmatory' | 'Exploratory' | 'Iterative Refinement'; + +export interface GraphMetrics { + totalNodes: number; + branchPoints: number; + backtracks: number; + maxDepth: number; + sessionDurationMs: number; + avgTimePerStateMs: number; + branchRatio: number; + strategyLabel: StrategyLabel; + insightCount: number; +} function computeMaxDepth(graph: ProvenanceGraph): number { - let max = 0; + let maxDepth = 0; const visited = new Set(); - const dfs = (id: string, depth: number): void => { - if (visited.has(id)) return; - visited.add(id); - max = Math.max(max, depth); - const node = graph.nodes.get(id); - if (!node) return; - for (const child of node.childrenIds) dfs(child, depth + 1); - }; - dfs(graph.rootId, 0); - return max; + + function visit(nodeId: string, depth: number): void { + if (visited.has(nodeId)) return; + visited.add(nodeId); + maxDepth = Math.max(maxDepth, depth); + graph.nodes.get(nodeId)?.childrenIds.forEach((childId) => visit(childId, depth + 1)); + } + + visit(graph.rootId, 0); + return maxDepth; } export function computeGraphMetrics( graph: ProvenanceGraph ): GraphMetrics { const nodes = Array.from(graph.nodes.values()); - const total = nodes.length; - if (total <= 1) { + if (nodes.length <= 1) { return { - totalNodes: total, + totalNodes: nodes.length, branchPoints: 0, backtracks: 0, maxDepth: 0, @@ -35,29 +47,27 @@ export function computeGraphMetrics( }; } - const branchPoints = nodes.filter((n) => n.childrenIds.length > 1).length; - const backtracks = nodes.reduce((acc, n) => acc + Math.max(0, n.childrenIds.length - 1), 0); - const branchRatio = branchPoints / total; - const timestamps = nodes.map((n) => n.timestamp).sort((a, b) => a - b); + const branchPoints = nodes.filter((node) => node.childrenIds.length > 1).length; + const backtracks = nodes.reduce((count, node) => count + Math.max(0, node.childrenIds.length - 1), 0); + const timestamps = nodes.map((node) => node.timestamp).sort((a, b) => a - b); const sessionDurationMs = timestamps[timestamps.length - 1] - timestamps[0]; - const strategyLabel: StrategyLabel = - backtracks >= 3 && branchRatio >= 0.15 - ? 'Iterative Refinement' - : branchRatio >= 0.15 || backtracks >= 2 - ? 'Exploratory' - : 'Confirmatory'; + const branchRatio = branchPoints / nodes.length; + const insightCount = nodes.filter((node) => typeof node.metadata?.insight === 'string' && `${node.metadata.insight}`.trim().length > 0).length; return { - totalNodes: total, + totalNodes: nodes.length, branchPoints, backtracks, maxDepth: computeMaxDepth(graph), sessionDurationMs, - avgTimePerStateMs: total > 1 ? Math.round(sessionDurationMs / (total - 1)) : 0, + avgTimePerStateMs: Math.round(sessionDurationMs / (nodes.length - 1)), branchRatio, - strategyLabel, - insightCount: nodes.filter( - (n) => typeof n.metadata?.insight === 'string' && (n.metadata.insight as string).trim().length > 0 - ).length, + strategyLabel: + backtracks >= 3 && branchRatio >= 0.15 + ? 'Iterative Refinement' + : branchRatio >= 0.15 || backtracks >= 2 + ? 'Exploratory' + : 'Confirmatory', + insightCount, }; } diff --git a/autk-provenance/src/insights/narrative.ts b/autk-provenance/src/insights/narrative.ts index a176bd3b..a1f0c5e9 100644 --- a/autk-provenance/src/insights/narrative.ts +++ b/autk-provenance/src/insights/narrative.ts @@ -1,59 +1,59 @@ import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import type { InsightAnnotation } from './annotations'; import { computeSelectionFrequency } from './selection-frequency'; -import type { GraphMetrics, InsightAnnotation, StrategyLabel } from './types'; -import { formatDuration } from './utils'; +import type { GraphMetrics, StrategyLabel } from './graph-metrics'; + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} export function generateSessionNarrative( graph: ProvenanceGraph, metrics: GraphMetrics, annotations: InsightAnnotation[] ): string { - const lines: string[] = []; - const root = graph.nodes.get(graph.rootId); - const startTime = root ? new Date(root.timestamp).toLocaleTimeString() : '—'; - const strategyDesc: Record = { - Confirmatory: 'A focused, linear exploration — the analyst appeared to know what they were looking for.', - Exploratory: 'A broad, open-ended investigation with multiple diverging paths.', - 'Iterative Refinement': 'A hypothesis-driven approach with repeated backtracking and revision.', - }; - - lines.push(`Session started at ${startTime}.`); - lines.push( - `Duration: ${formatDuration(metrics.sessionDurationMs)} across ${metrics.totalNodes} states ` + - `(avg ${formatDuration(metrics.avgTimePerStateMs)} per state).` - ); - lines.push(`\nAnalysis strategy: ${metrics.strategyLabel}`); - lines.push(strategyDesc[metrics.strategyLabel]); + const lines = [ + `Session started at ${graph.nodes.get(graph.rootId) ? new Date(graph.nodes.get(graph.rootId)!.timestamp).toLocaleTimeString() : '—'}.`, + `Duration: ${formatDuration(metrics.sessionDurationMs)} across ${metrics.totalNodes} states (avg ${formatDuration(metrics.avgTimePerStateMs)} per state).`, + '', + `Analysis strategy: ${metrics.strategyLabel}`, + strategyDescription[metrics.strategyLabel], + ]; if (metrics.branchPoints > 0) { lines.push( - `The analysis diverged at ${metrics.branchPoints} branch point${metrics.branchPoints > 1 ? 's' : ''}, ` + - `with ${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''} before settling on the current path.` + `The analysis diverged at ${metrics.branchPoints} branch point${metrics.branchPoints > 1 ? 's' : ''}, with ${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''} before settling on the current path.` ); } - const freq = computeSelectionFrequency(graph); - const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); + const selectionFrequency = computeSelectionFrequency(graph); + const topMap = [...selectionFrequency.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); if (topMap.length > 0) { - lines.push( - `\nMost revisited map features across all branches: ` + - topMap.map(([id, c]) => `feature #${id} (${c} state${c !== 1 ? 's' : ''})`).join(', ') + - '.' - ); + lines.push('', `Most revisited map features across all branches: ${topMap.map(([id, count]) => `feature #${id} (${count} state${count !== 1 ? 's' : ''})`).join(', ')}.`); } - for (const [plotId, plotFreq] of freq.plots.entries()) { - const top = [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - if (top.length > 0 && top[0][1] > 1) { - lines.push(`Most revisited features in plot "${plotId}": ${top.map(([id, c]) => `#${id} (${c}×)`).join(', ')}.`); + + for (const [plotId, plotFreq] of selectionFrequency.plots.entries()) { + const topPlot = [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); + if (topPlot.length > 0 && topPlot[0][1] > 1) { + lines.push(`Most revisited features in plot "${plotId}": ${topPlot.map(([id, count]) => `#${id} (${count}×)`).join(', ')}.`); } } if (annotations.length > 0) { - lines.push(`\nRecorded insights (${annotations.length}):`); - for (const a of annotations) lines.push(` • [${a.actionLabel}] ${a.text}`); + lines.push('', `Recorded insights (${annotations.length}):`); + annotations.forEach((annotation) => lines.push(` • [${annotation.actionLabel}] ${annotation.text}`)); } else { - lines.push('\nNo insight annotations were recorded during this session.'); + lines.push('', 'No insight annotations were recorded during this session.'); } return lines.join('\n'); } + +const strategyDescription: Record = { + Confirmatory: 'A focused, linear exploration — the analyst appeared to know what they were looking for.', + Exploratory: 'A broad, open-ended investigation with multiple diverging paths.', + 'Iterative Refinement': 'A hypothesis-driven approach with repeated backtracking and revision.', +}; diff --git a/autk-provenance/src/insights/selection-frequency.ts b/autk-provenance/src/insights/selection-frequency.ts index 1b4b927d..2614fa28 100644 --- a/autk-provenance/src/insights/selection-frequency.ts +++ b/autk-provenance/src/insights/selection-frequency.ts @@ -1,5 +1,9 @@ import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; -import type { SelectionFrequency } from './types'; + +export interface SelectionFrequency { + map: Map; + plots: Map>; +} export function computeSelectionFrequency( graph: ProvenanceGraph @@ -8,18 +12,18 @@ export function computeSelectionFrequency( const plotsFreq = new Map>(); for (const node of graph.nodes.values()) { - const sel = node.state.selection; - if (sel?.map?.ids) { - for (const id of sel.map.ids) { + if (node.state.selection.map?.ids) { + for (const id of node.state.selection.map.ids) { mapFreq.set(id, (mapFreq.get(id) ?? 0) + 1); } } - for (const [plotId, plotSel] of Object.entries(sel?.plots ?? {})) { - if (!plotSel?.ids?.length) continue; + + for (const [plotId, plotSelection] of Object.entries(node.state.selection.plots ?? {})) { + if (!plotSelection?.ids.length) continue; if (!plotsFreq.has(plotId)) plotsFreq.set(plotId, new Map()); - const freq = plotsFreq.get(plotId)!; - for (const id of plotSel.ids) { - freq.set(id, (freq.get(id) ?? 0) + 1); + const plotFreq = plotsFreq.get(plotId)!; + for (const id of plotSelection.ids) { + plotFreq.set(id, (plotFreq.get(id) ?? 0) + 1); } } } diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts index 7e356c2c..abceeaa5 100644 --- a/autk-provenance/src/provenance-trail-ui.ts +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -1,15 +1,10 @@ import type { AutarkProvenanceApi } from './create-autark-provenance'; +import { ensureProvenanceTrailStyles } from './ui/styles'; import { createGraphModalController } from './ui/graph-modal'; -import { renderGraphPreview } from './ui/graph-preview'; +import { buildLayoutFromProvenance, renderGraphPreview } from './ui/graph-preview'; +import { renderPathList } from './ui/path-list'; +import { createGraphToolbar, createInsightsShell, createNavigationButtons } from './ui/toolbar'; import { renderInsightsPanel } from './ui/insights-panel'; -import { ensureProvenanceTrailStyles } from './ui/styles'; -import { - createGraphSection, - createInsightsSection, - createNavButtons, - createPathSection, -} from './ui/trail-shell'; -import { renderPathList } from './ui/trail-path'; export interface ProvenanceTrailUIOptions { provenance: AutarkProvenanceApi; @@ -22,166 +17,74 @@ export interface ProvenanceTrailUIOptions { } export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void { - const { - provenance, - container, - insightsContainer, - showBackForward = true, - showTimestamps = true, - showGraph = true, - showPathList = true, - } = options; - + const { provenance, container, insightsContainer, showBackForward = true, showTimestamps = true, showGraph = true, showPathList = true } = options; ensureProvenanceTrailStyles(); - container.innerHTML = ''; container.classList.add('autk-provenance-root'); let graphVisible = showGraph; - let graphWrap: HTMLDivElement | null = null; - let pathContainer: HTMLDivElement | null = null; - let insightsBodyEl: HTMLDivElement | null = null; - let insightsOpen = true; - - let graphToggleBtn: HTMLButtonElement | null = null; - let graphExpandBtn: HTMLButtonElement | null = null; - let graphHint: HTMLSpanElement | null = null; - let backBtn: HTMLButtonElement | null = null; - let fwdBtn: HTMLButtonElement | null = null; - let refreshRef = () => {}; - - const graphModal = createGraphModalController({ - provenance, - showTimestamps, - getEnabled: () => showGraph && graphVisible, - onRefresh: () => refreshRef(), - }); - - function updateButtons(): void { - if (backBtn) backBtn.disabled = !provenance.canGoBack(); - if (fwdBtn) fwdBtn.disabled = !provenance.canGoForward(); + const toolbar = showGraph ? createGraphToolbar() : null; + const graphWrap = showGraph ? document.createElement('div') : null; + const pathContainer = showPathList ? document.createElement('div') : null; + const navigation = showBackForward ? createNavigationButtons() : null; + const insights = createInsightsShell(insightsContainer ?? container); + const modal = createGraphModalController({ provenance, showTimestamps, buildLayout: () => buildLayoutFromProvenance(provenance), onRefresh: refresh }); + + if (toolbar && graphWrap) { + graphWrap.className = 'autk-provenance-graph-wrap'; + toolbar.toggleButton.addEventListener('click', () => { graphVisible = !graphVisible; if (!graphVisible) modal.close(); refresh(); }); + toolbar.expandButton.addEventListener('click', () => { modal.isOpen() ? modal.close() : modal.open(); refresh(); }); + graphWrap.addEventListener('click', () => { if (graphVisible && !modal.isOpen()) { modal.open(); refresh(); } }); + container.appendChild(toolbar.toolbar); + container.appendChild(graphWrap); } - function renderGraph(): void { - if (!graphWrap) return; - if (!graphVisible) { - graphWrap.style.display = 'none'; - return; - } - graphWrap.style.display = 'block'; - renderGraphPreview(graphWrap, provenance, showTimestamps); + if (pathContainer) { + pathContainer.className = 'autk-provenance-path'; + pathContainer.setAttribute('role', 'list'); + container.appendChild(pathContainer); + } + if (navigation) { + navigation.backButton.addEventListener('click', () => provenance.goBackOneStep()); + navigation.forwardButton.addEventListener('click', () => provenance.goForwardOneStep()); + container.appendChild(navigation.row); } function refresh(): void { - if (graphToggleBtn) { - graphToggleBtn.textContent = graphVisible ? 'Hide Graph' : 'Show Graph'; - } - if (graphExpandBtn) { - graphExpandBtn.textContent = graphModal.isOpen() ? 'Close Graph' : 'Open Graph'; - graphExpandBtn.disabled = !graphVisible; + if (toolbar) { + toolbar.toggleButton.textContent = graphVisible ? 'Hide Graph' : 'Show Graph'; + toolbar.expandButton.textContent = modal.isOpen() ? 'Close Graph' : 'Open Graph'; + toolbar.expandButton.disabled = !graphVisible; + toolbar.hint.textContent = modal.isOpen() ? 'Modal open: click nodes to jump state' : 'Click graph preview to open modal'; + toolbar.hint.style.visibility = graphVisible ? 'visible' : 'hidden'; } - if (graphHint) { - graphHint.textContent = graphModal.isOpen() - ? 'Modal open: click nodes to jump state' - : 'Click graph preview to open modal'; - graphHint.style.visibility = graphVisible ? 'visible' : 'hidden'; - } - - if (showGraph) renderGraph(); - if (graphModal.isOpen()) graphModal.render(); - if (showPathList && pathContainer) { - renderPathList(pathContainer, provenance.getPathFromRoot(), provenance, showTimestamps); + if (graphWrap) { + graphWrap.style.display = graphVisible ? 'block' : 'none'; + if (graphVisible) renderGraphPreview({ container: graphWrap, provenance, showTimestamps }); } - updateButtons(); - if (insightsBodyEl && insightsOpen) { - renderInsightsPanel(insightsBodyEl, provenance); + if (pathContainer) renderPathList({ container: pathContainer, provenance, path: provenance.getPathFromRoot(), showTimestamps }); + if (navigation) { + navigation.backButton.disabled = !provenance.canGoBack(); + navigation.forwardButton.disabled = !provenance.canGoForward(); } + if (insights.isOpen()) renderInsightsPanel(insights.body, provenance); + if (modal.isOpen()) modal.render(); } - if (showGraph) { - const graphSection = createGraphSection(container); - graphWrap = graphSection.graphWrap; - graphToggleBtn = graphSection.graphToggleBtn; - graphExpandBtn = graphSection.graphExpandBtn; - graphHint = graphSection.graphHint; - } - - if (showPathList) { - pathContainer = createPathSection(container); - } - - if (showBackForward) { - const nav = createNavButtons( - container, - () => provenance.goBackOneStep(), - () => provenance.goForwardOneStep() - ); - backBtn = nav.backBtn; - fwdBtn = nav.fwdBtn; - } - - const insightsSection = createInsightsSection(insightsContainer ?? container); - const insightsWrap = insightsSection.wrap; - const insightsBody = insightsSection.body; - const insightsChevron = insightsSection.chevron; - insightsBodyEl = insightsBody; - - insightsWrap.firstElementChild?.addEventListener('click', () => { - insightsOpen = !insightsOpen; - insightsBody.style.display = insightsOpen ? 'flex' : 'none'; - insightsChevron.textContent = insightsOpen ? '\u25b4' : '\u25be'; - }); - - const unsub = provenance.addObserver(() => refresh()); - - if (graphToggleBtn) { - graphToggleBtn.addEventListener('click', () => { - graphVisible = !graphVisible; - if (!graphVisible) graphModal.close(); - refresh(); - }); - } - - if (graphExpandBtn) { - graphExpandBtn.addEventListener('click', () => { - if (graphModal.isOpen()) { - graphModal.close(); - } else { - graphModal.open(); - } - refresh(); - }); - } - - if (graphWrap) { - graphWrap.addEventListener('click', () => { - if (!graphVisible || graphModal.isOpen()) return; - graphModal.open(); - refresh(); - }); - } - - const handleDocKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || !graphModal.isOpen()) return; - graphModal.close(); - refresh(); - }; - document.addEventListener('keydown', handleDocKeyDown); - - const handleWindowResize = () => { - if (!graphModal.isOpen()) return; - graphModal.render(); - }; - window.addEventListener('resize', handleWindowResize); - - refreshRef = refresh; + const unsubscribe = provenance.addObserver(() => refresh()); + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && modal.isOpen()) { modal.close(); refresh(); } }; + const handleResize = () => modal.isOpen() && modal.render(); + document.addEventListener('keydown', handleKeyDown); + window.addEventListener('resize', handleResize); refresh(); return () => { - unsub(); - document.removeEventListener('keydown', handleDocKeyDown); - window.removeEventListener('resize', handleWindowResize); - graphModal.close(); - graphModal.destroy(); + unsubscribe(); + document.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('resize', handleResize); + modal.close(); + container.innerHTML = ''; + container.classList.remove('autk-provenance-root'); + if (insightsContainer) insightsContainer.innerHTML = ''; }; } diff --git a/autk-provenance/src/ui/graph-layout.ts b/autk-provenance/src/ui/graph-layout.ts index c4abd0da..49dea70f 100644 --- a/autk-provenance/src/ui/graph-layout.ts +++ b/autk-provenance/src/ui/graph-layout.ts @@ -28,86 +28,47 @@ export function buildGraphLayout( const row = new Map(); const visited = new Set(); const edges: LayoutEdge[] = []; + let nextLeafRow = 0; - function assignDepth(nodeId: string, d: number): void { - if (visited.has(nodeId)) { - const prev = depth.get(nodeId); - if (prev === undefined || d < prev) depth.set(nodeId, d); - return; - } + const assignDepth = (nodeId: string, level: number): void => { + if (visited.has(nodeId)) return void depth.set(nodeId, Math.min(depth.get(nodeId) ?? level, level)); visited.add(nodeId); - depth.set(nodeId, d); - const node = nodesMap.get(nodeId); - if (!node) return; - for (const childId of node.childrenIds) { - if (!nodesMap.has(childId)) continue; + depth.set(nodeId, level); + nodesMap.get(nodeId)?.childrenIds.forEach((childId) => { + if (!nodesMap.has(childId)) return; edges.push({ from: nodeId, to: childId }); - assignDepth(childId, d + 1); - } - } - - assignDepth(rootId, 0); - - let nextLeafRow = 0; - function assignRow(nodeId: string): number { + assignDepth(childId, level + 1); + }); + }; + const assignRow = (nodeId: string): number => { if (row.has(nodeId)) return row.get(nodeId) ?? 0; - const node = nodesMap.get(nodeId); - if (!node) { - row.set(nodeId, nextLeafRow); - nextLeafRow += 1; - return row.get(nodeId) ?? 0; - } - - const validChildren = node.childrenIds.filter((id) => nodesMap.has(id)); - if (validChildren.length === 0) { - row.set(nodeId, nextLeafRow); - nextLeafRow += 1; - return row.get(nodeId) ?? 0; - } - - const childRows = validChildren.map(assignRow); - const avg = childRows.reduce((acc, n) => acc + n, 0) / childRows.length; + const validChildren = nodesMap.get(nodeId)?.childrenIds.filter((id) => nodesMap.has(id)) ?? []; + if (validChildren.length === 0) return row.set(nodeId, nextLeafRow++).get(nodeId) ?? 0; + const avg = validChildren.map(assignRow).reduce((sum, value) => sum + value, 0) / validChildren.length; row.set(nodeId, avg); return avg; - } + }; + assignDepth(rootId, 0); assignRow(rootId); - - for (const nodeId of nodesMap.keys()) { + nodesMap.forEach((_, nodeId) => { if (!depth.has(nodeId)) depth.set(nodeId, 0); - if (!row.has(nodeId)) { - row.set(nodeId, nextLeafRow); - nextLeafRow += 1; - } - } - - const xGap = 160; - const yGap = 64; - const marginX = 28; - const marginY = 24; - - const layoutNodes: LayoutNode[] = []; - let maxDepth = 0; - let maxRow = 0; - - for (const [id, node] of nodesMap.entries()) { - const d = depth.get(id) ?? 0; - const r = row.get(id) ?? 0; - maxDepth = Math.max(maxDepth, d); - maxRow = Math.max(maxRow, r); - layoutNodes.push({ - node, - depth: d, - row: r, - x: marginX + d * xGap, - y: marginY + r * yGap, - }); - } - + if (!row.has(nodeId)) row.set(nodeId, nextLeafRow++); + }); + + const layoutNodes = Array.from(nodesMap.entries()).map(([id, node]) => ({ + node, + depth: depth.get(id) ?? 0, + row: row.get(id) ?? 0, + x: 28 + (depth.get(id) ?? 0) * 160, + y: 24 + (row.get(id) ?? 0) * 64, + })); layoutNodes.sort((a, b) => (a.depth === b.depth ? a.row - b.row : a.depth - b.depth)); - const width = marginX * 2 + maxDepth * xGap + 280; - const height = marginY * 2 + Math.max(1, maxRow) * yGap + 44; - - return { nodes: layoutNodes, edges, width, height }; + return { + nodes: layoutNodes, + edges, + width: 56 + Math.max(...layoutNodes.map((node) => node.depth), 0) * 160 + 280, + height: 48 + Math.max(1, ...layoutNodes.map((node) => node.row)) * 64 + 44, + }; } diff --git a/autk-provenance/src/ui/graph-modal-shell.ts b/autk-provenance/src/ui/graph-modal-shell.ts new file mode 100644 index 00000000..e95f2788 --- /dev/null +++ b/autk-provenance/src/ui/graph-modal-shell.ts @@ -0,0 +1,90 @@ +export interface GraphModalShell { + canvas: HTMLDivElement; + destroy(): void; +} + +export function createGraphModalShell(options: { + onClose: () => void; + onFit: () => void; + onZoom: (factor: number, clientX?: number, clientY?: number) => void; + onPan: (deltaX: number, deltaY: number) => void; +}): GraphModalShell { + const { onClose, onFit, onZoom, onPan } = options; + const backdrop = document.createElement('div'); + const modal = document.createElement('div'); + const header = document.createElement('div'); + const title = document.createElement('div'); + const controls = document.createElement('div'); + const body = document.createElement('div'); + const canvas = document.createElement('div'); + + backdrop.className = 'autk-provenance-modal-backdrop'; + modal.className = 'autk-provenance-modal'; + header.className = 'autk-provenance-modal-header'; + title.className = 'autk-provenance-modal-title'; + controls.className = 'autk-provenance-modal-controls'; + body.className = 'autk-provenance-modal-body'; + canvas.className = 'autk-provenance-modal-canvas'; + title.textContent = 'Provenance Graph'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', 'Provenance graph'); + + const makeButton = (text: string, onClick: () => void) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = text; + button.addEventListener('click', onClick); + controls.appendChild(button); + return button; + }; + + makeButton('\u2212', () => onZoom(0.84)); + makeButton('+', () => onZoom(1.2)); + makeButton('Fit', onFit); + makeButton('Close', onClose); + + header.appendChild(title); + header.appendChild(controls); + body.appendChild(canvas); + modal.appendChild(header); + modal.appendChild(body); + backdrop.appendChild(modal); + document.body?.appendChild(backdrop); + + let panning = false; + let lastX = 0; + let lastY = 0; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + onZoom(event.deltaY < 0 ? 1.12 : 0.89, event.clientX, event.clientY); + }; + + canvas.addEventListener('wheel', handleWheel, { passive: false }); + backdrop.addEventListener('click', (event) => event.target === backdrop && onClose()); + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || (event.target as HTMLElement | null)?.closest('.autk-provenance-node')) return; + panning = true; + lastX = event.clientX; + lastY = event.clientY; + canvas.classList.add('autk-provenance-panning'); + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + canvas.addEventListener('pointermove', (event) => { + if (!panning) return; + onPan(event.clientX - lastX, event.clientY - lastY); + lastX = event.clientX; + lastY = event.clientY; + }); + const stopPanning = (event: PointerEvent) => { + if (!panning) return; + panning = false; + canvas.classList.remove('autk-provenance-panning'); + if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + }; + canvas.addEventListener('pointerup', stopPanning); + canvas.addEventListener('pointercancel', stopPanning); + + return { canvas, destroy: () => backdrop.remove() }; +} diff --git a/autk-provenance/src/ui/graph-modal.ts b/autk-provenance/src/ui/graph-modal.ts index cd33d477..0dee36c4 100644 --- a/autk-provenance/src/ui/graph-modal.ts +++ b/autk-provenance/src/ui/graph-modal.ts @@ -1,109 +1,80 @@ import type { AutarkProvenanceApi } from '../create-autark-provenance'; -import { buildGraphLayout, type GraphLayout } from './graph-layout'; -import { ensureGraphModalDom } from './graph-modal-dom'; -import { - applySceneTransform, - createGraphModalState, - fitGraphToView, - resetGraphModalState, - zoomGraph, - type GraphModalState, -} from './graph-modal-utils'; +import { clamp } from './utils'; +import type { GraphLayout } from './graph-layout'; +import { createGraphModalShell, type GraphModalShell } from './graph-modal-shell'; import { createGraphScene, createSvgElement } from './graph-scene'; -export interface GraphModalController { - isOpen(): boolean; - open(): void; - close(): void; - render(): void; - destroy(): void; -} - -interface CreateGraphModalControllerOptions { +export function createGraphModalController(options: { provenance: AutarkProvenanceApi; showTimestamps: boolean; - getEnabled(): boolean; - onRefresh(): void; -} - -export function createGraphModalController( - options: CreateGraphModalControllerOptions -): GraphModalController { - const { provenance, showTimestamps, getEnabled, onRefresh } = options; - const state: GraphModalState = createGraphModalState(); - - function close(): void { - state.open = false; - resetGraphModalState(state); - } - - function ensureModal(): void { - ensureGraphModalDom({ - state, - onClose: () => { - close(); - onRefresh(); - }, - onZoomIn: () => zoomGraph(state, 1.2), - onZoomOut: () => zoomGraph(state, 0.84), - onFit: () => fitGraphToView(state), - onWheelZoom: (event) => { - event.preventDefault(); - zoomGraph(state, event.deltaY < 0 ? 1.12 : 0.89, event.clientX, event.clientY); - }, - onPanMove: (dx, dy) => { - state.tx += dx; - state.ty += dy; - state.initialized = true; - applySceneTransform(state); - }, - }); - } - - function render(): void { - if (!state.open || !getEnabled()) return; - ensureModal(); - if (!state.canvas) return; + buildLayout: () => GraphLayout | null; + onRefresh: () => void; +}): { isOpen(): boolean; open(): void; close(): void; render(): void } { + const { provenance, showTimestamps, buildLayout, onRefresh } = options; + let open = false; + let shell: GraphModalShell | null = null; + let scene: SVGGElement | null = null; + let layout: GraphLayout | null = null; + let scale = 1; + let translateX = 0; + let translateY = 0; + let transformInitialized = false; - state.canvas.innerHTML = ''; - const graph = provenance.getGraph(); - if (!graph.nodes.get(graph.rootId)) return; - - const currentId = provenance.getCurrentNode()?.id ?? null; - state.layout = buildGraphLayout(graph.nodes, graph.rootId); - - const viewportWidth = Math.max(320, Math.floor(state.canvas.clientWidth)); - const viewportHeight = Math.max(260, Math.floor(state.canvas.clientHeight)); - - const svg = createSvgElement('svg'); - svg.classList.add('autk-provenance-modal-svg'); - svg.setAttribute('viewBox', `0 0 ${viewportWidth} ${viewportHeight}`); - svg.setAttribute('width', String(viewportWidth)); - svg.setAttribute('height', String(viewportHeight)); - - state.scene = createGraphScene(state.layout, currentId, showTimestamps, (nodeId) => { - provenance.goToNode(nodeId); - }); - svg.appendChild(state.scene); - state.canvas.appendChild(svg); + const applyTransform = () => scene?.setAttribute('transform', `translate(${translateX} ${translateY}) scale(${scale})`); + const fitView = () => { + if (!shell || !layout || !scene) return; + const width = Math.max(1, shell.canvas.clientWidth); + const height = Math.max(1, shell.canvas.clientHeight); + scale = clamp(Math.min((width - 104) / Math.max(1, layout.width), (height - 104) / Math.max(1, layout.height)), 0.25, 6); + translateX = (width - layout.width * scale) / 2; + translateY = (height - layout.height * scale) / 2; + transformInitialized = true; + applyTransform(); + }; + const zoom = (factor: number, clientX?: number, clientY?: number) => { + if (!shell || !scene) return; + const rect = shell.canvas.getBoundingClientRect(); + const x = clientX ?? rect.left + rect.width / 2; + const y = clientY ?? rect.top + rect.height / 2; + const nextScale = clamp(scale * factor, 0.25, 6); + const localX = x - rect.left; + const localY = y - rect.top; + const ratio = nextScale / scale; + translateX = localX - (localX - translateX) * ratio; + translateY = localY - (localY - translateY) * ratio; + scale = nextScale; + transformInitialized = true; + applyTransform(); + }; + const ensureShell = () => shell ?? (shell = createGraphModalShell({ + onClose: () => { controller.close(); onRefresh(); }, + onFit: fitView, + onZoom: zoom, + onPan: (deltaX, deltaY) => { translateX += deltaX; translateY += deltaY; transformInitialized = true; applyTransform(); }, + })); - if (!state.initialized) { - fitGraphToView(state); - } else { - applySceneTransform(state); - } - } + const controller = { + isOpen: () => open, + open: () => { open = true; transformInitialized = false; controller.render(); }, + close: () => { open = false; scene = null; layout = null; transformInitialized = false; shell?.destroy(); shell = null; }, + render: () => { + if (!open) return; + const modalShell = ensureShell(); + modalShell.canvas.innerHTML = ''; + layout = buildLayout(); + if (!layout) return; - return { - isOpen: () => state.open, - open: () => { - if (!getEnabled()) return; - state.open = true; - state.initialized = false; - render(); + const svg = createSvgElement('svg'); + svg.classList.add('autk-provenance-modal-svg'); + svg.setAttribute('viewBox', `0 0 ${Math.max(320, Math.floor(modalShell.canvas.clientWidth))} ${Math.max(260, Math.floor(modalShell.canvas.clientHeight))}`); + svg.setAttribute('width', String(Math.max(320, Math.floor(modalShell.canvas.clientWidth)))); + svg.setAttribute('height', String(Math.max(260, Math.floor(modalShell.canvas.clientHeight)))); + scene = createGraphScene(layout, provenance.getCurrentNode()?.id ?? null, showTimestamps, (nodeId) => provenance.goToNode(nodeId)); + svg.appendChild(scene); + modalShell.canvas.appendChild(svg); + transformInitialized ? applyTransform() : fitView(); }, - close, - render, - destroy: () => resetGraphModalState(state), }; + + return controller; } diff --git a/autk-provenance/src/ui/graph-preview.ts b/autk-provenance/src/ui/graph-preview.ts index 9a1745b1..48905a99 100644 --- a/autk-provenance/src/ui/graph-preview.ts +++ b/autk-provenance/src/ui/graph-preview.ts @@ -1,29 +1,29 @@ import type { AutarkProvenanceApi } from '../create-autark-provenance'; -import { buildGraphLayout } from './graph-layout'; +import { buildGraphLayout, type GraphLayout } from './graph-layout'; import { createGraphScene, createSvgElement } from './graph-scene'; -export function renderGraphPreview( - container: HTMLElement, - provenance: AutarkProvenanceApi, - showTimestamps: boolean -): void { - container.innerHTML = ''; - +export function buildLayoutFromProvenance( + provenance: AutarkProvenanceApi +): GraphLayout | null { const graph = provenance.getGraph(); - const currentId = provenance.getCurrentNode()?.id ?? null; - if (!graph.nodes.get(graph.rootId)) return; + return graph.nodes.get(graph.rootId) ? buildGraphLayout(graph.nodes, graph.rootId) : null; +} + +export function renderGraphPreview(options: { + container: HTMLDivElement; + provenance: AutarkProvenanceApi; + showTimestamps: boolean; +}): void { + const { container, provenance, showTimestamps } = options; + const layout = buildLayoutFromProvenance(provenance); + container.innerHTML = ''; + if (!layout) return; - const layout = buildGraphLayout(graph.nodes, graph.rootId); const svg = createSvgElement('svg'); svg.classList.add('autk-provenance-graph-svg'); svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`); svg.setAttribute('width', String(layout.width)); svg.setAttribute('height', String(layout.height)); - svg.appendChild( - createGraphScene(layout, currentId, showTimestamps, (nodeId) => { - provenance.goToNode(nodeId); - }) - ); - + svg.appendChild(createGraphScene(layout, provenance.getCurrentNode()?.id ?? null, showTimestamps, (nodeId) => provenance.goToNode(nodeId))); container.appendChild(svg); } diff --git a/autk-provenance/src/ui/graph-scene.ts b/autk-provenance/src/ui/graph-scene.ts index 7a2525d8..0ff38bc8 100644 --- a/autk-provenance/src/ui/graph-scene.ts +++ b/autk-provenance/src/ui/graph-scene.ts @@ -1,8 +1,9 @@ -import type { AutarkProvenanceState } from '../types'; import type { GraphLayout } from './graph-layout'; import { formatTime, truncate } from './utils'; -export function createSvgElement(tag: T): SVGElementTagNameMap[T] { +export function createSvgElement( + tag: T +): SVGElementTagNameMap[T] { return document.createElementNS('http://www.w3.org/2000/svg', tag); } @@ -13,37 +14,34 @@ export function createGraphScene( onNodeClick: (nodeId: string) => void ): SVGGElement { const root = createSvgElement('g'); - const positionById = new Map(layout.nodes.map((n) => [n.node.id, n])); + const positionById = new Map(layout.nodes.map((item) => [item.node.id, item])); - for (const edge of layout.edges) { + layout.edges.forEach((edge) => { const from = positionById.get(edge.from); const to = positionById.get(edge.to); - if (!from || !to) continue; - + if (!from || !to) return; const path = createSvgElement('path'); const ctrlDx = Math.max(32, (to.x - from.x) * 0.55); - path.setAttribute( - 'd', - `M ${from.x} ${from.y} C ${from.x + ctrlDx} ${from.y}, ${to.x - ctrlDx} ${to.y}, ${to.x} ${to.y}` - ); + path.setAttribute('d', `M ${from.x} ${from.y} C ${from.x + ctrlDx} ${from.y}, ${to.x - ctrlDx} ${to.y}, ${to.x} ${to.y}`); path.setAttribute('class', 'autk-provenance-edge'); root.appendChild(path); - } + }); - for (const item of layout.nodes) { - const g = createSvgElement('g'); + layout.nodes.forEach((item) => { + const group = createSvgElement('g'); const isCurrent = item.node.id === currentId; - const hasAnnotation = - typeof item.node.metadata?.insight === 'string' && - (item.node.metadata.insight as string).trim().length > 0; - - g.setAttribute('class', `autk-provenance-node${isCurrent ? ' autk-provenance-node-current' : ''}`); - g.setAttribute('transform', `translate(${item.x}, ${item.y})`); + const hasAnnotation = typeof item.node.metadata?.insight === 'string' && `${item.node.metadata.insight}`.trim().length > 0; + group.setAttribute('class', `autk-provenance-node${isCurrent ? ' autk-provenance-node-current' : ''}`); + group.setAttribute('transform', `translate(${item.x}, ${item.y})`); + group.addEventListener('click', (event) => { + event.stopPropagation(); + onNodeClick(item.node.id); + }); const circle = createSvgElement('circle'); circle.setAttribute('class', 'autk-provenance-node-circle'); circle.setAttribute('r', isCurrent ? '9' : '7'); - g.appendChild(circle); + group.appendChild(circle); if (hasAnnotation) { const dot = createSvgElement('circle'); @@ -51,22 +49,19 @@ export function createGraphScene( dot.setAttribute('cx', isCurrent ? '7' : '5'); dot.setAttribute('cy', isCurrent ? '-7' : '-5'); dot.setAttribute('r', '4'); - g.appendChild(dot); + group.appendChild(dot); } - const annotationText = hasAnnotation - ? `\n"${(item.node.metadata!.insight as string).trim()}"` - : ''; const title = createSvgElement('title'); - title.textContent = `${item.node.actionLabel} (${item.node.id})${annotationText}`; - g.appendChild(title); + title.textContent = `${item.node.actionLabel} (${item.node.id})${hasAnnotation ? `\n"${`${item.node.metadata?.insight}`.trim()}"` : ''}`; + group.appendChild(title); const label = createSvgElement('text'); label.setAttribute('class', 'autk-provenance-node-label'); label.setAttribute('x', '14'); label.setAttribute('y', '-2'); label.textContent = truncate(item.node.actionLabel); - g.appendChild(label); + group.appendChild(label); if (showTimestamps) { const time = createSvgElement('text'); @@ -74,16 +69,11 @@ export function createGraphScene( time.setAttribute('x', '14'); time.setAttribute('y', '12'); time.textContent = formatTime(item.node.timestamp); - g.appendChild(time); + group.appendChild(time); } - g.addEventListener('click', (event) => { - event.stopPropagation(); - onNodeClick(item.node.id); - }); - - root.appendChild(g); - } + root.appendChild(group); + }); return root; } diff --git a/autk-provenance/src/ui/insights-panel.ts b/autk-provenance/src/ui/insights-panel.ts index d286c6e4..4678f7e8 100644 --- a/autk-provenance/src/ui/insights-panel.ts +++ b/autk-provenance/src/ui/insights-panel.ts @@ -1,10 +1,5 @@ import type { AutarkProvenanceApi } from '../create-autark-provenance'; -import { - computeGraphMetrics, - generateSessionNarrative, - getInsightAnnotations, - type StrategyLabel, -} from '../insight-engine'; +import { computeGraphMetrics, generateSessionNarrative, getInsightAnnotations, type StrategyLabel } from '../insight-engine'; import { formatDurationShort, truncate } from './utils'; const STRATEGY_COLORS: Record = { @@ -13,138 +8,111 @@ const STRATEGY_COLORS: Record = { 'Iterative Refinement': '#6a1b9a', }; -export function renderInsightsPanel( - container: HTMLElement, - provenance: AutarkProvenanceApi -): void { - container.innerHTML = ''; - +export function renderInsightsPanel(container: HTMLElement, provenance: AutarkProvenanceApi): void { const graph = provenance.getGraph(); const currentNode = provenance.getCurrentNode(); const metrics = computeGraphMetrics(graph); const annotations = getInsightAnnotations(graph); + const narrative = generateSessionNarrative(graph, metrics, annotations); + container.innerHTML = ''; const metricsRow = document.createElement('div'); metricsRow.className = 'autk-prov-insights-metrics'; - const badge = document.createElement('span'); badge.className = 'autk-prov-strategy-badge'; badge.textContent = metrics.strategyLabel; badge.style.background = STRATEGY_COLORS[metrics.strategyLabel]; + const meta = document.createElement('span'); + meta.className = 'autk-prov-metrics-meta'; + meta.textContent = [ + metrics.sessionDurationMs > 0 ? formatDurationShort(metrics.sessionDurationMs) : null, + `${metrics.totalNodes} state${metrics.totalNodes !== 1 ? 's' : ''}`, + metrics.branchPoints > 0 ? `${metrics.branchPoints} branch${metrics.branchPoints !== 1 ? 'es' : ''}` : null, + metrics.backtracks > 0 ? `${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''}` : null, + ].filter(Boolean).join(' • '); metricsRow.appendChild(badge); - - const metaItems: string[] = []; - if (metrics.sessionDurationMs > 0) { - metaItems.push(formatDurationShort(metrics.sessionDurationMs)); - } - metaItems.push(`${metrics.totalNodes} state${metrics.totalNodes !== 1 ? 's' : ''}`); - if (metrics.branchPoints > 0) { - metaItems.push(`${metrics.branchPoints} branch${metrics.branchPoints !== 1 ? 'es' : ''}`); - } - if (metrics.backtracks > 0) { - metaItems.push(`${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''}`); - } - - const metaSpan = document.createElement('span'); - metaSpan.className = 'autk-prov-metrics-meta'; - metaSpan.textContent = metaItems.join(' • '); - metricsRow.appendChild(metaSpan); - + metricsRow.appendChild(meta); container.appendChild(metricsRow); - const annotateSection = document.createElement('div'); - annotateSection.className = 'autk-prov-insights-section'; - - const annotateLabel = document.createElement('div'); - annotateLabel.className = 'autk-prov-insights-section-title'; - annotateLabel.textContent = 'Insight at this step'; - annotateSection.appendChild(annotateLabel); + container.appendChild(createAnnotationEditor(provenance, container, currentNode?.id ?? null, typeof currentNode?.metadata?.insight === 'string' ? currentNode.metadata.insight : '')); + if (annotations.length > 0) container.appendChild(createAnnotationsList(provenance, annotations)); + container.appendChild(createSummarySection(narrative)); +} +function createAnnotationEditor( + provenance: AutarkProvenanceApi, + container: HTMLElement, + nodeId: string | null, + existingInsight: string +): HTMLElement { + const section = createSection('Insight at this step'); const textarea = document.createElement('textarea'); + const save = document.createElement('button'); textarea.className = 'autk-prov-annotation-textarea'; textarea.placeholder = 'What did you notice or conclude here?'; textarea.rows = 3; - if (currentNode) { - const existing = currentNode.metadata?.insight; - textarea.value = typeof existing === 'string' ? existing : ''; - } else { - textarea.disabled = true; - } - annotateSection.appendChild(textarea); - - const saveBtn = document.createElement('button'); - saveBtn.className = 'autk-prov-annotation-save'; - saveBtn.textContent = 'Save Insight'; - saveBtn.disabled = !currentNode; - saveBtn.addEventListener('click', () => { - if (!currentNode) return; - provenance.annotateNode(currentNode.id, textarea.value); + textarea.value = existingInsight; + textarea.disabled = !nodeId; + save.className = 'autk-prov-annotation-save'; + save.textContent = 'Save Insight'; + save.disabled = !nodeId; + save.addEventListener('click', () => { + if (!nodeId) return; + provenance.annotateNode(nodeId, textarea.value); renderInsightsPanel(container, provenance); }); - annotateSection.appendChild(saveBtn); - - container.appendChild(annotateSection); - - if (annotations.length > 0) { - const annoSection = document.createElement('div'); - annoSection.className = 'autk-prov-insights-section'; - - const annoTitle = document.createElement('div'); - annoTitle.className = 'autk-prov-insights-section-title'; - annoTitle.textContent = `Recorded insights (${annotations.length})`; - annoSection.appendChild(annoTitle); - - for (const a of annotations) { - const item = document.createElement('div'); - item.className = 'autk-prov-anno-item'; - - const step = document.createElement('div'); - step.className = 'autk-prov-anno-step'; - step.textContent = truncate(a.actionLabel, 24); - item.appendChild(step); - - const text = document.createElement('div'); - text.className = 'autk-prov-anno-text'; - text.textContent = a.text; - item.appendChild(text); - - item.addEventListener('click', () => { - provenance.goToNode(a.nodeId); - }); - - annoSection.appendChild(item); - } - - container.appendChild(annoSection); - } - - const summarySection = document.createElement('div'); - summarySection.className = 'autk-prov-insights-section'; - - const summaryTitle = document.createElement('div'); - summaryTitle.className = 'autk-prov-insights-section-title'; - summaryTitle.textContent = 'Analysis summary'; - summarySection.appendChild(summaryTitle); - - const narrative = generateSessionNarrative(graph, metrics, annotations); + section.appendChild(textarea); + section.appendChild(save); + return section; +} - const summaryPre = document.createElement('pre'); - summaryPre.className = 'autk-prov-summary-text'; - summaryPre.textContent = narrative; - summarySection.appendChild(summaryPre); +function createAnnotationsList( + provenance: AutarkProvenanceApi, + annotations: ReturnType +): HTMLElement { + const section = createSection(`Recorded insights (${annotations.length})`); + annotations.forEach((annotation) => { + const item = document.createElement('div'); + const step = document.createElement('div'); + const text = document.createElement('div'); + item.className = 'autk-prov-anno-item'; + step.className = 'autk-prov-anno-step'; + text.className = 'autk-prov-anno-text'; + step.textContent = truncate(annotation.actionLabel, 24); + text.textContent = annotation.text; + item.appendChild(step); + item.appendChild(text); + item.addEventListener('click', () => provenance.goToNode(annotation.nodeId)); + section.appendChild(item); + }); + return section; +} - const copyBtn = document.createElement('button'); - copyBtn.className = 'autk-prov-copy-btn'; - copyBtn.textContent = 'Copy to clipboard'; - copyBtn.addEventListener('click', () => { +function createSummarySection(narrative: string): HTMLElement { + const section = createSection('Analysis summary'); + const summary = document.createElement('pre'); + const copyButton = document.createElement('button'); + summary.className = 'autk-prov-summary-text'; + summary.textContent = narrative; + copyButton.className = 'autk-prov-copy-btn'; + copyButton.textContent = 'Copy to clipboard'; + copyButton.addEventListener('click', () => { navigator.clipboard.writeText(narrative).then(() => { - copyBtn.textContent = 'Copied!'; - setTimeout(() => { - copyBtn.textContent = 'Copy to clipboard'; - }, 1800); + copyButton.textContent = 'Copied!'; + setTimeout(() => { copyButton.textContent = 'Copy to clipboard'; }, 1800); }); }); - summarySection.appendChild(copyBtn); + section.appendChild(summary); + section.appendChild(copyButton); + return section; +} - container.appendChild(summarySection); +function createSection(titleText: string): HTMLDivElement { + const section = document.createElement('div'); + const title = document.createElement('div'); + section.className = 'autk-prov-insights-section'; + title.className = 'autk-prov-insights-section-title'; + title.textContent = titleText; + section.appendChild(title); + return section; } diff --git a/autk-provenance/src/ui/path-list.ts b/autk-provenance/src/ui/path-list.ts new file mode 100644 index 00000000..b9fd5421 --- /dev/null +++ b/autk-provenance/src/ui/path-list.ts @@ -0,0 +1,45 @@ +import type { AutarkProvenanceApi } from '../create-autark-provenance'; +import type { AutarkProvenanceState, PathNode } from '../types'; +import { formatTime } from './utils'; + +export function renderPathList(options: { + container: HTMLDivElement; + provenance: AutarkProvenanceApi; + path: PathNode[]; + showTimestamps: boolean; +}): void { + const { container, provenance, path, showTimestamps } = options; + const currentId = provenance.getCurrentNode()?.id ?? null; + const graph = provenance.getGraph(); + container.innerHTML = ''; + + path.forEach((node) => { + const item = document.createElement('div'); + item.className = `autk-provenance-path-item${node.id === currentId ? ' autk-provenance-path-item-current' : ''}`; + item.setAttribute('role', 'listitem'); + item.setAttribute('data-node-id', node.id); + + const label = document.createElement('span'); + label.className = 'autk-provenance-path-label'; + label.textContent = node.actionLabel; + item.appendChild(label); + + const annotation = graph.nodes.get(node.id)?.metadata?.insight; + if (typeof annotation === 'string' && annotation.trim().length > 0) { + const dot = document.createElement('span'); + dot.title = annotation; + dot.style.cssText = 'display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0'; + item.appendChild(dot); + } + + if (showTimestamps) { + const time = document.createElement('span'); + time.className = 'autk-provenance-path-time'; + time.textContent = formatTime(node.timestamp); + item.appendChild(time); + } + + item.addEventListener('click', () => provenance.goToNode(node.id)); + container.appendChild(item); + }); +} diff --git a/autk-provenance/src/ui/styles.ts b/autk-provenance/src/ui/styles.ts index a435b893..fe6616c6 100644 --- a/autk-provenance/src/ui/styles.ts +++ b/autk-provenance/src/ui/styles.ts @@ -1,12 +1,11 @@ export function ensureProvenanceTrailStyles(): void { if (typeof document === 'undefined' || !document.head) return; - const styleId = 'autk-provenance-trail-styles'; if (document.getElementById(styleId)) return; - const el = document.createElement('style'); - el.id = styleId; - el.textContent = [ + const style = document.createElement('style'); + style.id = styleId; + style.textContent = [ '.autk-provenance-root{display:flex;flex-direction:column;height:100%;font-family:system-ui,sans-serif;font-size:13px;gap:10px;min-height:0}', '.autk-provenance-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}', '.autk-provenance-toolbar-main{display:flex;align-items:center;gap:6px;flex-wrap:wrap}', @@ -74,5 +73,5 @@ export function ensureProvenanceTrailStyles(): void { '.autk-prov-copy-btn{align-self:flex-start;padding:5px 10px;border:1px solid #c3cfdb;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#1f344b}', '.autk-prov-copy-btn:hover{background:#edf5ff}', ].join(''); - document.head.appendChild(el); + document.head.appendChild(style); } diff --git a/autk-provenance/src/ui/toolbar.ts b/autk-provenance/src/ui/toolbar.ts new file mode 100644 index 00000000..1ff06690 --- /dev/null +++ b/autk-provenance/src/ui/toolbar.ts @@ -0,0 +1,89 @@ +export interface GraphToolbarElements { + toolbar: HTMLDivElement; + toggleButton: HTMLButtonElement; + expandButton: HTMLButtonElement; + hint: HTMLSpanElement; +} + +export interface NavigationButtons { + row: HTMLDivElement; + backButton: HTMLButtonElement; + forwardButton: HTMLButtonElement; +} + +export function createGraphToolbar(): GraphToolbarElements { + const toolbar = document.createElement('div'); + const main = document.createElement('div'); + const toggleButton = document.createElement('button'); + const expandButton = document.createElement('button'); + const hint = document.createElement('span'); + + toolbar.className = 'autk-provenance-toolbar'; + main.className = 'autk-provenance-toolbar-main'; + toggleButton.className = 'autk-provenance-toggle'; + expandButton.className = 'autk-provenance-toggle'; + hint.className = 'autk-provenance-toolbar-hint'; + + main.appendChild(toggleButton); + main.appendChild(expandButton); + toolbar.appendChild(main); + toolbar.appendChild(hint); + + return { toolbar, toggleButton, expandButton, hint }; +} + +export function createNavigationButtons(): NavigationButtons { + const row = document.createElement('div'); + const backButton = document.createElement('button'); + const forwardButton = document.createElement('button'); + row.className = 'autk-provenance-buttons'; + backButton.textContent = '\u2190 Back'; + backButton.setAttribute('aria-label', 'Go back one step'); + forwardButton.textContent = 'Forward \u2192'; + forwardButton.setAttribute('aria-label', 'Go forward one step'); + row.appendChild(backButton); + row.appendChild(forwardButton); + return { row, backButton, forwardButton }; +} + +export function createInsightsShell(parent: HTMLElement): { + body: HTMLDivElement; + setOpen(open: boolean): void; + isOpen(): boolean; +} { + const wrap = document.createElement('div'); + const header = document.createElement('div'); + const label = document.createElement('span'); + const chevron = document.createElement('span'); + const body = document.createElement('div'); + let open = true; + + wrap.className = 'autk-prov-insights-wrap'; + header.className = 'autk-prov-insights-header'; + label.className = 'autk-prov-insights-header-label'; + chevron.className = 'autk-prov-insights-chevron'; + body.className = 'autk-prov-insights-body'; + label.textContent = 'Session Insights'; + chevron.textContent = '\u25b4'; + header.appendChild(label); + header.appendChild(chevron); + wrap.appendChild(header); + wrap.appendChild(body); + parent.appendChild(wrap); + + header.addEventListener('click', () => { + open = !open; + body.style.display = open ? 'flex' : 'none'; + chevron.textContent = open ? '\u25b4' : '\u25be'; + }); + + return { + body, + setOpen: (value) => { + open = value; + body.style.display = open ? 'flex' : 'none'; + chevron.textContent = open ? '\u25b4' : '\u25be'; + }, + isOpen: () => open, + }; +} diff --git a/autk-provenance/src/ui/utils.ts b/autk-provenance/src/ui/utils.ts index aec6129a..931cc2e7 100644 --- a/autk-provenance/src/ui/utils.ts +++ b/autk-provenance/src/ui/utils.ts @@ -1,11 +1,13 @@ export function formatTime(ts: number): string { - const d = new Date(ts); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + return new Date(ts).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); } export function truncate(text: string, max = 30): string { - if (text.length <= max) return text; - return `${text.slice(0, max - 1)}\u2026`; + return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`; } export function clamp(value: number, min: number, max: number): number { @@ -14,7 +16,6 @@ export function clamp(value: number, min: number, max: number): number { export function formatDurationShort(ms: number): string { if (ms < 1000) return `${ms}ms`; - const s = Math.round(ms / 1000); - if (s < 60) return `${s}s`; - return `${Math.floor(s / 60)}m ${s % 60}s`; + const seconds = Math.round(ms / 1000); + return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; } From 81b4fb4e60158377a2532cee0d14195a4e58e47e Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Wed, 6 May 2026 01:02:56 -0500 Subject: [PATCH 14/21] feat(provenance): generalize session insights --- autk-provenance/src/core-engine.ts | 1 + autk-provenance/src/core/store.ts | 1 + autk-provenance/src/create-provenance.ts | 2 + autk-provenance/src/index.ts | 3 ++ autk-provenance/src/insight-engine.ts | 11 +++-- autk-provenance/src/insights/annotations.ts | 14 ++---- autk-provenance/src/insights/graph-metrics.ts | 28 +++--------- autk-provenance/src/insights/narrative.ts | 43 +++++++++++-------- .../src/insights/selection-frequency.ts | 12 ++---- autk-provenance/src/insights/types.ts | 16 +++++++ autk-provenance/src/provenance-trail-ui.ts | 3 ++ autk-provenance/src/ui/insights-panel.ts | 12 +++--- autk-provenance/src/ui/toolbar.ts | 7 +++ 13 files changed, 85 insertions(+), 68 deletions(-) diff --git a/autk-provenance/src/core-engine.ts b/autk-provenance/src/core-engine.ts index dad52f7c..ffc4382a 100644 --- a/autk-provenance/src/core-engine.ts +++ b/autk-provenance/src/core-engine.ts @@ -174,6 +174,7 @@ export function createGraphEngine(options: GraphEngineOptions): GraphEngin const node = nodes.get(nodeId); if (!node) return false; node.metadata = { ...(node.metadata ?? {}), insight: text }; + notify(); return true; } diff --git a/autk-provenance/src/core/store.ts b/autk-provenance/src/core/store.ts index 628dd50e..775d5cfe 100644 --- a/autk-provenance/src/core/store.ts +++ b/autk-provenance/src/core/store.ts @@ -129,6 +129,7 @@ export function createProvenanceCoreStore( const node = nodes.get(nodeId); if (!node) return false; node.metadata = { ...(node.metadata ?? {}), insight: text }; + notifyObservers(); return true; }, }; diff --git a/autk-provenance/src/create-provenance.ts b/autk-provenance/src/create-provenance.ts index ddd407fa..bdfff9c3 100644 --- a/autk-provenance/src/create-provenance.ts +++ b/autk-provenance/src/create-provenance.ts @@ -22,6 +22,7 @@ export interface ProvenanceApi { exportGraph(): string; importGraph(json: string): void; addObserver(callback: (node: ProvenanceNode) => void): () => void; + annotateNode(nodeId: string, text: string): boolean; } function defaultMerge(base: T, delta: Partial): T { @@ -56,5 +57,6 @@ export function createProvenance>( exportGraph: core.exportGraph.bind(core), importGraph: core.importGraph.bind(core), addObserver: core.addObserver.bind(core), + annotateNode: core.annotateNode.bind(core), }; } diff --git a/autk-provenance/src/index.ts b/autk-provenance/src/index.ts index bb939ba0..9355277f 100644 --- a/autk-provenance/src/index.ts +++ b/autk-provenance/src/index.ts @@ -7,6 +7,7 @@ export { createProvenance } from './create-provenance'; export type { ProvenanceApi, CreateProvenanceOptions } from './create-provenance'; export { renderProvenanceTrailUI } from './provenance-trail-ui'; export type { ProvenanceTrailUIOptions } from './provenance-trail-ui'; +export { renderInsightsPanel } from './ui/insights-panel'; export * from './adapters'; export { computeSelectionFrequency, @@ -19,4 +20,6 @@ export type { GraphMetrics, StrategyLabel, InsightAnnotation, + InsightsProvenanceApi, + InsightSelectionState, } from './insight-engine'; diff --git a/autk-provenance/src/insight-engine.ts b/autk-provenance/src/insight-engine.ts index 98273c9a..c81d8d9b 100644 --- a/autk-provenance/src/insight-engine.ts +++ b/autk-provenance/src/insight-engine.ts @@ -1,7 +1,12 @@ export { getInsightAnnotations } from './insights/annotations'; -export type { InsightAnnotation } from './insights/annotations'; export { computeGraphMetrics } from './insights/graph-metrics'; -export type { GraphMetrics, StrategyLabel } from './insights/graph-metrics'; export { generateSessionNarrative } from './insights/narrative'; export { computeSelectionFrequency } from './insights/selection-frequency'; -export type { SelectionFrequency } from './insights/selection-frequency'; +export type { + GraphMetrics, + InsightAnnotation, + InsightsProvenanceApi, + InsightSelectionState, + SelectionFrequency, + StrategyLabel, +} from './insights/types'; diff --git a/autk-provenance/src/insights/annotations.ts b/autk-provenance/src/insights/annotations.ts index d66fd7b7..c18854c1 100644 --- a/autk-provenance/src/insights/annotations.ts +++ b/autk-provenance/src/insights/annotations.ts @@ -1,15 +1,7 @@ -import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import type { ProvenanceGraph } from '../types'; +import type { InsightAnnotation } from './types'; -export interface InsightAnnotation { - nodeId: string; - actionLabel: string; - text: string; - timestamp: number; -} - -export function getInsightAnnotations( - graph: ProvenanceGraph -): InsightAnnotation[] { +export function getInsightAnnotations(graph: ProvenanceGraph): InsightAnnotation[] { const annotations: InsightAnnotation[] = []; for (const node of graph.nodes.values()) { diff --git a/autk-provenance/src/insights/graph-metrics.ts b/autk-provenance/src/insights/graph-metrics.ts index 295d96ce..f3b5aafb 100644 --- a/autk-provenance/src/insights/graph-metrics.ts +++ b/autk-provenance/src/insights/graph-metrics.ts @@ -1,20 +1,7 @@ -import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import type { ProvenanceGraph } from '../types'; +import type { GraphMetrics } from './types'; -export type StrategyLabel = 'Confirmatory' | 'Exploratory' | 'Iterative Refinement'; - -export interface GraphMetrics { - totalNodes: number; - branchPoints: number; - backtracks: number; - maxDepth: number; - sessionDurationMs: number; - avgTimePerStateMs: number; - branchRatio: number; - strategyLabel: StrategyLabel; - insightCount: number; -} - -function computeMaxDepth(graph: ProvenanceGraph): number { +function computeMaxDepth(graph: ProvenanceGraph): number { let maxDepth = 0; const visited = new Set(); @@ -29,10 +16,10 @@ function computeMaxDepth(graph: ProvenanceGraph): number return maxDepth; } -export function computeGraphMetrics( - graph: ProvenanceGraph -): GraphMetrics { +export function computeGraphMetrics(graph: ProvenanceGraph): GraphMetrics { const nodes = Array.from(graph.nodes.values()); + const insightCount = nodes.filter((node) => typeof node.metadata?.insight === 'string' && `${node.metadata.insight}`.trim().length > 0).length; + if (nodes.length <= 1) { return { totalNodes: nodes.length, @@ -43,7 +30,7 @@ export function computeGraphMetrics( avgTimePerStateMs: 0, branchRatio: 0, strategyLabel: 'Confirmatory', - insightCount: 0, + insightCount, }; } @@ -52,7 +39,6 @@ export function computeGraphMetrics( const timestamps = nodes.map((node) => node.timestamp).sort((a, b) => a - b); const sessionDurationMs = timestamps[timestamps.length - 1] - timestamps[0]; const branchRatio = branchPoints / nodes.length; - const insightCount = nodes.filter((node) => typeof node.metadata?.insight === 'string' && `${node.metadata.insight}`.trim().length > 0).length; return { totalNodes: nodes.length, diff --git a/autk-provenance/src/insights/narrative.ts b/autk-provenance/src/insights/narrative.ts index a1f0c5e9..bc6894cd 100644 --- a/autk-provenance/src/insights/narrative.ts +++ b/autk-provenance/src/insights/narrative.ts @@ -1,7 +1,6 @@ -import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; -import type { InsightAnnotation } from './annotations'; +import type { ProvenanceGraph } from '../types'; import { computeSelectionFrequency } from './selection-frequency'; -import type { GraphMetrics, StrategyLabel } from './graph-metrics'; +import type { GraphMetrics, InsightAnnotation, InsightSelectionState, StrategyLabel } from './types'; function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; @@ -10,36 +9,42 @@ function formatDuration(ms: number): string { return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; } -export function generateSessionNarrative( - graph: ProvenanceGraph, +export function generateSessionNarrative( + graph: ProvenanceGraph, metrics: GraphMetrics, annotations: InsightAnnotation[] ): string { + const timestamps = Array.from(graph.nodes.values()).map((node) => node.timestamp).sort((a, b) => a - b); + const sessionStart = timestamps[0]; + const selectionFrequency = computeSelectionFrequency(graph); + const topMap = [...selectionFrequency.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); + const topPlots = [...selectionFrequency.plots.entries()] + .map(([plotId, plotFreq]) => ({ + plotId, + entries: [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), + })) + .filter((plot) => plot.entries.length > 0); const lines = [ - `Session started at ${graph.nodes.get(graph.rootId) ? new Date(graph.nodes.get(graph.rootId)!.timestamp).toLocaleTimeString() : '—'}.`, + `Session started at ${typeof sessionStart === 'number' ? new Date(sessionStart).toLocaleTimeString() : '—'}.`, `Duration: ${formatDuration(metrics.sessionDurationMs)} across ${metrics.totalNodes} states (avg ${formatDuration(metrics.avgTimePerStateMs)} per state).`, '', `Analysis strategy: ${metrics.strategyLabel}`, strategyDescription[metrics.strategyLabel], + `Branch/backtrack summary: ${metrics.branchPoints} branch point${metrics.branchPoints !== 1 ? 's' : ''} and ${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''}.`, ]; - if (metrics.branchPoints > 0) { - lines.push( - `The analysis diverged at ${metrics.branchPoints} branch point${metrics.branchPoints > 1 ? 's' : ''}, with ${metrics.backtracks} backtrack${metrics.backtracks !== 1 ? 's' : ''} before settling on the current path.` - ); - } - - const selectionFrequency = computeSelectionFrequency(graph); - const topMap = [...selectionFrequency.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); if (topMap.length > 0) { lines.push('', `Most revisited map features across all branches: ${topMap.map(([id, count]) => `feature #${id} (${count} state${count !== 1 ? 's' : ''})`).join(', ')}.`); + } else { + lines.push('', 'Most revisited map features across all branches: none recorded.'); } - for (const [plotId, plotFreq] of selectionFrequency.plots.entries()) { - const topPlot = [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - if (topPlot.length > 0 && topPlot[0][1] > 1) { - lines.push(`Most revisited features in plot "${plotId}": ${topPlot.map(([id, count]) => `#${id} (${count}×)`).join(', ')}.`); - } + if (topPlots.length > 0) { + topPlots.forEach(({ plotId, entries }) => { + lines.push(`Most revisited features in plot "${plotId}": ${entries.map(([id, count]) => `#${id} (${count}×)`).join(', ')}.`); + }); + } else { + lines.push('Most revisited plot features: none recorded.'); } if (annotations.length > 0) { diff --git a/autk-provenance/src/insights/selection-frequency.ts b/autk-provenance/src/insights/selection-frequency.ts index 2614fa28..a75a8ac9 100644 --- a/autk-provenance/src/insights/selection-frequency.ts +++ b/autk-provenance/src/insights/selection-frequency.ts @@ -1,13 +1,7 @@ -import type { AutarkProvenanceState, ProvenanceGraph } from '../types'; +import type { ProvenanceGraph } from '../types'; +import type { InsightSelectionState, SelectionFrequency } from './types'; -export interface SelectionFrequency { - map: Map; - plots: Map>; -} - -export function computeSelectionFrequency( - graph: ProvenanceGraph -): SelectionFrequency { +export function computeSelectionFrequency(graph: ProvenanceGraph): SelectionFrequency { const mapFreq = new Map(); const plotsFreq = new Map>(); diff --git a/autk-provenance/src/insights/types.ts b/autk-provenance/src/insights/types.ts index aab65c45..01fe2065 100644 --- a/autk-provenance/src/insights/types.ts +++ b/autk-provenance/src/insights/types.ts @@ -1,3 +1,19 @@ +import type { ProvenanceGraph, ProvenanceNode } from '../types'; + +export interface InsightSelectionState { + selection: { + map: { layerId: string; ids: number[] } | null; + plots: Record; + }; +} + +export interface InsightsProvenanceApi { + getGraph(): ProvenanceGraph; + getCurrentNode(): ProvenanceNode | null; + goToNode(nodeId: string): boolean; + annotateNode(nodeId: string, text: string): boolean; +} + export interface SelectionFrequency { map: Map; plots: Map>; diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts index abceeaa5..e5d4a449 100644 --- a/autk-provenance/src/provenance-trail-ui.ts +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -29,6 +29,9 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = const navigation = showBackForward ? createNavigationButtons() : null; const insights = createInsightsShell(insightsContainer ?? container); const modal = createGraphModalController({ provenance, showTimestamps, buildLayout: () => buildLayoutFromProvenance(provenance), onRefresh: refresh }); + insights.onToggle((open) => { + if (open) renderInsightsPanel(insights.body, provenance); + }); if (toolbar && graphWrap) { graphWrap.className = 'autk-provenance-graph-wrap'; diff --git a/autk-provenance/src/ui/insights-panel.ts b/autk-provenance/src/ui/insights-panel.ts index 4678f7e8..80b99477 100644 --- a/autk-provenance/src/ui/insights-panel.ts +++ b/autk-provenance/src/ui/insights-panel.ts @@ -1,5 +1,4 @@ -import type { AutarkProvenanceApi } from '../create-autark-provenance'; -import { computeGraphMetrics, generateSessionNarrative, getInsightAnnotations, type StrategyLabel } from '../insight-engine'; +import { computeGraphMetrics, generateSessionNarrative, getInsightAnnotations, type InsightsProvenanceApi, type InsightSelectionState, type StrategyLabel } from '../insight-engine'; import { formatDurationShort, truncate } from './utils'; const STRATEGY_COLORS: Record = { @@ -8,7 +7,10 @@ const STRATEGY_COLORS: Record = { 'Iterative Refinement': '#6a1b9a', }; -export function renderInsightsPanel(container: HTMLElement, provenance: AutarkProvenanceApi): void { +export function renderInsightsPanel( + container: HTMLElement, + provenance: InsightsProvenanceApi +): void { const graph = provenance.getGraph(); const currentNode = provenance.getCurrentNode(); const metrics = computeGraphMetrics(graph); @@ -40,7 +42,7 @@ export function renderInsightsPanel(container: HTMLElement, provenance: AutarkPr } function createAnnotationEditor( - provenance: AutarkProvenanceApi, + provenance: InsightsProvenanceApi, container: HTMLElement, nodeId: string | null, existingInsight: string @@ -67,7 +69,7 @@ function createAnnotationEditor( } function createAnnotationsList( - provenance: AutarkProvenanceApi, + provenance: InsightsProvenanceApi, annotations: ReturnType ): HTMLElement { const section = createSection(`Recorded insights (${annotations.length})`); diff --git a/autk-provenance/src/ui/toolbar.ts b/autk-provenance/src/ui/toolbar.ts index 1ff06690..cadc7bf3 100644 --- a/autk-provenance/src/ui/toolbar.ts +++ b/autk-provenance/src/ui/toolbar.ts @@ -50,6 +50,7 @@ export function createInsightsShell(parent: HTMLElement): { body: HTMLDivElement; setOpen(open: boolean): void; isOpen(): boolean; + onToggle(callback: (open: boolean) => void): void; } { const wrap = document.createElement('div'); const header = document.createElement('div'); @@ -57,6 +58,7 @@ export function createInsightsShell(parent: HTMLElement): { const chevron = document.createElement('span'); const body = document.createElement('div'); let open = true; + let toggleCallback: ((open: boolean) => void) | null = null; wrap.className = 'autk-prov-insights-wrap'; header.className = 'autk-prov-insights-header'; @@ -75,6 +77,7 @@ export function createInsightsShell(parent: HTMLElement): { open = !open; body.style.display = open ? 'flex' : 'none'; chevron.textContent = open ? '\u25b4' : '\u25be'; + toggleCallback?.(open); }); return { @@ -83,7 +86,11 @@ export function createInsightsShell(parent: HTMLElement): { open = value; body.style.display = open ? 'flex' : 'none'; chevron.textContent = open ? '\u25b4' : '\u25be'; + toggleCallback?.(open); }, isOpen: () => open, + onToggle: (callback) => { + toggleCallback = callback; + }, }; } From 23fd5260cae43e3a31a6bf893dc9bf6b6644f1ad Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Wed, 6 May 2026 11:22:23 -0500 Subject: [PATCH 15/21] feat: added insights and charts --- Makefile | 7 +- autk-compute/package.json | 8 +- autk-db/package.json | 8 +- autk-map/package.json | 8 +- autk-plot/package.json | 8 +- autk-plot/src/index.ts | 5 + autk-plot/src/plot-base-data.ts | 14 +- autk-plot/src/plot-types/histogram.ts | 505 ++++++------- autk-plot/src/plots/barchart.ts | 41 + autk-provenance/package.json | 8 +- .../src/adapters/map-adapter-state.ts | 2 +- .../src/adapters/map-ui-helpers.ts | 4 +- autk-provenance/src/adapters/map/recording.ts | 14 +- autk-provenance/src/adapters/map/state.ts | 2 +- autk-provenance/src/adapters/map/utils.ts | 2 +- autk-provenance/src/adapters/plot-adapter.ts | 24 +- autk-provenance/src/charts/chart-config.ts | 126 ++++ autk-provenance/src/charts/chart-modal.ts | 239 ++++++ autk-provenance/src/charts/derived-metrics.ts | 90 +++ .../src/charts/feature-extraction.ts | 103 +++ autk-provenance/src/charts/types.ts | 70 ++ .../src/create-autark-provenance.ts | 2 +- autk-provenance/src/index.ts | 2 + autk-provenance/src/insights-workspace.ts | 314 ++++++++ autk-provenance/src/types.ts | 7 +- .../src/ui/workspace-session-insights.ts | 213 ++++++ autk-provenance/src/ui/workspace-shell.ts | 107 +++ autk-provenance/src/ui/workspace-styles.ts | 89 +++ autk-provenance/src/ui/workspace-tabs.ts | 23 + autk/src/provenance.ts | 1 + autk/vite.config.ts | 3 +- .../autk-provenance/all-plots-provenance.html | 148 +--- .../autk-provenance/all-plots-provenance.ts | 706 +----------------- .../autk-provenance/map-plot-provenance.html | 68 +- .../autk-provenance/map-plot-provenance.ts | 352 +-------- gallery/package.json | 6 +- .../autk-provenance/all-plots-provenance.css | 673 +++++++++++++++++ .../autk-provenance/all-plots-provenance.html | 12 + .../autk-provenance/all-plots-provenance.ts | 43 ++ .../autk-provenance/map-plot-provenance.css | 518 +++++++++++++ .../autk-provenance/map-plot-provenance.html | 12 + .../autk-provenance/map-plot-provenance.ts | 43 ++ gallery/vite.config.ts | 4 +- package.json | 1 + 44 files changed, 3103 insertions(+), 1532 deletions(-) create mode 100644 autk-provenance/src/charts/chart-config.ts create mode 100644 autk-provenance/src/charts/chart-modal.ts create mode 100644 autk-provenance/src/charts/derived-metrics.ts create mode 100644 autk-provenance/src/charts/feature-extraction.ts create mode 100644 autk-provenance/src/charts/types.ts create mode 100644 autk-provenance/src/insights-workspace.ts create mode 100644 autk-provenance/src/ui/workspace-session-insights.ts create mode 100644 autk-provenance/src/ui/workspace-shell.ts create mode 100644 autk-provenance/src/ui/workspace-styles.ts create mode 100644 autk-provenance/src/ui/workspace-tabs.ts create mode 100644 autk/src/provenance.ts create mode 100644 gallery/src/autk-provenance/all-plots-provenance.css create mode 100644 gallery/src/autk-provenance/all-plots-provenance.html create mode 100644 gallery/src/autk-provenance/all-plots-provenance.ts create mode 100644 gallery/src/autk-provenance/map-plot-provenance.css create mode 100644 gallery/src/autk-provenance/map-plot-provenance.html create mode 100644 gallery/src/autk-provenance/map-plot-provenance.ts diff --git a/Makefile b/Makefile index a38c956b..21b3ed26 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ lint: install: - $(CONCURRENTLY) $(foreach package,$(LIB_PACKAGES),cd $(package) && npm install) + npm install typecheck: $(CONCURRENTLY) \ @@ -88,9 +88,9 @@ test-codegen: dev: - npm install + make install make build - $(CONCURRENTLY) \ + ulimit -n 65536; $(CONCURRENTLY) \ "cd autk-map && npm run dev-build" \ "cd autk-db && npm run dev-build" \ "cd autk-plot && npm run dev-build" \ @@ -118,6 +118,7 @@ clean: "cd autk-db && $(RIMRAF) dist build" \ "cd autk-plot && $(RIMRAF) dist build" \ "cd autk-compute && $(RIMRAF) dist build" \ + "cd autk-provenance && $(RIMRAF) dist build" \ "cd gallery && $(RIMRAF) dist build" \ "cd usecases && $(RIMRAF) dist build" diff --git a/autk-compute/package.json b/autk-compute/package.json index 4685c42a..ca915e14 100644 --- a/autk-compute/package.json +++ b/autk-compute/package.json @@ -26,10 +26,10 @@ } }, "scripts": { - "dev": "vite", - "dev-build": "tsc && vite build --watch", - "build": "tsc && vite build", - "preview": "vite preview", + "dev": "ulimit -n 65536 && vite", + "dev-build": "ulimit -n 65536 && tsc && vite build --watch", + "build": "ulimit -n 65536 && tsc && vite build", + "preview": "ulimit -n 65536 && vite preview", "format": "prettier --write .", "doc": "typedoc --options typedoc.json" }, diff --git a/autk-db/package.json b/autk-db/package.json index 78a1f452..c40a2a71 100644 --- a/autk-db/package.json +++ b/autk-db/package.json @@ -23,10 +23,10 @@ } }, "scripts": { - "dev": "node ./node_modules/vite/bin/vite.js", - "dev-build": "tsc && node ./node_modules/vite/bin/vite.js build --watch", - "build": "tsc && node ./node_modules/vite/bin/vite.js build", - "preview": "node ./node_modules/vite/bin/vite.js preview", + "dev": "ulimit -n 65536 && vite", + "dev-build": "ulimit -n 65536 && tsc && vite build --watch", + "build": "ulimit -n 65536 && tsc && vite build", + "preview": "ulimit -n 65536 && vite preview", "format": "prettier --write .", "doc": "typedoc --options typedoc.json" }, diff --git a/autk-map/package.json b/autk-map/package.json index 9fe56831..83b86c70 100644 --- a/autk-map/package.json +++ b/autk-map/package.json @@ -27,10 +27,10 @@ }, "scripts": { "doc": "typedoc --options typedoc.json", - "dev": "vite", - "dev-build": "tsc && vite build --watch", - "build": "tsc && vite build", - "preview": "vite preview", + "dev": "ulimit -n 65536 && vite", + "dev-build": "ulimit -n 65536 && tsc && vite build --watch", + "build": "ulimit -n 65536 && tsc && vite build", + "preview": "ulimit -n 65536 && vite preview", "format": "prettier --write ." }, "devDependencies": { diff --git a/autk-plot/package.json b/autk-plot/package.json index 07dd314a..95bed250 100644 --- a/autk-plot/package.json +++ b/autk-plot/package.json @@ -21,10 +21,10 @@ } }, "scripts": { - "dev": "vite", - "dev-build": "tsc && vite build --watch", - "build": "tsc && vite build", - "preview": "vite preview", + "dev": "ulimit -n 65536 && vite", + "dev-build": "ulimit -n 65536 && tsc && vite build --watch", + "build": "ulimit -n 65536 && tsc && vite build", + "preview": "ulimit -n 65536 && vite preview", "format": "prettier --write .", "doc": "typedoc --options typedoc.json", "doc:watch": "typedoc --options typedoc.json --watch" diff --git a/autk-plot/src/index.ts b/autk-plot/src/index.ts index a2dbe73f..a80f6821 100644 --- a/autk-plot/src/index.ts +++ b/autk-plot/src/index.ts @@ -75,3 +75,8 @@ export { PlotStyle } from './plot-style'; /** Shared data transformation engine and ready-to-use transform presets. */ export * from './transforms'; + +// ─── Individual plot classes ───────────────────────────────────────────────── + +export { Scatterplot, Barchart, ParallelCoordinates, Linechart, TableVis, Heatmatrix } from './plots'; +export { Histogram } from './plot-types/histogram'; diff --git a/autk-plot/src/plot-base-data.ts b/autk-plot/src/plot-base-data.ts index cf5d7a40..35f851c6 100644 --- a/autk-plot/src/plot-base-data.ts +++ b/autk-plot/src/plot-base-data.ts @@ -249,10 +249,16 @@ export abstract class PlotBaseData { * @returns One `AutkDatum` per source feature with stable `autkIds` provenance. */ private buildSourceRows(): AutkDatum[] { - return this._sourceFeatures.map((feature, idx) => ({ - ...(feature.properties ?? {}), - autkIds: [idx], - })) as AutkDatum[]; + return this._sourceFeatures.map((feature, idx) => { + const explicitAutkIds = Array.isArray(feature.properties?.autkIds) + ? feature.properties.autkIds.filter((id): id is number => typeof id === 'number' && Number.isFinite(id)) + : null; + + return { + ...(feature.properties ?? {}), + autkIds: explicitAutkIds && explicitAutkIds.length > 0 ? explicitAutkIds : [idx], + }; + }) as AutkDatum[]; } /** diff --git a/autk-plot/src/plot-types/histogram.ts b/autk-plot/src/plot-types/histogram.ts index 3c8d0441..17d4c9c7 100644 --- a/autk-plot/src/plot-types/histogram.ts +++ b/autk-plot/src/plot-types/histogram.ts @@ -1,9 +1,10 @@ import * as d3 from 'd3'; -import { PlotD3 } from '../plot-d3'; -import { PlotConfig } from '../types'; +import { PlotBaseInteractive } from '../plot-base-interactive'; +import type { PlotConfig } from '../api'; import { PlotStyle } from '../plot-style'; -import { PlotEvent } from '../constants'; +import { PlotEvent } from '../types-events'; +import { valueAtPath } from '../types-core'; type BinDatum = { value: number; index: number }; @@ -11,292 +12,282 @@ type BinDatum = { value: number; index: number }; * D3 histogram chart. * * Each bar represents one bin computed by d3.bin() on the numeric property - * specified by config.labels.axis[0]. Selection emits and receives *feature + * specified by config.attributes.axis[0]. Selection emits and receives *feature * row indices* (not bin indices), so it is directly compatible with the * coordinated-view highlighting used by the map and other plots. * * config.labels.axis[1] is used as the Y-axis label (defaults to 'Count'). */ -export class Histogram extends PlotD3 { - private binData: d3.Bin[] = []; +export class Histogram extends PlotBaseInteractive { + private binData: d3.Bin[] = []; - constructor(config: PlotConfig) { - if (config.events === undefined) { config.events = [PlotEvent.CLICK]; } - super(config); - this.draw(); - } - - public async draw(): Promise { - const svg = d3 - .select(this._div) - .selectAll('#plot') - .data([0]) - .join('svg') - .attr('id', 'plot') - .style('width', `${this._width}px`) - .style('height', `${this._height}px`) - .style('visibility', 'visible'); + constructor(config: PlotConfig) { + if (config.events === undefined) { config.events = [PlotEvent.CLICK]; } + super(config); + this.draw(); + } - const node = svg.node(); - if (!svg || !node) throw new Error('SVG element could not be created.'); + public render(): void { + const svg = d3 + .select(this._div) + .selectAll('#plot') + .data([0]) + .join('svg') + .attr('id', 'plot') + .style('width', `${this._width}px`) + .style('height', `${this._height}px`) + .style('visibility', 'visible'); - const width = this._width - this._margins.left - this._margins.right; - const height = this._height - this._margins.top - this._margins.bottom; + const node = svg.node(); + if (!svg || !node) throw new Error('SVG element could not be created.'); - if (this._title && this._title.length > 0) { - svg - .selectAll('#plotTitle') - .data([this._title]) - .join('text') - .attr('id', 'plotTitle') - .attr('class', 'plot-title') - .attr('x', this._margins.left + width / 2) - .attr('y', Math.max(this._margins.top * 0.5, 10)) - .attr('text-anchor', 'middle') - .style('font-weight', '600') - .style('visibility', 'visible') - .text((d) => d); - } + const width = this._width - this._margins.left - this._margins.right; + const height = this._height - this._margins.top - this._margins.bottom; - const values: BinDatum[] = this.data.map((d, i) => ({ - value: Number(d?.[this._axis[0]] ?? 0), - index: i, - })); + if (this._title && this._title.length > 0) { + svg + .selectAll('#plotTitle') + .data([this._title]) + .join('text') + .attr('id', 'plotTitle') + .attr('class', 'plot-title') + .attr('x', this._margins.left + width / 2) + .attr('y', Math.max(this._margins.top * 0.5, 10)) + .attr('text-anchor', 'middle') + .style('font-weight', '600') + .style('visibility', 'visible') + .text((d) => d); + } - const extent = d3.extent(values, (d) => d.value) as [number, number]; - this.binData = d3 - .bin() - .value((d) => d.value) - .domain(extent)(values); + const axisAttr = this.renderAxisAttributes[0]; + const values: BinDatum[] = this._data.map((d, i) => ({ + value: Number(d ? valueAtPath(d, axisAttr) ?? 0 : 0), + index: i, + })); - const mapX = d3.scaleLinear().domain(extent).range([0, width]); - const maxCount = d3.max(this.binData, (b) => b.length) ?? 0; - const mapY = d3.scaleLinear().domain([0, maxCount]).range([height, 0]); + const extent = d3.extent(values, (d) => d.value) as [number, number]; + this.binData = d3 + .bin() + .value((d) => d.value) + .domain(extent)(values); - const xAxis = d3.axisBottom(mapX).tickSizeOuter(0).tickFormat(d3.format('.2s')); - svg - .selectAll('#axisX') - .data([0]) - .join('g') - .attr('id', 'axisX') - .attr('class', 'x axis') - .attr('transform', `translate(${this._margins.left}, ${this._height - this._margins.bottom})`) - .style('visibility', 'visible') - .call(xAxis); + const mapX = d3.scaleLinear().domain(extent).range([0, width]); + const maxCount = d3.max(this.binData, (b) => b.length) ?? 0; + const mapY = d3.scaleLinear().domain([0, maxCount]).range([height, 0]); - const yAxis = d3.axisLeft(mapY).tickSizeInner(-width).tickFormat(d3.format('.2s')); - const yAxisSel = svg - .selectAll('#axisY') - .data([0]) - .join('g') - .attr('id', 'axisY') - .attr('class', 'y axis') - .attr('transform', `translate(${this._margins.left}, ${this._margins.top})`) - .style('visibility', 'visible') - .call(yAxis); + const xAxis = d3.axisBottom(mapX).tickSizeOuter(0).tickFormat(d3.format('.2s')); + svg + .selectAll('#axisX') + .data([0]) + .join('g') + .attr('id', 'axisX') + .attr('class', 'x axis') + .attr('transform', `translate(${this._margins.left}, ${this._height - this._margins.bottom})`) + .style('visibility', 'visible') + .call(xAxis); - const yLabel = this._axis[1] ?? 'Count'; - yAxisSel - .selectAll('.hist-ylabel') - .data([yLabel]) - .join('text') - .attr('class', 'hist-ylabel title') - .attr('text-anchor', 'end') - .attr('transform', 'rotate(-90)') - .attr('y', -this._margins.left / 2 - 7) - .attr('x', -this._margins.top) - .style('visibility', 'visible') - .text((d) => d); + const yAxis = d3.axisLeft(mapY).tickSizeInner(-width).tickFormat(d3.format('.2s')); + const yAxisSel = svg + .selectAll('#axisY') + .data([0]) + .join('g') + .attr('id', 'axisY') + .attr('class', 'y axis') + .attr('transform', `translate(${this._margins.left}, ${this._margins.top})`) + .style('visibility', 'visible') + .call(yAxis); - const cGroup = svg - .selectAll('.autkBrushable') - .data([0]) - .join('g') - .attr('class', 'autkBrushable autkMarksGroup') - .attr('transform', `translate(${this._margins.left}, ${this._margins.top})`); + const yLabel = this._axisLabels[1] ?? 'Count'; + yAxisSel + .selectAll('.hist-ylabel') + .data([yLabel]) + .join('text') + .attr('class', 'hist-ylabel title') + .attr('text-anchor', 'end') + .attr('transform', 'rotate(-90)') + .attr('y', -this._margins.left / 2 - 7) + .attr('x', -this._margins.top) + .style('visibility', 'visible') + .text((d) => d); - cGroup - .selectAll('.autkClear') - .data([0]) - .join('rect') - .attr('class', 'autkClear') - .attr('width', width) - .attr('height', height) - .style('fill', 'white') - .style('opacity', 0) - .style('visibility', 'visible'); + const cGroup = svg + .selectAll('.autkBrushable') + .data([0]) + .join('g') + .attr('class', 'autkBrushable autkMarksGroup') + .attr('transform', `translate(${this._margins.left}, ${this._margins.top})`); - const barPad = 1; - cGroup - .selectAll('.autkMark') - .data(this.binData) - .join('rect') - .attr('class', 'autkMark') - .attr('x', (b) => mapX(b.x0!) + barPad) - .attr('y', (b) => mapY(b.length)) - .attr('width', (b) => Math.max(0, mapX(b.x1!) - mapX(b.x0!) - barPad * 2)) - .attr('height', (b) => height - mapY(b.length)) - .style('fill', PlotStyle.default) - .style('stroke', '#2f2f2f') - .style('visibility', 'visible'); + cGroup + .selectAll('.autkClear') + .data([0]) + .join('rect') + .attr('class', 'autkClear') + .attr('width', width) + .attr('height', height) + .style('fill', 'white') + .style('opacity', 0) + .style('visibility', 'visible'); - this.configureSignalListeners(); - } + const barPad = 1; + cGroup + .selectAll('.autkMark') + .data(this.binData) + .join('rect') + .attr('class', 'autkMark') + .attr('x', (b) => mapX(b.x0!) + barPad) + .attr('y', (b) => mapY(b.length)) + .attr('width', (b) => Math.max(0, mapX(b.x1!) - mapX(b.x0!) - barPad * 2)) + .attr('height', (b) => height - mapY(b.length)) + .style('fill', PlotStyle.default) + .style('stroke', '#2f2f2f') + .style('visibility', 'visible'); - // Expand a set of bin indices into the feature row indices within those bins. - private binIndicesToFeatureIds(binIndices: Set): number[] { - const ids: number[] = []; - for (const binIdx of binIndices) { - const bin = this.binData[binIdx]; - if (bin) ids.push(...bin.map((d) => d.index)); + this.configureSignalListeners(); } - return [...new Set(ids)]; - } - // Override: emit feature row indices (not bin indices) so coordinated-view works correctly. - clickEvent(): void { - const svgs = d3.select(this._div).selectAll('.autkMark'); - const cls = d3.select(this._div).selectAll('.autkClear'); - const plot = this; - - svgs.each(function (_d, binIdx: number) { - d3.select(this).on('click', function () { - const binFeatureIds = plot.binData[binIdx]?.map((d) => d.index) ?? []; - const allSelected = - binFeatureIds.length > 0 && - binFeatureIds.every((id) => plot.selection.includes(id)); - if (allSelected) { - plot.selection = plot.selection.filter((id) => !binFeatureIds.includes(id)); - } else { - plot.selection = [...new Set([...plot.selection, ...binFeatureIds])]; + private binIndicesToFeatureIds(binIndices: Set): number[] { + const ids: number[] = []; + for (const binIdx of binIndices) { + const bin = this.binData[binIdx]; + if (bin) ids.push(...bin.map((d) => d.index)); } - plot.plotEvents.emit(PlotEvent.CLICK, plot.selection); - plot.updatePlotSelection(); - }); - }); + return [...new Set(ids)]; + } + + // Highlight a bin only when every feature mapped to that bin is selected. + protected override renderSelection(): void { + const selectedSet = new Set(this.selection); + const plot = this; + d3.select(this._div).selectAll('.autkMark').style('fill', function (_d: unknown, binIdx: number) { + const bin = plot.binData[binIdx]; + const fullySelected = !!bin && bin.length > 0 && bin.every((d) => selectedSet.has(d.index)); + return fullySelected ? PlotStyle.highlight : PlotStyle.default; + }); + } - cls.on('click', function () { - plot.selection = []; - plot.plotEvents.emit(PlotEvent.CLICK, plot.selection); - plot.updatePlotSelection(); - }); - } + // Override: emit feature row indices (not bin indices) so coordinated-view works correctly. + protected override clickEvent(): void { + const svgs = d3.select(this._div).selectAll('.autkMark'); + const cls = d3.select(this._div).selectAll('.autkClear'); + const plot = this; - brushEvent(): void { - const brushable = d3.select(this._div).selectAll('.autkBrushable'); - const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); - const plot = this; + svgs.each(function (_d, binIdx: number) { + d3.select(this).on('click', function () { + const binFeatureIds = plot.binData[binIdx]?.map((d) => d.index) ?? []; + const current = plot.selection; + const allSelected = binFeatureIds.length > 0 && binFeatureIds.every((id) => current.includes(id)); + const next = allSelected + ? current.filter((id) => !binFeatureIds.includes(id)) + : [...new Set([...current, ...binFeatureIds])]; + plot.setSelection(next); + plot.events.emit(PlotEvent.CLICK, { selection: plot.selection }); + }); + }); - brushable.each(function () { - const cBrush = d3.select(this); - const brush = d3.brush() - .extent([[0, 0], [ - plot._width - plot._margins.left - plot._margins.right, - plot._height - plot._margins.top - plot._margins.bottom, - ]]) - .on('start end', function (event: d3.D3BrushEvent) { - if (event.selection) { - const [[x0, y0], [x1, y1]] = event.selection as [[number, number], [number, number]]; - const brushedBins = new Set(); - marksGroup.selectAll('.autkMark') - .each(function (_d, binIdx: number) { - const bbox = this.getBBox(); - if (!(bbox.x + bbox.width < x0 || bbox.x > x1 || bbox.y + bbox.height < y0 || bbox.y > y1)) { - brushedBins.add(binIdx); - } - }); - plot.selection = plot.binIndicesToFeatureIds(brushedBins); - } else { - plot.selection = []; - } - plot.plotEvents.emit(PlotEvent.BRUSH, plot.selection); - plot.updatePlotSelection(); + cls.on('click', function () { + plot.setSelection([]); + plot.events.emit(PlotEvent.CLICK, { selection: [] }); }); - cBrush.call(brush); - }); - } + } - brushXEvent(): void { - const brushable = d3.select(this._div).selectAll('.autkBrushable'); - const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); - const innerHeight = this._height - this._margins.top - this._margins.bottom; - const plot = this; + protected override brushEvent(): void { + const brushable = d3.select(this._div).selectAll('.autkBrushable'); + const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); + const plot = this; - brushable.each(function () { - const cBrush = d3.select(this); - const brush = d3.brushX() - .extent([[0, 0], [ - plot._width - plot._margins.left - plot._margins.right, - innerHeight, - ]]) - .on('start end', function (event: d3.D3BrushEvent) { - if (event.selection) { - const [x0, x1] = event.selection as [number, number]; - const brushedBins = new Set(); - marksGroup.selectAll('.autkMark') - .each(function (_d, binIdx: number) { - const bbox = this.getBBox(); - if (!(bbox.x + bbox.width < x0 || bbox.x > x1)) { - brushedBins.add(binIdx); - } - }); - plot.selection = plot.binIndicesToFeatureIds(brushedBins); - } else { - plot.selection = []; - } - plot.plotEvents.emit(PlotEvent.BRUSH_X, plot.selection); - plot.updatePlotSelection(); + brushable.each(function () { + const cBrush = d3.select(this); + const brush = d3.brush() + .extent([[0, 0], [ + plot._width - plot._margins.left - plot._margins.right, + plot._height - plot._margins.top - plot._margins.bottom, + ]]) + .on('start end', function (event: d3.D3BrushEvent) { + if (event.selection) { + const [[x0, y0], [x1, y1]] = event.selection as [[number, number], [number, number]]; + const brushedBins = new Set(); + marksGroup.selectAll('.autkMark') + .each(function (_d, binIdx: number) { + const bbox = this.getBBox(); + if (!(bbox.x + bbox.width < x0 || bbox.x > x1 || bbox.y + bbox.height < y0 || bbox.y > y1)) { + brushedBins.add(binIdx); + } + }); + plot.setSelection(plot.binIndicesToFeatureIds(brushedBins)); + } else { + plot.setSelection([]); + } + plot.events.emit(PlotEvent.BRUSH, { selection: plot.selection }); + }); + cBrush.call(brush); }); - cBrush.call(brush); - }); - } + } - brushYEvent(): void { - const brushable = d3.select(this._div).selectAll('.autkBrushable'); - const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); - const innerHeight = this._height - this._margins.top - this._margins.bottom; - const plot = this; + protected override brushXEvent(): void { + const brushable = d3.select(this._div).selectAll('.autkBrushable'); + const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); + const innerHeight = this._height - this._margins.top - this._margins.bottom; + const plot = this; - brushable.each(function () { - const cBrush = d3.select(this); - const brush = d3.brushY() - .extent([[0, 0], [ - plot._width - plot._margins.left - plot._margins.right, - innerHeight, - ]]) - .on('start end', function (event: d3.D3BrushEvent) { - if (event.selection) { - const [y0, y1] = event.selection as [number, number]; - const brushedBins = new Set(); - marksGroup.selectAll('.autkMark') - .each(function (_d, binIdx: number) { - const bbox = this.getBBox(); - if (!(bbox.y + bbox.height < y0 || bbox.y > y1)) { - brushedBins.add(binIdx); - } - }); - plot.selection = plot.binIndicesToFeatureIds(brushedBins); - } else { - plot.selection = []; - } - plot.plotEvents.emit(PlotEvent.BRUSH_Y, plot.selection); - plot.updatePlotSelection(); + brushable.each(function () { + const cBrush = d3.select(this); + const brush = d3.brushX() + .extent([[0, 0], [ + plot._width - plot._margins.left - plot._margins.right, + innerHeight, + ]]) + .on('start end', function (event: d3.D3BrushEvent) { + if (event.selection) { + const [x0, x1] = event.selection as [number, number]; + const brushedBins = new Set(); + marksGroup.selectAll('.autkMark') + .each(function (_d, binIdx: number) { + const bbox = this.getBBox(); + if (!(bbox.x + bbox.width < x0 || bbox.x > x1)) { + brushedBins.add(binIdx); + } + }); + plot.setSelection(plot.binIndicesToFeatureIds(brushedBins)); + } else { + plot.setSelection([]); + } + plot.events.emit(PlotEvent.BRUSH_X, { selection: plot.selection }); + }); + cBrush.call(brush); }); - cBrush.call(brush); - }); - } + } + + protected override brushYEvent(): void { + const brushable = d3.select(this._div).selectAll('.autkBrushable'); + const marksGroup = d3.select(this._div).selectAll('.autkMarksGroup'); + const innerHeight = this._height - this._margins.top - this._margins.bottom; + const plot = this; - // Highlight a bin only when every feature mapped to that bin is selected. - updatePlotSelection(): void { - const selectedSet = new Set(this._selection); - const plot = this; - d3.select(this._div).selectAll('.autkMark').style('fill', function (_d: unknown, binIdx: number) { - const bin = plot.binData[binIdx]; - const fullySelected = - !!bin && - bin.length > 0 && - bin.every((d) => selectedSet.has(d.index)); - return fullySelected ? PlotStyle.highlight : PlotStyle.default; - }); - } + brushable.each(function () { + const cBrush = d3.select(this); + const brush = d3.brushY() + .extent([[0, 0], [ + plot._width - plot._margins.left - plot._margins.right, + innerHeight, + ]]) + .on('start end', function (event: d3.D3BrushEvent) { + if (event.selection) { + const [y0, y1] = event.selection as [number, number]; + const brushedBins = new Set(); + marksGroup.selectAll('.autkMark') + .each(function (_d, binIdx: number) { + const bbox = this.getBBox(); + if (!(bbox.y + bbox.height < y0 || bbox.y > y1)) { + brushedBins.add(binIdx); + } + }); + plot.setSelection(plot.binIndicesToFeatureIds(brushedBins)); + } else { + plot.setSelection([]); + } + plot.events.emit(PlotEvent.BRUSH_Y, { selection: plot.selection }); + }); + cBrush.call(brush); + }); + } } diff --git a/autk-plot/src/plots/barchart.ts b/autk-plot/src/plots/barchart.ts index 6c2bc5c1..47a66b71 100644 --- a/autk-plot/src/plots/barchart.ts +++ b/autk-plot/src/plots/barchart.ts @@ -41,6 +41,7 @@ import type { PlotConfig } from '../api'; import { PlotBaseInteractive } from '../plot-base-interactive'; +import { PlotStyle } from '../plot-style'; import { PlotEvent } from '../types-events'; /** @@ -234,4 +235,44 @@ export class Barchart extends PlotBaseInteractive { this.configureSignalListeners(); } + protected override renderSelection(): void { + const selectedSet = new Set(this.selection); + d3.select(this._div) + .selectAll('.autkMark') + .style('fill', (datum: unknown) => { + const ids = Array.isArray((datum as { autkIds?: number[] })?.autkIds) + ? ((datum as { autkIds?: number[] }).autkIds ?? []) + : []; + const fullySelected = ids.length > 0 && ids.every((id) => selectedSet.has(id)); + return fullySelected ? PlotStyle.highlight : PlotStyle.default; + }); + } + + protected override clickEvent(): void { + const bars = d3.select(this._div).selectAll('.autkMark'); + const clearLayer = d3.select(this._div).selectAll('.autkClear'); + const plot = this; + + bars.each(function (datum: unknown) { + d3.select(this).on('click', function () { + const featureIds = Array.isArray((datum as { autkIds?: number[] })?.autkIds) + ? [...new Set((datum as { autkIds?: number[] }).autkIds ?? [])] + : []; + const current = plot.selection; + const allSelected = featureIds.length > 0 && featureIds.every((id) => current.includes(id)); + const next = allSelected + ? current.filter((id) => !featureIds.includes(id)) + : [...new Set([...current, ...featureIds])]; + + plot.setSelection(next); + plot.events.emit(PlotEvent.CLICK, { selection: plot.selection }); + }); + }); + + clearLayer.on('click', function () { + plot.setSelection([]); + plot.events.emit(PlotEvent.CLICK, { selection: [] }); + }); + } + } diff --git a/autk-provenance/package.json b/autk-provenance/package.json index cd0e9c04..51dfdcf7 100644 --- a/autk-provenance/package.json +++ b/autk-provenance/package.json @@ -16,10 +16,10 @@ } }, "scripts": { - "dev": "vite", - "dev-build": "tsc && vite build --watch", - "build": "tsc && vite build", - "preview": "vite preview", + "dev": "ulimit -n 65536 && vite", + "dev-build": "ulimit -n 65536 && tsc && vite build --watch", + "build": "ulimit -n 65536 && tsc && vite build", + "preview": "ulimit -n 65536 && vite preview", "format": "prettier --write ." }, "devDependencies": { diff --git a/autk-provenance/src/adapters/map-adapter-state.ts b/autk-provenance/src/adapters/map-adapter-state.ts index d15d565a..eed77892 100644 --- a/autk-provenance/src/adapters/map-adapter-state.ts +++ b/autk-provenance/src/adapters/map-adapter-state.ts @@ -54,7 +54,7 @@ export function applyMapProvenanceState( const fallbackPlotIds = [...new Set(allPlotIds)]; const fallbackLayerId = resolvedUi.activeLayerId; - for (const layer of map.layerManager.vectorLayers ?? []) { + for (const layer of ui.getAllLayers()) { const layerId = layer.layerInfo?.id; if (!layerId) continue; const combinedIds = new Set(); diff --git a/autk-provenance/src/adapters/map-ui-helpers.ts b/autk-provenance/src/adapters/map-ui-helpers.ts index 32ea690c..ecae28ab 100644 --- a/autk-provenance/src/adapters/map-ui-helpers.ts +++ b/autk-provenance/src/adapters/map-ui-helpers.ts @@ -8,7 +8,7 @@ export function createMapUiHelpers( customControls: CustomControlConfig[] ) { const getAllLayers = (): LayerLike[] => - [...(map.layerManager.vectorLayers ?? []), ...(map.layerManager.rasterLayers ?? [])] as LayerLike[]; + map.layerManager.layers as LayerLike[]; const getVisibleLayerIds = (): string[] => getAllLayers() @@ -32,7 +32,7 @@ export function createMapUiHelpers( .filter((id): id is string => typeof id === 'string'); return { mapMenuOpen: ui?.mapMenuOpen ?? false, - activeLayerId: ui?.activeLayerId ?? getActiveLayerId() ?? map.layerManager.vectorLayers?.[0]?.layerInfo?.id ?? null, + activeLayerId: ui?.activeLayerId ?? getActiveLayerId() ?? getAllLayers()[0]?.layerInfo?.id ?? null, visibleLayerIds: Array.isArray(ui?.visibleLayerIds) ? ui.visibleLayerIds : allLayerIds, thematicEnabled: ui?.thematicEnabled ?? false, }; diff --git a/autk-provenance/src/adapters/map/recording.ts b/autk-provenance/src/adapters/map/recording.ts index 046674db..590d96c3 100644 --- a/autk-provenance/src/adapters/map/recording.ts +++ b/autk-provenance/src/adapters/map/recording.ts @@ -4,7 +4,11 @@ import { isTargetInMapContainer } from './dom'; import type { CustomControlConfig, MapRecordCallback, ResolvedMapSelectors } from './types'; import { getActiveLayerId, getMenuOpen, getVisibleLayerIds, isElement } from './utils'; -const MAP_PICK_EVENT = 'pick'; +const MAP_PICK_EVENT = 'picking'; + +function selectionSignature(selection: number[]): string { + return selection.join(','); +} export function createMapRecordingController(options: { map: IMapForProvenance; @@ -57,6 +61,14 @@ export function createMapRecordingController(options: { const pickListener = (selection: number[], layerId: string) => { const activePlotIds = new Set(Object.values(getCurrentState().selection.plots ?? {}).flatMap((plot) => plot.ids)); const mapOwnedSelection = selection.filter((id) => !activePlotIds.has(id)); + const previousMapSelection = getCurrentState().selection.map?.ids ?? []; + const previousLayerId = getCurrentState().selection.map?.layerId ?? null; + if ( + selectionSignature(mapOwnedSelection) === selectionSignature(previousMapSelection) && + (mapOwnedSelection.length > 0 ? previousLayerId === layerId : previousMapSelection.length === 0) + ) { + return; + } const label = mapOwnedSelection.length === 0 ? `Cleared selection on ${layerId}` : `Picked ${mapOwnedSelection.length} feature(s) on ${layerId}`; onRecord(ProvenanceAction.MAP_PICK, label, { selection: { map: { layerId, ids: mapOwnedSelection }, plots: {} } }); }; diff --git a/autk-provenance/src/adapters/map/state.ts b/autk-provenance/src/adapters/map/state.ts index 4ce82ac8..001a8810 100644 --- a/autk-provenance/src/adapters/map/state.ts +++ b/autk-provenance/src/adapters/map/state.ts @@ -42,7 +42,7 @@ export function applyMapState(options: { const fallbackLayerId = ui.activeLayerId; const fallbackPlotIds = [...new Set(Object.values(state.selection.plots ?? {}).flatMap((plot) => plot.ids))]; - (map.layerManager.vectorLayers ?? []).forEach((layer) => { + getAllLayers(map).forEach((layer) => { const layerId = layer.layerInfo?.id; if (!layerId) return; const combinedIds = new Set(); diff --git a/autk-provenance/src/adapters/map/utils.ts b/autk-provenance/src/adapters/map/utils.ts index ed805791..1392081c 100644 --- a/autk-provenance/src/adapters/map/utils.ts +++ b/autk-provenance/src/adapters/map/utils.ts @@ -17,7 +17,7 @@ export function isElement(value: unknown): value is Element { } export function getAllLayers(map: IMapForProvenance): LayerLike[] { - return [...(map.layerManager.vectorLayers ?? []), ...(map.layerManager.rasterLayers ?? [])] as LayerLike[]; + return map.layerManager.layers as LayerLike[]; } export function getLayerIds(map: IMapForProvenance): string[] { diff --git a/autk-provenance/src/adapters/plot-adapter.ts b/autk-provenance/src/adapters/plot-adapter.ts index 1255ecfa..f8ebfa20 100644 --- a/autk-provenance/src/adapters/plot-adapter.ts +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -42,7 +42,8 @@ interface PlotEntry { export function createPlotAdapter( plots: IPlotForProvenance[], - onRecord: PlotRecordCallback + onRecord: PlotRecordCallback, + getCurrentState: () => AutarkProvenanceState ): PlotAdapterApi { let isApplyingState = false; @@ -59,20 +60,31 @@ export function createPlotAdapter( for (const [event, actionType] of Object.entries(EVENT_ACTION_MAP)) { const fn = (selection: number[]) => { - const sig = selectionSignature(selection); + const currentState = getCurrentState(); + const previousOwnedSelection = currentState.selection.plots?.[plot.plotId]?.ids ?? []; + const previousOwnedSet = new Set(previousOwnedSelection); + const mapOwnedSet = new Set(currentState.selection.map?.ids ?? []); + const borrowedPlotIds = new Set( + Object.entries(currentState.selection.plots ?? {}) + .filter(([plotId]) => plotId !== plot.plotId) + .flatMap(([, plotState]) => plotState.ids) + .filter((id) => !previousOwnedSet.has(id)) + ); + const ownedSelection = [...new Set(selection.filter((id) => !mapOwnedSet.has(id) && !borrowedPlotIds.has(id)))]; + const sig = selectionSignature(ownedSelection); if (sig === entry.lastSelectionSig) return; entry.lastSelectionSig = sig; const typeLabel = plotTypeLabel(plot.plotType); const label = - selection.length === 0 + ownedSelection.length === 0 ? `Cleared selection on ${typeLabel} (${plot.plotId})` - : `${event}: ${selection.length} point(s) on ${typeLabel} (${plot.plotId})`; + : `${event}: ${ownedSelection.length} point(s) on ${typeLabel} (${plot.plotId})`; onRecord(actionType, label, { selection: { plots: { - [plot.plotId]: { ids: selection, plotType: plot.plotType }, + [plot.plotId]: { ids: ownedSelection, plotType: plot.plotType }, }, } as unknown as AutarkProvenanceState['selection'], }); @@ -159,7 +171,7 @@ export function createPlotAdapter( ])]; for (const entry of entries) { - entry.lastSelectionSig = selectionSignature(coordinatedIds); + entry.lastSelectionSig = selectionSignature(state.selection?.plots?.[entry.plot.plotId]?.ids ?? []); entry.plot.setHighlightedIds(coordinatedIds); } } finally { diff --git a/autk-provenance/src/charts/chart-config.ts b/autk-provenance/src/charts/chart-config.ts new file mode 100644 index 00000000..d0dbb509 --- /dev/null +++ b/autk-provenance/src/charts/chart-config.ts @@ -0,0 +1,126 @@ +import type { FeatureCollection } from 'geojson'; +import { discoverFeatureFields } from './feature-extraction'; +import type { CategoricalFieldDescriptor, InsightsChartSchema, NumericFieldDescriptor } from './types'; + +const AREA_RE = /(shape_area|area)/i; +const PERIMETER_RE = /(shape_leng|perimeter|perim|length)/i; +const GROUP_RE = /(district|cdta|borough|zone|class|category|type|group|landuse|name)/i; + +function scoreField(field: NumericFieldDescriptor): number { + let score = field.coverage * 100 + Math.min(field.distinctCount, 40); + if (AREA_RE.test(field.key)) score += 30; + if (PERIMETER_RE.test(field.key)) score += 24; + if (/compactness/i.test(field.key)) score += 28; + if (/density/i.test(field.key)) score += 16; + if (field.source === 'derived') score += 10; + return score; +} + +function selectPrimaryMetrics(fields: NumericFieldDescriptor[]): NumericFieldDescriptor[] { + return [...fields].sort((a, b) => scoreField(b) - scoreField(a)); +} + +function buildGroupedCollection( + collection: FeatureCollection, + groupField: CategoricalFieldDescriptor | null, + histogramField: NumericFieldDescriptor +): { collection: FeatureCollection; axisField: string; label: string; subtitle: string } { + const groups = new Map(); + const axisField = 'group'; + const features = collection.features; + + features.forEach((feature, index) => { + const properties = feature.properties ?? {}; + let groupValue: string; + + if (groupField) { + groupValue = String((properties as Record)[groupField.key] ?? 'Unknown'); + } else { + const numericValue = Number((properties as Record)[histogramField.key]); + groupValue = Number.isFinite(numericValue) ? String(Math.round(numericValue)) : 'Unknown'; + } + + const bucket = groups.get(groupValue) ?? { autkIds: [], count: 0 }; + bucket.autkIds.push(index); + bucket.count += 1; + groups.set(groupValue, bucket); + }); + + return { + axisField, + label: groupField?.label ?? `${histogramField.label} Bucket`, + subtitle: groupField + ? `${groupField.label} groups with linked underlying feature ids` + : `Aggregated by ${histogramField.label} buckets with linked underlying feature ids`, + collection: { + type: 'FeatureCollection', + features: [...groups.entries()].map(([groupValue, bucket]) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: { + [axisField]: groupValue, + count: bucket.count, + autkIds: bucket.autkIds, + memberIds: bucket.autkIds, + }, + })), + }, + }; +} + +function selectGroupField(fields: CategoricalFieldDescriptor[]): CategoricalFieldDescriptor | null { + const sorted = [...fields].sort((a, b) => { + const aScore = (GROUP_RE.test(a.key) ? 50 : 0) + (24 - a.distinctCount) + a.coverage * 20; + const bScore = (GROUP_RE.test(b.key) ? 50 : 0) + (24 - b.distinctCount) + b.coverage * 20; + return bScore - aScore; + }); + return sorted[0] ?? null; +} + +export function buildInsightsChartSchema(collection: FeatureCollection): InsightsChartSchema { + const discovered = discoverFeatureFields(collection); + const ranked = selectPrimaryMetrics(discovered.numericFields); + + if (ranked.length < 2) { + throw new Error('Insights workspace requires at least two numeric fields.'); + } + + const scatterX = ranked.find((field) => AREA_RE.test(field.key)) ?? ranked[0]; + const scatterY = ranked.find((field) => field.key !== scatterX.key && PERIMETER_RE.test(field.key)) ?? ranked.find((field) => field.key !== scatterX.key) ?? ranked[1]; + const histogramField = ranked.find((field) => field.key !== scatterY.key && AREA_RE.test(field.key)) ?? scatterX; + const parallelFields = [scatterX, scatterY, histogramField, ...ranked] + .filter((field, index, all) => all.findIndex((candidate) => candidate.key === field.key) === index) + .slice(0, Math.min(5, ranked.length)); + const grouped = buildGroupedCollection(discovered.collection, selectGroupField(discovered.categoricalFields), histogramField); + + return { + collection: discovered.collection, + numericFields: ranked, + categoricalFields: discovered.categoricalFields, + thematicFields: ranked.slice(0, 8), + scatter: { + x: scatterX, + y: scatterY, + title: 'Scatterplot', + subtitle: `${scatterX.label} vs ${scatterY.label} · click or brush to select`, + }, + histogram: { + field: histogramField, + title: 'Histogram', + subtitle: `${histogramField.label} distribution · click bins to select`, + }, + parallel: { + fields: parallelFields, + title: 'Parallel Coordinates', + subtitle: `${parallelFields.map((field) => field.label).join(' · ')} · brush any axis`, + }, + bar: { + collection: grouped.collection, + axisField: grouped.axisField, + valueField: 'count', + groupFieldLabel: grouped.label, + title: 'Bar Chart', + subtitle: grouped.subtitle, + }, + }; +} diff --git a/autk-provenance/src/charts/chart-modal.ts b/autk-provenance/src/charts/chart-modal.ts new file mode 100644 index 00000000..3cc645d7 --- /dev/null +++ b/autk-provenance/src/charts/chart-modal.ts @@ -0,0 +1,239 @@ +import type { ChartModalDescriptor } from './types'; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function createChartModalController(descriptors: ChartModalDescriptor[]): { syncSelection(): void; destroy(): void } { + let active: ChartModalDescriptor | null = null; + let backdrop: HTMLDivElement | null = null; + let canvas: HTMLDivElement | null = null; + let stage: HTMLDivElement | null = null; + let modalTitle: HTMLDivElement | null = null; + let modalSubtitle: HTMLDivElement | null = null; + let scaleValue: HTMLSpanElement | null = null; + let modalPlot: ChartModalDescriptor['originalPlot'] | null = null; + let scale = 1; + let tx = 0; + let ty = 0; + let dragActive = false; + let dragX = 0; + let dragY = 0; + let spacePressed = false; + let contentWidth = 960; + let contentHeight = 620; + const cleanups: Array<() => void> = []; + + function applyTransform(): void { + if (!stage) return; + stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; + if (scaleValue) scaleValue.textContent = `${Math.round(scale * 100)}%`; + } + + function fit(): void { + if (!canvas || !stage) return; + const rect = canvas.getBoundingClientRect(); + const padding = 28; + const fitScale = Math.min((rect.width - padding * 2) / contentWidth, (rect.height - padding * 2) / contentHeight); + scale = clamp(Number.isFinite(fitScale) ? fitScale : 1, 0.3, 4); + tx = (rect.width - contentWidth * scale) / 2; + ty = (rect.height - contentHeight * scale) / 2; + applyTransform(); + } + + function zoomAt(clientX: number, clientY: number, factor: number): void { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const nextScale = clamp(scale * factor, 0.25, 6); + const localX = clientX - rect.left; + const localY = clientY - rect.top; + const ratio = nextScale / scale; + tx = localX - (localX - tx) * ratio; + ty = localY - (localY - ty) * ratio; + scale = nextScale; + applyTransform(); + } + + function syncSelection(): void { + if (active && modalPlot) modalPlot.setSelection([...active.originalPlot.selection]); + } + + function clearModalPlot(): void { + modalPlot = null; + if (stage) stage.innerHTML = ''; + } + + function close(): void { + clearModalPlot(); + backdrop?.remove(); + backdrop = null; + canvas = null; + stage = null; + modalTitle = null; + modalSubtitle = null; + scaleValue = null; + active = null; + document.body.style.overflow = ''; + } + + function renderActivePlot(): void { + if (!active || !stage || !canvas || !modalTitle || !modalSubtitle) return; + modalTitle.textContent = active.title; + modalSubtitle.textContent = active.subtitle; + clearModalPlot(); + + const host = document.createElement('div'); + stage.appendChild(host); + contentWidth = Math.max(920, canvas.clientWidth - 48); + contentHeight = Math.max(560, canvas.clientHeight - 48); + stage.style.width = `${contentWidth}px`; + stage.style.height = `${contentHeight}px`; + host.style.width = `${contentWidth}px`; + host.style.height = `${contentHeight}px`; + + modalPlot = active.createModalPlot(host, contentWidth, contentHeight); + syncSelection(); + active.events.forEach((eventName) => { + const listener = ({ selection }: { selection: number[] }) => { + if (!active) return; + active.originalPlot.setSelection([...selection]); + active.originalPlot.events.emit(eventName as never, { selection: [...selection] } as never); + requestAnimationFrame(syncSelection); + }; + modalPlot!.events.on(eventName as never, listener as never); + }); + + requestAnimationFrame(fit); + } + + function ensureModal(): void { + if (backdrop) return; + backdrop = document.createElement('div'); + backdrop.className = 'autk-chart-modal-backdrop'; + backdrop.innerHTML = ` + + `; + document.body.appendChild(backdrop); + + canvas = backdrop.querySelector('.autk-chart-modal-canvas'); + stage = backdrop.querySelector('.autk-chart-modal-stage'); + modalTitle = backdrop.querySelector('.autk-chart-modal-title'); + modalSubtitle = backdrop.querySelector('.autk-chart-modal-subtitle'); + scaleValue = backdrop.querySelector('.autk-chart-modal-scale'); + + backdrop.addEventListener('click', (event) => { + if (event.target === backdrop) close(); + }); + backdrop.querySelector('[data-action="zoom-out"]')?.addEventListener('click', () => { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 0.85); + }); + backdrop.querySelector('[data-action="zoom-in"]')?.addEventListener('click', () => { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.15); + }); + backdrop.querySelector('[data-action="fit"]')?.addEventListener('click', fit); + backdrop.querySelector('[data-action="reset"]')?.addEventListener('click', () => { + scale = 1; + tx = 24; + ty = 24; + applyTransform(); + }); + backdrop.querySelector('[data-action="close"]')?.addEventListener('click', close); + + canvas?.addEventListener('wheel', (event) => { + event.preventDefault(); + zoomAt(event.clientX, event.clientY, event.deltaY < 0 ? 1.12 : 0.9); + }, { passive: false }); + canvas?.addEventListener('pointerdown', (event) => { + if (!(spacePressed && event.button === 0)) return; + dragActive = true; + dragX = event.clientX; + dragY = event.clientY; + canvas!.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + canvas?.addEventListener('pointermove', (event) => { + if (!dragActive) return; + tx += event.clientX - dragX; + ty += event.clientY - dragY; + dragX = event.clientX; + dragY = event.clientY; + applyTransform(); + }); + const endDrag = (event: PointerEvent) => { + if (!dragActive || !canvas) return; + dragActive = false; + try { canvas.releasePointerCapture(event.pointerId); } catch {} + }; + canvas?.addEventListener('pointerup', endDrag); + canvas?.addEventListener('pointercancel', endDrag); + } + + function open(descriptor: ChartModalDescriptor): void { + active = descriptor; + ensureModal(); + document.body.style.overflow = 'hidden'; + renderActivePlot(); + } + + descriptors.forEach((descriptor) => { + const openCurrent = () => open(descriptor); + descriptor.trigger.addEventListener('click', openCurrent); + descriptor.trigger.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openCurrent(); + }); + descriptor.button?.addEventListener('click', (event) => { + event.stopPropagation(); + openCurrent(); + }); + }); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === ' ') spacePressed = true; + if (event.key === 'Escape' && backdrop) close(); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === ' ') spacePressed = false; + }; + const handleResize = () => { + if (backdrop && active) renderActivePlot(); + }; + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + window.addEventListener('resize', handleResize); + cleanups.push(() => document.removeEventListener('keydown', handleKeyDown)); + cleanups.push(() => document.removeEventListener('keyup', handleKeyUp)); + cleanups.push(() => window.removeEventListener('resize', handleResize)); + + return { + syncSelection, + destroy: () => { + close(); + cleanups.splice(0).forEach((cleanup) => cleanup()); + }, + }; +} diff --git a/autk-provenance/src/charts/derived-metrics.ts b/autk-provenance/src/charts/derived-metrics.ts new file mode 100644 index 00000000..6fe98a75 --- /dev/null +++ b/autk-provenance/src/charts/derived-metrics.ts @@ -0,0 +1,90 @@ +import type { FeatureCollection, GeoJsonProperties } from 'geojson'; +import type { NumericFieldDescriptor } from './types'; + +const AREA_RE = /(shape_area|area|sq.?m|sq.?km|acres?)/i; +const PERIMETER_RE = /(shape_leng|perimeter|perim|length)/i; +const COUNT_RE = /(count|total|num|n_)/i; + +function cloneProperties(properties: GeoJsonProperties | null | undefined): GeoJsonProperties { + return properties ? { ...properties } : {}; +} + +function pickField(fields: NumericFieldDescriptor[], matcher: RegExp): NumericFieldDescriptor | null { + const matches = fields.filter((field) => matcher.test(field.key)); + return matches.length > 0 ? matches[0] : null; +} + +function roundMetric(value: number): number { + return Math.round(value * 1000) / 1000; +} + +export function deriveNumericMetrics( + collection: FeatureCollection, + nativeFields: NumericFieldDescriptor[] +): { collection: FeatureCollection; derivedFields: NumericFieldDescriptor[] } { + const areaField = pickField(nativeFields, AREA_RE); + const perimeterField = pickField(nativeFields, PERIMETER_RE); + const countField = pickField(nativeFields, COUNT_RE); + const features = collection.features.map((feature) => ({ + ...feature, + properties: cloneProperties(feature.properties), + })); + const derivedFields: NumericFieldDescriptor[] = []; + + if (areaField && perimeterField) { + let validCount = 0; + const distinct = new Set(); + + features.forEach((feature) => { + const area = Number(feature.properties?.[areaField.key]); + const perimeter = Number(feature.properties?.[perimeterField.key]); + if (!Number.isFinite(area) || !Number.isFinite(perimeter) || area <= 0 || perimeter <= 0) return; + const compactness = roundMetric((4 * Math.PI * area) / (perimeter * perimeter)); + feature.properties!.compactness = compactness; + validCount += 1; + distinct.add(compactness); + }); + + if (validCount > 0) { + derivedFields.push({ + key: 'compactness', + label: 'Compactness', + coverage: validCount / Math.max(features.length, 1), + distinctCount: distinct.size, + source: 'derived', + description: `Derived from ${areaField.label} and ${perimeterField.label}: 4πA / P².`, + }); + } + } + + if (countField && areaField) { + let validCount = 0; + const distinct = new Set(); + + features.forEach((feature) => { + const count = Number(feature.properties?.[countField.key]); + const area = Number(feature.properties?.[areaField.key]); + if (!Number.isFinite(count) || !Number.isFinite(area) || area <= 0) return; + const density = roundMetric(count / area); + feature.properties!.density = density; + validCount += 1; + distinct.add(density); + }); + + if (validCount > 0) { + derivedFields.push({ + key: 'density', + label: 'Density', + coverage: validCount / Math.max(features.length, 1), + distinctCount: distinct.size, + source: 'derived', + description: `Derived from ${countField.label} divided by ${areaField.label}.`, + }); + } + } + + return { + collection: { ...collection, features }, + derivedFields, + }; +} diff --git a/autk-provenance/src/charts/feature-extraction.ts b/autk-provenance/src/charts/feature-extraction.ts new file mode 100644 index 00000000..3374b8c8 --- /dev/null +++ b/autk-provenance/src/charts/feature-extraction.ts @@ -0,0 +1,103 @@ +import type { FeatureCollection } from 'geojson'; +import { deriveNumericMetrics } from './derived-metrics'; +import type { CategoricalFieldDescriptor, NumericFieldDescriptor } from './types'; + +function prettifyLabel(key: string): string { + return key + .split('.') + .pop()! + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function walkObject(value: unknown, path: string[], visit: (key: string, value: unknown) => void): void { + if (Array.isArray(value) || value == null) return; + if (typeof value !== 'object') { + if (path.length > 0) visit(path.join('.'), value); + return; + } + + Object.entries(value).forEach(([key, child]) => { + if (Array.isArray(child) || child == null) return; + if (typeof child === 'object') { + walkObject(child, [...path, key], visit); + return; + } + visit([...path, key].join('.'), child); + }); +} + +function getValueByPath(source: Record, path: string): unknown { + return path.split('.').reduce((value, key) => ( + value && typeof value === 'object' ? (value as Record)[key] : undefined + ), source); +} + +export function discoverFeatureFields(collection: FeatureCollection): { + collection: FeatureCollection; + numericFields: NumericFieldDescriptor[]; + categoricalFields: CategoricalFieldDescriptor[]; +} { + const featureCount = collection.features.length; + const fieldKeys = new Set(); + + collection.features.forEach((feature) => { + walkObject(feature.properties ?? {}, [], (key) => fieldKeys.add(key)); + }); + + const numericFields: NumericFieldDescriptor[] = []; + const categoricalFields: CategoricalFieldDescriptor[] = []; + + fieldKeys.forEach((key) => { + let presentCount = 0; + let numericCount = 0; + const numericDistinct = new Set(); + const categoricalDistinct = new Set(); + + collection.features.forEach((feature) => { + const properties = (feature.properties ?? {}) as Record; + const rawValue = getValueByPath(properties, key); + if (rawValue == null || rawValue === '') return; + presentCount += 1; + + const numericValue = typeof rawValue === 'number' ? rawValue : Number(rawValue); + if (Number.isFinite(numericValue)) { + numericCount += 1; + numericDistinct.add(numericValue); + } else { + categoricalDistinct.add(String(rawValue)); + } + }); + + const coverage = presentCount / Math.max(featureCount, 1); + if (numericCount > 1 && coverage >= 0.4 && numericDistinct.size > 1) { + numericFields.push({ + key, + label: prettifyLabel(key), + coverage, + distinctCount: numericDistinct.size, + source: 'native', + description: `Native numeric field discovered from "${key}".`, + }); + } + + if (categoricalDistinct.size > 1 && categoricalDistinct.size <= Math.min(24, Math.max(6, featureCount - 1)) && coverage >= 0.4) { + categoricalFields.push({ + key, + label: prettifyLabel(key), + coverage, + distinctCount: categoricalDistinct.size, + }); + } + }); + + const enriched = deriveNumericMetrics(collection, numericFields); + const mergedNumericFields = [...numericFields, ...enriched.derivedFields] + .sort((a, b) => b.coverage - a.coverage || b.distinctCount - a.distinctCount); + + return { + collection: enriched.collection, + numericFields: mergedNumericFields, + categoricalFields: categoricalFields.sort((a, b) => a.distinctCount - b.distinctCount || b.coverage - a.coverage), + }; +} diff --git a/autk-provenance/src/charts/types.ts b/autk-provenance/src/charts/types.ts new file mode 100644 index 00000000..a0c7378f --- /dev/null +++ b/autk-provenance/src/charts/types.ts @@ -0,0 +1,70 @@ +import type { FeatureCollection } from 'geojson'; +import type { PlotBaseInteractive } from 'autk-plot'; + +export interface NumericFieldDescriptor { + key: string; + label: string; + coverage: number; + distinctCount: number; + source: 'native' | 'derived'; + description: string; +} + +export interface CategoricalFieldDescriptor { + key: string; + label: string; + coverage: number; + distinctCount: number; +} + +export interface ScatterChartConfig { + x: NumericFieldDescriptor; + y: NumericFieldDescriptor; + title: string; + subtitle: string; +} + +export interface HistogramChartConfig { + field: NumericFieldDescriptor; + title: string; + subtitle: string; +} + +export interface ParallelChartConfig { + fields: NumericFieldDescriptor[]; + title: string; + subtitle: string; +} + +export interface BarChartConfig { + collection: FeatureCollection; + axisField: string; + valueField: string; + groupFieldLabel: string; + title: string; + subtitle: string; +} + +export interface InsightsChartSchema { + collection: FeatureCollection; + numericFields: NumericFieldDescriptor[]; + categoricalFields: CategoricalFieldDescriptor[]; + thematicFields: NumericFieldDescriptor[]; + scatter: ScatterChartConfig; + histogram: HistogramChartConfig; + parallel: ParallelChartConfig; + bar: BarChartConfig; +} + +export type ChartKey = 'scatter' | 'histogram' | 'bar' | 'parallel'; + +export interface ChartModalDescriptor { + key: ChartKey; + title: string; + subtitle: string; + originalPlot: PlotBaseInteractive; + events: string[]; + trigger: HTMLElement; + button: HTMLButtonElement | null; + createModalPlot(container: HTMLElement, width: number, height: number): PlotBaseInteractive; +} diff --git a/autk-provenance/src/create-autark-provenance.ts b/autk-provenance/src/create-autark-provenance.ts index 6c8d8729..77e0db8d 100644 --- a/autk-provenance/src/create-autark-provenance.ts +++ b/autk-provenance/src/create-autark-provenance.ts @@ -83,7 +83,7 @@ export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): const plotAdapter = plots && plots.length > 0 ? createPlotAdapter(plots, (actionType, label, delta) => { core.applyAction(actionType, label, delta); - }) + }, () => core.getCurrentState() ?? initialState) : null; const dbAdapter = db diff --git a/autk-provenance/src/index.ts b/autk-provenance/src/index.ts index 9355277f..aadf6898 100644 --- a/autk-provenance/src/index.ts +++ b/autk-provenance/src/index.ts @@ -8,6 +8,8 @@ export type { ProvenanceApi, CreateProvenanceOptions } from './create-provenance export { renderProvenanceTrailUI } from './provenance-trail-ui'; export type { ProvenanceTrailUIOptions } from './provenance-trail-ui'; export { renderInsightsPanel } from './ui/insights-panel'; +export { renderInsightsWorkspace } from './insights-workspace'; +export type { RenderInsightsWorkspaceOptions, RenderInsightsWorkspaceResult } from './insights-workspace'; export * from './adapters'; export { computeSelectionFrequency, diff --git a/autk-provenance/src/insights-workspace.ts b/autk-provenance/src/insights-workspace.ts new file mode 100644 index 00000000..f75741e7 --- /dev/null +++ b/autk-provenance/src/insights-workspace.ts @@ -0,0 +1,314 @@ +import type { FeatureCollection } from 'geojson'; +import type { AutkMap, MapEvent } from 'autk-map'; +import { Scatterplot, Barchart, ParallelCoordinates, Histogram, PlotEvent, type PlotBaseInteractive } from 'autk-plot'; +import { createAutarkProvenance, type AutarkProvenanceApi } from './create-autark-provenance'; +import { renderProvenanceTrailUI } from './provenance-trail-ui'; +import { buildInsightsChartSchema } from './charts/chart-config'; +import { createChartModalController } from './charts/chart-modal'; +import type { ChartModalDescriptor, InsightsChartSchema } from './charts/types'; +import { createInsightsWorkspaceShell } from './ui/workspace-shell'; +import { renderWorkspaceSessionInsights } from './ui/workspace-session-insights'; +import { ensureInsightsWorkspaceStyles } from './ui/workspace-styles'; +import { bindWorkspaceTabs } from './ui/workspace-tabs'; +import type { IDbForProvenance } from './adapters/db-adapter'; +import type { MapSelectorConfig } from './adapters/map-adapter'; +import { PlotType, type MapViewState } from './types'; + +export interface RenderInsightsWorkspaceOptions { + container: HTMLElement; + map: AutkMap; + collection: FeatureCollection; + layerId: string; + db?: IDbForProvenance; + title?: string; + description?: string; + mapConfig?: MapSelectorConfig; +} + +export interface RenderInsightsWorkspaceResult { + provenance: AutarkProvenanceApi; + schema: InsightsChartSchema; + destroy(): void; +} + +function plotSize(element: HTMLElement, fallbackWidth: number, fallbackHeight: number): { width: number; height: number } { + const rect = element.getBoundingClientRect(); + return { + width: rect.width > 20 ? Math.floor(rect.width) : fallbackWidth, + height: rect.height > 20 ? Math.floor(rect.height) : fallbackHeight, + }; +} + +function createPlotAdapter(plot: PlotBaseInteractive, plotId: string, plotType: PlotType) { + return Object.assign(plot, { + plotId, + plotType, + plotEvents: { + addEventListener(event: string, fn: (selection: number[]) => void) { + plot.events.on(event as PlotEvent, ({ selection }) => fn(selection)); + }, + removeEventListener(event: string, fn: (selection: number[]) => void) { + plot.events.off(event as PlotEvent, fn as never); + }, + }, + setHighlightedIds(ids: number[]) { + plot.setSelection(ids); + }, + }); +} + +function mountMapInWorkspace(map: AutkMap, body: HTMLElement): void { + const internals = map as AutkMap & { + _resizeEvents?: { resize?: () => void }; + }; + + map.ui.destroy(); + body.replaceChildren(map.canvas); + map.canvas.style.width = '100%'; + map.canvas.style.height = '100%'; + internals._resizeEvents?.resize?.(); + map.ui.buildUi(); + map.ui.handleResize(); + map.draw(); +} + +function applyThematic(map: AutkMap, collection: FeatureCollection, layerId: string, property: string): void { + const mapApi = map as AutkMap & { + updateThematic?: (id: string, options: { collection: FeatureCollection; property: string }) => void; + }; + if (property) { + mapApi.updateThematic?.(layerId, { collection, property }); + return; + } + map.updateRenderInfo(layerId, { renderInfo: { isColorMap: false } }); +} + +export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions): RenderInsightsWorkspaceResult { + const { container, map, collection, layerId, db, title, description, mapConfig } = options; + ensureInsightsWorkspaceStyles(); + const schema = buildInsightsChartSchema(collection); + const shell = createInsightsWorkspaceShell(container, title, description); + const detachTabs = bindWorkspaceTabs(shell.root); + shell.thematicSelect.innerHTML = `${schema.thematicFields.map((field) => ``).join('')}`; + shell.plotPanels.scatter.title.textContent = schema.scatter.title; + shell.plotPanels.scatter.hint.textContent = schema.scatter.subtitle; + shell.plotPanels.bar.title.textContent = schema.bar.title; + shell.plotPanels.bar.hint.textContent = schema.bar.subtitle; + shell.plotPanels.parallel.title.textContent = schema.parallel.title; + shell.plotPanels.parallel.hint.textContent = schema.parallel.subtitle; + shell.plotPanels.histogram.title.textContent = schema.histogram.title; + shell.plotPanels.histogram.hint.textContent = schema.histogram.subtitle; + mountMapInWorkspace(map, shell.mapBody); + + const scatterDims = plotSize(shell.plotPanels.scatter.body, 360, 280); + const barDims = plotSize(shell.plotPanels.bar.body, 360, 280); + const parallelDims = plotSize(shell.plotPanels.parallel.body, 360, 280); + const histogramDims = plotSize(shell.plotPanels.histogram.body, 360, 280); + + const scatter = new Scatterplot({ + div: shell.plotPanels.scatter.body, + collection: schema.collection, + attributes: { axis: [schema.scatter.x.key, schema.scatter.y.key] }, + labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, + width: scatterDims.width, + height: scatterDims.height, + margins: { left: 62, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.CLICK, PlotEvent.BRUSH], + }); + const bar = new Barchart({ + div: shell.plotPanels.bar.body, + collection: schema.bar.collection, + attributes: { axis: [schema.bar.axisField, schema.bar.valueField] }, + labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, + width: barDims.width, + height: barDims.height, + margins: { left: 55, right: 16, top: 36, bottom: 72 }, + events: [PlotEvent.CLICK], + }); + const parallel = new ParallelCoordinates({ + div: shell.plotPanels.parallel.body, + collection: schema.collection, + attributes: { axis: schema.parallel.fields.map((field) => field.key) }, + labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, + width: parallelDims.width, + height: parallelDims.height, + margins: { left: 28, right: 28, top: 36, bottom: 36 }, + events: [PlotEvent.BRUSH_Y], + }); + const histogram = new Histogram({ + div: shell.plotPanels.histogram.body, + collection: schema.collection, + attributes: { axis: [schema.histogram.field.key] }, + labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, + width: histogramDims.width, + height: histogramDims.height, + margins: { left: 55, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.CLICK], + }); + + const modalDescriptors: ChartModalDescriptor[] = [ + { + key: 'scatter', + title: schema.scatter.title, + subtitle: schema.scatter.subtitle, + originalPlot: scatter, + events: [PlotEvent.CLICK, PlotEvent.BRUSH], + trigger: shell.plotPanels.scatter.header, + button: shell.plotPanels.scatter.button, + createModalPlot: (div, width, height) => new Scatterplot({ + div, + collection: schema.collection, + attributes: { axis: [schema.scatter.x.key, schema.scatter.y.key] }, + labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, + width, + height, + margins: { left: 62, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.CLICK, PlotEvent.BRUSH], + }), + }, + { + key: 'bar', + title: schema.bar.title, + subtitle: schema.bar.subtitle, + originalPlot: bar, + events: [PlotEvent.CLICK], + trigger: shell.plotPanels.bar.header, + button: shell.plotPanels.bar.button, + createModalPlot: (div, width, height) => new Barchart({ + div, + collection: schema.bar.collection, + attributes: { axis: [schema.bar.axisField, schema.bar.valueField] }, + labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, + width, + height, + margins: { left: 55, right: 16, top: 36, bottom: 72 }, + events: [PlotEvent.CLICK], + }), + }, + { + key: 'parallel', + title: schema.parallel.title, + subtitle: schema.parallel.subtitle, + originalPlot: parallel, + events: [PlotEvent.BRUSH_Y], + trigger: shell.plotPanels.parallel.header, + button: shell.plotPanels.parallel.button, + createModalPlot: (div, width, height) => new ParallelCoordinates({ + div, + collection: schema.collection, + attributes: { axis: schema.parallel.fields.map((field) => field.key) }, + labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, + width, + height, + margins: { left: 28, right: 28, top: 36, bottom: 36 }, + events: [PlotEvent.BRUSH_Y], + }), + }, + { + key: 'histogram', + title: schema.histogram.title, + subtitle: schema.histogram.subtitle, + originalPlot: histogram, + events: [PlotEvent.CLICK], + trigger: shell.plotPanels.histogram.header, + button: shell.plotPanels.histogram.button, + createModalPlot: (div, width, height) => new Histogram({ + div, + collection: schema.collection, + attributes: { axis: [schema.histogram.field.key] }, + labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, + width, + height, + margins: { left: 55, right: 16, top: 36, bottom: 48 }, + events: [PlotEvent.CLICK], + }), + }, + ]; + const chartModal = createChartModalController(modalDescriptors); + + const thematicControl = { + selector: '.autk-workspace-select', + event: 'change' as const, + actionType: 'MAP_THEMATIC_PROPERTY', + getLabel: (element: Element) => { + const value = (element as HTMLSelectElement).value; + return value ? `Color by: ${value}` : 'Thematic off'; + }, + getStateDelta: (element: Element) => { + const value = (element as HTMLSelectElement).value; + return { filters: { thematicProperty: value || null }, ui: { thematicEnabled: !!value } }; + }, + applyState: (element: Element, state: { filters?: Record }) => { + const value = (state.filters?.thematicProperty as string | null) ?? ''; + (element as HTMLSelectElement).value = value; + applyThematic(map, schema.collection, layerId, value); + }, + }; + shell.thematicSelect.addEventListener('change', () => applyThematic(map, schema.collection, layerId, shell.thematicSelect.value)); + + const mapViewApi = map as AutkMap & { + addViewListener?: (callback: (state: MapViewState) => void) => void; + removeViewListener?: (callback: (state: MapViewState) => void) => void; + setViewState?: (state: MapViewState) => void; + updateRenderInfoProperty?: (layerName: string, property: string, value: unknown) => void; + }; + const mapForProvenance: NonNullable[0]['map']> = { + mapEvents: { + addEventListener(event: string, fn: (selection: number[], currentLayerId: string) => void) { + map.events.on(event as MapEvent, ({ selection, layerId: eventLayerId }: { selection: number[]; layerId: string }) => fn(selection, eventLayerId)); + }, + removeEventListener(event: string, fn: (selection: number[], currentLayerId: string) => void) { + map.events.off(event as MapEvent, fn as never); + }, + }, + addViewListener: mapViewApi.addViewListener?.bind(map), + removeViewListener: mapViewApi.removeViewListener?.bind(map), + setViewState: mapViewApi.setViewState?.bind(map), + canvas: map.canvas, + ui: map.ui, + updateRenderInfoProperty: mapViewApi.updateRenderInfoProperty?.bind(map), + layerManager: map.layerManager, + }; + const provenance = createAutarkProvenance({ + map: mapForProvenance, + plots: [ + createPlotAdapter(scatter, 'Scatterplot', PlotType.SCATTERPLOT), + createPlotAdapter(bar, 'Bar Chart', PlotType.BARCHART), + createPlotAdapter(parallel, 'Parallel Coordinates', PlotType.PARALLEL_COORDINATES), + createPlotAdapter(histogram, 'Histogram', PlotType.HISTOGRAM), + ], + db, + mapConfig: { ...(mapConfig ?? {}), customControls: [...(mapConfig?.customControls ?? []), thematicControl] }, + }); + + const destroyTrail = renderProvenanceTrailUI({ + provenance, + container: shell.provenanceTrail, + insightsContainer: shell.provenanceInsights, + showTimestamps: true, + showGraph: true, + showPathList: true, + showBackForward: true, + }); + + const updateNodeCount = () => { + const count = provenance.getGraph().nodes.size; + shell.nodeCountBadge.textContent = `${count} step${count !== 1 ? 's' : ''} recorded`; + renderWorkspaceSessionInsights(shell.chartsInsights, provenance); + chartModal.syncSelection(); + }; + const unsubscribe = provenance.addObserver(updateNodeCount); + updateNodeCount(); + + return { + provenance, + schema, + destroy: () => { + unsubscribe(); + destroyTrail(); + detachTabs(); + chartModal.destroy(); + container.innerHTML = ''; + }, + }; +} diff --git a/autk-provenance/src/types.ts b/autk-provenance/src/types.ts index 89fc12d4..94cccbeb 100644 --- a/autk-provenance/src/types.ts +++ b/autk-provenance/src/types.ts @@ -148,16 +148,13 @@ export interface IMapForProvenance { layerInfo?: { id: string }; layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; } | null; - vectorLayers?: Array<{ + /** All registered layers in render order (vector + raster). */ + layers: Array<{ layerInfo?: { id: string }; setHighlightedIds?(ids: number[]): void; clearHighlightedIds?(): void; layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; }>; - rasterLayers?: Array<{ - layerInfo?: { id: string }; - layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; - }>; }; } diff --git a/autk-provenance/src/ui/workspace-session-insights.ts b/autk-provenance/src/ui/workspace-session-insights.ts new file mode 100644 index 00000000..1992fc3e --- /dev/null +++ b/autk-provenance/src/ui/workspace-session-insights.ts @@ -0,0 +1,213 @@ +import { + computeGraphMetrics, + computeSelectionFrequency, + generateSessionNarrative, + getInsightAnnotations, + type InsightsProvenanceApi, + type InsightSelectionState, + type StrategyLabel, +} from '../insight-engine'; +import { formatDurationShort } from './utils'; + +const STRATEGY_COLORS: Record = { + Confirmatory: '#2e7d32', + Exploratory: '#1565c0', + 'Iterative Refinement': '#6a1b9a', +}; + +const STRATEGY_DESCRIPTIONS: Record = { + Confirmatory: 'A focused, linear exploration where the analyst appears to be validating a known hunch.', + Exploratory: 'A broader scan across multiple directions, with branching used to compare possibilities.', + 'Iterative Refinement': 'A revise-and-compare workflow with meaningful backtracking before converging.', +}; + +export function renderWorkspaceSessionInsights( + container: HTMLElement, + provenance: InsightsProvenanceApi +): void { + const graph = provenance.getGraph(); + const metrics = computeGraphMetrics(graph); + const annotations = getInsightAnnotations(graph); + const frequency = computeSelectionFrequency(graph); + const narrative = generateSessionNarrative(graph, metrics, annotations); + const currentNode = provenance.getCurrentNode(); + const insight = typeof currentNode?.metadata?.insight === 'string' ? currentNode.metadata.insight : ''; + + container.innerHTML = ''; + const grid = document.createElement('div'); + grid.className = 'autk-session-insights-grid'; + grid.appendChild(createOverviewCard(metrics)); + grid.appendChild(createAnnotationCard(provenance, currentNode?.id ?? null, currentNode?.actionLabel ?? 'Current step', insight, container)); + grid.appendChild(createFrequencyCard(frequency)); + grid.appendChild(createSummaryCard(narrative)); + container.appendChild(grid); +} + +function createOverviewCard(metrics: ReturnType): HTMLElement { + const card = createCard('Analysis Strategy'); + const body = card.querySelector('.autk-session-insights-card-body') as HTMLElement; + const badge = document.createElement('span'); + const desc = document.createElement('p'); + const chips = document.createElement('div'); + + badge.className = 'autk-session-strategy-badge'; + badge.textContent = metrics.strategyLabel; + badge.style.background = STRATEGY_COLORS[metrics.strategyLabel]; + + desc.className = 'autk-session-strategy-desc'; + desc.textContent = STRATEGY_DESCRIPTIONS[metrics.strategyLabel]; + + chips.className = 'autk-session-metrics-row'; + [ + ['Duration', metrics.sessionDurationMs > 0 ? formatDurationShort(metrics.sessionDurationMs) : '0s'], + ['States', `${metrics.totalNodes}`], + ['Branches', `${metrics.branchPoints}`], + ['Backtracks', `${metrics.backtracks}`], + ['Avg/state', metrics.avgTimePerStateMs > 0 ? formatDurationShort(metrics.avgTimePerStateMs) : '0s'], + ['Insights', `${metrics.insightCount}`], + ].forEach(([label, value]) => chips.appendChild(createMetricChip(label, value))); + + body.appendChild(badge); + body.appendChild(desc); + body.appendChild(chips); + return card; +} + +function createAnnotationCard( + provenance: InsightsProvenanceApi, + nodeId: string | null, + actionLabel: string, + insight: string, + container: HTMLElement +): HTMLElement { + const card = createCard('Annotate This Step'); + const body = card.querySelector('.autk-session-insights-card-body') as HTMLElement; + const step = document.createElement('div'); + const textarea = document.createElement('textarea'); + const save = document.createElement('button'); + + step.className = 'autk-session-insights-step'; + step.textContent = `Step: ${actionLabel}`; + textarea.className = 'autk-session-annotation-textarea'; + textarea.placeholder = 'Capture what stands out at this point in the analysis.'; + textarea.value = insight; + textarea.disabled = !nodeId; + save.className = 'autk-session-button'; + save.textContent = 'Save Insight'; + save.disabled = !nodeId; + save.addEventListener('click', () => { + if (!nodeId) return; + provenance.annotateNode(nodeId, textarea.value); + renderWorkspaceSessionInsights(container, provenance); + }); + + body.appendChild(step); + body.appendChild(textarea); + body.appendChild(save); + return card; +} + +function createFrequencyCard(frequency: ReturnType): HTMLElement { + const card = createCard('Feature Revisits'); + const body = card.querySelector('.autk-session-insights-card-body') as HTMLElement; + const scroll = document.createElement('div'); + scroll.className = 'autk-session-frequency-scroll'; + + const mapEntries = topEntries(frequency.map); + const plotGroups = [...frequency.plots.entries()] + .map(([plotId, values]) => ({ plotId, entries: topEntries(values, 4) })) + .filter((group) => group.entries.length > 0); + + if (mapEntries.length === 0 && plotGroups.length === 0) { + const empty = document.createElement('div'); + empty.className = 'autk-session-frequency-empty'; + empty.textContent = 'Selections will start appearing here as the session branches and revisits features.'; + scroll.appendChild(empty); + } else { + if (mapEntries.length > 0) scroll.appendChild(createFrequencyGroup('Map features', mapEntries)); + plotGroups.forEach((group) => scroll.appendChild(createFrequencyGroup(group.plotId, group.entries))); + } + + body.appendChild(scroll); + return card; +} + +function createSummaryCard(narrative: string): HTMLElement { + const card = createCard('Analysis Summary'); + const body = card.querySelector('.autk-session-insights-card-body') as HTMLElement; + const pre = document.createElement('pre'); + const copy = document.createElement('button'); + + pre.className = 'autk-session-summary'; + pre.textContent = narrative; + copy.className = 'autk-session-button'; + copy.textContent = 'Copy to clipboard'; + copy.addEventListener('click', () => { + navigator.clipboard.writeText(narrative).then(() => { + copy.textContent = 'Copied!'; + setTimeout(() => { copy.textContent = 'Copy to clipboard'; }, 1800); + }); + }); + + body.appendChild(pre); + body.appendChild(copy); + return card; +} + +function createCard(title: string): HTMLElement { + const card = document.createElement('section'); + const heading = document.createElement('div'); + const body = document.createElement('div'); + card.className = 'autk-session-insights-card'; + heading.className = 'autk-session-insights-card-title'; + heading.textContent = title; + body.className = 'autk-session-insights-card-body'; + card.appendChild(heading); + card.appendChild(body); + return card; +} + +function createMetricChip(label: string, value: string): HTMLElement { + const chip = document.createElement('div'); + chip.className = 'autk-session-metric-chip'; + chip.innerHTML = `${label}: ${value}`; + return chip; +} + +function topEntries(values: Map, limit = 5): Array<[number, number]> { + return [...values.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit); +} + +function createFrequencyGroup(title: string, entries: Array<[number, number]>): HTMLElement { + const group = document.createElement('div'); + const heading = document.createElement('div'); + const max = Math.max(...entries.map(([, count]) => count), 1); + group.className = 'autk-session-frequency-group'; + heading.className = 'autk-session-frequency-group-title'; + heading.textContent = title; + group.appendChild(heading); + + entries.forEach(([id, count]) => { + const row = document.createElement('div'); + const label = document.createElement('div'); + const bar = document.createElement('div'); + const fill = document.createElement('div'); + const countLabel = document.createElement('div'); + + row.className = 'autk-session-frequency-row'; + label.className = 'autk-session-frequency-id'; + label.textContent = `Feature #${id}`; + bar.className = 'autk-session-frequency-bar'; + fill.className = 'autk-session-frequency-fill'; + fill.style.width = `${(count / max) * 100}%`; + countLabel.className = 'autk-session-frequency-count'; + countLabel.textContent = `${count}`; + bar.appendChild(fill); + row.appendChild(label); + row.appendChild(bar); + row.appendChild(countLabel); + group.appendChild(row); + }); + + return group; +} diff --git a/autk-provenance/src/ui/workspace-shell.ts b/autk-provenance/src/ui/workspace-shell.ts new file mode 100644 index 00000000..cc875bd7 --- /dev/null +++ b/autk-provenance/src/ui/workspace-shell.ts @@ -0,0 +1,107 @@ +export interface WorkspaceShell { + root: HTMLElement; + mapBody: HTMLElement; + thematicSelect: HTMLSelectElement; + nodeCountBadge: HTMLElement; + chartsTab: HTMLElement; + provenanceTab: HTMLElement; + provenanceTrail: HTMLElement; + provenanceInsights: HTMLElement; + chartsInsights: HTMLElement; + plotPanels: Record<'scatter' | 'bar' | 'parallel' | 'histogram', { + title: HTMLElement; + hint: HTMLElement; + header: HTMLElement; + body: HTMLElement; + button: HTMLButtonElement | null; + }>; +} + +function getPlotPanel(root: ParentNode, key: 'scatter' | 'bar' | 'parallel' | 'histogram') { + const panel = root.querySelector(`[data-chart-key="${key}"]`)!; + return { + title: panel.querySelector('.autk-workspace-panel-title') as HTMLElement, + hint: panel.querySelector('.autk-workspace-panel-hint') as HTMLElement, + header: panel.querySelector('.autk-workspace-plot-header') as HTMLElement, + body: panel.querySelector('.autk-workspace-plot-body-inner') as HTMLElement, + button: panel.querySelector('.autk-workspace-expand-btn') as HTMLButtonElement | null, + }; +} + +export function createInsightsWorkspaceShell(container: HTMLElement, title?: string, description?: string): WorkspaceShell { + container.innerHTML = ` +
+ ${title ? `

${title}

${description ? `

${description}

` : ''}
` : ''} +
+
+
+ Map + +
+
+
+
+
+ + +
+
+
+ ${['scatter', 'bar', 'parallel', 'histogram'].map((key) => ` +
+
+
+ + +
+ +
+
+
+ `).join('')} +
+
+
+
+
+
+
+
+
+
+
+
+ Session Insights + Updates as you interact with any visualization +
+
+
+
+ `; + + return { + root: container.firstElementChild as HTMLElement, + mapBody: container.querySelector('.autk-workspace-map-body') as HTMLElement, + thematicSelect: container.querySelector('.autk-workspace-select') as HTMLSelectElement, + nodeCountBadge: container.querySelector('.autk-workspace-badge') as HTMLElement, + chartsTab: container.querySelector('[data-panel="charts"]') as HTMLElement, + provenanceTab: container.querySelector('[data-panel="provenance"]') as HTMLElement, + provenanceTrail: container.querySelector('.autk-workspace-trail') as HTMLElement, + provenanceInsights: container.querySelector('.autk-workspace-insights-slot') as HTMLElement, + chartsInsights: container.querySelector('.autk-workspace-session-insights-body') as HTMLElement, + plotPanels: { + scatter: getPlotPanel(container, 'scatter'), + bar: getPlotPanel(container, 'bar'), + parallel: getPlotPanel(container, 'parallel'), + histogram: getPlotPanel(container, 'histogram'), + }, + }; +} diff --git a/autk-provenance/src/ui/workspace-styles.ts b/autk-provenance/src/ui/workspace-styles.ts new file mode 100644 index 00000000..b637232b --- /dev/null +++ b/autk-provenance/src/ui/workspace-styles.ts @@ -0,0 +1,89 @@ +export function ensureInsightsWorkspaceStyles(): void { + if (typeof document === 'undefined' || !document.head) return; + const styleId = 'autk-insights-workspace-styles'; + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = [ + '.autk-insights-workspace{display:flex;flex-direction:column;gap:14px;font-family:system-ui,sans-serif;color:#1b3148}', + '.autk-workspace-header{padding:14px 18px;border:1px solid #d4dde7;border-radius:12px;background:linear-gradient(180deg,#fff,#f6f9fc);box-shadow:0 8px 24px rgba(28,53,76,.09)}', + '.autk-workspace-header h1{margin:0;font-size:22px;line-height:1.2;color:#1d3b5c}', + '.autk-workspace-header p{margin:4px 0 0;font-size:12px;color:#546b80}', + '.autk-workspace-layout{display:grid;grid-template-columns:minmax(360px,1fr) minmax(420px,1fr);gap:14px;align-items:stretch}', + '.autk-workspace-panel{border:1px solid #d4dde7;border-radius:12px;background:#fff;box-shadow:0 8px 24px rgba(28,53,76,.09);overflow:hidden;min-height:0}', + '.autk-workspace-panel-header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border-bottom:1px solid #d4dde7;background:linear-gradient(180deg,#fff,#f6f9fc)}', + '.autk-workspace-panel-header-main{display:flex;flex-direction:column;gap:2px;min-width:0}', + '.autk-workspace-panel-title{font-size:13px;font-weight:600}', + '.autk-workspace-panel-hint{font-size:11px;color:#546b80;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', + '.autk-workspace-select-wrap{display:flex;align-items:center;gap:6px;font-size:11px;color:#546b80}', + '.autk-workspace-select{padding:4px 8px;border:1px solid #d4dde7;border-radius:6px;background:#fff;color:#1b3148;font:inherit}', + '.autk-workspace-map-panel,.autk-workspace-side-panel{min-height:680px;display:flex;flex-direction:column}', + '.autk-workspace-map-body{flex:1;min-height:0;display:flex;align-items:stretch;justify-content:center;overflow:hidden;background:#c8dae6}', + '.autk-workspace-map-body canvas{display:block;width:100%;height:100%}', + '.autk-workspace-tabbar{display:flex;border-bottom:1px solid #d4dde7;background:#f6f9fc}', + '.autk-workspace-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:12px;border:none;border-bottom:3px solid transparent;background:transparent;color:#546b80;font:600 12px system-ui,sans-serif;cursor:pointer}', + '.autk-workspace-tab.active{color:#2f5f96;border-bottom-color:#2f5f96;background:#fff}', + '.autk-workspace-tab-label{font-size:14px;font-weight:700;color:inherit}', + '.autk-workspace-tab-meta{font-size:11px;font-weight:500;color:#6d8398;line-height:1.3}', + '.autk-workspace-badge{font-weight:500;font-size:11px}', + '.autk-workspace-tab-content{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}', + '.autk-workspace-hidden{display:none}', + '.autk-workspace-plot-grid{flex:1;min-height:0;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:10px;padding:10px}', + '.autk-workspace-plot-card{display:flex;flex-direction:column;min-height:0;border:1px solid #d4dde7;border-radius:10px;overflow:hidden;background:#fff}', + '.autk-workspace-plot-header{cursor:zoom-in}', + '.autk-workspace-expand-btn{padding:5px 10px;border:1px solid #d4dde7;border-radius:999px;background:#fff;color:#2f5f96;font:600 11px system-ui,sans-serif;cursor:pointer}', + '.autk-workspace-plot-body{position:relative;flex:1;min-height:0;overflow:hidden}', + '.autk-workspace-plot-body-inner{position:absolute;inset:0}', + '.autk-workspace-provenance-grid{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,1fr);gap:10px;padding:10px}', + '.autk-workspace-provenance-main,.autk-workspace-provenance-side{min-height:0;overflow:hidden}', + '.autk-workspace-trail{height:100%;display:flex;flex-direction:column;min-height:0}', + '.autk-workspace-insights-slot{height:100%;min-height:0;overflow:auto}', + '.autk-workspace-session-insights{display:flex;flex-direction:column}', + '.autk-workspace-session-insights-body{padding:0;min-height:0}', + '.autk-workspace-bottom-insights{border-top:1px solid #e7eef6}', + '.autk-session-insights-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0}', + '.autk-session-insights-card{min-height:220px;padding:16px;border-right:1px solid #dbe5f0;display:flex;flex-direction:column;gap:12px}', + '.autk-session-insights-card:last-child{border-right:none}', + '.autk-session-insights-card-title{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#5c7389}', + '.autk-session-insights-card-body{flex:1;display:flex;flex-direction:column;gap:8px;min-height:0}', + '.autk-session-insights-step{font-size:12px;font-weight:600;color:#49627a}', + '.autk-session-insights-label{font-size:12px;color:#5c7389}', + '.autk-session-strategy-badge{display:inline-block;align-self:flex-start;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:700;color:#fff}', + '.autk-session-strategy-desc{font-size:12px;line-height:1.5;color:#4d6479}', + '.autk-session-metrics-row{display:flex;flex-wrap:wrap;gap:8px}', + '.autk-session-metric-chip{padding:4px 10px;border:1px solid #dbe5f0;border-radius:8px;background:#f6f9fc;font-size:11px;color:#1f344b}', + '.autk-session-metric-chip strong{font-weight:700}', + '.autk-session-annotation-textarea{width:100%;min-height:88px;flex:1;padding:8px 10px;border:1px solid #c3cfdb;border-radius:8px;background:#fff;color:#1f344b;font:inherit;font-size:12px;resize:vertical;box-sizing:border-box}', + '.autk-session-annotation-textarea:focus{outline:none;border-color:#2f5f96}', + '.autk-session-button{align-self:flex-start;padding:7px 14px;border:1px solid #c3cfdb;border-radius:8px;background:#fff;color:#1f344b;font:inherit;cursor:pointer}', + '.autk-session-button:hover:not(:disabled){background:#edf5ff;border-color:#2f5f96;color:#2f5f96}', + '.autk-session-button:disabled{opacity:.45;cursor:not-allowed}', + '.autk-session-frequency-scroll{display:flex;flex-direction:column;gap:10px;overflow:auto;min-height:0}', + '.autk-session-frequency-empty{font-size:12px;color:#5c7389}', + '.autk-session-frequency-group{display:flex;flex-direction:column;gap:6px}', + '.autk-session-frequency-group-title{font-size:11px;font-weight:700;color:#1f344b}', + '.autk-session-frequency-row{display:flex;align-items:center;gap:8px;min-height:18px}', + '.autk-session-frequency-id{width:112px;flex-shrink:0;font-size:11px;color:#5c7389;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', + '.autk-session-frequency-bar{flex:1;height:8px;border-radius:999px;background:#e7eef6;overflow:hidden}', + '.autk-session-frequency-fill{height:100%;background:#2f5f96;border-radius:999px}', + '.autk-session-frequency-count{width:26px;flex-shrink:0;text-align:right;font-size:11px;color:#1f344b}', + '.autk-session-summary{margin:0;padding:10px;border:1px solid #dbe5f0;border-radius:8px;background:#f0f5fb;white-space:pre-wrap;word-break:break-word;line-height:1.6;font-size:11px;color:#1f344b;overflow:auto;min-height:0;max-height:170px}', + '.autk-chart-modal-backdrop{position:fixed;inset:0;z-index:12000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(14,24,36,.56)}', + '.autk-chart-modal{width:min(1320px,96vw);height:min(900px,92vh);display:flex;flex-direction:column;overflow:hidden;border-radius:16px;border:1px solid rgba(212,221,231,.85);background:linear-gradient(180deg,#f9fcff,#f3f8fd);box-shadow:0 32px 90px rgba(13,26,40,.45)}', + '.autk-chart-modal-header{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 16px;border-bottom:1px solid #d4dde7;background:rgba(255,255,255,.92)}', + '.autk-chart-modal-heading{display:flex;flex-direction:column;gap:3px;min-width:0}', + '.autk-chart-modal-title{font-size:15px;font-weight:700}', + '.autk-chart-modal-subtitle{font-size:12px;color:#546b80}', + '.autk-chart-modal-controls{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}', + '.autk-chart-modal-controls button{padding:7px 10px;border:1px solid #d4dde7;border-radius:8px;background:#fff;color:#1b3148;font:inherit;cursor:pointer}', + '.autk-chart-modal-body{flex:1;display:flex;min-height:0;padding:14px}', + '.autk-chart-modal-canvas{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden;border:1px solid #d4dde7;border-radius:12px;background:linear-gradient(90deg,rgba(207,220,232,.35) 1px,transparent 1px) 0 0 / 32px 32px,linear-gradient(rgba(207,220,232,.35) 1px,transparent 1px) 0 0 / 32px 32px,#fff}', + '.autk-chart-modal-stage{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}', + '.autk-chart-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px 14px;font-size:12px;color:#546b80}', + '@media (max-width:1260px){.autk-session-insights-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.autk-session-insights-card:nth-child(2){border-right:none}.autk-session-insights-card:nth-child(-n+2){border-bottom:1px solid #dbe5f0}}', + '@media (max-width:1200px){.autk-workspace-layout{grid-template-columns:1fr}.autk-workspace-provenance-grid{grid-template-columns:1fr}.autk-workspace-map-panel,.autk-workspace-side-panel{min-height:520px}}', + '@media (max-width:760px){.autk-workspace-plot-grid{grid-template-columns:1fr;grid-template-rows:repeat(4,280px)}.autk-chart-modal{width:96vw;height:92vh}.autk-chart-modal-header,.autk-chart-modal-footer{flex-direction:column;align-items:flex-start}.autk-session-insights-grid{grid-template-columns:1fr}.autk-session-insights-card{border-right:none;border-bottom:1px solid #dbe5f0}.autk-session-insights-card:last-child{border-bottom:none}}', + ].join(''); + document.head.appendChild(style); +} diff --git a/autk-provenance/src/ui/workspace-tabs.ts b/autk-provenance/src/ui/workspace-tabs.ts new file mode 100644 index 00000000..47e75874 --- /dev/null +++ b/autk-provenance/src/ui/workspace-tabs.ts @@ -0,0 +1,23 @@ +export function bindWorkspaceTabs(root: HTMLElement): () => void { + const tabs = Array.from(root.querySelectorAll('.autk-workspace-tab')); + const panels = Array.from(root.querySelectorAll('.autk-workspace-tab-content')); + const chartsOnly = Array.from(root.querySelectorAll('[data-charts-only="true"]')); + + const handleClick = (button: HTMLButtonElement) => { + const tab = button.dataset.tab; + tabs.forEach((item) => item.classList.toggle('active', item === button)); + panels.forEach((panel) => panel.classList.toggle('autk-workspace-hidden', panel.dataset.panel !== tab)); + chartsOnly.forEach((panel) => panel.classList.toggle('autk-workspace-hidden', tab !== 'charts')); + }; + + tabs.forEach((button) => { + const listener = () => handleClick(button); + button.addEventListener('click', listener); + }); + + return () => { + tabs.forEach((button) => { + button.replaceWith(button.cloneNode(true)); + }); + }; +} diff --git a/autk/src/provenance.ts b/autk/src/provenance.ts new file mode 100644 index 00000000..b2f54c61 --- /dev/null +++ b/autk/src/provenance.ts @@ -0,0 +1 @@ +export * from 'autk-provenance'; diff --git a/autk/vite.config.ts b/autk/vite.config.ts index b80fcbd8..76e27b43 100644 --- a/autk/vite.config.ts +++ b/autk/vite.config.ts @@ -2,7 +2,7 @@ import { resolve } from 'path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; -const externalPackages = ['autk-map', 'autk-db', 'autk-compute', 'autk-plot']; +const externalPackages = ['autk-map', 'autk-db', 'autk-compute', 'autk-plot', 'autk-provenance']; export default defineConfig({ plugins: [dts()], @@ -14,6 +14,7 @@ export default defineConfig({ db: resolve(__dirname, 'src/db.ts'), compute: resolve(__dirname, 'src/compute.ts'), plot: resolve(__dirname, 'src/plot.ts'), + provenance: resolve(__dirname, 'src/provenance.ts'), }, formats: ['es'], }, diff --git a/examples/src/autk-provenance/all-plots-provenance.html b/examples/src/autk-provenance/all-plots-provenance.html index c03ff1a8..5eda5e23 100644 --- a/examples/src/autk-provenance/all-plots-provenance.html +++ b/examples/src/autk-provenance/all-plots-provenance.html @@ -2,153 +2,11 @@ - Autark – All Visualizations with Provenance - + + Autark Provenance Insights Workspace -
- - - - - -
- - -
-
-
- Map -
- - -
-
-
- -
-
-
- - -
-
- - -
- - -
-
- -
-
-
- Scatterplot - Area vs Perimeter · drag to brush -
- -
-
-
-
-
- -
-
-
- Bar Chart - Neighborhoods per district · click a bar -
- -
-
-
-
-
- -
-
-
- Parallel Coordinates - Area · Perimeter · Compactness · brush any axis -
- -
-
-
-
-
- -
-
-
- Histogram - Neighborhood area distribution · click bins to select -
- -
-
-
-
-
- -
-
- - - -
-
- - - - - -
-
- Session Insights - Updates as you interact with any visualization -
-
- -
-
Annotate This Step
-
Interact with any visualization to begin…
-
- -
-
Analysis Summary
-
Interact with any visualization to begin…
-
- -
-
- -
+
diff --git a/examples/src/autk-provenance/all-plots-provenance.ts b/examples/src/autk-provenance/all-plots-provenance.ts index 63010e4e..6958b66f 100644 --- a/examples/src/autk-provenance/all-plots-provenance.ts +++ b/examples/src/autk-provenance/all-plots-provenance.ts @@ -1,712 +1,42 @@ import type { FeatureCollection } from 'geojson'; -import { SpatialDb } from 'autk-db'; -import { Scatterplot, Barchart, ParallelCoordinates, Histogram, PlotEvent } from 'autk-plot'; +import { AutkSpatialDb } from 'autk-db'; import { AutkMap } from 'autk-map'; -import { - createAutarkProvenance, - renderProvenanceTrailUI, - computeGraphMetrics, - getInsightAnnotations, - generateSessionNarrative, - PlotType, - type CustomControlConfig, -} from 'autk-provenance'; - -// --------------------------------------------------------------------------- -// Data helpers -// --------------------------------------------------------------------------- - -// Add a compactness metric (how circular the polygon is; 1 = perfect circle) -// to every feature so Parallel Coordinates has a meaningful third axis. -function enrichFeatures(geojson: FeatureCollection): void { - for (const f of geojson.features) { - const area = f.properties?.shape_area as number ?? 0; - const perim = f.properties?.shape_leng as number ?? 1; - if (f.properties) { - f.properties.compactness = Math.round((4 * Math.PI * area / (perim * perim)) * 1000) / 1000; - } - } -} - -// Bar chart: one row per community district (cdta2020), height = neighborhood count. -// Indices here (0–N) don't align 1:1 with the 38-feature map dataset — that's expected. -// The bar chart's provenance tracking is correct; map cross-highlight uses the district -// count index as a fallback, which is an acceptable approximation for this demo. -function buildDistrictData(geojson: FeatureCollection): FeatureCollection { - const groups = new Map(); - geojson.features.forEach((f, index) => { - const cd = f.properties?.cdta2020 as string ?? 'Unknown'; - const area = f.properties?.shape_area as number ?? 0; - const g = groups.get(cd) ?? { count: 0, totalArea: 0, memberIds: [] }; - g.count++; - g.totalArea += area; - g.memberIds.push(index); - groups.set(cd, g); - }); - return { - type: 'FeatureCollection', - features: [...groups.entries()] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([cdta2020, { count, totalArea, memberIds }]) => ({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [0, 0] }, - properties: { - cdta2020, - count, - avg_area_km2: Math.round(totalArea / count / 1e6 * 100) / 100, - memberIds, - }, - })), - }; -} - -// --------------------------------------------------------------------------- -// Session insight card renderers -// --------------------------------------------------------------------------- - -type ProvenanceApi = ReturnType; -type ChartKey = 'scatter' | 'bar' | 'pc' | 'hist'; -type InteractivePlot = { - selection: number[]; - plotEvents: { - addEventListener(event: string, listener: (selection: number[]) => void): void; - removeEventListener?(event: string, listener: (selection: number[]) => void): void; - emit(event: string, selection: number[]): void; - }; - setHighlightedIds(selection: number[]): void; - updatePlotSelection(): void; -}; -type ChartModalDescriptor = { - key: ChartKey; - title: string; - subtitle: string; - originalPlot: InteractivePlot; - events: PlotEvent[]; - trigger: HTMLElement; - button: HTMLButtonElement | null; - createModalPlot(container: HTMLElement, width: number, height: number): InteractivePlot; -}; -type ChartModalApi = { syncSelection(): void }; - -let _annotationNodeId: string | null = null; -let _annotationTextarea: HTMLTextAreaElement | null = null; - -function renderAnnotateCard(el: HTMLElement, provenance: ProvenanceApi): void { - const current = provenance.getCurrentNode(); - if (!current) { - el.innerHTML = 'No active node.'; - _annotationNodeId = null; _annotationTextarea = null; - return; - } - if (current.id === _annotationNodeId && _annotationTextarea) { - if (!_annotationTextarea.matches(':focus')) { - const ex = current.metadata?.insight; - _annotationTextarea.value = typeof ex === 'string' ? ex : ''; - } - return; - } - _annotationNodeId = current.id; - el.innerHTML = ''; - - const lbl = document.createElement('div'); - lbl.className = 'annotation-label'; - lbl.textContent = `Step: ${current.actionLabel}`; - el.appendChild(lbl); - - const ta = document.createElement('textarea'); - ta.className = 'annotation-textarea'; - ta.placeholder = 'What did you notice or conclude at this step?'; - const ex = current.metadata?.insight; - ta.value = typeof ex === 'string' ? ex : ''; - el.appendChild(ta); - _annotationTextarea = ta; - - const btn = document.createElement('button'); - btn.className = 'annotation-save-btn'; - btn.textContent = 'Save Insight'; - btn.addEventListener('click', () => { - provenance.annotateNode(current.id, ta.value.trim()); - btn.textContent = 'Saved ✓'; - setTimeout(() => { btn.textContent = 'Save Insight'; }, 1500); - }); - el.appendChild(btn); -} - -function renderSummaryCard(el: HTMLElement, provenance: ProvenanceApi): void { - const graph = provenance.getGraph(); - const narrative = generateSessionNarrative(graph, computeGraphMetrics(graph), getInsightAnnotations(graph)); - el.innerHTML = ''; - const pre = document.createElement('pre'); pre.className = 'summary-pre'; pre.textContent = narrative; - el.appendChild(pre); - const btn = document.createElement('button'); btn.className = 'copy-btn'; btn.textContent = 'Copy to clipboard'; - btn.addEventListener('click', () => navigator.clipboard.writeText(narrative).then(() => { - btn.textContent = 'Copied ✓'; setTimeout(() => { btn.textContent = 'Copy to clipboard'; }, 1800); - })); - el.appendChild(btn); -} - -// --------------------------------------------------------------------------- -// Tab switching -// --------------------------------------------------------------------------- - -function setupTabs(provenance: ProvenanceApi): void { - const chartsTab = document.getElementById('chartsTab')!; - const provTab = document.getElementById('provenanceTab')!; - const provInsights = document.getElementById('provTrailInsights')!; - const badge = document.getElementById('nodeCountBadge')!; - - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - const tab = btn.dataset.tab as 'charts' | 'provenance'; - document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b === btn)); - chartsTab.classList.toggle('hidden', tab !== 'charts'); - provTab.classList.toggle('hidden', tab !== 'provenance'); - provInsights.classList.toggle('hidden', tab !== 'provenance'); - }); - }); - - provenance.addObserver(() => { - const n = provenance.getGraph().nodes.size; - badge.textContent = `${n} step${n !== 1 ? 's' : ''} recorded`; - }); -} - -function setupChartModal(descriptors: ChartModalDescriptor[]): ChartModalApi { - let active: ChartModalDescriptor | null = null; - let backdrop: HTMLDivElement | null = null; - let canvas: HTMLDivElement | null = null; - let stage: HTMLDivElement | null = null; - let titleEl: HTMLDivElement | null = null; - let subtitleEl: HTMLDivElement | null = null; - let scaleEl: HTMLSpanElement | null = null; - let modalPlot: InteractivePlot | null = null; - let modalPlotCleanup: Array<() => void> = []; - let contentWidth = 1; - let contentHeight = 1; - let scale = 1; - let tx = 0; - let ty = 0; - let spacePressed = false; - let dragActive = false; - let dragX = 0; - let dragY = 0; - - const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); - - function applyTransform(): void { - if (!stage) return; - stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; - if (scaleEl) scaleEl.textContent = `${Math.round(scale * 100)}%`; - } - - function contentSize(): { width: number; height: number } { - return { width: Math.max(1, contentWidth), height: Math.max(1, contentHeight) }; - } - - function fit(): void { - if (!canvas || !stage) return; - const rect = canvas.getBoundingClientRect(); - const { width, height } = contentSize(); - const padding = 28; - const fitScale = Math.min( - (rect.width - padding * 2) / Math.max(1, width), - (rect.height - padding * 2) / Math.max(1, height) - ); - scale = clamp(Number.isFinite(fitScale) ? fitScale : 1, 0.3, 4); - tx = (rect.width - width * scale) / 2; - ty = (rect.height - height * scale) / 2; - applyTransform(); - } - - function zoomAt(clientX: number, clientY: number, factor: number): void { - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const localX = clientX - rect.left; - const localY = clientY - rect.top; - const nextScale = clamp(scale * factor, 0.25, 6); - const ratio = nextScale / scale; - tx = localX - (localX - tx) * ratio; - ty = localY - (localY - ty) * ratio; - scale = nextScale; - applyTransform(); - } - - function clearModalPlot(): void { - modalPlotCleanup.forEach((cleanup) => cleanup()); - modalPlotCleanup = []; - modalPlot = null; - if (stage) stage.innerHTML = ''; - } - - function syncSelection(): void { - if (!active || !modalPlot) return; - modalPlot.setHighlightedIds([...active.originalPlot.selection]); - } - - function renderInteractivePlot(): void { - if (!active || !stage || !titleEl || !subtitleEl || !canvas) return; - titleEl.textContent = active.title; - subtitleEl.textContent = active.subtitle; - clearModalPlot(); - - const host = document.createElement('div'); - host.style.width = '100%'; - host.style.height = '100%'; - stage.appendChild(host); - - contentWidth = Math.max(920, canvas.clientWidth - 48); - contentHeight = Math.max(560, canvas.clientHeight - 48); - stage.style.width = `${contentWidth}px`; - stage.style.height = `${contentHeight}px`; - host.style.width = `${contentWidth}px`; - host.style.height = `${contentHeight}px`; - - modalPlot = active.createModalPlot(host, contentWidth, contentHeight); - syncSelection(); - - for (const eventName of active.events) { - const listener = (selection: number[]) => { - if (!active) return; - active.originalPlot.selection = [...selection]; - active.originalPlot.updatePlotSelection(); - active.originalPlot.plotEvents.emit(eventName, [...selection]); - requestAnimationFrame(syncSelection); - }; - modalPlot.plotEvents.addEventListener(eventName, listener); - modalPlotCleanup.push(() => { - modalPlot?.plotEvents.removeEventListener?.(eventName, listener); - }); - } - - requestAnimationFrame(() => fit()); - } - - function closeModal(): void { - clearModalPlot(); - backdrop?.remove(); - backdrop = null; - canvas = null; - stage = null; - titleEl = null; - subtitleEl = null; - scaleEl = null; - active = null; - document.body.style.overflow = ''; - } - - function ensureModal(): void { - if (backdrop) return; - - backdrop = document.createElement('div'); - backdrop.className = 'chart-modal-backdrop'; - - const modal = document.createElement('div'); - modal.className = 'chart-modal'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - modal.setAttribute('aria-label', 'Expanded chart view'); - - const header = document.createElement('div'); - header.className = 'chart-modal-header'; - - const heading = document.createElement('div'); - heading.className = 'chart-modal-heading'; - titleEl = document.createElement('div'); - titleEl.className = 'chart-modal-title'; - subtitleEl = document.createElement('div'); - subtitleEl.className = 'chart-modal-subtitle'; - heading.appendChild(titleEl); - heading.appendChild(subtitleEl); - - const controls = document.createElement('div'); - controls.className = 'chart-modal-controls'; - - const makeBtn = (label: string, onClick: () => void): HTMLButtonElement => { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'chart-modal-btn'; - btn.textContent = label; - btn.addEventListener('click', onClick); - return btn; - }; - - controls.appendChild(makeBtn('−', () => { - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 0.85); - })); - controls.appendChild(makeBtn('+', () => { - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.15); - })); - controls.appendChild(makeBtn('Fit', fit)); - controls.appendChild(makeBtn('100%', () => { - scale = 1; - tx = 24; - ty = 24; - applyTransform(); - })); - controls.appendChild(makeBtn('Close', closeModal)); - - header.appendChild(heading); - header.appendChild(controls); - - const body = document.createElement('div'); - body.className = 'chart-modal-body'; - - canvas = document.createElement('div'); - canvas.className = 'chart-modal-canvas'; - - stage = document.createElement('div'); - stage.className = 'chart-modal-stage'; - canvas.appendChild(stage); - body.appendChild(canvas); - - const footer = document.createElement('div'); - footer.className = 'chart-modal-footer'; - const hint = document.createElement('div'); - hint.className = 'chart-modal-hint'; - hint.textContent = 'Mouse wheel to zoom. Hold Space and drag to pan. Press Esc to close.'; - scaleEl = document.createElement('span'); - scaleEl.className = 'chart-modal-scale'; - scaleEl.textContent = '100%'; - footer.appendChild(hint); - footer.appendChild(scaleEl); - - modal.appendChild(header); - modal.appendChild(body); - modal.appendChild(footer); - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - - backdrop.addEventListener('click', (event) => { - if (event.target === backdrop) closeModal(); - }); - - canvas.addEventListener('wheel', (event) => { - event.preventDefault(); - zoomAt(event.clientX, event.clientY, event.deltaY < 0 ? 1.12 : 0.9); - }, { passive: false }); - - canvas.addEventListener('pointerdown', (event) => { - if (!(event.button === 0 && spacePressed)) return; - dragActive = true; - dragX = event.clientX; - dragY = event.clientY; - canvas!.setPointerCapture(event.pointerId); - event.preventDefault(); - }); - - canvas.addEventListener('pointermove', (event) => { - if (!dragActive) return; - tx += event.clientX - dragX; - ty += event.clientY - dragY; - dragX = event.clientX; - dragY = event.clientY; - applyTransform(); - }); - - const endDrag = (event: PointerEvent) => { - if (!dragActive || !canvas) return; - dragActive = false; - try { - canvas.releasePointerCapture(event.pointerId); - } catch { - // ignore - } - }; - canvas.addEventListener('pointerup', endDrag); - canvas.addEventListener('pointercancel', endDrag); - } - - function openModal(descriptor: ChartModalDescriptor): void { - active = descriptor; - ensureModal(); - document.body.style.overflow = 'hidden'; - renderInteractivePlot(); - } - - document.addEventListener('keydown', (event) => { - if (event.key === ' ') spacePressed = true; - if (event.key === 'Escape' && backdrop) closeModal(); - }); - - document.addEventListener('keyup', (event) => { - if (event.key === ' ') spacePressed = false; - }); - - window.addEventListener('resize', () => { - if (!backdrop || !active) return; - renderInteractivePlot(); - }); - - for (const descriptor of descriptors) { - const open = () => openModal(descriptor); - descriptor.trigger.addEventListener('click', open); - descriptor.trigger.addEventListener('keydown', (event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - open(); - }); - descriptor.button?.addEventListener('click', (event) => { - event.stopPropagation(); - open(); - }); - } - - return { syncSelection }; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- +import { renderInsightsWorkspace } from 'autk-provenance'; async function main(): Promise { - // Loading overlay + const root = document.getElementById('app'); + if (!root) throw new Error('Missing #app container'); + const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(238,243,248,.92);display:flex;align-items:center;justify-content:center;font-size:15px;color:#1b3148;z-index:9999;font-family:system-ui,sans-serif;'; overlay.textContent = 'Loading Manhattan neighborhood data…'; document.body.appendChild(overlay); - // Load real Manhattan data via SpatialDb (handles Mercator projection automatically) - const db = new SpatialDb(); + const db = new AutkSpatialDb(); await db.init(); await db.loadCustomLayer({ geojsonFileUrl: '/data/mnt_neighs_proj.geojson', outputTableName: 'neighborhoods', }); - const geojson = await db.getLayer('neighborhoods') as FeatureCollection; - - enrichFeatures(geojson); - - overlay.remove(); + const collection = await db.getLayer('neighborhoods') as FeatureCollection; - // ── Map ────────────────────────────────────────────────────────────────── - const canvas = document.querySelector('canvas') as HTMLCanvasElement; - const map = new AutkMap(canvas); + const canvas = document.createElement('canvas'); + const map = new AutkMap(canvas); await map.init(); - map.loadGeoJsonLayer('neighborhoods', geojson); - map.updateRenderInfoProperty('neighborhoods', 'isPick', true); + map.loadCollection('neighborhoods', { collection }); + map.updateRenderInfo('neighborhoods', { renderInfo: { isPick: true } }); map.draw(); - // ── Plots ───────────────────────────────────────────────────────────────── - const districtData = buildDistrictData(geojson); - - function plotDims(el: HTMLElement, fw: number, fh: number) { - const r = el.getBoundingClientRect(); - return { width: r.width > 20 ? Math.floor(r.width) : fw, height: r.height > 20 ? Math.floor(r.height) : fh }; - } - - const scatterEl = document.getElementById('scatterBody')!; - const barEl = document.getElementById('barBody')!; - const pcEl = document.getElementById('pcBody')!; - const histEl = document.getElementById('histBody')!; - - const sd = plotDims(scatterEl, 340, 260); - const bd = plotDims(barEl, 340, 260); - const pd = plotDims(pcEl, 340, 260); - const hd = plotDims(histEl, 340, 260); - - const scatter = new Scatterplot({ - div: scatterEl, data: geojson, - labels: { axis: ['shape_area', 'shape_leng'], title: 'Area vs Perimeter' }, - width: sd.width, height: sd.height, - margins: { left: 62, right: 16, top: 36, bottom: 48 }, - events: [PlotEvent.CLICK, PlotEvent.BRUSH], - }); - - const bar = new Barchart({ - div: barEl, data: districtData, - labels: { axis: ['cdta2020', 'count'], title: 'Neighborhoods per District' }, - width: bd.width, height: bd.height, - margins: { left: 50, right: 16, top: 36, bottom: 68 }, - events: [PlotEvent.CLICK], - }); - - const pc = new ParallelCoordinates({ - div: pcEl, data: geojson, - labels: { axis: ['shape_area', 'shape_leng', 'compactness'], title: 'Neighborhood Metrics' }, - width: pd.width, height: pd.height, - margins: { left: 28, right: 28, top: 36, bottom: 36 }, - parallelCoordinates: { - normalization: { - mode: 'robust', - quantileClamp: [0.05, 0.95], - }, - }, - events: [PlotEvent.BRUSH_Y], - }); - - const hist = new Histogram({ - div: histEl, data: geojson, - labels: { axis: ['shape_area', 'Count'], title: 'Area Distribution' }, - width: hd.width, height: hd.height, - margins: { left: 55, right: 16, top: 36, bottom: 48 }, - events: [PlotEvent.CLICK], - }); - - const chartModal = setupChartModal([ - { - key: 'scatter', - title: 'Scatterplot', - subtitle: 'Area vs Perimeter · drag to brush', - originalPlot: scatter, - events: [PlotEvent.CLICK, PlotEvent.BRUSH], - trigger: document.querySelector('[data-chart-key="scatter"] .plot-panel-header') as HTMLElement, - button: document.querySelector('[data-chart-key="scatter"] .panel-expand-btn') as HTMLButtonElement | null, - createModalPlot: (container, width, height) => new Scatterplot({ - div: container, - data: geojson, - labels: { axis: ['shape_area', 'shape_leng'], title: 'Area vs Perimeter' }, - width, - height, - margins: { left: 62, right: 16, top: 36, bottom: 48 }, - events: [PlotEvent.CLICK, PlotEvent.BRUSH], - }), - }, - { - key: 'bar', - title: 'Bar Chart', - subtitle: 'Neighborhoods per district · click a bar', - originalPlot: bar, - events: [PlotEvent.CLICK], - trigger: document.querySelector('[data-chart-key="bar"] .plot-panel-header') as HTMLElement, - button: document.querySelector('[data-chart-key="bar"] .panel-expand-btn') as HTMLButtonElement | null, - createModalPlot: (container, width, height) => new Barchart({ - div: container, - data: districtData, - labels: { axis: ['cdta2020', 'count'], title: 'Neighborhoods per District' }, - width, - height, - margins: { left: 50, right: 16, top: 36, bottom: 68 }, - events: [PlotEvent.CLICK], - }), - }, - { - key: 'pc', - title: 'Parallel Coordinates', - subtitle: 'Area · Perimeter · Compactness · brush any axis', - originalPlot: pc, - events: [PlotEvent.BRUSH_Y], - trigger: document.querySelector('[data-chart-key="pc"] .plot-panel-header') as HTMLElement, - button: document.querySelector('[data-chart-key="pc"] .panel-expand-btn') as HTMLButtonElement | null, - createModalPlot: (container, width, height) => new ParallelCoordinates({ - div: container, - data: geojson, - labels: { axis: ['shape_area', 'shape_leng', 'compactness'], title: 'Neighborhood Metrics' }, - width, - height, - margins: { left: 28, right: 28, top: 36, bottom: 36 }, - parallelCoordinates: { - normalization: { - mode: 'robust', - quantileClamp: [0.05, 0.95], - }, - }, - events: [PlotEvent.BRUSH_Y], - }), - }, - { - key: 'hist', - title: 'Histogram', - subtitle: 'Neighborhood area distribution · click bins to select', - originalPlot: hist, - events: [PlotEvent.CLICK], - trigger: document.querySelector('[data-chart-key="hist"] .plot-panel-header') as HTMLElement, - button: document.querySelector('[data-chart-key="hist"] .panel-expand-btn') as HTMLButtonElement | null, - createModalPlot: (container, width, height) => new Histogram({ - div: container, - data: geojson, - labels: { axis: ['shape_area', 'Count'], title: 'Area Distribution' }, - width, - height, - margins: { left: 55, right: 16, top: 36, bottom: 48 }, - events: [PlotEvent.CLICK], - }), - }, - ]); - - // ── Thematic dropdown (custom control tracked by provenance) ────────────── - function applyThematic(prop: string): void { - if (prop) { - map.updateGeoJsonLayerThematic( - 'neighborhoods', geojson, - (f) => +(f.properties?.[prop] ?? 0) - ); - map.updateRenderInfoProperty('neighborhoods', 'isColorMap', true); - } else { - map.updateRenderInfoProperty('neighborhoods', 'isColorMap', false); - } - } - - const thematicControl: CustomControlConfig = { - selector: '#thematicSelect', - event: 'change', - actionType: 'MAP_THEMATIC_PROPERTY', - getLabel: (el) => { - const v = (el as HTMLSelectElement).value; - return v ? `Color by: ${v}` : 'Thematic off'; - }, - getStateDelta: (el) => { - const v = (el as HTMLSelectElement).value; - return { filters: { thematicProperty: v || null }, ui: { thematicEnabled: !!v } }; - }, - applyState: (el, state) => { - const prop = (state.filters?.thematicProperty as string) || ''; - (el as HTMLSelectElement).value = prop; - applyThematic(prop); - }, - }; - - // ── Provenance ──────────────────────────────────────────────────────────── - const scatterProv = Object.assign(scatter, { plotId: 'Scatterplot', plotType: PlotType.SCATTERPLOT }); - const barProv = Object.assign(bar, { plotId: 'Bar Chart', plotType: PlotType.BARCHART }); - const pcProv = Object.assign(pc, { plotId: 'Parallel Coordinates', plotType: PlotType.PARALLEL_COORDINATES }); - const histProv = Object.assign(hist, { plotId: 'Histogram', plotType: PlotType.HISTOGRAM }); + overlay.remove(); - const provenance = createAutarkProvenance({ + renderInsightsWorkspace({ + container: root, map, - plots: [scatterProv, barProv, pcProv, histProv], + collection, + layerId: 'neighborhoods', db, - mapConfig: { customControls: [thematicControl] }, - }); - - // ── Provenance trail UI ─────────────────────────────────────────────────── - renderProvenanceTrailUI({ - provenance, container: document.getElementById('provenanceTrail')!, - insightsContainer: document.getElementById('provTrailInsights')!, - showTimestamps: true, showGraph: true, showPathList: true, showBackForward: true, - }); - - setupTabs(provenance); - - // ── Session insight cards ───────────────────────────────────────────────── - const annotateEl = document.querySelector('#insightsAnnotate .insights-card-body') as HTMLElement; - const summaryEl = document.querySelector('#insightsSummary .insights-card-body') as HTMLElement; - - function refreshInsights(): void { - renderAnnotateCard(annotateEl, provenance); - renderSummaryCard(summaryEl, provenance); - chartModal.syncSelection(); - } - provenance.addObserver(refreshInsights); - refreshInsights(); - - // ── Export / Import ─────────────────────────────────────────────────────── - document.getElementById('exportBtn')!.addEventListener('click', () => { - const url = URL.createObjectURL(new Blob([provenance.exportGraph()], { type: 'application/json' })); - Object.assign(document.createElement('a'), { href: url, download: `provenance-${Date.now()}.json` }).click(); - URL.revokeObjectURL(url); - }); - document.getElementById('importBtn')!.addEventListener('click', () => - (document.getElementById('importFile') as HTMLInputElement).click() - ); - (document.getElementById('importFile') as HTMLInputElement).addEventListener('change', (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => { if (typeof ev.target?.result === 'string') provenance.importGraph(ev.target.result); }; - reader.readAsText(file); + title: 'Autark Provenance', + description: 'Manhattan neighborhoods with coordinated charts, provenance navigation, and session insights.', }); } diff --git a/examples/src/autk-provenance/map-plot-provenance.html b/examples/src/autk-provenance/map-plot-provenance.html index 4b489205..597de1cc 100644 --- a/examples/src/autk-provenance/map-plot-provenance.html +++ b/examples/src/autk-provenance/map-plot-provenance.html @@ -2,71 +2,11 @@ - Autark - Provenance (Map + Plot) - + + Autark Map + Plot Provenance Workspace -
- - -
-
-
-

Map View

-
- -
-
- -
-

Scatterplot

-
-
-
-
-
-
-
- - -
- - -
-

Session Insights

-
-
-
Analysis Strategy
-
Waiting for interactions…
-
-
-
Annotate This Step
-
Waiting for interactions…
-
-
-
Selection Focus
-
Waiting for interactions…
-
-
-
Analysis Summary
-
Waiting for interactions…
-
-
-
-
+
+ - diff --git a/examples/src/autk-provenance/map-plot-provenance.ts b/examples/src/autk-provenance/map-plot-provenance.ts index 156a582d..c84af21c 100644 --- a/examples/src/autk-provenance/map-plot-provenance.ts +++ b/examples/src/autk-provenance/map-plot-provenance.ts @@ -1,345 +1,43 @@ -import { FeatureCollection } from 'geojson'; -import { SpatialDb } from 'autk-db'; -import { PlotEvent, Scatterplot } from 'autk-plot'; -import { AutkMap, MapEvent, VectorLayer } from 'autk-map'; -import { - createAutarkProvenance, - PlotType, - renderProvenanceTrailUI, - computeGraphMetrics, - computeSelectionFrequency, - getInsightAnnotations, - generateSessionNarrative, - type StrategyLabel, -} from 'autk-provenance'; +import type { FeatureCollection } from 'geojson'; +import { AutkSpatialDb } from 'autk-db'; +import { AutkMap } from 'autk-map'; +import { renderInsightsWorkspace } from 'autk-provenance'; -// --------------------------------------------------------------------------- -// Strategy colours (mirrors insight-engine classification) -// --------------------------------------------------------------------------- -const STRATEGY_COLORS: Record = { - Confirmatory: '#2e7d32', - Exploratory: '#1565c0', - 'Iterative Refinement': '#6a1b9a', -}; +async function main(): Promise { + const root = document.getElementById('app'); + if (!root) throw new Error('Missing #app container'); -const STRATEGY_DESC: Record = { - Confirmatory: 'Focused, linear exploration — you appear to know what you are looking for.', - Exploratory: 'Broad, open-ended investigation with multiple diverging paths.', - 'Iterative Refinement': 'Hypothesis-driven: repeated backtracking and revision of prior selections.', -}; + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(238,243,248,.92);display:flex;align-items:center;justify-content:center;font-size:15px;color:#1b3148;z-index:9999;font-family:system-ui,sans-serif;'; + overlay.textContent = 'Loading Manhattan neighborhood data…'; + document.body.appendChild(overlay); -// --------------------------------------------------------------------------- -// Insight card renderers -// --------------------------------------------------------------------------- - -function renderMetricsCard(el: HTMLElement, provenance: ReturnType): void { - const graph = provenance.getGraph(); - const m = computeGraphMetrics(graph); - - el.innerHTML = ''; - - const badge = document.createElement('span'); - badge.className = 'strategy-badge'; - badge.textContent = m.strategyLabel; - badge.style.background = STRATEGY_COLORS[m.strategyLabel]; - el.appendChild(badge); - - const desc = document.createElement('p'); - desc.className = 'strategy-desc'; - desc.textContent = STRATEGY_DESC[m.strategyLabel]; - el.appendChild(desc); - - const chips: [string, string][] = [ - ['States', String(m.totalNodes)], - ['Branch points', String(m.branchPoints)], - ['Backtracks', String(m.backtracks)], - ['Max depth', String(m.maxDepth)], - ['Insights noted', String(m.insightCount)], - ]; - - if (m.sessionDurationMs > 0) { - const s = Math.round(m.sessionDurationMs / 1000); - const dur = s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; - chips.unshift(['Duration', dur]); - } - - const row = document.createElement('div'); - row.className = 'metrics-row'; - for (const [label, value] of chips) { - const chip = document.createElement('div'); - chip.className = 'metric-chip'; - chip.innerHTML = `${value} ${label}`; - row.appendChild(chip); - } - el.appendChild(row); -} - -// Keep annotation textarea stable across re-renders — only recreate when node changes -let _annotationNodeId: string | null = null; -let _annotationTextarea: HTMLTextAreaElement | null = null; - -function renderAnnotateCard(el: HTMLElement, provenance: ReturnType): void { - const currentNode = provenance.getCurrentNode(); - - if (!currentNode) { - el.innerHTML = 'No active state.'; - _annotationNodeId = null; - _annotationTextarea = null; - return; - } - - // If same node, just sync the textarea value (don't wipe the DOM) - if (currentNode.id === _annotationNodeId && _annotationTextarea) { - if (!_annotationTextarea.matches(':focus')) { - const existing = currentNode.metadata?.insight; - _annotationTextarea.value = typeof existing === 'string' ? existing : ''; - } - return; - } - - // New node — rebuild the card - _annotationNodeId = currentNode.id; - el.innerHTML = ''; - - const label = document.createElement('p'); - label.className = 'annotation-label'; - label.textContent = `Current step: "${currentNode.actionLabel}"`; - el.appendChild(label); - - const textarea = document.createElement('textarea'); - textarea.className = 'annotation-textarea'; - textarea.placeholder = 'What did you notice or conclude at this step?'; - const existing = currentNode.metadata?.insight; - textarea.value = typeof existing === 'string' ? existing : ''; - el.appendChild(textarea); - _annotationTextarea = textarea; - - const saveBtn = document.createElement('button'); - saveBtn.className = 'annotation-save-btn'; - saveBtn.textContent = 'Save Insight'; - saveBtn.addEventListener('click', () => { - provenance.annotateNode(currentNode.id, textarea.value.trim()); - saveBtn.textContent = 'Saved!'; - setTimeout(() => { saveBtn.textContent = 'Save Insight'; }, 1500); - }); - el.appendChild(saveBtn); -} - -function renderFreqCard( - el: HTMLElement, - provenance: ReturnType, - featureName: (id: number) => string, -): void { - const graph = provenance.getGraph(); - const freq = computeSelectionFrequency(graph); - - const topMap = [...freq.map.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); - const topPlotsByPlot = [...freq.plots.entries()].map(([plotId, plotFreq]) => ({ - plotId, - top: [...plotFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6), - })).filter(({ top }) => top.length > 0); - const allTopPlot = topPlotsByPlot.flatMap(({ top }) => top); - - el.innerHTML = ''; - - if (topMap.length === 0 && allTopPlot.length === 0) { - const empty = document.createElement('span'); - empty.className = 'freq-empty'; - empty.textContent = 'No selections yet. Click features on the map or brush the scatterplot.'; - el.appendChild(empty); - return; - } - - const maxCount = Math.max(...[...topMap, ...allTopPlot].map(([, c]) => c), 1); - - const renderGroup = (label: string, items: [number, number][]): void => { - if (items.length === 0) return; - - const groupLabel = document.createElement('div'); - groupLabel.className = 'freq-group-label'; - groupLabel.textContent = label; - el.appendChild(groupLabel); - - for (const [id, count] of items) { - const row = document.createElement('div'); - row.className = 'freq-row'; - - const nameSpan = document.createElement('span'); - nameSpan.className = 'freq-id'; - nameSpan.textContent = featureName(id); - nameSpan.title = `Feature index #${id}`; - row.appendChild(nameSpan); - - const barWrap = document.createElement('div'); - barWrap.className = 'freq-bar-wrap'; - const fill = document.createElement('div'); - fill.className = 'freq-bar-fill'; - fill.style.width = `${Math.round((count / maxCount) * 100)}%`; - barWrap.appendChild(fill); - row.appendChild(barWrap); - - const countSpan = document.createElement('span'); - countSpan.className = 'freq-count'; - countSpan.textContent = `${count}×`; - row.appendChild(countSpan); - - el.appendChild(row); - } - }; - - renderGroup('Map features', topMap); - for (const { plotId, top } of topPlotsByPlot) { - renderGroup(`Plot: ${plotId}`, top); - } -} - -function renderSummaryCard(el: HTMLElement, provenance: ReturnType): void { - const graph = provenance.getGraph(); - const metrics = computeGraphMetrics(graph); - const annotations = getInsightAnnotations(graph); - const narrative = generateSessionNarrative(graph, metrics, annotations); - - el.innerHTML = ''; - - const pre = document.createElement('pre'); - pre.className = 'summary-pre'; - pre.textContent = narrative; - el.appendChild(pre); - - const copyBtn = document.createElement('button'); - copyBtn.className = 'copy-btn'; - copyBtn.textContent = 'Copy to clipboard'; - copyBtn.addEventListener('click', () => { - navigator.clipboard.writeText(narrative).then(() => { - copyBtn.textContent = 'Copied!'; - setTimeout(() => { copyBtn.textContent = 'Copy to clipboard'; }, 1800); - }); - }); - el.appendChild(copyBtn); -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main() { - const db = new SpatialDb(); + const db = new AutkSpatialDb(); await db.init(); await db.loadCustomLayer({ geojsonFileUrl: '/data/mnt_neighs_proj.geojson', outputTableName: 'neighborhoods', }); - const geojson = await db.getLayer('neighborhoods') as FeatureCollection; - - const canvas = document.querySelector('canvas') as HTMLCanvasElement; - const plotBody = document.querySelector('#plotBody') as HTMLElement; - const trailContainer = document.querySelector('#provenanceTrail') as HTMLElement; - - if (!canvas || !plotBody) { - console.error('Canvas or plot body not found'); - return; - } + const collection = await db.getLayer('neighborhoods') as FeatureCollection; + const canvas = document.createElement('canvas'); const map = new AutkMap(canvas); await map.init(); - map.loadGeoJsonLayer('neighborhoods', geojson); - map.updateRenderInfoProperty('neighborhoods', 'isPick', true); + map.loadCollection('neighborhoods', { collection }); + map.updateRenderInfo('neighborhoods', { renderInfo: { isPick: true } }); map.draw(); - const plotWidth = Math.max(320, Math.floor(plotBody.getBoundingClientRect().width)); - const plotHeight = Math.max(500, Math.floor(plotBody.getBoundingClientRect().height || 500)); - - const plot = new Scatterplot({ - div: plotBody, - data: geojson, - labels: { axis: ['shape_area', 'shape_leng'], title: 'Plot with provenance' }, - width: plotWidth, - height: plotHeight, - events: [PlotEvent.BRUSH], - }); - - map.mapEvents.addEventListener(MapEvent.PICK, (selection: number[]) => { - plot.setHighlightedIds(selection); - }); - plot.plotEvents.addEventListener(PlotEvent.BRUSH, (selection: number[]) => { - const layer = map.layerManager.searchByLayerId('neighborhoods') as VectorLayer | null; - if (!layer) return; - if (selection.length === 0) { - layer.clearHighlightedIds(); - } else { - layer.setHighlightedIds(selection); - } - }); - - const scatterplotForProvenance = Object.assign(plot, { - plotId: 'scatter-neighborhoods', - plotType: PlotType.SCATTERPLOT, - }); - const provenance = createAutarkProvenance({ map, plots: [scatterplotForProvenance], db }); - - // ── Export / Import ────────────────────────────────────────────────────── - const exportBtn = document.querySelector('#exportBtn') as HTMLButtonElement; - const importBtn = document.querySelector('#importBtn') as HTMLButtonElement; - const importFile = document.querySelector('#importFile') as HTMLInputElement; + overlay.remove(); - exportBtn.addEventListener('click', () => { - const json = provenance.exportGraph(); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `provenance-session-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); + renderInsightsWorkspace({ + container: root, + map, + collection, + layerId: 'neighborhoods', + db, + title: 'Autark Provenance', + description: 'Manhattan neighborhoods · Scatterplot · Bar Chart · Parallel Coordinates · Histogram — every interaction captured in one shared provenance graph', }); - - importBtn.addEventListener('click', () => importFile.click()); - importFile.addEventListener('change', () => { - const file = importFile.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (e) => { - const json = e.target?.result as string; - provenance.importGraph(json); - }; - reader.readAsText(file); - importFile.value = ''; - }); - - // ── Provenance trail sidebar ────────────────────────────────────────────── - if (trailContainer) { - renderProvenanceTrailUI({ - provenance, - container: trailContainer, - showBackForward: true, - showTimestamps: true, - showGraph: true, - showPathList: true, - }); - } - - // ── Session Insights section ────────────────────────────────────────────── - const cardMetrics = document.querySelector('#insightsMetrics .insights-card-body') as HTMLElement; - const cardAnnotate = document.querySelector('#insightsAnnotate .insights-card-body') as HTMLElement; - const cardFreq = document.querySelector('#insightsFreq .insights-card-body') as HTMLElement; - const cardSummary = document.querySelector('#insightsSummary .insights-card-body') as HTMLElement; - - // Build a lookup from feature index → neighborhood name (ntaname property) - const featureName = (id: number): string => { - const feat = geojson.features[id]; - const name = feat?.properties?.['ntaname'] as string | undefined; - return name ?? `#${id}`; - }; - - function refreshInsights(): void { - if (cardMetrics) renderMetricsCard(cardMetrics, provenance); - if (cardAnnotate) renderAnnotateCard(cardAnnotate, provenance); - if (cardFreq) renderFreqCard(cardFreq, provenance, featureName); - if (cardSummary) renderSummaryCard(cardSummary, provenance); - } - - provenance.addObserver(() => refreshInsights()); - refreshInsights(); } main(); diff --git a/gallery/package.json b/gallery/package.json index a5f66cb8..47eef232 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "node ./node_modules/vite/bin/vite.js", - "build": "tsc && node ./node_modules/vite/bin/vite.js build", - "preview": "node ./node_modules/vite/bin/vite.js preview", + "dev": "ulimit -n 65536 && vite", + "build": "ulimit -n 65536 && tsc && vite build", + "preview": "ulimit -n 65536 && vite preview", "format": "prettier --write ." }, "devDependencies": { diff --git a/gallery/src/autk-provenance/all-plots-provenance.css b/gallery/src/autk-provenance/all-plots-provenance.css new file mode 100644 index 00000000..c4614ce7 --- /dev/null +++ b/gallery/src/autk-provenance/all-plots-provenance.css @@ -0,0 +1,673 @@ +:root { + --bg: #eef3f8; + --surface: #ffffff; + --surface-muted: #f6f9fc; + --border: #d4dde7; + --text: #1b3148; + --text-muted: #546b80; + --accent: #2f5f96; + --radius: 12px; + --shadow: 0 8px 24px rgba(28,53,76,.09); + --viz-height: 640px; + } + + * { box-sizing: border-box; } + + body { + margin: 0; + min-height: 100vh; + padding: 16px; + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + color: var(--text); + background: radial-gradient(circle at top left, #f4f8ff, var(--bg) 60%); + } + + #app { + max-width: 1700px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 14px; + } + + /* ── Header ──────────────────────────────────────────────────────────────── */ + + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + } + + .header-text h1 { margin: 0; font-size: 22px; line-height: 1.2; } + .header-text p { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; } + + .header-actions { display: flex; gap: 8px; flex-shrink: 0; } + + .hdr-btn { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + } + .hdr-btn:hover { background: #edf5ff; border-color: var(--accent); color: var(--accent); } + + /* ── Main layout: two equal columns ─────────────────────────────────────── */ + + #layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + align-items: stretch; + } + + /* ── Left column: map ────────────────────────────────────────────────────── */ + + #mapColumn { + display: flex; + flex-direction: column; + } + + .map-panel { + height: var(--viz-height); + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; + box-shadow: var(--shadow); + } + + .map-body { + flex: 1; + min-height: 0; + display: flex; + justify-content: center; + align-items: stretch; + overflow: hidden; + } + + canvas { + display: block; + height: 100%; + width: auto; + max-width: 100%; + background: #c8dae6; + } + + .map-header-controls { + display: flex; + align-items: center; + gap: 6px; + } + + .ctrl-label { font-size: 11px; color: var(--text-muted); } + + .thematic-select { + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 12px; + cursor: pointer; + } + .thematic-select:focus { outline: none; border-color: var(--accent); } + + /* ── Right column: tabbed panel ──────────────────────────────────────────── */ + + #rightColumn { + height: var(--viz-height); + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; + box-shadow: var(--shadow); + } + + /* Tab bar */ + .tab-bar { + display: flex; + border-bottom: 2px solid var(--border); + background: var(--surface-muted); + flex-shrink: 0; + } + + .tab-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 10px 12px; + border: none; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + background: transparent; + cursor: pointer; + font-family: inherit; + color: var(--text-muted); + transition: background .15s; + } + .tab-btn:hover:not(.active) { background: #f0f5fb; } + .tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: var(--surface); + } + + .tab-label { font-size: 13px; font-weight: 600; } + .tab-hint { font-size: 11px; opacity: .75; } + + /* Tab content */ + .tab-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + .tab-content.hidden { display: none; } + + /* ── Charts tab: 2×2 plot grid ───────────────────────────────────────────── */ + + #plotGrid { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 10px; + padding: 10px; + } + + .plot-panel { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + overflow: hidden; + min-height: 0; + } + + .plot-panel:hover { + border-color: #bfd0e3; + box-shadow: 0 10px 26px rgba(30, 55, 80, .08); + } + + .plot-panel .plot-panel-header { + cursor: zoom-in; + gap: 12px; + } + + .plot-panel .panel-header-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .plot-panel .panel-header-main .panel-hint { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .panel-expand-btn { + flex-shrink: 0; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--accent); + font-family: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; + } + + .panel-expand-btn:hover, + .panel-expand-btn:focus-visible { + background: #edf5ff; + border-color: var(--accent); + outline: none; + } + + .plot-body { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; + } + + #scatterBody, #barBody, #pcBody, #histBody { + position: absolute; + inset: 0; + } + + svg { display: block; overflow: visible; } + + .chart-modal-backdrop { + position: fixed; + inset: 0; + z-index: 12000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(14, 24, 36, .56); + } + + .chart-modal { + width: min(1320px, 96vw); + height: min(900px, 92vh); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(212, 221, 231, .85); + background: linear-gradient(180deg, #f9fcff, #f3f8fd); + box-shadow: 0 32px 90px rgba(13, 26, 40, .45); + } + + .chart-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, .92); + } + + .chart-modal-heading { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + } + + .chart-modal-title { + font-size: 15px; + font-weight: 700; + color: var(--text); + } + + .chart-modal-subtitle { + font-size: 12px; + color: var(--text-muted); + } + + .chart-modal-controls { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .chart-modal-btn { + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 12px; + cursor: pointer; + } + + .chart-modal-btn:hover, + .chart-modal-btn:focus-visible { + background: #edf5ff; + border-color: var(--accent); + outline: none; + } + + .chart-modal-body { + display: flex; + flex: 1; + min-height: 0; + padding: 14px; + } + + .chart-modal-canvas { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: + linear-gradient(90deg, rgba(207, 220, 232, .35) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(rgba(207, 220, 232, .35) 1px, transparent 1px) 0 0 / 32px 32px, + #ffffff; + } + + .chart-modal-stage { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + will-change: transform; + } + + .chart-modal-stage svg { + display: block; + overflow: visible; + } + + .chart-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px 14px; + color: var(--text-muted); + font-size: 12px; + } + + .chart-modal-hint { + min-width: 0; + } + + .chart-modal-scale { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + } + + /* ── Shared panel header ─────────────────────────────────────────────────── */ + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 12px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + flex-shrink: 0; + } + .panel-title { font-size: 13px; font-weight: 600; } + .panel-hint { font-size: 11px; color: var(--text-muted); } + + /* ── Provenance tab ──────────────────────────────────────────────────────── */ + + #provenanceTab { overflow: hidden; } + + /* Inner subtab bar */ + .prov-subtab-bar { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); + } + + .prov-subtab { + flex: 1; + padding: 8px 12px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + font-family: inherit; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + transition: background .15s; + } + .prov-subtab:hover:not(.active) { background: #f0f5fb; } + .prov-subtab.active { + color: var(--accent); + border-bottom-color: var(--accent); + background: var(--surface); + } + + /* Inner subtab content panes */ + .prov-subtab-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + .prov-subtab-content.hidden { display: none; } + + #provenanceGraphPanel { + overflow: hidden; + } + + #provenanceTrail { + height: 100%; + padding: 10px; + box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: 0; + } + + #provenanceTrail.autk-provenance-root { gap: 8px; min-height: 0; } + #provenanceTrail .autk-provenance-graph-wrap { height: 260px; max-height: 320px; } + #provenanceTrail .autk-provenance-path { max-height: 160px; flex-shrink: 0; } + #provenanceTrail .autk-prov-insights-wrap { display: none; } + + /* ── Provenance insight charts subtab ────────────────────────────────────── */ + + #provenanceInsightsPanel { + overflow-y: auto; + } + + #provTrailInsights { + min-height: 100%; + } + + /* Strip the inner wrap's own border/background so it merges with the pane */ + #provTrailInsights .autk-prov-insights-wrap { + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + max-height: none; + overflow-y: visible; + } + #provTrailInsights .autk-prov-insights-body { + max-height: none; + overflow-y: visible; + } + + /* ── Insights section ────────────────────────────────────────────────────── */ + + .insights-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; + box-shadow: var(--shadow); + } + + .insights-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + } + + .insights-card { + padding: 14px 16px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 10px; + min-height: 200px; + } + .insights-card:last-child { border-right: none; } + + .insights-card-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); + } + + .insights-card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + } + + .insights-loading { + font-size: 12px; + color: var(--text-muted); + justify-content: center; + align-items: center; + text-align: center; + } + + /* ── Insight card content ────────────────────────────────────────────────── */ + + .strategy-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 700; + color: #fff; + align-self: flex-start; + } + + .strategy-desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; } + + .metrics-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } + .metric-chip { + padding: 3px 8px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 11px; + } + + .annotation-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .annotation-textarea { + width: 100%; + padding: 7px 8px; + border: 1px solid var(--border); + border-radius: 7px; + font-family: inherit; + font-size: 12px; + color: var(--text); + background: var(--surface); + resize: vertical; + box-sizing: border-box; + flex: 1; + min-height: 70px; + } + .annotation-textarea:focus { outline: none; border-color: var(--accent); } + + .annotation-save-btn { + align-self: flex-start; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + font-size: 12px; + font-family: inherit; + color: var(--text); + cursor: pointer; + } + .annotation-save-btn:hover:not(:disabled) { background: #edf5ff; border-color: var(--accent); color: var(--accent); } + .annotation-save-btn:disabled { opacity: .45; cursor: not-allowed; } + + .freq-empty { font-size: 12px; color: var(--text-muted); } + .freq-group-label { font-size: 11px; font-weight: 600; color: var(--text); margin-top: 4px; } + .freq-row { display: flex; align-items: center; gap: 6px; min-height: 18px; } + .freq-id { font-size: 11px; color: var(--text-muted); width: 110px; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .freq-bar-wrap { flex: 1; height: 7px; background: #e7eef6; border-radius: 4px; overflow: hidden; } + .freq-bar-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width .3s; } + .freq-count { font-size: 11px; width: 24px; text-align: right; flex-shrink: 0; } + + .summary-pre { + flex: 1; + margin: 0; + padding: 8px 10px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 7px; + font-size: 11px; + font-family: inherit; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6; + overflow-y: auto; + max-height: 140px; + } + + .copy-btn { + align-self: flex-start; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + font-size: 12px; + font-family: inherit; + color: var(--text); + cursor: pointer; + } + .copy-btn:hover { background: #edf5ff; } + + /* ── D3 chart chrome ─────────────────────────────────────────────────────── */ + + .tick line { stroke: #dde4ec; stroke-width: 1; } + .domain { stroke: #cfd8e3; } + .title { font-size: 12px; fill: var(--text); font-weight: 600; } + + /* ── Responsive ──────────────────────────────────────────────────────────── */ + + @media (max-width: 1200px) { + #layout { grid-template-columns: 1fr; } + :root { --viz-height: 480px; } + } + + @media (max-width: 1200px) { + .insights-grid { grid-template-columns: repeat(2, 1fr); } + .insights-card:nth-child(2) { border-right: none; } + .insights-card:nth-child(1), + .insights-card:nth-child(2) { border-bottom: 1px solid var(--border); } + } + + @media (max-width: 700px) { + body { padding: 8px; } + #plotGrid { grid-template-columns: 1fr; grid-template-rows: repeat(4, 280px); } + .insights-grid { grid-template-columns: 1fr; } + .insights-card { border-right: none; border-bottom: 1px solid var(--border); } + .insights-card:last-child { border-bottom: none; } + .chart-modal { width: 96vw; height: 92vh; } + .chart-modal-header, + .chart-modal-footer { flex-direction: column; align-items: flex-start; } + .chart-modal-controls { justify-content: flex-start; } + } \ No newline at end of file diff --git a/gallery/src/autk-provenance/all-plots-provenance.html b/gallery/src/autk-provenance/all-plots-provenance.html new file mode 100644 index 00000000..5eda5e23 --- /dev/null +++ b/gallery/src/autk-provenance/all-plots-provenance.html @@ -0,0 +1,12 @@ + + + + + + Autark Provenance Insights Workspace + + +
+ + + diff --git a/gallery/src/autk-provenance/all-plots-provenance.ts b/gallery/src/autk-provenance/all-plots-provenance.ts new file mode 100644 index 00000000..6958b66f --- /dev/null +++ b/gallery/src/autk-provenance/all-plots-provenance.ts @@ -0,0 +1,43 @@ +import type { FeatureCollection } from 'geojson'; +import { AutkSpatialDb } from 'autk-db'; +import { AutkMap } from 'autk-map'; +import { renderInsightsWorkspace } from 'autk-provenance'; + +async function main(): Promise { + const root = document.getElementById('app'); + if (!root) throw new Error('Missing #app container'); + + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(238,243,248,.92);display:flex;align-items:center;justify-content:center;font-size:15px;color:#1b3148;z-index:9999;font-family:system-ui,sans-serif;'; + overlay.textContent = 'Loading Manhattan neighborhood data…'; + document.body.appendChild(overlay); + + const db = new AutkSpatialDb(); + await db.init(); + await db.loadCustomLayer({ + geojsonFileUrl: '/data/mnt_neighs_proj.geojson', + outputTableName: 'neighborhoods', + }); + const collection = await db.getLayer('neighborhoods') as FeatureCollection; + + const canvas = document.createElement('canvas'); + const map = new AutkMap(canvas); + await map.init(); + map.loadCollection('neighborhoods', { collection }); + map.updateRenderInfo('neighborhoods', { renderInfo: { isPick: true } }); + map.draw(); + + overlay.remove(); + + renderInsightsWorkspace({ + container: root, + map, + collection, + layerId: 'neighborhoods', + db, + title: 'Autark Provenance', + description: 'Manhattan neighborhoods with coordinated charts, provenance navigation, and session insights.', + }); +} + +main(); diff --git a/gallery/src/autk-provenance/map-plot-provenance.css b/gallery/src/autk-provenance/map-plot-provenance.css new file mode 100644 index 00000000..22b2a930 --- /dev/null +++ b/gallery/src/autk-provenance/map-plot-provenance.css @@ -0,0 +1,518 @@ +:root { + --bg: #eef3f8; + --surface: #ffffff; + --surface-muted: #f6f9fc; + --border: #d4dde7; + --text: #1b3148; + --text-muted: #546b80; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + min-height: 100vh; + padding: 20px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: var(--text); + background: radial-gradient(circle at top left, #f8fbff, var(--bg) 62%); + } + + #app { + max-width: 1500px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; + } + + .page-header { + border: 1px solid var(--border); + border-radius: 14px; + padding: 18px 20px; + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + box-shadow: 0 10px 26px rgba(28, 53, 76, 0.08); + } + + .page-header h1 { + margin: 0; + font-size: 28px; + line-height: 1.2; + } + + .page-header p { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 14px; + } + + #layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 420px; + gap: 16px; + align-items: start; + } + + #analysisColumn { + min-width: 0; + display: grid; + grid-template-rows: minmax(420px, 56vh) minmax(560px, 44vh); + gap: 16px; + } + + .panel { + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + overflow: hidden; + box-shadow: 0 10px 26px rgba(28, 53, 76, 0.08); + } + + .panel h2 { + margin: 0; + padding: 12px 14px; + font-size: 16px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + } + + .panel-body { + flex: 1; + padding: 12px; + min-height: 0; + } + + .map-panel .panel-body { + padding: 0; + } + + .plot-panel .panel-body { + display: flex; + overflow: hidden; + } + + canvas { + display: block; + width: 100%; + height: 100%; + background: #c8dae6; + } + + #plot { + display: flex; + flex: 1; + position: relative; + z-index: 1; + width: 100%; + height: 100%; + min-height: 540px; + border: 1px solid #d8e1eb; + border-radius: 10px; + background: #fff; + overflow: hidden; + } + + #plotBody { + flex: 1; + width: 100%; + height: 100%; + min-height: 0; + } + + svg { + width: 100%; + height: 100%; + border: none !important; + background: #fff; + } + + #sidebar { + position: sticky; + top: 20px; + height: calc(100vh - 40px); + max-height: 920px; + } + + .session-actions { + display: flex; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); + } + + .session-actions button { + flex: 1; + padding: 6px 10px; + font-size: 13px; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + cursor: pointer; + } + + .session-actions button:hover { + background: var(--bg); + } + + #sidebar .panel-body { + flex: 1; + min-height: 0; + padding: 10px; + overflow: hidden; + } + + #provenanceTrail { + height: 100%; + display: flex; + flex-direction: column; + } + + #provenanceTrail.autk-provenance-root { + min-height: 0; + gap: 8px; + } + + #provenanceTrail .autk-provenance-graph-wrap { + height: 340px; + max-height: 420px; + } + + #provenanceTrail .autk-provenance-path { + flex: 0 0 auto; + max-height: 160px; + overflow-y: auto; + } + + #provenanceTrail .autk-prov-insights-wrap { + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; + } + + /* ── Session Insights Section ─────────────────────────────────────────────── */ + + .insights-panel { + margin-top: 0; + } + + .insights-panel h2 { + font-size: 16px; + padding: 12px 16px; + margin: 0; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface), var(--surface-muted)); + } + + .insights-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0; + } + + .insights-card { + padding: 16px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 12px; + min-height: 220px; + } + + .insights-card:last-child { + border-right: none; + } + + .insights-card-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); + } + + .insights-card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + } + + .insights-loading { + font-size: 12px; + color: var(--text-muted); + justify-content: center; + align-items: center; + } + + /* Card 1 – Strategy */ + .strategy-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 700; + color: #fff; + align-self: flex-start; + } + + .strategy-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; + } + + .metrics-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 4px; + } + + .metric-chip { + padding: 4px 10px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 11px; + color: var(--text); + } + + .metric-chip strong { + color: var(--text); + } + + /* Card 2 – Annotate */ + .annotation-label { + font-size: 12px; + color: var(--text-muted); + } + + .annotation-textarea { + width: 100%; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + font-family: inherit; + font-size: 12px; + color: var(--text); + background: var(--surface); + resize: vertical; + box-sizing: border-box; + flex: 1; + min-height: 80px; + } + + .annotation-textarea:focus { + outline: none; + border-color: #2f5f96; + } + + .annotation-save-btn { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + font-size: 12px; + font-family: inherit; + color: var(--text); + cursor: pointer; + align-self: flex-start; + } + + .annotation-save-btn:hover:not(:disabled) { + background: #edf5ff; + border-color: #2f5f96; + color: #2f5f96; + } + + .annotation-save-btn:disabled { + opacity: .45; + cursor: not-allowed; + } + + /* Card 3 – Selection frequency */ + .freq-empty { + font-size: 12px; + color: var(--text-muted); + } + + .freq-group-label { + font-size: 11px; + font-weight: 600; + color: var(--text); + margin-top: 4px; + } + + .freq-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 20px; + } + + .freq-id { + font-size: 11px; + color: var(--text-muted); + width: 140px; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .freq-bar-wrap { + flex: 1; + height: 8px; + background: #e7eef6; + border-radius: 4px; + overflow: hidden; + } + + .freq-bar-fill { + height: 100%; + background: #2f5f96; + border-radius: 4px; + } + + .freq-count { + font-size: 11px; + color: var(--text); + width: 24px; + text-align: right; + flex-shrink: 0; + } + + /* Card 4 – Summary */ + .summary-pre { + flex: 1; + margin: 0; + padding: 10px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 11px; + font-family: inherit; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6; + overflow-y: auto; + max-height: 160px; + } + + .copy-btn { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + font-size: 12px; + font-family: inherit; + color: var(--text); + cursor: pointer; + align-self: flex-start; + } + + .copy-btn:hover { + background: #edf5ff; + } + + @media (max-width: 1260px) { + .insights-grid { + grid-template-columns: repeat(2, 1fr); + } + .insights-card:nth-child(2) { + border-right: none; + } + .insights-card:nth-child(1), + .insights-card:nth-child(2) { + border-bottom: 1px solid var(--border); + } + } + + @media (max-width: 700px) { + .insights-grid { + grid-template-columns: 1fr; + } + .insights-card { + border-right: none; + border-bottom: 1px solid var(--border); + } + .insights-card:last-child { + border-bottom: none; + } + } + + .tick line { + stroke-width: 1px; + stroke: #d7dde6; + } + + .title { + font-size: 14px; + fill: #24374a; + font-weight: 600; + } + + @media (max-width: 1260px) { + #layout { + grid-template-columns: 1fr; + } + + #analysisColumn { + grid-template-rows: minmax(420px, 54vh) 600px; + } + + #sidebar { + position: static; + height: 420px; + max-height: none; + } + + #sidebar .panel-body { + height: calc(100% - 46px); + } + } + + @media (max-width: 700px) { + body { + padding: 12px; + } + + .page-header { + padding: 14px; + } + + .page-header h1 { + font-size: 22px; + } + + .page-header p { + font-size: 13px; + } + + .panel h2 { + padding: 10px 12px; + font-size: 14px; + } + + .panel-body { + padding: 10px; + } + + #analysisColumn { + grid-template-rows: 420px 560px; + } + + #sidebar { + height: 360px; + } + } \ No newline at end of file diff --git a/gallery/src/autk-provenance/map-plot-provenance.html b/gallery/src/autk-provenance/map-plot-provenance.html new file mode 100644 index 00000000..597de1cc --- /dev/null +++ b/gallery/src/autk-provenance/map-plot-provenance.html @@ -0,0 +1,12 @@ + + + + + + Autark Map + Plot Provenance Workspace + + +
+ + + diff --git a/gallery/src/autk-provenance/map-plot-provenance.ts b/gallery/src/autk-provenance/map-plot-provenance.ts new file mode 100644 index 00000000..c84af21c --- /dev/null +++ b/gallery/src/autk-provenance/map-plot-provenance.ts @@ -0,0 +1,43 @@ +import type { FeatureCollection } from 'geojson'; +import { AutkSpatialDb } from 'autk-db'; +import { AutkMap } from 'autk-map'; +import { renderInsightsWorkspace } from 'autk-provenance'; + +async function main(): Promise { + const root = document.getElementById('app'); + if (!root) throw new Error('Missing #app container'); + + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(238,243,248,.92);display:flex;align-items:center;justify-content:center;font-size:15px;color:#1b3148;z-index:9999;font-family:system-ui,sans-serif;'; + overlay.textContent = 'Loading Manhattan neighborhood data…'; + document.body.appendChild(overlay); + + const db = new AutkSpatialDb(); + await db.init(); + await db.loadCustomLayer({ + geojsonFileUrl: '/data/mnt_neighs_proj.geojson', + outputTableName: 'neighborhoods', + }); + const collection = await db.getLayer('neighborhoods') as FeatureCollection; + + const canvas = document.createElement('canvas'); + const map = new AutkMap(canvas); + await map.init(); + map.loadCollection('neighborhoods', { collection }); + map.updateRenderInfo('neighborhoods', { renderInfo: { isPick: true } }); + map.draw(); + + overlay.remove(); + + renderInsightsWorkspace({ + container: root, + map, + collection, + layerId: 'neighborhoods', + db, + title: 'Autark Provenance', + description: 'Manhattan neighborhoods · Scatterplot · Bar Chart · Parallel Coordinates · Histogram — every interaction captured in one shared provenance graph', + }); +} + +main(); diff --git a/gallery/vite.config.ts b/gallery/vite.config.ts index 61771f75..59c4ffa4 100644 --- a/gallery/vite.config.ts +++ b/gallery/vite.config.ts @@ -14,9 +14,9 @@ export function pluginWatchNodeModules(modules: string[]) { } export default defineConfig({ - plugins: [pluginWatchNodeModules(['autk-core', 'autk-map', 'autk-db', 'autk-plot', 'autk-compute'])], + plugins: [pluginWatchNodeModules(['autk-core', 'autk-map', 'autk-db', 'autk-plot', 'autk-compute', 'autk-provenance'])], optimizeDeps: { - exclude: ['autk-core', 'autk-map', 'autk-db', 'autk-plot', 'autk-compute'], + exclude: ['autk-core', 'autk-map', 'autk-db', 'autk-plot', 'autk-compute', 'autk-provenance'], }, server: { diff --git a/package.json b/package.json index 548fe4dd..48bc138f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "autk-map", "autk-compute", "autk-plot", + "autk-provenance", "gallery", "usecases" ], From 21ce0da6f63ed7cc7d781ec66aed667593b3df0b Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Wed, 6 May 2026 14:01:25 -0500 Subject: [PATCH 16/21] fixes: provenance for layers --- Makefile | 4 +- autk-core/src/camera.ts | 14 ++ autk-map/src/events-mouse.ts | 3 + autk-map/src/map-ui.ts | 8 + autk-map/src/map.ts | 53 ++++++ .../src/adapters/compute-adapter.ts | 2 +- .../src/adapters/map-adapter-shared.ts | 2 +- autk-provenance/src/adapters/map/dom.ts | 1 + autk-provenance/src/adapters/map/recording.ts | 172 +++++++++++++++++- autk-provenance/src/adapters/map/utils.ts | 3 +- autk-provenance/src/adapters/plot-adapter.ts | 4 +- autk-provenance/src/charts/chart-modal.ts | 2 +- autk-provenance/src/insights-workspace.ts | 67 +++++-- autk-provenance/src/insights/narrative.ts | 4 +- autk-provenance/src/provenance-trail-ui.ts | 9 +- autk-provenance/src/types.ts | 5 + autk-provenance/src/ui/styles.ts | 2 +- .../src/ui/workspace-session-insights.ts | 124 ++----------- autk-provenance/src/ui/workspace-shell.ts | 5 +- autk-provenance/src/ui/workspace-styles.ts | 38 ++-- .../autk-provenance/map-plot-provenance.css | 8 +- .../autk-provenance/map-plot-provenance.ts | 2 +- .../autk-provenance/map-plot-provenance.css | 10 +- .../autk-provenance/map-plot-provenance.ts | 2 +- 24 files changed, 358 insertions(+), 186 deletions(-) diff --git a/Makefile b/Makefile index 21b3ed26..4b96d2bd 100644 --- a/Makefile +++ b/Makefile @@ -31,16 +31,16 @@ build: "cd autk-map && npm run build" \ "cd autk-db && npm run build" \ "cd autk-plot && npm run build" \ - "cd autk-provenance && npm run build" \ "cd autk-compute && npm run build" + cd autk-provenance && npm run build build-all: $(CONCURRENTLY) \ "cd autk-map && npm run build" \ "cd autk-db && npm run build" \ "cd autk-plot && npm run build" \ - "cd autk-provenance && npm run build" \ "cd autk-compute && npm run build" + cd autk-provenance && npm run build docs: $(CONCURRENTLY) \ diff --git a/autk-core/src/camera.ts b/autk-core/src/camera.ts index 543121fc..cc85f5dc 100644 --- a/autk-core/src/camera.ts +++ b/autk-core/src/camera.ts @@ -146,6 +146,20 @@ export class Camera { return this.mViewMatrix; } + /** + * Returns a serialisable snapshot of the current camera state. + * + * @returns Plain arrays for `up`, `lookAt`, and `eye`. + * @throws Never throws. + */ + public getCameraData(): CameraData { + return { + up: [this.wUp[0], this.wUp[1], this.wUp[2]], + eye: [this.wEye[0], this.wEye[1], this.wEye[2]], + lookAt: [this.wLookAt[0], this.wLookAt[1], this.wLookAt[2]], + }; + } + /** * Updates viewport size and recomputes matrices in one call. * diff --git a/autk-map/src/events-mouse.ts b/autk-map/src/events-mouse.ts index 0c522e13..f06aea72 100644 --- a/autk-map/src/events-mouse.ts +++ b/autk-map/src/events-mouse.ts @@ -179,6 +179,8 @@ export class MouseEvents { this._map.camera.translate(dx / cssWidth, dy / cssHeight); } + this._map.notifyViewChange(); + this._lastPoint = point; } @@ -216,6 +218,7 @@ export class MouseEvents { const y = 1.0 - (event.clientY - rect.top) / this._map.renderer.cssHeight; this._map.camera.zoom(event.deltaY * 0.01, x, y); + this._map.notifyViewChange(); } /** diff --git a/autk-map/src/map-ui.ts b/autk-map/src/map-ui.ts index f7c64e60..da57c227 100644 --- a/autk-map/src/map-ui.ts +++ b/autk-map/src/map-ui.ts @@ -543,10 +543,14 @@ export class AutkMapUi { const eyeBtn = this.makeIconButton(EYE_SVG, !layer.layerRenderInfo.isSkip, () => { this.map.updateRenderInfo(layer.layerInfo.id, { renderInfo: { isSkip: !layer.layerRenderInfo.isSkip } }); }); + eyeBtn.dataset.autkMapControl = 'visibility'; + eyeBtn.dataset.layerId = layer.layerInfo.id; const paletteBtn = this.makeIconButton(RAMP_SVG, layer.layerRenderInfo.isColorMap ?? false, () => { this.map.updateRenderInfo(layer.layerInfo.id, { renderInfo: { isColorMap: !layer.layerRenderInfo.isColorMap } }); }); + paletteBtn.dataset.autkMapControl = 'thematic'; + paletteBtn.dataset.layerId = layer.layerInfo.id; const isRaster = layer.layerInfo.typeLayer === 'raster'; const cursorBtn = isRaster @@ -554,6 +558,10 @@ export class AutkMapUi { : this.makeIconButton(CURSOR_SVG, layer.layerRenderInfo.isPick ?? false, () => { this.changeActiveLayer(this.map.layerManager.searchByLayerId(layer.layerInfo.id)); }); + if (cursorBtn instanceof HTMLButtonElement) { + cursorBtn.dataset.autkMapControl = 'active-layer'; + cursorBtn.dataset.layerId = layer.layerInfo.id; + } const nameEl = document.createElement('span'); nameEl.textContent = layer.layerInfo.id; diff --git a/autk-map/src/map.ts b/autk-map/src/map.ts index f154b428..faf552b3 100644 --- a/autk-map/src/map.ts +++ b/autk-map/src/map.ts @@ -74,6 +74,8 @@ import { PipelineBuildingSSAO } from './pipeline-triangle-ssao'; import { AutkMapUi } from './map-ui'; +type ViewListener = (state: { eye: [number, number, number]; lookAt: [number, number, number]; up: [number, number, number] }) => void; + /** * Main map controller for rendering, interaction, and layer lifecycle. * @@ -105,6 +107,8 @@ export class AutkMap { protected _resizeEvents!: ResizeEvents; /** Public event bus for map events. */ protected _mapEvents!: EventEmitter; + /** Subscribers notified when the camera view changes due to interaction or replay. */ + protected _viewListeners: Set = new Set(); /** Map UI controller. */ protected _ui!: AutkMapUi; @@ -166,6 +170,47 @@ export class AutkMap { return this._mapEvents; } + /** + * Registers a callback for camera view changes. + * + * @param callback Listener receiving a serialisable camera snapshot. + */ + addViewListener(callback: ViewListener): void { + this._viewListeners.add(callback); + } + + /** + * Removes a previously registered camera view listener. + * + * @param callback Listener to remove. + */ + removeViewListener(callback: ViewListener): void { + this._viewListeners.delete(callback); + } + + /** + * Returns the current serialisable camera state. + */ + getViewState(): { eye: [number, number, number]; lookAt: [number, number, number]; up: [number, number, number] } { + const state = this._camera.getCameraData(); + return { + eye: [state.eye[0], state.eye[1], state.eye[2]], + lookAt: [state.lookAt[0], state.lookAt[1], state.lookAt[2]], + up: [state.up[0], state.up[1], state.up[2]], + }; + } + + /** + * Restores the camera to a serialised view state and notifies listeners. + * + * @param state View snapshot to restore. + */ + setViewState(state: { eye: [number, number, number]; lookAt: [number, number, number]; up: [number, number, number] }): void { + this._camera.resetCamera(state.up, state.lookAt, state.eye); + this._camera.update(); + this.notifyViewChange(); + } + /** Currently active pick-enabled layer, if any. */ get activePickingLayer(): Layer | null { return this._layerManager.layers.find((layer) => layer.layerRenderInfo.isPick) ?? null; @@ -197,6 +242,14 @@ export class AutkMap { this._ui.buildUi(); } + /** + * Notifies registered listeners of the latest camera view. + */ + notifyViewChange(): void { + const snapshot = this.getViewState(); + this._viewListeners.forEach((listener) => listener(snapshot)); + } + /** * Loads a GeoJSON feature collection as a map layer. * diff --git a/autk-provenance/src/adapters/compute-adapter.ts b/autk-provenance/src/adapters/compute-adapter.ts index 6048e811..a0e0d8e7 100644 --- a/autk-provenance/src/adapters/compute-adapter.ts +++ b/autk-provenance/src/adapters/compute-adapter.ts @@ -9,7 +9,7 @@ export type ComputeRecordCallback = ( /** * Minimal interface for a compute object compatible with autk-compute's GeojsonCompute. - * Only the methods that exist on the instance are wrapped — missing methods are ignored. + * Only the methods that exist on the instance are wrapped - missing methods are ignored. */ export interface IComputeForProvenance { computeFunctionIntoProperties?(...args: unknown[]): Promise; diff --git a/autk-provenance/src/adapters/map-adapter-shared.ts b/autk-provenance/src/adapters/map-adapter-shared.ts index 198af959..fa4c52dc 100644 --- a/autk-provenance/src/adapters/map-adapter-shared.ts +++ b/autk-provenance/src/adapters/map-adapter-shared.ts @@ -20,7 +20,7 @@ export function resolveMapSelectors(selectorConfig?: MapSelectorConfig): { } { return { selectors: { - menuIcon: selectorConfig?.menuIcon ?? '#menuIcon', + menuIcon: selectorConfig?.menuIcon ?? '#autkMapUi', subMenu: selectorConfig?.subMenu ?? '#autkMapSubMenu', thematicCheckbox: selectorConfig?.thematicCheckbox ?? '#showThematicCheckbox', legend: selectorConfig?.legend ?? '#autkMapLegend', diff --git a/autk-provenance/src/adapters/map/dom.ts b/autk-provenance/src/adapters/map/dom.ts index a39b9424..3a1a9417 100644 --- a/autk-provenance/src/adapters/map/dom.ts +++ b/autk-provenance/src/adapters/map/dom.ts @@ -11,6 +11,7 @@ export function syncUiDom( const submenu = parent.querySelector(selectors.subMenu) as HTMLElement | null; if (submenu) submenu.style.visibility = ui.mapMenuOpen ? 'visible' : 'hidden'; + if (ui.mapMenuOpen) map.ui?.refreshLayerList?.(); const thematic = parent.querySelector(selectors.thematicCheckbox) as HTMLInputElement | null; if (thematic) thematic.checked = ui.thematicEnabled; diff --git a/autk-provenance/src/adapters/map/recording.ts b/autk-provenance/src/adapters/map/recording.ts index 590d96c3..860708c1 100644 --- a/autk-provenance/src/adapters/map/recording.ts +++ b/autk-provenance/src/adapters/map/recording.ts @@ -2,14 +2,30 @@ import type { AutarkProvenanceState, IMapForProvenance, MapViewState } from '../ import { ProvenanceAction } from '../../types'; import { isTargetInMapContainer } from './dom'; import type { CustomControlConfig, MapRecordCallback, ResolvedMapSelectors } from './types'; -import { getActiveLayerId, getMenuOpen, getVisibleLayerIds, isElement } from './utils'; +import { getActiveLayerId, getLayerIds, getMenuOpen, getThematicEnabled, getVisibleLayerIds, isElement } from './utils'; const MAP_PICK_EVENT = 'picking'; +const MAP_VIEW_DEBOUNCE_MS = 180; function selectionSignature(selection: number[]): string { return selection.join(','); } +function viewsEqual(a: MapViewState | undefined, b: MapViewState | undefined, epsilon = 1e-6): boolean { + if (!a || !b) return a === b; + return vectorsEqual(a.eye, b.eye, epsilon) + && vectorsEqual(a.lookAt, b.lookAt, epsilon) + && vectorsEqual(a.up, b.up, epsilon); +} + +function vectorsEqual(a: number[], b: number[], epsilon: number): boolean { + return a.length === b.length && a.every((value, index) => Math.abs(value - b[index]) <= epsilon); +} + +function arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + export function createMapRecordingController(options: { map: IMapForProvenance; selectors: ResolvedMapSelectors; @@ -21,14 +37,47 @@ export function createMapRecordingController(options: { }): { start(): void; stop(): void } { const { map, selectors, customControls, onRecord, getCurrentState, isApplyingState, buildUiDelta } = options; const mapObj = map as unknown as Record; + const mapUiObj = (map.ui ?? {}) as Record; const wrappedMethods = new Map(); const cleanups: Array<() => void> = []; + let pendingViewState: MapViewState | null = null; + let viewTimer: ReturnType | null = null; + + function flushPendingViewState(): void { + if (!pendingViewState || isApplyingState()) return; + const nextViewState = pendingViewState; + pendingViewState = null; + const currentViewState = getCurrentState().view; + if (viewsEqual(currentViewState, nextViewState)) return; + onRecord(ProvenanceAction.MAP_VIEW, `View changed (alt: ${nextViewState.eye[2].toFixed(0)})`, { view: nextViewState }); + } + + function scheduleViewRecord(viewState: MapViewState): void { + pendingViewState = { + eye: [...viewState.eye] as [number, number, number], + lookAt: [...viewState.lookAt] as [number, number, number], + up: [...viewState.up] as [number, number, number], + }; + if (viewTimer) clearTimeout(viewTimer); + viewTimer = setTimeout(() => { + viewTimer = null; + flushPendingViewState(); + }, MAP_VIEW_DEBOUNCE_MS); + } function wrapMapMethod(methodName: string, onAfter: (args: unknown[]) => void): void { - const current = mapObj[methodName]; + wrapMethod(mapObj, methodName, onAfter); + } + + function wrapUiMethod(methodName: string, onAfter: (args: unknown[]) => void): void { + wrapMethod(mapUiObj, methodName, onAfter); + } + + function wrapMethod(target: Record, methodName: string, onAfter: (args: unknown[]) => void): void { + const current = target[methodName]; if (typeof current !== 'function' || wrappedMethods.has(methodName)) return; wrappedMethods.set(methodName, current); - mapObj[methodName] = function (...args: unknown[]) { + target[methodName] = function (...args: unknown[]) { const result = (current as (...values: unknown[]) => unknown).apply(this, args); if (!isApplyingState()) onAfter(args); return result; @@ -47,6 +96,59 @@ export function createMapRecordingController(options: { return false; } + function recordLayerControl(target: Element): boolean { + const button = target.closest('[data-autk-map-control]') as HTMLElement | null; + if (!button) return false; + + const control = button.dataset.autkMapControl; + const layerId = button.dataset.layerId; + if (!control || !layerId) return false; + + const currentUi = getCurrentState().ui; + const layer = map.layerManager.searchByLayerId(layerId); + + if (control === 'visibility') { + const nextVisibleLayerIds = getVisibleLayerIds(map); + const previousVisibleLayerIds = currentUi?.visibleLayerIds ?? getLayerIds(map); + if (arraysEqual(previousVisibleLayerIds, nextVisibleLayerIds)) return true; + onRecord( + ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, + nextVisibleLayerIds.includes(layerId) ? `Show layer: ${layerId}` : `Hide layer: ${layerId}`, + buildUiDelta({ visibleLayerIds: nextVisibleLayerIds }), + ); + return true; + } + + if (control === 'thematic') { + // Read thematic state directly from the clicked layer — getThematicEnabled() reads from + // map.ui.activeLayer which may be null, causing changes on non-active layers to be missed. + const nowThematic = !!layer?.layerRenderInfo?.isColorMap; + const prevThematic = currentUi?.thematicEnabled ?? false; + if (prevThematic === nowThematic) return true; + onRecord( + ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, + nowThematic ? `Enable thematic: ${layerId}` : `Disable thematic: ${layerId}`, + buildUiDelta({ thematicEnabled: nowThematic, activeLayerId: layerId }), + ); + return true; + } + + if (control === 'active-layer') { + const nextActiveLayerId = getActiveLayerId(map); + // Compare stored previous id against the new id (not the fallback-to-new pattern) + const previousActiveLayerId = currentUi?.activeLayerId ?? null; + if (previousActiveLayerId === nextActiveLayerId) return true; + onRecord( + ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, + `Active layer: ${nextActiveLayerId ?? layerId}`, + buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: !!layer?.layerRenderInfo?.isColorMap }), + ); + return true; + } + + return false; + } + return { start: () => { if (cleanups.length > 0) return; @@ -57,6 +159,51 @@ export function createMapRecordingController(options: { wrapMapMethod('loadGeoTiffLayer', ([layerName]) => { onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${typeof layerName === 'string' ? layerName : 'raster'}`, buildUiDelta()); }); + wrapMapMethod('updateRenderInfo', ([layerId, params]) => { + const info = params && typeof params === 'object' && 'renderInfo' in (params as Record) + ? (params as { renderInfo?: Record }).renderInfo ?? {} + : (params as Record | undefined) ?? {}; + const resolvedLayerId = typeof layerId === 'string' ? layerId : 'layer'; + + if ('isSkip' in info) { + const nextVisibleLayerIds = getVisibleLayerIds(map); + const previousVisibleLayerIds = getCurrentState().ui?.visibleLayerIds ?? getLayerIds(map); + if (!arraysEqual(previousVisibleLayerIds, nextVisibleLayerIds)) { + onRecord( + ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, + nextVisibleLayerIds.includes(resolvedLayerId) ? `Show layer: ${resolvedLayerId}` : `Hide layer: ${resolvedLayerId}`, + buildUiDelta({ visibleLayerIds: nextVisibleLayerIds }) + ); + return; + } + } + + if ('isColorMap' in info) { + const nextActiveLayerId = getActiveLayerId(map); + const nextThematicEnabled = getThematicEnabled(map); + const previousUi = getCurrentState().ui; + if ( + (previousUi?.activeLayerId ?? nextActiveLayerId) !== nextActiveLayerId + || (previousUi?.thematicEnabled ?? nextThematicEnabled) !== nextThematicEnabled + ) { + onRecord( + ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, + nextThematicEnabled ? `Enable thematic: ${nextActiveLayerId ?? resolvedLayerId}` : `Disable thematic: ${resolvedLayerId}`, + buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: nextThematicEnabled }) + ); + } + } + }); + wrapUiMethod('changeActiveLayer', () => { + const nextActiveLayerId = getActiveLayerId(map); + const nextThematicEnabled = getThematicEnabled(map); + if ((getCurrentState().ui?.activeLayerId ?? nextActiveLayerId) === nextActiveLayerId) return; + onRecord( + ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, + `Active layer: ${nextActiveLayerId ?? 'layer'}`, + buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: nextThematicEnabled }) + ); + }); const pickListener = (selection: number[], layerId: string) => { const activePlotIds = new Set(Object.values(getCurrentState().selection.plots ?? {}).flatMap((plot) => plot.ids)); @@ -77,7 +224,7 @@ export function createMapRecordingController(options: { if (map.addViewListener) { const viewListener = (viewState: MapViewState) => { - if (!isApplyingState()) onRecord(ProvenanceAction.MAP_VIEW, `View changed (alt: ${viewState.eye[2].toFixed(0)})`, { view: viewState }); + if (!isApplyingState()) scheduleViewRecord(viewState); }; map.addViewListener(viewListener); cleanups.push(() => map.removeViewListener?.(viewListener)); @@ -88,6 +235,12 @@ export function createMapRecordingController(options: { const target = event.target; if (isApplyingState() || !isElement(target)) return; if (recordCustomControl(target, 'click')) return; + // recordLayerControl must run before isTargetInMapContainer: the map UI's + // onClick handler calls updateRenderInfo which triggers refreshLayerList(), + // removing the original button from the DOM before this listener fires. + // Element.closest() still works on detached nodes via the element's own + // ancestor chain, but parentElement.contains() returns false for detached nodes. + if (recordLayerControl(target)) return; if (!isTargetInMapContainer(map, target)) return; if (target.closest(selectors.menuIcon)) { const mapMenuOpen = getMenuOpen(map, selectors); @@ -113,9 +266,18 @@ export function createMapRecordingController(options: { cleanups.push(() => document.removeEventListener('change', changeListener)); }, stop: () => { + if (viewTimer) { + clearTimeout(viewTimer); + viewTimer = null; + } + pendingViewState = null; cleanups.splice(0).forEach((cleanup) => cleanup()); wrappedMethods.forEach((original, methodName) => { - mapObj[methodName] = original; + if (mapObj[methodName] !== undefined) { + mapObj[methodName] = original; + } else if (mapUiObj[methodName] !== undefined) { + mapUiObj[methodName] = original; + } }); wrappedMethods.clear(); }, diff --git a/autk-provenance/src/adapters/map/utils.ts b/autk-provenance/src/adapters/map/utils.ts index 1392081c..5e53b1e2 100644 --- a/autk-provenance/src/adapters/map/utils.ts +++ b/autk-provenance/src/adapters/map/utils.ts @@ -3,7 +3,7 @@ import type { LayerLike, MapSelectorConfig, ResolvedMapSelectors, ResolvedUiStat export function resolveMapSelectors(config?: MapSelectorConfig): ResolvedMapSelectors { return { - menuIcon: config?.menuIcon ?? '#menuIcon', + menuIcon: config?.menuIcon ?? '#autkMapUi', subMenu: config?.subMenu ?? '#autkMapSubMenu', thematicCheckbox: config?.thematicCheckbox ?? '#showThematicCheckbox', legend: config?.legend ?? '#autkMapLegend', @@ -65,6 +65,7 @@ export function setLayerRenderFlag( value: boolean ): void { if (map.updateRenderInfoProperty) return void map.updateRenderInfoProperty(layerId, property, value); + if (map.updateRenderInfo) return void map.updateRenderInfo(layerId, { renderInfo: { [property]: value } }); const layer = map.layerManager.searchByLayerId(layerId); if (layer?.layerRenderInfo) layer.layerRenderInfo[property] = value; } diff --git a/autk-provenance/src/adapters/plot-adapter.ts b/autk-provenance/src/adapters/plot-adapter.ts index f8ebfa20..dd474f22 100644 --- a/autk-provenance/src/adapters/plot-adapter.ts +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -121,7 +121,7 @@ export function createPlotAdapter( if (!isApplyingState) { const dataLen = Array.isArray(value) ? value.length : 0; const label = `Data updated: ${dataLen} row(s) on ${plotTypeLabel(plot.plotType)} (${plot.plotId})`; - // Reset selection for this plot — indices are no longer valid after a data swap. + // Reset selection for this plot - indices are no longer valid after a data swap. onRecord(ProvenanceAction.PLOT_DATA, label, { selection: { plots: { [plot.plotId]: { ids: [], plotType: plot.plotType } }, @@ -138,7 +138,7 @@ export function createPlotAdapter( try { delete (plotObj as Record)['data']; } catch { - // non-configurable — leave it + // non-configurable - leave it } }; } diff --git a/autk-provenance/src/charts/chart-modal.ts b/autk-provenance/src/charts/chart-modal.ts index 3cc645d7..52c62555 100644 --- a/autk-provenance/src/charts/chart-modal.ts +++ b/autk-provenance/src/charts/chart-modal.ts @@ -118,7 +118,7 @@ export function createChartModalController(descriptors: ChartModalDescriptor[]):
- + diff --git a/autk-provenance/src/insights-workspace.ts b/autk-provenance/src/insights-workspace.ts index f75741e7..9a2be46b 100644 --- a/autk-provenance/src/insights-workspace.ts +++ b/autk-provenance/src/insights-workspace.ts @@ -31,6 +31,20 @@ export interface RenderInsightsWorkspaceResult { destroy(): void; } +const CARD_CHART_MARGINS = { + scatter: { left: 60, right: 16, top: 48, bottom: 48 }, + bar: { left: 55, right: 16, top: 48, bottom: 72 }, + parallel: { left: 28, right: 28, top: 50, bottom: 36 }, + histogram: { left: 55, right: 16, top: 48, bottom: 48 }, +} as const; + +const MODAL_CHART_MARGINS = { + scatter: { left: 62, right: 16, top: 42, bottom: 48 }, + bar: { left: 55, right: 16, top: 42, bottom: 72 }, + parallel: { left: 28, right: 28, top: 44, bottom: 36 }, + histogram: { left: 55, right: 16, top: 42, bottom: 48 }, +} as const; + function plotSize(element: HTMLElement, fallbackWidth: number, fallbackHeight: number): { width: number; height: number } { const rect = element.getBoundingClientRect(); return { @@ -45,7 +59,7 @@ function createPlotAdapter(plot: PlotBaseInteractive, plotId: string, plotType: plotType, plotEvents: { addEventListener(event: string, fn: (selection: number[]) => void) { - plot.events.on(event as PlotEvent, ({ selection }) => fn(selection)); + plot.events.on(event as PlotEvent, ({ selection }: { selection: number[] }) => fn(selection)); }, removeEventListener(event: string, fn: (selection: number[]) => void) { plot.events.off(event as PlotEvent, fn as never); @@ -57,19 +71,39 @@ function createPlotAdapter(plot: PlotBaseInteractive, plotId: string, plotType: }); } -function mountMapInWorkspace(map: AutkMap, body: HTMLElement): void { +function mountMapInWorkspace(map: AutkMap, body: HTMLElement): () => void { const internals = map as AutkMap & { _resizeEvents?: { resize?: () => void }; }; + let frame = 0; + let observer: ResizeObserver | null = null; + + const syncSize = () => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = 0; + internals._resizeEvents?.resize?.(); + map.ui.handleResize(); + map.draw(); + }); + }; map.ui.destroy(); body.replaceChildren(map.canvas); map.canvas.style.width = '100%'; map.canvas.style.height = '100%'; - internals._resizeEvents?.resize?.(); map.ui.buildUi(); - map.ui.handleResize(); - map.draw(); + syncSize(); + + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(() => syncSize()); + observer.observe(body); + } + + return () => { + if (frame) cancelAnimationFrame(frame); + observer?.disconnect(); + }; } function applyThematic(map: AutkMap, collection: FeatureCollection, layerId: string, property: string): void { @@ -98,7 +132,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) shell.plotPanels.parallel.hint.textContent = schema.parallel.subtitle; shell.plotPanels.histogram.title.textContent = schema.histogram.title; shell.plotPanels.histogram.hint.textContent = schema.histogram.subtitle; - mountMapInWorkspace(map, shell.mapBody); + const unmountMap = mountMapInWorkspace(map, shell.mapBody); const scatterDims = plotSize(shell.plotPanels.scatter.body, 360, 280); const barDims = plotSize(shell.plotPanels.bar.body, 360, 280); @@ -112,7 +146,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, width: scatterDims.width, height: scatterDims.height, - margins: { left: 62, right: 16, top: 36, bottom: 48 }, + margins: CARD_CHART_MARGINS.scatter, events: [PlotEvent.CLICK, PlotEvent.BRUSH], }); const bar = new Barchart({ @@ -122,7 +156,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, width: barDims.width, height: barDims.height, - margins: { left: 55, right: 16, top: 36, bottom: 72 }, + margins: CARD_CHART_MARGINS.bar, events: [PlotEvent.CLICK], }); const parallel = new ParallelCoordinates({ @@ -132,7 +166,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, width: parallelDims.width, height: parallelDims.height, - margins: { left: 28, right: 28, top: 36, bottom: 36 }, + margins: CARD_CHART_MARGINS.parallel, events: [PlotEvent.BRUSH_Y], }); const histogram = new Histogram({ @@ -142,7 +176,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, width: histogramDims.width, height: histogramDims.height, - margins: { left: 55, right: 16, top: 36, bottom: 48 }, + margins: CARD_CHART_MARGINS.histogram, events: [PlotEvent.CLICK], }); @@ -162,7 +196,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, width, height, - margins: { left: 62, right: 16, top: 36, bottom: 48 }, + margins: MODAL_CHART_MARGINS.scatter, events: [PlotEvent.CLICK, PlotEvent.BRUSH], }), }, @@ -181,7 +215,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, width, height, - margins: { left: 55, right: 16, top: 36, bottom: 72 }, + margins: MODAL_CHART_MARGINS.bar, events: [PlotEvent.CLICK], }), }, @@ -200,7 +234,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, width, height, - margins: { left: 28, right: 28, top: 36, bottom: 36 }, + margins: MODAL_CHART_MARGINS.parallel, events: [PlotEvent.BRUSH_Y], }), }, @@ -219,7 +253,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, width, height, - margins: { left: 55, right: 16, top: 36, bottom: 48 }, + margins: MODAL_CHART_MARGINS.histogram, events: [PlotEvent.CLICK], }), }, @@ -251,6 +285,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) removeViewListener?: (callback: (state: MapViewState) => void) => void; setViewState?: (state: MapViewState) => void; updateRenderInfoProperty?: (layerName: string, property: string, value: unknown) => void; + updateRenderInfo?: (layerName: string, params: unknown) => void; }; const mapForProvenance: NonNullable[0]['map']> = { mapEvents: { @@ -267,6 +302,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) canvas: map.canvas, ui: map.ui, updateRenderInfoProperty: mapViewApi.updateRenderInfoProperty?.bind(map), + updateRenderInfo: mapViewApi.updateRenderInfo?.bind(map), layerManager: map.layerManager, }; const provenance = createAutarkProvenance({ @@ -284,7 +320,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) const destroyTrail = renderProvenanceTrailUI({ provenance, container: shell.provenanceTrail, - insightsContainer: shell.provenanceInsights, + showInsights: false, showTimestamps: true, showGraph: true, showPathList: true, @@ -308,6 +344,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) destroyTrail(); detachTabs(); chartModal.destroy(); + unmountMap(); container.innerHTML = ''; }, }; diff --git a/autk-provenance/src/insights/narrative.ts b/autk-provenance/src/insights/narrative.ts index bc6894cd..89e1aa50 100644 --- a/autk-provenance/src/insights/narrative.ts +++ b/autk-provenance/src/insights/narrative.ts @@ -25,7 +25,7 @@ export function generateSessionNarrative( })) .filter((plot) => plot.entries.length > 0); const lines = [ - `Session started at ${typeof sessionStart === 'number' ? new Date(sessionStart).toLocaleTimeString() : '—'}.`, + `Session started at ${typeof sessionStart === 'number' ? new Date(sessionStart).toLocaleTimeString() : '-'}.`, `Duration: ${formatDuration(metrics.sessionDurationMs)} across ${metrics.totalNodes} states (avg ${formatDuration(metrics.avgTimePerStateMs)} per state).`, '', `Analysis strategy: ${metrics.strategyLabel}`, @@ -58,7 +58,7 @@ export function generateSessionNarrative( } const strategyDescription: Record = { - Confirmatory: 'A focused, linear exploration — the analyst appeared to know what they were looking for.', + Confirmatory: 'A focused, linear exploration - the analyst appeared to know what they were looking for.', Exploratory: 'A broad, open-ended investigation with multiple diverging paths.', 'Iterative Refinement': 'A hypothesis-driven approach with repeated backtracking and revision.', }; diff --git a/autk-provenance/src/provenance-trail-ui.ts b/autk-provenance/src/provenance-trail-ui.ts index e5d4a449..48699098 100644 --- a/autk-provenance/src/provenance-trail-ui.ts +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -10,6 +10,7 @@ export interface ProvenanceTrailUIOptions { provenance: AutarkProvenanceApi; container: HTMLElement; insightsContainer?: HTMLElement; + showInsights?: boolean; showBackForward?: boolean; showTimestamps?: boolean; showGraph?: boolean; @@ -17,7 +18,7 @@ export interface ProvenanceTrailUIOptions { } export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void { - const { provenance, container, insightsContainer, showBackForward = true, showTimestamps = true, showGraph = true, showPathList = true } = options; + const { provenance, container, insightsContainer, showInsights = true, showBackForward = true, showTimestamps = true, showGraph = true, showPathList = true } = options; ensureProvenanceTrailStyles(); container.innerHTML = ''; container.classList.add('autk-provenance-root'); @@ -27,9 +28,9 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = const graphWrap = showGraph ? document.createElement('div') : null; const pathContainer = showPathList ? document.createElement('div') : null; const navigation = showBackForward ? createNavigationButtons() : null; - const insights = createInsightsShell(insightsContainer ?? container); + const insights = showInsights ? createInsightsShell(insightsContainer ?? container) : null; const modal = createGraphModalController({ provenance, showTimestamps, buildLayout: () => buildLayoutFromProvenance(provenance), onRefresh: refresh }); - insights.onToggle((open) => { + insights?.onToggle((open) => { if (open) renderInsightsPanel(insights.body, provenance); }); @@ -70,7 +71,7 @@ export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () = navigation.backButton.disabled = !provenance.canGoBack(); navigation.forwardButton.disabled = !provenance.canGoForward(); } - if (insights.isOpen()) renderInsightsPanel(insights.body, provenance); + if (insights?.isOpen()) renderInsightsPanel(insights.body, provenance); if (modal.isOpen()) modal.render(); } diff --git a/autk-provenance/src/types.ts b/autk-provenance/src/types.ts index 94cccbeb..81fd836e 100644 --- a/autk-provenance/src/types.ts +++ b/autk-provenance/src/types.ts @@ -135,12 +135,17 @@ export interface IMapForProvenance { ui?: { activeLayer?: { layerInfo?: { id: string }; layerRenderInfo?: { isColorMap?: boolean } } | null; changeActiveLayer?(layer: unknown): void; + refreshLayerList?(): void; }; updateRenderInfoProperty?( layerName: string, property: string, value: unknown ): void; + updateRenderInfo?( + layerName: string, + params: unknown + ): void; layerManager: { searchByLayerId(layerId: string): { setHighlightedIds?(ids: number[]): void; diff --git a/autk-provenance/src/ui/styles.ts b/autk-provenance/src/ui/styles.ts index fe6616c6..c820d804 100644 --- a/autk-provenance/src/ui/styles.ts +++ b/autk-provenance/src/ui/styles.ts @@ -33,7 +33,7 @@ export function ensureProvenanceTrailStyles(): void { '.autk-provenance-modal-canvas{position:relative;flex:1;min-height:0;border:1px solid #dbe5f0;border-radius:8px;background:#fff;overflow:hidden;cursor:grab;touch-action:none}', '.autk-provenance-modal-canvas.autk-provenance-panning{cursor:grabbing}', '.autk-provenance-modal-svg{display:block;width:100%;height:100%;min-width:0}', - '.autk-provenance-path{display:flex;flex-direction:column;gap:2px;border:1px solid #dbe5f0;border-radius:8px;background:#fbfdff;max-height:200px;overflow-y:auto}', + '.autk-provenance-path{display:flex;flex:1 1 auto;min-height:0;flex-direction:column;gap:2px;border:1px solid #dbe5f0;border-radius:8px;background:#fbfdff;overflow-y:auto}', '.autk-provenance-path-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #e7eef6;cursor:pointer}', '.autk-provenance-path-item:last-child{border-bottom:0}', '.autk-provenance-path-item:hover{background:#f1f7ff}', diff --git a/autk-provenance/src/ui/workspace-session-insights.ts b/autk-provenance/src/ui/workspace-session-insights.ts index 1992fc3e..31339eed 100644 --- a/autk-provenance/src/ui/workspace-session-insights.ts +++ b/autk-provenance/src/ui/workspace-session-insights.ts @@ -1,25 +1,10 @@ import { computeGraphMetrics, - computeSelectionFrequency, generateSessionNarrative, getInsightAnnotations, type InsightsProvenanceApi, type InsightSelectionState, - type StrategyLabel, } from '../insight-engine'; -import { formatDurationShort } from './utils'; - -const STRATEGY_COLORS: Record = { - Confirmatory: '#2e7d32', - Exploratory: '#1565c0', - 'Iterative Refinement': '#6a1b9a', -}; - -const STRATEGY_DESCRIPTIONS: Record = { - Confirmatory: 'A focused, linear exploration where the analyst appears to be validating a known hunch.', - Exploratory: 'A broader scan across multiple directions, with branching used to compare possibilities.', - 'Iterative Refinement': 'A revise-and-compare workflow with meaningful backtracking before converging.', -}; export function renderWorkspaceSessionInsights( container: HTMLElement, @@ -28,7 +13,6 @@ export function renderWorkspaceSessionInsights( const graph = provenance.getGraph(); const metrics = computeGraphMetrics(graph); const annotations = getInsightAnnotations(graph); - const frequency = computeSelectionFrequency(graph); const narrative = generateSessionNarrative(graph, metrics, annotations); const currentNode = provenance.getCurrentNode(); const insight = typeof currentNode?.metadata?.insight === 'string' ? currentNode.metadata.insight : ''; @@ -36,43 +20,11 @@ export function renderWorkspaceSessionInsights( container.innerHTML = ''; const grid = document.createElement('div'); grid.className = 'autk-session-insights-grid'; - grid.appendChild(createOverviewCard(metrics)); grid.appendChild(createAnnotationCard(provenance, currentNode?.id ?? null, currentNode?.actionLabel ?? 'Current step', insight, container)); - grid.appendChild(createFrequencyCard(frequency)); - grid.appendChild(createSummaryCard(narrative)); + grid.appendChild(createSummaryCard(metrics, narrative)); container.appendChild(grid); } -function createOverviewCard(metrics: ReturnType): HTMLElement { - const card = createCard('Analysis Strategy'); - const body = card.querySelector('.autk-session-insights-card-body') as HTMLElement; - const badge = document.createElement('span'); - const desc = document.createElement('p'); - const chips = document.createElement('div'); - - badge.className = 'autk-session-strategy-badge'; - badge.textContent = metrics.strategyLabel; - badge.style.background = STRATEGY_COLORS[metrics.strategyLabel]; - - desc.className = 'autk-session-strategy-desc'; - desc.textContent = STRATEGY_DESCRIPTIONS[metrics.strategyLabel]; - - chips.className = 'autk-session-metrics-row'; - [ - ['Duration', metrics.sessionDurationMs > 0 ? formatDurationShort(metrics.sessionDurationMs) : '0s'], - ['States', `${metrics.totalNodes}`], - ['Branches', `${metrics.branchPoints}`], - ['Backtracks', `${metrics.backtracks}`], - ['Avg/state', metrics.avgTimePerStateMs > 0 ? formatDurationShort(metrics.avgTimePerStateMs) : '0s'], - ['Insights', `${metrics.insightCount}`], - ].forEach(([label, value]) => chips.appendChild(createMetricChip(label, value))); - - body.appendChild(badge); - body.appendChild(desc); - body.appendChild(chips); - return card; -} - function createAnnotationCard( provenance: InsightsProvenanceApi, nodeId: string | null, @@ -107,37 +59,20 @@ function createAnnotationCard( return card; } -function createFrequencyCard(frequency: ReturnType): HTMLElement { - const card = createCard('Feature Revisits'); - const body = card.querySelector('.autk-session-insights-card-body') as HTMLElement; - const scroll = document.createElement('div'); - scroll.className = 'autk-session-frequency-scroll'; - - const mapEntries = topEntries(frequency.map); - const plotGroups = [...frequency.plots.entries()] - .map(([plotId, values]) => ({ plotId, entries: topEntries(values, 4) })) - .filter((group) => group.entries.length > 0); - - if (mapEntries.length === 0 && plotGroups.length === 0) { - const empty = document.createElement('div'); - empty.className = 'autk-session-frequency-empty'; - empty.textContent = 'Selections will start appearing here as the session branches and revisits features.'; - scroll.appendChild(empty); - } else { - if (mapEntries.length > 0) scroll.appendChild(createFrequencyGroup('Map features', mapEntries)); - plotGroups.forEach((group) => scroll.appendChild(createFrequencyGroup(group.plotId, group.entries))); - } - - body.appendChild(scroll); - return card; -} - -function createSummaryCard(narrative: string): HTMLElement { +function createSummaryCard(metrics: ReturnType, narrative: string): HTMLElement { const card = createCard('Analysis Summary'); const body = card.querySelector('.autk-session-insights-card-body') as HTMLElement; + const chips = document.createElement('div'); const pre = document.createElement('pre'); const copy = document.createElement('button'); + chips.className = 'autk-session-metrics-row'; + [ + ['States', `${metrics.totalNodes}`], + ['Branches', `${metrics.branchPoints}`], + ['Backtracks', `${metrics.backtracks}`], + ['Insights', `${metrics.insightCount}`], + ].forEach(([label, value]) => chips.appendChild(createMetricChip(label, value))); pre.className = 'autk-session-summary'; pre.textContent = narrative; copy.className = 'autk-session-button'; @@ -149,6 +84,7 @@ function createSummaryCard(narrative: string): HTMLElement { }); }); + body.appendChild(chips); body.appendChild(pre); body.appendChild(copy); return card; @@ -173,41 +109,3 @@ function createMetricChip(label: string, value: string): HTMLElement { chip.innerHTML = `${label}: ${value}`; return chip; } - -function topEntries(values: Map, limit = 5): Array<[number, number]> { - return [...values.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit); -} - -function createFrequencyGroup(title: string, entries: Array<[number, number]>): HTMLElement { - const group = document.createElement('div'); - const heading = document.createElement('div'); - const max = Math.max(...entries.map(([, count]) => count), 1); - group.className = 'autk-session-frequency-group'; - heading.className = 'autk-session-frequency-group-title'; - heading.textContent = title; - group.appendChild(heading); - - entries.forEach(([id, count]) => { - const row = document.createElement('div'); - const label = document.createElement('div'); - const bar = document.createElement('div'); - const fill = document.createElement('div'); - const countLabel = document.createElement('div'); - - row.className = 'autk-session-frequency-row'; - label.className = 'autk-session-frequency-id'; - label.textContent = `Feature #${id}`; - bar.className = 'autk-session-frequency-bar'; - fill.className = 'autk-session-frequency-fill'; - fill.style.width = `${(count / max) * 100}%`; - countLabel.className = 'autk-session-frequency-count'; - countLabel.textContent = `${count}`; - bar.appendChild(fill); - row.appendChild(label); - row.appendChild(bar); - row.appendChild(countLabel); - group.appendChild(row); - }); - - return group; -} diff --git a/autk-provenance/src/ui/workspace-shell.ts b/autk-provenance/src/ui/workspace-shell.ts index cc875bd7..81a29aa3 100644 --- a/autk-provenance/src/ui/workspace-shell.ts +++ b/autk-provenance/src/ui/workspace-shell.ts @@ -6,7 +6,6 @@ export interface WorkspaceShell { chartsTab: HTMLElement; provenanceTab: HTMLElement; provenanceTrail: HTMLElement; - provenanceInsights: HTMLElement; chartsInsights: HTMLElement; plotPanels: Record<'scatter' | 'bar' | 'parallel' | 'histogram', { title: HTMLElement; @@ -72,12 +71,11 @@ export function createInsightsWorkspaceShell(container: HTMLElement, title?: str
-
-
+
Session Insights Updates as you interact with any visualization @@ -95,7 +93,6 @@ export function createInsightsWorkspaceShell(container: HTMLElement, title?: str chartsTab: container.querySelector('[data-panel="charts"]') as HTMLElement, provenanceTab: container.querySelector('[data-panel="provenance"]') as HTMLElement, provenanceTrail: container.querySelector('.autk-workspace-trail') as HTMLElement, - provenanceInsights: container.querySelector('.autk-workspace-insights-slot') as HTMLElement, chartsInsights: container.querySelector('.autk-workspace-session-insights-body') as HTMLElement, plotPanels: { scatter: getPlotPanel(container, 'scatter'), diff --git a/autk-provenance/src/ui/workspace-styles.ts b/autk-provenance/src/ui/workspace-styles.ts index b637232b..effde046 100644 --- a/autk-provenance/src/ui/workspace-styles.ts +++ b/autk-provenance/src/ui/workspace-styles.ts @@ -12,10 +12,10 @@ export function ensureInsightsWorkspaceStyles(): void { '.autk-workspace-header p{margin:4px 0 0;font-size:12px;color:#546b80}', '.autk-workspace-layout{display:grid;grid-template-columns:minmax(360px,1fr) minmax(420px,1fr);gap:14px;align-items:stretch}', '.autk-workspace-panel{border:1px solid #d4dde7;border-radius:12px;background:#fff;box-shadow:0 8px 24px rgba(28,53,76,.09);overflow:hidden;min-height:0}', - '.autk-workspace-panel-header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border-bottom:1px solid #d4dde7;background:linear-gradient(180deg,#fff,#f6f9fc)}', - '.autk-workspace-panel-header-main{display:flex;flex-direction:column;gap:2px;min-width:0}', - '.autk-workspace-panel-title{font-size:13px;font-weight:600}', - '.autk-workspace-panel-hint{font-size:11px;color:#546b80;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', + '.autk-workspace-panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:10px 12px;border-bottom:1px solid #d4dde7;background:linear-gradient(180deg,#fff,#f6f9fc)}', + '.autk-workspace-panel-header-main{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}', + '.autk-workspace-panel-title{font-size:12px;font-weight:600;line-height:1.25}', + '.autk-workspace-panel-hint{font-size:10px;line-height:1.35;color:#546b80;white-space:normal;overflow:hidden;text-overflow:ellipsis}', '.autk-workspace-select-wrap{display:flex;align-items:center;gap:6px;font-size:11px;color:#546b80}', '.autk-workspace-select{padding:4px 8px;border:1px solid #d4dde7;border-radius:6px;background:#fff;color:#1b3148;font:inherit}', '.autk-workspace-map-panel,.autk-workspace-side-panel{min-height:680px;display:flex;flex-direction:column}', @@ -33,24 +33,25 @@ export function ensureInsightsWorkspaceStyles(): void { '.autk-workspace-plot-card{display:flex;flex-direction:column;min-height:0;border:1px solid #d4dde7;border-radius:10px;overflow:hidden;background:#fff}', '.autk-workspace-plot-header{cursor:zoom-in}', '.autk-workspace-expand-btn{padding:5px 10px;border:1px solid #d4dde7;border-radius:999px;background:#fff;color:#2f5f96;font:600 11px system-ui,sans-serif;cursor:pointer}', - '.autk-workspace-plot-body{position:relative;flex:1;min-height:0;overflow:hidden}', - '.autk-workspace-plot-body-inner{position:absolute;inset:0}', - '.autk-workspace-provenance-grid{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,1fr);gap:10px;padding:10px}', - '.autk-workspace-provenance-main,.autk-workspace-provenance-side{min-height:0;overflow:hidden}', + '.autk-workspace-plot-body{position:relative;flex:1;min-height:0;overflow:hidden;padding:8px 10px 10px}', + '.autk-workspace-plot-body-inner{position:absolute;inset:8px 10px 10px}', + '.autk-workspace-plot-body-inner .plot-title{font-size:13px;font-weight:600;fill:#1f344b}', + '.autk-workspace-plot-body-inner .axis-label,.autk-workspace-plot-body-inner .title{font-size:11px;fill:#1f344b}', + '.autk-workspace-plot-body-inner .tick text{font-size:10px;fill:#5d7286}', + '.autk-workspace-provenance-grid{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:10px;padding:10px}', + '.autk-workspace-provenance-main,.autk-workspace-provenance-side{min-height:0;min-width:0;overflow:hidden}', + '.autk-workspace-provenance-main{display:flex;flex-direction:column}', '.autk-workspace-trail{height:100%;display:flex;flex-direction:column;min-height:0}', - '.autk-workspace-insights-slot{height:100%;min-height:0;overflow:auto}', '.autk-workspace-session-insights{display:flex;flex-direction:column}', '.autk-workspace-session-insights-body{padding:0;min-height:0}', '.autk-workspace-bottom-insights{border-top:1px solid #e7eef6}', - '.autk-session-insights-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0}', + '.autk-session-insights-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0}', '.autk-session-insights-card{min-height:220px;padding:16px;border-right:1px solid #dbe5f0;display:flex;flex-direction:column;gap:12px}', '.autk-session-insights-card:last-child{border-right:none}', '.autk-session-insights-card-title{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#5c7389}', '.autk-session-insights-card-body{flex:1;display:flex;flex-direction:column;gap:8px;min-height:0}', '.autk-session-insights-step{font-size:12px;font-weight:600;color:#49627a}', '.autk-session-insights-label{font-size:12px;color:#5c7389}', - '.autk-session-strategy-badge{display:inline-block;align-self:flex-start;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:700;color:#fff}', - '.autk-session-strategy-desc{font-size:12px;line-height:1.5;color:#4d6479}', '.autk-session-metrics-row{display:flex;flex-wrap:wrap;gap:8px}', '.autk-session-metric-chip{padding:4px 10px;border:1px solid #dbe5f0;border-radius:8px;background:#f6f9fc;font-size:11px;color:#1f344b}', '.autk-session-metric-chip strong{font-weight:700}', @@ -59,15 +60,6 @@ export function ensureInsightsWorkspaceStyles(): void { '.autk-session-button{align-self:flex-start;padding:7px 14px;border:1px solid #c3cfdb;border-radius:8px;background:#fff;color:#1f344b;font:inherit;cursor:pointer}', '.autk-session-button:hover:not(:disabled){background:#edf5ff;border-color:#2f5f96;color:#2f5f96}', '.autk-session-button:disabled{opacity:.45;cursor:not-allowed}', - '.autk-session-frequency-scroll{display:flex;flex-direction:column;gap:10px;overflow:auto;min-height:0}', - '.autk-session-frequency-empty{font-size:12px;color:#5c7389}', - '.autk-session-frequency-group{display:flex;flex-direction:column;gap:6px}', - '.autk-session-frequency-group-title{font-size:11px;font-weight:700;color:#1f344b}', - '.autk-session-frequency-row{display:flex;align-items:center;gap:8px;min-height:18px}', - '.autk-session-frequency-id{width:112px;flex-shrink:0;font-size:11px;color:#5c7389;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', - '.autk-session-frequency-bar{flex:1;height:8px;border-radius:999px;background:#e7eef6;overflow:hidden}', - '.autk-session-frequency-fill{height:100%;background:#2f5f96;border-radius:999px}', - '.autk-session-frequency-count{width:26px;flex-shrink:0;text-align:right;font-size:11px;color:#1f344b}', '.autk-session-summary{margin:0;padding:10px;border:1px solid #dbe5f0;border-radius:8px;background:#f0f5fb;white-space:pre-wrap;word-break:break-word;line-height:1.6;font-size:11px;color:#1f344b;overflow:auto;min-height:0;max-height:170px}', '.autk-chart-modal-backdrop{position:fixed;inset:0;z-index:12000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(14,24,36,.56)}', '.autk-chart-modal{width:min(1320px,96vw);height:min(900px,92vh);display:flex;flex-direction:column;overflow:hidden;border-radius:16px;border:1px solid rgba(212,221,231,.85);background:linear-gradient(180deg,#f9fcff,#f3f8fd);box-shadow:0 32px 90px rgba(13,26,40,.45)}', @@ -81,8 +73,8 @@ export function ensureInsightsWorkspaceStyles(): void { '.autk-chart-modal-canvas{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden;border:1px solid #d4dde7;border-radius:12px;background:linear-gradient(90deg,rgba(207,220,232,.35) 1px,transparent 1px) 0 0 / 32px 32px,linear-gradient(rgba(207,220,232,.35) 1px,transparent 1px) 0 0 / 32px 32px,#fff}', '.autk-chart-modal-stage{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}', '.autk-chart-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px 14px;font-size:12px;color:#546b80}', - '@media (max-width:1260px){.autk-session-insights-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.autk-session-insights-card:nth-child(2){border-right:none}.autk-session-insights-card:nth-child(-n+2){border-bottom:1px solid #dbe5f0}}', - '@media (max-width:1200px){.autk-workspace-layout{grid-template-columns:1fr}.autk-workspace-provenance-grid{grid-template-columns:1fr}.autk-workspace-map-panel,.autk-workspace-side-panel{min-height:520px}}', + '@media (max-width:1260px){.autk-session-insights-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}', + '@media (max-width:1200px){.autk-workspace-layout{grid-template-columns:1fr}.autk-workspace-provenance-grid{grid-template-columns:1fr;grid-template-rows:minmax(0,1fr)}.autk-workspace-map-panel,.autk-workspace-side-panel{min-height:520px}}', '@media (max-width:760px){.autk-workspace-plot-grid{grid-template-columns:1fr;grid-template-rows:repeat(4,280px)}.autk-chart-modal{width:96vw;height:92vh}.autk-chart-modal-header,.autk-chart-modal-footer{flex-direction:column;align-items:flex-start}.autk-session-insights-grid{grid-template-columns:1fr}.autk-session-insights-card{border-right:none;border-bottom:1px solid #dbe5f0}.autk-session-insights-card:last-child{border-bottom:none}}', ].join(''); document.head.appendChild(style); diff --git a/examples/src/autk-provenance/map-plot-provenance.css b/examples/src/autk-provenance/map-plot-provenance.css index 95b5149d..f9141bb2 100644 --- a/examples/src/autk-provenance/map-plot-provenance.css +++ b/examples/src/autk-provenance/map-plot-provenance.css @@ -253,7 +253,7 @@ svg { align-items: center; } -/* Card 1 – Strategy */ +/* Card 1 - Strategy */ .strategy-badge { display: inline-block; padding: 4px 12px; @@ -290,7 +290,7 @@ svg { color: var(--text); } -/* Card 2 – Annotate */ +/* Card 2 - Annotate */ .annotation-label { font-size: 12px; color: var(--text-muted); @@ -339,7 +339,7 @@ svg { cursor: not-allowed; } -/* Card 3 – Selection frequency */ +/* Card 3 - Selection frequency */ .freq-empty { font-size: 12px; color: var(--text-muted); @@ -391,7 +391,7 @@ svg { flex-shrink: 0; } -/* Card 4 – Summary */ +/* Card 4 - Summary */ .summary-pre { flex: 1; margin: 0; diff --git a/examples/src/autk-provenance/map-plot-provenance.ts b/examples/src/autk-provenance/map-plot-provenance.ts index c84af21c..03978f9a 100644 --- a/examples/src/autk-provenance/map-plot-provenance.ts +++ b/examples/src/autk-provenance/map-plot-provenance.ts @@ -36,7 +36,7 @@ async function main(): Promise { layerId: 'neighborhoods', db, title: 'Autark Provenance', - description: 'Manhattan neighborhoods · Scatterplot · Bar Chart · Parallel Coordinates · Histogram — every interaction captured in one shared provenance graph', + description: 'Manhattan neighborhoods · Scatterplot · Bar Chart · Parallel Coordinates · Histogram - every interaction captured in one shared provenance graph', }); } diff --git a/gallery/src/autk-provenance/map-plot-provenance.css b/gallery/src/autk-provenance/map-plot-provenance.css index 22b2a930..4e0712d6 100644 --- a/gallery/src/autk-provenance/map-plot-provenance.css +++ b/gallery/src/autk-provenance/map-plot-provenance.css @@ -253,7 +253,7 @@ align-items: center; } - /* Card 1 – Strategy */ + /* Card 1 - Strategy */ .strategy-badge { display: inline-block; padding: 4px 12px; @@ -290,7 +290,7 @@ color: var(--text); } - /* Card 2 – Annotate */ + /* Card 2 - Annotate */ .annotation-label { font-size: 12px; color: var(--text-muted); @@ -339,7 +339,7 @@ cursor: not-allowed; } - /* Card 3 – Selection frequency */ + /* Card 3 - Selection frequency */ .freq-empty { font-size: 12px; color: var(--text-muted); @@ -391,7 +391,7 @@ flex-shrink: 0; } - /* Card 4 – Summary */ + /* Card 4 - Summary */ .summary-pre { flex: 1; margin: 0; @@ -515,4 +515,4 @@ #sidebar { height: 360px; } - } \ No newline at end of file + } diff --git a/gallery/src/autk-provenance/map-plot-provenance.ts b/gallery/src/autk-provenance/map-plot-provenance.ts index c84af21c..03978f9a 100644 --- a/gallery/src/autk-provenance/map-plot-provenance.ts +++ b/gallery/src/autk-provenance/map-plot-provenance.ts @@ -36,7 +36,7 @@ async function main(): Promise { layerId: 'neighborhoods', db, title: 'Autark Provenance', - description: 'Manhattan neighborhoods · Scatterplot · Bar Chart · Parallel Coordinates · Histogram — every interaction captured in one shared provenance graph', + description: 'Manhattan neighborhoods · Scatterplot · Bar Chart · Parallel Coordinates · Histogram - every interaction captured in one shared provenance graph', }); } From dac3cc57731bd2bcfddf89e0be542057da6f4897 Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Wed, 6 May 2026 14:41:43 -0500 Subject: [PATCH 17/21] fixes: chart selection and deselection --- autk-plot/src/plot-base-interactive.ts | 117 +++---- autk-plot/src/plot-types/histogram.ts | 18 +- autk-plot/src/plots/barchart.ts | 6 +- autk-plot/src/plots/scatterplot.ts | 2 + .../src/adapters/map/recording-controls.ts | 139 ++++++++ .../src/adapters/map/recording-shared.ts | 20 ++ .../src/adapters/map/recording-view.ts | 48 +++ autk-provenance/src/adapters/map/recording.ts | 311 ++++++------------ autk-provenance/src/adapters/plot-adapter.ts | 25 +- autk-provenance/src/charts/chart-modal.ts | 6 +- .../src/create-autark-provenance.ts | 2 +- autk-provenance/src/insights-workspace.ts | 297 ++--------------- autk-provenance/src/types.ts | 1 + autk-provenance/src/workspace/charts.ts | 152 +++++++++ autk-provenance/src/workspace/map-host.ts | 106 ++++++ autk-provenance/src/workspace/plot-adapter.ts | 24 ++ 16 files changed, 688 insertions(+), 586 deletions(-) create mode 100644 autk-provenance/src/adapters/map/recording-controls.ts create mode 100644 autk-provenance/src/adapters/map/recording-shared.ts create mode 100644 autk-provenance/src/adapters/map/recording-view.ts create mode 100644 autk-provenance/src/workspace/charts.ts create mode 100644 autk-provenance/src/workspace/map-host.ts create mode 100644 autk-provenance/src/workspace/plot-adapter.ts diff --git a/autk-plot/src/plot-base-interactive.ts b/autk-plot/src/plot-base-interactive.ts index ca1ffeaf..383c5214 100644 --- a/autk-plot/src/plot-base-interactive.ts +++ b/autk-plot/src/plot-base-interactive.ts @@ -6,7 +6,7 @@ import type { } from 'geojson'; import type { AutkDatum } from './types-plot'; -import type { PlotConfig, PlotTransformConfig } from './api'; +import type { PlotConfig } from './api'; import { ColorMapInterpolator, @@ -31,15 +31,15 @@ export abstract class PlotBaseInteractive extends PlotBaseData { protected _enabledEvents: PlotEvent[] = []; /** CSS property used when applying colors to marks. */ protected _colorProperty: 'fill' | 'stroke' = 'fill'; + /** When false, clicking the plot background does not clear the selection. */ + protected _backgroundClickClearsSelection = true; /** Datums corresponding to marks currently selected by local interaction. */ private _selectedMarkDatums: Set = new Set(); - /** Source feature ids derived from mark selection or provided externally. */ + /** Source feature ids owned by local interaction on this plot. */ private _selectedFeatureIds: Set = new Set(); - /** Tracks whether the current selection came from local or external input. */ - private _selectionOrigin: 'local' | 'external' | null = null; - /** Controls how source ids map back onto rendered marks. */ - private _selectionProjection: 'bijective' | 'aggregated' = 'bijective'; + /** Source feature ids highlighted from other coordinated views. */ + private _externalSelectedFeatureIds: Set = new Set(); /** Typed event emitter exposed to plot consumers. */ private _plotEvents!: EventEmitter; /** Active brush rectangles keyed by brush host id. */ @@ -67,16 +67,22 @@ export abstract class PlotBaseInteractive extends PlotBaseData { super(config); this._plotEvents = new EventEmitter(); this._enabledEvents = config.events ?? []; - this._selectionProjection = this.resolveSelectionProjection(config.transform); } /** - * Returns the active selection as source feature ids. + * Returns the locally owned selection as source feature ids. */ get selection(): number[] { return Array.from(this._selectedFeatureIds); } + /** + * Returns the full highlighted feature set visible in this view. + */ + get highlightedSelection(): number[] { + return Array.from(this.getCombinedSelectedFeatureIds()); + } + /** * Returns the typed event emitter used by this plot instance. */ @@ -94,7 +100,7 @@ export abstract class PlotBaseInteractive extends PlotBaseData { this._sourceFeatures = collection.features; this._selectedMarkDatums = new Set(); this._selectedFeatureIds = new Set(); - this._selectionOrigin = null; + this._externalSelectedFeatureIds = new Set(); this._activeBrushes.clear(); this.clearBrushVisuals(); this._brushBehaviors.clear(); @@ -102,15 +108,25 @@ export abstract class PlotBaseInteractive extends PlotBaseData { } /** - * Applies an externally authored selection to the plot. + * Applies an externally authored highlight set to the plot. * * @param selection Source feature ids to highlight. * @throws Never throws. */ setSelection(selection: number[]): void { + this._externalSelectedFeatureIds = new Set(selection); + this.renderSelection(); + } + + /** + * Replaces the locally owned selection for this plot only. + * + * @param selection Source feature ids owned by this plot. + * @throws Never throws. + */ + setLocalSelection(selection: number[]): void { this._selectedFeatureIds = new Set(selection); - this._selectionOrigin = selection.length > 0 ? 'external' : null; - this.syncSelectedMarksFromFeatures(); + this.syncSelectedMarksFromLocalFeatures(); if (selection.length === 0) { this._activeBrushes.clear(); this.clearBrushVisuals(); @@ -206,23 +222,8 @@ export abstract class PlotBaseInteractive extends PlotBaseData { const datum = d as AutkDatum; - if (this._selectionProjection === 'aggregated') { - if (this._selectionOrigin === 'local') { - return this._selectedMarkDatums.has(d as object); - } - if (this._selectionOrigin === 'external') { - return (datum.autkIds ?? []).some(fid => this._selectedFeatureIds.has(fid)); - } - return false; - } - - if (this._selectedMarkDatums.has(d as object)) return true; - - if (this._selectedFeatureIds.size > 0) { - return (datum.autkIds ?? []).some(fid => this._selectedFeatureIds.has(fid)); - } - - return false; + const combinedIds = this.getCombinedSelectedFeatureIds(); + return combinedIds.size > 0 && (datum.autkIds ?? []).some(fid => combinedIds.has(fid)); } /** @@ -242,19 +243,17 @@ export abstract class PlotBaseInteractive extends PlotBaseData { plot._selectedMarkDatums.add(d as object); } plot.syncSelectedFeaturesFromMarks(); - plot._selectionOrigin = plot._selectedFeatureIds.size > 0 ? 'local' : null; plot.renderSelection(); plot.events.emit(PlotEvent.CLICK, { selection: plot.selection }); }); }); - cls.on('click', function () { - plot._selectedMarkDatums = new Set(); - plot._selectedFeatureIds = new Set(); - plot._selectionOrigin = null; - plot.renderSelection(); - plot.events.emit(PlotEvent.CLICK, { selection: [] }); - }); + if (this._backgroundClickClearsSelection) { + cls.on('click', function () { + plot.setLocalSelection([]); + plot.events.emit(PlotEvent.CLICK, { selection: [] }); + }); + } } /** @@ -439,7 +438,6 @@ export abstract class PlotBaseInteractive extends PlotBaseData { }); this.syncSelectedFeaturesFromMarks(); - this._selectionOrigin = this._selectedFeatureIds.size > 0 ? 'local' : null; return this.selection; } @@ -511,18 +509,10 @@ export abstract class PlotBaseInteractive extends PlotBaseData { * Restores locally selected aggregated marks after transformed rows are recreated. */ private restoreLocalSelectionAfterDraw(): void { - if (this._selectionProjection !== 'aggregated' || this._selectionOrigin !== 'local') { + if (this._selectedFeatureIds.size === 0) { return; } - - const selectedMarks = new Set(); - for (const datum of this._data) { - const ids = datum.autkIds ?? []; - if (ids.some(fid => this._selectedFeatureIds.has(fid))) { - selectedMarks.add(datum as object); - } - } - this._selectedMarkDatums = selectedMarks; + this.syncSelectedMarksFromLocalFeatures(); } /** @@ -736,7 +726,6 @@ export abstract class PlotBaseInteractive extends PlotBaseData { if (activeBrushes.size === 0) { this._selectedMarkDatums = new Set(); this._selectedFeatureIds = new Set(); - this._selectionOrigin = null; } else { this.resolveSelectionFromRects(activeBrushes); } @@ -744,25 +733,6 @@ export abstract class PlotBaseInteractive extends PlotBaseData { this.events.emit(event, { selection: this.selection }); } - /** - * Resolves whether this plot should use bijective or aggregated selection projection. - * - * @param transform Optional transform configuration for the plot. - * @returns Selection projection mode used by interaction logic. - */ - private resolveSelectionProjection(transform: PlotTransformConfig | undefined): 'bijective' | 'aggregated' { - const preset = transform?.preset; - if ( - preset === 'binning-1d' || - preset === 'binning-2d' || - preset === 'binning-events' || - preset === 'reduce-series' - ) { - return 'aggregated'; - } - return 'bijective'; - } - /** * Rebuilds the selected source feature id set from the currently selected marks. */ @@ -776,9 +746,9 @@ export abstract class PlotBaseInteractive extends PlotBaseData { } /** - * Rebuilds the selected mark set from the current selected source feature ids. + * Rebuilds the locally selected mark set from the current selected source feature ids. */ - private syncSelectedMarksFromFeatures(): void { + private syncSelectedMarksFromLocalFeatures(): void { const selectedMarks = new Set(); if (this._selectedFeatureIds.size === 0) { @@ -798,4 +768,11 @@ export abstract class PlotBaseInteractive extends PlotBaseData { this._selectedMarkDatums = selectedMarks; } + + /** + * Returns the union of local and externally provided feature ids. + */ + private getCombinedSelectedFeatureIds(): Set { + return new Set([...this._selectedFeatureIds, ...this._externalSelectedFeatureIds]); + } } diff --git a/autk-plot/src/plot-types/histogram.ts b/autk-plot/src/plot-types/histogram.ts index 17d4c9c7..6b9fed48 100644 --- a/autk-plot/src/plot-types/histogram.ts +++ b/autk-plot/src/plot-types/histogram.ts @@ -156,7 +156,7 @@ export class Histogram extends PlotBaseInteractive { // Highlight a bin only when every feature mapped to that bin is selected. protected override renderSelection(): void { - const selectedSet = new Set(this.selection); + const selectedSet = new Set(this.highlightedSelection); const plot = this; d3.select(this._div).selectAll('.autkMark').style('fill', function (_d: unknown, binIdx: number) { const bin = plot.binData[binIdx]; @@ -179,13 +179,13 @@ export class Histogram extends PlotBaseInteractive { const next = allSelected ? current.filter((id) => !binFeatureIds.includes(id)) : [...new Set([...current, ...binFeatureIds])]; - plot.setSelection(next); + plot.setLocalSelection(next); plot.events.emit(PlotEvent.CLICK, { selection: plot.selection }); }); }); cls.on('click', function () { - plot.setSelection([]); + plot.setLocalSelection([]); plot.events.emit(PlotEvent.CLICK, { selection: [] }); }); } @@ -213,9 +213,9 @@ export class Histogram extends PlotBaseInteractive { brushedBins.add(binIdx); } }); - plot.setSelection(plot.binIndicesToFeatureIds(brushedBins)); + plot.setLocalSelection(plot.binIndicesToFeatureIds(brushedBins)); } else { - plot.setSelection([]); + plot.setLocalSelection([]); } plot.events.emit(PlotEvent.BRUSH, { selection: plot.selection }); }); @@ -247,9 +247,9 @@ export class Histogram extends PlotBaseInteractive { brushedBins.add(binIdx); } }); - plot.setSelection(plot.binIndicesToFeatureIds(brushedBins)); + plot.setLocalSelection(plot.binIndicesToFeatureIds(brushedBins)); } else { - plot.setSelection([]); + plot.setLocalSelection([]); } plot.events.emit(PlotEvent.BRUSH_X, { selection: plot.selection }); }); @@ -281,9 +281,9 @@ export class Histogram extends PlotBaseInteractive { brushedBins.add(binIdx); } }); - plot.setSelection(plot.binIndicesToFeatureIds(brushedBins)); + plot.setLocalSelection(plot.binIndicesToFeatureIds(brushedBins)); } else { - plot.setSelection([]); + plot.setLocalSelection([]); } plot.events.emit(PlotEvent.BRUSH_Y, { selection: plot.selection }); }); diff --git a/autk-plot/src/plots/barchart.ts b/autk-plot/src/plots/barchart.ts index 47a66b71..b55d8e7d 100644 --- a/autk-plot/src/plots/barchart.ts +++ b/autk-plot/src/plots/barchart.ts @@ -236,7 +236,7 @@ export class Barchart extends PlotBaseInteractive { } protected override renderSelection(): void { - const selectedSet = new Set(this.selection); + const selectedSet = new Set(this.highlightedSelection); d3.select(this._div) .selectAll('.autkMark') .style('fill', (datum: unknown) => { @@ -264,13 +264,13 @@ export class Barchart extends PlotBaseInteractive { ? current.filter((id) => !featureIds.includes(id)) : [...new Set([...current, ...featureIds])]; - plot.setSelection(next); + plot.setLocalSelection(next); plot.events.emit(PlotEvent.CLICK, { selection: plot.selection }); }); }); clearLayer.on('click', function () { - plot.setSelection([]); + plot.setLocalSelection([]); plot.events.emit(PlotEvent.CLICK, { selection: [] }); }); } diff --git a/autk-plot/src/plots/scatterplot.ts b/autk-plot/src/plots/scatterplot.ts index 07b0e93d..ec340377 100644 --- a/autk-plot/src/plots/scatterplot.ts +++ b/autk-plot/src/plots/scatterplot.ts @@ -49,6 +49,8 @@ export class Scatterplot extends PlotBaseInteractive { protected mapX!: d3.ScaleLinear; /** Linear scale mapping the y-attribute domain to pixel coordinates. */ protected mapY!: d3.ScaleLinear; + /** Clicking the plot background should not clear the selection. */ + protected override _backgroundClickClearsSelection = false; /** * Creates a scatter plot instance and performs the initial draw. diff --git a/autk-provenance/src/adapters/map/recording-controls.ts b/autk-provenance/src/adapters/map/recording-controls.ts new file mode 100644 index 00000000..95763ce5 --- /dev/null +++ b/autk-provenance/src/adapters/map/recording-controls.ts @@ -0,0 +1,139 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../../types'; +import { ProvenanceAction } from '../../types'; +import type { CustomControlConfig, MapRecordCallback } from './types'; +import { arraysEqual } from './recording-shared'; +import { getActiveLayerId, getLayerIds, getThematicEnabled, getVisibleLayerIds } from './utils'; + +export function createControlRecorder(options: { + map: IMapForProvenance; + customControls: CustomControlConfig[]; + onRecord: MapRecordCallback; + getCurrentState: () => AutarkProvenanceState; + buildUiDelta: (overrides?: Partial>) => Partial; +}) { + const { map, customControls, onRecord, getCurrentState, buildUiDelta } = options; + + return { + recordCustomControl(target: Element, eventType: 'click' | 'change'): boolean { + for (const control of customControls) { + if (control.event !== eventType) continue; + const match = target.matches(control.selector) ? target : target.closest(control.selector); + if (!match) continue; + onRecord(control.actionType, control.getLabel(match), control.getStateDelta(match)); + return true; + } + return false; + }, + + recordLayerControl(target: Element): boolean { + const button = target.closest('[data-autk-map-control]') as HTMLElement | null; + if (!button) return false; + + const control = button.dataset.autkMapControl; + const layerId = button.dataset.layerId; + if (!control || !layerId) return false; + + const currentUi = getCurrentState().ui; + const layer = map.layerManager.searchByLayerId(layerId); + + if (control === 'visibility') { + const nextVisibleLayerIds = getVisibleLayerIds(map); + const previousVisibleLayerIds = currentUi?.visibleLayerIds ?? getLayerIds(map); + if (arraysEqual(previousVisibleLayerIds, nextVisibleLayerIds)) return true; + onRecord( + ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, + nextVisibleLayerIds.includes(layerId) ? `Show layer: ${layerId}` : `Hide layer: ${layerId}`, + buildUiDelta({ visibleLayerIds: nextVisibleLayerIds }), + ); + return true; + } + + if (control === 'thematic') { + const nowThematic = !!layer?.layerRenderInfo?.isColorMap; + const prevThematic = currentUi?.thematicEnabled ?? false; + if (prevThematic === nowThematic) return true; + onRecord( + ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, + nowThematic ? `Enable thematic: ${layerId}` : `Disable thematic: ${layerId}`, + buildUiDelta({ thematicEnabled: nowThematic, activeLayerId: layerId }), + ); + return true; + } + + if (control === 'active-layer') { + const nextActiveLayerId = getActiveLayerId(map); + const previousActiveLayerId = currentUi?.activeLayerId ?? null; + if (previousActiveLayerId === nextActiveLayerId) return true; + onRecord( + ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, + `Active layer: ${nextActiveLayerId ?? layerId}`, + buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: !!layer?.layerRenderInfo?.isColorMap }), + ); + return true; + } + + return false; + }, + + recordThematicCheckbox(target: HTMLInputElement): void { + onRecord( + ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, + target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', + buildUiDelta({ thematicEnabled: target.checked }), + ); + }, + + recordActiveLayerRadio(target: HTMLInputElement): void { + onRecord( + ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, + `Active layer: ${getActiveLayerId(map) ?? target.value}`, + buildUiDelta({ activeLayerId: getActiveLayerId(map) ?? target.value }), + ); + }, + + recordVisibleLayerCheckbox(target: HTMLInputElement): void { + onRecord( + ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, + target.checked ? `Show layer: ${target.value || 'layer'}` : `Hide layer: ${target.value || 'layer'}`, + buildUiDelta({ visibleLayerIds: getVisibleLayerIds(map) }), + ); + }, + + recordUpdateRenderInfo(layerId: unknown, params: unknown): boolean { + const info = params && typeof params === 'object' && 'renderInfo' in (params as Record) + ? (params as { renderInfo?: Record }).renderInfo ?? {} + : (params as Record | undefined) ?? {}; + const resolvedLayerId = typeof layerId === 'string' ? layerId : 'layer'; + + if ('isSkip' in info) { + const nextVisibleLayerIds = getVisibleLayerIds(map); + const previousVisibleLayerIds = getCurrentState().ui?.visibleLayerIds ?? getLayerIds(map); + if (arraysEqual(previousVisibleLayerIds, nextVisibleLayerIds)) return true; + onRecord( + ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, + nextVisibleLayerIds.includes(resolvedLayerId) ? `Show layer: ${resolvedLayerId}` : `Hide layer: ${resolvedLayerId}`, + buildUiDelta({ visibleLayerIds: nextVisibleLayerIds }), + ); + return true; + } + + if ('isColorMap' in info) { + const nextActiveLayerId = getActiveLayerId(map); + const nextThematicEnabled = getThematicEnabled(map); + const previousUi = getCurrentState().ui; + if ( + (previousUi?.activeLayerId ?? nextActiveLayerId) !== nextActiveLayerId + || (previousUi?.thematicEnabled ?? nextThematicEnabled) !== nextThematicEnabled + ) { + onRecord( + ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, + nextThematicEnabled ? `Enable thematic: ${nextActiveLayerId ?? resolvedLayerId}` : `Disable thematic: ${resolvedLayerId}`, + buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: nextThematicEnabled }), + ); + } + } + + return true; + }, + }; +} diff --git a/autk-provenance/src/adapters/map/recording-shared.ts b/autk-provenance/src/adapters/map/recording-shared.ts new file mode 100644 index 00000000..23045bae --- /dev/null +++ b/autk-provenance/src/adapters/map/recording-shared.ts @@ -0,0 +1,20 @@ +import type { MapViewState } from '../../types'; + +export function selectionSignature(selection: number[]): string { + return selection.join(','); +} + +export function arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +export function viewsEqual(a: MapViewState | undefined, b: MapViewState | undefined, epsilon = 1e-6): boolean { + if (!a || !b) return a === b; + return vectorsEqual(a.eye, b.eye, epsilon) + && vectorsEqual(a.lookAt, b.lookAt, epsilon) + && vectorsEqual(a.up, b.up, epsilon); +} + +function vectorsEqual(a: number[], b: number[], epsilon: number): boolean { + return a.length === b.length && a.every((value, index) => Math.abs(value - b[index]) <= epsilon); +} diff --git a/autk-provenance/src/adapters/map/recording-view.ts b/autk-provenance/src/adapters/map/recording-view.ts new file mode 100644 index 00000000..2df355f2 --- /dev/null +++ b/autk-provenance/src/adapters/map/recording-view.ts @@ -0,0 +1,48 @@ +import type { AutarkProvenanceState, MapViewState } from '../../types'; +import { ProvenanceAction } from '../../types'; +import type { MapRecordCallback } from './types'; +import { viewsEqual } from './recording-shared'; + +const MAP_VIEW_DEBOUNCE_MS = 180; + +export function createViewRecorder( + onRecord: MapRecordCallback, + getCurrentState: () => AutarkProvenanceState, + isApplyingState: () => boolean, +): { + schedule(viewState: MapViewState): void; + stop(): void; +} { + let pendingViewState: MapViewState | null = null; + let viewTimer: ReturnType | null = null; + + const flushPendingViewState = () => { + if (!pendingViewState || isApplyingState()) return; + const nextViewState = pendingViewState; + pendingViewState = null; + if (viewsEqual(getCurrentState().view, nextViewState)) return; + onRecord(ProvenanceAction.MAP_VIEW, `View changed (alt: ${nextViewState.eye[2].toFixed(0)})`, { view: nextViewState }); + }; + + return { + schedule(viewState: MapViewState) { + pendingViewState = { + eye: [...viewState.eye] as [number, number, number], + lookAt: [...viewState.lookAt] as [number, number, number], + up: [...viewState.up] as [number, number, number], + }; + if (viewTimer) clearTimeout(viewTimer); + viewTimer = setTimeout(() => { + viewTimer = null; + flushPendingViewState(); + }, MAP_VIEW_DEBOUNCE_MS); + }, + stop() { + if (viewTimer) { + clearTimeout(viewTimer); + viewTimer = null; + } + pendingViewState = null; + }, + }; +} diff --git a/autk-provenance/src/adapters/map/recording.ts b/autk-provenance/src/adapters/map/recording.ts index 860708c1..942f5788 100644 --- a/autk-provenance/src/adapters/map/recording.ts +++ b/autk-provenance/src/adapters/map/recording.ts @@ -1,30 +1,13 @@ import type { AutarkProvenanceState, IMapForProvenance, MapViewState } from '../../types'; import { ProvenanceAction } from '../../types'; import { isTargetInMapContainer } from './dom'; +import { createControlRecorder } from './recording-controls'; +import { selectionSignature } from './recording-shared'; +import { createViewRecorder } from './recording-view'; import type { CustomControlConfig, MapRecordCallback, ResolvedMapSelectors } from './types'; -import { getActiveLayerId, getLayerIds, getMenuOpen, getThematicEnabled, getVisibleLayerIds, isElement } from './utils'; +import { getActiveLayerId, getMenuOpen, getThematicEnabled, isElement } from './utils'; const MAP_PICK_EVENT = 'picking'; -const MAP_VIEW_DEBOUNCE_MS = 180; - -function selectionSignature(selection: number[]): string { - return selection.join(','); -} - -function viewsEqual(a: MapViewState | undefined, b: MapViewState | undefined, epsilon = 1e-6): boolean { - if (!a || !b) return a === b; - return vectorsEqual(a.eye, b.eye, epsilon) - && vectorsEqual(a.lookAt, b.lookAt, epsilon) - && vectorsEqual(a.up, b.up, epsilon); -} - -function vectorsEqual(a: number[], b: number[], epsilon: number): boolean { - return a.length === b.length && a.every((value, index) => Math.abs(value - b[index]) <= epsilon); -} - -function arraysEqual(a: string[], b: string[]): boolean { - return a.length === b.length && a.every((value, index) => value === b[index]); -} export function createMapRecordingController(options: { map: IMapForProvenance; @@ -40,40 +23,17 @@ export function createMapRecordingController(options: { const mapUiObj = (map.ui ?? {}) as Record; const wrappedMethods = new Map(); const cleanups: Array<() => void> = []; - let pendingViewState: MapViewState | null = null; - let viewTimer: ReturnType | null = null; - - function flushPendingViewState(): void { - if (!pendingViewState || isApplyingState()) return; - const nextViewState = pendingViewState; - pendingViewState = null; - const currentViewState = getCurrentState().view; - if (viewsEqual(currentViewState, nextViewState)) return; - onRecord(ProvenanceAction.MAP_VIEW, `View changed (alt: ${nextViewState.eye[2].toFixed(0)})`, { view: nextViewState }); - } + const controls = createControlRecorder({ map, customControls, onRecord, getCurrentState, buildUiDelta }); + const viewRecorder = createViewRecorder(onRecord, getCurrentState, isApplyingState); - function scheduleViewRecord(viewState: MapViewState): void { - pendingViewState = { - eye: [...viewState.eye] as [number, number, number], - lookAt: [...viewState.lookAt] as [number, number, number], - up: [...viewState.up] as [number, number, number], - }; - if (viewTimer) clearTimeout(viewTimer); - viewTimer = setTimeout(() => { - viewTimer = null; - flushPendingViewState(); - }, MAP_VIEW_DEBOUNCE_MS); - } - - function wrapMapMethod(methodName: string, onAfter: (args: unknown[]) => void): void { + const wrapMapMethod = (methodName: string, onAfter: (args: unknown[]) => void) => { wrapMethod(mapObj, methodName, onAfter); - } - - function wrapUiMethod(methodName: string, onAfter: (args: unknown[]) => void): void { + }; + const wrapUiMethod = (methodName: string, onAfter: (args: unknown[]) => void) => { wrapMethod(mapUiObj, methodName, onAfter); - } + }; - function wrapMethod(target: Record, methodName: string, onAfter: (args: unknown[]) => void): void { + const wrapMethod = (target: Record, methodName: string, onAfter: (args: unknown[]) => void) => { const current = target[methodName]; if (typeof current !== 'function' || wrappedMethods.has(methodName)) return; wrappedMethods.set(methodName, current); @@ -82,76 +42,97 @@ export function createMapRecordingController(options: { if (!isApplyingState()) onAfter(args); return result; }; - } - - function recordCustomControl(target: Element, eventType: 'click' | 'change'): boolean { - for (const control of customControls) { - if (control.event !== eventType) continue; - const match = target.matches(control.selector) ? target : target.closest(control.selector); - if (match) { - onRecord(control.actionType, control.getLabel(match), control.getStateDelta(match)); - return true; - } - } - return false; - } - - function recordLayerControl(target: Element): boolean { - const button = target.closest('[data-autk-map-control]') as HTMLElement | null; - if (!button) return false; - - const control = button.dataset.autkMapControl; - const layerId = button.dataset.layerId; - if (!control || !layerId) return false; - - const currentUi = getCurrentState().ui; - const layer = map.layerManager.searchByLayerId(layerId); - - if (control === 'visibility') { - const nextVisibleLayerIds = getVisibleLayerIds(map); - const previousVisibleLayerIds = currentUi?.visibleLayerIds ?? getLayerIds(map); - if (arraysEqual(previousVisibleLayerIds, nextVisibleLayerIds)) return true; - onRecord( - ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, - nextVisibleLayerIds.includes(layerId) ? `Show layer: ${layerId}` : `Hide layer: ${layerId}`, - buildUiDelta({ visibleLayerIds: nextVisibleLayerIds }), - ); - return true; - } + }; - if (control === 'thematic') { - // Read thematic state directly from the clicked layer — getThematicEnabled() reads from - // map.ui.activeLayer which may be null, causing changes on non-active layers to be missed. - const nowThematic = !!layer?.layerRenderInfo?.isColorMap; - const prevThematic = currentUi?.thematicEnabled ?? false; - if (prevThematic === nowThematic) return true; - onRecord( - ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, - nowThematic ? `Enable thematic: ${layerId}` : `Disable thematic: ${layerId}`, - buildUiDelta({ thematicEnabled: nowThematic, activeLayerId: layerId }), + const attachPickListener = () => { + const pickListener = (selection: number[], layerId: string) => { + const currentState = getCurrentState(); + const normalizedSelection = [...new Set(selection)].sort((a, b) => a - b); + const previousMapSelection = currentState.selection.map?.ids ?? []; + const previousLayerId = getCurrentState().selection.map?.layerId ?? null; + const previousPlots = currentState.selection.plots ?? {}; + const previousCombined = new Set([ + ...previousMapSelection, + ...Object.values(previousPlots).flatMap((plot) => plot.ids), + ]); + const nextCombined = new Set(normalizedSelection); + const removedIds = [...previousCombined].filter((id) => !nextCombined.has(id)); + const removedIdSet = new Set(removedIds); + const nextPlots = Object.fromEntries( + Object.entries(previousPlots).map(([plotId, plotState]) => [ + plotId, + removedIdSet.size > 0 + ? { ...plotState, ids: plotState.ids.filter((id) => !removedIdSet.has(id)) } + : plotState, + ]), ); - return true; - } + const coordinatedPlotIds = new Set(Object.values(nextPlots).flatMap((plot) => plot.ids)); + const nextMapOwnedSelection = normalizedSelection.filter((id) => !coordinatedPlotIds.has(id)); + + if ( + selectionSignature(nextMapOwnedSelection) === selectionSignature(previousMapSelection) && + JSON.stringify(nextPlots) === JSON.stringify(previousPlots) && + (nextMapOwnedSelection.length > 0 ? previousLayerId === layerId : previousMapSelection.length === 0) + ) { + return; + } + const label = normalizedSelection.length === 0 ? `Cleared selection on ${layerId}` : `Picked ${normalizedSelection.length} feature(s) on ${layerId}`; + onRecord(ProvenanceAction.MAP_PICK, label, { + selection: { + map: nextMapOwnedSelection.length > 0 ? { layerId, ids: nextMapOwnedSelection } : null, + plots: nextPlots, + }, + }); + }; + map.mapEvents.addEventListener(MAP_PICK_EVENT, pickListener); + cleanups.push(() => map.mapEvents.removeEventListener?.(MAP_PICK_EVENT, pickListener)); + }; - if (control === 'active-layer') { - const nextActiveLayerId = getActiveLayerId(map); - // Compare stored previous id against the new id (not the fallback-to-new pattern) - const previousActiveLayerId = currentUi?.activeLayerId ?? null; - if (previousActiveLayerId === nextActiveLayerId) return true; - onRecord( - ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, - `Active layer: ${nextActiveLayerId ?? layerId}`, - buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: !!layer?.layerRenderInfo?.isColorMap }), - ); - return true; - } + const attachViewListener = () => { + if (!map.addViewListener) return; + const viewListener = (viewState: MapViewState) => { + if (!isApplyingState()) viewRecorder.schedule(viewState); + }; + map.addViewListener(viewListener); + cleanups.push(() => map.removeViewListener?.(viewListener)); + }; - return false; - } + const attachDocumentListeners = () => { + if (typeof document === 'undefined') return; + const clickListener = (event: Event) => { + const target = event.target; + if (isApplyingState() || !isElement(target)) return; + if (controls.recordCustomControl(target, 'click')) return; + if (controls.recordLayerControl(target)) return; + if (!isTargetInMapContainer(map, target)) return; + if (target.closest(selectors.menuIcon)) { + const mapMenuOpen = getMenuOpen(map, selectors); + onRecord(ProvenanceAction.MAP_UI_MENU_TOGGLE, mapMenuOpen ? 'Opened map menu' : 'Closed map menu', buildUiDelta({ mapMenuOpen })); + } + }; + const changeListener = (event: Event) => { + const target = event.target; + if (isApplyingState() || !isElement(target)) return; + if (controls.recordCustomControl(target, 'change')) return; + if (!isTargetInMapContainer(map, target) || !(target instanceof HTMLInputElement)) return; + if (target.id === selectors.thematicCheckbox.replace(/^#/, '')) { + controls.recordThematicCheckbox(target); + } else if (target.classList.contains(selectors.activeLayerRadioClass.replace(/^\./, ''))) { + controls.recordActiveLayerRadio(target); + } else if (target.type === 'checkbox' && target.closest(`#${selectors.visibleLayerList.replace(/^#/, '')}`)) { + controls.recordVisibleLayerCheckbox(target); + } + }; + document.addEventListener('click', clickListener); + document.addEventListener('change', changeListener); + cleanups.push(() => document.removeEventListener('click', clickListener)); + cleanups.push(() => document.removeEventListener('change', changeListener)); + }; return { start: () => { if (cleanups.length > 0) return; + wrapMapMethod('init', () => onRecord(ProvenanceAction.MAP_INIT, 'Map initialized', buildUiDelta())); wrapMapMethod('loadGeoJsonLayer', ([layerName]) => { onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Map layer loaded: ${typeof layerName === 'string' ? layerName : 'layer'}`, buildUiDelta()); @@ -160,39 +141,7 @@ export function createMapRecordingController(options: { onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${typeof layerName === 'string' ? layerName : 'raster'}`, buildUiDelta()); }); wrapMapMethod('updateRenderInfo', ([layerId, params]) => { - const info = params && typeof params === 'object' && 'renderInfo' in (params as Record) - ? (params as { renderInfo?: Record }).renderInfo ?? {} - : (params as Record | undefined) ?? {}; - const resolvedLayerId = typeof layerId === 'string' ? layerId : 'layer'; - - if ('isSkip' in info) { - const nextVisibleLayerIds = getVisibleLayerIds(map); - const previousVisibleLayerIds = getCurrentState().ui?.visibleLayerIds ?? getLayerIds(map); - if (!arraysEqual(previousVisibleLayerIds, nextVisibleLayerIds)) { - onRecord( - ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, - nextVisibleLayerIds.includes(resolvedLayerId) ? `Show layer: ${resolvedLayerId}` : `Hide layer: ${resolvedLayerId}`, - buildUiDelta({ visibleLayerIds: nextVisibleLayerIds }) - ); - return; - } - } - - if ('isColorMap' in info) { - const nextActiveLayerId = getActiveLayerId(map); - const nextThematicEnabled = getThematicEnabled(map); - const previousUi = getCurrentState().ui; - if ( - (previousUi?.activeLayerId ?? nextActiveLayerId) !== nextActiveLayerId - || (previousUi?.thematicEnabled ?? nextThematicEnabled) !== nextThematicEnabled - ) { - onRecord( - ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, - nextThematicEnabled ? `Enable thematic: ${nextActiveLayerId ?? resolvedLayerId}` : `Disable thematic: ${resolvedLayerId}`, - buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: nextThematicEnabled }) - ); - } - } + controls.recordUpdateRenderInfo(layerId, params); }); wrapUiMethod('changeActiveLayer', () => { const nextActiveLayerId = getActiveLayerId(map); @@ -201,76 +150,16 @@ export function createMapRecordingController(options: { onRecord( ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, `Active layer: ${nextActiveLayerId ?? 'layer'}`, - buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: nextThematicEnabled }) + buildUiDelta({ activeLayerId: nextActiveLayerId, thematicEnabled: nextThematicEnabled }), ); }); - const pickListener = (selection: number[], layerId: string) => { - const activePlotIds = new Set(Object.values(getCurrentState().selection.plots ?? {}).flatMap((plot) => plot.ids)); - const mapOwnedSelection = selection.filter((id) => !activePlotIds.has(id)); - const previousMapSelection = getCurrentState().selection.map?.ids ?? []; - const previousLayerId = getCurrentState().selection.map?.layerId ?? null; - if ( - selectionSignature(mapOwnedSelection) === selectionSignature(previousMapSelection) && - (mapOwnedSelection.length > 0 ? previousLayerId === layerId : previousMapSelection.length === 0) - ) { - return; - } - const label = mapOwnedSelection.length === 0 ? `Cleared selection on ${layerId}` : `Picked ${mapOwnedSelection.length} feature(s) on ${layerId}`; - onRecord(ProvenanceAction.MAP_PICK, label, { selection: { map: { layerId, ids: mapOwnedSelection }, plots: {} } }); - }; - map.mapEvents.addEventListener(MAP_PICK_EVENT, pickListener); - cleanups.push(() => map.mapEvents.removeEventListener?.(MAP_PICK_EVENT, pickListener)); - - if (map.addViewListener) { - const viewListener = (viewState: MapViewState) => { - if (!isApplyingState()) scheduleViewRecord(viewState); - }; - map.addViewListener(viewListener); - cleanups.push(() => map.removeViewListener?.(viewListener)); - } - - if (typeof document === 'undefined') return; - const clickListener = (event: Event) => { - const target = event.target; - if (isApplyingState() || !isElement(target)) return; - if (recordCustomControl(target, 'click')) return; - // recordLayerControl must run before isTargetInMapContainer: the map UI's - // onClick handler calls updateRenderInfo which triggers refreshLayerList(), - // removing the original button from the DOM before this listener fires. - // Element.closest() still works on detached nodes via the element's own - // ancestor chain, but parentElement.contains() returns false for detached nodes. - if (recordLayerControl(target)) return; - if (!isTargetInMapContainer(map, target)) return; - if (target.closest(selectors.menuIcon)) { - const mapMenuOpen = getMenuOpen(map, selectors); - onRecord(ProvenanceAction.MAP_UI_MENU_TOGGLE, mapMenuOpen ? 'Opened map menu' : 'Closed map menu', buildUiDelta({ mapMenuOpen })); - } - }; - const changeListener = (event: Event) => { - const target = event.target; - if (isApplyingState() || !isElement(target)) return; - if (recordCustomControl(target, 'change')) return; - if (!isTargetInMapContainer(map, target) || !(target instanceof HTMLInputElement)) return; - if (target.id === selectors.thematicCheckbox.replace(/^#/, '')) { - onRecord(ProvenanceAction.MAP_UI_THEMATIC_TOGGLE, target.checked ? 'Enabled thematic legend' : 'Disabled thematic legend', buildUiDelta({ thematicEnabled: target.checked })); - } else if (target.classList.contains(selectors.activeLayerRadioClass.replace(/^\./, ''))) { - onRecord(ProvenanceAction.MAP_UI_ACTIVE_LAYER_CHANGE, `Active layer: ${getActiveLayerId(map) ?? target.value}`, buildUiDelta({ activeLayerId: getActiveLayerId(map) ?? target.value })); - } else if (target.type === 'checkbox' && target.closest(`#${selectors.visibleLayerList.replace(/^#/, '')}`)) { - onRecord(ProvenanceAction.MAP_UI_VISIBLE_LAYER_TOGGLE, target.checked ? `Show layer: ${target.value || 'layer'}` : `Hide layer: ${target.value || 'layer'}`, buildUiDelta({ visibleLayerIds: getVisibleLayerIds(map) })); - } - }; - document.addEventListener('click', clickListener); - document.addEventListener('change', changeListener); - cleanups.push(() => document.removeEventListener('click', clickListener)); - cleanups.push(() => document.removeEventListener('change', changeListener)); + attachPickListener(); + attachViewListener(); + attachDocumentListeners(); }, stop: () => { - if (viewTimer) { - clearTimeout(viewTimer); - viewTimer = null; - } - pendingViewState = null; + viewRecorder.stop(); cleanups.splice(0).forEach((cleanup) => cleanup()); wrappedMethods.forEach((original, methodName) => { if (mapObj[methodName] !== undefined) { diff --git a/autk-provenance/src/adapters/plot-adapter.ts b/autk-provenance/src/adapters/plot-adapter.ts index dd474f22..765a746a 100644 --- a/autk-provenance/src/adapters/plot-adapter.ts +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -26,7 +26,11 @@ const EVENT_ACTION_MAP: Record = { }; function selectionSignature(selection: number[]): string { - return selection.join(','); + return normalizeSelection(selection).join(','); +} + +function normalizeSelection(selection: number[]): number[] { + return [...new Set(selection)].sort((a, b) => a - b); } function plotTypeLabel(plotType: string): string { @@ -42,8 +46,7 @@ interface PlotEntry { export function createPlotAdapter( plots: IPlotForProvenance[], - onRecord: PlotRecordCallback, - getCurrentState: () => AutarkProvenanceState + onRecord: PlotRecordCallback ): PlotAdapterApi { let isApplyingState = false; @@ -60,17 +63,7 @@ export function createPlotAdapter( for (const [event, actionType] of Object.entries(EVENT_ACTION_MAP)) { const fn = (selection: number[]) => { - const currentState = getCurrentState(); - const previousOwnedSelection = currentState.selection.plots?.[plot.plotId]?.ids ?? []; - const previousOwnedSet = new Set(previousOwnedSelection); - const mapOwnedSet = new Set(currentState.selection.map?.ids ?? []); - const borrowedPlotIds = new Set( - Object.entries(currentState.selection.plots ?? {}) - .filter(([plotId]) => plotId !== plot.plotId) - .flatMap(([, plotState]) => plotState.ids) - .filter((id) => !previousOwnedSet.has(id)) - ); - const ownedSelection = [...new Set(selection.filter((id) => !mapOwnedSet.has(id) && !borrowedPlotIds.has(id)))]; + const ownedSelection = normalizeSelection(selection); const sig = selectionSignature(ownedSelection); if (sig === entry.lastSelectionSig) return; entry.lastSelectionSig = sig; @@ -171,7 +164,9 @@ export function createPlotAdapter( ])]; for (const entry of entries) { - entry.lastSelectionSig = selectionSignature(state.selection?.plots?.[entry.plot.plotId]?.ids ?? []); + const ownedIds = state.selection?.plots?.[entry.plot.plotId]?.ids ?? []; + entry.lastSelectionSig = selectionSignature(ownedIds); + entry.plot.setOwnedSelection(ownedIds); entry.plot.setHighlightedIds(coordinatedIds); } } finally { diff --git a/autk-provenance/src/charts/chart-modal.ts b/autk-provenance/src/charts/chart-modal.ts index 52c62555..697cbb04 100644 --- a/autk-provenance/src/charts/chart-modal.ts +++ b/autk-provenance/src/charts/chart-modal.ts @@ -55,7 +55,9 @@ export function createChartModalController(descriptors: ChartModalDescriptor[]): } function syncSelection(): void { - if (active && modalPlot) modalPlot.setSelection([...active.originalPlot.selection]); + if (!active || !modalPlot) return; + modalPlot.setLocalSelection([...active.originalPlot.selection]); + modalPlot.setSelection([...active.originalPlot.highlightedSelection]); } function clearModalPlot(): void { @@ -96,7 +98,7 @@ export function createChartModalController(descriptors: ChartModalDescriptor[]): active.events.forEach((eventName) => { const listener = ({ selection }: { selection: number[] }) => { if (!active) return; - active.originalPlot.setSelection([...selection]); + active.originalPlot.setLocalSelection([...selection]); active.originalPlot.events.emit(eventName as never, { selection: [...selection] } as never); requestAnimationFrame(syncSelection); }; diff --git a/autk-provenance/src/create-autark-provenance.ts b/autk-provenance/src/create-autark-provenance.ts index 77e0db8d..6c8d8729 100644 --- a/autk-provenance/src/create-autark-provenance.ts +++ b/autk-provenance/src/create-autark-provenance.ts @@ -83,7 +83,7 @@ export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): const plotAdapter = plots && plots.length > 0 ? createPlotAdapter(plots, (actionType, label, delta) => { core.applyAction(actionType, label, delta); - }, () => core.getCurrentState() ?? initialState) + }) : null; const dbAdapter = db diff --git a/autk-provenance/src/insights-workspace.ts b/autk-provenance/src/insights-workspace.ts index 9a2be46b..2819f036 100644 --- a/autk-provenance/src/insights-workspace.ts +++ b/autk-provenance/src/insights-workspace.ts @@ -1,18 +1,20 @@ import type { FeatureCollection } from 'geojson'; -import type { AutkMap, MapEvent } from 'autk-map'; -import { Scatterplot, Barchart, ParallelCoordinates, Histogram, PlotEvent, type PlotBaseInteractive } from 'autk-plot'; +import type { AutkMap } from 'autk-map'; import { createAutarkProvenance, type AutarkProvenanceApi } from './create-autark-provenance'; import { renderProvenanceTrailUI } from './provenance-trail-ui'; import { buildInsightsChartSchema } from './charts/chart-config'; +import type { InsightsChartSchema } from './charts/types'; import { createChartModalController } from './charts/chart-modal'; -import type { ChartModalDescriptor, InsightsChartSchema } from './charts/types'; +import type { IDbForProvenance } from './adapters/db-adapter'; +import type { MapSelectorConfig } from './adapters/map-adapter'; import { createInsightsWorkspaceShell } from './ui/workspace-shell'; import { renderWorkspaceSessionInsights } from './ui/workspace-session-insights'; import { ensureInsightsWorkspaceStyles } from './ui/workspace-styles'; import { bindWorkspaceTabs } from './ui/workspace-tabs'; -import type { IDbForProvenance } from './adapters/db-adapter'; -import type { MapSelectorConfig } from './adapters/map-adapter'; -import { PlotType, type MapViewState } from './types'; +import { createWorkspaceCharts, applySchemaToShell } from './workspace/charts'; +import { createMapForProvenance, createThematicControl, applyThematic, mountMapInWorkspace } from './workspace/map-host'; +import { createWorkspacePlotAdapter } from './workspace/plot-adapter'; +import { PlotType } from './types'; export interface RenderInsightsWorkspaceOptions { container: HTMLElement; @@ -31,287 +33,31 @@ export interface RenderInsightsWorkspaceResult { destroy(): void; } -const CARD_CHART_MARGINS = { - scatter: { left: 60, right: 16, top: 48, bottom: 48 }, - bar: { left: 55, right: 16, top: 48, bottom: 72 }, - parallel: { left: 28, right: 28, top: 50, bottom: 36 }, - histogram: { left: 55, right: 16, top: 48, bottom: 48 }, -} as const; - -const MODAL_CHART_MARGINS = { - scatter: { left: 62, right: 16, top: 42, bottom: 48 }, - bar: { left: 55, right: 16, top: 42, bottom: 72 }, - parallel: { left: 28, right: 28, top: 44, bottom: 36 }, - histogram: { left: 55, right: 16, top: 42, bottom: 48 }, -} as const; - -function plotSize(element: HTMLElement, fallbackWidth: number, fallbackHeight: number): { width: number; height: number } { - const rect = element.getBoundingClientRect(); - return { - width: rect.width > 20 ? Math.floor(rect.width) : fallbackWidth, - height: rect.height > 20 ? Math.floor(rect.height) : fallbackHeight, - }; -} - -function createPlotAdapter(plot: PlotBaseInteractive, plotId: string, plotType: PlotType) { - return Object.assign(plot, { - plotId, - plotType, - plotEvents: { - addEventListener(event: string, fn: (selection: number[]) => void) { - plot.events.on(event as PlotEvent, ({ selection }: { selection: number[] }) => fn(selection)); - }, - removeEventListener(event: string, fn: (selection: number[]) => void) { - plot.events.off(event as PlotEvent, fn as never); - }, - }, - setHighlightedIds(ids: number[]) { - plot.setSelection(ids); - }, - }); -} - -function mountMapInWorkspace(map: AutkMap, body: HTMLElement): () => void { - const internals = map as AutkMap & { - _resizeEvents?: { resize?: () => void }; - }; - let frame = 0; - let observer: ResizeObserver | null = null; - - const syncSize = () => { - if (frame) cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { - frame = 0; - internals._resizeEvents?.resize?.(); - map.ui.handleResize(); - map.draw(); - }); - }; - - map.ui.destroy(); - body.replaceChildren(map.canvas); - map.canvas.style.width = '100%'; - map.canvas.style.height = '100%'; - map.ui.buildUi(); - syncSize(); - - if (typeof ResizeObserver !== 'undefined') { - observer = new ResizeObserver(() => syncSize()); - observer.observe(body); - } - - return () => { - if (frame) cancelAnimationFrame(frame); - observer?.disconnect(); - }; -} - -function applyThematic(map: AutkMap, collection: FeatureCollection, layerId: string, property: string): void { - const mapApi = map as AutkMap & { - updateThematic?: (id: string, options: { collection: FeatureCollection; property: string }) => void; - }; - if (property) { - mapApi.updateThematic?.(layerId, { collection, property }); - return; - } - map.updateRenderInfo(layerId, { renderInfo: { isColorMap: false } }); -} - export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions): RenderInsightsWorkspaceResult { const { container, map, collection, layerId, db, title, description, mapConfig } = options; + ensureInsightsWorkspaceStyles(); const schema = buildInsightsChartSchema(collection); const shell = createInsightsWorkspaceShell(container, title, description); const detachTabs = bindWorkspaceTabs(shell.root); - shell.thematicSelect.innerHTML = `${schema.thematicFields.map((field) => ``).join('')}`; - shell.plotPanels.scatter.title.textContent = schema.scatter.title; - shell.plotPanels.scatter.hint.textContent = schema.scatter.subtitle; - shell.plotPanels.bar.title.textContent = schema.bar.title; - shell.plotPanels.bar.hint.textContent = schema.bar.subtitle; - shell.plotPanels.parallel.title.textContent = schema.parallel.title; - shell.plotPanels.parallel.hint.textContent = schema.parallel.subtitle; - shell.plotPanels.histogram.title.textContent = schema.histogram.title; - shell.plotPanels.histogram.hint.textContent = schema.histogram.subtitle; - const unmountMap = mountMapInWorkspace(map, shell.mapBody); - const scatterDims = plotSize(shell.plotPanels.scatter.body, 360, 280); - const barDims = plotSize(shell.plotPanels.bar.body, 360, 280); - const parallelDims = plotSize(shell.plotPanels.parallel.body, 360, 280); - const histogramDims = plotSize(shell.plotPanels.histogram.body, 360, 280); + applySchemaToShell(shell, schema); + const unmountMap = mountMapInWorkspace(map, shell.mapBody); + const charts = createWorkspaceCharts(shell, schema); + const chartModal = createChartModalController(charts.modalDescriptors); - const scatter = new Scatterplot({ - div: shell.plotPanels.scatter.body, - collection: schema.collection, - attributes: { axis: [schema.scatter.x.key, schema.scatter.y.key] }, - labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, - width: scatterDims.width, - height: scatterDims.height, - margins: CARD_CHART_MARGINS.scatter, - events: [PlotEvent.CLICK, PlotEvent.BRUSH], - }); - const bar = new Barchart({ - div: shell.plotPanels.bar.body, - collection: schema.bar.collection, - attributes: { axis: [schema.bar.axisField, schema.bar.valueField] }, - labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, - width: barDims.width, - height: barDims.height, - margins: CARD_CHART_MARGINS.bar, - events: [PlotEvent.CLICK], - }); - const parallel = new ParallelCoordinates({ - div: shell.plotPanels.parallel.body, - collection: schema.collection, - attributes: { axis: schema.parallel.fields.map((field) => field.key) }, - labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, - width: parallelDims.width, - height: parallelDims.height, - margins: CARD_CHART_MARGINS.parallel, - events: [PlotEvent.BRUSH_Y], - }); - const histogram = new Histogram({ - div: shell.plotPanels.histogram.body, - collection: schema.collection, - attributes: { axis: [schema.histogram.field.key] }, - labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, - width: histogramDims.width, - height: histogramDims.height, - margins: CARD_CHART_MARGINS.histogram, - events: [PlotEvent.CLICK], + const thematicControl = createThematicControl(map, schema, layerId); + shell.thematicSelect.addEventListener('change', () => { + applyThematic(map, schema.collection, layerId, shell.thematicSelect.value); }); - const modalDescriptors: ChartModalDescriptor[] = [ - { - key: 'scatter', - title: schema.scatter.title, - subtitle: schema.scatter.subtitle, - originalPlot: scatter, - events: [PlotEvent.CLICK, PlotEvent.BRUSH], - trigger: shell.plotPanels.scatter.header, - button: shell.plotPanels.scatter.button, - createModalPlot: (div, width, height) => new Scatterplot({ - div, - collection: schema.collection, - attributes: { axis: [schema.scatter.x.key, schema.scatter.y.key] }, - labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, - width, - height, - margins: MODAL_CHART_MARGINS.scatter, - events: [PlotEvent.CLICK, PlotEvent.BRUSH], - }), - }, - { - key: 'bar', - title: schema.bar.title, - subtitle: schema.bar.subtitle, - originalPlot: bar, - events: [PlotEvent.CLICK], - trigger: shell.plotPanels.bar.header, - button: shell.plotPanels.bar.button, - createModalPlot: (div, width, height) => new Barchart({ - div, - collection: schema.bar.collection, - attributes: { axis: [schema.bar.axisField, schema.bar.valueField] }, - labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, - width, - height, - margins: MODAL_CHART_MARGINS.bar, - events: [PlotEvent.CLICK], - }), - }, - { - key: 'parallel', - title: schema.parallel.title, - subtitle: schema.parallel.subtitle, - originalPlot: parallel, - events: [PlotEvent.BRUSH_Y], - trigger: shell.plotPanels.parallel.header, - button: shell.plotPanels.parallel.button, - createModalPlot: (div, width, height) => new ParallelCoordinates({ - div, - collection: schema.collection, - attributes: { axis: schema.parallel.fields.map((field) => field.key) }, - labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, - width, - height, - margins: MODAL_CHART_MARGINS.parallel, - events: [PlotEvent.BRUSH_Y], - }), - }, - { - key: 'histogram', - title: schema.histogram.title, - subtitle: schema.histogram.subtitle, - originalPlot: histogram, - events: [PlotEvent.CLICK], - trigger: shell.plotPanels.histogram.header, - button: shell.plotPanels.histogram.button, - createModalPlot: (div, width, height) => new Histogram({ - div, - collection: schema.collection, - attributes: { axis: [schema.histogram.field.key] }, - labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, - width, - height, - margins: MODAL_CHART_MARGINS.histogram, - events: [PlotEvent.CLICK], - }), - }, - ]; - const chartModal = createChartModalController(modalDescriptors); - - const thematicControl = { - selector: '.autk-workspace-select', - event: 'change' as const, - actionType: 'MAP_THEMATIC_PROPERTY', - getLabel: (element: Element) => { - const value = (element as HTMLSelectElement).value; - return value ? `Color by: ${value}` : 'Thematic off'; - }, - getStateDelta: (element: Element) => { - const value = (element as HTMLSelectElement).value; - return { filters: { thematicProperty: value || null }, ui: { thematicEnabled: !!value } }; - }, - applyState: (element: Element, state: { filters?: Record }) => { - const value = (state.filters?.thematicProperty as string | null) ?? ''; - (element as HTMLSelectElement).value = value; - applyThematic(map, schema.collection, layerId, value); - }, - }; - shell.thematicSelect.addEventListener('change', () => applyThematic(map, schema.collection, layerId, shell.thematicSelect.value)); - - const mapViewApi = map as AutkMap & { - addViewListener?: (callback: (state: MapViewState) => void) => void; - removeViewListener?: (callback: (state: MapViewState) => void) => void; - setViewState?: (state: MapViewState) => void; - updateRenderInfoProperty?: (layerName: string, property: string, value: unknown) => void; - updateRenderInfo?: (layerName: string, params: unknown) => void; - }; - const mapForProvenance: NonNullable[0]['map']> = { - mapEvents: { - addEventListener(event: string, fn: (selection: number[], currentLayerId: string) => void) { - map.events.on(event as MapEvent, ({ selection, layerId: eventLayerId }: { selection: number[]; layerId: string }) => fn(selection, eventLayerId)); - }, - removeEventListener(event: string, fn: (selection: number[], currentLayerId: string) => void) { - map.events.off(event as MapEvent, fn as never); - }, - }, - addViewListener: mapViewApi.addViewListener?.bind(map), - removeViewListener: mapViewApi.removeViewListener?.bind(map), - setViewState: mapViewApi.setViewState?.bind(map), - canvas: map.canvas, - ui: map.ui, - updateRenderInfoProperty: mapViewApi.updateRenderInfoProperty?.bind(map), - updateRenderInfo: mapViewApi.updateRenderInfo?.bind(map), - layerManager: map.layerManager, - }; const provenance = createAutarkProvenance({ - map: mapForProvenance, + map: createMapForProvenance(map), plots: [ - createPlotAdapter(scatter, 'Scatterplot', PlotType.SCATTERPLOT), - createPlotAdapter(bar, 'Bar Chart', PlotType.BARCHART), - createPlotAdapter(parallel, 'Parallel Coordinates', PlotType.PARALLEL_COORDINATES), - createPlotAdapter(histogram, 'Histogram', PlotType.HISTOGRAM), + createWorkspacePlotAdapter(charts.scatter, 'Scatterplot', PlotType.SCATTERPLOT), + createWorkspacePlotAdapter(charts.bar, 'Bar Chart', PlotType.BARCHART), + createWorkspacePlotAdapter(charts.parallel, 'Parallel Coordinates', PlotType.PARALLEL_COORDINATES), + createWorkspacePlotAdapter(charts.histogram, 'Histogram', PlotType.HISTOGRAM), ], db, mapConfig: { ...(mapConfig ?? {}), customControls: [...(mapConfig?.customControls ?? []), thematicControl] }, @@ -333,6 +79,7 @@ export function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions) renderWorkspaceSessionInsights(shell.chartsInsights, provenance); chartModal.syncSelection(); }; + const unsubscribe = provenance.addObserver(updateNodeCount); updateNodeCount(); diff --git a/autk-provenance/src/types.ts b/autk-provenance/src/types.ts index 81fd836e..1b36bbf6 100644 --- a/autk-provenance/src/types.ts +++ b/autk-provenance/src/types.ts @@ -175,6 +175,7 @@ export interface IPlotForProvenance { addEventListener(event: string, listener: (selection: number[]) => void): void; removeEventListener?(event: string, listener: (selection: number[]) => void): void; }; + setOwnedSelection(selection: number[]): void; setHighlightedIds(selection: number[]): void; } diff --git a/autk-provenance/src/workspace/charts.ts b/autk-provenance/src/workspace/charts.ts new file mode 100644 index 00000000..5a4b5683 --- /dev/null +++ b/autk-provenance/src/workspace/charts.ts @@ -0,0 +1,152 @@ +import { Scatterplot, Barchart, ParallelCoordinates, Histogram, PlotEvent } from 'autk-plot'; +import type { createInsightsWorkspaceShell } from '../ui/workspace-shell'; +import type { ChartModalDescriptor, InsightsChartSchema } from '../charts/types'; + +const CARD_CHART_MARGINS = { + scatter: { left: 60, right: 16, top: 48, bottom: 48 }, + bar: { left: 55, right: 16, top: 48, bottom: 72 }, + parallel: { left: 28, right: 28, top: 50, bottom: 36 }, + histogram: { left: 55, right: 16, top: 48, bottom: 48 }, +} as const; + +const MODAL_CHART_MARGINS = { + scatter: { left: 62, right: 16, top: 42, bottom: 48 }, + bar: { left: 55, right: 16, top: 42, bottom: 72 }, + parallel: { left: 28, right: 28, top: 44, bottom: 36 }, + histogram: { left: 55, right: 16, top: 42, bottom: 48 }, +} as const; + +type InsightsWorkspaceShell = ReturnType; + +function plotSize(element: HTMLElement, fallbackWidth: number, fallbackHeight: number): { width: number; height: number } { + const rect = element.getBoundingClientRect(); + return { + width: rect.width > 20 ? Math.floor(rect.width) : fallbackWidth, + height: rect.height > 20 ? Math.floor(rect.height) : fallbackHeight, + }; +} + +export function applySchemaToShell(shell: InsightsWorkspaceShell, schema: InsightsChartSchema): void { + shell.thematicSelect.innerHTML = `${schema.thematicFields.map((field) => ``).join('')}`; + shell.plotPanels.scatter.title.textContent = schema.scatter.title; + shell.plotPanels.scatter.hint.textContent = schema.scatter.subtitle; + shell.plotPanels.bar.title.textContent = schema.bar.title; + shell.plotPanels.bar.hint.textContent = schema.bar.subtitle; + shell.plotPanels.parallel.title.textContent = schema.parallel.title; + shell.plotPanels.parallel.hint.textContent = schema.parallel.subtitle; + shell.plotPanels.histogram.title.textContent = schema.histogram.title; + shell.plotPanels.histogram.hint.textContent = schema.histogram.subtitle; +} + +export function createWorkspaceCharts(shell: InsightsWorkspaceShell, schema: InsightsChartSchema) { + const scatterDims = plotSize(shell.plotPanels.scatter.body, 360, 280); + const barDims = plotSize(shell.plotPanels.bar.body, 360, 280); + const parallelDims = plotSize(shell.plotPanels.parallel.body, 360, 280); + const histogramDims = plotSize(shell.plotPanels.histogram.body, 360, 280); + + const scatter = new Scatterplot({ + div: shell.plotPanels.scatter.body, + collection: schema.collection, + attributes: { axis: [schema.scatter.x.key, schema.scatter.y.key] }, + labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, + width: scatterDims.width, + height: scatterDims.height, + margins: CARD_CHART_MARGINS.scatter, + events: [PlotEvent.CLICK, PlotEvent.BRUSH], + }); + const bar = new Barchart({ + div: shell.plotPanels.bar.body, + collection: schema.bar.collection, + attributes: { axis: [schema.bar.axisField, schema.bar.valueField] }, + labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, + width: barDims.width, + height: barDims.height, + margins: CARD_CHART_MARGINS.bar, + events: [PlotEvent.CLICK], + }); + const parallel = new ParallelCoordinates({ + div: shell.plotPanels.parallel.body, + collection: schema.collection, + attributes: { axis: schema.parallel.fields.map((field) => field.key) }, + labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, + width: parallelDims.width, + height: parallelDims.height, + margins: CARD_CHART_MARGINS.parallel, + events: [PlotEvent.BRUSH_Y], + }); + const histogram = new Histogram({ + div: shell.plotPanels.histogram.body, + collection: schema.collection, + attributes: { axis: [schema.histogram.field.key] }, + labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, + width: histogramDims.width, + height: histogramDims.height, + margins: CARD_CHART_MARGINS.histogram, + events: [PlotEvent.CLICK], + }); + + const modalDescriptors: ChartModalDescriptor[] = [ + { + key: 'scatter', + title: schema.scatter.title, + subtitle: schema.scatter.subtitle, + originalPlot: scatter, + events: [PlotEvent.CLICK, PlotEvent.BRUSH], + trigger: shell.plotPanels.scatter.header, + button: shell.plotPanels.scatter.button, + createModalPlot: (div, width, height) => new Scatterplot({ + div, collection: schema.collection, + attributes: { axis: [schema.scatter.x.key, schema.scatter.y.key] }, + labels: { axis: [schema.scatter.x.label, schema.scatter.y.label], title: schema.scatter.title }, + width, height, margins: MODAL_CHART_MARGINS.scatter, events: [PlotEvent.CLICK, PlotEvent.BRUSH], + }), + }, + { + key: 'bar', + title: schema.bar.title, + subtitle: schema.bar.subtitle, + originalPlot: bar, + events: [PlotEvent.CLICK], + trigger: shell.plotPanels.bar.header, + button: shell.plotPanels.bar.button, + createModalPlot: (div, width, height) => new Barchart({ + div, collection: schema.bar.collection, + attributes: { axis: [schema.bar.axisField, schema.bar.valueField] }, + labels: { axis: [schema.bar.groupFieldLabel, 'Count'], title: schema.bar.title }, + width, height, margins: MODAL_CHART_MARGINS.bar, events: [PlotEvent.CLICK], + }), + }, + { + key: 'parallel', + title: schema.parallel.title, + subtitle: schema.parallel.subtitle, + originalPlot: parallel, + events: [PlotEvent.BRUSH_Y], + trigger: shell.plotPanels.parallel.header, + button: shell.plotPanels.parallel.button, + createModalPlot: (div, width, height) => new ParallelCoordinates({ + div, collection: schema.collection, + attributes: { axis: schema.parallel.fields.map((field) => field.key) }, + labels: { axis: schema.parallel.fields.map((field) => field.label), title: schema.parallel.title }, + width, height, margins: MODAL_CHART_MARGINS.parallel, events: [PlotEvent.BRUSH_Y], + }), + }, + { + key: 'histogram', + title: schema.histogram.title, + subtitle: schema.histogram.subtitle, + originalPlot: histogram, + events: [PlotEvent.CLICK], + trigger: shell.plotPanels.histogram.header, + button: shell.plotPanels.histogram.button, + createModalPlot: (div, width, height) => new Histogram({ + div, collection: schema.collection, + attributes: { axis: [schema.histogram.field.key] }, + labels: { axis: [schema.histogram.field.label, 'Count'], title: schema.histogram.title }, + width, height, margins: MODAL_CHART_MARGINS.histogram, events: [PlotEvent.CLICK], + }), + }, + ]; + + return { scatter, bar, parallel, histogram, modalDescriptors }; +} diff --git a/autk-provenance/src/workspace/map-host.ts b/autk-provenance/src/workspace/map-host.ts new file mode 100644 index 00000000..60ad000e --- /dev/null +++ b/autk-provenance/src/workspace/map-host.ts @@ -0,0 +1,106 @@ +import type { FeatureCollection } from 'geojson'; +import type { AutkMap, MapEvent } from 'autk-map'; +import type { createAutarkProvenance } from '../create-autark-provenance'; +import type { MapViewState } from '../types'; + +export function mountMapInWorkspace(map: AutkMap, body: HTMLElement): () => void { + const internals = map as AutkMap & { _resizeEvents?: { resize?: () => void } }; + let frame = 0; + let observer: ResizeObserver | null = null; + + const syncSize = () => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + frame = 0; + internals._resizeEvents?.resize?.(); + map.ui.handleResize(); + map.draw(); + }); + }; + + map.ui.destroy(); + body.replaceChildren(map.canvas); + map.canvas.style.width = '100%'; + map.canvas.style.height = '100%'; + map.ui.buildUi(); + syncSize(); + + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(syncSize); + observer.observe(body); + } + + return () => { + if (frame) cancelAnimationFrame(frame); + observer?.disconnect(); + }; +} + +export function applyThematic(map: AutkMap, collection: FeatureCollection, layerId: string, property: string): void { + const thematicMap = map as AutkMap & { + updateThematic?: (id: string, options: { collection: FeatureCollection; property: string }) => void; + }; + + if (property) { + thematicMap.updateThematic?.(layerId, { collection, property }); + return; + } + + map.updateRenderInfo(layerId, { renderInfo: { isColorMap: false } }); +} + +export function createThematicControl( + map: AutkMap, + schema: { collection: FeatureCollection }, + layerId: string, +) { + return { + selector: '.autk-workspace-select', + event: 'change' as const, + actionType: 'MAP_THEMATIC_PROPERTY', + getLabel: (element: Element) => { + const value = (element as HTMLSelectElement).value; + return value ? `Color by: ${value}` : 'Thematic off'; + }, + getStateDelta: (element: Element) => { + const value = (element as HTMLSelectElement).value; + return { filters: { thematicProperty: value || null }, ui: { thematicEnabled: !!value } }; + }, + applyState: (element: Element, state: { filters?: Record }) => { + const value = (state.filters?.thematicProperty as string | null) ?? ''; + (element as HTMLSelectElement).value = value; + applyThematic(map, schema.collection, layerId, value); + }, + }; +} + +export function createMapForProvenance( + map: AutkMap, +): NonNullable[0]['map']> { + const mapViewApi = map as AutkMap & { + addViewListener?: (callback: (state: MapViewState) => void) => void; + removeViewListener?: (callback: (state: MapViewState) => void) => void; + setViewState?: (state: MapViewState) => void; + updateRenderInfoProperty?: (layerName: string, property: string, value: unknown) => void; + updateRenderInfo?: (layerName: string, params: unknown) => void; + }; + + return { + mapEvents: { + addEventListener(event: string, fn: (selection: number[], currentLayerId: string) => void) { + map.events.on(event as MapEvent, ({ selection, layerId: eventLayerId }: { selection: number[]; layerId: string }) => fn(selection, eventLayerId)); + }, + removeEventListener(event: string, fn: (selection: number[], currentLayerId: string) => void) { + map.events.off(event as MapEvent, fn as never); + }, + }, + addViewListener: mapViewApi.addViewListener?.bind(map), + removeViewListener: mapViewApi.removeViewListener?.bind(map), + setViewState: mapViewApi.setViewState?.bind(map), + canvas: map.canvas, + ui: map.ui, + updateRenderInfoProperty: mapViewApi.updateRenderInfoProperty?.bind(map), + updateRenderInfo: mapViewApi.updateRenderInfo?.bind(map), + layerManager: map.layerManager, + }; +} diff --git a/autk-provenance/src/workspace/plot-adapter.ts b/autk-provenance/src/workspace/plot-adapter.ts new file mode 100644 index 00000000..e7304dc4 --- /dev/null +++ b/autk-provenance/src/workspace/plot-adapter.ts @@ -0,0 +1,24 @@ +import type { PlotBaseInteractive } from 'autk-plot'; +import { PlotEvent } from 'autk-plot'; +import { PlotType } from '../types'; + +export function createWorkspacePlotAdapter(plot: PlotBaseInteractive, plotId: string, plotType: PlotType) { + return Object.assign(plot, { + plotId, + plotType, + plotEvents: { + addEventListener(event: string, fn: (selection: number[]) => void) { + plot.events.on(event as PlotEvent, ({ selection }: { selection: number[] }) => fn(selection)); + }, + removeEventListener(event: string, fn: (selection: number[]) => void) { + plot.events.off(event as PlotEvent, fn as never); + }, + }, + setOwnedSelection(ids: number[]) { + plot.setLocalSelection(ids); + }, + setHighlightedIds(ids: number[]) { + plot.setSelection(ids); + }, + }); +} From 3ab785e64709789dbe35eee624590500030533ee Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Wed, 6 May 2026 14:56:41 -0500 Subject: [PATCH 18/21] fixes: chart selection and deselection --- autk-provenance/src/ui/workspace-styles.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/autk-provenance/src/ui/workspace-styles.ts b/autk-provenance/src/ui/workspace-styles.ts index effde046..1454e15d 100644 --- a/autk-provenance/src/ui/workspace-styles.ts +++ b/autk-provenance/src/ui/workspace-styles.ts @@ -18,8 +18,8 @@ export function ensureInsightsWorkspaceStyles(): void { '.autk-workspace-panel-hint{font-size:10px;line-height:1.35;color:#546b80;white-space:normal;overflow:hidden;text-overflow:ellipsis}', '.autk-workspace-select-wrap{display:flex;align-items:center;gap:6px;font-size:11px;color:#546b80}', '.autk-workspace-select{padding:4px 8px;border:1px solid #d4dde7;border-radius:6px;background:#fff;color:#1b3148;font:inherit}', - '.autk-workspace-map-panel,.autk-workspace-side-panel{min-height:680px;display:flex;flex-direction:column}', - '.autk-workspace-map-body{flex:1;min-height:0;display:flex;align-items:stretch;justify-content:center;overflow:hidden;background:#c8dae6}', + '.autk-workspace-map-panel,.autk-workspace-side-panel{height:680px;max-height:680px;display:flex;flex-direction:column}', + '.autk-workspace-map-body{flex:1;min-height:0;display:flex;align-items:stretch;justify-content:center;overflow:auto;background:#c8dae6}', '.autk-workspace-map-body canvas{display:block;width:100%;height:100%}', '.autk-workspace-tabbar{display:flex;border-bottom:1px solid #d4dde7;background:#f6f9fc}', '.autk-workspace-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:12px;border:none;border-bottom:3px solid transparent;background:transparent;color:#546b80;font:600 12px system-ui,sans-serif;cursor:pointer}', @@ -38,10 +38,10 @@ export function ensureInsightsWorkspaceStyles(): void { '.autk-workspace-plot-body-inner .plot-title{font-size:13px;font-weight:600;fill:#1f344b}', '.autk-workspace-plot-body-inner .axis-label,.autk-workspace-plot-body-inner .title{font-size:11px;fill:#1f344b}', '.autk-workspace-plot-body-inner .tick text{font-size:10px;fill:#5d7286}', - '.autk-workspace-provenance-grid{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:10px;padding:10px}', - '.autk-workspace-provenance-main,.autk-workspace-provenance-side{min-height:0;min-width:0;overflow:hidden}', + '.autk-workspace-provenance-grid{flex:1;min-height:0;height:100%;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);gap:10px;padding:10px;overflow:hidden}', + '.autk-workspace-provenance-main,.autk-workspace-provenance-side{min-height:0;min-width:0;height:100%;overflow:hidden}', '.autk-workspace-provenance-main{display:flex;flex-direction:column}', - '.autk-workspace-trail{height:100%;display:flex;flex-direction:column;min-height:0}', + '.autk-workspace-trail{height:100%;display:flex;flex-direction:column;min-height:0;overflow:hidden}', '.autk-workspace-session-insights{display:flex;flex-direction:column}', '.autk-workspace-session-insights-body{padding:0;min-height:0}', '.autk-workspace-bottom-insights{border-top:1px solid #e7eef6}', @@ -74,7 +74,7 @@ export function ensureInsightsWorkspaceStyles(): void { '.autk-chart-modal-stage{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}', '.autk-chart-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px 14px;font-size:12px;color:#546b80}', '@media (max-width:1260px){.autk-session-insights-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}', - '@media (max-width:1200px){.autk-workspace-layout{grid-template-columns:1fr}.autk-workspace-provenance-grid{grid-template-columns:1fr;grid-template-rows:minmax(0,1fr)}.autk-workspace-map-panel,.autk-workspace-side-panel{min-height:520px}}', + '@media (max-width:1200px){.autk-workspace-layout{grid-template-columns:1fr}.autk-workspace-provenance-grid{grid-template-columns:1fr;grid-template-rows:minmax(0,1fr)}.autk-workspace-map-panel,.autk-workspace-side-panel{height:520px;max-height:520px}}', '@media (max-width:760px){.autk-workspace-plot-grid{grid-template-columns:1fr;grid-template-rows:repeat(4,280px)}.autk-chart-modal{width:96vw;height:92vh}.autk-chart-modal-header,.autk-chart-modal-footer{flex-direction:column;align-items:flex-start}.autk-session-insights-grid{grid-template-columns:1fr}.autk-session-insights-card{border-right:none;border-bottom:1px solid #dbe5f0}.autk-session-insights-card:last-child{border-bottom:none}}', ].join(''); document.head.appendChild(style); From 3db3bfff853ba6a0343e103dc5980384d171f154 Mon Sep 17 00:00:00 2001 From: Prathik Pugazhenthi Date: Wed, 6 May 2026 15:48:40 -0500 Subject: [PATCH 19/21] docs: added README.md --- autk-provenance/README.md | 849 +++++++++++++++++++++++++++++++++----- 1 file changed, 737 insertions(+), 112 deletions(-) diff --git a/autk-provenance/README.md b/autk-provenance/README.md index 5fa86b69..97459018 100644 --- a/autk-provenance/README.md +++ b/autk-provenance/README.md @@ -1,178 +1,803 @@ -# Autark Provenance: Startup, Verification, and Testing Guide +# autk-provenance: Provenance Tracking for Urban Visual Analytics -This guide explains how to run the provenance example, verify that provenance is working in the UI, and test core provenance behavior (trail, graph navigation, and state replay). +
+ Autark Logo
+
+
-## What This Covers +**autk-provenance** is the provenance and session-tracking module of the Autark ecosystem. It records every analytical interaction — map picks, plot selections, layer toggles, database operations, compute runs — as nodes in a persistent, branching provenance graph. Analysts can navigate back and forward through their analysis history, branch into alternative exploration paths, annotate key findings, and export the full session graph for reproducibility or sharing. -- Start the project with provenance enabled -- Open the provenance example page -- Verify that the provenance model is recording events -- Validate that going to older nodes restores full state -- Run quick build-level checks for `autk-provenance` +The library can be used standalone (with any custom state shape) or as a drop-in layer on top of the other Autark modules (`autk-map`, `autk-plot`, `autk-db`, `autk-compute`). The highest-level entry point — `renderInsightsWorkspace` — wires an entire coordinated analytics workspace (map + four charts + provenance trail + insights panel) from a single function call. -## Prerequisites +## Resources -- Node.js installed -- Dependencies installed in workspace -- WebGPU enabled browser (Chrome/Edge recommended) +- [Documentation](https://autarkjs.org/introduction.html) +- [Examples](https://autarkjs.org/gallery/) +- [Use Cases](https://autarkjs.org/usecases/) -From project root: +--- + +## Table of Contents + +1. [Core Concepts](#core-concepts) +2. [Installation](#installation) +3. [Quick Start — Full Workspace](#quick-start--full-workspace) +4. [Quick Start — Autark Integration](#quick-start--autark-integration) +5. [Quick Start — Generic State](#quick-start--generic-state) +6. [State Shape](#state-shape) +7. [Provenance Graph](#provenance-graph) +8. [API Reference](#api-reference) + - [createAutarkProvenance](#createautarkprovenance) + - [renderInsightsWorkspace](#renderinsightsworkspace) + - [renderProvenanceTrailUI](#renderprovenancetrailui) + - [createProvenance](#createprovenance) + - [Insight Engine](#insight-engine) +9. [Adapters](#adapters) + - [Map Adapter](#map-adapter) + - [Plot Adapter](#plot-adapter) + - [DB Adapter](#db-adapter) + - [Compute Adapter](#compute-adapter) +10. [Custom Controls](#custom-controls) +11. [Tracked Interactions](#tracked-interactions) +12. [Session Insights](#session-insights) +13. [Export and Import](#export-and-import) +14. [Building](#building) +15. [Development Workflow](#development-workflow) + +--- + +## Core Concepts + +### Provenance Graph + +Every interaction creates a **node** in a directed acyclic graph. Each node stores: +- A full snapshot of the application state at that moment +- The action type and human-readable label that created it +- A timestamp +- Optional analyst annotations + +Nodes are connected from parent to child. When an analyst navigates back to an earlier node and then takes a new action, a new branch is created — the graph is non-linear, recording the full history of exploratory paths rather than a single linear undo stack. + +### State Restoration + +Navigating to any node in the graph (via the trail UI, the graph modal, or programmatic API) triggers a full **state restoration**: the map highlights the correct features, plot selections are re-applied, layer visibility and thematic state are restored, and the camera viewport is reset. All coordinated views update simultaneously. + +### Adapters + +Each Autark module is connected through an **adapter** that: +1. **Records** interactions by listening to module events (DOM clicks, method calls, event emitters) and writing nodes to the graph. +2. **Applies state** from any node back onto the module when the analyst navigates the graph. + +Recording is guarded so that state-restoration calls do not themselves generate new provenance nodes. + +### Cross-view Coordination + +Selections are coordinated across all connected views. Picking features on the map highlights the corresponding rows in every plot. Selecting a point in the scatter plot highlights the matching map feature and plot rows in all other charts. This coordination is tracked in the provenance state so every node stores a complete cross-view snapshot. + +--- + +## Installation ```bash -make install +npm install autk-provenance ``` -## Startup Options +`autk-provenance` depends on `autk-plot` at runtime when using `renderInsightsWorkspace`. Build `autk-plot` before `autk-provenance` in any build pipeline. -### Option A: Full workspace (recommended) +--- -From project root: +## Quick Start — Full Workspace -```bash -make dev +`renderInsightsWorkspace` is the highest-level entry point. It builds a complete coordinated analytics workspace — map, four charts (scatter plot, bar chart, parallel coordinates, histogram), a provenance trail panel, and a session insights panel — from a single call. + +```ts +import { renderInsightsWorkspace } from 'autk-provenance'; +import type { AutkMap } from 'autk-map'; +import type { FeatureCollection } from 'geojson'; + +const root = document.getElementById('app') as HTMLElement; + +// Assumes map and collection are already set up +renderInsightsWorkspace({ + container: root, + map, + collection, // GeoJSON FeatureCollection with numeric feature properties + layerId: 'neighborhoods', + title: 'NYC Neighborhood Analysis', + description: 'Cross-view exploration of neighborhood indicators', +}); ``` -This builds and watches all modules (`autk-map`, `autk-db`, `autk-plot`, `autk-compute`, `autk-provenance`) and starts the examples Vite server. +The workspace renders: +- A **Map tab** with the 3D map and a thematic-color dropdown +- An **Insights tab** with four coordinated charts (scatter, bar, parallel coordinates, histogram) +- A **Provenance tab** with the interactive graph, the step trail, and back/forward navigation -### Option B: Faster provenance-focused workflow +All interactions across map and charts are automatically tracked. Chart axes and groupings are inferred from the GeoJSON properties. -Terminal 1: +### Full options -```bash -cd autk-provenance -npm run dev-build +```ts +renderInsightsWorkspace({ + container: root, + map, // AutkMap instance (required) + collection, // GeoJSON FeatureCollection (required) + layerId: 'neighborhoods', // Layer ID registered in the map (required) + db, // Optional: autk-db instance for DB provenance + title: 'My Analysis', // Workspace header title + description: 'Description…', // Workspace header subtitle + mapConfig: { // Optional: override map UI selectors or add custom controls + customControls: [...], + }, +}); ``` -Terminal 2: +The returned object exposes the provenance API and a `destroy()` method for cleanup: -```bash -cd examples -npm run dev +```ts +const { provenance, schema, destroy } = renderInsightsWorkspace({ ... }); + +// Inspect the current provenance graph +console.log(provenance.getGraph()); + +// Clean up when done +destroy(); +``` + +--- + +## Quick Start — Autark Integration + +Use `createAutarkProvenance` when you want to wire provenance into your own custom layout rather than using the pre-built workspace shell. + +```ts +import { createAutarkProvenance, renderProvenanceTrailUI } from 'autk-provenance'; +import { PlotType } from 'autk-provenance'; + +const provenance = createAutarkProvenance({ + map: mapForProvenance, // IMapForProvenance adapter object + plots: [ + Object.assign(scatter, { + plotId: 'Scatterplot', + plotType: PlotType.SCATTERPLOT, + plotEvents: { + addEventListener: (event, fn) => scatter.events.on(event, ({ selection }) => fn(selection)), + removeEventListener: (event, fn) => scatter.events.off(event, fn), + }, + setHighlightedIds: (ids) => scatter.setSelection(ids), + }), + ], + db, +}); + +// Render the trail UI into a sidebar container +const destroyTrail = renderProvenanceTrailUI({ + provenance, + container: document.getElementById('provenance-sidebar'), + showGraph: true, + showPathList: true, + showBackForward: true, + showTimestamps: true, +}); + +// Navigate +provenance.goBackOneStep(); +provenance.goForwardOneStep(); +provenance.goToNode(nodeId); + +// Annotate a key finding +provenance.annotateNode(provenance.getCurrentNode().id, 'High density cluster in Brooklyn'); + +// Export for sharing +const json = provenance.exportGraph(); +``` + +--- + +## Quick Start — Generic State + +Use `createProvenance` to track any custom state shape with your own adapter, without any dependency on Autark modules. + +```ts +import { createProvenance } from 'autk-provenance'; + +interface AppState { + selectedRegion: string | null; + year: number; + showGrid: boolean; +} + +const provenance = createProvenance({ + initialState: { selectedRegion: null, year: 2024, showGrid: false }, + adapter: { + applyState(state) { + updateUI(state.selectedRegion, state.year, state.showGrid); + }, + }, +}); + +// Record interactions manually +provenance.applyAction('SELECT_REGION', 'Selected Brooklyn', { selectedRegion: 'Brooklyn' }); +provenance.applyAction('CHANGE_YEAR', 'Year: 2020', { year: 2020 }); + +// Navigate +provenance.goBackOneStep(); ``` -## Open the Provenance UI +--- + +## State Shape -After the dev server starts, open: +The canonical state type for Autark provenance is `AutarkProvenanceState`: -```text -http://localhost:5173/src/autk-provenance/map-plot-provenance.html +```ts +interface AutarkProvenanceState { + selection: { + /** Map pick: the layer and feature IDs currently selected on the map. */ + map: { layerId: string; ids: number[] } | null; + /** Per-plot selection, keyed by plotId. */ + plots: Record; + }; + ui?: { + mapMenuOpen?: boolean; + activeLayerId?: string | null; + visibleLayerIds?: string[]; + thematicEnabled?: boolean; + }; + /** Camera viewport: eye, lookAt, up vectors for 3D restoration. */ + view?: MapViewState; + /** Database workspace and layer references for DB state restoration. */ + data?: { + workspace: string; + layerTableNames: string[]; + activeLayerIds?: string[]; + }; + /** + * Arbitrary filter state contributed by custom UI controls + * (dropdowns, sliders, date pickers, etc.). + */ + filters?: Record; +} ``` -Expected page sections: +Each provenance node stores a full snapshot of this state. Navigating to a node restores the complete snapshot. -- `Map View` -- `Scatterplot` -- `Provenance Trail` (right panel) -- Graph preview inside the provenance panel +--- -## How to Check Provenance Is Working +## Provenance Graph -1. Interact with map and plot: -- Click map features (pick selection) -- Brush in scatterplot -- Clear brush +The graph data structure is: -2. Interact with map UI controls: -- Open/close hamburger menu -- Toggle visible layers -- Change active layer -- Toggle thematic checkbox +```ts +interface ProvenanceGraph { + nodes: Map>; + rootId: string; + currentId: string; +} + +interface ProvenanceNode { + id: string; + parentId: string | null; + childrenIds: string[]; + state: T; + actionLabel: string; + actionType: ProvenanceAction | string; + timestamp: number; + metadata?: Record; // used for analyst annotations +} +``` -3. Confirm provenance updates: -- Trail list should append new entries with timestamps -- Back/Forward buttons should enable when navigation is possible -- Graph preview should show branching nodes +The graph is a **directed tree** rooted at the `ROOT` node created on initialization. Each action appends a child to the current node. Navigating back and then taking a new action branches the tree, creating a new child from the historical node. -4. Open graph modal: -- Click graph preview (or `Open Graph`) -- Use `+`, `−`, `Fit`, wheel zoom, and drag-pan -- Click a node in modal: app should restore to that node state +`getPathFromRoot()` returns the linear path of nodes from the root to the current node — the "main branch" visible in the trail UI. -## What Should Be Replayed on Node Navigation +--- -When you click a node (trail or graph), the app should restore: +## API Reference -- Map selection + plot selection consistency -- Active layer -- Visible layers -- Thematic checkbox state -- Thematic legend visibility -- Menu open/closed state +### `createAutarkProvenance` -If you jump to a node before thematic was enabled, thematic and legend should both be off. +Creates a provenance instance wired to all provided Autark modules. -## Manual Test Checklist (Recommended) +```ts +function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkProvenanceApi +``` -### 1. Startup + data load tracking +**Options:** + +| Option | Type | Description | +|--------|------|-------------| +| `map` | `IMapForProvenance` | Map adapter object (see [Map Adapter](#map-adapter)) | +| `plots` | `IPlotForProvenance[]` | Plot instances to track. Each must have `plotId`, `plotType`, `plotEvents`, `setHighlightedIds`. | +| `db` | `IDbForProvenance` | autk-db instance to track | +| `compute` | `IComputeForProvenance` | autk-compute instance to track | +| `mapConfig` | `MapSelectorConfig` | Override default map UI selectors and register custom controls | +| `initialState` | `Partial` | Override the initial state of the root node | + +**Returns `AutarkProvenanceApi`:** + +| Method | Description | +|--------|-------------| +| `goToNode(nodeId)` | Navigate to any node in the graph by ID. Returns `true` if successful. | +| `goBackOneStep()` | Move to the parent of the current node. Returns `true` if possible. | +| `goForwardOneStep()` | Move to the most-recently-visited child. Returns `true` if possible. | +| `canGoBack()` | Returns `true` if the current node has a parent. | +| `canGoForward()` | Returns `true` if the current node has children. | +| `getPathFromRoot()` | Returns the ordered list of nodes from root to current. | +| `getGraph()` | Returns the full `ProvenanceGraph` (all nodes, root ID, current ID). | +| `getCurrentNode()` | Returns the current `ProvenanceNode` or `null`. | +| `getCurrentState()` | Returns the current `AutarkProvenanceState` or `null`. | +| `exportGraph()` | Serializes the full graph to a JSON string. | +| `importGraph(json)` | Replaces the graph from a previously exported JSON string. | +| `addObserver(cb)` | Registers a callback fired on every graph change. Returns an unsubscribe function. | +| `annotateNode(nodeId, text)` | Attaches a text annotation to any node without creating a new provenance step. | +| `startRecording()` | Resume recording after a `stopRecording()` call. | +| `stopRecording()` | Pause all recording (interactions are ignored until `startRecording()`). | +| `db` | Exposes the `DbAdapterApi` for manual DB recording operations. | + +--- + +### `renderInsightsWorkspace` + +Renders a fully wired analytics workspace (map + 4 charts + provenance trail + insights) into a container element. -1. Reload page -2. Confirm early trail entries for DB/map initialization and layer loading -3. Confirm `Start` root node exists +```ts +function renderInsightsWorkspace( + options: RenderInsightsWorkspaceOptions +): RenderInsightsWorkspaceResult +``` -### 2. Branch navigation +**Options:** -1. Do flow A: pick map feature(s) -2. Go back to earlier node -3. Do flow B: brush different scatterplot points -4. Open graph modal and confirm visible branch structure -5. Click nodes from both branches and verify state replay +| Option | Type | Description | +|--------|------|-------------| +| `container` | `HTMLElement` | Root element to render the workspace into | +| `map` | `AutkMap` | Map instance from autk-map | +| `collection` | `FeatureCollection` | GeoJSON collection — source for all charts | +| `layerId` | `string` | Layer ID already registered in the map | +| `db` | `IDbForProvenance` | Optional database instance for DB provenance | +| `title` | `string` | Workspace header title | +| `description` | `string` | Workspace header description | +| `mapConfig` | `MapSelectorConfig` | Optional map selector / custom control overrides | -### 3. Redundant clear dedupe +**Returns:** -1. Clear plot once -> should log clear event -2. Clear again immediately (no state change) -> should NOT log extra clear -3. Make new selection, then clear -> should log clear again +| Property | Type | Description | +|----------|------|-------------| +| `provenance` | `AutarkProvenanceApi` | The live provenance API for the workspace | +| `schema` | `InsightsChartSchema` | Resolved chart configuration (fields, titles, collections) | +| `destroy()` | `() => void` | Tears down all listeners, empties the container | -### 4. Cross-view consistency +The workspace infers chart axes automatically from the GeoJSON feature properties: +- **Scatter plot**: two highest-variance numeric fields +- **Bar chart**: most distinct categorical field vs count +- **Parallel coordinates**: up to six numeric fields +- **Histogram**: the field with the widest numeric range -1. Select via map -> plot highlights should sync -2. Select via plot -> map highlights should sync -3. Clear plot -> map highlights should also clear +--- -### 5. Thematic rollback +### `renderProvenanceTrailUI` -1. Enable thematic and choose properties -2. Create additional provenance steps -3. Jump to node created before thematic enable -4. Verify thematic checkbox + legend + rendering are reverted +Renders the provenance trail panel — graph preview, step list, navigation buttons, and optional insights — into any container element. -## Build / Smoke Checks +```ts +function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void +``` + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `provenance` | `AutarkProvenanceApi` | required | The provenance instance to display | +| `container` | `HTMLElement` | required | Element to render into | +| `insightsContainer` | `HTMLElement` | — | Separate container for the insights panel (defaults to `container`) | +| `showInsights` | `boolean` | `true` | Show the collapsible insights panel | +| `showBackForward` | `boolean` | `true` | Show back/forward navigation buttons | +| `showTimestamps` | `boolean` | `true` | Show timestamps on trail steps | +| `showGraph` | `boolean` | `true` | Show the mini graph preview | +| `showPathList` | `boolean` | `true` | Show the linear step trail list | + +Returns a **cleanup function** — call it to remove all event listeners and clear the container. + +The graph preview is interactive: clicking it opens a full **graph modal** with zoom (mouse wheel or `+`/`−` buttons), pan (drag), fit-to-view, and node navigation. Clicking any node in the modal navigates to that state. Press `Escape` to close the modal. -Run provenance package build: +--- + +### `createProvenance` + +Generic provenance factory for custom state shapes not tied to Autark modules. + +```ts +function createProvenance>( + options: CreateProvenanceOptions +): ProvenanceApi +``` + +**Options:** + +| Option | Type | Description | +|--------|------|-------------| +| `initialState` | `T` | The initial state for the root node | +| `adapter` | `ProvenanceAdapter` | Object implementing `applyState(state: T): void` | +| `mergeState` | `(base: T, delta: Partial) => T` | Optional custom merge strategy (defaults to shallow spread) | + +**Returns `ProvenanceApi`** — the same navigation, graph, export/import, and observer API as `AutarkProvenanceApi`, plus `applyAction(actionType, label, delta)` for manually recording interactions. + +--- + +### Insight Engine + +The insight engine derives analytical observations from the provenance graph without modifying it. + +```ts +import { + computeSelectionFrequency, + computeGraphMetrics, + getInsightAnnotations, + generateSessionNarrative, +} from 'autk-provenance'; +``` + +#### `computeSelectionFrequency(graph)` + +Returns how often each feature ID was selected across all nodes in the graph: + +```ts +const freq = computeSelectionFrequency(graph); +// freq.map — Map for map selections +// freq.plots — Map> for plot selections +``` + +#### `computeGraphMetrics(graph)` + +Derives structural statistics about the exploration session: + +```ts +const metrics = computeGraphMetrics(graph); +// metrics.totalNodes — total number of recorded states +// metrics.branchPoints — nodes with more than one child +// metrics.backtracks — total backtrack events (children beyond the first) +// metrics.maxDepth — longest path from root to leaf +// metrics.sessionDurationMs +// metrics.avgTimePerStateMs +// metrics.branchRatio — branchPoints / totalNodes +// metrics.strategyLabel — 'Confirmatory' | 'Exploratory' | 'Iterative Refinement' +// metrics.insightCount — number of annotated nodes +``` + +**Strategy classification:** +- **Confirmatory** — linear, focused analysis; the analyst knew what they were looking for +- **Exploratory** — multiple diverging branches; broad, open-ended investigation +- **Iterative Refinement** — high backtrack rate and branch ratio; hypothesis-driven revision + +#### `getInsightAnnotations(graph)` + +Returns all nodes that have a text annotation attached: + +```ts +const annotations = getInsightAnnotations(graph); +// [{ nodeId, actionLabel, text, timestamp }, ...] +``` + +#### `generateSessionNarrative(graph, metrics, annotations)` + +Produces a plain-text narrative summarizing the session: + +```ts +const narrative = generateSessionNarrative(graph, metrics, annotations); +// "Session started at 14:32:01. +// Duration: 4m 12s across 23 states (avg 11s per state). +// Analysis strategy: Exploratory +// ..." +``` + +--- + +## Adapters + +### Map Adapter + +The map adapter connects an `AutkMap` (or any object implementing `IMapForProvenance`) to the provenance system. + +**What it records:** +- Map initialization (`MAP_INIT`) and layer loads (`MAP_LAYER_LOAD`) +- Feature picks / selections (`MAP_PICK`) via the `picking` event +- Camera viewport changes (`MAP_VIEW`) — debounced at 180 ms +- Hamburger menu open/close (`MAP_UI_MENU_TOGGLE`) +- Layer visibility toggles (`MAP_UI_VISIBLE_LAYER_TOGGLE`) — eye icon +- Active layer changes (`MAP_UI_ACTIVE_LAYER_CHANGE`) — cursor icon +- Thematic colormap toggles (`MAP_UI_THEMATIC_TOGGLE`) — palette icon + +**What it restores on node navigation:** +- Layer visibility (`isSkip` render flag on each layer) +- Active layer (`map.ui.changeActiveLayer`) +- Thematic state (`isColorMap` render flag on each layer) +- Map menu open/closed state +- Camera viewport (`map.setViewState`) +- Feature highlight on each layer (`setHighlightedIds` / `clearHighlightedIds`) + +**`IMapForProvenance` interface:** + +The map object passed to `createAutarkProvenance` must satisfy: + +```ts +interface IMapForProvenance { + mapEvents: { + addEventListener(event: string, listener: (selection: number[], layerId: string) => void): void; + removeEventListener?(event: string, listener: ...): void; + }; + canvas: { parentElement: HTMLElement | null }; + layerManager: { + searchByLayerId(id: string): LayerLike | null; + layers: LayerLike[]; + }; + addViewListener?(cb: (state: MapViewState) => void): void; + removeViewListener?(cb: (state: MapViewState) => void): void; + setViewState?(state: MapViewState): void; + ui?: { + activeLayer?: { layerInfo?: { id: string }; layerRenderInfo?: { isColorMap?: boolean } } | null; + changeActiveLayer?(layer: unknown): void; + refreshLayerList?(): void; + }; + updateRenderInfo?(layerName: string, params: unknown): void; + updateRenderInfoProperty?(layerName: string, property: string, value: unknown): void; +} +``` + +When using `renderInsightsWorkspace` or `createAutarkProvenance` with a real `AutkMap`, this wrapper is assembled automatically in the workspace layer. + +--- + +### Plot Adapter + +The plot adapter connects one or more interactive plots to the provenance system. + +**What it records:** +- Click-based selections (`PLOT_CLICK`) — individual point or bar clicks +- 2D brush selections (`PLOT_BRUSH`) — rectangular lasso in scatter plots +- 1D horizontal brush (`PLOT_BRUSH_X`) +- 1D vertical brush (`PLOT_BRUSH_Y`) — used in parallel coordinates +- Data updates (`PLOT_DATA`) — when a plot's data collection is replaced + +**Cross-view deduplication:** The plot adapter separates each plot's **owned selection** (IDs the user explicitly selected in that plot) from **borrowed IDs** (IDs highlighted because of another plot or map selection). Only owned selections are stored per plot in the provenance state. On restoration, all owned IDs across all sources are unioned and applied as highlights to every plot simultaneously. + +**`IPlotForProvenance` interface:** + +```ts +interface IPlotForProvenance { + plotId: string; // Stable unique identifier, used as the state key + plotType: PlotType; // SCATTERPLOT | BARCHART | PARALLEL_COORDINATES | HISTOGRAM + plotEvents: { + addEventListener(event: string, listener: (selection: number[]) => void): void; + removeEventListener?(event: string, listener: (selection: number[]) => void): void; + }; + setHighlightedIds(ids: number[]): void; // Apply coordinated highlight +} +``` + +`PlotType` values: `PlotType.SCATTERPLOT`, `PlotType.BARCHART`, `PlotType.PARALLEL_COORDINATES`, `PlotType.HISTOGRAM`. + +--- + +### DB Adapter + +The DB adapter connects an `autk-db` database instance to provenance. It records data-loading and transformation operations, and restores the workspace and active layer state on node navigation. + +**What it records:** + +| Action | Trigger | +|--------|---------| +| `DB_INIT` | Database initialization | +| `DB_WORKSPACE` | Workspace open | +| `DB_LOAD_OSM` | OpenStreetMap data load | +| `DB_LOAD_CSV` / `DB_LOAD_JSON` | CSV or JSON file import | +| `DB_LOAD_LAYER` / `DB_LOAD_CUSTOM_LAYER` / `DB_LOAD_GRID_LAYER` | Layer loads | +| `DB_GET_LAYER` | Layer retrieval | +| `DB_SPATIAL_JOIN` | Spatial join operation | +| `DB_UPDATE_TABLE` / `DB_DROP_TABLE` | Table mutations | +| `DB_RAW_QUERY` | Arbitrary SQL execution | +| `DB_BUILD_HEATMAP` | Heatmap construction | + +The DB adapter also exposes a `db` property on `AutarkProvenanceApi` for manual recording calls when automatic wrapping is insufficient. + +--- + +### Compute Adapter + +The compute adapter wraps `autk-compute`'s `GeojsonCompute` instance and records GPU computation runs. + +**What it records:** +- `COMPUTE_RUN` — fires after `computeFunctionIntoProperties` resolves, storing the output column name, feature count, and timestamp in `state.filters.lastCompute`. + +--- + +## Custom Controls + +Custom controls allow tracking of arbitrary DOM elements (dropdowns, toggle buttons, sliders, date pickers) that live in or near the map container. + +```ts +const mapConfig: MapSelectorConfig = { + customControls: [ + { + selector: '#month-dropdown', + event: 'change', + actionType: 'FILTER_MONTH', + getLabel: (el) => `Month: ${(el as HTMLSelectElement).value}`, + getStateDelta: (el) => ({ + filters: { selectedMonth: (el as HTMLSelectElement).value }, + }), + applyState: (el, state) => { + (el as HTMLSelectElement).value = (state.filters?.selectedMonth as string) ?? ''; + }, + }, + { + selector: '#reset-button', + event: 'click', + actionType: 'RESET_FILTERS', + getLabel: () => 'Reset all filters', + getStateDelta: () => ({ filters: {} }), + }, + ], +}; + +const provenance = createAutarkProvenance({ map, plots, mapConfig }); +``` + +Each custom control config: +- `selector` — CSS selector to match the control element +- `event` — `'click'` or `'change'` +- `actionType` — the provenance action label stored on the node +- `getLabel(el)` — human-readable description shown in the trail +- `getStateDelta(el)` — returns the state slice to record +- `applyState(el, state)` — optional: restores the control's DOM state on node navigation + +--- + +## Tracked Interactions + +The following actions are defined in `ProvenanceAction` and each creates one node in the provenance graph: + +| Action | Description | +|--------|-------------| +| `ROOT` | Initial state node, created automatically on startup | +| `MAP_INIT` | Map canvas initialized | +| `MAP_LAYER_LOAD` | A GeoJSON or raster layer was loaded | +| `MAP_PICK` | User picked features on the map | +| `MAP_VIEW` | Camera viewport changed (debounced) | +| `MAP_UI_MENU_TOGGLE` | Hamburger menu opened or closed | +| `MAP_UI_VISIBLE_LAYER_TOGGLE` | Eye icon toggled a layer's visibility | +| `MAP_UI_ACTIVE_LAYER_CHANGE` | Cursor icon changed the active picking layer | +| `MAP_UI_THEMATIC_TOGGLE` | Palette icon toggled thematic colormap | +| `MAP_UI_CUSTOM_CONTROL` | A registered custom control was interacted with | +| `PLOT_CLICK` | User clicked a mark in a plot | +| `PLOT_BRUSH` | 2D brush selection in scatter plot | +| `PLOT_BRUSH_X` | Horizontal brush selection | +| `PLOT_BRUSH_Y` | Vertical brush (parallel coordinates) | +| `PLOT_DATA` | A plot's data collection was replaced | +| `PLOT_ADD` / `PLOT_REMOVE` | Plot added or removed dynamically | +| `COMPUTE_RUN` | GPU computation completed | +| `DB_INIT` | Database initialized | +| `DB_WORKSPACE` | Workspace opened | +| `DB_LOAD_OSM` | OSM data loaded | +| `DB_LOAD_CSV` / `DB_LOAD_JSON` | CSV or JSON imported | +| `DB_LOAD_LAYER` / `DB_LOAD_CUSTOM_LAYER` / `DB_LOAD_GRID_LAYER` | Layer loaded | +| `DB_GET_LAYER` | Layer retrieved from DB | +| `DB_SPATIAL_JOIN` | Spatial join performed | +| `DB_UPDATE_TABLE` / `DB_DROP_TABLE` | Table modified or dropped | +| `DB_RAW_QUERY` | Raw SQL query executed | +| `DB_BUILD_HEATMAP` | Heatmap built | + +--- + +## Session Insights + +The workspace and the provenance trail both include a **session insights panel** that surfaces: + +- **Analysis Strategy** — Confirmatory, Exploratory, or Iterative Refinement, based on branch ratio and backtrack count +- **Selection Focus** — the most revisited map features and plot data points across all graph branches +- **Annotate This Step** — a free-text input to attach a note to the current node +- **Analysis Summary** — a generated narrative describing the session, strategy, top selections, and all annotations + +Annotations are attached to nodes with `provenance.annotateNode(nodeId, text)` without creating a new provenance step. + +--- + +## Export and Import + +The full provenance graph can be serialized and restored: + +```ts +// Export +const json = provenance.exportGraph(); +localStorage.setItem('session', json); + +// Import (replaces current graph) +const json = localStorage.getItem('session'); +provenance.importGraph(json); +``` + +The exported JSON contains all nodes, the root ID, the current node ID, and all state snapshots. Imported graphs trigger state restoration to the previously-current node. + +--- + +## Building + +```bash +# From project root — builds leaf packages first, then autk-provenance +make build + +# Build only autk-provenance (autk-plot must already be built) +cd autk-provenance && npm run build + +# Type-check without emitting +cd autk-provenance && npx tsc --noEmit --skipLibCheck +``` + +> **Build order note:** `autk-provenance` imports `autk-plot` at build time. Always build `autk-plot` before `autk-provenance`. The root `Makefile` enforces this order automatically. + +--- + +## Development Workflow + +### Start the full dev server ```bash -cd autk-provenance -npm run build +make dev ``` -This validates TypeScript + bundling for `autk-provenance`. +Builds and watches all modules (`autk-map`, `autk-db`, `autk-plot`, `autk-compute`, `autk-provenance`) and starts the gallery Vite server at `http://localhost:5173`. -## Optional Debug Hook (for console inspection) +### Open the provenance workspace example -If you want to inspect graph/state in browser console, temporarily expose the API in: +``` +http://localhost:5173/src/autk-provenance/all-plots-provenance.html +``` -`examples/src/autk-provenance/map-plot-provenance.ts` +### Inspect state from the browser console ```ts -// after createAutarkProvenance(...) -(window as any).__autkProvenance = provenance; +// Temporarily expose in your page script for debugging +(window as any).__provenance = provenance; ``` Then in DevTools: ```js -__autkProvenance.getCurrentState(); -__autkProvenance.getGraph(); -__autkProvenance.exportGraph(); +__provenance.getCurrentState(); +__provenance.getGraph(); +__provenance.exportGraph(); ``` -## Troubleshooting - -- Provenance panel empty: - - Confirm you opened `/src/autk-provenance/map-plot-provenance.html`, not another example. -- UI interactions not recorded: - - Ensure page is using latest `autk-provenance` build/watch output. -- Node click does not restore as expected: - - Check browser console for runtime errors. - - Reproduce with a fresh reload and minimal sequence, then compare trail entries. - +### Manual test checklist + +**Startup and data load:** +- Reload the page — confirm early trail entries for map initialization and layer loading +- Confirm the root `Start` node appears in the graph + +**Branch navigation:** +1. Pick a map feature → confirm a `MAP_PICK` node appears +2. Navigate back to the root +3. Brush the scatter plot → confirm a `PLOT_BRUSH` node appears on a new branch +4. Open the graph modal — confirm visible branching structure +5. Click nodes from both branches — confirm state replay for each + +**Hamburger menu tracking:** +1. Open the menu (confirm `MAP_UI_MENU_TOGGLE`) +2. Click the eye icon to hide a layer (confirm `MAP_UI_VISIBLE_LAYER_TOGGLE`) +3. Click the palette icon to enable thematic (confirm `MAP_UI_THEMATIC_TOGGLE`) +4. Click the cursor icon to change active layer (confirm `MAP_UI_ACTIVE_LAYER_CHANGE`) +5. Navigate back to before the toggles — confirm layers restore correctly + +**Cross-view consistency:** +1. Pick a map feature → all chart highlights update +2. Click a scatter plot dot → map and other charts highlight +3. Navigate back → all views revert + +**Deduplication:** +- Clear a selection, then immediately clear again — only one clear node should be created +- Pan the map slowly — view should debounce and produce a single `MAP_VIEW` node per gesture + +**Annotations:** +1. Navigate to a node of interest +2. Enter text in the "Annotate This Step" field +3. Confirm the annotation appears in the Analysis Summary From bc598467784e5a66ba4fe84421c9c41830841a58 Mon Sep 17 00:00:00 2001 From: JP109 Date: Wed, 6 May 2026 17:37:00 -0500 Subject: [PATCH 20/21] fix: insight details in README --- autk-provenance/README.md | 249 ++++++++++++++++++++------------------ 1 file changed, 132 insertions(+), 117 deletions(-) diff --git a/autk-provenance/README.md b/autk-provenance/README.md index 97459018..a22426bb 100644 --- a/autk-provenance/README.md +++ b/autk-provenance/README.md @@ -51,6 +51,7 @@ The library can be used standalone (with any custom state shape) or as a drop-in ### Provenance Graph Every interaction creates a **node** in a directed acyclic graph. Each node stores: + - A full snapshot of the application state at that moment - The action type and human-readable label that created it - A timestamp @@ -65,6 +66,7 @@ Navigating to any node in the graph (via the trail UI, the graph modal, or progr ### Adapters Each Autark module is connected through an **adapter** that: + 1. **Records** interactions by listening to module events (DOM clicks, method calls, event emitters) and writing nodes to the graph. 2. **Applies state** from any node back onto the module when the analyst navigates the graph. @@ -101,7 +103,7 @@ const root = document.getElementById('app') as HTMLElement; renderInsightsWorkspace({ container: root, map, - collection, // GeoJSON FeatureCollection with numeric feature properties + collection, // GeoJSON FeatureCollection with numeric feature properties layerId: 'neighborhoods', title: 'NYC Neighborhood Analysis', description: 'Cross-view exploration of neighborhood indicators', @@ -109,9 +111,13 @@ renderInsightsWorkspace({ ``` The workspace renders: -- A **Map tab** with the 3D map and a thematic-color dropdown -- An **Insights tab** with four coordinated charts (scatter, bar, parallel coordinates, histogram) -- A **Provenance tab** with the interactive graph, the step trail, and back/forward navigation + +The workspace renders: + +- A persistent **Map panel** (left side) with the 3D map and a "Color by" thematic dropdown +- A **Charts tab** (right side) with four coordinated charts (scatter, bar, parallel coordinates, histogram) +- A **Provenance Trail tab** (right side) with the interactive graph, the step trail, and back/forward navigation +- A **Session Insights section** always visible below the tabs, updating as you interact All interactions across map and charts are automatically tracked. Chart axes and groupings are inferred from the GeoJSON properties. @@ -155,7 +161,7 @@ import { createAutarkProvenance, renderProvenanceTrailUI } from 'autk-provenance import { PlotType } from 'autk-provenance'; const provenance = createAutarkProvenance({ - map: mapForProvenance, // IMapForProvenance adapter object + map: mapForProvenance, // IMapForProvenance adapter object plots: [ Object.assign(scatter, { plotId: 'Scatterplot', @@ -164,6 +170,7 @@ const provenance = createAutarkProvenance({ addEventListener: (event, fn) => scatter.events.on(event, ({ selection }) => fn(selection)), removeEventListener: (event, fn) => scatter.events.off(event, fn), }, + setOwnedSelection: (ids) => scatter.setOwnedSelection(ids), setHighlightedIds: (ids) => scatter.setSelection(ids), }), ], @@ -283,7 +290,7 @@ interface ProvenanceNode { actionLabel: string; actionType: ProvenanceAction | string; timestamp: number; - metadata?: Record; // used for analyst annotations + metadata?: Record; // used for analyst annotations } ``` @@ -300,40 +307,40 @@ The graph is a **directed tree** rooted at the `ROOT` node created on initializa Creates a provenance instance wired to all provided Autark modules. ```ts -function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkProvenanceApi +function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkProvenanceApi; ``` **Options:** -| Option | Type | Description | -|--------|------|-------------| -| `map` | `IMapForProvenance` | Map adapter object (see [Map Adapter](#map-adapter)) | -| `plots` | `IPlotForProvenance[]` | Plot instances to track. Each must have `plotId`, `plotType`, `plotEvents`, `setHighlightedIds`. | -| `db` | `IDbForProvenance` | autk-db instance to track | -| `compute` | `IComputeForProvenance` | autk-compute instance to track | -| `mapConfig` | `MapSelectorConfig` | Override default map UI selectors and register custom controls | -| `initialState` | `Partial` | Override the initial state of the root node | +| Option | Type | Description | +| -------------- | -------------------------------- | ------------------------------------------------------------------------------------------------ | +| `map` | `IMapForProvenance` | Map adapter object (see [Map Adapter](#map-adapter)) | +| `plots` | `IPlotForProvenance[]` | Plot instances to track. Each must have `plotId`, `plotType`, `plotEvents`, `setHighlightedIds`. | +| `db` | `IDbForProvenance` | autk-db instance to track | +| `compute` | `IComputeForProvenance` | autk-compute instance to track | +| `mapConfig` | `MapSelectorConfig` | Override default map UI selectors and register custom controls | +| `initialState` | `Partial` | Override the initial state of the root node | **Returns `AutarkProvenanceApi`:** -| Method | Description | -|--------|-------------| -| `goToNode(nodeId)` | Navigate to any node in the graph by ID. Returns `true` if successful. | -| `goBackOneStep()` | Move to the parent of the current node. Returns `true` if possible. | -| `goForwardOneStep()` | Move to the most-recently-visited child. Returns `true` if possible. | -| `canGoBack()` | Returns `true` if the current node has a parent. | -| `canGoForward()` | Returns `true` if the current node has children. | -| `getPathFromRoot()` | Returns the ordered list of nodes from root to current. | -| `getGraph()` | Returns the full `ProvenanceGraph` (all nodes, root ID, current ID). | -| `getCurrentNode()` | Returns the current `ProvenanceNode` or `null`. | -| `getCurrentState()` | Returns the current `AutarkProvenanceState` or `null`. | -| `exportGraph()` | Serializes the full graph to a JSON string. | -| `importGraph(json)` | Replaces the graph from a previously exported JSON string. | -| `addObserver(cb)` | Registers a callback fired on every graph change. Returns an unsubscribe function. | -| `annotateNode(nodeId, text)` | Attaches a text annotation to any node without creating a new provenance step. | -| `startRecording()` | Resume recording after a `stopRecording()` call. | -| `stopRecording()` | Pause all recording (interactions are ignored until `startRecording()`). | -| `db` | Exposes the `DbAdapterApi` for manual DB recording operations. | +| Method | Description | +| ---------------------------- | ---------------------------------------------------------------------------------- | +| `goToNode(nodeId)` | Navigate to any node in the graph by ID. Returns `true` if successful. | +| `goBackOneStep()` | Move to the parent of the current node. Returns `true` if possible. | +| `goForwardOneStep()` | Move to the most-recently-visited child. Returns `true` if possible. | +| `canGoBack()` | Returns `true` if the current node has a parent. | +| `canGoForward()` | Returns `true` if the current node has children. | +| `getPathFromRoot()` | Returns the ordered list of nodes from root to current. | +| `getGraph()` | Returns the full `ProvenanceGraph` (all nodes, root ID, current ID). | +| `getCurrentNode()` | Returns the current `ProvenanceNode` or `null`. | +| `getCurrentState()` | Returns the current `AutarkProvenanceState` or `null`. | +| `exportGraph()` | Serializes the full graph to a JSON string. | +| `importGraph(json)` | Replaces the graph from a previously exported JSON string. | +| `addObserver(cb)` | Registers a callback fired on every graph change. Returns an unsubscribe function. | +| `annotateNode(nodeId, text)` | Attaches a text annotation to any node without creating a new provenance step. | +| `startRecording()` | Resume recording after a `stopRecording()` call. | +| `stopRecording()` | Pause all recording (interactions are ignored until `startRecording()`). | +| `db` | Exposes the `DbAdapterApi` for manual DB recording operations. | --- @@ -342,33 +349,32 @@ function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkP Renders a fully wired analytics workspace (map + 4 charts + provenance trail + insights) into a container element. ```ts -function renderInsightsWorkspace( - options: RenderInsightsWorkspaceOptions -): RenderInsightsWorkspaceResult +function renderInsightsWorkspace(options: RenderInsightsWorkspaceOptions): RenderInsightsWorkspaceResult; ``` **Options:** -| Option | Type | Description | -|--------|------|-------------| -| `container` | `HTMLElement` | Root element to render the workspace into | -| `map` | `AutkMap` | Map instance from autk-map | -| `collection` | `FeatureCollection` | GeoJSON collection — source for all charts | -| `layerId` | `string` | Layer ID already registered in the map | -| `db` | `IDbForProvenance` | Optional database instance for DB provenance | -| `title` | `string` | Workspace header title | -| `description` | `string` | Workspace header description | -| `mapConfig` | `MapSelectorConfig` | Optional map selector / custom control overrides | +| Option | Type | Description | +| ------------- | ------------------- | ------------------------------------------------ | +| `container` | `HTMLElement` | Root element to render the workspace into | +| `map` | `AutkMap` | Map instance from autk-map | +| `collection` | `FeatureCollection` | GeoJSON collection — source for all charts | +| `layerId` | `string` | Layer ID already registered in the map | +| `db` | `IDbForProvenance` | Optional database instance for DB provenance | +| `title` | `string` | Workspace header title | +| `description` | `string` | Workspace header description | +| `mapConfig` | `MapSelectorConfig` | Optional map selector / custom control overrides | **Returns:** -| Property | Type | Description | -|----------|------|-------------| -| `provenance` | `AutarkProvenanceApi` | The live provenance API for the workspace | -| `schema` | `InsightsChartSchema` | Resolved chart configuration (fields, titles, collections) | -| `destroy()` | `() => void` | Tears down all listeners, empties the container | +| Property | Type | Description | +| ------------ | --------------------- | ---------------------------------------------------------- | +| `provenance` | `AutarkProvenanceApi` | The live provenance API for the workspace | +| `schema` | `InsightsChartSchema` | Resolved chart configuration (fields, titles, collections) | +| `destroy()` | `() => void` | Tears down all listeners, empties the container | The workspace infers chart axes automatically from the GeoJSON feature properties: + - **Scatter plot**: two highest-variance numeric fields - **Bar chart**: most distinct categorical field vs count - **Parallel coordinates**: up to six numeric fields @@ -381,21 +387,21 @@ The workspace infers chart axes automatically from the GeoJSON feature propertie Renders the provenance trail panel — graph preview, step list, navigation buttons, and optional insights — into any container element. ```ts -function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void +function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void; ``` **Options:** -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `provenance` | `AutarkProvenanceApi` | required | The provenance instance to display | -| `container` | `HTMLElement` | required | Element to render into | -| `insightsContainer` | `HTMLElement` | — | Separate container for the insights panel (defaults to `container`) | -| `showInsights` | `boolean` | `true` | Show the collapsible insights panel | -| `showBackForward` | `boolean` | `true` | Show back/forward navigation buttons | -| `showTimestamps` | `boolean` | `true` | Show timestamps on trail steps | -| `showGraph` | `boolean` | `true` | Show the mini graph preview | -| `showPathList` | `boolean` | `true` | Show the linear step trail list | +| Option | Type | Default | Description | +| ------------------- | --------------------- | -------- | ------------------------------------------------------------------- | +| `provenance` | `AutarkProvenanceApi` | required | The provenance instance to display | +| `container` | `HTMLElement` | required | Element to render into | +| `insightsContainer` | `HTMLElement` | — | Separate container for the insights panel (defaults to `container`) | +| `showInsights` | `boolean` | `true` | Show the collapsible insights panel | +| `showBackForward` | `boolean` | `true` | Show back/forward navigation buttons | +| `showTimestamps` | `boolean` | `true` | Show timestamps on trail steps | +| `showGraph` | `boolean` | `true` | Show the mini graph preview | +| `showPathList` | `boolean` | `true` | Show the linear step trail list | Returns a **cleanup function** — call it to remove all event listeners and clear the container. @@ -408,18 +414,16 @@ The graph preview is interactive: clicking it opens a full **graph modal** with Generic provenance factory for custom state shapes not tied to Autark modules. ```ts -function createProvenance>( - options: CreateProvenanceOptions -): ProvenanceApi +function createProvenance>(options: CreateProvenanceOptions): ProvenanceApi; ``` **Options:** -| Option | Type | Description | -|--------|------|-------------| -| `initialState` | `T` | The initial state for the root node | -| `adapter` | `ProvenanceAdapter` | Object implementing `applyState(state: T): void` | -| `mergeState` | `(base: T, delta: Partial) => T` | Optional custom merge strategy (defaults to shallow spread) | +| Option | Type | Description | +| -------------- | ----------------------------------- | ----------------------------------------------------------- | +| `initialState` | `T` | The initial state for the root node | +| `adapter` | `ProvenanceAdapter` | Object implementing `applyState(state: T): void` | +| `mergeState` | `(base: T, delta: Partial) => T` | Optional custom merge strategy (defaults to shallow spread) | **Returns `ProvenanceApi`** — the same navigation, graph, export/import, and observer API as `AutarkProvenanceApi`, plus `applyAction(actionType, label, delta)` for manually recording interactions. @@ -466,6 +470,7 @@ const metrics = computeGraphMetrics(graph); ``` **Strategy classification:** + - **Confirmatory** — linear, focused analysis; the analyst knew what they were looking for - **Exploratory** — multiple diverging branches; broad, open-ended investigation - **Iterative Refinement** — high backtrack rate and branch ratio; hypothesis-driven revision @@ -500,6 +505,7 @@ const narrative = generateSessionNarrative(graph, metrics, annotations); The map adapter connects an `AutkMap` (or any object implementing `IMapForProvenance`) to the provenance system. **What it records:** + - Map initialization (`MAP_INIT`) and layer loads (`MAP_LAYER_LOAD`) - Feature picks / selections (`MAP_PICK`) via the `picking` event - Camera viewport changes (`MAP_VIEW`) — debounced at 180 ms @@ -509,6 +515,7 @@ The map adapter connects an `AutkMap` (or any object implementing `IMapForProven - Thematic colormap toggles (`MAP_UI_THEMATIC_TOGGLE`) — palette icon **What it restores on node navigation:** + - Layer visibility (`isSkip` render flag on each layer) - Active layer (`map.ui.changeActiveLayer`) - Thematic state (`isColorMap` render flag on each layer) @@ -553,6 +560,7 @@ When using `renderInsightsWorkspace` or `createAutarkProvenance` with a real `Au The plot adapter connects one or more interactive plots to the provenance system. **What it records:** + - Click-based selections (`PLOT_CLICK`) — individual point or bar clicks - 2D brush selections (`PLOT_BRUSH`) — rectangular lasso in scatter plots - 1D horizontal brush (`PLOT_BRUSH_X`) @@ -565,13 +573,14 @@ The plot adapter connects one or more interactive plots to the provenance system ```ts interface IPlotForProvenance { - plotId: string; // Stable unique identifier, used as the state key - plotType: PlotType; // SCATTERPLOT | BARCHART | PARALLEL_COORDINATES | HISTOGRAM + plotId: string; // Stable unique identifier, used as the state key + plotType: PlotType; // SCATTERPLOT | BARCHART | PARALLEL_COORDINATES | HISTOGRAM plotEvents: { addEventListener(event: string, listener: (selection: number[]) => void): void; removeEventListener?(event: string, listener: (selection: number[]) => void): void; }; - setHighlightedIds(ids: number[]): void; // Apply coordinated highlight + setOwnedSelection(ids: number[]): void; + setHighlightedIds(ids: number[]): void; // Apply coordinated highlight } ``` @@ -585,18 +594,18 @@ The DB adapter connects an `autk-db` database instance to provenance. It records **What it records:** -| Action | Trigger | -|--------|---------| -| `DB_INIT` | Database initialization | -| `DB_WORKSPACE` | Workspace open | -| `DB_LOAD_OSM` | OpenStreetMap data load | -| `DB_LOAD_CSV` / `DB_LOAD_JSON` | CSV or JSON file import | -| `DB_LOAD_LAYER` / `DB_LOAD_CUSTOM_LAYER` / `DB_LOAD_GRID_LAYER` | Layer loads | -| `DB_GET_LAYER` | Layer retrieval | -| `DB_SPATIAL_JOIN` | Spatial join operation | -| `DB_UPDATE_TABLE` / `DB_DROP_TABLE` | Table mutations | -| `DB_RAW_QUERY` | Arbitrary SQL execution | -| `DB_BUILD_HEATMAP` | Heatmap construction | +| Action | Trigger | +| --------------------------------------------------------------- | ----------------------- | +| `DB_INIT` | Database initialization | +| `DB_WORKSPACE` | Workspace open | +| `DB_LOAD_OSM` | OpenStreetMap data load | +| `DB_LOAD_CSV` / `DB_LOAD_JSON` | CSV or JSON file import | +| `DB_LOAD_LAYER` / `DB_LOAD_CUSTOM_LAYER` / `DB_LOAD_GRID_LAYER` | Layer loads | +| `DB_GET_LAYER` | Layer retrieval | +| `DB_SPATIAL_JOIN` | Spatial join operation | +| `DB_UPDATE_TABLE` / `DB_DROP_TABLE` | Table mutations | +| `DB_RAW_QUERY` | Arbitrary SQL execution | +| `DB_BUILD_HEATMAP` | Heatmap construction | The DB adapter also exposes a `db` property on `AutarkProvenanceApi` for manual recording calls when automatic wrapping is insufficient. @@ -607,6 +616,7 @@ The DB adapter also exposes a `db` property on `AutarkProvenanceApi` for manual The compute adapter wraps `autk-compute`'s `GeojsonCompute` instance and records GPU computation runs. **What it records:** + - `COMPUTE_RUN` — fires after `computeFunctionIntoProperties` resolves, storing the output column name, feature count, and timestamp in `state.filters.lastCompute`. --- @@ -644,6 +654,7 @@ const provenance = createAutarkProvenance({ map, plots, mapConfig }); ``` Each custom control config: + - `selector` — CSS selector to match the control element - `event` — `'click'` or `'change'` - `actionType` — the provenance action label stored on the node @@ -657,44 +668,42 @@ Each custom control config: The following actions are defined in `ProvenanceAction` and each creates one node in the provenance graph: -| Action | Description | -|--------|-------------| -| `ROOT` | Initial state node, created automatically on startup | -| `MAP_INIT` | Map canvas initialized | -| `MAP_LAYER_LOAD` | A GeoJSON or raster layer was loaded | -| `MAP_PICK` | User picked features on the map | -| `MAP_VIEW` | Camera viewport changed (debounced) | -| `MAP_UI_MENU_TOGGLE` | Hamburger menu opened or closed | -| `MAP_UI_VISIBLE_LAYER_TOGGLE` | Eye icon toggled a layer's visibility | -| `MAP_UI_ACTIVE_LAYER_CHANGE` | Cursor icon changed the active picking layer | -| `MAP_UI_THEMATIC_TOGGLE` | Palette icon toggled thematic colormap | -| `MAP_UI_CUSTOM_CONTROL` | A registered custom control was interacted with | -| `PLOT_CLICK` | User clicked a mark in a plot | -| `PLOT_BRUSH` | 2D brush selection in scatter plot | -| `PLOT_BRUSH_X` | Horizontal brush selection | -| `PLOT_BRUSH_Y` | Vertical brush (parallel coordinates) | -| `PLOT_DATA` | A plot's data collection was replaced | -| `PLOT_ADD` / `PLOT_REMOVE` | Plot added or removed dynamically | -| `COMPUTE_RUN` | GPU computation completed | -| `DB_INIT` | Database initialized | -| `DB_WORKSPACE` | Workspace opened | -| `DB_LOAD_OSM` | OSM data loaded | -| `DB_LOAD_CSV` / `DB_LOAD_JSON` | CSV or JSON imported | -| `DB_LOAD_LAYER` / `DB_LOAD_CUSTOM_LAYER` / `DB_LOAD_GRID_LAYER` | Layer loaded | -| `DB_GET_LAYER` | Layer retrieved from DB | -| `DB_SPATIAL_JOIN` | Spatial join performed | -| `DB_UPDATE_TABLE` / `DB_DROP_TABLE` | Table modified or dropped | -| `DB_RAW_QUERY` | Raw SQL query executed | -| `DB_BUILD_HEATMAP` | Heatmap built | +| Action | Description | +| --------------------------------------------------------------- | ---------------------------------------------------- | +| `ROOT` | Initial state node, created automatically on startup | +| `MAP_INIT` | Map canvas initialized | +| `MAP_LAYER_LOAD` | A GeoJSON or raster layer was loaded | +| `MAP_PICK` | User picked features on the map | +| `MAP_VIEW` | Camera viewport changed (debounced) | +| `MAP_UI_MENU_TOGGLE` | Hamburger menu opened or closed | +| `MAP_UI_VISIBLE_LAYER_TOGGLE` | Eye icon toggled a layer's visibility | +| `MAP_UI_ACTIVE_LAYER_CHANGE` | Cursor icon changed the active picking layer | +| `MAP_UI_THEMATIC_TOGGLE` | Palette icon toggled thematic colormap | +| `MAP_UI_CUSTOM_CONTROL` | A registered custom control was interacted with | +| `PLOT_CLICK` | User clicked a mark in a plot | +| `PLOT_BRUSH` | 2D brush selection in scatter plot | +| `PLOT_BRUSH_X` | Horizontal brush selection | +| `PLOT_BRUSH_Y` | Vertical brush (parallel coordinates) | +| `PLOT_DATA` | A plot's data collection was replaced | +| `PLOT_ADD` / `PLOT_REMOVE` | Plot added or removed dynamically | +| `COMPUTE_RUN` | GPU computation completed | +| `DB_INIT` | Database initialized | +| `DB_WORKSPACE` | Workspace opened | +| `DB_LOAD_OSM` | OSM data loaded | +| `DB_LOAD_CSV` / `DB_LOAD_JSON` | CSV or JSON imported | +| `DB_LOAD_LAYER` / `DB_LOAD_CUSTOM_LAYER` / `DB_LOAD_GRID_LAYER` | Layer loaded | +| `DB_GET_LAYER` | Layer retrieved from DB | +| `DB_SPATIAL_JOIN` | Spatial join performed | +| `DB_UPDATE_TABLE` / `DB_DROP_TABLE` | Table modified or dropped | +| `DB_RAW_QUERY` | Raw SQL query executed | +| `DB_BUILD_HEATMAP` | Heatmap built | --- ## Session Insights -The workspace and the provenance trail both include a **session insights panel** that surfaces: +The workspace includes a **session insights panel** that surfaces: -- **Analysis Strategy** — Confirmatory, Exploratory, or Iterative Refinement, based on branch ratio and backtrack count -- **Selection Focus** — the most revisited map features and plot data points across all graph branches - **Annotate This Step** — a free-text input to attach a note to the current node - **Analysis Summary** — a generated narrative describing the session, strategy, top selections, and all annotations @@ -771,10 +780,12 @@ __provenance.exportGraph(); ### Manual test checklist **Startup and data load:** + - Reload the page — confirm early trail entries for map initialization and layer loading - Confirm the root `Start` node appears in the graph **Branch navigation:** + 1. Pick a map feature → confirm a `MAP_PICK` node appears 2. Navigate back to the root 3. Brush the scatter plot → confirm a `PLOT_BRUSH` node appears on a new branch @@ -782,6 +793,7 @@ __provenance.exportGraph(); 5. Click nodes from both branches — confirm state replay for each **Hamburger menu tracking:** + 1. Open the menu (confirm `MAP_UI_MENU_TOGGLE`) 2. Click the eye icon to hide a layer (confirm `MAP_UI_VISIBLE_LAYER_TOGGLE`) 3. Click the palette icon to enable thematic (confirm `MAP_UI_THEMATIC_TOGGLE`) @@ -789,15 +801,18 @@ __provenance.exportGraph(); 5. Navigate back to before the toggles — confirm layers restore correctly **Cross-view consistency:** + 1. Pick a map feature → all chart highlights update 2. Click a scatter plot dot → map and other charts highlight 3. Navigate back → all views revert **Deduplication:** + - Clear a selection, then immediately clear again — only one clear node should be created - Pan the map slowly — view should debounce and produce a single `MAP_VIEW` node per gesture **Annotations:** + 1. Navigate to a node of interest 2. Enter text in the "Annotate This Step" field 3. Confirm the annotation appears in the Analysis Summary From ff2ce2451e7285fc6b68abab65175dbd9da967be Mon Sep 17 00:00:00 2001 From: JP109 Date: Wed, 6 May 2026 17:44:40 -0500 Subject: [PATCH 21/21] fix: quickstart guides for provenance module --- autk-provenance/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/autk-provenance/README.md b/autk-provenance/README.md index a22426bb..dac72fa7 100644 --- a/autk-provenance/README.md +++ b/autk-provenance/README.md @@ -180,7 +180,7 @@ const provenance = createAutarkProvenance({ // Render the trail UI into a sidebar container const destroyTrail = renderProvenanceTrailUI({ provenance, - container: document.getElementById('provenance-sidebar'), + container: document.getElementById('provenance-sidebar')!, showGraph: true, showPathList: true, showBackForward: true, @@ -193,7 +193,8 @@ provenance.goForwardOneStep(); provenance.goToNode(nodeId); // Annotate a key finding -provenance.annotateNode(provenance.getCurrentNode().id, 'High density cluster in Brooklyn'); +const currentNode = provenance.getCurrentNode(); +if (currentNode) provenance.annotateNode(currentNode.id, 'High density cluster in Brooklyn'); // Export for sharing const json = provenance.exportGraph(); @@ -208,11 +209,11 @@ Use `createProvenance` to track any custom state shape with your own adapter, wi ```ts import { createProvenance } from 'autk-provenance'; -interface AppState { +type AppState = { selectedRegion: string | null; year: number; showGrid: boolean; -} +}; const provenance = createProvenance({ initialState: { selectedRegion: null, year: 2024, showGrid: false },