diff --git a/Makefile b/Makefile index 7ebc27b0..4b96d2bd 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 +RIMRAF := rm -rf 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: + 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" @@ -27,41 +32,85 @@ build: "cd autk-db && npm run build" \ "cd autk-plot && npm run build" \ "cd autk-compute && npm run build" - cd autk && 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-compute && npm run build" + cd autk-provenance && 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 + 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" \ "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,18 @@ 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 autk-provenance && $(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/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-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-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-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-db/package.json b/autk-db/package.json index 40334a61..c40a2a71 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": "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-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-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-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 new file mode 100644 index 00000000..6b9fed48 --- /dev/null +++ b/autk-plot/src/plot-types/histogram.ts @@ -0,0 +1,293 @@ +import * as d3 from 'd3'; + +import { PlotBaseInteractive } from '../plot-base-interactive'; +import type { PlotConfig } from '../api'; +import { PlotStyle } from '../plot-style'; +import { PlotEvent } from '../types-events'; +import { valueAtPath } from '../types-core'; + +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.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 PlotBaseInteractive { + private binData: d3.Bin[] = []; + + constructor(config: PlotConfig) { + if (config.events === undefined) { config.events = [PlotEvent.CLICK]; } + super(config); + this.draw(); + } + + 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 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 axisAttr = this.renderAxisAttributes[0]; + const values: BinDatum[] = this._data.map((d, i) => ({ + value: Number(d ? valueAtPath(d, axisAttr) ?? 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._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); + + 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(); + } + + 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)]; + } + + // Highlight a bin only when every feature mapped to that bin is selected. + protected override renderSelection(): void { + 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]; + const fullySelected = !!bin && bin.length > 0 && bin.every((d) => selectedSet.has(d.index)); + return fullySelected ? PlotStyle.highlight : PlotStyle.default; + }); + } + + // 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; + + 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.setLocalSelection(next); + plot.events.emit(PlotEvent.CLICK, { selection: plot.selection }); + }); + }); + + cls.on('click', function () { + plot.setLocalSelection([]); + plot.events.emit(PlotEvent.CLICK, { selection: [] }); + }); + } + + 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.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.setLocalSelection(plot.binIndicesToFeatureIds(brushedBins)); + } else { + plot.setLocalSelection([]); + } + plot.events.emit(PlotEvent.BRUSH, { selection: plot.selection }); + }); + cBrush.call(brush); + }); + } + + 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.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.setLocalSelection(plot.binIndicesToFeatureIds(brushedBins)); + } else { + plot.setLocalSelection([]); + } + plot.events.emit(PlotEvent.BRUSH_X, { selection: plot.selection }); + }); + 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; + + 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.setLocalSelection(plot.binIndicesToFeatureIds(brushedBins)); + } else { + plot.setLocalSelection([]); + } + 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..b55d8e7d 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.highlightedSelection); + 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.setLocalSelection(next); + plot.events.emit(PlotEvent.CLICK, { selection: plot.selection }); + }); + }); + + clearLayer.on('click', function () { + 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/README.md b/autk-provenance/README.md new file mode 100644 index 00000000..dac72fa7 --- /dev/null +++ b/autk-provenance/README.md @@ -0,0 +1,819 @@ +# autk-provenance: Provenance Tracking for Urban Visual Analytics + +
+ Autark Logo
+
+
+ +**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. + +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. + +## Resources + +- [Documentation](https://autarkjs.org/introduction.html) +- [Examples](https://autarkjs.org/gallery/) +- [Use Cases](https://autarkjs.org/usecases/) + +--- + +## 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 +npm install autk-provenance +``` + +`autk-provenance` depends on `autk-plot` at runtime when using `renderInsightsWorkspace`. Build `autk-plot` before `autk-provenance` in any build pipeline. + +--- + +## Quick Start — Full Workspace + +`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', +}); +``` + +The workspace renders: + +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. + +### Full options + +```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: [...], + }, +}); +``` + +The returned object exposes the provenance API and a `destroy()` method for cleanup: + +```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), + }, + setOwnedSelection: (ids) => scatter.setOwnedSelection(ids), + 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 +const currentNode = provenance.getCurrentNode(); +if (currentNode) provenance.annotateNode(currentNode.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'; + +type 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(); +``` + +--- + +## State Shape + +The canonical state type for Autark provenance is `AutarkProvenanceState`: + +```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; +} +``` + +Each provenance node stores a full snapshot of this state. Navigating to a node restores the complete snapshot. + +--- + +## Provenance Graph + +The graph data structure is: + +```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 +} +``` + +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. + +`getPathFromRoot()` returns the linear path of nodes from the root to the current node — the "main branch" visible in the trail UI. + +--- + +## API Reference + +### `createAutarkProvenance` + +Creates a provenance instance wired to all provided Autark modules. + +```ts +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 | + +**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. + +```ts +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 | + +**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 | + +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 + +--- + +### `renderProvenanceTrailUI` + +Renders the provenance trail panel — graph preview, step list, navigation buttons, and optional insights — into any container element. + +```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. + +--- + +### `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; + }; + setOwnedSelection(ids: number[]): 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 includes a **session insights panel** that surfaces: + +- **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 +make dev +``` + +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`. + +### Open the provenance workspace example + +``` +http://localhost:5173/src/autk-provenance/all-plots-provenance.html +``` + +### Inspect state from the browser console + +```ts +// Temporarily expose in your page script for debugging +(window as any).__provenance = provenance; +``` + +Then in DevTools: + +```js +__provenance.getCurrentState(); +__provenance.getGraph(); +__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 +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 diff --git a/autk-provenance/package.json b/autk-provenance/package.json new file mode 100644 index 00000000..51dfdcf7 --- /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": "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": { + "@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/compute-adapter.ts b/autk-provenance/src/adapters/compute-adapter.ts new file mode 100644 index 00000000..a0e0d8e7 --- /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/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 new file mode 100644 index 00000000..c40ce731 --- /dev/null +++ b/autk-provenance/src/adapters/db-adapter.ts @@ -0,0 +1,64 @@ +import type { AutarkProvenanceState } from '../types'; +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, IDbForProvenance } from './db/types'; + +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; +} + +export function createDbAdapter(db: IDbForProvenance, onRecord: DbRecordCallback): DbAdapterApi { + let isRecording = false; + let isApplyingState = false; + const recorder = createDbRecorder(db, onRecord); + const methodRecording = createDbMethodRecordingController({ + db, + recorder, + isRecording: () => isRecording, + isApplyingState: () => isApplyingState, + }); + + return { + 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-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/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/index.ts b/autk-provenance/src/adapters/index.ts new file mode 100644 index 00000000..074abb44 --- /dev/null +++ b/autk-provenance/src/adapters/index.ts @@ -0,0 +1,19 @@ +export { createMapAdapter } 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 { + createDbAdapter, + createDbProvenanceWrapper, +} from './db-adapter'; +export type { + DbAdapterApi, + 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-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..fa4c52dc --- /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 ?? '#autkMapUi', + 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..eed77892 --- /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 ui.getAllLayers()) { + 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 new file mode 100644 index 00000000..b1933e55 --- /dev/null +++ b/autk-provenance/src/adapters/map-adapter.ts @@ -0,0 +1,51 @@ +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 = resolveMapSelectors(selectorConfig); + const customControls: CustomControlConfig[] = selectorConfig?.customControls ?? []; + let isApplyingState = false; + let currentState: AutarkProvenanceState = { selection: { map: null, plots: {} } }; + 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: 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-ui-helpers.ts b/autk-provenance/src/adapters/map-ui-helpers.ts new file mode 100644 index 00000000..ecae28ab --- /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.layers 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() ?? getAllLayers()[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/adapters/map/dom.ts b/autk-provenance/src/adapters/map/dom.ts new file mode 100644 index 00000000..3a1a9417 --- /dev/null +++ b/autk-provenance/src/adapters/map/dom.ts @@ -0,0 +1,54 @@ +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'; + if (ui.mapMenuOpen) map.ui?.refreshLayerList?.(); + + 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-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 new file mode 100644 index 00000000..942f5788 --- /dev/null +++ b/autk-provenance/src/adapters/map/recording.ts @@ -0,0 +1,174 @@ +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, getMenuOpen, getThematicEnabled, isElement } from './utils'; + +const MAP_PICK_EVENT = 'picking'; + +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 mapUiObj = (map.ui ?? {}) as Record; + const wrappedMethods = new Map(); + const cleanups: Array<() => void> = []; + const controls = createControlRecorder({ map, customControls, onRecord, getCurrentState, buildUiDelta }); + const viewRecorder = createViewRecorder(onRecord, getCurrentState, isApplyingState); + + const wrapMapMethod = (methodName: string, onAfter: (args: unknown[]) => void) => { + wrapMethod(mapObj, methodName, onAfter); + }; + const wrapUiMethod = (methodName: string, onAfter: (args: unknown[]) => void) => { + wrapMethod(mapUiObj, methodName, onAfter); + }; + + 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); + target[methodName] = function (...args: unknown[]) { + const result = (current as (...values: unknown[]) => unknown).apply(this, args); + if (!isApplyingState()) onAfter(args); + return result; + }; + }; + + 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, + ]), + ); + 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)); + }; + + const attachViewListener = () => { + if (!map.addViewListener) return; + const viewListener = (viewState: MapViewState) => { + if (!isApplyingState()) viewRecorder.schedule(viewState); + }; + map.addViewListener(viewListener); + cleanups.push(() => map.removeViewListener?.(viewListener)); + }; + + 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()); + }); + wrapMapMethod('loadGeoTiffLayer', ([layerName]) => { + onRecord(ProvenanceAction.MAP_LAYER_LOAD, `Raster layer loaded: ${typeof layerName === 'string' ? layerName : 'raster'}`, buildUiDelta()); + }); + wrapMapMethod('updateRenderInfo', ([layerId, params]) => { + controls.recordUpdateRenderInfo(layerId, params); + }); + 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 }), + ); + }); + + attachPickListener(); + attachViewListener(); + attachDocumentListeners(); + }, + stop: () => { + viewRecorder.stop(); + cleanups.splice(0).forEach((cleanup) => cleanup()); + wrappedMethods.forEach((original, methodName) => { + 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/state.ts b/autk-provenance/src/adapters/map/state.ts new file mode 100644 index 00000000..001a8810 --- /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))]; + + getAllLayers(map).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..5e53b1e2 --- /dev/null +++ b/autk-provenance/src/adapters/map/utils.ts @@ -0,0 +1,71 @@ +import type { AutarkProvenanceState, IMapForProvenance } from '../../types'; +import type { LayerLike, MapSelectorConfig, ResolvedMapSelectors, ResolvedUiState } from './types'; + +export function resolveMapSelectors(config?: MapSelectorConfig): ResolvedMapSelectors { + return { + menuIcon: config?.menuIcon ?? '#autkMapUi', + 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.layers 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); + 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 new file mode 100644 index 00000000..765a746a --- /dev/null +++ b/autk-provenance/src/adapters/plot-adapter.ts @@ -0,0 +1,182 @@ +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; +} + +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 normalizeSelection(selection).join(','); +} + +function normalizeSelection(selection: number[]): number[] { + return [...new Set(selection)].sort((a, b) => a - b); +} + +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( + plots: IPlotForProvenance[], + onRecord: PlotRecordCallback +): PlotAdapterApi { + let isApplyingState = false; + + 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 ownedSelection = normalizeSelection(selection); + const sig = selectionSignature(ownedSelection); + if (sig === entry.lastSelectionSig) return; + entry.lastSelectionSig = sig; + + const typeLabel = plotTypeLabel(plot.plotType); + const label = + ownedSelection.length === 0 + ? `Cleared selection on ${typeLabel} (${plot.plotId})` + : `${event}: ${ownedSelection.length} point(s) on ${typeLabel} (${plot.plotId})`; + + onRecord(actionType, label, { + selection: { + plots: { + [plot.plotId]: { ids: ownedSelection, plotType: plot.plotType }, + }, + } as unknown as AutarkProvenanceState['selection'], + }); + }; + entry.listeners.push({ event, fn }); + plot.plotEvents.addEventListener(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: { + plots: { [plot.plotId]: { ids: [], plotType: plot.plotType } }, + } as unknown as AutarkProvenanceState['selection'], + }); + } + }, + 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); + } + } + + 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 ownedIds = state.selection?.plots?.[entry.plot.plotId]?.ids ?? []; + entry.lastSelectionSig = selectionSignature(ownedIds); + entry.plot.setOwnedSelection(ownedIds); + entry.plot.setHighlightedIds(coordinatedIds); + } + } finally { + isApplyingState = false; + } + } + + return { + startRecording, + stopRecording, + applyState, + }; +} 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..697cbb04 --- /dev/null +++ b/autk-provenance/src/charts/chart-modal.ts @@ -0,0 +1,241 @@ +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) return; + modalPlot.setLocalSelection([...active.originalPlot.selection]); + modalPlot.setSelection([...active.originalPlot.highlightedSelection]); + } + + 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.setLocalSelection([...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/core-engine.ts b/autk-provenance/src/core-engine.ts new file mode 100644 index 00000000..ffc4382a --- /dev/null +++ b/autk-provenance/src/core-engine.ts @@ -0,0 +1,197 @@ +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 }; + notify(); + 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 new file mode 100644 index 00000000..412bd0e7 --- /dev/null +++ b/autk-provenance/src/core.ts @@ -0,0 +1,32 @@ +import type { AutarkProvenanceState } from './types'; +import { ProvenanceAction } from './types'; +import type { ProvenanceCoreApi } from './core/api'; +import { createProvenanceCoreStore } from './core/store'; +import { mergeAutarkProvenanceState } from './core/state'; + +export type { ProvenanceCoreApi } from './core/api'; + +export interface ProvenanceCoreOptions { + initialState: AutarkProvenanceState; +} + +export interface ProvenanceCoreGenericOptions { + initialState: T; + mergeState: (base: T, delta: Partial) => T; +} + +export function createProvenanceCore( + options: ProvenanceCoreOptions +): ProvenanceCoreApi { + return createProvenanceCoreStore({ + initialState: options.initialState, + mergeState: mergeAutarkProvenanceState, + rootActionType: ProvenanceAction.ROOT, + }); +} + +export function createProvenanceCoreGeneric>( + options: ProvenanceCoreGenericOptions +): ProvenanceCoreApi { + 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..775d5cfe --- /dev/null +++ b/autk-provenance/src/core/store.ts @@ -0,0 +1,136 @@ +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 }; + notifyObservers(); + return true; + }, + }; +} diff --git a/autk-provenance/src/create-autark-provenance.ts b/autk-provenance/src/create-autark-provenance.ts new file mode 100644 index 00000000..6c8d8729 --- /dev/null +++ b/autk-provenance/src/create-autark-provenance.ts @@ -0,0 +1,144 @@ +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 { 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, + plots: {}, + }, + ui: { + mapMenuOpen: false, + activeLayerId: null, + thematicEnabled: false, + }, +}; + +export interface CreateAutarkProvenanceOptions { + map?: IMapForProvenance; + /** 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; +} + +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; + /** Attach an insight annotation to any node without creating a new provenance step. */ + annotateNode(nodeId: string, text: string): boolean; + stopRecording(): void; + startRecording(): void; + db?: import('./adapters/db-adapter').DbAdapterApi; +} + +export function createAutarkProvenance(options: CreateAutarkProvenanceOptions): AutarkProvenanceApi { + const { map, plots, db, compute, mapConfig, initialState: initialPartial } = options; + const initialState: AutarkProvenanceState = { + selection: { + map: initialPartial?.selection?.map ?? DEFAULT_STATE.selection.map, + plots: initialPartial?.selection?.plots ?? { ...DEFAULT_STATE.selection.plots }, + }, + ui: { + ...(DEFAULT_STATE.ui ?? {}), + ...(initialPartial?.ui ?? {}), + }, + }; + 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 = plots && plots.length > 0 + ? createPlotAdapter(plots, (actionType, label, delta) => { + core.applyAction(actionType, label, delta); + }) + : null; + + const dbAdapter = db + ? createDbAdapter(db, (actionType, label, delta) => { + core.applyAction(actionType, label, delta); + }) + : 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); + plotAdapter?.applyState(state); + if (dbAdapter) { + dbAdapter.applyState(state).catch(() => {}); + } + }); + + function stopRecording(): void { + mapAdapter?.stopRecording(); + plotAdapter?.stopRecording(); + dbAdapter?.stopRecording(); + computeAdapter?.stopRecording(); + } + + function startRecording(): void { + mapAdapter?.startRecording(); + plotAdapter?.startRecording(); + dbAdapter?.startRecording(); + computeAdapter?.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), + annotateNode: core.annotateNode.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..bdfff9c3 --- /dev/null +++ b/autk-provenance/src/create-provenance.ts @@ -0,0 +1,62 @@ +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; + annotateNode(nodeId: string, text: string): boolean; +} + +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), + annotateNode: core.annotateNode.bind(core), + }; +} diff --git a/autk-provenance/src/index.ts b/autk-provenance/src/index.ts new file mode 100644 index 00000000..aadf6898 --- /dev/null +++ b/autk-provenance/src/index.ts @@ -0,0 +1,27 @@ +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 { renderInsightsPanel } from './ui/insights-panel'; +export { renderInsightsWorkspace } from './insights-workspace'; +export type { RenderInsightsWorkspaceOptions, RenderInsightsWorkspaceResult } from './insights-workspace'; +export * from './adapters'; +export { + computeSelectionFrequency, + computeGraphMetrics, + getInsightAnnotations, + generateSessionNarrative, +} from './insight-engine'; +export type { + SelectionFrequency, + GraphMetrics, + StrategyLabel, + InsightAnnotation, + InsightsProvenanceApi, + InsightSelectionState, +} from './insight-engine'; diff --git a/autk-provenance/src/insight-engine.ts b/autk-provenance/src/insight-engine.ts new file mode 100644 index 00000000..c81d8d9b --- /dev/null +++ b/autk-provenance/src/insight-engine.ts @@ -0,0 +1,12 @@ +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, + InsightsProvenanceApi, + InsightSelectionState, + SelectionFrequency, + StrategyLabel, +} from './insights/types'; diff --git a/autk-provenance/src/insights-workspace.ts b/autk-provenance/src/insights-workspace.ts new file mode 100644 index 00000000..2819f036 --- /dev/null +++ b/autk-provenance/src/insights-workspace.ts @@ -0,0 +1,98 @@ +import type { FeatureCollection } from 'geojson'; +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 { 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 { 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; + map: AutkMap; + collection: FeatureCollection; + layerId: string; + db?: IDbForProvenance; + title?: string; + description?: string; + mapConfig?: MapSelectorConfig; +} + +export interface RenderInsightsWorkspaceResult { + provenance: AutarkProvenanceApi; + schema: InsightsChartSchema; + destroy(): void; +} + +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); + + applySchemaToShell(shell, schema); + const unmountMap = mountMapInWorkspace(map, shell.mapBody); + const charts = createWorkspaceCharts(shell, schema); + const chartModal = createChartModalController(charts.modalDescriptors); + + const thematicControl = createThematicControl(map, schema, layerId); + shell.thematicSelect.addEventListener('change', () => { + applyThematic(map, schema.collection, layerId, shell.thematicSelect.value); + }); + + const provenance = createAutarkProvenance({ + map: createMapForProvenance(map), + plots: [ + 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] }, + }); + + const destroyTrail = renderProvenanceTrailUI({ + provenance, + container: shell.provenanceTrail, + showInsights: false, + 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(); + unmountMap(); + container.innerHTML = ''; + }, + }; +} diff --git a/autk-provenance/src/insights/annotations.ts b/autk-provenance/src/insights/annotations.ts new file mode 100644 index 00000000..c18854c1 --- /dev/null +++ b/autk-provenance/src/insights/annotations.ts @@ -0,0 +1,20 @@ +import type { ProvenanceGraph } from '../types'; +import type { InsightAnnotation } from './types'; + +export function getInsightAnnotations(graph: ProvenanceGraph): InsightAnnotation[] { + const annotations: InsightAnnotation[] = []; + + for (const node of graph.nodes.values()) { + const text = node.metadata?.insight; + if (typeof text === 'string' && text.trim().length > 0) { + annotations.push({ + nodeId: node.id, + actionLabel: node.actionLabel, + text: text.trim(), + timestamp: node.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 new file mode 100644 index 00000000..f3b5aafb --- /dev/null +++ b/autk-provenance/src/insights/graph-metrics.ts @@ -0,0 +1,59 @@ +import type { ProvenanceGraph } from '../types'; +import type { GraphMetrics } from './types'; + +function computeMaxDepth(graph: ProvenanceGraph): number { + let maxDepth = 0; + const visited = new Set(); + + 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 insightCount = nodes.filter((node) => typeof node.metadata?.insight === 'string' && `${node.metadata.insight}`.trim().length > 0).length; + + if (nodes.length <= 1) { + return { + totalNodes: nodes.length, + branchPoints: 0, + backtracks: 0, + maxDepth: 0, + sessionDurationMs: 0, + avgTimePerStateMs: 0, + branchRatio: 0, + strategyLabel: 'Confirmatory', + insightCount, + }; + } + + 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 branchRatio = branchPoints / nodes.length; + + return { + totalNodes: nodes.length, + branchPoints, + backtracks, + maxDepth: computeMaxDepth(graph), + sessionDurationMs, + avgTimePerStateMs: Math.round(sessionDurationMs / (nodes.length - 1)), + branchRatio, + 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 new file mode 100644 index 00000000..89e1aa50 --- /dev/null +++ b/autk-provenance/src/insights/narrative.ts @@ -0,0 +1,64 @@ +import type { ProvenanceGraph } from '../types'; +import { computeSelectionFrequency } from './selection-frequency'; +import type { GraphMetrics, InsightAnnotation, InsightSelectionState, StrategyLabel } from './types'; + +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 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 ${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 (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.'); + } + + 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) { + lines.push('', `Recorded insights (${annotations.length}):`); + annotations.forEach((annotation) => lines.push(` • [${annotation.actionLabel}] ${annotation.text}`)); + } else { + 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 new file mode 100644 index 00000000..a75a8ac9 --- /dev/null +++ b/autk-provenance/src/insights/selection-frequency.ts @@ -0,0 +1,26 @@ +import type { ProvenanceGraph } from '../types'; +import type { InsightSelectionState, SelectionFrequency } from './types'; + +export function computeSelectionFrequency(graph: ProvenanceGraph): SelectionFrequency { + const mapFreq = new Map(); + const plotsFreq = new Map>(); + + for (const node of graph.nodes.values()) { + 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, plotSelection] of Object.entries(node.state.selection.plots ?? {})) { + if (!plotSelection?.ids.length) continue; + if (!plotsFreq.has(plotId)) plotsFreq.set(plotId, new Map()); + const plotFreq = plotsFreq.get(plotId)!; + for (const id of plotSelection.ids) { + plotFreq.set(id, (plotFreq.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..01fe2065 --- /dev/null +++ b/autk-provenance/src/insights/types.ts @@ -0,0 +1,41 @@ +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>; +} + +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 new file mode 100644 index 00000000..48699098 --- /dev/null +++ b/autk-provenance/src/provenance-trail-ui.ts @@ -0,0 +1,94 @@ +import type { AutarkProvenanceApi } from './create-autark-provenance'; +import { ensureProvenanceTrailStyles } from './ui/styles'; +import { createGraphModalController } from './ui/graph-modal'; +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'; + +export interface ProvenanceTrailUIOptions { + provenance: AutarkProvenanceApi; + container: HTMLElement; + insightsContainer?: HTMLElement; + showInsights?: boolean; + showBackForward?: boolean; + showTimestamps?: boolean; + showGraph?: boolean; + showPathList?: boolean; +} + +export function renderProvenanceTrailUI(options: ProvenanceTrailUIOptions): () => void { + const { provenance, container, insightsContainer, showInsights = true, showBackForward = true, showTimestamps = true, showGraph = true, showPathList = true } = options; + ensureProvenanceTrailStyles(); + container.innerHTML = ''; + container.classList.add('autk-provenance-root'); + + let graphVisible = showGraph; + 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 = showInsights ? createInsightsShell(insightsContainer ?? container) : null; + 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'; + 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); + } + + 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 (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 (graphWrap) { + graphWrap.style.display = graphVisible ? 'block' : 'none'; + if (graphVisible) renderGraphPreview({ container: graphWrap, provenance, showTimestamps }); + } + 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(); + } + + 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 () => { + 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/types.ts b/autk-provenance/src/types.ts new file mode 100644 index 00000000..1b36bbf6 --- /dev/null +++ b/autk-provenance/src/types.ts @@ -0,0 +1,191 @@ +/** + * 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]; +} + +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. + */ +export interface AutarkProvenanceState { + selection: { + map: { layerId: string; ids: number[] } | null; + /** Per-plot selection state, keyed by plotId. */ + plots: Record; + }; + ui?: { + mapMenuOpen?: boolean; + activeLayerId?: string | null; + visibleLayerIds?: string[]; + thematicEnabled?: boolean; + }; + view?: MapViewState; + data?: { + workspace: string; + 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; +} + +/** + * Action types for every trackable interaction. + * Each creates one node in the provenance graph. + */ +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_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', + 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', +} + +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; + }; + /** 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; + }; + 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; + clearHighlightedIds?(): void; + layerInfo?: { id: string }; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; + } | null; + /** All registered layers in render order (vector + raster). */ + layers: Array<{ + layerInfo?: { id: string }; + setHighlightedIds?(ids: number[]): void; + clearHighlightedIds?(): void; + layerRenderInfo?: { isSkip?: boolean; isColorMap?: boolean }; + }>; + }; +} + +/** + * 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; + }; + setOwnedSelection(selection: number[]): 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/src/ui/graph-layout.ts b/autk-provenance/src/ui/graph-layout.ts new file mode 100644 index 00000000..49dea70f --- /dev/null +++ b/autk-provenance/src/ui/graph-layout.ts @@ -0,0 +1,74 @@ +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[] = []; + let nextLeafRow = 0; + + 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, level); + nodesMap.get(nodeId)?.childrenIds.forEach((childId) => { + if (!nodesMap.has(childId)) return; + edges.push({ from: nodeId, to: childId }); + assignDepth(childId, level + 1); + }); + }; + const assignRow = (nodeId: string): number => { + if (row.has(nodeId)) return row.get(nodeId) ?? 0; + 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); + nodesMap.forEach((_, nodeId) => { + if (!depth.has(nodeId)) depth.set(nodeId, 0); + 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)); + + 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-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-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-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..0dee36c4 --- /dev/null +++ b/autk-provenance/src/ui/graph-modal.ts @@ -0,0 +1,80 @@ +import type { AutarkProvenanceApi } from '../create-autark-provenance'; +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 function createGraphModalController(options: { + provenance: AutarkProvenanceApi; + showTimestamps: boolean; + 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; + + 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(); }, + })); + + 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; + + 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(); + }, + }; + + return controller; +} diff --git a/autk-provenance/src/ui/graph-preview.ts b/autk-provenance/src/ui/graph-preview.ts new file mode 100644 index 00000000..48905a99 --- /dev/null +++ b/autk-provenance/src/ui/graph-preview.ts @@ -0,0 +1,29 @@ +import type { AutarkProvenanceApi } from '../create-autark-provenance'; +import { buildGraphLayout, type GraphLayout } from './graph-layout'; +import { createGraphScene, createSvgElement } from './graph-scene'; + +export function buildLayoutFromProvenance( + provenance: AutarkProvenanceApi +): GraphLayout | null { + const graph = provenance.getGraph(); + 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 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, 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 new file mode 100644 index 00000000..0ff38bc8 --- /dev/null +++ b/autk-provenance/src/ui/graph-scene.ts @@ -0,0 +1,79 @@ +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((item) => [item.node.id, item])); + + layout.edges.forEach((edge) => { + const from = positionById.get(edge.from); + const to = positionById.get(edge.to); + 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('class', 'autk-provenance-edge'); + root.appendChild(path); + }); + + 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}`.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'); + group.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'); + group.appendChild(dot); + } + + const title = createSvgElement('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); + group.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); + group.appendChild(time); + } + + root.appendChild(group); + }); + + 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..80b99477 --- /dev/null +++ b/autk-provenance/src/ui/insights-panel.ts @@ -0,0 +1,120 @@ +import { computeGraphMetrics, generateSessionNarrative, getInsightAnnotations, type InsightsProvenanceApi, type InsightSelectionState, 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: InsightsProvenanceApi +): 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); + metricsRow.appendChild(meta); + container.appendChild(metricsRow); + + 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: InsightsProvenanceApi, + 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; + 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); + }); + section.appendChild(textarea); + section.appendChild(save); + return section; +} + +function createAnnotationsList( + provenance: InsightsProvenanceApi, + 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; +} + +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(() => { + copyButton.textContent = 'Copied!'; + setTimeout(() => { copyButton.textContent = 'Copy to clipboard'; }, 1800); + }); + }); + section.appendChild(summary); + section.appendChild(copyButton); + return section; +} + +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 new file mode 100644 index 00000000..c820d804 --- /dev/null +++ b/autk-provenance/src/ui/styles.ts @@ -0,0 +1,77 @@ +export function ensureProvenanceTrailStyles(): void { + if (typeof document === 'undefined' || !document.head) return; + const styleId = 'autk-provenance-trail-styles'; + if (document.getElementById(styleId)) return; + + 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}', + '.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: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}', + '.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(style); +} diff --git a/autk-provenance/src/ui/toolbar.ts b/autk-provenance/src/ui/toolbar.ts new file mode 100644 index 00000000..cadc7bf3 --- /dev/null +++ b/autk-provenance/src/ui/toolbar.ts @@ -0,0 +1,96 @@ +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; + onToggle(callback: (open: boolean) => void): void; +} { + 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; + let toggleCallback: ((open: boolean) => void) | null = null; + + 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'; + toggleCallback?.(open); + }); + + return { + body, + setOpen: (value) => { + open = value; + body.style.display = open ? 'flex' : 'none'; + chevron.textContent = open ? '\u25b4' : '\u25be'; + toggleCallback?.(open); + }, + isOpen: () => open, + onToggle: (callback) => { + toggleCallback = callback; + }, + }; +} 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..931cc2e7 --- /dev/null +++ b/autk-provenance/src/ui/utils.ts @@ -0,0 +1,21 @@ +export function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +export function truncate(text: string, max = 30): string { + return text.length <= max ? text : `${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 seconds = Math.round(ms / 1000); + return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} 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..31339eed --- /dev/null +++ b/autk-provenance/src/ui/workspace-session-insights.ts @@ -0,0 +1,111 @@ +import { + computeGraphMetrics, + generateSessionNarrative, + getInsightAnnotations, + type InsightsProvenanceApi, + type InsightSelectionState, +} from '../insight-engine'; + +export function renderWorkspaceSessionInsights( + container: HTMLElement, + provenance: InsightsProvenanceApi +): void { + const graph = provenance.getGraph(); + const metrics = computeGraphMetrics(graph); + const annotations = getInsightAnnotations(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(createAnnotationCard(provenance, currentNode?.id ?? null, currentNode?.actionLabel ?? 'Current step', insight, container)); + grid.appendChild(createSummaryCard(metrics, narrative)); + container.appendChild(grid); +} + +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 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'; + 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(chips); + 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; +} diff --git a/autk-provenance/src/ui/workspace-shell.ts b/autk-provenance/src/ui/workspace-shell.ts new file mode 100644 index 00000000..81a29aa3 --- /dev/null +++ b/autk-provenance/src/ui/workspace-shell.ts @@ -0,0 +1,104 @@ +export interface WorkspaceShell { + root: HTMLElement; + mapBody: HTMLElement; + thematicSelect: HTMLSelectElement; + nodeCountBadge: HTMLElement; + chartsTab: HTMLElement; + provenanceTab: HTMLElement; + provenanceTrail: 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, + 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..1454e15d --- /dev/null +++ b/autk-provenance/src/ui/workspace-styles.ts @@ -0,0 +1,81 @@ +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: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{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}', + '.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;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;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;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}', + '.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-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-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))}}', + '@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); +} 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-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); + }, + }); +} diff --git a/autk-provenance/tsconfig.json b/autk-provenance/tsconfig.json new file mode 100644 index 00000000..ae2a5cd1 --- /dev/null +++ b/autk-provenance/tsconfig.json @@ -0,0 +1,24 @@ +{ + "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, + "rootDir": "./src", + "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/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.css b/examples/src/autk-provenance/all-plots-provenance.css new file mode 100644 index 00000000..58ac9668 --- /dev/null +++ b/examples/src/autk-provenance/all-plots-provenance.css @@ -0,0 +1,624 @@ +: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; } + +#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 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 ────────────────────────────────────────────────────── */ + +.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(2, 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: 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; } +} 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..5eda5e23 --- /dev/null +++ b/examples/src/autk-provenance/all-plots-provenance.html @@ -0,0 +1,12 @@ + + + + + + Autark Provenance Insights Workspace + + +
+ + + 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..6958b66f --- /dev/null +++ b/examples/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/examples/src/autk-provenance/map-plot-provenance.css b/examples/src/autk-provenance/map-plot-provenance.css new file mode 100644 index 00000000..f9141bb2 --- /dev/null +++ b/examples/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; + } +} 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..597de1cc --- /dev/null +++ b/examples/src/autk-provenance/map-plot-provenance.html @@ -0,0 +1,12 @@ + + + + + + Autark Map + Plot Provenance Workspace + + +
+ + + 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..03978f9a --- /dev/null +++ b/examples/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/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/package.json b/gallery/package.json index 15712c06..47eef232 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": "ulimit -n 65536 && vite", + "build": "ulimit -n 65536 && tsc && vite build", + "preview": "ulimit -n 65536 && vite preview", "format": "prettier --write ." }, "devDependencies": { @@ -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/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 + 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..4e0712d6 --- /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; + } + } 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..03978f9a --- /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" ], 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: {