From 1f3db386d890fc9d776ea73964685e89a69d968c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franc=CC=A7ois-Guillaume=20Ribreau?= Date: Thu, 13 Aug 2026 13:38:34 +0200 Subject: [PATCH 01/33] feat(backend): add Image-Charts hosted-image-URL backend Add an `image-charts` backend that compiles the shared core semantic layer into a single Image-Charts URL. Unlike the JS-spec backends it emits a permanent hosted-image URL that renders server-side and embeds anywhere an works (email, PDF, Slack, no-code) with no runtime JavaScript. The assembler is pure: it builds a string with no network I/O, no crypto, and no dependencies, and emits unsigned free-tier URLs only. It reuses the Phase 0 semantic resolution and banded-axis overflow filtering, then serializes into the Image-Charts query grammar (cht, chd=a:, chs, chxt/chxl, chco, chdl, chm, chtt). Coverage is partial by design, like the Excel backend: bar/line/area/pie/doughnut/radar/ scatter map to a faithful cht; unsupported types throw. Wire the backend into the src barrel, tsup entries, package exports, the public-API smoke test, and the test-data generators. --- packages/flint-js/package.json | 5 + .../flint-js/src/image-charts/assemble.ts | 362 ++++++++++++++++++ .../flint-js/src/image-charts/chart-types.ts | 49 +++ packages/flint-js/src/image-charts/index.ts | 28 ++ packages/flint-js/src/index.ts | 3 + .../src/test-data/image-charts-tests.ts | 123 ++++++ packages/flint-js/src/test-data/index.ts | 3 + packages/flint-js/tests/smoke.test.ts | 36 ++ packages/flint-js/tsup.config.ts | 1 + 9 files changed, 610 insertions(+) create mode 100644 packages/flint-js/src/image-charts/assemble.ts create mode 100644 packages/flint-js/src/image-charts/chart-types.ts create mode 100644 packages/flint-js/src/image-charts/index.ts create mode 100644 packages/flint-js/src/test-data/image-charts-tests.ts diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 167841f7..e1bb0876 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -65,6 +65,11 @@ "import": "./dist/excel/index.js", "require": "./dist/excel/index.cjs" }, + "./image-charts": { + "types": "./dist/image-charts/index.d.ts", + "import": "./dist/image-charts/index.js", + "require": "./dist/image-charts/index.cjs" + }, "./test-data": { "types": "./dist/test-data/index.d.ts", "import": "./dist/test-data/index.js", diff --git a/packages/flint-js/src/image-charts/assemble.ts b/packages/flint-js/src/image-charts/assemble.ts new file mode 100644 index 00000000..740ffb73 --- /dev/null +++ b/packages/flint-js/src/image-charts/assemble.ts @@ -0,0 +1,362 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Image-Charts chart assembly — a hosted-image-URL backend. + * + * Unlike the other backends, Image-Charts does not emit a spec object that a + * local renderer draws: it emits a single permanent `https://image-charts.com` + * URL that renders the chart server-side. That URL is embeddable anywhere an + * `` works (email, PDF, Slack, no-code tools) with no runtime JavaScript. + * + * Contract: + * - PURE. No network I/O, no crypto, no npm dependencies. `assembleImageCharts` + * only builds a string; the data reaches Image-Charts only if something later + * loads the `` — an explicit choice by the caller, exactly as choosing + * the Excel backend chooses Office.js. + * - FREE TIER ONLY. Unsigned URLs (no `icac`/`ichm` account/HMAC pair, no + * `chof` output override). Signed enterprise URLs need a server-side secret + * that has no place in a pure, offline compiler function. + * + * Reuses the SAME core analysis pipeline as the other backends (Phase 0 semantic + * resolution + banded-axis overflow filtering), then serializes the resolved + * channel semantics, category/series roles, and values into the Image-Charts + * query grammar (`cht`, `chd=a:`, `chs`, `chxt`/`chxl`, `chco`, `chdl`, `chm`, + * `chtt`). Like the Excel backend it does the work inline rather than through a + * template registry, and it gates chart types to the ones with a faithful `cht`. + */ + +import type { ChartAssemblyInput, ChartEncoding, SemanticResult } from '../core/types'; +import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; +import { detectBandedAxisFromSemantics } from '../core/axis-detection'; +import { computeChannelBudgets, deriveStretchCaps, resolveBaseSize } from '../core/compute-layout'; +import { filterOverflow } from '../core/filter-overflow'; +import type { LayoutDeclaration } from '../core/types'; +import { IMAGE_CHARTS_TYPE_MAP } from './chart-types'; + +/** A backend-native Image-Charts artifact: a permanent hosted-image URL. */ +export interface ImageChartsArtifact { + type: 'image-charts'; + url: string; +} + +type Cell = string | number; + +/** Image-Charts base endpoint (public free tier). */ +const IMAGE_CHARTS_ENDPOINT = 'https://image-charts.com/chart?'; + +/** Free-tier size ceilings: each side ≤ 999px and area ≤ 998001px². */ +const MAX_SIDE = 999; +const MAX_AREA = 998001; + +/** Default target size when the spec provides no `baseSize`. */ +const DEFAULT_SIZE = { width: 700, height: 400 }; + +/** + * Categorical palette (hex, no `#`) used for `chco`. Emitted only when color is + * meaningful (multiple series, pie slices, area fill, scatter markers); a single + * plain series keeps Image-Charts' own default color. + */ +const SERIES_COLORS = [ + '4472C4', 'ED7D31', '70AD47', 'FFC000', '5B9BD5', + 'A5A5A5', '264478', '9E480E', '636363', '997300', +]; + +/** Normalize shorthand (`"x": "field"`) to `{ field }`. */ +function normalizeEncodings(raw: Record): Record { + const out: Record = {}; + for (const [ch, v] of Object.entries(raw ?? {})) { + if (v == null) continue; + out[ch] = typeof v === 'string' ? { field: v } : (v as ChartEncoding); + } + return out; +} + +/** Clamp a target size to the free-tier ceilings (side ≤ 999, area ≤ 998001). */ +function clampChartSize(width: number, height: number): { width: number; height: number } { + let w = Math.min(MAX_SIDE, Math.max(1, Math.round(width))); + let h = Math.min(MAX_SIDE, Math.max(1, Math.round(height))); + if (w * h > MAX_AREA) { + const scale = Math.sqrt(MAX_AREA / (w * h)); + w = Math.max(1, Math.floor(w * scale)); + h = Math.max(1, Math.floor(h * scale)); + } + return { width: w, height: h }; +} + +/** + * Encode one label/title/legend segment: keep ASCII alphanumerics, map spaces to + * `+`, percent-encode everything else (UTF-8). Structural separators (`|`, `,`, + * `:`) are added by the caller between segments and never pass through here, so + * a label that literally contains them stays escaped and cannot break parsing. + */ +function encodeSegment(text: string): string { + let out = ''; + for (const ch of text) { + if (/[0-9A-Za-z]/.test(ch)) out += ch; + else if (ch === ' ') out += '+'; + else out += encodeURIComponent(ch); + } + return out; +} + +/** Format one datum for the `a:` (awesome) encoding; `_` marks a gap/null. */ +function formatValue(value: number | null): string { + if (value == null || !Number.isFinite(value)) return '_'; + if (Number.isInteger(value)) return String(value); + return String(Number(value.toFixed(4))); +} + +/** Distinct values of a field in first-seen order (nulls skipped). */ +function distinct(rows: any[], field: string): Cell[] { + const seen = new Set(); + const out: Cell[] = []; + for (const r of rows) { + const v = r[field]; + if (v == null) continue; + if (!seen.has(v)) { seen.add(v); out.push(v as Cell); } + } + return out; +} + +/** + * Aggregate the long/tidy rows into a per-series × per-category value matrix, + * summing (or averaging) duplicates. `seriesField` undefined ⇒ one implicit + * series holding the whole measure column. + */ +function pivotValues( + rows: any[], + catField: string, + measField: string, + seriesField: string | undefined, + categories: Cell[], + seriesKeys: Cell[], + aggregate: 'sum' | 'average', +): (number | null)[][] { + const SINGLE = '__single__'; + const acc = new Map(); + for (const r of rows) { + const cv = r[catField]; + if (cv == null) continue; + const sv = seriesField ? r[seriesField] : SINGLE; + const num = Number(r[measField]); + if (!Number.isFinite(num)) continue; + const key = JSON.stringify([String(cv), String(sv)]); + const e = acc.get(key) ?? { sum: 0, count: 0 }; + e.sum += num; e.count += 1; acc.set(key, e); + } + const valueAt = (cv: Cell, sv: Cell): number | null => { + const e = acc.get(JSON.stringify([String(cv), String(seriesField ? sv : SINGLE)])); + if (!e) return null; + return aggregate === 'average' ? e.sum / e.count : e.sum; + }; + return seriesKeys.map((sv) => categories.map((cv) => valueAt(cv, sv))); +} + +/** + * Assemble an {@link ImageChartsArtifact} (a permanent hosted-image URL) from a + * {@link ChartAssemblyInput}. + * + * @throws if the chart type has no faithful Image-Charts `cht` equivalent + * (e.g. Boxplot, Sankey, Heatmap) or its roles cannot be resolved. + */ +export function assembleImageCharts(input: ChartAssemblyInput): ImageChartsArtifact { + const flintType = input.chart_spec.chartType; + const mapping = IMAGE_CHARTS_TYPE_MAP[flintType]; + if (!mapping) { + throw new Error(`Image-Charts backend does not support chart type "${flintType}".`); + } + + const semanticTypes = input.semantic_types ?? {}; + const rawData: any[] = input.data.values ?? []; + const encodings = normalizeEncodings(input.chart_spec.encodings); + + if (encodings.column?.field || encodings.row?.field) { + throw new Error(`Image-Charts backend does not support faceting in one chart: "${flintType}".`); + } + + // ── Phase 0 (reused core): resolve per-channel semantics ──────────────── + let table = convertTemporalData(rawData, semanticTypes); + const sem: SemanticResult = resolveChannelSemantics(encodings, rawData, semanticTypes, table); + const typeOf = (ch: string) => sem[ch]?.type; + const isMeasure = (ch: string) => typeOf(ch) === 'quantitative'; + const fieldOf = (ch: string) => encodings[ch]?.field; + + // A categorical color/group binding becomes the series (legend) dimension; + // a quantitative color is not a series and is ignored on this tier. + const seriesCh = encodings.group?.field + ? 'group' + : encodings.color?.field && !isMeasure('color') + ? 'color' + : undefined; + const seriesField = seriesCh ? fieldOf(seriesCh) : undefined; + + // ── Overflow filtering for banded (bar) families, so URLs stay bounded ── + const keptCategoryOrder = new Map(); + if (mapping.cht === 'bvg' || mapping.cht === 'bhg' || mapping.cht === 'bvs' || mapping.cht === 'bhs') { + const detected = detectBandedAxisFromSemantics(sem, table, { preferAxis: 'x' }); + const declaration: LayoutDeclaration = { + axisFlags: detected ? { [detected.axis]: { banded: true } } : { x: { banded: true } }, + resolvedTypes: detected?.resolvedTypes, + }; + const baseSize = resolveBaseSize(input.chart_spec.baseSize, input.chart_spec.canvasSize); + const options = { + facetFixedPadding: { width: 50, height: 40 }, + facetGap: 10, + targetBandAR: 10, + ...deriveStretchCaps(baseSize, input.chart_spec.canvasSize, {}), + }; + const budgets = computeChannelBudgets(sem, declaration, table, baseSize, options); + const overflow = filterOverflow(sem, declaration, encodings, table, budgets, new Set(['bar'])); + table = overflow.filteredData; + overflow.truncations.forEach((t) => keptCategoryOrder.set(t.field, t.keptValues as Cell[])); + } + + const params: string[] = []; + const size = clampChartSize( + input.chart_spec.baseSize?.width ?? DEFAULT_SIZE.width, + input.chart_spec.baseSize?.height ?? DEFAULT_SIZE.height, + ); + + if (mapping.noAxes) { + buildPartToWhole(params, mapping.cht, sem, table, fieldOf); + } else if (mapping.xy) { + buildScatter(params, table, fieldOf, isMeasure, seriesField, flintType); + } else { + buildAxes( + params, mapping, flintType, sem, table, + fieldOf, typeOf, isMeasure, seriesField, keptCategoryOrder, + ); + } + + params.push(`chs=${size.width}x${size.height}`); + const title = input.chart_spec.title?.trim(); + if (title) params.push(`chtt=${encodeSegment(title)}`); + + return { type: 'image-charts', url: IMAGE_CHARTS_ENDPOINT + params.join('&') }; +} + +/** Pie / doughnut: one series of slices, each with its own label and color. */ +function buildPartToWhole( + params: string[], + cht: string, + sem: SemanticResult, + table: any[], + fieldOf: (ch: string) => string | undefined, +): void { + const catField = fieldOf('color') ?? fieldOf('x'); + const measField = fieldOf('size') ?? fieldOf('theta') ?? fieldOf('y'); + if (!catField || !measField) { + throw new Error(`Image-Charts backend could not resolve slice/value fields for a part-to-whole chart (category=${catField}, value=${measField}).`); + } + const slices = distinct(table, catField); + const measCh = fieldOf('size') === measField ? 'size' : fieldOf('theta') === measField ? 'theta' : 'y'; + const aggregate = sem[measCh]?.aggregationDefault ?? 'sum'; + const [values] = pivotValues(table, catField, measField, undefined, slices, ['__single__'], aggregate); + + params.push(`cht=${cht}`); + params.push(`chd=a:${values.map(formatValue).join(',')}`); + params.push(`chl=${slices.map((s) => encodeSegment(String(s))).join('|')}`); + params.push(`chco=${slices.map((_s, i) => SERIES_COLORS[i % SERIES_COLORS.length]).join('|')}`); +} + +/** Scatter: `lxy` with one (x-set, y-set) pair per series, drawn as markers. */ +function buildScatter( + params: string[], + table: any[], + fieldOf: (ch: string) => string | undefined, + isMeasure: (ch: string) => boolean, + seriesField: string | undefined, + flintType: string, +): void { + const xField = fieldOf('x'); + const yField = fieldOf('y'); + if (!xField || !yField || !isMeasure('x') || !isMeasure('y')) { + throw new Error(`Image-Charts backend requires quantitative x and y fields for "${flintType}".`); + } + const seriesKeys = seriesField ? distinct(table, seriesField) : ['__single__']; + const datasets: string[] = []; + const markers: string[] = []; + const colors: string[] = []; + seriesKeys.forEach((key, index) => { + const rows = seriesField ? table.filter((r) => r[seriesField] === key) : table; + const xs = rows.map((r) => Number(r[xField])); + const ys = rows.map((r) => Number(r[yField])); + datasets.push(xs.map(formatValue).join(',')); + datasets.push(ys.map(formatValue).join(',')); + const color = SERIES_COLORS[index % SERIES_COLORS.length]; + colors.push(color); + markers.push(`s,${color},${index},-1,6`); + }); + + params.push('cht=lxy'); + params.push(`chd=a:${datasets.join('|')}`); + params.push(`chco=${colors.join(',')}`); + params.push(`chm=${markers.join('|')}`); + if (seriesField && seriesKeys.length > 1) { + params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`); + } +} + +/** Bar / line / area / radar: a category axis plus one measure per series. */ +function buildAxes( + params: string[], + mapping: { cht: string; horizontal?: string; radar?: boolean; area?: boolean }, + flintType: string, + sem: SemanticResult, + table: any[], + fieldOf: (ch: string) => string | undefined, + typeOf: (ch: string) => string | undefined, + isMeasure: (ch: string) => boolean, + seriesField: string | undefined, + keptCategoryOrder: Map, +): void { + // Horizontal bar when the measure sits on x and the category on y. + const horizontal = Boolean(mapping.horizontal) && isMeasure('x') && !isMeasure('y'); + const catCh = horizontal ? 'y' : 'x'; + const measCh = horizontal ? 'x' : 'y'; + const catField = fieldOf(catCh); + const measField = fieldOf(measCh); + if (!catField || !measField) { + throw new Error(`Image-Charts backend could not resolve category/measure for "${flintType}" (category=${catField}, measure=${measField}).`); + } + + let categories = keptCategoryOrder.get(catField) ?? distinct(table, catField); + // Ordered domains (line / area over time or a numeric axis) sort ascending. + if (!mapping.radar && (flintType === 'Line Chart' || flintType === 'Area Chart' || flintType === 'Sparkline')) { + if (typeOf(catCh) === 'temporal') { + categories = [...categories].sort((a, b) => new Date(String(a)).getTime() - new Date(String(b)).getTime()); + } else if (typeOf(catCh) === 'quantitative') { + categories = [...categories].sort((a, b) => Number(a) - Number(b)); + } + } + + const seriesKeys = seriesField ? distinct(table, seriesField) : [measField]; + const aggregate = sem[measCh]?.aggregationDefault ?? 'sum'; + const seriesValues = pivotValues(table, catField, measField, seriesField, categories, seriesKeys, aggregate); + + const cht = horizontal ? (mapping.horizontal as string) : mapping.cht; + params.push(`cht=${cht}`); + params.push(`chd=a:${seriesValues.map((vals) => vals.map(formatValue).join(',')).join('|')}`); + + // Category axis: index 0 (x) for vertical/radar, index 1 (y) for horizontal. + const categoryLabels = categories.map((c) => encodeSegment(String(c))).join('|'); + if (mapping.radar) { + params.push('chxt=r'); + params.push(`chxl=0:|${categoryLabels}`); + } else { + params.push('chxt=x,y'); + params.push(`chxl=${horizontal ? 1 : 0}:|${categoryLabels}`); + } + + const seriesColors = seriesKeys.map((_k, i) => SERIES_COLORS[i % SERIES_COLORS.length]); + if (seriesKeys.length > 1 || mapping.area) { + params.push(`chco=${seriesColors.join(',')}`); + } + if (mapping.area) { + params.push(`chm=${seriesColors.map((c, i) => `B,${c},${i},0,0`).join('|')}`); + } + if (seriesField && seriesKeys.length > 1) { + params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`); + } +} diff --git a/packages/flint-js/src/image-charts/chart-types.ts b/packages/flint-js/src/image-charts/chart-types.ts new file mode 100644 index 00000000..7a66a373 --- /dev/null +++ b/packages/flint-js/src/image-charts/chart-types.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Image-Charts chart-type mapping. + * + * Image-Charts renders through a fixed set of `cht` chart codes (the Google + * Image Charts / Image-Charts query grammar), so a Flint chart type maps to the + * closest native `cht`. Orientation (vertical vs horizontal) is decided by the + * assembler from channel semantics and selects the `bv*` vs `bh*` family. + * + * Coverage is partial by design (like the Excel backend): only chart types with + * a faithful `cht` equivalent are mapped. Everything else throws in `assemble`. + */ + +/** Which Image-Charts `cht` family a Flint chart type maps to. */ +export interface ImageChartsTypeMapping { + /** Base Image-Charts `cht` value (vertical / category-on-x orientation). */ + cht: string; + /** `cht` for the horizontal (category-on-y) variant, when supported. */ + horizontal?: string; + /** True for pie/doughnut charts: slice labels, no value/category axes. */ + noAxes?: boolean; + /** True for XY (both-measure) scatter charts rendered as `lxy`. */ + xy?: boolean; + /** True for radar charts, which use the `chxt=r` polar axis. */ + radar?: boolean; + /** True for area charts: a line (`lc`) plus a `chm=B` fill to the baseline. */ + area?: boolean; +} + +/** Flint chart type (display name) → Image-Charts `cht` family. */ +export const IMAGE_CHARTS_TYPE_MAP: Record = { + 'Bar Chart': { cht: 'bvg', horizontal: 'bhg' }, + 'Grouped Bar Chart': { cht: 'bvg', horizontal: 'bhg' }, + 'Stacked Bar Chart': { cht: 'bvs', horizontal: 'bhs' }, + 'Line Chart': { cht: 'lc' }, + 'Sparkline': { cht: 'ls' }, + 'Area Chart': { cht: 'lc', area: true }, + 'Scatter Plot': { cht: 'lxy', xy: true }, + 'Pie Chart': { cht: 'p', noAxes: true }, + 'Donut Chart': { cht: 'pd', noAxes: true }, + 'Radar Chart': { cht: 'r', radar: true }, +}; + +/** Chart types this backend can render as an Image-Charts URL. */ +export function isImageChartsSupported(flintChartType: string): boolean { + return flintChartType in IMAGE_CHARTS_TYPE_MAP; +} diff --git a/packages/flint-js/src/image-charts/index.ts b/packages/flint-js/src/image-charts/index.ts new file mode 100644 index 00000000..ddc4957c --- /dev/null +++ b/packages/flint-js/src/image-charts/index.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @module flint-chart/image-charts + * + * Image-Charts backend for flint-chart. + * + * Compiles the core semantic layer into a single permanent + * `https://image-charts.com` chart URL (the Google Image Charts / Image-Charts + * query grammar). The URL renders server-side and embeds anywhere an `` + * works — email, PDF, Slack, no-code tools — with no runtime JavaScript. + * + * Architecture contrast with the other backends: + * VL: encoding-channel spec — { encoding: { x, y }, mark } + * EC: series-based option — { series: [...], xAxis, yAxis } + * CJS: dataset-based config — { type, data: { labels, datasets } } + * Excel: range/matrix spec — { chartType, data: [[...]], axes } + * Image-Charts: hosted-image URL — { type: 'image-charts', url } + * + * `assembleImageCharts` is PURE: it builds a string, performs no network I/O and + * no signing, and emits unsigned free-tier URLs only. + */ + +export { assembleImageCharts } from './assemble'; +export type { ImageChartsArtifact } from './assemble'; +export { IMAGE_CHARTS_TYPE_MAP, isImageChartsSupported } from './chart-types'; +export type { ImageChartsTypeMapping } from './chart-types'; diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts index 824f9746..eeb753d1 100644 --- a/packages/flint-js/src/index.ts +++ b/packages/flint-js/src/index.ts @@ -57,3 +57,6 @@ export * from './plotly'; // Excel backend: assembleExcel + Excel chart spec types export * from './excel'; + +// Image-Charts backend: assembleImageCharts + hosted-image-URL artifact type +export * from './image-charts'; diff --git a/packages/flint-js/src/test-data/image-charts-tests.ts b/packages/flint-js/src/test-data/image-charts-tests.ts new file mode 100644 index 00000000..abd64f6a --- /dev/null +++ b/packages/flint-js/src/test-data/image-charts-tests.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Gallery generators for the Image-Charts backend. + * + * These cases exercise the URL-grammar paths the backend builds: a plain bar + * (`cht=bvg`, `chxl` categories), a multi-series grouped bar (`chco` + `chdl` + * legend), a line, a filled area (`chm=B`), a pie (per-slice `chl` + `chco`), + * and a scatter (`cht=lxy` + `chm=s` markers). The data is backend-agnostic — + * the gallery renders it through `assembleImageCharts`. + */ + +import { Type } from './df-types'; +import { TestCase, makeField, makeEncodingItem } from './types'; + +const CATEGORY_META = { type: Type.String, semanticType: 'Category', levels: [] as any[] }; +const QUANTITY_META = { type: Type.Number, semanticType: 'Quantity', levels: [] as any[] }; + +export function genImageChartsTests(): TestCase[] { + return [ + { + title: 'Bar — sales by region', + description: 'A single-series vertical bar, category labels on the x axis.', + tags: ['bar', 'nominal', 'quantitative', 'image-charts'], + chartType: 'Bar Chart', + data: [ + { Region: 'North', Sales: 42 }, + { Region: 'South', Sales: 35 }, + { Region: 'East', Sales: 58 }, + { Region: 'West', Sales: 27 }, + ], + fields: [makeField('Region'), makeField('Sales')], + metadata: { Region: CATEGORY_META, Sales: QUANTITY_META }, + encodingMap: { x: makeEncodingItem('Region'), y: makeEncodingItem('Sales') }, + }, + { + title: 'Grouped bar — sales by region and channel', + description: 'Two series dodge per category, driving a per-series palette and a legend.', + tags: ['bar', 'grouped', 'series', 'legend', 'image-charts'], + chartType: 'Grouped Bar Chart', + data: [ + { Region: 'North', Sales: 42, Channel: 'Retail' }, + { Region: 'North', Sales: 20, Channel: 'Online' }, + { Region: 'South', Sales: 35, Channel: 'Retail' }, + { Region: 'South', Sales: 31, Channel: 'Online' }, + ], + fields: [makeField('Region'), makeField('Sales'), makeField('Channel')], + metadata: { Region: CATEGORY_META, Sales: QUANTITY_META, Channel: CATEGORY_META }, + encodingMap: { + x: makeEncodingItem('Region'), + y: makeEncodingItem('Sales'), + group: makeEncodingItem('Channel'), + }, + }, + { + title: 'Line — monthly signups', + description: 'An ordered category axis with a single quantitative series.', + tags: ['line', 'temporal', 'quantitative', 'image-charts'], + chartType: 'Line Chart', + data: [ + { Month: '2026-01', Signups: 120 }, + { Month: '2026-02', Signups: 150 }, + { Month: '2026-03', Signups: 138 }, + { Month: '2026-04', Signups: 176 }, + ], + fields: [makeField('Month'), makeField('Signups')], + metadata: { + Month: { type: Type.String, semanticType: 'YearMonth', levels: [] }, + Signups: QUANTITY_META, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Signups') }, + }, + { + title: 'Area — traffic over time', + description: 'A line filled to the baseline via a chm=B marker.', + tags: ['area', 'temporal', 'quantitative', 'image-charts'], + chartType: 'Area Chart', + data: [ + { Day: '2026-01-01', Visits: 30 }, + { Day: '2026-01-02', Visits: 52 }, + { Day: '2026-01-03', Visits: 41 }, + { Day: '2026-01-04', Visits: 66 }, + ], + fields: [makeField('Day'), makeField('Visits')], + metadata: { + Day: { type: Type.Date, semanticType: 'Date', levels: [] }, + Visits: QUANTITY_META, + }, + encodingMap: { x: makeEncodingItem('Day'), y: makeEncodingItem('Visits') }, + }, + { + title: 'Pie — market share', + description: 'Slice labels and a per-slice palette.', + tags: ['pie', 'part-to-whole', 'image-charts'], + chartType: 'Pie Chart', + data: [ + { Vendor: 'Acme', Share: 45 }, + { Vendor: 'Globex', Share: 30 }, + { Vendor: 'Initech', Share: 15 }, + { Vendor: 'Umbrella', Share: 10 }, + ], + fields: [makeField('Vendor'), makeField('Share')], + metadata: { Vendor: CATEGORY_META, Share: QUANTITY_META }, + encodingMap: { color: makeEncodingItem('Vendor'), size: makeEncodingItem('Share') }, + }, + { + title: 'Scatter — weight vs mpg', + description: 'Two measures on lxy, drawn as chm=s point markers.', + tags: ['scatter', 'quantitative', 'image-charts'], + chartType: 'Scatter Plot', + data: [ + { Weight: 1.6, Mpg: 32 }, + { Weight: 2.1, Mpg: 27 }, + { Weight: 1.9, Mpg: 29 }, + { Weight: 2.4, Mpg: 24 }, + ], + fields: [makeField('Weight'), makeField('Mpg')], + metadata: { Weight: QUANTITY_META, Mpg: QUANTITY_META }, + encodingMap: { x: makeEncodingItem('Weight'), y: makeEncodingItem('Mpg') }, + }, + ]; +} diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts index e59fc3ad..34c54324 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -41,6 +41,7 @@ export { genLineAreaStretchTests } from './line-area-stretch-tests'; export { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; export { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; export { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; +export { genImageChartsTests } from './image-charts-tests'; export { genDiscreteAxisTests } from './discrete-axis-tests'; export { genDateTests, genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDecadeTests, genDateDateTimeTests, genDateHoursTests } from './date-tests'; export { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; @@ -116,6 +117,7 @@ import { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; import { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; import { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; import { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; +import { genImageChartsTests } from './image-charts-tests'; import { genGalleryRegionalSurveyScatterTests, genGalleryRegionalSurveyLineTests, @@ -256,6 +258,7 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Chart.js: Stress Tests': genChartJsStressTests, 'Plotly: Core Templates': genPlotlyCoreTests, 'Plotly: Facets': genPlotlyFacetTests, + 'Image-Charts: Core Templates': genImageChartsTests, 'Gallery: Scatter': genGalleryRegionalSurveyScatterTests, 'Gallery: Line': genGalleryRegionalSurveyLineTests, 'Gallery: Bar': genGalleryRegionalSurveyBarTests, diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts index 0b0bdb5f..c6aaad15 100644 --- a/packages/flint-js/tests/smoke.test.ts +++ b/packages/flint-js/tests/smoke.test.ts @@ -8,6 +8,7 @@ import { assembleChartjs, assemblePlotly, assembleExcel, + assembleImageCharts, } from '../src'; const DATA = [ @@ -98,6 +99,41 @@ describe('public API smoke', () => { expect(spec.seriesBy).toBe('Columns'); }); + it('assembleImageCharts returns a permanent free-tier Image-Charts URL', () => { + const artifact = assembleImageCharts({ + data: { values: [ + { Category: 'A', Value: 10 }, + { Category: 'B', Value: 20 }, + { Category: 'C', Value: 15 }, + ] }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Category', y: 'Value' }, + title: 'Sales by region', + }, + }); + + expect(artifact.type).toBe('image-charts'); + expect(artifact.url.startsWith('https://image-charts.com/chart?')).toBe(true); + expect(artifact.url).toContain('cht=bvg'); + expect(artifact.url).toContain('chd=a:10,20,15'); + expect(artifact.url).toContain('chxl=0:|A|B|C'); + expect(artifact.url).toContain('chtt=Sales+by+region'); + // Free tier only: never signed, never an output override. + expect(artifact.url).not.toContain('icac'); + expect(artifact.url).not.toContain('ichm'); + expect(artifact.url).not.toContain('chof'); + }); + + it('assembleImageCharts throws on chart types with no faithful cht', () => { + expect(() => assembleImageCharts({ + data: { values: [{ Group: 'A', Value: 1 }, { Group: 'A', Value: 5 }] }, + semantic_types: { Group: 'Category', Value: 'Quantity' }, + chart_spec: { chartType: 'Boxplot', encodings: { x: 'Group', y: 'Value' } }, + })).toThrow('does not support chart type "Boxplot"'); + }); + it('assembleExcel uses field display names for native axis titles', () => { const spec = assembleExcel({ data: { values: [ diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts index 519b97fa..8d27d220 100644 --- a/packages/flint-js/tsup.config.ts +++ b/packages/flint-js/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ 'chartjs/index': 'src/chartjs/index.ts', 'plotly/index': 'src/plotly/index.ts', 'excel/index': 'src/excel/index.ts', + 'image-charts/index': 'src/image-charts/index.ts', 'test-data/index': 'src/test-data/index.ts', 'gallery/index': 'src/gallery/index.ts', }, From 2f81939ba54f8ef7168694b35c9b9e8216e0b2f4 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 14 Aug 2026 20:28:17 -0700 Subject: [PATCH 02/33] improves labeling --- CHANGELOG.md | 16 ++ agent-skills/flint-chart-author/SKILL.md | 15 +- docs/design-semantics.md | 5 +- docs/reference-vegalite.md | 4 +- packages/flint-js/src/core/field-semantics.ts | 38 +++ packages/flint-js/src/core/theme/ground.ts | 43 ++-- packages/flint-js/src/core/theme/types.ts | 8 +- packages/flint-js/src/core/types.ts | 6 + .../flint-js/src/docs/design-semantics.md | 9 +- packages/flint-js/src/plotly/theme.ts | 4 +- packages/flint-js/src/vegalite/assemble.ts | 21 +- .../flint-js/src/vegalite/instantiate-spec.ts | 62 ++++- .../src/vegalite/templates/bar-table.ts | 29 ++- packages/flint-js/src/vegalite/theme.ts | 217 ++++++++++++++++-- .../flint-js/tests/bar-table-labels.test.ts | 57 +++++ .../tests/series-end-collision.test.ts | 106 +++++++++ .../flint-js/tests/theme-axis-labels.test.ts | 86 +++++++ packages/flint-js/tests/theme-titles.test.ts | 24 ++ packages/flint-js/tests/unit-display.test.ts | 64 ++++++ .../flint-js/tests/value-label-format.test.ts | 25 ++ site/src/main.tsx | 2 + site/src/playground/LabelExperimentLab.tsx | 209 +++++++++++++++++ site/src/playground/PlaygroundShell.tsx | 1 + site/src/playground/label-experiment-lab.css | 134 +++++++++++ 24 files changed, 1102 insertions(+), 83 deletions(-) create mode 100644 packages/flint-js/tests/bar-table-labels.test.ts create mode 100644 packages/flint-js/tests/series-end-collision.test.ts create mode 100644 packages/flint-js/tests/unit-display.test.ts create mode 100644 site/src/playground/LabelExperimentLab.tsx create mode 100644 site/src/playground/label-experiment-lab.css diff --git a/CHANGELOG.md b/CHANGELOG.md index a87c26c3..9ea1af0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Visible units now require an explicit `unit` in the field's semantic + annotation. Conventional compact units may accompany values, while lexical + units such as `years` are stated once as part of the field title. Bar Tables + also no longer repeat their value column as annotations on the bars. +- A raw sum-stacked chart whose total lands exactly on a clean axis tick now + keeps that edge flush instead of adding an empty interval above it, including + machine-scale residue from calculated shares. Totals meaningfully beyond the + clean endpoint still advance to the next tick; the rule is derived from the + plotted stack and does not special-case percentages or 100. +- Series-end labels now use a bounded screen-space packing pass when endpoints + form one readable column. Small adjustments keep labels attached by proximity; + crowded or horizontally staggered sets fall back together to the next legend + placement instead of leaving a partial or overlapping direct-label system. + ## [0.5.1] - 2026-08-13 ### Added diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index af1a890f..f7326fb1 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -421,7 +421,20 @@ understates what you know: } ``` -- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `unit` — an optional assertion that authorizes Flint to display a unit. Add + it only when the data or surrounding context establishes the measurement + and seeing it materially changes how a reader interprets the number. A type + such as `Duration`, a field name such as `life_expectancy`, or values that + merely look plausible are not enough evidence by themselves. + - Prefer canonical codes: `"USD"`, `"°C"`, `"kg"`, `"km/h"`, `"min"`. + - Conventional compact units are normalized and may appear beside values + (`USD` → `$`, `hours` → `hr`). + - Lexical units such as `"years"` are stated once beside the field name as + `field (years)`, not repeated after every value. + - Do not put explanatory phrases in `unit`. Put qualifications such as + `"per working-age resident"` or `"constant 2024 prices"` in the subtitle. + - Omit `unit` when its meaning, scale, or denominator is uncertain. Flint + does not infer a visible unit from the semantic type or field name. - `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` for a five-star rating, `[0, 100]` for a percentage score. Not for open-ended measures. diff --git a/docs/design-semantics.md b/docs/design-semantics.md index 0836a96a..b607a26e 100644 --- a/docs/design-semantics.md +++ b/docs/design-semantics.md @@ -656,7 +656,10 @@ Only override native formatting when semantic context adds value: prefix/suffix, | **Sentiment / Correlation** | `+` + data-driven | — | — | — | Signed decimal | | **Latitude / Longitude** | — (empty) | — | — | — | VL native | -Unit/currency priority is `annotation.unit` > column-name heuristics > data-value scanning > type defaults. +Visible unit text requires `annotation.unit`; semantic types, column names, and +data values do not authorize display by themselves. Conventional compact units +such as `$`, `%`, `°C`, `kg`, or `min` may accompany values. Lexical units such +as `years` are stated once with the field title (`field (years)`). **Parsing** is the compiler's job, guided by semantic type rather than stored on context: diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index a65a9836..1bbfdd3d 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -415,7 +415,9 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `color` -_No template-specific parameters._ +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. | ### ![](chart-icon-bar-table.svg) Bar Table diff --git a/packages/flint-js/src/core/field-semantics.ts b/packages/flint-js/src/core/field-semantics.ts index 91e8b76c..5120d50a 100644 --- a/packages/flint-js/src/core/field-semantics.ts +++ b/packages/flint-js/src/core/field-semantics.ts @@ -261,6 +261,44 @@ const UNIT_SUFFIX_MAP: Record = { '%': '%', }; +export interface DisplayUnit { + /** Normalized display text, e.g. `USD` becomes `$` and `hours` becomes `hr`. */ + text: string; + /** Compact conventional tags may accompany values; lexical units belong once beside the field name. */ + placement: 'value' | 'field'; + /** Currency symbols precede values; other compact units follow them. */ + position: 'prefix' | 'suffix'; +} + +/** + * Resolve display intent only from a unit explicitly declared in the semantic + * annotation. A semantic type or suggestive field name is not permission to + * print a unit. + */ +export function resolveDisplayUnit(annotation?: SemanticAnnotation): DisplayUnit | undefined { + const declared = annotation?.unit?.trim(); + if (!declared) return undefined; + + const currency = CURRENCY_MAP[declared.toUpperCase()] ?? CURRENCY_MAP[declared]; + if (currency) return { text: currency, placement: 'value', position: 'prefix' }; + + const compact = UNIT_SUFFIX_MAP[declared] ?? UNIT_SUFFIX_MAP[declared.toLowerCase()]; + if (compact) return { text: compact.trim(), placement: 'value', position: 'suffix' }; + + // Field-level units are labels, not prose. Reject control characters, + // parenthetical fragments, and long descriptions; those belong in a + // subtitle supplied by the authoring agent. + if (declared.length > 24 || /[\r\n()]/.test(declared)) return undefined; + return { text: declared, placement: 'field', position: 'suffix' }; +} + +/** Append a field-level unit once, preserving labels that already name it. */ +export function titleWithDisplayUnit(title: string, unit?: DisplayUnit): string { + if (unit?.placement !== 'field') return title; + if (title.toLocaleLowerCase().includes(`(${unit.text.toLocaleLowerCase()})`)) return title; + return `${title} (${unit.text})`; +} + /** * Detect whether percentage data uses 0–1 (fractional) or 0–100 (whole-number) * representation. diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts index 6cdbd7ae..ad4e7315 100644 --- a/packages/flint-js/src/core/theme/ground.ts +++ b/packages/flint-js/src/core/theme/ground.ts @@ -42,7 +42,7 @@ import { resolvePresenceInk, sampleRamp, } from './presence.js'; -import { CURRENCY_MAP } from '../field-semantics.js'; +import { resolveDisplayUnit } from '../field-semantics.js'; import { getRegistryEntry } from '../type-registry.js'; import { inferValueLabelFormat, longestLabelChars } from './value-label-format.js'; import { deepMerge } from './merge.js'; @@ -581,26 +581,9 @@ function percentOfWhole(ctx: GroundingContext, channel: string): string | undefi return n >= 3 && Math.abs(sum - 100) < 0.5 ? '%' : undefined; } -/** - * The unit a measure is counted in, when the chart already knows it. - * - * Either the annotation says so outright, or the field names it the way a - * person does — `CO₂ (ppm)`, `Unemployment (%)`. Anything longer than a short - * tag is a phrase, not a unit, and belongs in the subtitle. - */ -const UNIT_IN_FIELD_NAME = /\(([^()]{1,6})\)\s*$/; - -function unitText(ctx: GroundingContext, channel: string): string | undefined { +function displayUnit(ctx: GroundingContext, channel: string) { const sem = ctx.channelSemantics?.[channel]; - const declared = sem?.semanticAnnotation?.unit; - const field = sem?.field ?? (ctx.positional as any)?.[channel]?.field; - const named = typeof field === 'string' ? field.match(UNIT_IN_FIELD_NAME) : null; - const raw = (typeof declared === 'string' && declared.length > 0 && declared.length <= 6) - ? declared - : named?.[1]; - if (!raw) return undefined; - // A currency is written with its sign, not its ISO code: `$8`, not `8 USD`. - return CURRENCY_MAP[raw.toUpperCase()] ?? raw; + return resolveDisplayUnit(sem?.semanticAnnotation); } /** @@ -923,12 +906,14 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe // reads in shares, whatever the field was measured in. const unitPolicy = theme.annotation?.unit ?? 'never'; const inFieldUnits = ctx.stacked !== 'normalize' && !ctx.partToWhole; - const unit = role === 'measure' && inFieldUnits ? unitText(ctx, channel) : undefined; - const unitTag = unitPolicy !== 'never' ? unit : undefined; + const unit = role === 'measure' && inFieldUnits ? displayUnit(ctx, channel) : undefined; + const unitTag = unitPolicy !== 'never' && unit?.placement === 'value' ? unit.text : undefined; // Where the house keeps its axis titles, the title is the natural place // for the unit — `Weight (lb)` — and the ticks stay bare numbers. - const titleUnit = showTitle && theme.annotation?.unitsInAxisTitle === true ? unit : undefined; + const titleUnit = showTitle && unit && ( + unit.placement === 'field' || theme.annotation?.unitsInAxisTitle === true + ) ? unit.text : undefined; // The gap between a label and the plot is the same gap whether or not a // tick is drawn in it. Where there is one, the tick spans the first part @@ -1460,22 +1445,20 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe const shareUnit = signals.isPartToWhole && !axisStatesUnit ? percentOfWhole(ctx, valueUnitChannel ?? '') : undefined; + const valueDisplayUnit = displayUnit(ctx, valueUnitChannel ?? ''); const valueUnit = houseStatesUnit - ? (unitText(ctx, valueUnitChannel ?? '') ?? shareUnit) + ? (valueDisplayUnit?.placement === 'value' ? valueDisplayUnit.text : shareUnit) : shareUnit; // A label placed at the mark sits *inside* it, which only works while the // mark is longer than the label. Below that length the label has to move - // out, and above the point where the mark reaches the end of the scale an - // outside label has nowhere left to go. Grounding is the stage that can - // say where those two lines are. + // out. Outside placement is chart-wide: the backend reserves room instead + // of flipping only the longest mark inward. let insideMinValue: number | undefined; - let outsideMaxValue: number | undefined; if (dlShow && measureChannel) { const span = measureChannel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight; if (valueMaxAbs > 0 && span > 0) { insideMinValue = (valueLabelWidthPx / span) * valueMaxAbs; - outsideMaxValue = valueMaxAbs - insideMinValue; } } @@ -1754,7 +1737,6 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe format: numberFormat, ...(valueUnit ? { unit: valueUnit } : {}), insideMinValue, - outsideMaxValue, ...(segmentMinShare !== undefined ? { segmentMinShare } : {}), }, // A house that dots the end of a line is saying where the story stops. @@ -1773,6 +1755,7 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe padding, density, plotWidth: ctx.layout.subplotWidth, + plotHeight: ctx.layout.subplotHeight, xStep: ctx.layout.xStep, canvasWidth: ctx.canvasSize?.width, }, diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts index dafa0f78..a3b80912 100644 --- a/packages/flint-js/src/core/theme/types.ts +++ b/packages/flint-js/src/core/theme/types.ts @@ -802,11 +802,6 @@ export interface ResolvedDataLabels { * question about space, not about style. */ insideMinValue?: number; - /** - * Above this magnitude the mark reaches the end of the scale, so an - * outside label would fall off the plot. The mirror of `insideMinValue`. - */ - outsideMaxValue?: number; /** * The smallest share of the measure axis a stacked segment may occupy and * still be labelled — a line of text over the plot's extent along that @@ -928,11 +923,12 @@ export interface DesignDecisions { spacing?: number; preferredColumns?: number; }; - /** `plotWidth`/`xStep` are what the layout settled, so an axis can ask whether its names still fit. */ + /** Plot dimensions and step are what layout settled, so realization can test whether annotations fit. */ layout: { padding: number; density: 'compact' | 'normal' | 'airy'; plotWidth?: number; + plotHeight?: number; xStep?: number; /** The graphic the caller asked for. Wider than `plotWidth` by the axis gutter. */ canvasWidth?: number; diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index b2fc03b7..00b065b7 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -972,6 +972,12 @@ export interface ChartTemplateDef { */ ownsValueLabels?: boolean; + /** + * The template already presents values in a dedicated table column, so a + * generic label layer would repeat the same number on the data mark. + */ + suppressValueLabels?: boolean; + /** * Opt out of a backend's *generic* column/row facet-splitting pass, even * though the template declares `x`/`y` (so the axis-less `hasAxes` gate diff --git a/packages/flint-js/src/docs/design-semantics.md b/packages/flint-js/src/docs/design-semantics.md index c2800eab..d6b80637 100644 --- a/packages/flint-js/src/docs/design-semantics.md +++ b/packages/flint-js/src/docs/design-semantics.md @@ -1162,7 +1162,10 @@ For generic decimal types (Number, Score, Rating, Ratio, Latitude, Longitude), t **Unit and currency from annotation metadata:** When the LLM provides `unit` in the annotation (e.g., `"unit": "EUR"` for Price, `"unit": "kg"` for Weight), the format spec uses that directly. See §3 for the full annotation schema. -**Fallback priority for units:** annotation.unit > column-name heuristics ("Weight (kg)") > data-value scanning ("$1,234") > type-specific defaults ("$" for Price). +**Visible-unit policy:** only `annotation.unit` authorizes unit text. Semantic +types, column names, and data scanning may inform parsing or other semantic +decisions, but do not cause a unit to be printed. Conventional compact units +may accompany values; lexical units are stated once with the field title. ### 5.1.1 Parsing @@ -2070,9 +2073,9 @@ After this phase, all semantic-type-driven decisions flow through the flat `Chan 1. **Unit/domain annotation reliability.** How reliably will the LLM provide `domain` and `unit`? Mitigation strategies: - (a) Require domain/unit for a small set of types (Rating, Score, Temperature, Price) — reject annotations without them - - (b) Treat domain/unit as best-effort hints — fall back gracefully to data-inferred or type-intrinsic defaults (current proposal) + - (b) Treat domain/unit as best-effort hints, but require an explicit unit annotation before displaying unit text (current policy) - (c) Prompt the user to confirm/correct LLM-provided annotations in certain cases - - Fallback priority: annotation.unit > column-name heuristics ("Weight (kg)") > data scan ("$1,234") > type defaults + - Visible unit text has no fallback: it requires `annotation.unit` - Note: `intrinsicDomain` replaces the old `domain` property for clarity 2. **Scale type auto-detection.** Should we auto-switch to log scale when data spans >2 orders of magnitude? This is powerful but can surprise users. Options: diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts index 3e930e34..cd4809c2 100644 --- a/packages/flint-js/src/plotly/theme.ts +++ b/packages/flint-js/src/plotly/theme.ts @@ -23,7 +23,7 @@ * is one number for the whole figure rather than per mark. * - Text on marks is a trace property (`text` + `textposition`), and Plotly * places inside/outside labels itself — the geometry stage 2 computed - * (`insideMinValue`/`outsideMaxValue`) is handed over as `textposition: + * (`insideMinValue`) is handed over as `textposition: * 'auto'` rather than realized as two filtered layers. * - A figure may hold several subplot axis pairs (`xaxis2`, `yaxis3`, …) for * facets and composites. Every axis pass walks all of them. @@ -2472,7 +2472,7 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say } // Plotly places the label inside where it fits and outside where it // does not, which is exactly the geometry stage 2 computed with - // `insideMinValue`/`outsideMaxValue`. `auto` hands that decision to + // `insideMinValue`. `auto` hands that decision to // the renderer, which can measure the drawn bar; `outside` is // honoured literally because it is a house habit, not a fit. // A segment of a stack has no outside — "outside" is the middle of diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 4ef2a977..2ee4f14f 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -60,7 +60,7 @@ import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } import { vlGetTemplateDef } from './templates'; import { inferVisCategory, computeZeroDecision } from '../core/semantic-types'; import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; -import { toTypeString, type SemanticAnnotation } from '../core/field-semantics'; +import { resolveDisplayUnit, titleWithDisplayUnit, toTypeString, type SemanticAnnotation } from '../core/field-semantics'; import { filterOverflow } from '../core/filter-overflow'; import { computeLayout, computeChannelBudgets, computeMinSubplotDimensions, deriveStretchCaps, resolveBaseSize, resolveFacetColumnsOption } from '../core/compute-layout'; import { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; @@ -842,7 +842,9 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { titled: Boolean(vgObj.title), headline: headlineText(vgObj.title), hostSurface: (input.options as any)?.background, - valueLabels: resolveValueLabelChoice(chartProperties), + valueLabels: chartTemplate.suppressValueLabels + ? 'off' + : resolveValueLabelChoice(chartProperties), geometryKinds: chartTemplate.geometryKinds, }); @@ -916,8 +918,8 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { // whose template already writes its own text. Templates that print labels // *on request* are the exception: they answer to the toggle themselves. const designCoupledApplicability: Record = { - showValueLabels: ownsLabels - || (design?.dataLabels?.possible === true && !templateDrawsOwnText), + showValueLabels: !chartTemplate.suppressValueLabels && (ownsLabels + || (design?.dataLabels?.possible === true && !templateDrawsOwnText)), // The older spelling stays an accepted *input* for compatibility, but a // host should be shown one switch, not two that fight. showTextLabels: false, @@ -1312,6 +1314,17 @@ function buildVLEncodings( encodingObj.title = fieldDisplayNames[fieldName]; } + // A lexical unit explicitly declared by the author belongs once with + // the field name, independent of whether a visual theme is applied. + const displayUnit = resolveDisplayUnit(cs?.semanticAnnotation); + if ((channel === 'x' || channel === 'y') && cs?.type === 'quantitative' + && displayUnit?.placement === 'field' && encodingObj.title !== null) { + const currentTitle = typeof encodingObj.title === 'string' + ? encodingObj.title + : fieldName; + if (currentTitle) encodingObj.title = titleWithDisplayUnit(currentTitle, displayUnit); + } + // --- Collect resolved encoding --- if (Object.keys(encodingObj).length !== 0) { resolvedEncodings[channel] = encodingObj; diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts index d89ce135..6e70a9f5 100644 --- a/packages/flint-js/src/vegalite/instantiate-spec.ts +++ b/packages/flint-js/src/vegalite/instantiate-spec.ts @@ -553,6 +553,54 @@ function computeStackedExtremes( return { maxPos, minNeg }; } +const NICE_E10 = Math.sqrt(50); +const NICE_E5 = Math.sqrt(10); +const NICE_E2 = Math.SQRT2; + +function niceStackSpan(start: number, stop: number, count: number): [number, number] { + let lo = start; + let hi = stop; + let previousStep: number | undefined; + for (let index = 0; index < 32; index += 1) { + const rawStep = (hi - lo) / Math.max(1, count); + const power = Math.floor(Math.log10(rawStep)); + const error = rawStep / 10 ** power; + const factor = error >= NICE_E10 ? 10 : error >= NICE_E5 ? 5 : error >= NICE_E2 ? 2 : 1; + const step = power >= 0 ? factor * 10 ** power : -(10 ** -power) / factor; + if (step === previousStep || step === 0 || !Number.isFinite(step)) break; + if (step > 0) { + lo = Math.floor(lo / step) * step; + hi = Math.ceil(hi / step) * step; + } else { + lo = Math.ceil(lo * step) / step; + hi = Math.floor(hi * step) / step; + } + previousStep = step; + } + return [lo, hi]; +} + +/** + * Pin a positive sum stack that already ends on the clean tick `nice` would + * choose. Stored calculated shares can total 99.9999999999; leaving that to + * Vega's post-stack arithmetic may cross the tick by a rounding bit and add a + * whole empty interval. A meaningful excess remains on automatic nice. + */ +function pinCleanStackEndpoint(enc: any, extremes: { maxPos: number; minNeg: number }): void { + if (extremes.minNeg < 0 || !(extremes.maxPos > 0)) return; + if (enc.scale?.domain != null || enc.scale?.domainMax != null || enc.scale?.nice === false) return; + const count = typeof enc.scale?.nice === 'number' ? enc.scale.nice : 10; + const tolerance = Math.max(1, Math.abs(extremes.maxPos)) * 1e-9; + const [, cleanMax] = niceStackSpan(0, extremes.maxPos - tolerance, count); + if (Math.abs(cleanMax - extremes.maxPos) > tolerance) return; + enc.scale = { + ...(enc.scale ?? {}), + domainMin: enc.scale?.domainMin ?? 0, + domainMax: cleanMax, + nice: false, + }; +} + /** * Detect whether a discrete category repeats across rows — i.e., multiple rows * share the same category value, which makes Vega-Lite stack the measure even @@ -745,11 +793,15 @@ function vlApplyFieldContext( const otherChannel = ch === 'y' ? 'x' : 'y'; const otherCS = channelSemantics[otherChannel]; const otherIsDiscrete = otherCS?.type === 'nominal' || otherCS?.type === 'ordinal'; - const isImplicitlyStacked = isBarLike && otherIsDiscrete && enc.stack !== null - && (hasColorEncoding || hasRepeatedCategory(context.table, otherCS?.field, enc.field)); + const isImplicitlyStacked = isBarLike && enc.stack !== null + && (hasColorEncoding + || (otherIsDiscrete && hasRepeatedCategory(context.table, otherCS?.field, enc.field))); const isStacked = isExplicitlyStacked || isImplicitlyStacked; const isNormalizeStacked = enc.stack === 'normalize'; const isSumStacked = isStacked && !isNormalizeStacked; + const stackedExtremes = isSumStacked + ? computeStackedExtremes(context.table, enc.field, ch, channelSemantics) + : undefined; // For sum-stacked charts, check if stacked totals exceed the // intrinsic domain. If they do, skip the domain constraint. @@ -770,9 +822,7 @@ function vlApplyFieldContext( // can't find the intrinsic bounds to snap totals against. const intrinsic = getEffectiveIntrinsicDomain(cs, context.table, enc.field); if (intrinsic) { - const extremes = computeStackedExtremes( - context.table, enc.field, ch, channelSemantics, - ); + const extremes = stackedExtremes; if (extremes !== undefined) { // VL stacks positive and negative contributions @@ -866,6 +916,8 @@ function vlApplyFieldContext( } } + if (stackedExtremes) pinCleanStackEndpoint(enc, stackedExtremes); + // ── 4. Tick constraint (axis.tickMinStep + axis.values) ── // Skip binned encodings — VL handles bin ticks natively. // Without this: Rating 1-5 and Count axes show fractional ticks diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index eaab140a..cd807eae 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -3,7 +3,7 @@ import { ChartTemplateDef, ChartPropertyDef, ChannelSemantics } from '../../core/types'; import { getRegistryEntry } from '../../core/type-registry'; -import type { FormatSpec } from '../../core/field-semantics'; +import { resolveDisplayUnit, titleWithDisplayUnit, type FormatSpec } from '../../core/field-semantics'; import { formatSpecToVegaExpr } from '../format'; /** @@ -36,6 +36,7 @@ export const barTableDef: ChartTemplateDef = { }, channels: ["y", "x", "color", "column", "row"], markCognitiveChannel: 'length', + suppressValueLabels: true, declareLayoutMode: (cs, table, chartProperties) => { // Bar tables split the plot width into 3 horizontal panels // (bar | % | value), so they need a wider canvas than a basic @@ -268,7 +269,6 @@ export const barTableDef: ChartTemplateDef = { // Derived directly from field names; no override knobs. const categoryHeader = yField; const percentHeader = '%'; - const valueHeader = xField; // headerStyle.fontSize is set below once the responsive // `fontSize` constant is available. @@ -284,7 +284,19 @@ export const barTableDef: ChartTemplateDef = { // The %-share column (panel 1) is a different story: it's a // *derived* 0..1 ratio computed by us, so it always needs `%` // formatting. That's `pctPattern` below. - const valueFmt: FormatSpec | undefined = xCS?.format; + const displayUnit = resolveDisplayUnit(xCS?.semanticAnnotation); + const valueFmt: FormatSpec | undefined = displayUnit?.placement === 'value' + ? { + ...(xCS?.format ?? {}), + ...(displayUnit.position === 'prefix' && !xCS?.format?.prefix + ? { prefix: displayUnit.text } + : {}), + ...(displayUnit.position === 'suffix' && !xCS?.format?.suffix + ? { suffix: /^[A-Za-z]/.test(displayUnit.text) ? ` ${displayUnit.text}` : displayUnit.text } + : {}), + } + : xCS?.format; + const valueHeader = titleWithDisplayUnit(xField, displayUnit); const pctPattern = '.1%'; // ── Text-panel transforms ──────────────────────────────────── @@ -616,13 +628,14 @@ export const barTableDef: ChartTemplateDef = { outFieldHint: string, ): any => { if (!fmt || (!fmt.pattern && !fmt.prefix && !fmt.suffix)) { - return { field: sourceField, type: 'quantitative' }; - } - if (!fmt.abbreviate && fmt.pattern && !fmt.prefix && !fmt.suffix) { - return { field: sourceField, type: 'quantitative', format: fmt.pattern }; + transformsOut.push({ calculate: `datum[${JSON.stringify(sourceField)}] + ''`, as: outFieldHint }); + return { field: outFieldHint, type: 'nominal' }; } const formatExpr = formatSpecToVegaExpr(fmt, `datum[${JSON.stringify(sourceField)}]`); - if (!formatExpr) return { field: sourceField, type: 'quantitative' }; + if (!formatExpr) { + transformsOut.push({ calculate: `datum[${JSON.stringify(sourceField)}] + ''`, as: outFieldHint }); + return { field: outFieldHint, type: 'nominal' }; + } transformsOut.push({ calculate: formatExpr, as: outFieldHint, diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index 21a32be1..7cdea15d 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -275,12 +275,12 @@ export function realizeThemeVegaLite(spec: any, d: DesignDecisions, table: any[] harmonizeLinePoints(spec, d, table, say); applyConnectors(spec, d, say); applyRedundantChannels(spec, d, say); - demoteSeriesEnd(spec, d, say); + const seriesEndLayout = demoteSeriesEnd(spec, d, table, say); applyLegend(spec, config, d, table, say); applyFacetChrome(config, d); applyPanelTitles(spec, d, say); const valueLayer = applyDataLabels(spec, d, table, say); - applySeriesEndLabels(spec, d, valueLayer, table, say); + applySeriesEndLabels(spec, d, valueLayer, table, say, seriesEndLayout); applyPointEmphasis(spec, d, say); applyPrintedUnits(spec, d, say); applyStatistics(spec, d, table, say); @@ -738,9 +738,15 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say const rightSeated = side === 'right'; // The title clears the topmost value instead of sitting on // it, so the lift carries a line of the label's own size. + // A column facet owns the next line above the plot; clear + // that header too instead of laying the shared y title on + // the final panel's name. const labelSize = axis.label.fontSize ?? 11; const gap = axis.title.gap ?? (axis.title.fontSize ?? 11) + 6; - const lift = gap + Math.round(labelSize * 0.75); + const headerClearance = d.facets.header.show && hasTopFacetHeader(spec) + ? Math.round((d.facets.header.fontSize ?? 11) * 1.7) + : 0; + const lift = gap + Math.round(labelSize * 0.75) + headerClearance; enc.axis = { ...(enc.axis ?? {}), titleAngle: 0, @@ -1441,6 +1447,15 @@ function panelCount(spec: any, table: any[]): number { return panels; } +function hasTopFacetHeader(spec: any): boolean { + let found = false; + walk(spec, (node) => { + if (node.encoding?.facet?.field || node.encoding?.column?.field + || node.facet?.field || node.facet?.column?.field) found = true; + }); + return found; +} + /** * Whether the spec draws a dot per row — a scatter, a strip, a dot plot. Only * then does the crowding budget below have a claim on the plot's area: a @@ -4594,9 +4609,9 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa delete labelEncoding.theta; } - // A label goes where there is room. A mark shorter than its own label - // cannot hold it, and a mark that reaches the end of the scale has no room - // past its end — so each case sends those few labels the other way. + // Inside placement has one legibility exception: a mark shorter than its + // own label cannot hold it, so that label moves outside. Outside placement + // is chart-wide and never flips only the longest mark inward. // Vega-Lite has no conditional `align`, so this is two layers with // complementary filters. const flipInk = (within: boolean): string | undefined => { @@ -4620,13 +4635,6 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa say('dataLabels.placement', message); }; - // A vertical bar's outside label is cleared by giving the measure scale - // headroom (below); a horizontal one by reserving right margin. The - // scale-end flip — printing the tallest bars' labels inside instead — - // solves the same "no room past the end" problem, so it is only needed - // where headroom is not the remedy: on horizontal bars. - const headroomClears = !inside && onMarkBody && !horizontal && !radial && !cells; - // A stacked segment is exempt: "outside" a segment is the top of the // stack, a different quantity. Segments too short for their number drop it // instead, which the keep test above already arranges. @@ -4634,8 +4642,6 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa if (inside && d.dataLabels.insideMinValue != null) { split(d.dataLabels.insideMinValue, '<', 'marks shorter than their own label print it outside instead'); growPadding(spec, horizontal ? 'right' : 'top', (t.fontSize ?? 10) * 2); - } else if (!inside && d.dataLabels.outsideMaxValue != null && !headroomClears) { - split(d.dataLabels.outsideMaxValue, '>', 'marks that reach the end of the scale print their label inside instead'); } } @@ -4778,9 +4784,19 @@ function addMeasureHeadroom( * *before* the legend is drawn — once the colour legends have been suppressed * in favour of end labels there is nothing to fall back to. */ -function demoteSeriesEnd(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { - if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; - if (!d.legend.show) return; +interface SeriesEndLayout { + adjustedValues: Map; + maxDisplacement: number; +} + +function demoteSeriesEnd( + spec: any, + d: DesignDecisions, + table: any[], + say: (p: string, m: string) => void, +): SeriesEndLayout | undefined { + if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return undefined; + if (!d.legend.show) return undefined; const body = plotBody(spec); // A band carries its own end label inside itself, so it counts as a run // with an end just as much as a line does. @@ -4816,14 +4832,158 @@ function demoteSeriesEnd(spec: any, d: DesignDecisions, say: (p: string, m: stri : marginTaken ? `the ${runsAlongX ? 'right' : 'top'} margin holds the value axis, so a name too big for its band has nowhere to stand` : null)); - if (!reason) return; + const collision = !reason && !bands + ? planSeriesEndLayout(spec, d, table, enc, field) + : undefined; + const finalReason = reason ?? collision?.reason; + if (!finalReason) return collision?.layout; // The house ranked its placements; a demotion should land on the next one // it named, not on whatever this function happens to prefer. const next = d.legend.fallbacks?.find((p) => p !== 'seriesEnd' && p !== 'inline') ?? 'right'; - say('legend.placement', `${reason} — the key is drawn \`${next}\` instead`); + say('legend.placement', `${finalReason} — the key is drawn \`${next}\` instead`); d.legend.placement = next; d.legend.orient = next === 'inside' ? 'top-right' : next as any; d.legend.direction = next === 'top' || next === 'bottom' ? 'horizontal' : 'vertical'; + return undefined; +} + +function planSeriesEndLayout( + spec: any, + d: DesignDecisions, + table: any[], + enc: any, + seriesField: string | undefined, +): { layout?: SeriesEndLayout; reason?: string } { + if (!seriesField || runChannel(d) !== 'x') return {}; + if (d.bound.isFaceted) return { reason: '`seriesEnd` collision checks do not guess across facet scales' }; + if (d.bound.seriesCount > 8) return { reason: '`seriesEnd` is limited to eight series so the margin stays readable' }; + + const domain = enc.x; + const value = enc.y; + if (!domain?.field || !value?.field || value.type !== 'quantitative') return {}; + if (value.scale?.type && value.scale.type !== 'linear') { + return { reason: '`seriesEnd` collision checks need a linear value scale' }; + } + + const orderedDomain = domain.type === 'quantitative' || domain.type === 'temporal' || domain.type === 'ordinal'; + const explicitOrder: Map | undefined = Array.isArray(domain.sort) + ? new Map(domain.sort.map((entry: unknown, index: number): [unknown, number] => [entry, index])) + : undefined; + const comparable = (raw: unknown): number | undefined => { + if (explicitOrder) return explicitOrder.get(raw); + if (domain.type === 'temporal') { + const time = raw instanceof Date ? raw.getTime() : Date.parse(String(raw)); + return Number.isFinite(time) ? time : undefined; + } + const number = Number(raw); + return Number.isFinite(number) ? number : undefined; + }; + + const endpoints = new Map(); + const allDomain: number[] = []; + const allValues: number[] = []; + table.forEach((row, order) => { + const series = row?.[seriesField]; + const domainValue = comparable(row?.[domain.field]); + const valueNumber = Number(row?.[value.field]); + if (series == null || domainValue == null || !Number.isFinite(valueNumber)) return; + allDomain.push(domainValue); + allValues.push(valueNumber); + const previous = endpoints.get(series); + const takesEnd = !previous || (orderedDomain + ? (domain.sort === 'descending' ? domainValue < previous.domain : domainValue > previous.domain) + : order > previous.order); + if (takesEnd) endpoints.set(series, { domain: domainValue, value: valueNumber, order }); + }); + if (endpoints.size < 2 || allDomain.length < 2 || allValues.length < 2) return {}; + + const plotWidth = Number(d.layout.plotWidth ?? spec.width); + const plotHeight = Number(d.layout.plotHeight ?? plotBody(spec).height ?? spec.height); + if (!(plotWidth > 0) || !(plotHeight > 0)) return { reason: '`seriesEnd` could not measure the plot for collision checks' }; + + const domainMin = Math.min(...allDomain); + const domainMax = Math.max(...allDomain); + const domainSpan = domainMax - domainMin; + if (!(domainSpan > 0)) return {}; + const endDomain = Array.from(endpoints.values(), (endpoint) => endpoint.domain); + const endSpreadPx = (Math.max(...endDomain) - Math.min(...endDomain)) / domainSpan * plotWidth; + const uniqueDomain = [...new Set(allDomain)].sort((a, b) => a - b); + const steps = uniqueDomain.slice(1).map((entry, index) => entry - uniqueDomain[index]).filter((step) => step > 0); + const medianStep = steps.length + ? steps.sort((a, b) => a - b)[Math.floor(steps.length / 2)] / domainSpan * plotWidth + : 0; + const alignmentTolerance = Math.max(8, medianStep * 0.25); + + let valueMin = value.scale?.domainMin ?? Math.min(...allValues); + let valueMax = value.scale?.domainMax ?? Math.max(...allValues); + if (Array.isArray(value.scale?.domain) && value.scale.domain.length >= 2) { + valueMin = Number(value.scale.domain[0]); + valueMax = Number(value.scale.domain[1]); + } + if (value.scale?.zero !== false) { + valueMin = Math.min(0, valueMin); + valueMax = Math.max(0, valueMax); + } + const valueSpan = valueMax - valueMin; + if (!(valueSpan > 0)) return {}; + + const reversed = value.scale?.reverse === true; + const toPixel = (number: number) => reversed + ? (number - valueMin) / valueSpan * plotHeight + : (valueMax - number) / valueSpan * plotHeight; + const fromPixel = (pixel: number) => reversed + ? valueMin + pixel / plotHeight * valueSpan + : valueMax - pixel / plotHeight * valueSpan; + const fontSize = Math.max(9, (d.legend.label.fontSize ?? 11) - 1); + const separation = fontSize + 2; + const naturalRows = Array.from(endpoints.values(), (endpoint) => toPixel(endpoint.value)) + .sort((a, b) => a - b); + const naturallyCollides = naturalRows.some((pixel, index) => + index > 0 && pixel - naturalRows[index - 1] < separation); + if (!naturallyCollides) return {}; + if (endSpreadPx > alignmentTolerance) { + return { reason: `series-end labels overlap and their endpoints span ${Math.round(endSpreadPx)}px horizontally, so they cannot be dodged as one column` }; + } + if (endpoints.size * separation > plotHeight) { + return { reason: '`seriesEnd` labels cannot fit vertically without overlap' }; + } + + const packed = Array.from(endpoints, ([series, endpoint]) => ({ + series, + value: endpoint.value, + desired: toPixel(endpoint.value), + placed: toPixel(endpoint.value), + })).sort((a, b) => a.desired - b.desired); + // A label centred on the top or bottom endpoint may straddle the plot + // boundary; Vega includes that text in the figure bounds. Pulling it half + // a line inward creates a needless dodge and disconnects it from the + // endpoint. Keep boundary labels pinned and pack only their neighbours. + const minCenter = 0; + const maxCenter = plotHeight; + packed[0].placed = Math.max(minCenter, packed[0].desired); + for (let index = 1; index < packed.length; index += 1) { + packed[index].placed = Math.max(packed[index].desired, packed[index - 1].placed + separation); + } + const overflow = packed[packed.length - 1].placed - maxCenter; + if (overflow > 0) packed.forEach((entry) => { entry.placed -= overflow; }); + for (let index = packed.length - 2; index >= 0; index -= 1) { + packed[index].placed = Math.min(packed[index].placed, packed[index + 1].placed - separation); + } + if (packed[0].placed < minCenter) { + const shift = minCenter - packed[0].placed; + packed.forEach((entry) => { entry.placed += shift; }); + } + + const maxDisplacement = Math.max(...packed.map((entry) => Math.abs(entry.placed - entry.desired))); + if (maxDisplacement > fontSize) { + return { reason: `series-end labels need ${Math.round(maxDisplacement)}px of dodge, more than one line of text` }; + } + return { + layout: { + adjustedValues: new Map(packed.map((entry) => [entry.series, fromPixel(entry.placed)])), + maxDisplacement, + }, + }; } /** @@ -4846,6 +5006,7 @@ function applySeriesEndLabels( valueLayer: any, table: any[], say: (p: string, m: string) => void, + layout?: SeriesEndLayout, ): void { if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; if (!d.legend.show) return; @@ -4964,6 +5125,18 @@ function applySeriesEndLabels( 'series name and final value merged into one label — they compete for the same space'); } + let labelValue = value; + if (layout?.maxDisplacement && layout.maxDisplacement > 0.5) { + const series = `datum[${JSON.stringify(seriesField)}]`; + let adjusted = `datum[${JSON.stringify(value.field)}]`; + for (const [name, number] of layout.adjustedValues) { + adjusted = `${series} === ${JSON.stringify(name)} ? ${number} : (${adjusted})`; + } + transform.push({ calculate: adjusted, as: '__seriesEndLabelValue' }); + labelValue = { ...value, field: '__seriesEndLabelValue' }; + say('legend.placement', `series-end labels dodged by at most ${Math.round(layout.maxDisplacement)}px to avoid overlap`); + } + const labelLayer: any = { __themeSynthetic: true, transform, @@ -4974,13 +5147,13 @@ function applySeriesEndLabels( dx: domainChannel === 'x' ? 5 : 0, dy: domainChannel === 'x' ? 0 : -5, font: t.font, - fontSize: t.fontSize, + fontSize: Math.max(9, (t.fontSize ?? 11) - 1), ...(t.fontWeight ? { fontWeight: t.fontWeight } : {}), ...(t.fontStyle ? { fontStyle: t.fontStyle } : {}), }, encoding: { [domainChannel]: stripAxis(domain), - [valueChannel]: stripAxis(value), + [valueChannel]: layout ? { ...stripAxis(labelValue), title: null } : stripAxis(labelValue), text: { field: textField, type: 'nominal' }, ...(colourEnc?.field ? { color: { ...colourEnc, legend: null } } : {}), }, diff --git a/packages/flint-js/tests/bar-table-labels.test.ts b/packages/flint-js/tests/bar-table-labels.test.ts new file mode 100644 index 00000000..0d06e9ad --- /dev/null +++ b/packages/flint-js/tests/bar-table-labels.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; + +describe('Bar Table labels', () => { + const titleText = (title: string | string[]) => Array.isArray(title) ? title.join(' ') : title; + + function barTable(unit?: string, field = 'life_expect_gain'): any { + return assembleVegaLite({ + data: { values: [ + { country: 'Peru', [field]: 33.49 }, + { country: 'Iran', [field]: 32.34 }, + ] }, + semantic_types: { + country: 'Country', + [field]: unit ? { semanticType: 'Duration', unit } : 'Duration', + }, + chart_spec: { + chartType: 'Bar Table', + encodings: { y: 'country', x: field }, + baseSize: { width: 600, height: 300 }, + }, + theme_spec: 'nyt', + } as any) as any; + } + + it('does not repeat the value as a generic annotation on each bar', () => { + const spec = barTable('years'); + + expect(spec.hconcat[0].mark.type).toBe('bar'); + expect(spec.hconcat[0].layer).toBeUndefined(); + const valuePanel = spec.hconcat.at(-1); + expect(valuePanel.mark.type).toBe('text'); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain (years)'); + expect(valuePanel.encoding.text.type).toBe('nominal'); + expect(JSON.stringify(valuePanel.transform)).not.toContain('years'); + }); + + it('prints a declared compact unit beside values', () => { + const valuePanel = barTable('kg').hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain'); + expect(JSON.stringify(valuePanel.transform)).toContain(' kg'); + }); + + it('does not display an undeclared unit', () => { + const valuePanel = barTable().hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain'); + expect(JSON.stringify(valuePanel.transform)).not.toMatch(/years| kg/); + }); + + it('does not duplicate a lexical unit already present in the field name', () => { + const valuePanel = barTable('years', 'life_expect_gain (years)').hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain (years)'); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/series-end-collision.test.ts b/packages/flint-js/tests/series-end-collision.test.ts new file mode 100644 index 00000000..a4b3029d --- /dev/null +++ b/packages/flint-js/tests/series-end-collision.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; +import type { ThemeSpec } from '../src/core/theme/types'; + +const theme: ThemeSpec = { + id: 'series-end-test', + label: 'Series end test', + ink: { + surface: { canvas: '#fff', plot: '#fff' }, + text: { primary: '#111' }, + series: { single: '#333', categorical: ['#1261a0', '#d1495b', '#2a9d8f', '#725ac1'] }, + }, + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, +} as ThemeSpec; + +function rows(endValues: number[], endYears?: number[]): any[] { + return endValues.flatMap((endValue, seriesIndex) => { + const endYear = endYears?.[seriesIndex] ?? 2020; + return [1950, 1980, endYear].map((year, index) => ({ + year, + series: `S${seriesIndex + 1}`, + value: index === 2 ? endValue : 70 - seriesIndex * 4 - index * 5, + })); + }); +} + +function build(endValues: number[], endYears?: number[]): any { + return assembleVegaLite({ + data: { values: rows(endValues, endYears) }, + semantic_types: { year: 'Year', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'year', y: 'value', color: 'series' }, + baseSize: { width: 480, height: 300 }, + }, + theme_spec: theme, + } as any) as any; +} + +function layers(spec: any): any[] { + const body = spec.layer ?? []; + return Array.isArray(body) ? body : []; +} + +function endLabel(spec: any): any | undefined { + return layers(spec).find((layer) => { + const mark = typeof layer.mark === 'string' ? layer.mark : layer.mark?.type; + return mark === 'text' && ['series', '__seriesEndLabel'].includes(layer.encoding?.text?.field); + }); +} + +function messages(spec: any): string { + return (spec._theme?.report ?? []) + .filter((entry: any) => entry.path === 'legend.placement') + .map((entry: any) => entry.message) + .join(' '); +} + +describe('series-end collision policy', () => { + it('keeps aligned, separated endpoints directly labelled', () => { + const spec = build([20, 40, 60]); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(endLabel(spec)?.mark.fontSize).toBe(10); + expect(messages(spec)).toContain('synthesized text layer'); + }); + + it('slightly dodges close labels without adding connector ticks', () => { + const spec = build([50, 52]); + expect(endLabel(spec)?.encoding.y.field).toBe('__seriesEndLabelValue'); + expect(layers(spec).some((layer) => { + const mark = typeof layer.mark === 'string' ? layer.mark : layer.mark?.type; + return mark === 'rule' && layer.encoding?.y2?.field === '__seriesEndLabelValue'; + })).toBe(false); + expect(messages(spec)).toMatch(/dodged by at most \d+px/); + }); + + it.each([ + { edge: 'top', values: [25, 48, 72] }, + { edge: 'bottom', values: [0, 25, 48] }, + ])('keeps a label on the $edge boundary anchored to its endpoint', ({ values }) => { + const spec = build(values); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(messages(spec)).not.toContain('dodged by at most'); + }); + + it('falls back as a set when dense labels need too much displacement', () => { + const spec = build([50, 51, 52, 53]); + expect(endLabel(spec)).toBeUndefined(); + expect(messages(spec)).toMatch(/more than one line of text/); + }); + + it('keeps staggered endpoints direct when their labels do not collide', () => { + const spec = build([20, 45, 70], [2020, 2010, 2000]); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(messages(spec)).not.toContain('key is drawn'); + }); + + it('falls back when staggered endpoint labels actually collide', () => { + const spec = build([50, 52], [2020, 2000]); + expect(endLabel(spec)).toBeUndefined(); + expect(messages(spec)).toMatch(/labels overlap.*cannot be dodged as one column/); + }); +}); diff --git a/packages/flint-js/tests/theme-axis-labels.test.ts b/packages/flint-js/tests/theme-axis-labels.test.ts index 6b1408b3..5aa5f8da 100644 --- a/packages/flint-js/tests/theme-axis-labels.test.ts +++ b/packages/flint-js/tests/theme-axis-labels.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { describe, it, expect } from 'vitest'; +import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src'; /** @@ -114,3 +115,88 @@ describe('an axis is ticked at observations only where they are a step', () => { expect(enc.axis?.values).toEqual([2012, 2016, 2020, 2024]); }); }); + +describe('stacked measure endpoints', () => { + function stackedArea(total: number): any { + const values = [ + { year: 2000, cluster: 'A', share: 40 }, + { year: 2000, cluster: 'B', share: total - 40 }, + { year: 2001, cluster: 'A', share: 35 }, + { year: 2001, cluster: 'B', share: total - 35 }, + ]; + const out: any = assembleVegaLite({ + data: { values }, + semantic_types: { year: 'Year', cluster: 'Category', share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'share', color: 'cluster' }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: 'swiss', + } as any); + return out.spec ?? out; + } + + it('keeps a clean stacked maximum flush with the axis', () => { + const spec = stackedArea(100); + expect(spec.encoding.y.scale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + }); + + it('treats floating-point residue from calculated shares as flush', () => { + const yearlyShares = [ + ['1955', 22.7131238639, 16.6700124857, 2.9797012748, 16.2510873496, 38.8083620953, 2.5777129306], + ['1960', 23.1673306654, 15.8887417506, 3.0347038067, 16.6089891163, 38.6352683699, 2.6649662911], + ['1965', 23.5800548972, 15.0968881093, 3.1139372122, 16.7420827415, 38.6837925492, 2.7832444907], + ['1970', 23.8122264795, 14.1851153816, 3.1965034231, 16.5995289954, 39.3139453013, 2.8926804191], + ['1975', 24.1962723441, 13.3226671714, 3.3192782807, 16.5053852188, 39.6345427027, 3.0218542823], + ['1980', 24.9156312003, 12.5591286953, 3.5311844524, 16.5577898534, 39.2200529277, 3.2162128709], + ['1985', 25.6874049251, 11.8031165523, 3.7351451602, 16.4680484502, 38.8244431604, 3.4818417517], + ['1990', 26.3861262603, 11.0895550616, 3.9583434243, 16.3135719813, 38.5452173843, 3.7071858881], + ['1995', 27.3018090463, 10.5337539377, 4.0952183708, 16.374040122, 37.8327450169, 3.8624335062], + ['2000', 28.247815136, 10.0455245409, 4.3245615068, 16.4175767489, 36.9529066531, 4.0116154143], + ['2005', 29.121162734, 9.7053050731, 4.5674750342, 16.3698617817, 36.0714490806, 4.1647462963], + ] as const; + const values = yearlyShares.flatMap(([year, ...shares]) => + shares.map((population_share, cluster) => ({ year, cluster: String(cluster), population_share })) + ); + const out: any = assembleVegaLite({ + data: { values }, + semantic_types: { year: 'Year', cluster: 'Category', population_share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'population_share', color: 'cluster' }, + baseSize: { width: 300, height: 300 }, + title: 'Population share by cluster over time', + subtitle: 'Shares are calculated within each year', + }, + } as any); + const spec = out.spec ?? out; + const totals = yearlyShares.map(([, ...shares]) => shares.reduce((sum, share) => sum + share, 0)); + expect(Math.max(...totals)).toBeGreaterThan(100); + expect(Math.max(...totals)).toBeCloseTo(100, 8); + expect(spec.encoding.y.scale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + + const compiled = compile(spec).spec as any; + const yScale = compiled.scales.find((scale: any) => scale.name === 'y'); + expect(yScale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + }); + + it('leaves a meaningful stacked excess eligible for outward nice rounding', () => { + const out: any = assembleVegaLite({ + data: { values: [ + { year: 2000, cluster: 'A', share: 40 }, + { year: 2000, cluster: 'B', share: 60.3 }, + ] }, + semantic_types: { year: 'Year', cluster: 'Category', share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'share', color: 'cluster' }, + baseSize: { width: 400, height: 300 }, + }, + } as any); + const spec = out.spec ?? out; + expect(spec.encoding.y.scale.domainMax).toBeUndefined(); + expect(spec.encoding.y.scale.nice).not.toBe(false); + }); + +}); diff --git a/packages/flint-js/tests/theme-titles.test.ts b/packages/flint-js/tests/theme-titles.test.ts index 731dc598..83c00906 100644 --- a/packages/flint-js/tests/theme-titles.test.ts +++ b/packages/flint-js/tests/theme-titles.test.ts @@ -129,6 +129,30 @@ describe('axis titles', () => { expect(bare.title).toBeUndefined(); }); + it('lifts a flat y title above column facet headers', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Year: 2000, Country: 'Germany', Rate: 8 }, + { Year: 2020, Country: 'Germany', Rate: 4 }, + { Year: 2000, Country: 'United States', Rate: 4 }, + { Year: 2020, Country: 'United States', Rate: 8 }, + ] }, + semantic_types: { Year: 'Year', Country: 'Country', Rate: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Out of work', + encodings: { x: 'Year', y: 'Rate', column: 'Country' }, + }, + theme_spec: { + ...house({ axisTitles: 'whenAmbiguous', axisTitlePlacement: 'flatAboveAxis', axisTitleGap: 8 }), + structure: { axis: { measure: { placement: 'opposite' } } }, + }, + } as any) as any; + const y = spec.encoding?.y ?? spec.spec?.encoding?.y; + expect(y.axis.orient).toBe('right'); + expect(y.axis.titleY).toBeLessThanOrEqual(-30); + }); + it('leaves an authored subtitle untouched and keeps the measure named', () => { const spec = assembleVegaLite({ data: { values: MONTHLY }, diff --git a/packages/flint-js/tests/unit-display.test.ts b/packages/flint-js/tests/unit-display.test.ts new file mode 100644 index 00000000..a3484622 --- /dev/null +++ b/packages/flint-js/tests/unit-display.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; +import { resolveDisplayUnit } from '../src/core/field-semantics'; + +const values = [ + { country: 'Peru', gain: 33.49 }, + { country: 'Iran', gain: 32.34 }, +]; + +function bars(unit?: string, themed = true): any { + return assembleVegaLite({ + data: { values }, + semantic_types: { + country: 'Country', + gain: unit ? { semanticType: 'Duration', unit } : 'Duration', + }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'country', y: 'gain' }, + baseSize: { width: 400, height: 300 }, + }, + ...(themed ? { theme_spec: 'economist' } : {}), + } as any) as any; +} + +describe('explicit unit display policy', () => { + it('does not infer a visible unit from the semantic type', () => { + const axis = bars()._theme.decisions.axes.y; + expect(axis.unit).toBeUndefined(); + expect(axis.title.unit).toBeUndefined(); + }); + + it('places a declared compact unit beside values', () => { + const axis = bars('kg')._theme.decisions.axes.y; + expect(axis.unit).toMatchObject({ text: 'kg' }); + expect(axis.title.unit).toBeUndefined(); + }); + + it('normalizes conventional compact unit names', () => { + expect(resolveDisplayUnit({ semanticType: 'Duration', unit: 'hours' })) + .toEqual({ text: 'hr', placement: 'value', position: 'suffix' }); + expect(resolveDisplayUnit({ semanticType: 'Amount', unit: 'USD' })) + .toEqual({ text: '$', placement: 'value', position: 'prefix' }); + }); + + it('places a declared lexical unit beside the field name', () => { + const axis = bars('years')._theme.decisions.axes.y; + expect(axis.unit).toBeUndefined(); + expect(axis.title.unit).toBe('years'); + + const unthemed = bars('years', false); + expect(unthemed.encoding.y.title).toBe('gain (years)'); + }); + + it('does not display prose as a unit', () => { + expect(resolveDisplayUnit({ + semanticType: 'Quantity', + unit: 'per working-age resident in constant prices', + })).toBeUndefined(); + }); +}); diff --git a/packages/flint-js/tests/value-label-format.test.ts b/packages/flint-js/tests/value-label-format.test.ts index a57b1831..0d445abe 100644 --- a/packages/flint-js/tests/value-label-format.test.ts +++ b/packages/flint-js/tests/value-label-format.test.ts @@ -231,6 +231,31 @@ describe('value label precision', () => { const labelMark = (spec: any) => (spec.layer ?? []).find((l: any) => (l.mark?.type ?? l.mark) === 'text')?.mark; + it('keeps every label outside when the chart chooses outside placement', () => { + const spec: any = assembleVegaLite({ + data: { + values: [703, 608, 227, 165, 148, 120, 102, 58, 55, 49] + .map((value, index) => ({ cause: `Cause ${index + 1}`, value })), + }, + semantic_types: { cause: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { y: 'cause', x: 'value' }, + baseSize: { width: 420, height: 320 }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'datawrapper', + } as any); + const body = spec.layer ? spec : spec.vconcat?.[0]; + const labels = (body?.layer ?? []) + .filter((layer: any) => (layer.mark?.type ?? layer.mark) === 'text'); + expect(spec._theme.decisions.dataLabels.placement).toBe('outsideMark'); + expect(labels).toHaveLength(1); + expect(labels[0].mark.align).toBe('left'); + expect(labels[0].mark.dx).toBeGreaterThan(0); + expect(labels[0].transform).toBeUndefined(); + }); + it('sends the label below a bar that runs down from zero', () => { // A bar drawn downwards ends at the bottom, so "outside" is below it. // Placed above, the number lands on top of the bar it labels. A narrow diff --git a/site/src/main.tsx b/site/src/main.tsx index fb8c034f..003d6a7c 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -22,6 +22,7 @@ import { ThemeLab } from './playground/ThemeLab'; import { ThemeLabR2 } from './playground/ThemeLabR2'; import { ThemeLabReal } from './playground/ThemeLabReal'; import { BandStretchingLab } from './playground/BandStretchingLab'; +import { LabelExperimentLab } from './playground/LabelExperimentLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; import { LocaleProvider, useLocale } from './i18n/LocaleContext'; @@ -70,6 +71,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> {/* The Swiss and cartoon labs were the same page twice; keep the links they were reached by working. */} diff --git a/site/src/playground/LabelExperimentLab.tsx b/site/src/playground/LabelExperimentLab.tsx new file mode 100644 index 00000000..eb20a05e --- /dev/null +++ b/site/src/playground/LabelExperimentLab.tsx @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useMemo } from 'react'; +import { assembleVegaLite, THEME_PRESETS, type ChartAssemblyInput } from 'flint-chart'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { VegaLiteView } from '../components/VegaLiteView'; +import './label-experiment-lab.css'; + +type Outcome = 'direct' | 'dodge' | 'fallback'; + +interface ExperimentCase { + id: string; + title: string; + note: string; + expected: Outcome; + input: ChartAssemblyInput; +} + +const baseTheme = (THEME_PRESETS as any).datawrapper.spec; +const labelTheme = { + ...baseTheme, + id: 'label-experiment', + label: 'Label experiment', + legend: { + ...baseTheme.legend, + show: 'always', + placement: ['seriesEnd', 'right'], + }, +}; + +function lineRows(endValues: number[], endYears?: number[]): any[] { + return endValues.flatMap((endValue, seriesIndex) => { + const endYear = endYears?.[seriesIndex] ?? 2020; + return [1950, 1980, endYear].map((year, index) => ({ + year, + series: `S${seriesIndex + 1}`, + value: index === 2 ? endValue : 72 - seriesIndex * 4 - index * 5, + })); + }); +} + +function lineInput(endValues: number[], endYears?: number[]): ChartAssemblyInput { + return { + data: { values: lineRows(endValues, endYears) }, + semantic_types: { year: 'Year', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'year', y: 'value', color: 'series' }, + baseSize: { width: 420, height: 280 }, + }, + theme_spec: labelTheme, + } as ChartAssemblyInput; +} + +const connectedRows = [ + ['0', 1955, 4.3, 37], ['0', 1980, 5.5, 55], ['0', 2005, 6.1, 58], + ['1', 1955, 1.8, 79], ['1', 1980, 2.8, 69], ['1', 2005, 6.5, 54], + ['2', 1955, 1.6, 77], ['2', 1980, 2.7, 68], ['2', 2005, 6.7, 55], + ['3', 1955, 1.9, 75], ['3', 1980, 3.2, 66], ['3', 2005, 4.9, 63], + ['4', 1955, 1.7, 73], ['4', 1980, 3.0, 67], ['4', 2005, 5.9, 60], + ['5', 1955, 2.8, 71], ['5', 1980, 4.1, 68], ['5', 2005, 6.7, 47], +].map(([cluster, year, fertility, longevity]) => ({ cluster, year, fertility, longevity })); + +const CASES: ExperimentCase[] = [ + { + id: 'separated', + title: 'Aligned and separated', + note: 'One endpoint column; labels already have enough vertical air.', + expected: 'direct', + input: lineInput([18, 38, 58]), + }, + { + id: 'small-dodge', + title: 'Two close endpoints', + note: 'A sub-line-height adjustment is accepted and connected back to each point.', + expected: 'dodge', + input: lineInput([50, 52]), + }, + { + id: 'dense', + title: 'Dense endpoint cluster', + note: 'The required movement exceeds one line of text, so the whole set returns to a legend.', + expected: 'fallback', + input: lineInput([50, 51, 52, 53]), + }, + { + id: 'staggered', + title: 'Staggered final x positions', + note: 'Different final years are harmless when the natural label rows do not overlap.', + expected: 'direct', + input: lineInput([20, 42, 64], [2020, 2010, 2000]), + }, + { + id: 'boundary', + title: 'Top boundary endpoint', + note: 'The highest label stays centred on its endpoint; the figure bounds carry the overhang.', + expected: 'direct', + input: lineInput([25, 48, 72]), + }, + { + id: 'connected', + title: 'Connected scatter trajectories', + note: 'Rightmost points occupy a broad x range, matching the difficult real-world pattern.', + expected: 'fallback', + input: { + data: { values: connectedRows }, + semantic_types: { + cluster: 'Category', + year: 'Year', + fertility: { semanticType: 'Quantity', unit: 'children per woman' }, + longevity: { semanticType: 'Duration', unit: 'years' }, + }, + chart_spec: { + chartType: 'Connected Scatter Plot', + title: 'Cluster development trajectories', + subtitle: 'Synthetic endpoints modeled after the reported collision', + encodings: { x: 'fertility', y: 'longevity', color: 'cluster', order: 'year' }, + baseSize: { width: 420, height: 280 }, + }, + theme_spec: labelTheme, + } as ChartAssemblyInput, + }, +]; + +function stripInternal(node: any): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(stripInternal); + return; + } + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key]); + } +} + +function buildCase(testCase: ExperimentCase): { spec?: any; outcome: Outcome; message: string; error?: string } { + try { + const spec = assembleVegaLite(testCase.input as any) as any; + const messages = (spec._theme?.report ?? []) + .filter((entry: any) => entry.path === 'legend.placement') + .map((entry: any) => entry.message); + const message = messages.find((entry: string) => + entry.includes('dodged by at most') + || entry.includes('key is drawn') + || entry.includes('do not form one readable label column') + || entry.includes('more than one line of text') + ) ?? messages.at(-1) ?? 'No placement report'; + const outcome: Outcome = messages.some((entry: string) => entry.includes('dodged by at most')) + ? 'dodge' + : messages.some((entry: string) => entry.includes('key is drawn')) + ? 'fallback' + : 'direct'; + stripInternal(spec); + return { spec, outcome, message }; + } catch (error) { + return { outcome: 'fallback', message: 'Assembly failed', error: String((error as Error)?.message ?? error) }; + } +} + +function CaseTile({ testCase }: { testCase: ExperimentCase }) { + const built = useMemo(() => buildCase(testCase), [testCase]); + const matches = built.outcome === testCase.expected; + + return ( +
+
+
+

{testCase.title}

+

{testCase.note}

+
+ + {built.outcome} + +
+
+ {built.error || !built.spec + ?
{built.error}
+ : ( + + + + )} +
+
+ {built.message} + {!matches && Expected {testCase.expected}} +
+
+ ); +} + +export function LabelExperimentLab() { + return ( +
+
+

Series-end label experiment

+

+ Direct labels stay when no more than eight endpoints form one column and need at most one + line of vertical adjustment. Otherwise, the complete set falls back to a legend. +

+
+
+ {CASES.map((testCase) => )} +
+
+ ); +} diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 3c0cc16d..6be6b7b2 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -17,6 +17,7 @@ const pages: NavEntry[] = [ { to: 'theme-lab-r2', label: 'Theme lab R2' }, { to: 'theme-lab-real', label: 'Theme lab real' }, { to: 'band-stretching', label: 'Band stretching' }, + { to: 'label-experiment', label: 'Label experiment' }, { to: 'style-references', label: 'Style references' }, ], }, diff --git a/site/src/playground/label-experiment-lab.css b/site/src/playground/label-experiment-lab.css new file mode 100644 index 00000000..c89cd6c3 --- /dev/null +++ b/site/src/playground/label-experiment-lab.css @@ -0,0 +1,134 @@ +.label-lab { + max-width: 960px; + margin: 0 auto; + padding: 10px 4px 56px; + color: #16202a; +} + +.label-lab-intro { + margin-bottom: 18px; +} + +.label-lab-intro h1 { + margin: 0 0 5px; + font-size: 20px; + font-weight: 650; + letter-spacing: 0; +} + +.label-lab-intro p { + max-width: 820px; + margin: 0; + color: #5d6872; + font-size: 13px; + line-height: 1.55; +} + +.label-case-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.label-case { + display: grid; + grid-template-rows: auto 300px auto; + min-width: 0; + border-top: 1px solid #d8dde2; + background: #fff; +} + +.label-case-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + padding: 12px 0 10px; +} + +.label-case-header h2 { + margin: 0; + font-size: 15px; + line-height: 1.25; + letter-spacing: 0; +} + +.label-case-header p { + max-width: 520px; + margin: 5px 0 0; + color: #6a747d; + font-size: 12px; + line-height: 1.4; +} + +.label-outcome { + flex: 0 0 auto; + padding-top: 2px; + color: #737d86; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + text-transform: uppercase; +} + +.label-chart-frame { + position: relative; + min-width: 0; + overflow: hidden; + border-top: 1px solid #edf0f2; + border-bottom: 1px solid #edf0f2; + background: #fff; +} + +.label-error { + display: grid; + height: 100%; + place-items: center; + color: #a52c24; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.label-case-footer { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 8px 0 12px; +} + +.label-case-footer > span { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; +} + +.label-mismatch { color: #a52c24; } + +.label-case-footer code { + min-width: 0; + color: #5d6872; + font-size: 10px; + line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; +} + +@media (max-width: 980px) { + .label-lab { + padding: 10px 0 40px; + } + + .label-case-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .label-case { + grid-template-rows: auto 300px auto; + } + + .label-case-footer { + grid-template-columns: 1fr; + } +} From b3aec98c2284442332d32691f003ac50beb68479 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 14 Aug 2026 21:09:50 -0700 Subject: [PATCH 03/33] update --- docs/community-backends.md | 63 +++++++++++++++++++ .../flint-js/src/image-charts/assemble.ts | 26 ++++++-- site/src/shared/docs-catalog.ts | 6 ++ 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 docs/community-backends.md diff --git a/docs/community-backends.md b/docs/community-backends.md new file mode 100644 index 00000000..ffc539af --- /dev/null +++ b/docs/community-backends.md @@ -0,0 +1,63 @@ +# Community backends + +Community backends extend Flint to additional renderers and delivery surfaces. +They use the same `ChartAssemblyInput`, but may have different chart coverage, +release cadence, and gallery, editor, MCP, or ThemeSpec integration from Flint's +core backends. + +## Image-Charts + +> Originally contributed by +> [François-Guillaume Ribreau](https://github.com/FGRibreau). + +The Image-Charts backend compiles a Flint input into an unsigned URL for the +third-party [Image-Charts](https://www.image-charts.com/) service. It is useful +when the output must work as an ordinary image URL, including email, generated +documents, chat messages, and other no-JavaScript environments. + +```ts +import { + assembleImageCharts, + isImageChartsSupported, +} from 'flint-chart/image-charts'; + +if (isImageChartsSupported(input.chart_spec.chartType)) { + const artifact = assembleImageCharts(input); + // { type: 'image-charts', url: 'https://image-charts.com/chart?...' } +} +``` + +Assembly is pure: it creates the URL without making a network request. Loading +the returned URL sends the encoded chart data to Image-Charts, so do not use it +with confidential data unless sending that data to the service is acceptable +under your privacy and deployment requirements. + +### Supported charts + +- Bar Chart, Grouped Bar Chart, and Stacked Bar Chart +- Line Chart, Sparkline, and Area Chart +- Scatter Plot +- Pie Chart and Donut Chart +- Radar Chart + +Unsupported chart types and faceted inputs throw an error rather than silently +falling back to another representation. + +### Current scope + +- Output is an unsigned `https://image-charts.com/chart?...` GET URL. Account + identifiers, HMAC signatures, and secrets are outside this pure compiler. +- Width and height are clamped to 999 pixels, and total area is clamped to + 998,001 pixels, matching the service's documented chart-size limits. +- Data, labels, legends, colors, and titles are carried in the query string. + Large or label-heavy charts can produce long URLs; Flint does not currently + convert them to Image-Charts POST requests or enforce a maximum URL length. +- Banded bar charts use Flint's overflow filtering before URL serialization. +- The backend uses a fixed categorical palette. ThemeSpec and most + `chartProperties` are not applied. +- Flint does not currently render this artifact in its gallery, editor, or MCP + server. Availability, caching, retention, quotas, and subscription behavior + are controlled by Image-Charts. + +See the [Image-Charts API documentation](https://documentation.image-charts.com/) +for the hosted service's current request grammar and limits. diff --git a/packages/flint-js/src/image-charts/assemble.ts b/packages/flint-js/src/image-charts/assemble.ts index 740ffb73..8ab7822d 100644 --- a/packages/flint-js/src/image-charts/assemble.ts +++ b/packages/flint-js/src/image-charts/assemble.ts @@ -107,6 +107,20 @@ function formatValue(value: number | null): string { return String(Number(value.toFixed(4))); } +function finiteNumber(value: unknown): number | null { + if (value == null || (typeof value === 'string' && value.trim() === '')) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function cellKey(value: unknown): string { + return `${typeof value}:${String(value)}`; +} + +function pairKey(first: unknown, second: unknown): string { + return JSON.stringify([cellKey(first), cellKey(second)]); +} + /** Distinct values of a field in first-seen order (nulls skipped). */ function distinct(rows: any[], field: string): Cell[] { const seen = new Set(); @@ -139,14 +153,14 @@ function pivotValues( const cv = r[catField]; if (cv == null) continue; const sv = seriesField ? r[seriesField] : SINGLE; - const num = Number(r[measField]); - if (!Number.isFinite(num)) continue; - const key = JSON.stringify([String(cv), String(sv)]); + const num = finiteNumber(r[measField]); + if (num == null) continue; + const key = pairKey(cv, sv); const e = acc.get(key) ?? { sum: 0, count: 0 }; e.sum += num; e.count += 1; acc.set(key, e); } const valueAt = (cv: Cell, sv: Cell): number | null => { - const e = acc.get(JSON.stringify([String(cv), String(seriesField ? sv : SINGLE)])); + const e = acc.get(pairKey(cv, seriesField ? sv : SINGLE)); if (!e) return null; return aggregate === 'average' ? e.sum / e.count : e.sum; }; @@ -280,8 +294,8 @@ function buildScatter( const colors: string[] = []; seriesKeys.forEach((key, index) => { const rows = seriesField ? table.filter((r) => r[seriesField] === key) : table; - const xs = rows.map((r) => Number(r[xField])); - const ys = rows.map((r) => Number(r[yField])); + const xs = rows.map((r) => finiteNumber(r[xField])); + const ys = rows.map((r) => finiteNumber(r[yField])); datasets.push(xs.map(formatValue).join(',')); datasets.push(ys.map(formatValue).join(',')); const color = SERIES_COLORS[index % SERIES_COLORS.length]; diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts index 2cb735fd..5069074f 100644 --- a/site/src/shared/docs-catalog.ts +++ b/site/src/shared/docs-catalog.ts @@ -136,6 +136,12 @@ export const DOCUMENTATION_GROUPS: DocGroup[] = [ description: 'Every native Excel chart type, its channels, and Office.js mapping.', file: '../../../docs/reference-excel.md', }, + { + slug: 'community-backends', + title: 'Community backends', + description: 'Community-contributed renderers and delivery targets, their coverage, and integration notes.', + file: '../../../docs/community-backends.md', + }, ], }, { From 758e89b13536cb8ace0994b27c3441bfccdbdbcc Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 14 Aug 2026 21:21:54 -0700 Subject: [PATCH 04/33] fix --- .../flint-mcp/assets/flint-chart-author.SKILL.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index af1a890f..f7326fb1 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -421,7 +421,20 @@ understates what you know: } ``` -- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `unit` — an optional assertion that authorizes Flint to display a unit. Add + it only when the data or surrounding context establishes the measurement + and seeing it materially changes how a reader interprets the number. A type + such as `Duration`, a field name such as `life_expectancy`, or values that + merely look plausible are not enough evidence by themselves. + - Prefer canonical codes: `"USD"`, `"°C"`, `"kg"`, `"km/h"`, `"min"`. + - Conventional compact units are normalized and may appear beside values + (`USD` → `$`, `hours` → `hr`). + - Lexical units such as `"years"` are stated once beside the field name as + `field (years)`, not repeated after every value. + - Do not put explanatory phrases in `unit`. Put qualifications such as + `"per working-age resident"` or `"constant 2024 prices"` in the subtitle. + - Omit `unit` when its meaning, scale, or denominator is uncertain. Flint + does not infer a visible unit from the semantic type or field name. - `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` for a five-star rating, `[0, 100]` for a percentage score. Not for open-ended measures. From 20fa7f698b3504d70ea16c15f7b8babf1a3b6c96 Mon Sep 17 00:00:00 2001 From: zhnd Date: Sat, 15 Aug 2026 22:59:41 +0800 Subject: [PATCH 05/33] fix(echarts): pin categorical legend with right instead of design-canvas left legend.left was `_width - gutter`, so chart.resize() overlapped the plot or clipped the legend. Anchor the legend and its title graphic with right: 16. At `_width` the gutter is unchanged; only fluid hosts change. Fixes #98. --- CHANGELOG.md | 5 + docs/figs/issue-98-slope-534.png | Bin 0 -> 69152 bytes docs/figs/issue-98-slope-800-before.png | Bin 0 -> 79596 bytes docs/figs/issue-98-slope-800.png | Bin 0 -> 79864 bytes packages/flint-js/src/echarts/facet.ts | 14 +- .../flint-js/src/echarts/instantiate-spec.ts | 38 ++--- .../src/echarts/templates/streamgraph.ts | 14 +- packages/flint-js/tests/slope.test.ts | 15 ++ scripts/issue-98-slope-shots.mjs | 145 ++++++++++++++++++ site/src/components/EChartsView.tsx | 10 +- 10 files changed, 194 insertions(+), 47 deletions(-) create mode 100644 docs/figs/issue-98-slope-534.png create mode 100644 docs/figs/issue-98-slope-800-before.png create mode 100644 docs/figs/issue-98-slope-800.png create mode 100644 scripts/issue-98-slope-shots.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea1af0e..4919a6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- ECharts categorical legends (and their title graphics) are pinned with + `legend.right` instead of a design-canvas `left` pixel. Hosts that size the + container independently of `_width` and call `chart.resize()` keep the + reserved gutter instead of overlapping the plot or clipping the legend + ([#98](https://github.com/microsoft/flint-chart/issues/98)). - Visible units now require an explicit `unit` in the field's semantic annotation. Conventional compact units may accompany values, while lexical units such as `years` are stated once as part of the field title. Bar Tables diff --git a/docs/figs/issue-98-slope-534.png b/docs/figs/issue-98-slope-534.png new file mode 100644 index 0000000000000000000000000000000000000000..77518fd906c8241c7c6c83deb130914f2f6269d8 GIT binary patch literal 69152 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

A@ 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

~Iv6u7!U;&v>U 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 (g.left ?? 0) + (g.width ?? 0))); + const { left: _ignoredLeft, ...legendRest } = combined.legend; + void _ignoredLeft; combined.legend = { - ...combined.legend, - left: rightMost + GAP, + ...legendRest, + right: BUFFER, top: combined.legend.top ?? 20, orient: combined.legend.orient || 'vertical', align: 'left', - right: undefined, textStyle: { fontSize: highCardinality ? 8 : 11, ...(combined.legend.textStyle || {}), @@ -565,13 +566,14 @@ function repositionFacetedPolarLegend(combined: any): void { const r = Number(p?.radius) || 0; return cx + r; })); + const { left: _ignoredLeft, ...legendRest } = combined.legend; + void _ignoredLeft; combined.legend = { - ...combined.legend, - left: rightMost + GAP, + ...legendRest, + right: BUFFER, top: combined.legend.top ?? 20, orient: combined.legend.orient || 'vertical', align: 'left', - right: undefined, textStyle: { fontSize: highCardinality ? 8 : 11, ...(combined.legend.textStyle || {}), diff --git a/packages/flint-js/src/echarts/instantiate-spec.ts b/packages/flint-js/src/echarts/instantiate-spec.ts index b4474d4c..0299005f 100644 --- a/packages/flint-js/src/echarts/instantiate-spec.ts +++ b/packages/flint-js/src/echarts/instantiate-spec.ts @@ -495,41 +495,24 @@ export function ecApplyLayoutToSpec( option.graphic = Array.isArray(existing) ? [...existing, titleGraphic] : (existing ? [existing, titleGraphic] : [titleGraphic]); } } else { - // Single legend: use left positioning so title and legend circles share the same left edge + // Single legend: pin to the canvas right edge (not a design-width + // `left` px). Hosts that call chart.resize() keep the gutter; + // `right = designW - left` is wrong — ECharts `right` is the inset + // to the legend's *right* edge, which would grow into the plot. + // See https://github.com/microsoft/flint-chart/issues/98 const maxLabelLen = Math.max(...legendLabels.map((l: string) => l.length), 3); const highCardinality = legendLabels.length >= 16; const legendSymbolWidth = highCardinality ? 12 : 14; const legendItemGap = 5; const estimatedTextWidth = Math.min(120, maxLabelLen * 7 + 30); option._legendWidth = legendSymbolWidth + legendItemGap + estimatedTextWidth; - const LEGEND_GAP = 12; const CANVAS_BUFFER = 16; - const rightMarginPx = option._legendWidth + LEGEND_GAP + CANVAS_BUFFER; - const hasYTitle = !!option.yAxis?.name; - const gridLeft = (hasYTitle ? 70 : 50) + CANVAS_BUFFER; - // Use same effective plot width as canvas block (grouped bar/boxplot widen the plot) so legend does not overlap chart - let plotW = layout?.subplotWidth ?? canvasSize?.width ?? 400; - const xIsDiscreteForLegend = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0; - if (xIsDiscreteForLegend) { - let xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0; - if (layout.xStepUnit === 'group' && option.series && Array.isArray(option.series) && layout.xNominalCount > 0) { - const barSeriesCount = option.series.filter((s: any) => s.type === 'bar').length || option.series.length; - if (barSeriesCount > 0) { - xItemCount = Math.max(1, Math.round(layout.xNominalCount / barSeriesCount)); - } - } - plotW = xItemCount > 0 ? layout.xStep * xItemCount : plotW; - } - const boxplotMinWForLegend = estimateGroupedBoxplotMinPlotWidth(option, layout); - if (boxplotMinWForLegend > 0) { - plotW = Math.max(plotW, boxplotMinWForLegend); - } - const effectiveChartWidth = plotW + gridLeft + rightMarginPx; - const legendLeftPx = Math.max(0, effectiveChartWidth - rightMarginPx); + const { left: _ignoredLeft, ...legendRest } = option.legend; + void _ignoredLeft; option.legend = { - ...option.legend, + ...legendRest, top: legendTitle != null ? 20 : 0, - left: legendLeftPx, + right: CANVAS_BUFFER, orient: option.legend.orient || 'vertical', align: 'left', // icon on left, text on right textStyle: { @@ -542,7 +525,7 @@ export function ecApplyLayoutToSpec( if (legendTitle != null) { const titleGraphic = { type: 'text' as const, - left: legendLeftPx, + right: CANVAS_BUFFER, top: 4, z: 100, style: { @@ -551,6 +534,7 @@ export function ecApplyLayoutToSpec( fontWeight: 'bold', fill: '#333', textAlign: 'left', + width: option._legendWidth, }, }; const existing = option.graphic; diff --git a/packages/flint-js/src/echarts/templates/streamgraph.ts b/packages/flint-js/src/echarts/templates/streamgraph.ts index ef8135c7..a414558b 100644 --- a/packages/flint-js/src/echarts/templates/streamgraph.ts +++ b/packages/flint-js/src/echarts/templates/streamgraph.ts @@ -200,22 +200,20 @@ export const ecStreamgraphDef: ChartTemplateDef = { option.singleAxis.left = option.singleAxis.left || 50; option.singleAxis.right = Math.max(option.singleAxis.right || 0, rightMargin); - // Position legend in the right margin so it doesn't overlap the stream + // Pin legend to the right gutter (not design-canvas `left`) so resize() + // does not drop it into the stream. See microsoft/flint-chart#98. if (hasLegend && option.legend) { - const legendLeft = option._width - rightMargin + BUFFER; - option.legend.left = legendLeft; - delete option.legend.right; // Use left to align with graphic titles + delete option.legend.left; + option.legend.right = BUFFER; option.legend.top = 20; option.legend.orient = option.legend.orient || 'vertical'; option.legend.align = 'left'; - // Also update any custom graphic legend titles if (Array.isArray(option.graphic)) { for (const g of option.graphic) { - // The legend title added in instantiate-spec.ts typically has top: 4 and type: 'text' if (g.type === 'text' && (g.top === 4 || g.top === 20) && g.style && g.style.fontWeight === 'bold') { - g.left = legendLeft; - delete g.right; + delete g.left; + g.right = BUFFER; } } } diff --git a/packages/flint-js/tests/slope.test.ts b/packages/flint-js/tests/slope.test.ts index 3490ea8e..375fd83d 100644 --- a/packages/flint-js/tests/slope.test.ts +++ b/packages/flint-js/tests/slope.test.ts @@ -144,6 +144,21 @@ describe('ECharts Slope chart', () => { expect(option.yAxis.type).toBe('value'); }); + it('anchors the color legend from the right so chart.resize() keeps the gutter', () => { + // Design-canvas `left` (e.g. 422 of 534) overlaps the plot once the host + // is wider than `_width`. `right` is the inset to the legend box edge. + expect(option.legend.right).toBe(16); + expect(option.legend.left).toBeUndefined(); + expect(option.legend.orient).toBe('vertical'); + expect(option.grid.right).toBeGreaterThan(option.legend.right); + const title = (option.graphic ?? []).find( + (g: { type?: string; style?: { fontWeight?: string } }) => + g.type === 'text' && g.style?.fontWeight === 'bold', + ); + expect(title?.right).toBe(16); + expect(title?.left).toBeUndefined(); + }); + it('orders temporal year periods as two ordered categories', () => { const temporal = byTitle( cases, diff --git a/scripts/issue-98-slope-shots.mjs b/scripts/issue-98-slope-shots.mjs new file mode 100644 index 00000000..34325487 --- /dev/null +++ b/scripts/issue-98-slope-shots.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * One-off shots for microsoft/flint-chart#98. Not part of the test suite. + * Usage: node scripts/issue-98-slope-shots.mjs + */ +import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer-core'; +import { assembleECharts } from '../packages/flint-js/dist/echarts/index.js'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(root, 'docs/figs'); + +const assembled = assembleECharts({ + data: { + values: [ + { period: '2024', team: 'Alpha', nps: 32 }, + { period: '2025', team: 'Alpha', nps: 48 }, + { period: '2024', team: 'Beta', nps: 41 }, + { period: '2025', team: 'Beta', nps: 39 }, + { period: '2024', team: 'Gamma', nps: 28 }, + { period: '2025', team: 'Gamma', nps: 52 }, + { period: '2024', team: 'Delta', nps: 55 }, + { period: '2025', team: 'Delta', nps: 61 }, + ], + }, + semantic_types: { period: 'Year', team: 'Name', nps: 'Score' }, + chart_spec: { + chartType: 'Slope Chart', + encodings: { + x: { field: 'period' }, + y: { field: 'nps' }, + color: { field: 'team' }, + }, + baseSize: { width: 420, height: 280 }, + }, +}); + +const { _width: designW, _height: designH, _warnings, _dataLength, _pivot, ...option } = + assembled; +void _warnings; +void _dataLength; +void _pivot; + +const cloneOpt = (o) => + JSON.parse(JSON.stringify(o, (_k, v) => (typeof v === 'function' ? undefined : v))); +const before = cloneOpt(option); +const gutter = designW - (option.grid?.right ?? 112); +before.legend = { ...before.legend, left: gutter }; +delete before.legend.right; +if (Array.isArray(before.graphic)) { + before.graphic = before.graphic.map((g) => { + if (g?.type === 'text' && g.style?.fontWeight === 'bold') { + const { right: _r, ...rest } = g; + void _r; + return { ...rest, left: gutter }; + } + return g; + }); +} + +const payload = { after: cloneOpt(option), before, designW, designH }; + +const html = ` + + + + + + +

+ + +`; + +mkdirSync(outDir, { recursive: true }); +const htmlPath = join(outDir, '.issue-98-host.html'); +writeFileSync(htmlPath, html); + +const echartsFile = join(root, 'node_modules/echarts/dist/echarts.esm.min.js'); +const server = createServer((req, res) => { + if (req.url === '/echarts.js') { + res.writeHead(200, { 'content-type': 'text/javascript' }); + res.end(readFileSync(echartsFile)); + return; + } + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(html); +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const { port } = server.address(); +const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const browser = await puppeteer.launch({ + executablePath: chrome, + headless: true, + args: ['--hide-scrollbars'], +}); +const page = await browser.newPage(); +await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' }); +await page.waitForFunction(() => window.__ready === true); + +async function shot(name, width, which) { + await page.setViewport({ width, height: designH, deviceScaleFactor: 2 }); + await page.evaluate( + async ({ width: w, height, which: key }) => { + const echarts = window.__echarts; + const payload = window.__payload; + const el = document.getElementById('c'); + el.style.width = `${w}px`; + el.style.height = `${height}px`; + echarts.dispose(el); + const chart = echarts.init(el, undefined, { renderer: 'canvas', width: w, height }); + const opt = { ...payload[key], animation: false }; + chart.setOption(opt, { notMerge: true }); + if (w !== payload.designW) chart.resize({ width: w, height }); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + await new Promise((r) => setTimeout(r, 50)); + }, + { width, height: designH, which }, + ); + const path = join(outDir, name); + await page.screenshot({ path, type: 'png', clip: { x: 0, y: 0, width, height: designH } }); + console.log('wrote', path); +} + +await shot('issue-98-slope-534.png', designW, 'after'); +await shot('issue-98-slope-800.png', 800, 'after'); +await shot('issue-98-slope-800-before.png', 800, 'before'); + +await browser.close(); +server.close(); +try { + const { unlinkSync } = await import('node:fs'); + unlinkSync(htmlPath); +} catch { + /* ignore */ +} diff --git a/site/src/components/EChartsView.tsx b/site/src/components/EChartsView.tsx index 3836c1da..ffaea6ed 100644 --- a/site/src/components/EChartsView.tsx +++ b/site/src/components/EChartsView.tsx @@ -20,12 +20,10 @@ export function EChartsView({ const chartRef = useRef(null); const [error, setError] = useState(null); - // The flint ECharts assembler computes a designed canvas size (`_width`/`_height`) - // and positions legends / visualMaps with absolute pixels relative to it — the same - // way Vega-Lite sizes its plot area and lets the SVG wrap around it. Render at those - // dimensions so the legend lands where it was designed, instead of snapping to the - // live container's bounding box (which made rose legends drift far right, streamgraph - // legends overlap the plot, and heatmap colour bars float below a stretched plot). + // The assembler still designs a canvas (`_width`/`_height`). Categorical legends + // are now `right`-anchored (issue #98) and survive container resize(); other + // chrome (visualMap, rose, some radii) is still design-px. Render at the + // designed size so those leftovers land where they were laid out. const designedWidth = asFinite(option?._width); const designedHeight = asFinite(option?._height); const renderHeight = designedHeight ?? height ?? 320; From 5ae05d030270d1e28576d3273fed956d39531c4c Mon Sep 17 00:00:00 2001 From: zhnd Date: Sat, 15 Aug 2026 23:03:16 +0800 Subject: [PATCH 06/33] docs: add design-width before shot for #98 Show that at `_width` the right-anchored legend matches the old left pixel layout; only the resized canvas changes. --- docs/figs/issue-98-slope-534-before.png | Bin 0 -> 68737 bytes scripts/issue-98-slope-shots.mjs | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/figs/issue-98-slope-534-before.png diff --git a/docs/figs/issue-98-slope-534-before.png b/docs/figs/issue-98-slope-534-before.png new file mode 100644 index 0000000000000000000000000000000000000000..5a63b302dfebbf8921fadc06ec29f2aa3c018fdd GIT binary patch literal 68737 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

6lvAz~ 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 Date: Tue, 18 Aug 2026 11:58:48 -0700 Subject: [PATCH 07/33] fix --- packages/flint-js/src/core/decisions.ts | 4 +- packages/flint-js/tests/year-legend.test.ts | 41 +++++++ packages/flint-py/flint/core/decisions.py | 2 +- site/src/main.tsx | 3 + site/src/playground/DebugGym.tsx | 124 ++++++++++++++++++++ site/src/playground/PlaygroundShell.tsx | 1 + 6 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 packages/flint-js/tests/year-legend.test.ts create mode 100644 site/src/playground/DebugGym.tsx diff --git a/packages/flint-js/src/core/decisions.ts b/packages/flint-js/src/core/decisions.ts index b2803e47..cdc487f9 100644 --- a/packages/flint-js/src/core/decisions.ts +++ b/packages/flint-js/src/core/decisions.ts @@ -139,8 +139,8 @@ function resolveTemporalEncoding( if (['size', 'column', 'row'].includes(channel)) { return { vlType: 'ordinal', visCategory, channelOverride: true, cardinalityGuard: false }; } - // Temporal on color with low cardinality → ordinal for distinct colors - if (channel === 'color') { + // Temporal on color/group with low cardinality → ordinal for distinct colors + if (channel === 'color' || channel === 'group') { const uniqueCount = new Set(data.map(r => r[fieldName])).size; if (uniqueCount <= 12) { return { vlType: 'ordinal', visCategory, channelOverride: true, cardinalityGuard: false }; diff --git a/packages/flint-js/tests/year-legend.test.ts b/packages/flint-js/tests/year-legend.test.ts new file mode 100644 index 00000000..0fb61b36 --- /dev/null +++ b/packages/flint-js/tests/year-legend.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src/vegalite'; + +const values = [ + { 品牌: '惠普', 年度: 2025, 毛利: 49933.56 }, + { 品牌: '惠普', 年度: 2026, 毛利: 30973.54 }, + { 品牌: '华为', 年度: 2025, 毛利: 25407.73 }, + { 品牌: '华为', 年度: 2026, 毛利: 14659.13 }, +]; + +function input(typed: boolean) { + return { + data: { values }, + semantic_types: typed + ? { 品牌: 'Category', 年度: 'Year', 毛利: 'Currency' } + : { 品牌: 'Category', 毛利: 'Currency' }, + chart_spec: { + chartType: 'Grouped Bar Chart', + encodings: { + x: { field: '品牌' }, + y: { field: '毛利' }, + group: { field: '年度' }, + }, + }, + } as any; +} + +describe('two-value year legend', () => { + it('uses discrete colors when the group field is typed as Year', () => { + const spec = assembleVegaLite(input(true)) as any; + + expect(spec.encoding.color).toMatchObject({ field: '年度', type: 'ordinal' }); + expect(spec.encoding.xOffset).toMatchObject({ field: '年度', type: 'nominal' }); + }); + + it('keeps an unrecognized numeric group field quantitative', () => { + const spec = assembleVegaLite(input(false)) as any; + + expect(spec.encoding.color).toMatchObject({ field: '年度', type: 'quantitative' }); + }); +}); \ No newline at end of file diff --git a/packages/flint-py/flint/core/decisions.py b/packages/flint-py/flint/core/decisions.py index f363bbca..447abf9d 100644 --- a/packages/flint-py/flint/core/decisions.py +++ b/packages/flint-py/flint/core/decisions.py @@ -68,7 +68,7 @@ def _resolve_temporal_encoding( "vlType": "ordinal", "visCategory": vis_category, "channelOverride": True, "cardinalityGuard": False, } - if channel == "color": + if channel in ("color", "group"): unique_count = len({r.get(field_name) for r in data}) if unique_count <= 12: return { diff --git a/site/src/main.tsx b/site/src/main.tsx index 003d6a7c..e81d6d02 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -25,6 +25,7 @@ import { BandStretchingLab } from './playground/BandStretchingLab'; import { LabelExperimentLab } from './playground/LabelExperimentLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; +import { DebugGym } from './playground/DebugGym'; import { LocaleProvider, useLocale } from './i18n/LocaleContext'; import type { Locale } from './i18n/locales'; import { localePath } from './i18n/paths'; @@ -73,6 +74,8 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> + } /> {/* The Swiss and cartoon labs were the same page twice; keep the links they were reached by working. */} } /> diff --git a/site/src/playground/DebugGym.tsx b/site/src/playground/DebugGym.tsx new file mode 100644 index 00000000..624177b4 --- /dev/null +++ b/site/src/playground/DebugGym.tsx @@ -0,0 +1,124 @@ +import { useMemo, type CSSProperties } from 'react'; +import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { siteTheme } from '../shared/theme'; + +const rows = [ + ['惠普', 2025, 49933.56], ['惠普', 2026, 30973.54], + ['华为', 2025, 25407.73], ['华为', 2026, 14659.13], + ['佳能', 2025, 14717.72], ['佳能', 2026, 5770.24], + ['奔图', 2025, 6094.31], ['奔图', 2026, 2518.72], + ['盈佳', 2025, 68500.12], ['盈佳', 2026, 63500.45], + ['爱普生', 2025, 13120.44], ['爱普生', 2026, 8920.16], +].map(([品牌, 年度, 毛利]) => ({ 品牌, 年度, 毛利 })); + +function makeInput(typed: boolean): ChartAssemblyInput { + return { + data: { values: rows }, + semantic_types: typed + ? { 品牌: 'Category', 年度: 'Year', 毛利: 'Currency' } + : { 品牌: 'Category', 毛利: 'Currency' }, + chart_spec: { + chartType: 'Grouped Bar Chart', + encodings: { + x: { field: '品牌' }, + y: { field: '毛利' }, + group: { field: '年度' }, + }, + baseSize: { width: 400, height: 260 }, + }, + }; +} + +function findFieldEncoding(node: unknown, field: string): Record | null { + if (!node || typeof node !== 'object') return null; + const record = node as Record; + for (const channel of ['color', 'fill', 'stroke']) { + if (record.encoding?.[channel]?.field === field) return record.encoding[channel]; + } + for (const value of Object.values(record)) { + if (Array.isArray(value)) { + for (const item of value) { + const found = findFieldEncoding(item, field); + if (found) return found; + } + } else { + const found = findFieldEncoding(value, field); + if (found) return found; + } + } + return null; +} + +function compileCase(typed: boolean) { + try { + const spec = assembleVegaLite(makeInput(typed)) as any; + const color = findFieldEncoding(spec, '年度'); + const resolvedType = color?.type ?? 'not found'; + const legendKind = resolvedType === 'quantitative' || resolvedType === 'temporal' + ? 'continuous gradient' + : 'categorical swatches'; + return { spec, error: null as string | null, resolvedType, legendKind }; + } catch (error) { + return { + spec: null, + error: String((error as Error)?.message ?? error), + resolvedType: 'error', + legendKind: 'error', + }; + } +} + +const cardStyle: CSSProperties = { + minWidth: 0, + border: `1px solid ${siteTheme.border}`, + borderRadius: siteTheme.radius, + background: siteTheme.surface, + padding: 12, +}; + +function CasePanel({ typed }: { typed: boolean }) { + const result = useMemo(() => compileCase(typed), [typed]); + return ( +

+
+

+ {typed ? 'Year semantic type supplied' : 'Year semantic type missing'} +

+ + {typed ? 'semantic_types: { 年度: "Year" }' : 'semantic_types: { /* 年度 omitted */ }'} + +
+ {result.error ? ( +
{result.error}
+ ) : ( + + + + )} +
+ Color type {result.resolvedType} + Legend {result.legendKind} +
+
+ ); +} + +export function DebugGym() { + return ( +
+
+

Debug gym

+

+ Same two-year data, one variable: 年度: Year resolves to ordinal; an untyped numeric + 年度 remains quantitative. +

+
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 6be6b7b2..a13e1ca1 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -9,6 +9,7 @@ const pages: NavEntry[] = [ { to: 'illustrations', label: 'Illustrations' }, { to: 'mcp-ui', label: 'MCP UI test' }, { to: 'labs', label: 'Labs' }, + { to: 'debug-gym', label: 'Debug gym' }, { to: 'demo-wall', label: 'Demo wall' }, { group: 'Theme labs', From a6ecf36fbc6bf10dadbc8ce6a0024df85d0c5c7e Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Tue, 18 Aug 2026 14:01:38 -0700 Subject: [PATCH 08/33] cleanp --- docs/figs/issue-98-slope-534-before.png | Bin 68737 -> 0 bytes docs/figs/issue-98-slope-534.png | Bin 69152 -> 0 bytes docs/figs/issue-98-slope-800-before.png | Bin 79596 -> 0 bytes docs/figs/issue-98-slope-800.png | Bin 79864 -> 0 bytes scripts/issue-98-slope-shots.mjs | 146 ------------------------ 5 files changed, 146 deletions(-) delete mode 100644 docs/figs/issue-98-slope-534-before.png delete mode 100644 docs/figs/issue-98-slope-534.png delete mode 100644 docs/figs/issue-98-slope-800-before.png delete mode 100644 docs/figs/issue-98-slope-800.png delete mode 100644 scripts/issue-98-slope-shots.mjs diff --git a/docs/figs/issue-98-slope-534-before.png b/docs/figs/issue-98-slope-534-before.png deleted file mode 100644 index 5a63b302dfebbf8921fadc06ec29f2aa3c018fdd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68737 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

A@ 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

~Iv6u7!U;&v>U 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 - JSON.parse(JSON.stringify(o, (_k, v) => (typeof v === 'function' ? undefined : v))); -const before = cloneOpt(option); -const gutter = designW - (option.grid?.right ?? 112); -before.legend = { ...before.legend, left: gutter }; -delete before.legend.right; -if (Array.isArray(before.graphic)) { - before.graphic = before.graphic.map((g) => { - if (g?.type === 'text' && g.style?.fontWeight === 'bold') { - const { right: _r, ...rest } = g; - void _r; - return { ...rest, left: gutter }; - } - return g; - }); -} - -const payload = { after: cloneOpt(option), before, designW, designH }; - -const html = ` - - - - - - -

- - -`; - -mkdirSync(outDir, { recursive: true }); -const htmlPath = join(outDir, '.issue-98-host.html'); -writeFileSync(htmlPath, html); - -const echartsFile = join(root, 'node_modules/echarts/dist/echarts.esm.min.js'); -const server = createServer((req, res) => { - if (req.url === '/echarts.js') { - res.writeHead(200, { 'content-type': 'text/javascript' }); - res.end(readFileSync(echartsFile)); - return; - } - res.writeHead(200, { 'content-type': 'text/html' }); - res.end(html); -}); - -await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); -const { port } = server.address(); -const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; -const browser = await puppeteer.launch({ - executablePath: chrome, - headless: true, - args: ['--hide-scrollbars'], -}); -const page = await browser.newPage(); -await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' }); -await page.waitForFunction(() => window.__ready === true); - -async function shot(name, width, which) { - await page.setViewport({ width, height: designH, deviceScaleFactor: 2 }); - await page.evaluate( - async ({ width: w, height, which: key }) => { - const echarts = window.__echarts; - const payload = window.__payload; - const el = document.getElementById('c'); - el.style.width = `${w}px`; - el.style.height = `${height}px`; - echarts.dispose(el); - const chart = echarts.init(el, undefined, { renderer: 'canvas', width: w, height }); - const opt = { ...payload[key], animation: false }; - chart.setOption(opt, { notMerge: true }); - if (w !== payload.designW) chart.resize({ width: w, height }); - await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); - await new Promise((r) => setTimeout(r, 50)); - }, - { width, height: designH, which }, - ); - const path = join(outDir, name); - await page.screenshot({ path, type: 'png', clip: { x: 0, y: 0, width, height: designH } }); - console.log('wrote', path); -} - -await shot('issue-98-slope-534-before.png', designW, 'before'); -await shot('issue-98-slope-534.png', designW, 'after'); -await shot('issue-98-slope-800-before.png', 800, 'before'); -await shot('issue-98-slope-800.png', 800, 'after'); - -await browser.close(); -server.close(); -try { - const { unlinkSync } = await import('node:fs'); - unlinkSync(htmlPath); -} catch { - /* ignore */ -} From 90f980a29ab6d019d6129708af6b8cd9e283f7eb Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Tue, 18 Aug 2026 17:44:51 -0700 Subject: [PATCH 09/33] fix --- site/src/playground/DebugGym.tsx | 85 +++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/site/src/playground/DebugGym.tsx b/site/src/playground/DebugGym.tsx index 624177b4..4e1d66c3 100644 --- a/site/src/playground/DebugGym.tsx +++ b/site/src/playground/DebugGym.tsx @@ -1,7 +1,10 @@ -import { useMemo, type CSSProperties } from 'react'; -import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; +import { useMemo, useState, type CSSProperties } from 'react'; +import { assembleECharts, assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; +import { genEChartsSlopeTests } from 'flint-chart/test-data'; +import { EChartsView } from '../components/EChartsView'; import { ScaleToFit } from '../components/ScaleToFit'; import { VegaLiteView } from '../components/VegaLiteView'; +import { testCaseToAssemblyInput } from '../shared/test-case-utils'; import { siteTheme } from '../shared/theme'; const rows = [ @@ -105,6 +108,83 @@ function CasePanel({ typed }: { typed: boolean }) { ); } +const SLOPE_WIDTHS = [534, 800] as const; + +function legendTitle(option: any): any { + return (option.graphic ?? []).find( + (item: any) => item?.type === 'text' && item?.style?.fontWeight === 'bold', + ); +} + +function SlopeGym() { + const [hostWidth, setHostWidth] = useState<(typeof SLOPE_WIDTHS)[number]>(800); + const cases = useMemo(() => genEChartsSlopeTests().map((testCase) => { + const input = testCaseToAssemblyInput(testCase, { width: 420, height: 280 }); + const option = assembleECharts(input) as any; + return { testCase, option }; + }), []); + + return ( +
+
+
+

ECharts slope resize

+

+ Issue #98: the legend and its title stay pinned to the right gutter when the host resizes. +

+
+
+ {SLOPE_WIDTHS.map((width) => ( + + ))} +
+
+
+ {cases.map(({ testCase, option }) => { + const resized = { ...option, _width: hostWidth }; + const title = legendTitle(option); + const anchored = option.legend?.right === 16 + && option.legend?.left == null + && title?.right === 16 + && title?.left == null; + return ( +
+

{testCase.title}

+ + + +
+ Host {hostWidth}px + + {anchored ? 'right: 16 ✓' : 'anchor failed'} + +
+
+ ); + })} +
+
+ ); +} + export function DebugGym() { return (
@@ -119,6 +199,7 @@ export function DebugGym() {
+ ); } \ No newline at end of file From 281ecd798066de988b5ac43859e31e68b960850a Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:06:45 +0800 Subject: [PATCH 10/33] fix(bar-table): honour Rank ordinal semantics instead of length-encoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rank semantic type is an ordinal (a standing), not a magnitude. The Bar Table template length-encoded it — bar length plus a sequential colour ramp — which inverted the ranking: rank 1 got the shortest, palest bar and the last-placed item the longest, darkest one. Honour the documented behaviour ("Rank → reversed axis (1 on top), discrete color") in both the Vega-Lite and Plotly templates: - order rows by rank ascending (1 first / on top), - use a discrete colour scale instead of a magnitude ramp, - draw equal-length bars so the mark no longer implies a magnitude that is not there. Fixes #85 --- .../src/plotly/templates/bar-table.ts | 18 +++-- .../src/vegalite/templates/bar-table.ts | 52 ++++++++++---- .../flint-js/tests/bar-table-rank.test.ts | 68 +++++++++++++++++++ 3 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 packages/flint-js/tests/bar-table-rank.test.ts diff --git a/packages/flint-js/src/plotly/templates/bar-table.ts b/packages/flint-js/src/plotly/templates/bar-table.ts index c1bccb6a..c21483e6 100644 --- a/packages/flint-js/src/plotly/templates/bar-table.ts +++ b/packages/flint-js/src/plotly/templates/bar-table.ts @@ -108,7 +108,7 @@ interface AggRow { /** Aggregate raw rows into ranked-and-topN'd category rows for one facet scope. */ function buildScopeRows( rows: any[], yField: string, xField: string, colorField: string | undefined, - useMean: boolean, maxRows: number, reversed: boolean, + useMean: boolean, maxRows: number, reversed: boolean, xOrdinal: boolean, ): AggRow[] { const byCat = new Map }>(); for (const r of rows) { @@ -126,7 +126,7 @@ function buildScopeRows( const agg = (g: { sum: number; n: number }) => useMean ? g.sum / Math.max(1, g.n) : g.sum; const ranked = Array.from(byCat.entries()) .map(([cat, g]) => ({ cat, value: agg(g), byColor: colorField ? g.byColor : undefined })) - .sort((a, b) => reversed ? a.value - b.value : b.value - a.value); + .sort((a, b) => (reversed || xOrdinal) ? a.value - b.value : b.value - a.value); if (maxRows <= 0 || ranked.length <= maxRows) { return ranked.map(r => ({ ...r, isOthers: false })); @@ -177,6 +177,10 @@ export const plBarTableDef: ChartTemplateDef = { const showPercent = chartProperties?.showPercent === true; const useMean = channelSemantics.x?.aggregationDefault === 'average'; const reversed = !!channelSemantics.y?.reversed; + // Ordinal measures (Rank) are standings, not magnitudes — length-encoding + // them inverts the ranking (see issue #85). Honor the documented `Rank` + // behaviour: rank ascending (1 first), discrete colour, equal-length bars. + const xIsOrdinal = channelSemantics.x?.type === 'ordinal'; const xEntry = getRegistryEntry(channelSemantics.x?.semanticAnnotation?.semanticType ?? 'Unknown'); let hasNegative = false, hasPositive = false; @@ -233,7 +237,7 @@ export const plBarTableDef: ChartTemplateDef = { // ── Per-cell aggregation (Top-N rollup within each facet scope). ── const scoped = cells.map(row => row.map(cell => - buildScopeRows(cell.rows, yField, xField, colorField, useMean, maxRows, reversed))); + buildScopeRows(cell.rows, yField, xField, colorField, useMean, maxRows, reversed, xIsOrdinal))); const allColorValues = colorField ? [...new Set(scoped.flat().flatMap(sr => sr.filter(r => !r.isOthers).flatMap(r => [...(r.byColor?.keys() ?? [])])))] @@ -374,12 +378,16 @@ export const plBarTableDef: ChartTemplateDef = { }); } } else { - const vals = sr.map(r => r.value); + const vals = xIsOrdinal ? sr.map(() => 1) : sr.map(r => r.value); const finite = vals.filter(Number.isFinite); const vmin = finite.length ? Math.min(...finite, 0) : 0; const vmax = finite.length ? Math.max(...finite) : 1; - const colors = sr.map(r => { + const colors = sr.map((r, idx) => { if (r.isOthers) return OTHERS_GRAY; + if (xIsOrdinal) { + // Discrete colour per rank — no magnitude ramp. + return palette[idx % palette.length]; + } if (isDiverging) { const span = Math.max(Math.abs(vmin), Math.abs(vmax)) || 1; const t = r.value / span; // -1..1 diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index eaab140a..5132e136 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -115,6 +115,17 @@ export const barTableDef: ChartTemplateDef = { const yCS: ChannelSemantics | undefined = ctx.channelSemantics?.y; const xEntry = getRegistryEntry(xCS?.semanticAnnotation?.semanticType ?? 'Unknown'); + // ── Ordinal measures (Rank) ────────────────────────────────── + // An ordinal is a standing, not a magnitude: "how much better is 1st + // than 2nd" has no answer. Length-encoding it (bar length + sequential + // colour ramp) would invert the ranking — rank 1 gets the shortest, + // palest bar. Honor the documented `Rank` behaviour instead (see + // flint://agent-skill: "Rank → reversed axis (1 on top), discrete + // color"): sort by rank ascending (1 first), use a discrete colour + // scale, and keep bars equal-length so the mark does not imply a + // magnitude that isn't there. + const xIsOrdinal = xCS?.type === 'ordinal'; + // Sign profile of x values — used by the diverging-palette check. let hasNegative = false; let hasPositive = false; @@ -192,7 +203,9 @@ export const barTableDef: ChartTemplateDef = { && maxScopedCategoryCount > maxRows; const sortRowsByValue = (items: Array<{ cat: any; value: number }>) => items - .sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value); + .sort((a, b) => xIsOrdinal + ? a.value - b.value + : (yCS?.reversed ? a.value - b.value : b.value - a.value)); let displayTable: any[] = []; let othersCatLabel: string | undefined; @@ -418,7 +431,9 @@ export const barTableDef: ChartTemplateDef = { } return uniqueCats .map(cat => ({ cat, value: aggValue(globalCategoryAgg.get(cat)!) })) - .sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value) + .sort((a, b) => xIsOrdinal + ? a.value - b.value + : (yCS?.reversed ? a.value - b.value : b.value - a.value)) .map(a => a.cat); })(); const ySort: any = ySortOrder && ySortOrder.length > 0 @@ -483,12 +498,19 @@ export const barTableDef: ChartTemplateDef = { legend: null, scale: { scheme: 'redyellowgreen', domainMid: 0 }, } - : { - field: xField, - type: 'quantitative', - legend: null, - scale: { range: ['#cdebd3', '#41a25f'] }, - }; + : xIsOrdinal + ? { + field: xField, + type: 'ordinal', + legend: null, + scale: { scheme: 'tableau10' }, + } + : { + field: xField, + type: 'quantitative', + legend: null, + scale: { range: ['#cdebd3', '#41a25f'] }, + }; // ── Dynamic panel widths from longest formatted label ──────── // @@ -708,12 +730,14 @@ export const barTableDef: ChartTemplateDef = { }, encoding: { y: yEncWithLabels, - x: { - field: barXField, - type: 'quantitative', - axis: null, - scale: barXScale, - }, + x: xIsOrdinal + ? { datum: 1, type: 'quantitative', axis: null, scale: { domain: [0, 1], nice: false } } + : { + field: barXField, + type: 'quantitative', + axis: null, + scale: barXScale, + }, color: barColorEnc, }, }); diff --git a/packages/flint-js/tests/bar-table-rank.test.ts b/packages/flint-js/tests/bar-table-rank.test.ts new file mode 100644 index 00000000..aacfaebe --- /dev/null +++ b/packages/flint-js/tests/bar-table-rank.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assemblePlotly } from '../src'; + +/** + * Regression test for issue #85: the `Rank` semantic type is an ordinal, not a + * magnitude. The Bar Table template used to length-encode it (bar length + + * sequential colour ramp), inverting the ranking so rank 1 got the shortest, + * palest bar. The documented behaviour is "Rank → reversed axis (1 on top), + * discrete color". + * + * The fix honours that in both the Vega-Lite and Plotly Bar Table templates: + * - rows ordered by rank ascending (1 first / on top), + * - discrete colour (no magnitude ramp), + * - equal-length bars (no length encoding of an ordinal). + */ + +const RANK_INPUT = { + data: { + values: [ + { Engine: 'Inworld TTS-2', Rank: 1 }, + { Engine: 'xAI leo', Rank: 2 }, + { Engine: 'Kokoro am_michael', Rank: 3 }, + { Engine: 'Gemini', Rank: 4 }, + { Engine: 'Inworld 1.5-max', Rank: 5 }, + ], + }, + semantic_types: { Engine: 'Name', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bar Table', + encodings: { y: { field: 'Engine' }, x: { field: 'Rank' } }, + baseSize: { width: 560, height: 280 }, + }, +}; + +const RANK_ORDER_ASC = ['Inworld TTS-2', 'xAI leo', 'Kokoro am_michael', 'Gemini', 'Inworld 1.5-max']; + +describe('Bar Table honours Rank semantic (issue #85)', () => { + it('Vega-Lite: sorts rank ascending, discrete colour, equal-length bars', () => { + const spec = assembleVegaLite(RANK_INPUT as never) as any; + const barPanel = spec.hconcat[0]; + + // Rank ascending: rank 1 first (top). + expect(barPanel.encoding.y.sort).toEqual(RANK_ORDER_ASC); + + // Discrete colour scale (ordinal), not a sequential magnitude ramp. + expect(barPanel.encoding.color.type).toBe('ordinal'); + expect(barPanel.encoding.color.scale.scheme).toBeTruthy(); + + // No length encoding: bars are a constant value, not the rank field. + expect(barPanel.encoding.x.field).toBeUndefined(); + expect(barPanel.encoding.x.datum).toBe(1); + }); + + it('Plotly: sorts rank ascending, discrete colour, equal-length bars', () => { + const fig = assemblePlotly(RANK_INPUT as never) as any; + const trace = (fig.data ?? []).find((t: any) => t.type === 'bar' && t.orientation === 'h'); + + // Rank ascending: rank 1 first (top). + expect(trace.y).toEqual(RANK_ORDER_ASC); + + // Equal-length bars (no magnitude encoding) and discrete colour. + expect(trace.x.every((v: number) => v === 1)).toBe(true); + expect(new Set(trace.marker.color).size).toBeGreaterThan(1); + }); +}); From a11938201b41e7fe11a952c458ba8145614d1ddc Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 21 Aug 2026 11:37:52 -0700 Subject: [PATCH 11/33] basic interactions --- docs/api-reference.md | 2 +- docs/design-stretch-model.md | 8 +- docs/zh-CN/api-reference.md | 2 +- docs/zh-CN/design-stretch-model.md | 8 +- package-lock.json | 10 +- packages/flint-js/package.json | 35 +- packages/flint-js/src/README.md | 23 ++ packages/flint-js/src/chartjs/assemble.ts | 3 + packages/flint-js/src/chartjs/interactive.ts | 94 ++++++ packages/flint-js/src/core/compute-layout.ts | 11 +- packages/flint-js/src/core/filter-overflow.ts | 74 ++++- packages/flint-js/src/core/index.ts | 3 +- packages/flint-js/src/core/types.ts | 16 + .../flint-js/src/docs/design-stretch-model.md | 8 +- packages/flint-js/src/echarts/assemble.ts | 3 + packages/flint-js/src/echarts/interactive.ts | 82 +++++ .../flint-js/src/echarts/templates/heatmap.ts | 2 +- packages/flint-js/src/interactive/index.ts | 79 +++++ packages/flint-js/src/interactive/surface.ts | 252 ++++++++++++++ packages/flint-js/src/interactive/types.ts | 45 +++ packages/flint-js/src/plotly/assemble.ts | 3 + packages/flint-js/src/plotly/interactive.ts | 84 +++++ .../src/plotly/plotly-js-dist-min.d.ts | 4 + packages/flint-js/src/vegalite/assemble.ts | 3 + .../src/vegalite/interactive-focus.ts | 136 ++++++++ packages/flint-js/src/vegalite/interactive.ts | 133 ++++++++ .../flint-js/tests/filter-overflow.test.ts | 41 ++- .../flint-js/tests/interactive-focus.test.ts | 101 ++++++ .../flint-js/tests/sizing-ceiling.test.ts | 27 +- packages/flint-js/tests/theme-plotly.test.ts | 12 +- packages/flint-js/tsup.config.ts | 10 +- .../flint-py/flint/core/compute_layout.py | 9 +- .../flint-py/flint/core/filter_overflow.py | 40 ++- packages/flint-py/flint/vegalite/assemble.py | 2 + site/src/main.tsx | 2 + site/src/playground/OverflowViewportLab.tsx | 199 +++++++++++ site/src/playground/PlaygroundShell.tsx | 1 + site/src/playground/overflow-viewport-lab.css | 308 ++++++++++++++++++ site/src/types/plotly.d.ts | 4 + site/vite.config.ts | 4 + 40 files changed, 1838 insertions(+), 45 deletions(-) create mode 100644 packages/flint-js/src/chartjs/interactive.ts create mode 100644 packages/flint-js/src/echarts/interactive.ts create mode 100644 packages/flint-js/src/interactive/index.ts create mode 100644 packages/flint-js/src/interactive/surface.ts create mode 100644 packages/flint-js/src/interactive/types.ts create mode 100644 packages/flint-js/src/plotly/interactive.ts create mode 100644 packages/flint-js/src/plotly/plotly-js-dist-min.d.ts create mode 100644 packages/flint-js/src/vegalite/interactive-focus.ts create mode 100644 packages/flint-js/src/vegalite/interactive.ts create mode 100644 packages/flint-js/tests/interactive-focus.test.ts create mode 100644 site/src/playground/OverflowViewportLab.tsx create mode 100644 site/src/playground/overflow-viewport-lab.css diff --git a/docs/api-reference.md b/docs/api-reference.md index 9473e74b..5c67e97a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -209,7 +209,7 @@ interface AssembleOptions { maxStretchX?: number; // per-dimension width cap (derived from canvasSize) maxStretchY?: number; // per-dimension height cap (derived from canvasSize) facetElasticity?: number; // facet stretch (default 0.3) - minStep?: number; // min px per discrete item (default 6) + minStep?: number; // min px per discrete item (default 8) minSubplotSize?: number; // min facet subplot px (default 60) maxColorValues?: number; // color cardinality before truncation (default 24) stepPadding?: number; // band inner padding fraction (default 0.1) diff --git a/docs/design-stretch-model.md b/docs/design-stretch-model.md index 4a631341..6551ce04 100644 --- a/docs/design-stretch-model.md +++ b/docs/design-stretch-model.md @@ -283,12 +283,12 @@ The layout balances two directions: | $L_{\max}$ | Maximum axis length | `base × β` (β from `maxStretch` or `canvasSize`) | 800 px | | $N$ | Number of banded items | Field cardinality | data-dependent | | $\ell_0$ | Natural (base) size per band | `defaultBandSize` | ~20 px | -| $\ell_{\min}$ | Minimum size per band | `minStep` option | 6 px | +| $\ell_{\min}$ | Minimum size per band | `minStep` option | 8 px | | $\ell_{\max}$ | Maximum size per band | `maxBandSize` option | = $\ell_0$ | | $\alpha$ | Elasticity exponent | `elasticity` option | 0.5 | | $\beta$ | Maximum stretch multiplier | `maxStretch`, or derived from `canvasSize` | 1.5 | -> **Code defaults:** `elasticity: 0.5`, `minStep: 6`, and `maxStretch: 1.5` when no `canvasSize` ceiling is set. $\ell_0$ and $\ell_{\max}$ are given at a 300 px reference canvas and scaled with size: `round(bandSize × max(1, sizeRatio))`. +> **Code defaults:** `elasticity: 0.5`, `minStep: 8`, and `maxStretch: 1.5` when no `canvasSize` ceiling is set. $\ell_0$ and $\ell_{\max}$ are given at a 300 px reference canvas and scaled with size: `round(bandSize × max(1, sizeRatio))`. ### §2.2.1 Band size bounds — min, base, max @@ -422,7 +422,7 @@ Grouped items, such as a grouped bar chart with $m$ sub-bars per group, are trea | Parameter | Simple discrete | Grouped bar ($m$ sub-bars) | |---|---|---| | $\ell_0$ (natural) | `defaultStepSize` | $m \times$ `defaultStepSize` | -| $\ell_{\min}$ (solid) | `minStep` (6 px) | $2m$ px (2 px per sub-bar) | +| $\ell_{\min}$ (solid) | `minStep` (8 px) | $2m$ px (2 px per sub-bar) | | $N$ (item count) | Field cardinality | Number of **groups** | The elastic budget formula is unchanged — only the parameter values change. @@ -530,7 +530,7 @@ The minimum subplot size ($S_{\min}$) is axis-aware: |---|---|---| | $N$ | Number of discrete items | data-dependent | | $\ell_0$ | Natural step size | ~20 px | -| $\ell_{\min}$ | Minimum step size | 6 px | +| $\ell_{\min}$ | Minimum step size | 8 px | | $\alpha$ | Elasticity exponent | 0.5 | | $\beta$ | Maximum stretch | 1.5 | diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index 97f83fef..ad2fb88d 100644 --- a/docs/zh-CN/api-reference.md +++ b/docs/zh-CN/api-reference.md @@ -174,7 +174,7 @@ interface AssembleOptions { maxStretchX?: number; // per-dimension width cap (derived from canvasSize) maxStretchY?: number; // per-dimension height cap (derived from canvasSize) facetElasticity?: number; // facet stretch (default 0.3) - minStep?: number; // min px per discrete item (default 6) + minStep?: number; // min px per discrete item (default 8) minSubplotSize?: number; // min facet subplot px (default 60) maxColorValues?: number; // color cardinality before truncation (default 24) stepPadding?: number; // band inner padding fraction (default 0.1) diff --git a/docs/zh-CN/design-stretch-model.md b/docs/zh-CN/design-stretch-model.md index 78218072..cbe30394 100644 --- a/docs/zh-CN/design-stretch-model.md +++ b/docs/zh-CN/design-stretch-model.md @@ -263,11 +263,11 @@ continuousWidth = stepSize × (N + 1) | $L_{\max}$ | Maximum axis length | `base × β`(β 来自 `maxStretch` 或 `canvasSize`) | 800 px | | $N$ | Number of banded items | Field cardinality | data-dependent | | $\ell_0$ | Natural length per item | `defaultStepSize` | ~20 px | -| $\ell_{\min}$ | Minimum length per item | `minStep` option | 6 px | +| $\ell_{\min}$ | Minimum length per item | `minStep` option | 8 px | | $\alpha$ | Elasticity exponent | `elasticity` option | 0.5 | | $\beta$ | Maximum stretch multiplier | `maxStretch`,或从 `canvasSize` 推导 | 1.5 | -> **Code defaults:** 未设置 `canvasSize` 上限时,`elasticity: 0.5`、`minStep: 6`、`maxStretch: 1.5`。`defaultStepSize` 根据画布尺寸动态计算:`round(20 × max(1, sizeRatio) × defaultStepMultiplier)`。 +> **Code defaults:** 未设置 `canvasSize` 上限时,`elasticity: 0.5`、`minStep: 8`、`maxStretch: 1.5`。`defaultStepSize` 根据画布尺寸动态计算:`round(20 × max(1, sizeRatio) × defaultStepMultiplier)`。 ## §2.3 三种状态 @@ -357,7 +357,7 @@ $$\boxed{\ell = \frac{\kappa \cdot \ell_0 + L_0 / N}{1 + \kappa}}$$ | Parameter | Simple discrete | Grouped bar ($m$ sub-bars) | |---|---|---| | $\ell_0$ (natural) | `defaultStepSize` | $m \times$ `defaultStepSize` | -| $\ell_{\min}$ (solid) | `minStep` (6 px) | $2m$ px(每子 bar 2 px) | +| $\ell_{\min}$ (solid) | `minStep` (8 px) | $2m$ px(每子 bar 2 px) | | $N$ (item count) | Field cardinality | **组**数量 | elastic budget 公式不变 — 仅参数值变化。 @@ -465,7 +465,7 @@ gas pressure 模型(§3)在每个子图内运行,容器为 $W_{\text{sub}} |---|---|---| | $N$ | Number of discrete items | data-dependent | | $\ell_0$ | Natural step size | ~20 px | -| $\ell_{\min}$ | Minimum step size | 6 px | +| $\ell_{\min}$ | Minimum step size | 8 px | | $\alpha$ | Elasticity exponent | 0.5 | | $\beta$ | Maximum stretch | 1.5 | diff --git a/package-lock.json b/package-lock.json index b7958def..75d0209b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10301,8 +10301,10 @@ "chart.js": "^4.0.0", "echarts": "^5.0.0 || ^6.0.0", "plotly.js": "^2.0.0 || ^3.0.0", + "plotly.js-dist-min": "^2.0.0 || ^3.0.0", "vega": "^5.0.0 || ^6.0.0", - "vega-lite": "^5.0.0 || ^6.0.0" + "vega-lite": "^5.0.0 || ^6.0.0", + "vega-tooltip": "^1.0.0" }, "peerDependenciesMeta": { "chart.js": { @@ -10314,11 +10316,17 @@ "plotly.js": { "optional": true }, + "plotly.js-dist-min": { + "optional": true + }, "vega": { "optional": true }, "vega-lite": { "optional": true + }, + "vega-tooltip": { + "optional": true } } }, diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index bf8c69ff..28627964 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -70,6 +70,31 @@ "import": "./dist/image-charts/index.js", "require": "./dist/image-charts/index.cjs" }, + "./interactive": { + "types": "./dist/interactive/index.d.ts", + "import": "./dist/interactive/index.js", + "require": "./dist/interactive/index.cjs" + }, + "./vegalite/interactive": { + "types": "./dist/vegalite/interactive.d.ts", + "import": "./dist/vegalite/interactive.js", + "require": "./dist/vegalite/interactive.cjs" + }, + "./echarts/interactive": { + "types": "./dist/echarts/interactive.d.ts", + "import": "./dist/echarts/interactive.js", + "require": "./dist/echarts/interactive.cjs" + }, + "./chartjs/interactive": { + "types": "./dist/chartjs/interactive.d.ts", + "import": "./dist/chartjs/interactive.js", + "require": "./dist/chartjs/interactive.cjs" + }, + "./plotly/interactive": { + "types": "./dist/plotly/interactive.d.ts", + "import": "./dist/plotly/interactive.js", + "require": "./dist/plotly/interactive.cjs" + }, "./test-data": { "types": "./dist/test-data/index.d.ts", "import": "./dist/test-data/index.js", @@ -103,9 +128,11 @@ "peerDependencies": { "chart.js": "^4.0.0", "plotly.js": "^2.0.0 || ^3.0.0", + "plotly.js-dist-min": "^2.0.0 || ^3.0.0", "echarts": "^5.0.0 || ^6.0.0", "vega": "^5.0.0 || ^6.0.0", - "vega-lite": "^5.0.0 || ^6.0.0" + "vega-lite": "^5.0.0 || ^6.0.0", + "vega-tooltip": "^1.0.0" }, "peerDependenciesMeta": { "vega": { @@ -114,6 +141,9 @@ "vega-lite": { "optional": true }, + "vega-tooltip": { + "optional": true + }, "echarts": { "optional": true }, @@ -122,6 +152,9 @@ }, "plotly.js": { "optional": true + }, + "plotly.js-dist-min": { + "optional": true } }, "devDependencies": { diff --git a/packages/flint-js/src/README.md b/packages/flint-js/src/README.md index fee77c2a..523b4b0a 100644 --- a/packages/flint-js/src/README.md +++ b/packages/flint-js/src/README.md @@ -163,6 +163,29 @@ Each backend has its own assembly function. All accept the same | `assembleECharts(input)` | ECharts option object | `import { assembleECharts } from 'flint-chart'` | | `assembleChartjs(input)` | Chart.js config object | `import { assembleChartjs } from 'flint-chart'` | +### Interactive surface + +Interactive renderers are opt-in and shipped separately from the static assembly entry point. The surface owns viewport state, accessible scroll controls, and renderer lifecycle; the caller supplies only a container and chart input. + +```ts +import { buildInteractiveChart } from 'flint-chart/interactive'; + +const surface = buildInteractiveChart( + container, + input, + { + backend: 'vegalite', + renderer: 'canvas', + focusOnClick: true, + }, +); + +await surface.ready; +// Later: surface.destroy(); +``` + +The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite discrete marks also enable local click focus by default: click selects, Shift/Ctrl/Meta-click toggles marks, and clicking empty plot space clears. Set `focusOnClick: false` to disable it. Other backends currently ignore this option. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. + ### Input types ```ts diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts index 85a16571..72da8d85 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -446,6 +446,9 @@ export function assembleChartjs(input: ChartAssemblyInput): any { if (warnings.length > 0) { cjsConfig._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + cjsConfig._viewports = overflowResult.viewports; + } cjsConfig._dataLength = values.length; diff --git a/packages/flint-js/src/chartjs/interactive.ts b/packages/flint-js/src/chartjs/interactive.ts new file mode 100644 index 00000000..691aaf9a --- /dev/null +++ b/packages/flint-js/src/chartjs/interactive.ts @@ -0,0 +1,94 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assembleChartjs } from './assemble'; +import { Chart, registerables } from 'chart.js'; + +Chart.register(...registerables); + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +function renderConfig(config: any): any { + return { + ...config, + options: { + ...(config.options ?? {}), + responsive: true, + maintainAspectRatio: false, + }, + }; +} + +export function createChartjsInteractiveRenderer(): InteractiveRendererAdapter { + return { + async mount(container, input) { + const plannedConfig = assembleChartjs(input) as any; + const viewports = (plannedConfig._viewports ?? []) as CategoryViewport[]; + const initialConfig = viewports.length > 0 + ? assembleChartjs(windowedInput(input, viewports, {})) as any + : plannedConfig; + const wrapper = document.createElement('div'); + const canvas = document.createElement('canvas'); + wrapper.style.position = 'relative'; + wrapper.style.width = Number.isFinite(initialConfig._width) ? `${initialConfig._width}px` : '100%'; + wrapper.style.height = `${Number.isFinite(initialConfig._height) ? initialConfig._height : 320}px`; + wrapper.style.maxWidth = '100%'; + wrapper.append(canvas); + container.append(wrapper); + const chart = new Chart(canvas, renderConfig(initialConfig)); + + let destroyed = false; + let updateTimer: number | undefined; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || updateTimer !== undefined) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const config = renderConfig(assembleChartjs(windowedInput(input, viewports, latestStarts))); + chart.data = config.data; + chart.options = config.options; + chart.update('none'); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const area = chart.chartArea; + return channel === 'x' + ? { offset: area.left, extent: area.right - area.left } + : { offset: area.top, extent: area.bottom - area.top }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + schedule(); + }, + resize(size) { + wrapper.style.width = `${size.width}px`; + wrapper.style.height = `${size.height}px`; + chart.resize(size.width, size.height); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + chart.destroy(); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/core/compute-layout.ts b/packages/flint-js/src/core/compute-layout.ts index f5aec42b..d0fa3cf0 100644 --- a/packages/flint-js/src/core/compute-layout.ts +++ b/packages/flint-js/src/core/compute-layout.ts @@ -79,6 +79,9 @@ const APPROX_CHAR_WIDTH_RATIO = 0.62; */ const SPARSE_FIT_BAND_CEILING = 100; +/** Smallest readable default step for a discrete item before overflow activates. */ +export const DEFAULT_MIN_STEP = 8; + /** Distinct label strings for a discrete axis field, plus derived stats. */ interface DiscreteLabelStats { count: number; @@ -292,7 +295,7 @@ export function computeLayout( const { elasticity: elasticityVal = 0.5, facetElasticity: facetElasticityVal = 0.3, - minStep: minStepVal = 6, + minStep: minStepVal = DEFAULT_MIN_STEP, minSubplotSize: minSubplotVal = 60, stepPadding: stepPaddingVal = 0.1, bandStepFit: bandStepFitVal = 0, @@ -1436,7 +1439,7 @@ export function computeChannelBudgets( options: AssembleOptions, ): ChannelBudgets { const { - minStep: minStepVal = 6, + minStep: minStepVal = DEFAULT_MIN_STEP, stepPadding: stepPaddingVal = 0.1, maxColorValues: maxColorVal = 24, } = options; @@ -1592,7 +1595,7 @@ export function computeFacetGrid( const fixW = options.facetFixedPadding?.width ?? 0; const fixH = options.facetFixedPadding?.height ?? 0; const gap = options.facetGap ?? 0; - const minStep = options.minStep ?? 6; + const minStep = options.minStep ?? DEFAULT_MIN_STEP; const stepPadding = options.stepPadding ?? 0.1; const baseMinSubplot = options.minSubplotSize ?? 60; @@ -1888,7 +1891,7 @@ export function computeMinSubplotDimensions( data: any[], options: { minStep?: number; minSubplotSize?: number }, ): { minSubplotWidth: number; minSubplotHeight: number } { - const minStep = options.minStep ?? 6; + const minStep = options.minStep ?? DEFAULT_MIN_STEP; const minSubplot = options.minSubplotSize ?? 60; let minSubplotWidth = minSubplot; diff --git a/packages/flint-js/src/core/filter-overflow.ts b/packages/flint-js/src/core/filter-overflow.ts index 39cd07b1..065678c8 100644 --- a/packages/flint-js/src/core/filter-overflow.ts +++ b/packages/flint-js/src/core/filter-overflow.ts @@ -79,6 +79,7 @@ export function filterOverflow( }; const truncations: TruncationWarning[] = []; const warnings: ChartWarning[] = []; + const viewports: OverflowResult['viewports'] = []; let filteredData = data; // Compute group nominal count @@ -137,7 +138,22 @@ export function filterOverflow( nominalCounts[channel] = Math.min(uniqueValues.length, maxToKeep); if (uniqueValues.length > maxToKeep) { - const valuesToKeep = strategy(channel, fieldName, uniqueValues, maxToKeep, strategyContext); + const orderedValues = strategy === defaultOverflowStrategy + ? defaultOverflowOrder(channel, fieldName, uniqueValues, strategyContext) + : undefined; + const valuesToKeep = orderedValues + ? orderedValues.slice(0, maxToKeep) + : strategy(channel, fieldName, uniqueValues, maxToKeep, strategyContext); + + if ((channel === 'x' || channel === 'y') && orderedValues) { + viewports.push({ + channel, + field: fieldName, + orderedValues, + visibleCount: valuesToKeep.length, + totalCount: orderedValues.length, + }); + } const omittedCount = uniqueValues.length - valuesToKeep.length; const placeholder = `...${omittedCount} items omitted`; @@ -168,7 +184,34 @@ export function filterOverflow( } } - return { filteredData, nominalCounts, truncations, warnings }; + return { filteredData, nominalCounts, truncations, warnings, viewports }; +} + +/** Resolve a clamped category window for one viewport axis. */ +export function resolveCategoryViewport( + viewport: OverflowResult['viewports'][number], + requestedStart: number = 0, +): { start: number; end: number; values: any[] } { + const maxStart = Math.max(0, viewport.totalCount - viewport.visibleCount); + const start = Math.min(maxStart, Math.max(0, Math.floor(requestedStart))); + const end = Math.min(viewport.totalCount, start + viewport.visibleCount); + return { start, end, values: viewport.orderedValues.slice(start, end) }; +} + +/** + * Apply one or more host-controlled category windows to the original rows. + * A heatmap may provide both x and y starts; ordinary bar charts provide one. + */ +export function applyCategoryViewports( + data: any[], + viewports: OverflowResult['viewports'], + starts: Partial> = {}, +): any[] { + const windows = viewports.map((viewport) => ({ + field: viewport.field, + values: new Set(resolveCategoryViewport(viewport, starts[viewport.channel]).values), + })); + return data.filter((row) => windows.every((window) => window.values.has(row[window.field]))); } // --------------------------------------------------------------------------- @@ -185,7 +228,15 @@ export function filterOverflow( */ const defaultOverflowStrategy: OverflowStrategy = ( channel, fieldName, uniqueValues, maxToKeep, context, -) => { +) => defaultOverflowOrder(channel, fieldName, uniqueValues, context).slice(0, maxToKeep); + +/** Resolve the complete display order before a static or interactive window is applied. */ +function defaultOverflowOrder( + channel: string, + fieldName: string, + uniqueValues: any[], + context: OverflowStrategyContext, +): any[] { const { data, channelSemantics, encodings, allMarkTypes } = context; // Determine sort intent from user encodings @@ -211,7 +262,7 @@ const defaultOverflowStrategy: OverflowStrategy = ( const sortedList = JSON.parse(sortBy); if (Array.isArray(sortedList)) { const orderedValues = (sortOrder === 'descending') ? sortedList.reverse() : sortedList; - return orderedValues.filter((v: any) => uniqueValues.includes(v)).slice(0, maxToKeep); + return orderedValues.filter((v: any) => uniqueValues.includes(v)); } } catch { // not a JSON list, fall through @@ -243,7 +294,6 @@ const defaultOverflowStrategy: OverflowStrategy = ( return Array.from(valueAggregates.entries()) .map(([value, agg]) => ({ value, agg })) .sort((a, b) => isDescending ? b.agg - a.agg : a.agg - b.agg) - .slice(0, maxToKeep) .map(v => v.value); } @@ -253,29 +303,29 @@ const defaultOverflowStrategy: OverflowStrategy = ( const ordered = canonicalOrder.filter(value => present.has(value)); const canonicalValues = new Set(ordered); ordered.push(...uniqueValues.filter(value => !canonicalValues.has(value))); - return ordered.slice(0, maxToKeep); + return ordered; } // Match the display default for quantitative values treated as discrete. const fieldOriginalType = inferVisCategory(data.map(r => r[fieldName])); if (fieldOriginalType === 'quantitative' || channel === 'color') { return [...uniqueValues].sort((a, b) => Number(a) - Number(b)) - .slice(0, maxToKeep); + ; } // Facet channels: first N if (channel === 'column' || channel === 'row') { - return uniqueValues.slice(0, maxToKeep); + return uniqueValues; } // Explicit field-order sort follows the displayed label order. if (sortOrder === 'descending') { - return [...uniqueValues].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true })).slice(0, maxToKeep); + return [...uniqueValues].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true })); } if (sortOrder === 'ascending') { - return [...uniqueValues].sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true })).slice(0, maxToKeep); + return [...uniqueValues].sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true })); } // Default: first N values - return uniqueValues.slice(0, maxToKeep); -}; + return uniqueValues; +} diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 555a4299..3f3e7754 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -39,6 +39,7 @@ export { type OverflowStrategy, type OverflowStrategyContext, type OverflowResult, + type CategoryViewport, type ChannelBudgets, } from './types'; @@ -132,7 +133,7 @@ export { // Phase modules (analysis pipeline — VL-free) export { resolveChannelSemantics, convertTemporalData } from './resolve-semantics'; -export { filterOverflow } from './filter-overflow'; +export { filterOverflow, resolveCategoryViewport, applyCategoryViewports } from './filter-overflow'; export { computeLayout, computeChannelBudgets } from './compute-layout'; export { normalizeStaticSeries, diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 00b065b7..352cf8da 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -308,6 +308,20 @@ export interface ChannelBudgets { facetGrid?: FacetGridResult; } +/** A scrollable window over an ordered positional category domain. */ +export interface CategoryViewport { + /** Positional channel controlled by this viewport. */ + channel: 'x' | 'y'; + /** Source field whose values define the category domain. */ + field: string; + /** Complete display order, before the static fallback window is applied. */ + orderedValues: any[]; + /** Number of categories shown in an interactive window at the minimum valid step. */ + visibleCount: number; + /** Total number of categories in the ordered domain. */ + totalCount: number; +} + /** Result of overflow filtering. */ export interface OverflowResult { /** Data after removing overflow rows */ @@ -318,6 +332,8 @@ export interface OverflowResult { truncations: TruncationWarning[]; /** Warning messages for the UI */ warnings: ChartWarning[]; + /** Positional category windows that an interactive host can navigate. */ + viewports: CategoryViewport[]; } /** diff --git a/packages/flint-js/src/docs/design-stretch-model.md b/packages/flint-js/src/docs/design-stretch-model.md index 8ca851d1..b4227a7d 100644 --- a/packages/flint-js/src/docs/design-stretch-model.md +++ b/packages/flint-js/src/docs/design-stretch-model.md @@ -209,12 +209,12 @@ The layout balances two directions: | $L_{\max}$ | Maximum axis length | `width × maxStretch` | 800 px | | $N$ | Number of banded items | Field cardinality | data-dependent | | $\ell_0$ | Natural (base) size per band | `defaultBandSize` | ~20 px | -| $\ell_{\min}$ | Minimum size per band | `minStep` option | 6 px | +| $\ell_{\min}$ | Minimum size per band | `minStep` option | 8 px | | $\ell_{\max}$ | Maximum size per band | `maxBandSize` option | = $\ell_0$ | | $\alpha$ | Elasticity exponent | `elasticity` option | 0.5 | | $\beta$ | Maximum stretch multiplier | `maxStretch` option | 1.5 | -> **Code defaults:** `ElasticStretchParams` in `core/decisions.ts` — `elasticity: 0.5`, `maxStretch: 1.5`, `minStep: 6`. $\ell_0$ (`defaultBandSize`) and $\ell_{\max}$ (`maxBandSize`) are given at a 300px reference and scaled with size: `round(bandSize × max(1, sizeRatio))`. +> **Code defaults:** `ElasticStretchParams` in `core/decisions.ts` — `elasticity: 0.5`, `maxStretch: 1.5`, `minStep: 8`. $\ell_0$ (`defaultBandSize`) and $\ell_{\max}$ (`maxBandSize`) are given at a 300px reference and scaled with size: `round(bandSize × max(1, sizeRatio))`. ### §1.2.1 Band size bounds — min, base, max @@ -336,7 +336,7 @@ Grouped items (e.g., grouped bar with $m$ sub-bars per group) are treated as a s | Parameter | Simple discrete | Grouped bar ($m$ sub-bars) | |---|---|---| | $\ell_0$ (natural) | `defaultStepSize` | $m \times$ `defaultStepSize` | -| $\ell_{\min}$ (solid) | `minStep` (6 px) | $2m$ px (2 px per sub-bar) | +| $\ell_{\min}$ (solid) | `minStep` (8 px) | $2m$ px (2 px per sub-bar) | | $N$ (item count) | Field cardinality | Number of **groups** | The elastic budget formula is unchanged — only the parameter values change. @@ -444,7 +444,7 @@ The minimum subplot size ($S_{\min}$) is axis-aware: |---|---|---| | $N$ | Number of discrete items | data-dependent | | $\ell_0$ | Natural step size | ~20 px | -| $\ell_{\min}$ | Minimum step size | 6 px | +| $\ell_{\min}$ | Minimum step size | 8 px | | $\alpha$ | Elasticity exponent | 0.5 | | $\beta$ | Maximum stretch | 2.0 | diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index fa67bd77..d6978aba 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -538,6 +538,9 @@ export function assembleECharts(input: ChartAssemblyInput): any { if (warnings.length > 0) { ecOption._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + ecOption._viewports = overflowResult.viewports; + } // Store data reference (unlike VL which embeds data.values, // ECharts data is embedded directly in series[].data) diff --git a/packages/flint-js/src/echarts/interactive.ts b/packages/flint-js/src/echarts/interactive.ts new file mode 100644 index 00000000..b298707b --- /dev/null +++ b/packages/flint-js/src/echarts/interactive.ts @@ -0,0 +1,82 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assembleECharts } from './assemble'; +import * as echarts from 'echarts'; + +export interface EChartsInteractiveRendererOptions { + renderer?: 'canvas' | 'svg'; +} + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +export function createEChartsInteractiveRenderer( + options: EChartsInteractiveRendererOptions = {}, +): InteractiveRendererAdapter { + return { + async mount(container, input) { + const plannedOption = assembleECharts(input) as any; + const viewports = (plannedOption._viewports ?? []) as CategoryViewport[]; + const initialOption = viewports.length > 0 + ? assembleECharts(windowedInput(input, viewports, {})) as any + : plannedOption; + const chart = echarts.init(container, undefined, { + renderer: options.renderer ?? 'canvas', + width: initialOption._width, + height: initialOption._height, + }); + chart.setOption(initialOption, { notMerge: true }); + + let destroyed = false; + let updateTimer: number | undefined; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || updateTimer !== undefined) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const option = assembleECharts(windowedInput(input, viewports, latestStarts)); + chart.setOption(option, { notMerge: true }); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const grid = (chart as any).getModel().getComponent('grid'); + const rect = grid?.coordinateSystem?.getRect?.(); + if (!rect) return undefined; + return channel === 'x' + ? { offset: rect.x, extent: rect.width } + : { offset: rect.y, extent: rect.height }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + schedule(); + }, + resize(size) { + chart.resize(size); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + chart.dispose(); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/echarts/templates/heatmap.ts b/packages/flint-js/src/echarts/templates/heatmap.ts index 5dcaf9b6..b6783022 100644 --- a/packages/flint-js/src/echarts/templates/heatmap.ts +++ b/packages/flint-js/src/echarts/templates/heatmap.ts @@ -59,7 +59,7 @@ export const ecHeatmapDef: ChartTemplateDef = { declareLayoutMode: () => ({ axisFlags: { x: { banded: true }, y: { banded: true } }, // No paramOverrides needed — uses the backend default band size - // (defaultBandSize=20, minStep=6), matching VL heatmap sizing. + // (defaultBandSize=20, minStep=8), matching VL heatmap sizing. }), instantiate: (spec, ctx) => { const { channelSemantics, table, colorDecisions, encodings } = ctx; diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts new file mode 100644 index 00000000..67c12789 --- /dev/null +++ b/packages/flint-js/src/interactive/index.ts @@ -0,0 +1,79 @@ +import type { ChartAssemblyInput } from '../core/types'; +import { mountInteractiveChartSurface } from './surface'; +import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types'; + +export type { + BuildInteractiveChartOptions, + InteractiveBackend, + InteractiveChartSurface, + InteractiveChartSurfaceOptions, + InteractiveRenderer, + InteractiveRendererAdapter, + ViewportChannel, + ViewportGeometry, + ViewportState, +} from './types'; +export { clampViewportStart, mountInteractiveChartSurface } from './surface'; + +export function buildInteractiveChart( + container: HTMLElement, + input: ChartAssemblyInput, + options: BuildInteractiveChartOptions, +): InteractiveChartSurface { + const { backend, renderer, focusOnClick, expressionInterpreter, background, className, ariaLabel } = options; + switch (backend) { + case 'vegalite': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createVegaInteractiveRenderer } = await import('../vegalite/interactive'); + return createVegaInteractiveRenderer({ + renderer, + focusOnClick, + expressionInterpreter, + background, + }).mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + case 'echarts': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createEChartsInteractiveRenderer } = await import('../echarts/interactive'); + return createEChartsInteractiveRenderer({ renderer }).mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + case 'chartjs': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createChartjsInteractiveRenderer } = await import('../chartjs/interactive'); + return createChartjsInteractiveRenderer().mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + case 'plotly': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createPlotlyInteractiveRenderer } = await import('../plotly/interactive'); + return createPlotlyInteractiveRenderer().mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + } +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/surface.ts b/packages/flint-js/src/interactive/surface.ts new file mode 100644 index 00000000..498a3cf2 --- /dev/null +++ b/packages/flint-js/src/interactive/surface.ts @@ -0,0 +1,252 @@ +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { + InteractiveChartSurface, + InteractiveChartSurfaceOptions, + InteractiveRenderer, + InteractiveRendererAdapter, + ViewportChannel, + ViewportState, +} from './types'; + +const RAIL_THICKNESS = 8; +const RAIL_GAP = 9; +const RAIL_TRACK_COLOR = 'rgba(31, 41, 55, 0.035)'; +const RAIL_THUMB_COLOR = 'rgba(31, 41, 55, 0.14)'; +const MIN_HORIZONTAL_RAIL_INSET = 8; +const MAX_HORIZONTAL_RAIL_INSET = 16; +const HORIZONTAL_RAIL_INSET_RATIO = 0.025; + +export function clampViewportStart(viewport: CategoryViewport, requestedStart: number): number { + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + return Math.min(max, Math.max(0, Math.floor(requestedStart))); +} + +function applyStyles(element: HTMLElement, styles: Partial): void { + Object.assign(element.style, styles); +} + +function createViewportRail( + viewport: CategoryViewport, + initialStart: number, + onChange: (start: number) => void, +): { element: HTMLElement; update(start: number): void; setGeometry(offset: number, extent: number): void } { + const vertical = viewport.channel === 'y'; + const rail = document.createElement('div'); + const track = document.createElement('span'); + const thumb = document.createElement('span'); + let start = clampViewportStart(viewport, initialStart); + let dragOffset = 0; + + rail.dataset.flintViewport = viewport.channel; + applyStyles(rail, vertical ? { + display: 'flex', flexDirection: 'column', alignItems: 'center', alignSelf: 'start', minHeight: '0', + } : { + display: 'block', justifySelf: 'start', width: '100%', maxWidth: '100%', minWidth: '0', + }); + track.tabIndex = 0; + track.setAttribute('role', 'scrollbar'); + track.setAttribute('aria-label', `Visible ${viewport.field} range`); + track.setAttribute('aria-orientation', vertical ? 'vertical' : 'horizontal'); + applyStyles(track, vertical ? { + position: 'relative', display: 'block', width: `${RAIL_THICKNESS}px`, flex: '1 1 auto', minHeight: '96px', overflow: 'hidden', + borderRadius: '4px', background: RAIL_TRACK_COLOR, cursor: 'ns-resize', touchAction: 'none', outline: 'none', + } : { + position: 'relative', display: 'block', width: '100%', height: `${RAIL_THICKNESS}px`, overflow: 'hidden', + borderRadius: '4px', background: RAIL_TRACK_COLOR, cursor: 'ew-resize', touchAction: 'none', outline: 'none', + }); + applyStyles(thumb, vertical ? { + position: 'absolute', left: '0', right: '0', borderRadius: '4px', background: RAIL_THUMB_COLOR, pointerEvents: 'none', + } : { + position: 'absolute', top: '0', bottom: '0', borderRadius: '4px', background: RAIL_THUMB_COLOR, pointerEvents: 'none', + }); + track.append(thumb); + rail.append(track); + + const update = (requestedStart: number): void => { + start = clampViewportStart(viewport, requestedStart); + const end = Math.min(viewport.totalCount, start + viewport.visibleCount); + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + const leading = viewport.totalCount > 0 ? start / viewport.totalCount * 100 : 0; + const size = viewport.totalCount > 0 ? viewport.visibleCount / viewport.totalCount * 100 : 100; + track.setAttribute('aria-valuemin', '0'); + track.setAttribute('aria-valuemax', String(max)); + track.setAttribute('aria-valuenow', String(start)); + track.setAttribute('aria-valuetext', `${start + 1} through ${end} of ${viewport.totalCount}`); + if (vertical) { + thumb.style.top = `${leading}%`; + thumb.style.height = `${size}%`; + } else { + thumb.style.left = `${leading}%`; + thumb.style.width = `${size}%`; + } + }; + + const updateFromPointer = (event: PointerEvent): void => { + const rect = track.getBoundingClientRect(); + const length = vertical ? rect.height : rect.width; + const thumbLength = length * viewport.visibleCount / viewport.totalCount; + const available = Math.max(1, length - thumbLength); + const pointer = vertical ? event.clientY - rect.top : event.clientX - rect.left; + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + const next = Math.round(Math.min(1, Math.max(0, (pointer - dragOffset) / available)) * max); + update(next); + onChange(next); + }; + + track.addEventListener('pointerdown', (event) => { + const rect = track.getBoundingClientRect(); + const length = vertical ? rect.height : rect.width; + const pointer = vertical ? event.clientY - rect.top : event.clientX - rect.left; + const thumbLeading = length * start / viewport.totalCount; + const thumbLength = length * viewport.visibleCount / viewport.totalCount; + dragOffset = event.target === thumb ? pointer - thumbLeading : thumbLength / 2; + track.setPointerCapture(event.pointerId); + updateFromPointer(event); + }); + track.addEventListener('pointermove', (event) => { + if (track.hasPointerCapture(event.pointerId)) updateFromPointer(event); + }); + const release = (event: PointerEvent): void => { + if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId); + }; + track.addEventListener('pointerup', release); + track.addEventListener('pointercancel', release); + track.addEventListener('keydown', (event) => { + const previous = vertical ? 'ArrowUp' : 'ArrowLeft'; + const next = vertical ? 'ArrowDown' : 'ArrowRight'; + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + let requested: number | undefined; + if (event.key === previous) requested = start - 1; + else if (event.key === next) requested = start + 1; + else if (event.key === 'PageUp') requested = start - viewport.visibleCount; + else if (event.key === 'PageDown') requested = start + viewport.visibleCount; + else if (event.key === 'Home') requested = 0; + else if (event.key === 'End') requested = max; + if (requested === undefined) return; + event.preventDefault(); + const clamped = Math.min(max, Math.max(0, requested)); + update(clamped); + onChange(clamped); + }); + update(start); + const setGeometry = (offset: number, extent: number): void => { + if (!Number.isFinite(offset) || !Number.isFinite(extent) || extent <= 0) return; + if (vertical) { + rail.style.marginTop = `${Math.max(0, Math.floor(offset))}px`; + rail.style.height = `${Math.floor(extent)}px`; + rail.style.maxHeight = '100%'; + } else { + const inset = Math.min( + MAX_HORIZONTAL_RAIL_INSET, + Math.max(MIN_HORIZONTAL_RAIL_INSET, Math.round(extent * HORIZONTAL_RAIL_INSET_RATIO)), + ); + rail.style.marginLeft = `${Math.max(0, Math.floor(offset + inset))}px`; + rail.style.width = `${Math.max(1, Math.floor(extent - inset * 2))}px`; + } + }; + return { element: rail, update, setGeometry }; +} + +function renderedChartExtent(chart: HTMLElement): { width: number; height: number } { + const bounds = Array.from(chart.children) + .map((element) => element.getBoundingClientRect()) + .filter((rect) => rect.width > 0 && rect.height > 0); + if (bounds.length === 0) { + const rect = chart.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + } + return { + width: Math.max(...bounds.map((rect) => rect.width)), + height: Math.max(...bounds.map((rect) => rect.height)), + }; +} + +export function mountInteractiveChartSurface( + container: HTMLElement, + input: ChartAssemblyInput, + adapter: InteractiveRendererAdapter, + options: InteractiveChartSurfaceOptions = {}, +): InteractiveChartSurface { + const root = document.createElement('div'); + const chart = document.createElement('div'); + const state: ViewportState = {}; + const rails = new Map>(); + let renderer: InteractiveRenderer | undefined; + let updateTimer: number | undefined; + let destroyed = false; + + root.className = options.className ?? 'flint-interactive-surface'; + root.setAttribute('role', 'figure'); + root.setAttribute('aria-label', options.ariaLabel ?? input.chart_spec.title ?? 'Interactive chart'); + applyStyles(root, { + display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'minmax(0, auto) auto', + alignItems: 'stretch', rowGap: '6px', minWidth: '0', maxWidth: '100%', marginInline: 'auto', + }); + chart.dataset.flintChart = ''; + applyStyles(chart, { gridColumn: '1', gridRow: '1', minWidth: '0', overflow: 'hidden' }); + root.append(chart); + container.replaceChildren(root); + + const scheduleRender = (): void => { + if (!renderer || updateTimer !== undefined || destroyed) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + void renderer?.setViewports({ ...state }); + }, 0); + }; + const setViewport = (channel: ViewportChannel, requestedStart: number): void => { + const viewport = renderer?.viewports.find((candidate) => candidate.channel === channel); + if (!viewport) return; + state[channel] = clampViewportStart(viewport, requestedStart); + rails.get(channel)?.update(state[channel] ?? 0); + scheduleRender(); + }; + + const ready = adapter.mount(chart, input).then((mounted) => { + if (destroyed) { + mounted.destroy(); + return; + } + renderer = mounted; + for (const viewport of mounted.viewports) { + state[viewport.channel] = 0; + const rail = createViewportRail(viewport, 0, (start) => setViewport(viewport.channel, start)); + rails.set(viewport.channel, rail); + if (viewport.channel === 'x') { + rail.element.style.gridColumn = '1'; + rail.element.style.gridRow = '2'; + } else { + rail.element.style.gridColumn = '2'; + rail.element.style.gridRow = '1'; + root.style.gridTemplateColumns = `minmax(0, 1fr) ${RAIL_THICKNESS}px`; + root.style.columnGap = `${RAIL_GAP}px`; + } + root.append(rail.element); + } + const syncRailExtents = (): void => { + const extent = renderedChartExtent(chart); + const xGeometry = renderer?.getViewportGeometry?.('x'); + const yGeometry = renderer?.getViewportGeometry?.('y'); + rails.get('x')?.setGeometry(xGeometry?.offset ?? 0, xGeometry?.extent ?? extent.width); + rails.get('y')?.setGeometry(yGeometry?.offset ?? 0, yGeometry?.extent ?? extent.height); + const verticalRailGutter = rails.has('y') ? RAIL_THICKNESS + RAIL_GAP : 0; + root.style.width = `${Math.ceil(extent.width + verticalRailGutter)}px`; + }; + syncRailExtents(); + window.setTimeout(syncRailExtents, 0); + }); + + return { + element: root, + ready, + getViewportState: () => ({ ...state }), + setViewport, + destroy: () => { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + renderer?.destroy(); + container.replaceChildren(); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts new file mode 100644 index 00000000..a06b6b34 --- /dev/null +++ b/packages/flint-js/src/interactive/types.ts @@ -0,0 +1,45 @@ +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; + +export type ViewportChannel = 'x' | 'y'; +export type ViewportState = Partial>; + +export interface ViewportGeometry { + offset: number; + extent: number; +} + +export interface InteractiveRenderer { + viewports: CategoryViewport[]; + setViewports(starts: ViewportState): void | Promise; + getViewportGeometry?(channel: ViewportChannel): ViewportGeometry | undefined; + resize?(size: { width: number; height: number }): void | Promise; + destroy(): void; +} + +export interface InteractiveRendererAdapter { + mount(container: HTMLElement, input: ChartAssemblyInput): Promise; +} + +export interface InteractiveChartSurfaceOptions { + className?: string; + ariaLabel?: string; +} + +export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; + +export interface BuildInteractiveChartOptions extends InteractiveChartSurfaceOptions { + backend: InteractiveBackend; + renderer?: 'canvas' | 'svg'; + /** Enable local click focus where supported. Defaults to true for Vega-Lite. */ + focusOnClick?: boolean; + expressionInterpreter?: unknown; + background?: string; +} + +export interface InteractiveChartSurface { + readonly element: HTMLElement; + readonly ready: Promise; + getViewportState(): ViewportState; + setViewport(channel: ViewportChannel, start: number): void; + destroy(): void; +} \ No newline at end of file diff --git a/packages/flint-js/src/plotly/assemble.ts b/packages/flint-js/src/plotly/assemble.ts index 8b338b07..6cf3e7e0 100644 --- a/packages/flint-js/src/plotly/assemble.ts +++ b/packages/flint-js/src/plotly/assemble.ts @@ -554,6 +554,9 @@ export function assemblePlotly(input: ChartAssemblyInput): any { if (warnings.length > 0) { figure._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + figure._viewports = overflowResult.viewports; + } figure._dataLength = values.length; diff --git a/packages/flint-js/src/plotly/interactive.ts b/packages/flint-js/src/plotly/interactive.ts new file mode 100644 index 00000000..fff13080 --- /dev/null +++ b/packages/flint-js/src/plotly/interactive.ts @@ -0,0 +1,84 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assemblePlotly } from './assemble'; +import Plotly from 'plotly.js-dist-min'; + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +export function createPlotlyInteractiveRenderer(): InteractiveRendererAdapter { + return { + async mount(container, input) { + const plannedFigure = assemblePlotly(input) as any; + const viewports = (plannedFigure._viewports ?? []) as CategoryViewport[]; + const initialFigure = viewports.length > 0 + ? assemblePlotly(windowedInput(input, viewports, {})) as any + : plannedFigure; + await Plotly.newPlot(container, initialFigure.data ?? [], initialFigure.layout ?? {}, { + displayModeBar: false, + responsive: false, + }); + + let destroyed = false; + let running = false; + let updateTimer: number | undefined; + let requestedVersion = 0; + let appliedVersion = 0; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || running || updateTimer !== undefined) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const version = requestedVersion; + const figure = assemblePlotly(windowedInput(input, viewports, latestStarts)); + running = true; + void Plotly.react(container, figure.data ?? [], figure.layout ?? {}, { + displayModeBar: false, + responsive: false, + }).finally(() => { + running = false; + appliedVersion = version; + if (requestedVersion !== appliedVersion) schedule(); + }); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const axis = (container as any)._fullLayout?.[`${channel}axis`]; + if (!axis || !Number.isFinite(axis._offset) || !Number.isFinite(axis._length)) return undefined; + return { offset: axis._offset, extent: axis._length }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + requestedVersion += 1; + schedule(); + }, + resize() { + void Plotly.Plots.resize(container); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + Plotly.purge(container); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/plotly/plotly-js-dist-min.d.ts b/packages/flint-js/src/plotly/plotly-js-dist-min.d.ts new file mode 100644 index 00000000..748f0073 --- /dev/null +++ b/packages/flint-js/src/plotly/plotly-js-dist-min.d.ts @@ -0,0 +1,4 @@ +declare module 'plotly.js-dist-min' { + const Plotly: any; + export default Plotly; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 2ee4f14f..5bcdddf9 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -874,6 +874,9 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (warnings.length > 0) { result._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + result._viewports = overflowResult.viewports; + } result._width = layoutResult.subplotWidth; result._height = layoutResult.subplotHeight; // Annotated option catalog: every configurable property this template diff --git a/packages/flint-js/src/vegalite/interactive-focus.ts b/packages/flint-js/src/vegalite/interactive-focus.ts new file mode 100644 index 00000000..aff589e4 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactive-focus.ts @@ -0,0 +1,136 @@ +const FOCUS_PARAM = '__flint_focus'; +const FOCUS_KEY = '__flint_focus_key'; +const CLEAR_MARK = '__flint_focus_clear'; +const DIMMED_OPACITY = 0.3; +const FOCUSABLE_MARKS = new Set(['bar', 'arc', 'point', 'circle', 'square', 'rect']); + +function markType(mark: unknown): string | undefined { + return typeof mark === 'string' + ? mark + : typeof mark === 'object' && mark !== null + ? (mark as Record).type as string | undefined + : undefined; +} + +function selectionField(encoding: Record | undefined): string | undefined { + if (!encoding) return undefined; + for (const channel of ['x', 'y', 'color']) { + const definition = encoding[channel]; + if ( + definition + && typeof definition === 'object' + && (definition.type === 'nominal' || definition.type === 'ordinal') + && typeof definition.field === 'string' + ) { + return definition.field; + } + } + return undefined; +} + +function focusParam(): Record { + return { + name: FOCUS_PARAM, + select: { + type: 'point', + fields: [FOCUS_KEY], + toggle: 'event.shiftKey || event.ctrlKey || event.metaKey', + clear: { type: 'click', markname: CLEAR_MARK }, + }, + }; +} + +function focusTransform(field: string): Record { + return { calculate: `datum[${JSON.stringify(field)}]`, as: FOCUS_KEY }; +} + +function focusOpacity(restOpacity: number): Record { + return { + condition: { param: FOCUS_PARAM, value: restOpacity }, + value: Math.min(DIMMED_OPACITY, restOpacity), + }; +} + +function withFocusDetail(encoding: Record): Record { + const focusDetail = { field: FOCUS_KEY, type: 'nominal' }; + const existing = encoding.detail; + return { + ...encoding, + detail: existing == null + ? focusDetail + : [...(Array.isArray(existing) ? existing : [existing]), focusDetail], + }; +} + +export function withoutInteractiveFocusField(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const filtered = { ...(value as Record) }; + delete filtered[FOCUS_KEY]; + return filtered; +} + +function focusableEncoding(encoding: Record | undefined): boolean { + return !!encoding && !encoding.opacity && !encoding.fillOpacity && !encoding.strokeOpacity; +} + +function markWithoutOpacity(mark: unknown): { mark: unknown; opacity: number } { + if (!mark || typeof mark !== 'object' || typeof (mark as Record).opacity !== 'number') { + return { mark, opacity: 1 }; + } + const copy = { ...(mark as Record) }; + const opacity = copy.opacity as number; + delete copy.opacity; + return { mark: copy, opacity }; +} + +export function addInteractiveFocus(spec: Record): boolean { + if (Array.isArray(spec.params) && spec.params.length > 0) return false; + + const unitType = markType(spec.mark); + if (unitType && FOCUSABLE_MARKS.has(unitType) && focusableEncoding(spec.encoding)) { + const field = selectionField(spec.encoding); + if (!field) return false; + const resolvedMark = markWithoutOpacity(spec.mark); + spec.mark = resolvedMark.mark; + spec.transform = [...(Array.isArray(spec.transform) ? spec.transform : []), focusTransform(field)]; + spec.params = [focusParam()]; + spec.encoding = withFocusDetail({ ...spec.encoding, opacity: focusOpacity(resolvedMark.opacity) }); + return true; + } + + if (!Array.isArray(spec.layer)) return false; + const topEncoding = spec.encoding ?? {}; + for (const layer of spec.layer) { + const layerType = markType(layer?.mark); + const encoding = { ...topEncoding, ...(layer?.encoding ?? {}) }; + if (!layerType || !FOCUSABLE_MARKS.has(layerType) || !focusableEncoding(encoding)) continue; + if (Array.isArray(layer.params) && layer.params.length > 0) continue; + const field = selectionField(encoding); + if (!field) continue; + const resolvedMark = markWithoutOpacity(layer.mark); + layer.mark = resolvedMark.mark; + layer.params = [focusParam()]; + layer.encoding = withFocusDetail({ ...(layer.encoding ?? {}), opacity: focusOpacity(resolvedMark.opacity) }); + spec.transform = [...(Array.isArray(spec.transform) ? spec.transform : []), focusTransform(field)]; + return true; + } + return false; +} + +export function injectFocusClearMark(vegaSpec: Record): void { + if (!Array.isArray(vegaSpec.marks)) return; + vegaSpec.marks.unshift({ + type: 'rect', + name: CLEAR_MARK, + encode: { + enter: { + x: { value: 0 }, + x2: { signal: 'width' }, + y: { value: 0 }, + y2: { signal: 'height' }, + opacity: { value: 0 }, + tooltip: { value: null }, + }, + }, + }); +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts new file mode 100644 index 00000000..f5366346 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -0,0 +1,133 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assembleVegaLite } from './assemble'; +import { addInteractiveFocus, injectFocusClearMark, withoutInteractiveFocusField } from './interactive-focus'; +import { compile } from 'vega-lite'; +import { Error as VegaError, parse, View } from 'vega'; +import { Handler } from 'vega-tooltip'; + +export interface VegaInteractiveRendererOptions { + renderer?: 'canvas' | 'svg'; + focusOnClick?: boolean; + expressionInterpreter?: unknown; + background?: string; +} + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +function applyViewportSorts(node: unknown, viewports: CategoryViewport[]): void { + if (!node || typeof node !== 'object') return; + const record = node as Record; + for (const viewport of viewports) { + const encoding = record.encoding?.[viewport.channel]; + if (encoding?.field === viewport.field) encoding.sort = viewport.orderedValues; + } + for (const value of Object.values(record)) applyViewportSorts(value, viewports); +} + +export function createVegaInteractiveRenderer( + options: VegaInteractiveRendererOptions = {}, +): InteractiveRendererAdapter { + return { + async mount(container, input) { + const interactiveInput: ChartAssemblyInput = { + ...input, + options: { + ...input.options, + addTooltips: input.options?.addTooltips ?? true, + }, + }; + const assembled = assembleVegaLite(interactiveInput) as any; + const viewports = (assembled._viewports ?? []) as CategoryViewport[]; + const firstInput = windowedInput(interactiveInput, viewports, {}); + const vlSpec = assembleVegaLite(firstInput) as any; + applyViewportSorts(vlSpec, viewports); + const hasFocus = options.focusOnClick !== false && addInteractiveFocus(vlSpec); + const vegaSpec = compile(vlSpec).spec as any; + if (hasFocus) injectFocusClearMark(vegaSpec); + const source = vegaSpec.data?.find((entry: any) => Array.isArray(entry.values))?.name as string | undefined; + if (viewports.length > 0 && !source) { + throw new Error('Compiled chart has no mutable inline data source.'); + } + const view = new View( + parse(vegaSpec, { background: options.background } as any, { ast: true } as any), + { + renderer: options.renderer ?? 'canvas', + container, + ...(options.expressionInterpreter ? { expr: options.expressionInterpreter } : {}), + } as any, + ); + view.logLevel(VegaError); + const tooltip = new Handler(); + view.tooltip((handler, event, item, value) => { + tooltip.call(handler, event, item, withoutInteractiveFocusField(value)); + }); + await view.runAsync(); + + let destroyed = false; + let running = false; + let updateTimer: number | undefined; + let requestedVersion = 0; + let appliedVersion = 0; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || running || updateTimer !== undefined || !source) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const version = requestedVersion; + const rows = applyCategoryViewports(interactiveInput.data.values ?? [], viewports, latestStarts); + running = true; + view.data(source, []); + void view + .runAsync() + .then(() => view.data(source, rows).runAsync()) + .finally(() => { + running = false; + appliedVersion = version; + if (requestedVersion !== appliedVersion) schedule(); + }); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const [left, top] = view.origin(); + return channel === 'x' + ? { offset: left, extent: view.width() } + : { offset: top, extent: view.height() }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + requestedVersion += 1; + schedule(); + }, + resize(size) { + view.width(size.width).height(size.height); + void view.runAsync(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + view.finalize(); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/tests/filter-overflow.test.ts b/packages/flint-js/tests/filter-overflow.test.ts index 4f828c61..21beda52 100644 --- a/packages/flint-js/tests/filter-overflow.test.ts +++ b/packages/flint-js/tests/filter-overflow.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, expect, it } from 'vitest'; -import { filterOverflow } from '../src/core/filter-overflow'; +import { applyCategoryViewports, filterOverflow, resolveCategoryViewport } from '../src/core/filter-overflow'; import type { ChannelSemantics, ChartEncoding } from '../src/core/types'; const budgets = { maxValues: { x: 3 } }; @@ -76,4 +76,43 @@ describe('overflow category selection', () => { { field: 'Category', sortBy: 'y', sortOrder: 'descending' }, )).toEqual(['Delta', 'Charlie', 'Bravo']); }); + + it('retains the complete ordered domain for an interactive viewport', () => { + const data = [ + { Category: 'Delta', Value: 100 }, + { Category: 'Alpha', Value: 1 }, + { Category: 'Charlie', Value: 80 }, + { Category: 'Bravo', Value: 50 }, + ]; + + const result = filterOverflow( + { + x: { field: 'Category', type: 'nominal', semanticAnnotation: annotation }, + y: { field: 'Value', type: 'quantitative', semanticAnnotation: { semanticType: 'Quantity' } }, + }, + { axisFlags: { x: { banded: true } } }, + { + x: { field: 'Category', sortBy: 'y', sortOrder: 'descending' }, + y: { field: 'Value' }, + }, + data, + budgets, + marks, + ); + + expect(result.viewports).toEqual([{ + channel: 'x', + field: 'Category', + orderedValues: ['Delta', 'Charlie', 'Bravo', 'Alpha'], + visibleCount: 3, + totalCount: 4, + }]); + expect(resolveCategoryViewport(result.viewports[0], 99)).toEqual({ + start: 1, + end: 4, + values: ['Charlie', 'Bravo', 'Alpha'], + }); + expect(applyCategoryViewports(data, result.viewports, { x: 1 })) + .toEqual([data[1], data[2], data[3]]); + }); }); diff --git a/packages/flint-js/tests/interactive-focus.test.ts b/packages/flint-js/tests/interactive-focus.test.ts new file mode 100644 index 00000000..581978a7 --- /dev/null +++ b/packages/flint-js/tests/interactive-focus.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + addInteractiveFocus, + injectFocusClearMark, + withoutInteractiveFocusField, +} from '../src/vegalite/interactive-focus'; + +describe('Vega-Lite interactive focus', () => { + it('adds point selection and dimming to a discrete unit mark', () => { + const spec: Record = { + mark: 'bar', + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + expect(addInteractiveFocus(spec)).toBe(true); + expect(spec.params[0].select).toMatchObject({ + type: 'point', + fields: ['__flint_focus_key'], + toggle: 'event.shiftKey || event.ctrlKey || event.metaKey', + }); + expect(spec.transform).toContainEqual({ + calculate: 'datum["category"]', + as: '__flint_focus_key', + }); + expect(spec.encoding.detail).toEqual({ field: '__flint_focus_key', type: 'nominal' }); + expect(spec.encoding.opacity).toEqual({ + condition: { param: '__flint_focus', value: 1 }, + value: 0.3, + }); + }); + + it('preserves authored selections and opacity encodings', () => { + const withParams: Record = { + mark: 'bar', + params: [{ name: 'authored', select: 'point' }], + encoding: { x: { field: 'category', type: 'nominal' } }, + }; + const withOpacity: Record = { + mark: 'bar', + encoding: { + x: { field: 'category', type: 'nominal' }, + opacity: { field: 'weight', type: 'quantitative' }, + }, + }; + + expect(addInteractiveFocus(withParams)).toBe(false); + expect(addInteractiveFocus(withOpacity)).toBe(false); + }); + + it('skips unsupported continuous marks', () => { + const spec: Record = { + mark: 'line', + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + expect(addInteractiveFocus(spec)).toBe(false); + expect(spec).not.toHaveProperty('params'); + }); + + it('adds focus to the first eligible layer', () => { + const spec: Record = { + encoding: { x: { field: 'category', type: 'nominal' } }, + layer: [ + { mark: 'line', encoding: { y: { field: 'value', type: 'quantitative' } } }, + { mark: 'point', encoding: { y: { field: 'value', type: 'quantitative' } } }, + ], + }; + + expect(addInteractiveFocus(spec)).toBe(true); + expect(spec.layer[0]).not.toHaveProperty('params'); + expect(spec.layer[1].params[0].name).toBe('__flint_focus'); + expect(spec.layer[1].encoding.detail).toEqual({ field: '__flint_focus_key', type: 'nominal' }); + }); + + it('injects a transparent clear catcher below compiled marks', () => { + const spec: Record = { marks: [{ type: 'rect', name: 'marks' }] }; + + injectFocusClearMark(spec); + + expect(spec.marks[0]).toMatchObject({ + type: 'rect', + name: '__flint_focus_clear', + encode: { enter: { opacity: { value: 0 } } }, + }); + }); + + it('removes the internal focus key from tooltip objects', () => { + expect(withoutInteractiveFocusField({ + category: 'A', + value: 10, + __flint_focus_key: 'A', + })).toEqual({ category: 'A', value: 10 }); + expect(withoutInteractiveFocusField('label')).toBe('label'); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/sizing-ceiling.test.ts b/packages/flint-js/tests/sizing-ceiling.test.ts index 5fdb6b66..bfc8b099 100644 --- a/packages/flint-js/tests/sizing-ceiling.test.ts +++ b/packages/flint-js/tests/sizing-ceiling.test.ts @@ -3,7 +3,13 @@ import { describe, it, expect } from 'vitest'; import { assembleVegaLite } from '../src'; -import { deriveStretchCaps, resolveStretchCaps, resolveBaseSize } from '../src/core/compute-layout'; +import { + computeChannelBudgets, + DEFAULT_MIN_STEP, + deriveStretchCaps, + resolveStretchCaps, + resolveBaseSize, +} from '../src/core/compute-layout'; import { computeAxisStep } from '../src/core/decisions'; /** @@ -19,6 +25,25 @@ import { computeAxisStep } from '../src/core/decisions'; const BASE = { width: 400, height: 320 }; +describe('minimum discrete step', () => { + it('uses an 8px default when computing overflow capacity', () => { + const data = Array.from({ length: 20 }, (_, index) => ({ category: `C${index}`, value: index })); + const budgets = computeChannelBudgets( + { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + } as never, + {}, + data, + { width: 80, height: 100 }, + { maxStretch: 1 }, + ); + + expect(DEFAULT_MIN_STEP).toBe(8); + expect(budgets.maxValues.x).toBe(10); + }); +}); + describe('bandStepFit (base pitch ↔ available span)', () => { const decision = (bandStepFit: number) => computeAxisStep(4, 0, 400, { elasticity: 0.5, diff --git a/packages/flint-js/tests/theme-plotly.test.ts b/packages/flint-js/tests/theme-plotly.test.ts index 8c97504e..f0b84274 100644 --- a/packages/flint-js/tests/theme-plotly.test.ts +++ b/packages/flint-js/tests/theme-plotly.test.ts @@ -246,7 +246,7 @@ describe('semantic geometry survives house styling', () => { ]); }); - it('thins labels on a dense categorical axis without dropping bars', () => { + it('thins labels in the visible dense window and retains the full viewport domain', () => { const values = Array.from({ length: 100 }, (_v, i) => ({ category: `Page ${i + 1}`, value: i + 1, @@ -261,8 +261,14 @@ describe('semantic geometry survives house styling', () => { }, theme_spec: theme(), } as any) as any; - expect(fig.data[0].x).toHaveLength(100); - expect(fig.layout.xaxis.tickvals.length).toBeLessThan(100); + expect(fig.data[0].x).toHaveLength(90); + expect(fig.layout.xaxis.tickvals.length).toBeLessThan(90); + expect(fig._viewports).toMatchObject([{ + channel: 'x', + visibleCount: 90, + totalCount: 100, + }]); + expect(fig._viewports[0].orderedValues).toHaveLength(100); }); it('factors color and dash into separate forecast legend dimensions', () => { diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts index 8d27d220..6b795ae6 100644 --- a/packages/flint-js/tsup.config.ts +++ b/packages/flint-js/tsup.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ 'plotly/index': 'src/plotly/index.ts', 'excel/index': 'src/excel/index.ts', 'image-charts/index': 'src/image-charts/index.ts', + 'interactive/index': 'src/interactive/index.ts', + 'vegalite/interactive': 'src/vegalite/interactive.ts', + 'echarts/interactive': 'src/echarts/interactive.ts', + 'chartjs/interactive': 'src/chartjs/interactive.ts', + 'plotly/interactive': 'src/plotly/interactive.ts', 'test-data/index': 'src/test-data/index.ts', 'gallery/index': 'src/gallery/index.ts', }, @@ -20,5 +25,8 @@ export default defineConfig({ splitting: false, treeshake: true, target: 'es2020', - external: ['vega', 'vega-lite', 'echarts', 'chart.js', 'plotly.js'], + external: [ + 'vega', 'vega-lite', 'vega-tooltip', 'echarts', 'chart.js', 'plotly.js', 'plotly.js-dist-min', + '../vegalite/interactive', '../echarts/interactive', '../chartjs/interactive', '../plotly/interactive', + ], }); diff --git a/packages/flint-py/flint/core/compute_layout.py b/packages/flint-py/flint/core/compute_layout.py index c510f02b..655f7ef0 100644 --- a/packages/flint-py/flint/core/compute_layout.py +++ b/packages/flint-py/flint/core/compute_layout.py @@ -16,6 +16,7 @@ from . import js_round +DEFAULT_MIN_STEP = 8 VL_SHORT_DISCRETE_CATEGORY_COUNT = 4 VL_SHORT_DISCRETE_LABEL_MAX_LEN = 8 @@ -190,7 +191,7 @@ def compute_layout( elasticity_val = options.get("elasticity", 0.5) max_stretch_x, max_stretch_y = resolve_stretch_caps(options) facet_elasticity_val = options.get("facetElasticity", 0.3) - min_step_val = options.get("minStep", 6) + min_step_val = options.get("minStep", DEFAULT_MIN_STEP) min_subplot_val = options.get("minSubplotSize", 60) step_padding_val = options.get("stepPadding", 0.1) maintain_continuous_axis_ratio = options.get("maintainContinuousAxisRatio", False) @@ -894,7 +895,7 @@ def compute_channel_budgets( options: dict[str, Any], ) -> dict[str, Any]: max_stretch_x, max_stretch_y = resolve_stretch_caps(options) - min_step_val = options.get("minStep", 6) + min_step_val = options.get("minStep", DEFAULT_MIN_STEP) step_padding_val = options.get("stepPadding", 0.1) max_color_val = options.get("maxColorValues", 24) @@ -996,7 +997,7 @@ def compute_facet_grid( fix_w = facet_fixed_padding.get("width", 0) fix_h = facet_fixed_padding.get("height", 0) gap = options.get("facetGap", 0) - min_step = options.get("minStep", 6) + min_step = options.get("minStep", DEFAULT_MIN_STEP) step_padding = options.get("stepPadding", 0.1) base_min_subplot = options.get("minSubplotSize", 60) @@ -1190,7 +1191,7 @@ def compute_min_subplot_dimensions( data: list[dict[str, Any]], options: dict[str, Any], ) -> dict[str, float]: - min_step = options.get("minStep", 6) + min_step = options.get("minStep", DEFAULT_MIN_STEP) min_subplot = options.get("minSubplotSize", 60) min_subplot_width = min_subplot diff --git a/packages/flint-py/flint/core/filter_overflow.py b/packages/flint-py/flint/core/filter_overflow.py index 5cb7e3b4..8ce82461 100644 --- a/packages/flint-py/flint/core/filter_overflow.py +++ b/packages/flint-py/flint/core/filter_overflow.py @@ -36,6 +36,7 @@ def is_discrete_type(t: Optional[str]) -> bool: nominal_counts: dict[str, int] = {"x": 0, "y": 0, "column": 0, "row": 0, "group": 0} truncations: list[dict[str, Any]] = [] warnings: list[dict[str, Any]] = [] + viewports: list[dict[str, Any]] = [] filtered_data = data group_cs = channel_semantics.get("group") @@ -83,7 +84,23 @@ def is_discrete_type(t: Optional[str]) -> bool: nominal_counts[channel] = int(min(len(unique_values), max_to_keep)) if len(unique_values) > max_to_keep: - values_to_keep = strategy(channel, field_name, unique_values, int(max_to_keep), strategy_context) + ordered_values = ( + strategy(channel, field_name, unique_values, len(unique_values), strategy_context) + if strategy is _default_overflow_strategy else None + ) + values_to_keep = ( + ordered_values[:int(max_to_keep)] if ordered_values is not None + else strategy(channel, field_name, unique_values, int(max_to_keep), strategy_context) + ) + + if channel in ("x", "y") and ordered_values is not None: + viewports.append({ + "channel": channel, + "field": field_name, + "orderedValues": ordered_values, + "visibleCount": len(values_to_keep), + "totalCount": len(ordered_values), + }) omitted_count = len(unique_values) - len(values_to_keep) placeholder = f"...{omitted_count} items omitted" @@ -111,9 +128,30 @@ def is_discrete_type(t: Optional[str]) -> bool: "nominalCounts": nominal_counts, "truncations": truncations, "warnings": warnings, + "viewports": viewports, } +def resolve_category_viewport(viewport: dict[str, Any], requested_start: int = 0) -> dict[str, Any]: + max_start = max(0, viewport["totalCount"] - viewport["visibleCount"]) + start = min(max_start, max(0, math.floor(requested_start))) + end = min(viewport["totalCount"], start + viewport["visibleCount"]) + return {"start": start, "end": end, "values": viewport["orderedValues"][start:end]} + + +def apply_category_viewports( + data: list[dict[str, Any]], + viewports: list[dict[str, Any]], + starts: Optional[dict[str, int]] = None, +) -> list[dict[str, Any]]: + starts = starts or {} + windows = [ + (viewport["field"], set(resolve_category_viewport(viewport, starts.get(viewport["channel"], 0))["values"])) + for viewport in viewports + ] + return [row for row in data if all(row.get(field) in values for field, values in windows)] + + def _js_sort_key(v: Any) -> str: """JS Array.prototype.sort() default coerces to string.""" if v is None: diff --git a/packages/flint-py/flint/vegalite/assemble.py b/packages/flint-py/flint/vegalite/assemble.py index 5a537f9c..da6b4a42 100644 --- a/packages/flint-py/flint/vegalite/assemble.py +++ b/packages/flint-py/flint/vegalite/assemble.py @@ -509,6 +509,8 @@ def _is_discrete_t(t): if len(warnings) > 0: result["_warnings"] = warnings + if len(overflow_result["viewports"]) > 0: + result["_viewports"] = overflow_result["viewports"] result["_width"] = layout_result["subplotWidth"] result["_height"] = layout_result["subplotHeight"] diff --git a/site/src/main.tsx b/site/src/main.tsx index e81d6d02..fff22776 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -23,6 +23,7 @@ import { ThemeLabR2 } from './playground/ThemeLabR2'; import { ThemeLabReal } from './playground/ThemeLabReal'; import { BandStretchingLab } from './playground/BandStretchingLab'; import { LabelExperimentLab } from './playground/LabelExperimentLab'; +import { OverflowViewportLab } from './playground/OverflowViewportLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; import { DebugGym } from './playground/DebugGym'; @@ -73,6 +74,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/site/src/playground/OverflowViewportLab.tsx b/site/src/playground/OverflowViewportLab.tsx new file mode 100644 index 00000000..ebca8a48 --- /dev/null +++ b/site/src/playground/OverflowViewportLab.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { buildInteractiveChart } from 'flint-chart/interactive'; +import { expressionInterpreter } from 'vega-interpreter'; +import { BACKENDS, getSupportedBackends, type PreviewBackend } from '../shared/supported-backends'; +import './overflow-viewport-lab.css'; + +const categoryRows = Array.from({ length: 160 }, (_, index) => ({ + Category: `Studio ${String(index + 1).padStart(2, '0')}`, + Gross: 18 + ((index * 47) % 83), +})); + +const heatmapRows = Array.from({ length: 90 }, (_, row) => + Array.from({ length: 130 }, (_, column) => ({ + Product: `Product ${String(row + 1).padStart(2, '0')}`, + Week: `W${String(column + 1).padStart(2, '0')}`, + Activity: 20 + ((row * 31 + column * 17) % 80), + })), +).flat(); + +const commonSizing = { + baseSize: { width: 430, height: 300 }, + canvasSize: { width: 620, height: 420 }, +}; + +const verticalInput: ChartAssemblyInput = { + semantic_types: { Category: 'Category', Gross: 'Currency' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'WW Gross by Studio', + ...commonSizing, + encodings: { x: 'Category', y: 'Gross' }, + }, + data: { values: categoryRows }, +}; + +const horizontalInput: ChartAssemblyInput = { + ...verticalInput, + chart_spec: { + ...verticalInput.chart_spec, + encodings: { x: 'Gross', y: 'Category' }, + }, +}; + +const heatmapInput: ChartAssemblyInput = { + semantic_types: { Product: 'Category', Week: 'Category', Activity: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + title: 'Product activity by week', + ...commonSizing, + encodings: { x: 'Week', y: 'Product', color: 'Activity' }, + }, + data: { values: heatmapRows }, +}; + +function InteractiveBackendSurface({ input, backend, renderer = 'canvas' }: { + input: ChartAssemblyInput; + backend: PreviewBackend; + renderer?: 'canvas' | 'svg'; +}) { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const surface = buildInteractiveChart( + container, + input, + { + backend, + renderer, + expressionInterpreter: backend === 'vegalite' ? expressionInterpreter : undefined, + ariaLabel: input.chart_spec.title, + }, + ); + void surface.ready.catch((error) => { + container.textContent = error instanceof Error ? error.message : String(error); + }); + return () => surface.destroy(); + }, [backend, input, renderer]); + + return
); diff --git a/site/src/playground/IndexChartStage.tsx b/site/src/playground/IndexChartStage.tsx new file mode 100644 index 00000000..5cd444a5 --- /dev/null +++ b/site/src/playground/IndexChartStage.tsx @@ -0,0 +1,263 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { scaleLinear, scaleUtc } from 'd3'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { + buildInteractiveChart, + inspectIndex, + type FlintInteractionEventDetail, + type InteractiveChartSurface, +} from 'flint-chart/interactive'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { INDEX_CHART_STOCKS } from '../data/index-chart-stocks'; +import { + deriveIndexChartState, + clampDateToPreparedDomain, + prepareIndexChartData, +} from './index-chart-model'; +import './index-chart-stage.css'; + +const VIEW_WIDTH = 900; +const VIEW_HEIGHT = 520; +const FALLBACK_PLOT_BOUNDS = { left: 63, right: 760, top: 26, bottom: 474 }; +const DATA_UPDATE_ID = 'index-chart-data'; +const INSPECT_INTERACTION_ID = 'index-chart-inspect'; +const PREPARED = prepareIndexChartData(INDEX_CHART_STOCKS); +const INITIAL_ACTIVE_DATE = new Date('2015-05-13T00:00:00Z'); + +function xScaleForBounds(bounds: { left: number; right: number }) { + return scaleUtc() + .domain([PREPARED.minDate, PREPARED.maxDate]) + .range([bounds.left, bounds.right]) + .clamp(true); +} + +function chartInput(rows: ReturnType['indexedRows']): ChartAssemblyInput { + return { + data: { values: rows }, + semantic_types: { + Date: 'Date', + Symbol: 'Category', + IndexedReturn: { + semanticType: 'Quantity', + intrinsicDomain: PREPARED.returnDomain, + }, + }, + field_display_names: { + IndexedReturn: 'Return vs. reference date', + Symbol: 'Ticker', + }, + theme_spec: { + extends: 'datawrapper', + legend: { + show: 'always', + placement: ['seriesEnd', 'right'], + }, + }, + options: { addTooltips: false }, + chart_spec: { + chartType: 'Line Chart', + title: 'Index chart (Flint + D3 reference)', + subtitle: 'Flint redraws the return lines; the host overlay supplies the movable reference cursor.', + encodings: { x: 'Date', y: 'IndexedReturn', color: 'Symbol' }, + baseSize: { width: VIEW_WIDTH, height: VIEW_HEIGHT }, + canvasSize: { width: VIEW_WIDTH, height: VIEW_HEIGHT }, + chartProperties: { + includeZero_y: true, + showPoints: false, + }, + }, + }; +} + +function measurePlotBounds(mount: HTMLDivElement) { + const frame = mount.querySelector('.mark-group.role-frame.root'); + if (!frame) return FALLBACK_PLOT_BOUNDS; + const background = [...frame.children] + .find((child): child is SVGGraphicsElement => child instanceof SVGGraphicsElement + && child.classList.contains('background')); + const box = (background ?? frame).getBBox(); + const lineBoxes = [...mount.querySelectorAll('g.mark-line.role-mark path')] + .map((path) => path.getBBox()) + .filter((candidate) => + Number.isFinite(candidate.x) + && Number.isFinite(candidate.width) + && candidate.width > 2); + const matrix = frame.getCTM(); + const scaleX = matrix?.a || 1; + const scaleY = matrix?.d || scaleX; + const plotX = lineBoxes.length > 0 ? Math.min(...lineBoxes.map((candidate) => candidate.x)) : box.x; + const plotRight = lineBoxes.length > 0 + ? Math.max(...lineBoxes.map((candidate) => candidate.x + candidate.width)) + : box.x + box.width; + const left = ((matrix?.e ?? 0) / scaleX) + plotX; + const top = ((matrix?.f ?? 0) / scaleY) + box.y; + const width = plotRight - plotX; + if (!Number.isFinite(width) || !Number.isFinite(box.height) || width < 2 || box.height < 2) { + return FALLBACK_PLOT_BOUNDS; + } + return { + left, + right: left + width, + top, + bottom: top + box.height, + }; +} + +function formatMonth(date: Date) { + return new Intl.DateTimeFormat('en', { month: 'short', year: 'numeric', timeZone: 'UTC' }).format(date); +} + +export function IndexChartStage() { + const initialState = useMemo(() => deriveIndexChartState(PREPARED, INITIAL_ACTIVE_DATE), []); + const [activeDate, setActiveDate] = useState(initialState.activeDate); + const derived = useMemo(() => deriveIndexChartState(PREPARED, activeDate), [activeDate]); + const [plotBounds, setPlotBounds] = useState(FALLBACK_PLOT_BOUNDS); + const [cursorX, setCursorX] = useState(() => xScaleForBounds(FALLBACK_PLOT_BOUNDS)(initialState.activeDate)); + const mountRef = useRef(null); + const surfaceRef = useRef(null); + const inspectInteraction = useMemo(() => inspectIndex({ + id: INSPECT_INTERACTION_ID, + axis: 'x', + show: 'all', + }), []); + const plotWidth = Math.max(1, plotBounds.right - plotBounds.left); + const plotXScale = useMemo(() => ( + scaleUtc() + .domain([PREPARED.minDate, PREPARED.maxDate]) + .range([0, plotWidth]) + .clamp(true) + ), [plotWidth]); + const plotBoundsRef = useRef(plotBounds); + const plotWidthRef = useRef(plotWidth); + const plotXScaleRef = useRef(plotXScale); + + useEffect(() => { + plotBoundsRef.current = plotBounds; + plotWidthRef.current = plotWidth; + plotXScaleRef.current = plotXScale; + }, [plotBounds, plotWidth, plotXScale]); + + const xScale = useMemo(() => xScaleForBounds(plotBounds), [plotBounds.left, plotBounds.right]); + + const yScale = useMemo(() => ( + scaleLinear() + .domain(PREPARED.returnDomain) + .range([plotBounds.bottom, plotBounds.top]) + ), [plotBounds.bottom, plotBounds.top]); + + const activeX = xScale(derived.activeDate); + const ruleX = Number.isFinite(cursorX) ? cursorX : activeX; + const baselineY = yScale(0); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return undefined; + + const handleInteraction = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail.interactionId !== INSPECT_INTERACTION_ID) return; + if (detail.event.phase === 'cancel') return; + + const plot = detail.event.geometry.plot; + if (plot?.kind === 'point') { + const currentBounds = plotBoundsRef.current; + const currentWidth = plotWidthRef.current; + const currentScale = plotXScaleRef.current; + const localX = Math.max(0, Math.min(currentWidth, plot.point.x)); + const nextCursorX = currentBounds.left + localX; + const nextDate = clampDateToPreparedDomain(PREPARED, currentScale.invert(localX)); + setCursorX(Math.max(currentBounds.left, Math.min(currentBounds.right, nextCursorX))); + setActiveDate((current) => (current.getTime() === nextDate.getTime() ? current : nextDate)); + } + }; + + mount.addEventListener('flint-interaction', handleInteraction); + + const surface = buildInteractiveChart(mount, chartInput(initialState.indexedRows), { + backend: 'vegalite', + renderer: 'svg', + interactions: [inspectInteraction], + ariaLabel: 'Index chart with a movable reference date', + chartId: 'index-chart-stage', + }); + surfaceRef.current = surface; + void surface.ready.then(() => { + if (!mount.isConnected) return; + setPlotBounds(measurePlotBounds(mount)); + }); + + return () => { + mount.removeEventListener('flint-interaction', handleInteraction); + surfaceRef.current = null; + surface.destroy(); + }; + }, [initialState.indexedRows, inspectInteraction]); + + useEffect(() => { + const surface = surfaceRef.current; + if (!surface) return undefined; + let cancelled = false; + + void surface.ready.then(async () => { + if (cancelled) return; + await surface.applyUpdate({ + id: DATA_UPDATE_ID, + ops: [{ + op: 'set-data', + source: 'main', + value: { rows: derived.indexedRows as unknown as Record[] }, + }], + }); + const mount = mountRef.current; + if (mount && mount.isConnected) setPlotBounds(measurePlotBounds(mount)); + }); + + return () => { + cancelled = true; + }; + }, [derived.indexedRows]); + + useEffect(() => { + setCursorX(activeX); + }, [activeX]); + return ( +
+
+ Continuous reference date + + Move across the chart to re-index every series against the current date. + +
+
+ +
+
+ + + + + {formatMonth(derived.activeDate)} + + +
+ +
+
+ Reference: {formatMonth(derived.activeDate)} +
+
+ ); +} diff --git a/site/src/playground/index-chart-model.ts b/site/src/playground/index-chart-model.ts new file mode 100644 index 00000000..310388da --- /dev/null +++ b/site/src/playground/index-chart-model.ts @@ -0,0 +1,197 @@ +import type { IndexChartStockRow } from '../data/index-chart-stocks'; + +export interface PreparedIndexPoint { + date: Date; + dateMs: number; + close: number; +} + +export interface PreparedIndexSeries { + symbol: IndexChartStockRow['Symbol']; + points: PreparedIndexPoint[]; +} + +export interface PreparedIndexChartData { + series: PreparedIndexSeries[]; + availableDates: Date[]; + minDate: Date; + maxDate: Date; + returnDomain: [number, number]; +} + +export interface IndexedReturnRow { + Symbol: IndexChartStockRow['Symbol']; + Date: string; + IndexedReturn: number; + Close: number; + ReferenceDate: string; + ReferenceClose: number; +} + +export interface BaselineResolution { + Symbol: IndexChartStockRow['Symbol']; + requestedDate: string; + resolvedDate: string; + referenceClose: number; +} + +export interface SeriesEndLabel { + Symbol: IndexChartStockRow['Symbol']; + Date: string; + IndexedReturn: number; +} + +export interface IndexChartState { + activeDate: Date; + indexedRows: IndexedReturnRow[]; + baselines: BaselineResolution[]; + endLabels: SeriesEndLabel[]; +} + +function toUtcDate(value: string | Date): Date { + if (value instanceof Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); + } + return new Date(`${value}T00:00:00Z`); +} + +function toIsoDate(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function clampDate(value: Date, min: Date, max: Date): Date { + const time = Math.min(Math.max(value.getTime(), min.getTime()), max.getTime()); + return new Date(time); +} + +function nearestPoint(points: readonly PreparedIndexPoint[], targetMs: number): PreparedIndexPoint { + if (points.length === 1) return points[0]; + let low = 0; + let high = points.length - 1; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (points[mid].dateMs < targetMs) { + low = mid + 1; + } else { + high = mid; + } + } + const right = points[low]; + const left = points[Math.max(0, low - 1)]; + return Math.abs(right.dateMs - targetMs) < Math.abs(left.dateMs - targetMs) ? right : left; +} + +function interpolatedClose(points: readonly PreparedIndexPoint[], targetMs: number): number { + if (points.length === 1) return points[0].close; + if (targetMs <= points[0].dateMs) return points[0].close; + if (targetMs >= points[points.length - 1].dateMs) return points[points.length - 1].close; + + let low = 0; + let high = points.length - 1; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (points[mid].dateMs < targetMs) { + low = mid + 1; + } else { + high = mid; + } + } + + const right = points[low]; + if (right.dateMs === targetMs) return right.close; + const left = points[Math.max(0, low - 1)]; + const span = right.dateMs - left.dateMs; + if (span <= 0) return right.close; + const t = (targetMs - left.dateMs) / span; + return left.close + ((right.close - left.close) * t); +} + +export function snapDateToAvailableDate(availableDates: readonly Date[], candidate: string | Date): Date { + const target = toUtcDate(candidate); + const available = availableDates.map((date) => ({ date, dateMs: date.getTime() })); + return nearestPoint(available.map(({ date, dateMs }) => ({ date, dateMs, close: 0 })), target.getTime()).date; +} + +export function clampDateToPreparedDomain( + prepared: Pick, + candidate: string | Date, +): Date { + return clampDate(toUtcDate(candidate), prepared.minDate, prepared.maxDate); +} + +export function prepareIndexChartData(rows: readonly IndexChartStockRow[]): PreparedIndexChartData { + const grouped = new Map(); + const allDates = new Map(); + let maxReturn = 0; + let minReturn = 0; + + for (const row of rows) { + const date = toUtcDate(row.Date); + const dateMs = date.getTime(); + allDates.set(dateMs, date); + const points = grouped.get(row.Symbol) ?? []; + points.push({ date, dateMs, close: row.Close }); + grouped.set(row.Symbol, points); + } + + const series = [...grouped.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([symbol, points]) => { + const sorted = [...points].sort((left, right) => left.dateMs - right.dateMs); + const closes = sorted.map((point) => point.close); + const minClose = Math.min(...closes); + const maxClose = Math.max(...closes); + maxReturn = Math.max(maxReturn, (maxClose / minClose) - 1); + minReturn = Math.min(minReturn, (minClose / maxClose) - 1); + return { symbol, points: sorted }; + }); + + const availableDates = [...allDates.entries()] + .sort(([left], [right]) => left - right) + .map(([, date]) => date); + const padding = Math.max(0.06, (maxReturn - minReturn) * 0.08); + + return { + series, + availableDates, + minDate: availableDates[0], + maxDate: availableDates[availableDates.length - 1], + returnDomain: [minReturn - padding, maxReturn + padding], + }; +} + +export function deriveIndexChartState( + prepared: PreparedIndexChartData, + requestedDate: string | Date, +): IndexChartState { + const activeDate = clampDateToPreparedDomain(prepared, requestedDate); + const activeMs = activeDate.getTime(); + const requestedDateIso = toIsoDate(activeDate); + const indexedRows: IndexedReturnRow[] = []; + const baselines: BaselineResolution[] = []; + const endLabels: SeriesEndLabel[] = []; + + for (const series of prepared.series) { + const referenceClose = interpolatedClose(series.points, activeMs); + baselines.push({ + Symbol: series.symbol, + requestedDate: requestedDateIso, + resolvedDate: requestedDateIso, + referenceClose, + }); + + const rows = series.points.map((point) => ({ + Symbol: series.symbol, + Date: toIsoDate(point.date), + IndexedReturn: (point.close / referenceClose) - 1, + Close: point.close, + ReferenceDate: requestedDateIso, + ReferenceClose: referenceClose, + })); + indexedRows.push(...rows); + const last = rows[rows.length - 1]; + endLabels.push({ Symbol: last.Symbol, Date: last.Date, IndexedReturn: last.IndexedReturn }); + } + + return { activeDate, indexedRows, baselines, endLabels }; +} diff --git a/site/src/playground/index-chart-stage.css b/site/src/playground/index-chart-stage.css new file mode 100644 index 00000000..22898214 --- /dev/null +++ b/site/src/playground/index-chart-stage.css @@ -0,0 +1,53 @@ +.index-chart-shell { + padding: 12px 12px 14px; +} + +.index-chart-stack { + position: relative; + width: 900px; + min-height: 520px; +} + +.index-chart-mount { + min-height: 520px; +} + +.index-chart-overlay { + position: absolute; + inset: 0; + z-index: 3; + width: 900px; + height: 520px; + overflow: visible; + pointer-events: none; +} + +.index-chart-baseline { + stroke: #cad2d8; + stroke-dasharray: 4 4; + stroke-width: 1px; +} + +.index-chart-rule { + stroke: #9a3f3f; + stroke-width: 1.4px; + stroke-dasharray: 3 2; +} + +.index-chart-badge rect { + fill: rgba(255, 255, 255, 0.94); + stroke: #aeb8bf; + stroke-width: 1px; +} + +.index-chart-badge text { + fill: #3a434a; + font-size: 10px; + font-weight: 700; +} + +.index-chart-footer { + display: flex; + justify-content: flex-start; + padding-top: 2px; +} From 9fd7f39d1b84995b981d32f24a9636bd40c2a4b1 Mon Sep 17 00:00:00 2001 From: lx9days <571768326@qq.com> Date: Sat, 5 Sep 2026 01:45:22 +0800 Subject: [PATCH 33/33] fix: preserve ranged histogram bars under Power BI Keep histogram bins square when a Power BI theme rounds bars so interactive ranged bars do not collapse to zero width in the click-focus playground. Add regressions for theme assembly and interactive rendering to lock the behavior in. --- packages/flint-js/src/vegalite/theme.ts | 8 +++++++ .../tests/semantic-interactions.test.ts | 23 +++++++++++++++++++ packages/flint-js/tests/theme-presets.test.ts | 17 ++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index d3626937..0549f7e3 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -2010,6 +2010,14 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string if (isLiteralMark(node)) return; const enc = node.encoding ?? {}; if (isGridCell(node, enc)) return; + if (enc.x2 != null || enc.y2 != null) { + // Ranged bars such as histogram bins are authored with a start + // and end position. Rounding only the value end can collapse + // them into zero-width paths once interactive instrumentation + // wraps the marks, so keep these bars square. + node.mark = { ...normalizeMark(node.mark), cornerRadiusEnd: 0 }; + return; + } const barW = estimateBarExtent(node, enc, table, plotWidth, plotHeight); const capped = Math.round(barW * MAX_CORNER_FRACTION * 10) / 10; if (capped >= m.cornerRadius!) return; diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index 3d9c10aa..11edb17c 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -327,6 +327,29 @@ describe('Vega-Lite semantic interactions', () => { view.finalize(); }); + it('keeps interactive Power BI histogram bins non-zero in width', async () => { + const spec = assembleVegaLite({ + data: { + values: [1.7, 1.9, 2.1, 2.4, 3.1, 3.4, 3.8, 4.2, 4.6].map((duration) => ({ + 'Duration (min)': duration, + })), + }, + semantic_types: { 'Duration (min)': 'Quantity' }, + chart_spec: { chartType: 'Histogram', encodings: { x: 'Duration (min)' } }, + theme_spec: 'powerbi', + } as never) as any; + const { compiled } = instrument(spec, [clickMark()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view) + .filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(bars.length).toBeGreaterThan(1); + for (const bar of bars) { + expect(bar.bounds.x2).toBeGreaterThan(bar.bounds.x1); + } + view.finalize(); + }); + it('inspect-x chooses one stacked category and returns all of its segments', async () => { const spec = assembleVegaLite({ data: { values: [ diff --git a/packages/flint-js/tests/theme-presets.test.ts b/packages/flint-js/tests/theme-presets.test.ts index 29394baa..e1752d69 100644 --- a/packages/flint-js/tests/theme-presets.test.ts +++ b/packages/flint-js/tests/theme-presets.test.ts @@ -317,6 +317,23 @@ describe('cartoon mark character', () => { expect(JSON.stringify(thin._theme?.report ?? [])).toContain('round the bar away'); }); + it('keeps ranged histogram bars square under Power BI', () => { + const spec = assembleVegaLite({ + data: { + values: [1.7, 1.9, 2.1, 2.4, 3.1, 3.4, 3.8, 4.2, 4.6].map((duration) => ({ + 'Duration (min)': duration, + })), + }, + semantic_types: { 'Duration (min)': 'Quantity' }, + chart_spec: { chartType: 'Histogram', encodings: { x: 'Duration (min)' } }, + theme_spec: THEME_PRESETS.powerbi.spec, + } as any) as any; + + const barMark = (spec.layer ?? [spec]).find((l: any) => markTypeOf(l.mark) === 'bar')?.mark; + expect(spec.config.bar.cornerRadiusEnd).toBe(3); + expect(barMark?.cornerRadiusEnd).toBe(0); + }); + it('keeps a crowded trajectory in the lab dot-to-line proportion', () => { const diameter = (size: number) => 2 * Math.sqrt(size / Math.PI); const ratioOf = (spec: any) => {
; +} + +function BackendPicker({ + value, + availableBackends, + onChange, +}: { + value: PreviewBackend; + availableBackends: PreviewBackend[]; + onChange: (value: PreviewBackend) => void; +}) { + return ( +
+ {availableBackends.map((backend) => ( + + ))} +
+ ); +} + +function GeneralViewportDemo({ + input, + initialBackend, + description, +}: { + input: ChartAssemblyInput; + initialBackend: PreviewBackend; + description: string; +}) { + const [backend, setBackend] = useState(initialBackend); + const availableBackends = getSupportedBackends(input.chart_spec.chartType); + + useEffect(() => { + if (!availableBackends.includes(backend)) { + setBackend(availableBackends[0] ?? 'vegalite'); + } + }, [availableBackends, backend]); + + return ( +
+
+
+

{input.chart_spec.title}

+

{description}

+
+ +
+
+ +
+
+ ); +} + +function McpViewportDemo() { + return ( +
+
+ Flint chart + Flint-owned interactive surface + Retained Vega renderer +
+
+ +
+
+ Theme Default + View retained while dragging +
+
+ ); +} + +export function OverflowViewportLab() { + return ( +
+
+

Overflow viewport lab

+

Category capacity becomes a navigable viewport only after the chart reaches its stretch ceiling and bands reach their normal minimum step. Static output still uses the first window; interactive hosts retain the complete ordered domain.

+
+ +
+ Retained MCP App path + One Vega compile; slider movement updates the existing dataflow. +
+ + +
+ General host path + The same core viewport plan drives every backend through ordinary assembly. +
+ + + +
+ ); +} \ No newline at end of file diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index a13e1ca1..dbdbc1c4 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -9,6 +9,7 @@ const pages: NavEntry[] = [ { to: 'illustrations', label: 'Illustrations' }, { to: 'mcp-ui', label: 'MCP UI test' }, { to: 'labs', label: 'Labs' }, + { to: 'overflow-viewport', label: 'Overflow viewport' }, { to: 'debug-gym', label: 'Debug gym' }, { to: 'demo-wall', label: 'Demo wall' }, { diff --git a/site/src/playground/overflow-viewport-lab.css b/site/src/playground/overflow-viewport-lab.css new file mode 100644 index 00000000..c0904593 --- /dev/null +++ b/site/src/playground/overflow-viewport-lab.css @@ -0,0 +1,308 @@ +.ov-page { + gap: 26px; +} + +.ov-heading p, +.ov-demo-header p { + margin: 7px 0 0; + color: #66707a; + font-size: 13px; + line-height: 1.5; +} + +.ov-section-heading, +.ov-demo, +.ov-mcp { + width: min(100%, 960px); + box-sizing: border-box; +} + +.ov-section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; + margin-top: 10px; + border-bottom: 1px solid #cfd5da; + padding-bottom: 7px; +} + +.ov-section-heading span { + font-size: 14px; + font-weight: 650; +} + +.ov-section-heading small { + color: #737d86; +} + +.ov-demo, +.ov-mcp { + border: 1px solid #d8dde2; + border-radius: 8px; + background: #fff; + overflow: hidden; +} + +.ov-demo { + padding: 18px; +} + +.ov-demo-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 14px; +} + +.ov-demo-header h2 { + margin: 0; + font-size: 15px; + letter-spacing: 0; +} + +.ov-backends { + display: inline-flex; + flex: 0 0 auto; + padding: 2px; + border-radius: 6px; + background: #eef1f3; +} + +.ov-backends button { + border: 0; + border-radius: 4px; + padding: 5px 8px; + color: #5d6670; + background: transparent; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.ov-backends button.active { + color: #1f2328; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.ov-stage-row { + display: flex; + align-items: stretch; + gap: 9px; + min-width: 0; +} + +.ov-stage-row-grid { + width: fit-content; + max-width: 100%; + margin-inline: auto; +} + +.ov-stage-row-grid .ov-chart-column { + flex: 0 1 auto; +} + +.ov-stage-row-grid .ov-stage { + min-height: 0; +} + +.ov-chart-column { + flex: 1 1 auto; + min-width: 0; +} + +.ov-stage { + display: grid; + place-items: center; + min-height: 330px; + overflow: auto; + border-top: 1px solid #edf0f2; + border-bottom: 1px solid #edf0f2; +} + +.ov-interactive-stage { + display: block; + padding: 12px; +} + +.ov-interactive-mount { + width: 100%; + max-width: 960px; + margin-inline: auto; +} + +.ov-interactive-mount [data-flint-chart] { + display: grid; + place-items: center; + min-height: 220px; +} + +.ov-rail { + color: #59636d; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.ov-rail-horizontal { + display: grid; + grid-template-columns: 105px minmax(120px, 1fr); + align-items: center; + gap: 10px; + min-height: 28px; + padding: 5px 2px 0; +} + +.ov-rail-vertical { + display: flex; + align-items: center; + flex-direction: column; + width: 34px; + padding: 7px 0; +} + +.ov-rail-vertical .ov-rail-label { + writing-mode: vertical-rl; + margin-bottom: 8px; +} + +.ov-window-track { + position: relative; + display: block; + overflow: hidden; + border-radius: 3px; + background: #dfe3e6; + touch-action: none; + cursor: ew-resize; +} + +.ov-window-track:focus-visible { + outline: 2px solid #118dff; + outline-offset: 3px; +} + +.ov-rail-horizontal .ov-window-track { + width: 100%; + height: 7px; +} + +.ov-rail-vertical .ov-window-track { + width: 7px; + flex: 1 1 auto; + min-height: 235px; + cursor: ns-resize; +} + +.ov-window-thumb { + position: absolute; + border-radius: 3px; + background: #4d5963; + pointer-events: none; +} + +.ov-rail-horizontal .ov-window-thumb { + top: 0; + bottom: 0; +} + +.ov-rail-vertical .ov-window-thumb { + left: 0; + right: 0; +} + +.ov-mcp-titlebar { + display: flex; + align-items: center; + gap: 12px; + min-height: 42px; + padding: 0 14px; + border-bottom: 1px solid #e2e5e8; + color: #636c75; + font-size: 11px; +} + +.ov-mcp-titlebar strong { + color: #1f2328; + font-size: 13px; +} + +.ov-live-status { + margin-left: auto; + color: #207047; + font-variant-numeric: tabular-nums; +} + +.ov-mcp-chart { + display: grid; + place-items: center; + min-width: 0; + padding: 12px; +} + +.ov-mcp-figure { + width: 100%; + max-width: 620px; + min-width: 0; +} + +.ov-live-chart { + display: grid; + place-items: center; + min-width: 0; + min-height: 220px; + overflow: auto; +} + +.ov-mcp-figure .ov-rail-horizontal { + padding-top: 4px; +} + +.ov-mcp-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 34px; + padding: 4px 12px; + background: #fff; +} + +.ov-option-chip, +.ov-option-note { + color: #68727c; + font-size: 10px; +} + +.ov-option-chip { + padding: 6px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.05); +} + +.ov-option-chip strong { + margin-left: 5px; + color: #252a2f; + font-weight: 550; +} + +@media (max-width: 720px) { + .ov-demo-header, + .ov-section-heading { + align-items: stretch; + flex-direction: column; + } + + .ov-backends { + align-self: flex-start; + flex-wrap: wrap; + } + + .ov-option-note { + display: none; + } + + .ov-live-chart { + min-width: 0; + } +} \ No newline at end of file diff --git a/site/src/types/plotly.d.ts b/site/src/types/plotly.d.ts index 5116476f..d3c4ff6c 100644 --- a/site/src/types/plotly.d.ts +++ b/site/src/types/plotly.d.ts @@ -1,6 +1,10 @@ declare module 'plotly.js-dist-min' { const Plotly: { newPlot: (el: HTMLElement, data: unknown[], layout?: unknown, config?: unknown) => Promise; + react: (el: HTMLElement, data: unknown[], layout?: unknown, config?: unknown) => Promise; + Plots: { + resize: (el: HTMLElement) => Promise | void; + }; purge: (el: HTMLElement) => void; }; export default Plotly; diff --git a/site/vite.config.ts b/site/vite.config.ts index a4f3ecf3..88f387cc 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ // NOTE: order matters — longer aliases must come first so 'flint-chart/test-data' // is matched before the bare 'flint-chart' substring alias. alias: [ + { + find: 'flint-chart/interactive', + replacement: path.resolve(__dirname, '../packages/flint-js/src/interactive/index.ts'), + }, { find: 'flint-chart/test-data', replacement: path.resolve(__dirname, '../packages/flint-js/src/test-data/index.ts'), From 82c9cb09df003657d5785ebfcb8a68c13f4daa48 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Mon, 24 Aug 2026 19:03:47 -0700 Subject: [PATCH 12/33] halfway --- package-lock.json | 472 ++++++++ .../src/core/interaction-semantics.ts | 97 ++ packages/flint-js/src/core/types.ts | 20 + packages/flint-js/src/interactive/README.md | 259 ++++ .../flint-js/src/interactive/chart-update.ts | 28 + .../src/interactive/emphasis-update.ts | 35 + packages/flint-js/src/interactive/index.ts | 62 +- .../flint-js/src/interactive/interactions.ts | 132 +++ .../src/interactive/presets/click-annotate.ts | 57 + .../presets/click-group-highlight.ts | 73 ++ .../interactive/presets/click-highlight.ts | 39 + .../flint-js/src/interactive/presets/index.ts | 4 + .../src/interactive/presets/select.ts | 27 + packages/flint-js/src/interactive/surface.ts | 17 + .../src/interactive/triggers/events.ts | 65 + .../src/interactive/triggers/index.ts | 47 + .../flint-js/src/interactive/triggers/vega.ts | 376 ++++++ packages/flint-js/src/interactive/types.ts | 9 +- packages/flint-js/src/vegalite/assemble.ts | 3 + .../src/vegalite/interactive-focus.ts | 5 +- packages/flint-js/src/vegalite/interactive.ts | 32 +- .../src/vegalite/semantic-interactions.ts | 1027 ++++++++++++++++ .../flint-js/src/vegalite/templates/area.ts | 29 + .../src/vegalite/templates/bar-table.ts | 28 + .../flint-js/src/vegalite/templates/bar.ts | 170 +++ .../src/vegalite/templates/candlestick.ts | 24 + .../vegalite/templates/connected-scatter.ts | 31 + .../flint-js/src/vegalite/templates/gantt.ts | 28 + .../flint-js/src/vegalite/templates/jitter.ts | 33 + .../flint-js/src/vegalite/templates/line.ts | 62 + .../src/vegalite/templates/lollipop.ts | 30 + .../flint-js/src/vegalite/templates/pie.ts | 26 + .../flint-js/src/vegalite/templates/rose.ts | 28 + .../src/vegalite/templates/scatter.ts | 96 ++ .../src/vegalite/templates/waterfall.ts | 42 + .../flint-js/tests/encoding-shorthand.test.ts | 7 +- packages/flint-js/tests/interactions.test.ts | 354 ++++++ .../flint-js/tests/interactive-focus.test.ts | 2 +- .../tests/semantic-interactions.test.ts | 1053 +++++++++++++++++ .../tests/stacked-bar-tooltip.test.ts | 51 + .../flint-js/tests/waterfall-titles.test.ts | 33 + site/package.json | 2 + site/src/components/VegaLiteView.tsx | 42 +- site/src/main.tsx | 8 + site/src/playground/ChartToExternalLab.tsx | 249 ++++ site/src/playground/ClickFocusLab.tsx | 271 +++++ site/src/playground/ExternalToChartLab.tsx | 308 +++++ site/src/playground/InteractionCandidates.tsx | 335 ++++++ site/src/playground/InteractionDemoChart.tsx | 55 + site/src/playground/PlaygroundShell.tsx | 13 +- site/src/playground/click-focus-lab.css | 185 +++ .../src/playground/interaction-candidates.css | 125 ++ site/src/playground/interaction-demo-data.ts | 189 +++ site/src/playground/interaction-transport.css | 511 ++++++++ 54 files changed, 7275 insertions(+), 31 deletions(-) create mode 100644 packages/flint-js/src/core/interaction-semantics.ts create mode 100644 packages/flint-js/src/interactive/README.md create mode 100644 packages/flint-js/src/interactive/chart-update.ts create mode 100644 packages/flint-js/src/interactive/emphasis-update.ts create mode 100644 packages/flint-js/src/interactive/interactions.ts create mode 100644 packages/flint-js/src/interactive/presets/click-annotate.ts create mode 100644 packages/flint-js/src/interactive/presets/click-group-highlight.ts create mode 100644 packages/flint-js/src/interactive/presets/click-highlight.ts create mode 100644 packages/flint-js/src/interactive/presets/index.ts create mode 100644 packages/flint-js/src/interactive/presets/select.ts create mode 100644 packages/flint-js/src/interactive/triggers/events.ts create mode 100644 packages/flint-js/src/interactive/triggers/index.ts create mode 100644 packages/flint-js/src/interactive/triggers/vega.ts create mode 100644 packages/flint-js/src/vegalite/semantic-interactions.ts create mode 100644 packages/flint-js/tests/interactions.test.ts create mode 100644 packages/flint-js/tests/semantic-interactions.test.ts create mode 100644 packages/flint-js/tests/stacked-bar-tooltip.test.ts create mode 100644 site/src/playground/ChartToExternalLab.tsx create mode 100644 site/src/playground/ClickFocusLab.tsx create mode 100644 site/src/playground/ExternalToChartLab.tsx create mode 100644 site/src/playground/InteractionCandidates.tsx create mode 100644 site/src/playground/InteractionDemoChart.tsx create mode 100644 site/src/playground/click-focus-lab.css create mode 100644 site/src/playground/interaction-candidates.css create mode 100644 site/src/playground/interaction-demo-data.ts create mode 100644 site/src/playground/interaction-transport.css diff --git a/package-lock.json b/package-lock.json index 75d0209b..2b187b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2582,6 +2582,290 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha1-1FUKhdCPSXj68KTDa4SMYeqsB+I=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha1-4CFRRk0C1KG0RkbQ/NuT+viP3ow=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha1-52DldluBiLHe+jK8i7YGL4Hkx5U=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha1-wvQ2KwRdRy4bGGzb7DKbpSva7mw=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha1-FwbKQM9+pZoK3Y9EVu//j4d1eT0=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha1-NoyWGhjech2oIA6AvzlD+1MTavI=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha1-mto/qcTQDjpQk/7QNWx6uSlgQjE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha1-GFwagMyAf92io/6WD3wRxKJ5UuE=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha1-7wBNihKARs/OQ00XGC+DTkTvlbI=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha1-sTq6iyRCtAaMmp5tHYL4vOp3/AI=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha1-CjUfmW3Jmzf0+li0ksLRwE49rBc=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha1-4o2xv7+mFwdvd3DdHZpI6qO2xRs=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha1-wEorTyMYGqN28wrwKD28eztWmYA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha1-bcj8bh81cE87BXCQvu63rGdL/xo=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha1-seRGVkTds/3zomP+uyQKbNYW3pA=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha1-nig68XlgHFSVgWALP+wllBkRMp0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha1-YCP7Oy1GMiny1oD5rEtHRm9x8Xs=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha1-QSuQ6EhwKF8v+KhGxutgNE8SpBw=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha1-9jKzgMOsoduo40qgSbzWpK8j34o=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha1-365UptNdGedqyVZbyzKo5UaTGJw=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha1-1HQLD+NbHFi2bhSI9OftApUvVw8=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha1-a9NoO4My/A8B5wWbdja8XH7eczc=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha1-V6L3ByQub+Hega17/Myq9gYXmvs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha1-3G1Pmpg3bxjqULrWw5U38bVGPDk=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha1-vXpF/AqMMWemMWdeYbwsorBY1KM=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha1-0VFsxQh1O+BoUs0GdY47tUoisOM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha1-hHL+7NY5aRRQ3YAA6zPt1EThMj8=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha1-1rwea2p9tpzM+73Uw0twYy2enbI=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha1-cLvad9wjqnJ0E+IuIUr6Pw6FL3A=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha1-ETa8V+nds8OQ3MybX/O30rjZRwY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha1-3Msy0cVrHhxuDxGA2ZSJbwOLxAs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/debug/-/debug-4.1.13.tgz", @@ -3739,6 +4023,47 @@ "integrity": "sha1-7EjA8+mT5QZIyG2lWeJhCZXPmJo=", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3/-/d3-7.9.0.tgz", + "integrity": "sha1-V556yz10nK+IYL0XQa6NNxBwzV0=", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-array/-/d3-array-3.2.4.tgz", @@ -3751,6 +4076,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha1-xCpKE+gTHWN7dF/Clzgkz+r5MyI=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha1-b3Z8Ttjct53n7ePhwPieY+9k0xw=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha1-0VbWH0hfzoMn5qvzOctB2Mu6aWY=", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-color/-/d3-color-3.1.0.tgz", @@ -3760,6 +4122,18 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha1-u5IGO8jFZjrLJCL5nHPLtsauO8w=", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-delaunay": { "version": "6.0.4", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-delaunay/-/d3-delaunay-6.0.4.tgz", @@ -3781,6 +4155,19 @@ "node": ">=12" } }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha1-mUqunNI8cZ9TteEOOgphCMaWB7o=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dsv": { "version": "3.0.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-dsv/-/d3-dsv-3.0.1.tgz", @@ -3827,6 +4214,27 @@ "node": ">=0.10.0" } }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha1-llisOKIUDVnTRhYPH2ww/aC9EvQ=", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha1-gxQb/5hWoO21443onNz+Y9CmCiI=", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-force": { "version": "3.0.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-force/-/d3-force-3.0.0.tgz", @@ -3922,6 +4330,15 @@ "node": ">=12" } }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha1-C0XT3RxIopyOBX5hNWk+yAvxY5g=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-quadtree": { "version": "3.0.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-quadtree/-/d3-quadtree-3.0.1.tgz", @@ -3931,6 +4348,15 @@ "node": ">=12" } }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha1-1JJjeNMz2cC/0eb6AZTTCuuqIPQ=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-scale/-/d3-scale-4.0.2.tgz", @@ -3960,6 +4386,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha1-wlM4IH76csxbm9FFihpBkB8eGzE=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-shape/-/d3-shape-3.2.0.tgz", @@ -4005,6 +4440,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha1-aGn93hRIhoB3/dWYkgDLYbKhZF8=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha1-0T9BZccyF//qpUKVzWlps+eu6PM=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", @@ -10426,6 +10896,7 @@ "@fontsource-variable/inter": "^5.2.8", "@uiw/react-codemirror": "^4.23.0", "chart.js": "^4.5.1", + "d3": "^7.9.0", "echarts": "^6.0.0", "flint-chart": "*", "i18next": "^26.3.6", @@ -10446,6 +10917,7 @@ "vega-lite": "^6.4.1" }, "devDependencies": { + "@types/d3": "^7.4.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/packages/flint-js/src/core/interaction-semantics.ts b/packages/flint-js/src/core/interaction-semantics.ts new file mode 100644 index 00000000..200919bb --- /dev/null +++ b/packages/flint-js/src/core/interaction-semantics.ts @@ -0,0 +1,97 @@ +export interface RenderHit { + datum: Record; + source: 'mark' | 'legend-item'; + markType?: string; + markName?: string; + layerRole?: string; +} + +export interface SemanticElement { + key: Record; + records?: readonly Record[]; +} + +export interface SemanticTarget { + visual: { + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle'; + role: string; + }; + elements: readonly SemanticElement[]; +} + +export interface SemanticResolveEvent { + gesture: 'click' | 'hover' | 'rectangle'; + role: string; + hits: readonly RenderHit[]; + legendValue?: unknown; + legendField?: string; +} + +export interface SemanticResolveContext { + allHits: readonly RenderHit[]; + keyField: string; + categoryField?: string; + seriesField?: string; +} + +export type ChartInteractionResolver = ( + event: SemanticResolveEvent, + context: SemanticResolveContext, +) => SemanticTarget | null; + +/** Neutral hover ink that blends with the mark instead of reading as a hard outline. */ +export const MUTED_HOVER_STROKE = 'rgba(71, 82, 92, 0.58)'; +export const MUTED_HOVER_FILL = '#eef1f3'; + +export function elementsFromHits(hits: readonly RenderHit[], keyField: string): SemanticElement[] { + const seen = new Set(); + const elements: SemanticElement[] = []; + for (const hit of hits) { + const key = hit.datum[keyField]; + if (typeof key !== 'string' || seen.has(key)) continue; + seen.add(key); + elements.push({ key: { [keyField]: key }, records: [hit.datum] }); + } + return elements; +} + +export function fieldsFromEncodingChannels( + resolvedEncodings: Readonly>, + channels: readonly string[], + additionalFields: readonly string[] = [], +): string[] { + return [...new Set([ + ...channels.map((channel) => resolvedEncodings[channel]?.field).filter(Boolean), + ...additionalFields, + ])]; +} + +export function firstDiscreteEncodingField( + resolvedEncodings: Readonly>, + channels: readonly string[], +): string | undefined { + return channels + .map((channel) => resolvedEncodings[channel]) + .find((encoding) => encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal')) + ?.field; +} + +export function legendMatchedHits( + event: SemanticResolveEvent, + context: SemanticResolveContext, + field: string, +): RenderHit[] { + if (event.legendValue === undefined) return []; + return context.allHits + .filter((hit) => hit.datum[field] === event.legendValue) + .map((hit) => ({ ...hit, source: 'legend-item' })); +} + +export function targetFromHits( + hits: readonly RenderHit[], + keyField: string, + visual: SemanticTarget['visual'], +): SemanticTarget | null { + const elements = elementsFromHits(hits, keyField); + return elements.length > 0 ? { visual, elements } : null; +} diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 352cf8da..22e48b36 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -913,6 +913,26 @@ export interface ChartTemplateDef { */ markCognitiveChannel: MarkCognitiveChannel; + /** Template-owned semantic resolution and chart-specific presentation. */ + semanticInteractions?: (context: { + resolvedEncodings: Readonly>; + }) => { + fields: string[]; + categoryField?: string; + seriesField?: string; + legendFields?: Record; + selectableMarks: string[]; + renderHoverStyles?: Record; + resolve: import('./interaction-semantics').ChartInteractionResolver; + presentUpdate: import('../interactive/interactions').ChartUpdateProcessor; + }; + /** * Phase 1a: Declare layout intent. * Runs BEFORE layout computation. diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md new file mode 100644 index 00000000..eee3ae10 --- /dev/null +++ b/packages/flint-js/src/interactive/README.md @@ -0,0 +1,259 @@ +# Interaction Event Architecture + +## Status + +Implemented by the interactive surface, trigger modules, ChartDef semantics, presets, and Vega runtime. + +## Goal + +Separate interaction input, semantic resolution, policy, chart updates, and renderer presentation so that: + +- canvas gestures and external application events can drive the same update language; +- chart resolution reports only the physical semantic unit that produced an internal event; +- interaction policy decides what semantic cohort to act on, including chart-specific behavior; +- chart definitions decide how semantic updates should be presented; +- runtimes apply presented updates mechanically; +- resolved semantic events can be emitted to the host application with stable chart identity. + +## Pipeline + +```mermaid +flowchart LR + A[Raw browser or Vega event] --> B[Trigger normalization] + B --> C[Normalized Element or Region event] + C --> D[ChartDef resolve] + D --> E[Semantic event] + E --> F[Interaction coordinator] + F --> G[flint-interaction transport] + F --> H[Preset update policy] + X[External event] --> H + H --> I[ChartUpdate] + I --> J[ChartDef presentUpdate] + J --> K[Renderer runtime] +``` + +The normative ownership boundary is: + +| Stage | Owner | Input | Output | Must not own | +| --- | --- | --- | --- | --- | +| 1. Normalize input | Trigger | Raw browser or renderer event | `ElementInteractionEvent` or `RegionInteractionEvent` | Semantic meaning or chart updates | +| 2. Resolve semantics | ChartDef resolver | Normalized geometry and `RenderHit[]` | Physical `SemanticTarget` | Interaction policy or cohort expansion | +| 3. Coordinate | Interaction coordinator | Resolved semantic event | Outbound event and policy invocation | Chart-specific semantic meaning | +| 4. Decide update | Preset policy | Semantic or External event | `ChartUpdate` | Renderer-specific presentation | +| 5. Present update | ChartDef `presentUpdate` | `ChartUpdate` | Chart-specific presented update | Renderer mutation | +| 6. Apply update | Renderer runtime | Presented update | Renderer state | Semantic inference or policy | + +ChartDef resolves and presents chart semantics. It does **not** own DOM transport. The coordinator emits resolved semantic events externally because transport identity (`chartId`, `interactionId`, and transaction metadata) is surface-level state, not chart semantics. + +An internal event follows this call sequence: + +```mermaid +sequenceDiagram + participant Browser as Browser/Vega + participant Trigger + participant ChartDef as ChartDef.resolve + participant Coordinator + participant Host as External host + participant Preset as Preset.update + participant Present as ChartDef.presentUpdate + participant Runtime as Renderer runtime + + Browser->>Trigger: raw event + rendered item + Trigger->>Coordinator: Element/Region event with geometry + hits + Coordinator->>ChartDef: normalized internal event + ChartDef-->>Coordinator: physical SemanticTarget + Coordinator-->>Host: flint-interaction semantic event + Coordinator->>Preset: SemanticInteractionEvent + Preset-->>Coordinator: ChartUpdate + Coordinator->>Present: ChartUpdate + Present-->>Coordinator: presented update + Coordinator->>Runtime: apply presented update +``` + +An external event bypasses trigger geometry and semantic resolution: + +```mermaid +sequenceDiagram + participant Host as External host + participant Coordinator + participant Preset as Preset.update + participant Present as ChartDef.presentUpdate + participant Runtime as Renderer runtime + + Host->>Coordinator: ExternalInteractionEvent + Coordinator->>Preset: ExternalInteractionEvent + Preset-->>Coordinator: ChartUpdate + Coordinator->>Present: ChartUpdate + Present-->>Coordinator: presented update + Coordinator->>Runtime: apply presented update +``` + +External events bypass chart resolution because their payload already uses the vocabulary agreed between the source and interaction definition. + +## Normalized Events + +```ts +type InteractionPhase = 'start' | 'preview' | 'commit' | 'cancel'; + +type NormalizedInteractionEvent = + | ElementInteractionEvent + | RegionInteractionEvent + | ExternalInteractionEvent; + +interface ElementInteractionEvent { + type: 'element'; + phase: 'preview' | 'commit' | 'cancel'; + hits: readonly RenderHit[]; + point?: PlotPoint; + modifiers?: InteractionModifiers; +} + +interface RegionInteractionEvent { + type: 'region'; + phase: InteractionPhase; + region: PlotRect | PlotPolygon; + hits: readonly RenderHit[]; + match: 'intersect' | 'contain'; + modifiers?: InteractionModifiers; +} + +interface ExternalInteractionEvent { + type: 'external'; + source: string; + phase: InteractionPhase; + payload: TPayload; +} +``` + +`Element` and `Region` describe physical chart input at the geometry level. They may contain coordinates, region geometry, rendered mark metadata, and data records in `RenderHit[]`, but they do not claim semantic meaning. `External` is deliberately generic and typed by the interaction that consumes it. + +## Semantic Resolution + +Only internal Element and Region events are resolved. The owning ChartDef resolver is observational and data-driven. It answers what physical visual/data unit produced the event, not what should happen because of it. + +```ts +interface SemanticInteractionEvent { + type: 'semantic'; + source: 'element' | 'region'; + phase: InteractionPhase; + target: SemanticTarget | null; + point?: PlotPoint; + region?: PlotRect | PlotPolygon; + modifiers?: InteractionModifiers; +} +``` + +For a dumbbell endpoint, resolution returns a single unit such as `point[Country=US, Sex=Male]`. It does not add the female endpoint or connector. + +After resolution, the coordinator constructs the semantic event, emits it through `flint-interaction`, and passes it to the matching preset. External emission is therefore downstream of ChartDef resolution but is not performed by ChartDef. + +## Interaction Policy + +An interaction consumes either a resolved semantic event or an external event and returns a chart update. + +```ts +type InteractionInput = + | SemanticInteractionEvent + | ExternalInteractionEvent; + +interface InteractionDef { + readonly id: string; + readonly eventSource: InteractionEventSource; + update( + event: InteractionInput, + context: InteractionContext, + ): ChartUpdate | null; +} +``` + +Chart-specific action policy belongs here. For example, element highlighting for `Ranged Dot Plot` expands the resolved endpoint to both endpoints and the connector before producing an `emphasize` update. + +Presets are compositions of predefined triggers from `interactive/triggers/` and policies. They refer to reusable descriptors such as `clickTrigger` and `rectangleTrigger()` rather than defining event acquisition inline. They are convenience APIs, not architectural primitives. + +## Triggers + +`interactive/triggers/` owns the event-source contracts, built-in trigger definitions, the shared interaction-event vocabulary, and renderer-specific user-event normalization. Interaction policy types only refer to those contracts; they do not define event acquisition. + +Type colocation does not change production ownership: triggers produce only Element, Region, and External normalized events. The coordinator produces `SemanticInteractionEvent` only after ChartDef resolution. Its type lives in `events.ts` so the full event vocabulary has one definition site. + +Flint provides common triggers for element activation, hover preview, rectangle drag, and external dispatch: + +```ts +clickTrigger +hoverTrigger +rectangleTrigger('intersect' | 'contain') +externalTrigger(source?) +``` + +The folder is organized as: + +- `index.ts`: event-source contracts and built-in trigger definitions. +- `events.ts`: shared geometry, phases, normalized input event types, and the post-resolution semantic event type. +- `vega.ts`: Vega coordinates, scenegraph hits, legend targets, and region geometry normalization. + +The public triggers are exported from `flint-chart/interactive`. The source contract remains open so applications can define custom sources. + +A source may register listeners, track gesture state, compute renderer geometry, inspect rendered marks, and emit normalized events. It must not resolve semantic targets, contain chart-type policy, mutate renderer state, or construct chart updates. + +## Update Language + +`ChartUpdate` is the only interaction-to-chart command language. Updates have a phase and renderer-neutral operations such as emphasize, annotate, and reset. + +```ts +interface ChartUpdate { + phase?: InteractionPhase; + ops: readonly UpdateOp[]; +} +``` + +`preview` is transient, `commit` changes persistent state, and `cancel` restores the last committed state. Chart definitions lower semantic operations through `presentUpdate`; runtimes apply the lowered result mechanically. + +## External Dispatch + +An interactive surface exposes a chart-scoped dispatch API: + +```ts +surface.dispatch({ + type: 'external', + source: 'story-scroll', + phase: 'preview', + payload: { countries: ['Japan'] }, +}); +``` + +The event is offered to matching interaction definitions without semantic resolution. + +## Outbound Events + +Resolved internal events are emitted as a bubbling, composed DOM event named `flint-interaction`. + +```ts +interface FlintInteractionEventDetail { + chartId: string; + interactionId: string; + timestamp: number; + transactionId?: string; + event: SemanticInteractionEvent; +} +``` + +`chartId` identifies the source chart and remains stable for the surface lifetime. It is available on `surface.chartId` and as `data-flint-chart-id` on the surface element. `interactionId` identifies the policy receiving the event. + +The interaction coordinator, not ChartDef, owns this emission. Outbound emission does not depend on whether the preset returns a canvas update. External applications may coordinate text, tables, or other charts from semantic events while leaving the source chart unchanged. + +## Identity + +Callers should provide `chartId` when coordinating charts. Flint generates an ID when omitted. Re-rendering, viewport changes, and data updates do not change the resolved ID. + +Chart identity belongs to the transport envelope, not `SemanticTarget`: semantic targets describe visual/data identity, while `chartId` describes event origin or dispatch destination. + +## Compatibility + +Existing helpers remain presets: + +- `clickHighlight()` +- `clickGroupHighlight()` +- `clickAnnotate()` +- `select()` + +They are implemented on the normalized event pipeline. Existing chart resolution and `presentUpdate` hooks remain valid; chart-specific action expansion moves into interaction policy. diff --git a/packages/flint-js/src/interactive/chart-update.ts b/packages/flint-js/src/interactive/chart-update.ts new file mode 100644 index 00000000..2cff2219 --- /dev/null +++ b/packages/flint-js/src/interactive/chart-update.ts @@ -0,0 +1,28 @@ +import type { + AnnotationRenderPlan, + ChartUpdate, + ChartUpdateProcessor, + InteractionContext, + SemanticElement, +} from './interactions'; + +export function presentInteractionUpdate( + presentAnnotation: ( + element: SemanticElement, + context: InteractionContext, + ) => Pick, +): ChartUpdateProcessor { + return (update, context) => ({ + ops: update.ops.map((op) => op.op === 'annotate' + ? { + op: 'render-annotation', + element: op.element, + point: op.point, + annotation: { + text: op.text, + ...presentAnnotation(op.element, context), + }, + } + : op), + }); +} diff --git a/packages/flint-js/src/interactive/emphasis-update.ts b/packages/flint-js/src/interactive/emphasis-update.ts new file mode 100644 index 00000000..ef4da8e5 --- /dev/null +++ b/packages/flint-js/src/interactive/emphasis-update.ts @@ -0,0 +1,35 @@ +import type { + ChartUpdate, + InteractionModifiers, + SemanticElement, + SemanticTarget, +} from './interactions'; + +export const DEFAULT_DIM_OPACITY = 0.25; + +export function normalizedOpacity(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_DIM_OPACITY; + return Math.min(1, Math.max(0, value)); +} + +function selectionMode(modifiers: InteractionModifiers | undefined): 'replace' | 'toggle' { + return modifiers?.shift || modifiers?.ctrl || modifiers?.meta ? 'toggle' : 'replace'; +} + +export function emphasisUpdate( + target: SemanticTarget | null, + modifiers: InteractionModifiers | undefined, + dimOpacity: number, + elements: readonly SemanticElement[] = target?.elements ?? [], +): ChartUpdate | null { + if (!target) return { ops: [{ op: 'reset' }] }; + if (!target || elements.length === 0) return null; + return { + ops: [{ + op: 'emphasize', + elements, + mode: selectionMode(modifiers), + dimOpacity, + }], + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 67c12789..df77a5fc 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -1,4 +1,5 @@ import type { ChartAssemblyInput } from '../core/types'; +import { normalizeInteractions } from './interactions'; import { mountInteractiveChartSurface } from './surface'; import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types'; @@ -13,6 +14,42 @@ export type { ViewportGeometry, ViewportState, } from './types'; +export type { + AnnotationRenderPlan, + ChartUpdate, + ChartUpdateProcessor, + ClickAnnotateOptions, + ClickGroupHighlightOptions, + ClickHighlightOptions, + ElementInteractionEvent, + ExternalInteractionEvent, + FlintInteractionEventDetail, + InteractionInput, + InteractionPhase, + InteractionContext, + InteractionDef, + InteractionModifiers, + PlotPoint, + PlotPolygon, + PlotRect, + RenderHit, + SelectOptions, + SelectionMode, + SemanticElement, + SemanticInteractionEvent, + SemanticTarget, + NormalizedInteractionEvent, + UpdateOp, +} from './interactions'; +export { clickAnnotate, clickGroupHighlight, clickHighlight, select } from './interactions'; +export type { InteractionEventSource, InteractionEventSourceContext } from './triggers'; +export { clickTrigger, externalTrigger, hoverTrigger, rectangleTrigger } from './triggers'; +export { + ClickAnnotateInteraction, + ClickGroupHighlightInteraction, + ClickHighlightInteraction, + SelectInteraction, +} from './presets'; export { clampViewportStart, mountInteractiveChartSurface } from './surface'; export function buildInteractiveChart( @@ -20,7 +57,20 @@ export function buildInteractiveChart( input: ChartAssemblyInput, options: BuildInteractiveChartOptions, ): InteractiveChartSurface { - const { backend, renderer, focusOnClick, expressionInterpreter, background, className, ariaLabel } = options; + const { backend, renderer, focusOnClick, expressionInterpreter, background, className, ariaLabel, chartId } = options; + const interactions = normalizeInteractions(options.interactions, focusOnClick); + if (backend !== 'vegalite' && interactions.length > 0) { + return mountInteractiveChartSurface( + container, + input, + { + async mount() { + throw new Error(`Semantic interactions are not supported by backend "${backend}".`); + }, + }, + { className, ariaLabel, chartId }, + ); + } switch (backend) { case 'vegalite': return mountInteractiveChartSurface( @@ -31,13 +81,13 @@ export function buildInteractiveChart( const { createVegaInteractiveRenderer } = await import('../vegalite/interactive'); return createVegaInteractiveRenderer({ renderer, - focusOnClick, + interactions, expressionInterpreter, background, }).mount(chartContainer, chartInput); }, }, - { className, ariaLabel }, + { className, ariaLabel, chartId }, ); case 'echarts': return mountInteractiveChartSurface( @@ -49,7 +99,7 @@ export function buildInteractiveChart( return createEChartsInteractiveRenderer({ renderer }).mount(chartContainer, chartInput); }, }, - { className, ariaLabel }, + { className, ariaLabel, chartId }, ); case 'chartjs': return mountInteractiveChartSurface( @@ -61,7 +111,7 @@ export function buildInteractiveChart( return createChartjsInteractiveRenderer().mount(chartContainer, chartInput); }, }, - { className, ariaLabel }, + { className, ariaLabel, chartId }, ); case 'plotly': return mountInteractiveChartSurface( @@ -73,7 +123,7 @@ export function buildInteractiveChart( return createPlotlyInteractiveRenderer().mount(chartContainer, chartInput); }, }, - { className, ariaLabel }, + { className, ariaLabel, chartId }, ); } } \ No newline at end of file diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts new file mode 100644 index 00000000..fe4eefda --- /dev/null +++ b/packages/flint-js/src/interactive/interactions.ts @@ -0,0 +1,132 @@ +import type { RenderHit, SemanticElement, SemanticTarget } from '../core/interaction-semantics'; +import type { InteractionEventSource } from './triggers'; +import type { + ExternalInteractionEvent, + InteractionModifiers, + InteractionPhase, + PlotPoint, + SemanticInteractionEvent, +} from './triggers/events'; +import { + ClickAnnotateInteraction, + ClickGroupHighlightInteraction, + ClickHighlightInteraction, + SelectInteraction, +} from './presets'; +export type { RenderHit, SemanticElement, SemanticTarget } from '../core/interaction-semantics'; + +export type InteractionInput = + | SemanticInteractionEvent + | ExternalInteractionEvent; + +export interface FlintInteractionEventDetail { + chartId: string; + interactionId: string; + timestamp: number; + transactionId?: string; + event: SemanticInteractionEvent; +} + +export type { + ElementInteractionEvent, + ExternalInteractionEvent, + InteractionModifiers, + InteractionPhase, + NormalizedInteractionEvent, + PlotPoint, + PlotPolygon, + PlotRect, + RegionInteractionEvent, + SemanticInteractionEvent, +} from './triggers/events'; + +export type SelectionMode = 'replace' | 'toggle'; + +export interface AnnotationRenderPlan { + text: string; + placement?: 'auto' | 'above' | 'below' | 'left' | 'right'; + anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'mark-end' | 'arc-centroid'; +} + +export type UpdateOp = + | { op: 'emphasize'; elements: readonly SemanticElement[]; mode: SelectionMode; dimOpacity: number } + | { op: 'annotate'; element: SemanticElement; text: string; point?: PlotPoint } + | { op: 'render-annotation'; element: SemanticElement; point?: PlotPoint; annotation: AnnotationRenderPlan } + | { op: 'clear-annotation' } + | { op: 'reset' }; + +export interface ChartUpdate { + phase?: InteractionPhase; + ops: readonly UpdateOp[]; +} + +export type ChartUpdateProcessor = ( + update: ChartUpdate, + context: InteractionContext, +) => ChartUpdate; + +export interface InteractionContext { + readonly chartType: string; + readonly selected: readonly SemanticElement[]; + readonly available?: readonly SemanticElement[]; + readonly categoryField?: string; + readonly seriesField?: string; +} + +export interface InteractionDef { + readonly id: string; + readonly eventSource: InteractionEventSource; + actOn?(target: SemanticTarget | null, context: InteractionContext): SemanticTarget | null; + update(event: InteractionInput, context: InteractionContext): ChartUpdate | null; +} + +export interface ClickHighlightOptions { + id?: string; + dimOpacity?: number; +} + +export interface ClickGroupHighlightOptions extends ClickHighlightOptions { + groupBy?: string | ((element: SemanticElement, context: InteractionContext) => unknown); +} + +export interface ClickAnnotateOptions extends ClickHighlightOptions { + format?: (element: SemanticElement, context: InteractionContext) => string; +} + +export interface SelectOptions { + id?: string; + match?: 'intersect' | 'contain'; + dimOpacity?: number; +} + +export function clickHighlight(options: ClickHighlightOptions = {}): InteractionDef { + return new ClickHighlightInteraction(options); +} + +export function clickGroupHighlight(options: ClickGroupHighlightOptions = {}): InteractionDef { + return new ClickGroupHighlightInteraction(options); +} + +export function clickAnnotate(options: ClickAnnotateOptions = {}): InteractionDef { + return new ClickAnnotateInteraction(options); +} + +export function select(options: SelectOptions = {}): InteractionDef { + return new SelectInteraction(options); +} + +export function normalizeInteractions( + interactions: readonly InteractionDef[] | undefined, + focusOnClick: boolean | undefined, +): readonly InteractionDef[] { + const normalized = [...(interactions ?? [])]; + if (focusOnClick === true && !normalized.some((interaction) => interaction.id === 'click-highlight')) { + normalized.push(clickHighlight()); + } + const ids = new Set(); + for (const interaction of normalized) { + if (ids.has(interaction.id)) throw new Error(`Duplicate interaction id: "${interaction.id}".`); + ids.add(interaction.id); + } + return normalized; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts new file mode 100644 index 00000000..8f2eec17 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -0,0 +1,57 @@ +import type { + ChartUpdate, + ClickAnnotateOptions, + InteractionContext, + InteractionDef, + InteractionInput, + SemanticTarget, +} from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from '../emphasis-update'; +import { clickTrigger } from '../triggers'; + +function displayValue(value: unknown): string { + if (value === null || value === undefined) return 'None'; + if (value instanceof Date) return value.toLocaleString(); + if (typeof value === 'number') { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 3 }).format(value); + } + return String(value); +} + +function annotationText(element: SemanticTarget['elements'][number], context: InteractionContext): string { + const record = element.records?.[0] ?? {}; + const entries = Object.entries(record).filter(([field]) => !field.startsWith('__')); + const candidates = entries.filter(([field]) => field !== context.categoryField && field !== context.seriesField); + const numeric = [...candidates].reverse().find(([, value]) => typeof value === 'number' && Number.isFinite(value)); + const selected = numeric ?? candidates.at(-1) ?? entries.at(-1); + return displayValue(selected?.[1]); +} + +export class ClickAnnotateInteraction implements InteractionDef { + readonly id: string; + readonly eventSource = clickTrigger; + private readonly dimOpacity: number; + private readonly format: NonNullable; + + constructor(options: ClickAnnotateOptions = {}) { + this.id = options.id ?? 'click-annotate'; + this.dimOpacity = normalizedOpacity(options.dimOpacity); + this.format = options.format ?? annotationText; + } + + update(event: InteractionInput, context: InteractionContext): ChartUpdate | null { + if (event.type !== 'semantic' || event.source !== 'element' || event.phase !== 'commit') return null; + if (!event.target) return { ops: [{ op: 'clear-annotation' }, { op: 'reset' }] }; + const element = event.target.elements[0]; + if (!element) return null; + const emphasis = emphasisUpdate(event.target, event.modifiers, this.dimOpacity); + return { + ops: [{ + op: 'annotate', + element, + text: this.format(element, context), + point: event.point, + }, ...(emphasis?.ops ?? [])], + }; + } +} diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts new file mode 100644 index 00000000..e6401510 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -0,0 +1,73 @@ +import type { + ChartUpdate, + ClickGroupHighlightOptions, + InteractionContext, + InteractionDef, + InteractionInput, + SemanticElement, + SemanticTarget, +} from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from '../emphasis-update'; +import { clickTrigger } from '../triggers'; + +export class ClickGroupHighlightInteraction implements InteractionDef { + readonly id: string; + readonly eventSource = clickTrigger; + private readonly dimOpacity: number; + private readonly groupBy?: ClickGroupHighlightOptions['groupBy']; + + constructor(options: ClickGroupHighlightOptions = {}) { + this.id = options.id ?? 'click-group-highlight'; + this.dimOpacity = normalizedOpacity(options.dimOpacity); + this.groupBy = options.groupBy; + } + + update(event: InteractionInput, context: InteractionContext): ChartUpdate | null { + if (event.type !== 'semantic' || event.source !== 'element' || event.phase !== 'commit') return null; + return emphasisUpdate( + event.target, + event.modifiers, + this.dimOpacity, + event.target ? this.propagate(event.target, context) : [], + ); + } + + private groupValue(element: SemanticElement, context: InteractionContext): unknown { + if (typeof this.groupBy === 'function') return this.groupBy(element, context); + const record = element.records?.[0]; + if (!record) return undefined; + if (typeof this.groupBy === 'string') return record[this.groupBy]; + + const field = this.defaultGroupField(context); + return field ? record[field] : undefined; + } + + private defaultGroupField(context: InteractionContext): string | undefined { + switch (context.chartType) { + case 'Waterfall Chart': + return '__wf_color'; + case 'Strip Plot': + return context.categoryField; + default: + return context.seriesField; + } + } + + private propagate( + target: SemanticTarget, + context: InteractionContext, + ): readonly SemanticElement[] { + if (target.visual.role === 'legend-item') return target.elements; + const source = target.elements[0]; + if (!source) return target.elements; + const value = this.groupValue(source, context); + if (value === undefined) return target.elements; + + const available = context.available ?? []; + const values = new Set(available.map((element) => this.groupValue(element, context))); + if (values.size < 2) return target.elements; + + const cohort = available.filter((element) => this.groupValue(element, context) === value); + return cohort.length > 1 ? cohort : target.elements; + } +} diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts new file mode 100644 index 00000000..123f1307 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -0,0 +1,39 @@ +import type { + ChartUpdate, + ClickHighlightOptions, + InteractionContext, + InteractionDef, + InteractionInput, + SemanticTarget, +} from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from '../emphasis-update'; +import { clickTrigger } from '../triggers'; + +export class ClickHighlightInteraction implements InteractionDef { + readonly id: string; + readonly eventSource = clickTrigger; + private readonly dimOpacity: number; + + constructor(options: ClickHighlightOptions = {}) { + this.id = options.id ?? 'click-highlight'; + this.dimOpacity = normalizedOpacity(options.dimOpacity); + } + + actOn(target: SemanticTarget | null, context: InteractionContext): SemanticTarget | null { + if (!target) return null; + if (context.chartType !== 'Ranged Dot Plot' + || target.visual.role === 'legend-item' + || !context.categoryField + || target.elements.length !== 1) return target; + const category = target.elements[0].records?.[0]?.[context.categoryField]; + if (category === undefined) return target; + const elements = context.available?.filter((element) => + element.records?.some((record) => record[context.categoryField!] === category)); + return elements?.length ? { ...target, elements } : target; + } + + update(event: InteractionInput, context: InteractionContext): ChartUpdate | null { + if (event.type !== 'semantic' || event.source !== 'element' || event.phase !== 'commit') return null; + return emphasisUpdate(this.actOn(event.target, context), event.modifiers, this.dimOpacity); + } +} diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts new file mode 100644 index 00000000..0935ff3c --- /dev/null +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -0,0 +1,4 @@ +export { ClickAnnotateInteraction } from './click-annotate'; +export { ClickGroupHighlightInteraction } from './click-group-highlight'; +export { ClickHighlightInteraction } from './click-highlight'; +export { SelectInteraction } from './select'; diff --git a/packages/flint-js/src/interactive/presets/select.ts b/packages/flint-js/src/interactive/presets/select.ts new file mode 100644 index 00000000..d3d68b7e --- /dev/null +++ b/packages/flint-js/src/interactive/presets/select.ts @@ -0,0 +1,27 @@ +import type { + ChartUpdate, + InteractionContext, + InteractionDef, + InteractionInput, + SelectOptions, +} from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from '../emphasis-update'; +import { rectangleTrigger } from '../triggers'; + +export class SelectInteraction implements InteractionDef { + readonly id: string; + readonly eventSource; + private readonly dimOpacity: number; + + constructor(options: SelectOptions = {}) { + this.id = options.id ?? 'select'; + this.eventSource = rectangleTrigger(options.match ?? 'intersect'); + this.dimOpacity = normalizedOpacity(options.dimOpacity); + } + + update(event: InteractionInput, context: InteractionContext): ChartUpdate | null { + if (event.type !== 'semantic' || event.source !== 'region' + || event.phase === 'start' || event.phase === 'cancel') return null; + return emphasisUpdate(event.target, event.modifiers, this.dimOpacity); + } +} diff --git a/packages/flint-js/src/interactive/surface.ts b/packages/flint-js/src/interactive/surface.ts index 498a3cf2..0a4a82a8 100644 --- a/packages/flint-js/src/interactive/surface.ts +++ b/packages/flint-js/src/interactive/surface.ts @@ -8,6 +8,13 @@ import type { ViewportState, } from './types'; +let generatedChartId = 0; + +function nextChartId(): string { + generatedChartId += 1; + return `flint-chart-${generatedChartId}`; +} + const RAIL_THICKNESS = 8; const RAIL_GAP = 9; const RAIL_TRACK_COLOR = 'rgba(31, 41, 55, 0.035)'; @@ -167,6 +174,7 @@ export function mountInteractiveChartSurface( adapter: InteractiveRendererAdapter, options: InteractiveChartSurfaceOptions = {}, ): InteractiveChartSurface { + const chartId = options.chartId ?? nextChartId(); const root = document.createElement('div'); const chart = document.createElement('div'); const state: ViewportState = {}; @@ -174,10 +182,12 @@ export function mountInteractiveChartSurface( let renderer: InteractiveRenderer | undefined; let updateTimer: number | undefined; let destroyed = false; + const pendingEvents: import('./interactions').ExternalInteractionEvent[] = []; root.className = options.className ?? 'flint-interactive-surface'; root.setAttribute('role', 'figure'); root.setAttribute('aria-label', options.ariaLabel ?? input.chart_spec.title ?? 'Interactive chart'); + root.dataset.flintChartId = chartId; applyStyles(root, { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'minmax(0, auto) auto', alignItems: 'stretch', rowGap: '6px', minWidth: '0', maxWidth: '100%', marginInline: 'auto', @@ -208,6 +218,7 @@ export function mountInteractiveChartSurface( return; } renderer = mounted; + for (const event of pendingEvents.splice(0)) void mounted.dispatchInteraction?.(event); for (const viewport of mounted.viewports) { state[viewport.channel] = 0; const rail = createViewportRail(viewport, 0, (start) => setViewport(viewport.channel, start)); @@ -238,9 +249,15 @@ export function mountInteractiveChartSurface( return { element: root, + chartId, ready, getViewportState: () => ({ ...state }), setViewport, + dispatch: (event) => { + if (destroyed) return; + if (renderer) void renderer.dispatchInteraction?.(event); + else pendingEvents.push(event); + }, destroy: () => { if (destroyed) return; destroyed = true; diff --git a/packages/flint-js/src/interactive/triggers/events.ts b/packages/flint-js/src/interactive/triggers/events.ts new file mode 100644 index 00000000..75ebec04 --- /dev/null +++ b/packages/flint-js/src/interactive/triggers/events.ts @@ -0,0 +1,65 @@ +import type { RenderHit, SemanticTarget } from '../../core/interaction-semantics'; + +export interface PlotPoint { + x: number; + y: number; +} + +export interface PlotRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PlotPolygon { + points: readonly PlotPoint[]; +} + +export interface InteractionModifiers { + shift: boolean; + ctrl: boolean; + meta: boolean; +} + +export type InteractionPhase = 'start' | 'preview' | 'commit' | 'cancel'; + +export interface ElementInteractionEvent { + type: 'element'; + phase: 'preview' | 'commit' | 'cancel'; + hits: readonly RenderHit[]; + point?: PlotPoint; + modifiers?: InteractionModifiers; +} + +export interface RegionInteractionEvent { + type: 'region'; + phase: InteractionPhase; + region: PlotRect | PlotPolygon; + hits: readonly RenderHit[]; + match: 'intersect' | 'contain'; + modifiers?: InteractionModifiers; +} + +export interface ExternalInteractionEvent { + type: 'external'; + source: string; + phase: InteractionPhase; + payload: TPayload; + transactionId?: string; +} + +export type NormalizedInteractionEvent = + | ElementInteractionEvent + | RegionInteractionEvent + | ExternalInteractionEvent; + +export interface SemanticInteractionEvent { + type: 'semantic'; + source: 'element' | 'region'; + phase: InteractionPhase; + target: SemanticTarget | null; + point?: PlotPoint; + region?: PlotRect | PlotPolygon; + modifiers?: InteractionModifiers; +} diff --git a/packages/flint-js/src/interactive/triggers/index.ts b/packages/flint-js/src/interactive/triggers/index.ts new file mode 100644 index 00000000..7faf3020 --- /dev/null +++ b/packages/flint-js/src/interactive/triggers/index.ts @@ -0,0 +1,47 @@ +import type { NormalizedInteractionEvent } from './events'; + +export type { + ElementInteractionEvent, + ExternalInteractionEvent, + InteractionModifiers, + InteractionPhase, + NormalizedInteractionEvent, + PlotPoint, + PlotPolygon, + PlotRect, + RegionInteractionEvent, + SemanticInteractionEvent, +} from './events'; + +export interface InteractionEventSourceContext { + readonly container: HTMLElement; + emit(event: NormalizedInteractionEvent): void; +} + +export interface InteractionEventSource { + readonly type: 'element' | 'region' | 'external' | (string & {}); + readonly gesture?: 'click' | 'hover' | 'drag'; + readonly match?: 'intersect' | 'contain'; + readonly source?: string; + mount?(context: InteractionEventSourceContext): void | (() => void); +} + +export const clickTrigger = Object.freeze({ + type: 'element', + gesture: 'click', +} as const satisfies InteractionEventSource); + +export const hoverTrigger = Object.freeze({ + type: 'element', + gesture: 'hover', +} as const satisfies InteractionEventSource); + +export function rectangleTrigger( + match: 'intersect' | 'contain' = 'intersect', +): InteractionEventSource { + return { type: 'region', gesture: 'drag', match }; +} + +export function externalTrigger(source?: string): InteractionEventSource { + return { type: 'external', source }; +} diff --git a/packages/flint-js/src/interactive/triggers/vega.ts b/packages/flint-js/src/interactive/triggers/vega.ts new file mode 100644 index 00000000..a8bc2617 --- /dev/null +++ b/packages/flint-js/src/interactive/triggers/vega.ts @@ -0,0 +1,376 @@ +import type { RenderHit } from '../../core/interaction-semantics'; +import type { + ElementInteractionEvent, + InteractionModifiers, + InteractionPhase, + PlotPoint, + RegionInteractionEvent, +} from './events'; + +export const INTERACTION_KEY = '__flint_interaction_key'; +export const PATH_KEY_SUFFIX = '|__flint_path'; + +const SUPPORTED_RENDER_MARKS = new Set(['arc', 'area', 'bar', 'line', 'rect', 'rule', 'symbol']); + +export interface RendererCoordinateSpace { + rect: DOMRect; + logicalWidth: number; + logicalHeight: number; + originX: number; + originY: number; + plotWidth: number; + plotHeight: number; +} + +export interface SelectionRect { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export function interactionModifiers(event: MouseEvent | PointerEvent): InteractionModifiers { + return { shift: event.shiftKey, ctrl: event.ctrlKey, meta: event.metaKey }; +} + +interface PathGeometry { + kind: 'segment' | 'slice'; + points: PlotPoint[]; + offset: PlotPoint; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function clientToPlotPoint(client: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + const rendererX = (client.x - space.rect.left) * space.logicalWidth / space.rect.width; + const rendererY = (client.y - space.rect.top) * space.logicalHeight / space.rect.height; + return { + x: clamp(rendererX - space.originX, 0, space.plotWidth), + y: clamp(rendererY - space.originY, 0, space.plotHeight), + }; +} + +export function plotToClientPoint(point: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + return { + x: space.rect.left + (point.x + space.originX) * space.rect.width / space.logicalWidth, + y: space.rect.top + (point.y + space.originY) * space.rect.height / space.logicalHeight, + }; +} + +export function clientToLayoutPoint( + point: PlotPoint, + rect: Pick, + layoutSize: { width: number; height: number }, +): PlotPoint { + return { + x: (point.x - rect.left) * layoutSize.width / rect.width, + y: (point.y - rect.top) * layoutSize.height / rect.height, + }; +} + +export function clientRectToLayoutRect( + rect: Pick, + containerRect: Pick, + layoutSize: { width: number; height: number }, +): { left: number; top: number; width: number; height: number } { + const leading = clientToLayoutPoint({ x: rect.left, y: rect.top }, containerRect, layoutSize); + const trailing = clientToLayoutPoint({ x: rect.right, y: rect.bottom }, containerRect, layoutSize); + return { + left: leading.x, + top: leading.y, + width: trailing.x - leading.x, + height: trailing.y - leading.y, + }; +} + +function keyOfDatum(datum: unknown): string | undefined { + if (!datum || typeof datum !== 'object') return undefined; + const key = (datum as Record)[INTERACTION_KEY]; + return typeof key === 'string' ? key : undefined; +} + +function pathGeometry(item: any, offsetX: number, offsetY: number): PathGeometry | null { + const items = item?.mark?.items; + if (!Array.isArray(items)) return null; + const index = items.indexOf(item); + if (index < 0) return null; + const point = (candidate: any): PlotPoint => ({ x: candidate.x + offsetX, y: candidate.y + offsetY }); + if (item.mark.marktype === 'line') { + if (index >= items.length - 1) return null; + return { + kind: 'segment', + points: [point(item), point(items[index + 1])], + offset: { x: offsetX, y: offsetY }, + }; + } + if (item.mark.marktype !== 'area' || typeof item.y2 !== 'number') return null; + const previous = items[index - 1]; + const next = items[index + 1]; + return { + kind: 'slice', + points: [ + { x: (previous ? (previous.x + item.x) / 2 : item.x) + offsetX, y: (previous ? (previous.y + item.y) / 2 : item.y) + offsetY }, + { x: (next ? (item.x + next.x) / 2 : item.x) + offsetX, y: (next ? (item.y + next.y) / 2 : item.y) + offsetY }, + { x: (next ? (item.x + next.x) / 2 : item.x) + offsetX, y: (next ? (item.y2 + next.y2) / 2 : item.y2) + offsetY }, + { x: (previous ? (previous.x + item.x) / 2 : item.x) + offsetX, y: (previous ? (previous.y2 + item.y2) / 2 : item.y2) + offsetY }, + ], + offset: { x: offsetX, y: offsetY }, + }; +} + +export function sceneItems(view: any): any[] { + const result: any[] = []; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + if (SUPPORTED_RENDER_MARKS.has(item.mark?.marktype) && keyOfDatum(item.datum) && item.bounds) { + const interactionGeometry = pathGeometry(item, offsetX, offsetY); + if ((item.mark.marktype === 'line' || item.mark.marktype === 'area') && !interactionGeometry) return; + const points = interactionGeometry?.points; + result.push({ + ...item, + x: typeof item.x === 'number' ? item.x + offsetX : item.x, + y: typeof item.y === 'number' ? item.y + offsetY : item.y, + bounds: points ? { + x1: Math.min(...points.map((point) => point.x)), + x2: Math.max(...points.map((point) => point.x)), + y1: Math.min(...points.map((point) => point.y)), + y2: Math.max(...points.map((point) => point.y)), + } : { + x1: item.bounds.x1 + offsetX, + x2: item.bounds.x2 + offsetX, + y1: item.bounds.y1 + offsetY, + y2: item.bounds.y2 + offsetY, + }, + interactionGeometry, + }); + } + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (Array.isArray(item.items)) { + for (const child of item.items) visit(child, childOffsetX, childOffsetY); + } + }; + visit(view.scenegraph()?.root, 0, 0); + return result; +} + +export function boundsIntersectRect( + bounds: SelectionRect, + rect: SelectionRect, + minimumOverlap = 0.5, +): boolean { + const overlapX = Math.min(bounds.x2, rect.x2) - Math.max(bounds.x1, rect.x1); + const overlapY = Math.min(bounds.y2, rect.y2) - Math.max(bounds.y1, rect.y1); + return overlapX > minimumOverlap && overlapY > minimumOverlap; +} + +function pointInRect(point: PlotPoint, rect: SelectionRect): boolean { + return point.x >= rect.x1 && point.x <= rect.x2 && point.y >= rect.y1 && point.y <= rect.y2; +} + +function orientation(a: PlotPoint, b: PlotPoint, c: PlotPoint): number { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +function segmentsIntersect(a: PlotPoint, b: PlotPoint, c: PlotPoint, d: PlotPoint): boolean { + const abC = orientation(a, b, c); + const abD = orientation(a, b, d); + const cdA = orientation(c, d, a); + const cdB = orientation(c, d, b); + return abC * abD <= 0 && cdA * cdB <= 0; +} + +function pointInPolygon(point: PlotPoint, polygon: readonly PlotPoint[]): boolean { + let inside = false; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + const a = polygon[current]; + const b = polygon[previous]; + if ((a.y > point.y) !== (b.y > point.y) + && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x) inside = !inside; + } + return inside; +} + +export function geometryIntersectsRect(geometry: PathGeometry, rect: SelectionRect, contain: boolean): boolean { + if (contain) return geometry.points.every((point) => pointInRect(point, rect)); + if (geometry.points.some((point) => pointInRect(point, rect))) return true; + const corners: PlotPoint[] = [ + { x: rect.x1, y: rect.y1 }, { x: rect.x2, y: rect.y1 }, + { x: rect.x2, y: rect.y2 }, { x: rect.x1, y: rect.y2 }, + ]; + if (geometry.kind === 'slice' && corners.some((point) => pointInPolygon(point, geometry.points))) return true; + const geometryEdges = geometry.kind === 'segment' + ? [[geometry.points[0], geometry.points[1]] as const] + : geometry.points.map((point, index) => [point, geometry.points[(index + 1) % geometry.points.length]] as const); + const rectEdges = corners.map((point, index) => [point, corners[(index + 1) % corners.length]] as const); + return geometryEdges.some(([a, b]) => rectEdges.some(([c, d]) => segmentsIntersect(a, b, c, d))); +} + +function arcPolygon(item: any): PlotPoint[] | null { + if (item?.mark?.marktype !== 'arc') return null; + const values = [item.x, item.y, item.innerRadius, item.outerRadius, item.startAngle, item.endAngle]; + if (!values.every((value) => typeof value === 'number' && Number.isFinite(value))) return null; + const delta = item.endAngle - item.startAngle; + const steps = Math.max(8, Math.ceil(Math.abs(delta) * item.outerRadius / 4)); + const pointAt = (radius: number, angle: number): PlotPoint => ({ + x: item.x + radius * Math.sin(angle), + y: item.y - radius * Math.cos(angle), + }); + const polygon: PlotPoint[] = []; + for (let index = 0; index <= steps; index += 1) { + polygon.push(pointAt(item.outerRadius, item.startAngle + delta * index / steps)); + } + for (let index = steps; index >= 0; index -= 1) { + polygon.push(pointAt(item.innerRadius, item.startAngle + delta * index / steps)); + } + return polygon; +} + +export function arcIntersectsRect(item: any, rect: SelectionRect, contain = false): boolean { + const polygon = arcPolygon(item); + if (!polygon) return false; + if (contain) return polygon.every((point) => pointInRect(point, rect)); + if (polygon.some((point) => pointInRect(point, rect))) return true; + const corners: PlotPoint[] = [ + { x: rect.x1, y: rect.y1 }, { x: rect.x2, y: rect.y1 }, + { x: rect.x2, y: rect.y2 }, { x: rect.x1, y: rect.y2 }, + ]; + if (corners.some((point) => pointInPolygon(point, polygon))) return true; + const rectEdges = corners.map((point, index) => [point, corners[(index + 1) % corners.length]] as const); + for (let index = 0; index < polygon.length; index += 1) { + const a = polygon[index]; + const b = polygon[(index + 1) % polygon.length]; + if (rectEdges.some(([c, d]) => segmentsIntersect(a, b, c, d))) return true; + } + return false; +} + +export function renderHit(item: any): RenderHit | null { + if (!SUPPORTED_RENDER_MARKS.has(item?.mark?.marktype) || !keyOfDatum(item?.datum)) return null; + const datum = item.mark.marktype === 'line' || item.mark.marktype === 'area' + ? { ...item.datum, [INTERACTION_KEY]: `${keyOfDatum(item.datum)}${PATH_KEY_SUFFIX}` } + : item.datum; + return { + datum, + source: 'mark', + markType: item.mark?.marktype, + markName: item.mark?.name, + layerRole: item.mark?.role, + }; +} + +export function physicalItemAt(view: any, item: any, point: PlotPoint): any { + const pathItems = item?.mark?.marktype === 'line' || item?.mark?.marktype === 'area' + ? sceneItems(view).filter((candidate) => candidate.mark === item.mark && candidate.interactionGeometry) + : []; + if (item?.mark?.marktype === 'area') { + return pathItems.find((candidate) => pointInPolygon(point, candidate.interactionGeometry.points)); + } + if (item?.mark?.marktype === 'line') { + return pathItems.reduce((nearest, candidate) => { + const [a, b] = candidate.interactionGeometry.points; + const lengthSquared = (b.x - a.x) ** 2 + (b.y - a.y) ** 2; + const ratio = lengthSquared === 0 ? 0 : clamp( + ((point.x - a.x) * (b.x - a.x) + (point.y - a.y) * (b.y - a.y)) / lengthSquared, + 0, + 1, + ); + const distance = Math.hypot(point.x - (a.x + ratio * (b.x - a.x)), point.y - (a.y + ratio * (b.y - a.y))); + return !nearest || distance < nearest.distance ? { item: candidate, distance } : nearest; + }, null)?.item; + } + return item; +} + +export function legendTarget( + item: any, + legendFields?: Readonly>, +): { channel?: string; value: unknown; field?: string } | null { + const isLegend = typeof item?.mark?.role === 'string' && item.mark.role.startsWith('legend-'); + if (!isLegend) return null; + const scales = item?.mark?.group?.mark?.group?.datum?.scales + ?? item?.mark?.group?.mark?.group?.mark?.group?.datum?.scales; + const channel = scales && typeof scales === 'object' + ? Object.keys(scales).map((key) => key === 'fill' || key === 'stroke' ? 'color' : key)[0] + : undefined; + return { channel, value: item?.datum?.value, field: channel ? legendFields?.[channel] : undefined }; +} + +export interface NormalizedVegaElement { + event: ElementInteractionEvent; + role: 'mark' | 'legend-item'; + legend: { channel?: string; value: unknown; field?: string } | null; +} + +export function normalizeVegaElementEvent( + view: any, + item: any, + point: PlotPoint, + phase: 'preview' | 'commit' | 'cancel', + modifiers: InteractionModifiers, + legendFields?: Readonly>, +): NormalizedVegaElement { + const legend = legendTarget(item, legendFields); + const physicalItem = physicalItemAt(view, item, point); + const hit = renderHit(physicalItem ?? item); + return { + event: { + type: 'element', + phase, + hits: hit ? [hit] : legend ? [{ datum: item?.datum ?? {}, source: 'legend-item' }] : [], + point, + modifiers, + }, + role: legend ? 'legend-item' : 'mark', + legend, + }; +} + +export function regionHits( + view: any, + a: PlotPoint, + b: PlotPoint, + contain = false, +): RenderHit[] { + const rect = { + x1: Math.min(a.x, b.x), x2: Math.max(a.x, b.x), + y1: Math.min(a.y, b.y), y2: Math.max(a.y, b.y), + }; + return sceneItems(view) + .filter((item) => item.interactionGeometry + ? geometryIntersectsRect(item.interactionGeometry, rect, contain) + : item.mark?.marktype === 'arc' + ? arcIntersectsRect(item, rect, contain) + : contain + ? item.bounds.x1 >= rect.x1 && item.bounds.x2 <= rect.x2 + && item.bounds.y1 >= rect.y1 && item.bounds.y2 <= rect.y2 + : boundsIntersectRect(item.bounds, rect)) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +export function normalizeVegaRegionEvent( + view: any, + start: PlotPoint, + end: PlotPoint, + phase: InteractionPhase, + match: 'intersect' | 'contain', + modifiers: InteractionModifiers, +): RegionInteractionEvent { + return { + type: 'region', + phase, + region: { + x: Math.min(start.x, end.x), + y: Math.min(start.y, end.y), + width: Math.abs(end.x - start.x), + height: Math.abs(end.y - start.y), + }, + hits: regionHits(view, start, end, match === 'contain'), + match, + modifiers, + }; +} diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index a06b6b34..6cce97a7 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -1,4 +1,5 @@ import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { ExternalInteractionEvent, InteractionDef } from './interactions'; export type ViewportChannel = 'x' | 'y'; export type ViewportState = Partial>; @@ -13,6 +14,7 @@ export interface InteractiveRenderer { setViewports(starts: ViewportState): void | Promise; getViewportGeometry?(channel: ViewportChannel): ViewportGeometry | undefined; resize?(size: { width: number; height: number }): void | Promise; + dispatchInteraction?(event: ExternalInteractionEvent): void | Promise; destroy(): void; } @@ -23,6 +25,7 @@ export interface InteractiveRendererAdapter { export interface InteractiveChartSurfaceOptions { className?: string; ariaLabel?: string; + chartId?: string; } export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; @@ -30,7 +33,9 @@ export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; export interface BuildInteractiveChartOptions extends InteractiveChartSurfaceOptions { backend: InteractiveBackend; renderer?: 'canvas' | 'svg'; - /** Enable local click focus where supported. Defaults to true for Vega-Lite. */ + /** Semantic interactions to enable. Omit for viewport controls only. */ + interactions?: readonly InteractionDef[]; + /** @deprecated Use `interactions: [clickHighlight()]`. */ focusOnClick?: boolean; expressionInterpreter?: unknown; background?: string; @@ -38,8 +43,10 @@ export interface BuildInteractiveChartOptions extends InteractiveChartSurfaceOpt export interface InteractiveChartSurface { readonly element: HTMLElement; + readonly chartId: string; readonly ready: Promise; getViewportState(): ViewportState; setViewport(channel: ViewportChannel, start: number): void; + dispatch(event: ExternalInteractionEvent): void; destroy(): void; } \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 5bcdddf9..b22fc4e0 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -877,6 +877,9 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (overflowResult.viewports.length > 0) { result._viewports = overflowResult.viewports; } + if (chartTemplate.semanticInteractions) { + result._interactionSemantics = chartTemplate.semanticInteractions({ resolvedEncodings }); + } result._width = layoutResult.subplotWidth; result._height = layoutResult.subplotHeight; // Annotated option catalog: every configurable property this template diff --git a/packages/flint-js/src/vegalite/interactive-focus.ts b/packages/flint-js/src/vegalite/interactive-focus.ts index aff589e4..f28f146b 100644 --- a/packages/flint-js/src/vegalite/interactive-focus.ts +++ b/packages/flint-js/src/vegalite/interactive-focus.ts @@ -1,7 +1,8 @@ +import { DEFAULT_DIM_OPACITY } from '../interactive/emphasis-update'; + const FOCUS_PARAM = '__flint_focus'; const FOCUS_KEY = '__flint_focus_key'; const CLEAR_MARK = '__flint_focus_clear'; -const DIMMED_OPACITY = 0.3; const FOCUSABLE_MARKS = new Set(['bar', 'arc', 'point', 'circle', 'square', 'rect']); function markType(mark: unknown): string | undefined { @@ -47,7 +48,7 @@ function focusTransform(field: string): Record { function focusOpacity(restOpacity: number): Record { return { condition: { param: FOCUS_PARAM, value: restOpacity }, - value: Math.min(DIMMED_OPACITY, restOpacity), + value: Math.min(DEFAULT_DIM_OPACITY, restOpacity), }; } diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index f5366346..2d9dd3d8 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -1,15 +1,21 @@ import { applyCategoryViewports } from '../core/filter-overflow'; import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractionDef } from '../interactive/interactions'; import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; import { assembleVegaLite } from './assemble'; -import { addInteractiveFocus, injectFocusClearMark, withoutInteractiveFocusField } from './interactive-focus'; +import { + addVegaLiteInteractions, + injectVegaInteractionStore, + mountVegaInteractions, + withoutSemanticInteractionField, +} from './semantic-interactions'; import { compile } from 'vega-lite'; import { Error as VegaError, parse, View } from 'vega'; import { Handler } from 'vega-tooltip'; export interface VegaInteractiveRendererOptions { renderer?: 'canvas' | 'svg'; - focusOnClick?: boolean; + interactions?: readonly InteractionDef[]; expressionInterpreter?: unknown; background?: string; } @@ -54,9 +60,10 @@ export function createVegaInteractiveRenderer( const firstInput = windowedInput(interactiveInput, viewports, {}); const vlSpec = assembleVegaLite(firstInput) as any; applyViewportSorts(vlSpec, viewports); - const hasFocus = options.focusOnClick !== false && addInteractiveFocus(vlSpec); + const interactions = options.interactions ?? []; + const interactionPlan = addVegaLiteInteractions(vlSpec, interactions); const vegaSpec = compile(vlSpec).spec as any; - if (hasFocus) injectFocusClearMark(vegaSpec); + if (interactionPlan) injectVegaInteractionStore(vegaSpec, interactionPlan); const source = vegaSpec.data?.find((entry: any) => Array.isArray(entry.values))?.name as string | undefined; if (viewports.length > 0 && !source) { throw new Error('Compiled chart has no mutable inline data source.'); @@ -72,9 +79,20 @@ export function createVegaInteractiveRenderer( view.logLevel(VegaError); const tooltip = new Handler(); view.tooltip((handler, event, item, value) => { - tooltip.call(handler, event, item, withoutInteractiveFocusField(value)); + tooltip.call(handler, event, item, withoutSemanticInteractionField(value)); }); await view.runAsync(); + const interactionController = interactionPlan?.resolve && interactionPlan.presentUpdate + ? mountVegaInteractions( + view, + container, + input.chart_spec.chartType, + interactionPlan, + interactions, + interactionPlan.resolve, + interactionPlan.presentUpdate, + ) + : undefined; let destroyed = false; let running = false; @@ -105,6 +123,9 @@ export function createVegaInteractiveRenderer( return { viewports, + dispatchInteraction(event) { + return interactionController?.dispatch(event); + }, getViewportGeometry(channel) { const [left, top] = view.origin(); return channel === 'x' @@ -124,6 +145,7 @@ export function createVegaInteractiveRenderer( if (destroyed) return; destroyed = true; if (updateTimer !== undefined) window.clearTimeout(updateTimer); + interactionController?.destroy(); view.finalize(); container.replaceChildren(); }, diff --git a/packages/flint-js/src/vegalite/semantic-interactions.ts b/packages/flint-js/src/vegalite/semantic-interactions.ts new file mode 100644 index 00000000..92592a22 --- /dev/null +++ b/packages/flint-js/src/vegalite/semantic-interactions.ts @@ -0,0 +1,1027 @@ +import { changeset } from 'vega'; +import type { ChartInteractionResolver } from '../core/interaction-semantics'; +import type { + ChartUpdate, + ChartUpdateProcessor, + ExternalInteractionEvent, + FlintInteractionEventDetail, + InteractionDef, + NormalizedInteractionEvent, + PlotPoint, + RenderHit, + SemanticTarget, + SemanticInteractionEvent, +} from '../interactive/interactions'; +import { DEFAULT_DIM_OPACITY } from '../interactive/emphasis-update'; +import { + INTERACTION_KEY, + PATH_KEY_SUFFIX, + arcIntersectsRect, + boundsIntersectRect, + clientRectToLayoutRect, + clientToLayoutPoint, + clientToPlotPoint, + interactionModifiers, + normalizeVegaElementEvent, + normalizeVegaRegionEvent, + plotToClientPoint, + renderHit, + sceneItems, + type RendererCoordinateSpace, +} from '../interactive/triggers/vega'; + +export { + INTERACTION_KEY, + arcIntersectsRect, + boundsIntersectRect, + clientRectToLayoutRect, + clientToLayoutPoint, + clientToPlotPoint, + plotToClientPoint, + sceneItems, +} from '../interactive/triggers/vega'; + +export const INTERACTION_STORE = '__flint_interaction_store'; +export const HOVER_STORE = '__flint_hover_store'; +export const LEGEND_HOVER_STORE = '__flint_legend_hover_store'; +export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; +const CLEAR_MARK = '__flint_interaction_clear'; +const SUPPORTED_SPEC_MARKS = new Set(['arc', 'area', 'bar', 'boxplot', 'circle', 'line', 'point', 'rect', 'rule', 'tick']); + +interface TemplateInteractionSemantics { + fields: string[]; + categoryField?: string; + seriesField?: string; + legendFields?: Record; + selectableMarks: string[]; + renderHoverStyles?: Record; + resolve?: ChartInteractionResolver; + presentUpdate?: ChartUpdateProcessor; +} + +interface HoverStyle { + fill?: string; + fillOpacity?: number; + opacity?: 'contrast'; + stroke?: string; + strokeWidth?: number; +} + +export interface VegaInteractionPlan { + fields: readonly string[]; + categoryField?: string; + seriesField?: string; + legendFields?: Readonly>; + dimOpacity: number; + renderHoverStyles?: Readonly>; + resolve?: ChartInteractionResolver; + presentUpdate?: ChartUpdateProcessor; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function withoutSemanticInteractionField(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const filtered = { ...(value as Record) }; + delete filtered[INTERACTION_KEY]; + return filtered; +} + +function markType(mark: unknown): string | undefined { + return typeof mark === 'string' + ? mark + : typeof mark === 'object' && mark !== null + ? (mark as Record).type as string | undefined + : undefined; +} + +function expandInteractiveLinePoints(spec: Record): void { + const type = markType(spec.mark); + if (type === 'line' && typeof spec.mark === 'object' && spec.mark.point) { + const lineMark = { ...spec.mark }; + const point = lineMark.point; + delete lineMark.point; + spec.layer = [ + { mark: lineMark }, + { mark: typeof point === 'object' ? { type: 'point', ...point } : { type: 'point', filled: true } }, + ]; + delete spec.mark; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) expandInteractiveLinePoints(child); + } +} + +function keyExpression(fields: readonly string[]): string { + return fields + .map((field) => `replace(toString(datum[${JSON.stringify(field)}]), '|', '\\|')`) + .join(` + '|' + `); +} + +function instrumentNode( + node: Record, + inherited: Record, + dimOpacity: number, + selectableMarks: ReadonlySet, + clickCursor: boolean, +): boolean { + const type = markType(node.mark); + if (!type || !SUPPORTED_SPEC_MARKS.has(type) || !selectableMarks.has(type)) return false; + const encoding = { ...inherited, ...(node.encoding ?? {}) }; + const encodedOpacity = encoding.opacity; + const dataDrivenOpacity = encodedOpacity?.field && !encodedOpacity.condition; + if ((encodedOpacity && typeof encodedOpacity.value !== 'number' && !dataDrivenOpacity) + || encoding.fillOpacity || encoding.strokeOpacity) return false; + const authoredOpacity = typeof encodedOpacity?.value === 'number' + ? encodedOpacity.value + : typeof node.mark === 'object' && typeof node.mark.opacity === 'number' + ? node.mark.opacity + : 1; + if (typeof node.mark === 'object' && typeof node.mark.opacity === 'number') { + node.mark = { ...node.mark }; + delete node.mark.opacity; + } + if (clickCursor) { + node.mark = typeof node.mark === 'string' + ? { type: node.mark, cursor: 'pointer' } + : { ...node.mark, cursor: node.mark.cursor ?? 'pointer' }; + } + const isPath = type === 'line' || type === 'area'; + const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; + const existingDetail = node.encoding?.detail; + const selectionTest = isPath + ? `!length(data('${INTERACTION_STORE}'))` + : `!length(data('${INTERACTION_STORE}')) || indata('${INTERACTION_STORE}', 'key', datum.${INTERACTION_KEY})`; + node.encoding = { + ...(node.encoding ?? {}), + ...(isPath ? {} : { + detail: existingDetail == null + ? { field: INTERACTION_KEY, type: 'nominal' } + : [...(Array.isArray(existingDetail) ? existingDetail : [existingDetail]), { field: INTERACTION_KEY, type: 'nominal' }], + }), + opacity: dataDrivenOpacity ? { + condition: { test: selectionTest, ...encodedOpacity }, + value: dimOpacity, + } : { + condition: { + test: `${selectionTest} || ${hoverTest}`, + value: authoredOpacity, + }, + value: Math.min(dimOpacity, authoredOpacity), + }, + }; + return true; +} + +function instrumentMarks( + spec: Record, + inherited: Record, + dimOpacity: number, + selectableMarks: ReadonlySet, + clickCursor: boolean, +): boolean { + const encoding = { ...inherited, ...(spec.encoding ?? {}) }; + let instrumented = instrumentNode(spec, inherited, dimOpacity, selectableMarks, clickCursor); + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) { + instrumented = instrumentMarks(child, encoding, dimOpacity, selectableMarks, clickCursor) || instrumented; + } + } + return instrumented; +} + +function addLocalKeyTransforms( + spec: Record, + fields: readonly string[], + selectableMarks: ReadonlySet, +): void { + const type = markType(spec.mark); + if (type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type) && spec.data) { + spec.transform = [ + ...(Array.isArray(spec.transform) ? spec.transform : []), + { calculate: keyExpression(fields), as: INTERACTION_KEY }, + ]; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) addLocalKeyTransforms(child, fields, selectableMarks); + } +} + +export function addVegaLiteInteractions( + spec: Record, + interactions: readonly InteractionDef[], +): VegaInteractionPlan | null { + if (interactions.length === 0) return null; + const templateSemantics = spec._interactionSemantics as TemplateInteractionSemantics | undefined; + delete spec._interactionSemantics; + if (!templateSemantics || templateSemantics.fields.length === 0) return null; + const selectableMarks = new Set(templateSemantics?.selectableMarks ?? SUPPORTED_SPEC_MARKS); + const fields = templateSemantics.fields; + expandInteractiveLinePoints(spec); + + const dimOpacity = interactions.reduce((value, interaction) => { + if (interaction.eventSource.type === 'external') return value; + const update = interaction.update({ + type: 'semantic', + source: interaction.eventSource.type === 'region' ? 'region' : 'element', + phase: 'commit', + target: { + visual: { kind: 'mark', role: 'probe' }, + elements: [{ key: {} }], + }, + }, { chartType: 'Unknown', selected: [] }); + const emphasize = update?.ops.find((op) => op.op === 'emphasize'); + return emphasize?.op === 'emphasize' ? Math.min(value, emphasize.dimOpacity) : value; + }, DEFAULT_DIM_OPACITY); + + const clickCursor = interactions.some((interaction) => interaction.eventSource.gesture === 'click') + && !interactions.some((interaction) => interaction.eventSource.gesture === 'drag'); + const instrumented = instrumentMarks(spec, {}, dimOpacity, selectableMarks, clickCursor); + if (!instrumented) return null; + addLocalKeyTransforms(spec, fields, selectableMarks); + spec.transform = [ + ...(Array.isArray(spec.transform) ? spec.transform : []), + { calculate: keyExpression(fields), as: INTERACTION_KEY }, + ]; + return { + fields, + categoryField: templateSemantics.categoryField, + seriesField: templateSemantics.seriesField, + legendFields: templateSemantics.legendFields, + dimOpacity, + renderHoverStyles: templateSemantics.renderHoverStyles, + resolve: templateSemantics.resolve, + presentUpdate: templateSemantics.presentUpdate, + }; +} + +function applyCompiledHoverStyles( + marks: Record[], + renderHoverStyles: Readonly>, +): void { + const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; + for (const mark of marks) { + if (Array.isArray(mark.marks)) applyCompiledHoverStyles(mark.marks, renderHoverStyles); + const style = renderHoverStyles[mark.type]; + const update = mark.encode?.update; + if (!style || !update || !JSON.stringify(mark.encode).includes(INTERACTION_KEY)) continue; + for (const [channel, value] of Object.entries(style)) { + if (channel === 'opacity' && value === 'contrast') { + const numericValues = (Array.isArray(update.opacity) ? update.opacity : [update.opacity]) + .map((entry: any) => entry?.value) + .filter((entry: unknown): entry is number => typeof entry === 'number'); + const authoredOpacity = numericValues.length > 0 ? Math.max(...numericValues) : 1; + update.opacity = [ + { + test: `!length(data('${INTERACTION_STORE}')) && ${hoverTest}`, + value: authoredOpacity < 1 ? 1 : 0.9, + }, + ...(Array.isArray(update.opacity) ? update.opacity : [update.opacity]), + ]; + continue; + } + const existing = update[channel] ?? mark.encode?.enter?.[channel] ?? ( + channel === 'stroke' + ? { value: mark.type === 'line' || mark.type === 'rule' ? 'black' : 'transparent' } + : channel === 'strokeWidth' + ? { value: mark.type === 'line' ? 2 : mark.type === 'rule' ? 1 : mark.type === 'symbol' ? 1.5 : 0 } + : undefined + ); + if (existing === undefined) continue; + update[channel] = [ + { test: hoverTest, value }, + ...(Array.isArray(existing) ? existing : [existing]), + ]; + } + } +} + +export function injectVegaInteractionStore( + vegaSpec: Record, + plan?: Pick, +): void { + vegaSpec.data = [ + ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), + { name: INTERACTION_STORE, values: [] }, + { name: HOVER_STORE, values: [] }, + { name: LEGEND_HOVER_STORE, values: [] }, + { name: LEGEND_SELECTION_STORE, values: [] }, + ]; + for (const legend of vegaSpec.legends ?? []) { + const scaleChannel = ['fill', 'stroke', 'size', 'shape', 'opacity'] + .find((channel) => legend[channel] !== undefined); + const channel = scaleChannel === 'fill' || scaleChannel === 'stroke' ? 'color' : scaleChannel; + const peerOfSelectedLegend = channel + ? `length(data('${LEGEND_SELECTION_STORE}')) && ` + + `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_SELECTION_STORE}')[0].value !== datum.value` + : undefined; + const interactiveItem = (encode: Record | undefined): Record => { + const existingOpacity = encode?.update?.opacity ?? encode?.enter?.opacity ?? { value: 1 }; + return { + ...(encode ?? {}), + interactive: true, + update: { + ...(encode?.update ?? {}), + cursor: { value: 'pointer' }, + opacity: peerOfSelectedLegend ? [ + { test: peerOfSelectedLegend, value: plan?.dimOpacity ?? DEFAULT_DIM_OPACITY }, + ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), + ] : existingOpacity, + }, + }; + }; + legend.encode = { + ...(legend.encode ?? {}), + symbols: interactiveItem(legend.encode?.symbols), + labels: interactiveItem(legend.encode?.labels), + }; + } + if (!Array.isArray(vegaSpec.marks)) return; + if (plan?.renderHoverStyles) applyCompiledHoverStyles(vegaSpec.marks, plan.renderHoverStyles); + vegaSpec.marks.unshift({ + type: 'rect', + name: CLEAR_MARK, + encode: { + enter: { + x: { value: 0 }, x2: { signal: 'width' }, + y: { value: 0 }, y2: { signal: 'height' }, + opacity: { value: 0 }, tooltip: { value: null }, + }, + }, + }); +} + +function keyOfDatum(datum: unknown): string | undefined { + if (!datum || typeof datum !== 'object') return undefined; + const key = (datum as Record)[INTERACTION_KEY]; + return typeof key === 'string' ? key : undefined; +} + +export interface VegaInteractionController { + dispatch(event: ExternalInteractionEvent): Promise; + destroy(): void; +} + +export function mountVegaInteractions( + view: any, + container: HTMLElement, + chartType: string, + plan: VegaInteractionPlan, + interactions: readonly InteractionDef[], + resolve: ChartInteractionResolver, + presentUpdate: ChartUpdateProcessor, +): VegaInteractionController { + const clickInteraction = interactions.find((interaction) => interaction.eventSource.gesture === 'click'); + const rectangleInteraction = interactions.find((interaction) => interaction.eventSource.gesture === 'drag'); + let selected = new Set(); + let selectedLegend: { channel: string; value: unknown } | null = null; + let hoveredPathKeys = new Set(); + let committed = new Set(); + let suppressClick = false; + let dragStart: { x: number; y: number } | undefined; + let pointerId: number | undefined; + let syncRunning = false; + let syncRequested = false; + + const containerLayoutSize = (): { width: number; height: number } => { + const rect = container.getBoundingClientRect(); + return { + width: container.offsetWidth || rect.width, + height: container.offsetHeight || rect.height, + }; + }; + + const coordinateSpace = (): RendererCoordinateSpace => { + const renderer = container.querySelector('canvas, svg') as HTMLElement | null; + const rect = (renderer ?? container).getBoundingClientRect(); + const [viewOriginX, viewOriginY] = view.origin(); + const svg = renderer instanceof SVGSVGElement ? renderer : undefined; + // SVG autosize/padding can make View#origin differ from the renderer's + // final plot translation. The rendered root-frame CTM is authoritative. + const rootFrame = svg?.querySelector('.mark-group.role-frame.root'); + const rootMatrix = rootFrame?.getCTM(); + const originX = rootMatrix?.e ?? viewOriginX; + const originY = rootMatrix?.f ?? viewOriginY; + const logicalWidth = svg?.viewBox.baseVal.width || rect.width; + const logicalHeight = svg?.viewBox.baseVal.height || rect.height; + const viewWidth = view.width(); + const viewHeight = view.height(); + return { + rect, + logicalWidth, + logicalHeight, + originX, + originY, + plotWidth: viewWidth > 0 ? viewWidth : Math.max(0, logicalWidth - originX), + plotHeight: viewHeight > 0 ? viewHeight : Math.max(0, logicalHeight - originY), + }; + }; + + const focusLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const pathVisuals = new Map(); + Object.assign(focusLayer.style, { + position: 'absolute', inset: '0', zIndex: '3', width: '100%', height: '100%', + pointerEvents: 'none', overflow: 'hidden', + }); + const renderPathFocus = (): void => { + focusLayer.replaceChildren(); + const scene = sceneItems(view); + for (const item of scene) { + if (!item.interactionGeometry) continue; + const hit = renderHit(item); + const key = hit?.datum[INTERACTION_KEY]; + if (typeof key !== 'string' || pathVisuals.has(key)) continue; + pathVisuals.set(key, { + fill: item.fill, + fillOpacity: (typeof item.opacity === 'number' ? item.opacity : 1) + * (typeof item.fillOpacity === 'number' ? item.fillOpacity : 1), + stroke: item.stroke, + strokeWidth: typeof item.strokeWidth === 'number' ? item.strokeWidth : 2, + }); + } + const items = scene.filter((item) => { + const hit = renderHit(item); + const key = String(hit?.datum[INTERACTION_KEY]); + return hit && (selected.has(key) || hoveredPathKeys.has(key)) && item.interactionGeometry; + }); + if (items.length === 0) { + focusLayer.remove(); + return; + } + if (!focusLayer.isConnected) container.append(focusLayer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(focusLayer.style, { + inset: 'auto', + left: `${rendererLayout.left}px`, + top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, + height: `${rendererLayout.height}px`, + }); + focusLayer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + for (const item of items) { + const key = renderHit(item)?.datum[INTERACTION_KEY]; + const visual = typeof key === 'string' ? pathVisuals.get(key) : undefined; + const hovered = typeof key === 'string' && hoveredPathKeys.has(key); + const hoverStyle = hovered ? plan.renderHoverStyles?.[item.mark.marktype] : undefined; + const basePath = renderer + ? [...renderer.querySelectorAll('[role="graphics-symbol"]')] + .find((candidate) => (candidate as any).__data__?.mark === item.mark) + : undefined; + const matrix = basePath?.getCTM(); + const points = item.interactionGeometry.points.map((plotPoint: PlotPoint) => { + if (!matrix || !renderer) { + return { x: plotPoint.x + space.originX, y: plotPoint.y + space.originY }; + } + const local = renderer.createSVGPoint(); + local.x = plotPoint.x - item.interactionGeometry.offset.x; + local.y = plotPoint.y - item.interactionGeometry.offset.y; + const transformed = local.matrixTransform(matrix); + return { x: transformed.x, y: transformed.y }; + }); + const segment = item.interactionGeometry.kind === 'segment'; + const shape = document.createElementNS('http://www.w3.org/2000/svg', segment ? 'path' : 'polygon'); + if (segment) { + shape.setAttribute('d', `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`); + shape.setAttribute('fill', 'none'); + shape.setAttribute('stroke', hoverStyle?.stroke ?? visual?.stroke ?? item.stroke ?? '#4c78a8'); + shape.setAttribute('stroke-width', String(hoverStyle?.strokeWidth ?? visual?.strokeWidth ?? item.strokeWidth ?? 2)); + shape.setAttribute('stroke-linecap', 'round'); + } else { + shape.setAttribute('points', points.map((plotPoint: PlotPoint) => `${plotPoint.x},${plotPoint.y}`).join(' ')); + shape.setAttribute('fill', hoverStyle?.fill ?? visual?.fill ?? item.fill ?? '#4c78a8'); + shape.setAttribute('fill-opacity', String(hoverStyle?.fillOpacity ?? visual?.fillOpacity ?? 1)); + if (hoverStyle?.stroke) shape.setAttribute('stroke', hoverStyle.stroke); + if (hoverStyle?.strokeWidth !== undefined) shape.setAttribute('stroke-width', String(hoverStyle.strokeWidth)); + } + focusLayer.append(shape); + } + }; + renderPathFocus(); + + const annotationLayer = document.createElement('div'); + Object.assign(annotationLayer.style, { + position: 'absolute', inset: '0', zIndex: '4', pointerEvents: 'none', overflow: 'hidden', + }); + const annotationSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + Object.assign(annotationSvg.style, { position: 'absolute', inset: '0', width: '100%', height: '100%' }); + const annotationPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + annotationPath.setAttribute('fill', 'none'); + annotationPath.setAttribute('stroke', '#176f58'); + annotationPath.setAttribute('stroke-width', '1.5'); + annotationPath.setAttribute('stroke-linecap', 'round'); + const annotationDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + annotationDot.setAttribute('r', '3'); + annotationDot.setAttribute('fill', '#176f58'); + annotationDot.setAttribute('stroke', '#ffffff'); + annotationDot.setAttribute('stroke-width', '1.5'); + annotationSvg.append(annotationPath, annotationDot); + const annotationCard = document.createElement('div'); + Object.assign(annotationCard.style, { + position: 'absolute', color: '#176f58', fontFamily: 'ui-sans-serif, sans-serif', + fontSize: '11px', fontWeight: '700', lineHeight: '1', whiteSpace: 'nowrap', + textShadow: '-1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff', + }); + annotationLayer.append(annotationSvg, annotationCard); + + const clearAnnotation = (): void => annotationLayer.remove(); + const renderAnnotation = ( + element: import('../core/interaction-semantics').SemanticElement, + annotation: import('../interactive/interactions').AnnotationRenderPlan, + point?: PlotPoint, + ): void => { + const key = element.key[INTERACTION_KEY]; + const item = typeof key === 'string' + ? sceneItems(view).find((candidate) => keyOfDatum(candidate.datum) === key) + : undefined; + if (!item?.bounds) { + clearAnnotation(); + return; + } + if (!annotationLayer.isConnected) container.append(annotationLayer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + + annotationCard.textContent = annotation.text; + + const containerRect = container.getBoundingClientRect(); + const space = coordinateSpace(); + const arcAngle = typeof item.startAngle === 'number' && typeof item.endAngle === 'number' + ? (item.startAngle + item.endAngle) / 2 + : undefined; + const arcRadius = typeof item.innerRadius === 'number' && typeof item.outerRadius === 'number' + ? (item.innerRadius + item.outerRadius) / 2 + : undefined; + const arcAnchor = annotation.anchor === 'arc-centroid' && arcAngle !== undefined && arcRadius !== undefined + ? { x: item.x + arcRadius * Math.sin(arcAngle), y: item.y - arcRadius * Math.cos(arcAngle) } + : undefined; + type Placement = 'above' | 'below' | 'left' | 'right'; + let outward: Placement | undefined; + let markEnd: PlotPoint | undefined; + if (annotation.anchor === 'mark-end') { + const horizontal = item.bounds.x2 - item.bounds.x1 >= item.bounds.y2 - item.bounds.y1; + const items = sceneItems(view); + const countAt = (edge: 'x1' | 'x2' | 'y1' | 'y2', value: number): number => items + .filter((candidate) => Math.abs(candidate.bounds[edge] - value) < 0.5) + .length; + if (horizontal) { + const leftIsBaseline = countAt('x1', item.bounds.x1) >= countAt('x2', item.bounds.x2); + outward = leftIsBaseline ? 'right' : 'left'; + markEnd = { + x: leftIsBaseline ? item.bounds.x2 : item.bounds.x1, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; + } else { + const topIsBaseline = countAt('y1', item.bounds.y1) > countAt('y2', item.bounds.y2); + outward = topIsBaseline ? 'below' : 'above'; + markEnd = { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: topIsBaseline ? item.bounds.y2 : item.bounds.y1, + }; + } + } else if (annotation.anchor === 'arc-centroid' && arcAnchor) { + const deltaX = arcAnchor.x - item.x; + const deltaY = arcAnchor.y - item.y; + outward = Math.abs(deltaX) >= Math.abs(deltaY) + ? deltaX >= 0 ? 'right' : 'left' + : deltaY >= 0 ? 'below' : 'above'; + } else if (annotation.anchor === 'top') outward = 'above'; + else if (annotation.anchor === 'bottom') outward = 'below'; + else if (annotation.anchor === 'left') outward = 'left'; + else if (annotation.anchor === 'right') outward = 'right'; + const exactPoint = annotation.anchor === 'center' ? point : undefined; + const anchorPlotX = exactPoint?.x ?? markEnd?.x ?? arcAnchor?.x ?? (annotation.anchor === 'left' ? item.bounds.x1 + : annotation.anchor === 'right' ? item.bounds.x2 + : (item.bounds.x1 + item.bounds.x2) / 2); + const anchorPlotY = exactPoint?.y ?? markEnd?.y ?? arcAnchor?.y ?? (annotation.anchor === 'top' ? item.bounds.y1 + : annotation.anchor === 'bottom' ? item.bounds.y2 + : (item.bounds.y1 + item.bounds.y2) / 2); + const anchorClient = plotToClientPoint({ x: anchorPlotX, y: anchorPlotY }, space); + const anchorLayout = clientToLayoutPoint(anchorClient, containerRect, containerLayoutSize()); + const anchorX = anchorLayout.x; + const anchorY = anchorLayout.y; + const width = container.clientWidth; + const height = container.clientHeight; + const cardWidth = annotationCard.offsetWidth; + const cardHeight = annotationCard.offsetHeight; + let placement = annotation.placement === 'auto' || !annotation.placement + ? (outward ?? (anchorX < width * 0.58 ? 'right' : 'left')) + : annotation.placement; + if (placement === 'above' && anchorY < cardHeight + 34) placement = 'below'; + if (placement === 'below' && anchorY + cardHeight + 34 > height) placement = 'above'; + if (placement === 'right' && anchorX + cardWidth + 38 > width) placement = 'left'; + if (placement === 'left' && anchorX - cardWidth - 38 < 0) placement = 'right'; + let cardX = anchorX + 34; + let cardY = anchorY - cardHeight / 2; + if (placement === 'left') cardX = anchorX - cardWidth - 38; + if (placement === 'above') { + cardX = anchorX - cardWidth / 2; + cardY = anchorY - cardHeight - 28; + } else if (placement === 'below') { + cardX = anchorX - cardWidth / 2; + cardY = anchorY + 28; + } + cardX = clamp(cardX, 8, Math.max(8, width - cardWidth - 8)); + cardY = clamp(cardY, 8, Math.max(8, height - cardHeight - 8)); + annotationCard.style.left = `${cardX}px`; + annotationCard.style.top = `${cardY}px`; + + const vertical = placement === 'above' || placement === 'below'; + const endX = vertical ? clamp(anchorX, cardX, cardX + cardWidth) : placement === 'right' ? cardX : cardX + cardWidth; + const endY = vertical ? placement === 'above' ? cardY + cardHeight : cardY : clamp(anchorY, cardY, cardY + cardHeight); + const control1X = vertical ? anchorX : anchorX + (placement === 'right' ? 18 : -18); + const control1Y = vertical ? anchorY + (placement === 'below' ? 14 : -14) : anchorY; + const control2X = vertical ? endX : endX + (placement === 'right' ? -18 : 18); + const control2Y = vertical ? endY + (placement === 'below' ? -14 : 14) : endY; + annotationSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); + annotationPath.setAttribute( + 'd', + `M ${anchorX} ${anchorY} C ${control1X} ${control1Y}, ${control2X} ${control2Y}, ${endX} ${endY}`, + ); + annotationDot.setAttribute('cx', String(anchorX)); + annotationDot.setAttribute('cy', String(anchorY)); + }; + + const allHits = (): RenderHit[] => sceneItems(view) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); + const resolveContext = (hits: readonly RenderHit[]) => ({ + allHits: hits, + keyField: INTERACTION_KEY, + categoryField: plan.categoryField, + seriesField: plan.seriesField, + }); + const context = () => { + const hits = allHits(); + const available = resolve( + { gesture: 'rectangle', role: 'region', hits }, + resolveContext(hits), + )?.elements; + return { + chartType, + selected: [...selected].map((key) => ({ key: { [INTERACTION_KEY]: key } })), + available, + categoryField: plan.categoryField, + seriesField: plan.seriesField, + }; + }; + const sync = async (): Promise => { + syncRequested = true; + if (syncRunning) return; + syncRunning = true; + try { + while (syncRequested) { + syncRequested = false; + const keys = [...selected]; + view.change( + INTERACTION_STORE, + changeset().remove(() => true).insert(keys.map((key) => ({ key }))), + ); + view.change( + LEGEND_SELECTION_STORE, + changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), + ); + await view.runAsync(); + renderPathFocus(); + } + } finally { + syncRunning = false; + } + }; + const applyUpdate = async ( + update: ChartUpdate | null, + legendSelection: { channel: string; value: unknown } | null = null, + ): Promise => { + if (!update) return; + for (const op of update.ops) { + if (op.op === 'reset') { + selected.clear(); + selectedLegend = null; + clearAnnotation(); + } else if (op.op === 'clear-annotation') { + clearAnnotation(); + } else if (op.op === 'render-annotation') { + renderAnnotation(op.element, op.annotation, op.point); + } else if (op.op === 'emphasize') { + const keys = op.elements + .map((element) => element.key[INTERACTION_KEY]) + .filter((key): key is string => typeof key === 'string'); + if (op.mode === 'replace') selected = new Set(keys); + else { + const allSelected = keys.every((key) => selected.has(key)); + for (const key of keys) allSelected ? selected.delete(key) : selected.add(key); + } + selectedLegend = legendSelection && keys.some((key) => selected.has(key)) + ? legendSelection + : null; + } + } + await sync(); + }; + const emitSemanticEvent = ( + interaction: InteractionDef, + event: SemanticInteractionEvent, + transactionId?: string, + ): void => { + const root = container.closest('[data-flint-chart-id]'); + const detail: FlintInteractionEventDetail = { + chartId: root?.dataset.flintChartId ?? '', + interactionId: interaction.id, + timestamp: Date.now(), + transactionId, + event, + }; + container.dispatchEvent(new CustomEvent('flint-interaction', { + detail, + bubbles: true, + composed: true, + })); + }; + const dispatch = async ( + interaction: InteractionDef, + event: SemanticInteractionEvent, + legendSelection: { channel: string; value: unknown } | null = null, + ): Promise => { + const interactionContext = context(); + emitSemanticEvent(interaction, event); + const update = interaction.update(event, interactionContext); + await applyUpdate(update ? presentUpdate(update, interactionContext) : null, legendSelection); + }; + const dispatchExternal = async (event: ExternalInteractionEvent): Promise => { + for (const interaction of interactions) { + const configuredSource = interaction.eventSource.source; + const acceptsSource = interaction.eventSource.type === 'external'; + if (configuredSource && configuredSource !== event.source) continue; + if (!acceptsSource) continue; + const interactionContext = context(); + const update = interaction.update(event, interactionContext); + await applyUpdate(update ? presentUpdate(update, interactionContext) : null); + } + }; + const resolveTarget = ( + gesture: 'click' | 'hover' | 'rectangle', + role: string, + hits: readonly RenderHit[], + legendValue?: unknown, + legendField?: string, + ): SemanticTarget | null => { + const availableHits = allHits(); + return resolve( + { gesture, role, hits, legendValue, legendField }, + resolveContext(availableHits), + ); + }; + + let hoveredKeys = ''; + const setHover = async ( + keys: readonly string[], + legend: { channel: string; value: unknown } | null = null, + ): Promise => { + const next = [...new Set(keys)].sort(); + const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}`; + if (signature === hoveredKeys) return; + hoveredKeys = signature; + hoveredPathKeys = new Set(next.filter((key) => key.endsWith(PATH_KEY_SUFFIX))); + view.change( + HOVER_STORE, + changeset().remove(() => true).insert(next.map((key) => ({ key }))), + ); + view.change( + LEGEND_HOVER_STORE, + changeset().remove(() => true).insert(legend ? [legend] : []), + ); + await view.runAsync(); + renderPathFocus(); + }; + const clearHover = (): void => { + void setHover([]); + if (!rectangleInteraction) container.style.cursor = previousCursor; + }; + const hoverHandler = (event: MouseEvent, item: any): void => { + if (!clickInteraction || dragStart) return; + const point = localPoint(event as unknown as PointerEvent); + const normalized = normalizeVegaElementEvent( + view, item, point, 'preview', interactionModifiers(event), plan.legendFields, + ); + const legend = normalized.legend; + if (legend) { + if (!rectangleInteraction) container.style.cursor = 'pointer'; + void setHover([], + legend.channel ? { channel: legend.channel, value: legend.value } : null); + return; + } + const hovered = normalized.event.hits[0]; + if (!hovered) { + clearHover(); + return; + } + if (!rectangleInteraction) container.style.cursor = 'pointer'; + const resolved = resolveTarget('hover', normalized.role, normalized.event.hits); + const target = clickInteraction.actOn?.(resolved, context()) ?? resolved; + emitSemanticEvent(clickInteraction, { + type: 'semantic', source: 'element', phase: 'preview', target, point, + modifiers: normalized.event.modifiers, + }); + void setHover(target?.elements + .map((element) => element.key[INTERACTION_KEY]) + .filter((key): key is string => typeof key === 'string') ?? []); + }; + + const clickHandler = (event: MouseEvent, item: any): void => { + if (!clickInteraction || suppressClick) return; + const point = localPoint(event as unknown as PointerEvent); + const normalized = normalizeVegaElementEvent( + view, item, point, 'commit', interactionModifiers(event), plan.legendFields, + ); + const { legend } = normalized; + const target = resolveTarget( + 'click', normalized.role, normalized.event.hits, legend?.value, legend?.field, + ); + void dispatch(clickInteraction, { + type: 'semantic', source: 'element', phase: 'commit', target, point, + modifiers: normalized.event.modifiers, + }, legend?.channel ? { channel: legend.channel, value: legend.value } : null); + }; + view.addEventListener('click', clickHandler); + view.addEventListener('mousemove', hoverHandler); + view.addEventListener('mouseout', clearHover); + + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'absolute', display: 'none', pointerEvents: 'none', + boxSizing: 'border-box', + border: '1px solid rgba(37, 99, 235, 0.85)', background: 'rgba(37, 99, 235, 0.12)', + }); + const previousPosition = container.style.position; + const previousUserSelect = container.style.userSelect; + const previousCursor = container.style.cursor; + if (rectangleInteraction) { + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + container.style.userSelect = 'none'; + container.style.cursor = 'crosshair'; + container.append(overlay); + container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; + } + + const localPoint = (event: PointerEvent): { x: number; y: number } => { + return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); + }; + const showRegion = (a: { x: number; y: number }, b: { x: number; y: number }): void => { + const space = coordinateSpace(); + const leading = plotToClientPoint({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }, space); + const trailing = plotToClientPoint({ x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }, space); + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + const localLeading = clientToLayoutPoint(leading, containerRect, layoutSize); + const localTrailing = clientToLayoutPoint(trailing, containerRect, layoutSize); + Object.assign(overlay.style, { + display: 'block', + left: `${localLeading.x}px`, + top: `${localLeading.y}px`, + width: `${localTrailing.x - localLeading.x}px`, + height: `${localTrailing.y - localLeading.y}px`, + }); + }; + const pointerDown = (event: PointerEvent): void => { + if (!rectangleInteraction || event.button !== 0) return; + clearHover(); + dragStart = localPoint(event); + pointerId = event.pointerId; + committed = new Set(selected); + container.setPointerCapture(event.pointerId); + }; + const pointerMove = (event: PointerEvent): void => { + if (!rectangleInteraction || !dragStart || pointerId !== event.pointerId) return; + const point = localPoint(event); + if (Math.hypot(point.x - dragStart.x, point.y - dragStart.y) < 4) return; + suppressClick = true; + showRegion(dragStart, point); + const normalized = normalizeVegaRegionEvent( + view, dragStart, point, 'preview', rectangleInteraction.eventSource.match ?? 'intersect', + interactionModifiers(event), + ); + selected = new Set(committed); + void dispatch(rectangleInteraction, { + type: 'semantic', source: 'region', phase: 'preview', + target: resolveTarget('rectangle', 'region', normalized.hits), + region: normalized.region, modifiers: normalized.modifiers, + }); + }; + const finishDrag = (event: PointerEvent): void => { + if (!rectangleInteraction || !dragStart || pointerId !== event.pointerId) return; + const point = localPoint(event); + const dragged = Math.hypot(point.x - dragStart.x, point.y - dragStart.y) >= 4; + if (dragged) { + const normalized = normalizeVegaRegionEvent( + view, dragStart, point, 'commit', rectangleInteraction.eventSource.match ?? 'intersect', + interactionModifiers(event), + ); + selected = new Set(committed); + void dispatch(rectangleInteraction, { + type: 'semantic', source: 'region', phase: 'commit', + target: resolveTarget('rectangle', 'region', normalized.hits), + region: normalized.region, modifiers: normalized.modifiers, + }); + } else { + const normalized = normalizeVegaRegionEvent( + view, dragStart, point, 'commit', rectangleInteraction.eventSource.match ?? 'intersect', + interactionModifiers(event), + ); + selected = new Set(committed); + void dispatch(rectangleInteraction, { + type: 'semantic', source: 'region', phase: 'commit', target: null, + region: normalized.region, modifiers: normalized.modifiers, + }); + } + dragStart = undefined; + pointerId = undefined; + overlay.style.display = 'none'; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + if (dragged) window.setTimeout(() => { suppressClick = false; }, 0); + }; + const cancelDrag = (event: PointerEvent): void => { + if (!rectangleInteraction || !dragStart || pointerId !== event.pointerId) return; + selected = new Set(committed); + dragStart = undefined; + pointerId = undefined; + overlay.style.display = 'none'; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + void sync(); + }; + const keyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return; + if (dragStart) selected = new Set(committed); + else { + selected.clear(); + clearAnnotation(); + } + dragStart = undefined; + pointerId = undefined; + overlay.style.display = 'none'; + void sync(); + }; + container.addEventListener('pointerdown', pointerDown); + container.addEventListener('pointermove', pointerMove); + container.addEventListener('pointerup', finishDrag); + container.addEventListener('pointercancel', cancelDrag); + container.addEventListener('keydown', keyDown); + + const customSourceCleanups = interactions.flatMap((interaction) => { + if (!interaction.eventSource?.mount) return []; + const cleanup = interaction.eventSource.mount({ + container, + emit(event: NormalizedInteractionEvent) { + if (event.type === 'external') { + void dispatchExternal(event); + return; + } + const gesture = event.type === 'region' ? 'rectangle' : 'click'; + const role = event.type === 'region' ? 'region' : 'mark'; + const target = resolveTarget(gesture, role, event.hits); + void dispatch(interaction, { + type: 'semantic', + source: event.type, + phase: event.phase, + target, + point: event.type === 'element' ? event.point : undefined, + region: event.type === 'region' ? event.region : undefined, + modifiers: event.modifiers, + }); + }, + }); + return cleanup ? [cleanup] : []; + }); + + const destroy = (): void => { + view.removeEventListener('click', clickHandler); + view.removeEventListener('mousemove', hoverHandler); + view.removeEventListener('mouseout', clearHover); + container.removeEventListener('pointerdown', pointerDown); + container.removeEventListener('pointermove', pointerMove); + container.removeEventListener('pointerup', finishDrag); + container.removeEventListener('pointercancel', cancelDrag); + container.removeEventListener('keydown', keyDown); + overlay.remove(); + focusLayer.remove(); + annotationLayer.remove(); + container.style.position = previousPosition; + container.style.userSelect = previousUserSelect; + container.style.cursor = previousCursor; + for (const cleanup of customSourceCleanups) cleanup(); + }; + return { dispatch: dispatchExternal, destroy }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 57518511..76055fd1 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -4,6 +4,14 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { makeCartesianPivot } from '../../core/pivot'; import { defaultBuildEncodings, setMarkProp, alignStackOrderToColorOrder } from './utils'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; const interpolateConfigProperty: ChartPropertyDef = { key: "interpolate", label: "Curve", type: "discrete", options: [ @@ -118,6 +126,27 @@ export const areaChartDef: ChartTemplateDef = { channels: ["x", "y", "color", "opacity", "column", "row"], markCognitiveChannel: 'area', geometryKinds: ['area', 'line', 'point'], + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['area'], + renderHoverStyles: { area: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'path', role: event.role }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'auto' })), + }; + }, declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index 0d96eb46..324879ab 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -4,6 +4,13 @@ import { ChartTemplateDef, ChartPropertyDef, ChannelSemantics } from '../../core/types'; import { getRegistryEntry } from '../../core/type-registry'; import { resolveDisplayUnit, titleWithDisplayUnit, type FormatSpec } from '../../core/field-semantics'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; import { formatSpecToVegaExpr } from '../format'; /** @@ -36,6 +43,27 @@ export const barTableDef: ChartTemplateDef = { }, channels: ["y", "x", "color", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: { rect: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'bar-table-row' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'mark-end', placement: 'auto' })), + }; + }, suppressValueLabels: true, declareLayoutMode: (cs, table, chartProperties) => { // Bar tables split the plot width into 3 horizontal panels diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index f7c8467e..e5e1a137 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -6,6 +6,14 @@ import { makeSortAction } from '../../core/encoding-actions'; import { makeCartesianPivot } from '../../core/pivot'; import { planBandDodge, resolveDodge } from '../../core/band-dodge'; import { snapToBoundHeuristic } from '../../core/field-semantics'; +import { + elementsFromHits, + MUTED_HOVER_STROKE, + type SemanticResolveContext, + type SemanticResolveEvent, + type SemanticTarget, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, } from '../../core/axis-detection'; @@ -14,6 +22,12 @@ import { resolveAsDiscrete, alignStackOrderToColorOrder, } from './utils'; +const rectHoverStyle = (resolvedEncodings: Readonly>) => ({ + rect: resolvedEncodings.opacity?.field + ? { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } + : { opacity: 'contrast' as const }, +}); + /** * Fraction of a lane's pitch a locally-dodged bar fills, leaving a small gap * between the bars inside one band. A house that states its own @@ -38,6 +52,44 @@ const HEATMAP_SCHEME_COLORS: Record = { const DEFAULT_HEATMAP_SCHEME = 'blues'; +function discreteField( + resolvedEncodings: Readonly>, + channels: readonly string[], +): string | undefined { + return channels + .map((channel) => resolvedEncodings[channel]) + .find((encoding) => encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal')) + ?.field; +} + +function tooltipForChannels(encoding: Record, channels: readonly string[]): any[] { + const tooltipKeys = ['field', 'type', 'title', 'aggregate', 'bin', 'timeUnit', 'format', 'formatType']; + return channels.flatMap((channel) => { + const source = encoding[channel]; + if (!source?.field) return []; + const tooltip: Record = {}; + for (const key of tooltipKeys) { + if (source[key] !== undefined) tooltip[key] = source[key]; + } + return [tooltip]; + }); +} + +function resolveBarTarget( + event: SemanticResolveEvent, + context: SemanticResolveContext, + seriesField: string | undefined, +): SemanticTarget | null { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField && event.legendValue !== undefined + ? context.allHits.filter((hit) => hit.datum[legendField] === event.legendValue) + : event.hits; + const elements = elementsFromHits(hits, context.keyField); + return elements.length > 0 + ? { visual: { kind: 'mark', role: event.role }, elements } + : null; +} + function isDivergingHeatmapScheme(scheme: string | undefined): boolean { return scheme === 'blueorange' || scheme === 'redblue'; } @@ -77,6 +129,27 @@ export const barChartDef: ChartTemplateDef = { template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentInteractionUpdate(() => ({ + anchor: 'mark-end', + placement: 'auto', + })), + }; + }, geometryKinds: ['band'], declareLayoutMode: (cs, table) => { const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); @@ -135,6 +208,24 @@ export const pyramidChartDef: ChartTemplateDef = { }, channels: ["x", "y", "color"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'mark-end', placement: 'auto' })), + }; + }, declareLayoutMode: () => ({ axisFlags: { y: { banded: true } }, }), @@ -252,6 +343,24 @@ export const groupedBarChartDef: ChartTemplateDef = { template: { mark: "bar", encoding: {} }, channels: ["x", "y", "group", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'mark-end', placement: 'auto' })), + }; + }, declareLayoutMode: (cs, table, chartProperties) => { const result = detectBandedAxisForceDiscrete(cs, table, { preferAxis: 'x' }); const axis = result?.axis || 'x'; @@ -354,6 +463,24 @@ export const stackedBarChartDef: ChartTemplateDef = { template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'mark-end', placement: 'auto' })), + }; + }, declareLayoutMode: (cs, table) => { const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); return { @@ -364,6 +491,7 @@ export const stackedBarChartDef: ChartTemplateDef = { }, instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); + spec.encoding.tooltip = tooltipForChannels(spec.encoding, ['x', 'y', 'color']); // Apply stack mode const config = ctx.chartProperties; const hasStackSeries = !!ctx.channelSemantics.color?.field; @@ -416,6 +544,19 @@ export const histogramDef: ChartTemplateDef = { }, channels: ["x", "color", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const colorField = resolvedEncodings.color?.field; + const seriesField = discreteField(resolvedEncodings, ['color']); + return { + fields: [...new Set([colorField, '__bin_start', '__bin_end'].filter((field): field is string => !!field))], + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'auto' })), + }; + }, // A binned x is an index axis, not a measure: the reader keys counts off // its intervals, and its identity comes from banding even though the field // is quantitative. Declaring it banded keeps the count off it and stops a @@ -431,6 +572,21 @@ export const histogramDef: ChartTemplateDef = { if (binCount && spec.encoding?.x) { spec.encoding.x.bin = { maxbins: binCount }; } + const x = spec.encoding?.x; + if (x?.field) { + const sourceField = x.field; + spec.transform = [ + ...(spec.transform ?? []), + { bin: x.bin ?? true, field: sourceField, as: ['__bin_start', '__bin_end'] }, + ]; + spec.encoding.x = { + ...x, + field: '__bin_start', + bin: 'binned', + title: x.title ?? sourceField, + }; + spec.encoding.x2 = { field: '__bin_end' }; + } adjustBarMarks(spec, ctx); }, properties: [ @@ -454,6 +610,20 @@ export const heatmapDef: ChartTemplateDef = { template: { mark: "rect", encoding: {} }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'color', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + return { + fields: [...new Set(fields)], + categoryField, + selectableMarks: ['rect'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, undefined), + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'above' })), + }; + }, ownsValueLabels: true, declareLayoutMode: (_channelSemantics, _table, chartProperties) => { const showTextLabels = !!chartProperties?.showTextLabels; diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts index 0fdb598d..b3196cb5 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -3,6 +3,8 @@ import { ChartTemplateDef } from '../../core/types'; import { adjustBarMarks } from './utils'; +import { elementsFromHits } from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; export const candlestickChartDef: ChartTemplateDef = { chart: "Candlestick Chart", @@ -15,6 +17,28 @@ export const candlestickChartDef: ChartTemplateDef = { }, channels: ["x", "open", "high", "low", "close", "column", "row"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = resolvedEncodings.x?.field; + return { + fields: categoryField ? [categoryField] : [], + categoryField, + selectableMarks: ['rule', 'bar', 'tick'], + renderHoverStyles: { + rule: { strokeWidth: 2.5 }, + rect: { opacity: 'contrast' }, + }, + resolve: (event, context) => { + const elements = elementsFromHits(event.hits, context.keyField); + return elements.length > 0 + ? { visual: { kind: 'mark', role: 'candlestick' }, elements } + : null; + }, + presentUpdate: presentInteractionUpdate(() => ({ + anchor: 'top', + placement: 'above', + })), + }; + }, declareLayoutMode: () => ({ axisFlags: { x: { banded: true } }, }), diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index cf17b03d..b06b24c6 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -29,6 +29,14 @@ import { ChartTemplateDef } from '../../core/types'; import { defaultBuildEncodings } from './utils'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; /** * Pick a *sortable* Vega-Lite type for the order encoding. The order channel @@ -64,6 +72,29 @@ export const connectedScatterDef: ChartTemplateDef = { }, channels: ["x", "y", "order", "color", "detail", "column", "row"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'order', 'color', 'detail']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + const kind = event.role === 'line' ? 'path' : 'mark'; + return targetFromHits(hits, context.keyField, { kind, role: event.role }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'auto' })), + }; + }, instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts index 4451301a..8c0d0026 100644 --- a/packages/flint-js/src/vegalite/templates/gantt.ts +++ b/packages/flint-js/src/vegalite/templates/gantt.ts @@ -2,6 +2,13 @@ // Licensed under the MIT License. import { ChartTemplateDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; import { coerceGanttEndpoint, ganttDurationLabelExpression, @@ -32,6 +39,27 @@ export const ganttChartDef: ChartTemplateDef = { }, channels: ["y", "x", "x2", "color", "detail", "column", "row"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'color', 'detail']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: { rect: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'task' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'mark-end', placement: 'auto' })), + }; + }, declareLayoutMode: () => ({ axisFlags: { y: { banded: true } }, }), diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts index 64ce922c..9cd6620a 100644 --- a/packages/flint-js/src/vegalite/templates/jitter.ts +++ b/packages/flint-js/src/vegalite/templates/jitter.ts @@ -4,6 +4,14 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { defaultBuildEncodings } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; export const stripPlotDef: ChartTemplateDef = { chart: "Strip Plot", @@ -13,6 +21,31 @@ export const stripPlotDef: ChartTemplateDef = { }, channels: ["x", "y", "color", "size", "column", "row"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const sizeField = resolvedEncodings.size?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'size']), + categoryField, + seriesField, + legendFields: { + ...(colorField ? { color: colorField } : {}), + ...(sizeField ? { size: sizeField } : {}), + }, + selectableMarks: ['circle'], + renderHoverStyles: { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'point' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'above' })), + }; + }, declareLayoutMode: () => ({ paramOverrides: { defaultBandSize: 50, minStep: 16 }, }), diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index d8597621..aad73b7a 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -4,6 +4,14 @@ import { ChartTemplateDef, ChartPropertyDef, type InstantiateContext } from '../../core/types'; import { defaultBuildEncodings, setMarkProp } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; +import { + elementsFromHits, + MUTED_HOVER_STROKE, + type SemanticResolveContext, + type SemanticResolveEvent, + type SemanticTarget, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; export const interpolateConfigProperty: ChartPropertyDef = { key: "interpolate", label: "Curve", type: "discrete", options: [ @@ -67,6 +75,36 @@ function isContinuousColor(ctx: InstantiateContext): boolean { return type === 'quantitative' || type === 'temporal'; } +function discreteField( + resolvedEncodings: Readonly>, + channels: readonly string[], +): string | undefined { + return channels + .map((channel) => resolvedEncodings[channel]) + .find((encoding) => encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal')) + ?.field; +} + +function resolveLineTarget( + event: SemanticResolveEvent, + context: SemanticResolveContext, + seriesField: string | undefined, +): SemanticTarget | null { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField && event.legendValue !== undefined + ? context.allHits.filter((hit) => hit.datum[legendField] === event.legendValue) + : event.hits; + const elements = elementsFromHits(hits, context.keyField); + if (elements.length === 0) return null; + return { + visual: { + kind: event.role === 'line' ? 'path' : 'mark', + role: event.role, + }, + elements, + }; +} + /** * Vega-Lite splits a line into one segment per datum when color is quantitative, * so nothing visible connects. Mirror ECharts: a neutral line + colored points. @@ -119,6 +157,30 @@ export const lineChartDef: ChartTemplateDef = { channels: ["x", "y", "color", "strokeDash", "detail", "opacity", "column", "row"], markCognitiveChannel: 'position', geometryKinds: ['line', 'point'], + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color', 'detail'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x']); + const seriesField = discreteField(resolvedEncodings, ['color', 'detail']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => resolveLineTarget(event, context, seriesField), + presentUpdate: presentInteractionUpdate(() => ({ + anchor: 'center', + placement: 'auto', + })), + }; + }, declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 11f94086..85fd59c3 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -6,6 +6,14 @@ import { makeSortAction } from '../../core/encoding-actions'; import { makeCartesianPivot } from '../../core/pivot'; import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; import { setMarkProp } from './utils'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; export const lollipopChartDef: ChartTemplateDef = { chart: "Lollipop Chart", @@ -18,6 +26,28 @@ export const lollipopChartDef: ChartTemplateDef = { }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['rule', 'circle'], + renderHoverStyles: { + rule: { stroke: MUTED_HOVER_STROKE }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'lollipop' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'mark-end', placement: 'auto' })), + }; + }, declareLayoutMode: (cs, table) => { const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); return { diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index 02616fb8..397844ce 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -3,6 +3,13 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { computeCircumferencePressure, computeEffectiveBarCount } from '../../core/decisions'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; import { setMarkProp } from './utils'; export const pieChartDef: ChartTemplateDef = { @@ -10,6 +17,25 @@ export const pieChartDef: ChartTemplateDef = { template: { mark: "arc", encoding: {} }, channels: ["size", "color", "column", "row"], markCognitiveChannel: 'area', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['color']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['arc'], + renderHoverStyles: { arc: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'slice' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'arc-centroid', placement: 'auto' })), + }; + }, geometryKinds: ['arc'], instantiate: (spec, ctx) => { // Remap abstract channels to VL channels: diff --git a/packages/flint-js/src/vegalite/templates/rose.ts b/packages/flint-js/src/vegalite/templates/rose.ts index d7517fb0..796fab7c 100644 --- a/packages/flint-js/src/vegalite/templates/rose.ts +++ b/packages/flint-js/src/vegalite/templates/rose.ts @@ -17,6 +17,13 @@ */ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; import { setMarkProp } from './utils'; export const roseChartDef: ChartTemplateDef = { @@ -31,6 +38,27 @@ export const roseChartDef: ChartTemplateDef = { }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'area', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorLegendField = resolvedEncodings.color?.field ?? resolvedEncodings.x?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'color']), + categoryField, + seriesField, + legendFields: colorLegendField ? { color: colorLegendField } : undefined, + selectableMarks: ['arc'], + renderHoverStyles: { arc: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField ?? categoryField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'polar-bar' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'arc-centroid', placement: 'auto' })), + }; + }, // Polar charts have no positional axes — declare no banded axes // so the layout pipeline won't produce step-based sizing. diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 29a21f43..59bd10fe 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -8,6 +8,15 @@ import { defaultBuildEncodings, applyPointSizeScaling, setMarkProp, } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_FILL, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; const isDiscreteType = (t: string | undefined) => t === 'nominal' || t === 'ordinal'; @@ -37,6 +46,34 @@ export const scatterPlotDef: ChartTemplateDef = { template: { mark: "circle", encoding: {} }, channels: ["x", "y", "color", "size", "shape", "opacity", "column", "row"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const shapeOnlyHover = resolvedEncodings.shape?.field && !resolvedEncodings.color?.field + ? { fill: MUTED_HOVER_FILL } + : {}; + const legendFields = Object.fromEntries( + ['color', 'size', 'shape'] + .map((channel) => [channel, resolvedEncodings[channel]?.field]) + .filter((entry): entry is [string, string] => !!entry[1]), + ); + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'size', 'shape']), + seriesField, + legendFields, + selectableMarks: ['circle', 'point'], + renderHoverStyles: { + symbol: { ...shapeOnlyHover, stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'point' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'above' })), + }; + }, instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); // A `shape` encoding only renders distinct glyphs on the `point` mark; @@ -168,6 +205,37 @@ export const rangedDotPlotDef: ChartTemplateDef = { }, channels: ["x", "y", "color"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + const markType = event.hits[0]?.markType; + const kind = markType === 'line' ? 'path' : 'mark'; + const role = event.role === 'legend-item' + ? 'legend-item' + : markType === 'symbol' + ? 'point' + : markType ?? event.role; + return targetFromHits(hits, context.keyField, { kind, role }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'auto' })), + }; + }, instantiate: (spec, ctx) => { const { color, ...rest } = ctx.resolvedEncodings; if (!spec.encoding) spec.encoding = {}; @@ -192,6 +260,34 @@ export const boxplotDef: ChartTemplateDef = { template: { mark: "boxplot", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set([ + ...fieldsFromEncodingChannels(resolvedEncodings, ['color']), + ...(categoryField ? [categoryField] : []), + ])], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['boxplot'], + renderHoverStyles: { + rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + rule: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'distribution' }); + }, + presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'auto' })), + }; + }, declareLayoutMode: (cs, table, chartProperties) => { if (!cs.x?.field || !cs.y?.field) return {}; const result = detectBandedAxisForceDiscrete(cs, table, { preferAxis: 'x' }); diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 93b33dcb..d9a31ab7 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -3,6 +3,13 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { resolveDiscreteType } from '../../core/axis-detection'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentInteractionUpdate } from '../../interactive/chart-update'; import { resolveTotalsMode } from '../../chart-types/waterfall'; /** @@ -21,6 +28,29 @@ export const waterfallChartDef: ChartTemplateDef = { template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: { rect: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legendField ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'waterfall-step' }); + }, + presentUpdate: presentInteractionUpdate((element) => element.records?.[0]?.__wf_color === 'decrease' + ? { anchor: 'bottom', placement: 'below' } + : { anchor: 'top', placement: 'above' }), + }; + }, ownsValueLabels: true, // The steps are drawn on a band scale whatever the column holds — a month // is a step here, not a date. Saying so keeps the layout's category sizing @@ -45,6 +75,7 @@ export const waterfallChartDef: ChartTemplateDef = { // rather than falling back to the raw field names. const xTitle = x?.title ?? xField; const yTitle = y?.title ?? yField; + const colorTitle = color?.title ?? colorField ?? "Type"; if (!spec.encoding) spec.encoding = {}; if (column) spec.encoding.column = column; @@ -188,6 +219,13 @@ export const waterfallChartDef: ChartTemplateDef = { // extreme top/bottom bar tips isn't clipped by the plot edge. const labelPad = showLabels && labelFits ? ((labelFontSize + 8) / plotH) * ySpan : 0; const yDomain = labelPad > 0 ? [yMin - labelPad, yMax + labelPad] : null; + const tooltip = [ + { field: xField, type: x?.type ?? "ordinal", title: xTitle }, + { field: yField, type: y?.type ?? "quantitative", title: yTitle }, + hasTypeCol + ? { field: colorField, type: color?.type ?? "nominal", title: colorTitle } + : { field: "__wf_color", type: "nominal", title: "Type" }, + ]; spec.encoding = { x: xEnc, @@ -217,6 +255,7 @@ export const waterfallChartDef: ChartTemplateDef = { }, legend: { title: "Type" }, }, + tooltip, }, }, // Thin connector lines bridging each bar to the next at the running @@ -236,6 +275,7 @@ export const waterfallChartDef: ChartTemplateDef = { x: { field: xField, type: "ordinal", sort: null, bandPosition: 0 }, x2: { field: "__wf_lead", bandPosition: 1 }, y: { field: "__wf_connector_y", type: "quantitative", title: yTitle }, + tooltip: null, }, }, ]; @@ -265,6 +305,7 @@ export const waterfallChartDef: ChartTemplateDef = { encoding: { y: { field: "__wf_sum", type: "quantitative", title: yTitle }, text: { field: "__wf_sum", type: "quantitative", format: labelFormat }, + tooltip: null, }, }, // Delta inside the bar, muted in the bar's own hue. Skipped when the @@ -284,6 +325,7 @@ export const waterfallChartDef: ChartTemplateDef = { condition: { test: "datum.__wf_color === 'total'", value: "#725a30" }, value: "white", }, + tooltip: null, }, }, ); diff --git a/packages/flint-js/tests/encoding-shorthand.test.ts b/packages/flint-js/tests/encoding-shorthand.test.ts index 724e3b73..4c8c149a 100644 --- a/packages/flint-js/tests/encoding-shorthand.test.ts +++ b/packages/flint-js/tests/encoding-shorthand.test.ts @@ -13,6 +13,9 @@ const DATA = [ { weight: 2.1, mpg: 27, origin: 'US' }, { weight: 1.9, mpg: 29, origin: 'EU' }, ]; +function serializableSpec(spec: unknown): unknown { + return JSON.parse(JSON.stringify(spec)); +} const SEMANTIC = { weight: 'Quantity', mpg: 'Quantity', origin: 'Country' }; @@ -70,7 +73,7 @@ describe('channel field shorthand', () => { }, }); - expect(shorthand).toEqual(explicit); + expect(serializableSpec(shorthand)).toEqual(serializableSpec(explicit)); }); it('supports shorthand strings inside a static-series array', () => { @@ -101,6 +104,6 @@ describe('channel field shorthand', () => { }, }); - expect(shorthand).toEqual(explicit); + expect(serializableSpec(shorthand)).toEqual(serializableSpec(explicit)); }); }); diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts new file mode 100644 index 00000000..1fc156cc --- /dev/null +++ b/packages/flint-js/tests/interactions.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; +import { clickAnnotate, clickGroupHighlight, clickHighlight, normalizeInteractions, select } from '../src/interactive/interactions'; +import { + ClickAnnotateInteraction, + ClickGroupHighlightInteraction, + ClickHighlightInteraction, + SelectInteraction, +} from '../src/interactive/presets'; +import { presentInteractionUpdate } from '../src/interactive/chart-update'; +import { clickTrigger, externalTrigger, hoverTrigger, rectangleTrigger } from '../src/interactive/triggers'; +import type { + InteractionContext, + InteractionDef, + InteractionModifiers, + InteractionPhase, + SemanticTarget, +} from '../src/interactive/interactions'; + +function semanticUpdate( + interaction: InteractionDef, + target: SemanticTarget | null, + context: InteractionContext, + options: { + source?: 'element' | 'region'; + phase?: InteractionPhase; + modifiers?: InteractionModifiers; + } = {}, +) { + return interaction.update({ + type: 'semantic', + source: options.source ?? 'element', + phase: options.phase ?? 'commit', + target, + modifiers: options.modifiers, + }, context); +} + +describe('interaction definitions', () => { + it('declares normalized event sources for built-in presets', () => { + expect(clickHighlight().eventSource).toBe(clickTrigger); + expect(clickGroupHighlight().eventSource).toBe(clickTrigger); + expect(clickAnnotate().eventSource).toBe(clickTrigger); + expect(select().eventSource).toEqual(rectangleTrigger('intersect')); + }); + + it('provides reusable trigger descriptors', () => { + expect(clickTrigger).toEqual({ type: 'element', gesture: 'click' }); + expect(hoverTrigger).toEqual({ type: 'element', gesture: 'hover' }); + expect(rectangleTrigger('contain')).toEqual({ type: 'region', gesture: 'drag', match: 'contain' }); + expect(externalTrigger('story-scroll')).toEqual({ type: 'external', source: 'story-scroll' }); + }); + + it('processes resolved semantic events through normalized update policies', () => { + const context = { chartType: 'Bar Chart', selected: [] }; + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ key: { category: 'A' }, records: [{ category: 'A', value: 4 }] }], + }; + + expect(clickHighlight().update?.({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, context)).toEqual({ + ops: [{ op: 'emphasize', elements: target.elements, mode: 'replace', dimOpacity: 0.25 }], + }); + expect(select().update?.({ + type: 'semantic', source: 'region', phase: 'preview', target, + }, context)).toEqual({ + ops: [{ op: 'emphasize', elements: target.elements, mode: 'replace', dimOpacity: 0.25 }], + }); + }); + + it('creates preset definitions with stable defaults', () => { + expect(clickHighlight()).toBeInstanceOf(ClickHighlightInteraction); + expect(clickHighlight()).toMatchObject({ id: 'click-highlight', eventSource: clickTrigger }); + expect(clickGroupHighlight()).toBeInstanceOf(ClickGroupHighlightInteraction); + expect(clickGroupHighlight()).toMatchObject({ id: 'click-group-highlight', eventSource: clickTrigger }); + expect(clickAnnotate()).toBeInstanceOf(ClickAnnotateInteraction); + expect(clickAnnotate()).toMatchObject({ id: 'click-annotate', eventSource: clickTrigger }); + expect(select()).toBeInstanceOf(SelectInteraction); + expect(select()).toMatchObject({ + id: 'select', + eventSource: rectangleTrigger('intersect'), + }); + }); + + it('clears selection for an empty rectangle commit', () => { + const interaction = select(); + const context = { chartType: 'Waterfall Chart', selected: [{ key: { Step: 'Revenue' } }] }; + expect(semanticUpdate(interaction, null, context, { source: 'region' })) + .toEqual({ ops: [{ op: 'reset' }] }); + }); + + it('maps the deprecated focusOnClick alias without duplicating an explicit definition', () => { + expect(normalizeInteractions(undefined, undefined)).toEqual([]); + expect(normalizeInteractions(undefined, true).map((interaction) => interaction.id)).toEqual(['click-highlight']); + expect(normalizeInteractions([clickHighlight()], true).map((interaction) => interaction.id)).toEqual(['click-highlight']); + }); + + it('rejects duplicate interaction ids', () => { + expect(() => normalizeInteractions([ + clickHighlight({ id: 'selection' }), + select({ id: 'selection' }), + ], false)).toThrow('Duplicate interaction id: "selection".'); + }); + + it('produces replace and toggle emphasis updates', () => { + const interaction = clickHighlight({ dimOpacity: 0.2 }); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ key: { Region: 'West' } }], + }; + const context = { chartType: 'Bar Chart', selected: [] }; + const replace = semanticUpdate(interaction, target, context, { + modifiers: { shift: false, ctrl: false, meta: false }, + }); + const toggle = semanticUpdate(interaction, target, context, { + modifiers: { shift: true, ctrl: false, meta: false }, + }); + + expect(replace?.ops[0]).toMatchObject({ op: 'emphasize', mode: 'replace', dimOpacity: 0.2 }); + expect(toggle?.ops[0]).toMatchObject({ op: 'emphasize', mode: 'toggle' }); + }); + + it('keeps basic clicks local and lets group clicks propagate to the series', () => { + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ key: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }], + }; + const context = { + chartType: 'Grouped Bar Chart', + selected: [], + seriesField: 'Segment', + available: [ + { key: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }, + { key: { key: 'east-consumer' }, records: [{ Segment: 'Consumer' }] }, + { key: { key: 'west-corporate' }, records: [{ Segment: 'Corporate' }] }, + ], + }; + + expect(semanticUpdate(clickHighlight(), target, context)?.ops[0]).toMatchObject({ + elements: [{ key: { key: 'west-consumer' } }], + }); + expect(semanticUpdate(clickGroupHighlight(), target, context)?.ops[0]).toMatchObject({ + elements: [ + { key: { key: 'west-consumer' } }, + { key: { key: 'east-consumer' } }, + ], + }); + }); + + it('expands a Ranged Dot Plot unit to its complete interval in element mode', () => { + const interaction = clickHighlight(); + const target = { + visual: { kind: 'mark' as const, role: 'mark' }, + elements: [{ key: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }], + }; + const context = { + chartType: 'Ranged Dot Plot', + selected: [], + categoryField: 'Country', + seriesField: 'Sex', + available: [ + ...target.elements, + { key: { key: 'us-female' }, records: [{ Country: 'United States', Sex: 'Female' }] }, + { key: { key: 'us-connector' }, records: [{ Country: 'United States' }] }, + { key: { key: 'japan-male' }, records: [{ Country: 'Japan', Sex: 'Male' }] }, + ], + }; + + expect(interaction.actOn?.(target, context)?.elements).toEqual(context.available.slice(0, 3)); + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + elements: context.available.slice(0, 3), + }); + }); + + it('uses implicit rendered color for Waterfall grouping', () => { + const interaction = clickGroupHighlight(); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ key: { key: 'asia' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }], + }; + const context = { + chartType: 'Waterfall Chart', + selected: [], + seriesField: 'Type', + available: [ + ...target.elements, + { key: { key: 'africa' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }, + { key: { key: 'oceania' }, records: [{ Type: 'delta', __wf_color: 'decrease' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + elements: [ + { key: { key: 'asia' } }, + { key: { key: 'africa' } }, + ], + }); + }); + + it('does not infer Waterfall grouping from a field name on another chart', () => { + const interaction = clickGroupHighlight(); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ + key: { key: 'west-consumer' }, + records: [{ Segment: 'Consumer', __wf_color: 'increase' }], + }], + }; + const context = { + chartType: 'Grouped Bar Chart', + selected: [], + seriesField: 'Segment', + available: [ + ...target.elements, + { key: { key: 'east-consumer' }, records: [{ Segment: 'Consumer', __wf_color: 'decrease' }] }, + { key: { key: 'west-corporate' }, records: [{ Segment: 'Corporate', __wf_color: 'increase' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + elements: [ + { key: { key: 'west-consumer' } }, + { key: { key: 'east-consumer' } }, + ], + }); + }); + + it('keeps an already-resolved legend cohort in group mode', () => { + const interaction = clickGroupHighlight(); + const target = { + visual: { kind: 'mark' as const, role: 'legend-item' }, + elements: [ + { key: { key: 'blue-circle' }, records: [{ Color: 'Blue', Shape: 'Circle' }] }, + { key: { key: 'orange-circle' }, records: [{ Color: 'Orange', Shape: 'Circle' }] }, + ], + }; + const context = { + chartType: 'Scatter Plot', + selected: [], + seriesField: 'Color', + available: [ + ...target.elements, + { key: { key: 'blue-square' }, records: [{ Color: 'Blue', Shape: 'Square' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + elements: target.elements, + }); + }); + + it('groups Strip Plot points by their categorical jitter lane', () => { + const interaction = clickGroupHighlight(); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ key: { key: 'control-4.1' }, records: [{ Group: 'Control', Value: 4.1, Color: 'Low' }] }], + }; + const context = { + chartType: 'Strip Plot', + selected: [], + categoryField: 'Group', + seriesField: 'Color', + available: [ + ...target.elements, + { key: { key: 'control-5.2' }, records: [{ Group: 'Control', Value: 5.2, Color: 'High' }] }, + { key: { key: 'treatment-4.1' }, records: [{ Group: 'Treatment', Value: 4.1, Color: 'Low' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + elements: [ + { key: { key: 'control-4.1' } }, + { key: { key: 'control-5.2' } }, + ], + }); + }); + + it('allows callers to override how a group is interpreted', () => { + const interaction = clickGroupHighlight({ + groupBy: (element) => element.records?.[0]?.Region, + }); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ key: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], + }; + const context = { + chartType: 'Grouped Bar Chart', + selected: [], + seriesField: 'Segment', + available: [ + ...target.elements, + { key: { key: 'west-b' }, records: [{ Region: 'West', Segment: 'B' }] }, + { key: { key: 'east-a' }, records: [{ Region: 'East', Segment: 'A' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + elements: [ + { key: { key: 'west-a' } }, + { key: { key: 'west-b' } }, + ], + }); + }); + + it('creates element-level annotation content without selecting the mark', () => { + const interaction = clickAnnotate(); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ + key: { key: 'setosa-1.4' }, + records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], + }], + }; + const context = { chartType: 'Strip Plot', selected: [] }; + + expect(semanticUpdate(interaction, target, context)).toEqual({ + ops: [ + { op: 'annotate', element: target.elements[0], text: '1.4', point: undefined }, + { op: 'emphasize', elements: target.elements, mode: 'replace', dimOpacity: 0.25 }, + ], + }); + expect(semanticUpdate(interaction, null, context)).toEqual({ + ops: [{ op: 'clear-annotation' }, { op: 'reset' }], + }); + }); + + it('lets the chart turn annotation intent into a render plan', () => { + const element = { + key: { key: 'setosa-1.4' }, + records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], + }; + const presentUpdate = presentInteractionUpdate(() => ({ + anchor: 'center', + placement: 'above', + })); + + expect(presentUpdate( + { ops: [{ op: 'annotate', element, text: '1.4' }] }, + { chartType: 'Strip Plot', selected: [] }, + )).toEqual({ + ops: [{ + op: 'render-annotation', + element, + point: undefined, + annotation: { + text: '1.4', + placement: 'above', + anchor: 'center', + }, + }], + }); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/interactive-focus.test.ts b/packages/flint-js/tests/interactive-focus.test.ts index 581978a7..7cc14990 100644 --- a/packages/flint-js/tests/interactive-focus.test.ts +++ b/packages/flint-js/tests/interactive-focus.test.ts @@ -28,7 +28,7 @@ describe('Vega-Lite interactive focus', () => { expect(spec.encoding.detail).toEqual({ field: '__flint_focus_key', type: 'nominal' }); expect(spec.encoding.opacity).toEqual({ condition: { param: '__flint_focus', value: 1 }, - value: 0.3, + value: 0.25, }); }); diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts new file mode 100644 index 00000000..e562a4af --- /dev/null +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -0,0 +1,1053 @@ +import { describe, expect, it } from 'vitest'; +import { changeset, parse, View } from 'vega'; +import { compile } from 'vega-lite'; +import { assembleVegaLite } from '../src/vegalite/assemble'; +import { clickHighlight, select } from '../src/interactive/interactions'; +import { MUTED_HOVER_FILL, MUTED_HOVER_STROKE } from '../src/core/interaction-semantics'; +import { barChartDef, pyramidChartDef } from '../src/vegalite/templates/bar'; +import { barTableDef } from '../src/vegalite/templates/bar-table'; +import { rangedDotPlotDef, scatterPlotDef } from '../src/vegalite/templates/scatter'; +import { + addVegaLiteInteractions, + arcIntersectsRect, + boundsIntersectRect, + clientRectToLayoutRect, + clientToPlotPoint, + clientToLayoutPoint, + HOVER_STORE, + injectVegaInteractionStore, + INTERACTION_KEY, + INTERACTION_STORE, + LEGEND_HOVER_STORE, + LEGEND_SELECTION_STORE, + plotToClientPoint, + sceneItems, +} from '../src/vegalite/semantic-interactions'; + +function instrument(spec: Record, interactions = [clickHighlight()]) { + const plan = addVegaLiteInteractions(spec, interactions); + const compiled = compile(spec as any).spec as Record; + if (plan) injectVegaInteractionStore(compiled, plan); + return { plan, compiled }; +} + +function allSceneItems(view: View): any[] { + const items: any[] = []; + const visit = (item: any): void => { + if (!item) return; + if (item.mark) items.push(item); + if (Array.isArray(item.items)) item.items.forEach(visit); + }; + visit((view.scenegraph() as any).root); + return items; +} + +describe('Vega-Lite semantic interactions', () => { + it('keeps concatenated-chart hover paint geometry invariant', () => { + const renderHoverStyles = (definition: typeof pyramidChartDef) => + definition.semanticInteractions?.({ resolvedEncodings: {} }).renderHoverStyles; + expect(renderHoverStyles(pyramidChartDef)).toEqual({ rect: { opacity: 'contrast' } }); + expect(renderHoverStyles(barTableDef)).toEqual({ rect: { opacity: 'contrast' } }); + }); + + it('updates arc opacity in a composed Rose chart', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Direction'], + categoryField: 'Direction', + selectableMarks: ['arc'], + }, + data: { + values: [ + { Direction: 'N', Speed: 12 }, + { Direction: 'E', Speed: 20 }, + { Direction: 'S', Speed: 8 }, + { Direction: 'W', Speed: 16 }, + ], + }, + encoding: { + theta: { field: 'Direction', type: 'nominal', stack: true }, + }, + layer: [ + { + mark: { type: 'arc', stroke: 'white' }, + encoding: { + radius: { field: 'Speed', type: 'quantitative', scale: { type: 'sqrt' } }, + color: { field: 'Direction', type: 'nominal' }, + }, + }, + { + mark: { type: 'text', radiusOffset: 15 }, + encoding: { text: { field: 'Direction', type: 'nominal' } }, + }, + ], + }; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const before = sceneItems(view).filter((item) => item.mark.marktype === 'arc'); + const selectedKey = before[0]?.datum[INTERACTION_KEY]; + expect(selectedKey).toBeTypeOf('string'); + + view.change(INTERACTION_STORE, changeset().insert([{ key: selectedKey }])); + await view.runAsync(); + const opacities = sceneItems(view) + .filter((item) => item.mark.marktype === 'arc') + .map((item) => ({ key: item.datum[INTERACTION_KEY], opacity: item.opacity })); + + expect(opacities.filter((item) => item.opacity === 1).map((item) => item.key)).toEqual([selectedKey]); + expect(opacities.filter((item) => item.key !== selectedKey).every((item) => item.opacity === 0.25)).toBe(true); + }); + + it('maps a synthesized Rose color legend back to its category field', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Direction: 'N', Speed: 12 }, + { Direction: 'E', Speed: 20 }, + ], + }, + semantic_types: { Direction: 'Category', Speed: 'Quantity' }, + chart_spec: { + chartType: 'Rose Chart', + encodings: { x: { field: 'Direction' }, y: { field: 'Speed' } }, + }, + } as any) as any; + + expect(spec._interactionSemantics).toMatchObject({ + fields: ['Direction'], + categoryField: 'Direction', + legendFields: { color: 'Direction' }, + }); + }); + + it('uses one local hover rule across color semantics', () => { + const makeSpec = (colorSemanticType: 'Category' | 'Quantity') => assembleVegaLite({ + data: { values: [ + { Region: 'North', Sales: 10, Color: colorSemanticType === 'Category' ? 'Retail' : 0.2 }, + { Region: 'South', Sales: 14, Color: colorSemanticType === 'Category' ? 'Enterprise' : 0.8 }, + ] }, + semantic_types: { Region: 'Category', Sales: 'Quantity', Color: colorSemanticType }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'Region' }, y: { field: 'Sales' }, color: { field: 'Color' } }, + }, + } as any) as any; + + expect(makeSpec('Category')._interactionSemantics.renderHoverStyles).toEqual({ + rect: { opacity: 'contrast' }, + }); + expect(makeSpec('Quantity')._interactionSemantics.renderHoverStyles).toEqual({ + rect: { opacity: 'contrast' }, + }); + }); + + it('uses an outline when a bar opacity channel is data-encoded', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Region: 'North', Sales: 10, Confidence: 0.4 }, + { Region: 'South', Sales: 14, Confidence: 0.8 }, + ] }, + semantic_types: { Region: 'Category', Sales: 'Quantity', Confidence: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Region', y: 'Sales', opacity: 'Confidence' }, + }, + } as any) as any; + + expect(spec._interactionSemantics.renderHoverStyles).toEqual({ + rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 }, + }); + }); + + it('makes generated legend symbols and labels physical click targets', () => { + const spec = { + data: { values: [{ X: 1, Y: 2, Color: 'Blue' }] }, + mark: 'point', + encoding: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Color', type: 'nominal' }, + }, + _interactionSemantics: { + fields: ['X', 'Y', 'Color'], + seriesField: 'Color', + legendFields: { color: 'Color' }, + selectableMarks: ['point'], + }, + }; + const { compiled } = instrument(spec); + expect(compiled.legends.length).toBeGreaterThan(0); + for (const legend of compiled.legends) { + expect(legend.encode.symbols.interactive).toBe(true); + expect(legend.encode.symbols.update.cursor.value).toBe('pointer'); + expect(legend.encode.labels.interactive).toBe(true); + expect(legend.encode.labels.update.cursor.value).toBe('pointer'); + } + }); + + it('uses pointer cursors only for marks with click interactions', () => { + const spec = { + data: { values: [{ X: 1, Y: 2 }] }, + mark: 'point', + encoding: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['X', 'Y'], + selectableMarks: ['point'], + }, + }; + const clickable = instrument(structuredClone(spec)).compiled; + const selectable = instrument(structuredClone(spec), [select()]).compiled; + const symbolMark = (compiled: Record) => compiled.marks + .flatMap((mark: Record) => mark.marks ?? [mark]) + .find((mark: Record) => mark.type === 'symbol'); + + expect(symbolMark(clickable).encode.update.cursor.value).toBe('pointer'); + expect(symbolMark(selectable).encode.update.cursor).toBeUndefined(); + }); + + it('compiles template hover paint into native mark encodings', async () => { + for (const [mark, renderMark, width] of [['rect', 'rect', 1.5], ['circle', 'symbol', 2]] as const) { + const spec: Record = { + data: { values: [{ X: 'A', Y: 2 }] }, + mark, + encoding: { + x: { field: 'X', type: 'nominal' }, + y: { field: 'Y', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['X', 'Y'], + selectableMarks: [mark], + renderHoverStyles: { [renderMark]: { stroke: '#59636d', strokeWidth: width } }, + }, + }; + + const plan = addVegaLiteInteractions(spec, [clickHighlight()]); + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const item = sceneItems(view).find((candidate) => candidate.datum[INTERACTION_KEY]); + const key = item?.datum[INTERACTION_KEY]; + view.change(HOVER_STORE, changeset().insert([{ key }])); + await view.runAsync(); + const hovered = sceneItems(view).find((candidate) => candidate.datum[INTERACTION_KEY] === key); + + expect(hovered?.stroke).toBe('#59636d'); + expect(hovered?.strokeWidth).toBe(width); + } + }); + + it('adds a light hover fill only to shape-only scatter points', () => { + const hoverStyle = (resolvedEncodings: Record) => + scatterPlotDef.semanticInteractions!({ resolvedEncodings }).renderHoverStyles?.symbol; + + expect(hoverStyle({ shape: { field: 'Shape', type: 'nominal' } })).toMatchObject({ + fill: MUTED_HOVER_FILL, + }); + expect(hoverStyle({ + shape: { field: 'Shape', type: 'nominal' }, + color: { field: 'Color', type: 'nominal' }, + })).not.toHaveProperty('fill'); + }); + + it('applies one lollipop hover key to both stem and point layers', async () => { + const spec: Record = { + data: { values: [{ Category: 'A', Value: 2 }] }, + layer: [ + { mark: { type: 'rule', strokeWidth: 1.5 }, encoding: {} }, + { mark: { type: 'circle', size: 80 }, encoding: {} }, + ], + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Category', 'Value'], + selectableMarks: ['rule', 'circle'], + renderHoverStyles: { + rule: { stroke: '#59636d' }, + symbol: { stroke: '#59636d', strokeWidth: 2 }, + }, + }, + }; + + const plan = addVegaLiteInteractions(spec, [clickHighlight()]); + + expect(spec.layer[0].encoding.detail.field).toBe(INTERACTION_KEY); + expect(spec.layer[1].encoding.detail.field).toBe(INTERACTION_KEY); + + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const key = sceneItems(view).find((item) => item.mark.marktype === 'symbol')?.datum[INTERACTION_KEY]; + view.change(HOVER_STORE, changeset().insert([{ key }])); + await view.runAsync(); + const unit = sceneItems(view).filter((item) => item.datum[INTERACTION_KEY] === key); + + expect(unit.find((item) => item.mark.marktype === 'rule')?.stroke).toBe('#59636d'); + expect(unit.find((item) => item.mark.marktype === 'symbol')?.stroke).toBe('#59636d'); + }); + + it('preserves base strokes for line and composite charts before hover', async () => { + const cases: Record[] = [ + { + data: { values: [{ X: 1, Y: 2, Group: 'A' }] }, + mark: 'point', + encoding: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Group', type: 'nominal' }, + }, + _interactionSemantics: { + fields: ['X', 'Y', 'Group'], selectableMarks: ['point'], + renderHoverStyles: { symbol: { stroke: '#59636d', strokeWidth: 2 } }, + }, + }, + { + data: { values: [{ X: 0, Y: 1 }, { X: 1, Y: 2 }] }, + mark: { type: 'line', strokeWidth: 2 }, + encoding: { x: { field: 'X', type: 'quantitative' }, y: { field: 'Y', type: 'quantitative' } }, + _interactionSemantics: { + fields: ['X', 'Y'], selectableMarks: ['line'], + renderHoverStyles: { line: { strokeWidth: 3 } }, + }, + }, + { + data: { values: [{ X: 'A', Low: 1, High: 4, Open: 2, Close: 3 }] }, + encoding: { x: { field: 'X', type: 'nominal' } }, + layer: [ + { mark: 'rule', encoding: { y: { field: 'Low', type: 'quantitative' }, y2: { field: 'High' } } }, + { mark: 'bar', encoding: { y: { field: 'Open', type: 'quantitative' }, y2: { field: 'Close' } } }, + ], + _interactionSemantics: { + fields: ['X'], selectableMarks: ['rule', 'bar'], + renderHoverStyles: { rule: { strokeWidth: 2.5 }, rect: { stroke: '#59636d', strokeWidth: 1.5 } }, + }, + }, + { + data: { values: [ + { Group: 'A', Value: 1 }, { Group: 'A', Value: 2 }, { Group: 'A', Value: 3 }, + ] }, + mark: 'boxplot', + encoding: { + x: { field: 'Group', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Group'], selectableMarks: ['boxplot'], + renderHoverStyles: { + rect: { stroke: '#59636d', strokeWidth: 2 }, + rule: { stroke: '#59636d', strokeWidth: 2 }, + symbol: { stroke: '#59636d', strokeWidth: 2 }, + }, + }, + }, + ]; + + for (const spec of cases) { + const plan = addVegaLiteInteractions(spec, [clickHighlight()]); + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const strokes = sceneItems(view).filter((item) => + item.datum[INTERACTION_KEY] + && (item.mark.marktype === 'line' || item.mark.marktype === 'rule' || item.mark.marktype === 'symbol')); + + expect(strokes.length).toBeGreaterThan(0); + expect(strokes.every((item) => item.stroke !== 'transparent' && item.strokeWidth > 0)).toBe(true); + } + }); + + it('tests rectangle selection against an arc sector rather than its broad bounds', () => { + const quarter = { + mark: { marktype: 'arc' }, + x: 100, + y: 100, + innerRadius: 0, + outerRadius: 80, + endAngle: 0, + startAngle: Math.PI / 2, + }; + const donutQuarter = { ...quarter, innerRadius: 40 }; + const clockwiseQuarter = { ...quarter, startAngle: 0, endAngle: Math.PI / 2 }; + const roseWedge = { ...quarter, startAngle: 0, endAngle: Math.PI / 6 }; + + expect(arcIntersectsRect(quarter, { x1: 130, y1: 40, x2: 160, y2: 70 })).toBe(true); + expect(arcIntersectsRect(clockwiseQuarter, { x1: 130, y1: 40, x2: 160, y2: 70 })).toBe(true); + expect(arcIntersectsRect(clockwiseQuarter, { x1: 40, y1: 130, x2: 70, y2: 160 })).toBe(false); + expect(arcIntersectsRect(roseWedge, { x1: 110, y1: 20, x2: 140, y2: 50 })).toBe(true); + expect(arcIntersectsRect(roseWedge, { x1: 40, y1: 130, x2: 70, y2: 160 })).toBe(false); + expect(arcIntersectsRect(quarter, { x1: 40, y1: 130, x2: 70, y2: 160 })).toBe(false); + expect(arcIntersectsRect(donutQuarter, { x1: 95, y1: 95, x2: 105, y2: 105 })).toBe(false); + expect(arcIntersectsRect(quarter, { x1: 15, y1: 15, x2: 185, y2: 185 }, true)).toBe(true); + expect(arcIntersectsRect(quarter, { x1: 90, y1: 90, x2: 180, y2: 180 }, true)).toBe(false); + }); + + it('does not select adjacent cells that only touch the selection boundary', () => { + const selection = { x1: 10, y1: 10, x2: 30, y2: 30 }; + + expect(boundsIntersectRect({ x1: 10, y1: 10, x2: 30, y2: 30 }, selection)).toBe(true); + expect(boundsIntersectRect({ x1: 30, y1: 10, x2: 50, y2: 30 }, selection)).toBe(false); + expect(boundsIntersectRect({ x1: 10, y1: 30, x2: 30, y2: 50 }, selection)).toBe(false); + expect(boundsIntersectRect({ x1: 29.75, y1: 10, x2: 50, y2: 30 }, selection)).toBe(false); + expect(boundsIntersectRect({ x1: 29, y1: 10, x2: 50, y2: 30 }, selection)).toBe(true); + }); + + it('round-trips coordinates through SVG scaling and Vega plot padding', () => { + const space = { + rect: { left: 100, top: 50, width: 250, height: 150 } as DOMRect, + logicalWidth: 500, + logicalHeight: 300, + originX: 60, + originY: 30, + plotWidth: 400, + plotHeight: 240, + }; + + const plot = clientToPlotPoint({ x: 180, y: 100 }, space); + expect(plot).toEqual({ x: 100, y: 70 }); + expect(plotToClientPoint(plot, space)).toEqual({ x: 180, y: 100 }); + expect(clientToPlotPoint({ x: 0, y: 0 }, space)).toEqual({ x: 0, y: 0 }); + expect(clientToLayoutPoint( + { x: 180, y: 100 }, + { left: 20, top: 20, width: 320, height: 160 }, + { width: 400, height: 200 }, + )).toEqual({ x: 200, y: 100 }); + expect(clientRectToLayoutRect( + { left: 60, top: 40, right: 260, bottom: 140 }, + { left: 20, top: 20, width: 320, height: 160 }, + { width: 400, height: 200 }, + )).toEqual({ left: 50, top: 25, width: 250, height: 125 }); + }); + + it('translates concat marks by ancestor group offsets', () => { + const view = { + scenegraph: () => ({ + root: { + items: [{ + mark: { marktype: 'group' }, + x: 240, + y: 12, + items: [{ + mark: { marktype: 'bar' }, + datum: { [INTERACTION_KEY]: '20-29|F' }, + x: 10, + y: 20, + bounds: { x1: 10, x2: 80, y1: 20, y2: 50 }, + }], + }], + }, + }), + }; + + expect(sceneItems(view)[0]).toMatchObject({ + x: 250, + y: 32, + bounds: { x1: 250, x2: 320, y1: 32, y2: 62 }, + }); + }); + + it('keys a basic bar by its category and emits a valid retained store', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Region'], categoryField: 'Region', selectableMarks: ['bar'], + }, + data: { values: [{ Region: 'West', Sales: 10 }] }, + mark: 'bar', + encoding: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Sales', type: 'quantitative' }, + }, + }; + + const { plan, compiled } = instrument(spec); + + expect(plan).toMatchObject({ fields: ['Region'], categoryField: 'Region' }); + expect(spec.transform).toContainEqual(expect.objectContaining({ as: INTERACTION_KEY })); + expect(spec.encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(compiled.data).toContainEqual({ name: INTERACTION_STORE, values: [] }); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('uses category plus series for grouped-bar element identity', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Region', 'Segment'], + categoryField: 'Region', + seriesField: 'Segment', + selectableMarks: ['bar'], + }, + mark: 'bar', + encoding: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Sales', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + xOffset: { field: 'Segment', type: 'nominal' }, + }, + }; + + const { plan } = instrument(spec, [clickHighlight(), select()]); + + expect(plan).toMatchObject({ + fields: ['Region', 'Segment'], + categoryField: 'Region', + seriesField: 'Segment', + }); + }); + + it('uses both discrete axes for a heatmap cell', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Month', 'Product'], categoryField: 'Month', selectableMarks: ['rect'], + }, + mark: 'rect', + encoding: { + x: { field: 'Month', type: 'ordinal' }, + y: { field: 'Product', type: 'nominal' }, + color: { field: 'Revenue', type: 'quantitative' }, + }, + }; + + expect(instrument(spec).plan?.fields).toEqual(['Month', 'Product']); + }); + + it('instruments concatenated pyramid bars with constant opacity', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Age', 'Gender'], + categoryField: 'Age', + seriesField: 'Gender', + selectableMarks: ['bar'], + }, + data: { values: [{ Age: '20-29', Population: 10, Gender: 'F' }] }, + hconcat: [ + { + mark: 'bar', + transform: [{ filter: { field: 'Gender', equal: 'F' } }], + encoding: { + x: { field: 'Population', type: 'quantitative' }, + y: { field: 'Age', type: 'ordinal' }, + opacity: { value: 0.9 }, + }, + }, + { + mark: 'bar', + transform: [{ filter: { field: 'Gender', equal: 'M' } }], + encoding: { + x: { field: 'Population', type: 'quantitative' }, + y: { field: 'Age', type: 'ordinal' }, + opacity: { value: 0.9 }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [select()]); + + expect(plan).toMatchObject({ + fields: ['Age', 'Gender'], + categoryField: 'Age', + seriesField: 'Gender', + }); + expect(spec.hconcat[0].encoding.opacity.condition.value).toBe(0.9); + expect(spec.hconcat[1].encoding.opacity.condition.value).toBe(0.9); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('hovers Pyramid bars without changing their geometry or center gap', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Age', 'Gender'], categoryField: 'Age', seriesField: 'Gender', + selectableMarks: ['bar'], + renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } }, + }, + data: { values: [ + { Age: '20-29', Population: 10, Gender: 'F' }, + { Age: '20-29', Population: 12, Gender: 'M' }, + ] }, + spacing: 0, + hconcat: [ + { + mark: 'bar', transform: [{ filter: { field: 'Gender', equal: 'F' } }], + encoding: { + x: { field: 'Population', type: 'quantitative', scale: { reverse: true } }, + y: { field: 'Age', type: 'ordinal' }, opacity: { value: 0.9 }, + }, + }, + { + mark: 'bar', transform: [{ filter: { field: 'Gender', equal: 'M' } }], + encoding: { + x: { field: 'Population', type: 'quantitative' }, + y: { field: 'Age', type: 'ordinal', axis: null }, opacity: { value: 0.9 }, + }, + }, + ], + }; + const { compiled } = instrument(spec, [clickHighlight()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const target = sceneItems(view).find((item) => item.mark.marktype === 'rect'); + const geometry = { + x: target.x, x2: target.x2, y: target.y, y2: target.y2, + width: target.width, height: target.height, + }; + const opacity = target.opacity; + + view.change(HOVER_STORE, changeset().insert([{ key: target.datum[INTERACTION_KEY] }])); + await view.runAsync(); + const hovered = sceneItems(view).find((item) => item.datum[INTERACTION_KEY] === target.datum[INTERACTION_KEY]); + + expect(hovered?.opacity).toBe(opacity); + expect({ + x: hovered?.x, x2: hovered?.x2, y: hovered?.y, y2: hovered?.y2, + width: hovered?.width, height: hovered?.height, + }).toEqual(geometry); + expect(hovered?.stroke).toBe(MUTED_HOVER_STROKE); + expect(hovered?.strokeWidth).toBe(1.5); + }); + + it.each([ + { authoredOpacity: 1, hoveredOpacity: 0.9 }, + { authoredOpacity: 0.6, hoveredOpacity: 1 }, + ])('contrasts target opacity from $authoredOpacity without changing peers', async ({ authoredOpacity, hoveredOpacity }) => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], + renderHoverStyles: { rect: { opacity: 'contrast' } }, + }, + data: { values: [ + { Category: 'Alpha', Value: 10 }, + { Category: 'Beta', Value: 12 }, + ] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + opacity: { value: authoredOpacity }, + }, + }; + const { compiled } = instrument(spec, [clickHighlight()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const [target, peer] = bars; + const targetKey = target.datum[INTERACTION_KEY]; + const peerKey = peer.datum[INTERACTION_KEY]; + + view.change(HOVER_STORE, changeset().insert([{ key: targetKey }])); + await view.runAsync(); + let renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const hoveredTarget = renderedBars.find((item) => item.datum[INTERACTION_KEY] === targetKey); + const hoverPeer = renderedBars.find((item) => item.datum[INTERACTION_KEY] === peerKey); + expect(hoveredTarget?.opacity).toBe(hoveredOpacity); + expect(hoverPeer?.opacity).toBe(authoredOpacity); + + view.change(INTERACTION_STORE, changeset().insert([{ key: targetKey }])); + await view.runAsync(); + renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === peerKey)?.opacity).toBe(0.25); + }); + + it('preserves a data-encoded opacity channel and uses an outline on hover', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], + renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } }, + }, + data: { values: [ + { Category: 'Alpha', Value: 10, Confidence: 0.4 }, + { Category: 'Beta', Value: 12, Confidence: 0.8 }, + ] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + opacity: { field: 'Confidence', type: 'quantitative', scale: null }, + }, + }; + const { compiled } = instrument(spec, [clickHighlight()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const [target, peer] = bars; + + view.change(HOVER_STORE, changeset().insert([{ key: target.datum[INTERACTION_KEY] }])); + await view.runAsync(); + let renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === target.datum[INTERACTION_KEY])?.opacity).toBe(0.4); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === target.datum[INTERACTION_KEY])?.stroke).toBe(MUTED_HOVER_STROKE); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === peer.datum[INTERACTION_KEY])?.opacity).toBe(0.8); + + view.change(INTERACTION_STORE, changeset().insert([{ key: target.datum[INTERACTION_KEY] }])); + await view.runAsync(); + renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === peer.datum[INTERACTION_KEY])?.opacity).toBe(0.25); + }); + + it.each(['click', 'hover'] as const)( + 'resolves a ranged-dot endpoint %s to only the physical semantic unit', + (gesture) => { + const resolve = rangedDotPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Life expectancy', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + color: { field: 'Sex', type: 'nominal' }, + }, + }).resolve; + const male = { + datum: { [INTERACTION_KEY]: 'Japan|81.5|Male', Country: 'Japan', Sex: 'Male' }, + source: 'mark' as const, + markType: 'symbol', + }; + const female = { + datum: { [INTERACTION_KEY]: 'Japan|87.6|Female', Country: 'Japan', Sex: 'Female' }, + source: 'mark' as const, + }; + const connector = { + datum: { [INTERACTION_KEY]: 'Japan|connector', Country: 'Japan' }, + source: 'mark' as const, + }; + const other = { + datum: { [INTERACTION_KEY]: 'Brazil|76|Female', Country: 'Brazil', Sex: 'Female' }, + source: 'mark' as const, + }; + + const target = resolve( + { gesture, role: 'mark', hits: [male] }, + { + allHits: [male, female, connector, other], + keyField: INTERACTION_KEY, + categoryField: 'Country', + seriesField: 'Sex', + }, + ); + + expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual([ + 'Japan|81.5|Male', + ]); + expect(target?.visual).toEqual({ kind: 'mark', role: 'point' }); + }, + ); + + it('highlights only the hovered legend item until it is clicked', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Region: 'West', Segment: 'Consumer', Value: 10 }, + { Region: 'West', Segment: 'Corporate', Value: 12 }, + { Region: 'East', Segment: 'Consumer', Value: 8 }, + { Region: 'East', Segment: 'Corporate', Value: 9 }, + ] }, + semantic_types: { Region: 'Category', Segment: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'Region', y: 'Value', color: 'Segment' }, + }, + } as never) as any; + const { compiled } = instrument(spec, [clickHighlight()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + view.change(LEGEND_HOVER_STORE, changeset().insert([{ channel: 'color', value: 'Consumer' }])); + await view.runAsync(); + + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(bars.every((item) => item.opacity === 1)).toBe(true); + let legendItems = allSceneItems(view).filter((item) => + item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); + expect(legendItems.filter((item) => item.datum.value === 'Consumer').every((item) => item.opacity === 1)).toBe(true); + expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 1)).toBe(true); + const legendLabels = legendItems.filter((item) => item.mark.role === 'legend-label'); + const consumerLabel = legendLabels.find((item) => item.datum.value === 'Consumer'); + const corporateLabel = legendLabels.find((item) => item.datum.value === 'Corporate'); + expect(consumerLabel?.fill).toBe(corporateLabel?.fill); + + view.change(LEGEND_HOVER_STORE, changeset().remove(() => true)); + view.change(LEGEND_SELECTION_STORE, changeset().insert([{ channel: 'color', value: 'Consumer' }])); + const consumerKeys = bars + .filter((item) => item.datum.Segment === 'Consumer') + .map((item) => ({ key: item.datum[INTERACTION_KEY] })); + view.change(INTERACTION_STORE, changeset().insert(consumerKeys)); + await view.runAsync(); + + const selectedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(selectedBars.filter((item) => item.datum.Segment === 'Consumer').every((item) => item.opacity === 1)).toBe(true); + expect(selectedBars.filter((item) => item.datum.Segment === 'Corporate').every((item) => item.opacity === 0.25)).toBe(true); + legendItems = allSceneItems(view).filter((item) => + item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); + expect(legendItems.filter((item) => item.datum.value === 'Consumer').every((item) => item.opacity === 1)).toBe(true); + expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 0.25)).toBe(true); + }); + + it('calculates keys inside a Bar Table panel with its own named data', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], + }, + datasets: { rows: [{ Category: 'Alpha', Value: 10 }] }, + hconcat: [ + { + data: { name: 'rows' }, + mark: 'bar', + transform: [{ aggregate: [{ op: 'sum', field: 'Value', as: 'Value' }], groupby: ['Category'] }], + encoding: { + x: { field: 'Value', type: 'quantitative' }, + y: { field: 'Category', type: 'nominal' }, + color: { value: '#41a25f' }, + }, + }, + { + data: { name: 'rows' }, + mark: 'text', + encoding: { + y: { field: 'Category', type: 'nominal' }, + text: { field: 'Value', type: 'quantitative' }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [select()]); + + expect(plan).toMatchObject({ fields: ['Category'], categoryField: 'Category' }); + expect(spec.hconcat[0].transform).toContainEqual(expect.objectContaining({ as: INTERACTION_KEY })); + expect(spec.hconcat[1].transform).toBeUndefined(); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('uses template-owned quantitative fields for point identity', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Horsepower', 'Efficiency'], + selectableMarks: ['circle'], + markClick: 'element', + }, + data: { values: [{ Horsepower: 120, Efficiency: 32 }] }, + mark: { type: 'circle', opacity: 0.7 }, + encoding: { + x: { field: 'Horsepower', type: 'quantitative' }, + y: { field: 'Efficiency', type: 'quantitative' }, + }, + }; + + const { plan, compiled } = instrument(spec, [clickHighlight(), select()]); + + expect(plan).toMatchObject({ fields: ['Horsepower', 'Efficiency'] }); + expect(spec).not.toHaveProperty('_interactionSemantics'); + expect(spec.encoding.opacity.condition.value).toBe(0.7); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('coalesces lollipop rule and circle layers under one semantic key', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Value'], + categoryField: 'Category', + selectableMarks: ['rule', 'circle'], + markClick: 'element', + }, + data: { values: [{ Category: 'A', Value: 12 }] }, + layer: [ + { + mark: 'rule', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + y2: { datum: 0 }, + }, + }, + { + mark: 'circle', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [select()]); + + expect(plan).toMatchObject({ fields: ['Category', 'Value'], categoryField: 'Category' }); + expect(spec.layer[0].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(spec.layer[1].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('keeps a mark click local without a declared series', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }).resolve; + const westConsumer = { datum: { [INTERACTION_KEY]: 'West|Consumer', Region: 'West' }, source: 'mark' as const }; + const westCorporate = { datum: { [INTERACTION_KEY]: 'West|Corporate', Region: 'West' }, source: 'mark' as const }; + const eastConsumer = { datum: { [INTERACTION_KEY]: 'East|Consumer', Region: 'East' }, source: 'mark' as const }; + + const target = resolve( + { gesture: 'click', role: 'mark', hits: [westConsumer] }, + { + allHits: [westConsumer, westCorporate, eastConsumer], + keyField: INTERACTION_KEY, + categoryField: 'Region', + }, + ); + + expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['West|Consumer']); + }); + + it('keeps a mark click local when a series field is declared', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const westConsumer = { datum: { [INTERACTION_KEY]: 'West|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + const westCorporate = { datum: { [INTERACTION_KEY]: 'West|Corporate', Segment: 'Corporate' }, source: 'mark' as const }; + const eastConsumer = { datum: { [INTERACTION_KEY]: 'East|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + + const target = resolve( + { gesture: 'click', role: 'mark', hits: [westConsumer] }, + { + allHits: [westConsumer, westCorporate, eastConsumer], + keyField: INTERACTION_KEY, + seriesField: 'Segment', + }, + ); + + expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['West|Consumer']); + }); + + it.each(['click', 'hover'] as const)('lets the template resolver expand a legend %s to its series cohort', (gesture) => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const westConsumer = { datum: { [INTERACTION_KEY]: 'West|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + const westCorporate = { datum: { [INTERACTION_KEY]: 'West|Corporate', Segment: 'Corporate' }, source: 'mark' as const }; + const eastConsumer = { datum: { [INTERACTION_KEY]: 'East|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + + const target = resolve( + { gesture, role: 'legend-item', hits: [], legendValue: 'Consumer' }, + { + allHits: [westConsumer, westCorporate, eastConsumer], + keyField: INTERACTION_KEY, + seriesField: 'Segment', + }, + ); + + expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual([ + 'West|Consumer', + 'East|Consumer', + ]); + }); + + it('resolves color and shape legends by their own fields', () => { + const resolve = scatterPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Color', type: 'nominal' }, + shape: { field: 'Shape', type: 'nominal' }, + }, + }).resolve; + const hits = [ + { datum: { [INTERACTION_KEY]: 'a', Color: 'Blue', Shape: 'Circle' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'b', Color: 'Orange', Shape: 'Circle' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'c', Color: 'Blue', Shape: 'Square' }, source: 'mark' as const }, + ]; + const context = { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Color' }; + + const color = resolve( + { gesture: 'click', role: 'legend-item', hits: [], legendValue: 'Blue', legendField: 'Color' }, + context, + ); + const shape = resolve( + { gesture: 'click', role: 'legend-item', hits: [], legendValue: 'Circle', legendField: 'Shape' }, + context, + ); + + expect(color?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['a', 'c']); + expect(shape?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['a', 'b']); + }); + + it('resolves a size legend independently from color', () => { + const resolve = scatterPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Color', type: 'nominal' }, + size: { field: 'Size', type: 'nominal' }, + }, + }).resolve; + const hits = [ + { datum: { [INTERACTION_KEY]: 'a', Color: 'Blue', Size: 'Large' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'b', Color: 'Orange', Size: 'Large' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'c', Color: 'Blue', Size: 'Small' }, source: 'mark' as const }, + ]; + const target = resolve( + { gesture: 'click', role: 'legend-item', hits: [], legendValue: 'Large', legendField: 'Size' }, + { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Color' }, + ); + + expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['a', 'b']); + }); + + it('compiles grouped-bar semantic fields from its template', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Class: '1st', Sex: 'Female', Survival: 97 }, + { Class: '1st', Sex: 'Male', Survival: 34 }, + ], + }, + semantic_types: { Class: 'Category', Sex: 'Category', Survival: 'Quantity' }, + chart_spec: { + chartType: 'Grouped Bar Chart', + encodings: { + x: { field: 'Class' }, + y: { field: 'Survival' }, + group: { field: 'Sex' }, + }, + }, + } as any) as any; + + expect(spec._interactionSemantics).toMatchObject({ + fields: ['Class', 'Survival', 'Sex'], + categoryField: 'Class', + seriesField: 'Sex', + selectableMarks: ['bar'], + }); + }); + + it('instruments path marks without splitting them by interaction detail', () => { + const spec: Record = { + mark: 'line', + encoding: { + x: { field: 'Date', type: 'temporal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Date', 'Value'], + categoryField: 'Date', + selectableMarks: ['line'], + }, + }; + + expect(addVegaLiteInteractions(spec, [clickHighlight()])).toMatchObject({ + fields: ['Date', 'Value'], + }); + expect(spec.encoding).not.toHaveProperty('detail'); + expect(spec.encoding.opacity.condition.test).toContain("!length(data('__flint_interaction_store'))"); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/stacked-bar-tooltip.test.ts b/packages/flint-js/tests/stacked-bar-tooltip.test.ts new file mode 100644 index 00000000..5aaf0a72 --- /dev/null +++ b/packages/flint-js/tests/stacked-bar-tooltip.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { compile } from 'vega-lite'; +import { assembleVegaLite } from '../src'; + +function compiledTooltips(node: any): any[] { + if (!node || typeof node !== 'object') return []; + const current = node.encode?.update?.tooltip ? [node.encode.update.tooltip] : []; + return [ + ...current, + ...Object.values(node).flatMap((value) => compiledTooltips(value)), + ]; +} + +describe('Stacked Bar Chart tooltips', () => { + it('shows authored fields and display names without generated stack-order fields', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { country: 'France', share: 65, source: 'Nuclear' }, + { country: 'France', share: 27, source: 'Renewables' }, + { country: 'China', share: 62, source: 'Fossil' }, + ], + }, + semantic_types: { country: 'Country', share: 'Quantity', source: 'Category' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'country', y: 'share', color: 'source' }, + }, + field_display_names: { + country: 'Country', + share: 'Energy share', + source: 'Source', + }, + } as never) as any; + + expect(spec.encoding.tooltip).toEqual([ + { field: 'country', type: 'nominal', title: 'Country' }, + { field: 'share', type: 'quantitative', title: 'Energy share' }, + { field: 'source', type: 'nominal', title: 'Source' }, + ]); + expect(JSON.stringify(spec.encoding.tooltip)).not.toContain('sort_index'); + + const tooltips = compiledTooltips(compile(spec).spec); + expect(tooltips.length).toBeGreaterThan(0); + expect(JSON.stringify(tooltips)).toContain('Energy share'); + expect(JSON.stringify(tooltips)).not.toContain('sort_index'); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/waterfall-titles.test.ts b/packages/flint-js/tests/waterfall-titles.test.ts index 6849d84f..548070db 100644 --- a/packages/flint-js/tests/waterfall-titles.test.ts +++ b/packages/flint-js/tests/waterfall-titles.test.ts @@ -84,6 +84,39 @@ function allEncodings(spec: any): Array<[string, any]> { } describe('Waterfall Chart axis titles', () => { + it('uses display names in tooltips without exposing internal transform fields', () => { + const spec = build({ wsu_change: 'WSU weekly change', week: 'Week (Mon, JST)' }); + const bar = spec.layer.find((layer: any) => (layer.mark?.type ?? layer.mark) === 'bar'); + const connector = spec.layer.find((layer: any) => (layer.mark?.type ?? layer.mark) === 'rule'); + + expect(bar.encoding.tooltip).toEqual([ + { field: 'week', type: 'ordinal', title: 'Week (Mon, JST)' }, + { field: 'wsu_change', type: 'quantitative', title: 'WSU weekly change' }, + { field: '__wf_color', type: 'nominal', title: 'Type' }, + ]); + expect(bar.encoding.tooltip.every((entry: any) => !entry.title.startsWith('__wf_'))).toBe(true); + expect(connector.encoding.tooltip).toBeNull(); + }); + + it('uses the authored type field and its display name in tooltips', () => { + const spec = assembleVegaLite({ + data: { values: DATA.map((row, index) => ({ ...row, kind: index === 0 ? 'start' : 'delta' })) }, + semantic_types: { week: 'Date', wsu_change: 'Quantity', kind: 'Category' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: { field: 'week' }, y: { field: 'wsu_change' }, color: { field: 'kind' } }, + }, + field_display_names: { week: 'Week', wsu_change: 'Weekly change', kind: 'Change type' }, + } as never) as any; + const bar = spec.layer.find((layer: any) => (layer.mark?.type ?? layer.mark) === 'bar'); + + expect(bar.encoding.tooltip).toEqual([ + { field: 'week', type: 'ordinal', title: 'Week' }, + { field: 'wsu_change', type: 'quantitative', title: 'Weekly change' }, + { field: 'kind', type: 'nominal', title: 'Change type' }, + ]); + }); + it('applies field_display_names to the x and y axes', () => { const spec = build({ wsu_change: 'WSU weekly change', week: 'Week (Mon, JST)' }); diff --git a/site/package.json b/site/package.json index a00be58b..db256eec 100644 --- a/site/package.json +++ b/site/package.json @@ -15,6 +15,7 @@ "@fontsource-variable/inter": "^5.2.8", "@uiw/react-codemirror": "^4.23.0", "chart.js": "^4.5.1", + "d3": "^7.9.0", "echarts": "^6.0.0", "flint-chart": "*", "i18next": "^26.3.6", @@ -35,6 +36,7 @@ "vega-lite": "^6.4.1" }, "devDependencies": { + "@types/d3": "^7.4.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/site/src/components/VegaLiteView.tsx b/site/src/components/VegaLiteView.tsx index e79b964c..ecb9bf66 100644 --- a/site/src/components/VegaLiteView.tsx +++ b/site/src/components/VegaLiteView.tsx @@ -1,15 +1,24 @@ import { useEffect, useRef } from 'react'; import embed from 'vega-embed'; import { readCanvasFurniture } from 'flint-chart'; +import type { View } from 'vega'; const SVG_NS = 'http://www.w3.org/2000/svg'; -export function VegaLiteView({ spec, renderer = 'canvas' }: { spec: any; renderer?: 'canvas' | 'svg' }) { +interface VegaLiteViewProps { + spec: any; + renderer?: 'canvas' | 'svg'; + onReady?: (svg: SVGSVGElement, view: View) => void | (() => void); +} + +export function VegaLiteView({ spec, renderer = 'canvas', onReady }: VegaLiteViewProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) return; const host = ref.current; let cancelled = false; + let cleanupReady: void | (() => void); + let embeddedView: View | undefined; // Canvas-anchored furniture (the Economist red tab) is drawn onto the SVG // after render — Vega-Lite cannot express it. That requires SVG output, so // a spec carrying furniture is forced to render as SVG regardless of the @@ -17,26 +26,35 @@ export function VegaLiteView({ spec, renderer = 'canvas' }: { spec: any; rendere const furniture = readCanvasFurniture(spec); const useRenderer = furniture.length ? 'svg' : renderer; embed(host, spec, { actions: false, renderer: useRenderer }) - .then(() => { - if (cancelled || !furniture.length) return; + .then((result) => { + embeddedView = result.view; + if (cancelled) { + result.view.finalize(); + return; + } const svgEl = host.querySelector('svg'); if (!svgEl) return; - for (const it of furniture) { - const rect = document.createElementNS(SVG_NS, 'rect'); - rect.setAttribute('x', String(it.x)); - rect.setAttribute('y', String(it.y)); - rect.setAttribute('width', String(it.width)); - rect.setAttribute('height', String(it.height)); - rect.setAttribute('fill', it.color); - svgEl.appendChild(rect); + if (furniture.length) { + for (const it of furniture) { + const rect = document.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(it.x)); + rect.setAttribute('y', String(it.y)); + rect.setAttribute('width', String(it.width)); + rect.setAttribute('height', String(it.height)); + rect.setAttribute('fill', it.color); + svgEl.appendChild(rect); + } } + cleanupReady = onReady?.(svgEl, result.view); }) .catch((err) => { if (!cancelled) console.error('vega-embed failed', err); }); return () => { cancelled = true; + cleanupReady?.(); + embeddedView?.finalize(); }; - }, [spec, renderer]); + }, [spec, renderer, onReady]); return
; } diff --git a/site/src/main.tsx b/site/src/main.tsx index fff22776..4d74cac3 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -24,6 +24,10 @@ import { ThemeLabReal } from './playground/ThemeLabReal'; import { BandStretchingLab } from './playground/BandStretchingLab'; import { LabelExperimentLab } from './playground/LabelExperimentLab'; import { OverflowViewportLab } from './playground/OverflowViewportLab'; +import { ClickFocusLab } from './playground/ClickFocusLab'; +import { InteractionCandidates } from './playground/InteractionCandidates'; +import { ExternalToChartLab } from './playground/ExternalToChartLab'; +import { ChartToExternalLab } from './playground/ChartToExternalLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; import { DebugGym } from './playground/DebugGym'; @@ -75,6 +79,10 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/site/src/playground/ChartToExternalLab.tsx b/site/src/playground/ChartToExternalLab.tsx new file mode 100644 index 00000000..9e82f771 --- /dev/null +++ b/site/src/playground/ChartToExternalLab.tsx @@ -0,0 +1,249 @@ +import { useCallback, useMemo, useState, type ReactNode } from 'react'; +import type { + FlintInteractionEventDetail, + InteractionDef, + SemanticTarget, +} from 'flint-chart/interactive'; +import { clickHighlight, select as rectangleSelect } from 'flint-chart/interactive'; +import { InteractionDemoChart } from './InteractionDemoChart'; +import { + countriesFixture, + ganttFixture, + lifeFixture, + penguinsFixture, + populationFixture, + salesFixture, + stocksFixture, + weatherFixture, + type InteractionDemoFixture, +} from './interaction-demo-data'; +import './interaction-transport.css'; + +type Row = Record; + +interface OutboundDemo { + id: string; + fixture: InteractionDemoFixture; + title: string; + description: string; + panelTitle: string; + gesture: 'click' | 'select'; + render: (records: Row[], fixture: InteractionDemoFixture) => ReactNode; +} + +function targetRecords(target: SemanticTarget | null): Row[] { + const seen = new Set(); + const records: Row[] = []; + for (const record of target?.elements.flatMap((element) => element.records ?? []) ?? []) { + const clean = Object.fromEntries(Object.entries(record).filter(([field]) => !field.startsWith('__'))); + const signature = JSON.stringify(clean); + if (seen.has(signature)) continue; + seen.add(signature); + records.push(clean); + } + return records; +} + +function value(record: Row | undefined, field: string): string { + const result = record?.[field]; + return result === undefined || result === null ? '—' : String(result); +} + +function metric(label: string, result: string | number) { + return
{label}{result}
; +} + +function fixtureRows(fixture: InteractionDemoFixture): Row[] { + return (fixture.input.data.values ?? []) as Row[]; +} + +const demos: OutboundDemo[] = [ + { + id: 'order-explorer', fixture: salesFixture, title: 'Order explorer', gesture: 'click', + description: 'Hover or click a bar to send its region and segment into an order summary.', + panelTitle: 'Selected order cohort', + render(records) { + const row = records[0]; + return row ? <> +
{value(row, 'Region')} / {value(row, 'Segment')}
+
+ {metric('Sales', `$${value(row, 'Sales ($K)')}K`)} + {metric('Profit', `$${value(row, 'Profit ($K)')}K`)} +
+ : null; + }, + }, + { + id: 'country-profile', fixture: countriesFixture, title: 'Country profile', gesture: 'click', + description: 'A resolved point updates a profile panel without parsing SVG or Vega internals.', + panelTitle: 'Country profile', + render(records) { + const row = records[0]; + return row ? <> +
{value(row, 'Country')}
+
+ {metric('Continent', value(row, 'Continent'))} + {metric('Life expectancy', `${value(row, 'Life expectancy')} years`)} + {metric('GDP per capita', `$${Number(row['GDP per capita ($)']).toLocaleString()}`)} + {metric('Population', `${value(row, 'Population (M)')}M`)} +
+ : null; + }, + }, + { + id: 'penguin-cohort', fixture: penguinsFixture, title: 'Cohort summary', gesture: 'select', + description: 'Drag a rectangle around penguins to compute statistics from the semantic selection.', + panelTitle: 'Selected cohort', + render(records) { + if (records.length === 0) return null; + const average = (field: string) => records.reduce((sum, row) => sum + Number(row[field]), 0) / records.length; + const species = [...new Set(records.map((row) => String(row.Species)))].join(', '); + return <> +
{records.length} penguins
+
+ {metric('Species', species)} + {metric('Avg. bill', `${average('Bill length (mm)').toFixed(1)} mm`)} + {metric('Avg. flipper', `${average('Flipper length (mm)').toFixed(1)} mm`)} + {metric('Avg. mass', `${Math.round(average('Body mass (g)'))} g`)} +
+ ; + }, + }, + { + id: 'weather-detail', fixture: weatherFixture, title: 'Climate-cell details', gesture: 'click', + description: 'A heatmap cell becomes a typed city-month record for the surrounding application.', + panelTitle: 'Climate observation', + render(records) { + const row = records[0]; + return row ? <> +
{value(row, 'City')} in {value(row, 'Month')}
+
{value(row, 'Temperature (C)')}C
+

Monthly climate normal from the embedded snapshot.

+ : null; + }, + }, + { + id: 'trading-inspector', fixture: stocksFixture, title: 'Trading-day inspection', gesture: 'click', + description: 'The candle body and wick resolve to one trading interval and one OHLC panel.', + panelTitle: 'Daily market data', + render(records) { + const row = records[0]; + if (!row) return null; + const change = Number(row.Close) - Number(row.Open); + return <> +
{value(row, 'Date')}
+
+ {metric('Open', `$${value(row, 'Open')}`)} {metric('High', `$${value(row, 'High')}`)} + {metric('Low', `$${value(row, 'Low')}`)} {metric('Close', `$${value(row, 'Close')}`)} +
+

= 0 ? 'it-change positive' : 'it-change negative'}> + {change >= 0 ? '+' : ''}{change.toFixed(2)} daily change +

+ ; + }, + }, + { + id: 'task-details', fixture: ganttFixture, title: 'Task details', gesture: 'click', + description: 'Selecting a task interval updates delivery metadata outside the chart.', + panelTitle: 'Delivery record', + render(records) { + const row = records[0]; + return row ? <> +
{value(row, 'Task')}
+
+
Owner
{value(row, 'Owner')}
+
Team
{value(row, 'Team')}
+
Window
{value(row, 'Start')} to {value(row, 'End')}
+
Status
{value(row, 'Status')}
+
+ : null; + }, + }, + { + id: 'budget-explanation', fixture: populationFixture, title: 'Waterfall explanation', gesture: 'click', + description: 'A waterfall step drives a plain-language contribution explanation.', + panelTitle: 'Population contribution', + render(records) { + const row = records[0]; + return row ? <> +
{value(row, 'Step')}
+

+ This step contributes {Number(row['Population (M)']).toLocaleString()} million people to the 1950-2020 bridge. +

+ : null; + }, + }, + { + id: 'life-narrative', fixture: lifeFixture, title: 'Country comparison narrative', gesture: 'click', + description: 'Either endpoint identifies the country; the application supplies the full comparison.', + panelTitle: 'Life expectancy comparison', + render(records, fixture) { + const country = records[0]?.Country; + if (!country) return null; + const countryRows = fixtureRows(fixture).filter((row) => row.Country === country); + const male = Number(countryRows.find((row) => row.Sex === 'Male')?.['Life expectancy']); + const female = Number(countryRows.find((row) => row.Sex === 'Female')?.['Life expectancy']); + return <> +
{String(country)}
+

+ Female life expectancy is {female.toFixed(1)} years, {Math.abs(female - male).toFixed(1)} years higher than the male value of {male.toFixed(1)}. +

+ ; + }, + }, +]; + +function OutboundDemoRow({ demo }: { demo: OutboundDemo }) { + const [detail, setDetail] = useState(null); + const interaction: InteractionDef = useMemo( + () => demo.gesture === 'select' + ? rectangleSelect({ id: `${demo.id}-selection` }) + : clickHighlight({ id: `${demo.id}-element` }), + [demo.gesture, demo.id], + ); + const interactions = useMemo(() => [interaction], [interaction]); + const handleSemanticEvent = useCallback((event: FlintInteractionEventDetail) => setDetail(event), []); + const records = targetRecords(detail?.event.target ?? null); + const rendered = demo.render(records, demo.fixture); + + return ( +
+
+
+

{demo.title}

+

{demo.description}

+
+
+
+
+ +
+
+

{demo.panelTitle}

+
+ {rendered ??
Interact with the chart to populate this view.
} +
+
+
+
+ ); +} + +export function ChartToExternalLab() { + return ( +
+
+

Semantic chart events that drive application UI

+

Each chart resolves physical geometry into semantic records, emits a chart-scoped event, and updates an external React view without inspecting renderer internals.

+
+
+ {demos.map((demo) => )} +
+
+ ); +} diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx new file mode 100644 index 00000000..babf20a0 --- /dev/null +++ b/site/src/playground/ClickFocusLab.tsx @@ -0,0 +1,271 @@ +import { useEffect, useRef, useState } from 'react'; +import { Layers3, MousePointer2, Scan } from 'lucide-react'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { + genAreaTests, + genBarTableTests, + genBarTests, + genBoxplotTests, + genCandlestickTests, + genConnectedScatterTests, + genGanttTests, + genGroupedBarTests, + genHeatmapTests, + genHistogramTests, + genLineTests, + genLollipopTests, + genPieTests, + genPyramidTests, + genRangedDotPlotTests, + genRoseTests, + genScatterTests, + genStackedBarTests, + genStripPlotTests, + genWaterfallTests, + type TestCase, +} from 'flint-chart/test-data'; +import { + buildInteractiveChart, + clickGroupHighlight, + clickHighlight, + select as rectangleSelect, +} from 'flint-chart/interactive'; +import { expressionInterpreter } from 'vega-interpreter'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { testCaseToAssemblyInput } from '../shared/test-case-utils'; +import './click-focus-lab.css'; + +type InteractionMode = 'element' | 'group' | 'select'; +type Support = 'works' | 'partial' | 'none'; + +const interactionModes = [ + { value: 'element', label: 'Element', icon: MousePointer2 }, + { value: 'group', label: 'Group', icon: Layers3 }, + { value: 'select', label: 'Select', icon: Scan }, +] as const; + +interface InteractionCase { + id: string; + input: ChartAssemblyInput; + support: Support; + expectation: string; +} + +const SIZE = { width: 350, height: 240 }; + +function representative(generator: () => TestCase[]): TestCase { + const cases = generator(); + return cases.find((test) => test.tags?.includes('real') && !test.encodingMap.column?.fieldID && !test.encodingMap.row?.fieldID) + ?? cases.find((test) => !test.tags?.some((tag) => ['stress', 'edge-case', 'overflow'].includes(tag))) + ?? cases[0]; +} + +function interactionCase( + id: string, + generator: () => TestCase[], + support: Support, + expectation: string, +): InteractionCase { + const testCase = representative(generator); + return { + id, + input: testCaseToAssemblyInput(testCase, SIZE) as ChartAssemblyInput, + support, + expectation, + }; +} + +function multiLegendCase(kind: 'shape' | 'size'): InteractionCase { + const shapeCase = { + data: [ + ['Adelie', 'Male', 181, 3750], ['Adelie', 'Male', 190, 3650], + ['Adelie', 'Female', 186, 3800], ['Adelie', 'Female', 195, 3250], + ['Chinstrap', 'Male', 196, 3900], ['Chinstrap', 'Male', 193, 3650], + ['Chinstrap', 'Female', 192, 3500], ['Chinstrap', 'Female', 188, 3525], + ['Gentoo', 'Male', 230, 5700], ['Gentoo', 'Male', 218, 5700], + ['Gentoo', 'Female', 211, 4500], ['Gentoo', 'Female', 210, 4450], + ].map(([Species, Sex, flipper, mass]) => ({ + Species, Sex, 'Flipper length (mm)': flipper, 'Body mass (g)': mass, + })), + semanticTypes: { + Species: 'Category', Sex: 'Category', + 'Flipper length (mm)': 'Quantity', 'Body mass (g)': 'Quantity', + }, + title: 'Palmer Penguins — species and sex', + x: 'Flipper length (mm)', + y: 'Body mass (g)', + color: 'Species', + secondaryField: 'Sex', + expectation: 'Species and sex legends each highlight their cohort across the other grouping.', + }; + const sizeCase = { + data: [ + ['Norway', 64800, 82.3, 'Europe', 'Under 100M'], + ['Germany', 50900, 81.0, 'Europe', 'Under 100M'], + ['Russia', 25800, 72.4, 'Europe', '100M+'], + ['United States', 62600, 78.6, 'Americas', '100M+'], + ['Brazil', 15600, 75.7, 'Americas', '100M+'], + ['Chile', 25200, 80.0, 'Americas', 'Under 100M'], + ['China', 16800, 76.7, 'Asia', '100M+'], + ['Japan', 39300, 84.2, 'Asia', '100M+'], + ['Qatar', 116900, 80.1, 'Asia', 'Under 100M'], + ['Nigeria', 5300, 54.3, 'Africa', '100M+'], + ['Ethiopia', 2000, 66.2, 'Africa', '100M+'], + ['South Africa', 13000, 63.9, 'Africa', 'Under 100M'], + ].map(([Country, gdp, life, Continent, populationBand]) => ({ + Country, 'GDP per capita': gdp, 'Life expectancy': life, + Continent, 'Population band': populationBand, + })), + semanticTypes: { + Country: 'Category', Continent: 'Category', 'Population band': 'Category', + 'GDP per capita': 'Quantity', 'Life expectancy': 'Quantity', + }, + title: 'Countries — continent and population', + x: 'GDP per capita', + y: 'Life expectancy', + color: 'Continent', + secondaryField: 'Population band', + expectation: 'Continent and population-band legends each highlight their cohort across the other grouping.', + }; + const selected = kind === 'shape' ? shapeCase : sizeCase; + return { + id: `scatter-color-${kind}`, + input: { + data: { values: selected.data }, + semantic_types: selected.semanticTypes, + chart_spec: { + chartType: 'Scatter Plot', + title: selected.title, + encodings: { + x: { field: selected.x }, + y: { field: selected.y }, + color: { field: selected.color }, + [kind]: { field: selected.secondaryField }, + }, + chartProperties: kind === 'size' ? { logScale_x: true } : undefined, + baseSize: SIZE, + }, + } as ChartAssemblyInput, + support: 'works', + expectation: selected.expectation, + }; +} + +const interactionCases: InteractionCase[] = [ + interactionCase('bar', genBarTests, 'works', 'Element mode isolates one bar; group mode follows its color series.'), + interactionCase('grouped-bar', genGroupedBarTests, 'works', 'Element mode isolates one bar; group mode follows the same color across categories.'), + interactionCase('stacked-bar', genStackedBarTests, 'works', 'Element mode isolates one segment; group mode follows its color across stacks.'), + interactionCase('heatmap', genHeatmapTests, 'works', 'A cell is keyed by both discrete axes.'), + interactionCase('pie', genPieTests, 'works', 'Each arc is one semantic slice.'), + interactionCase('rose', genRoseTests, 'works', 'Each radial arc is one semantic category.'), + interactionCase('pyramid', genPyramidTests, 'works', 'Each age-and-side bar resolves independently.'), + interactionCase('gantt', genGanttTests, 'works', 'Task bars resolve by their discrete task axis.'), + interactionCase('waterfall', genWaterfallTests, 'partial', 'Group mode follows the implicit increase, decrease, or total color; connectors remain independent.'), + interactionCase('bar-table', genBarTableTests, 'partial', 'Bars respond; text and table furniture do not.'), + interactionCase('histogram', genHistogramTests, 'works', 'Each generated bin is one selectable interval.'), + interactionCase('lollipop', genLollipopTests, 'works', 'A dot and its stem resolve to one encoded observation.'), + interactionCase('candlestick', genCandlestickTests, 'works', 'Body, wick, and doji tick resolve to one trading interval.'), + interactionCase('scatter', genScatterTests, 'works', 'Each point resolves by its complete encoded identity.'), + multiLegendCase('shape'), + multiLegendCase('size'), + interactionCase('strip', genStripPlotTests, 'works', 'Element mode isolates one point; group mode follows every point in its jitter lane.'), + interactionCase('line', genLineTests, 'works', 'Click or drag a segment; visible points remain independently selectable.'), + interactionCase('area', genAreaTests, 'works', 'Each domain interval resolves to an area slice.'), + interactionCase('boxplot', genBoxplotTests, 'works', 'Box, median, and whiskers coalesce by category and series.'), + interactionCase('ranged-dot', genRangedDotPlotTests, 'works', 'Both endpoints and their connector resolve as one interval.'), + interactionCase('connected-scatter', genConnectedScatterTests, 'works', 'Trajectory segments and observed points resolve independently.'), +]; + +function InteractiveChart({ input, mode }: { input: ChartAssemblyInput; mode: InteractionMode }) { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const surface = buildInteractiveChart(container, input, { + backend: 'vegalite', + renderer: 'svg', + interactions: mode === 'element' + ? [clickHighlight()] + : mode === 'group' + ? [clickGroupHighlight()] + : [rectangleSelect()], + expressionInterpreter, + ariaLabel: input.chart_spec.title, + }); + void surface.ready.catch((error) => { + container.textContent = error instanceof Error ? error.message : String(error); + }); + return () => surface.destroy(); + }, [input, mode]); + + return
; +} + +function CaseCard({ item, mode }: { item: InteractionCase; mode: InteractionMode }) { + const title = item.input.chart_spec.title || item.input.chart_spec.chartType; + return ( +
+
+
+

{title}

+

{item.expectation}

+
+ + {item.support === 'works' ? 'Bound' : item.support === 'partial' ? 'Partial' : 'Not bound'} + +
+
+ + + +
+
+ ); +} + +export function ClickFocusLab() { + const [mode, setMode] = useState('element'); + const counts = interactionCases.reduce((result, item) => { + result[item.support] += 1; + return result; + }, { works: 0, partial: 0, none: 0 }); + + return ( +
+
+ {interactionModes.map(({ value, label, icon: Icon }) => ( + + ))} +
+
+

Interaction gallery

+

Flint currently supports three interactions:

+
    +
  • Element: Click a mark to focus it and dim the other marks.
  • +
  • Group: Click a mark to focus related marks in the same category or series.
  • +
  • Select: Drag a rectangle to focus all marks within an area.
  • +
+
+ {counts.works} bound + {counts.partial} partial + {counts.none} not bound +
+
+
+ {interactionCases.map((item) => )} +
+
+ ); +} \ No newline at end of file diff --git a/site/src/playground/ExternalToChartLab.tsx b/site/src/playground/ExternalToChartLab.tsx new file mode 100644 index 00000000..7c62ef2f --- /dev/null +++ b/site/src/playground/ExternalToChartLab.tsx @@ -0,0 +1,308 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { + InteractionDef, + InteractiveChartSurface, + SemanticElement, +} from 'flint-chart/interactive'; +import { externalTrigger } from 'flint-chart/interactive'; +import { InteractionDemoChart } from './InteractionDemoChart'; +import { + countriesFixture, + ganttFixture, + incidentsFixture, + lifeFixture, + salesFixture, + stocksFixture, + weatherFixture, + type InteractionDemoFixture, +} from './interaction-demo-data'; +import './interaction-transport.css'; + +interface MatchPayload { + label: string; + match?: Record; +} + +interface ControlOption extends MatchPayload {} + +interface ExternalDemo { + id: string; + fixture: InteractionDemoFixture; + title: string; + description: string; + controlLabel: string; + options: ControlOption[]; +} + +function matchValue(actual: unknown, expected: unknown): boolean { + if (actual === expected) return true; + if (typeof actual === 'number' && typeof expected === 'string') { + const date = new Date(actual); + if (!Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === expected) return true; + } + return false; +} + +function matchingElements( + elements: readonly SemanticElement[] | undefined, + match: Record, +): SemanticElement[] { + return elements?.filter((element) => element.records?.some((record) => + Object.entries(match).every(([field, value]) => matchValue(record[field], value)))) ?? []; +} + +function externalMatchInteraction(id: string): InteractionDef { + return { + id, + eventSource: externalTrigger('demo-control'), + update(event, context) { + if (event.type !== 'external' || event.source !== 'demo-control') return null; + const payload = event.payload as MatchPayload; + if (!payload.match) return { ops: [{ op: 'reset' }] }; + const elements = matchingElements(context.available, payload.match); + return elements.length > 0 + ? { ops: [{ op: 'emphasize', elements, mode: 'replace', dimOpacity: 0.25 }] } + : { ops: [{ op: 'reset' }] }; + }, + }; +} + +function ExternalControlContent({ + demo, + activeLabel, + onSelect, +}: { + demo: ExternalDemo; + activeLabel?: string; + onSelect: (option: ControlOption) => void; +}) { + const control = (option: ControlOption, className: string, content: React.ReactNode = option.label) => ( + + ); + + if (demo.id === 'sales-table') { + return ( +
+
RegionSegment
+ {demo.options.map((option) => { + const [region, segment] = option.label.split(' / '); + return control(option, 'it-mini-table-row', <>{region}{segment}); + })} +
+ ); + } + + if (demo.id === 'country-finder') { + return ( +
+

Country profiles compare income, health, and population. Read about{' '} + {demo.options.map((option, index) => {index > 0 && (index === demo.options.length - 1 ? ', or ' : ', ')}{control(option, 'it-inline-link')})}. +

+
+ ); + } + + if (demo.id === 'continent-filter') { + const continentCopy: Record = { + Africa: { + heading: 'A young, fast-growing region', + text: 'Health outcomes have improved substantially, while income levels still vary sharply across the continent.', + }, + Americas: { + heading: 'Wide differences across neighboring economies', + text: 'The region spans high-income countries and emerging markets, with life expectancy clustering more closely than income.', + }, + Asia: { + heading: 'Scale and development move together unevenly', + text: 'Asia contains both the largest populations and some of the widest gaps in income and longevity in this snapshot.', + }, + Europe: { + heading: 'High longevity across varied incomes', + text: 'European countries occupy the upper end of life expectancy, even as GDP per capita remains meaningfully dispersed.', + }, + }; + const selected = activeLabel ? continentCopy[activeLabel] : null; + return ( +
+
+ {demo.options.map((option) => control(option, 'it-tab'))} +
+
+ {selected ? <>{selected.heading}

{selected.text}

:

Select a continent to explore its position in the global income and health distribution.

} +
+
+ ); + } + + if (demo.id === 'stock-date') { + return ( +
    + {demo.options.map((option, index) =>
  1. {index + 2}{control(option, 'it-date-link')}
  2. )} +
+ ); + } + + if (demo.id === 'weather-alert') { + const levels = ['Cold anomaly', 'Heat warning', 'Local maximum', 'Stable pattern']; + return ( +
+ {demo.options.map((option, index) => control(option, 'it-alert-item', <>{levels[index]}{option.label}))} +
+ ); + } + + if (demo.id === 'task-lookup') { + return ( +
+ {demo.options.map((option, index) => control(option, 'it-timeline-item', <>{String(index + 1).padStart(2, '0')}{option.label}))} +
+ ); + } + + if (demo.id === 'life-comparison') { + return ( +
+

Compare the female and male life expectancy gap for:

+

{demo.options.map((option, index) => {index > 0 && ' · '}{control(option, 'it-inline-link')})}

+
+ ); + } + + const counts: Record = { Critical: 3, High: 11, Medium: 24 }; + return ( +
+ {demo.options.map((option) => control(option, 'it-queue-item', <>{option.label}{counts[option.label]} open))} +
+ ); +} + +const demos: ExternalDemo[] = [ + { + id: 'sales-table', fixture: salesFixture, title: 'Sales table selection', + description: 'A row in an operations table targets one region and product segment.', + controlLabel: 'Order summary rows', + options: [ + { label: 'West / Technology', match: { Region: 'West', Segment: 'Technology' } }, + { label: 'East / Office Supplies', match: { Region: 'East', Segment: 'Office Supplies' } }, + { label: 'Central / Furniture', match: { Region: 'Central', Segment: 'Furniture' } }, + ], + }, + { + id: 'country-finder', fixture: countriesFixture, title: 'Country finder', + description: 'A search result identifies one observation in a dense country scatterplot.', + controlLabel: 'Country results', + options: ['Japan', 'Brazil', 'Nigeria', 'Germany'].map((Country) => ({ label: Country, match: { Country } })), + }, + { + id: 'continent-filter', fixture: countriesFixture, title: 'Continent cohort', + description: 'A segmented filter targets a semantic cohort rather than a single mark.', + controlLabel: 'Continent', + options: ['Africa', 'Americas', 'Asia', 'Europe'].map((Continent) => ({ label: Continent, match: { Continent } })), + }, + { + id: 'stock-date', fixture: stocksFixture, title: 'Trading-day navigator', + description: 'A date list drives the matching OHLC candle, including its body and wick.', + controlLabel: 'Trading dates', + options: ['2024-01-02', '2024-01-05', '2024-01-10', '2024-01-16'].map((Date) => ({ label: Date, match: { Date } })), + }, + { + id: 'weather-alert', fixture: weatherFixture, title: 'Climate alert list', + description: 'Named exceptions from an alert service target precise city-month cells.', + controlLabel: 'Detected conditions', + options: [ + { label: 'Moscow coldest', match: { City: 'Moscow', Month: 'Jan' } }, + { label: 'Cairo hottest', match: { City: 'Cairo', Month: 'Jul' } }, + { label: 'Seattle warmest', match: { City: 'Seattle', Month: 'Jul' } }, + { label: 'Singapore stable', match: { City: 'Singapore', Month: 'Apr' } }, + ], + }, + { + id: 'task-lookup', fixture: ganttFixture, title: 'Task lookup', + description: 'A delivery tracker selects a task interval from outside the chart.', + controlLabel: 'Release tasks', + options: ['Planning', 'Design', 'Implementation', 'Testing', 'Launch'].map((Task) => ({ label: Task, match: { Task } })), + }, + { + id: 'life-comparison', fixture: lifeFixture, title: 'Population comparison', + description: 'Selecting a country emphasizes both endpoints and the connecting interval.', + controlLabel: 'Country comparison', + options: ['Japan', 'United States', 'Brazil', 'Nigeria'].map((Country) => ({ label: Country, match: { Country } })), + }, + { + id: 'severity-queue', fixture: incidentsFixture, title: 'Incident severity queue', + description: 'An incident queue highlights one severity across every reporting week.', + controlLabel: 'Severity', + options: ['Critical', 'High', 'Medium'].map((Severity) => ({ label: Severity, match: { Severity } })), + }, +]; + +function ExternalDemoRow({ demo }: { demo: ExternalDemo }) { + const surfaceRef = useRef(null); + const [lastPayload, setLastPayload] = useState(null); + const interaction = useMemo(() => externalMatchInteraction(`${demo.id}-external`), [demo.id]); + const interactions = useMemo(() => [interaction], [interaction]); + const handleSurface = useCallback((surface: InteractiveChartSurface | null) => { + surfaceRef.current = surface; + }, []); + const dispatch = (payload: MatchPayload) => { + setLastPayload(payload); + surfaceRef.current?.dispatch({ + type: 'external', source: 'demo-control', phase: 'commit', payload, + }); + }; + + return ( +
+
+
+

{demo.title}

+

{demo.description}

+
+
+
+
+

{demo.controlLabel}

+
+ +
+ +
+
+ +
+
+
+ ); +} + +export function ExternalToChartLab() { + return ( +
+
+

Application controls that speak chart semantics

+

Each control dispatches an external event. Its preset interprets the payload, produces a ChartUpdate, and lets the chart definition present it.

+
+
+ {demos.map((demo) => )} +
+
+ ); +} diff --git a/site/src/playground/InteractionCandidates.tsx b/site/src/playground/InteractionCandidates.tsx new file mode 100644 index 00000000..2207df79 --- /dev/null +++ b/site/src/playground/InteractionCandidates.tsx @@ -0,0 +1,335 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { pointer, select } from 'd3'; +import { assembleVegaLite } from 'flint-chart'; +import { FlaskConical } from 'lucide-react'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { PREVIEW_CASES, type PreviewCase } from '../shared/preview-cases'; +import './interaction-candidates.css'; + +type InteractionKind = 'hover-points' | 'brush-points' | 'click-bars' | 'click-arcs' | 'hover-cells'; + +interface Example { + id: string; + caseId: string; + label: string; + note: string; + interaction: InteractionKind; +} + +const EXAMPLES: Example[] = [ + { + id: 'point-hover', + caseId: 'driving', + label: 'Point hover + tooltip', + note: 'Candidate for compiler-owned hover targeting, emphasis, and semantic tooltip content.', + interaction: 'hover-points', + }, + { + id: 'brush-feedback', + caseId: 'penguins', + label: 'Brush selection feedback', + note: 'Reference treatment for the supported rectangle primitive: live count, outline, and brush styling.', + interaction: 'brush-points', + }, + { + id: 'bar-focus', + caseId: 'population', + label: 'Bar focus + details', + note: 'Reference treatment for click focus with a contextual detail readout and double-click reset.', + interaction: 'click-bars', + }, + { + id: 'arc-focus', + caseId: 'mobile-donut', + label: 'Arc focus + details', + note: 'Reference treatment for semantic arc focus, stronger boundaries, and contextual values.', + interaction: 'click-arcs', + }, + { + id: 'cell-hover', + caseId: 'temp-heatmap', + label: 'Cell hover + tooltip', + note: 'Candidate for semantic cell hover with field-aware tooltip content.', + interaction: 'hover-cells', + }, +]; + +function findCase(id: string): PreviewCase { + const found = PREVIEW_CASES.find((candidate) => candidate.id === id); + if (!found) throw new Error(`Unknown preview case: ${id}`); + return found; +} + +function compileCase(previewCase: PreviewCase): any { + return assembleVegaLite({ + data: { values: previewCase.data }, + semantic_types: previewCase.semantic_types, + chart_spec: { + chartType: previewCase.chartType, + title: previewCase.title, + encodings: previewCase.encodings, + baseSize: { width: 440, height: 280 }, + ...(previewCase.chartProperties ? { chartProperties: previewCase.chartProperties } : {}), + }, + } as any); +} + +function datumOf(sceneItem: any): Record { + return sceneItem?.datum ?? {}; +} + +function installTooltip(card: HTMLElement) { + const tooltip = document.createElement('div'); + tooltip.className = 'ic-tooltip'; + tooltip.setAttribute('role', 'status'); + card.appendChild(tooltip); + return { + show(event: MouseEvent, lines: string[]) { + const [x, y] = pointer(event, card); + tooltip.replaceChildren(...lines.map((line) => { + const row = document.createElement('div'); + row.textContent = line; + return row; + })); + tooltip.style.left = `${x + 12}px`; + tooltip.style.top = `${y + 12}px`; + tooltip.dataset.visible = 'true'; + }, + hide() { + delete tooltip.dataset.visible; + }, + remove() { + tooltip.remove(); + }, + }; +} + +function fieldLines(datum: Record, fields: string[]): string[] { + return fields + .filter((field) => datum[field] !== undefined) + .map((field) => `${field}: ${datum[field]}`); +} + +function attachHover( + svg: SVGSVGElement, + card: HTMLElement, + selector: string, + fields: string[], +) { + const marks = select(svg).selectAll(selector); + if (!marks.size()) return () => {}; + const tooltip = installTooltip(card); + const show = function (this: SVGGraphicsElement, event: MouseEvent, sceneItem: any) { + select(this).classed('ic-hovered', true); + tooltip.show(event, fieldLines(datumOf(sceneItem), fields)); + }; + marks + .style('cursor', 'pointer') + .on('mouseenter.candidate', show) + .on('mousemove.candidate', show) + .on('mouseleave.candidate', function (this: SVGGraphicsElement) { + select(this).classed('ic-hovered', false); + tooltip.hide(); + }); + return () => { + marks.on('.candidate', null).style('cursor', null).classed('ic-hovered', false); + tooltip.remove(); + }; +} + +function svgPoint(svg: SVGSVGElement, node: SVGGraphicsElement): [number, number] { + const rect = node.getBoundingClientRect(); + const point = svg.createSVGPoint(); + point.x = rect.left + rect.width / 2; + point.y = rect.top + rect.height / 2; + const matrix = svg.getScreenCTM(); + if (!matrix) return [0, 0]; + const local = point.matrixTransform(matrix.inverse()); + return [local.x, local.y]; +} + +function attachBrushPoints(svg: SVGSVGElement, card: HTMLElement) { + const marks = select(svg).selectAll('g.mark-symbol.role-mark path'); + if (!marks.size()) return () => {}; + const tooltip = installTooltip(card); + const positions = new Map(marks.nodes().map((node) => [node, svgPoint(svg, node)])); + const xs = [...positions.values()].map(([x]) => x); + const ys = [...positions.values()].map(([, y]) => y); + const x0 = Math.min(...xs) - 12; + const y0 = Math.min(...ys) - 12; + const width = Math.max(...xs) + 12 - x0; + const height = Math.max(...ys) + 12 - y0; + const layer = select(svg).append('g').attr('class', 'ic-brush'); + const selection = layer.append('rect').attr('class', 'selection').style('display', 'none'); + const overlay = layer.append('rect') + .attr('class', 'overlay') + .attr('x', x0) + .attr('y', y0) + .attr('width', width) + .attr('height', height) + .attr('fill', 'transparent'); + let anchor: [number, number] | undefined; + const overlayNode = overlay.node(); + + const update = (event: MouseEvent) => { + if (!anchor) return; + const [x, y] = pointer(event, svg); + const left = Math.min(anchor[0], x); + const top = Math.min(anchor[1], y); + const right = Math.max(anchor[0], x); + const bottom = Math.max(anchor[1], y); + selection + .attr('x', left) + .attr('y', top) + .attr('width', right - left) + .attr('height', bottom - top) + .style('display', null); + let count = 0; + marks.each(function () { + const [markX, markY] = positions.get(this) ?? [0, 0]; + const selected = markX >= left && markX <= right && markY >= top && markY <= bottom; + if (selected) count += 1; + select(this).classed('ic-selected', selected).classed('ic-dimmed', !selected); + }); + tooltip.show(event, [`Selected points: ${count}`]); + }; + + const start = (event: MouseEvent) => { + if (event.target !== overlayNode) return; + event.preventDefault(); + event.stopPropagation(); + anchor = pointer(event, svg); + update(event); + }; + const move = (event: MouseEvent) => { + if (!anchor) return; + event.preventDefault(); + event.stopPropagation(); + update(event); + }; + const end = (event: MouseEvent) => { + if (!anchor) return; + event.preventDefault(); + event.stopPropagation(); + anchor = undefined; + }; + const clear = (event: MouseEvent) => { + if (event.target !== overlayNode) return; + event.preventDefault(); + event.stopPropagation(); + anchor = undefined; + selection.style('display', 'none'); + marks.classed('ic-selected', false).classed('ic-dimmed', false); + tooltip.hide(); + }; + window.addEventListener('pointerdown', start, true); + window.addEventListener('pointermove', move, true); + window.addEventListener('pointerup', end, true); + window.addEventListener('pointercancel', end, true); + window.addEventListener('mousedown', start, true); + window.addEventListener('mousemove', move, true); + window.addEventListener('mouseup', end, true); + window.addEventListener('dblclick', clear, true); + return () => { + window.removeEventListener('pointerdown', start, true); + window.removeEventListener('pointermove', move, true); + window.removeEventListener('pointerup', end, true); + window.removeEventListener('pointercancel', end, true); + window.removeEventListener('mousedown', start, true); + window.removeEventListener('mousemove', move, true); + window.removeEventListener('mouseup', end, true); + window.removeEventListener('dblclick', clear, true); + marks.classed('ic-selected', false).classed('ic-dimmed', false); + layer.remove(); + tooltip.remove(); + }; +} + +function attachClickFocus( + svg: SVGSVGElement, + card: HTMLElement, + selector: string, + fields: string[], +) { + const marks = select(svg).selectAll(selector); + if (!marks.size()) return () => {}; + const tooltip = installTooltip(card); + const clear = () => { + marks.classed('ic-selected', false).classed('ic-dimmed', false); + tooltip.hide(); + }; + marks + .style('cursor', 'pointer') + .on('click.candidate', function (event: MouseEvent, sceneItem: any) { + event.stopPropagation(); + marks.classed('ic-selected', false).classed('ic-dimmed', true); + select(this).classed('ic-selected', true).classed('ic-dimmed', false); + tooltip.show(event, fieldLines(datumOf(sceneItem), fields)); + }); + select(svg).on('dblclick.candidate', clear); + return () => { + marks.on('.candidate', null).style('cursor', null).classed('ic-selected', false).classed('ic-dimmed', false); + select(svg).on('.candidate', null); + tooltip.remove(); + }; +} + +function attachInteraction(kind: InteractionKind, svg: SVGSVGElement, card: HTMLElement) { + switch (kind) { + case 'hover-points': + return attachHover(svg, card, 'g.mark-symbol.role-mark path', ['Year', 'Miles/person', 'Gas price']); + case 'brush-points': + return attachBrushPoints(svg, card); + case 'click-bars': + return attachClickFocus(svg, card, 'g.mark-rect.role-mark path', ['Country', 'Population']); + case 'click-arcs': + return attachClickFocus(svg, card, 'g.mark-arc.role-mark path', ['OS', 'Share']); + case 'hover-cells': + return attachHover(svg, card, 'g.mark-rect.role-mark path', ['City', 'Month', 'Temp (°C)']); + } +} + +function CandidateCard({ example }: { example: Example }) { + const previewCase = useMemo(() => findCase(example.caseId), [example.caseId]); + const spec = useMemo(() => compileCase(previewCase), [previewCase]); + const cardRef = useRef(null); + const onReady = useCallback((svg: SVGSVGElement) => { + const card = cardRef.current; + return card ? attachInteraction(example.interaction, svg, card) : undefined; + }, [example.interaction]); + + return ( +
+
+
+

{previewCase.title}

+ {example.label} +

{example.note}

+
+
+ + + +
+
+ ); +} + +export function InteractionCandidates() { + return ( +
+
+
Future interaction references
+

Hand-authored interaction candidates

+

+ Flint compiles each static chart. D3 is attached afterwards on this page only, preserving concrete + interaction treatments we may later move into compiler-owned semantics and rendering. +

+
+
+ {EXAMPLES.map((example) => )} +
+
+ ); +} \ No newline at end of file diff --git a/site/src/playground/InteractionDemoChart.tsx b/site/src/playground/InteractionDemoChart.tsx new file mode 100644 index 00000000..a613cedf --- /dev/null +++ b/site/src/playground/InteractionDemoChart.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react'; +import type { + FlintInteractionEventDetail, + InteractionDef, + InteractiveChartSurface, +} from 'flint-chart/interactive'; +import { buildInteractiveChart } from 'flint-chart/interactive'; +import { expressionInterpreter } from 'vega-interpreter'; +import type { InteractionDemoFixture } from './interaction-demo-data'; + +interface InteractionDemoChartProps { + fixture: InteractionDemoFixture; + interactions: readonly InteractionDef[]; + chartId: string; + onSurface?: (surface: InteractiveChartSurface | null) => void; + onSemanticEvent?: (detail: FlintInteractionEventDetail) => void; +} + +export function InteractionDemoChart({ + fixture, + interactions, + chartId, + onSurface, + onSemanticEvent, +}: InteractionDemoChartProps) { + const mountRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + const handleInteraction = (event: Event) => { + onSemanticEvent?.((event as CustomEvent).detail); + }; + mount.addEventListener('flint-interaction', handleInteraction); + const surface = buildInteractiveChart(mount, fixture.input, { + backend: 'vegalite', + renderer: 'svg', + interactions, + chartId, + expressionInterpreter, + ariaLabel: fixture.title, + }); + onSurface?.(surface); + void surface.ready.catch((error) => { + mount.textContent = error instanceof Error ? error.message : String(error); + }); + return () => { + onSurface?.(null); + mount.removeEventListener('flint-interaction', handleInteraction); + surface.destroy(); + }; + }, [chartId, fixture, interactions, onSemanticEvent, onSurface]); + + return
; +} diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index dbdbc1c4..c60b340e 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -10,6 +10,15 @@ const pages: NavEntry[] = [ { to: 'mcp-ui', label: 'MCP UI test' }, { to: 'labs', label: 'Labs' }, { to: 'overflow-viewport', label: 'Overflow viewport' }, + { + group: 'Interactions', + children: [ + { to: 'click-focus', label: 'Current support' }, + { to: 'external-to-chart', label: 'External to chart' }, + { to: 'chart-to-external', label: 'Chart to external' }, + { to: 'interaction-candidates', label: 'Candidates' }, + ], + }, { to: 'debug-gym', label: 'Debug gym' }, { to: 'demo-wall', label: 'Demo wall' }, { @@ -26,7 +35,7 @@ const pages: NavEntry[] = [ { to: 'full-test-cases', label: 'Full test cases' }, ]; -function ThemeLabsMenu({ group, children }: { group: string; children: NavLeaf[] }) { +function NavGroupMenu({ group, children }: { group: string; children: NavLeaf[] }) { const { pathname } = useLocation(); // A page may carry further segments (style-references/swiss), so match the // page rather than the end of the path. @@ -66,7 +75,7 @@ export function PlaygroundShell() {

Interaction gallery

-

Flint currently supports three interactions:

+

Flint currently supports five interaction families:

  • Element: Click a mark to focus it and dim the other marks.
  • Group: Click a mark to focus related marks in the same category or series.
  • Select: Drag a rectangle to focus all marks within an area.
  • +
  • X brush: Drag horizontally to focus marks across an X interval.
  • +
  • Y brush: Drag vertically to focus marks across a Y interval.
  • +
  • Stateful brush: Move the committed interval, resize either edge, or click outside to clear it.
{counts.works} bound diff --git a/site/src/playground/InteractionDashboardLab.tsx b/site/src/playground/InteractionDashboardLab.tsx new file mode 100644 index 00000000..d8bff94f --- /dev/null +++ b/site/src/playground/InteractionDashboardLab.tsx @@ -0,0 +1,376 @@ +import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'; +import { RotateCcw } from 'lucide-react'; +import type { + FlintInteractionEventDetail, + InteractionDef, + InteractiveChartSurface, + SemanticElement, +} from 'flint-chart/interactive'; +import { + brushY, + clickHighlight, + externalTrigger, + select, +} from 'flint-chart/interactive'; +import { InteractionDemoChart } from './InteractionDemoChart'; +import type { InteractionDemoFixture } from './interaction-demo-data'; +import { gapminderRows, type GapminderRow } from './gapminder-dashboard-data'; +import './interaction-dashboard-lab.css'; + +type Row = Record; + +interface LinkPayload { + observationIds: string[]; +} + +interface DashboardChart { + id: string; + fixture: InteractionDemoFixture; + interaction: InteractionDef; +} + +interface DashboardSelection { + ids: string[]; +} + +const rows: Row[] = gapminderRows.map((row) => ({ ...row, 'Year label': String(row.Year) })); +const years = [...new Set(gapminderRows.map((row) => row.Year))]; +const countryCount = new Set(gapminderRows.map((row) => row.Country)).size; +const focusCountries = new Set([ + 'Argentina', 'Australia', 'Brazil', 'China', 'Egypt', 'France', 'Germany', 'India', + 'Japan', 'Nigeria', 'South Africa', 'United States', +]); + +type DashboardMetric = 'Life expectancy' | 'GDP per capita'; +type TrendDensity = 'focus' | 'all'; + +const semanticTypes = { + Observation: 'Category', + 'Observation IDs': 'Category', + Country: 'Country', + Continent: 'Category', + Year: 'Quantity', + 'Year label': 'Category', + Population: 'Quantity', + 'Population (M)': 'Quantity', + 'Life expectancy': 'Quantity', + 'GDP per capita': 'Currency', +}; + +function idsFor(test: (row: GapminderRow) => boolean): string[] { + return gapminderRows.filter(test).map((row) => row.Observation); +} + +function recordObservationIds(record: Row): string[] { + return Array.isArray(record['Observation IDs']) + ? record['Observation IDs'].map(String) + : [String(record.Observation)]; +} + +function dashboardFixture( + id: string, + title: string, + chartType: string, + encodings: Record, + values: Row[] = rows, + chartProperties?: Record, + baseSize = { width: 350, height: 245 }, +): InteractionDemoFixture { + return { + id, + title, + source: 'Gapminder five-year data via Jenny Bryan / Plotly datasets', + input: { + data: { values }, + semantic_types: semanticTypes, + options: chartType === 'Bar Chart' + ? { defaultBandSize: 44, maxBandSize: 64 } + : undefined, + chart_spec: { + chartType, + title, + encodings, + baseSize, + chartProperties, + }, + }, + } as InteractionDemoFixture; +} + +const linkedFocus: InteractionDef = { + id: 'dashboard-linked-focus', + eventSource: externalTrigger('dashboard-link'), + update(event, context) { + if (event.type !== 'external' || event.source !== 'dashboard-link') return null; + const selected = new Set(event.payload.observationIds); + if (selected.size === 0) return { ops: [{ op: 'reset' }] }; + const elements = context.available?.filter((element) => + element.records?.some((record) => recordObservationIds(record) + .some((id) => selected.has(id)))) ?? []; + return elements.length > 0 + ? { ops: [{ op: 'emphasize', elements, mode: 'replace', dimOpacity: 0.22 }] } + : { ops: [{ op: 'reset' }] }; + }, +}; + +function buildDashboardCharts( + snapshotYear: number, + metric: DashboardMetric, + trendDensity: TrendDensity, +): DashboardChart[] { + const snapshotRows: Row[] = gapminderRows + .filter((row) => row.Year === snapshotYear) + .map((row) => ({ + ...row, + 'Population (M)': row.Population / 1_000_000, + 'Observation IDs': idsFor((candidate) => candidate.Country === row.Country), + })); + const continentRows: Row[] = [...new Set(gapminderRows.map((row) => row.Continent))].map((Continent) => { + const snapshot = gapminderRows.filter((row) => row.Year === snapshotYear && row.Continent === Continent); + return { + Continent, + 'Population (M)': snapshot.reduce((sum, row) => sum + row.Population, 0) / 1_000_000, + 'Observation IDs': idsFor((row) => row.Continent === Continent), + }; + }); + const trendRows = trendDensity === 'all' + ? rows + : rows.filter((row) => focusCountries.has(String(row.Country))); + const metricTitle = metric === 'Life expectancy' ? 'Life expectancy' : 'Income per person'; + + return [ + { + id: 'countries', + fixture: dashboardFixture( + `dashboard-countries-${snapshotYear}`, + `Health and wealth in ${snapshotYear}`, + 'Scatter Plot', + { x: 'GDP per capita', y: 'Life expectancy', size: 'Population (M)', color: 'Continent', detail: 'Country' }, + snapshotRows, + { logScale_x: true }, + { width: 430, height: 245 }, + ), + interaction: select({ id: 'dashboard-country-select' }), + }, + { + id: 'continents', + fixture: dashboardFixture( + `dashboard-continents-${snapshotYear}`, + `Population by continent, ${snapshotYear}`, + 'Bar Chart', + { x: 'Population (M)', y: 'Continent' }, + continentRows, + undefined, + { width: 430, height: 245 }, + ), + interaction: clickHighlight({ id: 'dashboard-continent-click' }), + }, + { + id: 'trends', + fixture: dashboardFixture( + `dashboard-trends-${metric}-${trendDensity}`, + `${metricTitle} trajectories, 1952–2007`, + 'Line Chart', + { x: 'Year label', y: metric, color: 'Country' }, + trendRows, + { showPoints: trendDensity === 'focus', logScale_y: metric === 'GDP per capita' }, + { width: 430, height: 300 }, + ), + interaction: clickHighlight({ id: 'dashboard-trend-click' }), + }, + { + id: 'history', + fixture: dashboardFixture( + `dashboard-history-${metric}`, + `${metricTitle} by country and year`, + 'Heatmap', + { x: 'Year label', y: 'Country', color: metric }, + rows, + ), + interaction: brushY({ id: 'dashboard-brush-y' }), + }, + ]; +} + +function observationIds(elements: readonly SemanticElement[]): string[] { + return [...new Set(elements.flatMap((element) => + element.records?.flatMap(recordObservationIds) ?? []))] + .filter((id) => id !== 'undefined'); +} + +function DashboardPanel({ + chart, + registerSurface, + routeEvent, +}: { + chart: DashboardChart; + registerSurface: (id: string, surface: InteractiveChartSurface | null) => void; + routeEvent: (detail: FlintInteractionEventDetail) => void; +}) { + const interactions = useMemo(() => [chart.interaction, linkedFocus], [chart.interaction]); + const handleSurface = useCallback( + (surface: InteractiveChartSurface | null) => registerSurface(chart.id, surface), + [chart.id, registerSurface], + ); + + return ( +
+ +
+ ); +} + +export function InteractionDashboardLab() { + const surfaces = useRef(new Map()); + const [selection, setSelection] = useState(null); + const [snapshotYear, setSnapshotYear] = useState(2007); + const [metric, setMetric] = useState('Life expectancy'); + const [trendDensity, setTrendDensity] = useState('focus'); + const deferredYear = useDeferredValue(snapshotYear); + const deferredMetric = useDeferredValue(metric); + const deferredTrendDensity = useDeferredValue(trendDensity); + const dashboardCharts = useMemo( + () => buildDashboardCharts(deferredYear, deferredMetric, deferredTrendDensity), + [deferredMetric, deferredTrendDensity, deferredYear], + ); + + const registerSurface = useCallback((id: string, surface: InteractiveChartSurface | null) => { + if (surface) surfaces.current.set(id, surface); + else surfaces.current.delete(id); + }, []); + + const dispatchSelection = useCallback((ids: string[], excludeId?: string) => { + for (const [id, surface] of surfaces.current) { + if (id === excludeId) continue; + surface.dispatch({ + type: 'external', + source: 'dashboard-link', + phase: 'commit', + payload: { observationIds: ids }, + }); + } + }, []); + + const routeEvent = useCallback((detail: FlintInteractionEventDetail) => { + if (detail.event.phase !== 'commit') return; + const ids = observationIds(detail.event.target?.elements ?? []); + const source = dashboardCharts.find((chart) => `dashboard-${chart.id}` === detail.chartId); + dispatchSelection(ids, source?.id); + setSelection(ids.length > 0 ? { ids } : null); + }, [dispatchSelection]); + + const clearSelection = useCallback(() => { + dispatchSelection([]); + setSelection(null); + }, [dispatchSelection]); + + const changeYear = useCallback((year: number) => { + clearSelection(); + setSnapshotYear(year); + }, [clearSelection]); + + const changeMetric = useCallback((nextMetric: DashboardMetric) => { + clearSelection(); + setMetric(nextMetric); + }, [clearSelection]); + + const changeTrendDensity = useCallback((density: TrendDensity) => { + clearSelection(); + setTrendDensity(density); + }, [clearSelection]); + + const activeIds = new Set(selection?.ids ?? rows.map((row) => String(row.Observation))); + const activeRows = rows.filter((row) => activeIds.has(String(row.Observation))); + const activeCountries = new Set(activeRows.map((row) => String(row.Country))); + const activeYears = activeRows.map((row) => Number(row.Year)); + const averageMetric = activeRows.reduce((sum, row) => sum + Number(row[metric]), 0) + / activeRows.length; + const yearSpan = activeYears.length > 0 + ? `${Math.min(...activeYears)}–${Math.max(...activeYears)}` + : '—'; + + return ( +
+
+
+

How health and wealth reshaped the world

+

Twenty countries across five continents and six decades of Gapminder observations.

+
+
+ +
+
+ +
+ +
+ Story metric +
+ {(['Life expectancy', 'GDP per capita'] as DashboardMetric[]).map((option) => ( + + ))} +
+
+
+ Trend lines +
+ + +
+
+
+ +
+
Countries{activeCountries.size} of {countryCount}
+
Years in focus{yearSpan}
+
+ Average {metric === 'Life expectancy' ? 'life expectancy' : 'income'} + {metric === 'Life expectancy' ? `${averageMetric.toFixed(1)} years` : `$${Math.round(averageMetric).toLocaleString()}`} +
+
Observations{activeRows.length} of {rows.length}
+
+ +
+ {dashboardCharts.map((chart) => ( + + ))} +
+
+ ); +} diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index c60b340e..60e65c77 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -8,30 +8,26 @@ type NavEntry = NavLeaf | { group: string; children: NavLeaf[] }; const pages: NavEntry[] = [ { to: 'illustrations', label: 'Illustrations' }, { to: 'mcp-ui', label: 'MCP UI test' }, - { to: 'labs', label: 'Labs' }, - { to: 'overflow-viewport', label: 'Overflow viewport' }, { - group: 'Interactions', + group: 'Labs', children: [ - { to: 'click-focus', label: 'Current support' }, - { to: 'external-to-chart', label: 'External to chart' }, - { to: 'chart-to-external', label: 'Chart to external' }, - { to: 'interaction-candidates', label: 'Candidates' }, + { to: 'labs', label: 'Labs' }, + { to: 'overflow-viewport', label: 'Overflow viewport' }, ], }, - { to: 'debug-gym', label: 'Debug gym' }, - { to: 'demo-wall', label: 'Demo wall' }, { - group: 'Theme labs', + group: 'Interactions', children: [ - { to: 'theme-labs', label: 'Theme lab' }, - { to: 'theme-lab-r2', label: 'Theme lab R2' }, - { to: 'theme-lab-real', label: 'Theme lab real' }, - { to: 'band-stretching', label: 'Band stretching' }, - { to: 'label-experiment', label: 'Label experiment' }, - { to: 'style-references', label: 'Style references' }, + { to: 'click-focus', label: 'Test cases' }, + { to: 'interaction-candidates', label: 'References' }, + { to: 'interaction-dashboard', label: 'Demo: dashboard' }, + { to: 'external-to-chart', label: 'Demo: external to chart' }, + { to: 'chart-to-external', label: 'Demo: chart to external' }, ], }, + { to: 'debug-gym', label: 'Debug gym' }, + { to: 'demo-wall', label: 'Demo wall' }, + { to: 'theme-labs', label: 'Theme' }, { to: 'full-test-cases', label: 'Full test cases' }, ]; diff --git a/site/src/playground/click-focus-lab.css b/site/src/playground/click-focus-lab.css index b052678a..05f408dc 100644 --- a/site/src/playground/click-focus-lab.css +++ b/site/src/playground/click-focus-lab.css @@ -119,7 +119,7 @@ display: flex; align-items: center; gap: 7px; - width: 92px; + width: 132px; height: 32px; border: 0; border-radius: 4px; @@ -130,6 +130,7 @@ font: inherit; font-size: 11px; font-weight: 600; + white-space: nowrap; } .cf-action-rail button:hover { diff --git a/site/src/playground/gapminder-dashboard-data.ts b/site/src/playground/gapminder-dashboard-data.ts new file mode 100644 index 00000000..24617115 --- /dev/null +++ b/site/src/playground/gapminder-dashboard-data.ts @@ -0,0 +1,47 @@ +export interface GapminderRow { + [key: string]: string | number; + Observation: string; + Country: string; + Continent: string; + Year: number; + Population: number; + 'Life expectancy': number; + 'GDP per capita': number; +} + +type GapminderTuple = [string, string, number, number, number, number]; + +const tuples: GapminderTuple[] = [ + ['Argentina','Americas',1952,17876956,62.485,5911.315053],['Argentina','Americas',1957,19610538,64.399,6856.856212],['Argentina','Americas',1962,21283783,65.142,7133.166023],['Argentina','Americas',1967,22934225,65.634,8052.953021],['Argentina','Americas',1972,24779799,67.065,9443.038526],['Argentina','Americas',1977,26983828,68.481,10079.02674],['Argentina','Americas',1982,29341374,69.942,8997.897412],['Argentina','Americas',1987,31620918,70.774,9139.671389],['Argentina','Americas',1992,33958947,71.868,9308.41871],['Argentina','Americas',1997,36203463,73.275,10967.28195],['Argentina','Americas',2002,38331121,74.34,8797.640716],['Argentina','Americas',2007,40301927,75.32,12779.37964], + ['Australia','Oceania',1952,8691212,69.12,10039.59564],['Australia','Oceania',1957,9712569,70.33,10949.64959],['Australia','Oceania',1962,10794968,70.93,12217.22686],['Australia','Oceania',1967,11872264,71.1,14526.12465],['Australia','Oceania',1972,13177000,71.93,16788.62948],['Australia','Oceania',1977,14074100,73.49,18334.19751],['Australia','Oceania',1982,15184200,74.74,19477.00928],['Australia','Oceania',1987,16257249,76.32,21888.88903],['Australia','Oceania',1992,17481977,77.56,23424.76683],['Australia','Oceania',1997,18565243,78.83,26997.93657],['Australia','Oceania',2002,19546792,80.37,30687.75473],['Australia','Oceania',2007,20434176,81.235,34435.36744], + ['Egypt','Africa',1952,22223309,41.893,1418.822445],['Egypt','Africa',1957,25009741,44.444,1458.915272],['Egypt','Africa',1962,28173309,46.992,1693.335853],['Egypt','Africa',1967,31681188,49.293,1814.880728],['Egypt','Africa',1972,34807417,51.137,2024.008147],['Egypt','Africa',1977,38783863,53.319,2785.493582],['Egypt','Africa',1982,45681811,56.006,3503.729636],['Egypt','Africa',1987,52799062,59.797,3885.46071],['Egypt','Africa',1992,59402198,63.674,3794.755195],['Egypt','Africa',1997,66134291,67.217,4173.181797],['Egypt','Africa',2002,73312559,69.806,4754.604414],['Egypt','Africa',2007,80264543,71.338,5581.180998], + ['France','Europe',1952,42459667,67.41,7029.809327],['France','Europe',1957,44310863,68.93,8662.834898],['France','Europe',1962,47124000,70.51,10560.48553],['France','Europe',1967,49569000,71.55,12999.91766],['France','Europe',1972,51732000,72.38,16107.19171],['France','Europe',1977,53165019,73.83,18292.63514],['France','Europe',1982,54433565,74.89,20293.89746],['France','Europe',1987,55630100,76.34,22066.44214],['France','Europe',1992,57374179,77.46,24703.79615],['France','Europe',1997,58623428,78.64,25889.78487],['France','Europe',2002,59925035,79.59,28926.03234],['France','Europe',2007,61083916,80.657,30470.0167], + ['Indonesia','Asia',1952,82052000,37.468,749.6816546],['Indonesia','Asia',1957,90124000,39.918,858.9002707],['Indonesia','Asia',1962,99028000,42.518,849.2897701],['Indonesia','Asia',1967,109343000,45.964,762.4317721],['Indonesia','Asia',1972,121282000,49.203,1111.107907],['Indonesia','Asia',1977,136725000,52.702,1382.702056],['Indonesia','Asia',1982,153343000,56.159,1516.872988],['Indonesia','Asia',1987,169276000,60.137,1748.356961],['Indonesia','Asia',1992,184816000,62.681,2383.140898],['Indonesia','Asia',1997,199278000,66.041,3119.335603],['Indonesia','Asia',2002,211060000,68.588,2873.91287],['Indonesia','Asia',2007,223547000,70.65,3540.651564], + ['Kenya','Africa',1952,6464046,42.27,853.540919],['Kenya','Africa',1957,7454779,44.686,944.4383152],['Kenya','Africa',1962,8678557,47.949,896.9663732],['Kenya','Africa',1967,10191512,50.654,1056.736457],['Kenya','Africa',1972,12044785,53.559,1222.359968],['Kenya','Africa',1977,14500404,56.155,1267.613204],['Kenya','Africa',1982,17661452,58.766,1348.225791],['Kenya','Africa',1987,21198082,59.339,1361.936856],['Kenya','Africa',1992,25020539,59.285,1341.921721],['Kenya','Africa',1997,28263827,54.407,1360.485021],['Kenya','Africa',2002,31386842,50.992,1287.514732],['Kenya','Africa',2007,35610177,54.11,1463.249282], + ['Korea, Rep.','Asia',1952,20947571,47.453,1030.592226],['Korea, Rep.','Asia',1957,22611552,52.681,1487.593537],['Korea, Rep.','Asia',1962,26420307,55.292,1536.344387],['Korea, Rep.','Asia',1967,30131000,57.716,2029.228142],['Korea, Rep.','Asia',1972,33505000,62.612,3030.87665],['Korea, Rep.','Asia',1977,36436000,64.766,4657.22102],['Korea, Rep.','Asia',1982,39326000,67.123,5622.942464],['Korea, Rep.','Asia',1987,41622000,69.81,8533.088805],['Korea, Rep.','Asia',1992,43805450,72.244,12104.27872],['Korea, Rep.','Asia',1997,46173816,74.647,15993.52796],['Korea, Rep.','Asia',2002,47969150,77.045,19233.98818],['Korea, Rep.','Asia',2007,49044790,78.623,23348.13973], + ['Mexico','Americas',1952,30144317,50.789,3478.125529],['Mexico','Americas',1957,35015548,55.19,4131.546641],['Mexico','Americas',1962,41121485,58.299,4581.609385],['Mexico','Americas',1967,47995559,60.11,5754.733883],['Mexico','Americas',1972,55984294,62.361,6809.40669],['Mexico','Americas',1977,63759976,65.032,7674.929108],['Mexico','Americas',1982,71640904,67.405,9611.147541],['Mexico','Americas',1987,80122492,69.498,8688.156003],['Mexico','Americas',1992,88111030,71.455,9472.384295],['Mexico','Americas',1997,95895146,73.67,9767.29753],['Mexico','Americas',2002,102479927,74.902,10742.44053],['Mexico','Americas',2007,108700891,76.195,11977.57496], + ['South Africa','Africa',1952,14264935,45.009,4725.295531],['South Africa','Africa',1957,16151549,47.985,5487.104219],['South Africa','Africa',1962,18356657,49.951,5768.729717],['South Africa','Africa',1967,20997321,51.927,7114.477971],['South Africa','Africa',1972,23935810,53.696,7765.962636],['South Africa','Africa',1977,27129932,55.527,8028.651439],['South Africa','Africa',1982,31140029,58.161,8568.266228],['South Africa','Africa',1987,35933379,60.834,7825.823398],['South Africa','Africa',1992,39964159,61.888,7225.069258],['South Africa','Africa',1997,42835005,60.236,7479.188244],['South Africa','Africa',2002,44433622,53.365,7710.946444],['South Africa','Africa',2007,43997828,49.339,9269.657808], + ['Spain','Europe',1952,28549870,64.94,3834.034742],['Spain','Europe',1957,29841614,66.66,4564.80241],['Spain','Europe',1962,31158061,69.69,5693.843879],['Spain','Europe',1967,32850275,71.44,7993.512294],['Spain','Europe',1972,34513161,73.06,10638.75131],['Spain','Europe',1977,36439000,74.39,13236.92117],['Spain','Europe',1982,37983310,76.3,13926.16997],['Spain','Europe',1987,38880702,76.9,15764.98313],['Spain','Europe',1992,39549438,77.57,18603.06452],['Spain','Europe',1997,39855442,78.77,20445.29896],['Spain','Europe',2002,40152517,79.78,24835.47166],['Spain','Europe',2007,40448191,80.941,28821.0637], + ['Turkey','Europe',1952,22235677,43.585,1969.10098],['Turkey','Europe',1957,25670939,48.079,2218.754257],['Turkey','Europe',1962,29788695,52.098,2322.869908],['Turkey','Europe',1967,33411317,54.336,2826.356387],['Turkey','Europe',1972,37492953,57.005,3450.69638],['Turkey','Europe',1977,42404033,59.507,4269.122326],['Turkey','Europe',1982,47328791,61.036,4241.356344],['Turkey','Europe',1987,52881328,63.108,5089.043686],['Turkey','Europe',1992,58179144,66.146,5678.348271],['Turkey','Europe',1997,63047647,68.835,6601.429915],['Turkey','Europe',2002,67308928,70.845,6508.085718],['Turkey','Europe',2007,71158647,71.777,8458.276384], + ['United Kingdom','Europe',1952,50430000,69.18,9979.508487],['United Kingdom','Europe',1957,51430000,70.42,11283.17795],['United Kingdom','Europe',1962,53292000,70.76,12477.17707],['United Kingdom','Europe',1967,54959000,71.36,14142.85089],['United Kingdom','Europe',1972,56079000,72.01,15895.11641],['United Kingdom','Europe',1977,56179000,72.76,17428.74846],['United Kingdom','Europe',1982,56339704,74.04,18232.42452],['United Kingdom','Europe',1987,56981620,75.007,21664.78767],['United Kingdom','Europe',1992,57866349,76.42,22705.09254],['United Kingdom','Europe',1997,58808266,77.218,26074.53136],['United Kingdom','Europe',2002,59912431,78.471,29478.99919],['United Kingdom','Europe',2007,60776238,79.425,33203.26128], + ['Brazil','Americas',1952,56602560,50.917,2108.944355],['Brazil','Americas',1957,65551171,53.285,2487.365989],['Brazil','Americas',1962,76039390,55.665,3336.585802],['Brazil','Americas',1967,88049823,57.632,3429.864357],['Brazil','Americas',1972,100840058,59.504,4985.711467],['Brazil','Americas',1977,114313951,61.489,6660.118654],['Brazil','Americas',1982,128962939,63.336,7030.835878],['Brazil','Americas',1987,142938076,65.205,7807.095818],['Brazil','Americas',1992,155975974,67.057,6950.283021],['Brazil','Americas',1997,168546719,69.388,7957.980824],['Brazil','Americas',2002,179914212,71.006,8131.212843],['Brazil','Americas',2007,190010647,72.39,9065.800825], + ['Canada','Americas',1952,14785584,68.75,11367.16112],['Canada','Americas',1957,17010154,69.96,12489.95006],['Canada','Americas',1962,18985849,71.3,13462.48555],['Canada','Americas',1967,20819767,72.13,16076.58803],['Canada','Americas',1972,22284500,72.88,18970.57086],['Canada','Americas',1977,23796400,74.21,22090.88306],['Canada','Americas',1982,25201900,75.76,22898.79214],['Canada','Americas',1987,26549700,76.86,26626.51503],['Canada','Americas',1992,28523502,77.95,26342.88426],['Canada','Americas',1997,30305843,78.61,28954.92589],['Canada','Americas',2002,31902268,79.77,33328.96507],['Canada','Americas',2007,33390141,80.653,36319.23501], + ['China','Asia',1952,556263528,44,400.4486107],['China','Asia',1957,637408000,50.54896,575.9870009],['China','Asia',1962,665770000,44.50136,487.6740183],['China','Asia',1967,754550000,58.38112,612.7056934],['China','Asia',1972,862030000,63.11888,676.9000921],['China','Asia',1977,943455000,63.96736,741.2374699],['China','Asia',1982,1000281000,65.525,962.4213805],['China','Asia',1987,1084035000,67.274,1378.904018],['China','Asia',1992,1164970000,68.69,1655.784158],['China','Asia',1997,1230075000,70.426,2289.234136],['China','Asia',2002,1280400000,72.028,3119.280896],['China','Asia',2007,1318683096,72.961,4959.114854], + ['Germany','Europe',1952,69145952,67.5,7144.114393],['Germany','Europe',1957,71019069,69.1,10187.82665],['Germany','Europe',1962,73739117,70.3,12902.46291],['Germany','Europe',1967,76368453,70.8,14745.62561],['Germany','Europe',1972,78717088,71,18016.18027],['Germany','Europe',1977,78160773,72.5,20512.92123],['Germany','Europe',1982,78335266,73.8,22031.53274],['Germany','Europe',1987,77718298,74.847,24639.18566],['Germany','Europe',1992,80597764,76.07,26505.30317],['Germany','Europe',1997,82011073,77.34,27788.88416],['Germany','Europe',2002,82350671,78.67,30035.80198],['Germany','Europe',2007,82400996,79.406,32170.37442], + ['India','Asia',1952,372000000,37.373,546.5657493],['India','Asia',1957,409000000,40.249,590.061996],['India','Asia',1962,454000000,43.605,658.3471509],['India','Asia',1967,506000000,47.193,700.7706107],['India','Asia',1972,567000000,50.651,724.032527],['India','Asia',1977,634000000,54.208,813.337323],['India','Asia',1982,708000000,56.596,855.7235377],['India','Asia',1987,788000000,58.553,976.5126756],['India','Asia',1992,872000000,60.223,1164.406809],['India','Asia',1997,959000000,61.765,1458.817442],['India','Asia',2002,1034172547,62.879,1746.769454],['India','Asia',2007,1110396331,64.698,2452.210407], + ['Japan','Asia',1952,86459025,63.03,3216.956347],['Japan','Asia',1957,91563009,65.5,4317.694365],['Japan','Asia',1962,95831757,68.73,6576.649461],['Japan','Asia',1967,100825279,71.43,9847.788607],['Japan','Asia',1972,107188273,73.42,14778.78636],['Japan','Asia',1977,113872473,75.38,16610.37701],['Japan','Asia',1982,118454974,77.11,19384.10571],['Japan','Asia',1987,122091325,78.67,22375.94189],['Japan','Asia',1992,124329269,79.36,26824.89511],['Japan','Asia',1997,125956499,80.69,28816.58499],['Japan','Asia',2002,127065841,82,28604.5919],['Japan','Asia',2007,127467972,82.603,31656.06806], + ['Nigeria','Africa',1952,33119096,36.324,1077.281856],['Nigeria','Africa',1957,37173340,37.802,1100.592563],['Nigeria','Africa',1962,41871351,39.36,1150.927478],['Nigeria','Africa',1967,47287752,41.04,1014.514104],['Nigeria','Africa',1972,53740085,42.821,1698.388838],['Nigeria','Africa',1977,62209173,44.514,1981.951806],['Nigeria','Africa',1982,73039376,45.826,1576.97375],['Nigeria','Africa',1987,81551520,46.886,1385.029563],['Nigeria','Africa',1992,93364244,47.472,1619.848217],['Nigeria','Africa',1997,106207839,47.464,1624.941275],['Nigeria','Africa',2002,119901274,46.608,1615.286395],['Nigeria','Africa',2007,135031164,46.859,2013.977305], + ['United States','Americas',1952,157553000,68.44,13990.48208],['United States','Americas',1957,171984000,69.49,14847.12712],['United States','Americas',1962,186538000,70.21,16173.14586],['United States','Americas',1967,198712000,70.76,19530.36557],['United States','Americas',1972,209896000,71.34,21806.03594],['United States','Americas',1977,220239000,73.38,24072.63213],['United States','Americas',1982,232187835,74.65,25009.55914],['United States','Americas',1987,242803533,75.02,29884.35041],['United States','Americas',1992,256894189,76.09,32003.93224],['United States','Americas',1997,272911760,76.81,35767.43303],['United States','Americas',2002,287675526,77.31,39097.09955],['United States','Americas',2007,301139947,78.242,42951.65309], +]; + +export const gapminderRows: GapminderRow[] = tuples.map( + ([Country, Continent, Year, Population, lifeExpectancy, gdpPerCapita]) => ({ + Observation: `${Country}-${Year}`, + Country, + Continent, + Year, + Population, + 'Life expectancy': lifeExpectancy, + 'GDP per capita': gdpPerCapita, + }), +); diff --git a/site/src/playground/interaction-dashboard-lab.css b/site/src/playground/interaction-dashboard-lab.css new file mode 100644 index 00000000..053f3045 --- /dev/null +++ b/site/src/playground/interaction-dashboard-lab.css @@ -0,0 +1,326 @@ +.idash-page { + gap: 18px; + width: min(100%, 1280px); + max-width: 1280px; + margin: 0 auto; + color: #20262c; +} + +.idash-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + padding: 10px 0 18px; + border-bottom: 2px solid #20262c; +} + +.idash-heading h1 { + margin: 5px 0 7px; + font-family: "Source Serif 4", "Iowan Old Style", Georgia, serif; + font-size: 38px; + font-weight: 620; + line-height: 1.08; + letter-spacing: 0; +} + +.idash-heading > div > p:last-child { + margin: 0; + color: #647078; + font-size: 13px; + line-height: 1.45; +} + +.idash-status { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 16px; +} + +.idash-status > div { + display: grid; + gap: 2px; + min-width: 120px; +} + +.idash-status span { + color: #7a858d; + font-size: 9px; + font-weight: 650; + letter-spacing: 0; + text-transform: uppercase; +} + +.idash-status strong { + max-width: 190px; + overflow: hidden; + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.idash-status button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + border: 1px solid #cbd2d7; + border-radius: 5px; + padding: 0 10px; + color: #44515a; + background: #fff; + cursor: pointer; + font: inherit; + font-size: 10px; + font-weight: 650; +} + +.idash-status button:hover:not(:disabled) { + border-color: #8f9aa2; + background: #f4f6f7; +} + +.idash-status button:disabled { + cursor: default; + opacity: 0.42; +} + +.idash-controls { + display: grid; + grid-template-columns: minmax(300px, 1.4fr) repeat(2, minmax(220px, 1fr)); + align-items: end; + gap: 24px; + padding: 2px 0 16px; + border-bottom: 1px solid #cfd6da; +} + +.idash-year-control, +.idash-control-group { + display: grid; + gap: 7px; + min-width: 0; +} + +.idash-year-control { + grid-template-columns: 1fr 52px; +} + +.idash-year-control > span, +.idash-control-group > span { + grid-column: 1 / -1; + color: #748088; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} + +.idash-year-control input { + width: 100%; + margin: 0; + accent-color: #27749d; +} + +.idash-year-control strong { + align-self: center; + font-family: "Source Serif 4", "Iowan Old Style", Georgia, serif; + font-size: 17px; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.idash-segments { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + min-height: 32px; + border: 1px solid #cbd2d7; + border-radius: 6px; + padding: 2px; + background: #f4f6f7; +} + +.idash-segments button { + min-width: 0; + border: 0; + border-radius: 4px; + padding: 0 10px; + color: #5d6971; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 10px; + font-weight: 650; + white-space: nowrap; +} + +.idash-segments button:hover { + color: #20262c; +} + +.idash-segments button[aria-pressed="true"] { + color: #20262c; + background: #fff; + box-shadow: 0 1px 3px rgb(32 38 44 / 12%); +} + +.idash-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid #dce1e4; +} + +.idash-kpis > div { + display: grid; + gap: 4px; + padding: 4px 18px 15px; + border-right: 1px solid #e1e5e8; +} + +.idash-kpis > div:first-child { + padding-left: 0; +} + +.idash-kpis > div:last-child { + border-right: 0; +} + +.idash-kpis span { + color: #748088; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} + +.idash-kpis strong { + font-family: "Source Serif 4", "Iowan Old Style", Georgia, serif; + font-size: 22px; + font-weight: 620; +} + +.idash-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + width: 100%; + gap: 14px; +} + +.idash-panel { + min-width: 0; + overflow: hidden; + border: 1px solid #d9dfe3; + border-radius: 6px; + background: #fff; +} + +.idash-panel .it-chart-mount { + display: grid; + align-items: start; + justify-items: center; + min-height: 320px; + overflow: hidden; + padding: 10px; +} + +@media (max-width: 900px) { + .idash-controls { + grid-template-columns: minmax(280px, 1fr) repeat(2, minmax(180px, 0.7fr)); + gap: 14px; + } + + .idash-chart-grid { + grid-template-columns: minmax(0, 1fr); + } + + .idash-panel-countries, + .idash-panel-continents, + .idash-panel-trends, + .idash-panel-history { + grid-column: auto; + } +} + +@media (max-width: 760px) { + .idash-heading { + align-items: flex-start; + flex-direction: column; + } + + .idash-status { + width: 100%; + } + + .idash-controls { + grid-template-columns: minmax(0, 1fr); + gap: 14px; + } + + .idash-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .idash-kpis > div { + padding: 8px 12px 12px; + border-bottom: 1px solid #e1e5e8; + } + + .idash-kpis > div:first-child { + padding-left: 12px; + } + + .idash-panel .it-chart-mount { + height: 394px; + min-height: 340px; + padding: 8px; + } + +} + +@media (max-width: 540px) { + .idash-status { + align-items: stretch; + flex-wrap: wrap; + gap: 12px 20px; + } + + .idash-status > div:first-child { + flex: 1 1 170px; + } + + .idash-status button { + margin-left: auto; + } + + .idash-heading h1 { + font-size: 32px; + } + + .idash-panel .it-chart-mount { + height: 350px; + min-height: 350px; + } + + .idash-panel .flint-interactive-surface { + position: relative; + left: 50%; + justify-self: start; + width: 516px !important; + max-width: none !important; + margin-left: -258px !important; + transform: scale(0.9); + transform-origin: top center; + } +} + +@media (max-width: 420px) { + .idash-panel .it-chart-mount { + height: 260px; + min-height: 260px; + } + + .idash-panel .flint-interactive-surface { + transform: scale(0.64); + } +} diff --git a/site/src/playground/interaction-demo-data.ts b/site/src/playground/interaction-demo-data.ts index df25232c..83a9c056 100644 --- a/site/src/playground/interaction-demo-data.ts +++ b/site/src/playground/interaction-demo-data.ts @@ -48,7 +48,7 @@ export const salesFixture = fixture( 'sales', 'Regional sales by segment', 'Sample Superstore-style snapshot', 'Grouped Bar Chart', salesRows, { Region: 'Category', Segment: 'Category', 'Sales ($K)': 'Quantity', 'Profit ($K)': 'Quantity' }, - { x: 'Region', y: 'Sales ($K)', color: 'Segment' }, + { x: 'Region', y: 'Sales ($K)', group: 'Segment' }, ); const countryRows = [ diff --git a/site/src/playground/playground.css b/site/src/playground/playground.css index f9c5c54d..948d65af 100644 --- a/site/src/playground/playground.css +++ b/site/src/playground/playground.css @@ -100,6 +100,15 @@ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); } +.dev-nav-dropdown::before { + position: absolute; + right: 0; + bottom: 100%; + left: 0; + height: 5px; + content: ""; +} + .dev-nav-group:hover .dev-nav-dropdown, .dev-nav-group:focus-within .dev-nav-dropdown { display: flex; From 2fd39834e28f30ef472a19adf906860bb4b16cfa Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Tue, 25 Aug 2026 17:23:30 -0700 Subject: [PATCH 14/33] updates --- agent-skills/flint-theme-author/SKILL.md | 4 +- docs/theme-spec.md | 20 + packages/flint-js/src/core/index.ts | 1 + .../src/core/interaction-semantics.ts | 2 +- packages/flint-js/src/core/theme/ground.ts | 12 + packages/flint-js/src/core/theme/types.ts | 24 +- packages/flint-js/src/core/types.ts | 10 + packages/flint-js/src/interactive/README.md | 272 +++- .../src/interactive/geometry/angular.ts | 49 + .../interactive/geometry/coordinate-space.ts | 61 + .../interactive/gestures/angular-region.ts | 49 + .../interactive/gestures/cartesian-region.ts | 72 ++ .../src/interactive/gestures/navigation.ts | 39 + packages/flint-js/src/interactive/index.ts | 13 +- .../flint-js/src/interactive/interactions.ts | 46 +- .../src/interactive/presets/README.md | 45 + .../src/interactive/presets/angular-brush.ts | 27 + .../src/interactive/presets/click-annotate.ts | 2 +- .../flint-js/src/interactive/presets/index.ts | 2 + .../src/interactive/presets/navigate.ts | 72 ++ .../src/interactive/triggers/events.ts | 31 +- .../src/interactive/triggers/index.ts | 35 +- packages/flint-js/src/vegalite/assemble.ts | 17 +- .../src/vegalite/interaction-provenance.ts | 28 + .../src/vegalite/interactions/compile.ts | 424 +++++++ .../src/vegalite/interactions/contracts.ts | 45 + .../interactions/gestures/navigation.ts | 174 +++ .../vegalite/interactions/gestures/region.ts | 373 ++++++ .../interactions/hit-adapter.ts} | 142 ++- .../vegalite/interactions/navigation-scale.ts | 140 ++ .../presentation/annotation-overlay.ts | 183 +++ .../presentation/focus-overlay.ts | 198 +++ .../src/vegalite/interactions/runtime.ts | 426 +++++++ .../src/vegalite/interactions/stores.ts | 4 + packages/flint-js/src/vegalite/interactive.ts | 19 +- .../src/vegalite/semantic-interactions.ts | 1126 ----------------- .../flint-js/src/vegalite/templates/area.ts | 2 + .../flint-js/src/vegalite/templates/bar.ts | 14 +- .../flint-js/src/vegalite/templates/bump.ts | 1 + .../src/vegalite/templates/candlestick.ts | 1 + .../vegalite/templates/connected-scatter.ts | 1 + .../src/vegalite/templates/density.ts | 1 + .../flint-js/src/vegalite/templates/ecdf.ts | 1 + .../flint-js/src/vegalite/templates/gantt.ts | 11 +- .../flint-js/src/vegalite/templates/jitter.ts | 1 + .../flint-js/src/vegalite/templates/line.ts | 2 + .../src/vegalite/templates/lollipop.ts | 1 + .../flint-js/src/vegalite/templates/pie.ts | 1 + .../src/vegalite/templates/range-area.ts | 1 + .../flint-js/src/vegalite/templates/rose.ts | 20 +- .../src/vegalite/templates/scatter.ts | 4 + .../flint-js/src/vegalite/templates/slope.ts | 1 + .../src/vegalite/templates/waterfall.ts | 15 +- packages/flint-js/src/vegalite/theme.ts | 16 +- packages/flint-js/tests/interactions.test.ts | 134 +- .../tests/semantic-interactions.test.ts | 417 +++++- .../assets/flint-theme-author.SKILL.md | 4 +- site/src/main.tsx | 1 + site/src/playground/ChartToExternalLab.tsx | 2 +- site/src/playground/ClickFocusLab.tsx | 143 ++- .../playground/InteractionDashboardLab.tsx | 84 +- site/src/playground/PlaygroundShell.tsx | 12 +- site/src/playground/click-focus-lab.css | 54 + .../playground/interaction-dashboard-lab.css | 71 +- 64 files changed, 3854 insertions(+), 1349 deletions(-) create mode 100644 packages/flint-js/src/interactive/geometry/angular.ts create mode 100644 packages/flint-js/src/interactive/geometry/coordinate-space.ts create mode 100644 packages/flint-js/src/interactive/gestures/angular-region.ts create mode 100644 packages/flint-js/src/interactive/gestures/cartesian-region.ts create mode 100644 packages/flint-js/src/interactive/gestures/navigation.ts create mode 100644 packages/flint-js/src/interactive/presets/README.md create mode 100644 packages/flint-js/src/interactive/presets/angular-brush.ts create mode 100644 packages/flint-js/src/interactive/presets/navigate.ts create mode 100644 packages/flint-js/src/vegalite/interaction-provenance.ts create mode 100644 packages/flint-js/src/vegalite/interactions/compile.ts create mode 100644 packages/flint-js/src/vegalite/interactions/contracts.ts create mode 100644 packages/flint-js/src/vegalite/interactions/gestures/navigation.ts create mode 100644 packages/flint-js/src/vegalite/interactions/gestures/region.ts rename packages/flint-js/src/{interactive/triggers/vega.ts => vegalite/interactions/hit-adapter.ts} (82%) create mode 100644 packages/flint-js/src/vegalite/interactions/navigation-scale.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts create mode 100644 packages/flint-js/src/vegalite/interactions/runtime.ts create mode 100644 packages/flint-js/src/vegalite/interactions/stores.ts delete mode 100644 packages/flint-js/src/vegalite/semantic-interactions.ts diff --git a/agent-skills/flint-theme-author/SKILL.md b/agent-skills/flint-theme-author/SKILL.md index 4df9c637..925d9697 100644 --- a/agent-skills/flint-theme-author/SKILL.md +++ b/agent-skills/flint-theme-author/SKILL.md @@ -129,7 +129,7 @@ the authored blocks and their jobs: | `layout` | Density, target width, title block, and band step | | `chartDefaults` | Optional defaults keyed by registered chart type or `*`; caller values still win | | `compileDefaults` | Preferred base size, canvas size, and supported assemble options | -| `interaction` | Tooltip format | +| `interaction` | Tooltip format and semantic selection-boundary paint | | `variants` | Conditional policy adaptations; variants may not change `ink` or `type` | ### High-value nested shapes @@ -154,6 +154,8 @@ the authored blocks and their jobs: } ``` +`interaction.selectionBoundary` accepts `color`, `width`, `opacity`, `haloColor`, `haloWidth`, and `haloOpacity`. Omitted paint is grounded from the theme: foreground from `ink.accent` then primary text, and halo from the plot or canvas surface. Use explicit values only when the house has a distinct interaction treatment. + This is a shape example, not a palette recommendation. Derive actual values from the user's references. diff --git a/docs/theme-spec.md b/docs/theme-spec.md index fe0fd614..47864ab9 100644 --- a/docs/theme-spec.md +++ b/docs/theme-spec.md @@ -70,11 +70,31 @@ Every field is optional. Start with the decisions that matter to your product, t | `annotation` | Units, axis titles, number formats, point emphasis, and statistics | | `layout`, `facets` | Density, title spacing, band steps, panel spacing, and shared scales | | `chartDefaults`, `compileDefaults` | House defaults for chart controls, base size, canvas size, and layout limits | +| `interaction` | Tooltip formatting and semantic selection-boundary paint | | `furniture` | Rules, tabs, and other recurring chart chrome | | `variants` | Semantic conditions that adapt policy to a chart's role, density, or shape | Theme rules are semantic. For example, `structure.grid.measure` controls the grid used to read values, whichever physical axis carries the measure. `legend.placement` gives the compiler an ordered set of acceptable positions rather than fixed coordinates. This is what lets one theme generalize across different chart types, data, and canvas sizes. +Selection boundaries are inferred from the theme unless explicitly stated. Their foreground defaults to `ink.accent`, then `ink.text.primary`; their halo defaults to the plot or canvas surface. This gives a continuous-color grid an outline that belongs to the house while remaining legible across both ends of its ramp. A theme can override the treatment: + +```json +{ + "interaction": { + "selectionBoundary": { + "color": "#b54a20", + "width": 1.5, + "opacity": 1, + "haloColor": "#ffffff", + "haloWidth": 3, + "haloOpacity": 0.8 + } + } +} +``` + +This block controls paint only. The ChartDef still decides whether a representation needs a boundary and the renderer still computes its contiguous geometry. + ### 3. Inherit and override Use `extends` when a preset is close to your brand: diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 26c622d7..da968a5e 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -200,6 +200,7 @@ export { // ThemeSpec: public visual-system vocabulary and chart-specific grounding export { type ThemeSpec, + type ThemeInteraction, type ThemePreset, type DesignDecisions, type ThemeReport, diff --git a/packages/flint-js/src/core/interaction-semantics.ts b/packages/flint-js/src/core/interaction-semantics.ts index 200919bb..c07ac0b0 100644 --- a/packages/flint-js/src/core/interaction-semantics.ts +++ b/packages/flint-js/src/core/interaction-semantics.ts @@ -20,7 +20,7 @@ export interface SemanticTarget { } export interface SemanticResolveEvent { - gesture: 'click' | 'hover' | 'rectangle'; + gesture: 'click' | 'hover' | 'rectangle' | 'angular'; role: string; hits: readonly RenderHit[]; legendValue?: unknown; diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts index ad4e7315..18affeb2 100644 --- a/packages/flint-js/src/core/theme/ground.ts +++ b/packages/flint-js/src/core/theme/ground.ts @@ -1698,6 +1698,8 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe const padding = isPaintedSurface(canvas) ? Math.max(densityPadding, Math.round((axisLabelText.fontSize ?? 10) * 1.5)) : densityPadding; + const selectionBoundary = theme.interaction?.selectionBoundary; + const selectionBoundaryWidth = Math.max(0, selectionBoundary?.width ?? 1.5); return { themeId: theme.id ?? 'flint', @@ -1750,6 +1752,16 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe } : undefined, marks, + interaction: { + selectionBoundary: { + color: selectionBoundary?.color ?? theme.ink?.accent ?? text.primary, + width: selectionBoundaryWidth, + opacity: clamp(selectionBoundary?.opacity ?? 1, 0, 1), + haloColor: selectionBoundary?.haloColor ?? plot ?? canvas, + haloWidth: Math.max(selectionBoundaryWidth, selectionBoundary?.haloWidth ?? 3), + haloOpacity: clamp(selectionBoundary?.haloOpacity ?? 0.8, 0, 1), + }, + }, facets, layout: { padding, diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts index a3b80912..64ff9b8a 100644 --- a/packages/flint-js/src/core/theme/types.ts +++ b/packages/flint-js/src/core/theme/types.ts @@ -601,6 +601,18 @@ export interface ThemeCompileDefaults extends Partial { canvasSize?: { width: number; height: number }; } +export interface ThemeInteraction { + tooltipFormat?: string; + selectionBoundary?: { + color?: string; + width?: number; + opacity?: number; + haloColor?: string; + haloWidth?: number; + haloOpacity?: number; + }; +} + /** * Level 1. One JSON document per design language. * @@ -634,7 +646,7 @@ export interface ThemeSpec { geometry?: ThemeGeometry; chartDefaults?: ThemeChartDefaults; compileDefaults?: ThemeCompileDefaults; - interaction?: { tooltipFormat?: string }; + interaction?: ThemeInteraction; variants?: ThemeVariant[]; } @@ -916,6 +928,16 @@ export interface DesignDecisions { size: number; }; marks: ResolvedMarks; + interaction: { + selectionBoundary: { + color: string; + width: number; + opacity: number; + haloColor: string; + haloWidth: number; + haloOpacity: number; + }; + }; facets: { header: { show: boolean; fieldTitle: boolean } & ResolvedText; panelFrame: boolean; diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 22e48b36..35e020f1 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -901,6 +901,11 @@ export interface ChartTemplateDef { /** Which encoding channels are available for this chart */ channels: string[]; + /** Cartesian positional channels whose continuous domains may be navigated at runtime. */ + navigation?: { + axes?: readonly ('x' | 'y')[]; + }; + /** * How the primary mark encodes its quantitative value. * Determines zero-baseline, scale tightness, and compression behavior. @@ -922,6 +927,7 @@ export interface ChartTemplateDef { seriesField?: string; legendFields?: Record; selectableMarks: string[]; + supportedRegionGestures?: ('cartesian' | 'angular')[]; renderHoverStyles?: Record; + renderSelectionStyles?: Record; resolve: import('./interaction-semantics').ChartInteractionResolver; presentUpdate: import('../interactive/interactions').ChartUpdateProcessor; }; diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index 6102b9e4..da2fe38c 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -19,9 +19,13 @@ Separate interaction input, semantic resolution, policy, chart updates, and rend ```mermaid flowchart LR - A[Raw browser or Vega event] --> B[Trigger normalization] - B --> C[Normalized Element or Region event] - C --> D[ChartDef resolve] + S[Interaction eventSource] --> B[Backend mount] + A[Raw browser or renderer event] --> B + B --> C[Gesture recognizer] + C --> N[Navigation event] + N --> H[Preset update policy] + C --> R[Backend hit adapter] + R --> D[ChartDef resolve] D --> E[Semantic event] E --> F[Interaction coordinator] F --> G[flint-interaction transport] @@ -36,12 +40,16 @@ The normative ownership boundary is: | Stage | Owner | Input | Output | Must not own | | --- | --- | --- | --- | --- | -| 1. Normalize input | Trigger | Raw browser or renderer event | `ElementInteractionEvent` or `RegionInteractionEvent` | Semantic meaning or chart updates | -| 2. Resolve semantics | ChartDef resolver | Normalized geometry and `RenderHit[]` | Physical `SemanticTarget` | Interaction policy or cohort expansion | -| 3. Coordinate | Interaction coordinator | Resolved semantic event | Outbound event and policy invocation | Chart-specific semantic meaning | -| 4. Decide update | Preset policy | Semantic or External event | `ChartUpdate` | Renderer-specific presentation | -| 5. Present update | ChartDef `presentUpdate` | `ChartUpdate` | Chart-specific presented update | Renderer mutation | -| 6. Apply update | Renderer runtime | Presented update | Renderer state | Semantic inference or policy | +| 1. Declare interpretation | Interaction `eventSource` | Author configuration | Element, region, navigation, or external source descriptor | Renderer geometry or semantic meaning | +| 2. Capture gesture | Backend mount + shared recognizer | Source descriptor and native events | Renderer-neutral point or region geometry | Gesture inference, semantic meaning, or chart updates | +| 3. Resolve physical hits | Backend hit adapter | Normalized geometry and renderer state | `RenderHit[]` | Chart-type meaning or interaction policy | +| 4. Resolve semantics | ChartDef resolver | Gesture context and `RenderHit[]` | Physical `SemanticTarget` | Interaction policy or cohort expansion | +| 5. Coordinate | Interaction coordinator | Resolved semantic event | Outbound event and policy invocation | Chart-specific semantic meaning | +| 6. Decide update | Preset policy | Semantic or External event | `ChartUpdate` | Renderer-specific presentation | +| 7. Present update | ChartDef `presentUpdate` | `ChartUpdate` | Chart-specific presented update | Renderer mutation | +| 8. Apply update | Renderer runtime | Presented update | Renderer state | Semantic inference or policy | + +Navigation deliberately takes a shorter path. It controls a continuous viewport rather than a semantic chart element, so its normalized event proceeds from the gesture recognizer to preset policy without fabricating `RenderHit[]` or a `SemanticTarget`. ChartDef resolves and presents chart semantics. It does **not** own DOM transport. The coordinator emits resolved semantic events externally because transport identity (`chartId`, `interactionId`, and transaction metadata) is surface-level state, not chart semantics. @@ -50,7 +58,9 @@ An internal event follows this call sequence: ```mermaid sequenceDiagram participant Browser as Browser/Vega - participant Trigger + participant Mount as Backend mount + participant Gesture as Gesture recognizer + participant Hits as Backend hit adapter participant ChartDef as ChartDef.resolve participant Coordinator participant Host as External host @@ -58,8 +68,11 @@ sequenceDiagram participant Present as ChartDef.presentUpdate participant Runtime as Renderer runtime - Browser->>Trigger: raw event + rendered item - Trigger->>Coordinator: Element/Region event with geometry + hits + Browser->>Mount: native event + Mount->>Gesture: configured eventSource + pointer stream + Gesture-->>Mount: normalized gesture geometry + Mount->>Hits: geometry + renderer state + Hits-->>Coordinator: Element/Region event with physical hits Coordinator->>ChartDef: normalized internal event ChartDef-->>Coordinator: physical SemanticTarget Coordinator-->>Host: flint-interaction semantic event @@ -90,6 +103,89 @@ sequenceDiagram External events bypass chart resolution because their payload already uses the vocabulary agreed between the source and interaction definition. +## Backward Semantic Resolution + +Internal interaction depends on a reversible path from authored chart semantics to rendered geometry and back. SVG nodes and Vega scenegraph items know about marks, bounds, and renderer data, but they do not inherently know which Flint semantic element they represent. Flint establishes that connection automatically during chart assembly, before rendering. + +This instrumentation is compiler-owned. The chart author declares semantic fields and interactions; they do not create hidden key columns, maintain selection parameters, or wire renderer predicates into every mark. The compiler derives the required identity metadata from the ChartDef and instruments generated marks consistently. By comparison, a direct Vega-Lite workflow generally requires the spec author to define selection parameters and connect them to mark encodings or transforms. Flint keeps that renderer bookkeeping out of the authored chart, so an agent or application can reason in terms of semantic elements rather than reconstructing scenegraph identity itself. + +At a high level, this resembles automatic differentiation: PyTorch instruments a forward computation so its runtime can traverse it backward without requiring users to maintain derivatives by hand. Flint instruments forward chart compilation so its runtime can traverse a rendered hit backward without requiring users to maintain selection keys or renderer-to-data mappings by hand. Flint performs semantic resolution rather than numerical differentiation, but the shared architectural idea is automatic, system-maintained provenance. + +```mermaid +flowchart LR + A[ChartDef semantic fields] --> B[Interaction instrumentation] + B --> C[Compiled renderer datum] + C --> D[SVG or scenegraph item] + D --> E[Physical RenderHit] + E --> F[ChartDef resolve] + F --> G[SemanticTarget] +``` + +### Instrumentation + +For each interactive mark, the compiler derives a stable key from the ChartDef's semantic identity fields and writes it into the renderer datum as `__flint_interaction_key`. The key survives Vega-Lite compilation and is therefore available on the rendered scenegraph item. It is private generated state, not part of the user's data contract. + +Generated semantic representations may also declare provenance. A generated text label declares: + +```ts +interface InteractionProvenance { + role: 'text-label'; + identity: 'inherit' | { fields: readonly string[] }; + presentation: 'on-mark' | 'independent'; +} +``` + +`identity` determines which semantic key the representation receives. Most value labels inherit the mark identity. An aggregate label can name a smaller field set; for example, a rose category label may identify only its category even though each arc is identified by category and series. + +Instrumentation lowers the generation-time provenance into runtime datum metadata: + +```ts +__flint_interaction_key // which semantic data identity this item represents +__flint_interaction_role // which kind of representation produced the hit +``` + +Generation-time provenance is removed before Vega compilation. The lowered datum fields are the bridge through compilation because Vega preserves them on scenegraph items. + +### Physical Hit Normalization + +The renderer trigger locates the SVG or scenegraph item under a pointer, reads its instrumented datum, and emits a renderer-neutral `RenderHit`. The trigger may report mark type, mark name, bounds, path geometry, and representation role, but it does not assign chart meaning. + +Text is deliberately inert unless its datum carries the `text-label` role. This prevents titles, axis text, and unrelated annotations from becoming selectable merely because they share chart data. + +The interaction key and role answer different questions: + +| Metadata | Question | Used for | +| --- | --- | --- | +| `__flint_interaction_key` | Which semantic data identity does this item represent? | Constructing and matching `SemanticElement.key` | +| `__flint_interaction_role` | Which representation of that identity was hit? | Choosing representation-aware resolution behavior | + +### ChartDef Resolution + +The normalized role and `RenderHit[]` are passed to the owning ChartDef resolver. The resolver converts renderer facts into a `SemanticTarget`; this is the backward boundary where renderer-specific items become semantic elements. + +For a direct mark, resolution commonly maps each hit key to one `SemanticElement`. A representation can require different resolution even when it refers to related data. For example, clicking one rose arc resolves that arc, while clicking a `text-label` for January can resolve the January label identity to every arc represented by that aggregate label. + +The role is not part of semantic identity and is not used to match forward updates. It is resolution context. The key establishes identity; the role lets the ChartDef interpret the physical representation that exposed it. Normal marks can use the default `mark` role, while representations such as `text-label` require an explicit role when their backward mapping differs. + +The result contains no SVG node or Vega scenegraph item: + +```ts +interface SemanticTarget { + visual: { + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle'; + role: string; + }; + elements: readonly SemanticElement[]; +} + +interface SemanticElement { + key: Record; + records?: readonly Record[]; +} +``` + +After this boundary, presets and external hosts operate on semantic elements. They do not inspect renderer geometry to rediscover meaning. + ## Normalized Events ```ts @@ -98,6 +194,7 @@ type InteractionPhase = 'start' | 'preview' | 'commit' | 'cancel'; type NormalizedInteractionEvent = | ElementInteractionEvent | RegionInteractionEvent + | NavigationInteractionEvent | ExternalInteractionEvent; interface ElementInteractionEvent { @@ -117,6 +214,16 @@ interface RegionInteractionEvent { modifiers?: InteractionModifiers; } +interface NavigationInteractionEvent { + type: 'navigation'; + phase: InteractionPhase; + operation: 'pan' | 'zoom' | 'reset'; + axes: 'x' | 'y' | 'xy'; + delta?: PlotPoint; // plot fractions + factor?: number; + anchor?: PlotPoint; // plot fractions +} + interface ExternalInteractionEvent { type: 'external'; source: string; @@ -125,7 +232,7 @@ interface ExternalInteractionEvent { } ``` -`Element` and `Region` describe physical chart input at the geometry level. They may contain coordinates, region geometry, rendered mark metadata, and data records in `RenderHit[]`, but they do not claim semantic meaning. `External` is deliberately generic and typed by the interaction that consumes it. +`Element` and `Region` describe physical chart input at the geometry level. They may contain coordinates, region geometry, rendered mark metadata, and data records in `RenderHit[]`, but they do not claim semantic meaning. `Navigation` describes a viewport transform in plot fractions and likewise carries no semantic target. `External` is deliberately generic and typed by the interaction that consumes it. ## Semantic Resolution @@ -154,6 +261,7 @@ An interaction consumes either a resolved semantic event or an external event an ```ts type InteractionInput = | SemanticInteractionEvent + | NavigationInteractionEvent | ExternalInteractionEvent; interface InteractionDef { @@ -166,13 +274,20 @@ interface InteractionDef { } ``` -Chart-specific action policy belongs here. For example, element highlighting for `Ranged Dot Plot` expands the resolved endpoint to both endpoints and the connector before producing an `emphasize` update. +An interaction definition has two declarative halves: + +1. `eventSource` declares **what input to capture and how to interpret it physically**. The same native `pointerdown -> pointermove -> pointerup` stream becomes a free rectangle for `select()`, an axis-constrained interval for `brushX()` or `brushY()`, and an angular sector for `brushAngle()`. +2. `update()` declares **what update policy to apply** after normalization. Target-bearing events first pass through ChartDef semantic resolution; navigation and external events do not. It returns renderer-neutral operations such as `emphasize`, `annotate`, `navigate-viewport`, or `reset`. + +The backend mount reads `eventSource`; it does not infer a gesture from pointer motion. It installs the required native listeners, supplies renderer coordinates and hit testing, and runs the recognizer requested by the interaction. This keeps an identical drag stream deterministic and author-controlled. + +Chart-specific action policy belongs in the interaction policy half. For example, element highlighting for `Ranged Dot Plot` expands the resolved endpoint to both endpoints and the connector before producing an `emphasize` update. Presets are compositions of predefined triggers from `interactive/triggers/` and policies. They refer to reusable descriptors such as `clickTrigger` and `rectangleTrigger()` rather than defining event acquisition inline. They are convenience APIs, not architectural primitives. ## Triggers -`interactive/triggers/` owns the event-source contracts, built-in trigger definitions, the shared interaction-event vocabulary, and renderer-specific user-event normalization. Interaction policy types only refer to those contracts; they do not define event acquisition. +`interactive/triggers/` owns event-source contracts, built-in trigger descriptors, and the shared interaction-event vocabulary. A backend owns renderer-specific event normalization and realizes these descriptors against its native event and coordinate systems. Type colocation does not change production ownership: triggers produce only Element, Region, and External normalized events. The coordinator produces `SemanticInteractionEvent` only after ChartDef resolution. Its type lives in `events.ts` so the full event vocabulary has one definition site. @@ -184,31 +299,152 @@ hoverTrigger rectangleTrigger('intersect' | 'contain') xBrushTrigger('intersect' | 'contain') yBrushTrigger('intersect' | 'contain') +angularBrushTrigger('intersect' | 'contain') +navigationTrigger() externalTrigger(source?) ``` +### Cartesian navigation + +`navigate()` combines drag pan, wheel zoom, and reset as one viewport policy. ChartDefs opt in explicitly with `navigation.axes`; assembly then intersects that capability with resolved quantitative or temporal x/y encodings. An explicitly requested unsupported axis is an error. With `axes: 'available'`, categorical axes are omitted automatically. + +The gesture reports incremental pan deltas and zoom anchors as plot fractions. The preset adds percentage-based domain guards to `navigate-viewport`: + +```ts +navigate({ + axes: 'available', + domainGuard: { + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, + }, +}) +``` + +The backend scale adapter owns conversion between these normalized fractions and linear, temporal, or logarithmic scale domains. Guards are relative to the initial domain: minimum and maximum visible fractions bound zoom, while overscroll controls how far the current domain may move beyond its allowed extent. Vega realizes the result through explicit `domainRaw` signals, so viewport mutation remains separate from semantic selection stores. + +V1 requires top-level continuous Cartesian scales. Faceted charts are excluded because each child view needs scoped scale ownership, and geographic/map navigation is excluded because projected coordinates require a projection-aware adapter rather than Cartesian domain arithmetic. + `xBrushTrigger()` and `yBrushTrigger()` emit region events constrained to one axis. An X brush spans the full plot height; a Y brush spans the full plot width. The trigger performs this projection in plot geometry and reports physical hits. It does not inspect chart orientation or invert scales. The corresponding `brushX()` and `brushY()` presets apply semantic emphasis to the elements resolved from those hits. This is element-based brushing; domain-range inversion for linked charts remains a separate ChartDef resolver capability. +`angularBrushTrigger()` emits an annular-sector region centered on a rendered polar chart. Pointer angles use the renderer's convention: zero is 12 o'clock and positive angles proceed clockwise. The runtime unwraps pointer motion continuously, so a drag can cross the $0/2\pi$ seam or proceed counterclockwise without jumping to the complementary sector. + +The corresponding `brushAngle()` preset is accepted only when the owning ChartDef declares angular-region support. Pie, donut, and rose charts opt in; Cartesian ChartDefs reject the interaction during planning. Arc intersection and containment are evaluated from rendered `startAngle`, `endAngle`, `innerRadius`, and `outerRadius` geometry, while the existing ChartDef resolver retains ownership of semantic identity. + Brushes support two lifecycle modes: ```ts brushX({ mode: 'ephemeral' }) // default: overlay exists only during the drag brushY({ mode: 'stateful' }) // committed overlay remains editable +brushAngle() // ephemeral polar sector ``` -Select and brush share the same region gesture engine. `select()` configures a free two-dimensional ephemeral rectangle. A stateful axis brush retains its committed interval, allows dragging the body to move it, allows dragging either edge to resize it, and clears on an outside click or Escape. Region events identify these transitions with `create`, `move`, `resize-leading`, `resize-trailing`, and `clear` operations. This state and its interaction chrome are owned per chart surface by the trigger runtime; presets remain stateless semantic policies. +Select and Cartesian brushes share the rectangular region gesture engine. `select()` configures a free two-dimensional ephemeral rectangle. A stateful axis brush retains its committed interval, allows dragging the body to move it, allows dragging either edge to resize it, and clears on an outside click or Escape. Angular brushing is currently ephemeral; editable wrapped-angle handles require a separate circular interaction model. Region events identify transitions with `create`, `move`, `resize-leading`, `resize-trailing`, and `clear` operations. This state and its interaction chrome are owned per chart surface by the trigger runtime; presets remain stateless semantic policies. The folder is organized as: - `index.ts`: event-source contracts and built-in trigger definitions. - `events.ts`: shared geometry, phases, normalized input event types, and the post-resolution semantic event type. -- `vega.ts`: Vega coordinates, scenegraph hits, legend targets, and region geometry normalization. The public triggers are exported from `flint-chart/interactive`. The source contract remains open so applications can define custom sources. -A source may register listeners, track gesture state, compute renderer geometry, inspect rendered marks, and emit normalized events. It must not resolve semantic targets, contain chart-type policy, mutate renderer state, or construct chart updates. +### Gesture and backend ownership + +Gesture recognition is shared interaction infrastructure; binding a gesture to rendered chart objects is backend infrastructure. + +The interaction is authoritative about gesture intent: + +```text +select() -> cartesian + xy -> free rectangular selection +brushX() -> cartesian + x -> horizontal interval +brushY() -> cartesian + y -> vertical interval +brushAngle() -> angular -> polar angular sector +navigate() -> cartesian axes -> pan / zoom viewport transform +``` + +The backend mount is authoritative about realization: + +```text +configured eventSource + native pointer stream + -> matching recognizer + -> normalized physical region + -> renderer-specific physical hits +``` + +It must not reinterpret an `x` brush as a free selection, choose angular behavior merely because the chart contains arcs, or guess among configured operations from pointer trajectory. + +`interactive/` owns: + +- renderer-neutral pointer-session state such as angular sweep accumulation and interval transitions; +- Cartesian and angular gesture math; +- renderer-neutral regions such as `PlotRect`, `PlotPolygon`, and `PlotAngularSector`; +- presets that translate resolved semantic targets into `ChartUpdate` operations. + +A backend owns: + +- discovering its plot coordinate space and converting client points into it; +- finding renderer-specific frames such as the center and radii of a polar plot; +- mapping normalized regions to physical rendered hits; +- normalizing renderer element and legend events; +- mounting the recognizer declared by `eventSource` against its native event system; +- owning pointer capture and drawing backend-aligned gesture chrome; +- applying updates to renderer stores and drawing representation-specific presentation. + +For Vega-Lite, `vegalite/interactions/` is the composition boundary. It wires shared gesture recognizers to Vega coordinate discovery, scenegraph hit testing, ChartDef semantic resolution, preset policy, and Vega presentation. Shared gesture modules must not import Vega or inspect scenegraph items. + +The stages are therefore distinct: + +```mermaid +flowchart TD + Source["Configured eventSource
Owner: InteractionDef"] --> Capture + Pointer["PointerEvent clientX/clientY
Owner: browser"] --> Capture + Capture["Native listener and pointer capture
Owner: backend mount"] --> Measure + Measure["DOM bounds, logical size, plot origin
Owner: backend coordinate discovery"] --> Convert + Convert["Client -> renderer -> plot coordinates
Owner: shared coordinate geometry"] --> Gesture + Gesture["Rectangle, interval, or angular sector
Owner: shared gesture recognizer"] --> Hits + Hits["Renderer geometry -> RenderHit[]
Owner: backend hit adapter"] --> Resolve + Resolve["RenderHit[] -> SemanticTarget
Owner: ChartDef resolver"] --> Policy + Policy["SemanticTarget -> ChartUpdate
Owner: interaction preset"] --> Present + Present["Representation-aware update
Owner: ChartDef presentUpdate"] --> Apply + Apply["Stores and visual overlays
Owner: backend runtime"] +``` + +Visual gesture feedback takes the reverse spatial path: the backend sends plot geometry through the shared plot-to-client and client-to-layout transforms, then draws it in its DOM, canvas, or SVG overlay. + +The implementation follows that boundary: + +```text +interactive/ + geometry/ + angular.ts # angular intervals and sector paths + coordinate-space.ts # renderer-neutral coordinate transforms + gestures/ + angular-region.ts # angular pointer-session state + cartesian-region.ts # Cartesian projection and interval transitions + navigation.ts # pan sessions and wheel normalization + presets/ # semantic update policies + triggers/ # renderer-neutral source descriptors and events + +vegalite/interactions/ + contracts.ts # Vega interaction plan contracts + stores.ts # Vega selection and hover stores + compile.ts # Vega-Lite instrumentation and Vega store injection + hit-adapter.ts # Vega coordinates, scenegraph traversal, and physical hits + runtime.ts # resolve -> policy -> present -> apply coordinator + gestures/ + region.ts # Vega mounting for rectangle, axis, and angular drags + navigation.ts # Vega mounting for pan, wheel zoom, and reset + navigation-scale.ts # Vega domain guards and signal updates + presentation/ + focus-overlay.ts # path focus and selection boundaries + annotation-overlay.ts # annotation anchors, placement, and drawing +``` + +Vega interaction code imports its concrete owner directly. Compile instrumentation comes from `vegalite/interactions/compile.ts`, runtime coordination from `runtime.ts`, and physical adaptation from `hit-adapter.ts`. There is intentionally no cross-layer interaction barrel: narrow imports make ownership violations visible during review. + +A custom source may register listeners and emit normalized events. Renderer-specific mounting code may additionally compute renderer geometry and inspect rendered marks. Neither source descriptors nor mounts may resolve semantic targets, contain chart-type policy, or construct chart updates. ## Update Language diff --git a/packages/flint-js/src/interactive/geometry/angular.ts b/packages/flint-js/src/interactive/geometry/angular.ts new file mode 100644 index 00000000..d35ac8d3 --- /dev/null +++ b/packages/flint-js/src/interactive/geometry/angular.ts @@ -0,0 +1,49 @@ +import type { PlotAngularSector, PlotPoint } from '../triggers/events'; + +export const TAU = 2 * Math.PI; + +export function angularSegments(startAngle: number, endAngle: number): [number, number][] { + const sweep = endAngle - startAngle; + if (Math.abs(sweep) >= TAU - 1e-9) return [[0, TAU]]; + const leading = sweep >= 0 ? startAngle : endAngle; + const extent = Math.abs(sweep); + const start = ((leading % TAU) + TAU) % TAU; + const end = start + extent; + return end <= TAU ? [[start, end]] : [[start, TAU], [0, end - TAU]]; +} + +export function angularSectorPath(sector: PlotAngularSector): string { + const rawSweep = sector.endAngle - sector.startAngle; + const sweep = Math.min(TAU, Math.max(-TAU, rawSweep)); + if (Math.abs(sweep) < 1e-9 || sector.outerRadius <= 0) return ''; + const point = (radius: number, angle: number): PlotPoint => ({ + x: sector.center.x + radius * Math.sin(angle), + y: sector.center.y - radius * Math.cos(angle), + }); + const outerStart = point(sector.outerRadius, sector.startAngle); + if (Math.abs(sweep) >= TAU - 1e-9) { + const direction = sweep > 0 ? 1 : 0; + const reverse = direction ? 0 : 1; + const outerMid = point(sector.outerRadius, sector.startAngle + Math.sign(sweep) * Math.PI); + const outerCircle = `M ${outerStart.x} ${outerStart.y} ` + + `A ${sector.outerRadius} ${sector.outerRadius} 0 1 ${direction} ${outerMid.x} ${outerMid.y} ` + + `A ${sector.outerRadius} ${sector.outerRadius} 0 1 ${direction} ${outerStart.x} ${outerStart.y}`; + if (sector.innerRadius <= 0) return `${outerCircle} Z`; + const innerStart = point(sector.innerRadius, sector.startAngle); + const innerMid = point(sector.innerRadius, sector.startAngle + Math.sign(sweep) * Math.PI); + return `${outerCircle} L ${innerStart.x} ${innerStart.y} ` + + `A ${sector.innerRadius} ${sector.innerRadius} 0 1 ${reverse} ${innerMid.x} ${innerMid.y} ` + + `A ${sector.innerRadius} ${sector.innerRadius} 0 1 ${reverse} ${innerStart.x} ${innerStart.y} Z`; + } + const outerEnd = point(sector.outerRadius, sector.startAngle + sweep); + const largeArc = Math.abs(sweep) > Math.PI ? 1 : 0; + const sweepFlag = sweep > 0 ? 1 : 0; + const outerArc = `A ${sector.outerRadius} ${sector.outerRadius} 0 ${largeArc} ${sweepFlag} ${outerEnd.x} ${outerEnd.y}`; + if (sector.innerRadius <= 0) { + return `M ${sector.center.x} ${sector.center.y} L ${outerStart.x} ${outerStart.y} ${outerArc} Z`; + } + const innerEnd = point(sector.innerRadius, sector.startAngle + sweep); + const innerStart = point(sector.innerRadius, sector.startAngle); + return `M ${outerStart.x} ${outerStart.y} ${outerArc} L ${innerEnd.x} ${innerEnd.y} ` + + `A ${sector.innerRadius} ${sector.innerRadius} 0 ${largeArc} ${sweepFlag ? 0 : 1} ${innerStart.x} ${innerStart.y} Z`; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/geometry/coordinate-space.ts b/packages/flint-js/src/interactive/geometry/coordinate-space.ts new file mode 100644 index 00000000..eef04754 --- /dev/null +++ b/packages/flint-js/src/interactive/geometry/coordinate-space.ts @@ -0,0 +1,61 @@ +import type { InteractionModifiers, PlotPoint } from '../triggers/events'; + +export interface RendererCoordinateSpace { + rect: DOMRect; + logicalWidth: number; + logicalHeight: number; + originX: number; + originY: number; + plotWidth: number; + plotHeight: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function interactionModifiers(event: MouseEvent | PointerEvent): InteractionModifiers { + return { shift: event.shiftKey, ctrl: event.ctrlKey, meta: event.metaKey }; +} + +export function clientToPlotPoint(client: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + const rendererX = (client.x - space.rect.left) * space.logicalWidth / space.rect.width; + const rendererY = (client.y - space.rect.top) * space.logicalHeight / space.rect.height; + return { + x: clamp(rendererX - space.originX, 0, space.plotWidth), + y: clamp(rendererY - space.originY, 0, space.plotHeight), + }; +} + +export function plotToClientPoint(point: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + return { + x: space.rect.left + (point.x + space.originX) * space.rect.width / space.logicalWidth, + y: space.rect.top + (point.y + space.originY) * space.rect.height / space.logicalHeight, + }; +} + +export function clientToLayoutPoint( + point: PlotPoint, + rect: Pick, + layoutSize: { width: number; height: number }, +): PlotPoint { + return { + x: (point.x - rect.left) * layoutSize.width / rect.width, + y: (point.y - rect.top) * layoutSize.height / rect.height, + }; +} + +export function clientRectToLayoutRect( + rect: Pick, + containerRect: Pick, + layoutSize: { width: number; height: number }, +): { left: number; top: number; width: number; height: number } { + const leading = clientToLayoutPoint({ x: rect.left, y: rect.top }, containerRect, layoutSize); + const trailing = clientToLayoutPoint({ x: rect.right, y: rect.bottom }, containerRect, layoutSize); + return { + left: leading.x, + top: leading.y, + width: trailing.x - leading.x, + height: trailing.y - leading.y, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/gestures/angular-region.ts b/packages/flint-js/src/interactive/gestures/angular-region.ts new file mode 100644 index 00000000..7c5eaf52 --- /dev/null +++ b/packages/flint-js/src/interactive/gestures/angular-region.ts @@ -0,0 +1,49 @@ +import type { PlotAngularSector, PlotPoint } from '../triggers/events'; +import { TAU } from '../geometry/angular'; + +export interface PolarFrame { + center: PlotPoint; + innerRadius: number; + outerRadius: number; +} + +export function polarPointerAngle(point: PlotPoint, frame: PolarFrame): number { + return Math.atan2(point.x - frame.center.x, frame.center.y - point.y); +} + +export class AngularRegionSession { + private previousAngle: number; + private sweep = 0; + private readonly startAngle: number; + + constructor( + start: PlotPoint, + readonly frame: PolarFrame, + ) { + this.startAngle = polarPointerAngle(start, frame); + this.previousAngle = this.startAngle; + } + + move(point: PlotPoint): void { + const angle = polarPointerAngle(point, this.frame); + this.sweep = Math.min(TAU, Math.max(-TAU, this.sweep + Math.atan2( + Math.sin(angle - this.previousAngle), + Math.cos(angle - this.previousAngle), + ))); + this.previousAngle = angle; + } + + dragDistance(): number { + return Math.abs(this.sweep) * this.frame.outerRadius; + } + + sector(): PlotAngularSector { + return { + center: this.frame.center, + innerRadius: this.frame.innerRadius, + outerRadius: this.frame.outerRadius, + startAngle: this.startAngle, + endAngle: this.startAngle + this.sweep, + }; + } +} diff --git a/packages/flint-js/src/interactive/gestures/cartesian-region.ts b/packages/flint-js/src/interactive/gestures/cartesian-region.ts new file mode 100644 index 00000000..9fcc761d --- /dev/null +++ b/packages/flint-js/src/interactive/gestures/cartesian-region.ts @@ -0,0 +1,72 @@ +import type { PlotPoint, RegionAxis, RegionOperation } from '../triggers/events'; + +export type CartesianRegionAxis = Extract; +export type IntervalOperation = Exclude; + +export interface PlotSize { + width: number; + height: number; +} + +export interface Interval { + leading: number; + trailing: number; +} + +export function constrainCartesianRegion( + start: PlotPoint, + end: PlotPoint, + axis: CartesianRegionAxis, + plotSize: PlotSize, +): { start: PlotPoint; end: PlotPoint } { + return { + start: { x: axis === 'y' ? 0 : start.x, y: axis === 'x' ? 0 : start.y }, + end: { x: axis === 'y' ? plotSize.width : end.x, y: axis === 'x' ? plotSize.height : end.y }, + }; +} + +export function cartesianDragDistance(start: PlotPoint, end: PlotPoint, axis: CartesianRegionAxis): number { + if (axis === 'x') return Math.abs(end.x - start.x); + if (axis === 'y') return Math.abs(end.y - start.y); + return Math.hypot(end.x - start.x, end.y - start.y); +} + +export function axisValue(point: PlotPoint, axis: Exclude): number { + return axis === 'y' ? point.y : point.x; +} + +export function intervalPoints( + interval: Interval, + axis: Exclude, +): { start: PlotPoint; end: PlotPoint } { + return axis === 'y' + ? { start: { x: 0, y: interval.leading }, end: { x: 0, y: interval.trailing } } + : { start: { x: interval.leading, y: 0 }, end: { x: interval.trailing, y: 0 } }; +} + +export function updateInterval( + point: PlotPoint, + start: PlotPoint, + axis: Exclude, + limit: number, + operation: IntervalOperation, + initial?: Interval, +): Interval { + const value = axisValue(point, axis); + if (!initial || operation === 'create') { + const anchor = axisValue(start, axis); + return { leading: Math.min(anchor, value), trailing: Math.max(anchor, value) }; + } + if (operation === 'move') { + const width = initial.trailing - initial.leading; + const delta = value - axisValue(start, axis); + const leading = Math.max(0, Math.min(limit - width, initial.leading + delta)); + return { leading, trailing: leading + width }; + } + const leading = operation === 'resize-leading' ? value : initial.leading; + const trailing = operation === 'resize-trailing' ? value : initial.trailing; + return { + leading: Math.max(0, Math.min(limit, Math.min(leading, trailing))), + trailing: Math.max(0, Math.min(limit, Math.max(leading, trailing))), + }; +} diff --git a/packages/flint-js/src/interactive/gestures/navigation.ts b/packages/flint-js/src/interactive/gestures/navigation.ts new file mode 100644 index 00000000..fbf46808 --- /dev/null +++ b/packages/flint-js/src/interactive/gestures/navigation.ts @@ -0,0 +1,39 @@ +import type { PlotPoint } from '../triggers/events'; + +export interface PlotSize { + width: number; + height: number; +} + +export class PanSession { + private previous: PlotPoint; + private totalDistance = 0; + + constructor(start: PlotPoint, private readonly plotSize: PlotSize) { + this.previous = start; + } + + move(point: PlotPoint): PlotPoint { + const pixelDelta = { x: point.x - this.previous.x, y: point.y - this.previous.y }; + this.previous = point; + this.totalDistance += Math.hypot(pixelDelta.x, pixelDelta.y); + return { + x: this.plotSize.width > 0 ? pixelDelta.x / this.plotSize.width : 0, + y: this.plotSize.height > 0 ? pixelDelta.y / this.plotSize.height : 0, + }; + } + + dragDistance(): number { + return this.totalDistance; + } +} + +export function wheelZoomFactor( + deltaY: number, + deltaMode: number, + viewportHeight: number, + sensitivity: number, +): number { + const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * viewportHeight : deltaY; + return Math.exp(-pixels * sensitivity); +} diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 9bb12d03..2f283db6 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -19,6 +19,7 @@ export type { ChartUpdate, ChartUpdateProcessor, BrushOptions, + AngularBrushOptions, ClickAnnotateOptions, ClickGroupHighlightOptions, ClickHighlightOptions, @@ -30,7 +31,13 @@ export type { InteractionContext, InteractionDef, InteractionModifiers, + NavigateOptions, + NavigationAxes, + NavigationDomainGuard, + NavigationInteractionEvent, + NavigationOperation, PlotPoint, + PlotAngularSector, PlotPolygon, PlotRect, RegionAxis, @@ -44,22 +51,26 @@ export type { NormalizedInteractionEvent, UpdateOp, } from './interactions'; -export { brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, select } from './interactions'; +export { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, navigate, select } from './interactions'; export type { InteractionEventSource, InteractionEventSourceContext } from './triggers'; export { axisBrushTrigger, + angularBrushTrigger, clickTrigger, externalTrigger, hoverTrigger, + navigationTrigger, rectangleTrigger, xBrushTrigger, yBrushTrigger, } from './triggers'; export { + AngularBrushInteraction, BrushInteraction, ClickAnnotateInteraction, ClickGroupHighlightInteraction, ClickHighlightInteraction, + NavigateInteraction, SelectInteraction, } from './presets'; export { clampViewportStart, mountInteractiveChartSurface } from './surface'; diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index e6467a4c..a94c4dd4 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -4,20 +4,25 @@ import type { ExternalInteractionEvent, InteractionModifiers, InteractionPhase, + NavigationAxes, + NavigationInteractionEvent, PlotPoint, SemanticInteractionEvent, } from './triggers/events'; import { BrushInteraction, + AngularBrushInteraction, ClickAnnotateInteraction, ClickGroupHighlightInteraction, ClickHighlightInteraction, SelectInteraction, + NavigateInteraction, } from './presets'; export type { RenderHit, SemanticElement, SemanticTarget } from '../core/interaction-semantics'; export type InteractionInput = | SemanticInteractionEvent + | NavigationInteractionEvent | ExternalInteractionEvent; export interface FlintInteractionEventDetail { @@ -25,7 +30,7 @@ export interface FlintInteractionEventDetail { interactionId: string; timestamp: number; transactionId?: string; - event: SemanticInteractionEvent; + event: SemanticInteractionEvent | NavigationInteractionEvent; } export type { @@ -33,8 +38,12 @@ export type { ExternalInteractionEvent, InteractionModifiers, InteractionPhase, + NavigationAxes, + NavigationInteractionEvent, + NavigationOperation, NormalizedInteractionEvent, PlotPoint, + PlotAngularSector, PlotPolygon, PlotRect, RegionAxis, @@ -51,11 +60,27 @@ export interface AnnotationRenderPlan { anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'mark-end' | 'arc-centroid'; } +export interface NavigationDomainGuard { + minVisibleFraction: number; + maxVisibleFraction: number; + overscrollFraction: number; +} + export type UpdateOp = | { op: 'emphasize'; elements: readonly SemanticElement[]; mode: SelectionMode; dimOpacity: number } | { op: 'annotate'; element: SemanticElement; text: string; point?: PlotPoint } | { op: 'render-annotation'; element: SemanticElement; point?: PlotPoint; annotation: AnnotationRenderPlan } | { op: 'clear-annotation' } + | { + op: 'navigate-viewport'; + phase: InteractionPhase; + operation: import('./triggers/events').NavigationOperation; + axes: NavigationAxes; + delta?: PlotPoint; + factor?: number; + anchor?: PlotPoint; + domainGuard: NavigationDomainGuard; + } | { op: 'reset' }; export interface ChartUpdate { @@ -106,6 +131,17 @@ export interface BrushOptions extends SelectOptions { mode?: 'ephemeral' | 'stateful'; } +export type AngularBrushOptions = SelectOptions; + +export interface NavigateOptions { + id?: string; + axes?: NavigationAxes | 'available'; + pan?: boolean; + zoom?: boolean; + wheelSensitivity?: number; + domainGuard?: Partial; +} + export function clickHighlight(options: ClickHighlightOptions = {}): InteractionDef { return new ClickHighlightInteraction(options); } @@ -130,6 +166,14 @@ export function brushY(options: BrushOptions = {}): InteractionDef { return new BrushInteraction('y', options); } +export function brushAngle(options: AngularBrushOptions = {}): InteractionDef { + return new AngularBrushInteraction(options); +} + +export function navigate(options: NavigateOptions = {}): InteractionDef { + return new NavigateInteraction(options); +} + export function normalizeInteractions( interactions: readonly InteractionDef[] | undefined, focusOnClick: boolean | undefined, diff --git a/packages/flint-js/src/interactive/presets/README.md b/packages/flint-js/src/interactive/presets/README.md new file mode 100644 index 00000000..ee2a1a7f --- /dev/null +++ b/packages/flint-js/src/interactive/presets/README.md @@ -0,0 +1,45 @@ +# Interaction Presets + +Presets translate semantic interaction events into renderer-neutral `ChartUpdate` operations. They decide what action to take, not how a particular mark should draw that action. + +Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, and rose. All three produce the same semantic `emphasize` operation after the owning ChartDef resolves physical hits. + +## Emphasis Policy + +Built-in selection presets use one clear opacity rule: + +- focused elements retain their authored opacity; +- unfocused elements use `0.25` opacity; +- categorical color does not introduce a separate dimming range. + +Keeping one value makes linked views predictable. A bar, point, arc, line, or cell receives the same semantic emphasis operation even when its chart presents focus differently. + +## Representation-Aware Presentation + +The owning ChartDef may add a focus treatment when opacity alone is insufficient: + +| Representation | Focus presentation | +| --- | --- | +| Filled categorical marks | Full opacity; unfocused peers at `0.25` | +| Lines | Full opacity and authored stroke width multiplied by `1.2`; unfocused peers at `0.25` | +| Continuous-color cells | Full opacity plus a contiguous-region boundary; unfocused cells at `0.25` | + +Line width is proportional rather than fixed, so a theme's authored hierarchy survives interaction: + +$$ +w_{focus} = 1.2 w_{authored} +$$ + +For continuous-color grids, the boundary is drawn once around each contiguous selected region rather than around every cell. This preserves the heatmap as a field instead of introducing a competing internal grid. Its paint comes from the grounded ThemeSpec `interaction.selectionBoundary` role. By default, the foreground borrows the theme accent and the halo borrows the plot surface, keeping the treatment visible over both ends of a color ramp without introducing renderer-owned colors. + +## Ownership + +The stages remain separate: + +1. Trigger normalization reports physical hits. +2. ChartDef resolution converts hits into semantic elements. +3. A preset emits `emphasize` with the selected elements and dim opacity. +4. ChartDef presentation declares representation-specific focus styling. +5. The renderer applies opacity, proportional line width, or region boundaries mechanically. + +Presets must not inspect SVG, scenegraph geometry, color scales, or authored stroke widths. Those are renderer and ChartDef presentation concerns. \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/angular-brush.ts b/packages/flint-js/src/interactive/presets/angular-brush.ts new file mode 100644 index 00000000..5464e190 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/angular-brush.ts @@ -0,0 +1,27 @@ +import type { + AngularBrushOptions, + ChartUpdate, + InteractionContext, + InteractionDef, + InteractionInput, +} from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from '../emphasis-update'; +import { angularBrushTrigger } from '../triggers'; + +export class AngularBrushInteraction implements InteractionDef { + readonly id: string; + readonly eventSource; + private readonly dimOpacity: number; + + constructor(options: AngularBrushOptions = {}) { + this.id = options.id ?? 'brush-angle'; + this.eventSource = angularBrushTrigger(options.match ?? 'intersect'); + this.dimOpacity = normalizedOpacity(options.dimOpacity); + } + + update(event: InteractionInput): ChartUpdate | null { + if (event.type !== 'semantic' || event.source !== 'region' || event.axis !== 'angle' + || event.phase === 'start' || event.phase === 'cancel') return null; + return emphasisUpdate(event.target, event.modifiers, this.dimOpacity); + } +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts index 8f2eec17..5fda326f 100644 --- a/packages/flint-js/src/interactive/presets/click-annotate.ts +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -23,7 +23,7 @@ function annotationText(element: SemanticTarget['elements'][number], context: In const entries = Object.entries(record).filter(([field]) => !field.startsWith('__')); const candidates = entries.filter(([field]) => field !== context.categoryField && field !== context.seriesField); const numeric = [...candidates].reverse().find(([, value]) => typeof value === 'number' && Number.isFinite(value)); - const selected = numeric ?? candidates.at(-1) ?? entries.at(-1); + const selected = numeric ?? candidates[candidates.length - 1] ?? entries[entries.length - 1]; return displayValue(selected?.[1]); } diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index 58ca1ee8..1350399f 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -1,5 +1,7 @@ export { BrushInteraction } from './brush'; +export { AngularBrushInteraction } from './angular-brush'; export { ClickAnnotateInteraction } from './click-annotate'; export { ClickGroupHighlightInteraction } from './click-group-highlight'; export { ClickHighlightInteraction } from './click-highlight'; export { SelectInteraction } from './select'; +export { NavigateInteraction } from './navigate'; diff --git a/packages/flint-js/src/interactive/presets/navigate.ts b/packages/flint-js/src/interactive/presets/navigate.ts new file mode 100644 index 00000000..c3427d2c --- /dev/null +++ b/packages/flint-js/src/interactive/presets/navigate.ts @@ -0,0 +1,72 @@ +import type { + ChartUpdate, + InteractionContext, + InteractionDef, + InteractionInput, + NavigateOptions, + NavigationDomainGuard, +} from '../interactions'; +import { navigationTrigger } from '../triggers'; + +const DEFAULT_DOMAIN_GUARD: NavigationDomainGuard = { + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, +}; + +function normalizedFraction(value: number | undefined, fallback: number, min: number): number { + return Number.isFinite(value) ? Math.max(min, value!) : fallback; +} + +export class NavigateInteraction implements InteractionDef { + readonly id: string; + readonly eventSource; + private readonly domainGuard: NavigationDomainGuard; + + constructor(options: NavigateOptions = {}) { + this.id = options.id ?? 'navigate'; + this.eventSource = navigationTrigger({ + axes: options.axes ?? 'available', + pan: options.pan ?? true, + zoom: options.zoom ?? true, + wheelSensitivity: options.wheelSensitivity ?? 0.002, + }); + this.domainGuard = { + minVisibleFraction: normalizedFraction( + options.domainGuard?.minVisibleFraction, + DEFAULT_DOMAIN_GUARD.minVisibleFraction, + Number.EPSILON, + ), + maxVisibleFraction: normalizedFraction( + options.domainGuard?.maxVisibleFraction, + DEFAULT_DOMAIN_GUARD.maxVisibleFraction, + Number.EPSILON, + ), + overscrollFraction: normalizedFraction( + options.domainGuard?.overscrollFraction, + DEFAULT_DOMAIN_GUARD.overscrollFraction, + 0, + ), + }; + if (this.domainGuard.maxVisibleFraction < this.domainGuard.minVisibleFraction) { + throw new Error('navigate() requires maxVisibleFraction >= minVisibleFraction.'); + } + } + + update(event: InteractionInput, _context: InteractionContext): ChartUpdate | null { + if (event.type !== 'navigation') return null; + return { + phase: event.phase, + ops: [{ + op: 'navigate-viewport', + phase: event.phase, + operation: event.operation, + axes: event.axes, + delta: event.delta, + factor: event.factor, + anchor: event.anchor, + domainGuard: this.domainGuard, + }], + }; + } +} diff --git a/packages/flint-js/src/interactive/triggers/events.ts b/packages/flint-js/src/interactive/triggers/events.ts index 627a0ee3..e10a0221 100644 --- a/packages/flint-js/src/interactive/triggers/events.ts +++ b/packages/flint-js/src/interactive/triggers/events.ts @@ -16,6 +16,14 @@ export interface PlotPolygon { points: readonly PlotPoint[]; } +export interface PlotAngularSector { + center: PlotPoint; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; +} + export interface InteractionModifiers { shift: boolean; ctrl: boolean; @@ -23,8 +31,10 @@ export interface InteractionModifiers { } export type InteractionPhase = 'start' | 'preview' | 'commit' | 'cancel'; -export type RegionAxis = 'x' | 'y' | 'xy'; +export type RegionAxis = 'x' | 'y' | 'xy' | 'angle'; export type RegionOperation = 'create' | 'move' | 'resize-leading' | 'resize-trailing' | 'clear'; +export type NavigationAxes = 'x' | 'y' | 'xy'; +export type NavigationOperation = 'pan' | 'zoom' | 'reset'; export interface ElementInteractionEvent { type: 'element'; @@ -39,12 +49,26 @@ export interface RegionInteractionEvent { phase: InteractionPhase; axis: RegionAxis; operation?: RegionOperation; - region: PlotRect | PlotPolygon; + region: PlotRect | PlotPolygon | PlotAngularSector; hits: readonly RenderHit[]; match: 'intersect' | 'contain'; modifiers?: InteractionModifiers; } +export interface NavigationInteractionEvent { + type: 'navigation'; + phase: InteractionPhase; + operation: NavigationOperation; + axes: NavigationAxes; + /** Incremental translation as a fraction of the plot width and height. */ + delta?: PlotPoint; + /** Multiplicative zoom where values greater than one zoom in. */ + factor?: number; + /** Zoom anchor as a fraction of the plot width and height. */ + anchor?: PlotPoint; + modifiers?: InteractionModifiers; +} + export interface ExternalInteractionEvent { type: 'external'; source: string; @@ -56,6 +80,7 @@ export interface ExternalInteractionEvent { export type NormalizedInteractionEvent = | ElementInteractionEvent | RegionInteractionEvent + | NavigationInteractionEvent | ExternalInteractionEvent; export interface SemanticInteractionEvent { @@ -64,7 +89,7 @@ export interface SemanticInteractionEvent { phase: InteractionPhase; target: SemanticTarget | null; point?: PlotPoint; - region?: PlotRect | PlotPolygon; + region?: PlotRect | PlotPolygon | PlotAngularSector; axis?: RegionAxis; operation?: RegionOperation; modifiers?: InteractionModifiers; diff --git a/packages/flint-js/src/interactive/triggers/index.ts b/packages/flint-js/src/interactive/triggers/index.ts index 9de069e8..44ed940a 100644 --- a/packages/flint-js/src/interactive/triggers/index.ts +++ b/packages/flint-js/src/interactive/triggers/index.ts @@ -1,12 +1,16 @@ -import type { NormalizedInteractionEvent } from './events'; +import type { NavigationAxes, NormalizedInteractionEvent } from './events'; export type { ElementInteractionEvent, ExternalInteractionEvent, InteractionModifiers, InteractionPhase, + NavigationAxes, + NavigationInteractionEvent, + NavigationOperation, NormalizedInteractionEvent, PlotPoint, + PlotAngularSector, PlotPolygon, PlotRect, RegionAxis, @@ -22,10 +26,15 @@ export interface InteractionEventSourceContext { export interface InteractionEventSource { readonly type: 'element' | 'region' | 'external' | (string & {}); - readonly gesture?: 'click' | 'hover' | 'drag'; + readonly gesture?: 'click' | 'hover' | 'drag' | 'navigate'; readonly match?: 'intersect' | 'contain'; readonly axis?: 'x' | 'y' | 'xy'; readonly mode?: 'ephemeral' | 'stateful'; + readonly regionGeometry?: 'cartesian' | 'angular'; + readonly axes?: NavigationAxes | 'available'; + readonly pan?: boolean; + readonly zoom?: boolean; + readonly wheelSensitivity?: number; readonly source?: string; mount?(context: InteractionEventSourceContext): void | (() => void); } @@ -68,6 +77,28 @@ export function yBrushTrigger( return axisBrushTrigger('y', match, mode); } +export function angularBrushTrigger( + match: 'intersect' | 'contain' = 'intersect', +): InteractionEventSource { + return { type: 'region', gesture: 'drag', regionGeometry: 'angular', match, mode: 'ephemeral' }; +} + +export function navigationTrigger(options: { + axes?: NavigationAxes | 'available'; + pan?: boolean; + zoom?: boolean; + wheelSensitivity?: number; +} = {}): InteractionEventSource { + return { + type: 'navigation', + gesture: 'navigate', + axes: options.axes ?? 'available', + pan: options.pan ?? true, + zoom: options.zoom ?? true, + wheelSensitivity: options.wheelSensitivity ?? 0.002, + }; +} + export function externalTrigger(source?: string): InteractionEventSource { return { type: 'external', source }; } diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index adce7e6f..ef2624df 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -877,8 +877,21 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (overflowResult.viewports.length > 0) { result._viewports = overflowResult.viewports; } - if (chartTemplate.semanticInteractions) { - result._interactionSemantics = chartTemplate.semanticInteractions({ resolvedEncodings }); + const navigationAxes = chartTemplate.navigation && !resolvedEncodings.column?.field && !resolvedEncodings.row?.field + ? (chartTemplate.navigation.axes ?? ['x', 'y']).filter((axis) => { + const encoding = resolvedEncodings[axis]; + return !!encoding?.field && (encoding.type === 'quantitative' || encoding.type === 'temporal'); + }) + : []; + if (chartTemplate.semanticInteractions || navigationAxes.length > 0) { + result._interactionSemantics = { + ...(chartTemplate.semanticInteractions?.({ resolvedEncodings }) ?? { + fields: [], + selectableMarks: [], + }), + navigationAxes, + selectionBoundary: design.interaction.selectionBoundary, + }; } result._width = layoutResult.subplotWidth; result._height = layoutResult.subplotHeight; diff --git a/packages/flint-js/src/vegalite/interaction-provenance.ts b/packages/flint-js/src/vegalite/interaction-provenance.ts new file mode 100644 index 00000000..874749c3 --- /dev/null +++ b/packages/flint-js/src/vegalite/interaction-provenance.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const INTERACTION_PROVENANCE = '__flintInteractionProvenance'; + +export interface InteractionProvenance { + role: 'text-label'; + identity: 'inherit' | { fields: readonly string[] }; + presentation: 'on-mark' | 'independent'; +} + +/** Declare a generated text mark and the data identity it represents. */ +export function withInteractionTextLabel>( + node: T, + options: { + fields?: readonly string[]; + presentation: InteractionProvenance['presentation']; + }, +): T { + return { + ...node, + [INTERACTION_PROVENANCE]: { + role: 'text-label', + identity: options.fields ? { fields: options.fields } : 'inherit', + presentation: options.presentation, + } satisfies InteractionProvenance, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts new file mode 100644 index 00000000..d155a0ad --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -0,0 +1,424 @@ +import type { ChartInteractionResolver } from '../../core/interaction-semantics'; +import type { ChartUpdateProcessor, InteractionDef } from '../../interactive/interactions'; +import { DEFAULT_DIM_OPACITY } from '../../interactive/emphasis-update'; +import { INTERACTION_PROVENANCE, type InteractionProvenance } from '../interaction-provenance'; +import type { + HoverStyle, + SelectionBoundaryStyle, + SelectionStyle, + VegaInteractionPlan, +} from './contracts'; +import { INTERACTION_KEY, INTERACTION_ROLE } from './hit-adapter'; +import { + HOVER_STORE, + INTERACTION_STORE, + LEGEND_HOVER_STORE, + LEGEND_SELECTION_STORE, +} from './stores'; + +const CLEAR_MARK = '__flint_interaction_clear'; +const SUPPORTED_SPEC_MARKS = new Set(['arc', 'area', 'bar', 'boxplot', 'circle', 'line', 'point', 'rect', 'rule', 'tick']); + +interface TemplateInteractionSemantics { + fields: string[]; + categoryField?: string; + seriesField?: string; + legendFields?: Record; + selectableMarks: string[]; + supportedRegionGestures?: ('cartesian' | 'angular')[]; + navigationAxes?: ('x' | 'y')[]; + renderHoverStyles?: Record; + renderSelectionStyles?: Record; + selectionBoundary?: SelectionBoundaryStyle; + resolve?: ChartInteractionResolver; + presentUpdate?: ChartUpdateProcessor; +} + +export function withoutSemanticInteractionField(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const filtered = { ...(value as Record) }; + delete filtered[INTERACTION_KEY]; + return filtered; +} + +function markType(mark: unknown): string | undefined { + return typeof mark === 'string' + ? mark + : typeof mark === 'object' && mark !== null + ? (mark as Record).type as string | undefined + : undefined; +} + +function expandInteractiveLinePoints(spec: Record): void { + const type = markType(spec.mark); + if (type === 'line' && typeof spec.mark === 'object' && spec.mark.point) { + const lineMark = { ...spec.mark }; + const point = lineMark.point; + delete lineMark.point; + spec.layer = [ + { mark: lineMark }, + { mark: typeof point === 'object' ? { type: 'point', ...point } : { type: 'point', filled: true } }, + ]; + delete spec.mark; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) expandInteractiveLinePoints(child); + } +} + +function keyExpression(fields: readonly string[]): string { + return fields + .map((field) => `replace(toString(datum[${JSON.stringify(field)}]), '|', '\\|')`) + .join(` + '|' + `); +} + +function instrumentNode( + node: Record, + inherited: Record, + semanticFields: readonly string[], + dimOpacity: number, + selectableMarks: ReadonlySet, + clickCursor: boolean, +): boolean { + const type = markType(node.mark); + const selectable = !!type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type); + const provenance = node[INTERACTION_PROVENANCE] as InteractionProvenance | undefined; + const textLabel = provenance?.role === 'text-label'; + if (!selectable && !textLabel) return false; + if (textLabel) { + const identityFields = provenance.identity === 'inherit' + ? semanticFields + : provenance.identity.fields; + node.transform = [ + ...(Array.isArray(node.transform) ? node.transform : []), + { calculate: keyExpression(identityFields), as: INTERACTION_KEY }, + { calculate: "'text-label'", as: INTERACTION_ROLE }, + ]; + } + const encoding = { ...inherited, ...(node.encoding ?? {}) }; + const encodedOpacity = encoding.opacity; + const dataDrivenOpacity = encodedOpacity?.field && !encodedOpacity.condition; + if (textLabel && provenance.presentation === 'on-mark') return true; + if ((encodedOpacity && typeof encodedOpacity.value !== 'number' && !dataDrivenOpacity) + || encoding.fillOpacity || encoding.strokeOpacity) return false; + const authoredOpacity = typeof encodedOpacity?.value === 'number' + ? encodedOpacity.value + : typeof node.mark === 'object' && typeof node.mark.opacity === 'number' + ? node.mark.opacity + : 1; + if (typeof node.mark === 'object' && typeof node.mark.opacity === 'number') { + node.mark = { ...node.mark }; + delete node.mark.opacity; + } + if (clickCursor && selectable) { + node.mark = typeof node.mark === 'string' + ? { type: node.mark, cursor: 'pointer' } + : { ...node.mark, cursor: node.mark.cursor ?? 'pointer' }; + } + const isPath = type === 'line' || type === 'area'; + const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; + const existingDetail = node.encoding?.detail; + const selectionTest = isPath + ? `!length(data('${INTERACTION_STORE}'))` + : `!length(data('${INTERACTION_STORE}')) || indata('${INTERACTION_STORE}', 'key', datum.${INTERACTION_KEY})`; + node.encoding = { + ...(node.encoding ?? {}), + ...(isPath ? {} : { + detail: existingDetail == null + ? { field: INTERACTION_KEY, type: 'nominal' } + : [...(Array.isArray(existingDetail) ? existingDetail : [existingDetail]), { field: INTERACTION_KEY, type: 'nominal' }], + }), + opacity: dataDrivenOpacity ? { + condition: { test: selectionTest, ...encodedOpacity }, + value: dimOpacity, + } : { + condition: { + test: `${selectionTest} || ${hoverTest}`, + value: authoredOpacity, + }, + value: Math.min(dimOpacity, authoredOpacity), + }, + }; + return true; +} + +function instrumentMarks( + spec: Record, + inherited: Record, + semanticFields: readonly string[], + dimOpacity: number, + selectableMarks: ReadonlySet, + clickCursor: boolean, +): boolean { + const encoding = { ...inherited, ...(spec.encoding ?? {}) }; + let instrumented = instrumentNode( + spec, + inherited, + semanticFields, + dimOpacity, + selectableMarks, + clickCursor, + ); + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) { + instrumented = instrumentMarks( + child, + encoding, + semanticFields, + dimOpacity, + selectableMarks, + clickCursor, + ) || instrumented; + } + } + return instrumented; +} + +function addLocalKeyTransforms( + spec: Record, + fields: readonly string[], + selectableMarks: ReadonlySet, +): void { + const type = markType(spec.mark); + if (type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type) && spec.data) { + spec.transform = [ + ...(Array.isArray(spec.transform) ? spec.transform : []), + { calculate: keyExpression(fields), as: INTERACTION_KEY }, + ]; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) addLocalKeyTransforms(child, fields, selectableMarks); + } +} + +function stripInteractionProvenance(spec: Record): void { + delete spec[INTERACTION_PROVENANCE]; + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) stripInteractionProvenance(child); + } +} + +function clipNavigableMarks(spec: Record): void { + if (spec.mark !== undefined) { + spec.mark = typeof spec.mark === 'string' + ? { type: spec.mark, clip: true } + : { ...spec.mark, clip: true }; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) clipNavigableMarks(child); + } +} + +export function addVegaLiteInteractions( + spec: Record, + interactions: readonly InteractionDef[], +): VegaInteractionPlan | null { + if (interactions.length === 0) return null; + const templateSemantics = spec._interactionSemantics as TemplateInteractionSemantics | undefined; + delete spec._interactionSemantics; + if (!templateSemantics) return null; + const navigationInteraction = interactions.find( + (interaction) => interaction.eventSource.type === 'navigation', + ); + const semanticInteractions = interactions.filter( + (interaction) => interaction.eventSource.type !== 'navigation', + ); + if (navigationInteraction?.eventSource.pan + && semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag')) { + throw new Error('Pan navigation cannot share an unmodified drag gesture with a region interaction.'); + } + const availableNavigationAxes = templateSemantics.navigationAxes ?? []; + const requestedNavigationAxes = navigationInteraction + ? navigationInteraction.eventSource.axes === 'available' + ? availableNavigationAxes + : navigationInteraction.eventSource.axes === 'xy' + ? ['x', 'y'] as const + : [navigationInteraction.eventSource.axes as 'x' | 'y'] + : []; + const unsupportedNavigationAxes = requestedNavigationAxes.filter( + (axis) => !availableNavigationAxes.includes(axis), + ); + if (navigationInteraction && requestedNavigationAxes.length === 0) { + throw new Error(`Interaction "${navigationInteraction.id}" requires a chart with a navigable continuous axis.`); + } + if (unsupportedNavigationAxes.length > 0) { + throw new Error( + `Interaction "${navigationInteraction?.id}" requested unsupported navigation axis: ${unsupportedNavigationAxes.join(', ')}.`, + ); + } + const angularInteraction = interactions.find( + (interaction) => interaction.eventSource.regionGeometry === 'angular', + ); + if (angularInteraction && !templateSemantics.supportedRegionGestures?.includes('angular')) { + throw new Error( + `Interaction "${angularInteraction.id}" requires a polar chart with angular-region support.`, + ); + } + const selectableMarks = new Set(templateSemantics.selectableMarks ?? SUPPORTED_SPEC_MARKS); + const fields = templateSemantics.fields ?? []; + if (semanticInteractions.length > 0) expandInteractiveLinePoints(spec); + if (navigationInteraction) clipNavigableMarks(spec); + + const dimOpacity = semanticInteractions.reduce((value, interaction) => { + if (interaction.eventSource.type === 'external') return value; + const update = interaction.update({ + type: 'semantic', + source: interaction.eventSource.type === 'region' ? 'region' : 'element', + phase: 'commit', + target: { + visual: { kind: 'mark', role: 'probe' }, + elements: [{ key: {} }], + }, + }, { chartType: 'Unknown', selected: [] }); + const emphasize = update?.ops.find((op) => op.op === 'emphasize'); + return emphasize?.op === 'emphasize' ? Math.min(value, emphasize.dimOpacity) : value; + }, DEFAULT_DIM_OPACITY); + + const clickCursor = semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'click') + && !semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag'); + const instrumented = semanticInteractions.length > 0 + ? instrumentMarks(spec, {}, fields, dimOpacity, selectableMarks, clickCursor) + : false; + if (semanticInteractions.length > 0 && !instrumented) return null; + if (instrumented) addLocalKeyTransforms(spec, fields, selectableMarks); + stripInteractionProvenance(spec); + if (instrumented) { + spec.transform = [ + ...(Array.isArray(spec.transform) ? spec.transform : []), + { calculate: keyExpression(fields), as: INTERACTION_KEY }, + ]; + } + return { + fields, + categoryField: templateSemantics.categoryField, + seriesField: templateSemantics.seriesField, + legendFields: templateSemantics.legendFields, + dimOpacity, + renderHoverStyles: templateSemantics.renderHoverStyles, + renderSelectionStyles: templateSemantics.renderSelectionStyles, + selectionBoundary: templateSemantics.selectionBoundary, + navigationChannels: [...requestedNavigationAxes], + resolve: templateSemantics.resolve, + presentUpdate: templateSemantics.presentUpdate, + }; +} + +export function injectVegaNavigationSignals( + vegaSpec: Record, + channels: readonly ('x' | 'y')[] = [], +): Partial> { + const result: Partial> = {}; + for (const channel of channels) { + const scale = (vegaSpec.scales ?? []).find((candidate: any) => candidate.name === channel); + if (!scale || !['linear', 'log', 'time', 'utc'].includes(scale.type)) { + throw new Error(`Vega navigation requires a top-level continuous "${channel}" scale.`); + } + const signal = `__flint_navigation_${channel}_domain`; + vegaSpec.signals = [...(vegaSpec.signals ?? []), { name: signal, value: null }]; + scale.domainRaw = { signal }; + result[channel] = { scale: channel, signal, type: scale.type }; + } + return result; +} + +function applyCompiledHoverStyles( + marks: Record[], + renderHoverStyles: Readonly>, +): void { + const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; + for (const mark of marks) { + if (Array.isArray(mark.marks)) applyCompiledHoverStyles(mark.marks, renderHoverStyles); + const style = renderHoverStyles[mark.type]; + const update = mark.encode?.update; + if (!style || !update || !JSON.stringify(mark.encode).includes(INTERACTION_KEY)) continue; + for (const [channel, value] of Object.entries(style)) { + if (channel === 'opacity' && value === 'contrast') { + const numericValues = (Array.isArray(update.opacity) ? update.opacity : [update.opacity]) + .map((entry: any) => entry?.value) + .filter((entry: unknown): entry is number => typeof entry === 'number'); + const authoredOpacity = numericValues.length > 0 ? Math.max(...numericValues) : 1; + update.opacity = [ + { + test: `!length(data('${INTERACTION_STORE}')) && ${hoverTest}`, + value: authoredOpacity < 1 ? 1 : 0.9, + }, + ...(Array.isArray(update.opacity) ? update.opacity : [update.opacity]), + ]; + continue; + } + const existing = update[channel] ?? mark.encode?.enter?.[channel] ?? ( + channel === 'stroke' + ? { value: mark.type === 'line' || mark.type === 'rule' ? 'black' : 'transparent' } + : channel === 'strokeWidth' + ? { value: mark.type === 'line' ? 2 : mark.type === 'rule' ? 1 : mark.type === 'symbol' ? 1.5 : 0 } + : undefined + ); + if (existing === undefined) continue; + update[channel] = [ + { test: hoverTest, value }, + ...(Array.isArray(existing) ? existing : [existing]), + ]; + } + } +} + +export function injectVegaInteractionStore( + vegaSpec: Record, + plan?: Pick, +): void { + vegaSpec.data = [ + ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), + { name: INTERACTION_STORE, values: [] }, + { name: HOVER_STORE, values: [] }, + { name: LEGEND_HOVER_STORE, values: [] }, + { name: LEGEND_SELECTION_STORE, values: [] }, + ]; + for (const legend of vegaSpec.legends ?? []) { + const scaleChannel = ['fill', 'stroke', 'size', 'shape', 'opacity'] + .find((channel) => legend[channel] !== undefined); + const channel = scaleChannel === 'fill' || scaleChannel === 'stroke' ? 'color' : scaleChannel; + const peerOfSelectedLegend = channel + ? `length(data('${LEGEND_SELECTION_STORE}')) && ` + + `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_SELECTION_STORE}')[0].value !== datum.value` + : undefined; + const interactiveItem = (encode: Record | undefined): Record => { + const existingOpacity = encode?.update?.opacity ?? encode?.enter?.opacity ?? { value: 1 }; + return { + ...(encode ?? {}), + interactive: true, + update: { + ...(encode?.update ?? {}), + cursor: { value: 'pointer' }, + opacity: peerOfSelectedLegend ? [ + { test: peerOfSelectedLegend, value: plan?.dimOpacity ?? DEFAULT_DIM_OPACITY }, + ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), + ] : existingOpacity, + }, + }; + }; + legend.encode = { + ...(legend.encode ?? {}), + symbols: interactiveItem(legend.encode?.symbols), + labels: interactiveItem(legend.encode?.labels), + }; + } + if (!Array.isArray(vegaSpec.marks)) return; + if (plan?.renderHoverStyles) applyCompiledHoverStyles(vegaSpec.marks, plan.renderHoverStyles); + vegaSpec.marks.unshift({ + type: 'rect', + name: CLEAR_MARK, + encode: { + enter: { + x: { value: 0 }, x2: { signal: 'width' }, + y: { value: 0 }, y2: { signal: 'height' }, + opacity: { value: 0 }, tooltip: { value: null }, + }, + }, + }); +} diff --git a/packages/flint-js/src/vegalite/interactions/contracts.ts b/packages/flint-js/src/vegalite/interactions/contracts.ts new file mode 100644 index 00000000..ac2b91d7 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/contracts.ts @@ -0,0 +1,45 @@ +import type { ChartInteractionResolver } from '../../core/interaction-semantics'; +import type { ChartUpdateProcessor } from '../../interactive/interactions'; + +export interface HoverStyle { + fill?: string; + fillOpacity?: number; + opacity?: 'contrast'; + stroke?: string; + strokeWidth?: number; +} + +export interface SelectionStyle { + strokeWidthMultiplier?: number; + boundary?: 'contiguous-region'; +} + +export interface SelectionBoundaryStyle { + color: string; + width: number; + opacity: number; + haloColor: string; + haloWidth: number; + haloOpacity: number; +} + +export interface VegaNavigationAxis { + scale: string; + signal: string; + type: 'linear' | 'log' | 'time' | 'utc'; +} + +export interface VegaInteractionPlan { + fields: readonly string[]; + categoryField?: string; + seriesField?: string; + legendFields?: Readonly>; + dimOpacity: number; + renderHoverStyles?: Readonly>; + renderSelectionStyles?: Readonly>; + selectionBoundary?: Readonly; + navigationChannels?: readonly ('x' | 'y')[]; + navigationAxes?: Partial>; + resolve?: ChartInteractionResolver; + presentUpdate?: ChartUpdateProcessor; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts new file mode 100644 index 00000000..d02087f9 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts @@ -0,0 +1,174 @@ +import type { + InteractionDef, + NavigationAxes, + NavigationInteractionEvent, + PlotPoint, +} from '../../../interactive/interactions'; +import { PanSession, wheelZoomFactor } from '../../../interactive/gestures/navigation'; +import { clientToPlotPoint, interactionModifiers, type RendererCoordinateSpace } from '../hit-adapter'; + +export interface VegaNavigationGestureOptions { + container: HTMLElement; + interaction: InteractionDef; + availableAxes: readonly ('x' | 'y')[]; + coordinateSpace(): RendererCoordinateSpace; + dispatch(event: NavigationInteractionEvent): Promise; + setSuppressClick(suppress: boolean): void; + setDragging(dragging: boolean): void; +} + +export interface VegaNavigationGestureController { + destroy(): void; +} + +function resolvedAxes(requested: unknown, available: readonly ('x' | 'y')[]): NavigationAxes { + const axes = requested === 'available' + ? available + : requested === 'xy' + ? available.filter((axis) => axis === 'x' || axis === 'y') + : available.filter((axis) => axis === requested); + return axes.length === 2 ? 'xy' : axes[0] ?? 'xy'; +} + +export function mountVegaNavigationGesture( + options: VegaNavigationGestureOptions, +): VegaNavigationGestureController { + const { + container, + interaction, + availableAxes, + coordinateSpace, + dispatch, + setSuppressClick, + setDragging, + } = options; + const source = interaction.eventSource; + const axes = resolvedAxes(source.axes, availableAxes); + let pointerId: number | undefined; + let session: PanSession | undefined; + let pendingDelta: PlotPoint = { x: 0, y: 0 }; + let dragged = false; + + const previousCursor = container.style.cursor; + const previousTouchAction = container.style.touchAction; + if (source.pan) { + container.style.cursor = 'grab'; + container.style.touchAction = 'none'; + } + + const localPoint = (event: PointerEvent): PlotPoint => clientToPlotPoint( + { x: event.clientX, y: event.clientY }, + coordinateSpace(), + ); + const emit = (event: NavigationInteractionEvent): void => { void dispatch(event); }; + + const pointerDown = (event: PointerEvent): void => { + if (!source.pan || event.button !== 0) return; + const space = coordinateSpace(); + session = new PanSession(localPoint(event), { width: space.plotWidth, height: space.plotHeight }); + pointerId = event.pointerId; + pendingDelta = { x: 0, y: 0 }; + dragged = false; + setDragging(true); + container.style.cursor = 'grabbing'; + container.setPointerCapture(event.pointerId); + emit({ + type: 'navigation', phase: 'start', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + }; + const pointerMove = (event: PointerEvent): void => { + if (!session || pointerId !== event.pointerId) return; + const delta = session.move(localPoint(event)); + pendingDelta = { x: pendingDelta.x + delta.x, y: pendingDelta.y + delta.y }; + if (session.dragDistance() < 4) return; + dragged = true; + setSuppressClick(true); + emit({ + type: 'navigation', phase: 'preview', operation: 'pan', axes, + delta: pendingDelta, modifiers: interactionModifiers(event), + }); + pendingDelta = { x: 0, y: 0 }; + }; + const finish = (event: PointerEvent): void => { + if (!session || pointerId !== event.pointerId) return; + if (pendingDelta.x !== 0 || pendingDelta.y !== 0) { + emit({ + type: 'navigation', phase: 'preview', operation: 'pan', axes, + delta: pendingDelta, modifiers: interactionModifiers(event), + }); + } + emit({ + type: 'navigation', phase: 'commit', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); + }; + const cancel = (event: PointerEvent): void => { + if (!session || pointerId !== event.pointerId) return; + emit({ + type: 'navigation', phase: 'cancel', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + }; + const wheel = (event: WheelEvent): void => { + if (!source.zoom) return; + event.preventDefault(); + const space = coordinateSpace(); + const point = clientToPlotPoint({ x: event.clientX, y: event.clientY }, space); + emit({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes, + factor: wheelZoomFactor( + event.deltaY, + event.deltaMode, + space.plotHeight, + source.wheelSensitivity ?? 0.002, + ), + anchor: { + x: space.plotWidth > 0 ? point.x / space.plotWidth : 0.5, + y: space.plotHeight > 0 ? point.y / space.plotHeight : 0.5, + }, + modifiers: interactionModifiers(event), + }); + }; + const doubleClick = (event: MouseEvent): void => { + event.preventDefault(); + emit({ + type: 'navigation', phase: 'commit', operation: 'reset', axes, + modifiers: interactionModifiers(event), + }); + }; + + container.addEventListener('pointerdown', pointerDown, true); + container.addEventListener('pointermove', pointerMove, true); + container.addEventListener('pointerup', finish, true); + container.addEventListener('pointercancel', cancel, true); + container.addEventListener('wheel', wheel, { passive: false }); + container.addEventListener('dblclick', doubleClick); + + return { + destroy(): void { + container.removeEventListener('pointerdown', pointerDown, true); + container.removeEventListener('pointermove', pointerMove, true); + container.removeEventListener('pointerup', finish, true); + container.removeEventListener('pointercancel', cancel, true); + container.removeEventListener('wheel', wheel); + container.removeEventListener('dblclick', doubleClick); + container.style.cursor = previousCursor; + container.style.touchAction = previousTouchAction; + setDragging(false); + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/gestures/region.ts b/packages/flint-js/src/vegalite/interactions/gestures/region.ts new file mode 100644 index 00000000..1623d7b5 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/gestures/region.ts @@ -0,0 +1,373 @@ +import type { + InteractionDef, + PlotAngularSector, + PlotPoint, + RenderHit, + SemanticInteractionEvent, + SemanticTarget, +} from '../../../interactive/interactions'; +import { angularSectorPath } from '../../../interactive/geometry/angular'; +import { AngularRegionSession, type PolarFrame } from '../../../interactive/gestures/angular-region'; +import { + axisValue, + cartesianDragDistance, + constrainCartesianRegion, + intervalPoints, + updateInterval, + type CartesianRegionAxis, + type Interval, + type IntervalOperation, +} from '../../../interactive/gestures/cartesian-region'; +import { + clientRectToLayoutRect, + clientToLayoutPoint, + clientToPlotPoint, + interactionModifiers, + normalizeVegaAngularRegionEvent, + normalizeVegaRegionEvent, + plotToClientPoint, + sceneItems, + type RendererCoordinateSpace, +} from '../hit-adapter'; + +export interface VegaRegionGestureOptions { + view: any; + container: HTMLElement; + interaction: InteractionDef; + getSelected(): ReadonlySet; + setSelected(selected: Set): void; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; + resolveTarget( + gesture: 'rectangle' | 'angular', + role: 'region', + hits: readonly RenderHit[], + ): SemanticTarget | null; + dispatch(event: SemanticInteractionEvent): Promise; + clearHover(): void; + clearAnnotation(): void; + sync(): Promise; + setSuppressClick(suppress: boolean): void; + setDragging(dragging: boolean): void; +} + +export interface VegaRegionGestureController { + destroy(): void; +} + +export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaRegionGestureController { + const { + view, + container, + interaction, + getSelected, + setSelected, + coordinateSpace, + containerLayoutSize, + resolveTarget, + dispatch, + clearHover, + clearAnnotation, + sync, + setSuppressClick, + setDragging, + } = options; + const regionAxis: CartesianRegionAxis = interaction.eventSource.axis ?? 'xy'; + const angularBrush = interaction.eventSource.regionGeometry === 'angular'; + const statefulBrush = interaction.eventSource.mode === 'stateful' && regionAxis !== 'xy'; + let committed = new Set(); + let dragStart: PlotPoint | undefined; + let pointerId: number | undefined; + let dragAction: IntervalOperation = 'create'; + let activeInterval: Interval | undefined; + let initialInterval: Interval | undefined; + let angularSession: AngularRegionSession | undefined; + + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', + boxSizing: 'border-box', + border: '1px solid rgba(37, 99, 235, 0.85)', background: 'rgba(37, 99, 235, 0.12)', + }); + const angularOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const angularPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + angularPath.setAttribute('fill', 'rgba(37, 99, 235, 0.12)'); + angularPath.setAttribute('stroke', 'rgba(37, 99, 235, 0.85)'); + angularPath.setAttribute('stroke-width', '1'); + angularPath.setAttribute('vector-effect', 'non-scaling-stroke'); + angularOverlay.append(angularPath); + Object.assign(angularOverlay.style, { + position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', overflow: 'visible', + }); + + const previousPosition = container.style.position; + const previousUserSelect = container.style.userSelect; + const previousCursor = container.style.cursor; + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + container.style.userSelect = 'none'; + container.style.cursor = 'crosshair'; + container.append(angularBrush ? angularOverlay : overlay); + container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; + + const localPoint = (event: PointerEvent): PlotPoint => { + return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); + }; + const brushPlotSize = (): { width: number; height: number } => { + const space = coordinateSpace(); + return { width: space.plotWidth, height: space.plotHeight }; + }; + const intervalAxis = (): 'x' | 'y' => regionAxis === 'y' ? 'y' : 'x'; + const axisLimit = (): number => intervalAxis() === 'y' ? brushPlotSize().height : brushPlotSize().width; + const intervalForDrag = (point: PlotPoint): Interval => { + return updateInterval(point, dragStart!, intervalAxis(), axisLimit(), dragAction, initialInterval); + }; + const showRegion = (a: PlotPoint, b: PlotPoint): void => { + const constrained = constrainCartesianRegion(a, b, regionAxis, brushPlotSize()); + const space = coordinateSpace(); + const leading = plotToClientPoint({ + x: Math.min(constrained.start.x, constrained.end.x), + y: Math.min(constrained.start.y, constrained.end.y), + }, space); + const trailing = plotToClientPoint({ + x: Math.max(constrained.start.x, constrained.end.x), + y: Math.max(constrained.start.y, constrained.end.y), + }, space); + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + const localLeading = clientToLayoutPoint(leading, containerRect, layoutSize); + const localTrailing = clientToLayoutPoint(trailing, containerRect, layoutSize); + Object.assign(overlay.style, { + display: 'block', + left: `${localLeading.x}px`, + top: `${localLeading.y}px`, + width: `${localTrailing.x - localLeading.x}px`, + height: `${localTrailing.y - localLeading.y}px`, + }); + }; + const showInterval = (interval: Interval): void => { + const points = intervalPoints(interval, intervalAxis()); + showRegion(points.start, points.end); + }; + const frameAt = (point: PlotPoint): PolarFrame | undefined => { + const frames = new Map(); + for (const item of sceneItems(view)) { + if (item.mark?.marktype !== 'arc' || typeof item.x !== 'number' || typeof item.y !== 'number' + || typeof item.innerRadius !== 'number' || typeof item.outerRadius !== 'number') continue; + const key = `${item.x}\u0000${item.y}`; + const existing = frames.get(key); + frames.set(key, existing ? { + center: existing.center, + innerRadius: Math.min(existing.innerRadius, item.innerRadius), + outerRadius: Math.max(existing.outerRadius, item.outerRadius), + } : { + center: { x: item.x, y: item.y }, + innerRadius: item.innerRadius, + outerRadius: item.outerRadius, + }); + } + return [...frames.values()].sort((left, right) => + Math.hypot(point.x - left.center.x, point.y - left.center.y) + - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; + }; + const showAngularSector = (sector: PlotAngularSector): void => { + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(angularOverlay.style, { + display: 'block', + left: `${rendererLayout.left}px`, + top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, + height: `${rendererLayout.height}px`, + }); + angularOverlay.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + angularPath.setAttribute('d', angularSectorPath({ + ...sector, + center: { x: sector.center.x + space.originX, y: sector.center.y + space.originY }, + })); + }; + const dispatchAngularRegion = ( + phase: 'preview' | 'commit', + sector: PlotAngularSector, + event: PointerEvent, + ): void => { + const normalized = normalizeVegaAngularRegionEvent( + view, sector, phase, interaction.eventSource.match ?? 'intersect', + interactionModifiers(event), 'create', + ); + setSelected(new Set(committed)); + void dispatch({ + type: 'semantic', source: 'region', phase, + target: resolveTarget('angular', 'region', normalized.hits), + region: normalized.region, axis: normalized.axis, operation: normalized.operation, + modifiers: normalized.modifiers, + }); + }; + const dispatchRegion = ( + phase: 'preview' | 'commit', + start: PlotPoint, + end: PlotPoint, + event: PointerEvent, + operation: IntervalOperation | 'clear', + target: SemanticTarget | null | undefined = undefined, + ): void => { + const normalized = normalizeVegaRegionEvent( + view, start, end, phase, interaction.eventSource.match ?? 'intersect', + interactionModifiers(event), regionAxis, brushPlotSize(), operation, + ); + setSelected(new Set(committed)); + void dispatch({ + type: 'semantic', source: 'region', phase, + target: target === undefined ? resolveTarget('rectangle', 'region', normalized.hits) : target, + region: normalized.region, axis: normalized.axis, operation: normalized.operation, + modifiers: normalized.modifiers, + }); + }; + const pointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + clearHover(); + const point = localPoint(event); + if (angularBrush) { + const frame = frameAt(point); + if (!frame) return; + angularSession = new AngularRegionSession(point, frame); + } + dragAction = 'create'; + initialInterval = activeInterval ? { ...activeInterval } : undefined; + if (statefulBrush && activeInterval) { + const value = axisValue(point, intervalAxis()); + const edgeTolerance = 8; + if (Math.abs(value - activeInterval.leading) <= edgeTolerance) dragAction = 'resize-leading'; + else if (Math.abs(value - activeInterval.trailing) <= edgeTolerance) dragAction = 'resize-trailing'; + else if (value > activeInterval.leading && value < activeInterval.trailing) dragAction = 'move'; + } + dragStart = point; + pointerId = event.pointerId; + committed = new Set(getSelected()); + setDragging(true); + container.setPointerCapture(event.pointerId); + }; + const pointerMove = (event: PointerEvent): void => { + if (!dragStart || pointerId !== event.pointerId) { + if (statefulBrush && activeInterval) { + const value = axisValue(localPoint(event), intervalAxis()); + const nearEdge = Math.abs(value - activeInterval.leading) <= 8 + || Math.abs(value - activeInterval.trailing) <= 8; + container.style.cursor = nearEdge + ? regionAxis === 'x' ? 'ew-resize' : 'ns-resize' + : value > activeInterval.leading && value < activeInterval.trailing ? 'grab' : 'crosshair'; + } + return; + } + const point = localPoint(event); + if (angularBrush) { + angularSession?.move(point); + if (!angularSession || angularSession.dragDistance() < 4) return; + setSuppressClick(true); + const sector = angularSession.sector(); + showAngularSector(sector); + dispatchAngularRegion('preview', sector, event); + return; + } + if (cartesianDragDistance(dragStart, point, regionAxis) < 4) return; + setSuppressClick(true); + const interval = regionAxis === 'xy' ? undefined : intervalForDrag(point); + const points = interval ? intervalPoints(interval, intervalAxis()) : { start: dragStart, end: point }; + interval ? showInterval(interval) : showRegion(dragStart, point); + dispatchRegion('preview', points.start, points.end, event, dragAction); + }; + const finishDrag = (event: PointerEvent): void => { + if (!dragStart || pointerId !== event.pointerId) return; + const point = localPoint(event); + if (angularBrush) angularSession?.move(point); + const dragged = angularBrush && angularSession + ? angularSession.dragDistance() >= 4 + : cartesianDragDistance(dragStart, point, regionAxis) >= 4; + if (dragged) { + if (angularBrush) { + dispatchAngularRegion('commit', angularSession!.sector(), event); + } else { + const interval = regionAxis === 'xy' ? undefined : intervalForDrag(point); + const points = interval ? intervalPoints(interval, intervalAxis()) : { start: dragStart, end: point }; + dispatchRegion('commit', points.start, points.end, event, dragAction); + if (statefulBrush && interval) { + activeInterval = interval; + showInterval(interval); + } + } + } else { + const clickedOutside = !activeInterval || axisValue(point, intervalAxis()) < activeInterval.leading + || axisValue(point, intervalAxis()) > activeInterval.trailing; + if (!statefulBrush || clickedOutside) { + activeInterval = undefined; + committed.clear(); + dispatchRegion('commit', dragStart, point, event, 'clear', null); + } + } + dragStart = undefined; + pointerId = undefined; + initialInterval = undefined; + angularSession = undefined; + setDragging(false); + if (!statefulBrush || !activeInterval) overlay.style.display = 'none'; + angularOverlay.style.display = 'none'; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); + }; + const cancelDrag = (event: PointerEvent): void => { + if (!dragStart || pointerId !== event.pointerId) return; + setSelected(new Set(committed)); + dragStart = undefined; + pointerId = undefined; + initialInterval = undefined; + angularSession = undefined; + setDragging(false); + if (statefulBrush && activeInterval) showInterval(activeInterval); + else overlay.style.display = 'none'; + angularOverlay.style.display = 'none'; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + void sync(); + }; + const keyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return; + if (dragStart) { + setSelected(new Set(committed)); + if (statefulBrush && initialInterval) activeInterval = initialInterval; + } else { + setSelected(new Set()); + activeInterval = undefined; + clearAnnotation(); + } + dragStart = undefined; + pointerId = undefined; + initialInterval = undefined; + setDragging(false); + overlay.style.display = 'none'; + angularOverlay.style.display = 'none'; + void sync(); + }; + + container.addEventListener('pointerdown', pointerDown, true); + container.addEventListener('pointermove', pointerMove, true); + container.addEventListener('pointerup', finishDrag, true); + container.addEventListener('pointercancel', cancelDrag, true); + container.addEventListener('keydown', keyDown); + + return { + destroy(): void { + container.removeEventListener('pointerdown', pointerDown, true); + container.removeEventListener('pointermove', pointerMove, true); + container.removeEventListener('pointerup', finishDrag, true); + container.removeEventListener('pointercancel', cancelDrag, true); + container.removeEventListener('keydown', keyDown); + overlay.remove(); + angularOverlay.remove(); + setDragging(false); + container.style.position = previousPosition; + container.style.userSelect = previousUserSelect; + container.style.cursor = previousCursor; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/triggers/vega.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts similarity index 82% rename from packages/flint-js/src/interactive/triggers/vega.ts rename to packages/flint-js/src/vegalite/interactions/hit-adapter.ts index f85a4ef9..31643c6e 100644 --- a/packages/flint-js/src/interactive/triggers/vega.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -4,25 +4,26 @@ import type { InteractionModifiers, InteractionPhase, PlotPoint, + PlotAngularSector, RegionAxis, RegionInteractionEvent, RegionOperation, -} from './events'; +} from '../../interactive/triggers/events'; +import { angularSegments, TAU } from '../../interactive/geometry/angular'; +export { + clientRectToLayoutRect, + clientToLayoutPoint, + clientToPlotPoint, + interactionModifiers, + plotToClientPoint, + type RendererCoordinateSpace, +} from '../../interactive/geometry/coordinate-space'; export const INTERACTION_KEY = '__flint_interaction_key'; +export const INTERACTION_ROLE = '__flint_interaction_role'; export const PATH_KEY_SUFFIX = '|__flint_path'; -const SUPPORTED_RENDER_MARKS = new Set(['arc', 'area', 'bar', 'line', 'rect', 'rule', 'symbol']); - -export interface RendererCoordinateSpace { - rect: DOMRect; - logicalWidth: number; - logicalHeight: number; - originX: number; - originY: number; - plotWidth: number; - plotHeight: number; -} +const SUPPORTED_RENDER_MARKS = new Set(['arc', 'area', 'bar', 'line', 'rect', 'rule', 'symbol', 'text']); export interface SelectionRect { x1: number; @@ -31,10 +32,6 @@ export interface SelectionRect { y2: number; } -export function interactionModifiers(event: MouseEvent | PointerEvent): InteractionModifiers { - return { shift: event.shiftKey, ctrl: event.ctrlKey, meta: event.metaKey }; -} - interface PathGeometry { kind: 'segment' | 'slice'; points: PlotPoint[]; @@ -45,48 +42,6 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -export function clientToPlotPoint(client: PlotPoint, space: RendererCoordinateSpace): PlotPoint { - const rendererX = (client.x - space.rect.left) * space.logicalWidth / space.rect.width; - const rendererY = (client.y - space.rect.top) * space.logicalHeight / space.rect.height; - return { - x: clamp(rendererX - space.originX, 0, space.plotWidth), - y: clamp(rendererY - space.originY, 0, space.plotHeight), - }; -} - -export function plotToClientPoint(point: PlotPoint, space: RendererCoordinateSpace): PlotPoint { - return { - x: space.rect.left + (point.x + space.originX) * space.rect.width / space.logicalWidth, - y: space.rect.top + (point.y + space.originY) * space.rect.height / space.logicalHeight, - }; -} - -export function clientToLayoutPoint( - point: PlotPoint, - rect: Pick, - layoutSize: { width: number; height: number }, -): PlotPoint { - return { - x: (point.x - rect.left) * layoutSize.width / rect.width, - y: (point.y - rect.top) * layoutSize.height / rect.height, - }; -} - -export function clientRectToLayoutRect( - rect: Pick, - containerRect: Pick, - layoutSize: { width: number; height: number }, -): { left: number; top: number; width: number; height: number } { - const leading = clientToLayoutPoint({ x: rect.left, y: rect.top }, containerRect, layoutSize); - const trailing = clientToLayoutPoint({ x: rect.right, y: rect.bottom }, containerRect, layoutSize); - return { - left: leading.x, - top: leading.y, - width: trailing.x - leading.x, - height: trailing.y - leading.y, - }; -} - function keyOfDatum(datum: unknown): string | undefined { if (!datum || typeof datum !== 'object') return undefined; const key = (datum as Record)[INTERACTION_KEY]; @@ -260,8 +215,67 @@ export function arcIntersectsRect(item: any, rect: SelectionRect, contain = fals return false; } +export function arcIntersectsAngularSector( + item: any, + sector: PlotAngularSector, + contain = false, +): boolean { + if (item?.mark?.marktype !== 'arc') return false; + const values = [item.x, item.y, item.innerRadius, item.outerRadius, item.startAngle, item.endAngle]; + if (!values.every((value) => typeof value === 'number' && Number.isFinite(value))) return false; + if (Math.hypot(item.x - sector.center.x, item.y - sector.center.y) > 1) return false; + const radialMatch = contain + ? item.innerRadius >= sector.innerRadius && item.outerRadius <= sector.outerRadius + : item.outerRadius > sector.innerRadius && item.innerRadius < sector.outerRadius; + if (!radialMatch) return false; + const selection = angularSegments(sector.startAngle, sector.endAngle); + const arc = angularSegments(item.startAngle, item.endAngle); + if (contain) { + return arc.every(([arcStart, arcEnd]) => selection.some( + ([selectionStart, selectionEnd]) => arcStart >= selectionStart - 1e-9 + && arcEnd <= selectionEnd + 1e-9, + )); + } + return arc.some(([arcStart, arcEnd]) => selection.some( + ([selectionStart, selectionEnd]) => Math.min(arcEnd, selectionEnd) - Math.max(arcStart, selectionStart) > 1e-9, + )); +} + +export function angularRegionHits( + view: any, + sector: PlotAngularSector, + contain = false, +): RenderHit[] { + return sceneItems(view) + .filter((item) => arcIntersectsAngularSector(item, sector, contain)) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +export function normalizeVegaAngularRegionEvent( + view: any, + sector: PlotAngularSector, + phase: InteractionPhase, + match: 'intersect' | 'contain', + modifiers: InteractionModifiers, + operation: RegionOperation = 'create', +): RegionInteractionEvent { + return { + type: 'region', + phase, + axis: 'angle', + operation, + region: sector, + hits: angularRegionHits(view, sector, match === 'contain'), + match, + modifiers, + }; +} + export function renderHit(item: any): RenderHit | null { - if (!SUPPORTED_RENDER_MARKS.has(item?.mark?.marktype) || !keyOfDatum(item?.datum)) return null; + const markType = item?.mark?.marktype; + const taggedText = markType !== 'text' || item?.datum?.[INTERACTION_ROLE] === 'text-label'; + if (!SUPPORTED_RENDER_MARKS.has(markType) || !taggedText || !keyOfDatum(item?.datum)) return null; const datum = item.mark.marktype === 'line' || item.mark.marktype === 'area' ? { ...item.datum, [INTERACTION_KEY]: `${keyOfDatum(item.datum)}${PATH_KEY_SUFFIX}` } : item.datum; @@ -270,7 +284,7 @@ export function renderHit(item: any): RenderHit | null { source: 'mark', markType: item.mark?.marktype, markName: item.mark?.name, - layerRole: item.mark?.role, + layerRole: item?.datum?.[INTERACTION_ROLE] ?? item.mark?.role, }; } @@ -313,7 +327,7 @@ export function legendTarget( export interface NormalizedVegaElement { event: ElementInteractionEvent; - role: 'mark' | 'legend-item'; + role: 'mark' | 'legend-item' | 'text-label'; legend: { channel?: string; value: unknown; field?: string } | null; } @@ -336,7 +350,11 @@ export function normalizeVegaElementEvent( point, modifiers, }, - role: legend ? 'legend-item' : 'mark', + role: legend + ? 'legend-item' + : hit?.layerRole === 'text-label' + ? 'text-label' + : 'mark', legend, }; } diff --git a/packages/flint-js/src/vegalite/interactions/navigation-scale.ts b/packages/flint-js/src/vegalite/interactions/navigation-scale.ts new file mode 100644 index 00000000..32ed9d9c --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/navigation-scale.ts @@ -0,0 +1,140 @@ +import type { NavigationDomainGuard, UpdateOp } from '../../interactive/interactions'; +import type { VegaNavigationAxis } from './contracts'; + +type NavigationUpdate = Extract; +type Axis = 'x' | 'y'; + +interface AxisState extends VegaNavigationAxis { + initialDomain: [unknown, unknown]; +} + +function numericValue(value: unknown): number { + return value instanceof Date ? value.getTime() : Number(value); +} + +function transformedValue(value: unknown, type: VegaNavigationAxis['type'], logSign: number): number { + const numeric = numericValue(value); + return type === 'log' ? logSign * Math.log(logSign * numeric) : numeric; +} + +function domainValue(value: number, type: VegaNavigationAxis['type'], initial: unknown, logSign: number): unknown { + const numeric = type === 'log' ? logSign * Math.exp(logSign * value) : value; + return initial instanceof Date ? new Date(numeric) : numeric; +} + +export function guardNavigationDomain( + proposed: readonly [unknown, unknown], + initial: readonly [unknown, unknown], + type: VegaNavigationAxis['type'], + guard: NavigationDomainGuard, +): [unknown, unknown] { + const logSign = type === 'log' && numericValue(initial[0]) < 0 ? -1 : 1; + const initialValues = initial.map((value) => transformedValue(value, type, logSign)); + const proposedValues = proposed.map((value) => transformedValue(value, type, logSign)); + const initialMin = Math.min(...initialValues); + const initialMax = Math.max(...initialValues); + const initialSpan = initialMax - initialMin; + if (!Number.isFinite(initialSpan) || initialSpan <= 0 || proposedValues.some((value) => !Number.isFinite(value))) { + return [...initial] as [unknown, unknown]; + } + + const direction = proposedValues[1] >= proposedValues[0] ? 1 : -1; + const requestedSpan = Math.abs(proposedValues[1] - proposedValues[0]); + const minimumSpan = initialSpan * guard.minVisibleFraction; + const maximumSpan = initialSpan * guard.maxVisibleFraction; + const span = Math.min(maximumSpan, Math.max(minimumSpan, requestedSpan)); + let center = (proposedValues[0] + proposedValues[1]) / 2; + const zoomOutMargin = Math.max(0, guard.maxVisibleFraction - 1) / 2; + const allowedMargin = guard.overscrollFraction + zoomOutMargin; + const allowedMin = initialMin - initialSpan * allowedMargin; + const allowedMax = initialMax + initialSpan * allowedMargin; + const allowedSpan = allowedMax - allowedMin; + const boundedSpan = Math.min(span, allowedSpan); + center = Math.max(allowedMin + boundedSpan / 2, Math.min(allowedMax - boundedSpan / 2, center)); + const lower = center - boundedSpan / 2; + const upper = center + boundedSpan / 2; + const values = direction > 0 ? [lower, upper] : [upper, lower]; + return values.map((value, index) => domainValue(value, type, initial[index], logSign)) as [unknown, unknown]; +} + +export interface VegaNavigationController { + apply(update: NavigationUpdate): Promise; +} + +export function createVegaNavigationController( + view: any, + axes: Partial>, +): VegaNavigationController { + const states = Object.fromEntries(Object.entries(axes).map(([axis, config]) => { + const domain = view.scale(config.scale).domain(); + return [axis, { ...config, initialDomain: [domain[0], domain[domain.length - 1]] }]; + })) as Partial>; + let gestureSnapshot: Partial> | undefined; + + const affectedAxes = (axesValue: NavigationUpdate['axes']): Axis[] => { + const requested: Axis[] = axesValue === 'xy' ? ['x', 'y'] : [axesValue]; + return requested.filter((axis) => states[axis]); + }; + + return { + async apply(update): Promise { + const activeAxes = affectedAxes(update.axes); + if (update.phase === 'start') { + gestureSnapshot = Object.fromEntries(activeAxes.map((axis) => { + const state = states[axis]!; + const domain = view.scale(state.scale).domain(); + return [axis, [domain[0], domain[domain.length - 1]]]; + })); + return; + } + + let changed = false; + for (const axis of activeAxes) { + const state = states[axis]!; + if (update.phase === 'cancel') { + const snapshot = gestureSnapshot?.[axis]; + if (snapshot) { + view.signal(state.signal, snapshot); + changed = true; + } + continue; + } + if (update.operation === 'reset') { + view.signal(state.signal, null); + changed = true; + continue; + } + const scale = view.scale(state.scale); + const domain = scale.domain(); + const current: [unknown, unknown] = [domain[0], domain[domain.length - 1]]; + const range = scale.range(); + const rangeStart = Number(range[0]); + const rangeEnd = Number(range[range.length - 1]); + const rangeExtent = Math.abs(rangeEnd - rangeStart); + let proposed: [unknown, unknown] | undefined; + if (update.operation === 'pan' && update.delta) { + const fraction = axis === 'x' ? update.delta.x : update.delta.y; + const pixelDelta = fraction * rangeExtent; + proposed = [scale.invert(rangeStart - pixelDelta), scale.invert(rangeEnd - pixelDelta)]; + } else if (update.operation === 'zoom' && update.factor && update.factor > 0 && update.anchor) { + const fraction = axis === 'x' ? update.anchor.x : update.anchor.y; + const anchor = Math.min(rangeStart, rangeEnd) + fraction * rangeExtent; + proposed = [ + scale.invert(anchor + (rangeStart - anchor) / update.factor), + scale.invert(anchor + (rangeEnd - anchor) / update.factor), + ]; + } + if (!proposed) continue; + view.signal(state.signal, guardNavigationDomain( + proposed, + state.initialDomain, + state.type, + update.domainGuard, + )); + changed = true; + } + if (update.phase === 'commit' || update.phase === 'cancel') gestureSnapshot = undefined; + if (changed) await view.runAsync(); + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts new file mode 100644 index 00000000..8502eccc --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts @@ -0,0 +1,183 @@ +import type { SemanticElement } from '../../../core/interaction-semantics'; +import type { AnnotationRenderPlan, PlotPoint } from '../../../interactive/interactions'; +import { + INTERACTION_KEY, + clientToLayoutPoint, + plotToClientPoint, + sceneItems, + type RendererCoordinateSpace, +} from '../hit-adapter'; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function keyOfDatum(datum: unknown): string | undefined { + if (!datum || typeof datum !== 'object') return undefined; + const key = (datum as Record)[INTERACTION_KEY]; + return typeof key === 'string' ? key : undefined; +} + +export interface AnnotationOverlayController { + render(element: SemanticElement, annotation: AnnotationRenderPlan, point?: PlotPoint): void; + clear(): void; + destroy(): void; +} + +export interface AnnotationOverlayOptions { + view: any; + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function createAnnotationOverlay({ + view, + container, + coordinateSpace, + containerLayoutSize, +}: AnnotationOverlayOptions): AnnotationOverlayController { + const annotationLayer = document.createElement('div'); + Object.assign(annotationLayer.style, { + position: 'absolute', inset: '0', zIndex: '4', pointerEvents: 'none', overflow: 'hidden', + }); + const annotationSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + Object.assign(annotationSvg.style, { position: 'absolute', inset: '0', width: '100%', height: '100%' }); + const annotationPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + annotationPath.setAttribute('fill', 'none'); + annotationPath.setAttribute('stroke', '#176f58'); + annotationPath.setAttribute('stroke-width', '1.5'); + annotationPath.setAttribute('stroke-linecap', 'round'); + const annotationDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + annotationDot.setAttribute('r', '3'); + annotationDot.setAttribute('fill', '#176f58'); + annotationDot.setAttribute('stroke', '#ffffff'); + annotationDot.setAttribute('stroke-width', '1.5'); + annotationSvg.append(annotationPath, annotationDot); + const annotationCard = document.createElement('div'); + Object.assign(annotationCard.style, { + position: 'absolute', color: '#176f58', fontFamily: 'ui-sans-serif, sans-serif', + fontSize: '11px', fontWeight: '700', lineHeight: '1', whiteSpace: 'nowrap', + textShadow: '-1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff', + }); + annotationLayer.append(annotationSvg, annotationCard); + + const clear = (): void => annotationLayer.remove(); + const render = (element: SemanticElement, annotation: AnnotationRenderPlan, point?: PlotPoint): void => { + const key = element.key[INTERACTION_KEY]; + const item = typeof key === 'string' + ? sceneItems(view).find((candidate) => keyOfDatum(candidate.datum) === key) + : undefined; + if (!item?.bounds) { + clear(); + return; + } + if (!annotationLayer.isConnected) container.append(annotationLayer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + + annotationCard.textContent = annotation.text; + + const containerRect = container.getBoundingClientRect(); + const space = coordinateSpace(); + const arcAngle = typeof item.startAngle === 'number' && typeof item.endAngle === 'number' + ? (item.startAngle + item.endAngle) / 2 + : undefined; + const arcRadius = typeof item.innerRadius === 'number' && typeof item.outerRadius === 'number' + ? (item.innerRadius + item.outerRadius) / 2 + : undefined; + const arcAnchor = annotation.anchor === 'arc-centroid' && arcAngle !== undefined && arcRadius !== undefined + ? { x: item.x + arcRadius * Math.sin(arcAngle), y: item.y - arcRadius * Math.cos(arcAngle) } + : undefined; + type Placement = 'above' | 'below' | 'left' | 'right'; + let outward: Placement | undefined; + let markEnd: PlotPoint | undefined; + if (annotation.anchor === 'mark-end') { + const horizontal = item.bounds.x2 - item.bounds.x1 >= item.bounds.y2 - item.bounds.y1; + const items = sceneItems(view); + const countAt = (edge: 'x1' | 'x2' | 'y1' | 'y2', value: number): number => items + .filter((candidate) => Math.abs(candidate.bounds[edge] - value) < 0.5) + .length; + if (horizontal) { + const leftIsBaseline = countAt('x1', item.bounds.x1) >= countAt('x2', item.bounds.x2); + outward = leftIsBaseline ? 'right' : 'left'; + markEnd = { + x: leftIsBaseline ? item.bounds.x2 : item.bounds.x1, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; + } else { + const topIsBaseline = countAt('y1', item.bounds.y1) > countAt('y2', item.bounds.y2); + outward = topIsBaseline ? 'below' : 'above'; + markEnd = { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: topIsBaseline ? item.bounds.y2 : item.bounds.y1, + }; + } + } else if (annotation.anchor === 'arc-centroid' && arcAnchor) { + const deltaX = arcAnchor.x - item.x; + const deltaY = arcAnchor.y - item.y; + outward = Math.abs(deltaX) >= Math.abs(deltaY) + ? deltaX >= 0 ? 'right' : 'left' + : deltaY >= 0 ? 'below' : 'above'; + } else if (annotation.anchor === 'top') outward = 'above'; + else if (annotation.anchor === 'bottom') outward = 'below'; + else if (annotation.anchor === 'left') outward = 'left'; + else if (annotation.anchor === 'right') outward = 'right'; + const exactPoint = annotation.anchor === 'center' ? point : undefined; + const anchorPlotX = exactPoint?.x ?? markEnd?.x ?? arcAnchor?.x ?? (annotation.anchor === 'left' ? item.bounds.x1 + : annotation.anchor === 'right' ? item.bounds.x2 + : (item.bounds.x1 + item.bounds.x2) / 2); + const anchorPlotY = exactPoint?.y ?? markEnd?.y ?? arcAnchor?.y ?? (annotation.anchor === 'top' ? item.bounds.y1 + : annotation.anchor === 'bottom' ? item.bounds.y2 + : (item.bounds.y1 + item.bounds.y2) / 2); + const anchorClient = plotToClientPoint({ x: anchorPlotX, y: anchorPlotY }, space); + const anchorLayout = clientToLayoutPoint(anchorClient, containerRect, containerLayoutSize()); + const anchorX = anchorLayout.x; + const anchorY = anchorLayout.y; + const width = container.clientWidth; + const height = container.clientHeight; + const cardWidth = annotationCard.offsetWidth; + const cardHeight = annotationCard.offsetHeight; + let placement = annotation.placement === 'auto' || !annotation.placement + ? (outward ?? (anchorX < width * 0.58 ? 'right' : 'left')) + : annotation.placement; + if (placement === 'above' && anchorY < cardHeight + 34) placement = 'below'; + if (placement === 'below' && anchorY + cardHeight + 34 > height) placement = 'above'; + if (placement === 'right' && anchorX + cardWidth + 38 > width) placement = 'left'; + if (placement === 'left' && anchorX - cardWidth - 38 < 0) placement = 'right'; + let cardX = anchorX + 34; + let cardY = anchorY - cardHeight / 2; + if (placement === 'left') cardX = anchorX - cardWidth - 38; + if (placement === 'above') { + cardX = anchorX - cardWidth / 2; + cardY = anchorY - cardHeight - 28; + } else if (placement === 'below') { + cardX = anchorX - cardWidth / 2; + cardY = anchorY + 28; + } + cardX = clamp(cardX, 8, Math.max(8, width - cardWidth - 8)); + cardY = clamp(cardY, 8, Math.max(8, height - cardHeight - 8)); + annotationCard.style.left = `${cardX}px`; + annotationCard.style.top = `${cardY}px`; + + const vertical = placement === 'above' || placement === 'below'; + const endX = vertical ? clamp(anchorX, cardX, cardX + cardWidth) : placement === 'right' ? cardX : cardX + cardWidth; + const endY = vertical ? placement === 'above' ? cardY + cardHeight : cardY : clamp(anchorY, cardY, cardY + cardHeight); + const control1X = vertical ? anchorX : anchorX + (placement === 'right' ? 18 : -18); + const control1Y = vertical ? anchorY + (placement === 'below' ? 14 : -14) : anchorY; + const control2X = vertical ? endX : endX + (placement === 'right' ? -18 : 18); + const control2Y = vertical ? endY + (placement === 'below' ? -14 : 14) : endY; + annotationSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); + annotationPath.setAttribute( + 'd', + `M ${anchorX} ${anchorY} C ${control1X} ${control1Y}, ${control2X} ${control2Y}, ${endX} ${endY}`, + ); + annotationDot.setAttribute('cx', String(anchorX)); + annotationDot.setAttribute('cy', String(anchorY)); + }; + + return { + render, + clear, + destroy: clear, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts new file mode 100644 index 00000000..2a131a19 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts @@ -0,0 +1,198 @@ +import type { PlotPoint } from '../../../interactive/interactions'; +import type { VegaInteractionPlan } from '../contracts'; +import { + INTERACTION_KEY, + clientRectToLayoutRect, + renderHit, + sceneItems, + type RendererCoordinateSpace, +} from '../hit-adapter'; + +export function mergeContiguousSelectionBounds( + bounds: readonly { x1: number; y1: number; x2: number; y2: number }[], + gap = 2, +): { x1: number; y1: number; x2: number; y2: number }[] { + const merged = bounds.map((bound) => ({ ...bound })); + const connected = (a: typeof merged[number], b: typeof merged[number]): boolean => { + const overlapX = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1); + const overlapY = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1); + return (overlapX > 0 && overlapY >= -gap) || (overlapY > 0 && overlapX >= -gap); + }; + for (let left = 0; left < merged.length; left += 1) { + for (let right = left + 1; right < merged.length;) { + if (!connected(merged[left], merged[right])) { + right += 1; + continue; + } + merged[left] = { + x1: Math.min(merged[left].x1, merged[right].x1), + y1: Math.min(merged[left].y1, merged[right].y1), + x2: Math.max(merged[left].x2, merged[right].x2), + y2: Math.max(merged[left].y2, merged[right].y2), + }; + merged.splice(right, 1); + left = -1; + break; + } + } + return merged; +} + +export interface FocusOverlayController { + render(selected: ReadonlySet, hoveredPathKeys: ReadonlySet): void; + destroy(): void; +} + +export interface FocusOverlayOptions { + view: any; + container: HTMLElement; + plan: VegaInteractionPlan; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function createFocusOverlay({ + view, + container, + plan, + coordinateSpace, + containerLayoutSize, +}: FocusOverlayOptions): FocusOverlayController { + const focusLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const pathVisuals = new Map(); + Object.assign(focusLayer.style, { + position: 'absolute', inset: '0', zIndex: '3', width: '100%', height: '100%', + pointerEvents: 'none', overflow: 'hidden', + }); + + const render = (selected: ReadonlySet, hoveredPathKeys: ReadonlySet): void => { + focusLayer.replaceChildren(); + const scene = sceneItems(view); + for (const item of scene) { + if (!item.interactionGeometry) continue; + const hit = renderHit(item); + const key = hit?.datum[INTERACTION_KEY]; + if (typeof key !== 'string' || pathVisuals.has(key)) continue; + pathVisuals.set(key, { + fill: item.fill, + fillOpacity: (typeof item.opacity === 'number' ? item.opacity : 1) + * (typeof item.fillOpacity === 'number' ? item.fillOpacity : 1), + stroke: item.stroke, + strokeWidth: typeof item.strokeWidth === 'number' ? item.strokeWidth : 2, + }); + } + const items = scene.filter((item) => { + const hit = renderHit(item); + const key = String(hit?.datum[INTERACTION_KEY]); + return hit && (selected.has(key) || hoveredPathKeys.has(key)) && item.interactionGeometry; + }); + const boundaryBounds = mergeContiguousSelectionBounds(scene + .filter((item) => { + const hit = renderHit(item); + const key = hit?.datum[INTERACTION_KEY]; + return typeof key === 'string' + && selected.has(key) + && plan.renderSelectionStyles?.[item.mark.marktype]?.boundary === 'contiguous-region'; + }) + .map((item) => item.bounds)); + if (items.length === 0 && boundaryBounds.length === 0) { + focusLayer.remove(); + return; + } + if (!focusLayer.isConnected) container.append(focusLayer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(focusLayer.style, { + inset: 'auto', + left: `${rendererLayout.left}px`, + top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, + height: `${rendererLayout.height}px`, + }); + focusLayer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + for (const item of items) { + const key = renderHit(item)?.datum[INTERACTION_KEY]; + const visual = typeof key === 'string' ? pathVisuals.get(key) : undefined; + const hovered = typeof key === 'string' && hoveredPathKeys.has(key); + const hoverStyle = hovered ? plan.renderHoverStyles?.[item.mark.marktype] : undefined; + const selectionStyle = typeof key === 'string' && selected.has(key) + ? plan.renderSelectionStyles?.[item.mark.marktype] + : undefined; + const basePath = renderer + ? [...renderer.querySelectorAll('[role="graphics-symbol"]')] + .find((candidate) => (candidate as any).__data__?.mark === item.mark) + : undefined; + const matrix = basePath?.getCTM(); + const points = item.interactionGeometry.points.map((plotPoint: PlotPoint) => { + if (!matrix || !renderer) { + return { x: plotPoint.x + space.originX, y: plotPoint.y + space.originY }; + } + const local = renderer.createSVGPoint(); + local.x = plotPoint.x - item.interactionGeometry.offset.x; + local.y = plotPoint.y - item.interactionGeometry.offset.y; + const transformed = local.matrixTransform(matrix); + return { x: transformed.x, y: transformed.y }; + }); + const segment = item.interactionGeometry.kind === 'segment'; + const shape = document.createElementNS('http://www.w3.org/2000/svg', segment ? 'path' : 'polygon'); + if (segment) { + shape.setAttribute('d', `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`); + shape.setAttribute('fill', 'none'); + shape.setAttribute('stroke', hoverStyle?.stroke ?? visual?.stroke ?? item.stroke ?? '#4c78a8'); + const authoredWidth = visual?.strokeWidth ?? item.strokeWidth ?? 2; + shape.setAttribute('stroke-width', String( + hoverStyle?.strokeWidth + ?? authoredWidth * (selectionStyle?.strokeWidthMultiplier ?? 1), + )); + shape.setAttribute('stroke-linecap', 'round'); + } else { + shape.setAttribute('points', points.map((plotPoint: PlotPoint) => `${plotPoint.x},${plotPoint.y}`).join(' ')); + shape.setAttribute('fill', hoverStyle?.fill ?? visual?.fill ?? item.fill ?? '#4c78a8'); + shape.setAttribute('fill-opacity', String(hoverStyle?.fillOpacity ?? visual?.fillOpacity ?? 1)); + if (hoverStyle?.stroke) shape.setAttribute('stroke', hoverStyle.stroke); + if (hoverStyle?.strokeWidth !== undefined) shape.setAttribute('stroke-width', String(hoverStyle.strokeWidth)); + } + focusLayer.append(shape); + } + for (const bounds of boundaryBounds) { + const boundaryStyle = plan.selectionBoundary ?? { + color: '#20262c', + width: 1.5, + opacity: 1, + haloColor: '#ffffff', + haloWidth: 3, + haloOpacity: 0.8, + }; + for (const [stroke, width, opacity] of [ + [boundaryStyle.haloColor, boundaryStyle.haloWidth, boundaryStyle.haloOpacity], + [boundaryStyle.color, boundaryStyle.width, boundaryStyle.opacity], + ] as const) { + const boundary = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + boundary.setAttribute('x', String(bounds.x1 + space.originX + 1)); + boundary.setAttribute('y', String(bounds.y1 + space.originY + 1)); + boundary.setAttribute('width', String(Math.max(0, bounds.x2 - bounds.x1 - 2))); + boundary.setAttribute('height', String(Math.max(0, bounds.y2 - bounds.y1 - 2))); + boundary.setAttribute('fill', 'none'); + boundary.setAttribute('stroke', stroke); + boundary.setAttribute('stroke-width', String(width)); + boundary.setAttribute('stroke-opacity', String(opacity)); + boundary.setAttribute('vector-effect', 'non-scaling-stroke'); + focusLayer.append(boundary); + } + } + }; + + return { + render, + destroy: () => focusLayer.remove(), + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts new file mode 100644 index 00000000..c151dfd7 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -0,0 +1,426 @@ +import { changeset } from 'vega'; +import type { ChartInteractionResolver } from '../../core/interaction-semantics'; +import type { + ChartUpdate, + ChartUpdateProcessor, + ExternalInteractionEvent, + FlintInteractionEventDetail, + InteractionDef, + NavigationInteractionEvent, + NormalizedInteractionEvent, + RenderHit, + SemanticTarget, + SemanticInteractionEvent, +} from '../../interactive/interactions'; +import type { VegaInteractionPlan } from './contracts'; +import { + INTERACTION_KEY, + PATH_KEY_SUFFIX, + clientToPlotPoint, + interactionModifiers, + normalizeVegaElementEvent, + renderHit, + sceneItems, + type RendererCoordinateSpace, +} from './hit-adapter'; +import { mountVegaRegionGesture } from './gestures/region'; +import { mountVegaNavigationGesture } from './gestures/navigation'; +import { createVegaNavigationController } from './navigation-scale'; +import { createAnnotationOverlay } from './presentation/annotation-overlay'; +import { createFocusOverlay } from './presentation/focus-overlay'; +import { + HOVER_STORE, + INTERACTION_STORE, + LEGEND_HOVER_STORE, + LEGEND_SELECTION_STORE, +} from './stores'; + +export { mergeContiguousSelectionBounds } from './presentation/focus-overlay'; + +export interface VegaInteractionController { + dispatch(event: ExternalInteractionEvent): Promise; + destroy(): void; +} + +export function mountVegaInteractions( + view: any, + container: HTMLElement, + chartType: string, + plan: VegaInteractionPlan, + interactions: readonly InteractionDef[], + resolve: ChartInteractionResolver | undefined, + presentUpdate: ChartUpdateProcessor, +): VegaInteractionController { + const clickInteraction = resolve + ? interactions.find((interaction) => interaction.eventSource.gesture === 'click') + : undefined; + const regionInteraction = resolve + ? interactions.find((interaction) => interaction.eventSource.gesture === 'drag') + : undefined; + const navigationInteraction = interactions.find( + (interaction) => interaction.eventSource.type === 'navigation', + ); + let selected = new Set(); + let selectedLegend: { channel: string; value: unknown } | null = null; + let hoveredPathKeys = new Set(); + let suppressClick = false; + let regionDragging = false; + let syncRunning = false; + let syncRequested = false; + + const containerLayoutSize = (): { width: number; height: number } => { + const rect = container.getBoundingClientRect(); + return { + width: container.offsetWidth || rect.width, + height: container.offsetHeight || rect.height, + }; + }; + + const coordinateSpace = (): RendererCoordinateSpace => { + const renderer = container.querySelector('canvas, svg') as HTMLElement | null; + const rect = (renderer ?? container).getBoundingClientRect(); + const [viewOriginX, viewOriginY] = view.origin(); + const svg = renderer instanceof SVGSVGElement ? renderer : undefined; + // SVG autosize/padding can make View#origin differ from the renderer's + // final plot translation. The rendered root-frame CTM is authoritative. + const rootFrame = svg?.querySelector('.mark-group.role-frame.root'); + const rootMatrix = rootFrame?.getCTM(); + const originX = rootMatrix?.e ?? viewOriginX; + const originY = rootMatrix?.f ?? viewOriginY; + const logicalWidth = svg?.viewBox.baseVal.width || rect.width; + const logicalHeight = svg?.viewBox.baseVal.height || rect.height; + const viewWidth = view.width(); + const viewHeight = view.height(); + return { + rect, + logicalWidth, + logicalHeight, + originX, + originY, + plotWidth: viewWidth > 0 ? viewWidth : Math.max(0, logicalWidth - originX), + plotHeight: viewHeight > 0 ? viewHeight : Math.max(0, logicalHeight - originY), + }; + }; + + const focusOverlay = createFocusOverlay({ view, container, plan, coordinateSpace, containerLayoutSize }); + const annotationOverlay = createAnnotationOverlay({ view, container, coordinateSpace, containerLayoutSize }); + const navigationController = createVegaNavigationController(view, plan.navigationAxes ?? {}); + const renderPathFocus = (): void => focusOverlay.render(selected, hoveredPathKeys); + const clearAnnotation = (): void => annotationOverlay.clear(); + renderPathFocus(); + + const allHits = (): RenderHit[] => sceneItems(view) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); + const resolveContext = (hits: readonly RenderHit[]) => ({ + allHits: hits, + keyField: INTERACTION_KEY, + categoryField: plan.categoryField, + seriesField: plan.seriesField, + }); + const context = (includeAvailable = true) => { + const hits = allHits(); + const available = includeAvailable ? resolve?.( + { gesture: 'rectangle', role: 'region', hits }, + resolveContext(hits), + )?.elements : undefined; + return { + chartType, + selected: [...selected].map((key) => ({ key: { [INTERACTION_KEY]: key } })), + available, + categoryField: plan.categoryField, + seriesField: plan.seriesField, + }; + }; + const sync = async (): Promise => { + syncRequested = true; + if (syncRunning) return; + syncRunning = true; + try { + while (syncRequested) { + syncRequested = false; + const keys = [...selected]; + view.change( + INTERACTION_STORE, + changeset().remove(() => true).insert(keys.map((key) => ({ key }))), + ); + view.change( + LEGEND_SELECTION_STORE, + changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), + ); + await view.runAsync(); + renderPathFocus(); + } + } finally { + syncRunning = false; + } + }; + const applyUpdate = async ( + update: ChartUpdate | null, + legendSelection: { channel: string; value: unknown } | null = null, + ): Promise => { + if (!update) return; + let requiresSemanticSync = false; + for (const op of update.ops) { + if (op.op === 'reset') { + selected.clear(); + selectedLegend = null; + clearAnnotation(); + requiresSemanticSync = true; + } else if (op.op === 'clear-annotation') { + clearAnnotation(); + } else if (op.op === 'render-annotation') { + annotationOverlay.render(op.element, op.annotation, op.point); + } else if (op.op === 'emphasize') { + requiresSemanticSync = true; + const keys = op.elements + .map((element) => element.key[INTERACTION_KEY]) + .filter((key): key is string => typeof key === 'string'); + if (op.mode === 'replace') selected = new Set(keys); + else { + const allSelected = keys.every((key) => selected.has(key)); + for (const key of keys) allSelected ? selected.delete(key) : selected.add(key); + } + selectedLegend = legendSelection && keys.some((key) => selected.has(key)) + ? legendSelection + : null; + } else if (op.op === 'navigate-viewport') { + await navigationController.apply(op); + } + } + if (requiresSemanticSync) await sync(); + }; + const emitInteractionEvent = ( + interaction: InteractionDef, + event: SemanticInteractionEvent | NavigationInteractionEvent, + transactionId?: string, + ): void => { + const root = container.closest('[data-flint-chart-id]'); + const detail: FlintInteractionEventDetail = { + chartId: root?.dataset.flintChartId ?? '', + interactionId: interaction.id, + timestamp: Date.now(), + transactionId, + event, + }; + container.dispatchEvent(new CustomEvent('flint-interaction', { + detail, + bubbles: true, + composed: true, + })); + }; + const dispatch = async ( + interaction: InteractionDef, + event: SemanticInteractionEvent, + legendSelection: { channel: string; value: unknown } | null = null, + ): Promise => { + const interactionContext = context(); + emitInteractionEvent(interaction, event); + const update = interaction.update(event, interactionContext); + await applyUpdate(update ? presentUpdate(update, interactionContext) : null, legendSelection); + }; + let navigationDispatch = Promise.resolve(); + const dispatchNavigation = ( + interaction: InteractionDef, + event: NavigationInteractionEvent, + ): Promise => { + const run = async (): Promise => { + const interactionContext = context(false); + emitInteractionEvent(interaction, event); + const update = interaction.update(event, interactionContext); + await applyUpdate(update ? presentUpdate(update, interactionContext) : null); + }; + navigationDispatch = navigationDispatch.then(run, run); + return navigationDispatch; + }; + const dispatchExternal = async (event: ExternalInteractionEvent): Promise => { + for (const interaction of interactions) { + const configuredSource = interaction.eventSource.source; + const acceptsSource = interaction.eventSource.type === 'external'; + if (configuredSource && configuredSource !== event.source) continue; + if (!acceptsSource) continue; + const interactionContext = context(); + const update = interaction.update(event, interactionContext); + await applyUpdate(update ? presentUpdate(update, interactionContext) : null); + } + }; + const resolveTarget = ( + gesture: 'click' | 'hover' | 'rectangle' | 'angular', + role: string, + hits: readonly RenderHit[], + legendValue?: unknown, + legendField?: string, + ): SemanticTarget | null => { + if (!resolve) return null; + const availableHits = allHits(); + return resolve( + { gesture, role, hits, legendValue, legendField }, + resolveContext(availableHits), + ); + }; + + let hoveredKeys = ''; + const setHover = async ( + keys: readonly string[], + legend: { channel: string; value: unknown } | null = null, + ): Promise => { + const next = [...new Set(keys)].sort(); + const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}`; + if (signature === hoveredKeys) return; + hoveredKeys = signature; + hoveredPathKeys = new Set(next.filter((key) => key.endsWith(PATH_KEY_SUFFIX))); + view.change( + HOVER_STORE, + changeset().remove(() => true).insert(next.map((key) => ({ key }))), + ); + view.change( + LEGEND_HOVER_STORE, + changeset().remove(() => true).insert(legend ? [legend] : []), + ); + await view.runAsync(); + renderPathFocus(); + }; + const clearHover = (): void => { + void setHover([]); + if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; + }; + const hoverHandler = (event: MouseEvent, item: any): void => { + if (!clickInteraction || regionDragging) return; + const point = localPoint(event as unknown as PointerEvent); + const normalized = normalizeVegaElementEvent( + view, item, point, 'preview', interactionModifiers(event), plan.legendFields, + ); + const legend = normalized.legend; + if (legend) { + if (!regionInteraction && !navigationInteraction) container.style.cursor = 'pointer'; + void setHover([], + legend.channel ? { channel: legend.channel, value: legend.value } : null); + return; + } + const hovered = normalized.event.hits[0]; + if (!hovered) { + clearHover(); + return; + } + if (!regionInteraction && !navigationInteraction) container.style.cursor = 'pointer'; + const resolved = resolveTarget('hover', normalized.role, normalized.event.hits); + const target = clickInteraction.actOn?.(resolved, context()) ?? resolved; + emitInteractionEvent(clickInteraction, { + type: 'semantic', source: 'element', phase: 'preview', target, point, + modifiers: normalized.event.modifiers, + }); + void setHover(target?.elements + .map((element) => element.key[INTERACTION_KEY]) + .filter((key): key is string => typeof key === 'string') ?? []); + }; + + const clickHandler = (event: MouseEvent, item: any): void => { + if (!clickInteraction || suppressClick) return; + const point = localPoint(event as unknown as PointerEvent); + const normalized = normalizeVegaElementEvent( + view, item, point, 'commit', interactionModifiers(event), plan.legendFields, + ); + const { legend } = normalized; + const target = resolveTarget( + 'click', normalized.role, normalized.event.hits, legend?.value, legend?.field, + ); + void dispatch(clickInteraction, { + type: 'semantic', source: 'element', phase: 'commit', target, point, + modifiers: normalized.event.modifiers, + }, legend?.channel ? { channel: legend.channel, value: legend.value } : null); + }; + if (clickInteraction) { + view.addEventListener('click', clickHandler); + view.addEventListener('mousemove', hoverHandler); + view.addEventListener('mouseout', clearHover); + } + + const previousCursor = container.style.cursor; + const localPoint = (event: PointerEvent): { x: number; y: number } => { + return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); + }; + const regionGesture = regionInteraction ? mountVegaRegionGesture({ + view, + container, + interaction: regionInteraction, + getSelected: () => selected, + setSelected: (next) => { selected = next; }, + coordinateSpace, + containerLayoutSize, + resolveTarget: (gesture, role, hits) => resolveTarget(gesture, role, hits), + dispatch: (event) => dispatch(regionInteraction, event), + clearHover, + clearAnnotation, + sync, + setSuppressClick: (suppress) => { suppressClick = suppress; }, + setDragging: (dragging) => { regionDragging = dragging; }, + }) : undefined; + const navigationGesture = navigationInteraction ? mountVegaNavigationGesture({ + container, + interaction: navigationInteraction, + availableAxes: Object.keys(plan.navigationAxes ?? {}) as ('x' | 'y')[], + coordinateSpace, + dispatch: (event) => dispatchNavigation(navigationInteraction, event), + setSuppressClick: (suppress) => { suppressClick = suppress; }, + setDragging: (dragging) => { regionDragging = dragging; }, + }) : undefined; + const clickOnlyKeyDown = (event: KeyboardEvent): void => { + if (regionInteraction || event.key !== 'Escape') return; + selected.clear(); + clearAnnotation(); + void sync(); + }; + if (clickInteraction && !regionInteraction) container.addEventListener('keydown', clickOnlyKeyDown); + + const customSourceCleanups = interactions.flatMap((interaction) => { + if (!interaction.eventSource?.mount) return []; + const cleanup = interaction.eventSource.mount({ + container, + emit(event: NormalizedInteractionEvent) { + if (event.type === 'external') { + void dispatchExternal(event); + return; + } + if (event.type === 'navigation') { + void dispatchNavigation(interaction, event); + return; + } + const gesture = event.type === 'region' + ? event.axis === 'angle' ? 'angular' : 'rectangle' + : 'click'; + const role = event.type === 'region' ? 'region' : 'mark'; + const target = resolveTarget(gesture, role, event.hits); + void dispatch(interaction, { + type: 'semantic', + source: event.type, + phase: event.phase, + target, + point: event.type === 'element' ? event.point : undefined, + region: event.type === 'region' ? event.region : undefined, + axis: event.type === 'region' ? event.axis : undefined, + operation: event.type === 'region' ? event.operation : undefined, + modifiers: event.modifiers, + }); + }, + }); + return cleanup ? [cleanup] : []; + }); + + const destroy = (): void => { + if (clickInteraction) { + view.removeEventListener('click', clickHandler); + view.removeEventListener('mousemove', hoverHandler); + view.removeEventListener('mouseout', clearHover); + } + if (clickInteraction && !regionInteraction) { + container.removeEventListener('keydown', clickOnlyKeyDown); + } + regionGesture?.destroy(); + navigationGesture?.destroy(); + focusOverlay.destroy(); + annotationOverlay.destroy(); + if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; + for (const cleanup of customSourceCleanups) cleanup(); + }; + return { dispatch: dispatchExternal, destroy }; +} diff --git a/packages/flint-js/src/vegalite/interactions/stores.ts b/packages/flint-js/src/vegalite/interactions/stores.ts new file mode 100644 index 00000000..e9268c57 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/stores.ts @@ -0,0 +1,4 @@ +export const INTERACTION_STORE = '__flint_interaction_store'; +export const HOVER_STORE = '__flint_hover_store'; +export const LEGEND_HOVER_STORE = '__flint_legend_hover_store'; +export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index 2d9dd3d8..6fc24c1f 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -6,9 +6,10 @@ import { assembleVegaLite } from './assemble'; import { addVegaLiteInteractions, injectVegaInteractionStore, - mountVegaInteractions, + injectVegaNavigationSignals, withoutSemanticInteractionField, -} from './semantic-interactions'; +} from './interactions/compile'; +import { mountVegaInteractions } from './interactions/runtime'; import { compile } from 'vega-lite'; import { Error as VegaError, parse, View } from 'vega'; import { Handler } from 'vega-tooltip'; @@ -63,7 +64,15 @@ export function createVegaInteractiveRenderer( const interactions = options.interactions ?? []; const interactionPlan = addVegaLiteInteractions(vlSpec, interactions); const vegaSpec = compile(vlSpec).spec as any; - if (interactionPlan) injectVegaInteractionStore(vegaSpec, interactionPlan); + if (interactionPlan) { + if (interactions.some((interaction) => interaction.eventSource.type !== 'navigation')) { + injectVegaInteractionStore(vegaSpec, interactionPlan); + } + interactionPlan.navigationAxes = injectVegaNavigationSignals( + vegaSpec, + interactionPlan.navigationChannels, + ); + } const source = vegaSpec.data?.find((entry: any) => Array.isArray(entry.values))?.name as string | undefined; if (viewports.length > 0 && !source) { throw new Error('Compiled chart has no mutable inline data source.'); @@ -82,7 +91,7 @@ export function createVegaInteractiveRenderer( tooltip.call(handler, event, item, withoutSemanticInteractionField(value)); }); await view.runAsync(); - const interactionController = interactionPlan?.resolve && interactionPlan.presentUpdate + const interactionController = interactionPlan ? mountVegaInteractions( view, container, @@ -90,7 +99,7 @@ export function createVegaInteractiveRenderer( interactionPlan, interactions, interactionPlan.resolve, - interactionPlan.presentUpdate, + interactionPlan.presentUpdate ?? ((update) => update), ) : undefined; diff --git a/packages/flint-js/src/vegalite/semantic-interactions.ts b/packages/flint-js/src/vegalite/semantic-interactions.ts deleted file mode 100644 index 9749a32a..00000000 --- a/packages/flint-js/src/vegalite/semantic-interactions.ts +++ /dev/null @@ -1,1126 +0,0 @@ -import { changeset } from 'vega'; -import type { ChartInteractionResolver } from '../core/interaction-semantics'; -import type { - ChartUpdate, - ChartUpdateProcessor, - ExternalInteractionEvent, - FlintInteractionEventDetail, - InteractionDef, - NormalizedInteractionEvent, - PlotPoint, - RenderHit, - SemanticTarget, - SemanticInteractionEvent, -} from '../interactive/interactions'; -import { DEFAULT_DIM_OPACITY } from '../interactive/emphasis-update'; -import { - INTERACTION_KEY, - PATH_KEY_SUFFIX, - arcIntersectsRect, - boundsIntersectRect, - clientRectToLayoutRect, - clientToLayoutPoint, - clientToPlotPoint, - interactionModifiers, - normalizeVegaElementEvent, - normalizeVegaRegionEvent, - plotToClientPoint, - renderHit, - sceneItems, - type RendererCoordinateSpace, -} from '../interactive/triggers/vega'; - -export { - INTERACTION_KEY, - arcIntersectsRect, - boundsIntersectRect, - clientRectToLayoutRect, - clientToLayoutPoint, - clientToPlotPoint, - plotToClientPoint, - sceneItems, -} from '../interactive/triggers/vega'; - -export const INTERACTION_STORE = '__flint_interaction_store'; -export const HOVER_STORE = '__flint_hover_store'; -export const LEGEND_HOVER_STORE = '__flint_legend_hover_store'; -export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; -const CLEAR_MARK = '__flint_interaction_clear'; -const SUPPORTED_SPEC_MARKS = new Set(['arc', 'area', 'bar', 'boxplot', 'circle', 'line', 'point', 'rect', 'rule', 'tick']); - -interface TemplateInteractionSemantics { - fields: string[]; - categoryField?: string; - seriesField?: string; - legendFields?: Record; - selectableMarks: string[]; - renderHoverStyles?: Record; - resolve?: ChartInteractionResolver; - presentUpdate?: ChartUpdateProcessor; -} - -interface HoverStyle { - fill?: string; - fillOpacity?: number; - opacity?: 'contrast'; - stroke?: string; - strokeWidth?: number; -} - -export interface VegaInteractionPlan { - fields: readonly string[]; - categoryField?: string; - seriesField?: string; - legendFields?: Readonly>; - dimOpacity: number; - renderHoverStyles?: Readonly>; - resolve?: ChartInteractionResolver; - presentUpdate?: ChartUpdateProcessor; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -export function withoutSemanticInteractionField(value: unknown): unknown { - if (!value || typeof value !== 'object' || Array.isArray(value)) return value; - const filtered = { ...(value as Record) }; - delete filtered[INTERACTION_KEY]; - return filtered; -} - -function markType(mark: unknown): string | undefined { - return typeof mark === 'string' - ? mark - : typeof mark === 'object' && mark !== null - ? (mark as Record).type as string | undefined - : undefined; -} - -function expandInteractiveLinePoints(spec: Record): void { - const type = markType(spec.mark); - if (type === 'line' && typeof spec.mark === 'object' && spec.mark.point) { - const lineMark = { ...spec.mark }; - const point = lineMark.point; - delete lineMark.point; - spec.layer = [ - { mark: lineMark }, - { mark: typeof point === 'object' ? { type: 'point', ...point } : { type: 'point', filled: true } }, - ]; - delete spec.mark; - } - for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { - if (!Array.isArray(spec[property])) continue; - for (const child of spec[property]) expandInteractiveLinePoints(child); - } -} - -function keyExpression(fields: readonly string[]): string { - return fields - .map((field) => `replace(toString(datum[${JSON.stringify(field)}]), '|', '\\|')`) - .join(` + '|' + `); -} - -function instrumentNode( - node: Record, - inherited: Record, - dimOpacity: number, - selectableMarks: ReadonlySet, - clickCursor: boolean, -): boolean { - const type = markType(node.mark); - if (!type || !SUPPORTED_SPEC_MARKS.has(type) || !selectableMarks.has(type)) return false; - const encoding = { ...inherited, ...(node.encoding ?? {}) }; - const encodedOpacity = encoding.opacity; - const dataDrivenOpacity = encodedOpacity?.field && !encodedOpacity.condition; - if ((encodedOpacity && typeof encodedOpacity.value !== 'number' && !dataDrivenOpacity) - || encoding.fillOpacity || encoding.strokeOpacity) return false; - const authoredOpacity = typeof encodedOpacity?.value === 'number' - ? encodedOpacity.value - : typeof node.mark === 'object' && typeof node.mark.opacity === 'number' - ? node.mark.opacity - : 1; - if (typeof node.mark === 'object' && typeof node.mark.opacity === 'number') { - node.mark = { ...node.mark }; - delete node.mark.opacity; - } - if (clickCursor) { - node.mark = typeof node.mark === 'string' - ? { type: node.mark, cursor: 'pointer' } - : { ...node.mark, cursor: node.mark.cursor ?? 'pointer' }; - } - const isPath = type === 'line' || type === 'area'; - const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; - const existingDetail = node.encoding?.detail; - const selectionTest = isPath - ? `!length(data('${INTERACTION_STORE}'))` - : `!length(data('${INTERACTION_STORE}')) || indata('${INTERACTION_STORE}', 'key', datum.${INTERACTION_KEY})`; - node.encoding = { - ...(node.encoding ?? {}), - ...(isPath ? {} : { - detail: existingDetail == null - ? { field: INTERACTION_KEY, type: 'nominal' } - : [...(Array.isArray(existingDetail) ? existingDetail : [existingDetail]), { field: INTERACTION_KEY, type: 'nominal' }], - }), - opacity: dataDrivenOpacity ? { - condition: { test: selectionTest, ...encodedOpacity }, - value: dimOpacity, - } : { - condition: { - test: `${selectionTest} || ${hoverTest}`, - value: authoredOpacity, - }, - value: Math.min(dimOpacity, authoredOpacity), - }, - }; - return true; -} - -function instrumentMarks( - spec: Record, - inherited: Record, - dimOpacity: number, - selectableMarks: ReadonlySet, - clickCursor: boolean, -): boolean { - const encoding = { ...inherited, ...(spec.encoding ?? {}) }; - let instrumented = instrumentNode(spec, inherited, dimOpacity, selectableMarks, clickCursor); - for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { - if (!Array.isArray(spec[property])) continue; - for (const child of spec[property]) { - instrumented = instrumentMarks(child, encoding, dimOpacity, selectableMarks, clickCursor) || instrumented; - } - } - return instrumented; -} - -function addLocalKeyTransforms( - spec: Record, - fields: readonly string[], - selectableMarks: ReadonlySet, -): void { - const type = markType(spec.mark); - if (type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type) && spec.data) { - spec.transform = [ - ...(Array.isArray(spec.transform) ? spec.transform : []), - { calculate: keyExpression(fields), as: INTERACTION_KEY }, - ]; - } - for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { - if (!Array.isArray(spec[property])) continue; - for (const child of spec[property]) addLocalKeyTransforms(child, fields, selectableMarks); - } -} - -export function addVegaLiteInteractions( - spec: Record, - interactions: readonly InteractionDef[], -): VegaInteractionPlan | null { - if (interactions.length === 0) return null; - const templateSemantics = spec._interactionSemantics as TemplateInteractionSemantics | undefined; - delete spec._interactionSemantics; - if (!templateSemantics || templateSemantics.fields.length === 0) return null; - const selectableMarks = new Set(templateSemantics?.selectableMarks ?? SUPPORTED_SPEC_MARKS); - const fields = templateSemantics.fields; - expandInteractiveLinePoints(spec); - - const dimOpacity = interactions.reduce((value, interaction) => { - if (interaction.eventSource.type === 'external') return value; - const update = interaction.update({ - type: 'semantic', - source: interaction.eventSource.type === 'region' ? 'region' : 'element', - phase: 'commit', - target: { - visual: { kind: 'mark', role: 'probe' }, - elements: [{ key: {} }], - }, - }, { chartType: 'Unknown', selected: [] }); - const emphasize = update?.ops.find((op) => op.op === 'emphasize'); - return emphasize?.op === 'emphasize' ? Math.min(value, emphasize.dimOpacity) : value; - }, DEFAULT_DIM_OPACITY); - - const clickCursor = interactions.some((interaction) => interaction.eventSource.gesture === 'click') - && !interactions.some((interaction) => interaction.eventSource.gesture === 'drag'); - const instrumented = instrumentMarks(spec, {}, dimOpacity, selectableMarks, clickCursor); - if (!instrumented) return null; - addLocalKeyTransforms(spec, fields, selectableMarks); - spec.transform = [ - ...(Array.isArray(spec.transform) ? spec.transform : []), - { calculate: keyExpression(fields), as: INTERACTION_KEY }, - ]; - return { - fields, - categoryField: templateSemantics.categoryField, - seriesField: templateSemantics.seriesField, - legendFields: templateSemantics.legendFields, - dimOpacity, - renderHoverStyles: templateSemantics.renderHoverStyles, - resolve: templateSemantics.resolve, - presentUpdate: templateSemantics.presentUpdate, - }; -} - -function applyCompiledHoverStyles( - marks: Record[], - renderHoverStyles: Readonly>, -): void { - const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; - for (const mark of marks) { - if (Array.isArray(mark.marks)) applyCompiledHoverStyles(mark.marks, renderHoverStyles); - const style = renderHoverStyles[mark.type]; - const update = mark.encode?.update; - if (!style || !update || !JSON.stringify(mark.encode).includes(INTERACTION_KEY)) continue; - for (const [channel, value] of Object.entries(style)) { - if (channel === 'opacity' && value === 'contrast') { - const numericValues = (Array.isArray(update.opacity) ? update.opacity : [update.opacity]) - .map((entry: any) => entry?.value) - .filter((entry: unknown): entry is number => typeof entry === 'number'); - const authoredOpacity = numericValues.length > 0 ? Math.max(...numericValues) : 1; - update.opacity = [ - { - test: `!length(data('${INTERACTION_STORE}')) && ${hoverTest}`, - value: authoredOpacity < 1 ? 1 : 0.9, - }, - ...(Array.isArray(update.opacity) ? update.opacity : [update.opacity]), - ]; - continue; - } - const existing = update[channel] ?? mark.encode?.enter?.[channel] ?? ( - channel === 'stroke' - ? { value: mark.type === 'line' || mark.type === 'rule' ? 'black' : 'transparent' } - : channel === 'strokeWidth' - ? { value: mark.type === 'line' ? 2 : mark.type === 'rule' ? 1 : mark.type === 'symbol' ? 1.5 : 0 } - : undefined - ); - if (existing === undefined) continue; - update[channel] = [ - { test: hoverTest, value }, - ...(Array.isArray(existing) ? existing : [existing]), - ]; - } - } -} - -export function injectVegaInteractionStore( - vegaSpec: Record, - plan?: Pick, -): void { - vegaSpec.data = [ - ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), - { name: INTERACTION_STORE, values: [] }, - { name: HOVER_STORE, values: [] }, - { name: LEGEND_HOVER_STORE, values: [] }, - { name: LEGEND_SELECTION_STORE, values: [] }, - ]; - for (const legend of vegaSpec.legends ?? []) { - const scaleChannel = ['fill', 'stroke', 'size', 'shape', 'opacity'] - .find((channel) => legend[channel] !== undefined); - const channel = scaleChannel === 'fill' || scaleChannel === 'stroke' ? 'color' : scaleChannel; - const peerOfSelectedLegend = channel - ? `length(data('${LEGEND_SELECTION_STORE}')) && ` + - `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + - `data('${LEGEND_SELECTION_STORE}')[0].value !== datum.value` - : undefined; - const interactiveItem = (encode: Record | undefined): Record => { - const existingOpacity = encode?.update?.opacity ?? encode?.enter?.opacity ?? { value: 1 }; - return { - ...(encode ?? {}), - interactive: true, - update: { - ...(encode?.update ?? {}), - cursor: { value: 'pointer' }, - opacity: peerOfSelectedLegend ? [ - { test: peerOfSelectedLegend, value: plan?.dimOpacity ?? DEFAULT_DIM_OPACITY }, - ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), - ] : existingOpacity, - }, - }; - }; - legend.encode = { - ...(legend.encode ?? {}), - symbols: interactiveItem(legend.encode?.symbols), - labels: interactiveItem(legend.encode?.labels), - }; - } - if (!Array.isArray(vegaSpec.marks)) return; - if (plan?.renderHoverStyles) applyCompiledHoverStyles(vegaSpec.marks, plan.renderHoverStyles); - vegaSpec.marks.unshift({ - type: 'rect', - name: CLEAR_MARK, - encode: { - enter: { - x: { value: 0 }, x2: { signal: 'width' }, - y: { value: 0 }, y2: { signal: 'height' }, - opacity: { value: 0 }, tooltip: { value: null }, - }, - }, - }); -} - -function keyOfDatum(datum: unknown): string | undefined { - if (!datum || typeof datum !== 'object') return undefined; - const key = (datum as Record)[INTERACTION_KEY]; - return typeof key === 'string' ? key : undefined; -} - -export interface VegaInteractionController { - dispatch(event: ExternalInteractionEvent): Promise; - destroy(): void; -} - -export function mountVegaInteractions( - view: any, - container: HTMLElement, - chartType: string, - plan: VegaInteractionPlan, - interactions: readonly InteractionDef[], - resolve: ChartInteractionResolver, - presentUpdate: ChartUpdateProcessor, -): VegaInteractionController { - const clickInteraction = interactions.find((interaction) => interaction.eventSource.gesture === 'click'); - const regionInteraction = interactions.find((interaction) => interaction.eventSource.gesture === 'drag'); - let selected = new Set(); - let selectedLegend: { channel: string; value: unknown } | null = null; - let hoveredPathKeys = new Set(); - let committed = new Set(); - let suppressClick = false; - let dragStart: { x: number; y: number } | undefined; - let pointerId: number | undefined; - let dragAction: 'create' | 'move' | 'resize-leading' | 'resize-trailing' = 'create'; - let activeInterval: { leading: number; trailing: number } | undefined; - let initialInterval: { leading: number; trailing: number } | undefined; - let syncRunning = false; - let syncRequested = false; - - const containerLayoutSize = (): { width: number; height: number } => { - const rect = container.getBoundingClientRect(); - return { - width: container.offsetWidth || rect.width, - height: container.offsetHeight || rect.height, - }; - }; - - const coordinateSpace = (): RendererCoordinateSpace => { - const renderer = container.querySelector('canvas, svg') as HTMLElement | null; - const rect = (renderer ?? container).getBoundingClientRect(); - const [viewOriginX, viewOriginY] = view.origin(); - const svg = renderer instanceof SVGSVGElement ? renderer : undefined; - // SVG autosize/padding can make View#origin differ from the renderer's - // final plot translation. The rendered root-frame CTM is authoritative. - const rootFrame = svg?.querySelector('.mark-group.role-frame.root'); - const rootMatrix = rootFrame?.getCTM(); - const originX = rootMatrix?.e ?? viewOriginX; - const originY = rootMatrix?.f ?? viewOriginY; - const logicalWidth = svg?.viewBox.baseVal.width || rect.width; - const logicalHeight = svg?.viewBox.baseVal.height || rect.height; - const viewWidth = view.width(); - const viewHeight = view.height(); - return { - rect, - logicalWidth, - logicalHeight, - originX, - originY, - plotWidth: viewWidth > 0 ? viewWidth : Math.max(0, logicalWidth - originX), - plotHeight: viewHeight > 0 ? viewHeight : Math.max(0, logicalHeight - originY), - }; - }; - - const focusLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - const pathVisuals = new Map(); - Object.assign(focusLayer.style, { - position: 'absolute', inset: '0', zIndex: '3', width: '100%', height: '100%', - pointerEvents: 'none', overflow: 'hidden', - }); - const renderPathFocus = (): void => { - focusLayer.replaceChildren(); - const scene = sceneItems(view); - for (const item of scene) { - if (!item.interactionGeometry) continue; - const hit = renderHit(item); - const key = hit?.datum[INTERACTION_KEY]; - if (typeof key !== 'string' || pathVisuals.has(key)) continue; - pathVisuals.set(key, { - fill: item.fill, - fillOpacity: (typeof item.opacity === 'number' ? item.opacity : 1) - * (typeof item.fillOpacity === 'number' ? item.fillOpacity : 1), - stroke: item.stroke, - strokeWidth: typeof item.strokeWidth === 'number' ? item.strokeWidth : 2, - }); - } - const items = scene.filter((item) => { - const hit = renderHit(item); - const key = String(hit?.datum[INTERACTION_KEY]); - return hit && (selected.has(key) || hoveredPathKeys.has(key)) && item.interactionGeometry; - }); - if (items.length === 0) { - focusLayer.remove(); - return; - } - if (!focusLayer.isConnected) container.append(focusLayer); - if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; - const space = coordinateSpace(); - const renderer = container.querySelector('svg') as SVGSVGElement | null; - const containerRect = container.getBoundingClientRect(); - const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; - const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); - Object.assign(focusLayer.style, { - inset: 'auto', - left: `${rendererLayout.left}px`, - top: `${rendererLayout.top}px`, - width: `${rendererLayout.width}px`, - height: `${rendererLayout.height}px`, - }); - focusLayer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); - for (const item of items) { - const key = renderHit(item)?.datum[INTERACTION_KEY]; - const visual = typeof key === 'string' ? pathVisuals.get(key) : undefined; - const hovered = typeof key === 'string' && hoveredPathKeys.has(key); - const hoverStyle = hovered ? plan.renderHoverStyles?.[item.mark.marktype] : undefined; - const basePath = renderer - ? [...renderer.querySelectorAll('[role="graphics-symbol"]')] - .find((candidate) => (candidate as any).__data__?.mark === item.mark) - : undefined; - const matrix = basePath?.getCTM(); - const points = item.interactionGeometry.points.map((plotPoint: PlotPoint) => { - if (!matrix || !renderer) { - return { x: plotPoint.x + space.originX, y: plotPoint.y + space.originY }; - } - const local = renderer.createSVGPoint(); - local.x = plotPoint.x - item.interactionGeometry.offset.x; - local.y = plotPoint.y - item.interactionGeometry.offset.y; - const transformed = local.matrixTransform(matrix); - return { x: transformed.x, y: transformed.y }; - }); - const segment = item.interactionGeometry.kind === 'segment'; - const shape = document.createElementNS('http://www.w3.org/2000/svg', segment ? 'path' : 'polygon'); - if (segment) { - shape.setAttribute('d', `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`); - shape.setAttribute('fill', 'none'); - shape.setAttribute('stroke', hoverStyle?.stroke ?? visual?.stroke ?? item.stroke ?? '#4c78a8'); - shape.setAttribute('stroke-width', String(hoverStyle?.strokeWidth ?? visual?.strokeWidth ?? item.strokeWidth ?? 2)); - shape.setAttribute('stroke-linecap', 'round'); - } else { - shape.setAttribute('points', points.map((plotPoint: PlotPoint) => `${plotPoint.x},${plotPoint.y}`).join(' ')); - shape.setAttribute('fill', hoverStyle?.fill ?? visual?.fill ?? item.fill ?? '#4c78a8'); - shape.setAttribute('fill-opacity', String(hoverStyle?.fillOpacity ?? visual?.fillOpacity ?? 1)); - if (hoverStyle?.stroke) shape.setAttribute('stroke', hoverStyle.stroke); - if (hoverStyle?.strokeWidth !== undefined) shape.setAttribute('stroke-width', String(hoverStyle.strokeWidth)); - } - focusLayer.append(shape); - } - }; - renderPathFocus(); - - const annotationLayer = document.createElement('div'); - Object.assign(annotationLayer.style, { - position: 'absolute', inset: '0', zIndex: '4', pointerEvents: 'none', overflow: 'hidden', - }); - const annotationSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - Object.assign(annotationSvg.style, { position: 'absolute', inset: '0', width: '100%', height: '100%' }); - const annotationPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - annotationPath.setAttribute('fill', 'none'); - annotationPath.setAttribute('stroke', '#176f58'); - annotationPath.setAttribute('stroke-width', '1.5'); - annotationPath.setAttribute('stroke-linecap', 'round'); - const annotationDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - annotationDot.setAttribute('r', '3'); - annotationDot.setAttribute('fill', '#176f58'); - annotationDot.setAttribute('stroke', '#ffffff'); - annotationDot.setAttribute('stroke-width', '1.5'); - annotationSvg.append(annotationPath, annotationDot); - const annotationCard = document.createElement('div'); - Object.assign(annotationCard.style, { - position: 'absolute', color: '#176f58', fontFamily: 'ui-sans-serif, sans-serif', - fontSize: '11px', fontWeight: '700', lineHeight: '1', whiteSpace: 'nowrap', - textShadow: '-1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff', - }); - annotationLayer.append(annotationSvg, annotationCard); - - const clearAnnotation = (): void => annotationLayer.remove(); - const renderAnnotation = ( - element: import('../core/interaction-semantics').SemanticElement, - annotation: import('../interactive/interactions').AnnotationRenderPlan, - point?: PlotPoint, - ): void => { - const key = element.key[INTERACTION_KEY]; - const item = typeof key === 'string' - ? sceneItems(view).find((candidate) => keyOfDatum(candidate.datum) === key) - : undefined; - if (!item?.bounds) { - clearAnnotation(); - return; - } - if (!annotationLayer.isConnected) container.append(annotationLayer); - if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; - - annotationCard.textContent = annotation.text; - - const containerRect = container.getBoundingClientRect(); - const space = coordinateSpace(); - const arcAngle = typeof item.startAngle === 'number' && typeof item.endAngle === 'number' - ? (item.startAngle + item.endAngle) / 2 - : undefined; - const arcRadius = typeof item.innerRadius === 'number' && typeof item.outerRadius === 'number' - ? (item.innerRadius + item.outerRadius) / 2 - : undefined; - const arcAnchor = annotation.anchor === 'arc-centroid' && arcAngle !== undefined && arcRadius !== undefined - ? { x: item.x + arcRadius * Math.sin(arcAngle), y: item.y - arcRadius * Math.cos(arcAngle) } - : undefined; - type Placement = 'above' | 'below' | 'left' | 'right'; - let outward: Placement | undefined; - let markEnd: PlotPoint | undefined; - if (annotation.anchor === 'mark-end') { - const horizontal = item.bounds.x2 - item.bounds.x1 >= item.bounds.y2 - item.bounds.y1; - const items = sceneItems(view); - const countAt = (edge: 'x1' | 'x2' | 'y1' | 'y2', value: number): number => items - .filter((candidate) => Math.abs(candidate.bounds[edge] - value) < 0.5) - .length; - if (horizontal) { - const leftIsBaseline = countAt('x1', item.bounds.x1) >= countAt('x2', item.bounds.x2); - outward = leftIsBaseline ? 'right' : 'left'; - markEnd = { - x: leftIsBaseline ? item.bounds.x2 : item.bounds.x1, - y: (item.bounds.y1 + item.bounds.y2) / 2, - }; - } else { - const topIsBaseline = countAt('y1', item.bounds.y1) > countAt('y2', item.bounds.y2); - outward = topIsBaseline ? 'below' : 'above'; - markEnd = { - x: (item.bounds.x1 + item.bounds.x2) / 2, - y: topIsBaseline ? item.bounds.y2 : item.bounds.y1, - }; - } - } else if (annotation.anchor === 'arc-centroid' && arcAnchor) { - const deltaX = arcAnchor.x - item.x; - const deltaY = arcAnchor.y - item.y; - outward = Math.abs(deltaX) >= Math.abs(deltaY) - ? deltaX >= 0 ? 'right' : 'left' - : deltaY >= 0 ? 'below' : 'above'; - } else if (annotation.anchor === 'top') outward = 'above'; - else if (annotation.anchor === 'bottom') outward = 'below'; - else if (annotation.anchor === 'left') outward = 'left'; - else if (annotation.anchor === 'right') outward = 'right'; - const exactPoint = annotation.anchor === 'center' ? point : undefined; - const anchorPlotX = exactPoint?.x ?? markEnd?.x ?? arcAnchor?.x ?? (annotation.anchor === 'left' ? item.bounds.x1 - : annotation.anchor === 'right' ? item.bounds.x2 - : (item.bounds.x1 + item.bounds.x2) / 2); - const anchorPlotY = exactPoint?.y ?? markEnd?.y ?? arcAnchor?.y ?? (annotation.anchor === 'top' ? item.bounds.y1 - : annotation.anchor === 'bottom' ? item.bounds.y2 - : (item.bounds.y1 + item.bounds.y2) / 2); - const anchorClient = plotToClientPoint({ x: anchorPlotX, y: anchorPlotY }, space); - const anchorLayout = clientToLayoutPoint(anchorClient, containerRect, containerLayoutSize()); - const anchorX = anchorLayout.x; - const anchorY = anchorLayout.y; - const width = container.clientWidth; - const height = container.clientHeight; - const cardWidth = annotationCard.offsetWidth; - const cardHeight = annotationCard.offsetHeight; - let placement = annotation.placement === 'auto' || !annotation.placement - ? (outward ?? (anchorX < width * 0.58 ? 'right' : 'left')) - : annotation.placement; - if (placement === 'above' && anchorY < cardHeight + 34) placement = 'below'; - if (placement === 'below' && anchorY + cardHeight + 34 > height) placement = 'above'; - if (placement === 'right' && anchorX + cardWidth + 38 > width) placement = 'left'; - if (placement === 'left' && anchorX - cardWidth - 38 < 0) placement = 'right'; - let cardX = anchorX + 34; - let cardY = anchorY - cardHeight / 2; - if (placement === 'left') cardX = anchorX - cardWidth - 38; - if (placement === 'above') { - cardX = anchorX - cardWidth / 2; - cardY = anchorY - cardHeight - 28; - } else if (placement === 'below') { - cardX = anchorX - cardWidth / 2; - cardY = anchorY + 28; - } - cardX = clamp(cardX, 8, Math.max(8, width - cardWidth - 8)); - cardY = clamp(cardY, 8, Math.max(8, height - cardHeight - 8)); - annotationCard.style.left = `${cardX}px`; - annotationCard.style.top = `${cardY}px`; - - const vertical = placement === 'above' || placement === 'below'; - const endX = vertical ? clamp(anchorX, cardX, cardX + cardWidth) : placement === 'right' ? cardX : cardX + cardWidth; - const endY = vertical ? placement === 'above' ? cardY + cardHeight : cardY : clamp(anchorY, cardY, cardY + cardHeight); - const control1X = vertical ? anchorX : anchorX + (placement === 'right' ? 18 : -18); - const control1Y = vertical ? anchorY + (placement === 'below' ? 14 : -14) : anchorY; - const control2X = vertical ? endX : endX + (placement === 'right' ? -18 : 18); - const control2Y = vertical ? endY + (placement === 'below' ? -14 : 14) : endY; - annotationSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); - annotationPath.setAttribute( - 'd', - `M ${anchorX} ${anchorY} C ${control1X} ${control1Y}, ${control2X} ${control2Y}, ${endX} ${endY}`, - ); - annotationDot.setAttribute('cx', String(anchorX)); - annotationDot.setAttribute('cy', String(anchorY)); - }; - - const allHits = (): RenderHit[] => sceneItems(view) - .map(renderHit) - .filter((hit): hit is RenderHit => hit !== null); - const resolveContext = (hits: readonly RenderHit[]) => ({ - allHits: hits, - keyField: INTERACTION_KEY, - categoryField: plan.categoryField, - seriesField: plan.seriesField, - }); - const context = () => { - const hits = allHits(); - const available = resolve( - { gesture: 'rectangle', role: 'region', hits }, - resolveContext(hits), - )?.elements; - return { - chartType, - selected: [...selected].map((key) => ({ key: { [INTERACTION_KEY]: key } })), - available, - categoryField: plan.categoryField, - seriesField: plan.seriesField, - }; - }; - const sync = async (): Promise => { - syncRequested = true; - if (syncRunning) return; - syncRunning = true; - try { - while (syncRequested) { - syncRequested = false; - const keys = [...selected]; - view.change( - INTERACTION_STORE, - changeset().remove(() => true).insert(keys.map((key) => ({ key }))), - ); - view.change( - LEGEND_SELECTION_STORE, - changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), - ); - await view.runAsync(); - renderPathFocus(); - } - } finally { - syncRunning = false; - } - }; - const applyUpdate = async ( - update: ChartUpdate | null, - legendSelection: { channel: string; value: unknown } | null = null, - ): Promise => { - if (!update) return; - for (const op of update.ops) { - if (op.op === 'reset') { - selected.clear(); - selectedLegend = null; - clearAnnotation(); - } else if (op.op === 'clear-annotation') { - clearAnnotation(); - } else if (op.op === 'render-annotation') { - renderAnnotation(op.element, op.annotation, op.point); - } else if (op.op === 'emphasize') { - const keys = op.elements - .map((element) => element.key[INTERACTION_KEY]) - .filter((key): key is string => typeof key === 'string'); - if (op.mode === 'replace') selected = new Set(keys); - else { - const allSelected = keys.every((key) => selected.has(key)); - for (const key of keys) allSelected ? selected.delete(key) : selected.add(key); - } - selectedLegend = legendSelection && keys.some((key) => selected.has(key)) - ? legendSelection - : null; - } - } - await sync(); - }; - const emitSemanticEvent = ( - interaction: InteractionDef, - event: SemanticInteractionEvent, - transactionId?: string, - ): void => { - const root = container.closest('[data-flint-chart-id]'); - const detail: FlintInteractionEventDetail = { - chartId: root?.dataset.flintChartId ?? '', - interactionId: interaction.id, - timestamp: Date.now(), - transactionId, - event, - }; - container.dispatchEvent(new CustomEvent('flint-interaction', { - detail, - bubbles: true, - composed: true, - })); - }; - const dispatch = async ( - interaction: InteractionDef, - event: SemanticInteractionEvent, - legendSelection: { channel: string; value: unknown } | null = null, - ): Promise => { - const interactionContext = context(); - emitSemanticEvent(interaction, event); - const update = interaction.update(event, interactionContext); - await applyUpdate(update ? presentUpdate(update, interactionContext) : null, legendSelection); - }; - const dispatchExternal = async (event: ExternalInteractionEvent): Promise => { - for (const interaction of interactions) { - const configuredSource = interaction.eventSource.source; - const acceptsSource = interaction.eventSource.type === 'external'; - if (configuredSource && configuredSource !== event.source) continue; - if (!acceptsSource) continue; - const interactionContext = context(); - const update = interaction.update(event, interactionContext); - await applyUpdate(update ? presentUpdate(update, interactionContext) : null); - } - }; - const resolveTarget = ( - gesture: 'click' | 'hover' | 'rectangle', - role: string, - hits: readonly RenderHit[], - legendValue?: unknown, - legendField?: string, - ): SemanticTarget | null => { - const availableHits = allHits(); - return resolve( - { gesture, role, hits, legendValue, legendField }, - resolveContext(availableHits), - ); - }; - - let hoveredKeys = ''; - const setHover = async ( - keys: readonly string[], - legend: { channel: string; value: unknown } | null = null, - ): Promise => { - const next = [...new Set(keys)].sort(); - const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}`; - if (signature === hoveredKeys) return; - hoveredKeys = signature; - hoveredPathKeys = new Set(next.filter((key) => key.endsWith(PATH_KEY_SUFFIX))); - view.change( - HOVER_STORE, - changeset().remove(() => true).insert(next.map((key) => ({ key }))), - ); - view.change( - LEGEND_HOVER_STORE, - changeset().remove(() => true).insert(legend ? [legend] : []), - ); - await view.runAsync(); - renderPathFocus(); - }; - const clearHover = (): void => { - void setHover([]); - if (!regionInteraction) container.style.cursor = previousCursor; - }; - const hoverHandler = (event: MouseEvent, item: any): void => { - if (!clickInteraction || dragStart) return; - const point = localPoint(event as unknown as PointerEvent); - const normalized = normalizeVegaElementEvent( - view, item, point, 'preview', interactionModifiers(event), plan.legendFields, - ); - const legend = normalized.legend; - if (legend) { - if (!regionInteraction) container.style.cursor = 'pointer'; - void setHover([], - legend.channel ? { channel: legend.channel, value: legend.value } : null); - return; - } - const hovered = normalized.event.hits[0]; - if (!hovered) { - clearHover(); - return; - } - if (!regionInteraction) container.style.cursor = 'pointer'; - const resolved = resolveTarget('hover', normalized.role, normalized.event.hits); - const target = clickInteraction.actOn?.(resolved, context()) ?? resolved; - emitSemanticEvent(clickInteraction, { - type: 'semantic', source: 'element', phase: 'preview', target, point, - modifiers: normalized.event.modifiers, - }); - void setHover(target?.elements - .map((element) => element.key[INTERACTION_KEY]) - .filter((key): key is string => typeof key === 'string') ?? []); - }; - - const clickHandler = (event: MouseEvent, item: any): void => { - if (!clickInteraction || suppressClick) return; - const point = localPoint(event as unknown as PointerEvent); - const normalized = normalizeVegaElementEvent( - view, item, point, 'commit', interactionModifiers(event), plan.legendFields, - ); - const { legend } = normalized; - const target = resolveTarget( - 'click', normalized.role, normalized.event.hits, legend?.value, legend?.field, - ); - void dispatch(clickInteraction, { - type: 'semantic', source: 'element', phase: 'commit', target, point, - modifiers: normalized.event.modifiers, - }, legend?.channel ? { channel: legend.channel, value: legend.value } : null); - }; - view.addEventListener('click', clickHandler); - view.addEventListener('mousemove', hoverHandler); - view.addEventListener('mouseout', clearHover); - - const overlay = document.createElement('div'); - Object.assign(overlay.style, { - position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', - boxSizing: 'border-box', - border: '1px solid rgba(37, 99, 235, 0.85)', background: 'rgba(37, 99, 235, 0.12)', - }); - const previousPosition = container.style.position; - const previousUserSelect = container.style.userSelect; - const previousCursor = container.style.cursor; - if (regionInteraction) { - if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; - container.style.userSelect = 'none'; - container.style.cursor = 'crosshair'; - container.append(overlay); - container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; - } - - const localPoint = (event: PointerEvent): { x: number; y: number } => { - return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); - }; - const regionAxis = regionInteraction?.eventSource.axis ?? 'xy'; - const statefulBrush = regionInteraction?.eventSource.mode === 'stateful' && regionAxis !== 'xy'; - const brushPlotSize = (): { width: number; height: number } => { - const space = coordinateSpace(); - return { width: space.plotWidth, height: space.plotHeight }; - }; - const constrainRegion = ( - a: { x: number; y: number }, - b: { x: number; y: number }, - plotSize = brushPlotSize(), - ) => ({ - start: { x: regionAxis === 'y' ? 0 : a.x, y: regionAxis === 'x' ? 0 : a.y }, - end: { x: regionAxis === 'y' ? plotSize.width : b.x, y: regionAxis === 'x' ? plotSize.height : b.y }, - }); - const dragDistance = (a: { x: number; y: number }, b: { x: number; y: number }): number => { - if (regionAxis === 'x') return Math.abs(b.x - a.x); - if (regionAxis === 'y') return Math.abs(b.y - a.y); - return Math.hypot(b.x - a.x, b.y - a.y); - }; - const axisValue = (point: { x: number; y: number }): number => regionAxis === 'y' ? point.y : point.x; - const axisLimit = (): number => regionAxis === 'y' ? brushPlotSize().height : brushPlotSize().width; - const pointsForInterval = (interval: { leading: number; trailing: number }): { - start: { x: number; y: number }; - end: { x: number; y: number }; - } => regionAxis === 'y' - ? { start: { x: 0, y: interval.leading }, end: { x: 0, y: interval.trailing } } - : { start: { x: interval.leading, y: 0 }, end: { x: interval.trailing, y: 0 } }; - const intervalForDrag = (point: { x: number; y: number }): { leading: number; trailing: number } => { - const value = axisValue(point); - const limit = axisLimit(); - if (!initialInterval || dragAction === 'create') { - const anchor = axisValue(dragStart!); - return { leading: Math.min(anchor, value), trailing: Math.max(anchor, value) }; - } - if (dragAction === 'move') { - const width = initialInterval.trailing - initialInterval.leading; - const delta = value - axisValue(dragStart!); - const leading = Math.max(0, Math.min(limit - width, initialInterval.leading + delta)); - return { leading, trailing: leading + width }; - } - const leading = dragAction === 'resize-leading' ? value : initialInterval.leading; - const trailing = dragAction === 'resize-trailing' ? value : initialInterval.trailing; - return { - leading: Math.max(0, Math.min(limit, Math.min(leading, trailing))), - trailing: Math.max(0, Math.min(limit, Math.max(leading, trailing))), - }; - }; - const showRegion = (a: { x: number; y: number }, b: { x: number; y: number }): void => { - const constrained = constrainRegion(a, b); - const space = coordinateSpace(); - const leading = plotToClientPoint({ - x: Math.min(constrained.start.x, constrained.end.x), - y: Math.min(constrained.start.y, constrained.end.y), - }, space); - const trailing = plotToClientPoint({ - x: Math.max(constrained.start.x, constrained.end.x), - y: Math.max(constrained.start.y, constrained.end.y), - }, space); - const containerRect = container.getBoundingClientRect(); - const layoutSize = containerLayoutSize(); - const localLeading = clientToLayoutPoint(leading, containerRect, layoutSize); - const localTrailing = clientToLayoutPoint(trailing, containerRect, layoutSize); - Object.assign(overlay.style, { - display: 'block', - left: `${localLeading.x}px`, - top: `${localLeading.y}px`, - width: `${localTrailing.x - localLeading.x}px`, - height: `${localTrailing.y - localLeading.y}px`, - }); - }; - const showInterval = (interval: { leading: number; trailing: number }): void => { - const points = pointsForInterval(interval); - showRegion(points.start, points.end); - }; - const dispatchRegion = ( - phase: 'preview' | 'commit', - start: { x: number; y: number }, - end: { x: number; y: number }, - event: PointerEvent, - operation: 'create' | 'move' | 'resize-leading' | 'resize-trailing' | 'clear', - target: SemanticTarget | null | undefined = undefined, - ): void => { - const normalized = normalizeVegaRegionEvent( - view, start, end, phase, regionInteraction!.eventSource.match ?? 'intersect', - interactionModifiers(event), regionAxis, brushPlotSize(), operation, - ); - selected = new Set(committed); - void dispatch(regionInteraction!, { - type: 'semantic', source: 'region', phase, - target: target === undefined ? resolveTarget('rectangle', 'region', normalized.hits) : target, - region: normalized.region, axis: normalized.axis, operation: normalized.operation, - modifiers: normalized.modifiers, - }); - }; - const pointerDown = (event: PointerEvent): void => { - if (!regionInteraction || event.button !== 0) return; - clearHover(); - const point = localPoint(event); - dragAction = 'create'; - initialInterval = activeInterval ? { ...activeInterval } : undefined; - if (statefulBrush && activeInterval) { - const value = axisValue(point); - const edgeTolerance = 8; - if (Math.abs(value - activeInterval.leading) <= edgeTolerance) dragAction = 'resize-leading'; - else if (Math.abs(value - activeInterval.trailing) <= edgeTolerance) dragAction = 'resize-trailing'; - else if (value > activeInterval.leading && value < activeInterval.trailing) dragAction = 'move'; - } - dragStart = point; - pointerId = event.pointerId; - committed = new Set(selected); - container.setPointerCapture(event.pointerId); - }; - const pointerMove = (event: PointerEvent): void => { - if (!regionInteraction) return; - if (!dragStart || pointerId !== event.pointerId) { - if (statefulBrush && activeInterval) { - const value = axisValue(localPoint(event)); - const nearEdge = Math.abs(value - activeInterval.leading) <= 8 - || Math.abs(value - activeInterval.trailing) <= 8; - container.style.cursor = nearEdge - ? regionAxis === 'x' ? 'ew-resize' : 'ns-resize' - : value > activeInterval.leading && value < activeInterval.trailing ? 'grab' : 'crosshair'; - } - return; - } - const point = localPoint(event); - if (dragDistance(dragStart, point) < 4) return; - suppressClick = true; - const interval = regionAxis === 'xy' ? undefined : intervalForDrag(point); - const points = interval ? pointsForInterval(interval) : { start: dragStart, end: point }; - interval ? showInterval(interval) : showRegion(dragStart, point); - dispatchRegion('preview', points.start, points.end, event, dragAction); - }; - const finishDrag = (event: PointerEvent): void => { - if (!regionInteraction || !dragStart || pointerId !== event.pointerId) return; - const point = localPoint(event); - const dragged = dragDistance(dragStart, point) >= 4; - if (dragged) { - const interval = regionAxis === 'xy' ? undefined : intervalForDrag(point); - const points = interval ? pointsForInterval(interval) : { start: dragStart, end: point }; - dispatchRegion('commit', points.start, points.end, event, dragAction); - if (statefulBrush && interval) { - activeInterval = interval; - showInterval(interval); - } - } else { - const clickedOutside = !activeInterval || axisValue(point) < activeInterval.leading - || axisValue(point) > activeInterval.trailing; - if (!statefulBrush || clickedOutside) { - activeInterval = undefined; - committed.clear(); - dispatchRegion('commit', dragStart, point, event, 'clear', null); - } - } - dragStart = undefined; - pointerId = undefined; - initialInterval = undefined; - if (!statefulBrush || !activeInterval) overlay.style.display = 'none'; - if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); - if (dragged) window.setTimeout(() => { suppressClick = false; }, 0); - }; - const cancelDrag = (event: PointerEvent): void => { - if (!regionInteraction || !dragStart || pointerId !== event.pointerId) return; - selected = new Set(committed); - dragStart = undefined; - pointerId = undefined; - initialInterval = undefined; - if (statefulBrush && activeInterval) showInterval(activeInterval); - else overlay.style.display = 'none'; - if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); - void sync(); - }; - const keyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape') return; - if (dragStart) { - selected = new Set(committed); - if (statefulBrush && initialInterval) activeInterval = initialInterval; - } else { - selected.clear(); - activeInterval = undefined; - clearAnnotation(); - } - dragStart = undefined; - pointerId = undefined; - initialInterval = undefined; - overlay.style.display = 'none'; - void sync(); - }; - container.addEventListener('pointerdown', pointerDown, true); - container.addEventListener('pointermove', pointerMove, true); - container.addEventListener('pointerup', finishDrag, true); - container.addEventListener('pointercancel', cancelDrag, true); - container.addEventListener('keydown', keyDown); - - const customSourceCleanups = interactions.flatMap((interaction) => { - if (!interaction.eventSource?.mount) return []; - const cleanup = interaction.eventSource.mount({ - container, - emit(event: NormalizedInteractionEvent) { - if (event.type === 'external') { - void dispatchExternal(event); - return; - } - const gesture = event.type === 'region' ? 'rectangle' : 'click'; - const role = event.type === 'region' ? 'region' : 'mark'; - const target = resolveTarget(gesture, role, event.hits); - void dispatch(interaction, { - type: 'semantic', - source: event.type, - phase: event.phase, - target, - point: event.type === 'element' ? event.point : undefined, - region: event.type === 'region' ? event.region : undefined, - axis: event.type === 'region' ? event.axis : undefined, - operation: event.type === 'region' ? event.operation : undefined, - modifiers: event.modifiers, - }); - }, - }); - return cleanup ? [cleanup] : []; - }); - - const destroy = (): void => { - view.removeEventListener('click', clickHandler); - view.removeEventListener('mousemove', hoverHandler); - view.removeEventListener('mouseout', clearHover); - container.removeEventListener('pointerdown', pointerDown, true); - container.removeEventListener('pointermove', pointerMove, true); - container.removeEventListener('pointerup', finishDrag, true); - container.removeEventListener('pointercancel', cancelDrag, true); - container.removeEventListener('keydown', keyDown); - overlay.remove(); - focusLayer.remove(); - annotationLayer.remove(); - container.style.position = previousPosition; - container.style.userSelect = previousUserSelect; - container.style.cursor = previousCursor; - for (const cleanup of customSourceCleanups) cleanup(); - }; - return { dispatch: dispatchExternal, destroy }; -} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 76055fd1..671ec476 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -124,6 +124,7 @@ export const areaChartDef: ChartTemplateDef = { chart: "Area Chart", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'area', geometryKinds: ['area', 'line', 'point'], semanticInteractions: ({ resolvedEncodings }) => { @@ -209,6 +210,7 @@ export const streamgraphDef: ChartTemplateDef = { chart: "Streamgraph", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'area', declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index a9bbab2a..f26b7ef2 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -14,6 +14,7 @@ import { type SemanticTarget, } from '../../core/interaction-semantics'; import { presentInteractionUpdate } from '../../interactive/chart-update'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, } from '../../core/axis-detection'; @@ -128,6 +129,7 @@ export const barChartDef: ChartTemplateDef = { chart: "Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -342,6 +344,7 @@ export const groupedBarChartDef: ChartTemplateDef = { chart: "Grouped Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "group", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -462,6 +465,7 @@ export const stackedBarChartDef: ChartTemplateDef = { chart: "Stacked Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -543,6 +547,7 @@ export const histogramDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const colorField = resolvedEncodings.color?.field; @@ -609,6 +614,7 @@ export const heatmapDef: ChartTemplateDef = { chart: "Heatmap", template: { mark: "rect", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'color', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y'] @@ -620,6 +626,10 @@ export const heatmapDef: ChartTemplateDef = { categoryField, selectableMarks: ['rect'], renderHoverStyles: rectHoverStyle(resolvedEncodings), + renderSelectionStyles: resolvedEncodings.color?.type === 'quantitative' + || resolvedEncodings.color?.type === 'temporal' + ? { rect: { boundary: 'contiguous-region' } } + : undefined, resolve: (event, context) => resolveBarTarget(event, context, undefined), presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', placement: 'above' })), }; @@ -796,7 +806,7 @@ export const heatmapDef: ChartTemplateDef = { ...(baseEncoding.color ? { color: spec.encoding.color } : {}), ...(spec.encoding.opacity ? { opacity: spec.encoding.opacity } : {}), }, - }, { + }, withInteractionTextLabel({ mark: { type: 'text', align: 'center', @@ -819,7 +829,7 @@ export const heatmapDef: ChartTemplateDef = { ? { condition: textColorConditions, value: defaultTextColor } : { value: defaultTextColor }, }, - }]; + }, { presentation: 'on-mark' })]; spec.layer = layers; delete spec.mark; diff --git a/packages/flint-js/src/vegalite/templates/bump.ts b/packages/flint-js/src/vegalite/templates/bump.ts index 351dfe09..4cc0bb68 100644 --- a/packages/flint-js/src/vegalite/templates/bump.ts +++ b/packages/flint-js/src/vegalite/templates/bump.ts @@ -18,6 +18,7 @@ export const bumpChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', properties: [interpolateConfigProperty], declareLayoutMode: () => ({ diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts index b3196cb5..31fd0344 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -16,6 +16,7 @@ export const candlestickChartDef: ChartTemplateDef = { ], }, channels: ["x", "open", "high", "low", "close", "column", "row"], + navigation: { axes: ['x'] }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = resolvedEncodings.x?.field; diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index b06b24c6..74d1a3ad 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -71,6 +71,7 @@ export const connectedScatterDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "order", "color", "detail", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); diff --git a/packages/flint-js/src/vegalite/templates/density.ts b/packages/flint-js/src/vegalite/templates/density.ts index d1ed1b54..59ecb277 100644 --- a/packages/flint-js/src/vegalite/templates/density.ts +++ b/packages/flint-js/src/vegalite/templates/density.ts @@ -63,6 +63,7 @@ export const densityPlotDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], + navigation: { axes: ['x'] }, markCognitiveChannel: 'area', instantiate: (spec, ctx) => { const { x, color, column, row } = ctx.resolvedEncodings; diff --git a/packages/flint-js/src/vegalite/templates/ecdf.ts b/packages/flint-js/src/vegalite/templates/ecdf.ts index 7972da76..fc83d78a 100644 --- a/packages/flint-js/src/vegalite/templates/ecdf.ts +++ b/packages/flint-js/src/vegalite/templates/ecdf.ts @@ -55,6 +55,7 @@ export const ecdfPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ['x', 'color', 'detail', 'column', 'row'], + navigation: { axes: ['x'] }, markCognitiveChannel: 'position', declareLayoutMode: () => ({ paramOverrides: { diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts index 8c0d0026..e8f015bf 100644 --- a/packages/flint-js/src/vegalite/templates/gantt.ts +++ b/packages/flint-js/src/vegalite/templates/gantt.ts @@ -9,6 +9,7 @@ import { targetFromHits, } from '../../core/interaction-semantics'; import { presentInteractionUpdate } from '../../interactive/chart-update'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { coerceGanttEndpoint, ganttDurationLabelExpression, @@ -38,6 +39,7 @@ export const ganttChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["y", "x", "x2", "color", "detail", "column", "row"], + navigation: { axes: ['x'] }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); @@ -55,7 +57,10 @@ export const ganttChartDef: ChartTemplateDef = { const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; - return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'task' }); + return targetFromHits(hits, context.keyField, { + kind: 'mark', + role: event.role === 'text-label' ? 'text-label' : 'task', + }); }, presentUpdate: presentInteractionUpdate(() => ({ anchor: 'mark-end', placement: 'auto' })), }; @@ -128,7 +133,7 @@ export const ganttChartDef: ChartTemplateDef = { spec.encoding = facetEncoding; spec.layer = [ { mark: spec.mark, encoding: barEncoding }, - { + withInteractionTextLabel({ mark: { type: 'text', align: 'left', baseline: 'middle', dx: 4, fontSize: 10 }, encoding: { y: { ...y }, @@ -139,7 +144,7 @@ export const ganttChartDef: ChartTemplateDef = { }, text: { field: labelField, type: 'nominal' }, }, - }, + }, { presentation: 'independent' }), ]; delete spec.mark; } diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts index 9cd6620a..37b7d4ee 100644 --- a/packages/flint-js/src/vegalite/templates/jitter.ts +++ b/packages/flint-js/src/vegalite/templates/jitter.ts @@ -20,6 +20,7 @@ export const stripPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "size", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index aad73b7a..a72d834a 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -155,6 +155,7 @@ export const lineChartDef: ChartTemplateDef = { chart: "Line Chart", template: { mark: "line", encoding: {} }, channels: ["x", "y", "color", "strokeDash", "detail", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', geometryKinds: ['line', 'point'], semanticInteractions: ({ resolvedEncodings }) => { @@ -174,6 +175,7 @@ export const lineChartDef: ChartTemplateDef = { line: { strokeWidth: 3 }, symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, + renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, resolve: (event, context) => resolveLineTarget(event, context, seriesField), presentUpdate: presentInteractionUpdate(() => ({ anchor: 'center', diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 85fd59c3..35b60c8a 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -25,6 +25,7 @@ export const lollipopChartDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index 397844ce..b4d9f691 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -25,6 +25,7 @@ export const pieChartDef: ChartTemplateDef = { seriesField, legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['arc'], + supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { const legendField = event.legendField ?? seriesField; diff --git a/packages/flint-js/src/vegalite/templates/range-area.ts b/packages/flint-js/src/vegalite/templates/range-area.ts index e95d418f..8d017967 100644 --- a/packages/flint-js/src/vegalite/templates/range-area.ts +++ b/packages/flint-js/src/vegalite/templates/range-area.ts @@ -43,6 +43,7 @@ export const rangeAreaChartDef: ChartTemplateDef = { chart: 'Range Area Chart', template: { mark: { type: 'area', opacity: 0.5, line: { strokeWidth: 1 } }, encoding: {} }, channels: ['x', 'y', 'y2', 'color', 'column', 'row'], + navigation: {}, markCognitiveChannel: 'area', declareLayoutMode: () => ({ paramOverrides: { diff --git a/packages/flint-js/src/vegalite/templates/rose.ts b/packages/flint-js/src/vegalite/templates/rose.ts index 796fab7c..025b5c7b 100644 --- a/packages/flint-js/src/vegalite/templates/rose.ts +++ b/packages/flint-js/src/vegalite/templates/rose.ts @@ -24,6 +24,7 @@ import { targetFromHits, } from '../../core/interaction-semantics'; import { presentInteractionUpdate } from '../../interactive/chart-update'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { setMarkProp } from './utils'; export const roseChartDef: ChartTemplateDef = { @@ -48,13 +49,21 @@ export const roseChartDef: ChartTemplateDef = { seriesField, legendFields: colorLegendField ? { color: colorLegendField } : undefined, selectableMarks: ['arc'], + supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { const legendField = event.legendField ?? seriesField ?? categoryField; - const hits = event.role === 'legend-item' && legendField + let hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; - return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'polar-bar' }); + if (event.role === 'text-label' && categoryField) { + const category = event.hits[0]?.datum[categoryField]; + hits = context.allHits.filter((hit) => hit.datum[categoryField] === category); + } + return targetFromHits(hits, context.keyField, { + kind: 'mark', + role: event.role === 'text-label' ? 'text-label' : 'polar-bar', + }); }, presentUpdate: presentInteractionUpdate(() => ({ anchor: 'arc-centroid', placement: 'auto' })), }; @@ -180,10 +189,13 @@ export const roseChartDef: ChartTemplateDef = { const arcMark = spec.mark; spec.layer = [ { mark: arcMark, encoding: {} as any }, - { + withInteractionTextLabel({ mark: { type: "text", radiusOffset: 15, fontSize: 11 }, encoding: {} as any, - }, + }, { + fields: x?.field ? [x.field] : undefined, + presentation: 'independent', + }), ]; delete spec.mark; diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 59bd10fe..09f1247d 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -45,6 +45,7 @@ export const scatterPlotDef: ChartTemplateDef = { chart: "Scatter Plot", template: { mark: "circle", encoding: {} }, channels: ["x", "y", "color", "size", "shape", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); @@ -122,6 +123,7 @@ export const regressionDef: ChartTemplateDef = { ], }, channels: ["x", "y", "size", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', instantiate: (spec, ctx) => { const { x, y, color, size, column, row } = ctx.resolvedEncodings; @@ -204,6 +206,7 @@ export const rangedDotPlotDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color"], + navigation: {}, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); @@ -259,6 +262,7 @@ export const boxplotDef: ChartTemplateDef = { chart: "Boxplot", template: { mark: "boxplot", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts index 2530cf9c..03a00581 100644 --- a/packages/flint-js/src/vegalite/templates/slope.ts +++ b/packages/flint-js/src/vegalite/templates/slope.ts @@ -67,6 +67,7 @@ export const slopeChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', declareLayoutMode: (cs, table) => { // Force the period axis to a discrete band so the two periods sit at two diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index d9a31ab7..0704123d 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -10,6 +10,7 @@ import { targetFromHits, } from '../../core/interaction-semantics'; import { presentInteractionUpdate } from '../../interactive/chart-update'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { resolveTotalsMode } from '../../chart-types/waterfall'; /** @@ -27,6 +28,7 @@ export const waterfallChartDef: ChartTemplateDef = { chart: "Waterfall Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); @@ -44,7 +46,10 @@ export const waterfallChartDef: ChartTemplateDef = { const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; - return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'waterfall-step' }); + return targetFromHits(hits, context.keyField, { + kind: 'mark', + role: event.role === 'text-label' ? 'text-label' : 'waterfall-step', + }); }, presentUpdate: presentInteractionUpdate((element) => element.records?.[0]?.__wf_color === 'decrease' ? { anchor: 'bottom', placement: 'below' } @@ -293,7 +298,7 @@ export const waterfallChartDef: ChartTemplateDef = { spec.layer.push( // Running total outside the bar (above increases / below decreases). - { + withInteractionTextLabel({ mark: { type: "text", align: "center", @@ -307,10 +312,10 @@ export const waterfallChartDef: ChartTemplateDef = { text: { field: "__wf_sum", type: "quantitative", format: labelFormat }, tooltip: null, }, - }, + }, { presentation: 'independent' }), // Delta inside the bar, muted in the bar's own hue. Skipped when the // bar is too short to hold the text. - { + withInteractionTextLabel({ transform: [{ filter: `abs(datum.__wf_sum - datum.__wf_prev_sum) >= ${minDataHeight}` }], mark: { type: "text", @@ -327,7 +332,7 @@ export const waterfallChartDef: ChartTemplateDef = { }, tooltip: null, }, - }, + }, { presentation: 'on-mark' }), ); } diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index 7cdea15d..41533820 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -19,6 +19,7 @@ import { contrastingInk, parseColor, luminance, mixHex, toHex } from '../core/th import { CONTINUOUS_BAR_STEP_FILL, coverageSizedMarks } from './templates/utils.js'; import { LOCAL_DODGE_LANE_FILL } from './templates/bar.js'; import { CANVAS_FURNITURE_KEY, readCanvasFurniture, type CanvasFurnitureItem } from './canvas-furniture.js'; +import { withInteractionTextLabel } from './interaction-provenance.js'; /** Mark families that carry data values (as opposed to chrome). */ const DATA_MARKS = new Set([ @@ -1635,9 +1636,10 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string if (fittedDots.has(node)) return; const mark = normalizeMark(node.mark); if (!mark.point) return; + const point = typeof mark.point === 'object' ? mark.point : {}; mark.point = { - ...(typeof mark.point === 'object' ? mark.point : {}), - ...(dot.filled != null ? { filled: dot.filled } : {}), + ...point, + ...(point.filled == null ? { filled: dot.filled !== false } : {}), ...(dot.size != null ? { size: dot.size } : {}), stroke: m.outline?.color ?? dot.haloColor, strokeWidth: m.outline?.width ?? dot.haloWidth ?? 1, @@ -4548,7 +4550,11 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa markDef.color = t.color ?? d.text.primary; } - const layer: any = { __themeSynthetic: true, mark: markDef, encoding: labelEncoding }; + const layer: any = withInteractionTextLabel({ + __themeSynthetic: true, + mark: markDef, + encoding: labelEncoding, + }, { presentation: inside ? 'on-mark' : 'independent' }); if (radialLabelKeepTest) { labelEncoding.opacity = { condition: { test: radialLabelKeepTest, value: 1 }, value: 0 }; } @@ -4624,12 +4630,12 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa const v = `abs(datum[${JSON.stringify(measure.field)}])`; const flipped = !inside; layer.transform = [{ filter: `${v} ${comparison === '<' ? '>=' : '<='} ${threshold}` }]; - const other: any = { + const other: any = withInteractionTextLabel({ __themeSynthetic: true, transform: [{ filter: `${v} ${comparison} ${threshold}` }], mark: { ...markDef, ...geometry(flipped), color: flipInk(flipped) }, encoding: labelEncoding, - }; + }, { presentation: flipped ? 'on-mark' : 'independent' }); if (other.mark.color === undefined) delete other.mark.color; appendLayer(body, other); say('dataLabels.placement', message); diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index 83a8b09f..3bb2cea4 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,23 +1,40 @@ import { describe, expect, it } from 'vitest'; -import { brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, normalizeInteractions, select } from '../src/interactive/interactions'; +import { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; import { + AngularBrushInteraction, BrushInteraction, ClickAnnotateInteraction, ClickGroupHighlightInteraction, ClickHighlightInteraction, + NavigateInteraction, SelectInteraction, } from '../src/interactive/presets'; import { presentInteractionUpdate } from '../src/interactive/chart-update'; import { axisBrushTrigger, + angularBrushTrigger, clickTrigger, externalTrigger, hoverTrigger, + navigationTrigger, rectangleTrigger, xBrushTrigger, yBrushTrigger, } from '../src/interactive/triggers'; -import { geometryIntersectsRect, INTERACTION_KEY, normalizeVegaRegionEvent } from '../src/interactive/triggers/vega'; +import { AngularRegionSession } from '../src/interactive/gestures/angular-region'; +import { + cartesianDragDistance, + constrainCartesianRegion, + intervalPoints, + updateInterval, +} from '../src/interactive/gestures/cartesian-region'; +import { PanSession, wheelZoomFactor } from '../src/interactive/gestures/navigation'; +import { guardNavigationDomain } from '../src/vegalite/interactions/navigation-scale'; +import { + geometryIntersectsRect, + INTERACTION_KEY, + normalizeVegaRegionEvent, +} from '../src/vegalite/interactions/hit-adapter'; import type { InteractionContext, InteractionDef, @@ -45,6 +62,108 @@ function semanticUpdate( }, context); } +describe('physical region gestures', () => { + it('projects Cartesian regions and measures only the configured axis', () => { + const start = { x: 20, y: 30 }; + const end = { x: 80, y: 90 }; + const plotSize = { width: 300, height: 180 }; + + expect(constrainCartesianRegion(start, end, 'x', plotSize)).toEqual({ + start: { x: 20, y: 0 }, end: { x: 80, y: 180 }, + }); + expect(constrainCartesianRegion(start, end, 'y', plotSize)).toEqual({ + start: { x: 0, y: 30 }, end: { x: 300, y: 90 }, + }); + expect(cartesianDragDistance(start, end, 'x')).toBe(60); + expect(cartesianDragDistance(start, end, 'y')).toBe(60); + expect(cartesianDragDistance(start, end, 'xy')).toBeCloseTo(Math.hypot(60, 60)); + }); + + it('creates, moves, and resizes stateful Cartesian intervals', () => { + expect(updateInterval({ x: 30, y: 0 }, { x: 70, y: 0 }, 'x', 100, 'create')).toEqual({ + leading: 30, trailing: 70, + }); + expect(updateInterval( + { x: 95, y: 0 }, { x: 50, y: 0 }, 'x', 100, 'move', { leading: 30, trailing: 70 }, + )).toEqual({ leading: 60, trailing: 100 }); + expect(updateInterval( + { x: 90, y: 0 }, { x: 0, y: 0 }, 'x', 100, 'resize-leading', { leading: 30, trailing: 70 }, + )).toEqual({ leading: 70, trailing: 90 }); + expect(intervalPoints({ leading: 20, trailing: 60 }, 'y')).toEqual({ + start: { x: 0, y: 20 }, end: { x: 0, y: 60 }, + }); + }); + + it('accumulates angular movement continuously across the zero-angle seam', () => { + const frame = { center: { x: 0, y: 0 }, innerRadius: 10, outerRadius: 100 }; + const pointAt = (angle: number) => ({ x: 100 * Math.sin(angle), y: -100 * Math.cos(angle) }); + const session = new AngularRegionSession(pointAt(Math.PI * 1.9), frame); + + session.move(pointAt(Math.PI * 0.1)); + + expect(session.sector().endAngle - session.sector().startAngle).toBeCloseTo(Math.PI * 0.2); + expect(session.dragDistance()).toBeCloseTo(Math.PI * 20); + }); +}); + +describe('viewport navigation', () => { + it('normalizes pan movement and wheel deltas without renderer state', () => { + const pan = new PanSession({ x: 20, y: 30 }, { width: 200, height: 100 }); + expect(pan.move({ x: 40, y: 20 })).toEqual({ x: 0.1, y: -0.1 }); + expect(pan.move({ x: 50, y: 40 })).toEqual({ x: 0.05, y: 0.2 }); + expect(pan.dragDistance()).toBeCloseTo(Math.hypot(20, -10) + Math.hypot(10, 20)); + expect(wheelZoomFactor(-100, 0, 400, 0.002)).toBeCloseTo(Math.exp(0.2)); + expect(wheelZoomFactor(1, 1, 400, 0.002)).toBeCloseTo(Math.exp(-0.032)); + }); + + it('declares navigation input separately from its viewport update policy', () => { + const interaction = navigate({ axes: 'xy' }); + expect(interaction).toBeInstanceOf(NavigateInteraction); + expect(interaction.eventSource).toEqual(navigationTrigger({ axes: 'xy' })); + expect(interaction.update({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes: 'xy', + factor: 1.5, anchor: { x: 0.25, y: 0.75 }, + }, { chartType: 'Scatter Plot', selected: [] })).toEqual({ + phase: 'commit', + ops: [{ + op: 'navigate-viewport', phase: 'commit', operation: 'zoom', axes: 'xy', + factor: 1.5, anchor: { x: 0.25, y: 0.75 }, + domainGuard: { + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, + }, + }], + }); + expect(() => navigate({ + domainGuard: { minVisibleFraction: 0.5, maxVisibleFraction: 0.25 }, + })).toThrow(/maxVisibleFraction/); + }); + + it('guards linear, temporal, and logarithmic domains against the initial extent', () => { + const guard = { minVisibleFraction: 0.1, maxVisibleFraction: 1, overscrollFraction: 0 }; + expect(guardNavigationDomain([45, 46], [0, 100], 'linear', guard)).toEqual([40.5, 50.5]); + expect(guardNavigationDomain([-20, 80], [0, 100], 'linear', guard)).toEqual([0, 100]); + const temporal = guardNavigationDomain( + [new Date('2020-05-01'), new Date('2020-05-02')], + [new Date('2020-01-01'), new Date('2021-01-01')], + 'time', + guard, + ); + expect(temporal[0]).toBeInstanceOf(Date); + expect((temporal[1] as Date).getTime() - (temporal[0] as Date).getTime()) + .toBeCloseTo((Date.UTC(2021, 0, 1) - Date.UTC(2020, 0, 1)) * 0.1, -2); + const logarithmic = guardNavigationDomain([10, 11], [1, 1000], 'log', guard).map(Number); + expect(logarithmic[1] / logarithmic[0]).toBeCloseTo(Math.pow(1000, 0.1)); + expect(guardNavigationDomain([-100, 200], [0, 100], 'linear', { + minVisibleFraction: 0.1, maxVisibleFraction: 1.5, overscrollFraction: 0, + })).toEqual([-25, 125]); + expect(guardNavigationDomain([-50, 50], [0, 100], 'linear', { + minVisibleFraction: 0.1, maxVisibleFraction: 1, overscrollFraction: 0.2, + })).toEqual([-20, 80]); + }); +}); + describe('interaction definitions', () => { it('declares normalized event sources for built-in presets', () => { expect(clickHighlight().eventSource).toBe(clickTrigger); @@ -53,6 +172,8 @@ describe('interaction definitions', () => { expect(select().eventSource).toEqual(rectangleTrigger('intersect')); expect(brushX().eventSource).toEqual(xBrushTrigger('intersect', 'ephemeral')); expect(brushY().eventSource).toEqual(yBrushTrigger('intersect', 'ephemeral')); + expect(brushAngle().eventSource).toEqual(angularBrushTrigger('intersect')); + expect(navigate().eventSource).toEqual(navigationTrigger()); }); it('provides reusable trigger descriptors', () => { @@ -68,6 +189,9 @@ describe('interaction definitions', () => { expect(yBrushTrigger('intersect', 'stateful')).toEqual({ type: 'region', gesture: 'drag', axis: 'y', match: 'intersect', mode: 'stateful', }); + expect(angularBrushTrigger('contain')).toEqual({ + type: 'region', gesture: 'drag', regionGeometry: 'angular', match: 'contain', mode: 'ephemeral', + }); expect(externalTrigger('story-scroll')).toEqual({ type: 'external', source: 'story-scroll' }); }); @@ -106,6 +230,8 @@ describe('interaction definitions', () => { expect(brushX()).toMatchObject({ id: 'brush-x', axis: 'x', eventSource: xBrushTrigger() }); expect(brushY()).toMatchObject({ id: 'brush-y', axis: 'y', eventSource: yBrushTrigger() }); expect(brushX({ mode: 'stateful' }).eventSource).toEqual(xBrushTrigger('intersect', 'stateful')); + expect(brushAngle()).toBeInstanceOf(AngularBrushInteraction); + expect(brushAngle()).toMatchObject({ id: 'brush-angle', eventSource: angularBrushTrigger() }); }); it('applies brush updates only for its configured axis', () => { @@ -127,6 +253,10 @@ describe('interaction definitions', () => { expect(brushX({ mode: 'stateful' }).update({ ...event, axis: 'x', phase: 'commit', operation: 'clear', target: null, }, context)).toEqual({ ops: [{ op: 'reset' }] }); + expect(brushAngle().update({ ...event, axis: 'angle' }, context)).toEqual({ + ops: [{ op: 'emphasize', elements: target.elements, mode: 'replace', dimOpacity: 0.25 }], + }); + expect(brushAngle().update({ ...event, axis: 'x' }, context)).toBeNull(); }); it('normalizes axis brushes across the orthogonal plot extent', () => { diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index e562a4af..56396a7e 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -2,27 +2,42 @@ import { describe, expect, it } from 'vitest'; import { changeset, parse, View } from 'vega'; import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src/vegalite/assemble'; -import { clickHighlight, select } from '../src/interactive/interactions'; +import { brushAngle, clickHighlight, navigate, select } from '../src/interactive/interactions'; import { MUTED_HOVER_FILL, MUTED_HOVER_STROKE } from '../src/core/interaction-semantics'; -import { barChartDef, pyramidChartDef } from '../src/vegalite/templates/bar'; +import { barChartDef, heatmapDef, pyramidChartDef } from '../src/vegalite/templates/bar'; import { barTableDef } from '../src/vegalite/templates/bar-table'; +import { roseChartDef } from '../src/vegalite/templates/rose'; import { rangedDotPlotDef, scatterPlotDef } from '../src/vegalite/templates/scatter'; import { addVegaLiteInteractions, + injectVegaInteractionStore, + injectVegaNavigationSignals, +} from '../src/vegalite/interactions/compile'; +import { angularSectorPath } from '../src/interactive/geometry/angular'; +import { + arcIntersectsAngularSector, arcIntersectsRect, boundsIntersectRect, clientRectToLayoutRect, clientToPlotPoint, clientToLayoutPoint, - HOVER_STORE, - injectVegaInteractionStore, INTERACTION_KEY, + INTERACTION_ROLE, + plotToClientPoint, + renderHit, + sceneItems, +} from '../src/vegalite/interactions/hit-adapter'; +import { + HOVER_STORE, INTERACTION_STORE, LEGEND_HOVER_STORE, LEGEND_SELECTION_STORE, - plotToClientPoint, - sceneItems, -} from '../src/vegalite/semantic-interactions'; +} from '../src/vegalite/interactions/stores'; +import { mergeContiguousSelectionBounds } from '../src/vegalite/interactions/presentation/focus-overlay'; +import { createVegaNavigationController } from '../src/vegalite/interactions/navigation-scale'; +import { INTERACTION_PROVENANCE } from '../src/vegalite/interaction-provenance'; +import { THEME_PRESETS } from '../src/core/theme/presets'; +import { lineChartDef } from '../src/vegalite/templates/line'; function instrument(spec: Record, interactions = [clickHighlight()]) { const plan = addVegaLiteInteractions(spec, interactions); @@ -43,6 +58,194 @@ function allSceneItems(view: View): any[] { } describe('Vega-Lite semantic interactions', () => { + it('compiles navigation capabilities into resettable Vega domain signals', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + }, + semantic_types: { x: 'Number', y: 'Number' }, + data: { values: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [navigate()]); + expect(plan?.navigationChannels).toEqual(['x', 'y']); + const compiled = compile(spec).spec as any; + const axes = injectVegaNavigationSignals(compiled, plan?.navigationChannels); + expect(axes).toMatchObject({ + x: { scale: 'x', signal: '__flint_navigation_x_domain', type: 'linear' }, + y: { scale: 'y', signal: '__flint_navigation_y_domain', type: 'linear' }, + }); + expect(compiled.scales.find((scale: any) => scale.name === 'x').domainRaw) + .toEqual({ signal: '__flint_navigation_x_domain' }); + expect(compiled.signals).toEqual(expect.arrayContaining([ + { name: '__flint_navigation_x_domain', value: null }, + { name: '__flint_navigation_y_domain', value: null }, + ])); + expect(compiled.marks.filter((mark: any) => mark.type === 'symbol')) + .toEqual(expect.arrayContaining([expect.objectContaining({ clip: true })])); + }); + + it('clips every generated layer when navigation is combined with semantic interaction', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + chartProperties: { showPoints: true }, + }, + semantic_types: { x: 'Date', y: 'Number' }, + data: { values: [{ x: '2025-01-01', y: 2 }, { x: '2025-02-01', y: 4 }] }, + }) as any; + addVegaLiteInteractions(spec, [navigate({ pan: false }), clickHighlight()]); + + const marks = (compile(spec).spec as any).marks; + const dataMarks = marks.filter((mark: any) => ['line', 'symbol'].includes(mark.type)); + expect(dataMarks) + .toEqual(expect.arrayContaining([expect.objectContaining({ clip: true })])); + expect(dataMarks.map((mark: any) => mark.type)).toEqual(expect.arrayContaining(['line', 'symbol'])); + expect(dataMarks).toSatisfy((compiledMarks: any[]) => ( + compiledMarks.every((mark) => mark.clip === true) + )); + }); + + it('zooms and resets an actual Vega scale through its domain signal', async () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + }, + semantic_types: { x: 'Number', y: 'Number' }, + data: { values: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [navigate()])!; + const compiled = compile(spec).spec as any; + const axes = injectVegaNavigationSignals(compiled, plan.navigationChannels); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const initial = view.scale('x').domain().map(Number); + const controller = createVegaNavigationController(view, axes); + + await controller.apply({ + op: 'navigate-viewport', phase: 'commit', operation: 'zoom', axes: 'x', + factor: 2, anchor: { x: 0.5, y: 0.5 }, + domainGuard: { minVisibleFraction: 0.02, maxVisibleFraction: 1, overscrollFraction: 0 }, + }); + const zoomed = view.scale('x').domain().map(Number); + expect(zoomed[1] - zoomed[0]).toBeCloseTo((initial[1] - initial[0]) / 2); + + await controller.apply({ + op: 'navigate-viewport', phase: 'commit', operation: 'reset', axes: 'x', + domainGuard: { minVisibleFraction: 0.02, maxVisibleFraction: 1, overscrollFraction: 0 }, + }); + expect(view.scale('x').domain().map(Number)).toEqual(initial); + view.finalize(); + }); + + it('declares proportional line focus and continuous-color region boundaries', () => { + expect(lineChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Year', type: 'ordinal' }, y: { field: 'Value', type: 'quantitative' } }, + }).renderSelectionStyles).toEqual({ line: { strokeWidthMultiplier: 1.2 } }); + + expect(heatmapDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Year', type: 'ordinal' }, y: { field: 'Country', type: 'nominal' }, color: { field: 'Value', type: 'quantitative' } }, + }).renderSelectionStyles).toEqual({ rect: { boundary: 'contiguous-region' } }); + expect(heatmapDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'X', type: 'ordinal' }, y: { field: 'Y', type: 'nominal' }, color: { field: 'Group', type: 'nominal' } }, + }).renderSelectionStyles).toBeUndefined(); + }); + + it('resolves continuous-color selection boundaries from the active theme', () => { + const makeSpec = (theme_spec: any) => assembleVegaLite({ + data: { values: [ + { Year: '2020', Country: 'A', Value: 10 }, + { Year: '2021', Country: 'A', Value: 14 }, + ] }, + semantic_types: { Year: 'Category', Country: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'Year', y: 'Country', color: 'Value' }, + }, + theme_spec, + } as any) as any; + + expect(makeSpec('economist')._interactionSemantics.selectionBoundary).toEqual({ + color: '#e3120b', + width: 1.5, + opacity: 1, + haloColor: '#ffffff', + haloWidth: 3, + haloOpacity: 0.8, + }); + expect(makeSpec({ + extends: 'economist', + interaction: { + selectionBoundary: { + color: '#b54a20', + width: 2, + opacity: 0.9, + haloColor: '#fffaf2', + haloWidth: 4, + haloOpacity: 0.7, + }, + }, + })._interactionSemantics.selectionBoundary).toEqual({ + color: '#b54a20', + width: 2, + opacity: 0.9, + haloColor: '#fffaf2', + haloWidth: 4, + haloOpacity: 0.7, + }); + }); + + it('merges selected heatmap cells by contiguous region without bridging gaps', () => { + expect(mergeContiguousSelectionBounds([ + { x1: 0, y1: 0, x2: 10, y2: 10 }, + { x1: 11, y1: 0, x2: 21, y2: 10 }, + { x1: 0, y1: 11, x2: 10, y2: 21 }, + { x1: 0, y1: 30, x2: 10, y2: 40 }, + ])).toEqual([ + { x1: 0, y1: 0, x2: 21, y2: 21 }, + { x1: 0, y1: 30, x2: 10, y2: 40 }, + ]); + }); + + it('keeps themed line vertices filled when expanding them for interaction', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Year: '2020', Country: 'A', Value: 10 }, + { Year: '2021', Country: 'A', Value: 14 }, + { Year: '2020', Country: 'B', Value: 13 }, + { Year: '2021', Country: 'B', Value: 11 }, + ], + }, + semantic_types: { Year: 'Category', Country: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'Year', y: 'Value', color: 'Country' }, + chartProperties: { showPoints: true }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + + addVegaLiteInteractions(spec, [clickHighlight()]); + const findPointMark = (node: any): any => { + if (node.mark?.type === 'point') return node.mark; + for (const property of ['layer', 'hconcat', 'vconcat', 'concat']) { + for (const child of node[property] ?? []) { + const found = findPointMark(child); + if (found) return found; + } + } + return undefined; + }; + expect(findPointMark(spec)).toMatchObject({ + type: 'point', + filled: true, + stroke: '#ffffff', + }); + }); + it('keeps concatenated-chart hover paint geometry invariant', () => { const renderHoverStyles = (definition: typeof pyramidChartDef) => definition.semanticInteractions?.({ resolvedEncodings: {} }).renderHoverStyles; @@ -121,6 +324,42 @@ describe('Vega-Lite semantic interactions', () => { }); }); + it('resolves a Rose category label to its label and all related arc segments', () => { + const resolve = roseChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Month', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve!; + const label = { + datum: { [INTERACTION_KEY]: 'Jan', Month: 'Jan', [INTERACTION_ROLE]: 'text-label' }, + source: 'mark' as const, + markType: 'text', + layerRole: 'text-label', + }; + const janA = { datum: { [INTERACTION_KEY]: 'Jan|A', Month: 'Jan', Segment: 'A' }, source: 'mark' as const }; + const janB = { datum: { [INTERACTION_KEY]: 'Jan|B', Month: 'Jan', Segment: 'B' }, source: 'mark' as const }; + const febA = { datum: { [INTERACTION_KEY]: 'Feb|A', Month: 'Feb', Segment: 'A' }, source: 'mark' as const }; + + const target = resolve( + { gesture: 'click', role: 'text-label', hits: [label] }, + { + allHits: [janA, janB, febA, label], + keyField: INTERACTION_KEY, + categoryField: 'Month', + seriesField: 'Segment', + }, + ); + + expect(target?.visual).toEqual({ kind: 'mark', role: 'text-label' }); + expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual([ + 'Jan|A', + 'Jan|B', + 'Jan', + ]); + }); + it('uses one local hover rule across color semantics', () => { const makeSpec = (colorSemanticType: 'Category' | 'Quantity') => assembleVegaLite({ data: { values: [ @@ -389,6 +628,63 @@ describe('Vega-Lite semantic interactions', () => { expect(arcIntersectsRect(quarter, { x1: 90, y1: 90, x2: 180, y2: 180 }, true)).toBe(false); }); + it('tests angular selection across the zero-angle seam and within one polar center', () => { + const arc = { + mark: { marktype: 'arc' }, x: 100, y: 100, + innerRadius: 20, outerRadius: 80, + startAngle: 11 * Math.PI / 6, endAngle: 13 * Math.PI / 6, + }; + const sector = { + center: { x: 100, y: 100 }, innerRadius: 0, outerRadius: 90, + startAngle: 7 * Math.PI / 4, endAngle: 9 * Math.PI / 4, + }; + + expect(arcIntersectsAngularSector(arc, sector)).toBe(true); + expect(arcIntersectsAngularSector(arc, sector, true)).toBe(true); + expect(arcIntersectsAngularSector(arc, { ...sector, endAngle: 2 * Math.PI }, true)).toBe(false); + expect(arcIntersectsAngularSector(arc, { ...sector, center: { x: 300, y: 100 } })).toBe(false); + expect(arcIntersectsAngularSector(arc, { ...sector, innerRadius: 85 })).toBe(false); + }); + + it('draws annular angular-brush geometry and admits it only on polar ChartDefs', () => { + expect(angularSectorPath({ + center: { x: 100, y: 100 }, innerRadius: 30, outerRadius: 80, + startAngle: 0, endAngle: Math.PI / 2, + })).toContain('A 80 80 0 0 1'); + const fullDisk = angularSectorPath({ + center: { x: 100, y: 100 }, innerRadius: 0, outerRadius: 80, + startAngle: 0, endAngle: 2 * Math.PI, + }); + const fullDonut = angularSectorPath({ + center: { x: 100, y: 100 }, innerRadius: 30, outerRadius: 80, + startAngle: 0, endAngle: -2 * Math.PI, + }); + expect(fullDisk.match(/ A /g)).toHaveLength(2); + expect(fullDonut.match(/ A /g)).toHaveLength(4); + expect(fullDisk).not.toContain('0.000001'); + + const cartesian = { + mark: 'bar', + data: { values: [{ category: 'A', value: 1 }] }, + encoding: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + _interactionSemantics: barChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + }), + }; + expect(() => addVegaLiteInteractions(cartesian, [brushAngle()])) + .toThrow('requires a polar chart with angular-region support'); + + const polar = { + mark: 'arc', + data: { values: [{ category: 'A', value: 1 }] }, + encoding: { theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' } }, + _interactionSemantics: roseChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + }), + }; + expect(addVegaLiteInteractions(polar, [brushAngle()])).not.toBeNull(); + }); + it('does not select adjacent cells that only touch the selection boundary', () => { const selection = { x1: 10, y1: 10, x2: 30, y2: 30 }; @@ -878,6 +1174,113 @@ describe('Vega-Lite semantic interactions', () => { expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); }); + it('dims independent text labels without instrumenting unrelated annotations', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Value'], + categoryField: 'Category', + selectableMarks: ['bar'], + }, + data: { + values: [ + { Category: 'A', Value: 12 }, + { Category: 'B', Value: 8 }, + ], + }, + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }, + { + [INTERACTION_PROVENANCE]: { + role: 'text-label', + identity: 'inherit', + presentation: 'independent', + }, + mark: { type: 'text', dy: -6 }, + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + text: { field: 'Value', type: 'quantitative' }, + }, + }, + { + mark: { type: 'text', dy: 12 }, + encoding: { + x: { datum: 'A', type: 'nominal' }, + y: { datum: 0, type: 'quantitative' }, + text: { value: 'Reference' }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [clickHighlight()]); + + expect(plan).not.toBeNull(); + expect(spec.layer[0].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(spec.layer[1].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(spec.layer[0].mark.cursor).toBe('pointer'); + expect(spec.layer[1].mark.cursor).toBeUndefined(); + expect(spec.layer[2].encoding.opacity).toBeUndefined(); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('carries generated on-mark label provenance without double opacity', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Category: 'A', Value: 12 }, + { Category: 'B', Value: 8 }, + ], + }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Category', y: 'Value' }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'economist', + } as never) as Record; + + const generatedLabel = spec.layer.find((layer: Record) => layer.mark?.type === 'text'); + expect(generatedLabel?.[INTERACTION_PROVENANCE]).toEqual({ + role: 'text-label', + identity: 'inherit', + presentation: 'on-mark', + }); + + const { compiled } = instrument(spec, [clickHighlight()]); + + expect(generatedLabel.encoding.opacity).toBeUndefined(); + expect(generatedLabel.transform).toContainEqual({ + calculate: "'text-label'", + as: INTERACTION_ROLE, + }); + expect(JSON.stringify(spec)).not.toContain(INTERACTION_PROVENANCE); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('resolves tagged label hits while leaving untagged text inert', () => { + const datum = { [INTERACTION_KEY]: 'A|12' }; + const mark = { marktype: 'text', name: 'value-label' }; + + expect(renderHit({ mark, datum })).toBeNull(); + expect(renderHit({ + mark, + datum: { ...datum, [INTERACTION_ROLE]: 'text-label' }, + })).toMatchObject({ + datum: { [INTERACTION_KEY]: 'A|12', [INTERACTION_ROLE]: 'text-label' }, + source: 'mark', + markType: 'text', + layerRole: 'text-label', + }); + }); + it('keeps a mark click local without a declared series', () => { const resolve = barChartDef.semanticInteractions!({ resolvedEncodings: { diff --git a/packages/flint-mcp/assets/flint-theme-author.SKILL.md b/packages/flint-mcp/assets/flint-theme-author.SKILL.md index 4df9c637..925d9697 100644 --- a/packages/flint-mcp/assets/flint-theme-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-theme-author.SKILL.md @@ -129,7 +129,7 @@ the authored blocks and their jobs: | `layout` | Density, target width, title block, and band step | | `chartDefaults` | Optional defaults keyed by registered chart type or `*`; caller values still win | | `compileDefaults` | Preferred base size, canvas size, and supported assemble options | -| `interaction` | Tooltip format | +| `interaction` | Tooltip format and semantic selection-boundary paint | | `variants` | Conditional policy adaptations; variants may not change `ink` or `type` | ### High-value nested shapes @@ -154,6 +154,8 @@ the authored blocks and their jobs: } ``` +`interaction.selectionBoundary` accepts `color`, `width`, `opacity`, `haloColor`, `haloWidth`, and `haloOpacity`. Omitted paint is grounded from the theme: foreground from `ink.accent` then primary text, and halo from the plot or canvas surface. Use explicit values only when the house has a distinct interaction treatment. + This is a shape example, not a palette recommendation. Derive actual values from the user's references. diff --git a/site/src/main.tsx b/site/src/main.tsx index fd554b79..2a281418 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -81,6 +81,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/site/src/playground/ChartToExternalLab.tsx b/site/src/playground/ChartToExternalLab.tsx index 9e82f771..a21db82c 100644 --- a/site/src/playground/ChartToExternalLab.tsx +++ b/site/src/playground/ChartToExternalLab.tsx @@ -203,7 +203,7 @@ function OutboundDemoRow({ demo }: { demo: OutboundDemo }) { ); const interactions = useMemo(() => [interaction], [interaction]); const handleSemanticEvent = useCallback((event: FlintInteractionEventDetail) => setDetail(event), []); - const records = targetRecords(detail?.event.target ?? null); + const records = targetRecords(detail?.event.type === 'semantic' ? detail.event.target : null); const rendered = demo.render(records, demo.fixture); return ( diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index ebfab175..c4456cf9 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { Layers3, MousePointer2, MoveHorizontal, MoveVertical, Scan } from 'lucide-react'; -import type { ChartAssemblyInput } from 'flint-chart'; +import { Layers3, MousePointer2, Move, MoveHorizontal, MoveVertical, RotateCcw, RotateCw, Scan } from 'lucide-react'; +import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; import { genAreaTests, genBarTableTests, @@ -26,29 +26,40 @@ import { } from 'flint-chart/test-data'; import { buildInteractiveChart, + brushAngle, brushX, brushY, clickGroupHighlight, clickHighlight, + navigate, select as rectangleSelect, } from 'flint-chart/interactive'; import { expressionInterpreter } from 'vega-interpreter'; import { ScaleToFit } from '../components/ScaleToFit'; import { testCaseToAssemblyInput } from '../shared/test-case-utils'; +import { ThemePicker } from './ThemePicker'; import './click-focus-lab.css'; type InteractionMode = 'element' | 'group' | 'select' - | 'brush-x' | 'brush-y' | 'brush-x-stateful' | 'brush-y-stateful'; + | 'brush-x' | 'brush-y' | 'brush-angle' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate'; type Support = 'works' | 'partial' | 'none'; +interface NavigationGuard { + minVisibleFraction: number; + maxVisibleFraction: number; + overscrollFraction: number; +} + const interactionModes = [ { value: 'element', label: 'Element', icon: MousePointer2 }, { value: 'group', label: 'Group', icon: Layers3 }, { value: 'select', label: 'Select', icon: Scan }, { value: 'brush-x', label: 'X brush', icon: MoveHorizontal }, { value: 'brush-y', label: 'Y brush', icon: MoveVertical }, + { value: 'brush-angle', label: 'Angular brush', icon: RotateCw }, { value: 'brush-x-stateful', label: 'X brush (edit)', icon: MoveHorizontal }, { value: 'brush-y-stateful', label: 'Y brush (edit)', icon: MoveVertical }, + { value: 'navigate', label: 'Pan & zoom', icon: Move }, ] as const; interface InteractionCase { @@ -183,7 +194,25 @@ const interactionCases: InteractionCase[] = [ interactionCase('connected-scatter', genConnectedScatterTests, 'works', 'Trajectory segments and observed points resolve independently.'), ]; -function InteractiveChart({ input, mode }: { input: ChartAssemblyInput; mode: InteractionMode }) { +const navigationAxesByCase = new Map(interactionCases.flatMap((item) => { + const spec = assembleVegaLite(item.input) as any; + const axes = spec._interactionSemantics?.navigationAxes as readonly ('x' | 'y')[] | undefined; + return axes?.length ? [[item.id, axes] as const] : []; +})); + +function InteractiveChart({ + input, + mode, + themeId, + navigationGuard, + resetVersion, +}: { + input: ChartAssemblyInput; + mode: InteractionMode; + themeId: string | undefined; + navigationGuard: NavigationGuard; + resetVersion: number; +}) { const containerRef = useRef(null); useEffect(() => { @@ -199,10 +228,15 @@ function InteractiveChart({ input, mode }: { input: ChartAssemblyInput; mode: In ? brushX() : mode === 'brush-y' ? brushY() - : mode === 'brush-x-stateful' - ? brushX({ mode: 'stateful' }) - : brushY({ mode: 'stateful' }); - const surface = buildInteractiveChart(container, input, { + : mode === 'brush-angle' + ? brushAngle() + : mode === 'brush-x-stateful' + ? brushX({ mode: 'stateful' }) + : mode === 'brush-y-stateful' + ? brushY({ mode: 'stateful' }) + : navigate({ axes: 'available', domainGuard: navigationGuard }); + const themedInput = themeId ? { ...input, theme_spec: themeId } : input; + const surface = buildInteractiveChart(container, themedInput, { backend: 'vegalite', renderer: 'svg', interactions: [interaction], @@ -213,27 +247,51 @@ function InteractiveChart({ input, mode }: { input: ChartAssemblyInput; mode: In container.textContent = error instanceof Error ? error.message : String(error); }); return () => surface.destroy(); - }, [input, mode]); + }, [input, mode, navigationGuard, resetVersion, themeId]); return
; } -function CaseCard({ item, mode }: { item: InteractionCase; mode: InteractionMode }) { +function CaseCard({ + item, + mode, + themeId, + navigationGuard, + resetVersion, +}: { + item: InteractionCase; + mode: InteractionMode; + themeId: string | undefined; + navigationGuard: NavigationGuard; + resetVersion: number; +}) { const title = item.input.chart_spec.title || item.input.chart_spec.chartType; + const navigationAxes = navigationAxesByCase.get(item.id); + const description = mode === 'navigate' + ? `Drag to pan and use the wheel or trackpad to zoom the ${navigationAxes?.join(' and ')} domain.` + : item.expectation; return (

{title}

-

{item.expectation}

+

{description}

- {item.support === 'works' ? 'Bound' : item.support === 'partial' ? 'Partial' : 'Not bound'} + {mode === 'navigate' + ? navigationAxes?.join(' + ') + : item.support === 'works' ? 'Bound' : item.support === 'partial' ? 'Partial' : 'Not bound'}
- +
@@ -242,10 +300,22 @@ function CaseCard({ item, mode }: { item: InteractionCase; mode: InteractionMode export function ClickFocusLab() { const [mode, setMode] = useState('element'); + const [themeId, setThemeId] = useState(undefined); + const [navigationGuard, setNavigationGuard] = useState({ + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, + }); + const [resetVersion, setResetVersion] = useState(0); const counts = interactionCases.reduce((result, item) => { result[item.support] += 1; return result; }, { works: 0, partial: 0, none: 0 }); + const visibleCases = mode === 'brush-angle' + ? interactionCases.filter((item) => item.id === 'pie' || item.id === 'rose') + : mode === 'navigate' + ? interactionCases.filter((item) => navigationAxesByCase.has(item.id)) + : interactionCases; return (
@@ -274,16 +344,61 @@ export function ClickFocusLab() {
  • Select: Drag a rectangle to focus all marks within an area.
  • X brush: Drag horizontally to focus marks across an X interval.
  • Y brush: Drag vertically to focus marks across a Y interval.
  • +
  • Angular brush: Drag around the center of a pie, donut, or rose chart.
  • Stateful brush: Move the committed interval, resize either edge, or click outside to clear it.
  • +
  • Pan & zoom: Drag continuous axes to pan; use the wheel or trackpad to zoom.
  • {counts.works} bound {counts.partial} partial {counts.none} not bound
    +
    + +
    + {mode === 'navigate' && ( +
    + + + + +
    + )}
    - {interactionCases.map((item) => )} + {visibleCases.map((item) => ( + + ))}
    ); diff --git a/site/src/playground/InteractionDashboardLab.tsx b/site/src/playground/InteractionDashboardLab.tsx index d8bff94f..6604552e 100644 --- a/site/src/playground/InteractionDashboardLab.tsx +++ b/site/src/playground/InteractionDashboardLab.tsx @@ -42,7 +42,6 @@ const focusCountries = new Set([ ]); type DashboardMetric = 'Life expectancy' | 'GDP per capita'; -type TrendDensity = 'focus' | 'all'; const semanticTypes = { Observation: 'Category', @@ -57,6 +56,18 @@ const semanticTypes = { 'GDP per capita': 'Currency', }; +const dashboardTheme = { + extends: 'economist', + ink: { + series: { + categoricalExtended: [ + '#006ba2', '#3ebcd2', '#ebb434', '#379a8b', '#9a3d5b', '#a17ba5', + '#003f5c', '#d46b27', '#5f8f3b', '#c75146', '#d66aa5', '#74624f', + ], + }, + }, +}; + function idsFor(test: (row: GapminderRow) => boolean): string[] { return gapminderRows.filter(test).map((row) => row.Observation); } @@ -74,7 +85,7 @@ function dashboardFixture( encodings: Record, values: Row[] = rows, chartProperties?: Record, - baseSize = { width: 350, height: 245 }, + baseSize = { width: 400, height: 230 }, ): InteractionDemoFixture { return { id, @@ -83,6 +94,7 @@ function dashboardFixture( input: { data: { values }, semantic_types: semanticTypes, + theme_spec: dashboardTheme, options: chartType === 'Bar Chart' ? { defaultBandSize: 44, maxBandSize: 64 } : undefined, @@ -116,7 +128,6 @@ const linkedFocus: InteractionDef = { function buildDashboardCharts( snapshotYear: number, metric: DashboardMetric, - trendDensity: TrendDensity, ): DashboardChart[] { const snapshotRows: Row[] = gapminderRows .filter((row) => row.Year === snapshotYear) @@ -133,9 +144,7 @@ function buildDashboardCharts( 'Observation IDs': idsFor((row) => row.Continent === Continent), }; }); - const trendRows = trendDensity === 'all' - ? rows - : rows.filter((row) => focusCountries.has(String(row.Country))); + const trendRows = rows.filter((row) => focusCountries.has(String(row.Country))); const metricTitle = metric === 'Life expectancy' ? 'Life expectancy' : 'Income per person'; return [ @@ -148,7 +157,7 @@ function buildDashboardCharts( { x: 'GDP per capita', y: 'Life expectancy', size: 'Population (M)', color: 'Continent', detail: 'Country' }, snapshotRows, { logScale_x: true }, - { width: 430, height: 245 }, + { width: 400, height: 230 }, ), interaction: select({ id: 'dashboard-country-select' }), }, @@ -161,20 +170,20 @@ function buildDashboardCharts( { x: 'Population (M)', y: 'Continent' }, continentRows, undefined, - { width: 430, height: 245 }, + { width: 400, height: 230 }, ), interaction: clickHighlight({ id: 'dashboard-continent-click' }), }, { id: 'trends', fixture: dashboardFixture( - `dashboard-trends-${metric}-${trendDensity}`, + `dashboard-trends-${metric}`, `${metricTitle} trajectories, 1952–2007`, 'Line Chart', { x: 'Year label', y: metric, color: 'Country' }, trendRows, - { showPoints: trendDensity === 'focus', logScale_y: metric === 'GDP per capita' }, - { width: 430, height: 300 }, + { showPoints: true, logScale_y: metric === 'GDP per capita' }, + { width: 400, height: 260 }, ), interaction: clickHighlight({ id: 'dashboard-trend-click' }), }, @@ -186,6 +195,8 @@ function buildDashboardCharts( 'Heatmap', { x: 'Year label', y: 'Country', color: metric }, rows, + undefined, + { width: 400, height: 260 }, ), interaction: brushY({ id: 'dashboard-brush-y' }), }, @@ -231,13 +242,11 @@ export function InteractionDashboardLab() { const [selection, setSelection] = useState(null); const [snapshotYear, setSnapshotYear] = useState(2007); const [metric, setMetric] = useState('Life expectancy'); - const [trendDensity, setTrendDensity] = useState('focus'); const deferredYear = useDeferredValue(snapshotYear); const deferredMetric = useDeferredValue(metric); - const deferredTrendDensity = useDeferredValue(trendDensity); const dashboardCharts = useMemo( - () => buildDashboardCharts(deferredYear, deferredMetric, deferredTrendDensity), - [deferredMetric, deferredTrendDensity, deferredYear], + () => buildDashboardCharts(deferredYear, deferredMetric), + [deferredMetric, deferredYear], ); const registerSurface = useCallback((id: string, surface: InteractiveChartSurface | null) => { @@ -258,7 +267,7 @@ export function InteractionDashboardLab() { }, []); const routeEvent = useCallback((detail: FlintInteractionEventDetail) => { - if (detail.event.phase !== 'commit') return; + if (detail.event.type !== 'semantic' || detail.event.phase !== 'commit') return; const ids = observationIds(detail.event.target?.elements ?? []); const source = dashboardCharts.find((chart) => `dashboard-${chart.id}` === detail.chartId); dispatchSelection(ids, source?.id); @@ -280,11 +289,6 @@ export function InteractionDashboardLab() { setMetric(nextMetric); }, [clearSelection]); - const changeTrendDensity = useCallback((density: TrendDensity) => { - clearSelection(); - setTrendDensity(density); - }, [clearSelection]); - const activeIds = new Set(selection?.ids ?? rows.map((row) => String(row.Observation))); const activeRows = rows.filter((row) => activeIds.has(String(row.Observation))); const activeCountries = new Set(activeRows.map((row) => String(row.Country))); @@ -310,8 +314,8 @@ export function InteractionDashboardLab() {
    -
    -

    Application controls that speak chart semantics

    -

    Each control dispatches an external event. Its preset interprets the payload, produces a ChartUpdate, and lets the chart definition present it.

    +

    Each control identifies semantic chart keys and applies a renderer-neutral update request.

    {demos.map((demo) => )} diff --git a/site/src/playground/InteractionDashboardLab.tsx b/site/src/playground/InteractionDashboardLab.tsx index 6604552e..ef433f17 100644 --- a/site/src/playground/InteractionDashboardLab.tsx +++ b/site/src/playground/InteractionDashboardLab.tsx @@ -5,11 +5,14 @@ import type { InteractionDef, InteractiveChartSurface, SemanticElement, + UpdateTarget, } from 'flint-chart/interactive'; import { brushY, + clickGroupHighlight, clickHighlight, - externalTrigger, + emphasize, + resetUpdate, select, } from 'flint-chart/interactive'; import { InteractionDemoChart } from './InteractionDemoChart'; @@ -19,10 +22,6 @@ import './interaction-dashboard-lab.css'; type Row = Record; -interface LinkPayload { - observationIds: string[]; -} - interface DashboardChart { id: string; fixture: InteractionDemoFixture; @@ -42,6 +41,7 @@ const focusCountries = new Set([ ]); type DashboardMetric = 'Life expectancy' | 'GDP per capita'; +const DASHBOARD_SELECTION_ID = 'dashboard-selection'; const semanticTypes = { Observation: 'Category', @@ -109,22 +109,6 @@ function dashboardFixture( } as InteractionDemoFixture; } -const linkedFocus: InteractionDef = { - id: 'dashboard-linked-focus', - eventSource: externalTrigger('dashboard-link'), - update(event, context) { - if (event.type !== 'external' || event.source !== 'dashboard-link') return null; - const selected = new Set(event.payload.observationIds); - if (selected.size === 0) return { ops: [{ op: 'reset' }] }; - const elements = context.available?.filter((element) => - element.records?.some((record) => recordObservationIds(record) - .some((id) => selected.has(id)))) ?? []; - return elements.length > 0 - ? { ops: [{ op: 'emphasize', elements, mode: 'replace', dimOpacity: 0.22 }] } - : { ops: [{ op: 'reset' }] }; - }, -}; - function buildDashboardCharts( snapshotYear: number, metric: DashboardMetric, @@ -159,7 +143,7 @@ function buildDashboardCharts( { logScale_x: true }, { width: 400, height: 230 }, ), - interaction: select({ id: 'dashboard-country-select' }), + interaction: select({ id: DASHBOARD_SELECTION_ID, dimOpacity: 0.22 }), }, { id: 'continents', @@ -172,7 +156,7 @@ function buildDashboardCharts( undefined, { width: 400, height: 230 }, ), - interaction: clickHighlight({ id: 'dashboard-continent-click' }), + interaction: clickHighlight({ id: DASHBOARD_SELECTION_ID, dimOpacity: 0.22 }), }, { id: 'trends', @@ -185,7 +169,11 @@ function buildDashboardCharts( { showPoints: true, logScale_y: metric === 'GDP per capita' }, { width: 400, height: 260 }, ), - interaction: clickHighlight({ id: 'dashboard-trend-click' }), + interaction: clickGroupHighlight({ + id: DASHBOARD_SELECTION_ID, + groupBy: 'Country', + dimOpacity: 0.22, + }), }, { id: 'history', @@ -198,7 +186,7 @@ function buildDashboardCharts( undefined, { width: 400, height: 260 }, ), - interaction: brushY({ id: 'dashboard-brush-y' }), + interaction: brushY({ id: DASHBOARD_SELECTION_ID, dimOpacity: 0.22 }), }, ]; } @@ -209,6 +197,14 @@ function observationIds(elements: readonly SemanticElement[]): string[] { .filter((id) => id !== 'undefined'); } +function linkedTargets(chartId: string, observationIds: readonly string[]): UpdateTarget[] { + const selected = new Set(observationIds); + const selectedRows = gapminderRows.filter((row) => selected.has(row.Observation)); + const field = chartId === 'continents' ? 'Continent' : 'Country'; + const values = new Set(selectedRows.map((row) => row[field])); + return [...values].map((value) => ({ select: { key: { [field]: value } } })); +} + function DashboardPanel({ chart, registerSurface, @@ -218,7 +214,7 @@ function DashboardPanel({ registerSurface: (id: string, surface: InteractiveChartSurface | null) => void; routeEvent: (detail: FlintInteractionEventDetail) => void; }) { - const interactions = useMemo(() => [chart.interaction, linkedFocus], [chart.interaction]); + const interactions = useMemo(() => [chart.interaction], [chart.interaction]); const handleSurface = useCallback( (surface: InteractiveChartSurface | null) => registerSurface(chart.id, surface), [chart.id, registerSurface], @@ -257,22 +253,24 @@ export function InteractionDashboardLab() { const dispatchSelection = useCallback((ids: string[], excludeId?: string) => { for (const [id, surface] of surfaces.current) { if (id === excludeId) continue; - surface.dispatch({ - type: 'external', - source: 'dashboard-link', + const targets = linkedTargets(id, ids); + void surface.applyUpdate({ + updateId: DASHBOARD_SELECTION_ID, phase: 'commit', - payload: { observationIds: ids }, + ops: targets.length > 0 + ? [emphasize({ targets, dimOpacity: 0.22 })] + : [resetUpdate()], }); } }, []); const routeEvent = useCallback((detail: FlintInteractionEventDetail) => { - if (detail.event.type !== 'semantic' || detail.event.phase !== 'commit') return; + if (detail.event.phase !== 'commit') return; const ids = observationIds(detail.event.target?.elements ?? []); const source = dashboardCharts.find((chart) => `dashboard-${chart.id}` === detail.chartId); dispatchSelection(ids, source?.id); setSelection(ids.length > 0 ? { ids } : null); - }, [dispatchSelection]); + }, [dashboardCharts, dispatchSelection]); const clearSelection = useCallback(() => { dispatchSelection([]); diff --git a/site/src/playground/click-focus-lab.css b/site/src/playground/click-focus-lab.css index 970dd4b8..bcfb2b81 100644 --- a/site/src/playground/click-focus-lab.css +++ b/site/src/playground/click-focus-lab.css @@ -97,7 +97,7 @@ .cf-probe { display: grid; - grid-template-rows: auto auto; + grid-template-rows: auto auto auto; min-width: 0; overflow: hidden; border: 1px solid #d8dde2; @@ -138,16 +138,17 @@ white-space: nowrap; } -.cf-status-works { +.cf-status-ready { color: #3f6b57; } -.cf-status-partial { +.cf-status-loading, +.cf-status-unsupported { color: #806327; } -.cf-status-none { - color: #737d86; +.cf-status-error { + color: #a33b36; } .cf-action-rail { @@ -210,6 +211,113 @@ place-items: center; } +.cf-probe-event { + min-width: 0; + min-height: 28px; + padding: 6px 12px; + border-top: 1px solid #edf0f2; + background: #fafbfc; + font-size: 10px; + line-height: 1.35; +} + +.cf-probe-event-summary { + display: flex; + align-items: center; + gap: 5px 0; + min-width: 0; + flex-wrap: wrap; +} + +.cf-probe-event-summary > * + *::before { + content: '·'; + margin: 0 8px; + color: #b0b7bd; + font-weight: 400; + text-decoration: none; +} + +.cf-probe-event strong { + flex: none; + font-weight: 600; +} + +.cf-probe-event span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cf-items-toggle { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 0; + border: 0; + padding: 0; + background: transparent; + color: #7a838b; + cursor: pointer; + font: inherit; + font-weight: 500; +} + +.cf-probe-event-data { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 8px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid #e7eaed; +} + +.cf-items-toggle:hover { + color: #46515a; + text-decoration: underline; + text-underline-offset: 2px; +} + +.cf-items-toggle svg { + transition: transform 120ms ease; +} + +.cf-items-toggle[aria-expanded='true'] svg { + transform: rotate(180deg); +} + +.cf-probe-event-resolved { + color: #46756a; +} + +.cf-probe-event-warning { + color: #8a6a2f; +} + +.cf-probe-event-value { + color: #68737c; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 9px; +} + +.cf-probe-event-geometry { + color: #68737c; + font-variant-numeric: tabular-nums; +} + +.cf-probe-event-items { + display: grid; + gap: 3px; + max-height: 128px; + margin: 0; + padding: 0 0 0 17px; + overflow: auto; + color: #68737c; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 9px; +} + @media (max-width: 820px) { .cf-grid { grid-template-columns: minmax(0, 1fr); diff --git a/site/src/playground/navigation-demo-data.ts b/site/src/playground/navigation-demo-data.ts new file mode 100644 index 00000000..9f3f9eb6 --- /dev/null +++ b/site/src/playground/navigation-demo-data.ts @@ -0,0 +1,101 @@ +import type { ChartAssemblyInput } from 'flint-chart'; +import { gapminderRows } from './gapminder-dashboard-data'; + +export interface NavigationDemoCase { + id: string; + input: ChartAssemblyInput; + navigationAxes: 'x' | 'y' | 'xy'; + expectation: string; +} + +const NAVIGATION_SIZE = { width: 520, height: 300 }; + +function isoDate(offset: number, hour = 0): string { + const date = new Date(Date.UTC(2025, 0, 1 + offset, hour)); + return date.toISOString(); +} + +const electricityDemand = Array.from({ length: 365 }, (_, day) => { + const weekday = (day + 3) % 7; + const annual = 4.8 * Math.cos((day - 18) * Math.PI * 2 / 365); + const weekly = weekday >= 5 ? -3.2 : 1.1; + const variation = 1.4 * Math.sin(day * 0.43) + 0.7 * Math.sin(day * 1.71); + return { + Date: isoDate(day), + 'Peak demand (GW)': Number((31 + annual + weekly + variation).toFixed(2)), + }; +}); + +const airQuality = Array.from({ length: 45 * 24 }, (_, hour) => { + const day = Math.floor(hour / 24); + const hourOfDay = hour % 24; + const commute = 13 * Math.exp(-Math.pow((hourOfDay - 8) / 2.2, 2)) + + 10 * Math.exp(-Math.pow((hourOfDay - 18) / 2.8, 2)); + const weather = 7 * Math.sin(day * 0.31) + 3 * Math.sin(hour * 0.17); + return { + Timestamp: isoDate(day, hourOfDay), + 'PM2.5 (ug/m3)': Number(Math.max(3, 16 + commute + weather).toFixed(1)), + }; +}); + +export const navigationDemoCases: readonly NavigationDemoCase[] = [ + { + id: 'navigate-electricity-demand', + navigationAxes: 'x', + input: { + data: { values: electricityDemand }, + semantic_types: { Date: 'Date', 'Peak demand (GW)': 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Daily electricity demand, 2025', + encodings: { x: { field: 'Date' }, y: { field: 'Peak demand (GW)' } }, + baseSize: NAVIGATION_SIZE, + }, + } as ChartAssemblyInput, + expectation: 'Zoom into seasonal and weekly demand structure, then pan across the year.', + }, + { + id: 'navigate-air-quality', + navigationAxes: 'x', + input: { + data: { values: airQuality }, + semantic_types: { Timestamp: 'Date', 'PM2.5 (ug/m3)': 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Hourly urban air quality, 45 days', + encodings: { x: { field: 'Timestamp' }, y: { field: 'PM2.5 (ug/m3)' } }, + baseSize: NAVIGATION_SIZE, + }, + } as ChartAssemblyInput, + expectation: 'Zoom from the full period into individual commute-hour peaks.', + }, + { + id: 'navigate-gapminder', + navigationAxes: 'xy', + input: { + data: { values: gapminderRows }, + semantic_types: { + Observation: 'Category', + Country: 'Country', + Continent: 'Category', + Year: 'Quantity', + Population: 'Quantity', + 'Life expectancy': 'Quantity', + 'GDP per capita': 'Quantity', + }, + chart_spec: { + chartType: 'Scatter Plot', + title: 'Income and life expectancy, 1952-2007', + encodings: { + x: { field: 'GDP per capita' }, + y: { field: 'Life expectancy' }, + color: { field: 'Continent' }, + detail: { field: 'Observation' }, + }, + chartProperties: { logScale_x: true }, + baseSize: NAVIGATION_SIZE, + }, + } as ChartAssemblyInput, + expectation: 'Zoom into dense regional clusters and pan across the income range.', + }, +]; From 4734276e5aa26103c54ba9ad7cb5ca2c5ff4edf3 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Fri, 28 Aug 2026 21:34:21 -0700 Subject: [PATCH 18/33] interaction design --- packages/flint-js/src/README.md | 28 +- .../src/core/interaction-contracts.ts | 176 ++++++ .../src/core/interaction-semantics.ts | 56 +- packages/flint-js/src/core/types.ts | 12 +- packages/flint-js/src/interactive/README.md | 425 ++++++------- .../src/interactive/canvas-interaction.ts | 74 +-- .../src/interactive/geometry/angular.ts | 2 +- .../interactive/geometry/coordinate-space.ts | 16 +- .../interactive/gestures/angular-region.ts | 2 +- .../interactive/gestures/cartesian-region.ts | 2 +- .../src/interactive/gestures/navigation.ts | 2 +- packages/flint-js/src/interactive/index.ts | 58 +- .../flint-js/src/interactive/interactions.ts | 181 +++--- .../{triggers => language}/events.ts | 76 ++- .../src/interactive/language/index.ts | 2 + .../src/interactive/language/updates.ts | 37 ++ .../{updates => presentation}/annotation.ts | 42 +- .../src/interactive/presets/README.md | 62 +- .../src/interactive/presets/angular-brush.ts | 10 +- .../flint-js/src/interactive/presets/brush.ts | 10 +- .../src/interactive/presets/click-annotate.ts | 23 +- .../presets/click-group-highlight.ts | 13 +- .../interactive/presets/click-highlight.ts | 8 +- .../src/interactive/presets/drag-reorder.ts | 9 +- .../src/interactive/presets/navigate.ts | 32 +- .../src/interactive/presets/select.ts | 10 +- .../flint-js/src/interactive/presets/utils.ts | 47 ++ .../src/interactive/selection-state.ts | 34 -- packages/flint-js/src/interactive/surface.ts | 65 +- .../{triggers/index.ts => triggers.ts} | 34 +- packages/flint-js/src/interactive/types.ts | 33 +- .../src/interactive/updates/emphasis.ts | 52 -- .../flint-js/src/interactive/updates/index.ts | 3 - .../src/interactive/updates/request.ts | 111 ---- packages/flint-js/src/vegalite/assemble.ts | 3 + .../src/vegalite/interactions/compile.ts | 89 ++- .../src/vegalite/interactions/contracts.ts | 7 +- .../interactions/gestures/navigation.ts | 7 +- .../vegalite/interactions/gestures/region.ts | 8 +- .../src/vegalite/interactions/hit-adapter.ts | 23 +- .../vegalite/interactions/navigation-scale.ts | 75 ++- .../presentation/annotation-leader-routing.ts | 277 +++++++++ .../presentation/annotation-overlay.ts | 261 ++++++-- .../presentation/annotation-text.ts | 0 .../presentation/drag-reorder-overlay.ts | 17 +- .../presentation/focus-overlay.ts | 21 +- .../src/vegalite/interactions/runtime.ts | 555 ++++++++++------- .../src/vegalite/interactive-focus.ts | 2 +- packages/flint-js/src/vegalite/interactive.ts | 39 +- .../flint-js/src/vegalite/templates/area.ts | 2 +- .../src/vegalite/templates/bar-table.ts | 12 +- .../flint-js/src/vegalite/templates/bar.ts | 2 +- .../flint-js/src/vegalite/templates/bullet.ts | 27 +- .../flint-js/src/vegalite/templates/bump.ts | 2 +- .../src/vegalite/templates/calendar.ts | 26 +- .../src/vegalite/templates/candlestick.ts | 2 +- .../vegalite/templates/connected-scatter.ts | 2 +- .../src/vegalite/templates/density.ts | 11 +- .../flint-js/src/vegalite/templates/ecdf.ts | 12 +- .../flint-js/src/vegalite/templates/gantt.ts | 2 +- .../flint-js/src/vegalite/templates/jitter.ts | 2 +- .../src/vegalite/templates/kpi-card.ts | 2 +- .../flint-js/src/vegalite/templates/line.ts | 2 +- .../src/vegalite/templates/lollipop.ts | 2 +- .../flint-js/src/vegalite/templates/map.ts | 11 +- .../flint-js/src/vegalite/templates/pie.ts | 4 +- .../flint-js/src/vegalite/templates/radar.ts | 2 +- .../src/vegalite/templates/range-area.ts | 2 +- .../flint-js/src/vegalite/templates/rose.ts | 4 +- .../src/vegalite/templates/scatter.ts | 2 +- .../flint-js/src/vegalite/templates/slope.ts | 2 +- .../src/vegalite/templates/sparkline.ts | 2 +- .../flint-js/src/vegalite/templates/violin.ts | 11 +- .../src/vegalite/templates/waterfall.ts | 6 +- packages/flint-js/tests/interactions.test.ts | 556 ++++++++++++------ .../tests/semantic-interactions.test.ts | 347 ++++++++++- site/src/main.tsx | 4 + site/src/playground/AnnotationLab.tsx | 270 +++++++++ .../playground/ArchitectureIllustrations.tsx | 20 + site/src/playground/ClickFocusLab.tsx | 34 +- .../CompilationProcessIllustration.tsx | 2 +- site/src/playground/ExternalToChartLab.tsx | 28 +- .../src/playground/IllustrationPageHeader.tsx | 23 + site/src/playground/Illustrations.tsx | 7 +- .../InteractionArchitectureIllustration.tsx | 257 ++++++++ .../playground/InteractionDashboardLab.tsx | 33 +- site/src/playground/PlaygroundShell.tsx | 13 +- site/src/playground/annotation-lab.css | 121 ++++ .../playground/architecture-illustrations.css | 103 ++++ site/src/playground/click-focus-lab.css | 24 +- site/src/playground/playground.css | 36 +- 91 files changed, 3660 insertions(+), 1503 deletions(-) create mode 100644 packages/flint-js/src/core/interaction-contracts.ts rename packages/flint-js/src/interactive/{triggers => language}/events.ts (58%) create mode 100644 packages/flint-js/src/interactive/language/index.ts create mode 100644 packages/flint-js/src/interactive/language/updates.ts rename packages/flint-js/src/interactive/{updates => presentation}/annotation.ts (82%) create mode 100644 packages/flint-js/src/interactive/presets/utils.ts delete mode 100644 packages/flint-js/src/interactive/selection-state.ts rename packages/flint-js/src/interactive/{triggers/index.ts => triggers.ts} (71%) delete mode 100644 packages/flint-js/src/interactive/updates/emphasis.ts delete mode 100644 packages/flint-js/src/interactive/updates/index.ts delete mode 100644 packages/flint-js/src/interactive/updates/request.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/annotation-leader-routing.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/annotation-text.ts create mode 100644 site/src/playground/AnnotationLab.tsx create mode 100644 site/src/playground/ArchitectureIllustrations.tsx create mode 100644 site/src/playground/IllustrationPageHeader.tsx create mode 100644 site/src/playground/InteractionArchitectureIllustration.tsx create mode 100644 site/src/playground/annotation-lab.css create mode 100644 site/src/playground/architecture-illustrations.css diff --git a/packages/flint-js/src/README.md b/packages/flint-js/src/README.md index 523b4b0a..1347202a 100644 --- a/packages/flint-js/src/README.md +++ b/packages/flint-js/src/README.md @@ -165,10 +165,10 @@ Each backend has its own assembly function. All accept the same ### Interactive surface -Interactive renderers are opt-in and shipped separately from the static assembly entry point. The surface owns viewport state, accessible scroll controls, and renderer lifecycle; the caller supplies only a container and chart input. +Interactive renderers are opt-in and shipped separately from the static assembly entry point. The surface owns interaction coordination, viewport state, accessible scroll controls, and renderer lifecycle; the caller supplies only a container and chart input. ```ts -import { buildInteractiveChart } from 'flint-chart/interactive'; +import { buildInteractiveChart, clickHighlight, externalInteraction } from 'flint-chart/interactive'; const surface = buildInteractiveChart( container, @@ -176,7 +176,7 @@ const surface = buildInteractiveChart( { backend: 'vegalite', renderer: 'canvas', - focusOnClick: true, + interactions: [clickHighlight()], }, ); @@ -184,7 +184,27 @@ await surface.ready; // Later: surface.destroy(); ``` -The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite discrete marks also enable local click focus by default: click selects, Shift/Ctrl/Meta-click toggles marks, and clicking empty plot space clears. Set `focusOnClick: false` to disable it. Other backends currently ignore this option. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. +Application input is transport-neutral. Register an external handler with the chart, +then dispatch payloads by interaction ID from React state, a DOM listener, a WebSocket, +or another chart: + +```ts +const countryPicker = externalInteraction<{ country: string }>({ + id: 'country-picker', + handle: ({ country }) => ({ + id: 'country-selection', + ops: [{ + op: 'set-presentation', + targets: [{ select: { key: { Country: country } } }], + value: { state: 'emphasized' }, + }], + }), +}); + +await surface.dispatch('country-picker', { country: 'Japan' }); +``` + +The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite interactions are enabled explicitly through `interactions`; `clickHighlight()` selects marks, Shift/Ctrl/Meta-click toggles marks, and clicking empty plot space clears. Other backends currently reject semantic interactions. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`; the surface invokes external handlers, while adapters expose interaction context and apply renderer-neutral updates. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. ### Input types diff --git a/packages/flint-js/src/core/interaction-contracts.ts b/packages/flint-js/src/core/interaction-contracts.ts new file mode 100644 index 00000000..37802289 --- /dev/null +++ b/packages/flint-js/src/core/interaction-contracts.ts @@ -0,0 +1,176 @@ +export interface RenderHit { + datum: Record; + endDatum?: Record; + source: 'mark' | 'legend-item'; + markType?: string; + markName?: string; + layerRole?: string; +} + +export interface SemanticElement { + key: Record; + value?: Record; + records?: readonly Record[]; +} + +export interface SemanticTarget { + visual: { + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle'; + role: string; + }; + elements: readonly SemanticElement[]; +} + +export interface SemanticResolveEvent { + gesture: 'click' | 'hover' | 'rectangle' | 'angular'; + role: string; + hits: readonly RenderHit[]; + legendValue?: unknown; + legendField?: string; +} + +export interface SemanticResolveContext { + allHits: readonly RenderHit[]; + keyField: string; + categoryField?: string; + seriesField?: string; +} + +export type ChartInteractionResolver = ( + event: SemanticResolveEvent, + context: SemanticResolveContext, +) => SemanticTarget | null; + +export type UpdateDomain = readonly [unknown, unknown]; + +export interface SemanticTargetRef { + visual: SemanticTarget['visual']; + elements: readonly SemanticElement[]; +} + +export interface SemanticTargetSelector { + select: { + key: Record; + visual?: Partial; + }; +} + +export type UpdateTarget = SemanticTargetRef | SemanticTargetSelector; + +export type AnnotationConnection = + | 'center' + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'value-end' + | 'value-side' + | 'segment-midpoint' + | 'radial-midpoint' + | 'outer-radial'; + +export interface AnnotationConnectorAnchor { + role: string; + connection: AnnotationConnection; + valueAxis?: 'x' | 'y'; +} + +export interface AnnotationCandidate { + connection: AnnotationConnection; + valueAxis?: 'x' | 'y'; + crossSide?: 'start' | 'end'; + valueInset?: number; + anglePreference?: 'normal' | 'oblique'; + textAlign?: 'left' | 'center' | 'right'; + connector?: 'line' | 'none'; + maxWidth?: number; + maxDistance?: number; + priority?: number; + connectorAnchors?: readonly AnnotationConnectorAnchor[]; +} + +export interface AnnotationSpec { + text?: string; + candidates?: readonly AnnotationCandidate[]; + subject?: Partial; +} + +export interface PresentationSpec { + visible?: boolean; + opacity?: number; + stroke?: string; + strokeWidth?: number; + state?: 'normal' | 'focused' | 'emphasized' | 'muted'; + mutedOpacity?: number; +} + +export type ChartUpdateOp = + | { + op: 'set-presentation'; + targets: readonly UpdateTarget[]; + value: PresentationSpec; + } + | { + op: 'set-annotation'; + target: UpdateTarget; + value: AnnotationSpec | null; + } + | { + op: 'set-viewport'; + axes: 'x' | 'y' | 'xy'; + value: { x?: UpdateDomain; y?: UpdateDomain }; + } + | { + op: 'set-order'; + scope: 'category' | 'series' | 'facet'; + field: string; + values: readonly unknown[]; + }; + +export interface ChartUpdate { + id: string; + ops: readonly ChartUpdateOp[]; +} + +export interface NavigationDomainGuard { + minVisibleFraction: number; + maxVisibleFraction: number; + overscrollFraction: number; +} + +export interface NavigationRequest { + type?: 'navigation'; + phase: 'start' | 'preview' | 'commit' | 'cancel'; + operation: 'pan' | 'zoom' | 'reset'; + axes: 'x' | 'y' | 'xy'; + delta?: { x: number; y: number }; + factor?: number; + anchor?: { x: number; y: number }; +} + +export type NavigationUpdate = Extract; + +export interface InteractionContext { + readonly chartType: string; + readonly selected: readonly SemanticElement[]; + readonly available?: readonly SemanticElement[]; + readonly resolveGroupValue?: (element: SemanticElement) => unknown; + readonly resolveNavigation?: ( + request: NavigationRequest, + guard: NavigationDomainGuard, + ) => NavigationUpdate | null; + readonly categoryField?: string; + readonly seriesField?: string; + readonly categoryAxis?: 'x' | 'y'; + readonly categoryOrder?: readonly unknown[]; + readonly reorderAxes?: readonly { + axis: 'x' | 'y'; + field: string; + order: readonly unknown[]; + }[]; +} + +export type ChartUpdatePresenter = ( + update: ChartUpdate, + context: InteractionContext, +) => ChartUpdate; \ No newline at end of file diff --git a/packages/flint-js/src/core/interaction-semantics.ts b/packages/flint-js/src/core/interaction-semantics.ts index 488069ae..a1b993fc 100644 --- a/packages/flint-js/src/core/interaction-semantics.ts +++ b/packages/flint-js/src/core/interaction-semantics.ts @@ -1,25 +1,19 @@ -export interface RenderHit { - datum: Record; - endDatum?: Record; - source: 'mark' | 'legend-item'; - markType?: string; - markName?: string; - layerRole?: string; -} - -export interface SemanticElement { - key: Record; - value?: Record; - records?: readonly Record[]; -} +import type { + RenderHit, + SemanticElement, + SemanticResolveContext, + SemanticResolveEvent, + SemanticTarget, +} from './interaction-contracts'; -export interface SemanticTarget { - visual: { - kind: 'mark' | 'path' | 'region' | 'widget' | 'handle'; - role: string; - }; - elements: readonly SemanticElement[]; -} +export type { + ChartInteractionResolver, + RenderHit, + SemanticElement, + SemanticResolveContext, + SemanticResolveEvent, + SemanticTarget, +} from './interaction-contracts'; export type SemanticVisualFamily = 'legend' | 'axis' | 'facet' | 'annotation' | 'element'; @@ -31,26 +25,6 @@ export function semanticVisualFamily(role: string | undefined): SemanticVisualFa return 'element'; } -export interface SemanticResolveEvent { - gesture: 'click' | 'hover' | 'rectangle' | 'angular'; - role: string; - hits: readonly RenderHit[]; - legendValue?: unknown; - legendField?: string; -} - -export interface SemanticResolveContext { - allHits: readonly RenderHit[]; - keyField: string; - categoryField?: string; - seriesField?: string; -} - -export type ChartInteractionResolver = ( - event: SemanticResolveEvent, - context: SemanticResolveContext, -) => SemanticTarget | null; - /** Neutral hover ink that blends with the mark instead of reading as a hard outline. */ export const MUTED_HOVER_STROKE = 'rgba(71, 82, 92, 0.58)'; export const MUTED_HOVER_FILL = '#eef1f3'; diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 1455f659..9b8b2556 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -910,6 +910,7 @@ export interface ChartTemplateDef { reorder?: false | { axes?: readonly ('x' | 'y')[]; includeConnectiveMarks?: boolean; + markTypes?: readonly string[]; }; /** @@ -931,10 +932,13 @@ export interface ChartTemplateDef { fields: string[]; categoryField?: string; seriesField?: string; - reorderAxis?: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean }; - reorderAxes?: readonly { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean }[]; + resolveGroupValue?: (element: import('./interaction-contracts').SemanticElement) => unknown; + reorderAxis?: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }; + reorderAxes?: readonly { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }[]; legendFields?: Record; selectableMarks: string[]; + /** Backend marktype to anchor annotations to when one key matches several marks. */ + annotationMarkType?: string; supportedRegionGestures?: ('cartesian' | 'angular')[]; renderHoverStyles?: Record; - resolve: import('./interaction-semantics').ChartInteractionResolver; - presentUpdate: import('../interactive/interactions').ChartUpdatePresenter; + resolve: import('./interaction-contracts').ChartInteractionResolver; + presentUpdate: import('./interaction-contracts').ChartUpdatePresenter; }; /** diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index ebfc771f..532035cf 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -2,85 +2,90 @@ ## Status -The trigger, semantic-resolution, output-only observer, preset, and internal +The canvas-acquisition, semantic-resolution, external-dispatch, preset, and unified `ChartUpdate` paths are implemented. Vega-Lite emits public `CanvasInteractionEvent` -payloads and supports `surface.applyUpdate(ChartUpdateRequest)`. Domain geometry, -appearance/visibility/focus operations, and semantic interaction runtimes for other -backends remain planned. +payloads and renders declarative updates. Canvas gesture state owns preview, commit, +and cancel behavior; external payloads invoke their bound handlers directly. Complete +presentation-property coverage and semantic interaction runtimes for other backends +remain planned. ## Goal Separate interaction input, semantic resolution, handling, chart updates, and renderer presentation so that: -- canvas gestures and external application events can drive the same update language; +- canvas gestures and external application payloads invoke different handler contracts + that produce the same update language; - resolved canvas interactions are useful whether or not a built-in update runs; - chart resolution reports only the physical semantic unit that produced an internal event; - an interaction handler decides what semantic cohort to act on, including chart-specific behavior; - chart definitions decide how semantic updates should be presented; - runtimes apply presented updates mechanically; - resolved semantic events are emitted to the host application with stable chart identity; -- applications can address semantic elements by stable ref or key selector and apply - renderer-neutral updates without understanding renderer structure. +- applications can bind transport-neutral payload handlers without understanding + renderer structure; +- precomputed renderer-neutral updates remain directly applicable as chart state. ## Developer Quick Start -Configure an output-only observer by providing an `InteractionDef` without `handle`: +Configure a canvas interaction with a reusable handler: ```ts import { buildInteractiveChart, clickTrigger, - emphasize, - type FlintInteractionEventDetail, - type InteractionDef, + externalInteraction, + type CanvasInteractionDef, } from 'flint-chart/interactive'; -const clickObserver: InteractionDef = { - id: 'report-clicks', +const selectCountry: CanvasInteractionDef = { + id: 'select-country', eventSource: clickTrigger, + handle: (event) => event.target ? { + id: 'country-selection', + ops: [{ + op: 'set-presentation', + targets: [event.target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + } : null, }; const surface = buildInteractiveChart(container, input, { backend: 'vegalite', - interactions: [clickObserver], + interactions: [selectCountry], }); await surface.ready; - -surface.element.addEventListener('flint-interaction', (rawEvent) => { - const { event } = (rawEvent as CustomEvent).detail; - if (event.action !== 'click-element' || !event.target) return; - - void surface.applyUpdate({ - updateId: 'application-selection', - phase: 'commit', - ops: [emphasize({ targets: [event.target] })], - }); -}); ``` -An external application can select by ChartDef-declared semantic fields without first -receiving an event: +Bind application input independently of its transport: ```ts -const result = await surface.applyUpdate({ - updateId: 'story-selection', - phase: 'commit', - ops: [emphasize({ - targets: [{ - select: { - key: { Country: 'Japan' }, - visual: { kind: 'mark' }, - }, +const countryPicker = externalInteraction<{ country: string; selected: boolean }>({ + id: 'country-picker', + handle: ({ country, selected }) => ({ + id: 'country-selection', + ops: [{ + op: 'set-presentation', + targets: selected ? [{ select: { key: { Country: country } } }] : [], + value: { state: selected ? 'emphasized' : 'normal' }, }], - })], + }), +}); + +const surface = buildInteractiveChart(container, input, { + backend: 'vegalite', + interactions: [countryPicker], }); -if (result.status !== 'applied') { +const result = await surface.dispatch('country-picker', { + country: 'Japan', + selected: true, +}); + +if (result && result.status !== 'applied') { console.warn(result.unresolvedTargets, result.unsupportedOps); } - -await surface.clearUpdate('story-selection'); ``` The selector is equality-only and each field must be declared by the compiled ChartDef. @@ -93,16 +98,17 @@ The surface API is intentionally small: | API | Purpose | |---|---| | `flint-interaction` event | Receive resolved canvas actions | -| `applyUpdate(request)` | Resolve and apply renderer-neutral operations | -| `clearUpdate(updateId)` | Remove one scoped emphasis contribution | -| `dispatch(externalEvent)` | Deprecated; reports migration guidance to use `applyUpdate()` | +| `applyUpdate(update)` | Apply precomputed retained chart state by ID | +| `setUpdates(updates)` | Replace the retained update collection | +| `clearUpdate(id)` | Remove one retained update | +| `dispatch(interactionId, payload)` | Invoke an external handler and report its update result | | `destroy()` | Remove listeners, renderer state, and DOM | ## Cross-chart routing Emission is universal and distributed: `flint-interaction` bubbles from every configured -chart interaction. Acceptance is explicit: the application chooses a destination chart -by calling `applyUpdate()` on that chart's surface. +canvas interaction. Acceptance is explicit: each destination registers an external +interaction, and the application chooses destinations by dispatching its semantic payload. ```ts dashboard.addEventListener('flint-interaction', (nativeEvent) => { @@ -111,21 +117,22 @@ dashboard.addEventListener('flint-interaction', (nativeEvent) => { for (const [chartId, surface] of dashboardSurfaces) { if (chartId === detail.chartId) continue; - void surface.applyUpdate(requestFor(chartId, selection)); + void surface.dispatch('linked-selection', { selection }); } }); ``` Charts do not automatically consume events from neighboring charts. The dashboard, story, or editor owns its cross-chart topology and semantic mapping. This coordinator is -scoped to that composition, not a global singleton. Reuse one `updateId` across local -and routed updates when each new interaction should replace the prior linked selection. +scoped to that composition, not a global singleton. Each destination's +`externalInteraction({ id: 'linked-selection', handle })` maps the shared payload to +targets meaningful for that chart. ## Pipeline ```mermaid flowchart LR - S[Interaction eventSource] --> B[Backend mount] + S[Canvas eventSource] --> B[Backend mount] A[Raw browser or renderer event] --> B B --> C[Gesture recognizer] C --> N[Navigation event] @@ -135,9 +142,12 @@ flowchart LR D --> E[Semantic event] E --> F[Interaction coordinator] F --> G[flint-interaction transport] - F -. optional .-> H[Interaction handler] - A2[Application ChartUpdateRequest] --> U[Update target resolution] - H --> I[ChartUpdateRequest] + F -. optional .-> H[Canvas handler] + X[External payload] --> D2[Surface dispatch by interaction ID] + D2 --> EH[External handler] + A2[Precomputed ChartUpdate] --> U[Update target resolution] + H --> I[ChartUpdate] + EH --> I I --> U U --> J[ChartDef presentUpdate] J --> K[Renderer runtime] @@ -152,7 +162,7 @@ The normative ownership boundary is: | 3. Resolve physical hits | Backend hit adapter | Normalized geometry and renderer state | `RenderHit[]` | Chart-type meaning or handler decisions | | 4. Resolve semantics | ChartDef resolver | Gesture context and `RenderHit[]` | Physical `SemanticTarget` | Handler decisions or cohort expansion | | 5. Coordinate | Interaction coordinator | Resolved semantic or navigation event | Canonical outbound event and optional handler invocation | Chart-specific semantic meaning | -| 6. Decide update | Optional handler or application | `CanvasInteractionEvent` | `ChartUpdateRequest` | Renderer-specific presentation | +| 6. Decide update | Bound canvas or external handler | Resolved canvas event or opaque external payload | `ChartUpdate` | Renderer-specific presentation | | 7. Resolve update | Coordinator + compiled semantic index | Public refs/selectors | Current semantic elements | Product relationships or approximate matching | | 8. Present update | ChartDef `presentUpdate` | `ChartUpdate` | Chart-specific presented update | Renderer mutation | | 9. Apply update | Renderer runtime | Presented update | Renderer state | Semantic inference or handler decisions | @@ -174,7 +184,7 @@ sequenceDiagram participant Hits as Backend hit adapter participant ChartDef as ChartDef.resolve participant Coordinator - participant Host as External host + participant Host as Host observer participant Handler as Interaction handle participant Present as ChartDef.presentUpdate participant Runtime as Renderer runtime @@ -189,32 +199,34 @@ sequenceDiagram Coordinator-->>Host: flint-interaction semantic event opt configured handler Coordinator->>Handler: resolved event - Handler-->>Coordinator: ChartUpdateRequest - Coordinator->>Coordinator: resolve request targets + Handler-->>Coordinator: ChartUpdate + Coordinator->>Coordinator: resolve update targets Coordinator->>Present: ChartUpdate Present-->>Coordinator: presented update Coordinator->>Runtime: apply presented update end ``` -When an application already knows the desired canvas update, the public API is more -direct: +An external payload follows a shorter input path while sharing update processing: ```mermaid sequenceDiagram participant Host as Application participant Surface + participant Handler as Bound external handler participant Index as Compiled semantic index participant Present as ChartDef.presentUpdate participant Runtime as Renderer runtime - Host->>Surface: applyUpdate(ChartUpdateRequest) + Host->>Surface: dispatch(interactionId, payload) + Surface->>Handler: payload + InteractionContext + Handler-->>Surface: ChartUpdate or null Surface->>Index: resolve refs and key selectors Index-->>Surface: current semantic elements Surface->>Present: ChartUpdate Present-->>Surface: presented update Surface->>Runtime: apply presented update - Surface-->>Host: ChartUpdateResult + Surface-->>Host: ChartUpdateResult or null ``` ## Backward Semantic Resolution @@ -306,19 +318,13 @@ inspect renderer geometry to rediscover meaning. ## Acquisition Events -Backends normalize physical input into these internal acquisition events. The external -variant remains only for the deprecated `dispatch()` transport; new application input -starts with `ChartUpdateRequest` instead. +Backends normalize physical input into these internal acquisition events. External +payloads do not enter this acquisition language because callers have already identified +the interaction and its semantic payload. ```ts type InteractionPhase = 'start' | 'preview' | 'commit' | 'cancel'; -type NormalizedInteractionEvent = - | ElementInteractionEvent - | RegionInteractionEvent - | NavigationInteractionEvent - | ExternalInteractionEvent; - interface ElementInteractionEvent { type: 'element'; phase: 'preview' | 'commit' | 'cancel'; @@ -346,15 +352,9 @@ interface NavigationInteractionEvent { anchor?: PlotPoint; // plot fractions } -interface ExternalInteractionEvent { - type: 'external'; - source: string; - phase: InteractionPhase; - payload: TPayload; -} ``` -`Element` and `Region` describe physical chart input at the geometry level. They may contain coordinates, region geometry, rendered mark metadata, and data records in `RenderHit[]`, but they do not claim semantic meaning. `Navigation` describes a viewport transform in plot fractions and likewise carries no semantic target. `External` is the legacy application transport and does not enter the canonical handler contract. +`Element` and `Region` describe physical chart input at the geometry level. They may contain coordinates, region geometry, rendered mark metadata, and data records in `RenderHit[]`, but they do not claim semantic meaning. `Navigation` describes a viewport transform in plot fractions and likewise carries no semantic target. External payloads bypass acquisition events and enter through their bound external handler. ## Semantic Resolution @@ -416,57 +416,71 @@ frames, but they are not reduced to commit-only output. ## Interaction Handlers -An interaction handler consumes a canonical resolved canvas event and may return a -`ChartUpdateRequest`. Event acquisition and outbound emission do not require a handler. +Canvas and external definitions bind different inputs to the same output language. +A canvas handler consumes a resolved canvas event; an external handler consumes its +application-defined payload. Both may return one `ChartUpdate`. ```ts -interface InteractionDef { +interface CanvasInteractionDef { readonly id: string; readonly eventSource: InteractionEventSource; handle?( event: CanvasInteractionEvent, context: InteractionContext, - ): ChartUpdateRequest | null; + ): ChartUpdate | null; +} + +interface ExternalInteractionDef { + readonly id: string; + readonly external: true; + handle(payload: TPayload, context: InteractionContext): ChartUpdate | null; } + +type InteractionDef = CanvasInteractionDef | ExternalInteractionDef; ``` -An interaction definition has two declarative halves: +Canvas definitions have two declarative halves: 1. `eventSource` declares **what input to capture and how to interpret it physically**. The same native `pointerdown -> pointermove -> pointerup` stream becomes a free rectangle for `select()`, an axis-constrained interval for `brushX()` or `brushY()`, and an angular sector for `brushAngle()`. -2. Optional `handle()` declares **what update request to produce** after normalization. +2. Optional `handle()` declares **what update JSON to produce** after normalization. Target-bearing events first pass through ChartDef semantic resolution. The handler consumes the same `CanvasInteractionEvent` emitted to applications and returns a - `ChartUpdateRequest` containing renderer-neutral operations such as - `emphasize`, `annotate`, `navigate-viewport`, or `reset`. + `ChartUpdate` containing renderer-neutral `set-presentation`, `set-annotation`, + `set-viewport`, or `set-order` operations. The backend mount reads `eventSource`; it does not infer a gesture from pointer motion. It installs the required native listeners, supplies renderer coordinates and hit testing, and runs the recognizer requested by the interaction. This keeps an identical drag stream deterministic and author-controlled. Chart-specific action processing belongs in the handler. For example, ranged-dot region targets -are expanded to complete category units before producing an `emphasize` request. Direct ranged-dot +are expanded to complete category units before producing a `set-presentation` update. Direct ranged-dot clicks already resolve to the complete dumbbell in the owning ChartDef. -The coordinator always emits the resolved event and invokes `handle()` only when it is -present. Returned requests enter the same `applyUpdate()` path used by applications. -This creates three public layers: +The coordinator always emits a resolved canvas event and invokes `handle()` only when +present. External dispatch does not emit or synthesize a canvas event. Updates returned +by either handler enter the same target-resolution, presentation, and renderer pipeline. +This creates four public layers: 1. predefined observers that acquire and resolve common canvas actions; -2. update factories that construct renderer-neutral `ChartUpdateRequestOp` values; -3. a small set of presets joining the most common action-to-update pairs. +2. external definitions binding application payloads to update policies; +3. direct renderer-neutral `ChartUpdateOp` JSON for precomputed state; +4. presets joining common canvas actions to updates. -Presets compose predefined triggers from `interactive/triggers/` with an optional handler. +Presets compose predefined triggers from `interactive/triggers.ts` with an optional handler. They refer to reusable descriptors such as `clickTrigger` and `rectangleTrigger()` rather than defining event acquisition inline. They are convenience APIs, not architectural primitives or the primary extensibility model. ## Triggers -`interactive/triggers/` owns event-source contracts, built-in trigger descriptors, and the shared interaction-event vocabulary. A backend owns renderer-specific event normalization and realizes these descriptors against its native event and coordinate systems. +`interactive/triggers.ts` owns event-source contracts and built-in trigger descriptors. +`interactive/language/events.ts` owns the shared interaction-event vocabulary. A backend +owns renderer-specific event normalization and realizes trigger descriptors against its +native event and coordinate systems. Type colocation does not change production ownership: chart triggers produce Element, Region, and Navigation normalized events. The coordinator produces `SemanticInteractionEvent` only after ChartDef resolution. Its type lives in `events.ts` -so the full event vocabulary has one definition site. External event types remain only -for the deprecated dispatch transport. +so the full canvas event vocabulary has one definition site. External payloads bypass +this acquisition vocabulary and enter through their bound external interaction handler. Flint provides common triggers for element activation, hover preview, rectangle drag, and navigation: @@ -485,7 +499,8 @@ navigationTrigger() `navigate()` combines drag pan, wheel zoom, and reset as one viewport handler. ChartDefs opt in explicitly with `navigation.axes`; assembly then intersects that capability with resolved quantitative or temporal x/y encodings. An explicitly requested unsupported axis is an error. With `axes: 'available'`, categorical axes are omitted automatically. -The gesture reports incremental pan deltas and zoom anchors as plot fractions. The preset adds percentage-based domain guards to `navigate-viewport`: +The gesture reports incremental pan deltas and zoom anchors as plot fractions. The +renderer reduces them to absolute `set-viewport` domains using percentage-based guards: ```ts navigate({ @@ -557,7 +572,7 @@ It must not reinterpret an `x` brush as a free selection, choose angular behavio - renderer-neutral pointer-session state such as angular sweep accumulation and interval transitions; - Cartesian and angular gesture math; - renderer-neutral regions such as `PlotRect`, `PlotPolygon`, and `PlotAngularSector`; -- presets that translate resolved semantic targets into `ChartUpdateRequest` operations. +- presets that translate resolved semantic targets into `ChartUpdate` JSON. A backend owns: @@ -583,8 +598,8 @@ flowchart TD Gesture["Rectangle, interval, or angular sector
    Owner: shared gesture recognizer"] --> Hits Hits["Renderer geometry -> RenderHit[]
    Owner: backend hit adapter"] --> Resolve Resolve["RenderHit[] -> SemanticTarget
    Owner: ChartDef resolver"] --> Handler - Handler["CanvasInteractionEvent -> ChartUpdateRequest
    Owner: interaction handler"] --> ResolveUpdate - ResolveUpdate["Targets -> ChartUpdate
    Owner: coordinator"] --> Present + Handler["CanvasInteractionEvent -> ChartUpdate
    Owner: interaction handler"] --> ResolveUpdate + ResolveUpdate["Selectors -> resolved targets
    Owner: coordinator"] --> Present Present["Representation-aware update
    Owner: ChartDef presenter"] --> Apply Apply["Stores and visual overlays
    Owner: backend runtime"] ``` @@ -603,7 +618,8 @@ interactive/ cartesian-region.ts # Cartesian projection and interval transitions navigation.ts # pan sessions and wheel normalization presets/ # source and handler combinations - triggers/ # renderer-neutral source descriptors and events + triggers.ts # renderer-neutral source descriptors + language/ # interaction event and chart update contracts vegalite/interactions/ contracts.ts # Vega interaction plan contracts @@ -626,42 +642,81 @@ A custom source may register listeners and emit normalized events. Renderer-spec ## Update Language -Presets and applications produce the public `ChartUpdateRequest`. It represents -renderer-neutral intent and may address semantic elements with exact event-derived refs -or unresolved key selectors. It also carries application-facing lifecycle identity: +Presets and applications produce one renderer-neutral `ChartUpdate` format. There is no +separate request operator, resolved operator, or renderer-only operator language: ```ts -interface ChartUpdateRequest { - updateId: string; - phase?: InteractionPhase; - transactionId?: string; - ops: readonly ChartUpdateRequestOp[]; +interface ChartUpdate { + id: string; + ops: readonly ChartUpdateOp[]; } + +type ChartUpdateOp = + | { op: 'set-presentation'; targets: readonly UpdateTarget[]; value: PresentationSpec } + | { op: 'set-annotation'; target: UpdateTarget; value: AnnotationSpec | null } + | { op: 'set-viewport'; axes: 'x' | 'y' | 'xy'; value: { x?: Domain; y?: Domain } } + | { + op: 'set-order'; + scope: 'category' | 'series' | 'facet'; + field: string; + values: readonly unknown[]; + }; +``` + +Operators are plain JSON. Presets and applications construct object literals directly; +there are no trivial operator factory functions. + +The renderer has two inputs: + +```text +chart + updates -> rendered chart ``` -The chart surface resolves that request into an internal `ChartUpdate`. These updates -contain current `SemanticElement` objects rather than selectors and may contain lowered -presentation operations that are not valid public input: +`chart` is the immutable base specification. `updates` describes what the chart should +display and is suitable for serialization and static composition. The renderer does +not know whether an interaction is previewing, committing, or reverting an update. + +Interaction state owns that lifecycle. During a gesture, the interaction controller may +compose its private preview over retained updates before rendering. Commit retains the +result; cancel drops the preview and renders the retained collection again. Preview +state is not part of the chart API or update language. + +The surface can replace the retained collection atomically: ```ts -interface ChartUpdate { - phase?: InteractionPhase; - ops: readonly UpdateOp[]; -} +await surface.setUpdates(updates); ``` -```text -ChartUpdateRequest - -> resolve refs and selectors against the destination chart - -> ChartUpdate with current SemanticElement objects - -> ChartDef presentUpdate lowering - -> renderer application +`applyUpdate(update, { composition: 'auto' })` replaces one retained update by ID, while +`clearUpdate(id)` removes that retained update. The default `auto` policy composes all +retained updates in insertion order: presentation selections accumulate, while later +annotation, viewport, and order operations take precedence. The policy is explicit so +future composition modes can extend the API without changing this default behavior. + +Relative gesture data is not update state. Pan deltas, zoom factors, toggle modifiers, +and drag positions are reduced by interaction state into absolute `set-viewport`, +`set-presentation`, or `set-order` values. Cancelling a gesture requires no inverse +chart command: the interaction drops its preview and sends the prior effective updates. + +Targets may be exact event-derived refs or unresolved equality selectors: + +```ts +type UpdateTarget = + | SemanticTargetRef + | { + select: { + key: Record; + visual?: Partial; + }; + }; ``` -For example, a public `annotate` request names only an `UpdateTarget` and text. It does -not include the click point or prescribe layout. After resolution, `ChartUpdate` names -one `SemanticElement`; ChartDef lowers it to `render-annotation` with a small candidate -space describing meaningful component connection sites: +Selectors accept only ChartDef-declared semantic fields. At runtime they are resolved to +the same `SemanticTargetRef` shape; the surrounding `ChartUpdateOp` does not change. + +ChartDefs may enrich an operator without changing its kind. For example, +`set-annotation` can begin with text only and gain meaningful connection candidates in +its `value`: ```ts interface AnnotationCandidate { @@ -679,114 +734,70 @@ interface AnnotationCandidate { } ``` -ChartDefs may offer multiple connection sites for one component; for example, a bar can -offer its value end and side ports inset from that focal end. A lollipop can require -oblique routes so its leader does not read as an extension of the stem. The runtime resolves those sites -against current rendered geometry, measures wrapped text at a bounded set of widths, -and samples nearby positions around each site up to `maxDistance`. It scores complete -arrangements for canvas and plot overflow, source and mark collision, connector length, -outward direction, wrapping, and ChartDef priority. It renders the best candidate with -a compact text box and a muted straight leader. The leader is the explicit relationship -between the independently placed annotation and its source; proximity shortens it but does -not remove it. The attachment is expressed by the line itself, without a permanent endpoint -dot, and the line stops outside the text glyphs. ChartDefs may explicitly request no connector -for an inline label. A leader -must leave its connection site through the outward half-plane; it -may touch the source at that site but must not pass through the source or another mark. -The planner chooses a different site or text position instead of routing through data. -It prefers the site's exact outward normal and searches only modest 30° and 60° deviations; -layered marks attach to the compact value mark rather than a long supporting stem or rule. -Leader direction also selects one of four text-box ports: vertical leaders attach at the -horizontal center with centered text, while horizontal leaders attach at the facing side -with left- or right-aligned text. This two-phase design keeps meaningful -connection choices compiler-owned while leaving canvas- and font-dependent math at -runtime. Keeping request and render types separate prevents applications and presets -from depending on current renderer state or internal presentation details. - -`preview` is transient, `commit` changes persistent state, and `cancel` describes -abandoned transient work. Emphasis selections with different `updateId` values coexist; -reusing an ID replaces or toggles that owner's selection. `clearUpdate(updateId)` clears -that scoped emphasis contribution. Annotation and viewport state are not yet independently -stacked by update ID. - -Public updates address semantic objects either by exact event-derived ref or by a -constrained key selector: +The renderer reconstructs effective state from the two arrays and updates Vega stores, +signals, and overlays in one dataflow run. It does not transform or recompile the base +chart for each preview. Current Vega-Lite coverage includes emphasized/focused target +state, one effective annotation, exact continuous viewport domains, and category order. +Visibility and direct ink properties, multiple simultaneous annotations, and series or +facet order remain implementation work within the existing four-operator grammar. -```ts -type UpdateTarget = - | SemanticTargetRef - | { - select: { - key: Record; - visual?: Partial; - }; - }; -``` - -A selector matches semantic keys containing the requested field/value pairs. It does -not query `value`, scan contributing `records`, accept predicates, or infer business -relationships. Exact `SemanticTargetRef` uses `visual + elements[].key`; therefore an -outbound event's non-null `target` can be passed directly to an update op. - -Implemented request operations are `emphasize`, `annotate`, `clear-annotation`, -`navigate-viewport`, and `reset`. `emphasize` is relational rather than merely opacity: -it relates targets to non-targets and owns replace/toggle state. Appearance, visibility, -focus, absolute-domain viewport, guide, and region-overlay operations remain design -candidates until compiler and renderer presentation support exists. Public operations -must not expose Vega signals, SVG attributes, Canvas state, or other backend properties. - -The chart-scoped application entry point is: +When a caller already has a complete `ChartUpdate`, it may bypass interaction handling +and apply that precomputed state directly: ```ts const result = await surface.applyUpdate({ - updateId: 'external-country-selection', - phase: 'commit', - ops: [emphasize({ + id: 'external-country-selection', + ops: [{ + op: 'set-presentation', targets: [{ select: { key: { Country: 'Japan' }, visual: { kind: 'mark' }, }, }], - mode: 'replace', - })], + value: { state: 'emphasized' }, + }], }); ``` -The compiler emits rendered provenance, declared semantic fields, scale metadata, -interaction stores, and ChartDef `presentUpdate` lowering. At runtime, exact refs are -validated against current rendered keys. Selectors accept equality constraints only on -ChartDef-declared fields, filter current rendered hits, and pass those hits back through -the ChartDef resolver. This produces the internal `ChartUpdate`; the coordinator then -calls `presentUpdate` and passes the lowered update to the backend mechanically. -Internal ops such as `render-annotation` are lowering results, not public input. - `ChartUpdateResult` reports applied, partially applied, or unsupported status plus unresolved targets and unsupported ops. Missing keys are never silently rebound to similar records. -Vega-Lite currently implements request application when the chart has a compiled +Vega-Lite currently implements update application when the chart has a compiled interaction plan. Other backends, or a Vega-Lite chart without interaction -instrumentation, return `status: 'unsupported'` rather than silently ignoring a request. +instrumentation, return `status: 'unsupported'` rather than silently ignoring an update. -## External Updates +## External Interactions -Older interactive surfaces expose a chart-scoped external dispatch API: +External definitions bind an arbitrary application payload to the same renderer-neutral +update language used by canvas interactions: ```ts -surface.dispatch({ - type: 'external', - source: 'story-scroll', - phase: 'preview', - payload: { countries: ['Japan'] }, +const countryPicker = externalInteraction<{ country: string; selected: boolean }>({ + id: 'country-picker', + handle: ({ country, selected }) => ({ + id: 'country-picker', + ops: [{ + op: 'set-presentation', + targets: selected ? [{ select: { key: { Country: country } } }] : [], + value: { state: selected ? 'emphasized' : 'normal' }, + }], + }), }); + +await surface.dispatch('country-picker', { country: 'Japan', selected: true }); ``` -External payloads cannot participate in the resolved `CanvasInteractionEvent` contract -because they do not originate from chart acquisition or semantic resolution. Applications -should process their payload and call `applyUpdate()` with the resulting request. The -legacy `dispatch()` transport is retained temporarily but reports this migration error -instead of silently running a parallel handler path. +The transport may be React state, a DOM listener, a WebSocket, or another chart. Flint +looks up the definition by ID, passes the opaque payload and current interaction context +to its handler, then resolves and presents the returned `ChartUpdate`. Internal canvas +interactions additionally use backend gesture state machines to acquire start, preview, +commit, and cancel phases; external handlers do not synthesize those phases. + +Payload typing is enforced at the `externalInteraction()` definition, while the +heterogeneous surface boundary accepts `unknown`. Canvas hit testing and navigation +domain calculation remain backend-assisted, and interaction definitions are mount-scoped. ## Outbound Events diff --git a/packages/flint-js/src/interactive/canvas-interaction.ts b/packages/flint-js/src/interactive/canvas-interaction.ts index 88e7de84..1bc7c1d6 100644 --- a/packages/flint-js/src/interactive/canvas-interaction.ts +++ b/packages/flint-js/src/interactive/canvas-interaction.ts @@ -1,79 +1,13 @@ import { semanticVisualFamily, type SemanticTarget } from '../core/interaction-semantics'; import type { InteractionEventSource } from './triggers'; import type { + CanvasInteractionAction, + CanvasInteractionEvent, InteractionModifiers, - InteractionPhase, NavigationInteractionEvent, - PlotAngularSector, - PlotPoint, - PlotPolygon, - PlotRect, - RegionAxis, - RegionOperation, + PlotGeometry, SemanticInteractionEvent, -} from './triggers/events'; - -export type CanvasInteractionAction = - | 'hover-element' - | 'click-element' - | 'hover-legend' - | 'click-legend' - | 'hover-axis' - | 'click-axis' - | 'hover-facet' - | 'click-facet' - | 'hover-annotation' - | 'click-annotation' - | 'drag-element' - | 'select-region' - | 'brush-x' - | 'brush-y' - | 'brush-angle' - | 'pan-viewport' - | 'zoom-viewport' - | 'reset-viewport' - | 'inspect-x' - | 'inspect-y' - | 'inspect-nearest' - | 'select-lasso' - | 'focus-element' - | 'activate-element'; - -export type PlotGeometry = - | { kind: 'point'; point: PlotPoint } - | { kind: 'drag'; start: PlotPoint; current: PlotPoint; delta: PlotPoint; axis?: 'x' | 'y' } - | { kind: 'rect'; rect: PlotRect; axis: Exclude } - | { kind: 'polygon'; polygon: PlotPolygon } - | { kind: 'angular-sector'; sector: PlotAngularSector } - | { - kind: 'viewport'; - axes: 'x' | 'y' | 'xy'; - delta?: PlotPoint; - factor?: number; - anchor?: PlotPoint; - }; - -export interface DomainGeometry { - x?: DomainCoordinate; - y?: DomainCoordinate; -} - -export type DomainCoordinate = - | { kind: 'value'; value: unknown } - | { kind: 'interval'; start: unknown; end: unknown }; - -export interface CanvasInteractionEvent { - action: CanvasInteractionAction; - phase: InteractionPhase; - operation?: RegionOperation | 'pan' | 'zoom' | 'reset'; - geometry: { - plot?: PlotGeometry; - domain?: DomainGeometry; - }; - target: SemanticTarget | null; - dropTarget?: SemanticTarget | null; - modifiers?: InteractionModifiers; -} +} from './language/events'; function elementAction( source: InteractionEventSource, diff --git a/packages/flint-js/src/interactive/geometry/angular.ts b/packages/flint-js/src/interactive/geometry/angular.ts index d35ac8d3..956eb62e 100644 --- a/packages/flint-js/src/interactive/geometry/angular.ts +++ b/packages/flint-js/src/interactive/geometry/angular.ts @@ -1,4 +1,4 @@ -import type { PlotAngularSector, PlotPoint } from '../triggers/events'; +import type { PlotAngularSector, PlotPoint } from '../language/events'; export const TAU = 2 * Math.PI; diff --git a/packages/flint-js/src/interactive/geometry/coordinate-space.ts b/packages/flint-js/src/interactive/geometry/coordinate-space.ts index eef04754..542bb85d 100644 --- a/packages/flint-js/src/interactive/geometry/coordinate-space.ts +++ b/packages/flint-js/src/interactive/geometry/coordinate-space.ts @@ -1,4 +1,4 @@ -import type { InteractionModifiers, PlotPoint } from '../triggers/events'; +import type { InteractionModifiers, PlotPoint } from '../language/events'; export interface RendererCoordinateSpace { rect: DOMRect; @@ -14,6 +14,20 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +/** + * Converts a rendered root-frame matrix into a plot origin expressed in the + * renderer's own units. `getCTM()` reports CSS pixels, so a CSS-scaled SVG + * would otherwise report an origin that disagrees with `logicalWidth`. + */ +export function rendererPlotOrigin( + matrix: { a: number; e: number; f: number } | null | undefined, + viewOrigin: PlotPoint, +): PlotPoint { + if (!matrix) return viewOrigin; + const scale = matrix.a || 1; + return { x: matrix.e / scale, y: matrix.f / scale }; +} + export function interactionModifiers(event: MouseEvent | PointerEvent): InteractionModifiers { return { shift: event.shiftKey, ctrl: event.ctrlKey, meta: event.metaKey }; } diff --git a/packages/flint-js/src/interactive/gestures/angular-region.ts b/packages/flint-js/src/interactive/gestures/angular-region.ts index 7c5eaf52..53359a7b 100644 --- a/packages/flint-js/src/interactive/gestures/angular-region.ts +++ b/packages/flint-js/src/interactive/gestures/angular-region.ts @@ -1,4 +1,4 @@ -import type { PlotAngularSector, PlotPoint } from '../triggers/events'; +import type { PlotAngularSector, PlotPoint } from '../language/events'; import { TAU } from '../geometry/angular'; export interface PolarFrame { diff --git a/packages/flint-js/src/interactive/gestures/cartesian-region.ts b/packages/flint-js/src/interactive/gestures/cartesian-region.ts index 9fcc761d..f73f3e4e 100644 --- a/packages/flint-js/src/interactive/gestures/cartesian-region.ts +++ b/packages/flint-js/src/interactive/gestures/cartesian-region.ts @@ -1,4 +1,4 @@ -import type { PlotPoint, RegionAxis, RegionOperation } from '../triggers/events'; +import type { PlotPoint, RegionAxis, RegionOperation } from '../language/events'; export type CartesianRegionAxis = Extract; export type IntervalOperation = Exclude; diff --git a/packages/flint-js/src/interactive/gestures/navigation.ts b/packages/flint-js/src/interactive/gestures/navigation.ts index fbf46808..67c57e43 100644 --- a/packages/flint-js/src/interactive/gestures/navigation.ts +++ b/packages/flint-js/src/interactive/gestures/navigation.ts @@ -1,4 +1,4 @@ -import type { PlotPoint } from '../triggers/events'; +import type { PlotPoint } from '../language/events'; export interface PlotSize { width: number; diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 61bffc58..2dbec8c6 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -1,10 +1,12 @@ import type { ChartAssemblyInput } from '../core/types'; -import { normalizeInteractions } from './interactions'; +import { isCanvasInteraction, normalizeInteractions } from './interactions'; import { mountInteractiveChartSurface } from './surface'; import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types'; export type { BuildInteractiveChartOptions, + ChartUpdateApplyOptions, + ChartUpdateComposition, InteractiveBackend, InteractiveChartSurface, InteractiveChartSurfaceOptions, @@ -17,8 +19,9 @@ export type { export type { AnnotationCandidate, AnnotationConnection, - AnnotationRenderPlan, + AnnotationSpec, ChartUpdate, + ChartUpdateOp, ChartUpdatePresenter, BrushOptions, AngularBrushOptions, @@ -26,11 +29,12 @@ export type { ClickGroupHighlightOptions, ClickHighlightOptions, ElementInteractionEvent, - ExternalInteractionEvent, FlintInteractionEventDetail, InteractionPhase, InteractionContext, InteractionDef, + CanvasInteractionDef, + ExternalInteractionDef, InteractionModifiers, NavigateOptions, NavigationAxes, @@ -45,12 +49,12 @@ export type { RegionOperation, RenderHit, SelectOptions, - SelectionMode, SemanticElement, SemanticInteractionEvent, SemanticTarget, - NormalizedInteractionEvent, - UpdateOp, + PresentationSpec, + UpdateDomain, + UpdateTarget, } from './interactions'; export type { CanvasInteractionAction, @@ -58,30 +62,20 @@ export type { DomainCoordinate, DomainGeometry, PlotGeometry, -} from './canvas-interaction'; +} from './language/events'; export { toCanvasInteractionEvent } from './canvas-interaction'; export type { - ChartUpdateRequest, - ChartUpdateRequestOp, ChartUpdateResult, SemanticTargetRef, SemanticTargetSelector, - UpdateTarget, -} from './updates/request'; -export { - annotate, - clearAnnotation, - emphasize, - navigateViewport, - resetUpdate, -} from './updates/request'; -export { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, dragReorder, navigate, select } from './interactions'; -export type { InteractionEventSource, InteractionEventSourceContext } from './triggers'; +} from './language/updates'; +export { matchesSemanticTargetSelector } from './language/updates'; +export { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, dragReorder, externalInteraction, isCanvasInteraction, isExternalInteraction, navigate, select } from './interactions'; +export type { InteractionEventSource } from './triggers'; export { axisBrushTrigger, angularBrushTrigger, clickTrigger, - externalTrigger, hoverTrigger, navigationTrigger, rectangleTrigger, @@ -95,8 +89,12 @@ export function buildInteractiveChart( input: ChartAssemblyInput, options: BuildInteractiveChartOptions, ): InteractiveChartSurface { - const { backend, renderer, focusOnClick, expressionInterpreter, background, className, ariaLabel, chartId } = options; - const interactions = normalizeInteractions(options.interactions, focusOnClick); + const { + backend, renderer, expressionInterpreter, background, + className, ariaLabel, chartId, updates, + } = options; + const interactions = normalizeInteractions(options.interactions); + const canvasInteractions = interactions.filter(isCanvasInteraction); if (backend !== 'vegalite' && interactions.length > 0) { return mountInteractiveChartSurface( container, @@ -106,7 +104,7 @@ export function buildInteractiveChart( throw new Error(`Semantic interactions are not supported by backend "${backend}".`); }, }, - { className, ariaLabel, chartId }, + { className, ariaLabel, chartId, updates }, ); } switch (backend) { @@ -119,13 +117,15 @@ export function buildInteractiveChart( const { createVegaInteractiveRenderer } = await import('../vegalite/interactive'); return createVegaInteractiveRenderer({ renderer, - interactions, + interactions: canvasInteractions, + enableSemanticUpdates: canvasInteractions.length < interactions.length + || (updates?.length ?? 0) > 0, expressionInterpreter, background, }).mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId }, + { className, ariaLabel, chartId, updates, interactions }, ); case 'echarts': return mountInteractiveChartSurface( @@ -137,7 +137,7 @@ export function buildInteractiveChart( return createEChartsInteractiveRenderer({ renderer }).mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId }, + { className, ariaLabel, chartId, updates, interactions }, ); case 'chartjs': return mountInteractiveChartSurface( @@ -149,7 +149,7 @@ export function buildInteractiveChart( return createChartjsInteractiveRenderer().mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId }, + { className, ariaLabel, chartId, updates, interactions }, ); case 'plotly': return mountInteractiveChartSurface( @@ -161,7 +161,7 @@ export function buildInteractiveChart( return createPlotlyInteractiveRenderer().mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId }, + { className, ariaLabel, chartId, updates, interactions }, ); } } \ No newline at end of file diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 0c66fd68..3489676c 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -1,10 +1,13 @@ -import type { SemanticElement, SemanticTarget } from '../core/interaction-semantics'; +import type { + ChartUpdate, + InteractionContext, + NavigationDomainGuard, + SemanticElement, +} from '../core/interaction-contracts'; import type { InteractionEventSource } from './triggers'; import type { - InteractionPhase, NavigationAxes, - PlotPoint, -} from './triggers/events'; +} from './language/events'; import { createBrushInteraction, createAngularBrushInteraction, @@ -15,9 +18,17 @@ import { createNavigateInteraction, createDragReorderInteraction, } from './presets'; -import type { CanvasInteractionEvent } from './canvas-interaction'; -export type { RenderHit, SemanticElement, SemanticTarget } from '../core/interaction-semantics'; -export type ChartUpdateRequest = import('./updates/request').ChartUpdateRequest; +import type { CanvasInteractionEvent } from './language/events'; +export type { + ChartUpdatePresenter, + InteractionContext, + NavigationDomainGuard, + NavigationRequest, + NavigationUpdate, + RenderHit, + SemanticElement, + SemanticTarget, +} from '../core/interaction-contracts'; export interface FlintInteractionEventDetail { chartId: string; @@ -33,17 +44,15 @@ export type { DomainCoordinate, DomainGeometry, PlotGeometry, -} from './canvas-interaction'; +} from './language/events'; export type { ElementInteractionEvent, - ExternalInteractionEvent, InteractionModifiers, InteractionPhase, NavigationAxes, NavigationInteractionEvent, NavigationOperation, - NormalizedInteractionEvent, PlotPoint, PlotAngularSector, PlotPolygon, @@ -52,96 +61,50 @@ export type { RegionOperation, RegionInteractionEvent, SemanticInteractionEvent, -} from './triggers/events'; - -export type SelectionMode = 'replace' | 'toggle'; - -export type AnnotationConnection = - | 'center' - | 'top' - | 'right' - | 'bottom' - | 'left' - | 'value-end' - | 'value-side' - | 'segment-midpoint' - | 'radial-midpoint' - | 'outer-radial'; - -export interface AnnotationCandidate { - connection: AnnotationConnection; - valueAxis?: 'x' | 'y'; - crossSide?: 'start' | 'end'; - valueInset?: number; - anglePreference?: 'normal' | 'oblique'; - textAlign?: 'left' | 'center' | 'right'; - connector?: 'line' | 'none'; - maxWidth?: number; - maxDistance?: number; - priority?: number; -} - -export interface AnnotationRenderPlan { - text: string; - candidates: readonly AnnotationCandidate[]; - subject?: Partial; - markType?: string; -} - -export interface NavigationDomainGuard { - minVisibleFraction: number; - maxVisibleFraction: number; - overscrollFraction: number; -} - -export type UpdateOp = - | { op: 'emphasize'; elements: readonly SemanticElement[]; mode: SelectionMode; dimOpacity: number } - | { op: 'annotate'; element: SemanticElement; visual?: Partial; text?: string } - | { op: 'render-annotation'; element: SemanticElement; annotation: AnnotationRenderPlan } - | { op: 'clear-annotation' } - | { - op: 'navigate-viewport'; - phase: InteractionPhase; - operation: import('./triggers/events').NavigationOperation; - axes: NavigationAxes; - delta?: PlotPoint; - factor?: number; - anchor?: PlotPoint; - domainGuard: NavigationDomainGuard; - } - | { op: 'reorder-category'; axis: 'x' | 'y'; field: string; orderedValues: readonly unknown[] } - | { op: 'reset' }; - -export interface ChartUpdate { - phase?: InteractionPhase; - ops: readonly UpdateOp[]; -} - -export type ChartUpdatePresenter = ( - update: ChartUpdate, - context: InteractionContext, -) => ChartUpdate; - -export interface InteractionContext { - readonly chartType: string; - readonly selected: readonly SemanticElement[]; - readonly available?: readonly SemanticElement[]; - readonly categoryField?: string; - readonly seriesField?: string; - readonly categoryAxis?: 'x' | 'y'; - /** Current rendered order for the active category axis. */ - readonly categoryOrder?: readonly unknown[]; - readonly reorderAxes?: readonly { - axis: 'x' | 'y'; - field: string; - order: readonly unknown[]; - }[]; -} - -export interface InteractionDef { +} from './language/events'; + +export type { + AnnotationCandidate, + AnnotationConnection, + AnnotationConnectorAnchor, + AnnotationSpec, + ChartUpdate, + ChartUpdateOp, + PresentationSpec, + SemanticTargetRef, + SemanticTargetSelector, + UpdateDomain, + UpdateTarget, +} from './language/updates'; + +export interface CanvasInteractionDef { readonly id: string; readonly eventSource: InteractionEventSource; - handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdateRequest | null; + readonly navigationDomainGuard?: NavigationDomainGuard; + handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdate | null; +} + +export interface ExternalInteractionDef { + readonly id: string; + readonly external: true; + handle(payload: TPayload, context: InteractionContext): ChartUpdate | null; +} + +export type InteractionDef = CanvasInteractionDef | ExternalInteractionDef; + +export function externalInteraction(definition: { + id: string; + handle(payload: TPayload, context: InteractionContext): ChartUpdate | null; +}): ExternalInteractionDef { + return { ...definition, external: true }; +} + +export function isCanvasInteraction(interaction: InteractionDef): interaction is CanvasInteractionDef { + return !('external' in interaction); +} + +export function isExternalInteraction(interaction: InteractionDef): interaction is ExternalInteractionDef { + return 'external' in interaction; } export interface ClickHighlightOptions { @@ -182,50 +145,46 @@ export interface DragReorderOptions { id?: string; } -export function clickHighlight(options: ClickHighlightOptions = {}): InteractionDef { +export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInteractionDef { return createClickHighlightInteraction(options); } -export function clickGroupHighlight(options: ClickGroupHighlightOptions = {}): InteractionDef { +export function clickGroupHighlight(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { return createClickGroupHighlightInteraction(options); } -export function clickAnnotate(options: ClickAnnotateOptions = {}): InteractionDef { +export function clickAnnotate(options: ClickAnnotateOptions = {}): CanvasInteractionDef { return createClickAnnotateInteraction(options); } -export function select(options: SelectOptions = {}): InteractionDef { +export function select(options: SelectOptions = {}): CanvasInteractionDef { return createSelectInteraction(options); } -export function brushX(options: BrushOptions = {}): InteractionDef { +export function brushX(options: BrushOptions = {}): CanvasInteractionDef { return createBrushInteraction('x', options); } -export function brushY(options: BrushOptions = {}): InteractionDef { +export function brushY(options: BrushOptions = {}): CanvasInteractionDef { return createBrushInteraction('y', options); } -export function brushAngle(options: AngularBrushOptions = {}): InteractionDef { +export function brushAngle(options: AngularBrushOptions = {}): CanvasInteractionDef { return createAngularBrushInteraction(options); } -export function navigate(options: NavigateOptions = {}): InteractionDef { +export function navigate(options: NavigateOptions = {}): CanvasInteractionDef { return createNavigateInteraction(options); } -export function dragReorder(options: DragReorderOptions = {}): InteractionDef { +export function dragReorder(options: DragReorderOptions = {}): CanvasInteractionDef { return createDragReorderInteraction(options); } export function normalizeInteractions( interactions: readonly InteractionDef[] | undefined, - focusOnClick: boolean | undefined, ): readonly InteractionDef[] { const normalized = [...(interactions ?? [])]; - if (focusOnClick === true && !normalized.some((interaction) => interaction.id === 'click-highlight')) { - normalized.push(clickHighlight()); - } const ids = new Set(); for (const interaction of normalized) { if (ids.has(interaction.id)) throw new Error(`Duplicate interaction id: "${interaction.id}".`); diff --git a/packages/flint-js/src/interactive/triggers/events.ts b/packages/flint-js/src/interactive/language/events.ts similarity index 58% rename from packages/flint-js/src/interactive/triggers/events.ts rename to packages/flint-js/src/interactive/language/events.ts index e10a0221..07c33a6c 100644 --- a/packages/flint-js/src/interactive/triggers/events.ts +++ b/packages/flint-js/src/interactive/language/events.ts @@ -69,20 +69,6 @@ export interface NavigationInteractionEvent { modifiers?: InteractionModifiers; } -export interface ExternalInteractionEvent { - type: 'external'; - source: string; - phase: InteractionPhase; - payload: TPayload; - transactionId?: string; -} - -export type NormalizedInteractionEvent = - | ElementInteractionEvent - | RegionInteractionEvent - | NavigationInteractionEvent - | ExternalInteractionEvent; - export interface SemanticInteractionEvent { type: 'semantic'; source: 'element' | 'region'; @@ -94,3 +80,65 @@ export interface SemanticInteractionEvent { operation?: RegionOperation; modifiers?: InteractionModifiers; } + +export type CanvasInteractionAction = + | 'hover-element' + | 'click-element' + | 'hover-legend' + | 'click-legend' + | 'hover-axis' + | 'click-axis' + | 'hover-facet' + | 'click-facet' + | 'hover-annotation' + | 'click-annotation' + | 'drag-element' + | 'select-region' + | 'brush-x' + | 'brush-y' + | 'brush-angle' + | 'pan-viewport' + | 'zoom-viewport' + | 'reset-viewport' + | 'inspect-x' + | 'inspect-y' + | 'inspect-nearest' + | 'select-lasso' + | 'focus-element' + | 'activate-element'; + +export type PlotGeometry = + | { kind: 'point'; point: PlotPoint } + | { kind: 'drag'; start: PlotPoint; current: PlotPoint; delta: PlotPoint; axis?: 'x' | 'y' } + | { kind: 'rect'; rect: PlotRect; axis: Exclude } + | { kind: 'polygon'; polygon: PlotPolygon } + | { kind: 'angular-sector'; sector: PlotAngularSector } + | { + kind: 'viewport'; + axes: 'x' | 'y' | 'xy'; + delta?: PlotPoint; + factor?: number; + anchor?: PlotPoint; + }; + +export interface DomainGeometry { + x?: DomainCoordinate; + y?: DomainCoordinate; +} + +export type DomainCoordinate = + | { kind: 'value'; value: unknown } + | { kind: 'interval'; start: unknown; end: unknown }; + +export interface CanvasInteractionEvent { + action: CanvasInteractionAction; + phase: InteractionPhase; + operation?: RegionOperation | 'pan' | 'zoom' | 'reset'; + geometry: { + plot?: PlotGeometry; + domain?: DomainGeometry; + }; + target: SemanticTarget | null; + dropTarget?: SemanticTarget | null; + modifiers?: InteractionModifiers; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/index.ts b/packages/flint-js/src/interactive/language/index.ts new file mode 100644 index 00000000..a79a8a3d --- /dev/null +++ b/packages/flint-js/src/interactive/language/index.ts @@ -0,0 +1,2 @@ +export * from './events'; +export * from './updates'; \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/updates.ts b/packages/flint-js/src/interactive/language/updates.ts new file mode 100644 index 00000000..da6ee192 --- /dev/null +++ b/packages/flint-js/src/interactive/language/updates.ts @@ -0,0 +1,37 @@ +import type { + ChartUpdateOp, + SemanticTargetSelector, + UpdateTarget, +} from '../../core/interaction-contracts'; + +export type { + AnnotationCandidate, + AnnotationConnection, + AnnotationConnectorAnchor, + AnnotationSpec, + ChartUpdate, + ChartUpdateOp, + PresentationSpec, + SemanticTargetRef, + SemanticTargetSelector, + UpdateDomain, + UpdateTarget, +} from '../../core/interaction-contracts'; + +export interface ChartUpdateResult { + status: 'applied' | 'partially-applied' | 'unsupported'; + resolvedTargets: number; + unresolvedTargets: readonly UpdateTarget[]; + unsupportedOps: readonly ChartUpdateOp['op'][]; +} + +export function matchesSemanticTargetSelector( + selector: SemanticTargetSelector, + declaredFields: readonly string[], + value: Readonly>, +): boolean { + const entries = Object.entries(selector.select.key); + return entries.length > 0 + && entries.every(([field]) => declaredFields.includes(field)) + && entries.every(([field, expected]) => Object.is(value[field], expected)); +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/updates/annotation.ts b/packages/flint-js/src/interactive/presentation/annotation.ts similarity index 82% rename from packages/flint-js/src/interactive/updates/annotation.ts rename to packages/flint-js/src/interactive/presentation/annotation.ts index 835a153f..c63744cf 100644 --- a/packages/flint-js/src/interactive/updates/annotation.ts +++ b/packages/flint-js/src/interactive/presentation/annotation.ts @@ -44,10 +44,14 @@ export function lollipopAnnotationCandidates( export function barAnnotationCandidates( valueAxis: 'x' | 'y', ): readonly AnnotationCandidate[] { + const crossAxisEdges: readonly AnnotationConnection[] = valueAxis === 'y' + ? ['top', 'bottom'] + : ['right', 'left']; return [ { connection: 'value-end', valueAxis, priority: 0 }, { connection: 'value-side', valueAxis, crossSide: 'start', valueInset: 1 / 8, priority: 1 }, { connection: 'value-side', valueAxis, crossSide: 'end', valueInset: 1 / 8, priority: 1 }, + ...crossAxisEdges.map((connection) => ({ connection, priority: 2 })), ]; } @@ -62,22 +66,23 @@ export function presentAnnotationUpdate( context: InteractionContext, visual?: Partial, ) => string | undefined = defaultAnnotationText, - markType?: string, ): ChartUpdatePresenter { return (update, context) => ({ + id: update.id, ops: update.ops.flatMap((op) => { - if (op.op !== 'annotate') return op; - const presentation = presentAnnotation(op.element, context, op.visual); - const text = op.text ?? formatAnnotation(op.element, context, op.visual); + if (op.op !== 'set-annotation' || op.value === null || 'select' in op.target) return op; + const element = op.target.elements[0]; + if (!element) return []; + const presentation = presentAnnotation(element, context, op.target.visual); + const text = op.value.text ?? formatAnnotation(element, context, op.target.visual); if (!text) return []; return { - op: 'render-annotation', - element: op.element, - annotation: { + ...op, + value: { + ...op.value, text, candidates: Array.isArray(presentation) ? presentation : [presentation], - subject: op.visual, - ...(markType ? { markType } : {}), + subject: op.target.visual, }, }; }), @@ -85,7 +90,8 @@ export function presentAnnotationUpdate( } export const suppressAnnotationUpdate: ChartUpdatePresenter = (update) => ({ - ops: update.ops.filter((op) => op.op !== 'annotate'), + id: update.id, + ops: update.ops.filter((op) => op.op !== 'set-annotation'), }); function displayValue(field: string | undefined, value: unknown): string | undefined { @@ -166,6 +172,20 @@ export function valueAnnotationText( }; } +export function comparisonAnnotationText( + actualField: string | undefined, + expectedField: string | undefined, +): (element: SemanticElement) => string | undefined { + return (element) => { + if (!actualField || !expectedField) return undefined; + const record = element.records?.[0] ?? element.value ?? {}; + const actual = displayValue(actualField, record[actualField]); + const expected = displayValue(expectedField, record[expectedField]); + if (!actual || !expected) return actual ?? expected; + return `Actual: ${actual}\nExpected: ${expected}`; + }; +} + function defaultAnnotationText(element: SemanticElement, context: InteractionContext): string | undefined { const record = element.records?.[0] ?? element.value ?? {}; const candidates = Object.entries(record).filter(([field, value]) => !field.startsWith('__') @@ -181,4 +201,4 @@ export function countAnnotationText(element: SemanticElement): string | undefine const count = Object.entries(record).find(([field, value]) => /count/i.test(field) && typeof value === 'number' && Number.isFinite(value)); return displayValue(count?.[0], count?.[1]); -} +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/README.md b/packages/flint-js/src/interactive/presets/README.md index ba8a1cf6..f51eeb0d 100644 --- a/packages/flint-js/src/interactive/presets/README.md +++ b/packages/flint-js/src/interactive/presets/README.md @@ -1,14 +1,14 @@ # Interaction Presets Presets translate `CanvasInteractionEvent` values into renderer-neutral -`ChartUpdateRequest` operations. They decide what action to take, not how a particular -mark should draw that action. Returned requests use the same `applyUpdate()` path as -application-authored requests. +`ChartUpdate` JSON. They decide what action to take, not how a particular mark should +draw that action. Returned updates use the same retained state and private gesture +preview state as other canvas interactions. Presets are a small convenience layer, not the primary interaction API. Flint separately -provides predefined interaction observers that emit resolved events and update factories -that construct `ChartUpdateRequestOp` values. Applications compose those output and input contracts -with ordinary JavaScript when behavior is product-specific. +provides canvas definitions that emit resolved events, external definitions that bind +application payloads, and the `ChartUpdateOp` data language. Applications compose these +contracts with ordinary JavaScript when behavior is product-specific. A preset earns a built-in when the action-to-update pairing is broadly useful and has non-trivial lifecycle behavior that Flint should implement consistently. The intended @@ -21,32 +21,36 @@ core set is: Flint should not add a preset for every combination of action and update. Legend toggle/isolate, annotation text formatting, group expansion, linked views, tooltips, -drilldown, and application-specific relationships are usually recipes. Existing helpers -may remain for compatibility without expanding into a combinatorial preset catalogue. +drilldown, and application-specific relationships are usually recipes. Built-ins remain +only when they represent broadly reusable canvas policies. ```ts -surface.addEventListener('flint-interaction', ({ detail }) => { - const { event } = detail; - if (event.action !== 'click-legend' || !event.target) return; - - void surface.applyUpdate({ - updateId: 'legend-selection', - phase: 'commit', - ops: [ - emphasize({ targets: [event.target], mode: 'replace' }), - annotate({ target: event.target, text: 'Selected series' }), - ], - }); -}); +const legendSelection: CanvasInteractionDef = { + id: 'legend-selection', + eventSource: clickTrigger, + handle: (event) => { + if (event.action !== 'click-legend' || !event.target) return null; + return { + id: 'legend-selection', + ops: [ + { + op: 'set-presentation', targets: [event.target], + value: { state: 'emphasized' }, + }, + { op: 'set-annotation', target: event.target, value: { text: 'Selected series' } }, + ], + }; + }, +}; ``` -`CanvasInteractionEvent`, public `surface.applyUpdate()`, and output-only interaction -definitions are implemented for the Vega-Lite semantic runtime. Current presets consume -the public event shape and produce public update requests; resolved renderer operations -remain internal. Other interactive backends and additional public appearance/visibility -operations remain future work. +`CanvasInteractionEvent`, `externalInteraction()`, `surface.dispatch()`, direct update +state APIs, and output-only canvas definitions are implemented for the Vega-Lite +semantic runtime. Current presets consume the public canvas event shape and produce +public update JSON; target resolution and backend application remain internal. Other +interactive backends and additional appearance/visibility operations remain future work. -Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, and rose. All three produce the same semantic `emphasize` operation after the owning ChartDef resolves physical hits. +Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, and rose. All three produce the same semantic `set-presentation` operation after the owning ChartDef resolves physical hits. ## Emphasis Behavior @@ -56,7 +60,7 @@ Built-in selection presets use one clear opacity rule: - unfocused elements use `0.25` opacity; - categorical color does not introduce a separate dimming range. -Keeping one value makes linked views predictable. A bar, point, arc, line, or cell receives the same semantic emphasis operation even when its chart presents focus differently. +Keeping one value makes linked views predictable. A bar, point, arc, line, or cell receives the same emphasized presentation state even when its chart presents focus differently. ## Representation-Aware Presentation @@ -83,7 +87,7 @@ The stages remain separate: 1. Trigger normalization reports physical hits. 2. ChartDef resolution converts hits into semantic elements. 3. The coordinator emits the resolved event whether or not a preset is configured. -4. An optional preset emits `emphasize` with selected elements and dim opacity. +4. An optional preset emits `set-presentation` with selected elements and muted-peer opacity. 5. ChartDef presentation declares representation-specific focus styling. 6. The renderer applies opacity, proportional line width, or region boundaries mechanically. diff --git a/packages/flint-js/src/interactive/presets/angular-brush.ts b/packages/flint-js/src/interactive/presets/angular-brush.ts index 8bd859bd..884ebfb3 100644 --- a/packages/flint-js/src/interactive/presets/angular-brush.ts +++ b/packages/flint-js/src/interactive/presets/angular-brush.ts @@ -1,16 +1,16 @@ -import type { AngularBrushOptions, InteractionDef } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from '../updates/emphasis'; +import type { AngularBrushOptions, CanvasInteractionDef } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; import { angularBrushTrigger } from '../triggers'; -export function createAngularBrushInteraction(options: AngularBrushOptions = {}): InteractionDef { +export function createAngularBrushInteraction(options: AngularBrushOptions = {}): CanvasInteractionDef { const id = options.id ?? 'brush-angle'; const dimOpacity = normalizedOpacity(options.dimOpacity); return { id, eventSource: angularBrushTrigger(options.match ?? 'intersect'), - handle(event) { + handle(event, context) { if (event.action !== 'brush-angle' || event.phase === 'start' || event.phase === 'cancel') return null; - return emphasisUpdate(id, event, event.target, dimOpacity); + return emphasisUpdate(id, event, event.target, dimOpacity, context); }, }; } \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/brush.ts b/packages/flint-js/src/interactive/presets/brush.ts index 9476ada9..3acbf2c9 100644 --- a/packages/flint-js/src/interactive/presets/brush.ts +++ b/packages/flint-js/src/interactive/presets/brush.ts @@ -1,9 +1,9 @@ -import type { BrushOptions, InteractionDef } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from '../updates/emphasis'; +import type { BrushOptions, CanvasInteractionDef } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; import { axisBrushTrigger } from '../triggers'; import { expandRangedDotTarget } from './ranged-dot-target'; -export function createBrushInteraction(axis: 'x' | 'y', options: BrushOptions = {}): InteractionDef { +export function createBrushInteraction(axis: 'x' | 'y', options: BrushOptions = {}): CanvasInteractionDef { const id = options.id ?? `brush-${axis}`; const dimOpacity = normalizedOpacity(options.dimOpacity); return { @@ -13,7 +13,7 @@ export function createBrushInteraction(axis: 'x' | 'y', options: BrushOptions = handle(event, context) { if (event.action !== `brush-${axis}` || event.phase === 'start' || event.phase === 'cancel') return null; const target = expandRangedDotTarget(event.target, context); - return emphasisUpdate(id, event, target, dimOpacity); + return emphasisUpdate(id, event, target, dimOpacity, context); }, - } as InteractionDef & { axis: 'x' | 'y' }; + } as CanvasInteractionDef & { axis: 'x' | 'y' }; } diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts index 0445c96e..d56655fb 100644 --- a/packages/flint-js/src/interactive/presets/click-annotate.ts +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -1,11 +1,11 @@ import type { ClickAnnotateOptions, - InteractionDef, + CanvasInteractionDef, } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from '../updates/emphasis'; +import { emphasisUpdate, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; -export function createClickAnnotateInteraction(options: ClickAnnotateOptions = {}): InteractionDef { +export function createClickAnnotateInteraction(options: ClickAnnotateOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-annotate'; const dimOpacity = normalizedOpacity(options.dimOpacity); return { @@ -15,22 +15,23 @@ export function createClickAnnotateInteraction(options: ClickAnnotateOptions = { if (!event.action.startsWith('click-') || event.phase !== 'commit') return null; if (!event.target) { return { - updateId: id, - phase: event.phase, - ops: [{ op: 'clear-annotation' }, { op: 'reset' }], + id, + ops: [ + { op: 'set-annotation', target: { select: { key: {} } }, value: null }, + { op: 'set-presentation', targets: [], value: { state: 'normal' } }, + ], }; } const element = event.target.elements[0]; if (!element) return null; - const emphasis = emphasisUpdate(id, event, event.target, dimOpacity); + const emphasis = emphasisUpdate(id, event, event.target, dimOpacity, context); const text = options.format?.(element, context); return { - updateId: id, - phase: event.phase, + id, ops: [{ - op: 'annotate', + op: 'set-annotation', target: { visual: event.target.visual, elements: [element] }, - ...(text === undefined ? {} : { text }), + value: text === undefined ? {} : { text }, }, ...(emphasis?.ops ?? [])], }; }, diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts index c023fc5c..a8b82146 100644 --- a/packages/flint-js/src/interactive/presets/click-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -1,11 +1,11 @@ import type { ClickGroupHighlightOptions, InteractionContext, - InteractionDef, + CanvasInteractionDef, SemanticElement, SemanticTarget, } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from '../updates/emphasis'; +import { emphasisUpdate, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; function groupValue( @@ -17,10 +17,9 @@ function groupValue( const record = element.records?.[0]; if (!record) return undefined; if (typeof groupBy === 'string') return record[groupBy]; + if (context.resolveGroupValue) return context.resolveGroupValue(element); - const field = context.chartType === 'Waterfall Chart' - ? '__wf_color' - : context.chartType === 'Strip Plot' ? context.categoryField : context.seriesField; + const field = context.chartType === 'Strip Plot' ? context.categoryField : context.seriesField; return field ? record[field] : undefined; } @@ -43,7 +42,7 @@ function groupElements( return cohort.length > 1 ? cohort : target.elements; } -export function createClickGroupHighlightInteraction(options: ClickGroupHighlightOptions = {}): InteractionDef { +export function createClickGroupHighlightInteraction(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-group-highlight'; const dimOpacity = normalizedOpacity(options.dimOpacity); return { @@ -54,7 +53,7 @@ export function createClickGroupHighlightInteraction(options: ClickGroupHighligh const target = event.target ? { ...event.target, elements: groupElements(event.target, context, options.groupBy) } : null; - return emphasisUpdate(id, event, target, dimOpacity); + return emphasisUpdate(id, event, target, dimOpacity, context); }, }; } diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts index 2dc48985..47642f2f 100644 --- a/packages/flint-js/src/interactive/presets/click-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -1,9 +1,9 @@ -import type { ClickHighlightOptions, InteractionDef } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from '../updates/emphasis'; +import type { CanvasInteractionDef, ClickHighlightOptions } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; import { expandRangedDotTarget } from './ranged-dot-target'; -export function createClickHighlightInteraction(options: ClickHighlightOptions = {}): InteractionDef { +export function createClickHighlightInteraction(options: ClickHighlightOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-highlight'; const dimOpacity = normalizedOpacity(options.dimOpacity); return { @@ -12,7 +12,7 @@ export function createClickHighlightInteraction(options: ClickHighlightOptions = handle(event, context) { if (!event.action.startsWith('click-') || event.phase === 'start' || event.phase === 'cancel') return null; const target = expandRangedDotTarget(event.target, context); - return emphasisUpdate(id, event, target, dimOpacity); + return emphasisUpdate(id, event, target, dimOpacity, context); }, }; } diff --git a/packages/flint-js/src/interactive/presets/drag-reorder.ts b/packages/flint-js/src/interactive/presets/drag-reorder.ts index c118474c..843523cc 100644 --- a/packages/flint-js/src/interactive/presets/drag-reorder.ts +++ b/packages/flint-js/src/interactive/presets/drag-reorder.ts @@ -1,4 +1,4 @@ -import type { DragReorderOptions, InteractionDef, SemanticElement } from '../interactions'; +import type { CanvasInteractionDef, DragReorderOptions, SemanticElement } from '../interactions'; import { elementDragTrigger } from '../triggers'; function categoryValue(element: SemanticElement | undefined, field: string): unknown { @@ -19,7 +19,7 @@ export function reorderValues( return reordered; } -export function createDragReorderInteraction(options: DragReorderOptions = {}): InteractionDef { +export function createDragReorderInteraction(options: DragReorderOptions = {}): CanvasInteractionDef { const id = options.id ?? 'drag-reorder'; return { id, @@ -53,9 +53,8 @@ export function createDragReorderInteraction(options: DragReorderOptions = {}): const orderedValues = reorderValues(values, source, destination); if (orderedValues.every((value, index) => Object.is(value, values[index]))) return null; return { - updateId: id, - phase: event.phase, - ops: [{ op: 'reorder-category', axis, field, orderedValues }], + id, + ops: [{ op: 'set-order', scope: 'category', field, values: orderedValues }], }; }, }; diff --git a/packages/flint-js/src/interactive/presets/navigate.ts b/packages/flint-js/src/interactive/presets/navigate.ts index 33310b53..197f2720 100644 --- a/packages/flint-js/src/interactive/presets/navigate.ts +++ b/packages/flint-js/src/interactive/presets/navigate.ts @@ -1,5 +1,5 @@ import type { - InteractionDef, + CanvasInteractionDef, NavigateOptions, NavigationDomainGuard, } from '../interactions'; @@ -15,7 +15,7 @@ function normalizedFraction(value: number | undefined, fallback: number, min: nu return Number.isFinite(value) ? Math.max(min, value!) : fallback; } -export function createNavigateInteraction(options: NavigateOptions = {}): InteractionDef { +export function createNavigateInteraction(options: NavigateOptions = {}): CanvasInteractionDef { const id = options.id ?? 'navigate'; const domainGuard = { minVisibleFraction: normalizedFraction( @@ -39,29 +39,25 @@ export function createNavigateInteraction(options: NavigateOptions = {}): Intera } return { id, + navigationDomainGuard: domainGuard, eventSource: navigationTrigger({ axes: options.axes ?? 'available', pan: options.pan ?? true, zoom: options.zoom ?? true, wheelSensitivity: options.wheelSensitivity ?? 0.002, }), - handle(event) { - if (event.geometry.plot?.kind !== 'viewport' || !event.operation - || !['pan', 'zoom', 'reset'].includes(event.operation)) return null; - const operation = event.operation as 'pan' | 'zoom' | 'reset'; - return { - updateId: id, + handle(event, context) { + const viewport = event.geometry.plot; + if (!context.resolveNavigation || viewport?.kind !== 'viewport' || !event.operation) return null; + const op = context.resolveNavigation({ phase: event.phase, - ops: [{ - op: 'navigate-viewport', - operation, - axes: event.geometry.plot.axes, - delta: event.geometry.plot.delta, - factor: event.geometry.plot.factor, - anchor: event.geometry.plot.anchor, - domainGuard, - }], - }; + operation: event.operation as 'pan' | 'zoom' | 'reset', + axes: viewport.axes, + delta: viewport.delta, + factor: viewport.factor, + anchor: viewport.anchor, + }, domainGuard); + return op ? { id, ops: [op] } : null; }, }; } diff --git a/packages/flint-js/src/interactive/presets/select.ts b/packages/flint-js/src/interactive/presets/select.ts index b1d813f8..c2f27be5 100644 --- a/packages/flint-js/src/interactive/presets/select.ts +++ b/packages/flint-js/src/interactive/presets/select.ts @@ -1,16 +1,16 @@ -import type { InteractionDef, SelectOptions } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from '../updates/emphasis'; +import type { CanvasInteractionDef, SelectOptions } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; import { rectangleTrigger } from '../triggers'; -export function createSelectInteraction(options: SelectOptions = {}): InteractionDef { +export function createSelectInteraction(options: SelectOptions = {}): CanvasInteractionDef { const id = options.id ?? 'select'; const dimOpacity = normalizedOpacity(options.dimOpacity); return { id, eventSource: rectangleTrigger(options.match ?? 'intersect'), - handle(event) { + handle(event, context) { if (event.action !== 'select-region' || event.phase === 'start' || event.phase === 'cancel') return null; - return emphasisUpdate(id, event, event.target, dimOpacity); + return emphasisUpdate(id, event, event.target, dimOpacity, context); }, }; } diff --git a/packages/flint-js/src/interactive/presets/utils.ts b/packages/flint-js/src/interactive/presets/utils.ts new file mode 100644 index 00000000..5183cd2d --- /dev/null +++ b/packages/flint-js/src/interactive/presets/utils.ts @@ -0,0 +1,47 @@ +import type { + CanvasInteractionEvent, + ChartUpdate, + InteractionModifiers, + InteractionContext, + SemanticTarget, +} from '../interactions'; + +export const DEFAULT_DIM_OPACITY = 0.25; + +export function normalizedOpacity(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_DIM_OPACITY; + return Math.min(1, Math.max(0, value)); +} + +function selectionMode(modifiers: InteractionModifiers | undefined): 'replace' | 'toggle' { + return modifiers?.shift || modifiers?.ctrl || modifiers?.meta ? 'toggle' : 'replace'; +} + +export function emphasisUpdate( + id: string, + event: Pick, + target: SemanticTarget | null, + dimOpacity: number, + context: InteractionContext, +): ChartUpdate | null { + if (!target) return { id, ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }] }; + if (target.elements.length === 0) return null; + const toggle = selectionMode(event.modifiers) === 'toggle'; + const targetKeys = new Set(target.elements.map((element) => JSON.stringify(element.key))); + const allSelected = target.elements.every((element) => + context.selected.some((selected) => JSON.stringify(selected.key) === JSON.stringify(element.key))); + const elements = !toggle + ? target.elements + : allSelected + ? context.selected.filter((element) => !targetKeys.has(JSON.stringify(element.key))) + : [...context.selected, ...target.elements.filter((element) => + !context.selected.some((selected) => JSON.stringify(selected.key) === JSON.stringify(element.key)))]; + return { + id, + ops: [{ + op: 'set-presentation', + targets: elements.length > 0 ? [{ visual: target.visual, elements }] : [], + value: { state: elements.length > 0 ? 'emphasized' : 'normal', mutedOpacity: dimOpacity }, + }], + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/selection-state.ts b/packages/flint-js/src/interactive/selection-state.ts deleted file mode 100644 index e043d56b..00000000 --- a/packages/flint-js/src/interactive/selection-state.ts +++ /dev/null @@ -1,34 +0,0 @@ -export class ScopedSelectionState { - private legacy = new Set(); - private readonly scopes = new Map>(); - - combined(): Set { - return new Set([ - ...this.legacy, - ...[...this.scopes.values()].flatMap((keys) => [...keys]), - ]); - } - - get(updateId?: string): ReadonlySet { - return updateId ? this.scopes.get(updateId) ?? new Set() : this.legacy; - } - - set(keys: ReadonlySet, updateId?: string): void { - const next = new Set(keys); - if (!updateId) { - this.legacy = next; - } else if (next.size > 0) { - this.scopes.set(updateId, next); - } else { - this.scopes.delete(updateId); - } - } - - clear(updateId?: string): void { - if (updateId) this.scopes.delete(updateId); - else { - this.legacy.clear(); - this.scopes.clear(); - } - } -} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/surface.ts b/packages/flint-js/src/interactive/surface.ts index 13b3f358..647f0006 100644 --- a/packages/flint-js/src/interactive/surface.ts +++ b/packages/flint-js/src/interactive/surface.ts @@ -7,7 +7,8 @@ import type { ViewportChannel, ViewportState, } from './types'; -import type { ChartUpdateRequest, ChartUpdateResult } from './updates/request'; +import { isExternalInteraction } from './interactions'; +import type { ChartUpdate, ChartUpdateResult } from './language/updates'; let generatedChartId = 0; @@ -180,10 +181,13 @@ export function mountInteractiveChartSurface( const chart = document.createElement('div'); const state: ViewportState = {}; const rails = new Map>(); + const interactions = options.interactions ?? []; + const externalInteractions = new Map( + interactions.filter(isExternalInteraction).map((interaction) => [interaction.id, interaction]), + ); let renderer: InteractiveRenderer | undefined; let updateTimer: number | undefined; let destroyed = false; - const pendingEvents: import('./interactions').ExternalInteractionEvent[] = []; root.className = options.className ?? 'flint-interactive-surface'; root.setAttribute('role', 'figure'); @@ -191,10 +195,11 @@ export function mountInteractiveChartSurface( root.dataset.flintChartId = chartId; applyStyles(root, { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'minmax(0, auto) auto', - alignItems: 'stretch', rowGap: '6px', minWidth: '0', maxWidth: '100%', marginInline: 'auto', + alignItems: 'stretch', rowGap: '6px', minWidth: '0', }); chart.dataset.flintChart = ''; - applyStyles(chart, { gridColumn: '1', gridRow: '1', minWidth: '0', overflow: 'hidden' }); + // The chart keeps its compiled width; handling any overflow is the host's decision. + applyStyles(chart, { gridColumn: '1', gridRow: '1', minWidth: '0' }); root.append(chart); container.replaceChildren(root); @@ -212,20 +217,23 @@ export function mountInteractiveChartSurface( rails.get(channel)?.update(state[channel] ?? 0); scheduleRender(); }; - const unsupportedUpdate = (update: ChartUpdateRequest): ChartUpdateResult => ({ + const unsupportedUpdate = (update: ChartUpdate): ChartUpdateResult => ({ status: 'unsupported', resolvedTargets: 0, unresolvedTargets: [], unsupportedOps: [...new Set(update.ops.map((op) => op.op))], }); - const ready = adapter.mount(chart, input).then((mounted) => { + const ready = adapter.mount(chart, input).then(async (mounted) => { if (destroyed) { mounted.destroy(); return; } renderer = mounted; - for (const event of pendingEvents.splice(0)) void mounted.dispatchInteraction?.(event); + if ((options.updates?.length ?? 0) > 0) { + if (!mounted.setUpdates) throw new Error('This interactive backend does not support chart updates.'); + await mounted.setUpdates(options.updates ?? []); + } for (const viewport of mounted.viewports) { state[viewport.channel] = 0; const rail = createViewportRail(viewport, 0, (start) => setViewport(viewport.channel, start)); @@ -247,8 +255,8 @@ export function mountInteractiveChartSurface( const yGeometry = renderer?.getViewportGeometry?.('y'); rails.get('x')?.setGeometry(xGeometry?.offset ?? 0, xGeometry?.extent ?? extent.width); rails.get('y')?.setGeometry(yGeometry?.offset ?? 0, yGeometry?.extent ?? extent.height); - const verticalRailGutter = rails.has('y') ? RAIL_THICKNESS + RAIL_GAP : 0; - root.style.width = `${Math.ceil(extent.width + verticalRailGutter)}px`; + // Sizing to content keeps the surface honest when the chart is wider than its host. + root.style.width = 'max-content'; }; syncRailExtents(); window.setTimeout(syncRailExtents, 0); @@ -260,19 +268,42 @@ export function mountInteractiveChartSurface( ready, getViewportState: () => ({ ...state }), setViewport, - dispatch: (event) => { - if (destroyed) return; - if (renderer) void renderer.dispatchInteraction?.(event); - else pendingEvents.push(event); + dispatch: async (interactionId, payload) => { + await ready; + if (destroyed) return null; + const interaction = externalInteractions.get(interactionId); + if (!interaction) { + const definition = interactions.find((candidate) => candidate.id === interactionId); + throw new Error(definition + ? `Interaction "${interactionId}" is a canvas interaction and cannot receive external payloads.` + : `External interaction "${interactionId}" is not defined.`); + } + if (!renderer?.getInteractionContext) { + throw new Error('This interactive backend does not provide interaction context.'); + } + const update = interaction.handle(payload, renderer.getInteractionContext()); + if (!update) return null; + if (!renderer.applyUpdate) return unsupportedUpdate(update); + return renderer.applyUpdate(update); }, - applyUpdate: async (update) => { + applyUpdate: async (update, applyOptions) => { await ready; if (destroyed || !renderer?.applyUpdate) return unsupportedUpdate(update); - return renderer.applyUpdate(update); + return renderer.applyUpdate(update, applyOptions); }, - clearUpdate: async (updateId) => { + setUpdates: async (updates) => { await ready; - if (!destroyed) await renderer?.clearUpdate?.(updateId); + if (destroyed || !renderer?.setUpdates) { + return updates.map(unsupportedUpdate); + } + return renderer.setUpdates(updates); + }, + clearUpdate: async (id) => { + await ready; + if (!destroyed) await renderer?.clearUpdate?.(id); + }, + refresh: () => { + if (!destroyed) renderer?.refresh?.(); }, destroy: () => { if (destroyed) return; diff --git a/packages/flint-js/src/interactive/triggers/index.ts b/packages/flint-js/src/interactive/triggers.ts similarity index 71% rename from packages/flint-js/src/interactive/triggers/index.ts rename to packages/flint-js/src/interactive/triggers.ts index 4a2d244a..47a951d9 100644 --- a/packages/flint-js/src/interactive/triggers/index.ts +++ b/packages/flint-js/src/interactive/triggers.ts @@ -1,31 +1,7 @@ -import type { NavigationAxes, NormalizedInteractionEvent } from './events'; - -export type { - ElementInteractionEvent, - ExternalInteractionEvent, - InteractionModifiers, - InteractionPhase, - NavigationAxes, - NavigationInteractionEvent, - NavigationOperation, - NormalizedInteractionEvent, - PlotPoint, - PlotAngularSector, - PlotPolygon, - PlotRect, - RegionAxis, - RegionInteractionEvent, - RegionOperation, - SemanticInteractionEvent, -} from './events'; - -export interface InteractionEventSourceContext { - readonly container: HTMLElement; - emit(event: NormalizedInteractionEvent): void; -} +import type { NavigationAxes } from './language/events'; export interface InteractionEventSource { - readonly type: 'element' | 'region' | 'external' | (string & {}); + readonly type: 'element' | 'region' | (string & {}); readonly gesture?: 'click' | 'hover' | 'drag' | 'drag-element' | 'navigate'; readonly match?: 'intersect' | 'contain'; readonly axis?: 'x' | 'y' | 'xy'; @@ -35,8 +11,6 @@ export interface InteractionEventSource { readonly pan?: boolean; readonly zoom?: boolean; readonly wheelSensitivity?: number; - readonly source?: string; - mount?(context: InteractionEventSourceContext): void | (() => void); } export function elementDragTrigger(): InteractionEventSource { @@ -102,7 +76,3 @@ export function navigationTrigger(options: { wheelSensitivity: options.wheelSensitivity ?? 0.002, }; } - -export function externalTrigger(source?: string): InteractionEventSource { - return { type: 'external', source }; -} diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index dc5ce0c4..7a64e0d6 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -1,6 +1,6 @@ import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; -import type { ExternalInteractionEvent, InteractionDef } from './interactions'; -import type { ChartUpdateRequest, ChartUpdateResult } from './updates/request'; +import type { InteractionContext, InteractionDef } from './interactions'; +import type { ChartUpdate, ChartUpdateResult } from './language/updates'; export type ViewportChannel = 'x' | 'y'; export type ViewportState = Partial>; @@ -10,14 +10,23 @@ export interface ViewportGeometry { extent: number; } +export type ChartUpdateComposition = 'auto'; + +export interface ChartUpdateApplyOptions { + composition?: ChartUpdateComposition; +} + export interface InteractiveRenderer { viewports: CategoryViewport[]; setViewports(starts: ViewportState): void | Promise; getViewportGeometry?(channel: ViewportChannel): ViewportGeometry | undefined; + getInteractionContext?(): InteractionContext; resize?(size: { width: number; height: number }): void | Promise; - dispatchInteraction?(event: ExternalInteractionEvent): void | Promise; - applyUpdate?(update: ChartUpdateRequest): Promise; - clearUpdate?(updateId: string): Promise; + /** Re-project overlays after the host rescales the chart in a way CSS cannot report. */ + refresh?(): void; + applyUpdate?(update: ChartUpdate, options?: ChartUpdateApplyOptions): Promise; + setUpdates?(updates: readonly ChartUpdate[]): Promise; + clearUpdate?(id: string): Promise; destroy(): void; } @@ -29,6 +38,8 @@ export interface InteractiveChartSurfaceOptions { className?: string; ariaLabel?: string; chartId?: string; + updates?: readonly ChartUpdate[]; + interactions?: readonly InteractionDef[]; } export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; @@ -36,10 +47,6 @@ export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; export interface BuildInteractiveChartOptions extends InteractiveChartSurfaceOptions { backend: InteractiveBackend; renderer?: 'canvas' | 'svg'; - /** Semantic interactions to enable. Omit for viewport controls only. */ - interactions?: readonly InteractionDef[]; - /** @deprecated Use `interactions: [clickHighlight()]`. */ - focusOnClick?: boolean; expressionInterpreter?: unknown; background?: string; } @@ -50,8 +57,10 @@ export interface InteractiveChartSurface { readonly ready: Promise; getViewportState(): ViewportState; setViewport(channel: ViewportChannel, start: number): void; - dispatch(event: ExternalInteractionEvent): void; - applyUpdate(update: ChartUpdateRequest): Promise; - clearUpdate(updateId: string): Promise; + dispatch(interactionId: string, payload: unknown): Promise; + applyUpdate(update: ChartUpdate, options?: ChartUpdateApplyOptions): Promise; + setUpdates(updates: readonly ChartUpdate[]): Promise; + clearUpdate(id: string): Promise; + refresh(): void; destroy(): void; } \ No newline at end of file diff --git a/packages/flint-js/src/interactive/updates/emphasis.ts b/packages/flint-js/src/interactive/updates/emphasis.ts deleted file mode 100644 index f2d00a3d..00000000 --- a/packages/flint-js/src/interactive/updates/emphasis.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { - CanvasInteractionEvent, - ChartUpdateRequest, - InteractionModifiers, - SemanticTarget, -} from '../interactions'; - -export const DEFAULT_DIM_OPACITY = 0.25; - -export function normalizedOpacity(value: number | undefined): number { - if (value === undefined || !Number.isFinite(value)) return DEFAULT_DIM_OPACITY; - return Math.min(1, Math.max(0, value)); -} - -function selectionMode(modifiers: InteractionModifiers | undefined): 'replace' | 'toggle' { - return modifiers?.shift || modifiers?.ctrl || modifiers?.meta ? 'toggle' : 'replace'; -} - -export function applySelectionMode( - current: ReadonlySet, - keys: readonly string[], - mode: 'replace' | 'toggle', -): Set { - if (mode === 'replace') return new Set(keys); - const next = new Set(current); - const allSelected = keys.every((key) => next.has(key)); - for (const key of keys) { - if (allSelected) next.delete(key); - else next.add(key); - } - return next; -} - -export function emphasisUpdate( - updateId: string, - event: Pick, - target: SemanticTarget | null, - dimOpacity: number, -): ChartUpdateRequest | null { - if (!target) return { updateId, phase: event.phase, ops: [{ op: 'reset' }] }; - if (target.elements.length === 0) return null; - return { - updateId, - phase: event.phase, - ops: [{ - op: 'emphasize', - targets: [target], - mode: selectionMode(event.modifiers), - dimOpacity, - }], - }; -} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/updates/index.ts b/packages/flint-js/src/interactive/updates/index.ts deleted file mode 100644 index dc892768..00000000 --- a/packages/flint-js/src/interactive/updates/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './annotation'; -export * from './emphasis'; -export * from './request'; \ No newline at end of file diff --git a/packages/flint-js/src/interactive/updates/request.ts b/packages/flint-js/src/interactive/updates/request.ts deleted file mode 100644 index ef713fc7..00000000 --- a/packages/flint-js/src/interactive/updates/request.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { SemanticTarget } from '../../core/interaction-semantics'; -import type { - InteractionPhase, - NavigationAxes, - NavigationDomainGuard, - NavigationOperation, - PlotPoint, - SelectionMode, -} from '../interactions'; - -export interface SemanticTargetRef { - visual: SemanticTarget['visual']; - elements: readonly { key: Record }[]; -} - -export interface SemanticTargetSelector { - select: { - key: Record; - visual?: Partial; - }; -} - -export type UpdateTarget = SemanticTargetRef | SemanticTargetSelector; - -export function matchesSemanticTargetSelector( - selector: SemanticTargetSelector, - declaredFields: readonly string[], - value: Readonly>, -): boolean { - const entries = Object.entries(selector.select.key); - return entries.length > 0 - && entries.every(([field]) => declaredFields.includes(field)) - && entries.every(([field, expected]) => Object.is(value[field], expected)); -} - -export type ChartUpdateRequestOp = - | { - op: 'emphasize'; - targets: readonly UpdateTarget[]; - mode: SelectionMode; - dimOpacity: number; - } - | { op: 'annotate'; target: UpdateTarget; text?: string } - | { op: 'clear-annotation' } - | { - op: 'navigate-viewport'; - operation: NavigationOperation; - axes: NavigationAxes; - delta?: PlotPoint; - factor?: number; - anchor?: PlotPoint; - domainGuard: NavigationDomainGuard; - } - | { op: 'reorder-category'; axis: 'x' | 'y'; field: string; orderedValues: readonly unknown[] } - | { op: 'reset' }; - -export interface ChartUpdateRequest { - updateId: string; - phase?: InteractionPhase; - transactionId?: string; - ops: readonly ChartUpdateRequestOp[]; -} - -export interface ChartUpdateResult { - status: 'applied' | 'partially-applied' | 'unsupported'; - resolvedTargets: number; - unresolvedTargets: readonly UpdateTarget[]; - unsupportedOps: readonly ChartUpdateRequestOp['op'][]; -} - -export function emphasize( - options: Omit, 'op' | 'mode' | 'dimOpacity'> & { - mode?: SelectionMode; - dimOpacity?: number; - }, -): ChartUpdateRequestOp { - return { - op: 'emphasize', - mode: options.mode ?? 'replace', - dimOpacity: options.dimOpacity ?? 0.25, - targets: options.targets, - }; -} - -export function annotate(options: Omit, 'op'>): ChartUpdateRequestOp { - return { op: 'annotate', ...options }; -} - -export function clearAnnotation(): ChartUpdateRequestOp { - return { op: 'clear-annotation' }; -} - -export function navigateViewport( - options: Omit, 'op' | 'domainGuard'> & { - domainGuard?: NavigationDomainGuard; - }, -): ChartUpdateRequestOp { - return { - op: 'navigate-viewport', - ...options, - domainGuard: options.domainGuard ?? { - minVisibleFraction: 0.02, - maxVisibleFraction: 1, - overscrollFraction: 0, - }, - }; -} - -export function resetUpdate(): ChartUpdateRequestOp { - return { op: 'reset' }; -} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 0f2588d9..c7c89a3e 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -905,6 +905,9 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { ...(chartTemplate.reorder && chartTemplate.reorder.includeConnectiveMarks ? { includeConnectiveMarks: true } : {}), + ...(chartTemplate.reorder && chartTemplate.reorder.markTypes + ? { markTypes: chartTemplate.reorder.markTypes } + : {}), }] : []; }) diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index bdd9e4fc..2b88bdc0 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -1,7 +1,12 @@ import type { ChartInteractionResolver } from '../../core/interaction-semantics'; -import type { ChartUpdatePresenter, InteractionDef } from '../../interactive/interactions'; +import { + isCanvasInteraction, + type ChartUpdatePresenter, + type InteractionContext, + type InteractionDef, +} from '../../interactive/interactions'; import { toCanvasInteractionEvent } from '../../interactive/canvas-interaction'; -import { DEFAULT_DIM_OPACITY } from '../../interactive/updates/emphasis'; +import { DEFAULT_DIM_OPACITY } from '../../interactive/presets/utils'; import { INTERACTION_PROVENANCE, type InteractionProvenance } from '../interaction-provenance'; import type { HoverStyle, @@ -24,12 +29,14 @@ interface TemplateInteractionSemantics { fields: string[]; categoryField?: string; seriesField?: string; + resolveGroupValue?: InteractionContext['resolveGroupValue']; legendFields?: Record; selectableMarks: string[]; + annotationMarkType?: string; supportedRegionGestures?: ('cartesian' | 'angular')[]; navigationAxes?: ('x' | 'y')[]; - reorderAxis?: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean }; - reorderAxes?: readonly { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean }[]; + reorderAxis?: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }; + reorderAxes?: readonly { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }[]; renderHoverStyles?: Record; renderSelectionStyles?: Record; selectionBoundary?: SelectionBoundaryStyle; @@ -198,7 +205,9 @@ function addLocalKeyTransforms( const type = markType(spec.mark); const provenance = spec[INTERACTION_PROVENANCE] as InteractionProvenance | undefined; if (provenance?.role === 'decorative') return; - if (type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type) && spec.data) { + // A composition can hoist `data` to an ancestor, so a unit is keyed on its + // own mark rather than on owning a data source. + if (type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type)) { spec.transform = [ ...(Array.isArray(spec.transform) ? spec.transform : []), { calculate: keyExpression(fields), as: INTERACTION_KEY }, @@ -237,26 +246,26 @@ function clipNavigableMarks(spec: Record): void { export function addVegaLiteInteractions( spec: Record, interactions: readonly InteractionDef[], + enableSemanticUpdates = false, ): VegaInteractionPlan | null { - if (interactions.length === 0) return null; + if (interactions.length === 0 && !enableSemanticUpdates) return null; + const canvasInteractions = interactions.filter(isCanvasInteraction); const templateSemantics = spec._interactionSemantics as TemplateInteractionSemantics | undefined; delete spec._interactionSemantics; - const reorderInteraction = interactions.find( + const reorderInteraction = canvasInteractions.find( (interaction) => interaction.eventSource.gesture === 'drag-element', ); if (!templateSemantics) { if (reorderInteraction) { throw new Error(`Interaction "${reorderInteraction.id}" requires a chart with a reorderable category axis.`); } - const builtInInteraction = interactions.find( - (interaction) => interaction.eventSource.type !== 'external', - ); + const builtInInteraction = canvasInteractions[0]; if (builtInInteraction) { throw new Error(`Interaction "${builtInInteraction.id}" requires chart interaction semantics.`); } return null; } - const navigationInteraction = interactions.find( + const navigationInteraction = canvasInteractions.find( (interaction) => interaction.eventSource.type === 'navigation', ); const declaredReorderAxes = templateSemantics.reorderAxes @@ -264,7 +273,7 @@ export function addVegaLiteInteractions( if (reorderInteraction && declaredReorderAxes.length === 0) { throw new Error(`Interaction "${reorderInteraction.id}" requires a chart with a reorderable category axis.`); } - const semanticGestureInteraction = interactions.find( + const semanticGestureInteraction = canvasInteractions.find( (interaction) => interaction.eventSource.type === 'element' || interaction.eventSource.type === 'region', ); @@ -274,9 +283,12 @@ export function addVegaLiteInteractions( && templateSemantics.selectableMarks.length === 0) { throw new Error(`Interaction "${semanticGestureInteraction.id}" requires chart element semantics.`); } - const semanticInteractions = interactions.filter( + const semanticInteractions = canvasInteractions.filter( (interaction) => interaction.eventSource.type !== 'navigation', ); + const needsSemanticPresentation = enableSemanticUpdates + || semanticInteractions.length > 0 + || canvasInteractions.length < interactions.length; if (navigationInteraction?.eventSource.pan && semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag')) { throw new Error('Pan navigation cannot share an unmodified drag gesture with a region interaction.'); @@ -300,7 +312,7 @@ export function addVegaLiteInteractions( `Interaction "${navigationInteraction?.id}" requested unsupported navigation axis: ${unsupportedNavigationAxes.join(', ')}.`, ); } - const angularInteraction = interactions.find( + const angularInteraction = canvasInteractions.find( (interaction) => interaction.eventSource.regionGeometry === 'angular', ); if (angularInteraction && !templateSemantics.supportedRegionGestures?.includes('angular')) { @@ -310,11 +322,11 @@ export function addVegaLiteInteractions( } const selectableMarks = new Set(templateSemantics.selectableMarks ?? SUPPORTED_SPEC_MARKS); const fields = templateSemantics.fields ?? []; - if (semanticInteractions.length > 0) expandInteractiveLinePoints(spec); + if (needsSemanticPresentation) expandInteractiveLinePoints(spec); if (navigationInteraction) clipNavigableMarks(spec); const dimOpacity = semanticInteractions.reduce((value, interaction) => { - if (interaction.eventSource.type === 'external' || !interaction.handle) return value; + if (!interaction.handle) return value; const semanticEvent = { type: 'semantic', source: interaction.eventSource.type === 'region' ? 'region' : 'element', @@ -326,16 +338,18 @@ export function addVegaLiteInteractions( } as const; const interactionContext = { chartType: 'Unknown', selected: [] }; const update = interaction.handle(toCanvasInteractionEvent(semanticEvent, interaction.eventSource), interactionContext); - const emphasize = update?.ops.find((op) => op.op === 'emphasize'); - return emphasize?.op === 'emphasize' ? Math.min(value, emphasize.dimOpacity) : value; + const presentation = update?.ops.find((op) => op.op === 'set-presentation'); + return presentation?.op === 'set-presentation' + ? Math.min(value, presentation.value.mutedOpacity ?? DEFAULT_DIM_OPACITY) + : value; }, DEFAULT_DIM_OPACITY); const clickCursor = semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'click') && !semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag'); - const instrumented = semanticInteractions.length > 0 + const instrumented = needsSemanticPresentation ? instrumentMarks(spec, {}, fields, dimOpacity, selectableMarks, clickCursor) : false; - if (semanticInteractions.length > 0 && !instrumented) return null; + if (needsSemanticPresentation && !instrumented) return null; if (instrumented) addLocalKeyTransforms(spec, fields, selectableMarks); stripInteractionProvenance(spec); if (instrumented) { @@ -348,34 +362,55 @@ export function addVegaLiteInteractions( fields, categoryField: templateSemantics.categoryField, seriesField: templateSemantics.seriesField, + resolveGroupValue: templateSemantics.resolveGroupValue, legendFields: templateSemantics.legendFields, + annotationMarkType: templateSemantics.annotationMarkType, + semanticStores: instrumented, dimOpacity, renderHoverStyles: templateSemantics.renderHoverStyles, renderSelectionStyles: templateSemantics.renderSelectionStyles, selectionBoundary: templateSemantics.selectionBoundary, navigationChannels: [...requestedNavigationAxes], - reorderAxis: declaredReorderAxes[0] + reorderAxis: reorderInteraction && declaredReorderAxes[0] ? { ...declaredReorderAxes[0], scale: '', signal: '' } : undefined, - reorderAxes: declaredReorderAxes.map((axis) => ({ ...axis, scale: '', signal: '' })), + reorderAxes: reorderInteraction + ? declaredReorderAxes.map((axis) => ({ ...axis, scale: '', signal: '' })) + : [], resolve: templateSemantics.resolve, presentUpdate: templateSemantics.presentUpdate, }; } +/** + * Composed specs (a themed `vconcat`, for example) rename `x` to `concat_0_x`, + * so an axis is matched by suffix when it is unambiguous. + */ +export function findVegaAxisScale( + vegaSpec: Record, + axis: 'x' | 'y', +): Record | undefined { + const scales: any[] = vegaSpec.scales ?? []; + const exact = scales.find((candidate) => candidate.name === axis); + if (exact) return exact; + const suffixed = scales.filter((candidate) => typeof candidate.name === 'string' + && candidate.name.endsWith(`_${axis}`)); + return suffixed.length === 1 ? suffixed[0] : undefined; +} + export function injectVegaReorderSignal( vegaSpec: Record, - reorderAxis: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean } | undefined, + reorderAxis: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] } | undefined, ): import('./contracts').VegaReorderAxis | undefined { if (!reorderAxis) return undefined; - const scale = (vegaSpec.scales ?? []).find((candidate: any) => candidate.name === reorderAxis.axis); + const scale = findVegaAxisScale(vegaSpec, reorderAxis.axis); if (!scale || !['band', 'point', 'ordinal'].includes(scale.type)) { throw new Error(`Vega category reorder requires a top-level discrete "${reorderAxis.axis}" scale.`); } const signal = `__flint_reorder_${reorderAxis.axis}_domain`; vegaSpec.signals = [...(vegaSpec.signals ?? []), { name: signal, value: null }]; scale.domainRaw = { signal }; - return { ...reorderAxis, scale: reorderAxis.axis, signal }; + return { ...reorderAxis, scale: scale.name, signal }; } export function injectVegaNavigationSignals( @@ -384,14 +419,14 @@ export function injectVegaNavigationSignals( ): Partial> { const result: Partial> = {}; for (const channel of channels) { - const scale = (vegaSpec.scales ?? []).find((candidate: any) => candidate.name === channel); + const scale = findVegaAxisScale(vegaSpec, channel); if (!scale || !['linear', 'log', 'time', 'utc'].includes(scale.type)) { throw new Error(`Vega navigation requires a top-level continuous "${channel}" scale.`); } const signal = `__flint_navigation_${channel}_domain`; vegaSpec.signals = [...(vegaSpec.signals ?? []), { name: signal, value: null }]; scale.domainRaw = { signal }; - result[channel] = { scale: channel, signal, type: scale.type }; + result[channel] = { scale: scale.name, signal, type: scale.type }; } return result; } diff --git a/packages/flint-js/src/vegalite/interactions/contracts.ts b/packages/flint-js/src/vegalite/interactions/contracts.ts index 51e4e031..085c5e97 100644 --- a/packages/flint-js/src/vegalite/interactions/contracts.ts +++ b/packages/flint-js/src/vegalite/interactions/contracts.ts @@ -1,5 +1,5 @@ import type { ChartInteractionResolver } from '../../core/interaction-semantics'; -import type { ChartUpdatePresenter } from '../../interactive/interactions'; +import type { ChartUpdatePresenter, InteractionContext } from '../../interactive/interactions'; export interface HoverStyle { fill?: string; @@ -33,6 +33,7 @@ export interface VegaReorderAxis { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; + markTypes?: readonly string[]; scale: string; signal: string; } @@ -41,7 +42,11 @@ export interface VegaInteractionPlan { fields: readonly string[]; categoryField?: string; seriesField?: string; + resolveGroupValue?: InteractionContext['resolveGroupValue']; legendFields?: Readonly>; + annotationMarkType?: string; + /** The compiled spec carries the semantic selection stores. */ + semanticStores?: boolean; dimOpacity: number; renderHoverStyles?: Readonly>; renderSelectionStyles?: Readonly>; diff --git a/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts index d02087f9..75f20e9a 100644 --- a/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts +++ b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts @@ -1,5 +1,5 @@ import type { - InteractionDef, + CanvasInteractionDef, NavigationAxes, NavigationInteractionEvent, PlotPoint, @@ -9,7 +9,7 @@ import { clientToPlotPoint, interactionModifiers, type RendererCoordinateSpace } export interface VegaNavigationGestureOptions { container: HTMLElement; - interaction: InteractionDef; + interaction: CanvasInteractionDef; availableAxes: readonly ('x' | 'y')[]; coordinateSpace(): RendererCoordinateSpace; dispatch(event: NavigationInteractionEvent): Promise; @@ -51,9 +51,11 @@ export function mountVegaNavigationGesture( const previousCursor = container.style.cursor; const previousTouchAction = container.style.touchAction; + const previousUserSelect = container.style.userSelect; if (source.pan) { container.style.cursor = 'grab'; container.style.touchAction = 'none'; + container.style.userSelect = 'none'; } const localPoint = (event: PointerEvent): PlotPoint => clientToPlotPoint( @@ -168,6 +170,7 @@ export function mountVegaNavigationGesture( container.removeEventListener('dblclick', doubleClick); container.style.cursor = previousCursor; container.style.touchAction = previousTouchAction; + container.style.userSelect = previousUserSelect; setDragging(false); }, }; diff --git a/packages/flint-js/src/vegalite/interactions/gestures/region.ts b/packages/flint-js/src/vegalite/interactions/gestures/region.ts index 775a38e8..7fb1f39c 100644 --- a/packages/flint-js/src/vegalite/interactions/gestures/region.ts +++ b/packages/flint-js/src/vegalite/interactions/gestures/region.ts @@ -1,5 +1,5 @@ import type { - InteractionDef, + CanvasInteractionDef, PlotAngularSector, PlotPoint, RenderHit, @@ -33,7 +33,7 @@ import { export interface VegaRegionGestureOptions { view: any; container: HTMLElement; - interaction: InteractionDef; + interaction: CanvasInteractionDef; getSelected(): ReadonlySet; setSelected(selected: Set): void; coordinateSpace(): RendererCoordinateSpace; @@ -52,6 +52,7 @@ export interface VegaRegionGestureOptions { } export interface VegaRegionGestureController { + sync(): void; destroy(): void; } @@ -357,6 +358,9 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR container.addEventListener('keydown', keyDown); return { + sync(): void { + if (statefulBrush && activeInterval) showInterval(activeInterval); + }, destroy(): void { container.removeEventListener('pointerdown', pointerDown, true); container.removeEventListener('pointermove', pointerMove, true); diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts index 27fb14df..947ab198 100644 --- a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -8,7 +8,7 @@ import type { RegionAxis, RegionInteractionEvent, RegionOperation, -} from '../../interactive/triggers/events'; +} from '../../interactive/language/events'; import { angularSegments } from '../../interactive/geometry/angular'; export { clientRectToLayoutRect, @@ -16,6 +16,7 @@ export { clientToPlotPoint, interactionModifiers, plotToClientPoint, + rendererPlotOrigin, type RendererCoordinateSpace, } from '../../interactive/geometry/coordinate-space'; @@ -80,17 +81,29 @@ function pathGeometry(item: any, offsetX: number, offsetY: number): PathGeometry endDatum: items[index + 1].datum, }; } - if (item.mark.marktype !== 'area' || typeof item.y2 !== 'number') return null; + if (item.mark.marktype !== 'area' + || (typeof item.y2 !== 'number' && typeof item.x2 !== 'number')) return null; const next = items[index + 1]; - if (!next || typeof next.y2 !== 'number') return null; + if (!next) return null; const annotationPoints = [point(item), point(next)]; + const secondaryPoints = typeof item.y2 === 'number' && typeof next.y2 === 'number' + ? [ + { x: next.x + offsetX, y: next.y2 + offsetY }, + { x: item.x + offsetX, y: item.y2 + offsetY }, + ] + : typeof item.x2 === 'number' && typeof next.x2 === 'number' + ? [ + { x: next.x2 + offsetX, y: next.y + offsetY }, + { x: item.x2 + offsetX, y: item.y + offsetY }, + ] + : undefined; + if (!secondaryPoints) return null; return { kind: 'slice', points: [ point(item), point(next), - { x: next.x + offsetX, y: next.y2 + offsetY }, - { x: item.x + offsetX, y: item.y2 + offsetY }, + ...secondaryPoints, ], annotationPoints, offset: { x: offsetX, y: offsetY }, diff --git a/packages/flint-js/src/vegalite/interactions/navigation-scale.ts b/packages/flint-js/src/vegalite/interactions/navigation-scale.ts index 32ed9d9c..6bbb6f64 100644 --- a/packages/flint-js/src/vegalite/interactions/navigation-scale.ts +++ b/packages/flint-js/src/vegalite/interactions/navigation-scale.ts @@ -1,7 +1,10 @@ -import type { NavigationDomainGuard, UpdateOp } from '../../interactive/interactions'; +import type { + NavigationDomainGuard, + NavigationRequest, + NavigationUpdate, +} from '../../interactive/interactions'; import type { VegaNavigationAxis } from './contracts'; -type NavigationUpdate = Extract; type Axis = 'x' | 'y'; interface AxisState extends VegaNavigationAxis { @@ -58,7 +61,8 @@ export function guardNavigationDomain( } export interface VegaNavigationController { - apply(update: NavigationUpdate): Promise; + resolve(event: NavigationRequest, guard: NavigationDomainGuard): NavigationUpdate | null; + apply(update: NavigationUpdate): boolean; } export function createVegaNavigationController( @@ -69,41 +73,19 @@ export function createVegaNavigationController( const domain = view.scale(config.scale).domain(); return [axis, { ...config, initialDomain: [domain[0], domain[domain.length - 1]] }]; })) as Partial>; - let gestureSnapshot: Partial> | undefined; - const affectedAxes = (axesValue: NavigationUpdate['axes']): Axis[] => { const requested: Axis[] = axesValue === 'xy' ? ['x', 'y'] : [axesValue]; return requested.filter((axis) => states[axis]); }; return { - async apply(update): Promise { - const activeAxes = affectedAxes(update.axes); - if (update.phase === 'start') { - gestureSnapshot = Object.fromEntries(activeAxes.map((axis) => { - const state = states[axis]!; - const domain = view.scale(state.scale).domain(); - return [axis, [domain[0], domain[domain.length - 1]]]; - })); - return; - } - - let changed = false; - for (const axis of activeAxes) { + resolve(event, guard): NavigationUpdate | null { + if (event.phase === 'start' || event.phase === 'cancel' + || (event.phase === 'commit' && event.operation === 'pan' && !event.delta)) return null; + if (event.operation === 'reset') return { op: 'set-viewport', axes: event.axes, value: {} }; + const value: { x?: [unknown, unknown]; y?: [unknown, unknown] } = {}; + for (const axis of affectedAxes(event.axes)) { const state = states[axis]!; - if (update.phase === 'cancel') { - const snapshot = gestureSnapshot?.[axis]; - if (snapshot) { - view.signal(state.signal, snapshot); - changed = true; - } - continue; - } - if (update.operation === 'reset') { - view.signal(state.signal, null); - changed = true; - continue; - } const scale = view.scale(state.scale); const domain = scale.domain(); const current: [unknown, unknown] = [domain[0], domain[domain.length - 1]]; @@ -112,29 +94,38 @@ export function createVegaNavigationController( const rangeEnd = Number(range[range.length - 1]); const rangeExtent = Math.abs(rangeEnd - rangeStart); let proposed: [unknown, unknown] | undefined; - if (update.operation === 'pan' && update.delta) { - const fraction = axis === 'x' ? update.delta.x : update.delta.y; + if (event.operation === 'pan' && event.delta) { + const fraction = axis === 'x' ? event.delta.x : event.delta.y; const pixelDelta = fraction * rangeExtent; proposed = [scale.invert(rangeStart - pixelDelta), scale.invert(rangeEnd - pixelDelta)]; - } else if (update.operation === 'zoom' && update.factor && update.factor > 0 && update.anchor) { - const fraction = axis === 'x' ? update.anchor.x : update.anchor.y; + } else if (event.operation === 'zoom' && event.factor && event.factor > 0 && event.anchor) { + const fraction = axis === 'x' ? event.anchor.x : event.anchor.y; const anchor = Math.min(rangeStart, rangeEnd) + fraction * rangeExtent; proposed = [ - scale.invert(anchor + (rangeStart - anchor) / update.factor), - scale.invert(anchor + (rangeEnd - anchor) / update.factor), + scale.invert(anchor + (rangeStart - anchor) / event.factor), + scale.invert(anchor + (rangeEnd - anchor) / event.factor), ]; } if (!proposed) continue; - view.signal(state.signal, guardNavigationDomain( + value[axis] = guardNavigationDomain( proposed, state.initialDomain, state.type, - update.domainGuard, - )); + guard, + ); + } + return Object.keys(value).length > 0 + ? { op: 'set-viewport', axes: event.axes, value } + : null; + }, + apply(update): boolean { + let changed = false; + for (const axis of affectedAxes(update.axes)) { + const state = states[axis]!; + view.signal(state.signal, update.value[axis] ?? null); changed = true; } - if (update.phase === 'commit' || update.phase === 'cancel') gestureSnapshot = undefined; - if (changed) await view.runAsync(); + return changed; }, }; } diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-leader-routing.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-leader-routing.ts new file mode 100644 index 00000000..fff3524c --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/annotation-leader-routing.ts @@ -0,0 +1,277 @@ +import type { PlotPoint } from '../../../interactive/interactions'; + +export type AnnotationPortEdge = 'top' | 'right' | 'bottom' | 'left'; + +export interface AnnotationRouteRect { + left: number; + top: number; + width: number; + height: number; +} + +export interface AnnotationLeaderPort extends PlotPoint { + edge: AnnotationPortEdge; + fraction: number; +} + +export interface AnnotationLeaderRoute { + source: PlotPoint; + port: AnnotationLeaderPort; + points: readonly PlotPoint[]; +} + +const SIDE_PORT_FRACTIONS = [0.25, 0.5, 0.75] as const; +const HORIZONTAL_PORT_FRACTIONS = [0.25, 0.75] as const; +const EDGE_ORDER: readonly AnnotationPortEdge[] = ['top', 'right', 'bottom', 'left']; +const AXIS_DOMINANCE_RATIO = 1.5; +const EPSILON = 1e-6; + +export function annotationLeaderPorts(card: AnnotationRouteRect): readonly AnnotationLeaderPort[] { + return EDGE_ORDER.flatMap((edge) => { + const fractions = edge === 'top' || edge === 'bottom' + ? HORIZONTAL_PORT_FRACTIONS + : SIDE_PORT_FRACTIONS; + return fractions.map((fraction) => { + if (edge === 'top' || edge === 'bottom') { + return { + edge, + fraction, + x: card.left + card.width * fraction, + y: edge === 'top' ? card.top : card.top + card.height, + }; + } + return { + edge, + fraction, + x: edge === 'left' ? card.left : card.left + card.width, + y: card.top + card.height * fraction, + }; + }); + }); +} + +export function annotationFacingEdges( + source: PlotPoint, + card: AnnotationRouteRect, +): readonly AnnotationPortEdge[] { + const edges: AnnotationPortEdge[] = []; + const horizontalGap = source.x < card.left + ? card.left - source.x + : Math.max(0, source.x - card.left - card.width); + const verticalGap = source.y < card.top + ? card.top - source.y + : Math.max(0, source.y - card.top - card.height); + const horizontalEdge = source.x < card.left ? 'left' : 'right'; + const verticalEdge = source.y < card.top ? 'top' : 'bottom'; + if (verticalGap > EPSILON && verticalGap >= horizontalGap * AXIS_DOMINANCE_RATIO) { + return [verticalEdge]; + } + if (horizontalGap > EPSILON && horizontalGap >= verticalGap * AXIS_DOMINANCE_RATIO) { + return [horizontalEdge]; + } + if (horizontalGap > EPSILON) edges.push(horizontalEdge); + if (verticalGap > EPSILON) edges.push(verticalEdge); + if (edges.length > 0) return edges; + + const distances: readonly [AnnotationPortEdge, number][] = [ + ['top', Math.abs(source.y - card.top)], + ['right', Math.abs(source.x - card.left - card.width)], + ['bottom', Math.abs(source.y - card.top - card.height)], + ['left', Math.abs(source.x - card.left)], + ]; + const nearest = Math.min(...distances.map(([, distance]) => distance)); + return distances.filter(([, distance]) => Math.abs(distance - nearest) < EPSILON).map(([edge]) => edge); +} + +function pointEqual(a: PlotPoint, b: PlotPoint): boolean { + return Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON; +} + +function simplifyPoints(points: readonly PlotPoint[]): PlotPoint[] { + const unique = points.filter((point, index) => index === 0 || !pointEqual(point, points[index - 1])); + return unique.filter((point, index) => { + if (index === 0 || index === unique.length - 1) return true; + const before = unique[index - 1]; + const after = unique[index + 1]; + return Math.abs((point.x - before.x) * (after.y - point.y) + - (point.y - before.y) * (after.x - point.x)) > EPSILON; + }); +} + +function pointInsideRect(point: PlotPoint, card: AnnotationRouteRect): boolean { + return point.x > card.left + EPSILON + && point.x < card.left + card.width - EPSILON + && point.y > card.top + EPSILON + && point.y < card.top + card.height - EPSILON; +} + +function segmentEntersRect(start: PlotPoint, end: PlotPoint, card: AnnotationRouteRect): boolean { + if (pointInsideRect(start, card) || pointInsideRect(end, card)) return true; + const left = card.left + EPSILON; + const right = card.left + card.width - EPSILON; + const top = card.top + EPSILON; + const bottom = card.top + card.height - EPSILON; + if (left >= right || top >= bottom) return false; + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + let entry = 0; + let exit = 1; + for (const [direction, offset] of [ + [-deltaX, start.x - left], + [deltaX, right - start.x], + [-deltaY, start.y - top], + [deltaY, bottom - start.y], + ] as const) { + if (Math.abs(direction) < EPSILON) { + if (offset < 0) return false; + continue; + } + const ratio = offset / direction; + if (direction < 0) entry = Math.max(entry, ratio); + else exit = Math.min(exit, ratio); + if (entry > exit) return false; + } + return exit > EPSILON && entry < 1 - EPSILON; +} + +function routeIsValid(points: readonly PlotPoint[], card: AnnotationRouteRect): boolean { + return points.slice(1).every((point, index) => !segmentEntersRect(points[index], point, card)); +} + +function routeCandidates( + source: PlotPoint, + port: AnnotationLeaderPort, + card: AnnotationRouteRect, +): readonly AnnotationLeaderRoute[] { + const middleX = (source.x + port.x) / 2; + const middleY = (source.y + port.y) / 2; + const pointSets: PlotPoint[][] = [ + [source, port], + [source, { x: source.x, y: port.y }, port], + [source, { x: port.x, y: source.y }, port], + [source, { x: middleX, y: source.y }, { x: middleX, y: port.y }, port], + [source, { x: source.x, y: middleY }, { x: port.x, y: middleY }, port], + ]; + const seen = new Set(); + return pointSets.flatMap((points) => { + const simplified = simplifyPoints(points); + const key = simplified.map((point) => `${point.x},${point.y}`).join(';'); + if (seen.has(key) || !routeIsValid(simplified, card)) return []; + seen.add(key); + return [{ source, port, points: simplified }]; + }); +} + +function orientation(a: PlotPoint, b: PlotPoint, c: PlotPoint): number { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +function segmentsCross(a: PlotPoint, b: PlotPoint, c: PlotPoint, d: PlotPoint): boolean { + if (pointEqual(a, c) || pointEqual(a, d) || pointEqual(b, c) || pointEqual(b, d)) return false; + const abC = orientation(a, b, c); + const abD = orientation(a, b, d); + const cdA = orientation(c, d, a); + const cdB = orientation(c, d, b); + return ((abC > EPSILON && abD < -EPSILON) || (abC < -EPSILON && abD > EPSILON)) + && ((cdA > EPSILON && cdB < -EPSILON) || (cdA < -EPSILON && cdB > EPSILON)); +} + +function routesCross(a: AnnotationLeaderRoute, b: AnnotationLeaderRoute): boolean { + for (let ai = 1; ai < a.points.length; ai += 1) { + for (let bi = 1; bi < b.points.length; bi += 1) { + if (segmentsCross(a.points[ai - 1], a.points[ai], b.points[bi - 1], b.points[bi])) return true; + } + } + return false; +} + +function sameEdgeOrderIsValid(routes: readonly AnnotationLeaderRoute[]): boolean { + for (let first = 0; first < routes.length; first += 1) { + for (let second = first + 1; second < routes.length; second += 1) { + const a = routes[first]; + const b = routes[second]; + if (a.port.edge !== b.port.edge) continue; + const sourceOrder = a.port.edge === 'top' || a.port.edge === 'bottom' + ? a.source.x - b.source.x + : a.source.y - b.source.y; + const portOrder = a.port.fraction - b.port.fraction; + if (sourceOrder * portOrder < -EPSILON) return false; + } + } + return true; +} + +function routeLength(route: AnnotationLeaderRoute): number { + return route.points.slice(1).reduce((sum, point, index) => + sum + Math.hypot(point.x - route.points[index].x, point.y - route.points[index].y), 0); +} + +function sharedSegmentCount(routes: readonly AnnotationLeaderRoute[]): number { + const keys = new Map(); + for (const route of routes) { + for (let index = 1; index < route.points.length; index += 1) { + const a = route.points[index - 1]; + const b = route.points[index]; + const key = [`${a.x},${a.y}`, `${b.x},${b.y}`].sort().join('|'); + keys.set(key, (keys.get(key) ?? 0) + 1); + } + } + return [...keys.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0); +} + +function compareRank(a: readonly number[], b: readonly number[]): number { + for (let index = 0; index < a.length; index += 1) { + if (Math.abs(a[index] - b[index]) > EPSILON) return a[index] - b[index]; + } + return 0; +} + +function assignmentRank(routes: readonly AnnotationLeaderRoute[], ports: readonly AnnotationLeaderPort[]): number[] { + let crossings = 0; + for (let first = 0; first < routes.length; first += 1) { + for (let second = first + 1; second < routes.length; second += 1) { + if (routesCross(routes[first], routes[second])) crossings += 1; + } + } + const bends = routes.reduce((sum, route) => sum + Math.max(0, route.points.length - 2), 0); + const length = routes.reduce((sum, route) => sum + routeLength(route), 0); + const deterministic = routes.reduce((sum, route, index) => + sum + ports.indexOf(route.port) * Math.pow(ports.length, routes.length - index - 1), 0); + return [crossings, sharedSegmentCount(routes), bends, length, deterministic]; +} + +export function routeAnnotationLeaders({ + card, + sources, +}: { + card: AnnotationRouteRect; + sources: readonly PlotPoint[]; +}): readonly AnnotationLeaderRoute[] { + if (sources.length === 0) return []; + const ports = annotationLeaderPorts(card); + const choices = sources.map((source) => ports + .filter((port) => annotationFacingEdges(source, card).includes(port.edge)) + .flatMap((port) => routeCandidates(source, port, card))); + let best: readonly AnnotationLeaderRoute[] | undefined; + let bestRank: readonly number[] | undefined; + + const visit = (index: number, routes: AnnotationLeaderRoute[]): void => { + if (index === choices.length) { + if (!sameEdgeOrderIsValid(routes)) return; + const rank = assignmentRank(routes, ports); + if (!bestRank || compareRank(rank, bestRank) < 0) { + best = [...routes]; + bestRank = rank; + } + return; + } + for (const route of choices[index]) { + if (routes.some((existing) => pointEqual(existing.port, route.port))) continue; + routes.push(route); + visit(index + 1, routes); + routes.pop(); + } + }; + visit(0, []); + return best ?? []; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts index c448485d..4ddd1f86 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts @@ -2,17 +2,28 @@ import type { SemanticElement, SemanticTarget } from '../../../core/interaction- import type { AnnotationCandidate, AnnotationConnection, - AnnotationRenderPlan, + AnnotationSpec, PlotPoint, } from '../../../interactive/interactions'; + +type RenderableAnnotation = AnnotationSpec & { + text: string; + candidates: readonly AnnotationCandidate[]; +}; import { INTERACTION_KEY, + INTERACTION_ROLE, PATH_KEY_SUFFIX, clientToLayoutPoint, plotToClientPoint, sceneItems, type RendererCoordinateSpace, } from '../hit-adapter'; +import { + routeAnnotationLeaders, + type AnnotationLeaderRoute, + type AnnotationPortEdge, +} from './annotation-leader-routing'; function keyOfDatum(datum: unknown): string | undefined { if (!datum || typeof datum !== 'object') return undefined; @@ -73,6 +84,28 @@ export function isAnnotationObstacle(item: any): boolean { return !!item?.mark?.marktype && item.mark.role !== 'axis-grid'; } +export function isAnnotationSourceItem(candidate: any, source: any): boolean { + if (candidate.mark !== source.mark) return false; + return source.mark?.marktype === 'area' && source.orient === 'horizontal' + ? keyOfDatum(candidate.datum) === keyOfDatum(source.datum) + : candidate.datum === source.datum; +} + +export function annotationSourceBounds(items: readonly any[], source: any): { + x1: number; x2: number; y1: number; y2: number; +} { + const sourceBounds = annotationBounds(source); + if (source.mark?.marktype !== 'area' || source.orient !== 'horizontal') return sourceBounds; + const sourceItems = items.filter((candidate) => isAnnotationSourceItem(candidate, source)); + if (sourceItems.length < 2) return sourceBounds; + return sourceItems.reduce((bounds, candidate) => ({ + x1: Math.min(bounds.x1, candidate.bounds.x1), + x2: Math.max(bounds.x2, candidate.bounds.x2), + y1: Math.min(bounds.y1, candidate.bounds.y1), + y2: Math.max(bounds.y2, candidate.bounds.y2), + }), { ...sourceBounds }); +} + function sceneObstacles(view: any): any[] { const obstacles: any[] = []; const visit = (item: any, offsetX: number, offsetY: number): void => { @@ -147,24 +180,40 @@ function segmentIntersectsRect( return ignoreStartTouch ? exit > 0.02 && entry < 0.98 : exit >= 0 && entry <= 1; } -function textAttachment( +function textAlignForPort(edge: AnnotationPortEdge): 'left' | 'center' | 'right' { + if (edge === 'left') return 'left'; + if (edge === 'right') return 'right'; + return 'center'; +} + +export function sourceEdgeAttachment( + source: LayoutRect, card: LayoutRect, - angle: number, -): { end: PlotPoint; align: 'left' | 'center' | 'right' } { - const horizontal = Math.cos(angle); - const vertical = Math.sin(angle); - if (Math.abs(horizontal) > Math.abs(vertical)) { - return horizontal > 0 - ? { end: { x: card.left, y: card.top + card.height / 2 }, align: 'left' } - : { end: { x: card.left + card.width, y: card.top + card.height / 2 }, align: 'right' }; + connection: AnnotationConnection, + fallback: PlotPoint, +): PlotPoint { + const cardCenterX = card.left + card.width / 2; + const cardCenterY = card.top + card.height / 2; + const sourceCenterX = source.left + source.width / 2; + const sourceCenterY = source.top + source.height / 2; + if (connection === 'top' || connection === 'bottom') { + return { + x: source.left + source.width * (cardCenterX < sourceCenterX ? 0.25 : 0.75), + y: connection === 'top' ? source.top : source.top + source.height, + }; } - return { - end: { - x: card.left + card.width / 2, - y: vertical > 0 ? card.top : card.top + card.height, - }, - align: 'center', - }; + if (connection === 'left' || connection === 'right') { + return { + x: connection === 'left' ? source.left : source.left + source.width, + y: source.top + source.height * (cardCenterY < sourceCenterY ? 0.25 : 0.75), + }; + } + return fallback; +} + +function routeIntersectsRect(route: AnnotationLeaderRoute, rect: LayoutRect, ignoreStartTouch = false): boolean { + return route.points.slice(1).some((point, index) => + segmentIntersectsRect(route.points[index], point, rect, ignoreStartTouch && index === 0)); } function vectorAngle(deltaX: number, deltaY: number): number { @@ -238,7 +287,9 @@ export function annotationCandidateAngles( preference: AnnotationCandidate['anglePreference'] = 'normal', ): readonly number[] { if (preferredAngle === undefined) return FREE_ANGLES; - const offsets = preference === 'oblique' ? [-1, 1, -2, 2] : [0, -1, 1, -2, 2, -3, 3]; + const offsets = preference === 'oblique' + ? [-1, 1, -2, 2] + : [0, -1, 1, -2, 2, -3, 3, -4, 4, -5, 5, 6]; return offsets.map((offset) => (preferredAngle + offset * ANGLE_STEP + TAU) % TAU); } @@ -247,6 +298,8 @@ export function annotationItem( key: string, subject?: Partial, preferredMarktype?: string, + preferredRole?: string, + preferredRecord?: Readonly>, ): any | undefined { const pathKey = key.endsWith(PATH_KEY_SUFFIX); const pathTarget = subject?.kind === 'path' || (subject?.kind === undefined && pathKey); @@ -257,14 +310,29 @@ export function annotationItem( const semanticCandidates = pathTarget ? matching.filter((candidate) => candidate.interactionGeometry) : matching.filter((candidate) => !candidate.interactionGeometry); - const candidates = preferredMarktype - ? semanticCandidates.filter((candidate) => candidate.mark?.marktype === preferredMarktype) + const roleCandidates = preferredRole + ? semanticCandidates.filter((candidate) => candidate.datum?.[INTERACTION_ROLE] === preferredRole) : semanticCandidates; - return (candidates.length > 0 ? candidates : matching) + if (preferredRole && roleCandidates.length === 0) return undefined; + const candidates = preferredMarktype + ? roleCandidates.filter((candidate) => candidate.mark?.marktype === preferredMarktype) + : roleCandidates; + const available = candidates.length > 0 ? candidates : preferredRole ? roleCandidates : matching; + const recordFields = preferredRecord + ? Object.entries(preferredRecord).filter(([field, value]) => !field.startsWith('__') + && value !== undefined && value !== null && typeof value !== 'object') + : []; + const recordMatches = recordFields.length > 0 + ? available.filter((candidate) => recordFields.every(([field, value]) => + candidate.datum?.[field] === undefined || Object.is(candidate.datum[field], value))) + : []; + const resolved = recordMatches.length > 0 ? recordMatches : available; + const preferRepresentativePath = pathTarget && recordMatches.length === 0 && resolved.length > 1; + return resolved .sort((a, b) => { const aSpan = Math.max(a.bounds.x2 - a.bounds.x1, a.bounds.y2 - a.bounds.y1); const bSpan = Math.max(b.bounds.x2 - b.bounds.x1, b.bounds.y2 - b.bounds.y1); - return aSpan - bSpan; + return preferRepresentativePath ? bSpan - aSpan : aSpan - bSpan; })[0]; } @@ -305,7 +373,7 @@ export function segmentMidpointConnectionPoint( return { point, preferredAngle: vectorAngle(point.x - plotCenter.x, point.y - plotCenter.y) }; } -function connectionPoint( +export function annotationConnectionPoint( item: any, connection: AnnotationConnection, items: readonly any[], @@ -318,6 +386,16 @@ function connectionPoint( x: (item.bounds.x1 + item.bounds.x2) / 2, y: (item.bounds.y1 + item.bounds.y2) / 2, }; + if (item.interactionGeometry && ['top', 'right', 'bottom', 'left'].includes(connection)) { + const segment = segmentMidpointConnectionPoint(item, plotCenter); + const preferredAngle = { + top: TAU * 0.75, + right: 0, + bottom: TAU * 0.25, + left: TAU * 0.5, + }[connection as 'top' | 'right' | 'bottom' | 'left']; + return { point: segment.point, preferredAngle }; + } if (connection === 'top') return { point: { x: center.x, y: item.bounds.y1 }, preferredAngle: TAU * 0.75 }; if (connection === 'right') return { point: { x: item.bounds.x2, y: center.y }, preferredAngle: 0 }; if (connection === 'bottom') return { point: { x: center.x, y: item.bounds.y2 }, preferredAngle: TAU * 0.25 }; @@ -350,8 +428,9 @@ function connectionPoint( } export interface AnnotationOverlayController { - render(element: SemanticElement, annotation: AnnotationRenderPlan): void; + render(element: SemanticElement, annotation: RenderableAnnotation): void; clear(): void; + sync(): void; destroy(): void; } @@ -360,6 +439,8 @@ export interface AnnotationOverlayOptions { container: HTMLElement; coordinateSpace(): RendererCoordinateSpace; containerLayoutSize(): { width: number; height: number }; + /** Vega marktype the chart anchors annotations to when a key matches several marks. */ + annotationMarkType?: string; } export function createAnnotationOverlay({ @@ -367,6 +448,7 @@ export function createAnnotationOverlay({ container, coordinateSpace, containerLayoutSize, + annotationMarkType, }: AnnotationOverlayOptions): AnnotationOverlayController { const annotationLayer = document.createElement('div'); Object.assign(annotationLayer.style, { @@ -389,12 +471,27 @@ export function createAnnotationOverlay({ }); annotationLayer.append(annotationSvg, annotationCard); - const clear = (): void => annotationLayer.remove(); - const render = (element: SemanticElement, annotation: AnnotationRenderPlan): void => { + // Placement is derived from rendered geometry, so the runtime re-syncs it + // whenever the renderer is resized or the host rescales the chart. + let current: { element: SemanticElement; annotation: RenderableAnnotation } | undefined; + + const clear = (): void => { + current = undefined; + annotationLayer.remove(); + }; + const render = (element: SemanticElement, annotation: RenderableAnnotation): void => { + current = { element, annotation }; const key = element.key[INTERACTION_KEY]; const items = sceneItems(view); const item = typeof key === 'string' - ? annotationItem(items, key, annotation.subject, annotation.markType) + ? annotationItem( + items, + key, + annotation.subject, + annotationMarkType, + undefined, + element.records?.[0], + ) : undefined; if (!item?.bounds) { clear(); @@ -404,6 +501,7 @@ export function createAnnotationOverlay({ if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; annotationCard.textContent = annotation.text; + annotationCard.style.whiteSpace = annotation.text.includes('\n') ? 'pre-line' : 'normal'; const directValue = annotation.text.length <= 18 && !annotation.text.includes('\n'); annotationCard.style.padding = directValue ? '1px 3px' : '2px 3px'; @@ -415,9 +513,8 @@ export function createAnnotationOverlay({ const toLayout = (point: PlotPoint): PlotPoint => clientToLayoutPoint( plotToClientPoint(point, space), containerRect, layoutSize, ); - const sourceKey = keyOfDatum(item.datum); const obstacles: LayoutObstacle[] = sceneObstacles(view).flatMap((candidate) => { - if (candidate.mark === item.mark && keyOfDatum(candidate.datum) === sourceKey) return []; + if (isAnnotationSourceItem(candidate, item)) return []; const leading = toLayout({ x: candidate.bounds.x1, y: candidate.bounds.y1 }); const trailing = toLayout({ x: candidate.bounds.x2, y: candidate.bounds.y2 }); return [{ @@ -439,7 +536,7 @@ export function createAnnotationOverlay({ width: Math.abs(plotTrailing.x - plotLeading.x), height: Math.abs(plotTrailing.y - plotLeading.y), }; - const sourceBounds = annotationBounds(item); + const sourceBounds = annotationSourceBounds(items, item); const sourceLeading = toLayout({ x: sourceBounds.x1, y: sourceBounds.y1 }); const sourceTrailing = toLayout({ x: sourceBounds.x2, y: sourceBounds.y2 }); const markSourceRect: LayoutRect = { @@ -452,8 +549,9 @@ export function createAnnotationOverlay({ const plotCenter = { x: space.plotWidth / 2, y: space.plotHeight / 2 }; const sourceGap = 10; let best: AnnotationLayout | undefined; + let fallback: AnnotationLayout | undefined; for (const candidate of annotation.candidates) { - const connection = connectionPoint( + const connection = annotationConnectionPoint( item, candidate.connection, items, @@ -464,12 +562,15 @@ export function createAnnotationOverlay({ ); const anchor = toLayout(connection.point); const boundarySourceRect = { left: anchor.x - 0.5, top: anchor.y - 0.5, width: 1, height: 1 }; - const sourceRect = candidate.connection === 'segment-midpoint' + const sourceRect = annotation.subject?.kind === 'region' + || (candidate.connection === 'segment-midpoint' + && !(item.mark?.marktype === 'area' && item.orient === 'horizontal')) || candidate.connection === 'outer-radial' || candidate.connection === 'radial-midpoint' ? { left: anchor.x - 0.5, top: anchor.y - 0.5, width: 1, height: 1 } : markSourceRect; - const connectorSourceRect = candidate.connection === 'outer-radial' + const connectorSourceRect = annotation.subject?.kind === 'region' + || candidate.connection === 'outer-radial' || candidate.connection === 'radial-midpoint' ? boundarySourceRect : sourceRect; @@ -494,41 +595,54 @@ export function createAnnotationOverlay({ width: cardWidth, height: cardHeight, }; - const attachment = textAttachment(card, angle); - const align = candidate.textAlign ?? attachment.align; + const route = routeAnnotationLeaders({ card, sources: [anchor] })[0]; + if (!route) continue; + const align = candidate.textAlign ?? textAlignForPort(route.port.edge); annotationCard.style.textAlign = align; - const end = attachment.end; + const end = route.port; const canvasOverflow = overflowDistance(card, canvasRect, 8); const plotOverflow = overflowDistance(card, plotRect, 6); const sourceCollision = overlapArea(card, sourceRect); const sourceClearance = rectDistance(card, sourceRect); const obstacleOverlapPenalty = obstacles.reduce((sum, obstacle) => sum + annotationObstacleOverlapCost(obstacle.tier, overlapArea(card, obstacle.rect)), 0); - const connectorLength = Math.hypot(end.x - anchor.x, end.y - anchor.y); + const connectorLength = route.points.slice(1).reduce((sum, point, index) => + sum + Math.hypot(point.x - route.points[index].x, point.y - route.points[index].y), 0); const drawsConnector = connectorFor(candidate) === 'line'; const leavesInward = connection.preferredAngle !== undefined && angularDistance(angle, connection.preferredAngle) > Math.PI / 2 + 1e-6; const crossesSource = drawsConnector + && annotation.subject?.kind !== 'region' && candidate.connection !== 'outer-radial' && candidate.connection !== 'radial-midpoint' - && segmentIntersectsRect(anchor, end, connectorSourceRect, true); + && routeIntersectsRect(route, connectorSourceRect, true); const obstacleCrossingPenalty = drawsConnector ? obstacles.reduce((sum, obstacle) => sum + ( - segmentIntersectsRect(anchor, end, obstacle.rect) + routeIntersectsRect(route, obstacle.rect) ? annotationObstacleOverlapCost(obstacle.tier, CONNECTOR_CROSSING_AREA) : 0 ), 0) : 0; - if (canvasOverflow > 0 || leavesInward || crossesSource || sourceClearance < sourceGap) continue; const directionPenalty = connection.preferredAngle === undefined ? 0 : angularDistance(angle, connection.preferredAngle); + const inwardPenalty = leavesInward ? 50 : 0; const lineCount = Math.max(1, Math.round((cardHeight - 4) / 15)); const wrappingPenalty = Math.max(0, lineCount - 1) * 10; const score = plotOverflow * PLOT_ESCAPE_WEIGHT + obstacleCrossingPenalty + obstacleOverlapPenalty + sourceCollision * OBSTACLE_WEIGHT[2] + connectorLength / 100 - + directionPenalty + wrappingPenalty + (candidate.priority ?? 0) / 100; + + directionPenalty + inwardPenalty + wrappingPenalty + (candidate.priority ?? 0) / 100; + const fallbackScore = score + canvasOverflow * 10_000 + + (crossesSource ? 100_000 : 0) + + Math.max(0, sourceGap - sourceClearance) * 1_000; + if (!fallback || fallbackScore < fallback.score) { + fallback = { + candidate, connection, angle, distance, align, maxWidth, card, end, + score: fallbackScore, + }; + } + if (canvasOverflow > 0 || crossesSource || sourceClearance < sourceGap) continue; if (!best || score < best.score) { best = { candidate, connection, angle, distance, align, maxWidth, card, end, score }; } @@ -536,22 +650,70 @@ export function createAnnotationOverlay({ } } } + if (!best && fallback) { + const card = { + ...fallback.card, + left: Math.min(width - fallback.card.width - 8, Math.max(8, fallback.card.left)), + top: Math.min(height - fallback.card.height - 8, Math.max(8, fallback.card.top)), + }; + const anchor = toLayout(fallback.connection.point); + const angle = vectorAngle( + card.left + card.width / 2 - anchor.x, + card.top + card.height / 2 - anchor.y, + ); + const route = routeAnnotationLeaders({ card, sources: [anchor] })[0]; + if (!route) { + clear(); + return; + } + best = { + ...fallback, + card, + angle, + align: fallback.candidate.textAlign ?? textAlignForPort(route.port.edge), + end: route.port, + }; + } if (!best) { clear(); return; } - const anchor = toLayout(best.connection.point); annotationCard.style.maxWidth = `${best.maxWidth}px`; annotationCard.style.textAlign = best.align; annotationCard.style.left = `${best.card.left}px`; annotationCard.style.top = `${best.card.top}px`; const connector = connectorFor(best.candidate); - const showConnector = connector === 'line'; + const connectorAnchors = best.candidate.connectorAnchors?.flatMap((connectorAnchor) => { + const connectorItem = typeof key === 'string' + ? annotationItem(items, key, annotation.subject, undefined, connectorAnchor.role) + : undefined; + if (!connectorItem) return []; + const connection = annotationConnectionPoint( + connectorItem, + connectorAnchor.connection, + items, + plotCenter, + connectorAnchor.valueAxis, + ); + return [toLayout(connection.point)]; + }); + const fallbackAnchor = toLayout(best.connection.point); + const primaryAnchor = sourceEdgeAttachment( + markSourceRect, + best.card, + best.candidate.connection, + fallbackAnchor, + ); + const anchors = connectorAnchors?.length ? connectorAnchors : [primaryAnchor]; + const showConnector = connector === 'line' && anchors.length > 0; + const routes = showConnector ? routeAnnotationLeaders({ card: best.card, sources: anchors }) : []; annotationSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); - annotationPath.setAttribute('d', showConnector - ? `M ${anchor.x} ${anchor.y} L ${best.end.x} ${best.end.y}` + annotationPath.setAttribute('d', showConnector && routes.length === anchors.length + ? routes.map((route) => route.points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' ')).join(' ') : ''); - annotationPath.style.display = showConnector ? '' : 'none'; + annotationPath.style.display = showConnector && routes.length === anchors.length ? '' : 'none'; annotationLayer.dataset.connection = best.candidate.connection; annotationLayer.dataset.angle = String(Math.round(best.angle * 180 / Math.PI)); annotationLayer.dataset.distance = String(best.distance); @@ -563,6 +725,11 @@ export function createAnnotationOverlay({ return { render, clear, - destroy: clear, + sync: () => { + if (current) render(current.element, current.annotation); + }, + destroy: () => { + clear(); + }, }; } diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-text.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-text.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts index 00c53f01..88a2083b 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts @@ -54,6 +54,15 @@ export function activeReorderAxis axis === preferred) ?? changed[0] ?? axes.find(({ axis }) => axis === preferred); } +export function reorderOwnedItems( + items: readonly any[], + axis: Pick, + value: unknown, +): any[] { + return items.filter((item) => renderHit(item)?.datum[axis.field] === value + && (!axis.markTypes || axis.markTypes.includes(item.mark?.marktype))); +} + export function createDragReorderOverlay({ view, container, @@ -73,8 +82,8 @@ export function createDragReorderOverlay({ const sourceValue = targetValue(preview.source, active.field); const destinationValue = targetValue(preview.destination, active.field); const scene = sceneItems(view); - const sourceCandidates = scene.filter((item) => renderHit(item)?.datum[active.field] === sourceValue - && (item.interactionGeometry?.points?.length >= 2 || item.bounds)); + const sourceCandidates = reorderOwnedItems(scene, active, sourceValue) + .filter((item) => item.interactionGeometry?.points?.length >= 2 || item.bounds); const discreteSourceItems = sourceCandidates.filter((item) => { const markType = item.mark?.marktype; return markType !== 'line' && markType !== 'area'; @@ -82,8 +91,8 @@ export function createDragReorderOverlay({ const sourceItems = active.includeConnectiveMarks || discreteSourceItems.length === 0 ? sourceCandidates : discreteSourceItems; - const destinationItems = scene.filter((item) => renderHit(item)?.datum[active.field] === destinationValue - && item.bounds); + const destinationItems = reorderOwnedItems(scene, active, destinationValue) + .filter((item) => item.bounds); if (sourceItems.length === 0 || destinationItems.length === 0) return clear(); layer.replaceChildren(); diff --git a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts index 1b49a7f2..e53730a2 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts @@ -131,21 +131,12 @@ export function createFocusOverlay({ const selectionStyle = typeof key === 'string' && selected.has(key) ? plan.renderSelectionStyles?.[item.mark.marktype] : undefined; - const basePath = renderer - ? [...renderer.querySelectorAll('[role="graphics-symbol"]')] - .find((candidate) => (candidate as any).__data__?.mark === item.mark) - : undefined; - const matrix = basePath?.getCTM(); - const points = item.interactionGeometry.points.map((plotPoint: PlotPoint) => { - if (!matrix || !renderer) { - return { x: plotPoint.x + space.originX, y: plotPoint.y + space.originY }; - } - const local = renderer.createSVGPoint(); - local.x = plotPoint.x - item.interactionGeometry.offset.x; - local.y = plotPoint.y - item.interactionGeometry.offset.y; - const transformed = local.matrixTransform(matrix); - return { x: transformed.x, y: transformed.y }; - }); + // Points already carry nested facet/group offsets, so renderer + // coordinates are just the plot point plus the plot origin. + const points = item.interactionGeometry.points.map((plotPoint: PlotPoint) => ({ + x: plotPoint.x + space.originX, + y: plotPoint.y + space.originY, + })); const segment = item.interactionGeometry.kind === 'segment'; const shape = document.createElementNS('http://www.w3.org/2000/svg', segment ? 'path' : 'polygon'); if (segment) { diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index b740c1db..26df91bf 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -1,27 +1,28 @@ import { changeset } from 'vega'; import type { ChartInteractionResolver } from '../../core/interaction-semantics'; import type { + CanvasInteractionDef, ChartUpdate, + ChartUpdateOp, ChartUpdatePresenter, - ExternalInteractionEvent, FlintInteractionEventDetail, InteractionDef, NavigationInteractionEvent, - NormalizedInteractionEvent, RenderHit, SemanticTarget, SemanticInteractionEvent, } from '../../interactive/interactions'; +import { isCanvasInteraction } from '../../interactive/interactions'; import type { - ChartUpdateRequest, ChartUpdateResult, + SemanticTargetRef, UpdateTarget, -} from '../../interactive/updates/request'; -import { matchesSemanticTargetSelector } from '../../interactive/updates/request'; -import { applySelectionMode } from '../../interactive/updates/emphasis'; -import { ScopedSelectionState } from '../../interactive/selection-state'; +} from '../../interactive/language/updates'; +import { matchesSemanticTargetSelector } from '../../interactive/language/updates'; import type { VegaInteractionPlan } from './contracts'; -import { toCanvasInteractionEvent, type CanvasInteractionEvent } from '../../interactive/canvas-interaction'; +import { toCanvasInteractionEvent } from '../../interactive/canvas-interaction'; +import type { CanvasInteractionEvent } from '../../interactive/language/events'; +import type { ChartUpdateApplyOptions } from '../../interactive/types'; import { INTERACTION_KEY, PATH_KEY_SUFFIX, @@ -30,6 +31,7 @@ import { normalizeVegaElementEvent, pathHoverPresentationKey, renderHit, + rendererPlotOrigin, sceneItems, type RendererCoordinateSpace, } from './hit-adapter'; @@ -49,6 +51,35 @@ import { export { mergeContiguousSelectionBounds } from './presentation/focus-overlay'; +export function resolveSupportedOperation( + op: ChartUpdateOp, + plan: Pick, +): { op: ChartUpdateOp | null; unsupported: boolean } { + if (op.op === 'set-viewport') { + const requestedAxes = op.axes === 'xy' ? ['x', 'y'] as const : [op.axes]; + const supportedAxes = requestedAxes.filter((axis) => plan.navigationAxes?.[axis]); + if (supportedAxes.length === 0) return { op: null, unsupported: true }; + const axes = supportedAxes.length === 2 ? 'xy' : supportedAxes[0]; + return { + op: { + ...op, + axes, + value: Object.fromEntries(supportedAxes + .filter((axis) => op.value[axis] !== undefined) + .map((axis) => [axis, op.value[axis]])), + }, + unsupported: supportedAxes.length < requestedAxes.length, + }; + } + if (op.op === 'set-order') { + const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const supported = op.scope === 'category' + && reorderAxes.some((axis) => axis.field === op.field); + return { op: supported ? op : null, unsupported: !supported }; + } + return { op, unsupported: false }; +} + export function nearestReorderHit( items: readonly any[], axis: 'x' | 'y', @@ -73,16 +104,18 @@ export function nearestReorderHit( } export interface VegaInteractionController { - dispatch(event: ExternalInteractionEvent): Promise; - applyUpdate(update: ChartUpdateRequest): Promise; - clearUpdate(updateId: string): Promise; + getInteractionContext(): import('../../interactive/interactions').InteractionContext; + applyUpdate(update: ChartUpdate, options?: ChartUpdateApplyOptions): Promise; + setUpdates(updates: readonly ChartUpdate[]): Promise; + clearUpdate(id: string): Promise; + refresh(): void; destroy(): void; } export function interactionsForHoverPresentation( - clickInteractions: readonly InteractionDef[], - hoverInteractions: readonly InteractionDef[], -): InteractionDef[] { + clickInteractions: readonly CanvasInteractionDef[], + hoverInteractions: readonly CanvasInteractionDef[], +): CanvasInteractionDef[] { return [ ...hoverInteractions, ...clickInteractions.filter((interaction) => interaction.handle), @@ -100,32 +133,33 @@ export function mountVegaInteractions( resolve: ChartInteractionResolver | undefined, presentUpdate: ChartUpdatePresenter, ): VegaInteractionController { + const canvasInteractions = interactions.filter(isCanvasInteraction); const clickInteractions = resolve - ? interactions.filter((interaction) => interaction.eventSource.gesture === 'click') + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'click') : []; const hoverInteractions = resolve - ? interactions.filter((interaction) => interaction.eventSource.gesture === 'hover') + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'hover') : []; const hoverPresentationInteractions = interactionsForHoverPresentation( clickInteractions, hoverInteractions, ); const regionInteraction = resolve - ? interactions.find((interaction) => interaction.eventSource.gesture === 'drag') + ? canvasInteractions.find((interaction) => interaction.eventSource.gesture === 'drag') : undefined; - const navigationInteraction = interactions.find( + const navigationInteraction = canvasInteractions.find( (interaction) => interaction.eventSource.type === 'navigation', ); const elementDragInteraction = resolve - ? interactions.find((interaction) => interaction.eventSource.gesture === 'drag-element') + ? canvasInteractions.find((interaction) => interaction.eventSource.gesture === 'drag-element') : undefined; - const selectionState = new ScopedSelectionState(); + const retainedUpdates = new Map(); + const previewUpdates = new Map(); + const selectedElements = new Map(); let selectedLegend: { channel: string; value: unknown } | null = null; let hoveredPathKeys = new Set(); let suppressClick = false; let regionDragging = false; - let syncRunning = false; - let syncRequested = false; const containerLayoutSize = (): { width: number; height: number } => { const rect = container.getBoundingClientRect(); @@ -144,10 +178,11 @@ export function mountVegaInteractions( // final plot translation. The rendered root-frame CTM is authoritative. const rootFrame = svg?.querySelector('.mark-group.role-frame.root'); const rootMatrix = rootFrame?.getCTM(); - const originX = rootMatrix?.e ?? viewOriginX; - const originY = rootMatrix?.f ?? viewOriginY; const logicalWidth = svg?.viewBox.baseVal.width || rect.width; const logicalHeight = svg?.viewBox.baseVal.height || rect.height; + const origin = rendererPlotOrigin(rootMatrix, { x: viewOriginX, y: viewOriginY }); + const originX = origin.x; + const originY = origin.y; const viewWidth = view.width(); const viewHeight = view.height(); return { @@ -162,7 +197,13 @@ export function mountVegaInteractions( }; const focusOverlay = createFocusOverlay({ view, container, plan, coordinateSpace, containerLayoutSize }); - const annotationOverlay = createAnnotationOverlay({ view, container, coordinateSpace, containerLayoutSize }); + const annotationOverlay = createAnnotationOverlay({ + view, + container, + coordinateSpace, + containerLayoutSize, + annotationMarkType: plan.annotationMarkType, + }); const dragReorderOverlay = createDragReorderOverlay({ view, container, reorderAxes: plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), @@ -174,12 +215,19 @@ export function mountVegaInteractions( isActive: (axis) => Array.isArray(view.signal(axis.signal)), reset: (axis) => { dragReorderOverlay.clear(); - view.signal(axis.signal, null); - void view.runAsync().then(() => reorderResetControls.layout()); + for (const layer of [retainedUpdates, previewUpdates]) { + for (const [id, update] of layer) { + const ops = update.ops.filter((op) => + op.op !== 'set-order' || op.scope !== 'category' || op.field !== axis.field); + if (ops.length > 0) layer.set(id, { id, ops }); + else layer.delete(id); + } + } + void renderUpdates(); }, }); const navigationController = createVegaNavigationController(view, plan.navigationAxes ?? {}); - const selectedKeys = (): Set => selectionState.combined(); + const selectedKeys = (): Set => new Set(selectedElements.keys()); const renderPathFocus = (): void => focusOverlay.render(selectedKeys(), hoveredPathKeys); const clearAnnotation = (): void => annotationOverlay.clear(); renderPathFocus(); @@ -194,11 +242,16 @@ export function mountVegaInteractions( seriesField: plan.seriesField, }); const context = (includeAvailable = true) => { - const hits = allHits(); - const available = includeAvailable ? resolve?.( - { gesture: 'rectangle', role: 'region', hits }, - resolveContext(hits), - )?.elements : undefined; + // Navigation resolves per gesture frame, so the scenegraph scan stays behind this flag. + const available = includeAvailable + ? (() => { + const hits = allHits(); + return resolve?.( + { gesture: 'rectangle', role: 'region', hits }, + resolveContext(hits), + )?.elements; + })() + : undefined; const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); const currentReorderAxes = reorderAxes.map((axis) => { const signaledOrder = view.signal(axis.signal); @@ -214,8 +267,10 @@ export function mountVegaInteractions( : undefined; return { chartType, - selected: [...selectedKeys()].map((key) => ({ key: { [INTERACTION_KEY]: key } })), + selected: [...selectedElements.values()], available, + resolveGroupValue: plan.resolveGroupValue, + resolveNavigation: navigationController.resolve, categoryField: plan.categoryField, seriesField: plan.seriesField, categoryAxis: reorderAxis?.axis, @@ -223,162 +278,185 @@ export function mountVegaInteractions( reorderAxes: currentReorderAxes, }; }; - const sync = async (): Promise => { - syncRequested = true; - if (syncRunning) return; - syncRunning = true; - try { - while (syncRequested) { - syncRequested = false; - const keys = [...selectedKeys()]; - view.change( - INTERACTION_STORE, - changeset().remove(() => true).insert(keys.map((key) => ({ key }))), - ); - view.change( - LEGEND_SELECTION_STORE, - changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), - ); - await view.runAsync(); - renderPathFocus(); - } - } finally { - syncRunning = false; - } - }; - const applyUpdate = async ( - update: ChartUpdate | null, - legendSelection: { channel: string; value: unknown } | null = null, - updateId?: string, - ): Promise => { - if (!update) return; - let requiresSemanticSync = false; - for (const op of update.ops) { - if (op.op === 'reset') { - if (updateId) selectionState.clear(updateId); - else { - selectionState.clear(); - selectedLegend = null; - clearAnnotation(); - } - requiresSemanticSync = true; - } else if (op.op === 'clear-annotation') { - clearAnnotation(); - } else if (op.op === 'render-annotation') { - annotationOverlay.render(op.element, op.annotation); - } else if (op.op === 'emphasize') { - requiresSemanticSync = true; - const keys = op.elements - .map((element) => element.key[INTERACTION_KEY]) - .filter((key): key is string => typeof key === 'string'); - let targetSelection = new Set(selectionState.get(updateId)); - targetSelection = applySelectionMode(targetSelection, keys, op.mode); - selectionState.set(targetSelection, updateId); - const combined = selectedKeys(); - selectedLegend = legendSelection && keys.some((key) => combined.has(key)) - ? legendSelection - : null; - } else if (op.op === 'navigate-viewport') { - await navigationController.apply(op); - } else if (op.op === 'reorder-category') { - const reorderAxis = (plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : [])) - .find((axis) => axis.axis === op.axis && axis.field === op.field); - if (reorderAxis && reorderAxis.axis === op.axis && reorderAxis.field === op.field) { - view.signal(reorderAxis.signal, op.orderedValues); - await view.runAsync(); - renderPathFocus(); - reorderResetControls.layout(); - } - } - } - if (requiresSemanticSync) await sync(); - }; - - const resolveUpdateTarget = (target: UpdateTarget): readonly import('../../core/interaction-semantics').SemanticElement[] => { + const resolveUpdateTarget = (target: UpdateTarget): SemanticTargetRef | null => { if (!('select' in target)) { const renderedKeys = new Set(allHits() .map((hit) => hit.datum[INTERACTION_KEY]) .filter((key): key is string => typeof key === 'string')); - return target.elements.filter((element) => { + const elements = target.elements.filter((element) => { const key = element.key[INTERACTION_KEY]; return typeof key === 'string' && renderedKeys.has(key); }); + return elements.length > 0 ? { ...target, elements } : null; } const entries = Object.entries(target.select.key); - if (entries.length === 0) return []; + if (entries.length === 0) return null; const hits = allHits().filter((hit) => matchesSemanticTargetSelector(target, plan.fields, hit.datum)); - if (hits.length === 0 || !resolve) return []; + if (hits.length === 0 || !resolve) return null; const resolved = resolve({ gesture: 'rectangle', role: target.select.visual?.role ?? 'external-selection', hits, }, resolveContext(hits)); - if (!resolved) return []; - if (target.select.visual?.kind && target.select.visual.kind !== resolved.visual.kind) return []; - if (target.select.visual?.role && target.select.visual.role !== resolved.visual.role) return []; - return resolved.elements; + if (!resolved) return null; + if (target.select.visual?.kind && target.select.visual.kind !== resolved.visual.kind) return null; + if (target.select.visual?.role && target.select.visual.role !== resolved.visual.role) return null; + return resolved; }; - const applyRequestedUpdate = async ( - request: ChartUpdateRequest, - legendSelection: { channel: string; value: unknown } | null = null, - ): Promise => { + const resolveUpdate = ( + update: ChartUpdate, + ): { update: ChartUpdate; result: ChartUpdateResult } => { const unresolvedTargets: UpdateTarget[] = []; + const unsupportedOps: ChartUpdateOp['op'][] = []; let resolvedTargets = 0; - const ops: ChartUpdate['ops'][number][] = []; - for (const op of request.ops) { - if (op.op === 'emphasize') { - const elements = op.targets.flatMap((target) => { + const ops: ChartUpdateOp[] = []; + for (const op of update.ops) { + if (op.op === 'set-presentation') { + const targets = op.targets.flatMap((target) => { const resolved = resolveUpdateTarget(target); - if (resolved.length === 0) unresolvedTargets.push(target); - resolvedTargets += resolved.length; - return [...resolved]; + if (!resolved) { + unresolvedTargets.push(target); + return []; + } + resolvedTargets += resolved.elements.length; + return [resolved]; }); - if (elements.length > 0) { - ops.push({ - op: 'emphasize', - elements, - mode: op.mode, - dimOpacity: op.dimOpacity, - }); - } - } else if (op.op === 'annotate') { - const elements = resolveUpdateTarget(op.target); - if (elements.length !== 1) unresolvedTargets.push(op.target); + if (targets.length > 0) ops.push({ ...op, targets }); + } else if (op.op === 'set-annotation' && op.value !== null) { + const target = resolveUpdateTarget(op.target); + if (!target || target.elements.length !== 1) unresolvedTargets.push(op.target); else { resolvedTargets += 1; - const visual = 'visual' in op.target ? op.target.visual : op.target.select.visual; - ops.push({ - op: 'annotate', - element: elements[0], - ...(visual === undefined ? {} : { visual }), - ...(op.text === undefined ? {} : { text: op.text }), - }); + ops.push({ ...op, target }); } - } else if (op.op === 'navigate-viewport') { - ops.push({ ...op, phase: request.phase ?? 'commit' }); + } else if (op.op === 'set-viewport') { + const supported = resolveSupportedOperation(op, plan); + if (supported.unsupported) unsupportedOps.push(op.op); + if (supported.op) ops.push(supported.op); + } else if (op.op === 'set-order') { + const supported = resolveSupportedOperation(op, plan); + if (supported.unsupported) unsupportedOps.push(op.op); + if (supported.op) ops.push(supported.op); } else { ops.push(op); } } - - if (ops.length > 0) { - const interactionContext = context(); - const update = { phase: request.phase, ops }; - await applyUpdate(presentUpdate(update, interactionContext), legendSelection, request.updateId); - } + const hasUnsupported = unresolvedTargets.length > 0 || unsupportedOps.length > 0; return { - status: unresolvedTargets.length === 0 - ? 'applied' - : ops.length > 0 ? 'partially-applied' : 'unsupported', - resolvedTargets, - unresolvedTargets, - unsupportedOps: [], + update: { id: update.id, ops }, + result: { + status: !hasUnsupported + ? 'applied' + : ops.length > 0 ? 'partially-applied' : 'unsupported', + resolvedTargets, + unresolvedTargets, + unsupportedOps: [...new Set(unsupportedOps)], + }, }; }; - const emitCanvasInteractionEvent = ( + + const renderUpdates = async (): Promise => { + const displayUpdates = [...retainedUpdates.values(), ...previewUpdates.values()]; + selectedElements.clear(); + let annotation: Extract | undefined; + const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + for (const axis of reorderAxes) view.signal(axis.signal, null); + for (const axis of Object.keys(plan.navigationAxes ?? {}) as ('x' | 'y')[]) { + navigationController.apply({ op: 'set-viewport', axes: axis, value: {} }); + } + for (const update of displayUpdates) { + for (const op of update.ops) { + if (op.op === 'set-presentation' + && (op.value.state === 'emphasized' || op.value.state === 'focused')) { + for (const target of op.targets) { + if ('select' in target) continue; + for (const element of target.elements) { + const key = element.key[INTERACTION_KEY]; + if (typeof key === 'string') selectedElements.set(key, element); + } + } + } else if (op.op === 'set-annotation') { + annotation = op; + } else if (op.op === 'set-viewport') { + navigationController.apply(op); + } else if (op.op === 'set-order' && op.scope === 'category') { + const axis = reorderAxes.find((candidate) => candidate.field === op.field); + if (axis) view.signal(axis.signal, op.values); + } + } + } + const keys = [...selectedKeys()]; + if (keys.length === 0) selectedLegend = null; + // A navigation-only chart compiles without the selection stores. + if (plan.semanticStores !== false) { + view.change( + INTERACTION_STORE, + changeset().remove(() => true).insert(keys.map((key) => ({ key }))), + ); + view.change( + LEGEND_SELECTION_STORE, + changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), + ); + } + await view.runAsync(); + observeRenderer(); + renderPathFocus(); + reorderResetControls.layout(); + clearAnnotation(); + if (annotation?.value && !('select' in annotation.target)) { + const element = annotation.target.elements[0]; + if (element && annotation.value.text && annotation.value.candidates) { + annotationOverlay.render(element, { + ...annotation.value, + text: annotation.value.text, + candidates: annotation.value.candidates, + }); + } + } + }; + + const storeUpdate = async ( + update: ChartUpdate, + destination: Map, + legendSelection: { channel: string; value: unknown } | null = null, + ): Promise => { + const resolved = resolveUpdate(update); + const presented = presentUpdate(resolved.update, context()); + destination.set(update.id, presented); + if (legendSelection) selectedLegend = legendSelection; + await renderUpdates(); + return resolved.result; + }; + + const applyInteractionUpdate = async ( interaction: InteractionDef, + phase: import('../../interactive/interactions').InteractionPhase, + update: ChartUpdate | null, + legendSelection: { channel: string; value: unknown } | null = null, + ): Promise => { + if (phase === 'cancel') { + if (previewUpdates.delete(interaction.id)) await renderUpdates(); + return; + } + if (update) { + const preview = phase === 'start' || phase === 'preview'; + if (!preview) previewUpdates.delete(interaction.id); + await storeUpdate(update, preview ? previewUpdates : retainedUpdates, legendSelection); + return; + } + if (phase === 'commit') { + const pending = previewUpdates.get(interaction.id); + if (pending) { + retainedUpdates.set(interaction.id, pending); + previewUpdates.delete(interaction.id); + await renderUpdates(); + } + } + }; + const emitCanvasInteractionEvent = ( + interaction: CanvasInteractionDef, event: CanvasInteractionEvent, transactionId?: string, ): void => { @@ -397,7 +475,7 @@ export function mountVegaInteractions( })); }; const emitInteractionEvent = ( - interaction: InteractionDef, + interaction: CanvasInteractionDef, event: SemanticInteractionEvent | NavigationInteractionEvent, transactionId?: string, ): void => emitCanvasInteractionEvent( @@ -406,7 +484,7 @@ export function mountVegaInteractions( transactionId, ); const dispatch = async ( - interaction: InteractionDef, + interaction: CanvasInteractionDef, event: SemanticInteractionEvent, legendSelection: { channel: string; value: unknown } | null = null, ): Promise => { @@ -414,32 +492,22 @@ export function mountVegaInteractions( const canvasEvent = toCanvasInteractionEvent(event, interaction.eventSource); emitInteractionEvent(interaction, event); const request = interaction.handle?.(canvasEvent, interactionContext) ?? null; - if (request) { - await applyRequestedUpdate(request, legendSelection); - } + await applyInteractionUpdate(interaction, event.phase, request, legendSelection); }; let navigationDispatch = Promise.resolve(); const dispatchNavigation = ( - interaction: InteractionDef, + interaction: CanvasInteractionDef, event: NavigationInteractionEvent, ): Promise => { const run = async (): Promise => { - const interactionContext = context(false); const canvasEvent = toCanvasInteractionEvent(event, interaction.eventSource); - emitInteractionEvent(interaction, event); - const request = interaction.handle?.(canvasEvent, interactionContext) ?? null; - if (request) { - await applyRequestedUpdate(request); - } + emitCanvasInteractionEvent(interaction, canvasEvent); + const request = interaction.handle?.(canvasEvent, context(false)) ?? null; + await applyInteractionUpdate(interaction, event.phase, request); }; navigationDispatch = navigationDispatch.then(run, run); return navigationDispatch; }; - const dispatchExternal = async (event: ExternalInteractionEvent): Promise => { - throw new Error( - `External interaction dispatch from "${event.source}" no longer runs update policies; use applyUpdate().`, - ); - }; const resolveTarget = ( gesture: 'click' | 'hover' | 'rectangle' | 'angular', role: string, @@ -531,7 +599,8 @@ export function mountVegaInteractions( type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, modifiers: normalized.event.modifiers, }, interaction.eventSource), interactionContext); - return preview?.ops.flatMap((op) => op.op === 'emphasize' + return preview?.ops.flatMap((op) => op.op === 'set-presentation' + && (op.value.state === 'emphasized' || op.value.state === 'focused') ? op.targets.flatMap((target) => 'select' in target ? [] : target.elements) : []) ?? []; }); @@ -632,7 +701,7 @@ export function mountVegaInteractions( }; emitCanvasInteractionEvent(elementDragInteraction, canvasEvent); const request = elementDragInteraction.handle?.(canvasEvent, context()) ?? null; - if (request) await applyRequestedUpdate(request); + await applyInteractionUpdate(elementDragInteraction, phase, request); }; const elementDragStart = (event: PointerEvent): void => { if (!elementDragInteraction || (event.button !== undefined && event.button !== 0)) return; @@ -711,15 +780,27 @@ export function mountVegaInteractions( view, container, interaction: regionInteraction, - getSelected: () => selectionState.get(regionInteraction.id), - setSelected: (next) => { selectionState.set(next, regionInteraction.id); }, + getSelected: selectedKeys, + setSelected: (next) => { + previewUpdates.set(regionInteraction.id, { + id: regionInteraction.id, + ops: [{ + op: 'set-presentation', + targets: next.size > 0 ? [{ + visual: { kind: 'region', role: 'selection' }, + elements: [...next].map((key) => ({ key: { [INTERACTION_KEY]: key } })), + }] : [], + value: { state: next.size > 0 ? 'emphasized' : 'normal' }, + }], + }); + }, coordinateSpace, containerLayoutSize, resolveTarget: (gesture, role, hits) => resolveTarget(gesture, role, hits), dispatch: (event) => dispatch(regionInteraction, event), clearHover, clearAnnotation, - sync, + sync: renderUpdates, setSuppressClick: (suppress) => { suppressClick = suppress; }, setDragging: (dragging) => { regionDragging = dragging; }, }) : undefined; @@ -734,46 +815,55 @@ export function mountVegaInteractions( }) : undefined; const clickOnlyKeyDown = (event: KeyboardEvent): void => { if (regionInteraction || event.key !== 'Escape') return; - selectionState.clear(); + for (const layer of [retainedUpdates, previewUpdates]) { + for (const [id, update] of layer) { + const ops = update.ops.filter((op) => + op.op !== 'set-presentation' && op.op !== 'set-annotation'); + if (ops.length > 0) layer.set(id, { id, ops }); + else layer.delete(id); + } + } selectedLegend = null; - clearAnnotation(); - void sync(); + void renderUpdates(); }; if (clickInteractions.length > 0 && !regionInteraction) container.addEventListener('keydown', clickOnlyKeyDown); - const customSourceCleanups = interactions.flatMap((interaction) => { - if (!interaction.eventSource?.mount) return []; - const cleanup = interaction.eventSource.mount({ - container, - emit(event: NormalizedInteractionEvent) { - if (event.type === 'external') { - void dispatchExternal(event); - return; - } - if (event.type === 'navigation') { - void dispatchNavigation(interaction, event); - return; - } - const gesture = event.type === 'region' - ? event.axis === 'angle' ? 'angular' : 'rectangle' - : 'click'; - const role = event.type === 'region' ? 'region' : 'mark'; - const target = resolveTarget(gesture, role, event.hits); - void dispatch(interaction, { - type: 'semantic', - source: event.type, - phase: event.phase, - target, - point: event.type === 'element' ? event.point : undefined, - region: event.type === 'region' ? event.region : undefined, - axis: event.type === 'region' ? event.axis : undefined, - operation: event.type === 'region' ? event.operation : undefined, - modifiers: event.modifiers, - }); - }, + // Overlays project scenegraph geometry into screen pixels, so every one of + // them is re-projected whenever the rendered size changes. + const syncOverlays = (): void => { + renderPathFocus(); + annotationOverlay.sync(); + regionGesture?.sync(); + reorderResetControls.layout(); + }; + let observedRenderer: Element | undefined; + // A drag-resize fires per frame, so repeated observations collapse into one pass. + let syncFrame: number | undefined; + const scheduleSync = (): void => { + if (typeof requestAnimationFrame === 'undefined') { + syncOverlays(); + return; + } + if (syncFrame !== undefined) return; + syncFrame = requestAnimationFrame(() => { + syncFrame = undefined; + syncOverlays(); }); - return cleanup ? [cleanup] : []; - }); + }; + const resizeObserver = typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(() => scheduleSync()); + // The container catches responsive layout; the renderer catches the chart + // itself being sized independently of it. + resizeObserver?.observe(container); + const observeRenderer = (): void => { + const renderer = container.querySelector('canvas, svg'); + if (!renderer || renderer === observedRenderer) return; + if (observedRenderer) resizeObserver?.unobserve(observedRenderer); + resizeObserver?.observe(renderer); + observedRenderer = renderer; + }; + observeRenderer(); const destroy = (): void => { if (clickInteractions.length > 0) { @@ -798,19 +888,40 @@ export function mountVegaInteractions( annotationOverlay.destroy(); dragReorderOverlay.destroy(); reorderResetControls.destroy(); + resizeObserver?.disconnect(); + observedRenderer = undefined; + if (syncFrame !== undefined && typeof cancelAnimationFrame !== 'undefined') { + cancelAnimationFrame(syncFrame); + syncFrame = undefined; + } if (elementDragInteraction) container.style.userSelect = previousUserSelect; if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; - for (const cleanup of customSourceCleanups) cleanup(); }; - const clearRequestedUpdate = async (updateId: string): Promise => { - if (selectionState.get(updateId).size === 0) return; - selectionState.clear(updateId); - await sync(); + const clearUpdate = async (id: string): Promise => { + if (retainedUpdates.delete(id)) await renderUpdates(); + }; + const replaceUpdates = async ( + nextUpdates: readonly ChartUpdate[], + ): Promise => { + retainedUpdates.clear(); + const results: ChartUpdateResult[] = []; + for (const update of nextUpdates) { + const resolved = resolveUpdate(update); + retainedUpdates.set(update.id, presentUpdate(resolved.update, context())); + results.push(resolved.result); + } + await renderUpdates(); + return results; }; return { - dispatch: dispatchExternal, - applyUpdate: applyRequestedUpdate, - clearUpdate: clearRequestedUpdate, + getInteractionContext: context, + applyUpdate: (update, _options) => storeUpdate(update, retainedUpdates), + setUpdates: replaceUpdates, + clearUpdate, + refresh: () => { + observeRenderer(); + syncOverlays(); + }, destroy, }; } diff --git a/packages/flint-js/src/vegalite/interactive-focus.ts b/packages/flint-js/src/vegalite/interactive-focus.ts index 53c1c3ad..75660663 100644 --- a/packages/flint-js/src/vegalite/interactive-focus.ts +++ b/packages/flint-js/src/vegalite/interactive-focus.ts @@ -1,4 +1,4 @@ -import { DEFAULT_DIM_OPACITY } from '../interactive/updates/emphasis'; +import { DEFAULT_DIM_OPACITY } from '../interactive/presets/utils'; const FOCUS_PARAM = '__flint_focus'; const FOCUS_KEY = '__flint_focus_key'; diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index ec3f9a8d..c7d6801f 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -1,6 +1,6 @@ import { applyCategoryViewports } from '../core/filter-overflow'; import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; -import type { InteractionDef } from '../interactive/interactions'; +import { isCanvasInteraction, type InteractionDef } from '../interactive/interactions'; import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; import { assembleVegaLite } from './assemble'; import { @@ -18,6 +18,7 @@ import { Handler } from 'vega-tooltip'; export interface VegaInteractiveRendererOptions { renderer?: 'canvas' | 'svg'; interactions?: readonly InteractionDef[]; + enableSemanticUpdates?: boolean; expressionInterpreter?: unknown; background?: string; } @@ -63,10 +64,15 @@ export function createVegaInteractiveRenderer( const vlSpec = assembleVegaLite(firstInput) as any; applyViewportSorts(vlSpec, viewports); const interactions = options.interactions ?? []; - const interactionPlan = addVegaLiteInteractions(vlSpec, interactions); + const canvasInteractions = interactions.filter(isCanvasInteraction); + const interactionPlan = addVegaLiteInteractions( + vlSpec, + interactions, + options.enableSemanticUpdates, + ); const vegaSpec = compile(vlSpec).spec as any; if (interactionPlan) { - if (interactions.some((interaction) => interaction.eventSource.type !== 'navigation')) { + if (interactionPlan.semanticStores) { injectVegaInteractionStore(vegaSpec, interactionPlan); } interactionPlan.navigationAxes = injectVegaNavigationSignals( @@ -137,11 +143,14 @@ export function createVegaInteractiveRenderer( return { viewports, - dispatchInteraction(event) { - return interactionController?.dispatch(event); + getInteractionContext() { + return interactionController?.getInteractionContext() ?? { + chartType: input.chart_spec.chartType, + selected: [], + }; }, - async applyUpdate(update) { - if (interactionController) return interactionController.applyUpdate(update); + async applyUpdate(update, options) { + if (interactionController) return interactionController.applyUpdate(update, options); return { status: 'unsupported', resolvedTargets: 0, @@ -149,8 +158,20 @@ export function createVegaInteractiveRenderer( unsupportedOps: [...new Set(update.ops.map((op) => op.op))], }; }, - async clearUpdate(updateId) { - await interactionController?.clearUpdate(updateId); + async setUpdates(updates) { + if (interactionController) return interactionController.setUpdates(updates); + return updates.map((update) => ({ + status: 'unsupported' as const, + resolvedTargets: 0, + unresolvedTargets: [], + unsupportedOps: [...new Set(update.ops.map((op) => op.op))], + })); + }, + async clearUpdate(id) { + await interactionController?.clearUpdate(id); + }, + refresh() { + interactionController?.refresh(); }, getViewportGeometry(channel) { const [left, top] = view.origin(); diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 1b90d364..0ffb908f 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -11,7 +11,7 @@ import { resolveSeriesTarget, targetFromHits, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; const interpolateConfigProperty: ChartPropertyDef = { key: "interpolate", label: "Curve", type: "discrete", options: [ diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index 78f68e3e..3e1454c7 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -10,7 +10,11 @@ import { legendMatchedHits, targetFromHits, } from '../../core/interaction-semantics'; -import { barAnnotationCandidates, presentAnnotationUpdate } from '../../interactive/updates/annotation'; +import { + barAnnotationCandidates, + presentAnnotationUpdate, + valueAnnotationText, +} from '../../interactive/presentation/annotation'; import { formatSpecToVegaExpr } from '../format'; /** @@ -47,6 +51,7 @@ export const barTableDef: ChartTemplateDef = { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); const colorField = resolvedEncodings.color?.field; + const valueField = resolvedEncodings.x?.field; return { fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'color']), categoryField, @@ -61,7 +66,10 @@ export const barTableDef: ChartTemplateDef = { : event.hits; return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'bar-table-row' }); }, - presentUpdate: presentAnnotationUpdate(() => barAnnotationCandidates('x')), + presentUpdate: presentAnnotationUpdate( + () => barAnnotationCandidates('x'), + valueAnnotationText(valueField), + ), }; }, suppressValueLabels: true, diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index c07c1c91..7781d591 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -19,7 +19,7 @@ import { countAnnotationText, presentAnnotationUpdate, valueAnnotationText, -} from '../../interactive/updates/annotation'; +} from '../../interactive/presentation/annotation'; import { withInteractionTextLabel } from '../interaction-provenance'; import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, diff --git a/packages/flint-js/src/vegalite/templates/bullet.ts b/packages/flint-js/src/vegalite/templates/bullet.ts index a9dede26..86f52b27 100644 --- a/packages/flint-js/src/vegalite/templates/bullet.ts +++ b/packages/flint-js/src/vegalite/templates/bullet.ts @@ -8,7 +8,12 @@ import { MUTED_HOVER_STROKE, resolveSeriesTarget, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/updates/annotation'; +import { + annotationCandidates, + comparisonAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; +import { INTERACTION_ROLE } from '../interactions/hit-adapter'; /** * Bullet chart — a compact KPI panel: one row per label, each showing a measure @@ -52,6 +57,8 @@ export const bulletChartDef: ChartTemplateDef = { const categoryField = resolvedEncodings.y?.field; const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); const colorField = resolvedEncodings.color?.field; + const actualField = resolvedEncodings.x?.field; + const expectedField = resolvedEncodings.goal?.field; return { fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'x', 'goal', 'color', 'column', 'row']), categoryField, @@ -63,9 +70,16 @@ export const bulletChartDef: ChartTemplateDef = { tick: { strokeWidth: 5 }, }, resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), - presentUpdate: presentAnnotationUpdate(() => annotationCandidates( - 'right', 'left', 'top', 'bottom', 'center', - )), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('right', 'left', 'top', 'bottom', 'center').map((candidate) => ({ + ...candidate, + connectorAnchors: [ + { role: 'bullet-actual', connection: 'value-end' as const, valueAxis: 'x' as const }, + { role: 'bullet-expected', connection: 'center' as const }, + ], + })), + comparisonAnnotationText(actualField, expectedField), + ), }; }, declareLayoutMode: () => ({ @@ -147,6 +161,10 @@ export const bulletChartDef: ChartTemplateDef = { title: null, }; } + barLayer.transform = [ + ...(barLayer.transform ?? []), + { calculate: "'bullet-actual'", as: INTERACTION_ROLE }, + ]; layers.push(barLayer); // --- Target marker — a dark tick at the goal, sized to the row band --- @@ -161,6 +179,7 @@ export const bulletChartDef: ChartTemplateDef = { ? Math.min(band, Math.max(8, Math.round(band * 0.72))) : 22; layers.push({ + transform: [{ calculate: "'bullet-expected'", as: INTERACTION_ROLE }], mark: { type: 'tick', color: '#1a1a1a', thickness: 3, opacity: 1, size: tickSize }, encoding: { x: { field: goal.field, type: 'quantitative', axis: xAxis }, diff --git a/packages/flint-js/src/vegalite/templates/bump.ts b/packages/flint-js/src/vegalite/templates/bump.ts index b09a8f70..0f975ee0 100644 --- a/packages/flint-js/src/vegalite/templates/bump.ts +++ b/packages/flint-js/src/vegalite/templates/bump.ts @@ -10,7 +10,7 @@ import { MUTED_HOVER_STROKE, resolveSeriesTarget, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; /** Semantic types that indicate a rank-like field */ const RANK_SEMANTIC_TYPES = new Set(['Rank', 'Score', 'Level']); diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 886d4f1f..78a2ba44 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -19,7 +19,11 @@ import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; import { MUTED_HOVER_STROKE, targetFromHits } from '../../core/interaction-semantics'; -import { suppressAnnotationUpdate } from '../../interactive/updates/annotation'; +import { + annotationCandidates, + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; /** Weekday row order, Monday-first — mirrors the ECharts template's dayLabel.firstDay = 1. */ const WEEKDAY_ORDER = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -83,8 +87,10 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, channels: ['x', 'color'], markCognitiveChannel: 'color', - semanticInteractions: () => ({ - fields: [WEEK_FIELD, WEEKDAY_FIELD], + semanticInteractions: ({ resolvedEncodings }) => { + const valueField = resolvedEncodings.color?.field ?? COUNT_FIELD; + return { + fields: [WEEK_FIELD, WEEKDAY_FIELD, DATE_FIELD], categoryField: WEEK_FIELD, selectableMarks: ['rect'], renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, @@ -92,8 +98,12 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { kind: 'mark', role: 'calendar-day', }), - presentUpdate: suppressAnnotationUpdate, - }), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('center', 'top', 'right', 'bottom', 'left'), + categoryValueAnnotationText(DATE_FIELD, valueField), + ), + }; + }, declareLayoutMode: () => ({ // Both axes are ordinal bands (week columns × weekday rows); square-ish // cells read as a calendar rather than a stretched grid. @@ -176,6 +186,12 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { legend: { title: null }, scale: colorScale, }, + tooltip: [ + { field: DATE_FIELD, type: 'temporal', title: 'Date' }, + valueField + ? { field: valueField, aggregate: 'sum', type: 'quantitative' } + : { field: COUNT_FIELD, aggregate: 'sum', type: 'quantitative' }, + ], }; }, encodingActions: [ diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts index cee9a6d4..f865259a 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -4,7 +4,7 @@ import { ChartTemplateDef } from '../../core/types'; import { adjustBarMarks } from './utils'; import { elementsFromHits, fieldsFromEncodingChannels } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/presentation/annotation'; export const candlestickChartDef: ChartTemplateDef = { chart: "Candlestick Chart", diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index f833417e..3758dace 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -36,7 +36,7 @@ import { MUTED_HOVER_STROKE, targetFromHits, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; /** * Pick a *sortable* Vega-Lite type for the order encoding. The order channel diff --git a/packages/flint-js/src/vegalite/templates/density.ts b/packages/flint-js/src/vegalite/templates/density.ts index 67732b19..59b61e04 100644 --- a/packages/flint-js/src/vegalite/templates/density.ts +++ b/packages/flint-js/src/vegalite/templates/density.ts @@ -7,7 +7,11 @@ import { firstDiscreteEncodingField, resolveSeriesTarget, } from '../../core/interaction-semantics'; -import { suppressAnnotationUpdate } from '../../interactive/updates/annotation'; +import { + annotationCandidates, + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; /** * Silverman/Scott rule-of-thumb bandwidth, matching vega-statistics' bandwidth.js @@ -83,7 +87,10 @@ export const densityPlotDef: ChartTemplateDef = { selectableMarks: ['area'], renderHoverStyles: { area: { opacity: 'spotlight' } }, resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), - presentUpdate: suppressAnnotationUpdate, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint'), + categoryValueAnnotationText('value', 'density'), + ), }; }, instantiate: (spec, ctx) => { diff --git a/packages/flint-js/src/vegalite/templates/ecdf.ts b/packages/flint-js/src/vegalite/templates/ecdf.ts index 14cd9e09..d724acc8 100644 --- a/packages/flint-js/src/vegalite/templates/ecdf.ts +++ b/packages/flint-js/src/vegalite/templates/ecdf.ts @@ -35,7 +35,11 @@ import { MUTED_HOVER_STROKE, resolveSeriesTarget, } from '../../core/interaction-semantics'; -import { suppressAnnotationUpdate } from '../../interactive/updates/annotation'; +import { + annotationCandidates, + presentAnnotationUpdate, + valueAnnotationText, +} from '../../interactive/presentation/annotation'; import { setMarkProp } from './utils'; const showPointsProperty: ChartPropertyDef = { @@ -65,6 +69,7 @@ export const ecdfPlotDef: ChartTemplateDef = { navigation: { axes: ['x'] }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { + const valueField = resolvedEncodings.x?.field; const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); const colorField = resolvedEncodings.color?.field; return { @@ -78,7 +83,10 @@ export const ecdfPlotDef: ChartTemplateDef = { }, renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), - presentUpdate: suppressAnnotationUpdate, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint'), + valueAnnotationText(valueField), + ), }; }, declareLayoutMode: () => ({ diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts index 281ac282..3d9fa0ff 100644 --- a/packages/flint-js/src/vegalite/templates/gantt.ts +++ b/packages/flint-js/src/vegalite/templates/gantt.ts @@ -8,7 +8,7 @@ import { legendMatchedHits, targetFromHits, } from '../../core/interaction-semantics'; -import { presentAnnotationUpdate, rangeAnnotationText, valueEndAnnotationCandidates } from '../../interactive/updates/annotation'; +import { presentAnnotationUpdate, rangeAnnotationText, valueEndAnnotationCandidates } from '../../interactive/presentation/annotation'; import { withInteractionTextLabel } from '../interaction-provenance'; import { coerceGanttEndpoint, diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts index 40968dd1..f4dd6efa 100644 --- a/packages/flint-js/src/vegalite/templates/jitter.ts +++ b/packages/flint-js/src/vegalite/templates/jitter.ts @@ -11,7 +11,7 @@ import { MUTED_HOVER_STROKE, targetFromHits, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; export const stripPlotDef: ChartTemplateDef = { chart: "Strip Plot", diff --git a/packages/flint-js/src/vegalite/templates/kpi-card.ts b/packages/flint-js/src/vegalite/templates/kpi-card.ts index eb126ff8..8b91cba0 100644 --- a/packages/flint-js/src/vegalite/templates/kpi-card.ts +++ b/packages/flint-js/src/vegalite/templates/kpi-card.ts @@ -6,7 +6,7 @@ import { fieldsFromEncodingChannels, targetFromHits, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; import { withInteractionDecorative, withInteractionTextLabel } from '../interaction-provenance'; /** diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index f28ccdc1..15afa38a 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -8,7 +8,7 @@ import { MUTED_HOVER_STROKE, resolveSeriesTarget, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; export const interpolateConfigProperty: ChartPropertyDef = { key: "interpolate", label: "Curve", type: "discrete", options: [ diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 21fa14e6..33b480bc 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -13,7 +13,7 @@ import { MUTED_HOVER_STROKE, targetFromHits, } from '../../core/interaction-semantics'; -import { lollipopAnnotationCandidates, presentAnnotationUpdate } from '../../interactive/updates/annotation'; +import { lollipopAnnotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; export const lollipopChartDef: ChartTemplateDef = { chart: "Lollipop Chart", diff --git a/packages/flint-js/src/vegalite/templates/map.ts b/packages/flint-js/src/vegalite/templates/map.ts index 0d4e676c..4bd1f507 100644 --- a/packages/flint-js/src/vegalite/templates/map.ts +++ b/packages/flint-js/src/vegalite/templates/map.ts @@ -14,7 +14,11 @@ import { MUTED_HOVER_STROKE, targetFromHits, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/updates/annotation'; +import { + annotationCandidates, + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; const mapProjections = [ { value: "mercator", label: "Mercator" }, @@ -319,7 +323,10 @@ export const choroplethDef: ChartTemplateDef = { kind: 'region', role: 'geographic-region', }), - presentUpdate: presentAnnotationUpdate(() => annotationCandidates('center')), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('center'), + categoryValueAnnotationText(idField, colorField), + ), legendFields: colorField ? { color: colorField } : undefined, }; }, diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index 5b0681f6..fff55365 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -13,7 +13,7 @@ import { annotationCandidates, categoryValueAnnotationText, presentAnnotationUpdate, -} from '../../interactive/updates/annotation'; +} from '../../interactive/presentation/annotation'; import { setMarkProp } from './utils'; export const pieChartDef: ChartTemplateDef = { @@ -30,6 +30,7 @@ export const pieChartDef: ChartTemplateDef = { seriesField, legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['arc'], + annotationMarkType: 'arc', supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { @@ -42,7 +43,6 @@ export const pieChartDef: ChartTemplateDef = { presentUpdate: presentAnnotationUpdate( () => annotationCandidates('radial-midpoint', 'outer-radial'), categoryValueAnnotationText(seriesField, valueField), - 'arc', ), }; }, diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index bf4dd68d..cbd67e5f 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -3,7 +3,7 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { resolveSeriesTarget } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; /** * Radar / Spider Chart diff --git a/packages/flint-js/src/vegalite/templates/range-area.ts b/packages/flint-js/src/vegalite/templates/range-area.ts index 79cd9b46..3eea60a8 100644 --- a/packages/flint-js/src/vegalite/templates/range-area.ts +++ b/packages/flint-js/src/vegalite/templates/range-area.ts @@ -31,7 +31,7 @@ import { legendMatchedHits, targetFromHits, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/presentation/annotation'; import { defaultBuildEncodings, setMarkProp } from './utils'; const interpolateConfigProperty: ChartPropertyDef = { diff --git a/packages/flint-js/src/vegalite/templates/rose.ts b/packages/flint-js/src/vegalite/templates/rose.ts index f2d07598..36220358 100644 --- a/packages/flint-js/src/vegalite/templates/rose.ts +++ b/packages/flint-js/src/vegalite/templates/rose.ts @@ -27,7 +27,7 @@ import { annotationCandidates, presentAnnotationUpdate, valueAnnotationText, -} from '../../interactive/updates/annotation'; +} from '../../interactive/presentation/annotation'; import { withInteractionTextLabel } from '../interaction-provenance'; import { setMarkProp } from './utils'; @@ -55,6 +55,7 @@ export const roseChartDef: ChartTemplateDef = { seriesField, legendFields: colorLegendField ? { color: colorLegendField } : undefined, selectableMarks: ['arc'], + annotationMarkType: 'arc', supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { @@ -74,7 +75,6 @@ export const roseChartDef: ChartTemplateDef = { presentUpdate: presentAnnotationUpdate( () => annotationCandidates('outer-radial'), valueAnnotationText(valueField), - 'arc', ), }; }, diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index a2c41d23..267d36a3 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -21,7 +21,7 @@ import { presentAnnotationUpdate, seriesValuesAnnotationText, suppressAnnotationUpdate, -} from '../../interactive/updates/annotation'; +} from '../../interactive/presentation/annotation'; const isDiscreteType = (t: string | undefined) => t === 'nominal' || t === 'ordinal'; diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts index bd685157..5c9ac1a4 100644 --- a/packages/flint-js/src/vegalite/templates/slope.ts +++ b/packages/flint-js/src/vegalite/templates/slope.ts @@ -34,7 +34,7 @@ import { MUTED_HOVER_STROKE, resolveSeriesTarget, } from '../../core/interaction-semantics'; -import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/updates/annotation'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; diff --git a/packages/flint-js/src/vegalite/templates/sparkline.ts b/packages/flint-js/src/vegalite/templates/sparkline.ts index 698cd235..5bc6336b 100644 --- a/packages/flint-js/src/vegalite/templates/sparkline.ts +++ b/packages/flint-js/src/vegalite/templates/sparkline.ts @@ -10,7 +10,7 @@ import { } from '../../core/interaction-semantics'; import { formatSpecToVegaExpr } from '../format'; import { interpolateConfigProperty, applyInterpolate } from './line'; -import { suppressAnnotationUpdate } from '../../interactive/updates/annotation'; +import { suppressAnnotationUpdate } from '../../interactive/presentation/annotation'; /** * Sparkline — a "sparkline table" / small-multiples strip layout. diff --git a/packages/flint-js/src/vegalite/templates/violin.ts b/packages/flint-js/src/vegalite/templates/violin.ts index 682030cc..9fa6409c 100644 --- a/packages/flint-js/src/vegalite/templates/violin.ts +++ b/packages/flint-js/src/vegalite/templates/violin.ts @@ -33,7 +33,10 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { resolveSeriesTarget } from '../../core/interaction-semantics'; -import { suppressAnnotationUpdate } from '../../interactive/updates/annotation'; +import { + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; import { detectBandedAxisForceDiscrete } from '../../core/axis-detection'; import { planBandDodge } from '../../core/band-dodge'; @@ -149,6 +152,7 @@ export const violinPlotDef: ChartTemplateDef = { markCognitiveChannel: 'area', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = resolvedEncodings.x?.field; + const measureField = resolvedEncodings.y?.field; const colorField = resolvedEncodings.color?.field; const rowField = resolvedEncodings.row?.field; const seriesField = colorField ?? categoryField; @@ -161,7 +165,10 @@ export const violinPlotDef: ChartTemplateDef = { selectableMarks: ['area'], renderHoverStyles: { area: { opacity: 'spotlight' } }, resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), - presentUpdate: suppressAnnotationUpdate, + presentUpdate: presentAnnotationUpdate( + () => ({ connection: 'segment-midpoint', maxDistance: 120, maxWidth: 120 }), + categoryValueAnnotationText(categoryField, measureField), + ), }; }, declareLayoutMode: (cs, table) => { diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 75d64c4b..dce58fed 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -10,7 +10,7 @@ import { targetFromHits, } from '../../core/interaction-semantics'; import type { AnnotationCandidate } from '../../interactive/interactions'; -import { presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/updates/annotation'; +import { presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/presentation/annotation'; import { withInteractionTextLabel } from '../interaction-provenance'; import { resolveTotalsMode } from '../../chart-types/waterfall'; @@ -38,6 +38,7 @@ export const waterfallChartDef: ChartTemplateDef = { template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], navigation: {}, + reorder: { markTypes: ['rect'] }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); @@ -47,8 +48,10 @@ export const waterfallChartDef: ChartTemplateDef = { fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'color']), categoryField, seriesField, + resolveGroupValue: (element) => element.records?.[0]?.__wf_color, legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['bar'], + annotationMarkType: 'rect', renderHoverStyles: { rect: { opacity: 'contrast' } }, resolve: (event, context) => { const legendField = event.legendField ?? seriesField; @@ -63,7 +66,6 @@ export const waterfallChartDef: ChartTemplateDef = { presentUpdate: presentAnnotationUpdate( waterfallAnnotationCandidates, rangeAnnotationText('__wf_prev_sum', '__wf_sum'), - 'rect', ), }; }, diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index ae69773a..7a7a73e0 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,24 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, dragReorder, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, dragReorder, externalInteraction, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; -import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/updates/annotation'; -import { applySelectionMode } from '../src/interactive/updates/emphasis'; -import { ScopedSelectionState } from '../src/interactive/selection-state'; +import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; import { toCanvasInteractionEvent } from '../src/interactive/canvas-interaction'; import { semanticVisualFamily } from '../src/core/interaction-semantics'; import { - annotate, - clearAnnotation, - emphasize, - navigateViewport, - resetUpdate, matchesSemanticTargetSelector, -} from '../src/interactive/updates/request'; +} from '../src/interactive/language/updates'; import { axisBrushTrigger, angularBrushTrigger, clickTrigger, - externalTrigger, hoverTrigger, navigationTrigger, rectangleTrigger, @@ -40,14 +32,29 @@ import { PATH_KEY_SUFFIX, pathHoverPresentationKey, normalizeVegaRegionEvent, + renderHit, + sceneItems, } from '../src/vegalite/interactions/hit-adapter'; -import { interactionsForHoverPresentation, nearestReorderHit } from '../src/vegalite/interactions/runtime'; +import { + interactionsForHoverPresentation, + nearestReorderHit, + resolveSupportedOperation, +} from '../src/vegalite/interactions/runtime'; +import { reorderOwnedItems } from '../src/vegalite/interactions/presentation/drag-reorder-overlay'; import { hoverContrastOpacity } from '../src/vegalite/interactions/presentation/focus-overlay'; +import { + annotationFacingEdges, + annotationLeaderPorts, + routeAnnotationLeaders, +} from '../src/vegalite/interactions/presentation/annotation-leader-routing'; import { annotationCandidateAngles, annotationItem, annotationObstacleOverlapCost, annotationObstacleTier, + annotationSourceBounds, + sourceEdgeAttachment, + isAnnotationSourceItem, isAnnotationObstacle, segmentMidpointConnectionPoint, valueEndConnectionPoint, @@ -64,16 +71,34 @@ import { rangeAreaChartDef } from '../src/vegalite/templates/range-area'; import { boxplotDef } from '../src/vegalite/templates/scatter'; import { waterfallChartDef } from '../src/vegalite/templates/waterfall'; import type { + CanvasInteractionDef, InteractionContext, InteractionDef, InteractionModifiers, InteractionPhase, + ChartUpdateOp, SemanticInteractionEvent, + SemanticElement, SemanticTarget, } from '../src/interactive/interactions'; +function annotationUpdate( + element: SemanticElement, + visual: SemanticTarget['visual'] = { kind: 'mark', role: 'test' }, + text?: string, +) { + return { + id: 'test-annotation', + ops: [{ + op: 'set-annotation' as const, + target: { visual, elements: [element] }, + value: text === undefined ? {} : { text }, + }], + }; +} + function handleSemanticEvent( - interaction: InteractionDef, + interaction: CanvasInteractionDef, event: SemanticInteractionEvent, context: InteractionContext, ) { @@ -81,7 +106,7 @@ function handleSemanticEvent( } function semanticUpdate( - interaction: InteractionDef, + interaction: CanvasInteractionDef, target: SemanticTarget | null, context: InteractionContext, options: { @@ -279,51 +304,35 @@ describe('hover presentation policy', () => { }); }); -describe('public chart update requests', () => { +describe('public chart updates', () => { const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ key: { __flint_interaction_key: 'japan' } }], }; - it('constructs target-bearing semantic operations', () => { - expect(emphasize({ targets: [target] })).toEqual({ - op: 'emphasize', - targets: [target], - mode: 'replace', - dimOpacity: 0.25, - }); - expect(emphasize({ + it('uses direct declarative operation JSON', () => { + const ops: ChartUpdateOp[] = [{ + op: 'set-presentation', targets: [target, { select: { key: { Country: 'Japan' } } }], - mode: 'replace', - dimOpacity: 0.25, - })).toEqual({ - op: 'emphasize', + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }, { + op: 'set-annotation', target, value: { text: 'Selected' }, + }, { + op: 'set-viewport', axes: 'x', value: { x: [0, 10] }, + }, { + op: 'set-order', scope: 'category', field: 'Country', values: ['Japan'], + }]; + expect(ops).toEqual([{ + op: 'set-presentation', targets: [target, { select: { key: { Country: 'Japan' } } }], - mode: 'replace', - dimOpacity: 0.25, - }); - expect(annotate({ target, text: 'Selected' })).toEqual({ - op: 'annotate', target, text: 'Selected', - }); - }); - - it('constructs annotation, navigation, and reset operations', () => { - expect(clearAnnotation()).toEqual({ op: 'clear-annotation' }); - expect(navigateViewport({ - operation: 'zoom', - axes: 'x', - factor: 1.2, - })).toMatchObject({ - op: 'navigate-viewport', - operation: 'zoom', - axes: 'x', - domainGuard: { - minVisibleFraction: 0.02, - maxVisibleFraction: 1, - overscrollFraction: 0, - }, - }); - expect(resetUpdate()).toEqual({ op: 'reset' }); + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }, { + op: 'set-annotation', target, value: { text: 'Selected' }, + }, { + op: 'set-viewport', axes: 'x', value: { x: [0, 10] }, + }, { + op: 'set-order', scope: 'category', field: 'Country', values: ['Japan'], + }]); }); it('matches selectors only against declared semantic fields', () => { @@ -340,32 +349,6 @@ describe('public chart update requests', () => { }); }); -describe('selection state updates', () => { - it('replaces or toggles keys without mutating the current set', () => { - const current = new Set(['a', 'b']); - - expect([...applySelectionMode(current, ['c'], 'replace')]).toEqual(['c']); - expect([...applySelectionMode(current, ['b', 'c'], 'toggle')]).toEqual(['a', 'b', 'c']); - expect([...applySelectionMode(current, ['a', 'b'], 'toggle')]).toEqual([]); - expect([...current]).toEqual(['a', 'b']); - }); - - it('keeps interaction-owned selections independent', () => { - const state = new ScopedSelectionState(); - state.set(new Set(['first', 'second']), 'brush-x'); - state.set(new Set(['third']), 'linked-view'); - - state.set(new Set(['second']), 'brush-x'); - expect([...state.get('brush-x')]).toEqual(['second']); - expect([...state.combined()]).toEqual(['second', 'third']); - - state.clear('brush-x'); - expect([...state.combined()]).toEqual(['third']); - state.clear(); - expect([...state.combined()]).toEqual([]); - }); -}); - describe('viewport navigation', () => { it('allows an interaction observer without a handler', () => { const interaction: InteractionDef = { @@ -385,30 +368,56 @@ describe('viewport navigation', () => { expect(wheelZoomFactor(1, 1, 400, 0.002)).toBeCloseTo(Math.exp(-0.032)); }); - it('declares navigation input separately from its viewport handler', () => { + it('resolves normalized navigation input through its viewport handler', () => { const interaction = navigate({ axes: 'xy' }); expect(interaction.eventSource).toEqual(navigationTrigger({ axes: 'xy' })); - expect(interaction.handle!(toCanvasInteractionEvent({ - type: 'navigation', phase: 'commit', operation: 'zoom', axes: 'xy', - factor: 1.5, anchor: { x: 0.25, y: 0.75 }, - }, interaction.eventSource), { chartType: 'Scatter Plot', selected: [] })).toEqual({ - updateId: 'navigate', - phase: 'commit', - ops: [{ - op: 'navigate-viewport', operation: 'zoom', axes: 'xy', - factor: 1.5, anchor: { x: 0.25, y: 0.75 }, - domainGuard: { - minVisibleFraction: 0.02, - maxVisibleFraction: 1, - overscrollFraction: 0, - }, - }], + expect(interaction.handle).toBeTypeOf('function'); + expect(interaction.navigationDomainGuard).toEqual({ + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, + }); + const resolveNavigation = vi.fn(() => ({ + op: 'set-viewport' as const, + axes: 'x' as const, + value: { x: [10, 20] as const }, + })); + const update = interaction.handle!(toCanvasInteractionEvent({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes: 'x', + factor: 2, anchor: { x: 0.5, y: 0.5 }, + }, interaction.eventSource), { + chartType: 'Line Chart', selected: [], resolveNavigation, }); + expect(update).toEqual({ id: 'navigate', ops: [{ + op: 'set-viewport', axes: 'x', value: { x: [10, 20] }, + }] }); + expect(resolveNavigation).toHaveBeenCalledWith(expect.objectContaining({ + operation: 'zoom', axes: 'x', factor: 2, + }), interaction.navigationDomainGuard); expect(() => navigate({ domainGuard: { minVisibleFraction: 0.5, maxVisibleFraction: 0.25 }, })).toThrow(/maxVisibleFraction/); }); + it('filters update operations against compiled chart capabilities', () => { + const plan = { + navigationAxes: { x: { scale: 'x', signal: 'xDomain', type: 'linear' as const } }, + reorderAxes: [{ axis: 'x' as const, field: 'Month', scale: 'x', signal: 'xOrder' }], + }; + expect(resolveSupportedOperation({ + op: 'set-viewport', axes: 'xy', value: { x: [0, 5], y: [0, 10] }, + }, plan)).toEqual({ + op: { op: 'set-viewport', axes: 'x', value: { x: [0, 5] } }, + unsupported: true, + }); + expect(resolveSupportedOperation({ + op: 'set-order', scope: 'series', field: 'Month', values: ['Jan'], + }, plan)).toEqual({ op: null, unsupported: true }); + expect(resolveSupportedOperation({ + op: 'set-order', scope: 'category', field: 'Month', values: ['Jan'], + }, plan).unsupported).toBe(false); + }); + it('guards linear, temporal, and logarithmic domains against the initial extent', () => { const guard = { minVisibleFraction: 0.1, maxVisibleFraction: 1, overscrollFraction: 0 }; expect(guardNavigationDomain([45, 46], [0, 100], 'linear', guard)).toEqual([40.5, 50.5]); @@ -469,8 +478,8 @@ describe('interaction definitions', () => { }); expect(update).toEqual({ - updateId: 'drag-reorder', phase: 'commit', - ops: [{ op: 'reorder-category', axis: 'x', field: 'Category', orderedValues: ['B', 'C', 'A'] }], + id: 'drag-reorder', + ops: [{ op: 'set-order', scope: 'category', field: 'Category', values: ['B', 'C', 'A'] }], }); }); @@ -491,9 +500,9 @@ describe('interaction definitions', () => { })?.ops[0]; const first = drag(4, 2, ['1', '2', '3', '4', '5']); - expect(first).toMatchObject({ orderedValues: ['1', '2', '5', '3', '4'] }); - const second = drag(3, 4, first?.op === 'reorder-category' ? first.orderedValues as string[] : []); - expect(second).toMatchObject({ orderedValues: ['1', '2', '4', '5', '3'] }); + expect(first).toMatchObject({ values: ['1', '2', '5', '3', '4'] }); + const second = drag(3, 4, first?.op === 'set-order' ? first.values as string[] : []); + expect(second).toMatchObject({ values: ['1', '2', '4', '5', '3'] }); }); it.each([ @@ -516,7 +525,7 @@ describe('interaction definitions', () => { ], }); - expect(update?.ops[0]).toEqual({ op: 'reorder-category', axis, field, orderedValues }); + expect(update?.ops[0]).toEqual({ op: 'set-order', scope: 'category', field, values: orderedValues }); }); it('keeps a Heatmap drag on its locked axis after the pointer changes direction', () => { @@ -542,7 +551,7 @@ describe('interaction definitions', () => { }); expect(update?.ops[0]).toEqual({ - op: 'reorder-category', axis: 'x', field: 'column', orderedValues: ['B', 'A'], + op: 'set-order', scope: 'category', field: 'column', values: ['B', 'A'], }); }); @@ -597,7 +606,13 @@ describe('interaction definitions', () => { expect(angularBrushTrigger('contain')).toEqual({ type: 'region', gesture: 'drag', regionGeometry: 'angular', match: 'contain', mode: 'ephemeral', }); - expect(externalTrigger('story-scroll')).toEqual({ type: 'external', source: 'story-scroll' }); + const external = externalInteraction<{ selected: boolean }>({ + id: 'story-scroll', + handle: (payload) => payload.selected ? { id: 'story-scroll', ops: [] } : null, + }); + expect(external.external).toBe(true); + expect(external.handle({ selected: true }, { chartType: 'Bar Chart', selected: [] })) + .toEqual({ id: 'story-scroll', ops: [] }); }); it('processes resolved semantic events through normalized update policies', () => { @@ -610,16 +625,20 @@ describe('interaction definitions', () => { expect(handleSemanticEvent(clickHighlight(), { type: 'semantic', source: 'element', phase: 'commit', target, }, context)).toEqual({ - updateId: 'click-highlight', - phase: 'commit', - ops: [{ op: 'emphasize', targets: [target], mode: 'replace', dimOpacity: 0.25 }], + id: 'click-highlight', + ops: [{ + op: 'set-presentation', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], }); expect(handleSemanticEvent(select(), { type: 'semantic', source: 'region', phase: 'preview', target, }, context)).toEqual({ - updateId: 'select', - phase: 'preview', - ops: [{ op: 'emphasize', targets: [target], mode: 'replace', dimOpacity: 0.25 }], + id: 'select', + ops: [{ + op: 'set-presentation', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], }); }); @@ -650,18 +669,25 @@ describe('interaction definitions', () => { target, }; expect(handleSemanticEvent(brushX(), { ...event, axis: 'x' }, context)).toEqual({ - updateId: 'brush-x', - phase: 'preview', - ops: [{ op: 'emphasize', targets: [target], mode: 'replace', dimOpacity: 0.25 }], + id: 'brush-x', + ops: [{ + op: 'set-presentation', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], }); expect(handleSemanticEvent(brushX(), { ...event, axis: 'y' }, context)).toBeNull(); expect(handleSemanticEvent(brushX({ mode: 'stateful' }), { ...event, axis: 'x', phase: 'commit', operation: 'clear', target: null, - }, context)).toEqual({ updateId: 'brush-x', phase: 'commit', ops: [{ op: 'reset' }] }); + }, context)).toEqual({ + id: 'brush-x', + ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + }); expect(handleSemanticEvent(brushAngle(), { ...event, axis: 'angle' }, context)).toEqual({ - updateId: 'brush-angle', - phase: 'preview', - ops: [{ op: 'emphasize', targets: [target], mode: 'replace', dimOpacity: 0.25 }], + id: 'brush-angle', + ops: [{ + op: 'set-presentation', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], }); expect(handleSemanticEvent(brushAngle(), { ...event, axis: 'x' }, context)).toBeNull(); }); @@ -712,20 +738,21 @@ describe('interaction definitions', () => { const interaction = select(); const context = { chartType: 'Waterfall Chart', selected: [{ key: { Step: 'Revenue' } }] }; expect(semanticUpdate(interaction, null, context, { source: 'region' })) - .toEqual({ updateId: 'select', phase: 'commit', ops: [{ op: 'reset' }] }); + .toEqual({ + id: 'select', + ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + }); }); - it('maps the deprecated focusOnClick alias without duplicating an explicit definition', () => { - expect(normalizeInteractions(undefined, undefined)).toEqual([]); - expect(normalizeInteractions(undefined, true).map((interaction) => interaction.id)).toEqual(['click-highlight']); - expect(normalizeInteractions([clickHighlight()], true).map((interaction) => interaction.id)).toEqual(['click-highlight']); + it('normalizes omitted interactions to an empty collection', () => { + expect(normalizeInteractions(undefined)).toEqual([]); }); it('rejects duplicate interaction ids', () => { expect(() => normalizeInteractions([ clickHighlight({ id: 'selection' }), select({ id: 'selection' }), - ], false)).toThrow('Duplicate interaction id: "selection".'); + ])).toThrow('Duplicate interaction id: "selection".'); }); it('produces replace and toggle emphasis updates', () => { @@ -742,8 +769,12 @@ describe('interaction definitions', () => { modifiers: { shift: true, ctrl: false, meta: false }, }); - expect(replace?.ops[0]).toMatchObject({ op: 'emphasize', mode: 'replace', dimOpacity: 0.2 }); - expect(toggle?.ops[0]).toMatchObject({ op: 'emphasize', mode: 'toggle' }); + expect(replace?.ops[0]).toMatchObject({ + op: 'set-presentation', value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(toggle?.ops[0]).toMatchObject({ + op: 'set-presentation', value: { state: 'emphasized' }, + }); }); it('keeps basic clicks local and lets group clicks propagate to the series', () => { @@ -831,6 +862,13 @@ describe('interaction definitions', () => { it('uses implicit rendered color for Waterfall grouping', () => { const interaction = clickGroupHighlight(); + const semantics = waterfallChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'ordinal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Type', type: 'nominal' }, + }, + }); const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ key: { key: 'asia' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }], @@ -839,6 +877,7 @@ describe('interaction definitions', () => { chartType: 'Waterfall Chart', selected: [], seriesField: 'Type', + resolveGroupValue: semantics.resolveGroupValue, available: [ ...target.elements, { key: { key: 'africa' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }, @@ -971,20 +1010,25 @@ describe('interaction definitions', () => { const context = { chartType: 'Strip Plot', selected: [] }; expect(semanticUpdate(interaction, target, context)).toEqual({ - updateId: 'click-annotate', - phase: 'commit', + id: 'click-annotate', ops: [ { - op: 'annotate', + op: 'set-annotation', target: { visual: target.visual, elements: target.elements }, + value: {}, + }, + { + op: 'set-presentation', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, }, - { op: 'emphasize', targets: [target], mode: 'replace', dimOpacity: 0.25 }, ], }); expect(semanticUpdate(interaction, null, context)).toEqual({ - updateId: 'click-annotate', - phase: 'commit', - ops: [{ op: 'clear-annotation' }, { op: 'reset' }], + id: 'click-annotate', + ops: [ + { op: 'set-annotation', target: { select: { key: {} } }, value: null }, + { op: 'set-presentation', targets: [], value: { state: 'normal' } }, + ], }); }); @@ -1005,11 +1049,11 @@ describe('interaction definitions', () => { categoryField: 'task', seriesField: 'phase', })?.ops[0]).toMatchObject({ - op: 'annotate', + op: 'set-annotation', }); expect(semanticUpdate(interaction, target, { chartType: 'Gantt Chart', selected: [], categoryField: 'task', seriesField: 'phase', - })?.ops[0]).not.toHaveProperty('text'); + })?.ops[0]).toMatchObject({ value: {} }); }); it('lets the chart turn annotation intent into a render plan', () => { @@ -1022,17 +1066,19 @@ describe('interaction definitions', () => { })); expect(presentUpdate( - { ops: [{ op: 'annotate', element, text: '1.4' }] }, + annotationUpdate(element, undefined, '1.4'), { chartType: 'Strip Plot', selected: [] }, )).toEqual({ + id: 'test-annotation', ops: [{ - op: 'render-annotation', - element, - annotation: { + op: 'set-annotation', + target: { visual: { kind: 'mark', role: 'test' }, elements: [element] }, + value: { text: '1.4', candidates: [{ connection: 'center', }], + subject: { kind: 'mark', role: 'test' }, }, }], }); @@ -1046,11 +1092,11 @@ describe('interaction definitions', () => { const presentUpdate = presentAnnotationUpdate(() => ({ connection: 'center' })); expect(presentUpdate( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType: 'Strip Plot', selected: [], categoryField: 'Species' }, ).ops[0]).toMatchObject({ - op: 'render-annotation', - annotation: { text: '1.4' }, + op: 'set-annotation', + value: { text: '1.4' }, }); }); @@ -1065,11 +1111,11 @@ describe('interaction definitions', () => { ); expect(presentUpdate( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType: 'Histogram', selected: [] }, ).ops[0]).toMatchObject({ - op: 'render-annotation', - annotation: { text: '8', candidates: [{ connection: 'value-end' }] }, + op: 'set-annotation', + value: { text: '8', candidates: [{ connection: 'value-end' }] }, }); }); @@ -1083,13 +1129,14 @@ describe('interaction definitions', () => { }; expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType: 'Histogram', selected: [] }, ).ops[0]).toEqual({ - op: 'render-annotation', - element, - annotation: { + op: 'set-annotation', + target: { visual: { kind: 'mark', role: 'test' }, elements: [element] }, + value: { text: '9', + subject: { kind: 'mark', role: 'test' }, candidates: [ { connection: 'value-end', valueAxis: 'y', priority: 0 }, { @@ -1106,6 +1153,8 @@ describe('interaction definitions', () => { valueInset: 1 / 8, priority: 1, }, + { connection: 'top', priority: 2 }, + { connection: 'bottom', priority: 2 }, ], }, }); @@ -1154,10 +1203,10 @@ describe('interaction definitions', () => { }; expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType: 'Lollipop Chart', selected: [], categoryField: 'Country' }, ).ops[0]).toMatchObject({ - annotation: { + value: { text: '37', candidates: [ { connection: 'value-end', valueAxis: 'y', anglePreference: 'oblique', priority: 0 }, @@ -1181,12 +1230,11 @@ describe('interaction definitions', () => { }; expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType: 'Waterfall Chart', selected: [] }, ).ops[0]).toMatchObject({ - annotation: { + value: { text: '2,536 → 5,773', - markType: 'rect', candidates: [ { connection: 'value-end', valueAxis: 'y', priority: 0 }, { connection: 'value-side', valueAxis: 'y', crossSide: 'start', valueInset: 1 / 2, priority: 1 }, @@ -1211,6 +1259,14 @@ describe('interaction definitions', () => { expect(angles.every((angle) => Math.abs(angle - stemDirection) > 0.001)).toBe(true); }); + it('searches the full circle for normal edge-target annotations', () => { + const preferred = Math.PI / 3; + const angles = annotationCandidateAngles(preferred); + + expect(angles).toHaveLength(12); + expect(angles.some((angle) => Math.abs(angle - (preferred + Math.PI)) < 1e-10)).toBe(true); + }); + it('ranks legend, solid, and dimmed annotation obstacles', () => { expect(annotationObstacleTier({ mark: { role: 'legend-label' }, opacity: 0.25 })).toBe(3); expect(annotationObstacleTier({ mark: { role: 'axis-label' }, opacity: 1 })).toBe(3); @@ -1220,6 +1276,66 @@ describe('interaction definitions', () => { expect(annotationObstacleOverlapCost(2, 10)).toBeLessThan(annotationObstacleOverlapCost(3, 10)); }); + describe('annotation leader routing', () => { + const card = { left: 100, top: 100, width: 120, height: 80 }; + + it.each([ + [{ x: 80, y: 140 }, ['left']], + [{ x: 240, y: 140 }, ['right']], + [{ x: 160, y: 80 }, ['top']], + [{ x: 160, y: 200 }, ['bottom']], + [{ x: 80, y: 80 }, ['left', 'top']], + [{ x: 240, y: 80 }, ['right', 'top']], + [{ x: 80, y: 200 }, ['left', 'bottom']], + [{ x: 240, y: 200 }, ['right', 'bottom']], + [{ x: 225, y: 230 }, ['bottom']], + [{ x: 260, y: 185 }, ['right']], + ] as const)('uses only card edges facing source %j', (source, edges) => { + expect(annotationFacingEdges(source, card)).toEqual(edges); + expect(edges).toContain(routeAnnotationLeaders({ card, sources: [source] })[0].port.edge); + }); + + it('avoids top-center and bottom-center ports on the text box', () => { + const ports = annotationLeaderPorts(card); + expect(ports).toHaveLength(10); + expect(ports.filter((port) => port.edge === 'top').map((port) => port.fraction)) + .toEqual([0.25, 0.75]); + expect(ports.filter((port) => port.edge === 'bottom').map((port) => port.fraction)) + .toEqual([0.25, 0.75]); + expect(ports.filter((port) => port.edge === 'left').map((port) => port.fraction)) + .toEqual([0.25, 0.5, 0.75]); + expect(ports.filter((port) => port.edge === 'right').map((port) => port.fraction)) + .toEqual([0.25, 0.5, 0.75]); + }); + + it('preserves source order and assigns distinct ports on a shared edge', () => { + const sources = [{ x: 60, y: 112 }, { x: 55, y: 165 }]; + const routes = routeAnnotationLeaders({ card, sources }); + + expect(routes.map((route) => route.port.edge)).toEqual(['left', 'left']); + expect(routes[0].port.fraction).toBeLessThan(routes[1].port.fraction); + expect(routes[0].port).not.toEqual(routes[1].port); + }); + + it('is stable for a diagonal multi-source assignment', () => { + const sources = [{ x: 70, y: 70 }, { x: 250, y: 72 }, { x: 255, y: 205 }]; + const first = routeAnnotationLeaders({ card, sources }); + const second = routeAnnotationLeaders({ card, sources }); + + expect(first).toEqual(second); + expect(first).toHaveLength(sources.length); + expect(new Set(first.map((route) => `${route.port.x},${route.port.y}`)).size).toBe(sources.length); + }); + + it('avoids the top-middle of a rectangular source mark', () => { + const source = { left: 240, top: 144, width: 32, height: 52 }; + const upperLeftCard = { left: 20, top: 40, width: 270, height: 66 }; + + expect(sourceEdgeAttachment(source, upperLeftCard, 'top', { x: 256, y: 144 })) + .toEqual({ x: 248, y: 144 }); + }); + }); + it('routes an area segment annotation normal to and away from the fill', () => { const item = { interactionGeometry: { @@ -1250,9 +1366,9 @@ describe('interaction definitions', () => { }; expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType, selected: [], categoryField: 'Month' }, - ).ops[0]).toMatchObject({ annotation: { text: '10 → 14' } }); + ).ops[0]).toMatchObject({ value: { text: '10 → 14' } }); }); it('gives line paths segment presentation and line points glyph presentation', () => { @@ -1272,21 +1388,21 @@ describe('interaction definitions', () => { }; expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: pathElement, visual: { kind: 'path', role: 'line' } }] }, + annotationUpdate(pathElement, { kind: 'path', role: 'line' }), { chartType: 'Line Chart', selected: [] }, - ).ops[0]).toMatchObject({ annotation: { text: '2.36 → 1.78' } }); + ).ops[0]).toMatchObject({ value: { text: '2.36 → 1.78' } }); expect((semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: pathElement, visual: { kind: 'path', role: 'line' } }] }, + annotationUpdate(pathElement, { kind: 'path', role: 'line' }), { chartType: 'Line Chart', selected: [] }, - ).ops[0] as any).annotation.candidates[0]).toEqual({ connection: 'segment-midpoint', priority: 0 }); + ).ops[0] as any).value.candidates[0]).toEqual({ connection: 'segment-midpoint', priority: 0 }); expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: pointElement, visual: { kind: 'mark', role: 'symbol' } }] }, + annotationUpdate(pointElement, { kind: 'mark', role: 'symbol' }), { chartType: 'Line Chart', selected: [] }, - ).ops[0]).toMatchObject({ annotation: { text: '2.36' } }); + ).ops[0]).toMatchObject({ value: { text: '2.36' } }); expect((semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: pointElement, visual: { kind: 'mark', role: 'symbol' } }] }, + annotationUpdate(pointElement, { kind: 'mark', role: 'symbol' }), { chartType: 'Line Chart', selected: [] }, - ).ops[0] as any).annotation.candidates[0]).toEqual({ connection: 'center', priority: 0 }); + ).ops[0] as any).value.candidates[0]).toEqual({ connection: 'center', priority: 0 }); }); it('gives connected-scatter paths transitions and vertices single-value glyph presentation', () => { @@ -1306,18 +1422,18 @@ describe('interaction definitions', () => { records: [{ Miles: 9800, Price: 2.14 }], }; const pathUpdate = semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: segment, visual: { kind: 'path', role: 'line' } }] }, + annotationUpdate(segment, { kind: 'path', role: 'line' }), { chartType: 'Connected Scatter Plot', selected: [] }, ); const pointUpdate = semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: vertex, visual: { kind: 'mark', role: 'symbol' } }] }, + annotationUpdate(vertex, { kind: 'mark', role: 'symbol' }), { chartType: 'Connected Scatter Plot', selected: [] }, ); - expect(pathUpdate.ops[0]).toMatchObject({ annotation: { text: '2.14 → 2.53' } }); - expect((pathUpdate.ops[0] as any).annotation.candidates[0]).toEqual({ connection: 'segment-midpoint', priority: 0 }); - expect(pointUpdate.ops[0]).toMatchObject({ annotation: { text: '2.14' } }); - expect((pointUpdate.ops[0] as any).annotation.candidates[0]).toEqual({ connection: 'center', priority: 0 }); + expect(pathUpdate.ops[0]).toMatchObject({ value: { text: '2.14 → 2.53' } }); + expect((pathUpdate.ops[0] as any).value.candidates[0]).toEqual({ connection: 'segment-midpoint', priority: 0 }); + expect(pointUpdate.ops[0]).toMatchObject({ value: { text: '2.14' } }); + expect((pointUpdate.ops[0] as any).value.candidates[0]).toEqual({ connection: 'center', priority: 0 }); }); it('resolves a path-suffixed annotation key to segment geometry instead of its point glyph', () => { @@ -1332,6 +1448,100 @@ describe('interaction definitions', () => { expect(annotationItem([point, segment], 'A')).toBe(point); }); + it('resolves the clicked generated segment when path points share a series key', () => { + const first = { + datum: { [INTERACTION_KEY]: 'Setosa', Species: 'Setosa', value: 4.8, density: 0.3 }, + bounds: { x1: 10, x2: 20, y1: 30, y2: 50 }, + interactionGeometry: { kind: 'segment', points: [{ x: 10, y: 50 }, { x: 20, y: 30 }] }, + }; + const selected = { + datum: { [INTERACTION_KEY]: 'Setosa', Species: 'Setosa', value: 5.1, density: 0.4 }, + bounds: { x1: 20, x2: 30, y1: 20, y2: 30 }, + interactionGeometry: { kind: 'segment', points: [{ x: 20, y: 30 }, { x: 30, y: 20 }] }, + }; + + expect(annotationItem( + [first, selected], + `Setosa${PATH_KEY_SUFFIX}`, + { kind: 'path' }, + undefined, + undefined, + { Species: 'Setosa', value: 5.1, density: 0.4 }, + )).toBe(selected); + }); + + it('uses the widest generated slice for a record-free series annotation', () => { + const tail = { + datum: { [INTERACTION_KEY]: 'Class A' }, + bounds: { x1: 19, x2: 21, y1: 20, y2: 30 }, + interactionGeometry: { kind: 'slice', points: [] }, + }; + const mode = { + datum: { [INTERACTION_KEY]: 'Class A' }, + bounds: { x1: 8, x2: 32, y1: 30, y2: 40 }, + interactionGeometry: { kind: 'slice', points: [] }, + }; + + expect(annotationItem([tail, mode], `Class A${PATH_KEY_SUFFIX}`, { kind: 'path' })) + .toBe(mode); + }); + + it('treats sibling area slices as one annotation source shape', () => { + const mark = { marktype: 'area' }; + const sourceDatum = { [INTERACTION_KEY]: 'Class A', value: 72 }; + const source = { + mark, orient: 'horizontal', datum: sourceDatum, bounds: { x1: 20, x2: 30, y1: 30, y2: 40 }, + interactionGeometry: { annotationPoints: [{ x: 25, y: 30 }, { x: 26, y: 40 }] }, + }; + const sibling = { + mark, orient: 'horizontal', datum: { [INTERACTION_KEY]: 'Class A', value: 73 }, + bounds: { x1: 8, x2: 42, y1: 40, y2: 50 }, + }; + + expect(isAnnotationSourceItem({ mark, datum: sourceDatum }, source)).toBe(true); + expect(isAnnotationSourceItem(sibling, source)).toBe(true); + expect(annotationSourceBounds([source, sibling], source)).toEqual({ + x1: 8, x2: 42, y1: 30, y2: 50, + }); + }); + + it('indexes horizontal area segments used by Violin plots', () => { + const mark: any = { marktype: 'area', items: [] }; + const first = { + mark, datum: { [INTERACTION_KEY]: 'Class A', value: 4.8, density: 0.3 }, + x: 20, x2: 10, y: 50, bounds: { x1: 10, x2: 20, y1: 50, y2: 50 }, + }; + const second = { + mark, datum: { [INTERACTION_KEY]: 'Class A', value: 5.1, density: 0.4 }, + x: 25, x2: 5, y: 30, bounds: { x1: 5, x2: 25, y1: 30, y2: 30 }, + }; + mark.items = [first, second]; + const view = { scenegraph: () => ({ root: { items: [first, second] } }) }; + + const segments = sceneItems(view); + + expect(segments).toHaveLength(1); + expect(segments[0].interactionGeometry).toMatchObject({ + kind: 'slice', + points: [ + { x: 20, y: 50 }, { x: 25, y: 30 }, + { x: 5, y: 30 }, { x: 10, y: 50 }, + ], + }); + expect(renderHit(segments[0])?.datum[INTERACTION_KEY]) + .toBe(`Class A${PATH_KEY_SUFFIX}`); + }); + + it('excludes connective rules from reorder-owned destination geometry', () => { + const items = [ + { mark: { marktype: 'rect' }, datum: { [INTERACTION_KEY]: 'B', step: 'B' } }, + { mark: { marktype: 'rule' }, datum: { [INTERACTION_KEY]: 'B', step: 'B' } }, + ]; + + expect(reorderOwnedItems(items, { field: 'step', markTypes: ['rect'] }, 'B')) + .toEqual([items[0]]); + }); + it('resolves radial annotations to the slice instead of a same-key text label', () => { const arc = { mark: { marktype: 'arc' }, @@ -1396,9 +1606,9 @@ describe('interaction definitions', () => { const element = { key: { key: chartType }, records: [record] }; expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType, selected: [] }, - ).ops[0]).toMatchObject({ annotation: { text } }); + ).ops[0]).toMatchObject({ value: { text } }); }); it('suppresses boxplot annotation until composite roles and statistics are semantic', () => { @@ -1414,7 +1624,7 @@ describe('interaction definitions', () => { }; expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element }] }, + annotationUpdate(element), { chartType: 'Boxplot', selected: [], categoryField: 'Species' }, ).ops).toEqual([]); }); @@ -1427,13 +1637,13 @@ describe('interaction definitions', () => { const element = { key: { key: 'datum' } }; const presentUpdate = presentAnnotationUpdate(() => ({ connection })); const update = presentUpdate( - { ops: [{ op: 'annotate', element, text: 'Value' }] }, + annotationUpdate(element, undefined, 'Value'), { chartType: 'Test', selected: [] }, ); expect(update.ops[0]).toMatchObject({ - op: 'render-annotation', - annotation: { candidates: [{ connection }] }, + op: 'set-annotation', + value: { candidates: [{ connection }] }, }); }); diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index 2bcfdcc5..cdaf25a4 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { changeset, parse, View } from 'vega'; import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src/vegalite/assemble'; -import { brushAngle, clickHighlight, dragReorder, navigate, select } from '../src/interactive/interactions'; +import { brushAngle, clickAnnotate, clickHighlight, dragReorder, externalInteraction, navigate, select } from '../src/interactive/interactions'; +import type { SemanticElement, SemanticTarget } from '../src/interactive/interactions'; import { MUTED_HOVER_FILL, MUTED_HOVER_STROKE } from '../src/core/interaction-semantics'; import { areaChartDef, streamgraphDef } from '../src/vegalite/templates/area'; import { @@ -34,6 +35,7 @@ import { INTERACTION_ROLE, PATH_KEY_SUFFIX, plotToClientPoint, + rendererPlotOrigin, renderHit, sceneItems, } from '../src/vegalite/interactions/hit-adapter'; @@ -44,7 +46,10 @@ import { LEGEND_SELECTION_STORE, } from '../src/vegalite/interactions/stores'; import { mergeContiguousSelectionBounds } from '../src/vegalite/interactions/presentation/focus-overlay'; -import { annotationBounds } from '../src/vegalite/interactions/presentation/annotation-overlay'; +import { + annotationBounds, + annotationConnectionPoint, +} from '../src/vegalite/interactions/presentation/annotation-overlay'; import { createVegaNavigationController } from '../src/vegalite/interactions/navigation-scale'; import { INTERACTION_PROVENANCE } from '../src/vegalite/interaction-provenance'; import { THEME_PRESETS } from '../src/core/theme/presets'; @@ -62,6 +67,20 @@ import { bulletChartDef } from '../src/vegalite/templates/bullet'; import { kpiCardDef } from '../src/vegalite/templates/kpi-card'; import { radarChartDef } from '../src/vegalite/templates/radar'; +function annotationUpdate( + element: SemanticElement, + visual: SemanticTarget['visual'] = { kind: 'mark', role: 'test' }, +) { + return { + id: 'test-annotation', + ops: [{ + op: 'set-annotation' as const, + target: { visual, elements: [element] }, + value: {}, + }], + }; +} + function instrument(spec: Record, interactions = [clickHighlight()]) { const plan = addVegaLiteInteractions(spec, interactions); const compiled = compile(spec as any).spec as Record; @@ -81,6 +100,25 @@ function allSceneItems(view: View): any[] { } describe('Vega-Lite semantic interactions', () => { + it('keeps path fallback connections anchored to the selected segment midpoint', () => { + const item = { + bounds: { x1: 56, y1: 52, x2: 196, y2: 220 }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 56, y: 220 }, { x: 196, y: 52 }], + annotationPoints: [{ x: 56, y: 220 }, { x: 196, y: 52 }], + }, + }; + const plotCenter = { x: 126, y: 136 }; + + const midpoint = annotationConnectionPoint(item, 'segment-midpoint', [item], plotCenter); + const rightFallback = annotationConnectionPoint(item, 'right', [item], plotCenter); + + expect(midpoint.point).toEqual({ x: 126, y: 136 }); + expect(rightFallback.point).toEqual(midpoint.point); + expect(rightFallback.preferredAngle).toBe(0); + }); + it('uses a borderless spotlight for area hover', () => { const hoverStyle = (resolvedEncodings: Record) => areaChartDef.semanticInteractions!({ resolvedEncodings }).renderHoverStyles?.area; @@ -117,6 +155,38 @@ describe('Vega-Lite semantic interactions', () => { .toEqual(expect.arrayContaining([expect.objectContaining({ clip: true })])); }); + it('leaves reorder unwired when no reorder interaction is configured', () => { + const spec = assembleVegaLite({ + chart_spec: { chartType: 'Bar Chart', encodings: { x: { field: 'category' }, y: { field: 'value' } } }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [{ category: 'A', value: 1 }, { category: 'B', value: 2 }] }, + }) as any; + + const plan = addVegaLiteInteractions(spec, [clickHighlight()])!; + + expect(plan.reorderAxis).toBeUndefined(); + expect(plan.reorderAxes).toEqual([]); + }); + + it('resolves an axis scale renamed by a composed spec', () => { + const composed = { + scales: [{ name: 'concat_0_x', type: 'band' }, { name: 'concat_0_y', type: 'linear' }], + } as any; + + expect(injectVegaReorderSignal(composed, { axis: 'x', field: 'category' })).toEqual({ + axis: 'x', field: 'category', scale: 'concat_0_x', signal: '__flint_reorder_x_domain', + }); + expect(composed.scales[0].domainRaw).toEqual({ signal: '__flint_reorder_x_domain' }); + expect(injectVegaNavigationSignals(composed, ['y']).y) + .toEqual({ scale: 'concat_0_y', signal: '__flint_navigation_y_domain', type: 'linear' }); + + // An ambiguous multi-panel concat must not silently pick a panel. + expect(() => injectVegaReorderSignal( + { scales: [{ name: 'concat_0_x', type: 'band' }, { name: 'concat_1_x', type: 'band' }] } as any, + { axis: 'x', field: 'category' }, + )).toThrow(/discrete "x" scale/); + }); + it.each([ ['vertical', { x: { field: 'category' }, y: { field: 'value' } }, 'x'], ['horizontal', { x: { field: 'value' }, y: { field: 'category' } }, 'y'], @@ -258,6 +328,24 @@ describe('Vega-Lite semantic interactions', () => { .toEqual([{ axis: 'x', field: 'period' }]); }); + it('moves only Waterfall bars during reorder preview', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: { field: 'step' }, y: { field: 'amount' } }, + }, + semantic_types: { step: 'Category', amount: 'Number' }, + data: { values: [ + { step: 'Revenue', amount: 100 }, + { step: 'Costs', amount: -40 }, + ] }, + }) as any; + + expect(spec._interactionSemantics.reorderAxes).toEqual([ + { axis: 'x', field: 'step', markTypes: ['rect'] }, + ]); + }); + it('rejects built-in interactions when a chart has no semantic contract', () => { expect(() => addVegaLiteInteractions({ mark: 'line' }, [clickHighlight()])) .toThrow('requires chart interaction semantics'); @@ -267,6 +355,49 @@ describe('Vega-Lite semantic interactions', () => { }, [clickHighlight()])).toThrow('requires chart element semantics'); }); + it('instruments semantic targets for external interactions without adding canvas gestures', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'category' }, y: { field: 'value' } }, + }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [{ category: 'A', value: 2 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [externalInteraction<{ category: string }>({ + id: 'category-picker', + handle: ({ category }) => ({ + id: 'category-picker', + ops: [{ + op: 'set-presentation', + targets: [{ select: { key: { category } } }], + value: { state: 'emphasized' }, + }], + }), + })]); + + expect(plan).not.toBeNull(); + expect(spec.transform).toEqual(expect.arrayContaining([ + expect.objectContaining({ as: INTERACTION_KEY }), + ])); + }); + + it('instruments semantic updates without passing external definitions to the renderer', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'category' }, y: { field: 'value' } }, + }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [{ category: 'A', value: 2 }] }, + }) as any; + + expect(addVegaLiteInteractions(spec, [], true)).not.toBeNull(); + expect(spec.transform).toEqual(expect.arrayContaining([ + expect.objectContaining({ as: INTERACTION_KEY }), + ])); + }); + it('clips every generated layer when navigation is combined with semantic interaction', () => { const spec = assembleVegaLite({ chart_spec: { @@ -306,18 +437,22 @@ describe('Vega-Lite semantic interactions', () => { const initial = view.scale('x').domain().map(Number); const controller = createVegaNavigationController(view, axes); - await controller.apply({ - op: 'navigate-viewport', phase: 'commit', operation: 'zoom', axes: 'x', + const guard = { minVisibleFraction: 0.02, maxVisibleFraction: 1, overscrollFraction: 0 }; + const zoom = controller.resolve({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes: 'x', factor: 2, anchor: { x: 0.5, y: 0.5 }, - domainGuard: { minVisibleFraction: 0.02, maxVisibleFraction: 1, overscrollFraction: 0 }, - }); + }, guard); + expect(zoom).not.toBeNull(); + controller.apply(zoom!); + await view.runAsync(); const zoomed = view.scale('x').domain().map(Number); expect(zoomed[1] - zoomed[0]).toBeCloseTo((initial[1] - initial[0]) / 2); - await controller.apply({ - op: 'navigate-viewport', phase: 'commit', operation: 'reset', axes: 'x', - domainGuard: { minVisibleFraction: 0.02, maxVisibleFraction: 1, overscrollFraction: 0 }, - }); + const reset = controller.resolve({ + type: 'navigation', phase: 'commit', operation: 'reset', axes: 'x', + }, guard); + controller.apply(reset!); + await view.runAsync(); expect(view.scale('x').domain().map(Number)).toEqual(initial); view.finalize(); }); @@ -559,16 +694,152 @@ describe('Vega-Lite semantic interactions', () => { }], }; const update = semantics.presentUpdate!( - { ops: [{ op: 'annotate', element, visual: { kind: 'mark', role: 'polar-bar' } }] }, + annotationUpdate(element, { kind: 'mark', role: 'polar-bar' }), { chartType: 'Rose Chart', selected: [], categoryField: 'Month' }, ); - expect(update.ops[0]).toMatchObject({ annotation: { text: '140' } }); - expect((update.ops[0] as any).annotation.candidates).toEqual([ + expect(update.ops[0]).toMatchObject({ value: { text: '140' } }); + expect((update.ops[0] as any).value.candidates).toEqual([ { connection: 'outer-radial', priority: 0 }, ]); }); + it('formats Bar Table and Bullet annotations from their authored measures', async () => { + const barTable = barTableDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'GDP ($T)', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + }, + }); + const bullet = bulletChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Share', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + goal: { field: 'Target', type: 'quantitative' }, + }, + }); + const barTableElement = { + key: { [INTERACTION_KEY]: 'China' }, + records: [{ Country: 'China', 'GDP ($T)': 17.8, period_end: 0 }], + }; + const bulletElement = { + key: { [INTERACTION_KEY]: 'Germany' }, + records: [{ Country: 'Germany', Share: 51.6, Target: 80 }], + }; + + expect(barTable.presentUpdate!( + annotationUpdate(barTableElement), + { chartType: 'Bar Table', selected: [], categoryField: 'Country' }, + ).ops[0]).toMatchObject({ value: { text: '17.8' } }); + const bulletUpdate = bullet.presentUpdate!( + annotationUpdate(bulletElement), + { chartType: 'Bullet Chart', selected: [], categoryField: 'Country' }, + ); + expect(bulletUpdate.ops[0]).toMatchObject({ + value: { + text: 'Actual: 51.6\nExpected: 80', + candidates: expect.arrayContaining([expect.objectContaining({ + connectorAnchors: [ + { role: 'bullet-actual', connection: 'value-end', valueAxis: 'x' }, + { role: 'bullet-expected', connection: 'center' }, + ], + })]), + }, + }); + + const bulletSpec = assembleVegaLite({ + data: { values: [{ Country: 'Germany', Share: 51.6, Target: 80 }] }, + semantic_types: { Country: 'Country', Share: 'Quantity', Target: 'Quantity' }, + chart_spec: { + chartType: 'Bullet Chart', + encodings: { y: 'Country', x: 'Share', goal: 'Target' }, + }, + } as never) as any; + const { compiled } = instrument(bulletSpec, [clickAnnotate()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const roles = sceneItems(view) + .filter((item) => item.datum.Country === 'Germany' && item.datum[INTERACTION_ROLE]) + .map((item) => item.datum[INTERACTION_ROLE]); + + expect(new Set(roles)).toEqual(new Set(['bullet-actual', 'bullet-expected'])); + }); + + it('presents Choropleth regions and Density segments with semantic values', () => { + const choropleth = choroplethDef.semanticInteractions!({ + resolvedEncodings: { + id: { field: 'State', type: 'nominal' }, + color: { field: 'Value', type: 'quantitative' }, + }, + }); + const density = densityPlotDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Score', type: 'quantitative' } }, + }); + + const regionUpdate = choropleth.presentUpdate!( + annotationUpdate( + { key: { [INTERACTION_KEY]: '35' }, records: [{ State: 'New Mexico', Value: 35 }] }, + { kind: 'region', role: 'geographic-region' }, + ), + { chartType: 'Choropleth', selected: [], categoryField: 'State' }, + ); + const densityUpdate = density.presentUpdate!( + annotationUpdate({ + key: { [INTERACTION_KEY]: 'segment' }, + records: [{ value: 72.5, density: 0.33 }, { value: 75, density: 0.32 }], + }, { kind: 'path', role: 'area' }), + { chartType: 'Density Plot', selected: [] }, + ); + + expect(regionUpdate.ops[0]).toMatchObject({ + value: { text: 'New Mexico: 35', candidates: [{ connection: 'center' }] }, + }); + expect(densityUpdate.ops[0]).toMatchObject({ + value: { text: '72.5: 0.33', candidates: [{ connection: 'segment-midpoint' }] }, + }); + }); + + it('presents transformed ECDF, Calendar, and Violin values', () => { + const ecdf = ecdfPlotDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Score', type: 'quantitative' } }, + }); + const calendar = vlCalendarHeatmapDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Day', type: 'temporal' }, + color: { field: 'Commits', type: 'quantitative' }, + }, + }); + const violin = violinPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Species', type: 'nominal' }, + y: { field: 'Length', type: 'quantitative' }, + }, + }); + + const ecdfUpdate = ecdf.presentUpdate!( + annotationUpdate({ key: { [INTERACTION_KEY]: 'step' }, records: [{ Score: 42 }, { Score: 44 }] }), + { chartType: 'ECDF Plot', selected: [] }, + ); + const calendarUpdate = calendar.presentUpdate!( + annotationUpdate({ + key: { [INTERACTION_KEY]: 'day' }, + records: [{ __calendar_date: Date.UTC(2026, 7, 27), Commits: 12 }], + }), + { chartType: 'Calendar Heatmap', selected: [] }, + ); + const violinUpdate = violin.presentUpdate!( + annotationUpdate({ + key: { [INTERACTION_KEY]: 'curve' }, + records: [{ Species: 'Setosa', Length: 5.1, density: 0.4 }], + }), + { chartType: 'Violin Plot', selected: [] }, + ); + + expect(ecdfUpdate.ops[0]).toMatchObject({ value: { text: '42' } }); + expect((calendarUpdate.ops[0] as any).value.text).toContain('12'); + expect(violinUpdate.ops[0]).toMatchObject({ value: { text: 'Setosa: 5.1' } }); + }); + it('uses one local hover rule across color semantics', () => { const makeSpec = (colorSemanticType: 'Category' | 'Quantity') => assembleVegaLite({ data: { values: [ @@ -753,10 +1024,10 @@ describe('Vega-Lite semantic interactions', () => { expect(annotationBounds(finalSegment).y2).toBeLessThan(finalSegment.bounds.y2); expect(target.elements[0].records?.map((record) => record.Users)).toEqual([60, 67]); expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: target.elements[0] }] }, + annotationUpdate(target.elements[0]), { chartType: 'Area Chart', selected: [] }, ).ops[0]).toMatchObject({ - annotation: { + value: { text: '60 → 67', candidates: [{ connection: 'segment-midpoint', priority: 0 }], }, @@ -1010,6 +1281,32 @@ describe('Vega-Lite semantic interactions', () => { expect(boundsIntersectRect({ x1: 29, y1: 10, x2: 50, y2: 30 }, selection)).toBe(true); }); + it('reports the plot origin in renderer units when the SVG is CSS-scaled', () => { + // Vega renders a 370x305 chart that CSS shrinks to 326px wide. + const cssScale = 326 / 370; + const matrix = { a: cssScale, e: 39 * cssScale, f: 10 * cssScale }; + + expect(rendererPlotOrigin(matrix, { x: 0, y: 0 })).toEqual({ x: 39, y: 10 }); + expect(rendererPlotOrigin({ a: 1, e: 39, f: 10 }, { x: 0, y: 0 })) + .toEqual({ x: 39, y: 10 }); + expect(rendererPlotOrigin(undefined, { x: 5, y: 6 })).toEqual({ x: 5, y: 6 }); + + const space = { + rect: { left: 0, top: 0, width: 370 * cssScale, height: 305 * cssScale } as DOMRect, + logicalWidth: 370, + logicalHeight: 305, + ...(({ x, y }) => ({ originX: x, originY: y }))(rendererPlotOrigin(matrix, { x: 0, y: 0 })), + plotWidth: 320, + plotHeight: 260, + }; + + // A plot-space point must land where the scaled mark actually renders. + expect(plotToClientPoint({ x: 0, y: 0 }, space).x).toBeCloseTo(39 * cssScale, 6); + const roundTrip = clientToPlotPoint({ x: 39 * cssScale, y: 10 * cssScale }, space); + expect(roundTrip.x).toBeCloseTo(0, 6); + expect(roundTrip.y).toBeCloseTo(0, 6); + }); + it('round-trips coordinates through SVG scaling and Vega plot padding', () => { const space = { rect: { left: 100, top: 50, width: 250, height: 150 } as DOMRect, @@ -1179,7 +1476,7 @@ describe('Vega-Lite semantic interactions', () => { const makeUpdate = (definition: typeof barChartDef, resolvedEncodings: Record) => { const semantics = definition.semanticInteractions!({ resolvedEncodings }); return semantics.presentUpdate!( - { ops: [{ op: 'annotate', element, visual: { kind: 'mark', role: 'bar' } }] }, + annotationUpdate(element, { kind: 'mark', role: 'bar' }), { chartType: definition.chart, selected: [] }, ); }; @@ -1190,8 +1487,8 @@ describe('Vega-Lite semantic interactions', () => { }; for (const definition of [barChartDef, groupedBarChartDef, stackedBarChartDef, pyramidChartDef]) { const update = makeUpdate(definition, horizontalEncodings); - expect(update.ops[0]).toMatchObject({ annotation: { text: '1,428.6' } }); - expect((update.ops[0] as any).annotation.candidates[0]).toMatchObject({ valueAxis: 'x' }); + expect(update.ops[0]).toMatchObject({ value: { text: '1,428.6' } }); + expect((update.ops[0] as any).value.candidates[0]).toMatchObject({ valueAxis: 'x' }); } const flipped = makeUpdate(pyramidChartDef, { @@ -1199,8 +1496,8 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Population', type: 'quantitative' }, }); - expect(flipped.ops[0]).toMatchObject({ annotation: { text: '1,428.6' } }); - expect((flipped.ops[0] as any).annotation.candidates[0]).toMatchObject({ valueAxis: 'y' }); + expect(flipped.ops[0]).toMatchObject({ value: { text: '1,428.6' } }); + expect((flipped.ops[0] as any).value.candidates[0]).toMatchObject({ valueAxis: 'y' }); }); it('hovers Pyramid bars without changing their geometry or center gap', async () => { @@ -1383,10 +1680,10 @@ describe('Vega-Lite semantic interactions', () => { expect(target?.visual).toEqual({ kind: 'path', role: 'line' }); expect(target?.elements[0].records).toEqual([connector.datum, connector.endDatum]); expect(semantics.presentUpdate!( - { ops: [{ op: 'annotate', element: target!.elements[0], visual: target!.visual }] }, + annotationUpdate(target!.elements[0], target!.visual), { chartType: 'Ranged Dot Plot', selected: [], categoryField: 'Country', seriesField: 'Sex' }, ).ops[0]).toMatchObject({ - annotation: { + value: { text: 'Male: 81.5, Female: 87.6', candidates: [{ connection: 'segment-midpoint', priority: 0 }], }, @@ -1406,12 +1703,12 @@ describe('Vega-Lite semantic interactions', () => { }; const update = semantics.presentUpdate!( - { ops: [{ op: 'annotate', element, visual: { kind: 'mark', role: 'slice' } }] }, + annotationUpdate(element, { kind: 'mark', role: 'slice' }), { chartType: 'Pie Chart', selected: [], seriesField: 'Browser' }, ); - expect(update.ops[0]).toMatchObject({ annotation: { text: 'Chrome: 65' } }); - expect((update.ops[0] as any).annotation.candidates).toEqual([ + expect(update.ops[0]).toMatchObject({ value: { text: 'Chrome: 65' } }); + expect((update.ops[0] as any).value.candidates).toEqual([ { connection: 'radial-midpoint', priority: 0 }, { connection: 'outer-radial', priority: 1 }, ]); diff --git a/site/src/main.tsx b/site/src/main.tsx index 4b4c6b3e..dca10951 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -15,6 +15,7 @@ import { AutoLayoutPlayground } from './routes/AutoLayoutPlayground'; import { DocSectionPage } from './routes/DocSectionPage'; import { PlaygroundShell } from './playground/PlaygroundShell'; import { Illustrations } from './playground/Illustrations'; +import { ArchitectureIllustrations } from './playground/ArchitectureIllustrations'; import { McpUi } from './playground/McpUi'; import { Labs } from './playground/Labs'; import { DemoWall } from './playground/DemoWall'; @@ -25,6 +26,7 @@ import { BandStretchingLab } from './playground/BandStretchingLab'; import { LabelExperimentLab } from './playground/LabelExperimentLab'; import { OverflowViewportLab } from './playground/OverflowViewportLab'; import { ClickFocusLab } from './playground/ClickFocusLab'; +import { AnnotationLab } from './playground/AnnotationLab'; import { InteractionDashboardLab } from './playground/InteractionDashboardLab'; import { InteractionCandidates } from './playground/InteractionCandidates'; import { ExternalToChartLab } from './playground/ExternalToChartLab'; @@ -69,6 +71,7 @@ function AppRoutes({ locale }: { locale: Locale }) { }> } /> } /> + } /> } /> } /> } /> @@ -81,6 +84,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/site/src/playground/AnnotationLab.tsx b/site/src/playground/AnnotationLab.tsx new file mode 100644 index 00000000..5cfa689a --- /dev/null +++ b/site/src/playground/AnnotationLab.tsx @@ -0,0 +1,270 @@ +import { useEffect, useRef, useState } from 'react'; +import { RotateCcw } from 'lucide-react'; +import { + buildInteractiveChart, + externalInteraction, + type ChartUpdate, + type ChartUpdateResult, +} from 'flint-chart/interactive'; +import { expressionInterpreter } from 'vega-interpreter'; +import { annotationCases, type InteractionCase } from './ClickFocusLab'; +import { ThemePicker } from './ThemePicker'; +import './click-focus-lab.css'; +import './annotation-lab.css'; + +type StaticStatus = 'loading' | 'applied' | 'unsupported' | 'error'; +type AnnotationFixture = { + label: string; + key: string; + visual: { kind: 'mark' | 'path' | 'region'; role: string }; + text: string; +}; + +const ANNOTATION_FIXTURES: Record = { + 'Area Chart': [ + { label: '1995–2000', key: '788918400000|1|__flint_path', visual: { kind: 'path', role: 'area' }, text: '1995–2000: 1% → 7%' }, + { label: '2010–2015', key: '1262304000000|29|__flint_path', visual: { kind: 'path', role: 'area' }, text: '2010–2015: 29% → 43%' }, + { label: '2020–2023', key: '1577836800000|60|__flint_path', visual: { kind: 'path', role: 'area' }, text: '2020–2023: 60% → 67%' }, + ], + 'Bar Chart': [ + { label: '1880s', key: '1880s|-0.17', visual: { kind: 'mark', role: 'mark' }, text: '1880s: -0.17 °C' }, + { label: '1960s', key: '1960s|-0.03', visual: { kind: 'mark', role: 'mark' }, text: '1960s: -0.03 °C' }, + { label: '2020s', key: '2020s|1.02', visual: { kind: 'mark', role: 'mark' }, text: '2020s: +1.02 °C' }, + ], + 'Bar Table': [ + { label: 'Brazil', key: 'Brazil', visual: { kind: 'mark', role: 'bar-table-row' }, text: 'Brazil: $2.2T GDP' }, + { label: 'India', key: 'India', visual: { kind: 'mark', role: 'bar-table-row' }, text: 'India: $3.9T GDP' }, + { label: 'United States', key: 'United States', visual: { kind: 'mark', role: 'bar-table-row' }, text: 'United States: $27.4T GDP' }, + ], + 'Bullet Chart': [ + { label: 'United States', key: 'United States|22.7|50', visual: { kind: 'mark', role: 'bullet-actual' }, text: 'United States: 22.7% vs 50% target' }, + { label: 'Germany', key: 'Germany|51.6|80', visual: { kind: 'mark', role: 'bullet-actual' }, text: 'Germany: 51.6% vs 80% target' }, + { label: 'Norway', key: 'Norway|98.6|100', visual: { kind: 'mark', role: 'bullet-actual' }, text: 'Norway: 98.6% vs 100% target' }, + ], + 'Calendar Heatmap': [ + { label: 'Jan 1', key: '1704067200000|Mon|1704067200000', visual: { kind: 'mark', role: 'calendar-day' }, text: 'Jan 1: 60 activities' }, + { label: 'Mar 1', key: '1708905600000|Fri|1709251200000', visual: { kind: 'mark', role: 'calendar-day' }, text: 'Mar 1: 68 activities' }, + { label: 'Apr 30', key: '1714348800000|Tue|1714435200000', visual: { kind: 'mark', role: 'calendar-day' }, text: 'Apr 30: 88 activities' }, + ], + 'Candlestick Chart': [ + { label: 'Jan 2', key: '1704153600000|187|188|183|185', visual: { kind: 'mark', role: 'candlestick' }, text: 'Jan 2: O 187, H 188, L 183, C 185' }, + { label: 'Jan 8', key: '1704672000000|182|186|182|185', visual: { kind: 'mark', role: 'candlestick' }, text: 'Jan 8: O 182, H 186, L 182, C 185' }, + { label: 'Jan 12', key: '1705017600000|186|188|185|185', visual: { kind: 'mark', role: 'candlestick' }, text: 'Jan 12: O 186, H 188, L 185, C 185' }, + ], + Choropleth: [ + { label: 'Alaska', key: 'Alaska|0.73', visual: { kind: 'region', role: 'geographic-region' }, text: 'Alaska: 0.73' }, + { label: 'Illinois', key: 'Illinois|12.81', visual: { kind: 'region', role: 'geographic-region' }, text: 'Illinois: 12.81' }, + { label: 'Maine', key: 'Maine|1.36', visual: { kind: 'region', role: 'geographic-region' }, text: 'Maine: 1.36' }, + ], + 'Connected Scatter Plot': [ + { label: '1956 point', key: '3675|2.38|1956', visual: { kind: 'mark', role: 'symbol' }, text: '1956: 3,675 miles/person; gas $2.38' }, + { label: '1982–1983 path', key: '6835|2.92|1982|__flint_path', visual: { kind: 'path', role: 'line' }, text: '1982 → 1983: gas $2.92 → $2.66' }, + { label: '2005 point', key: '10067|2.53|2005', visual: { kind: 'mark', role: 'symbol' }, text: '2005: 10,067 miles/person; gas $2.53' }, + ], + 'Density Plot': [ + { label: 'Low duration', key: '1.9040000000000001|0.23092429172368195|__flint_path', visual: { kind: 'path', role: 'area' }, text: '1.904 min: density 0.231' }, + { label: 'Middle duration', key: '3.184|0.21760927780507272|__flint_path', visual: { kind: 'path', role: 'area' }, text: '3.184 min: density 0.218' }, + { label: 'High duration', key: '4.464|0.2959686144815777|__flint_path', visual: { kind: 'path', role: 'area' }, text: '4.464 min: density 0.296' }, + ], + Heatmap: [ + { label: 'Singapore · Jan', key: 'Jan|Singapore', visual: { kind: 'mark', role: 'mark' }, text: 'Singapore · Jan: 26 °C' }, + { label: 'Seattle · Jun', key: 'Jun|Seattle', visual: { kind: 'mark', role: 'mark' }, text: 'Seattle · Jun: 16 °C' }, + { label: 'Seattle · Dec', key: 'Dec|Seattle', visual: { kind: 'mark', role: 'mark' }, text: 'Seattle · Dec: 4 °C' }, + ], + 'Pie Chart': [ + { label: 'Edge', key: 'Edge', visual: { kind: 'mark', role: 'slice' }, text: 'Edge: 12%' }, + { label: 'Other', key: 'Other', visual: { kind: 'mark', role: 'slice' }, text: 'Other: 5%' }, + { label: 'Chrome', key: 'Chrome', visual: { kind: 'mark', role: 'slice' }, text: 'Chrome: 65%' }, + ], + 'Ranged Dot Plot': [ + { label: 'Nigeria range', key: '51|Nigeria|Male|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Nigeria: Male 51, Female 54' }, + { label: 'Brazil range', key: '69|Brazil|Male|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Brazil: Male 69, Female 76' }, + { label: 'Japan range', key: '81.5|Japan|Male|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Japan: Male 81.5, Female 87.6' }, + ], + 'Scatter Plot': [ + { label: 'Ethiopia', key: '2000|66.2|Africa|109', visual: { kind: 'mark', role: 'point' }, text: 'Ethiopia: GDP/person 2,000; 66.2 years' }, + { label: 'China', key: '16800|76.7|Asia|1393', visual: { kind: 'mark', role: 'point' }, text: 'China: GDP/person 16,800; 76.7 years' }, + { label: 'Qatar', key: '116900|80.1|Asia|2.8', visual: { kind: 'mark', role: 'point' }, text: 'Qatar: GDP/person 116,900; 80.1 years' }, + ], + 'Slope Chart': [ + { label: 'Tablet 2019 point', key: '2019|69|Tablet', visual: { kind: 'mark', role: 'symbol' }, text: 'Tablet · 2019: 69 revenue' }, + { label: 'Phone path', key: '2019|56|Phone|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Phone: 56 → 42 revenue' }, + { label: 'Tablet 2024 point', key: '2024|35|Tablet', visual: { kind: 'mark', role: 'symbol' }, text: 'Tablet · 2024: 35 revenue' }, + ], + 'Violin Plot': [ + { label: 'Class A', key: 'Class A|__flint_path', visual: { kind: 'path', role: 'area' }, text: 'Class A density' }, + { label: 'Class B', key: 'Class B|__flint_path', visual: { kind: 'path', role: 'area' }, text: 'Class B density' }, + { label: 'Class D', key: 'Class D|__flint_path', visual: { kind: 'path', role: 'area' }, text: 'Class D density' }, + ], + 'Waterfall Chart': [ + { label: '1950 baseline', key: '1950', visual: { kind: 'mark', role: 'waterfall-step' }, text: '1950 baseline: 2,536M' }, + { label: 'Africa addition', key: 'Africa', visual: { kind: 'mark', role: 'waterfall-step' }, text: 'Africa: +1,134M' }, + { label: 'Oceania addition', key: 'Oceania', visual: { kind: 'mark', role: 'waterfall-step' }, text: 'Oceania: +32M' }, + ], +}; + +const coverage = [ + 'mark', 'path', 'area', 'distribution', 'composite', 'polar', 'region', +]; + +function nextLayoutTurn(): Promise { + return new Promise((resolve) => setTimeout(resolve, 50)); +} + +async function waitForStableChartLayout(container: HTMLElement): Promise { + let previous = container.getBoundingClientRect(); + let stableFrames = 0; + for (let attempt = 0; attempt < 8 && stableFrames < 2; attempt += 1) { + await nextLayoutTurn(); + const current = container.getBoundingClientRect(); + const stable = Math.abs(current.width - previous.width) < 0.5 + && Math.abs(current.height - previous.height) < 0.5; + stableFrames = stable ? stableFrames + 1 : 0; + previous = current; + } +} + + function StaticAnnotationChart({ + item, + fixture, + themeId, + resetVersion, + onStatus, + }: { + item: InteractionCase; + fixture: AnnotationFixture; + themeId: string | undefined; + resetVersion: number; + onStatus: (status: StaticStatus, result?: ChartUpdateResult | Error) => void; + }) { + const containerRef = useRef(null); + const statusRef = useRef(onStatus); + statusRef.current = onStatus; + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + statusRef.current('loading'); + const themedInput = themeId ? { ...item.input, theme_spec: themeId } : item.input; + const surface = buildInteractiveChart(container, themedInput, { + backend: 'vegalite', + renderer: 'svg', + interactions: [externalInteraction({ + id: 'static-annotation-policy', + handle: (update) => update, + })], + expressionInterpreter, + ariaLabel: item.input.chart_spec.title, + }); + let active = true; + void surface.ready.then(async () => { + await waitForStableChartLayout(container); + if (!active) return; + const target = { + visual: fixture.visual, + elements: [{ key: { __flint_interaction_key: fixture.key } }], + }; + const result = await surface.dispatch('static-annotation-policy', { + id: `annotation-lab-${item.id}-${fixture.label}`, + ops: [ + { op: 'set-annotation', target, value: { text: fixture.text } }, + { + op: 'set-presentation', + targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }, + ], + }); + if (!active) return; + statusRef.current(result?.status === 'applied' ? 'applied' : 'unsupported', result ?? undefined); + }).catch((error) => { + if (!active) return; + statusRef.current('error', error instanceof Error ? error : new Error(String(error))); + }); + return () => { + active = false; + surface.destroy(); + }; + }, [fixture, item, resetVersion, themeId]); + + return
    ; + } + + function StaticAnnotationCard({ + item, + fixture, + themeId, + resetVersion, + }: { + item: InteractionCase; + fixture: AnnotationFixture; + themeId: string | undefined; + resetVersion: number; + }) { + const [status, setStatus] = useState('loading'); + const [detail, setDetail] = useState('Applying annotation update'); + return ( +
    +
    +
    +

    {item.chartType}

    +

    {fixture.label} {fixture.visual.role}

    +
    + + {status === 'applied' ? 'Applied' : status} + +
    +
    + { + setStatus(nextStatus); + setDetail(result instanceof Error + ? result.message + : result ? `${result.status}: ${result.resolvedTargets} target` : 'Applying annotation update'); + }} + /> +
    +
    + ); + } + + export function AnnotationLab() { + const [themeId, setThemeId] = useState(); + const [resetVersion, setResetVersion] = useState(0); + const staticCases = annotationCases.flatMap((item) => + (ANNOTATION_FIXTURES[item.chartType] ?? []).map((fixture) => ({ item, fixture }))); + + return ( +
    +
    +
    +
    +

    Annotation lab

    +

    Static charts with annotation update specs applied directly after render.

    +
    + +
    +
    +
    + {coverage.map((item) => {item})} +
    + +
    +
    {staticCases.length} exact-target cases · {annotationCases.length} chart types
    +
    +
    + {staticCases.map(({ item, fixture }) => ( + + ))} +
    +
    + ); + } \ No newline at end of file diff --git a/site/src/playground/ArchitectureIllustrations.tsx b/site/src/playground/ArchitectureIllustrations.tsx new file mode 100644 index 00000000..18e257e8 --- /dev/null +++ b/site/src/playground/ArchitectureIllustrations.tsx @@ -0,0 +1,20 @@ +import { CompilationProcessIllustration } from './CompilationProcessIllustration'; +import { IllustrationPageHeader } from './IllustrationPageHeader'; +import { InteractionArchitectureIllustration } from './InteractionArchitectureIllustration'; +import './architecture-illustrations.css'; + +export function ArchitectureIllustrations() { + return ( +
    + + +
    + +
    + +
    + +
    +
    + ); +} \ No newline at end of file diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index 804d7d0a..40af5290 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -27,11 +27,11 @@ import { ThemePicker } from './ThemePicker'; import { navigationDemoCases } from './navigation-demo-data'; import './click-focus-lab.css'; -type InteractionMode = 'element' | 'group' | 'annotate' | 'select' +export type InteractionMode = 'element' | 'group' | 'annotate' | 'select' | 'brush-x' | 'brush-y' | 'brush-angle' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate' | 'drag-reorder'; type ProbeStatus = 'loading' | 'ready' | 'unsupported' | 'error'; -interface NavigationGuard { +export interface NavigationGuard { minVisibleFraction: number; maxVisibleFraction: number; overscrollFraction: number; @@ -51,7 +51,7 @@ const interactionModes = [ { value: 'drag-reorder', label: 'Drag reorder', icon: GripVertical }, ] as const; -interface InteractionCase { +export interface InteractionCase { id: string; input: ChartAssemblyInput; navigationAxes?: 'x' | 'y' | 'xy'; @@ -183,6 +183,30 @@ const interactionCases: InteractionCase[] = [ multiLegendCase('size'), ]; +const ANNOTATION_CHART_TYPES = [ + 'Area Chart', + 'Bar Chart', + 'Bar Table', + 'Bullet Chart', + 'Calendar Heatmap', + 'Candlestick Chart', + 'Choropleth', + 'Connected Scatter Plot', + 'Density Plot', + 'Heatmap', + 'Pie Chart', + 'Ranged Dot Plot', + 'Scatter Plot', + 'Slope Chart', + 'Violin Plot', + 'Waterfall Chart', +] as const; + +export const annotationCases = ANNOTATION_CHART_TYPES.flatMap((chartType) => { + const item = interactionCases.find((candidate) => candidate.chartType === chartType); + return item ? [item] : []; +}); + const navigationCases: InteractionCase[] = navigationDemoCases.map((item) => ({ ...item, chartType: item.input.chart_spec.chartType, @@ -336,7 +360,7 @@ function InteractiveChart({ return
    ; } -function CaseCard({ +export function CaseCard({ item, mode, themeId, @@ -531,7 +555,7 @@ export function ClickFocusLab() {
    )} -
    +
    {visibleCases.map((item) => ( (null); const [lastPayload, setLastPayload] = useState(null); - const interactions = useMemo(() => [{ - id: `${demo.id}-semantic-index`, - eventSource: clickTrigger, - }], [demo.id]); + const interactionId = `${demo.id}-control`; + const interactions = useMemo(() => [externalInteraction({ + id: interactionId, + handle: (payload) => ({ + id: interactionId, + ops: payload.match + ? [{ + op: 'set-presentation', + targets: [{ select: { key: selectorKey(payload.match) } }], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }] + : [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + }), + })], [interactionId]); const handleSurface = useCallback((surface: InteractiveChartSurface | null) => { surfaceRef.current = surface; }, []); @@ -230,13 +240,7 @@ function ExternalDemoRow({ demo }: { demo: ExternalDemo }) { setLastPayload(payload); const surface = surfaceRef.current; if (!surface) return; - void surface.applyUpdate({ - updateId: `${demo.id}-control`, - phase: 'commit', - ops: payload.match - ? [emphasize({ targets: [{ select: { key: selectorKey(payload.match) } }] })] - : [resetUpdate()], - }); + void surface.dispatch(interactionId, payload); }; return ( diff --git a/site/src/playground/IllustrationPageHeader.tsx b/site/src/playground/IllustrationPageHeader.tsx new file mode 100644 index 00000000..f727ab66 --- /dev/null +++ b/site/src/playground/IllustrationPageHeader.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router-dom'; + +type IllustrationView = 'gallery' | 'diagrams'; + +export function IllustrationPageHeader({ active }: { active: IllustrationView }) { + return ( +
    +

    Illustrations

    + +
    + ); +} diff --git a/site/src/playground/Illustrations.tsx b/site/src/playground/Illustrations.tsx index bebbea3f..baa77406 100644 --- a/site/src/playground/Illustrations.tsx +++ b/site/src/playground/Illustrations.tsx @@ -2,15 +2,12 @@ import { ChatMockup } from '../routes/McpServer'; import { ChartRedesignFigure } from './ChartRedesignFigure'; import { SpecPipelineFigure } from '../components/SpecPipelineFigure'; import { DiverseChartWallIllustration } from './DiverseChartWallIllustration'; -import { CompilationProcessIllustration } from './CompilationProcessIllustration'; +import { IllustrationPageHeader } from './IllustrationPageHeader'; export function Illustrations() { return (
    -
    -

    Illustrations

    -
    - +
    diff --git a/site/src/playground/InteractionArchitectureIllustration.tsx b/site/src/playground/InteractionArchitectureIllustration.tsx new file mode 100644 index 00000000..ef4b4d0b --- /dev/null +++ b/site/src/playground/InteractionArchitectureIllustration.tsx @@ -0,0 +1,257 @@ +import { useId } from 'react'; + +const layout = { + canvasWidth: 1943, + margin: 42, + gap: 28, + inset: 18, + centeredTextOffset: 6, + artifactTitleOffset: 28, + artifactDetailOffset: 51, + artifactLineGap: 21, + detailedArtifactHeight: 136, + width: { + input: 196, + resolve: 248, + dispatch: 254, + canvasEvent: 292, + externalEvent: 220, + update: 242, + group: 252, + compactProcess: 223, + baseState: 186, + output: 198, + }, + height: { + input: 82, + compact: 64, + standard: 72, + prominent: 92, + }, +} as const; + +const column = { + input: layout.margin, + canvasProcess: layout.margin + layout.width.input + layout.gap, + canvasEvent: layout.margin + layout.width.input + layout.gap + layout.width.resolve + layout.gap, + externalProcess: layout.margin + layout.width.input + layout.gap, + externalEvent: layout.margin + layout.width.input + layout.gap + layout.width.dispatch + layout.gap, + handler: layout.margin + layout.width.input + layout.gap + layout.width.resolve + layout.gap + layout.width.canvasEvent + layout.gap * 2, + update: layout.margin + layout.width.input + layout.gap + layout.width.resolve + layout.gap + layout.width.canvasEvent + layout.gap * 2 + layout.width.group + layout.gap, + apply: layout.margin + layout.width.input + layout.gap + layout.width.resolve + layout.gap + layout.width.canvasEvent + layout.gap * 2 + layout.width.group + layout.gap + layout.width.update + layout.gap, + output: layout.canvasWidth - layout.margin - layout.width.output, +} as const; + +const centeredY = (center: number, height: number): number => center - height / 2; + +function Box({ x, y, width, height, title, detail, className }: { + x: number; + y: number; + width: number; + height: number; + title: string; + detail?: string; + className?: string; +}) { + return ( + + + {title} + {detail && {detail}} + + ); +} + +function ItemBox({ x, y, width, height, title, detail }: { + x: number; + y: number; + width: number; + height: number; + title: string; + detail?: string | readonly string[]; +}) { + const details = typeof detail === 'string' ? [detail] : detail; + + return ( + + + + {title} + + {details?.map((line, index) => ( + {line} + ))} + + ); +} + +interface DetailLine { + text: string; + emphasis?: boolean; + indent?: boolean; +} + +function DetailedItemBox({ x, y, width, title, lines }: { + x: number; + y: number; + width: number; + title: string; + lines: readonly DetailLine[]; +}) { + return ( + + + {title} + {lines.map((line, index) => ( + + {line.text} + + ))} + + ); +} + +function CanvasAcquisitionBox({ arrow }: { arrow: string }) { + const center = column.canvasProcess + layout.width.resolve / 2; + const innerX = column.canvasProcess + layout.inset; + const innerWidth = layout.width.resolve - layout.inset * 2; + + return ( + + + + Resolve (stateful) + + + + + Track gesture state + + + + + + Hit test marks + + + + + + Reverse to semantics + + + ); +} + +export function InteractionArchitectureIllustration() { + const id = useId().replace(/:/g, ''); + const arrow = `${id}-arrow`; + + return ( +
    +
    + + + + + + + + + + flint-interactive + + + + + + + + + + + handled by application + + + + + register + + + + + + + + + + + + + + + + + + + + +
    +
    + Flint's interaction architecture reuses chart-specific knowledge in both directions. Canvas input maps rendered marks back to semantic events; chart update specs are then resolved through chart-specific presentation and applied through precompiled renderer stores and signals. Preserving the renderer's reactive dataflow avoids recompilation during interaction while enabling handlers and application code to express intent without reimplementing chart semantics or backend logic. +
    +
    + ); +} \ No newline at end of file diff --git a/site/src/playground/InteractionDashboardLab.tsx b/site/src/playground/InteractionDashboardLab.tsx index ef433f17..fa91c056 100644 --- a/site/src/playground/InteractionDashboardLab.tsx +++ b/site/src/playground/InteractionDashboardLab.tsx @@ -11,8 +11,7 @@ import { brushY, clickGroupHighlight, clickHighlight, - emphasize, - resetUpdate, + externalInteraction, select, } from 'flint-chart/interactive'; import { InteractionDemoChart } from './InteractionDemoChart'; @@ -42,6 +41,7 @@ const focusCountries = new Set([ type DashboardMetric = 'Life expectancy' | 'GDP per capita'; const DASHBOARD_SELECTION_ID = 'dashboard-selection'; +const DASHBOARD_LINKED_SELECTION_ID = 'dashboard-linked-selection'; const semanticTypes = { Observation: 'Category', @@ -214,7 +214,25 @@ function DashboardPanel({ registerSurface: (id: string, surface: InteractiveChartSurface | null) => void; routeEvent: (detail: FlintInteractionEventDetail) => void; }) { - const interactions = useMemo(() => [chart.interaction], [chart.interaction]); + const interactions = useMemo(() => [ + chart.interaction, + externalInteraction<{ observationIds: string[] }>({ + id: DASHBOARD_LINKED_SELECTION_ID, + handle: ({ observationIds: ids }) => { + const targets = linkedTargets(chart.id, ids); + return { + id: DASHBOARD_SELECTION_ID, + ops: targets.length > 0 + ? [{ + op: 'set-presentation', + targets, + value: { state: 'emphasized', mutedOpacity: 0.22 }, + }] + : [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + }; + }, + }), + ], [chart.id, chart.interaction]); const handleSurface = useCallback( (surface: InteractiveChartSurface | null) => registerSurface(chart.id, surface), [chart.id, registerSurface], @@ -253,14 +271,7 @@ export function InteractionDashboardLab() { const dispatchSelection = useCallback((ids: string[], excludeId?: string) => { for (const [id, surface] of surfaces.current) { if (id === excludeId) continue; - const targets = linkedTargets(id, ids); - void surface.applyUpdate({ - updateId: DASHBOARD_SELECTION_ID, - phase: 'commit', - ops: targets.length > 0 - ? [emphasize({ targets, dimOpacity: 0.22 })] - : [resetUpdate()], - }); + void surface.dispatch(DASHBOARD_LINKED_SELECTION_ID, { observationIds: ids }); } }, []); diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 75187000..226cc396 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -2,11 +2,17 @@ import { NavLink, Link, Outlet, useLocation } from 'react-router-dom'; import { siteTheme } from '../shared/theme'; import './playground.css'; -type NavLeaf = { to: string; label: string }; +type NavLeaf = { to: string; label: string; end?: boolean }; type NavEntry = NavLeaf | { group: string; children: NavLeaf[] }; const pages: NavEntry[] = [ - { to: 'illustrations', label: 'Illustrations' }, + { + group: 'Illustrations', + children: [ + { to: 'illustrations', label: 'Gallery', end: true }, + { to: 'illustrations/architecture', label: 'Architecture' }, + ], + }, { to: 'mcp-ui', label: 'MCP UI test' }, { group: 'Labs', @@ -20,6 +26,7 @@ const pages: NavEntry[] = [ group: 'Interactions', children: [ { to: 'click-focus', label: 'Test cases' }, + { to: 'annotation-lab', label: 'Annotation lab' }, { to: 'interaction-candidates', label: 'References' }, { to: 'interaction-dashboard', label: 'Demo: dashboard' }, { to: 'external-to-chart', label: 'Demo: external to chart' }, @@ -60,6 +67,7 @@ function NavGroupMenu({ group, children }: { group: string; children: NavLeaf[] isActive ? 'dev-nav-link dev-nav-link-active' : 'dev-nav-link'} > @@ -86,6 +94,7 @@ export function PlaygroundShell() { isActive ? 'dev-nav-link dev-nav-link-active' : 'dev-nav-link'} > {page.label} diff --git a/site/src/playground/annotation-lab.css b/site/src/playground/annotation-lab.css new file mode 100644 index 00000000..f9a0444d --- /dev/null +++ b/site/src/playground/annotation-lab.css @@ -0,0 +1,121 @@ +.annotation-page { + gap: 12px; +} + +.annotation-heading { + width: min(100%, 1120px); +} + +.annotation-title-row, +.annotation-toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.annotation-toolbar { + align-items: center; + margin-top: 8px; +} + +.annotation-coverage { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.annotation-coverage span { + padding: 3px 7px; + border: 1px solid #d8dde2; + border-radius: 4px; + color: #5f6972; + background: #fff; + font-size: 10px; + font-weight: 600; +} + +.annotation-reset { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + border: 1px solid #cfd5da; + border-radius: 5px; + padding: 0 10px; + color: #4d5963; + background: #fff; + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 600; +} + +.annotation-reset:hover { + background: #eef1f3; +} + +.annotation-grid { + width: min(100%, 1440px); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 8px; +} + +.annotation-static-card { + grid-template-rows: auto auto; + min-width: 0; +} + +.annotation-static-card .cf-probe-header { + min-height: 38px; + padding: 7px 9px; +} + +.annotation-static-card .cf-probe-header h2 { + font-size: 12px; +} + +.annotation-static-card .cf-probe-header p { + margin-top: 1px; + font-size: 9px; +} + +.annotation-static-card .cf-probe-header p span { + color: #87919a; +} + +.annotation-static-card .cf-status { + padding: 2px 5px; + font-size: 8px; +} + +.annotation-static-stage { + padding: 6px; + pointer-events: none; +} + +.annotation-static-stage .cf-mount { + min-height: 0; +} + +/* The Vega SVG carries a viewBox, so it scales down like an image. */ +.annotation-static-stage svg.marks { + max-width: 100%; + height: auto; +} +@media (max-width: 640px) { + .annotation-title-row, + .annotation-toolbar { + align-items: stretch; + flex-direction: column; + } + + .annotation-static-card .cf-probe-header { + align-items: flex-start; + flex-direction: row; + } + + .annotation-static-card .cf-status { + align-self: auto; + } +} \ No newline at end of file diff --git a/site/src/playground/architecture-illustrations.css b/site/src/playground/architecture-illustrations.css new file mode 100644 index 00000000..07d071ad --- /dev/null +++ b/site/src/playground/architecture-illustrations.css @@ -0,0 +1,103 @@ +.architecture-page { + --architecture-ink: #1f2328; + --architecture-muted: #66707a; + --architecture-blue: #2878a8; + --architecture-line: rgba(31, 35, 40, 0.18); + --architecture-soft: #f4f5f3; + gap: 64px; + color: var(--architecture-ink); + font-family: 'Helvetica Neue', Helvetica, 'Inter Variable', sans-serif; +} + +.architecture-section { + box-sizing: border-box; + width: min(100%, 1400px); +} + +.architecture-section { + padding-top: 24px; +} + +.architecture-page .fci-stage svg { + min-width: 700px; +} + +.architecture-page figcaption { + display: none; +} + +.iai-figure { + width: min(100%, 1400px); + margin: 0; +} + +.iai-stage { + --iai-artifact-fill: #f1f1ef; + --iai-process-fill: #fff; + --iai-compiler-fill: #eaf4f8; + --iai-boundary-fill: #f4f1ea; + --iai-box-stroke: 1.5px; + --iai-inner-stroke: 1px; + --iai-flow-stroke: 2px; + --iai-stage-flow-stroke: 1.5px; + --iai-arrow-stroke: 1.75px; + --iai-type-artifact: 19px; + --iai-type-artifact-expanded: 21px; + --iai-type-process: 19px; + --iai-type-nested: 17px; + --iai-type-detail: 15px; + --iai-type-detail-expanded: 15px; + --iai-type-flow-note: 14px; + width: 100%; + overflow-x: auto; + background: #fff; +} + +.iai-stage svg { + display: block; + width: 100%; + min-width: 980px; + height: auto; + font-family: 'Helvetica Neue', Helvetica, 'Inter Variable', sans-serif; + shape-rendering: geometricPrecision; +} + +.iai-figure figcaption { + display: block; + max-width: 1080px; + margin-top: 18px; + color: var(--architecture-muted); + font-size: 15px; + line-height: 1.58; +} + +.iai-boundary path { fill: var(--iai-boundary-fill); fill-opacity: 0.62; stroke: none; } +.iai-boundary text { fill: var(--architecture-muted); font-size: 13px; font-weight: 400; letter-spacing: 0; } +.iai-item rect { fill: var(--iai-artifact-fill); stroke: var(--architecture-ink); stroke-width: var(--iai-box-stroke); } +.iai-item-title { fill: var(--architecture-ink); font-size: var(--iai-type-artifact); font-weight: 600; letter-spacing: 0; } +.iai-item-detail { fill: var(--architecture-muted); font-size: var(--iai-type-detail); font-weight: 400; letter-spacing: 0; } +.iai-item-expanded .iai-item-title { font-size: var(--iai-type-artifact-expanded); } +.iai-item-expanded .iai-item-detail { font-size: var(--iai-type-detail-expanded); font-weight: 400; } +.iai-item-expanded .iai-item-detail-key { fill: var(--architecture-blue); font-weight: 700; } +.iai-box rect { fill: var(--iai-process-fill); stroke: var(--architecture-ink); stroke-width: var(--iai-box-stroke); } +.iai-title { fill: var(--architecture-ink); font-size: var(--iai-type-process); font-weight: 600; letter-spacing: 0; } +.iai-title-qualifier { fill: var(--architecture-muted); font-size: 13px; font-weight: 400; } +.iai-box .iai-title { font-weight: 500; } +.iai-detail { fill: var(--architecture-muted); font-size: var(--iai-type-detail); font-weight: 400; } +.iai-acquisition > rect:first-child { fill: var(--iai-process-fill); stroke: var(--architecture-ink); stroke-width: var(--iai-box-stroke); } +.iai-stage-box rect { fill: var(--iai-process-fill); stroke: var(--architecture-line); stroke-width: var(--iai-inner-stroke); } +.iai-compiler-pass rect { fill: var(--iai-compiler-fill); stroke: var(--architecture-blue); stroke-width: 2.25px; } +.iai-compiler-pass .iai-title, +.iai-compiler-pass .iai-stage-title { fill: var(--architecture-blue); font-weight: 600; } +.iai-stage-title { fill: var(--architecture-ink); font-size: var(--iai-type-nested); font-weight: 500; letter-spacing: 0; } +.iai-stage-connector { fill: none; stroke: var(--architecture-muted); stroke-width: var(--iai-stage-flow-stroke); stroke-linecap: square; stroke-linejoin: miter; } +.iai-flow { fill: none; stroke: var(--architecture-ink); stroke-width: var(--iai-flow-stroke); stroke-linecap: square; stroke-linejoin: miter; } +.iai-flow-dashed { stroke-dasharray: 7 6; } +.iai-flow-label { fill: var(--architecture-muted); font-size: var(--iai-type-flow-note); font-weight: 400; } +.iai-extension-label { fill: var(--architecture-muted); font-size: var(--iai-type-flow-note); font-weight: 400; } +.iai-arrow-head { fill: none; stroke: var(--architecture-ink); stroke-width: var(--iai-arrow-stroke); stroke-linecap: square; stroke-linejoin: miter; } +.iai-note { fill: var(--architecture-muted); font-size: 13px; } + +@media (max-width: 620px) { + .architecture-page { gap: 48px; } +} \ No newline at end of file diff --git a/site/src/playground/click-focus-lab.css b/site/src/playground/click-focus-lab.css index bcfb2b81..05cceb35 100644 --- a/site/src/playground/click-focus-lab.css +++ b/site/src/playground/click-focus-lab.css @@ -91,13 +91,20 @@ .cf-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); + align-items: start; gap: 18px; width: min(100%, 960px); } +/* Navigation demos are compiled wider than a half-width card. */ +.cf-grid-wide { + grid-template-columns: minmax(0, 1fr); +} + .cf-probe { display: grid; - grid-template-rows: auto auto auto; + grid-template-rows: auto max-content auto; + align-self: start; min-width: 0; overflow: hidden; border: 1px solid #d8dde2; @@ -196,19 +203,26 @@ .cf-stage { min-width: 0; - overflow: hidden; + /* Charts compile to their own width; reach the overflow instead of hiding it. */ + overflow-x: auto; + overflow-y: hidden; } .cf-mount { display: grid; - place-items: center; - width: 100%; + align-items: center; + /* Centring an oversized chart would push its left edge out of scroll reach. */ + justify-items: safe center; + /* Grow to the chart so the stage can scroll to its far edge. */ + width: max-content; + min-width: 100%; min-height: 280px; } .cf-mount [data-flint-chart] { display: grid; - place-items: center; + align-items: center; + justify-items: safe center; } .cf-probe-event { diff --git a/site/src/playground/playground.css b/site/src/playground/playground.css index 948d65af..1afea7fa 100644 --- a/site/src/playground/playground.css +++ b/site/src/playground/playground.css @@ -1,8 +1,11 @@ .dev-shell { --dev-grid-line: rgba(0, 0, 0, 0.035); + --dev-text: #1f2328; + --dev-muted: #66707a; + --dev-accent: #2878a8; min-height: 100vh; box-sizing: border-box; - color: #1f2328; + color: var(--dev-text); background-color: #fff; background-image: linear-gradient(90deg, var(--dev-grid-line) 1px, transparent 1px), @@ -152,6 +155,33 @@ letter-spacing: 0; } +.illustration-page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + width: min(100%, 1400px); +} + +.illustration-page-switch { + display: flex; + flex: 0 0 auto; + gap: 18px; + font-size: 13px; +} + +.illustration-page-switch a, +.illustration-page-switch span { + padding-bottom: 5px; + color: var(--dev-muted); + text-decoration: none; +} + +.illustration-page-switch span { + color: var(--dev-text); + border-bottom: 2px solid var(--dev-accent); +} + .dev-figure-wide { width: 960px; max-width: 100%; @@ -174,4 +204,8 @@ .dev-content { padding: 20px 14px 36px; } + + .illustration-page-heading { + align-items: flex-start; + } } \ No newline at end of file From df20e23c8f3d2a7cf9b3899743ed5c5be8671860 Mon Sep 17 00:00:00 2001 From: Aaron Chen <122733139+chen1plus@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:14:14 +0800 Subject: [PATCH 19/33] Add compile commands --- package-lock.json | 1 + packages/flint-mcp/package.json | 3 +- packages/flint-mcp/src/cli.ts | 102 ++----- packages/flint-mcp/src/compile.ts | 287 +++++++++++++++++++ packages/flint-mcp/src/flint.ts | 32 +++ packages/flint-mcp/src/render/data-source.ts | 22 +- packages/flint-mcp/src/render/index.ts | 1 + packages/flint-mcp/src/render/types.ts | 2 + packages/flint-mcp/src/server.ts | 6 +- packages/flint-mcp/src/version.ts | 8 + packages/flint-mcp/tests/compile.test.ts | 239 +++++++++++++++ packages/flint-mcp/tsup.config.ts | 1 + 12 files changed, 619 insertions(+), 85 deletions(-) create mode 100644 packages/flint-mcp/src/compile.ts create mode 100644 packages/flint-mcp/src/flint.ts create mode 100644 packages/flint-mcp/src/version.ts create mode 100644 packages/flint-mcp/tests/compile.test.ts diff --git a/package-lock.json b/package-lock.json index b7958def..dba7e640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10340,6 +10340,7 @@ "zod": "^3.25.1" }, "bin": { + "flint": "dist/flint.js", "flint-chart-mcp": "dist/cli.js" }, "devDependencies": { diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index efd74d77..1102cecb 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -28,7 +28,8 @@ }, "type": "module", "bin": { - "flint-chart-mcp": "dist/cli.js" + "flint-chart-mcp": "dist/cli.js", + "flint": "dist/flint.js" }, "main": "./dist/server.js", "types": "./dist/server.d.ts", diff --git a/packages/flint-mcp/src/cli.ts b/packages/flint-mcp/src/cli.ts index 3b2af32b..8562c1ae 100644 --- a/packages/flint-mcp/src/cli.ts +++ b/packages/flint-mcp/src/cli.ts @@ -2,7 +2,8 @@ // Licensed under the MIT License. import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { createServer, resolveBackends, VERSION } from './server.js'; +import { createServer, resolveBackends } from './server.js'; +import { VERSION } from './version.js'; import { startHttpServer, DEFAULT_MCP_PATH } from './http.js'; import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; @@ -12,7 +13,7 @@ MCP server that compiles and renders Flint chart specs to Vega-Lite, ECharts, or Chart.js artifacts (PNG/SVG), entirely in-process. Usage: - flint-chart-mcp [options] + flint-chart-mcp [options] Start the MCP server (stdio by default) Options: --transport Transport to use. Default: stdio. @@ -25,7 +26,7 @@ Options: --allowed-hosts Comma-separated Host header allowlist enabling DNS-rebinding protection (http transport only). --allowed-origins Comma-separated Origin header allowlist enabling - DNS-rebinding protection (http transport only). + DNS-rebinding protection (http transport only). --backends Comma-separated backends to expose (subset of: ${SUPPORTED_BACKENDS.join(', ')}). Overridden by the FLINT_MCP_BACKENDS env var if set. @@ -54,18 +55,21 @@ Prompts: Example MCP client config: { "command": "npx", "args": ["-y", "flint-chart-mcp"] } + +Local file compile (no agent needed): + flint compile chart.json --format svg (via the separate "flint" binary) + See "flint --help" for compile options. `; +// keep runCompile re-export for existing tests importing from cli.js +export { runCompile, type CompileIo } from './compile.js'; + interface ParsedArgs { transport: string; backends?: SupportedBackend[]; - /** When true, reject local data.url file references (inline rows only). */ disableFileReference: boolean; - /** True when --disable-file-reference was explicitly passed on the CLI. */ disableFileReferenceSet: boolean; - /** True when a deprecated --data-root(s) flag was passed (ignored, warned). */ usedDeprecatedDataRoots: boolean; - /** HTTP transport options. */ port?: number; host?: string; path?: string; @@ -75,24 +79,16 @@ interface ParsedArgs { function parseBackends(raw: string | undefined): SupportedBackend[] | undefined { if (!raw) return undefined; - const list = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) as SupportedBackend[]; + const list = raw.split(',').map((s) => s.trim()).filter(Boolean) as SupportedBackend[]; return list.length ? list : undefined; } -/** Split a comma-separated allowlist into trimmed entries. */ function parseList(raw: string | undefined): string[] | undefined { if (!raw) return undefined; - const list = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); + const list = raw.split(',').map((s) => s.trim()).filter(Boolean); return list.length ? list : undefined; } -/** Parse a boolean env var; undefined when unset so the flag can win. */ function parseBoolEnv(raw: string | undefined): boolean | undefined { if (raw == null) return undefined; const value = raw.trim().toLowerCase(); @@ -147,28 +143,19 @@ function parseArgs(argv: string[]): ParsedArgs { break; case '--data-roots': case '--data-root': - // Deprecated: consume and ignore the value; warned about in main(). i++; out.usedDeprecatedDataRoots = true; break; default: - if (arg.startsWith('--transport=')) { - out.transport = arg.slice('--transport='.length); - } else if (arg.startsWith('--port=')) { - out.port = Number(arg.slice('--port='.length)); - } else if (arg.startsWith('--host=')) { - out.host = arg.slice('--host='.length); - } else if (arg.startsWith('--path=')) { - out.path = arg.slice('--path='.length); - } else if (arg.startsWith('--allowed-hosts=')) { - out.allowedHosts = parseList(arg.slice('--allowed-hosts='.length)); - } else if (arg.startsWith('--allowed-origins=')) { - out.allowedOrigins = parseList(arg.slice('--allowed-origins='.length)); - } else if (arg.startsWith('--backends=')) { - out.backends = parseBackends(arg.slice('--backends='.length)); - } else if (arg.startsWith('--data-roots=') || arg.startsWith('--data-root=')) { - out.usedDeprecatedDataRoots = true; - } else { + if (arg.startsWith('--transport=')) out.transport = arg.slice('--transport='.length); + else if (arg.startsWith('--port=')) out.port = Number(arg.slice('--port='.length)); + else if (arg.startsWith('--host=')) out.host = arg.slice('--host='.length); + else if (arg.startsWith('--path=')) out.path = arg.slice('--path='.length); + else if (arg.startsWith('--allowed-hosts=')) out.allowedHosts = parseList(arg.slice('--allowed-hosts='.length)); + else if (arg.startsWith('--allowed-origins=')) out.allowedOrigins = parseList(arg.slice('--allowed-origins='.length)); + else if (arg.startsWith('--backends=')) out.backends = parseBackends(arg.slice('--backends='.length)); + else if (arg.startsWith('--data-roots=') || arg.startsWith('--data-root=')) out.usedDeprecatedDataRoots = true; + else { process.stderr.write(`Unknown argument: ${arg}\n`); process.exit(2); } @@ -182,46 +169,24 @@ async function main(): Promise { const transport = (process.env.FLINT_MCP_TRANSPORT?.trim() || args.transport).toLowerCase(); if (transport !== 'stdio' && transport !== 'http') { - process.stderr.write( - `Unsupported transport "${transport}". Use "stdio" or "http".\n`, - ); + process.stderr.write(`Unsupported transport "${transport}". Use "stdio" or "http".\n`); process.exit(2); } - // Env var takes precedence over the flag for deployment-time gating. - const enabledBackends = - parseBackends(process.env.FLINT_MCP_BACKENDS) ?? args.backends; + const enabledBackends = parseBackends(process.env.FLINT_MCP_BACKENDS) ?? args.backends; const envDisable = parseBoolEnv(process.env.FLINT_MCP_DISABLE_FILE_REFERENCE); - // The http transport is remote: local files belong to the server, not the - // user, so default to blocking file references unless explicitly overridden. - const disableFileReference = - envDisable ?? (args.disableFileReferenceSet ? args.disableFileReference : transport === 'http'); + const disableFileReference = envDisable ?? (args.disableFileReferenceSet ? args.disableFileReference : transport === 'http'); - // The legacy --data-roots/--data-root flags and FLINT_MCP_DATA_ROOTS env var - // are deprecated and no longer take effect. They USED to allow/whitelist local - // file reads, so we must NOT steer migrators toward --disable-file-reference - // (the opposite intent) — that would accidentally turn off all file charting. if (args.usedDeprecatedDataRoots || process.env.FLINT_MCP_DATA_ROOTS?.trim()) { process.stderr.write( - 'flint-chart-mcp: --data-roots / --data-root (and FLINT_MCP_DATA_ROOTS) are ' + - 'deprecated and have NO effect. Local data.url files are now readable by ' + - 'default, so you can safely REMOVE these flags and local-file charts keep ' + - 'working. (Only add --disable-file-reference if you instead want to BLOCK ' + - 'local file reads.)\n', + 'flint-chart-mcp: --data-roots / --data-root (and FLINT_MCP_DATA_ROOTS) are deprecated and have NO effect. Local data.url files are now readable by default, so you can safely REMOVE these flags and local-file charts keep working. (Only add --disable-file-reference if you instead want to BLOCK local file reads.)\n', ); } - // Validate eagerly so a bad config fails fast with a clear message. const resolved = resolveBackends({ enabledBackends }); - - const dataMode = disableFileReference - ? 'local file references disabled' - : 'local files readable on request'; + const dataMode = disableFileReference ? 'local file references disabled' : 'local files readable on request'; if (transport === 'http') { - // Some hosts (e.g. Azure App Service custom containers) inject an empty - // PORT env var that would override the intended port; treat blank env - // values as unset so the flag/default still applies. const portEnv = process.env.PORT?.trim() || process.env.FLINT_MCP_PORT?.trim(); const port = Number(portEnv || args.port || 8080); if (!Number.isFinite(port) || port <= 0) { @@ -238,10 +203,7 @@ async function main(): Promise { allowedHosts: args.allowedHosts, allowedOrigins: args.allowedOrigins, }); - process.stderr.write( - `flint-chart-mcp ${VERSION} listening on ${running.url} ` + - `(backends: ${resolved.join(', ')}; ${dataMode})\n`, - ); + process.stderr.write(`flint-chart-mcp ${VERSION} listening on ${running.url} (backends: ${resolved.join(', ')}; ${dataMode})\n`); const shutdown = () => { void running.close().finally(() => process.exit(0)); }; @@ -253,16 +215,10 @@ async function main(): Promise { const server = createServer({ enabledBackends, disableFileReference }); const stdio = new StdioServerTransport(); await server.connect(stdio); - - // stdout is the protocol channel; log to stderr only. - process.stderr.write( - `flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}; ` + - `${dataMode})\n`, - ); + process.stderr.write(`flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}; ${dataMode})\n`); } main().catch((err) => { process.stderr.write(`flint-chart-mcp failed to start: ${err?.stack ?? err}\n`); process.exit(1); }); - diff --git a/packages/flint-mcp/src/compile.ts b/packages/flint-mcp/src/compile.ts new file mode 100644 index 00000000..413141bb --- /dev/null +++ b/packages/flint-mcp/src/compile.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, extname, resolve as resolvePath } from 'node:path'; +import { VERSION } from './version.js'; +import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; +import { renderChart } from './render/index.js'; +import type { RenderBackend, RenderFormat } from './render/types.js'; + +const COMPILE_HELP = `flint ${VERSION} + +Compile a saved Flint ChartAssemblyInput JSON to SVG or PNG, entirely in-process. + +Usage: + flint compile [options] + flint [options] (shorthand, same as compile) + +Arguments: + Path to JSON file containing ChartAssemblyInput, or "-" for stdin. + +Options: + --backend Rendering backend: ${SUPPORTED_BACKENDS.join(', ')}. Default: vegalite. + --format Output format. Default: svg (vegalite/echarts) or png (chartjs). + --output , -o + Output file. Default: . next to input (chart.json → chart.svg). + Use "-" for stdout. Defaults to stdout when input is stdin and no output given. + --scale Device scale for PNG (0.5–4). Default: 1. + --background Background color. Default: #ffffff. + -h, --help Print this help and exit. + -v, --version Print version and exit. + +Note: + Relative data.url paths in the input resolve against the input file's + directory, or against the current working directory when reading from stdin. + +Examples: + flint compile chart.json --format svg + flint compile chart.json --backend echarts --format png --output chart.png + cat chart.json | flint compile - --format svg > chart.svg + flint chart.json --format svg --output chart.svg +`; + +interface CompileOptions { + input: string; + backend: RenderBackend; + format: RenderFormat; + output?: string; + scale?: number; + background?: string; +} + +type CompileParseResult = + | { kind: 'run'; options: CompileOptions } + | { kind: 'help' } + | { kind: 'version' }; + +class CompileError extends Error { + constructor(message: string, readonly exitCode: number = 2) { + super(message); + } +} + +function parseCompileArgs(argv: string[]): CompileParseResult { + let input: string | undefined; + let backend: string | undefined; + let format: string | undefined; + let output: string | undefined; + let scale: number | undefined; + let background: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + return { kind: 'help' }; + } else if (arg === '-v' || arg === '--version') { + return { kind: 'version' }; + } else if (arg === '--backend') { + backend = argv[++i]; + if (!backend) throw new CompileError('Missing value for --backend'); + } else if (arg.startsWith('--backend=')) { + backend = arg.slice('--backend='.length); + } else if (arg === '--format') { + format = argv[++i]; + if (!format) throw new CompileError('Missing value for --format'); + } else if (arg.startsWith('--format=')) { + format = arg.slice('--format='.length); + } else if (arg === '--output' || arg === '-o') { + output = argv[++i]; + if (!output) throw new CompileError('Missing value for --output'); + } else if (arg.startsWith('--output=')) { + output = arg.slice('--output='.length); + } else if (arg.startsWith('-o') && arg.length > 2 && !arg.startsWith('-output')) { + output = arg.slice(2); + } else if (arg === '--scale') { + const raw = argv[++i]; + scale = Number(raw); + if (!Number.isFinite(scale)) throw new CompileError(`Invalid --scale value: ${raw}`); + } else if (arg.startsWith('--scale=')) { + scale = Number(arg.slice('--scale='.length)); + if (!Number.isFinite(scale)) throw new CompileError(`Invalid --scale value: ${arg.slice('--scale='.length)}`); + } else if (arg === '--background') { + background = argv[++i]; + if (!background) throw new CompileError('Missing value for --background'); + } else if (arg.startsWith('--background=')) { + background = arg.slice('--background='.length); + } else if (arg === '-') { + if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); + input = arg; + } else if (arg.startsWith('-')) { + throw new CompileError(`Unknown compile option: ${arg}\nRun "flint --help" for usage.`); + } else { + if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); + input = arg; + } + } + + if (!input) throw new CompileError('Missing argument.\nRun "flint --help" for usage.'); + + const resolvedBackend = (backend ?? 'vegalite') as RenderBackend; + if (!SUPPORTED_BACKENDS.includes(resolvedBackend as SupportedBackend)) { + throw new CompileError(`Unsupported backend "${resolvedBackend}". Choose one of: ${SUPPORTED_BACKENDS.join(', ')}`); + } + + let resolvedFormat: RenderFormat; + if (format) { + const f = format.toLowerCase() as RenderFormat; + if (f !== 'png' && f !== 'svg') throw new CompileError(`Unsupported format "${format}". Use "png" or "svg".`); + resolvedFormat = f; + } else { + resolvedFormat = resolvedBackend === 'chartjs' ? 'png' : 'svg'; + } + + if (resolvedBackend === 'chartjs' && resolvedFormat === 'svg') { + throw new CompileError('the chartjs backend supports png output only (no SVG engine); request format "png"'); + } + + if (scale !== undefined && (!Number.isFinite(scale) || scale < 0.5 || scale > 4)) { + throw new CompileError(`Invalid --scale ${scale}: must be between 0.5 and 4`); + } + + return { + kind: 'run', + options: { input, backend: resolvedBackend, format: resolvedFormat, output, scale, background }, + }; +} + +export interface CompileIo { + readStdin(): string; + stdout(data: string | Buffer): void; + stderr(line: string): void; +} + +const defaultCompileIo: CompileIo = { + readStdin: () => readFileSync(0, 'utf8'), + stdout: (data) => process.stdout.write(data), + stderr: (line) => process.stderr.write(line), +}; + +function readInputJson(inputPath: string, io: CompileIo): { json: unknown; cwd: string | undefined } { + let raw: string; + let cwd: string | undefined; + if (inputPath === '-') { + try { + raw = io.readStdin(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Failed to read stdin: ${msg}`, 1); + } + } else { + const abs = resolvePath(inputPath); + try { + raw = readFileSync(abs, 'utf8'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Failed to read input file "${inputPath}": ${msg}`, 1); + } + cwd = dirname(abs); + } + try { + return { json: JSON.parse(raw), cwd }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Invalid JSON in "${inputPath}": ${msg}`, 1); + } +} + +function resolveOutputPath(input: string, explicitOutput: string | undefined, format: RenderFormat): string | undefined { + if (explicitOutput) { + if (explicitOutput === '-') return undefined; + return resolvePath(explicitOutput); + } + if (input === '-') return undefined; + const absInput = resolvePath(input); + const dir = dirname(absInput); + const base = basename(absInput, extname(absInput)); + const outBase = base || 'chart'; + return resolvePath(dir, `${outBase}.${format}`); +} + +export async function runCompile(argv: string[], io: CompileIo = defaultCompileIo): Promise { + let parsed: CompileParseResult; + try { + parsed = parseCompileArgs(argv); + } catch (err) { + if (err instanceof CompileError) { + io.stderr(`${err.message}\n`); + return err.exitCode; + } + throw err; + } + + if (parsed.kind === 'help') { + io.stdout(COMPILE_HELP); + return 0; + } + if (parsed.kind === 'version') { + io.stdout(`${VERSION}\n`); + return 0; + } + + const opts = parsed.options; + let json: unknown; + let cwd: string | undefined; + try { + ({ json, cwd } = readInputJson(opts.input, io)); + } catch (err) { + if (err instanceof CompileError) { + io.stderr(`${err.message}\n`); + return err.exitCode; + } + throw err; + } + + const input = json as Record; + if (input == null || typeof input !== 'object' || !('chart_spec' in input) || !('data' in input)) { + io.stderr( + 'Input JSON must be a ChartAssemblyInput with at least { data, chart_spec }.\n' + + 'Example: { "data": { "values": [...] }, "chart_spec": { "chartType": "Bar Chart", "encodings": { "x": { "field": "a" }, "y": { "field": "b" } } } }\n', + ); + return 2; + } + + let result: Awaited>; + try { + result = await renderChart(input as any, opts.backend, { + format: opts.format, + scale: opts.scale, + background: opts.background, + cwd, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`Compile failed: ${msg}\n`); + return 1; + } + + for (const w of result.warnings) io.stderr(`warning [${w.code}]: ${w.message}\n`); + + const outPath = resolveOutputPath(opts.input, opts.output, opts.format); + try { + if (result.format === 'svg') { + const svg = result.svg ?? ''; + if (outPath) { + writeFileSync(outPath, svg, 'utf8'); + io.stderr(`Wrote ${result.backend} · ${result.format} · ${result.width}×${result.height}px → ${outPath}\n`); + } else { + io.stdout(svg); + } + } else { + const buffer = result.buffer!; + if (outPath) { + writeFileSync(outPath, buffer); + io.stderr(`Wrote ${result.backend} · ${result.format} · ${result.width}×${result.height}px → ${outPath}\n`); + } else { + io.stdout(buffer); + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`Failed to write output: ${msg}\n`); + return 1; + } + return 0; +} + + diff --git a/packages/flint-mcp/src/flint.ts b/packages/flint-mcp/src/flint.ts new file mode 100644 index 00000000..f62b39ee --- /dev/null +++ b/packages/flint-mcp/src/flint.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCompile } from './compile.js'; + +async function main(): Promise { + const raw = process.argv.slice(2); + if (raw.length === 0 || raw[0] === '-h' || raw[0] === '--help' || raw[0] === '-v' || raw[0] === '--version') { + const code = await runCompile(raw); + process.exit(code); + } + const argv = raw[0] === 'compile' ? raw.slice(1) : raw; + const code = await runCompile(argv); + process.exit(code); +} + +const isEntry = process.argv[1] !== undefined && (() => { + try { + return resolvePath(process.argv[1]) === resolvePath(fileURLToPath(import.meta.url)); + } catch { + return false; + } +})(); + +if (isEntry) { + main().catch((err) => { + process.stderr.write(`flint failed: ${err?.stack ?? err}\n`); + process.exit(1); + }); +} diff --git a/packages/flint-mcp/src/render/data-source.ts b/packages/flint-mcp/src/render/data-source.ts index 911eca5f..f680c339 100644 --- a/packages/flint-mcp/src/render/data-source.ts +++ b/packages/flint-mcp/src/render/data-source.ts @@ -20,6 +20,12 @@ export interface DataSourceOptions { maxDataFileBytes?: number; /** Row-count guard after loading inline or referenced data. */ maxDataRows?: number; + /** + * Base directory for resolving relative `data.url` paths. Defaults to the + * current working directory. The CLI passes the input file's directory so a + * hand-edited `chart.json` can reference `./data.csv` next to it. + */ + cwd?: string; } /** @@ -68,7 +74,7 @@ export function resolveDataSource( ); } - const filePath = resolveTrustedDataPath(data.url); + const filePath = resolveTrustedDataPath(data.url, options.cwd); const rows = readLocalRows(filePath, options); return { ...input, data: { values: rows } } as ChartAssemblyInput; } @@ -80,11 +86,11 @@ function isRemoteReference(rawUrl: string): boolean { /** * Resolve a local data.url. Any local file the agent can name is read — the host - * governs the agent's file access. Relative references resolve against the - * working directory. + * governs the agent's file access. Relative references resolve against `cwd` (or + * the working directory when not specified). */ -function resolveTrustedDataPath(rawUrl: string): string { - const candidatePaths = trustedReferenceToPaths(rawUrl.trim()); +function resolveTrustedDataPath(rawUrl: string, cwd?: string): string { + const candidatePaths = trustedReferenceToPaths(rawUrl.trim(), cwd); let lastError: unknown; for (const candidatePath of candidatePaths) { try { @@ -105,7 +111,7 @@ function resolveTrustedDataPath(rawUrl: string): string { ); } -function trustedReferenceToPaths(rawReference: string): string[] { +function trustedReferenceToPaths(rawReference: string, cwd?: string): string[] { if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(rawReference)) { const parsedUrl = new URL(rawReference); if (parsedUrl.protocol !== 'file:') { @@ -116,7 +122,9 @@ function trustedReferenceToPaths(rawReference: string): string[] { } return [fileURLToPath(parsedUrl)]; } - // Absolute paths are used as given; relative paths resolve against cwd. + // Absolute paths are used as given; relative paths resolve against cwd when + // given, or the process working directory otherwise. + if (cwd) return [resolvePath(cwd, rawReference)]; return [resolvePath(rawReference)]; } diff --git a/packages/flint-mcp/src/render/index.ts b/packages/flint-mcp/src/render/index.ts index f5ab60f4..439d4e5b 100644 --- a/packages/flint-mcp/src/render/index.ts +++ b/packages/flint-mcp/src/render/index.ts @@ -71,6 +71,7 @@ export async function renderChart( const { spec, warnings, width, height } = assembleForBackend(backend, input, { disableFileReference: options.disableFileReference, + cwd: options.cwd, }); // Extract sizing before stripping Flint's private annotation keys. Vega-Lite diff --git a/packages/flint-mcp/src/render/types.ts b/packages/flint-mcp/src/render/types.ts index 4955a230..35cfcbad 100644 --- a/packages/flint-mcp/src/render/types.ts +++ b/packages/flint-mcp/src/render/types.ts @@ -21,6 +21,8 @@ export interface RenderOptions { background?: string; /** When true, reject local `data.url` file references (inline rows only). */ disableFileReference?: boolean; + /** Base directory for resolving relative `data.url` paths. Defaults to cwd. */ + cwd?: string; } /** A rendered artifact plus the assembly warnings that produced it. */ diff --git a/packages/flint-mcp/src/server.ts b/packages/flint-mcp/src/server.ts index 8ea3f516..9d073b41 100644 --- a/packages/flint-mcp/src/server.ts +++ b/packages/flint-mcp/src/server.ts @@ -23,10 +23,8 @@ import { type AssemblyInputArgs, } from './tools/schemas.js'; -/** Package version, kept in lockstep with the npm release. */ -export const VERSION = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), -).version as string; +import { VERSION } from './version.js'; +export { VERSION }; export const AGENT_SKILL_RESOURCE_URI = 'flint://agent-skill'; export const THEME_SKILL_RESOURCE_URI = 'flint://theme-skill'; diff --git a/packages/flint-mcp/src/version.ts b/packages/flint-mcp/src/version.ts new file mode 100644 index 00000000..c58c8acf --- /dev/null +++ b/packages/flint-mcp/src/version.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; + +export const VERSION: string = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version as string; diff --git a/packages/flint-mcp/tests/compile.test.ts b/packages/flint-mcp/tests/compile.test.ts new file mode 100644 index 00000000..a7935e15 --- /dev/null +++ b/packages/flint-mcp/tests/compile.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runCompile, type CompileIo } from '../src/cli.js'; + +function chartInput(data: unknown): string { + return JSON.stringify({ + data, + semantic_types: { region: 'Category', revenue: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'Revenue by region', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + }, + }); +} + +const CSV = 'region,revenue\nNorth,120\nSouth,90\nEast,150\n'; + +interface IoHarness { + io: CompileIo; + stdout(): Buffer; + stdoutText(): string; + stderrText(): string; + setStdin(text: string): void; +} + +function makeIo(): IoHarness { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdinText = ''; + return { + io: { + readStdin: () => stdinText, + stdout: (data) => stdoutChunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)), + stderr: (line) => stderrChunks.push(Buffer.from(line)), + }, + stdout: () => Buffer.concat(stdoutChunks), + stdoutText: () => Buffer.concat(stdoutChunks).toString('utf8'), + stderrText: () => Buffer.concat(stderrChunks).toString('utf8'), + setStdin: (text) => { + stdinText = text; + }, + }; +} + +let root: string; + +beforeEach(() => { + // realpathSync so macOS /var → /private/var symlink doesn't surprise path + // resolution in stderr assertions. + root = realpathSync(mkdtempSync(join(tmpdir(), 'flint-cli-'))); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('compile: argument parsing', () => { + it('prints help and exits 0 for --help / -h', async () => { + for (const flag of ['--help', '-h']) { + const harness = makeIo(); + expect(await runCompile([flag], harness.io)).toBe(0); + expect(harness.stdoutText()).toContain('flint compile'); + } + }); + + it('help documents data.url resolution', async () => { + const harness = makeIo(); + await runCompile(['--help'], harness.io); + expect(harness.stdoutText()).toMatch(/data\.url/i); + expect(harness.stdoutText()).toMatch(/working directory when reading from stdin/i); + }); + + it('prints version and exits 0 for --version / -v', async () => { + for (const flag of ['--version', '-v']) { + const harness = makeIo(); + expect(await runCompile([flag], harness.io)).toBe(0); + expect(harness.stdoutText().trim()).toMatch(/^\d+\.\d+\.\d+/); + } + }); + + it('errors with exit 2 when input is missing', async () => { + const harness = makeIo(); + expect(await runCompile([], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Missing argument.'); + }); + + it('errors with exit 2 on unknown options', async () => { + const harness = makeIo(); + expect(await runCompile(['--bogus', 'x.json'], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Unknown compile option: --bogus'); + }); + + it('rejects a single-dash "-output" typo as an unknown option (exit 2)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile(['-output', 'x.svg', chartPath], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Unknown compile option: -output'); + }); + + it('still accepts the joined -o form', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + const outPath = join(root, 'joined.svg'); + const harness = makeIo(); + expect(await runCompile([`-o${outPath}`, chartPath], harness.io)).toBe(0); + expect(readFileSync(outPath, 'utf8')).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile([chartPath, '--backend', 'nope'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--format', 'gif'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--scale', '9'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--scale', 'abc'], harness.io)).toBe(2); + }); + + it('rejects chartjs with svg output (exit 2)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile([chartPath, '--backend', 'chartjs', '--format', 'svg'], harness.io)).toBe(2); + expect(harness.stderrText()).toMatch(/chartjs backend supports png output only/); + }); +}); + +describe('compile: input reading and exit codes', () => { + it('errors with exit 1 for a missing input file', async () => { + const harness = makeIo(); + expect(await runCompile([join(root, 'missing.json')], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Failed to read input file'); + }); + + it('errors with exit 1 for invalid JSON', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, '{not json'); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Invalid JSON'); + }); + + it('errors with exit 2 when the JSON is not a ChartAssemblyInput', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, JSON.stringify({ hello: 'world' })); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('ChartAssemblyInput'); + }); + + it('reports render failures with exit 1 (remote data.url)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ url: 'https://example.com/sales.csv' })); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Compile failed'); + }); +}); + +describe('compile: rendering and output', () => { + it('resolves relative data.url against the input file directory and writes .svg', async () => { + writeFileSync(join(root, 'sales.csv'), CSV); + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ url: 'sales.csv' })); + + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(0); + expect(harness.stdoutText()).toBe(''); // written to file, not stdout + + const svgPath = join(root, 'chart.svg'); + const svg = readFileSync(svgPath, 'utf8'); + expect(svg).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + + const harness = makeIo(); + expect(await runCompile([chartPath, '--format', 'png'], harness.io)).toBe(0); + const pngPath = join(root, 'chart.png'); + const bytes = readFileSync(pngPath); + expect(bytes.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + expect(harness.stderrText()).toContain('Wrote vegalite · png'); + }); + + it('writes to stdout with -o - or --output -', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + + for (const flag of ['-o', '--output']) { + const harness = makeIo(); + expect(await runCompile([chartPath, flag, '-'], harness.io)).toBe(0); + expect(harness.stdoutText()).toContain(' { + writeFileSync(join(root, 'sales.csv'), CSV); + const harness = makeIo(); + harness.setStdin(chartInput({ url: 'sales.csv' })); + + const previousCwd = process.cwd(); + try { + process.chdir(root); + expect(await runCompile(['-'], harness.io)).toBe(0); + } finally { + process.chdir(previousCwd); + } + expect(harness.stdoutText()).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + const outPath = join(root, 'custom', 'result.svg'); + mkdirSync(join(root, 'custom'), { recursive: true }); + + const harness = makeIo(); + expect(await runCompile([chartPath, '--output', outPath], harness.io)).toBe(0); + expect(readFileSync(outPath, 'utf8')).toContain(' Date: Mon, 31 Aug 2026 21:28:01 -0700 Subject: [PATCH 20/33] iterations on interactive design --- docs/theme-spec.md | 10 +- .../src/core/interaction-contracts.ts | 30 +- .../src/core/interaction-semantics.ts | 110 +- packages/flint-js/src/core/theme/ground.ts | 15 +- packages/flint-js/src/core/theme/types.ts | 9 + packages/flint-js/src/core/types.ts | 3 + packages/flint-js/src/interactive/README.md | 39 +- .../src/interactive/canvas-interaction.ts | 13 +- .../interactive/geometry/coordinate-space.ts | 22 +- packages/flint-js/src/interactive/guides.ts | 89 ++ packages/flint-js/src/interactive/index.ts | 33 +- .../flint-js/src/interactive/interactions.ts | 91 +- .../src/interactive/language/events.ts | 17 +- .../src/interactive/presets/angular-brush.ts | 2 +- .../src/interactive/presets/brush-zoom.ts | 37 + .../flint-js/src/interactive/presets/brush.ts | 7 +- .../src/interactive/presets/click-annotate.ts | 5 +- .../presets/click-group-highlight.ts | 5 +- .../interactive/presets/click-highlight.ts | 5 +- .../interactive/presets/context-activate.ts | 15 + .../flint-js/src/interactive/presets/index.ts | 6 + .../src/interactive/presets/inspect.ts | 25 + .../src/interactive/presets/lasso-select.ts | 16 + .../src/interactive/presets/legend-toggle.ts | 97 ++ .../src/interactive/presets/long-press.ts | 37 + .../src/interactive/presets/select.ts | 2 +- .../flint-js/src/interactive/presets/utils.ts | 17 +- packages/flint-js/src/interactive/triggers.ts | 130 +- packages/flint-js/src/interactive/types.ts | 14 + packages/flint-js/src/vegalite/assemble.ts | 24 + .../src/vegalite/interaction-provenance.ts | 19 +- .../src/vegalite/interactions/compile.ts | 317 +++- .../src/vegalite/interactions/contracts.ts | 16 + .../vegalite/interactions/gestures/region.ts | 283 +++- .../src/vegalite/interactions/hit-adapter.ts | 756 ++++++++- .../presentation/annotation-overlay.ts | 4 +- .../presentation/drag-reorder-overlay.ts | 54 +- .../presentation/focus-overlay.ts | 106 +- .../presentation/inspect-guide-overlay.ts | 119 ++ .../presentation/legend-range-overlay.ts | 58 + .../presentation/viewport-reset-control.ts | 68 + .../src/vegalite/interactions/runtime.ts | 824 +++++++++- .../src/vegalite/interactions/stores.ts | 13 +- packages/flint-js/src/vegalite/interactive.ts | 13 +- .../flint-js/src/vegalite/templates/area.ts | 21 +- .../src/vegalite/templates/bar-table.ts | 2 +- .../flint-js/src/vegalite/templates/bar.ts | 46 +- .../flint-js/src/vegalite/templates/bullet.ts | 3 +- .../src/vegalite/templates/calendar.ts | 14 +- .../vegalite/templates/connected-scatter.ts | 2 +- .../flint-js/src/vegalite/templates/gantt.ts | 2 +- .../flint-js/src/vegalite/templates/jitter.ts | 2 +- .../src/vegalite/templates/lollipop.ts | 2 +- .../flint-js/src/vegalite/templates/map.ts | 20 +- .../flint-js/src/vegalite/templates/pie.ts | 2 +- .../flint-js/src/vegalite/templates/radar.ts | 11 +- .../src/vegalite/templates/range-area.ts | 2 +- .../flint-js/src/vegalite/templates/rose.ts | 2 +- .../src/vegalite/templates/scatter.ts | 13 +- .../src/vegalite/templates/waterfall.ts | 9 +- packages/flint-js/src/vegalite/theme.ts | 10 +- .../flint-js/tests/calendar-vegalite.test.ts | 2 + packages/flint-js/tests/interactions.test.ts | 982 +++++++++++- .../tests/semantic-interactions.test.ts | 1373 ++++++++++++++++- site/src/playground/AnnotationLab.tsx | 28 +- site/src/playground/ClickFocusLab.tsx | 354 ++++- site/src/playground/click-focus-lab.css | 129 +- 67 files changed, 6068 insertions(+), 538 deletions(-) create mode 100644 packages/flint-js/src/interactive/guides.ts create mode 100644 packages/flint-js/src/interactive/presets/brush-zoom.ts create mode 100644 packages/flint-js/src/interactive/presets/context-activate.ts create mode 100644 packages/flint-js/src/interactive/presets/inspect.ts create mode 100644 packages/flint-js/src/interactive/presets/lasso-select.ts create mode 100644 packages/flint-js/src/interactive/presets/legend-toggle.ts create mode 100644 packages/flint-js/src/interactive/presets/long-press.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/legend-range-overlay.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/viewport-reset-control.ts diff --git a/docs/theme-spec.md b/docs/theme-spec.md index 47864ab9..d2850ef5 100644 --- a/docs/theme-spec.md +++ b/docs/theme-spec.md @@ -83,17 +83,17 @@ Selection boundaries are inferred from the theme unless explicitly stated. Their "interaction": { "selectionBoundary": { "color": "#b54a20", - "width": 1.5, - "opacity": 1, + "width": 1.25, + "opacity": 0.68, "haloColor": "#ffffff", - "haloWidth": 3, - "haloOpacity": 0.8 + "haloWidth": 2.5, + "haloOpacity": 0.35 } } } ``` -This block controls paint only. The ChartDef still decides whether a representation needs a boundary and the renderer still computes its contiguous geometry. +This block controls paint only. Set `haloWidth` to `0` to disable the contrast halo. The ChartDef still decides whether a representation needs a boundary and the renderer still computes its contiguous geometry. ### 3. Inherit and override diff --git a/packages/flint-js/src/core/interaction-contracts.ts b/packages/flint-js/src/core/interaction-contracts.ts index 37802289..70bbfd9e 100644 --- a/packages/flint-js/src/core/interaction-contracts.ts +++ b/packages/flint-js/src/core/interaction-contracts.ts @@ -1,21 +1,41 @@ export interface RenderHit { + /** Backend render datum used while resolving physical hits; not semantic identity. */ datum: Record; endDatum?: Record; + /** All renderer datums in the same line/area path, when available. */ + pathData?: readonly Record[]; source: 'mark' | 'legend-item'; markType?: string; markName?: string; layerRole?: string; } +/** + * Backend-independent meaning and provenance of one resolved chart element. + * Consumers should reason from `value` and `records`, never from renderer metadata. + * Exact render lookup belongs to the backend and may map one element to many primitives. + */ export interface SemanticElement { - key: Record; - value?: Record; + /** Values represented by the mark's channels, or by a semantic control such as a legend item. */ + value: Record; + /** Contributing input records when provenance is available; zero or many may support one value. */ records?: readonly Record[]; } +export type LegendDomain = + | { kind: 'value'; value: unknown } + | { kind: 'interval'; start?: number; end?: number }; + +export interface LegendTargetValue extends Record { + channel?: string; + field?: string; + domain: LegendDomain; +} + +/** A semantic subject: its visual role plus represented values and provenance. */ export interface SemanticTarget { visual: { - kind: 'mark' | 'path' | 'region' | 'widget' | 'handle'; + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle' | 'legend'; role: string; }; elements: readonly SemanticElement[]; @@ -25,8 +45,7 @@ export interface SemanticResolveEvent { gesture: 'click' | 'hover' | 'rectangle' | 'angular'; role: string; hits: readonly RenderHit[]; - legendValue?: unknown; - legendField?: string; + legend?: LegendTargetValue; } export interface SemanticResolveContext { @@ -161,6 +180,7 @@ export interface InteractionContext { ) => NavigationUpdate | null; readonly categoryField?: string; readonly seriesField?: string; + readonly legendDomains?: Readonly>; readonly categoryAxis?: 'x' | 'y'; readonly categoryOrder?: readonly unknown[]; readonly reorderAxes?: readonly { diff --git a/packages/flint-js/src/core/interaction-semantics.ts b/packages/flint-js/src/core/interaction-semantics.ts index a1b993fc..cc83a31b 100644 --- a/packages/flint-js/src/core/interaction-semantics.ts +++ b/packages/flint-js/src/core/interaction-semantics.ts @@ -1,5 +1,6 @@ import type { RenderHit, + LegendTargetValue, SemanticElement, SemanticResolveContext, SemanticResolveEvent, @@ -9,6 +10,8 @@ import type { export type { ChartInteractionResolver, RenderHit, + LegendDomain, + LegendTargetValue, SemanticElement, SemanticResolveContext, SemanticResolveEvent, @@ -29,6 +32,74 @@ export function semanticVisualFamily(role: string | undefined): SemanticVisualFa export const MUTED_HOVER_STROKE = 'rgba(71, 82, 92, 0.58)'; export const MUTED_HOVER_FILL = '#eef1f3'; +const renderKeysByElement = new WeakMap(); + +export function semanticElementRenderKeys(element: SemanticElement): readonly string[] { + return renderKeysByElement.get(element) ?? []; +} + +export function associateSemanticElementRenderKeys( + element: SemanticElement, + renderKeys: readonly string[], +): SemanticElement { + renderKeysByElement.set(element, [...new Set(renderKeys)]); + return element; +} + +function withoutRenderIdentity( + datum: Record, + keyField: string, +): Record { + return Object.fromEntries(Object.entries(datum).filter(([field]) => + field !== keyField && !field.startsWith('__flint_interaction_') && field !== '_vgsid_')); +} + +export function sourceRecordsForRenderedRecords( + renderedRecords: readonly Record[], + sourceRecords: readonly Record[], + provenanceFields: readonly string[], + temporalFields: readonly string[] = [], + rangeProvenance: readonly { + field: string; + startField: string; + endField: string; + }[] = [], +): readonly Record[] { + const temporal = new Set(temporalFields); + const temporalValue = (value: unknown): number | undefined => { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') { + return Number.isInteger(value) && value >= 1000 && value <= 9999 + ? Date.UTC(value, 0, 1) + : value; + } + if (typeof value !== 'string') return undefined; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : parsed; + }; + const sameValue = (field: string, left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) return true; + if (!temporal.has(field)) return false; + const leftTime = temporalValue(left); + const rightTime = temporalValue(right); + return leftTime !== undefined && rightTime !== undefined && leftTime === rightTime; + }; + return sourceRecords.filter((sourceRecord) => renderedRecords.some((renderedRecord) => { + const sourceFields = provenanceFields.filter((field) => field in sourceRecord); + const equalityMatches = provenanceFields.length === 0 || (sourceFields.length > 0 + && sourceFields.every((field) => + field in renderedRecord && sameValue(field, sourceRecord[field], renderedRecord[field]))); + if (!equalityMatches) return false; + return rangeProvenance.every(({ field, startField, endField }) => { + const value = sourceRecord[field]; + const start = renderedRecord[startField]; + const end = renderedRecord[endField]; + if (typeof value !== 'number' || typeof start !== 'number' || typeof end !== 'number') return false; + return value >= start && value < end; + }); + })); +} + export function elementsFromHits(hits: readonly RenderHit[], keyField: string): SemanticElement[] { const seen = new Set(); const elements: SemanticElement[] = []; @@ -36,11 +107,12 @@ export function elementsFromHits(hits: readonly RenderHit[], keyField: string): const key = hit.datum[keyField]; if (typeof key !== 'string' || seen.has(key)) continue; seen.add(key); - elements.push({ - key: { [keyField]: key }, - value: hit.datum, - records: hit.endDatum ? [hit.datum, hit.endDatum] : [hit.datum], - }); + const records = (hit.endDatum ? [hit.datum, hit.endDatum] : [hit.datum]) + .map((datum) => withoutRenderIdentity(datum, keyField)); + elements.push(associateSemanticElementRenderKeys({ + value: withoutRenderIdentity(hit.datum, keyField), + records, + }, [key])); } return elements; } @@ -71,10 +143,30 @@ export function legendMatchedHits( context: SemanticResolveContext, field: string, ): RenderHit[] { - if (event.legendValue === undefined) return []; + const domain = event.legend?.domain; + if (!domain) return []; + const matches = (datum: Record): boolean => { + if (domain.kind === 'value') return datum[field] === domain.value; + const rawValue = datum[field]; + const value = rawValue instanceof Date ? rawValue.getTime() : rawValue; + return typeof value === 'number' + && (domain.start === undefined || value >= domain.start) + && (domain.end === undefined || value < domain.end); + }; return context.allHits - .filter((hit) => hit.datum[field] === event.legendValue) - .map((hit) => ({ ...hit, source: 'legend-item' })); + .filter((hit) => matches(hit.datum)) + .flatMap((hit) => { + const pathData = (hit.markType === 'line' || hit.markType === 'area') + && Array.isArray(hit.pathData) + ? hit.pathData.filter(matches) + : []; + return pathData.length > 0 + ? [ + ...(hit.markType === 'line' ? [{ ...hit, source: 'legend-item' as const }] : []), + ...pathData.map((datum) => ({ ...hit, datum, source: 'legend-item' as const })), + ] + : [{ ...hit, source: 'legend-item' as const }]; + }); } export function targetFromHits( @@ -91,7 +183,7 @@ export function resolveSeriesTarget( context: SemanticResolveContext, seriesField: string | undefined, ): SemanticTarget | null { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts index 18affeb2..85c5c282 100644 --- a/packages/flint-js/src/core/theme/ground.ts +++ b/packages/flint-js/src/core/theme/ground.ts @@ -1699,7 +1699,7 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe ? Math.max(densityPadding, Math.round((axisLabelText.fontSize ?? 10) * 1.5)) : densityPadding; const selectionBoundary = theme.interaction?.selectionBoundary; - const selectionBoundaryWidth = Math.max(0, selectionBoundary?.width ?? 1.5); + const selectionBoundaryWidth = Math.max(0, selectionBoundary?.width ?? 1.25); return { themeId: theme.id ?? 'flint', @@ -1753,13 +1753,20 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe : undefined, marks, interaction: { + continuousColorFocus: { + mutedFill: mixHex(plot ?? canvas, text.primary, 0.08, '#eeeeee'), + boundaryWidth: Math.min(selectionBoundaryWidth, 0.8), + boundaryOpacity: 0.42, + haloWidth: Math.min(Math.max(0, selectionBoundary?.haloWidth ?? 2.5), 1.25), + haloOpacity: 0.18, + }, selectionBoundary: { color: selectionBoundary?.color ?? theme.ink?.accent ?? text.primary, width: selectionBoundaryWidth, - opacity: clamp(selectionBoundary?.opacity ?? 1, 0, 1), + opacity: clamp(selectionBoundary?.opacity ?? 0.68, 0, 1), haloColor: selectionBoundary?.haloColor ?? plot ?? canvas, - haloWidth: Math.max(selectionBoundaryWidth, selectionBoundary?.haloWidth ?? 3), - haloOpacity: clamp(selectionBoundary?.haloOpacity ?? 0.8, 0, 1), + haloWidth: Math.max(0, selectionBoundary?.haloWidth ?? 2.5), + haloOpacity: clamp(selectionBoundary?.haloOpacity ?? 0.35, 0, 1), }, }, facets, diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts index 64ff9b8a..102c0777 100644 --- a/packages/flint-js/src/core/theme/types.ts +++ b/packages/flint-js/src/core/theme/types.ts @@ -603,11 +603,13 @@ export interface ThemeCompileDefaults extends Partial { export interface ThemeInteraction { tooltipFormat?: string; + /** Paint for the exterior of a contiguous semantic selection. */ selectionBoundary?: { color?: string; width?: number; opacity?: number; haloColor?: string; + /** Set to zero to disable the contrast halo. */ haloWidth?: number; haloOpacity?: number; }; @@ -929,6 +931,13 @@ export interface DesignDecisions { }; marks: ResolvedMarks; interaction: { + continuousColorFocus: { + mutedFill: string; + boundaryWidth: number; + boundaryOpacity: number; + haloWidth: number; + haloOpacity: number; + }; selectionBoundary: { color: string; width: number; diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 9b8b2556..1745dc41 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -930,6 +930,9 @@ export interface ChartTemplateDef { resolvedEncodings: Readonly>; }) => { fields: string[]; + provenanceFields?: readonly string[]; + temporalProvenanceFields?: readonly string[]; + rangeProvenance?: readonly { field: string; startField: string; endField: string }[]; categoryField?: string; seriesField?: string; resolveGroupValue?: (element: import('./interaction-contracts').SemanticElement) => unknown; diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index 532035cf..f730e458 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -282,7 +282,7 @@ The interaction key and role answer different questions: | Metadata | Question | Used for | | --- | --- | --- | -| `__flint_interaction_key` | Which semantic data identity does this item represent? | Constructing and matching `SemanticElement.key` | +| `__flint_interaction_key` | Which rendered primitive is this? | Private stable lookup for backend presentation updates | | `__flint_interaction_role` | Which representation of that identity was hit? | Choosing representation-aware resolution behavior | ### ChartDef Resolution @@ -291,28 +291,30 @@ The normalized role and `RenderHit[]` are passed to the owning ChartDef resolver For a direct mark, resolution commonly maps each hit key to one `SemanticElement`. A representation can require different resolution even when it refers to related data. For example, clicking one rose arc resolves that arc, while clicking a `text-label` for January can resolve the January label identity to every arc represented by that aggregate label. -The role is not part of semantic identity and is not used to match forward updates. It is resolution context. The key establishes identity; the role lets the ChartDef interpret the physical representation that exposed it. Normal marks can use the default `mark` role, while representations such as `text-label` require an explicit role when their backward mapping differs. +The role is resolution context rather than semantic identity. Private render keys remain in a backend sidecar so the ChartDef and applications do not need to carry renderer identity. Normal marks can use the default `mark` role, while representations such as `text-label` require an explicit role when their backward mapping differs. The result contains no SVG node or Vega scenegraph item: ```ts interface SemanticTarget { visual: { - kind: 'mark' | 'path' | 'region' | 'widget' | 'handle'; + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle' | 'legend'; role: string; }; elements: readonly SemanticElement[]; } interface SemanticElement { - key: Record; - value?: Record; + value: Record; records?: readonly Record[]; } ``` -`key` is stable semantic identity, `value` is the represented transformed or aggregated -chart value, and `records` are contributing source rows when available and bounded. +`value` is the represented transformed or aggregated chart value. It may contain derived +semantics such as stack or path endpoints. `records` are authored source rows only when +the runtime can prove their lineage; they are omitted rather than replaced with renderer +tuples when provenance is unavailable. Exact render identity is backend-private and can +map one semantic element to one or many rendered primitives. After this boundary, presets and applications operate on semantic elements. They do not inspect renderer geometry to rediscover meaning. @@ -400,7 +402,7 @@ interface CanvasInteractionEvent { ``` `action` reports the normalized semantic action, such as `click-element`, -`click-legend`, `brush-x`, `pan-viewport`, or `inspect-nearest`. `geometry.plot` reports +`click-legend`, `brush-x`, `pan-viewport`, or `inspect-xy`. `geometry.plot` reports renderer-neutral canvas geometry; optional `geometry.domain` reports scale-inverted values. `target` reports the semantic object and data provenance. Drag-and-drop also uses `dropTarget` for its destination. @@ -450,6 +452,27 @@ Canvas definitions have two declarative halves: The backend mount reads `eventSource`; it does not infer a gesture from pointer motion. It installs the required native listeners, supplies renderer coordinates and hit testing, and runs the recognizer requested by the interaction. This keeps an identical drag stream deterministic and author-controlled. +Transient gesture guides belong to that mount lifecycle, not to `ChartUpdate`. They visualize +the gesture's current geometry and clear on cancel, leave, or destroy. Disabling or styling a +guide never changes acquisition or the emitted semantic event: + +```ts +inspect({ + mode: 'xy', + guide: { style: { color: '#47525c', opacity: 0.5, width: 1 } }, +}); + +brushX({ + guide: { style: { fill: '#2563eb', fillOpacity: 0.1 } }, +}); + +lassoSelect({ guide: false }); +``` + +Inspect lines, Cartesian regions, angular sectors, and lasso paths use the shared +renderer-neutral gesture-guide styles. Retained guides such as reference lines instead belong +to chart presentation state and may be created by effects through chart updates. + Chart-specific action processing belongs in the handler. For example, ranged-dot region targets are expanded to complete category units before producing a `set-presentation` update. Direct ranged-dot clicks already resolve to the complete dumbbell in the owning ChartDef. diff --git a/packages/flint-js/src/interactive/canvas-interaction.ts b/packages/flint-js/src/interactive/canvas-interaction.ts index 1bc7c1d6..49d0f399 100644 --- a/packages/flint-js/src/interactive/canvas-interaction.ts +++ b/packages/flint-js/src/interactive/canvas-interaction.ts @@ -13,11 +13,22 @@ function elementAction( source: InteractionEventSource, target: SemanticTarget | null, ): CanvasInteractionAction { + if (source.gesture === 'keyboard') return 'focus-element'; + const family = semanticVisualFamily(target?.visual.role); + if (source.gesture === 'context') return `context-${family}` as CanvasInteractionAction; + if (source.gesture === 'long-press') return `long-press-${family}` as CanvasInteractionAction; + if (source.gesture === 'double') return `double-activate-${family}` as CanvasInteractionAction; + if (source.gesture === 'inspect') { + return source.inspect === 'x' ? 'inspect-x' + : source.inspect === 'y' ? 'inspect-y' + : 'inspect-xy'; + } const gesture = source.gesture === 'hover' ? 'hover' : 'click'; - return `${gesture}-${semanticVisualFamily(target?.visual.role)}` as CanvasInteractionAction; + return `${gesture}-${family}` as CanvasInteractionAction; } function regionAction(event: SemanticInteractionEvent): CanvasInteractionAction { + if (event.region && 'points' in event.region) return 'select-lasso'; if (event.axis === 'x') return 'brush-x'; if (event.axis === 'y') return 'brush-y'; if (event.axis === 'angle') return 'brush-angle'; diff --git a/packages/flint-js/src/interactive/geometry/coordinate-space.ts b/packages/flint-js/src/interactive/geometry/coordinate-space.ts index 542bb85d..c064cf52 100644 --- a/packages/flint-js/src/interactive/geometry/coordinate-space.ts +++ b/packages/flint-js/src/interactive/geometry/coordinate-space.ts @@ -20,12 +20,13 @@ function clamp(value: number, min: number, max: number): number { * would otherwise report an origin that disagrees with `logicalWidth`. */ export function rendererPlotOrigin( - matrix: { a: number; e: number; f: number } | null | undefined, + matrix: { a: number; d?: number; e: number; f: number } | null | undefined, viewOrigin: PlotPoint, ): PlotPoint { if (!matrix) return viewOrigin; - const scale = matrix.a || 1; - return { x: matrix.e / scale, y: matrix.f / scale }; + const scaleX = matrix.a || 1; + const scaleY = matrix.d || scaleX; + return { x: matrix.e / scaleX, y: matrix.f / scaleY }; } export function interactionModifiers(event: MouseEvent | PointerEvent): InteractionModifiers { @@ -33,11 +34,18 @@ export function interactionModifiers(event: MouseEvent | PointerEvent): Interact } export function clientToPlotPoint(client: PlotPoint, space: RendererCoordinateSpace): PlotPoint { - const rendererX = (client.x - space.rect.left) * space.logicalWidth / space.rect.width; - const rendererY = (client.y - space.rect.top) * space.logicalHeight / space.rect.height; + const renderer = clientToRendererPoint(client, space); return { - x: clamp(rendererX - space.originX, 0, space.plotWidth), - y: clamp(rendererY - space.originY, 0, space.plotHeight), + x: clamp(renderer.x - space.originX, 0, space.plotWidth), + y: clamp(renderer.y - space.originY, 0, space.plotHeight), + }; +} + +/** Client position in the full renderer, including plot margins and legends. */ +export function clientToRendererPoint(client: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + return { + x: (client.x - space.rect.left) * space.logicalWidth / space.rect.width, + y: (client.y - space.rect.top) * space.logicalHeight / space.rect.height, }; } diff --git a/packages/flint-js/src/interactive/guides.ts b/packages/flint-js/src/interactive/guides.ts new file mode 100644 index 00000000..e70c9624 --- /dev/null +++ b/packages/flint-js/src/interactive/guides.ts @@ -0,0 +1,89 @@ +/** Shared lifecycle for transient visuals owned by an active gesture. */ +export interface GestureGuideController { + clear(): void; + destroy(): void; +} + +/** Renderer-neutral styling for a line-based gesture guide. */ +export interface LineGestureGuideStyle { + color: string; + opacity: number; + width: number; +} + +export interface InspectGestureGuideStyle extends LineGestureGuideStyle { + fillOpacity: number; +} + +export interface AreaGestureGuideStyle { + fill: string; + fillOpacity: number; + stroke: string; + strokeOpacity: number; + strokeWidth: number; +} + +export interface GestureGuideOptions { + visible?: boolean; + style?: Partial; +} + +export type InspectGuideOptions = GestureGuideOptions; +export type RegionGuideOptions = GestureGuideOptions; + +export const DEFAULT_INSPECT_GUIDE_STYLE: Readonly = Object.freeze({ + color: '#47525c', + opacity: 0.46, + width: 1, + fillOpacity: 0.07, +}); + +export const DEFAULT_REGION_GUIDE_STYLE: Readonly = Object.freeze({ + fill: '#2563eb', + fillOpacity: 0.12, + stroke: '#2563eb', + strokeOpacity: 0.85, + strokeWidth: 1, +}); + +export function normalizeInspectGuideOptions( + options: InspectGuideOptions | false | undefined, +): { visible: boolean; style: InspectGestureGuideStyle } { + const style = options === false ? undefined : options?.style; + return { + visible: options !== false && options?.visible !== false, + style: { + color: style?.color ?? DEFAULT_INSPECT_GUIDE_STYLE.color, + opacity: Number.isFinite(style?.opacity) + ? Math.min(1, Math.max(0, style!.opacity!)) + : DEFAULT_INSPECT_GUIDE_STYLE.opacity, + width: Number.isFinite(style?.width) && style!.width! > 0 + ? style!.width! + : DEFAULT_INSPECT_GUIDE_STYLE.width, + fillOpacity: Number.isFinite(style?.fillOpacity) + ? Math.min(1, Math.max(0, style!.fillOpacity!)) + : DEFAULT_INSPECT_GUIDE_STYLE.fillOpacity, + }, + }; +} + +export function normalizeRegionGuideOptions( + options: RegionGuideOptions | false | undefined, +): { visible: boolean; style: AreaGestureGuideStyle } { + const style = options === false ? undefined : options?.style; + const unit = (value: number | undefined, fallback: number): number => Number.isFinite(value) + ? Math.min(1, Math.max(0, value!)) + : fallback; + return { + visible: options !== false && options?.visible !== false, + style: { + fill: style?.fill ?? DEFAULT_REGION_GUIDE_STYLE.fill, + fillOpacity: unit(style?.fillOpacity, DEFAULT_REGION_GUIDE_STYLE.fillOpacity), + stroke: style?.stroke ?? DEFAULT_REGION_GUIDE_STYLE.stroke, + strokeOpacity: unit(style?.strokeOpacity, DEFAULT_REGION_GUIDE_STYLE.strokeOpacity), + strokeWidth: Number.isFinite(style?.strokeWidth) && style!.strokeWidth! > 0 + ? style!.strokeWidth! + : DEFAULT_REGION_GUIDE_STYLE.strokeWidth, + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 2dbec8c6..01181d28 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -4,18 +4,28 @@ import { mountInteractiveChartSurface } from './surface'; import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types'; export type { + AssistedTargetingOptions, BuildInteractiveChartOptions, ChartUpdateApplyOptions, ChartUpdateComposition, InteractiveBackend, InteractiveChartSurface, InteractiveChartSurfaceOptions, + InteractionDismissPolicy, InteractiveRenderer, InteractiveRendererAdapter, ViewportChannel, ViewportGeometry, ViewportState, } from './types'; +export type { + GestureGuideController, + GestureGuideOptions, + AreaGestureGuideStyle, + InspectGuideOptions, + LineGestureGuideStyle, + RegionGuideOptions, +} from './guides'; export type { AnnotationCandidate, AnnotationConnection, @@ -24,6 +34,7 @@ export type { ChartUpdateOp, ChartUpdatePresenter, BrushOptions, + BrushZoomOptions, AngularBrushOptions, ClickAnnotateOptions, ClickGroupHighlightOptions, @@ -36,6 +47,8 @@ export type { CanvasInteractionDef, ExternalInteractionDef, InteractionModifiers, + InspectOptions, + LassoSelectOptions, NavigateOptions, NavigationAxes, NavigationDomainGuard, @@ -70,13 +83,20 @@ export type { SemanticTargetSelector, } from './language/updates'; export { matchesSemanticTargetSelector } from './language/updates'; -export { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, dragReorder, externalInteraction, isCanvasInteraction, isExternalInteraction, navigate, select } from './interactions'; +export { brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; export type { InteractionEventSource } from './triggers'; export { axisBrushTrigger, angularBrushTrigger, + brushZoomTrigger, clickTrigger, + contextTrigger, + doubleActivateTrigger, hoverTrigger, + inspectTrigger, + keyboardTrigger, + lassoTrigger, + longPressTrigger, navigationTrigger, rectangleTrigger, xBrushTrigger, @@ -84,6 +104,9 @@ export { } from './triggers'; export { clampViewportStart, mountInteractiveChartSurface } from './surface'; +/** Snap radius in renderer units when assisted targeting is enabled without a distance. */ +const DEFAULT_ASSIST_DISTANCE = 12; + export function buildInteractiveChart( container: HTMLElement, input: ChartAssemblyInput, @@ -91,7 +114,7 @@ export function buildInteractiveChart( ): InteractiveChartSurface { const { backend, renderer, expressionInterpreter, background, - className, ariaLabel, chartId, updates, + className, ariaLabel, chartId, updates, assistedTargeting, keyboardTargeting, dismiss, } = options; const interactions = normalizeInteractions(options.interactions); const canvasInteractions = interactions.filter(isCanvasInteraction); @@ -122,6 +145,12 @@ export function buildInteractiveChart( || (updates?.length ?? 0) > 0, expressionInterpreter, background, + assistDistance: assistedTargeting + ? (typeof assistedTargeting === 'object' ? assistedTargeting.maxDistance : undefined) + ?? DEFAULT_ASSIST_DISTANCE + : 0, + keyboardTargeting, + dismiss, }).mount(chartContainer, chartInput); }, }, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 3489676c..a1e091cf 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -3,17 +3,27 @@ import type { InteractionContext, NavigationDomainGuard, SemanticElement, + SemanticTargetSelector, } from '../core/interaction-contracts'; import type { InteractionEventSource } from './triggers'; +import type { InspectMode } from './triggers'; +import type { InspectGuideOptions, RegionGuideOptions } from './guides'; import type { NavigationAxes, } from './language/events'; import { createBrushInteraction, + createBrushZoomInteraction, createAngularBrushInteraction, createClickAnnotateInteraction, createClickGroupHighlightInteraction, createClickHighlightInteraction, + createContextActivateInteraction, + createDoubleActivateInteraction, + createInspectInteraction, + createLongPressInteraction, + createLassoSelectInteraction, + createLegendToggleInteraction, createSelectInteraction, createNavigateInteraction, createDragReorderInteraction, @@ -81,6 +91,8 @@ export interface CanvasInteractionDef { readonly id: string; readonly eventSource: InteractionEventSource; readonly navigationDomainGuard?: NavigationDomainGuard; + /** Claims legend activations exclusively, so a legend click never also reads as an element click. */ + readonly claimsLegendActivation?: boolean; handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdate | null; } @@ -110,13 +122,17 @@ export function isExternalInteraction(interaction: InteractionDef): interaction export interface ClickHighlightOptions { id?: string; dimOpacity?: number; + /** Whether legend activation focuses the represented series. Defaults to true. */ + legend?: boolean; } export interface ClickGroupHighlightOptions extends ClickHighlightOptions { groupBy?: string | ((element: SemanticElement, context: InteractionContext) => unknown); } -export interface ClickAnnotateOptions extends ClickHighlightOptions { +export interface ClickAnnotateOptions { + id?: string; + dimOpacity?: number; format?: (element: SemanticElement, context: InteractionContext) => string; } @@ -124,13 +140,56 @@ export interface SelectOptions { id?: string; match?: 'intersect' | 'contain'; dimOpacity?: number; + /** Transient region shown during the gesture; false disables visual feedback. */ + guide?: RegionGuideOptions | false; } export interface BrushOptions extends SelectOptions { mode?: 'ephemeral' | 'stateful'; } -export type AngularBrushOptions = SelectOptions; +export type AngularBrushOptions = SelectOptions & { mode?: 'ephemeral' | 'stateful' }; + +export type LassoSelectOptions = SelectOptions; + +export interface LegendToggleOptions { + id?: string; + mutedOpacity?: number; +} + +export interface ContextActivateOptions { + id?: string; +} + +export interface InspectOptions { + id?: string; + mode?: InspectMode; + /** Ordered modes cycled by wheel or context-menu gestures; mode is included automatically. */ + cycle?: readonly InspectMode[]; + /** Hit tolerance as a plot-size fraction. Defaults to 0.02 for XY and 0.01 otherwise. */ + tolerance?: number; + /** Transient guide shown while inspecting; false disables visual feedback. */ + guide?: InspectGuideOptions | false; + selector?: SemanticTargetSelector; + dimOpacity?: number; +} + +export interface BrushZoomOptions { + id?: string; + axes?: 'x' | 'y' | 'xy'; + guide?: RegionGuideOptions | false; +} + +export interface LongPressOptions { + id?: string; + holdMs?: number; + dimOpacity?: number; +} + +export interface DoubleActivateOptions { + id?: string; + dimOpacity?: number; +} export interface NavigateOptions { id?: string; @@ -161,6 +220,34 @@ export function select(options: SelectOptions = {}): CanvasInteractionDef { return createSelectInteraction(options); } +export function lassoSelect(options: LassoSelectOptions = {}): CanvasInteractionDef { + return createLassoSelectInteraction(options); +} + +export function legendToggle(options: LegendToggleOptions = {}): CanvasInteractionDef { + return createLegendToggleInteraction(options); +} + +export function contextActivate(options: ContextActivateOptions = {}): CanvasInteractionDef { + return createContextActivateInteraction(options); +} + +export function inspect(options: InspectOptions = {}): CanvasInteractionDef { + return createInspectInteraction(options); +} + +export function brushZoom(options: BrushZoomOptions = {}): CanvasInteractionDef { + return createBrushZoomInteraction(options); +} + +export function longPress(options: LongPressOptions = {}): CanvasInteractionDef { + return createLongPressInteraction(options); +} + +export function doubleActivate(options: DoubleActivateOptions = {}): CanvasInteractionDef { + return createDoubleActivateInteraction(options); +} + export function brushX(options: BrushOptions = {}): CanvasInteractionDef { return createBrushInteraction('x', options); } diff --git a/packages/flint-js/src/interactive/language/events.ts b/packages/flint-js/src/interactive/language/events.ts index 07c33a6c..eaa36fb6 100644 --- a/packages/flint-js/src/interactive/language/events.ts +++ b/packages/flint-js/src/interactive/language/events.ts @@ -92,6 +92,21 @@ export type CanvasInteractionAction = | 'click-facet' | 'hover-annotation' | 'click-annotation' + | 'context-element' + | 'long-press-element' + | 'double-activate-element' + | 'context-legend' + | 'long-press-legend' + | 'double-activate-legend' + | 'context-axis' + | 'long-press-axis' + | 'double-activate-axis' + | 'context-facet' + | 'long-press-facet' + | 'double-activate-facet' + | 'context-annotation' + | 'long-press-annotation' + | 'double-activate-annotation' | 'drag-element' | 'select-region' | 'brush-x' @@ -102,7 +117,7 @@ export type CanvasInteractionAction = | 'reset-viewport' | 'inspect-x' | 'inspect-y' - | 'inspect-nearest' + | 'inspect-xy' | 'select-lasso' | 'focus-element' | 'activate-element'; diff --git a/packages/flint-js/src/interactive/presets/angular-brush.ts b/packages/flint-js/src/interactive/presets/angular-brush.ts index 884ebfb3..673b5ad3 100644 --- a/packages/flint-js/src/interactive/presets/angular-brush.ts +++ b/packages/flint-js/src/interactive/presets/angular-brush.ts @@ -7,7 +7,7 @@ export function createAngularBrushInteraction(options: AngularBrushOptions = {}) const dimOpacity = normalizedOpacity(options.dimOpacity); return { id, - eventSource: angularBrushTrigger(options.match ?? 'intersect'), + eventSource: angularBrushTrigger(options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), handle(event, context) { if (event.action !== 'brush-angle' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/brush-zoom.ts b/packages/flint-js/src/interactive/presets/brush-zoom.ts new file mode 100644 index 00000000..ce8c6b2c --- /dev/null +++ b/packages/flint-js/src/interactive/presets/brush-zoom.ts @@ -0,0 +1,37 @@ +import type { BrushZoomOptions, CanvasInteractionDef, UpdateDomain } from '../interactions'; +import { brushZoomTrigger } from '../triggers'; + +const REGION_ACTIONS = new Set(['select-region', 'brush-x', 'brush-y']); + +/** Reduces the viewport to the brushed region, as an exact absolute domain. */ +export function createBrushZoomInteraction(options: BrushZoomOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'brush-zoom'; + const axes = options.axes ?? 'xy'; + return { + id, + eventSource: brushZoomTrigger(axes, options.guide), + handle(event) { + if (!REGION_ACTIONS.has(event.action) || event.phase !== 'commit' || event.operation === 'clear') return null; + const domain = event.geometry.domain; + if (!domain) return null; + const value: { x?: UpdateDomain; y?: UpdateDomain } = {}; + for (const axis of ['x', 'y'] as const) { + if (axes !== 'xy' && axes !== axis) continue; + const coordinate = domain[axis]; + if (coordinate?.kind !== 'interval') continue; + if (Object.is(coordinate.start, coordinate.end)) continue; + value[axis] = [coordinate.start, coordinate.end]; + } + const resolved = (['x', 'y'] as const).filter((axis) => value[axis]); + if (resolved.length === 0) return null; + return { + id, + ops: [{ + op: 'set-viewport', + axes: resolved.length === 2 ? 'xy' : resolved[0], + value, + }], + }; + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/brush.ts b/packages/flint-js/src/interactive/presets/brush.ts index 3acbf2c9..e1935fd1 100644 --- a/packages/flint-js/src/interactive/presets/brush.ts +++ b/packages/flint-js/src/interactive/presets/brush.ts @@ -9,9 +9,12 @@ export function createBrushInteraction(axis: 'x' | 'y', options: BrushOptions = return { id, axis, - eventSource: axisBrushTrigger(axis, options.match ?? 'intersect', options.mode ?? 'ephemeral'), + eventSource: axisBrushTrigger(axis, options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), handle(event, context) { - if (event.action !== `brush-${axis}` || event.phase === 'start' || event.phase === 'cancel') return null; + const acceptsAngular = axis === 'x' && event.action === 'brush-angle'; + if ((event.action !== `brush-${axis}` && !acceptsAngular) + || event.phase === 'start' + || event.phase === 'cancel') return null; const target = expandRangedDotTarget(event.target, context); return emphasisUpdate(id, event, target, dimOpacity, context); }, diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts index d56655fb..104eae93 100644 --- a/packages/flint-js/src/interactive/presets/click-annotate.ts +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -2,7 +2,7 @@ import type { ClickAnnotateOptions, CanvasInteractionDef, } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from './utils'; +import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; export function createClickAnnotateInteraction(options: ClickAnnotateOptions = {}): CanvasInteractionDef { @@ -12,7 +12,8 @@ export function createClickAnnotateInteraction(options: ClickAnnotateOptions = { id, eventSource: clickTrigger, handle(event, context) { - if (!event.action.startsWith('click-') || event.phase !== 'commit') return null; + if (!isActivationAction(event.action) || event.phase !== 'commit') return null; + if (event.target?.visual.role === 'legend-item') return null; if (!event.target) { return { id, diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts index a8b82146..8facf139 100644 --- a/packages/flint-js/src/interactive/presets/click-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -5,7 +5,7 @@ import type { SemanticElement, SemanticTarget, } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from './utils'; +import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; function groupValue( @@ -49,7 +49,8 @@ export function createClickGroupHighlightInteraction(options: ClickGroupHighligh id, eventSource: clickTrigger, handle(event, context) { - if (!event.action.startsWith('click-') || event.phase === 'start' || event.phase === 'cancel') return null; + if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; + if (event.target?.visual.role === 'legend-item' && options.legend === false) return null; const target = event.target ? { ...event.target, elements: groupElements(event.target, context, options.groupBy) } : null; diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts index 47642f2f..acf143d9 100644 --- a/packages/flint-js/src/interactive/presets/click-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -1,5 +1,5 @@ import type { CanvasInteractionDef, ClickHighlightOptions } from '../interactions'; -import { emphasisUpdate, normalizedOpacity } from './utils'; +import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; import { expandRangedDotTarget } from './ranged-dot-target'; @@ -10,7 +10,8 @@ export function createClickHighlightInteraction(options: ClickHighlightOptions = id, eventSource: clickTrigger, handle(event, context) { - if (!event.action.startsWith('click-') || event.phase === 'start' || event.phase === 'cancel') return null; + if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; + if (event.target?.visual.role === 'legend-item' && options.legend === false) return null; const target = expandRangedDotTarget(event.target, context); return emphasisUpdate(id, event, target, dimOpacity, context); }, diff --git a/packages/flint-js/src/interactive/presets/context-activate.ts b/packages/flint-js/src/interactive/presets/context-activate.ts new file mode 100644 index 00000000..c7d9c01d --- /dev/null +++ b/packages/flint-js/src/interactive/presets/context-activate.ts @@ -0,0 +1,15 @@ +import type { CanvasInteractionDef, ContextActivateOptions } from '../interactions'; +import { contextTrigger } from '../triggers'; + +/** + * Reports a context request on the chart target and leaves the chart unchanged; + * opening a menu is the application's decision. + */ +export function createContextActivateInteraction( + options: ContextActivateOptions = {}, +): CanvasInteractionDef { + return { + id: options.id ?? 'context-activate', + eventSource: contextTrigger, + }; +} diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index f32c4b62..c15dd882 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -1,8 +1,14 @@ export { createBrushInteraction } from './brush'; +export { createBrushZoomInteraction } from './brush-zoom'; export { createAngularBrushInteraction } from './angular-brush'; export { createClickAnnotateInteraction } from './click-annotate'; export { createClickGroupHighlightInteraction } from './click-group-highlight'; export { createClickHighlightInteraction } from './click-highlight'; +export { createContextActivateInteraction } from './context-activate'; +export { createInspectInteraction } from './inspect'; +export { createDoubleActivateInteraction, createLongPressInteraction } from './long-press'; +export { createLassoSelectInteraction } from './lasso-select'; +export { createLegendToggleInteraction } from './legend-toggle'; export { createSelectInteraction } from './select'; export { createNavigateInteraction } from './navigate'; export { createDragReorderInteraction } from './drag-reorder'; diff --git a/packages/flint-js/src/interactive/presets/inspect.ts b/packages/flint-js/src/interactive/presets/inspect.ts new file mode 100644 index 00000000..f64ca8e8 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/inspect.ts @@ -0,0 +1,25 @@ +import type { CanvasInteractionDef, InspectOptions } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; +import { inspectTrigger } from '../triggers'; + +const INSPECT_ACTIONS = new Set(['inspect-x', 'inspect-y', 'inspect-xy']); + +/** + * Reads the value under the pointer without committing a selection, so it is + * the binding point for a crosshair or shared tooltip. + */ +export function createInspectInteraction(options: InspectOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'inspect'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: inspectTrigger( + options.mode ?? 'xy', options.selector, options.tolerance, options.guide, options.cycle, + ), + handle(event, context) { + if (!INSPECT_ACTIONS.has(event.action) || event.phase === 'cancel') return null; + if (!event.target) return { id, ops: [] }; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/lasso-select.ts b/packages/flint-js/src/interactive/presets/lasso-select.ts new file mode 100644 index 00000000..5023674f --- /dev/null +++ b/packages/flint-js/src/interactive/presets/lasso-select.ts @@ -0,0 +1,16 @@ +import type { CanvasInteractionDef, LassoSelectOptions } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; +import { lassoTrigger } from '../triggers'; + +export function createLassoSelectInteraction(options: LassoSelectOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'lasso-select'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: lassoTrigger(options.match ?? 'intersect', options.guide), + handle(event, context) { + if (event.action !== 'select-lasso' || event.phase === 'start' || event.phase === 'cancel') return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/legend-toggle.ts b/packages/flint-js/src/interactive/presets/legend-toggle.ts new file mode 100644 index 00000000..e8680c16 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/legend-toggle.ts @@ -0,0 +1,97 @@ +import type { + CanvasInteractionDef, + CanvasInteractionEvent, + LegendToggleOptions, + SemanticElement, +} from '../interactions'; +import type { LegendTargetValue } from '../../core/interaction-contracts'; +import { isActivationAction, normalizedOpacity, semanticElementIdentity } from './utils'; +import { clickTrigger } from '../triggers'; + +function sameElement(left: SemanticElement, right: SemanticElement): boolean { + const leftLegend = left.value as LegendTargetValue; + const rightLegend = right.value as LegendTargetValue; + if (leftLegend.channel && leftLegend.domain && rightLegend.channel && rightLegend.domain) { + return JSON.stringify([leftLegend.channel, leftLegend.field, leftLegend.domain]) + === JSON.stringify([rightLegend.channel, rightLegend.field, rightLegend.domain]); + } + return semanticElementIdentity(left) === semanticElementIdentity(right); +} + +function withoutElements( + hidden: readonly SemanticElement[], + elements: readonly SemanticElement[], +): SemanticElement[] { + return hidden.filter((candidate) => !elements.some((element) => sameElement(candidate, element))); +} + +function legendElementMatches( + legendElement: SemanticElement, + candidate: SemanticElement, +): boolean { + const legend = legendElement.value as LegendTargetValue; + if (!legend.field || legend.domain?.kind !== 'value') return sameElement(legendElement, candidate); + const domainValue = legend.domain.value; + const records = candidate.records?.length ? candidate.records : [candidate.value]; + return records.some((record) => Object.is(record[legend.field!], domainValue)); +} + +function hidesFullLegendDomain( + elements: readonly SemanticElement[], + legendDomains: Readonly> | undefined, +): boolean { + if (!legendDomains) return false; + return Object.entries(legendDomains).some(([channel, domain]) => + domain.length > 0 && domain.every((value) => elements.some((element) => { + const legend = element.value as LegendTargetValue; + return legend.channel === channel + && legend.domain?.kind === 'value' + && Object.is(legend.domain.value, value); + }))); +} + +/** Only legend activations toggle series, so these presets compose with mark-click presets. */ +function legendActivation(event: CanvasInteractionEvent): boolean { + return isActivationAction(event.action) + && event.phase === 'commit' + && event.target?.visual.role === 'legend-item'; +} + +/** Hides or restores the activated series, the way a legend key normally behaves. */ +export function createLegendToggleInteraction(options: LegendToggleOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'legend-toggle'; + const mutedOpacity = normalizedOpacity(options.mutedOpacity); + let hidden: SemanticElement[] = []; + return { + id, + eventSource: clickTrigger, + claimsLegendActivation: true, + handle(event, context) { + if (!legendActivation(event)) return null; + const elements = event.target?.elements ?? []; + if (elements.length === 0) return null; + const remaining = withoutElements(hidden, elements); + const hiding = remaining.length === hidden.length; + const next = hiding ? [...hidden, ...elements] : remaining; + const available = context.available ?? []; + const hasStableLegendDomain = context.legendDomains + && Object.values(context.legendDomains).some((domain) => domain.length > 0); + const hidesEverySeries = hiding && ( + hidesFullLegendDomain(next, context.legendDomains) + || (!hasStableLegendDomain && available.length > 0 && available.every((candidate) => + elements.some((element) => legendElementMatches(element, candidate)))) + ); + hidden = hidesEverySeries ? [] : next; + return { + id, + ops: [{ + op: 'set-presentation', + targets: hidden.length > 0 + ? [{ visual: { kind: 'legend', role: 'legend-item' }, elements: hidden }] + : [], + value: { visible: false, mutedOpacity }, + }], + }; + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/long-press.ts b/packages/flint-js/src/interactive/presets/long-press.ts new file mode 100644 index 00000000..ea4c072e --- /dev/null +++ b/packages/flint-js/src/interactive/presets/long-press.ts @@ -0,0 +1,37 @@ +import type { + CanvasInteractionDef, + DoubleActivateOptions, + LongPressOptions, +} from '../interactions'; +import { doubleActivateTrigger, longPressTrigger } from '../triggers'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +/** Highlights and reports a sustained press on a chart target. */ +export function createLongPressInteraction(options: LongPressOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'long-press'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: longPressTrigger(options.holdMs ?? 500), + handle(event, context) { + if (!event.action.startsWith('long-press-') || event.phase !== 'commit') return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} + +/** Highlights and reports a double activation for drill-down-style workflows. */ +export function createDoubleActivateInteraction( + options: DoubleActivateOptions = {}, +): CanvasInteractionDef { + const id = options.id ?? 'double-activate'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: doubleActivateTrigger, + handle(event, context) { + if (!event.action.startsWith('double-activate-') || event.phase !== 'commit') return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/select.ts b/packages/flint-js/src/interactive/presets/select.ts index c2f27be5..08877606 100644 --- a/packages/flint-js/src/interactive/presets/select.ts +++ b/packages/flint-js/src/interactive/presets/select.ts @@ -7,7 +7,7 @@ export function createSelectInteraction(options: SelectOptions = {}): CanvasInte const dimOpacity = normalizedOpacity(options.dimOpacity); return { id, - eventSource: rectangleTrigger(options.match ?? 'intersect'), + eventSource: rectangleTrigger(options.match ?? 'intersect', options.guide), handle(event, context) { if (event.action !== 'select-region' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/utils.ts b/packages/flint-js/src/interactive/presets/utils.ts index 5183cd2d..862b87c6 100644 --- a/packages/flint-js/src/interactive/presets/utils.ts +++ b/packages/flint-js/src/interactive/presets/utils.ts @@ -13,6 +13,15 @@ export function normalizedOpacity(value: number | undefined): number { return Math.min(1, Math.max(0, value)); } +/** Keyboard activation reaches the same presets as a click on the target. */ +export function isActivationAction(action: string): boolean { + return action.startsWith('click-') || action === 'activate-element'; +} + +export function semanticElementIdentity(element: SemanticTarget['elements'][number]): string { + return JSON.stringify([element.value, element.records ?? []]); +} + function selectionMode(modifiers: InteractionModifiers | undefined): 'replace' | 'toggle' { return modifiers?.shift || modifiers?.ctrl || modifiers?.meta ? 'toggle' : 'replace'; } @@ -27,15 +36,15 @@ export function emphasisUpdate( if (!target) return { id, ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }] }; if (target.elements.length === 0) return null; const toggle = selectionMode(event.modifiers) === 'toggle'; - const targetKeys = new Set(target.elements.map((element) => JSON.stringify(element.key))); + const targetKeys = new Set(target.elements.map(semanticElementIdentity)); const allSelected = target.elements.every((element) => - context.selected.some((selected) => JSON.stringify(selected.key) === JSON.stringify(element.key))); + context.selected.some((selected) => semanticElementIdentity(selected) === semanticElementIdentity(element))); const elements = !toggle ? target.elements : allSelected - ? context.selected.filter((element) => !targetKeys.has(JSON.stringify(element.key))) + ? context.selected.filter((element) => !targetKeys.has(semanticElementIdentity(element))) : [...context.selected, ...target.elements.filter((element) => - !context.selected.some((selected) => JSON.stringify(selected.key) === JSON.stringify(element.key)))]; + !context.selected.some((selected) => semanticElementIdentity(selected) === semanticElementIdentity(element)))]; return { id, ops: [{ diff --git a/packages/flint-js/src/interactive/triggers.ts b/packages/flint-js/src/interactive/triggers.ts index 47a951d9..5386b048 100644 --- a/packages/flint-js/src/interactive/triggers.ts +++ b/packages/flint-js/src/interactive/triggers.ts @@ -1,12 +1,35 @@ import type { NavigationAxes } from './language/events'; +import type { SemanticTargetSelector } from '../core/interaction-contracts'; +import type { InspectGuideOptions, RegionGuideOptions } from './guides'; +import { normalizeInspectGuideOptions, normalizeRegionGuideOptions } from './guides'; + +export type InspectOperator = '<' | '<=' | '=' | '>=' | '>'; +export type InspectMode = + | 'x' | 'y' | 'xy' + | `x${InspectOperator}` | `y${InspectOperator}` | `xy${InspectOperator}` + | `x${InspectOperator};y${InspectOperator}`; + +export interface InspectPredicate { + readonly x?: InspectOperator; + readonly y?: InspectOperator; +} export interface InteractionEventSource { readonly type: 'element' | 'region' | (string & {}); - readonly gesture?: 'click' | 'hover' | 'drag' | 'drag-element' | 'navigate'; + readonly gesture?: 'click' | 'hover' | 'drag' | 'drag-element' | 'navigate' | 'keyboard' | 'context' | 'inspect' | 'long-press' | 'double'; readonly match?: 'intersect' | 'contain'; readonly axis?: 'x' | 'y' | 'xy'; readonly mode?: 'ephemeral' | 'stateful'; - readonly regionGeometry?: 'cartesian' | 'angular'; + readonly regionGeometry?: 'cartesian' | 'angular' | 'lasso'; + readonly inspect?: 'x' | 'y' | 'xy'; + readonly inspectPredicate?: InspectPredicate; + readonly inspectCycle?: readonly ReturnType[]; + readonly inspectTolerance?: number; + readonly inspectGuide?: ReturnType; + readonly regionGuide?: ReturnType; + readonly selector?: SemanticTargetSelector; + readonly holdMs?: number; + readonly viewport?: boolean; readonly axes?: NavigationAxes | 'available'; readonly pan?: boolean; readonly zoom?: boolean; @@ -29,36 +52,129 @@ export const hoverTrigger = Object.freeze({ export function rectangleTrigger( match: 'intersect' | 'contain' = 'intersect', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return { type: 'region', gesture: 'drag', match, regionGuide: normalizeRegionGuideOptions(guide) }; +} + +export function lassoTrigger( + match: 'intersect' | 'contain' = 'intersect', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return { + type: 'region', gesture: 'drag', regionGeometry: 'lasso', match, mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(guide), + }; +} + +export function brushZoomTrigger( + axis: 'x' | 'y' | 'xy' = 'xy', + guide?: RegionGuideOptions | false, ): InteractionEventSource { - return { type: 'region', gesture: 'drag', match }; + return { + type: 'region', gesture: 'drag', axis, match: 'intersect', mode: 'ephemeral', viewport: true, + regionGuide: normalizeRegionGuideOptions(guide), + }; } +export const keyboardTrigger = Object.freeze({ + type: 'element', + gesture: 'keyboard', +} as const satisfies InteractionEventSource); + +export const contextTrigger = Object.freeze({ + type: 'element', + gesture: 'context', +} as const satisfies InteractionEventSource); + +export function parseInspectMode(mode: InspectMode): { inspect: 'x' | 'y' | 'xy'; predicate: InspectPredicate } { + const shorthand = /^(x|y|xy)(<=|>=|=|<|>)?$/.exec(mode); + if (shorthand) { + const axes = shorthand[1]; + const operator = (shorthand[2] ?? '=') as InspectOperator; + return { + inspect: axes, + predicate: { + ...(axes.includes('x') ? { x: operator } : {}), + ...(axes.includes('y') ? { y: operator } : {}), + }, + } as { inspect: 'x' | 'y' | 'xy'; predicate: InspectPredicate }; + } + const mixed = /^x(<=|>=|=|<|>);y(<=|>=|=|<|>)$/.exec(mode); + if (!mixed) throw new Error(`Invalid inspect mode: ${mode}`); + return { inspect: 'xy', predicate: { x: mixed[1] as InspectOperator, y: mixed[2] as InspectOperator } }; +} + +/** Inspection modes are parsed once so renderer mounts consume structured predicates. */ +export function inspectTrigger( + mode: InspectMode = 'xy', + selector?: SemanticTargetSelector, + tolerance?: number, + guide?: InspectGuideOptions | false, + cycle: readonly InspectMode[] = [], +): InteractionEventSource { + const parsed = parseInspectMode(mode); + const cycleModes = [...new Set([mode, ...cycle])].map(parseInspectMode); + const defaultTolerance = parsed.inspect === 'xy' + && parsed.predicate.x === '=' + && parsed.predicate.y === '=' + ? 0.02 + : 0.01; + const inspectTolerance = tolerance === undefined || !Number.isFinite(tolerance) + ? defaultTolerance + : Math.min(0.5, Math.max(0, tolerance)); + return { + type: 'element', gesture: 'inspect', ...parsed, inspectTolerance, + inspectGuide: normalizeInspectGuideOptions(guide), + ...(cycle.length > 0 ? { inspectCycle: cycleModes } : {}), + ...(selector ? { selector } : {}), + }; +} + +/** Touch equivalent of a context request. */ +export function longPressTrigger(holdMs = 500): InteractionEventSource { + return { type: 'element', gesture: 'long-press', holdMs }; +} + +export const doubleActivateTrigger = Object.freeze({ + type: 'element', + gesture: 'double', +} as const satisfies InteractionEventSource); + export function axisBrushTrigger( axis: 'x' | 'y', match: 'intersect' | 'contain' = 'intersect', mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, ): InteractionEventSource { - return { type: 'region', gesture: 'drag', axis, match, mode }; + return { type: 'region', gesture: 'drag', axis, match, mode, regionGuide: normalizeRegionGuideOptions(guide) }; } export function xBrushTrigger( match: 'intersect' | 'contain' = 'intersect', mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, ): InteractionEventSource { - return axisBrushTrigger('x', match, mode); + return axisBrushTrigger('x', match, mode, guide); } export function yBrushTrigger( match: 'intersect' | 'contain' = 'intersect', mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, ): InteractionEventSource { - return axisBrushTrigger('y', match, mode); + return axisBrushTrigger('y', match, mode, guide); } export function angularBrushTrigger( match: 'intersect' | 'contain' = 'intersect', + mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, ): InteractionEventSource { - return { type: 'region', gesture: 'drag', regionGeometry: 'angular', match, mode: 'ephemeral' }; + return { + type: 'region', gesture: 'drag', regionGeometry: 'angular', match, mode, + regionGuide: normalizeRegionGuideOptions(guide), + }; } export function navigationTrigger(options: { diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index 7a64e0d6..a6de4980 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -12,10 +12,20 @@ export interface ViewportGeometry { export type ChartUpdateComposition = 'auto'; +/** Pointer acquisition that snaps to a nearby mark instead of requiring a direct hit. */ +export interface AssistedTargetingOptions { + maxDistance?: number; +} + export interface ChartUpdateApplyOptions { composition?: ChartUpdateComposition; } +export interface InteractionDismissPolicy { + click?: 'any' | 'non-element' | 'plot-background' | false; + escape?: boolean; +} + export interface InteractiveRenderer { viewports: CategoryViewport[]; setViewports(starts: ViewportState): void | Promise; @@ -40,6 +50,10 @@ export interface InteractiveChartSurfaceOptions { chartId?: string; updates?: readonly ChartUpdate[]; interactions?: readonly InteractionDef[]; + assistedTargeting?: boolean | AssistedTargetingOptions; + keyboardTargeting?: boolean; + /** How committed presentation and annotation state is cleared. */ + dismiss?: InteractionDismissPolicy | false; } export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index c7c89a3e..bd0d9caf 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -886,10 +886,22 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (chartTemplate.semanticInteractions || navigationAxes.length > 0) { const templateSemantics = chartTemplate.semanticInteractions?.({ resolvedEncodings }) ?? { fields: [], + provenanceFields: undefined, + temporalProvenanceFields: undefined, + rangeProvenance: undefined, selectableMarks: [], reorderAxis: undefined, reorderAxes: undefined, }; + const semanticEncodings = Object.values(resolvedEncodings) + .filter((encoding: any) => typeof encoding?.field === 'string') as any[]; + const hasAggregate = semanticEncodings.some((encoding) => encoding.aggregate); + const provenanceFields = [...new Set(semanticEncodings + .filter((encoding) => !hasAggregate || !encoding.aggregate) + .map((encoding) => encoding.field as string))]; + const temporalProvenanceFields = [...new Set(semanticEncodings + .filter((encoding) => encoding.type === 'temporal') + .map((encoding) => encoding.field as string))]; const allowedReorderAxes: readonly ('x' | 'y')[] = chartTemplate.reorder === false ? [] : chartTemplate.reorder?.axes ?? ['x', 'y']; @@ -914,16 +926,28 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { : []; const explicitReorderAxes = templateSemantics.reorderAxes ?? (templateSemantics.reorderAxis ? [templateSemantics.reorderAxis] : []); + const legendFields = 'legendFields' in templateSemantics ? templateSemantics.legendFields : undefined; + const rangeLegendChannels = Object.keys(legendFields ?? {}) + .filter((channel) => { + const type = resolvedEncodings[channel]?.type; + return type === 'quantitative' || type === 'temporal'; + }); const reorderAxes = [...explicitReorderAxes, ...defaultReorderAxes] .filter((candidate, index, candidates) => candidates.findIndex( (axis) => axis.axis === candidate.axis && axis.field === candidate.field, ) === index); result._interactionSemantics = { ...templateSemantics, + sourceRecords: values.map((record) => ({ ...record })), + provenanceFields: templateSemantics.provenanceFields ?? provenanceFields, + temporalProvenanceFields: templateSemantics.temporalProvenanceFields ?? temporalProvenanceFields, + rangeLegendChannels, navigationAxes, reorderAxis: reorderAxes[0], reorderAxes, selectionBoundary: design.interaction.selectionBoundary, + continuousColorFocus: design.interaction.continuousColorFocus, + neutralizeContinuousColor: chartTemplate.chart === 'Map' || chartTemplate.chart === 'Choropleth', }; } result._width = layoutResult.subplotWidth; diff --git a/packages/flint-js/src/vegalite/interaction-provenance.ts b/packages/flint-js/src/vegalite/interaction-provenance.ts index dfb3f777..b03d8bbb 100644 --- a/packages/flint-js/src/vegalite/interaction-provenance.ts +++ b/packages/flint-js/src/vegalite/interaction-provenance.ts @@ -4,9 +4,26 @@ export const INTERACTION_PROVENANCE = '__flintInteractionProvenance'; export interface InteractionProvenance { - role: 'text-label' | 'decorative'; + role: 'text-label' | 'legend-label' | 'decorative'; identity: 'inherit' | { fields: readonly string[] }; presentation: 'on-mark' | 'independent'; + legend?: { channel: string; field: string }; +} + +/** Declare a generated series label that acts as a direct legend entry. */ +export function withInteractionLegendLabel>( + node: T, + legend: { channel: string; field: string }, +): T { + return { + ...node, + [INTERACTION_PROVENANCE]: { + role: 'legend-label', + identity: 'inherit', + presentation: 'independent', + legend, + } satisfies InteractionProvenance, + }; } /** Exclude a structural or ornamental mark from semantic hit instrumentation. */ diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index 2b88bdc0..03fc14f9 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -11,11 +11,20 @@ import { INTERACTION_PROVENANCE, type InteractionProvenance } from '../interacti import type { HoverStyle, SelectionBoundaryStyle, + ContinuousColorFocusStyle, SelectionStyle, VegaInteractionPlan, } from './contracts'; -import { INTERACTION_KEY, INTERACTION_ROLE, PATH_KEY_SUFFIX } from './hit-adapter'; import { + INTERACTION_KEY, + INTERACTION_LEGEND_CHANNEL, + INTERACTION_LEGEND_FIELD, + INTERACTION_ROLE, + PATH_KEY_SUFFIX, +} from './hit-adapter'; +import { + HIDDEN_STORE, + LEGEND_HIDDEN_STORE, HOVER_STORE, INTERACTION_STORE, LEGEND_HOVER_STORE, @@ -23,14 +32,20 @@ import { } from './stores'; const CLEAR_MARK = '__flint_interaction_clear'; +const LEGEND_ENTRY_MARK = '__flint_legend_entry'; const SUPPORTED_SPEC_MARKS = new Set(['arc', 'area', 'bar', 'boxplot', 'circle', 'geoshape', 'line', 'point', 'rect', 'rule', 'tick']); interface TemplateInteractionSemantics { fields: string[]; + sourceRecords?: readonly Record[]; + provenanceFields?: readonly string[]; + temporalProvenanceFields?: readonly string[]; + rangeProvenance?: readonly { field: string; startField: string; endField: string }[]; categoryField?: string; seriesField?: string; resolveGroupValue?: InteractionContext['resolveGroupValue']; legendFields?: Record; + rangeLegendChannels?: readonly string[]; selectableMarks: string[]; annotationMarkType?: string; supportedRegionGestures?: ('cartesian' | 'angular')[]; @@ -40,6 +55,8 @@ interface TemplateInteractionSemantics { renderHoverStyles?: Record; renderSelectionStyles?: Record; selectionBoundary?: SelectionBoundaryStyle; + continuousColorFocus?: ContinuousColorFocusStyle; + neutralizeContinuousColor?: boolean; resolve?: ChartInteractionResolver; presentUpdate?: ChartUpdatePresenter; } @@ -88,6 +105,7 @@ function instrumentNode( inherited: Record, semanticFields: readonly string[], dimOpacity: number, + continuousColorFocus: ContinuousColorFocusStyle | undefined, selectableMarks: ReadonlySet, clickCursor: boolean, ): boolean { @@ -96,21 +114,31 @@ function instrumentNode( const provenance = node[INTERACTION_PROVENANCE] as InteractionProvenance | undefined; if (provenance?.role === 'decorative') return false; const textLabel = provenance?.role === 'text-label'; - if (!selectable && !textLabel) return false; - if (textLabel) { + const legendLabel = provenance?.role === 'legend-label'; + if (!selectable && !textLabel && !legendLabel) return false; + if (textLabel || legendLabel) { const identityFields = provenance.identity === 'inherit' ? semanticFields : provenance.identity.fields; node.transform = [ ...(Array.isArray(node.transform) ? node.transform : []), - { calculate: keyExpression(identityFields), as: INTERACTION_KEY }, - { calculate: "'text-label'", as: INTERACTION_ROLE }, + ...keyTransforms(identityFields), + { calculate: `'${provenance.role}'`, as: INTERACTION_ROLE }, + ...(legendLabel && provenance.legend ? [ + { calculate: JSON.stringify(provenance.legend.channel), as: INTERACTION_LEGEND_CHANNEL }, + { calculate: JSON.stringify(provenance.legend.field), as: INTERACTION_LEGEND_FIELD }, + ] : []), ]; } const encoding = { ...inherited, ...(node.encoding ?? {}) }; const encodedOpacity = encoding.opacity; + const encodedColor = encoding.color; + const continuousColor = continuousColorFocus + && encodedColor?.field + && (encodedColor.type === 'quantitative' || encodedColor.type === 'temporal') + && !encodedColor.condition; const dataDrivenOpacity = encodedOpacity?.field && !encodedOpacity.condition; - if (textLabel && provenance.presentation === 'on-mark') return true; + if ((textLabel || legendLabel) && provenance.presentation === 'on-mark') return true; if ((encodedOpacity && typeof encodedOpacity.value !== 'number' && !dataDrivenOpacity) || encoding.fillOpacity || encoding.strokeOpacity) return false; const authoredOpacity = typeof encodedOpacity?.value === 'number' @@ -122,7 +150,7 @@ function instrumentNode( node.mark = { ...node.mark }; delete node.mark.opacity; } - if (clickCursor && selectable) { + if (clickCursor && (selectable || legendLabel)) { node.mark = typeof node.mark === 'string' ? { type: node.mark, cursor: 'pointer' } : { ...node.mark, cursor: node.mark.cursor ?? 'pointer' }; @@ -140,7 +168,17 @@ function instrumentNode( ? { field: INTERACTION_KEY, type: 'nominal' } : [...(Array.isArray(existingDetail) ? existingDetail : [existingDetail]), { field: INTERACTION_KEY, type: 'nominal' }], }), - opacity: dataDrivenOpacity ? { + ...(continuousColor ? { + color: { + ...(encodedColor.legend !== undefined ? { legend: encodedColor.legend } : {}), + condition: { + test: `${selectionTest} || ${hoverTest}`, + ...Object.fromEntries(Object.entries(encodedColor).filter(([key]) => key !== 'legend')), + }, + value: continuousColorFocus.mutedFill, + }, + } : {}), + opacity: continuousColor ? { value: authoredOpacity } : dataDrivenOpacity ? { condition: { test: selectionTest, ...encodedOpacity }, value: dimOpacity, } : { @@ -159,6 +197,7 @@ function instrumentMarks( inherited: Record, semanticFields: readonly string[], dimOpacity: number, + continuousColorFocus: ContinuousColorFocusStyle | undefined, selectableMarks: ReadonlySet, clickCursor: boolean, ): boolean { @@ -168,6 +207,7 @@ function instrumentMarks( inherited, semanticFields, dimOpacity, + continuousColorFocus, selectableMarks, clickCursor, ); @@ -179,6 +219,7 @@ function instrumentMarks( encoding, semanticFields, dimOpacity, + continuousColorFocus, selectableMarks, clickCursor, ) || instrumented; @@ -190,6 +231,7 @@ function instrumentMarks( encoding, semanticFields, dimOpacity, + continuousColorFocus, selectableMarks, clickCursor, ) || instrumented; @@ -197,6 +239,92 @@ function instrumentMarks( return instrumented; } +function inlineRows(spec: Record): Record[] { + if (Array.isArray(spec?.data?.values)) return spec.data.values; + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) { + const rows = inlineRows(child); + if (rows.length > 0) return rows; + } + } + return spec.spec && typeof spec.spec === 'object' ? inlineRows(spec.spec) : []; +} + +function pinChannelDomain( + spec: Record, + channel: string, + field: string, + rows: readonly Record[], +): void { + const encoding = spec.encoding?.[channel]; + if (encoding && encoding.field === field && encoding.scale?.domain === undefined) { + const values = [...new Set(rows.map((row) => row?.[field]).filter((value) => value !== undefined))]; + const sort = encoding.sort; + if (Array.isArray(sort)) { + const order = new Map(sort.map((value: unknown, index: number) => [value, index])); + values.sort((left, right) => (order.get(left) ?? Number.POSITIVE_INFINITY) + - (order.get(right) ?? Number.POSITIVE_INFINITY)); + } else if (sort && typeof sort === 'object') { + const grouped = new Map(); + for (const row of rows) { + const key = row?.[field]; + const value = sort.op === 'count' ? 1 : Number(row?.[sort.field]); + if (key === undefined || !Number.isFinite(value)) continue; + grouped.set(key, [...(grouped.get(key) ?? []), value]); + } + const aggregate = (key: unknown): number => { + const entries = grouped.get(key) ?? []; + if (sort.op === 'count') return entries.length; + if (sort.op === 'min') return Math.min(...entries); + if (sort.op === 'max') return Math.max(...entries); + if (sort.op === 'mean' || sort.op === 'average') { + return entries.reduce((sum, value) => sum + value, 0) / entries.length; + } + return entries.reduce((sum, value) => sum + value, 0); + }; + const direction = sort.order === 'ascending' ? 1 : -1; + values.sort((left, right) => direction * (aggregate(left) - aggregate(right))); + } else { + values.sort((left, right) => (left as any) < (right as any) ? -1 : (left as any) > (right as any) ? 1 : 0); + if (sort === 'descending') values.reverse(); + } + encoding.scale = { ...(encoding.scale ?? {}), domain: values }; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) pinChannelDomain(child, channel, field, rows); + } + if (spec.spec && typeof spec.spec === 'object') pinChannelDomain(spec.spec, channel, field, rows); +} + +/** + * Hiding filters rows, which would otherwise shrink the legend and strand the hidden series + * with no key left to click. Pinning the domain keeps every series listed. + */ +function pinLegendDomains( + spec: Record, + legendFields: Readonly> | undefined, +): void { + const rows = inlineRows(spec); + if (rows.length === 0) return; + for (const [channel, field] of Object.entries(legendFields ?? {})) { + if (channel === 'size' || channel === 'opacity') continue; + const values = [...new Set(rows.map((row) => row?.[field]).filter((value) => value !== undefined))]; + if (values.length === 0 || values.some((value) => typeof value !== 'string' && typeof value !== 'number')) continue; + pinChannelDomain(spec, channel === 'color' ? 'color' : channel, field, rows); + } +} + +function keyTransforms(fields: readonly string[]): Record[] { + return [ + { calculate: keyExpression(fields), as: INTERACTION_KEY }, + // Hiding filters rows rather than blanking marks, so implicit domains, stacks and + // aggregates redraw against what is left. Domains Flint pinned explicitly are unaffected. + { filter: `!(length(data('${HIDDEN_STORE}')) && indata('${HIDDEN_STORE}', 'key', datum.${INTERACTION_KEY}))` }, + ]; +} + function addLocalKeyTransforms( spec: Record, fields: readonly string[], @@ -210,7 +338,7 @@ function addLocalKeyTransforms( if (type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type)) { spec.transform = [ ...(Array.isArray(spec.transform) ? spec.transform : []), - { calculate: keyExpression(fields), as: INTERACTION_KEY }, + ...keyTransforms(fields), ]; } for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { @@ -286,21 +414,28 @@ export function addVegaLiteInteractions( const semanticInteractions = canvasInteractions.filter( (interaction) => interaction.eventSource.type !== 'navigation', ); + const presentationInteractions = semanticInteractions.filter( + (interaction) => !interaction.eventSource.viewport, + ); const needsSemanticPresentation = enableSemanticUpdates - || semanticInteractions.length > 0 + || presentationInteractions.length > 0 || canvasInteractions.length < interactions.length; if (navigationInteraction?.eventSource.pan && semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag')) { throw new Error('Pan navigation cannot share an unmodified drag gesture with a region interaction.'); } const availableNavigationAxes = templateSemantics.navigationAxes ?? []; + // A region interaction can drive the viewport, which still needs domain signals. + const viewportRegion = canvasInteractions.find((interaction) => interaction.eventSource.viewport); const requestedNavigationAxes = navigationInteraction ? navigationInteraction.eventSource.axes === 'available' ? availableNavigationAxes : navigationInteraction.eventSource.axes === 'xy' ? ['x', 'y'] as const : [navigationInteraction.eventSource.axes as 'x' | 'y'] - : []; + : viewportRegion + ? availableNavigationAxes + : []; const unsupportedNavigationAxes = requestedNavigationAxes.filter( (axis) => !availableNavigationAxes.includes(axis), ); @@ -323,9 +458,9 @@ export function addVegaLiteInteractions( const selectableMarks = new Set(templateSemantics.selectableMarks ?? SUPPORTED_SPEC_MARKS); const fields = templateSemantics.fields ?? []; if (needsSemanticPresentation) expandInteractiveLinePoints(spec); - if (navigationInteraction) clipNavigableMarks(spec); + if (navigationInteraction || viewportRegion) clipNavigableMarks(spec); - const dimOpacity = semanticInteractions.reduce((value, interaction) => { + const dimOpacity = presentationInteractions.reduce((value, interaction) => { if (!interaction.handle) return value; const semanticEvent = { type: 'semantic', @@ -333,7 +468,7 @@ export function addVegaLiteInteractions( phase: 'commit', target: { visual: { kind: 'mark', role: 'probe' }, - elements: [{ key: {} }], + elements: [{ value: {} }], }, } as const; const interactionContext = { chartType: 'Unknown', selected: [] }; @@ -347,30 +482,44 @@ export function addVegaLiteInteractions( const clickCursor = semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'click') && !semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag'); const instrumented = needsSemanticPresentation - ? instrumentMarks(spec, {}, fields, dimOpacity, selectableMarks, clickCursor) + ? instrumentMarks( + spec, {}, fields, dimOpacity, + templateSemantics.neutralizeContinuousColor ? templateSemantics.continuousColorFocus : undefined, + selectableMarks, clickCursor, + ) : false; if (needsSemanticPresentation && !instrumented) return null; if (instrumented) addLocalKeyTransforms(spec, fields, selectableMarks); + if (instrumented && canvasInteractions.some((interaction) => interaction.claimsLegendActivation)) { + pinLegendDomains(spec, templateSemantics.legendFields); + } stripInteractionProvenance(spec); if (instrumented) { spec.transform = [ ...(Array.isArray(spec.transform) ? spec.transform : []), - { calculate: keyExpression(fields), as: INTERACTION_KEY }, + ...keyTransforms(fields), ]; } return { fields, + sourceRecords: templateSemantics.sourceRecords ?? inlineRows(spec).map((record) => ({ ...record })), + provenanceFields: templateSemantics.provenanceFields ?? fields, + temporalProvenanceFields: templateSemantics.temporalProvenanceFields ?? [], + rangeProvenance: templateSemantics.rangeProvenance ?? [], categoryField: templateSemantics.categoryField, seriesField: templateSemantics.seriesField, resolveGroupValue: templateSemantics.resolveGroupValue, legendFields: templateSemantics.legendFields, + rangeLegendChannels: templateSemantics.rangeLegendChannels, annotationMarkType: templateSemantics.annotationMarkType, semanticStores: instrumented, dimOpacity, renderHoverStyles: templateSemantics.renderHoverStyles, renderSelectionStyles: templateSemantics.renderSelectionStyles, selectionBoundary: templateSemantics.selectionBoundary, + continuousColorFocus: templateSemantics.continuousColorFocus, navigationChannels: [...requestedNavigationAxes], + angularXBrush: templateSemantics.supportedRegionGestures?.includes('angular') ?? false, reorderAxis: reorderInteraction && declaredReorderAxes[0] ? { ...declaredReorderAxes[0], scale: '', signal: '' } : undefined, @@ -484,45 +633,121 @@ function applyCompiledHoverStyles( export function injectVegaInteractionStore( vegaSpec: Record, - plan?: Pick, + plan?: Pick, ): void { + // Stores go first: transforms are parsed in data order, so a filter that reads a store + // cannot resolve one declared after it. vegaSpec.data = [ - ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), { name: INTERACTION_STORE, values: [] }, { name: HOVER_STORE, values: [] }, + { name: HIDDEN_STORE, values: [] }, + { name: LEGEND_HIDDEN_STORE, values: [] }, { name: LEGEND_HOVER_STORE, values: [] }, { name: LEGEND_SELECTION_STORE, values: [] }, + ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), ]; - for (const legend of vegaSpec.legends ?? []) { - const scaleChannel = ['fill', 'stroke', 'size', 'shape', 'opacity'] - .find((channel) => legend[channel] !== undefined); - const channel = scaleChannel === 'fill' || scaleChannel === 'stroke' ? 'color' : scaleChannel; - const peerOfSelectedLegend = channel - ? `length(data('${LEGEND_SELECTION_STORE}')) && ` + - `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + - `data('${LEGEND_SELECTION_STORE}')[0].value !== datum.value` - : undefined; - const interactiveItem = (encode: Record | undefined): Record => { - const existingOpacity = encode?.update?.opacity ?? encode?.enter?.opacity ?? { value: 1 }; - return { - ...(encode ?? {}), - interactive: true, - update: { - ...(encode?.update ?? {}), - cursor: { value: 'pointer' }, - opacity: peerOfSelectedLegend ? [ - { test: peerOfSelectedLegend, value: plan?.dimOpacity ?? DEFAULT_DIM_OPACITY }, - ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), - ] : existingOpacity, + const instrumentLegends = (scope: Record): void => { + for (const legend of scope.legends ?? []) { + const scaleChannel = ['fill', 'stroke', 'size', 'shape', 'opacity'] + .find((channel) => legend[channel] !== undefined); + const channel = scaleChannel === 'fill' || scaleChannel === 'stroke' ? 'color' : scaleChannel; + const peerOfSelectedLegend = channel + ? `isValid(datum.value) && length(data('${LEGEND_SELECTION_STORE}')) && ` + + `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_SELECTION_STORE}')[0].value !== datum.value` + : undefined; + const selectedLegendItem = channel + ? `isValid(datum.value) && length(data('${LEGEND_SELECTION_STORE}')) && ` + + `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_SELECTION_STORE}')[0].value === datum.value` + : undefined; + const hoveredLegendItem = channel + ? `isValid(datum.value) && length(data('${LEGEND_HOVER_STORE}')) && ` + + `data('${LEGEND_HOVER_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_HOVER_STORE}')[0].value === datum.value` + : undefined; + const hiddenLegendItem = channel + ? `isValid(datum.value) && length(data('${LEGEND_HIDDEN_STORE}')) && ` + + `indata('${LEGEND_HIDDEN_STORE}', 'identity', ${JSON.stringify(channel)} + ':' + datum.value)` + : undefined; + const interactiveItem = ( + encode: Record | undefined, + kind: 'gradient' | 'symbol' | 'label', + ): Record => { + const existingOpacity = encode?.update?.opacity ?? encode?.enter?.opacity ?? { value: 1 }; + const existingStroke = encode?.update?.stroke ?? encode?.enter?.stroke ?? { value: null }; + const existingStrokeWidth = encode?.update?.strokeWidth ?? encode?.enter?.strokeWidth ?? { value: 0 }; + const existingStrokeOpacity = encode?.update?.strokeOpacity ?? encode?.enter?.strokeOpacity ?? { value: 1 }; + const existingFill = encode?.update?.fill ?? encode?.enter?.fill; + const existingFontWeight = encode?.update?.fontWeight ?? encode?.enter?.fontWeight ?? { value: 'normal' }; + const selectionBoundary = plan?.selectionBoundary; + return { + ...(encode ?? {}), + interactive: true, + update: { + ...(encode?.update ?? {}), + cursor: { value: 'pointer' }, + opacity: hiddenLegendItem ? [ + { test: hiddenLegendItem, signal: `data('${LEGEND_HIDDEN_STORE}')[0].opacity` }, + ...(peerOfSelectedLegend ? [ + { test: peerOfSelectedLegend, value: plan?.dimOpacity ?? DEFAULT_DIM_OPACITY }, + ] : []), + ...(kind === 'symbol' && hoveredLegendItem + ? [{ test: hoveredLegendItem, value: 0.72 }] + : []), + ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), + ] : kind === 'symbol' && hoveredLegendItem ? [ + { test: hoveredLegendItem, value: 0.72 }, + ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), + ] : existingOpacity, + ...(kind === 'gradient' && selectedLegendItem ? { + stroke: [ + { test: selectedLegendItem, value: selectionBoundary?.color ?? '#20262c' }, + ...(Array.isArray(existingStroke) ? existingStroke : [existingStroke]), + ], + strokeWidth: [ + { test: selectedLegendItem, value: selectionBoundary?.width ?? 1.25 }, + ...(Array.isArray(existingStrokeWidth) ? existingStrokeWidth : [existingStrokeWidth]), + ], + strokeOpacity: [ + { test: selectedLegendItem, value: selectionBoundary?.opacity ?? 0.68 }, + ...(Array.isArray(existingStrokeOpacity) ? existingStrokeOpacity : [existingStrokeOpacity]), + ], + } : {}), + ...(kind === 'label' && hoveredLegendItem ? { + ...(existingFill ? { + fill: [ + { test: hoveredLegendItem, value: selectionBoundary?.color ?? '#20262c' }, + ...(Array.isArray(existingFill) ? existingFill : [existingFill]), + ], + } : {}), + fontWeight: [ + { test: hoveredLegendItem, value: 600 }, + ...(Array.isArray(existingFontWeight) ? existingFontWeight : [existingFontWeight]), + ], + } : {}), + }, + }; + }; + legend.encode = { + ...(legend.encode ?? {}), + entries: { + ...(legend.encode?.entries ?? {}), + name: legend.encode?.entries?.name ?? LEGEND_ENTRY_MARK, + interactive: true, + update: { + ...(legend.encode?.entries?.update ?? {}), + cursor: { value: 'pointer' }, + }, }, + gradient: interactiveItem(legend.encode?.gradient, 'gradient'), + symbols: interactiveItem(legend.encode?.symbols, 'symbol'), + labels: interactiveItem(legend.encode?.labels, 'label'), }; - }; - legend.encode = { - ...(legend.encode ?? {}), - symbols: interactiveItem(legend.encode?.symbols), - labels: interactiveItem(legend.encode?.labels), - }; - } + } + for (const mark of scope.marks ?? []) instrumentLegends(mark); + }; + instrumentLegends(vegaSpec); if (!Array.isArray(vegaSpec.marks)) return; if (plan?.renderHoverStyles) applyCompiledHoverStyles(vegaSpec.marks, plan.renderHoverStyles); vegaSpec.marks.unshift({ diff --git a/packages/flint-js/src/vegalite/interactions/contracts.ts b/packages/flint-js/src/vegalite/interactions/contracts.ts index 085c5e97..3f492c14 100644 --- a/packages/flint-js/src/vegalite/interactions/contracts.ts +++ b/packages/flint-js/src/vegalite/interactions/contracts.ts @@ -23,6 +23,14 @@ export interface SelectionBoundaryStyle { haloOpacity: number; } +export interface ContinuousColorFocusStyle { + mutedFill: string; + boundaryWidth: number; + boundaryOpacity: number; + haloWidth: number; + haloOpacity: number; +} + export interface VegaNavigationAxis { scale: string; signal: string; @@ -40,10 +48,15 @@ export interface VegaReorderAxis { export interface VegaInteractionPlan { fields: readonly string[]; + sourceRecords: readonly Record[]; + provenanceFields: readonly string[]; + temporalProvenanceFields: readonly string[]; + rangeProvenance: readonly { field: string; startField: string; endField: string }[]; categoryField?: string; seriesField?: string; resolveGroupValue?: InteractionContext['resolveGroupValue']; legendFields?: Readonly>; + rangeLegendChannels?: readonly string[]; annotationMarkType?: string; /** The compiled spec carries the semantic selection stores. */ semanticStores?: boolean; @@ -51,7 +64,10 @@ export interface VegaInteractionPlan { renderHoverStyles?: Readonly>; renderSelectionStyles?: Readonly>; selectionBoundary?: Readonly; + continuousColorFocus?: Readonly; navigationChannels?: readonly ('x' | 'y')[]; + /** Polar templates realize the primary X brush as an angular sector. */ + angularXBrush?: boolean; navigationAxes?: Partial>; reorderAxis?: VegaReorderAxis; reorderAxes?: readonly VegaReorderAxis[]; diff --git a/packages/flint-js/src/vegalite/interactions/gestures/region.ts b/packages/flint-js/src/vegalite/interactions/gestures/region.ts index 7fb1f39c..cc72bb46 100644 --- a/packages/flint-js/src/vegalite/interactions/gestures/region.ts +++ b/packages/flint-js/src/vegalite/interactions/gestures/region.ts @@ -7,7 +7,8 @@ import type { SemanticTarget, } from '../../../interactive/interactions'; import { angularSectorPath } from '../../../interactive/geometry/angular'; -import { AngularRegionSession, type PolarFrame } from '../../../interactive/gestures/angular-region'; +import { normalizeRegionGuideOptions } from '../../../interactive/guides'; +import { AngularRegionSession, polarPointerAngle, type PolarFrame } from '../../../interactive/gestures/angular-region'; import { axisValue, cartesianDragDistance, @@ -24,6 +25,7 @@ import { clientToPlotPoint, interactionModifiers, normalizeVegaAngularRegionEvent, + normalizeVegaLassoEvent, normalizeVegaRegionEvent, plotToClientPoint, sceneItems, @@ -49,6 +51,7 @@ export interface VegaRegionGestureOptions { sync(): Promise; setSuppressClick(suppress: boolean): void; setDragging(dragging: boolean): void; + resetViewport?(): void; } export interface VegaRegionGestureController { @@ -56,6 +59,40 @@ export interface VegaRegionGestureController { destroy(): void; } +export function isInteractiveControlTarget(target: EventTarget | null): boolean { + const closest = (target as { closest?: (selector: string) => unknown } | null)?.closest; + return typeof closest === 'function' + && Boolean(closest.call(target, 'button, input, select, textarea, a[href], [role="button"]')); +} + +const circularAngleDistance = (left: number, right: number): number => + Math.abs(Math.atan2(Math.sin(left - right), Math.cos(left - right))); + +function angleInAngularSector(angle: number, sector: PlotAngularSector): boolean { + const sweep = sector.endAngle - sector.startAngle; + if (Math.abs(sweep) >= Math.PI * 2) return true; + const directedDistance = sweep >= 0 + ? (angle - sector.startAngle + Math.PI * 2) % (Math.PI * 2) + : (sector.startAngle - angle + Math.PI * 2) % (Math.PI * 2); + return directedDistance <= Math.abs(sweep); +} + +export function angularEditAction( + angle: number, + sector: PlotAngularSector, + edgeTolerance = 0.1, +): IntervalOperation | undefined { + if (circularAngleDistance(angle, sector.startAngle) <= edgeTolerance) return 'resize-leading'; + if (circularAngleDistance(angle, sector.endAngle) <= edgeTolerance) return 'resize-trailing'; + return angleInAngularSector(angle, sector) ? 'move' : undefined; +} + +export function pointInAngularSector(point: PlotPoint, sector: PlotAngularSector): boolean { + const radius = Math.hypot(point.x - sector.center.x, point.y - sector.center.y); + if (radius < sector.innerRadius || radius > sector.outerRadius) return false; + return angleInAngularSector(polarPointerAngle(point, sector), sector); +} + export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaRegionGestureController { const { view, @@ -72,10 +109,19 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR sync, setSuppressClick, setDragging, + resetViewport, } = options; const regionAxis: CartesianRegionAxis = interaction.eventSource.axis ?? 'xy'; const angularBrush = interaction.eventSource.regionGeometry === 'angular'; - const statefulBrush = interaction.eventSource.mode === 'stateful' && regionAxis !== 'xy'; + const lassoBrush = interaction.eventSource.regionGeometry === 'lasso'; + const statefulBrush = !angularBrush && !lassoBrush + && interaction.eventSource.mode === 'stateful' && regionAxis !== 'xy'; + const statefulAngular = angularBrush && interaction.eventSource.mode === 'stateful'; + const guide = interaction.eventSource.regionGuide ?? normalizeRegionGuideOptions(undefined); + let activeSector: PlotAngularSector | undefined; + let initialSector: PlotAngularSector | undefined; + let angularAction: IntervalOperation = 'create'; + let angularGrabAngle = 0; let committed = new Set(); let dragStart: PlotPoint | undefined; let pointerId: number | undefined; @@ -83,31 +129,57 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR let activeInterval: Interval | undefined; let initialInterval: Interval | undefined; let angularSession: AngularRegionSession | undefined; + let lassoPoints: PlotPoint[] = []; const overlay = document.createElement('div'); Object.assign(overlay.style, { position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', boxSizing: 'border-box', - border: '1px solid rgba(37, 99, 235, 0.85)', background: 'rgba(37, 99, 235, 0.12)', + border: `${guide.style.strokeWidth}px solid ${guide.style.stroke}`, + borderColor: `color-mix(in srgb, ${guide.style.stroke} ${guide.style.strokeOpacity * 100}%, transparent)`, + background: `color-mix(in srgb, ${guide.style.fill} ${guide.style.fillOpacity * 100}%, transparent)`, }); const angularOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const angularPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - angularPath.setAttribute('fill', 'rgba(37, 99, 235, 0.12)'); - angularPath.setAttribute('stroke', 'rgba(37, 99, 235, 0.85)'); - angularPath.setAttribute('stroke-width', '1'); + angularPath.setAttribute('fill', guide.style.fill); + angularPath.setAttribute('fill-opacity', `${guide.style.fillOpacity}`); + angularPath.setAttribute('stroke', guide.style.stroke); + angularPath.setAttribute('stroke-opacity', `${guide.style.strokeOpacity}`); + angularPath.setAttribute('stroke-width', `${guide.style.strokeWidth}`); angularPath.setAttribute('vector-effect', 'non-scaling-stroke'); angularOverlay.append(angularPath); Object.assign(angularOverlay.style, { position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', overflow: 'visible', }); + const lassoOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const lassoFill = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + // The region is filled as if closed because that is what gets captured, but the + // closing chord is never stroked while the path is still being drawn. + lassoFill.setAttribute('fill', guide.style.fill); + lassoFill.setAttribute('fill-opacity', `${guide.style.fillOpacity}`); + lassoFill.setAttribute('fill-rule', 'evenodd'); + lassoFill.setAttribute('stroke', 'none'); + const lassoPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + lassoPath.setAttribute('fill', 'none'); + lassoPath.setAttribute('stroke', guide.style.stroke); + lassoPath.setAttribute('stroke-opacity', `${guide.style.strokeOpacity}`); + lassoPath.setAttribute('stroke-width', `${guide.style.strokeWidth}`); + lassoPath.setAttribute('stroke-linejoin', 'round'); + lassoPath.setAttribute('stroke-linecap', 'round'); + lassoPath.setAttribute('vector-effect', 'non-scaling-stroke'); + lassoOverlay.append(lassoFill, lassoPath); + Object.assign(lassoOverlay.style, { + position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', overflow: 'visible', + }); + const previousPosition = container.style.position; const previousUserSelect = container.style.userSelect; const previousCursor = container.style.cursor; if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; container.style.userSelect = 'none'; container.style.cursor = 'crosshair'; - container.append(angularBrush ? angularOverlay : overlay); + container.append(angularBrush ? angularOverlay : lassoBrush ? lassoOverlay : overlay); container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; const localPoint = (event: PointerEvent): PlotPoint => { @@ -123,6 +195,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR return updateInterval(point, dragStart!, intervalAxis(), axisLimit(), dragAction, initialInterval); }; const showRegion = (a: PlotPoint, b: PlotPoint): void => { + if (!guide.visible) return; const constrained = constrainCartesianRegion(a, b, regionAxis, brushPlotSize()); const space = coordinateSpace(); const leading = plotToClientPoint({ @@ -171,6 +244,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; }; const showAngularSector = (sector: PlotAngularSector): void => { + if (!guide.visible) return; const space = coordinateSpace(); const renderer = container.querySelector('svg') as SVGSVGElement | null; const containerRect = container.getBoundingClientRect(); @@ -189,19 +263,78 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR center: { x: sector.center.x + space.originX, y: sector.center.y + space.originY }, })); }; + const angleDelta = (from: number, to: number): number => + Math.atan2(Math.sin(from - to), Math.cos(from - to)); + const sectorForEdit = (angle: number): PlotAngularSector | undefined => { + if (!initialSector) return undefined; + const delta = angleDelta(angle, angularGrabAngle); + if (angularAction === 'move') { + return { + ...initialSector, + startAngle: initialSector.startAngle + delta, + endAngle: initialSector.endAngle + delta, + }; + } + if (angularAction === 'resize-leading') { + return { ...initialSector, startAngle: initialSector.startAngle + delta }; + } + if (angularAction === 'resize-trailing') { + return { ...initialSector, endAngle: initialSector.endAngle + delta }; + } + return undefined; + }; const dispatchAngularRegion = ( phase: 'preview' | 'commit', sector: PlotAngularSector, event: PointerEvent, + operation: IntervalOperation | 'clear' = 'create', + target: SemanticTarget | null | undefined = undefined, ): void => { const normalized = normalizeVegaAngularRegionEvent( view, sector, phase, interaction.eventSource.match ?? 'intersect', - interactionModifiers(event), 'create', + interactionModifiers(event), operation, + ); + setSelected(new Set(committed)); + void dispatch({ + type: 'semantic', source: 'region', phase, + target: target === undefined ? resolveTarget('angular', 'region', normalized.hits) : target, + region: normalized.region, axis: normalized.axis, operation: normalized.operation, + modifiers: normalized.modifiers, + }); + }; + const showLasso = (points: readonly PlotPoint[]): void => { + if (!guide.visible) return; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(lassoOverlay.style, { + display: 'block', + left: `${rendererLayout.left}px`, + top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, + height: `${rendererLayout.height}px`, + }); + lassoOverlay.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + const outline = points + .map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x + space.originX} ${point.y + space.originY}`) + .join(' '); + lassoFill.setAttribute('d', `${outline} Z`); + lassoPath.setAttribute('d', outline); + }; + const dispatchLasso = ( + phase: 'preview' | 'commit', + points: readonly PlotPoint[], + event: PointerEvent, + ): void => { + const normalized = normalizeVegaLassoEvent( + view, points, phase, interaction.eventSource.match ?? 'intersect', interactionModifiers(event), ); setSelected(new Set(committed)); void dispatch({ type: 'semantic', source: 'region', phase, - target: resolveTarget('angular', 'region', normalized.hits), + target: resolveTarget('rectangle', 'region', normalized.hits), region: normalized.region, axis: normalized.axis, operation: normalized.operation, modifiers: normalized.modifiers, }); @@ -217,26 +350,42 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR const normalized = normalizeVegaRegionEvent( view, start, end, phase, interaction.eventSource.match ?? 'intersect', interactionModifiers(event), regionAxis, brushPlotSize(), operation, + !interaction.eventSource.viewport, ); setSelected(new Set(committed)); void dispatch({ type: 'semantic', source: 'region', phase, - target: target === undefined ? resolveTarget('rectangle', 'region', normalized.hits) : target, + target: target === undefined + ? interaction.eventSource.viewport ? null : resolveTarget('rectangle', 'region', normalized.hits) + : target, region: normalized.region, axis: normalized.axis, operation: normalized.operation, modifiers: normalized.modifiers, }); }; const pointerDown = (event: PointerEvent): void => { - if (event.button !== 0) return; + if (event.button !== 0 || isInteractiveControlTarget(event.target)) return; clearHover(); const point = localPoint(event); if (angularBrush) { const frame = frameAt(point); if (!frame) return; angularSession = new AngularRegionSession(point, frame); + angularAction = 'create'; + initialSector = undefined; + if (statefulAngular && activeSector) { + const angle = polarPointerAngle(point, frame); + angularAction = pointInAngularSector(point, activeSector) + ? angularEditAction(angle, activeSector) ?? 'create' + : 'create'; + if (angularAction !== 'create') { + initialSector = { ...activeSector }; + angularGrabAngle = angle; + } + } } dragAction = 'create'; initialInterval = activeInterval ? { ...activeInterval } : undefined; + if (lassoBrush) lassoPoints = [point]; if (statefulBrush && activeInterval) { const value = axisValue(point, intervalAxis()); const edgeTolerance = 8; @@ -252,6 +401,15 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR }; const pointerMove = (event: PointerEvent): void => { if (!dragStart || pointerId !== event.pointerId) { + if (statefulAngular && activeSector) { + const point = localPoint(event); + const action = pointInAngularSector(point, activeSector) + ? angularEditAction(polarPointerAngle(point, activeSector), activeSector) + : undefined; + container.style.cursor = action?.startsWith('resize') ? 'ew-resize' + : action === 'move' ? 'grab' : 'crosshair'; + return; + } if (statefulBrush && activeInterval) { const value = axisValue(localPoint(event), intervalAxis()); const nearEdge = Math.abs(value - activeInterval.leading) <= 8 @@ -263,7 +421,25 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR return; } const point = localPoint(event); + if (lassoBrush) { + const last = lassoPoints[lassoPoints.length - 1]; + if (last && Math.hypot(point.x - last.x, point.y - last.y) < 2) return; + lassoPoints.push(point); + if (lassoPoints.length < 3) return; + setSuppressClick(true); + showLasso(lassoPoints); + dispatchLasso('preview', lassoPoints, event); + return; + } if (angularBrush) { + if (initialSector && angularSession) { + const edited = sectorForEdit(polarPointerAngle(point, angularSession.frame)); + if (!edited) return; + setSuppressClick(true); + showAngularSector(edited); + dispatchAngularRegion('preview', edited, event, angularAction); + return; + } angularSession?.move(point); if (!angularSession || angularSession.dragDistance() < 4) return; setSuppressClick(true); @@ -283,13 +459,38 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR const finishDrag = (event: PointerEvent): void => { if (!dragStart || pointerId !== event.pointerId) return; const point = localPoint(event); - if (angularBrush) angularSession?.move(point); - const dragged = angularBrush && angularSession - ? angularSession.dragDistance() >= 4 - : cartesianDragDistance(dragStart, point, regionAxis) >= 4; + if (lassoBrush) { + if (lassoPoints.length >= 3) dispatchLasso('commit', lassoPoints, event); + else { + committed.clear(); + dispatchLasso('commit', [], event); + } + lassoPoints = []; + lassoOverlay.style.display = 'none'; + dragStart = undefined; + pointerId = undefined; + setDragging(false); + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + window.setTimeout(() => { setSuppressClick(false); }, 0); + return; + } + if (angularBrush && !initialSector) angularSession?.move(point); + const editedSector = initialSector && angularSession + ? sectorForEdit(polarPointerAngle(point, angularSession.frame)) + : undefined; + const dragged = editedSector + ? true + : angularBrush && angularSession + ? angularSession.dragDistance() >= 4 + : cartesianDragDistance(dragStart, point, regionAxis) >= 4; if (dragged) { if (angularBrush) { - dispatchAngularRegion('commit', angularSession!.sector(), event); + const sector = editedSector ?? angularSession!.sector(); + dispatchAngularRegion('commit', sector, event, editedSector ? angularAction : 'create'); + if (statefulAngular) { + activeSector = sector; + showAngularSector(sector); + } } else { const interval = regionAxis === 'xy' ? undefined : intervalForDrag(point); const points = interval ? intervalPoints(interval, intervalAxis()) : { start: dragStart, end: point }; @@ -299,22 +500,34 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR showInterval(interval); } } - } else { - const clickedOutside = !activeInterval || axisValue(point, intervalAxis()) < activeInterval.leading - || axisValue(point, intervalAxis()) > activeInterval.trailing; - if (!statefulBrush || clickedOutside) { - activeInterval = undefined; - committed.clear(); - dispatchRegion('commit', dragStart, point, event, 'clear', null); + } else if (!interaction.eventSource.viewport) { + if (statefulAngular) { + const clickedOutside = !activeSector || !pointInAngularSector(point, activeSector); + if (clickedOutside) { + const clearSector = activeSector ?? angularSession?.sector(); + activeSector = undefined; + committed.clear(); + if (clearSector) dispatchAngularRegion('commit', clearSector, event, 'clear', null); + } + } else { + const clickedOutside = !activeInterval || axisValue(point, intervalAxis()) < activeInterval.leading + || axisValue(point, intervalAxis()) > activeInterval.trailing; + if (!statefulBrush || clickedOutside) { + activeInterval = undefined; + committed.clear(); + dispatchRegion('commit', dragStart, point, event, 'clear', null); + } } } dragStart = undefined; pointerId = undefined; initialInterval = undefined; + initialSector = undefined; + angularAction = 'create'; angularSession = undefined; setDragging(false); if (!statefulBrush || !activeInterval) overlay.style.display = 'none'; - angularOverlay.style.display = 'none'; + if (!statefulAngular || !activeSector) angularOverlay.style.display = 'none'; if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); }; @@ -325,21 +538,32 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR pointerId = undefined; initialInterval = undefined; angularSession = undefined; + lassoPoints = []; + lassoOverlay.style.display = 'none'; setDragging(false); if (statefulBrush && activeInterval) showInterval(activeInterval); else overlay.style.display = 'none'; - angularOverlay.style.display = 'none'; + if (statefulAngular && initialSector) { + activeSector = initialSector; + showAngularSector(activeSector); + } else if (!statefulAngular || !activeSector) { + angularOverlay.style.display = 'none'; + } + initialSector = undefined; + angularAction = 'create'; if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); void sync(); }; const keyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return; + if (interaction.eventSource.viewport) resetViewport?.(); if (dragStart) { setSelected(new Set(committed)); if (statefulBrush && initialInterval) activeInterval = initialInterval; } else { setSelected(new Set()); activeInterval = undefined; + activeSector = undefined; clearAnnotation(); } dragStart = undefined; @@ -350,16 +574,23 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR angularOverlay.style.display = 'none'; void sync(); }; + const doubleClick = (event: MouseEvent): void => { + if (!interaction.eventSource.viewport) return; + event.preventDefault(); + resetViewport?.(); + }; container.addEventListener('pointerdown', pointerDown, true); container.addEventListener('pointermove', pointerMove, true); container.addEventListener('pointerup', finishDrag, true); container.addEventListener('pointercancel', cancelDrag, true); container.addEventListener('keydown', keyDown); + container.addEventListener('dblclick', doubleClick); return { sync(): void { if (statefulBrush && activeInterval) showInterval(activeInterval); + if (statefulAngular && activeSector) showAngularSector(activeSector); }, destroy(): void { container.removeEventListener('pointerdown', pointerDown, true); @@ -367,8 +598,10 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR container.removeEventListener('pointerup', finishDrag, true); container.removeEventListener('pointercancel', cancelDrag, true); container.removeEventListener('keydown', keyDown); + container.removeEventListener('dblclick', doubleClick); overlay.remove(); angularOverlay.remove(); + lassoOverlay.remove(); setDragging(false); container.style.position = previousPosition; container.style.userSelect = previousUserSelect; diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts index 947ab198..cafff8d6 100644 --- a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -1,4 +1,11 @@ -import { semanticVisualFamily, type RenderHit } from '../../core/interaction-semantics'; +import { + associateSemanticElementRenderKeys, + semanticVisualFamily, + semanticElementRenderKeys, + type RenderHit, + type SemanticTarget, + type LegendTargetValue, +} from '../../core/interaction-semantics'; import type { ElementInteractionEvent, InteractionModifiers, @@ -14,6 +21,7 @@ export { clientRectToLayoutRect, clientToLayoutPoint, clientToPlotPoint, + clientToRendererPoint, interactionModifiers, plotToClientPoint, rendererPlotOrigin, @@ -22,6 +30,8 @@ export { export const INTERACTION_KEY = '__flint_interaction_key'; export const INTERACTION_ROLE = '__flint_interaction_role'; +export const INTERACTION_LEGEND_CHANNEL = '__flint_legend_channel'; +export const INTERACTION_LEGEND_FIELD = '__flint_legend_field'; export const PATH_KEY_SUFFIX = '|__flint_path'; const SUPPORTED_RENDER_MARKS = new Set(['arc', 'area', 'bar', 'line', 'rect', 'rule', 'shape', 'symbol', 'text']); @@ -33,18 +43,72 @@ export interface SelectionRect { y2: number; } +export interface LegendHitIdentity extends LegendTargetValue { + value: unknown; + visualBounds?: SelectionRect; +} + interface PathGeometry { kind: 'segment' | 'slice'; points: PlotPoint[]; annotationPoints?: PlotPoint[]; offset: PlotPoint; endDatum?: Record; + closed?: boolean; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +const LEGEND_SEGMENT_TARGET_PX = 44; +const MIN_LEGEND_SEGMENTS = 3; +const MAX_LEGEND_SEGMENTS = 7; + +export function continuousLegendSegmentCount(span: number, distinctValues = Infinity): number { + const physical = clamp( + Math.round(Math.max(0, span) / LEGEND_SEGMENT_TARGET_PX), + MIN_LEGEND_SEGMENTS, + MAX_LEGEND_SEGMENTS, + ); + return Math.max(1, Math.min(physical, Math.max(1, Math.floor(distinctValues)))); +} + +function signedPow(value: number, exponent: number): number { + return Math.sign(value) * Math.abs(value) ** exponent; +} + +function continuousScaleValue(scale: any, fraction: number): number | undefined { + const domain = typeof scale?.domain === 'function' ? scale.domain() : undefined; + if (!Array.isArray(domain) || domain.length < 2) return undefined; + const values = domain.map((entry: unknown) => entry instanceof Date ? entry.getTime() : Number(entry)); + if (values.some((entry: number) => !Number.isFinite(entry))) return undefined; + const position = clamp(fraction, 0, 1) * (values.length - 1); + const index = Math.min(values.length - 2, Math.floor(position)); + const local = position - index; + const start = values[index]; + const end = values[index + 1]; + const type = String(scale?.type ?? 'linear'); + let transform = (value: number): number => value; + let untransform = (value: number): number => value; + if (type.includes('symlog')) { + const constant = typeof scale.constant === 'function' ? scale.constant() : 1; + transform = (value) => Math.sign(value) * Math.log1p(Math.abs(value / constant)); + untransform = (value) => Math.sign(value) * Math.expm1(Math.abs(value)) * constant; + } else if (type.includes('log') && start !== 0 && end !== 0 && Math.sign(start) === Math.sign(end)) { + transform = (value) => Math.sign(value) * Math.log(Math.abs(value)); + untransform = (value) => Math.sign(value) * Math.exp(Math.abs(value)); + } else if (type.includes('sqrt')) { + transform = (value) => signedPow(value, 0.5); + untransform = (value) => signedPow(value, 2); + } else if (type.includes('pow')) { + const exponent = typeof scale.exponent === 'function' ? scale.exponent() : 1; + transform = (value) => signedPow(value, exponent); + untransform = (value) => signedPow(value, 1 / exponent); + } + return untransform(transform(start) + (transform(end) - transform(start)) * local); +} + function keyOfDatum(datum: unknown): string | undefined { if (!datum || typeof datum !== 'object') return undefined; const key = (datum as Record)[INTERACTION_KEY]; @@ -65,20 +129,23 @@ export function pathHoverPresentationKey(items: readonly any[], semanticKey: str return pathKey ? `${pathKey}${PATH_KEY_SUFFIX}` : semanticKey; } -function pathGeometry(item: any, offsetX: number, offsetY: number): PathGeometry | null { +function pathGeometry(item: any, offsetX: number, offsetY: number, siblingIndex?: number): PathGeometry | null { const items = item?.mark?.items; if (!Array.isArray(items)) return null; - const index = items.indexOf(item); + const index = siblingIndex ?? items.indexOf(item); if (index < 0) return null; const point = (candidate: any): PlotPoint => ({ x: candidate.x + offsetX, y: candidate.y + offsetY }); if (item.mark.marktype === 'line') { - if (index >= items.length - 1) return null; + const closed = typeof item.interpolate === 'string' && item.interpolate.endsWith('-closed'); + const next = index < items.length - 1 ? items[index + 1] : closed ? items[0] : undefined; + if (!next) return null; return { kind: 'segment', - points: [point(item), point(items[index + 1])], - annotationPoints: [point(item), point(items[index + 1])], + points: [point(item), point(next)], + annotationPoints: [point(item), point(next)], offset: { x: offsetX, y: offsetY }, - endDatum: items[index + 1].datum, + endDatum: next.datum, + closed, }; } if (item.mark.marktype !== 'area' @@ -113,10 +180,10 @@ function pathGeometry(item: any, offsetX: number, offsetY: number): PathGeometry export function sceneItems(view: any): any[] { const result: any[] = []; - const visit = (item: any, offsetX: number, offsetY: number): void => { + const visit = (item: any, offsetX: number, offsetY: number, siblingIndex?: number): void => { if (!item) return; if (SUPPORTED_RENDER_MARKS.has(item.mark?.marktype) && keyOfDatum(item.datum) && item.bounds) { - const interactionGeometry = pathGeometry(item, offsetX, offsetY); + const interactionGeometry = pathGeometry(item, offsetX, offsetY, siblingIndex); if ((item.mark.marktype === 'line' || item.mark.marktype === 'area') && !interactionGeometry) return; const points = interactionGeometry?.points; result.push({ @@ -141,7 +208,7 @@ export function sceneItems(view: any): any[] { const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); if (Array.isArray(item.items)) { - for (const child of item.items) visit(child, childOffsetX, childOffsetY); + item.items.forEach((child: any, index: number) => visit(child, childOffsetX, childOffsetY, index)); } }; visit(view.scenegraph()?.root, 0, 0); @@ -195,6 +262,28 @@ function pointInPolygon(point: PlotPoint, polygon: readonly PlotPoint[]): boolea return inside; } +/** + * Lasso capture matches the rectangle brush: `intersect` is a real area overlap + * rather than a sample of the mark's centre and corners. + */ +function polygonIntersectsRect(polygon: readonly PlotPoint[], rect: SelectionRect): boolean { + const corners: PlotPoint[] = [ + { x: rect.x1, y: rect.y1 }, { x: rect.x2, y: rect.y1 }, + { x: rect.x2, y: rect.y2 }, { x: rect.x1, y: rect.y2 }, + ]; + if (corners.some((corner) => pointInPolygon(corner, polygon))) return true; + if (polygon.some((vertex) => pointInRect(vertex, rect))) return true; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + for (let corner = 0; corner < corners.length; corner++) { + if (segmentsIntersect( + polygon[previous], polygon[current], + corners[corner], corners[(corner + 1) % corners.length], + )) return true; + } + } + return false; +} + export function geometryIntersectsRect(geometry: PathGeometry, rect: SelectionRect, contain: boolean): boolean { if (contain) return geometry.points.every((point) => pointInRect(point, rect)); if (geometry.points.some((point) => pointInRect(point, rect))) return true; @@ -275,6 +364,67 @@ export function arcIntersectsAngularSector( )); } +export function polarFrameFromItems( + items: readonly any[], + point?: PlotPoint, +): { center: PlotPoint; innerRadius: number; outerRadius: number } | undefined { + const frames = new Map(); + for (const item of items) { + if (item?.mark?.marktype !== 'arc' || typeof item.x !== 'number' || typeof item.y !== 'number' + || typeof item.innerRadius !== 'number' || typeof item.outerRadius !== 'number') continue; + const key = `${item.x}\u0000${item.y}`; + const existing = frames.get(key); + frames.set(key, existing ? { + center: existing.center, + innerRadius: Math.min(existing.innerRadius, item.innerRadius), + outerRadius: Math.max(existing.outerRadius, item.outerRadius), + } : { + center: { x: item.x, y: item.y }, + innerRadius: item.innerRadius, + outerRadius: item.outerRadius, + }); + } + const available = [...frames.values()]; + if (!point) return available[0]; + return available.sort((left, right) => + Math.hypot(point.x - left.center.x, point.y - left.center.y) + - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; +} + +export function polarGuideSegment( + frame: { center: PlotPoint; outerRadius: number }, + point: PlotPoint, +): { start: PlotPoint; end: PlotPoint } { + const dx = point.x - frame.center.x; + const dy = point.y - frame.center.y; + const distance = Math.hypot(dx, dy); + const scale = distance > 0 ? frame.outerRadius / distance : 0; + return { + start: frame.center, + end: distance > 0 + ? { x: frame.center.x + dx * scale, y: frame.center.y + dy * scale } + : { x: frame.center.x, y: frame.center.y - frame.outerRadius }, + }; +} + +export function polarInspectHits( + items: readonly any[], + point: PlotPoint, + frame: { center: PlotPoint }, +): RenderHit[] { + const angle = ((Math.atan2(point.x - frame.center.x, frame.center.y - point.y) % (2 * Math.PI)) + + 2 * Math.PI) % (2 * Math.PI); + return items + .filter((item) => item?.mark?.marktype === 'arc' + && Number.isFinite(item.startAngle) && Number.isFinite(item.endAngle) + && Math.hypot(item.x - frame.center.x, item.y - frame.center.y) <= 1 + && angularSegments(item.startAngle, item.endAngle).some( + ([start, end]) => angle >= start - 1e-9 && angle <= end + 1e-9, + )) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + export function angularRegionHits( view: any, sector: PlotAngularSector, @@ -316,6 +466,9 @@ export function renderHit(item: any): RenderHit | null { return { datum, endDatum: item.interactionGeometry?.endDatum, + pathData: markType === 'line' || markType === 'area' + ? item.mark.items?.map((pathItem: any) => pathItem.datum).filter(Boolean) + : undefined, source: 'mark', markType: item.mark?.marktype, markName: item.mark?.name, @@ -349,21 +502,245 @@ export function physicalItemAt(view: any, item: any, point: PlotPoint): any { export function legendTarget( item: any, legendFields?: Readonly>, -): { channel?: string; value: unknown; field?: string } | null { + rangeLegendChannels: readonly string[] = [], + view?: any, + rootPoint?: PlotPoint, +): LegendHitIdentity | null { + if (item?.datum?.[INTERACTION_ROLE] === 'legend-label') { + const channel = item.datum[INTERACTION_LEGEND_CHANNEL]; + const field = item.datum[INTERACTION_LEGEND_FIELD]; + const value = typeof field === 'string' ? item.datum[field] : undefined; + if (typeof channel !== 'string' || typeof field !== 'string' || value === undefined) return null; + return { channel, field, value, domain: { kind: 'value', value } }; + } const isLegend = semanticVisualFamily(item?.mark?.role) === 'legend'; if (!isLegend) return null; - const scales = item?.mark?.group?.mark?.group?.datum?.scales - ?? item?.mark?.group?.mark?.group?.mark?.group?.datum?.scales; + let legendEntry = item?.mark?.group; + while (legendEntry && !legendEntry.datum?.scales) legendEntry = legendEntry.mark?.group; + const scales = legendEntry?.datum?.scales; const channel = scales && typeof scales === 'object' ? Object.keys(scales).map((key) => key === 'fill' || key === 'stroke' ? 'color' : key)[0] : undefined; - return { channel, value: item?.datum?.value, field: channel ? legendFields?.[channel] : undefined }; + let value = item?.datum?.value; + let range: { min?: number; max?: number } | undefined; + let visualBounds: SelectionRect | undefined; + if (channel && rangeLegendChannels.includes(channel)) { + const anchors: { index: number; value: number; perc?: number }[] = []; + const visit = (candidate: any): void => { + const anchor = candidate?.datum?.value; + const numeric = anchor instanceof Date ? anchor.getTime() : anchor; + if (typeof numeric === 'number') { + anchors.push({ + index: typeof candidate.datum.index === 'number' ? candidate.datum.index : anchors.length, + value: numeric, + ...(typeof candidate.datum.perc === 'number' ? { perc: candidate.datum.perc } : {}), + }); + } + if (Array.isArray(candidate?.items)) candidate.items.forEach(visit); + }; + visit(legendEntry); + const unique = [...new Map(anchors.map((anchor) => [anchor.index, anchor])).values()] + .sort((left, right) => left.index - right.index); + if (item.mark.role === 'legend-gradient' && rootPoint && view) { + const bounds = rootBoundsForItem(view, item); + const scaleName = scales && typeof scales === 'object' + ? Object.entries(scales).find(([key]) => + (key === 'fill' || key === 'stroke' ? 'color' : key) === channel)?.[1] + : undefined; + const scale = typeof scaleName === 'string' && typeof view.scale === 'function' + ? view.scale(scaleName) + : undefined; + if (bounds && unique.length > 0) { + const vertical = legendEntry?.datum?.vgrad === true; + const span = vertical ? bounds.y2 - bounds.y1 : bounds.x2 - bounds.x1; + const position = vertical ? bounds.y2 - rootPoint.y : rootPoint.x - bounds.x1; + const fraction = span > 0 ? clamp(position / span, 0, 1) : 0; + const segmentCount = continuousLegendSegmentCount(span); + const index = Math.min(segmentCount - 1, Math.floor(fraction * segmentCount)); + const lower = index / segmentCount; + const upper = (index + 1) / segmentCount; + const fallbackValue = (unique[0].value + + (unique[unique.length - 1].value - unique[0].value) * ((lower + upper) / 2)); + value = continuousScaleValue(scale, (lower + upper) / 2) ?? fallbackValue; + range = { + ...(index > 0 ? { + min: continuousScaleValue(scale, lower) + ?? unique[0].value + (unique[unique.length - 1].value - unique[0].value) * lower, + } : {}), + ...(index < segmentCount - 1 ? { + max: continuousScaleValue(scale, upper) + ?? unique[0].value + (unique[unique.length - 1].value - unique[0].value) * upper, + } : {}), + }; + const width = bounds.x2 - bounds.x1; + const height = bounds.y2 - bounds.y1; + visualBounds = vertical + ? { x1: bounds.x1, x2: bounds.x2, y1: bounds.y2 - upper * height, y2: bounds.y2 - lower * height } + : { x1: bounds.x1 + lower * width, x2: bounds.x1 + upper * width, y1: bounds.y1, y2: bounds.y2 }; + } + } + const numericValue = value instanceof Date ? value.getTime() : value; + const index = unique.findIndex((anchor) => anchor.value === numericValue); + if (item.mark.role === 'legend-band' && index >= 0) { + const min = unique[index].value; + const max = unique[index + 1]?.value; + range = { + ...(Number.isFinite(min) ? { min } : {}), + ...(Number.isFinite(max) ? { max } : {}), + }; + value = min; + } + else if (!range && index >= 0 && unique.length > 1) { + range = { + ...(index > 0 ? { min: (unique[index - 1].value + unique[index].value) / 2 } : {}), + ...(index < unique.length - 1 ? { max: (unique[index].value + unique[index + 1].value) / 2 } : {}), + }; + } + } + if (value === undefined) return null; + return { + channel, + value, + field: channel ? legendFields?.[channel] : undefined, + domain: range + ? { kind: 'interval', ...(range.min !== undefined ? { start: range.min } : {}), ...(range.max !== undefined ? { end: range.max } : {}) } + : { kind: 'value', value }, + ...(visualBounds ? { visualBounds } : {}), + }; +} + +function rootBoundsForItem(view: any, target: any): SelectionRect | undefined { + const element = target?._svg as SVGGraphicsElement | undefined; + const svg = element?.ownerSVGElement; + if (svg && typeof element.getBoundingClientRect === 'function') { + const itemRect = element.getBoundingClientRect(); + const svgRect = svg.getBoundingClientRect(); + const viewBox = svg.viewBox.baseVal; + if (svgRect.width > 0 && svgRect.height > 0 && viewBox.width > 0 && viewBox.height > 0) { + const scaleX = viewBox.width / svgRect.width; + const scaleY = viewBox.height / svgRect.height; + return { + x1: viewBox.x + (itemRect.left - svgRect.left) * scaleX, + y1: viewBox.y + (itemRect.top - svgRect.top) * scaleY, + x2: viewBox.x + (itemRect.right - svgRect.left) * scaleX, + y2: viewBox.y + (itemRect.bottom - svgRect.top) * scaleY, + }; + } + } + let result: SelectionRect | undefined; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item || result) return; + if (item === target && item.bounds) { + result = { + x1: item.bounds.x1 + offsetX, + y1: item.bounds.y1 + offsetY, + x2: item.bounds.x2 + offsetX, + y2: item.bounds.y2 + offsetY, + }; + return; + } + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (Array.isArray(item.items)) item.items.forEach((child: any) => visit(child, childOffsetX, childOffsetY)); + }; + visit(view.scenegraph()?.root, 0, 0); + return result; +} + +function legendOwner(item: any): any { + let owner = item?.mark?.group; + while (owner && !owner.datum?.scales) owner = owner.mark?.group; + return owner; +} + +function legendEntryCandidates(view: any): { item: any; bounds: SelectionRect }[] { + const entries = new Map>(); + const visit = (item: any): void => { + const role = item?.mark?.role; + const owner = (role === 'legend-symbol' || role === 'legend-label') + ? legendOwner(item) + : undefined; + if (owner && item.datum?.value !== undefined) { + const value = item.datum.value instanceof Date ? item.datum.value.getTime() : item.datum.value; + const bounds = rootBoundsForItem(view, item); + if (bounds) { + let byValue = entries.get(owner); + if (!byValue) { + byValue = new Map(); + entries.set(owner, byValue); + } + const existing = byValue.get(value); + byValue.set(value, existing ? { + item: existing.item, + bounds: { + x1: Math.min(existing.bounds.x1, bounds.x1), + y1: Math.min(existing.bounds.y1, bounds.y1), + x2: Math.max(existing.bounds.x2, bounds.x2), + y2: Math.max(existing.bounds.y2, bounds.y2), + }, + } : { item, bounds }); + } + } + if (Array.isArray(item?.items)) item.items.forEach(visit); + }; + visit(view.scenegraph()?.root); + return [...entries.values()].flatMap((byValue) => [...byValue.values()]); +} + +export function legendEntryItemAtPoint( + view: any, + point: PlotPoint, + padding = 3, +): any | null { + return nearestItemByBounds(legendEntryCandidates(view), point, padding)?.item ?? null; +} + +/** Nearest keyed mark or native legend entry within the same physical assist radius. */ +export function nearestInteractiveSceneItem( + view: any, + plotPoint: PlotPoint, + maxDistance: number, + rootPoint: PlotPoint = plotPoint, + includeMarks = true, +): any | undefined { + const items = sceneItems(view); + const generatedLegend = nearestItemByBounds( + items.filter((item) => item.datum?.[INTERACTION_ROLE] === 'legend-label'), + plotPoint, + maxDistance, + ); + const mark = includeMarks + ? nearestItemByBounds(items.filter((item) => item.datum?.[INTERACTION_ROLE] !== 'legend-label'), plotPoint, maxDistance) + : undefined; + const nativeLegend = nearestItemByBounds(legendEntryCandidates(view), rootPoint, maxDistance); + const candidates = [ + ...(mark ? [{ item: mark, distance: distanceToItem(plotPoint, mark) }] : []), + ...(generatedLegend ? [{ item: generatedLegend, distance: distanceToItem(plotPoint, generatedLegend) }] : []), + ...(nativeLegend ? [{ item: nativeLegend.item, distance: distanceToItem(rootPoint, nativeLegend) }] : []), + ]; + return candidates.sort((left, right) => left.distance - right.distance)[0]?.item; +} + +export function legendSemanticTarget( + legend: LegendHitIdentity | null, +): SemanticTarget | null { + if (!legend) return null; + const value: LegendTargetValue = { + ...(legend.channel ? { channel: legend.channel } : {}), + ...(legend.field ? { field: legend.field } : {}), + domain: legend.domain, + }; + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ value }], + }; } export interface NormalizedVegaElement { event: ElementInteractionEvent; role: 'mark' | 'legend-item' | 'text-label'; - legend: { channel?: string; value: unknown; field?: string } | null; + legend: LegendHitIdentity | null; } export function normalizeVegaElementEvent( @@ -373,15 +750,22 @@ export function normalizeVegaElementEvent( phase: 'preview' | 'commit' | 'cancel', modifiers: InteractionModifiers, legendFields?: Readonly>, + rangeLegendChannels?: readonly string[], + rootPoint?: PlotPoint, ): NormalizedVegaElement { - const legend = legendTarget(item, legendFields); + const directLegend = legendTarget(item, legendFields, rangeLegendChannels, view, rootPoint); + const legendItem = directLegend || !rootPoint + ? item + : legendEntryItemAtPoint(view, rootPoint) ?? item; + const legend = directLegend + ?? legendTarget(legendItem, legendFields, rangeLegendChannels, view, rootPoint); const physicalItem = physicalItemAt(view, item, point); const hit = renderHit(physicalItem ?? item); return { event: { type: 'element', phase, - hits: hit ? [hit] : legend ? [{ datum: item?.datum ?? {}, source: 'legend-item' }] : [], + hits: hit ? [hit] : legend ? [{ datum: legendItem?.datum ?? {}, source: 'legend-item' }] : [], point, modifiers, }, @@ -394,6 +778,297 @@ export function normalizeVegaElementEvent( }; } +/** Nearest item to a plot point, for pointer acquisition that does not require a direct hit. */ +export function nearestItemByBounds( + items: readonly any[], + point: PlotPoint, + maxDistance: number, +): any | undefined { + let best: { item: any; distance: number } | undefined; + for (const item of items) { + const bounds = item?.bounds; + if (!bounds) continue; + const distance = distanceToItem(point, item); + if (distance > maxDistance) continue; + if (!best || distance < best.distance) best = { item, distance }; + } + return best?.item; +} + +function distanceToBounds(point: PlotPoint, bounds: SelectionRect): number { + const dx = point.x < bounds.x1 ? bounds.x1 - point.x : point.x > bounds.x2 ? point.x - bounds.x2 : 0; + const dy = point.y < bounds.y1 ? bounds.y1 - point.y : point.y > bounds.y2 ? point.y - bounds.y2 : 0; + return Math.hypot(dx, dy); +} + +function distanceToSegment(point: PlotPoint, start: PlotPoint, end: PlotPoint): number { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lengthSquared = dx * dx + dy * dy; + const ratio = lengthSquared === 0 ? 0 : clamp( + ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, + 0, + 1, + ); + return Math.hypot( + point.x - (start.x + ratio * dx), + point.y - (start.y + ratio * dy), + ); +} + +function distanceToItem(point: PlotPoint, item: any): number { + const geometry = item?.interactionGeometry as PathGeometry | undefined; + if (geometry?.points.length) { + if (geometry.kind === 'slice' && pointInPolygon(point, geometry.points)) return 0; + const segmentCount = geometry.kind === 'slice' ? geometry.points.length : geometry.points.length - 1; + let geometryDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < segmentCount; index += 1) { + geometryDistance = Math.min(geometryDistance, distanceToSegment( + point, + geometry.points[index], + geometry.points[(index + 1) % geometry.points.length], + )); + } + return geometryDistance; + } + const polygon = arcPolygon(item); + if (!polygon) return item?.bounds ? distanceToBounds(point, item.bounds) : Number.POSITIVE_INFINITY; + if (pointInPolygon(point, polygon)) return 0; + let distance = Number.POSITIVE_INFINITY; + for (let index = 0; index < polygon.length; index += 1) { + distance = Math.min(distance, distanceToSegment(point, polygon[index], polygon[(index + 1) % polygon.length])); + } + return distance; +} + +export function nearestSceneItem(view: any, point: PlotPoint, maxDistance: number): any | undefined { + return nearestItemByBounds(sceneItems(view), point, maxDistance); +} + +export type SpatialDirection = 'left' | 'right' | 'up' | 'down'; + +function itemCenter(item: any): PlotPoint { + return { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; +} + +/** + * Nearest item strictly in one direction, preferring candidates aligned with the + * travel axis so arrows read as left/right and up/down rather than list order. + */ +export function nextItemInDirection( + items: readonly any[], + from: PlotPoint, + direction: SpatialDirection, +): any | undefined { + const horizontal = direction === 'left' || direction === 'right'; + let best: { item: any; score: number } | undefined; + for (const item of items) { + if (!item?.bounds) continue; + const center = itemCenter(item); + const dx = center.x - from.x; + const dy = center.y - from.y; + const along = direction === 'right' ? dx : direction === 'left' ? -dx : direction === 'down' ? dy : -dy; + if (along <= 0.5) continue; + const across = Math.abs(horizontal ? dy : dx); + const score = along + across * 3; + if (!best || score < best.score) best = { item, score }; + } + return best?.item; +} + +export function axisIntersectingHits( + items: readonly any[], + coordinate: number, + mode: 'x' | 'y', +): RenderHit[] { + const segmentCrosses = (start: PlotPoint, end: PlotPoint): boolean => { + const leading = mode === 'x' ? start.x : start.y; + const trailing = mode === 'x' ? end.x : end.y; + const minimum = Math.min(leading, trailing); + const maximum = Math.max(leading, trailing); + return minimum === maximum + ? Math.abs(coordinate - minimum) <= 1e-6 + : coordinate >= minimum && coordinate < maximum; + }; + return items + .filter((item) => { + const geometry = item.interactionGeometry?.points as readonly PlotPoint[] | undefined; + if (geometry?.length) { + const closed = item.interactionGeometry.kind === 'slice'; + const segmentCount = closed ? geometry.length : geometry.length - 1; + for (let index = 0; index < segmentCount; index += 1) { + if (segmentCrosses(geometry[index], geometry[(index + 1) % geometry.length])) return true; + } + return false; + } + const polygon = arcPolygon(item); + if (polygon) { + return polygon.some((point, index) => + segmentCrosses(point, polygon[(index + 1) % polygon.length])); + } + if (!item.bounds) return false; + return mode === 'x' + ? coordinate >= item.bounds.x1 && coordinate < item.bounds.x2 + : coordinate >= item.bounds.y1 && coordinate < item.bounds.y2; + }) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +type InspectComparison = '<' | '<=' | '=' | '>=' | '>'; + +/** Acquires marks around a raw inspect point without changing the guide position. */ +export function tolerantInspectHits( + items: readonly any[], + point: PlotPoint, + mode: 'x' | 'y' | 'xy', + predicate: { x?: InspectComparison; y?: InspectComparison }, + tolerance: { x: number; y: number }, +): RenderHit[] { + const xComparison = predicate.x; + const yComparison = predicate.y; + const directionalQuarter = mode === 'xy' + && xComparison !== undefined && xComparison !== '=' + && yComparison !== undefined && yComparison !== '='; + if (directionalQuarter) { + const intersectsOnAxis = ( + start: number, + end: number, + comparison: Exclude') return end > boundary; + return end >= boundary; + }; + return items + .filter((item) => { + return item?.bounds + && intersectsOnAxis(item.bounds.x1, item.bounds.x2, xComparison, point.x) + && intersectsOnAxis(item.bounds.y1, item.bounds.y2, yComparison, point.y); + }) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); + } + const axisMatches = ( + item: any, + axis: 'x' | 'y', + comparison: InspectComparison, + boundary: number, + distance: number, + ): boolean => { + if (!item?.bounds) return false; + const start = axis === 'x' ? item.bounds.x1 : item.bounds.y1; + const end = axis === 'x' ? item.bounds.x2 : item.bounds.y2; + if (comparison === '=') return boundary >= start - distance && boundary <= end + distance; + if (comparison === '<') return start < boundary + distance; + if (comparison === '<=') return start <= boundary + distance; + if (comparison === '>') return end > boundary - distance; + return end >= boundary - distance; + }; + const acquire = (distance: { x: number; y: number }): any[] => items + .filter((item) => { + if (mode === 'xy' && (predicate.x ?? '=') === '=' && (predicate.y ?? '=') === '=') { + const rect = { + x1: point.x - distance.x, + y1: point.y - distance.y, + x2: point.x + distance.x, + y2: point.y + distance.y, + }; + if (item.interactionGeometry) return geometryIntersectsRect(item.interactionGeometry, rect, false); + if (arcPolygon(item)) return arcIntersectsRect(item, rect); + return item?.bounds + && point.x >= item.bounds.x1 - distance.x && point.x <= item.bounds.x2 + distance.x + && point.y >= item.bounds.y1 - distance.y && point.y <= item.bounds.y2 + distance.y; + } + const matchesX = mode === 'y' || axisMatches(item, 'x', predicate.x ?? '=', point.x, distance.x); + const matchesY = mode === 'x' || axisMatches(item, 'y', predicate.y ?? '=', point.y, distance.y); + return matchesX && matchesY; + }); + const render = (matchedItems: readonly any[]): RenderHit[] => matchedItems + .map(renderHit).filter((hit): hit is RenderHit => hit !== null); + const equalityAxis = (mode === 'x' && (predicate.x ?? '=') === '=') + || (mode === 'y' && (predicate.y ?? '=') === '='); + const inspectAxisSlice = (): RenderHit[] => { + const axis = mode as 'x' | 'y'; + const coordinate = axis === 'x' ? point.x : point.y; + const exactHits = axisIntersectingHits(items, coordinate, axis); + if (exactHits.length > 0) return exactHits; + const distance = axis === 'x' ? tolerance.x : tolerance.y; + const candidates = items.filter((item) => item?.bounds && axisMatches(item, axis, '=', coordinate, distance)); + if (candidates.length === 0) return []; + const range = (item: any): { start: number; end: number } => axis === 'x' + ? { start: item.bounds.x1, end: item.bounds.x2 } + : { start: item.bounds.y1, end: item.bounds.y2 }; + const gap = (item: any): number => { + const { start, end } = range(item); + return coordinate < start ? start - coordinate : coordinate >= end ? coordinate - end : 0; + }; + const winner = candidates.reduce((best, candidate) => { + const difference = gap(candidate) - gap(best); + if (difference < -1e-6) return candidate; + if (Math.abs(difference) > 1e-6) return best; + const candidateRange = range(candidate); + const bestRange = range(best); + const candidateCenter = (candidateRange.start + candidateRange.end) / 2; + const bestCenter = (bestRange.start + bestRange.end) / 2; + return Math.abs(candidateCenter - coordinate) <= Math.abs(bestCenter - coordinate) ? candidate : best; + }); + const { start, end } = range(winner); + const inset = Math.min(0.25, Math.max(0, end - start) / 2); + const selectedCoordinate = coordinate <= start + ? start + inset + : coordinate >= end + ? end - inset + : coordinate; + return axisIntersectingHits(items, selectedCoordinate, axis); + }; + + if (equalityAxis) return inspectAxisSlice(); + + const exact = acquire({ x: 0, y: 0 }); + if (exact.length > 0) return render(exact); + + const candidates = acquire(tolerance); + if (candidates.length === 0) return []; + if (mode === 'xy' && (predicate.x ?? '=') === '=' && (predicate.y ?? '=') === '=') { + const winner = candidates.reduce((best, candidate) => + distanceToItem(point, candidate) <= distanceToItem(point, best) ? candidate : best); + const hit = renderHit(winner); + return hit ? [hit] : []; + } + return render(candidates); +} + +export function nearestItemOnInspectAxis( + items: readonly any[], + point: PlotPoint, + mode: 'x' | 'y', +): any | undefined { + const coordinate = (item: any) => mode === 'x' + ? (item.bounds.x1 + item.bounds.x2) / 2 + : (item.bounds.y1 + item.bounds.y2) / 2; + const crossCoordinate = (item: any) => mode === 'x' + ? (item.bounds.y1 + item.bounds.y2) / 2 + : (item.bounds.x1 + item.bounds.x2) / 2; + const target = mode === 'x' ? point.x : point.y; + const crossTarget = mode === 'x' ? point.y : point.x; + return items.reduce<{ item: any; axisDistance: number; crossDistance: number } | undefined>((best, item) => { + if (!item?.bounds) return best; + const axisDistance = Math.abs(coordinate(item) - target); + const crossDistance = Math.abs(crossCoordinate(item) - crossTarget); + if (!best || axisDistance < best.axisDistance - 0.5 + || (Math.abs(axisDistance - best.axisDistance) <= 0.5 && crossDistance < best.crossDistance)) { + return { item, axisDistance, crossDistance }; + } + return best; + }, undefined)?.item; +} + export function regionHits( view: any, a: PlotPoint, @@ -417,6 +1092,50 @@ export function regionHits( .filter((hit): hit is RenderHit => hit !== null); } +/** Marks captured by a freeform lasso path. */ +export function polygonHits( + view: any, + polygon: readonly PlotPoint[], + contain = false, +): RenderHit[] { + if (polygon.length < 3) return []; + return sceneItems(view) + .filter((item) => { + const bounds = item.bounds; + if (!bounds) return false; + if (contain) { + return [ + { x: bounds.x1, y: bounds.y1 }, + { x: bounds.x2, y: bounds.y1 }, + { x: bounds.x2, y: bounds.y2 }, + { x: bounds.x1, y: bounds.y2 }, + ].every((corner) => pointInPolygon(corner, polygon)); + } + return polygonIntersectsRect(polygon, bounds); + }) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +export function normalizeVegaLassoEvent( + view: any, + points: readonly PlotPoint[], + phase: InteractionPhase, + match: 'intersect' | 'contain', + modifiers: InteractionModifiers, +): RegionInteractionEvent { + return { + type: 'region', + phase, + axis: 'xy', + operation: 'create', + region: { points: [...points] }, + hits: phase === 'cancel' ? [] : polygonHits(view, points, match === 'contain'), + match, + modifiers, + }; +} + export function normalizeVegaRegionEvent( view: any, start: PlotPoint, @@ -427,6 +1146,7 @@ export function normalizeVegaRegionEvent( axis: RegionAxis = 'xy', plotSize: { width: number; height: number } = { width: view.width(), height: view.height() }, operation: RegionOperation = 'create', + collectHits = true, ): RegionInteractionEvent { const constrainedStart = { x: axis === 'y' ? 0 : start.x, @@ -447,7 +1167,7 @@ export function normalizeVegaRegionEvent( width: Math.abs(constrainedEnd.x - constrainedStart.x), height: Math.abs(constrainedEnd.y - constrainedStart.y), }, - hits: regionHits(view, constrainedStart, constrainedEnd, match === 'contain'), + hits: collectHits ? regionHits(view, constrainedStart, constrainedEnd, match === 'contain') : [], match, modifiers, }; diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts index 4ddd1f86..fd5fb728 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts @@ -1,4 +1,4 @@ -import type { SemanticElement, SemanticTarget } from '../../../core/interaction-semantics'; +import { semanticElementRenderKeys, type SemanticElement, type SemanticTarget } from '../../../core/interaction-semantics'; import type { AnnotationCandidate, AnnotationConnection, @@ -481,7 +481,7 @@ export function createAnnotationOverlay({ }; const render = (element: SemanticElement, annotation: RenderableAnnotation): void => { current = { element, annotation }; - const key = element.key[INTERACTION_KEY]; + const key = semanticElementRenderKeys(element)[0]; const items = sceneItems(view); const item = typeof key === 'string' ? annotationItem( diff --git a/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts index 88a2083b..3a867551 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts @@ -1,4 +1,4 @@ -import type { SemanticTarget } from '../../../core/interaction-semantics'; +import type { RenderHit, SemanticTarget } from '../../../core/interaction-semantics'; import type { PlotPoint } from '../../../interactive/interactions'; import type { VegaReorderAxis } from '../contracts'; import { @@ -32,8 +32,34 @@ export interface DragReorderOverlayOptions { const SVG_NS = 'http://www.w3.org/2000/svg'; +function hasOneValue(values: readonly unknown[]): boolean { + return values.length > 0 && values.every((value) => Object.is(value, values[0])); +} + +export function eligibleReorderAxes>( + axes: readonly T[], + target: SemanticTarget, +): T[] { + return axes.filter(({ field }) => hasOneValue(target.elements.flatMap((element) => { + const records = element.records?.length ? element.records : [element.value]; + return records.flatMap((record) => record[field] === undefined ? [] : [record[field]]); + }))); +} + +export function eligibleReorderAxesForHit>( + axes: readonly T[], + hit: RenderHit, +): T[] { + const records = hit.pathData?.length ? hit.pathData : [hit.datum]; + return axes.filter(({ field }) => hasOneValue( + records.flatMap((record) => record[field] === undefined ? [] : [record[field]]), + )); +} + function targetValue(target: SemanticTarget, field: string): unknown { - return target.elements[0]?.records?.find((record) => record[field] !== undefined)?.[field]; + const element = target.elements[0]; + return element?.records?.find((record) => record[field] !== undefined)?.[field] + ?? element?.value[field]; } export function activeReorderAxis>( @@ -63,6 +89,20 @@ export function reorderOwnedItems( && (!axis.markTypes || axis.markTypes.includes(item.mark?.marktype))); } +export function reorderPreviewItems( + items: readonly any[], + axis: Pick, + value: unknown, +): any[] { + const candidates = reorderOwnedItems(items, axis, value) + .filter((item) => item.interactionGeometry?.points?.length >= 2 || item.bounds); + const discrete = candidates.filter((item) => { + const markType = item.mark?.marktype; + return markType !== 'line' && markType !== 'area'; + }); + return axis.includeConnectiveMarks || discrete.length === 0 ? candidates : discrete; +} + export function createDragReorderOverlay({ view, container, @@ -82,15 +122,7 @@ export function createDragReorderOverlay({ const sourceValue = targetValue(preview.source, active.field); const destinationValue = targetValue(preview.destination, active.field); const scene = sceneItems(view); - const sourceCandidates = reorderOwnedItems(scene, active, sourceValue) - .filter((item) => item.interactionGeometry?.points?.length >= 2 || item.bounds); - const discreteSourceItems = sourceCandidates.filter((item) => { - const markType = item.mark?.marktype; - return markType !== 'line' && markType !== 'area'; - }); - const sourceItems = active.includeConnectiveMarks || discreteSourceItems.length === 0 - ? sourceCandidates - : discreteSourceItems; + const sourceItems = reorderPreviewItems(scene, active, sourceValue); const destinationItems = reorderOwnedItems(scene, active, destinationValue) .filter((item) => item.bounds); if (sourceItems.length === 0 || destinationItems.length === 0) return clear(); diff --git a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts index e53730a2..f1ec0b98 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts @@ -38,6 +38,43 @@ export function mergeContiguousSelectionBounds( return merged; } +export interface SelectionBoundarySegment { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export function selectionBoundarySegments( + bounds: readonly { x1: number; y1: number; x2: number; y2: number }[], + gap = 2, +): SelectionBoundarySegment[] { + const overlaps = (a1: number, a2: number, b1: number, b2: number): boolean => + Math.min(a2, b2) - Math.max(a1, b1) > 0; + const adjacent = ( + bound: typeof bounds[number], + side: 'left' | 'right' | 'top' | 'bottom', + ): boolean => bounds.some((candidate) => { + if (candidate === bound) return false; + if (side === 'left' || side === 'right') { + const distance = side === 'left' + ? Math.abs(candidate.x2 - bound.x1) + : Math.abs(candidate.x1 - bound.x2); + return distance <= gap && overlaps(bound.y1, bound.y2, candidate.y1, candidate.y2); + } + const distance = side === 'top' + ? Math.abs(candidate.y2 - bound.y1) + : Math.abs(candidate.y1 - bound.y2); + return distance <= gap && overlaps(bound.x1, bound.x2, candidate.x1, candidate.x2); + }); + return bounds.flatMap((bound) => [ + ...(!adjacent(bound, 'top') ? [{ x1: bound.x1, y1: bound.y1, x2: bound.x2, y2: bound.y1 }] : []), + ...(!adjacent(bound, 'right') ? [{ x1: bound.x2, y1: bound.y1, x2: bound.x2, y2: bound.y2 }] : []), + ...(!adjacent(bound, 'bottom') ? [{ x1: bound.x1, y1: bound.y2, x2: bound.x2, y2: bound.y2 }] : []), + ...(!adjacent(bound, 'left') ? [{ x1: bound.x1, y1: bound.y1, x2: bound.x1, y2: bound.y2 }] : []), + ]); +} + export interface FocusOverlayController { render(selected: ReadonlySet, hoveredPathKeys: ReadonlySet): void; destroy(): void; @@ -93,18 +130,21 @@ export function createFocusOverlay({ const items = scene.filter((item) => { const hit = renderHit(item); const key = String(hit?.datum[INTERACTION_KEY]); - return hit && (selected.has(key) || hoveredPathKeys.has(key)) && item.interactionGeometry; + const boundaryMode = plan.renderSelectionStyles?.[item.mark.marktype]?.boundary === 'contiguous-region'; + return hit + && (selected.has(key) || hoveredPathKeys.has(key)) + && item.interactionGeometry + && !boundaryMode; }); - const boundaryBounds = mergeContiguousSelectionBounds(scene - .filter((item) => { + const boundaryItems = scene.filter((item) => { const hit = renderHit(item); const key = hit?.datum[INTERACTION_KEY]; return typeof key === 'string' - && selected.has(key) + && (selected.has(key) || hoveredPathKeys.has(key)) && plan.renderSelectionStyles?.[item.mark.marktype]?.boundary === 'contiguous-region'; - }) - .map((item) => item.bounds)); - if (items.length === 0 && boundaryBounds.length === 0) { + }); + const boundarySegments = selectionBoundarySegments(boundaryItems.map((item) => item.bounds)); + if (items.length === 0 && boundarySegments.length === 0) { focusLayer.remove(); return; } @@ -123,6 +163,26 @@ export function createFocusOverlay({ height: `${rendererLayout.height}px`, }); focusLayer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + const filledClosedMarks = new Set(); + for (const item of items) { + if (!item.interactionGeometry.closed || filledClosedMarks.has(item.mark)) continue; + const closedItems = scene.filter((candidate) => + candidate.mark === item.mark && candidate.interactionGeometry?.closed); + const visual = closedItems + .map((candidate) => renderHit(candidate)?.datum[INTERACTION_KEY]) + .find((key): key is string => typeof key === 'string' && selected.has(key)); + if (!visual) continue; + const style = pathVisuals.get(visual); + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', closedItems.map((candidate) => { + const point = candidate.interactionGeometry.points[0] as PlotPoint; + return `${point.x + space.originX},${point.y + space.originY}`; + }).join(' ')); + polygon.setAttribute('fill', style?.fill ?? item.fill ?? '#4c78a8'); + polygon.setAttribute('fill-opacity', String(style?.fillOpacity ?? 1)); + focusLayer.append(polygon); + filledClosedMarks.add(item.mark); + } for (const item of items) { const key = renderHit(item)?.datum[INTERACTION_KEY]; const visual = typeof key === 'string' ? pathVisuals.get(key) : undefined; @@ -164,24 +224,32 @@ export function createFocusOverlay({ } focusLayer.append(shape); } - for (const bounds of boundaryBounds) { + if (boundarySegments.length > 0) { const boundaryStyle = plan.selectionBoundary ?? { color: '#20262c', - width: 1.5, - opacity: 1, + width: 1.25, + opacity: 0.68, haloColor: '#ffffff', - haloWidth: 3, - haloOpacity: 0.8, + haloWidth: 2.5, + haloOpacity: 0.35, }; + const continuousStyle = plan.continuousColorFocus; for (const [stroke, width, opacity] of [ - [boundaryStyle.haloColor, boundaryStyle.haloWidth, boundaryStyle.haloOpacity], - [boundaryStyle.color, boundaryStyle.width, boundaryStyle.opacity], + [ + boundaryStyle.haloColor, + continuousStyle?.haloWidth ?? boundaryStyle.haloWidth, + continuousStyle?.haloOpacity ?? boundaryStyle.haloOpacity, + ], + [ + boundaryStyle.color, + continuousStyle?.boundaryWidth ?? boundaryStyle.width, + continuousStyle?.boundaryOpacity ?? boundaryStyle.opacity, + ], ] as const) { - const boundary = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - boundary.setAttribute('x', String(bounds.x1 + space.originX + 1)); - boundary.setAttribute('y', String(bounds.y1 + space.originY + 1)); - boundary.setAttribute('width', String(Math.max(0, bounds.x2 - bounds.x1 - 2))); - boundary.setAttribute('height', String(Math.max(0, bounds.y2 - bounds.y1 - 2))); + const boundary = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + boundary.setAttribute('d', boundarySegments.map((segment) => + `M ${segment.x1 + space.originX} ${segment.y1 + space.originY} ` + + `L ${segment.x2 + space.originX} ${segment.y2 + space.originY}`).join(' ')); boundary.setAttribute('fill', 'none'); boundary.setAttribute('stroke', stroke); boundary.setAttribute('stroke-width', String(width)); diff --git a/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts new file mode 100644 index 00000000..436729f3 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts @@ -0,0 +1,119 @@ +import type { RendererCoordinateSpace } from '../hit-adapter'; +import { clientToLayoutPoint, plotToClientPoint } from '../../../interactive/geometry/coordinate-space'; +import type { GestureGuideController, InspectGestureGuideStyle } from '../../../interactive/guides'; + +export interface InspectGuideOverlay extends GestureGuideController { + renderAxes( + point: { x: number; y: number }, + axes: 'x' | 'y' | 'xy', + style: InspectGestureGuideStyle, + ): void; + renderSegment( + start: { x: number; y: number }, + end: { x: number; y: number }, + style: InspectGestureGuideStyle, + ): void; +} + +export interface InspectGuideOverlayOptions { + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function inspectGuideLine( + mode: 'x' | 'y', + coordinate: number, + plotSize: { width: number; height: number }, +): { x1: number; y1: number; x2: number; y2: number } { + return mode === 'x' + ? { x1: coordinate, y1: 0, x2: coordinate, y2: plotSize.height } + : { x1: 0, y1: coordinate, x2: plotSize.width, y2: coordinate }; +} + +export function createInspectGuideOverlay({ + container, + coordinateSpace, + containerLayoutSize, +}: InspectGuideOverlayOptions): InspectGuideOverlay { + const previousPosition = container.style.position; + const line = document.createElement('div'); + const crossLine = document.createElement('div'); + const baseStyle = { + position: 'absolute', display: 'none', zIndex: '4', pointerEvents: 'none', + } as const; + Object.assign(line.style, baseStyle); + Object.assign(crossLine.style, baseStyle); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + container.append(line, crossLine); + + const renderLine = ( + element: HTMLDivElement, + mode: 'x' | 'y', + coordinate: number, + style: InspectGestureGuideStyle, + ): void => { + const space = coordinateSpace(); + const guide = inspectGuideLine(mode, coordinate, { + width: space.plotWidth, + height: space.plotHeight, + }); + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + const start = clientToLayoutPoint(plotToClientPoint({ x: guide.x1, y: guide.y1 }, space), containerRect, layoutSize); + const end = clientToLayoutPoint(plotToClientPoint({ x: guide.x2, y: guide.y2 }, space), containerRect, layoutSize); + Object.assign(element.style, mode === 'x' ? { + display: 'block', left: `${start.x - style.width / 2}px`, top: `${start.y}px`, + width: `${style.width}px`, height: `${end.y - start.y}px`, transform: 'none', + transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, + } : { + display: 'block', left: `${start.x}px`, top: `${start.y - style.width / 2}px`, + width: `${end.x - start.x}px`, height: `${style.width}px`, transform: 'none', + transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, + }); + }; + + const renderAxes = ( + point: { x: number; y: number }, + axes: 'x' | 'y' | 'xy', + style: InspectGestureGuideStyle, + ): void => { + renderLine(line, axes === 'y' ? 'y' : 'x', axes === 'y' ? point.y : point.x, style); + if (axes === 'xy') renderLine(crossLine, 'y', point.y, style); + else crossLine.style.display = 'none'; + }; + + const renderSegment = ( + segmentStart: { x: number; y: number }, + segmentEnd: { x: number; y: number }, + style: InspectGestureGuideStyle, + ): void => { + crossLine.style.display = 'none'; + const space = coordinateSpace(); + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + const start = clientToLayoutPoint(plotToClientPoint(segmentStart, space), containerRect, layoutSize); + const end = clientToLayoutPoint(plotToClientPoint(segmentEnd, space), containerRect, layoutSize); + const length = Math.hypot(end.x - start.x, end.y - start.y); + const angle = Math.atan2(end.y - start.y, end.x - start.x); + Object.assign(line.style, { + display: 'block', left: `${start.x}px`, top: `${start.y - style.width / 2}px`, + width: `${length}px`, height: `${style.width}px`, transformOrigin: '0 50%', + transform: `rotate(${angle}rad)`, background: style.color, opacity: `${style.opacity}`, + }); + }; + + return { + renderAxes, + renderSegment, + clear(): void { + line.style.display = 'none'; + crossLine.style.display = 'none'; + }, + destroy(): void { + line.remove(); + crossLine.remove(); + container.style.position = previousPosition; + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/legend-range-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/legend-range-overlay.ts new file mode 100644 index 00000000..daa5db9a --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/legend-range-overlay.ts @@ -0,0 +1,58 @@ +import type { LegendHitIdentity, RendererCoordinateSpace } from '../hit-adapter'; +import { clientRectToLayoutRect } from '../hit-adapter'; + +export interface LegendRangeOverlayController { + render(selected: LegendHitIdentity | null, hovered: LegendHitIdentity | null): void; + destroy(): void; +} + +export function createLegendRangeOverlay(options: { + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +}): LegendRangeOverlayController { + const { container, coordinateSpace, containerLayoutSize } = options; + const layer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + Object.assign(layer.style, { + position: 'absolute', zIndex: '4', pointerEvents: 'none', overflow: 'visible', + }); + + const render = (selected: LegendHitIdentity | null, hovered: LegendHitIdentity | null): void => { + layer.replaceChildren(); + const visible = [ + selected?.visualBounds ? { target: selected, selected: true } : undefined, + hovered?.visualBounds ? { target: hovered, selected: false } : undefined, + ].filter(Boolean) as { target: LegendHitIdentity; selected: boolean }[]; + if (visible.length === 0) { + layer.remove(); + return; + } + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const layout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(layer.style, { + left: `${layout.left}px`, top: `${layout.top}px`, + width: `${layout.width}px`, height: `${layout.height}px`, + }); + layer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + for (const { target, selected: pinned } of visible) { + const bounds = target.visualBounds!; + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('x', String(bounds.x1)); + rect.setAttribute('y', String(bounds.y1)); + rect.setAttribute('width', String(Math.max(0, bounds.x2 - bounds.x1))); + rect.setAttribute('height', String(Math.max(0, bounds.y2 - bounds.y1))); + rect.setAttribute('fill', pinned ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.2)'); + rect.setAttribute('stroke', pinned ? 'rgba(32,38,44,0.68)' : 'rgba(32,38,44,0.48)'); + rect.setAttribute('stroke-width', pinned ? '1.25' : '1'); + rect.setAttribute('vector-effect', 'non-scaling-stroke'); + layer.append(rect); + } + }; + + return { render, destroy: () => layer.remove() }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/presentation/viewport-reset-control.ts b/packages/flint-js/src/vegalite/interactions/presentation/viewport-reset-control.ts new file mode 100644 index 00000000..f5e48669 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/viewport-reset-control.ts @@ -0,0 +1,68 @@ +import type { RendererCoordinateSpace } from '../hit-adapter'; +import { clientToLayoutPoint } from '../../../interactive/geometry/coordinate-space'; + +export interface ViewportResetControl { + layout(): void; + destroy(): void; +} + +export interface ViewportResetControlOptions { + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; + isActive(): boolean; + reset(): void; +} + +export function createViewportResetControl({ + container, + coordinateSpace, + containerLayoutSize, + isActive, + reset, +}: ViewportResetControlOptions): ViewportResetControl { + const previousPosition = container.style.position; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '↺'; + button.title = 'Reset zoom'; + button.setAttribute('aria-label', 'Reset zoom'); + Object.assign(button.style, { + position: 'absolute', zIndex: '6', width: '28px', height: '28px', padding: '0', + border: '1px solid rgba(115, 125, 134, 0.35)', borderRadius: '4px', + background: 'rgba(255, 255, 255, 0.92)', color: '#4b5560', cursor: 'pointer', + font: '18px sans-serif', lineHeight: '26px', letterSpacing: '0', + boxShadow: '0 1px 2px rgba(0, 0, 0, 0.08)', + }); + button.addEventListener('click', reset); + container.append(button); + + const layout = (): void => { + if (!isActive()) { + button.hidden = true; + return; + } + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + button.hidden = false; + const space = coordinateSpace(); + const containerRect = container.getBoundingClientRect(); + const scaleX = space.rect.width / space.logicalWidth; + const scaleY = space.rect.height / space.logicalHeight; + const plotTopRight = clientToLayoutPoint({ + x: space.rect.left + (space.originX + space.plotWidth) * scaleX, + y: space.rect.top + space.originY * scaleY, + }, containerRect, containerLayoutSize()); + button.style.left = `${plotTopRight.x - 34}px`; + button.style.top = `${plotTopRight.y + 6}px`; + }; + + layout(); + return { + layout, + destroy(): void { + button.removeEventListener('click', reset); + button.remove(); + container.style.position = previousPosition; + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index 26df91bf..c98d9661 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -1,5 +1,12 @@ import { changeset } from 'vega'; -import type { ChartInteractionResolver } from '../../core/interaction-semantics'; +import { + associateSemanticElementRenderKeys, + semanticElementRenderKeys, + sourceRecordsForRenderedRecords, + type ChartInteractionResolver, + type LegendTargetValue, + type SemanticResolveContext, +} from '../../core/interaction-semantics'; import type { CanvasInteractionDef, ChartUpdate, @@ -21,36 +28,128 @@ import type { import { matchesSemanticTargetSelector } from '../../interactive/language/updates'; import type { VegaInteractionPlan } from './contracts'; import { toCanvasInteractionEvent } from '../../interactive/canvas-interaction'; -import type { CanvasInteractionEvent } from '../../interactive/language/events'; +import { keyboardTrigger } from '../../interactive/triggers'; +import { normalizeInspectGuideOptions } from '../../interactive/guides'; +import type { CanvasInteractionEvent, DomainGeometry } from '../../interactive/language/events'; import type { ChartUpdateApplyOptions } from '../../interactive/types'; import { INTERACTION_KEY, PATH_KEY_SUFFIX, + axisIntersectingHits, clientToPlotPoint, + clientToRendererPoint, interactionModifiers, normalizeVegaElementEvent, + nearestItemByBounds, + nearestInteractiveSceneItem, + nearestSceneItem, + nextItemInDirection, pathHoverPresentationKey, + polarFrameFromItems, + polarGuideSegment, + polarInspectHits, + tolerantInspectHits, + legendSemanticTarget, renderHit, rendererPlotOrigin, sceneItems, type RendererCoordinateSpace, + type LegendHitIdentity, + type SpatialDirection, } from './hit-adapter'; -import { mountVegaRegionGesture } from './gestures/region'; +import { isInteractiveControlTarget, mountVegaRegionGesture } from './gestures/region'; import { mountVegaNavigationGesture } from './gestures/navigation'; import { createVegaNavigationController } from './navigation-scale'; import { createAnnotationOverlay } from './presentation/annotation-overlay'; -import { createDragReorderOverlay } from './presentation/drag-reorder-overlay'; +import { + createDragReorderOverlay, + eligibleReorderAxesForHit, +} from './presentation/drag-reorder-overlay'; import { createFocusOverlay } from './presentation/focus-overlay'; +import { createLegendRangeOverlay } from './presentation/legend-range-overlay'; import { createReorderResetControls } from './presentation/reorder-reset-controls'; +import { createViewportResetControl } from './presentation/viewport-reset-control'; +import { createInspectGuideOverlay } from './presentation/inspect-guide-overlay'; import { + HIDDEN_STORE, + LEGEND_HIDDEN_STORE, HOVER_STORE, INTERACTION_STORE, LEGEND_HOVER_STORE, LEGEND_SELECTION_STORE, } from './stores'; +const EMPTY_SEMANTIC_SELECTION_KEY = '__flint_empty_semantic_selection'; + export { mergeContiguousSelectionBounds } from './presentation/focus-overlay'; +export function resolveLegendPresentationTarget( + legend: LegendTargetValue, + resolve: ChartInteractionResolver, + context: SemanticResolveContext, +): SemanticTarget { + const resolved = resolve({ + gesture: 'click', role: 'legend-item', hits: [], legend, + }, context); + if (resolved) return resolved; + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: [associateSemanticElementRenderKeys( + { value: legend }, + [EMPTY_SEMANTIC_SELECTION_KEY], + )], + }; +} + +function legendDomainIdentity(legend: LegendTargetValue): string { + return JSON.stringify([legend.channel, legend.field, legend.domain]); +} + +export function resolveRetainedLegendPresentationTarget( + legend: LegendTargetValue, + resolve: ChartInteractionResolver, + context: SemanticResolveContext, + retained: Map, +): SemanticTarget { + const identity = legendDomainIdentity(legend); + const resolved = resolveLegendPresentationTarget(legend, resolve, context); + const hasConcreteKeys = resolved.elements.some((element) => + semanticElementRenderKeys(element).some((key) => key !== EMPTY_SEMANTIC_SELECTION_KEY)); + if (hasConcreteKeys) { + retained.set(identity, resolved); + return resolved; + } + return retained.get(identity) ?? resolved; +} + +export function resolveRetainedLegendPresentationTargets( + legends: readonly LegendTargetValue[], + resolve: ChartInteractionResolver, + context: SemanticResolveContext, + retained: Map, +): SemanticTarget { + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: legends.flatMap((legend) => + resolveRetainedLegendPresentationTarget(legend, resolve, context, retained).elements), + }; +} + +export function resolvedLegendInteractionTarget( + legend: LegendTargetValue, + resolved: SemanticTarget | null, +): SemanticTarget { + const records = [...new Set(resolved?.elements.flatMap((element) => element.records ?? []) ?? [])]; + const renderKeys = resolved?.elements.flatMap(semanticElementRenderKeys) ?? []; + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: [associateSemanticElementRenderKeys({ + value: legend, + ...(records.length > 0 ? { records } : {}), + }, renderKeys.length > 0 ? renderKeys : [EMPTY_SEMANTIC_SELECTION_KEY])], + }; +} + export function resolveSupportedOperation( op: ChartUpdateOp, plan: Pick, @@ -80,6 +179,32 @@ export function resolveSupportedOperation( return { op, unsupported: false }; } +export function domainForPlotGeometry( + plot: CanvasInteractionEvent['geometry']['plot'], + axes: VegaInteractionPlan['navigationAxes'], + scaleFor: (name: string) => { + invert?(value: number): unknown; + } | undefined, +): DomainGeometry | undefined { + if (!plot || plot.kind !== 'rect') return undefined; + const domain: DomainGeometry = {}; + for (const axis of ['x', 'y'] as const) { + const config = axes?.[axis]; + if (!config) continue; + const scale = scaleFor(config.scale); + if (typeof scale?.invert !== 'function') continue; + const lower = axis === 'x' ? plot.rect.x : plot.rect.y; + const upper = lower + (axis === 'x' ? plot.rect.width : plot.rect.height); + const lowerValue = scale.invert(lower); + const upperValue = scale.invert(upper); + const start = axis === 'y' ? upperValue : lowerValue; + const end = axis === 'y' ? lowerValue : upperValue; + if (start === undefined || end === undefined) continue; + domain[axis] = { kind: 'interval', start, end }; + } + return domain.x || domain.y ? domain : undefined; +} + export function nearestReorderHit( items: readonly any[], axis: 'x' | 'y', @@ -124,6 +249,33 @@ export function interactionsForHoverPresentation( ); } +export function enrichTargetWithSourceProvenance( + target: SemanticTarget | null, + plan: Pick, +): SemanticTarget | null { + if (!target) return null; + const elements = target.elements.map((element) => { + const renderedRecords = element.records?.length ? element.records : [element.value]; + const records = sourceRecordsForRenderedRecords( + renderedRecords, + plan.sourceRecords, + plan.provenanceFields, + plan.temporalProvenanceFields, + plan.rangeProvenance, + ); + const value = plan.rangeProvenance.length > 0 + ? { ...element.value, count: records.length } + : element.value; + const publicElement = { + value, + ...(records.length > 0 ? { records } : {}), + }; + return associateSemanticElementRenderKeys(publicElement, semanticElementRenderKeys(element)); + }); + return { ...target, elements }; +} + export function mountVegaInteractions( view: any, container: HTMLElement, @@ -132,6 +284,9 @@ export function mountVegaInteractions( interactions: readonly InteractionDef[], resolve: ChartInteractionResolver | undefined, presentUpdate: ChartUpdatePresenter, + assistDistance = 0, + keyboardTargeting = false, + dismiss: import('../../interactive/types').InteractionDismissPolicy | false | undefined = undefined, ): VegaInteractionController { const canvasInteractions = interactions.filter(isCanvasInteraction); const clickInteractions = resolve @@ -140,6 +295,18 @@ export function mountVegaInteractions( const hoverInteractions = resolve ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'hover') : []; + const contextInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'context') + : []; + const inspectInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'inspect') + : []; + const longPressInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'long-press') + : []; + const doubleInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'double') + : []; const hoverPresentationInteractions = interactionsForHoverPresentation( clickInteractions, hoverInteractions, @@ -156,7 +323,10 @@ export function mountVegaInteractions( const retainedUpdates = new Map(); const previewUpdates = new Map(); const selectedElements = new Map(); - let selectedLegend: { channel: string; value: unknown } | null = null; + const hiddenKeys = new Set(); + const retainedLegendTargets = new Map(); + let selectedLegend: LegendHitIdentity | null = null; + let hoveredLegend: LegendHitIdentity | null = null; let hoveredPathKeys = new Set(); let suppressClick = false; let regionDragging = false; @@ -197,6 +367,7 @@ export function mountVegaInteractions( }; const focusOverlay = createFocusOverlay({ view, container, plan, coordinateSpace, containerLayoutSize }); + const legendRangeOverlay = createLegendRangeOverlay({ container, coordinateSpace, containerLayoutSize }); const annotationOverlay = createAnnotationOverlay({ view, container, @@ -204,6 +375,11 @@ export function mountVegaInteractions( containerLayoutSize, annotationMarkType: plan.annotationMarkType, }); + const inspectGuideOverlay = createInspectGuideOverlay({ + container, + coordinateSpace, + containerLayoutSize, + }); const dragReorderOverlay = createDragReorderOverlay({ view, container, reorderAxes: plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), @@ -226,9 +402,25 @@ export function mountVegaInteractions( void renderUpdates(); }, }); + const resetViewportRegion = (): void => { + if (!regionInteraction?.eventSource.viewport) return; + retainedUpdates.delete(regionInteraction.id); + previewUpdates.delete(regionInteraction.id); + void renderUpdates(); + }; + const viewportResetControl = createViewportResetControl({ + container, + coordinateSpace, + containerLayoutSize, + isActive: () => Boolean(regionInteraction?.eventSource.viewport + && [retainedUpdates, previewUpdates].some((layer) => + layer.get(regionInteraction.id)?.ops.some((op) => op.op === 'set-viewport'))), + reset: resetViewportRegion, + }); const navigationController = createVegaNavigationController(view, plan.navigationAxes ?? {}); const selectedKeys = (): Set => new Set(selectedElements.keys()); const renderPathFocus = (): void => focusOverlay.render(selectedKeys(), hoveredPathKeys); + const renderLegendRange = (): void => legendRangeOverlay.render(selectedLegend, hoveredLegend); const clearAnnotation = (): void => annotationOverlay.clear(); renderPathFocus(); @@ -241,15 +433,17 @@ export function mountVegaInteractions( categoryField: plan.categoryField, seriesField: plan.seriesField, }); + const withSourceProvenance = (target: SemanticTarget | null): SemanticTarget | null => + enrichTargetWithSourceProvenance(target, plan); const context = (includeAvailable = true) => { // Navigation resolves per gesture frame, so the scenegraph scan stays behind this flag. const available = includeAvailable ? (() => { const hits = allHits(); - return resolve?.( + return withSourceProvenance(resolve?.( { gesture: 'rectangle', role: 'region', hits }, resolveContext(hits), - )?.elements; + ) ?? null)?.elements; })() : undefined; const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); @@ -265,6 +459,12 @@ export function mountVegaInteractions( const categoryOrder = reorderAxis ? reorderAxis.order : undefined; + const legendDomains = Object.fromEntries(Object.entries(plan.legendFields ?? {}).map(([channel, field]) => [ + channel, + [...new Set(plan.sourceRecords + .map((record) => record[field]) + .filter((value) => value !== undefined))], + ])); return { chartType, selected: [...selectedElements.values()], @@ -273,6 +473,7 @@ export function mountVegaInteractions( resolveNavigation: navigationController.resolve, categoryField: plan.categoryField, seriesField: plan.seriesField, + legendDomains, categoryAxis: reorderAxis?.axis, categoryOrder, reorderAxes: currentReorderAxes, @@ -280,12 +481,33 @@ export function mountVegaInteractions( }; const resolveUpdateTarget = (target: UpdateTarget): SemanticTargetRef | null => { if (!('select' in target)) { + if (target.visual.kind === 'legend') { + if (!resolve) return null; + const hits = allHits(); + const legends = target.elements + .map((element) => element.value as LegendTargetValue) + .filter((legend) => Boolean(legend.domain)); + const resolved = withSourceProvenance(resolveRetainedLegendPresentationTargets( + legends, resolve, resolveContext(hits), retainedLegendTargets, + )); + return resolved && resolved.elements.length > 0 ? { + visual: target.visual, + elements: [...resolved.elements, ...target.elements], + } : null; + } const renderedKeys = new Set(allHits() .map((hit) => hit.datum[INTERACTION_KEY]) .filter((key): key is string => typeof key === 'string')); - const elements = target.elements.filter((element) => { - const key = element.key[INTERACTION_KEY]; - return typeof key === 'string' && renderedKeys.has(key); + const hits = allHits(); + const elements = target.elements.flatMap((element) => { + const associated = semanticElementRenderKeys(element).filter((key) => renderedKeys.has(key)); + if (associated.length > 0) return [element]; + const semanticRecords = element.records?.length ? element.records : [element.value]; + const matched = hits.flatMap((hit) => semanticRecords.some((record) => + Object.entries(record).every(([field, value]) => Object.is(hit.datum[field], value))) + ? [hit.datum[INTERACTION_KEY]] : []); + const keys = matched.filter((key): key is string => typeof key === 'string'); + return keys.length > 0 ? [associateSemanticElementRenderKeys(element, keys)] : []; }); return elements.length > 0 ? { ...target, elements } : null; } @@ -294,11 +516,11 @@ export function mountVegaInteractions( if (entries.length === 0) return null; const hits = allHits().filter((hit) => matchesSemanticTargetSelector(target, plan.fields, hit.datum)); if (hits.length === 0 || !resolve) return null; - const resolved = resolve({ + const resolved = withSourceProvenance(resolve({ gesture: 'rectangle', role: target.select.visual?.role ?? 'external-selection', hits, - }, resolveContext(hits)); + }, resolveContext(hits))); if (!resolved) return null; if (target.select.visual?.kind && target.select.visual.kind !== resolved.visual.kind) return null; if (target.select.visual?.role && target.select.visual.role !== resolved.visual.role) return null; @@ -358,8 +580,18 @@ export function mountVegaInteractions( }; const renderUpdates = async (): Promise => { - const displayUpdates = [...retainedUpdates.values(), ...previewUpdates.values()]; + // A live preview supersedes the same interaction's retained state; other + // interactions keep showing theirs. + const displayUpdates = [ + ...[...retainedUpdates] + .filter(([id]) => !previewUpdates.has(id)) + .map(([, update]) => update), + ...previewUpdates.values(), + ]; + const hiddenLegendDomains = new Map(); + const activeHiddenLegendDomains = new Set(); selectedElements.clear(); + hiddenKeys.clear(); let annotation: Extract | undefined; const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); for (const axis of reorderAxes) view.signal(axis.signal, null); @@ -368,13 +600,36 @@ export function mountVegaInteractions( } for (const update of displayUpdates) { for (const op of update.ops) { - if (op.op === 'set-presentation' + if (op.op === 'set-presentation' && op.value.visible === false) { + for (const target of op.targets) { + if ('select' in target) continue; + for (const element of target.elements) { + for (const key of semanticElementRenderKeys(element)) { + hiddenKeys.add(key.endsWith(PATH_KEY_SUFFIX) + ? key.slice(0, -PATH_KEY_SUFFIX.length) + : key); + } + const legend = element.value as LegendTargetValue; + if (target.visual.kind === 'legend' + && legend.domain?.kind === 'value' + && legend.channel) { + activeHiddenLegendDomains.add(legendDomainIdentity(legend)); + } + if (target.visual.kind === 'legend' + && legend.domain?.kind === 'value' + && legend.channel + && op.value.mutedOpacity !== undefined) { + const identity = `${legend.channel}:${String(legend.domain.value)}`; + hiddenLegendDomains.set(identity, { legend, opacity: op.value.mutedOpacity }); + } + } + } + } else if (op.op === 'set-presentation' && (op.value.state === 'emphasized' || op.value.state === 'focused')) { for (const target of op.targets) { if ('select' in target) continue; for (const element of target.elements) { - const key = element.key[INTERACTION_KEY]; - if (typeof key === 'string') selectedElements.set(key, element); + for (const key of semanticElementRenderKeys(element)) selectedElements.set(key, element); } } } else if (op.op === 'set-annotation') { @@ -388,6 +643,9 @@ export function mountVegaInteractions( } } const keys = [...selectedKeys()]; + for (const identity of retainedLegendTargets.keys()) { + if (!activeHiddenLegendDomains.has(identity)) retainedLegendTargets.delete(identity); + } if (keys.length === 0) selectedLegend = null; // A navigation-only chart compiles without the selection stores. if (plan.semanticStores !== false) { @@ -395,6 +653,15 @@ export function mountVegaInteractions( INTERACTION_STORE, changeset().remove(() => true).insert(keys.map((key) => ({ key }))), ); + view.change( + HIDDEN_STORE, + changeset().remove(() => true).insert([...hiddenKeys].map((key) => ({ key }))), + ); + view.change( + LEGEND_HIDDEN_STORE, + changeset().remove(() => true).insert([...hiddenLegendDomains] + .map(([identity, { opacity }]) => ({ identity, opacity }))), + ); view.change( LEGEND_SELECTION_STORE, changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), @@ -403,7 +670,9 @@ export function mountVegaInteractions( await view.runAsync(); observeRenderer(); renderPathFocus(); + renderLegendRange(); reorderResetControls.layout(); + viewportResetControl.layout(); clearAnnotation(); if (annotation?.value && !('select' in annotation.target)) { const element = annotation.target.elements[0]; @@ -420,7 +689,7 @@ export function mountVegaInteractions( const storeUpdate = async ( update: ChartUpdate, destination: Map, - legendSelection: { channel: string; value: unknown } | null = null, + legendSelection: LegendHitIdentity | null = null, ): Promise => { const resolved = resolveUpdate(update); const presented = presentUpdate(resolved.update, context()); @@ -434,7 +703,7 @@ export function mountVegaInteractions( interaction: InteractionDef, phase: import('../../interactive/interactions').InteractionPhase, update: ChartUpdate | null, - legendSelection: { channel: string; value: unknown } | null = null, + legendSelection: LegendHitIdentity | null = null, ): Promise => { if (phase === 'cancel') { if (previewUpdates.delete(interaction.id)) await renderUpdates(); @@ -483,14 +752,23 @@ export function mountVegaInteractions( toCanvasInteractionEvent(event, interaction.eventSource), transactionId, ); + // A region can be read as data domains, which is what viewport updates need. + const domainForGeometry = (plot: CanvasInteractionEvent['geometry']['plot']) => + domainForPlotGeometry(plot, plan.navigationAxes, (name) => view.scale(name)); const dispatch = async ( interaction: CanvasInteractionDef, event: SemanticInteractionEvent, - legendSelection: { channel: string; value: unknown } | null = null, + legendSelection: LegendHitIdentity | null = null, + actionOverride?: CanvasInteractionEvent['action'], ): Promise => { - const interactionContext = context(); - const canvasEvent = toCanvasInteractionEvent(event, interaction.eventSource); - emitInteractionEvent(interaction, event); + const interactionContext = context(!interaction.eventSource.viewport); + const base = toCanvasInteractionEvent(event, interaction.eventSource); + const domain = domainForGeometry(base.geometry.plot); + const withDomain = domain + ? { ...base, geometry: { ...base.geometry, domain } } + : base; + const canvasEvent = actionOverride ? { ...withDomain, action: actionOverride } : withDomain; + emitCanvasInteractionEvent(interaction, canvasEvent); const request = interaction.handle?.(canvasEvent, interactionContext) ?? null; await applyInteractionUpdate(interaction, event.phase, request, legendSelection); }; @@ -512,28 +790,27 @@ export function mountVegaInteractions( gesture: 'click' | 'hover' | 'rectangle' | 'angular', role: string, hits: readonly RenderHit[], - legendValue?: unknown, - legendField?: string, + legend?: LegendTargetValue, ): SemanticTarget | null => { if (!resolve) return null; const availableHits = allHits(); - return resolve( - { gesture, role, hits, legendValue, legendField }, + return withSourceProvenance(resolve( + { gesture, role, hits, legend }, resolveContext(availableHits), - ); + )); }; - - let hoveredKeys = ''; + let hoveredKeys = '\u0001\u0000'; let hoverActive = false; const setHover = async ( keys: readonly string[], - legend: { channel: string; value: unknown } | null = null, + legend: LegendHitIdentity | null = null, ): Promise => { const next = [...new Set(keys)].sort(); const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}`; if (signature === hoveredKeys) return; hoveredKeys = signature; hoveredPathKeys = new Set(next.filter((key) => key.endsWith(PATH_KEY_SUFFIX))); + hoveredLegend = legend; const renderedItems = hoveredPathKeys.size > 0 ? sceneItems(view) : []; const presentationKeys = [...new Set(next.map( (key) => pathHoverPresentationKey(renderedItems, key), @@ -548,6 +825,7 @@ export function mountVegaInteractions( ); await view.runAsync(); renderPathFocus(); + renderLegendRange(); }; const clearHover = (): void => { void setHover([]); @@ -561,18 +839,40 @@ export function mountVegaInteractions( } if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; }; + // A pointer that misses every mark still acquires the nearest one, so small + // marks stay reachable without changing which action the preset receives. + const acquire = ( + item: any, + point: import('../../interactive/interactions').PlotPoint, + rootPoint: import('../../interactive/interactions').PlotPoint, + phase: 'preview' | 'commit', + modifiers: ReturnType, + ) => { + const space = coordinateSpace(); + const direct = normalizeVegaElementEvent( + view, item, point, phase, modifiers, plan.legendFields, plan.rangeLegendChannels, rootPoint, + ); + if (assistDistance <= 0 || direct.legend || direct.event.hits.length > 0) return direct; + const rawPlotPoint = { x: rootPoint.x - space.originX, y: rootPoint.y - space.originY }; + const overPlot = rawPlotPoint.x >= 0 && rawPlotPoint.x <= space.plotWidth + && rawPlotPoint.y >= 0 && rawPlotPoint.y <= space.plotHeight; + const snapped = nearestInteractiveSceneItem( + view, rawPlotPoint, assistDistance, rootPoint, overPlot, + ); + return snapped + ? normalizeVegaElementEvent( + view, snapped, point, phase, modifiers, plan.legendFields, plan.rangeLegendChannels, rootPoint, + ) + : direct; + }; const hoverHandler = (event: MouseEvent, item: any): void => { if (hoverPresentationInteractions.length === 0 || regionDragging) return; - const point = localPoint(event as unknown as PointerEvent); - const normalized = normalizeVegaElementEvent( - view, item, point, 'preview', interactionModifiers(event), plan.legendFields, - ); + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const normalized = acquire(item, point, rootPoint, 'preview', interactionModifiers(event)); const legend = normalized.legend; if (legend) { if (!regionInteraction && !navigationInteraction) container.style.cursor = 'pointer'; - const resolved = resolveTarget( - 'hover', normalized.role, normalized.event.hits, legend.value, legend.field, - ); + const resolved = legendSemanticTarget(legend); hoverActive = true; for (const interaction of hoverInteractions) { void dispatch(interaction, { @@ -580,8 +880,7 @@ export function mountVegaInteractions( modifiers: normalized.event.modifiers, }); } - void setHover([], - legend.channel ? { channel: legend.channel, value: legend.value } : null); + void setHover([], legend); return; } const hovered = normalized.event.hits[0]; @@ -611,27 +910,251 @@ export function mountVegaInteractions( }); } void setHover(presentationElements - .map((element) => element.key[INTERACTION_KEY]) - .filter((key): key is string => typeof key === 'string') ?? []); + .flatMap(semanticElementRenderKeys)); }; const clickHandler = (event: MouseEvent, item: any): void => { if (clickInteractions.length === 0 || suppressClick) return; + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const normalized = acquire(item, point, rootPoint, 'commit', interactionModifiers(event)); + const { legend } = normalized; + const target = legend ? resolvedLegendInteractionTarget( + { channel: legend.channel, field: legend.field, domain: legend.domain }, + resolveTarget('click', 'legend-item', [], legend), + ) + : resolveTarget('click', normalized.role, normalized.event.hits); + for (const interaction of clickInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', target, point, + modifiers: normalized.event.modifiers, + }, legend); + } + }; + const contextHandler = (event: MouseEvent): void => { + if (contextInteractions.length === 0) return; + event.preventDefault(); const point = localPoint(event as unknown as PointerEvent); + // A zero radius resolves the mark under the pointer; assist widens it. + const item = nearestSceneItem(view, point, Math.max(assistDistance, 0)); const normalized = normalizeVegaElementEvent( - view, item, point, 'commit', interactionModifiers(event), plan.legendFields, + view, item, point, 'commit', interactionModifiers(event), plan.legendFields, plan.rangeLegendChannels, + { x: point.x + coordinateSpace().originX, y: point.y + coordinateSpace().originY }, ); const { legend } = normalized; - const target = resolveTarget( - 'click', normalized.role, normalized.event.hits, legend?.value, legend?.field, - ); - for (const interaction of clickInteractions) { + const target = legend ? legendSemanticTarget(legend) + : resolveTarget('click', normalized.role, normalized.event.hits); + for (const interaction of contextInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target, point, modifiers: normalized.event.modifiers, - }, legend?.channel ? { channel: legend.channel, value: legend.value } : null); + }); + } + }; + const inspectModeIndices = new Map(inspectInteractions.map((interaction) => [interaction.id, 0])); + const inspectModes = (interaction: CanvasInteractionDef) => interaction.eventSource.inspectCycle ?? [{ + inspect: interaction.eventSource.inspect ?? 'xy', + predicate: interaction.eventSource.inspectPredicate ?? {}, + }]; + const activeInspectMode = (interaction: CanvasInteractionDef) => { + const modes = inspectModes(interaction); + return modes[inspectModeIndices.get(interaction.id) ?? 0] ?? modes[0]; + }; + const inspectHandler = (event: MouseEvent): void => { + if (inspectInteractions.length === 0) return; + const point = localPoint(event as unknown as PointerEvent); + const space = coordinateSpace(); + const items = sceneItems(view); + const polarFrame = polarFrameFromItems(items, point); + let guideRendered = false; + for (const interaction of inspectInteractions) { + const activeMode = activeInspectMode(interaction); + const mode = activeMode.inspect; + const eligibleItems = interaction.eventSource.selector + ? items.filter((item) => matchesSemanticTargetSelector( + interaction.eventSource.selector!, plan.fields, item.datum ?? {}, + )) + : items; + const tolerance = interaction.eventSource.inspectTolerance ?? 0.01; + const guide = interaction.eventSource.inspectGuide ?? normalizeInspectGuideOptions(undefined); + const hits = polarFrame + ? polarInspectHits(eligibleItems, point, polarFrame) + : tolerantInspectHits( + eligibleItems, + point, + mode, + activeMode.predicate, + { x: space.plotWidth * tolerance, y: space.plotHeight * tolerance }, + ); + if (guide.visible && polarFrame) { + const segment = polarGuideSegment(polarFrame, point); + inspectGuideOverlay.renderSegment(segment.start, segment.end, guide.style); + guideRendered = true; + } else if (guide.visible) { + inspectGuideOverlay.renderAxes(point, mode, guide.style); + guideRendered = true; + } + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'preview', + target: hits.length > 0 ? resolveTarget('hover', 'mark', hits) : null, + point, + modifiers: interactionModifiers(event), + }); } + if (!guideRendered) inspectGuideOverlay.clear(); }; + let lastInspectWheelAt = 0; + const cycleInspect = (event: MouseEvent, direction: 1 | -1): void => { + const cycling = inspectInteractions.filter((interaction) => inspectModes(interaction).length > 1); + if (cycling.length === 0) return; + event.preventDefault(); + event.stopImmediatePropagation(); + for (const interaction of cycling) { + const modes = inspectModes(interaction); + const current = inspectModeIndices.get(interaction.id) ?? 0; + inspectModeIndices.set(interaction.id, (current + direction + modes.length) % modes.length); + } + inspectHandler(event); + }; + const inspectWheel = (event: WheelEvent): void => { + const now = performance.now(); + event.preventDefault(); + if (now - lastInspectWheelAt < 160 || event.deltaY === 0) return; + lastInspectWheelAt = now; + cycleInspect(event, event.deltaY > 0 ? 1 : -1); + }; + const inspectContext = (event: MouseEvent): void => cycleInspect(event, 1); + const inspectLeave = (): void => { + inspectGuideOverlay.clear(); + for (const interaction of inspectInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'cancel', target: null, + }); + } + }; + if (inspectInteractions.length > 0) { + container.addEventListener('pointermove', inspectHandler); + container.addEventListener('pointerleave', inspectLeave); + if (inspectInteractions.some((interaction) => inspectModes(interaction).length > 1)) { + container.addEventListener('wheel', inspectWheel, { passive: false }); + container.addEventListener('contextmenu', inspectContext); + } + } + const pointerTarget = (event: MouseEvent) => { + const point = localPoint(event as unknown as PointerEvent); + const item = nearestSceneItem(view, point, Math.max(assistDistance, 0)); + const normalized = normalizeVegaElementEvent( + view, item, point, 'commit', interactionModifiers(event), plan.legendFields, plan.rangeLegendChannels, + { x: point.x + coordinateSpace().originX, y: point.y + coordinateSpace().originY }, + ); + return { + point, + modifiers: normalized.event.modifiers, + legend: normalized.legend, + target: normalized.legend ? legendSemanticTarget(normalized.legend) + : resolveTarget('click', normalized.role, normalized.event.hits), + }; + }; + let longPressTimer: number | undefined; + const dismissPolicy = dismiss === false ? { click: false as const, escape: false } : { + click: dismiss?.click ?? 'non-element' as const, + escape: dismiss?.escape ?? true, + }; + let dismissTimer: number | undefined; + let consumeDismissClick = false; + const cancelPendingDismiss = (): void => { + if (dismissTimer === undefined) return; + window.clearTimeout(dismissTimer); + dismissTimer = undefined; + }; + const clearDismissibleState = (): void => { + cancelPendingDismiss(); + let changed = false; + for (const layer of [retainedUpdates, previewUpdates]) { + for (const [id, update] of layer) { + const ops = update.ops.filter((op) => + op.op !== 'set-presentation' && op.op !== 'set-annotation'); + if (ops.length > 0) layer.set(id, { id, ops }); + else layer.delete(id); + changed = changed || ops.length !== update.ops.length; + } + } + if (changed) void renderUpdates(); + }; + const dismissOnClick = (event: MouseEvent, item: any): void => { + if (!dismissPolicy.click || isInteractiveControlTarget(event.target)) return; + if (consumeDismissClick) { + consumeDismissClick = false; + return; + } + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const normalized = acquire(item, point, rootPoint, 'commit', interactionModifiers(event)); + const target = normalized.legend + ? resolvedLegendInteractionTarget( + { channel: normalized.legend.channel, field: normalized.legend.field, domain: normalized.legend.domain }, + resolveTarget('click', 'legend-item', [], normalized.legend), + ) + : resolveTarget('click', normalized.role, normalized.event.hits); + const space = coordinateSpace(); + const inPlot = point.x >= 0 && point.x <= space.plotWidth + && point.y >= 0 && point.y <= space.plotHeight; + if (dismissPolicy.click === 'non-element' && target) return; + if (dismissPolicy.click === 'plot-background' && (!inPlot || target)) return; + cancelPendingDismiss(); + if (doubleInteractions.length > 0) { + dismissTimer = window.setTimeout(() => { + dismissTimer = undefined; + clearDismissibleState(); + }, 250); + } else { + clearDismissibleState(); + } + }; + const cancelLongPress = (): void => { + if (longPressTimer === undefined) return; + window.clearTimeout(longPressTimer); + longPressTimer = undefined; + }; + const longPressStart = (event: PointerEvent): void => { + if (longPressInteractions.length === 0 || event.button !== 0) return; + cancelLongPress(); + const holdMs = longPressInteractions[0].eventSource.holdMs ?? 500; + longPressTimer = window.setTimeout(() => { + longPressTimer = undefined; + const acquired = pointerTarget(event); + if (!acquired.target) return; + consumeDismissClick = true; + suppressClick = true; + window.setTimeout(() => { suppressClick = false; }, 0); + for (const interaction of longPressInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', + target: acquired.target, point: acquired.point, modifiers: acquired.modifiers, + }); + } + }, holdMs); + }; + const doubleHandler = (event: MouseEvent): void => { + if (doubleInteractions.length === 0) return; + cancelPendingDismiss(); + const acquired = pointerTarget(event); + for (const interaction of doubleInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', + target: acquired.target, point: acquired.point, modifiers: acquired.modifiers, + }); + } + }; + if (longPressInteractions.length > 0) { + container.addEventListener('pointerdown', longPressStart, true); + container.addEventListener('pointerup', cancelLongPress, true); + container.addEventListener('pointermove', cancelLongPress, true); + container.addEventListener('pointercancel', cancelLongPress, true); + } + if (doubleInteractions.length > 0) container.addEventListener('dblclick', doubleHandler); + if (dismissPolicy.click) view.addEventListener('click', dismissOnClick); + if (contextInteractions.length > 0) { + container.addEventListener('contextmenu', contextHandler); + } if (clickInteractions.length > 0) { view.addEventListener('click', clickHandler); } @@ -642,15 +1165,26 @@ export function mountVegaInteractions( const previousCursor = container.style.cursor; const previousUserSelect = container.style.userSelect; + const suppressLegendTextSelection = canvasInteractions.some((interaction) => interaction.claimsLegendActivation); + if (suppressLegendTextSelection) container.style.userSelect = 'none'; const localPoint = (event: PointerEvent): { x: number; y: number } => { return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); }; + const pointerPoints = (event: PointerEvent) => { + const space = coordinateSpace(); + const client = { x: event.clientX, y: event.clientY }; + return { + point: clientToPlotPoint(client, space), + rootPoint: clientToRendererPoint(client, space), + }; + }; let elementDrag: { start: { x: number; y: number }; source: SemanticTarget; destination: SemanticTarget; moved: boolean; axis?: 'x' | 'y'; + eligibleAxes: readonly ('x' | 'y')[]; } | undefined; const reorderItemAt = (event: PointerEvent): any => { const eventItem = (event.target as any)?.__data__; @@ -663,9 +1197,11 @@ export function mountVegaInteractions( && point.y >= bounds.y1 && point.y <= bounds.y2; }); }; - const resolveDraggedTarget = (event: PointerEvent): SemanticTarget | null => { + const resolveDraggedTarget = (event: PointerEvent): { hit: RenderHit; target: SemanticTarget } | null => { const hit = renderHit(reorderItemAt(event)); - return hit ? resolveTarget('click', hit.layerRole ?? hit.markType ?? 'mark', [hit]) : null; + if (!hit) return null; + const target = resolveTarget('click', hit.layerRole ?? hit.markType ?? 'mark', [hit]); + return target ? { hit, target } : null; }; const resolveReorderDestination = ( current: { x: number; y: number }, @@ -707,8 +1243,11 @@ export function mountVegaInteractions( if (!elementDragInteraction || (event.button !== undefined && event.button !== 0)) return; const source = resolveDraggedTarget(event); if (!source) return; + const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const eligibleAxes = eligibleReorderAxesForHit(axes, source.hit).map(({ axis }) => axis); + if (eligibleAxes.length === 0) return; const start = localPoint(event); - elementDrag = { start, source, destination: source, moved: false }; + elementDrag = { start, source: source.target, destination: source.target, moved: false, eligibleAxes }; try { container.setPointerCapture?.(event.pointerId); } catch { @@ -720,7 +1259,14 @@ export function mountVegaInteractions( void dispatchElementDrag('start', event, start); }; const elementDragMove = (event: PointerEvent): void => { - if (!elementDrag) return; + if (!elementDrag) { + const source = resolveDraggedTarget(event); + const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + container.style.cursor = source && eligibleReorderAxesForHit(axes, source.hit).length > 0 + ? 'grab' + : previousCursor; + return; + } const current = localPoint(event); if (!elementDrag.moved && Math.hypot( current.x - elementDrag.start.x, @@ -730,7 +1276,8 @@ export function mountVegaInteractions( const deltaX = Math.abs(current.x - elementDrag.start.x); const deltaY = Math.abs(current.y - elementDrag.start.y); const preferred = deltaY > deltaX ? 'y' : 'x'; - const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const axes = (plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : [])) + .filter(({ axis }) => elementDrag?.eligibleAxes.includes(axis)); elementDrag.axis = axes.find((axis) => axis.axis === preferred)?.axis ?? axes[0]?.axis; } if (!elementDrag.axis) return; @@ -769,26 +1316,36 @@ export function mountVegaInteractions( container.style.cursor = previousCursor; }; if (elementDragInteraction) { - container.style.cursor = 'grab'; container.style.userSelect = 'none'; container.addEventListener('pointerdown', elementDragStart, true); container.addEventListener('pointermove', elementDragMove, true); container.addEventListener('pointerup', elementDragEnd, true); container.addEventListener('pointercancel', elementDragCancel, true); } - const regionGesture = regionInteraction ? mountVegaRegionGesture({ + const mountedRegionInteraction = regionInteraction + && plan.angularXBrush + && regionInteraction.eventSource.type === 'region' + && regionInteraction.eventSource.axis === 'x' + && regionInteraction.eventSource.regionGeometry === undefined + ? { + ...regionInteraction, + eventSource: { ...regionInteraction.eventSource, regionGeometry: 'angular' as const }, + } + : regionInteraction; + const regionGesture = mountedRegionInteraction ? mountVegaRegionGesture({ view, container, - interaction: regionInteraction, + interaction: mountedRegionInteraction, getSelected: selectedKeys, setSelected: (next) => { - previewUpdates.set(regionInteraction.id, { - id: regionInteraction.id, + if (mountedRegionInteraction.eventSource.viewport) return; + previewUpdates.set(mountedRegionInteraction.id, { + id: mountedRegionInteraction.id, ops: [{ op: 'set-presentation', targets: next.size > 0 ? [{ visual: { kind: 'region', role: 'selection' }, - elements: [...next].map((key) => ({ key: { [INTERACTION_KEY]: key } })), + elements: [...next].map((key) => associateSemanticElementRenderKeys({ value: {} }, [key])), }] : [], value: { state: next.size > 0 ? 'emphasized' : 'normal' }, }], @@ -797,12 +1354,13 @@ export function mountVegaInteractions( coordinateSpace, containerLayoutSize, resolveTarget: (gesture, role, hits) => resolveTarget(gesture, role, hits), - dispatch: (event) => dispatch(regionInteraction, event), + dispatch: (event) => dispatch(mountedRegionInteraction, event), clearHover, clearAnnotation, sync: renderUpdates, setSuppressClick: (suppress) => { suppressClick = suppress; }, setDragging: (dragging) => { regionDragging = dragging; }, + resetViewport: resetViewportRegion, }) : undefined; const navigationGesture = navigationInteraction ? mountVegaNavigationGesture({ container, @@ -813,25 +1371,120 @@ export function mountVegaInteractions( setSuppressClick: (suppress) => { suppressClick = suppress; }, setDragging: (dragging) => { regionDragging = dragging; }, }) : undefined; - const clickOnlyKeyDown = (event: KeyboardEvent): void => { - if (regionInteraction || event.key !== 'Escape') return; - for (const layer of [retainedUpdates, previewUpdates]) { - for (const [id, update] of layer) { - const ops = update.ops.filter((op) => - op.op !== 'set-presentation' && op.op !== 'set-annotation'); - if (ops.length > 0) layer.set(id, { id, ops }); - else layer.delete(id); - } - } + const dismissKeyDown = (event: KeyboardEvent): void => { + if (regionInteraction || event.key !== 'Escape' || !dismissPolicy.escape) return; selectedLegend = null; - void renderUpdates(); + clearDismissibleState(); }; - if (clickInteractions.length > 0 && !regionInteraction) container.addEventListener('keydown', clickOnlyKeyDown); + if (dismissPolicy.escape && !regionInteraction) { + container.addEventListener('keydown', dismissKeyDown); + } + + // One tab stop enters the chart; arrows move to the nearest target in that direction. + let activeKeyboardKey: string | undefined; + const keyboardTargets = (): any[] => { + const seen = new Set(); + const items: any[] = []; + for (const item of sceneItems(view)) { + const key = renderHit(item)?.datum[INTERACTION_KEY]; + if (typeof key !== 'string' || seen.has(key) || !item.bounds) continue; + seen.add(key); + items.push(item); + } + return items.sort((left, right) => + (left.bounds.x1 - right.bounds.x1) || (left.bounds.y1 - right.bounds.y1)); + }; + const keyboardFocus = (item: any) => { + const hit = renderHit(item); + if (!hit) return undefined; + return { + point: { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }, + target: resolveTarget('click', 'mark', [hit]), + key: hit.datum[INTERACTION_KEY], + }; + }; + const moveKeyboardTarget = (direction: SpatialDirection): void => { + const items = keyboardTargets(); + if (items.length === 0) return; + const current = activeKeyboardKey === undefined + ? undefined + : items.find((item) => renderHit(item)?.datum[INTERACTION_KEY] === activeKeyboardKey); + const next = current + ? nextItemInDirection(items, { + x: (current.bounds.x1 + current.bounds.x2) / 2, + y: (current.bounds.y1 + current.bounds.y2) / 2, + }, direction) + : direction === 'right' || direction === 'down' ? items[0] : items[items.length - 1]; + if (!next) return; + const active = keyboardFocus(next); + if (!active) return; + activeKeyboardKey = typeof active.key === 'string' ? active.key : undefined; + const interaction = clickInteractions[0]; + if (interaction) { + emitCanvasInteractionEvent(interaction, toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', + target: active.target, point: active.point, + }, keyboardTrigger)); + } + void setHover(activeKeyboardKey ? [activeKeyboardKey] : []); + }; + const activateKeyboardTarget = (): void => { + if (activeKeyboardKey === undefined) return; + const item = keyboardTargets() + .find((candidate) => renderHit(candidate)?.datum[INTERACTION_KEY] === activeKeyboardKey); + const active = item ? keyboardFocus(item) : undefined; + if (!active) return; + for (const interaction of clickInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', + target: active.target, point: active.point, + }, null, 'activate-element'); + } + }; + const keyboardKeyDown = (event: KeyboardEvent): void => { + switch (event.key) { + case 'ArrowRight': + event.preventDefault(); + moveKeyboardTarget('right'); + return; + case 'ArrowLeft': + event.preventDefault(); + moveKeyboardTarget('left'); + return; + case 'ArrowDown': + event.preventDefault(); + moveKeyboardTarget('down'); + return; + case 'ArrowUp': + event.preventDefault(); + moveKeyboardTarget('up'); + return; + case 'Enter': + case ' ': + event.preventDefault(); + activateKeyboardTarget(); + return; + case 'Escape': + activeKeyboardKey = undefined; + void setHover([]); + return; + default: + } + }; + const keyboardEnabled = keyboardTargeting && clickInteractions.length > 0; + if (keyboardEnabled) { + container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; + container.addEventListener('keydown', keyboardKeyDown); + } // Overlays project scenegraph geometry into screen pixels, so every one of // them is re-projected whenever the rendered size changes. const syncOverlays = (): void => { renderPathFocus(); + renderLegendRange(); annotationOverlay.sync(); regionGesture?.sync(); reorderResetControls.layout(); @@ -873,8 +1526,28 @@ export function mountVegaInteractions( view.removeEventListener('mousemove', hoverHandler); view.removeEventListener('mouseout', clearHover); } - if (clickInteractions.length > 0 && !regionInteraction) { - container.removeEventListener('keydown', clickOnlyKeyDown); + if (dismissPolicy.escape && !regionInteraction) { + container.removeEventListener('keydown', dismissKeyDown); + } + if (keyboardEnabled) container.removeEventListener('keydown', keyboardKeyDown); + if (contextInteractions.length > 0) container.removeEventListener('contextmenu', contextHandler); + if (longPressInteractions.length > 0) { + cancelLongPress(); + container.removeEventListener('pointerdown', longPressStart, true); + container.removeEventListener('pointerup', cancelLongPress, true); + container.removeEventListener('pointermove', cancelLongPress, true); + container.removeEventListener('pointercancel', cancelLongPress, true); + } + if (doubleInteractions.length > 0) container.removeEventListener('dblclick', doubleHandler); + if (dismissPolicy.click) { + cancelPendingDismiss(); + view.removeEventListener('click', dismissOnClick); + } + if (inspectInteractions.length > 0) { + container.removeEventListener('pointermove', inspectHandler); + container.removeEventListener('pointerleave', inspectLeave); + container.removeEventListener('wheel', inspectWheel); + container.removeEventListener('contextmenu', inspectContext); } regionGesture?.destroy(); navigationGesture?.destroy(); @@ -885,16 +1558,19 @@ export function mountVegaInteractions( container.removeEventListener('pointercancel', elementDragCancel, true); } focusOverlay.destroy(); + legendRangeOverlay.destroy(); annotationOverlay.destroy(); + inspectGuideOverlay.destroy(); dragReorderOverlay.destroy(); reorderResetControls.destroy(); + viewportResetControl.destroy(); resizeObserver?.disconnect(); observedRenderer = undefined; if (syncFrame !== undefined && typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(syncFrame); syncFrame = undefined; } - if (elementDragInteraction) container.style.userSelect = previousUserSelect; + if (elementDragInteraction || suppressLegendTextSelection) container.style.userSelect = previousUserSelect; if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; }; const clearUpdate = async (id: string): Promise => { diff --git a/packages/flint-js/src/vegalite/interactions/stores.ts b/packages/flint-js/src/vegalite/interactions/stores.ts index e9268c57..a807ded3 100644 --- a/packages/flint-js/src/vegalite/interactions/stores.ts +++ b/packages/flint-js/src/vegalite/interactions/stores.ts @@ -1,4 +1,15 @@ export const INTERACTION_STORE = '__flint_interaction_store'; export const HOVER_STORE = '__flint_hover_store'; +export const HIDDEN_STORE = '__flint_hidden_store'; +export const LEGEND_HIDDEN_STORE = '__flint_legend_hidden_store'; export const LEGEND_HOVER_STORE = '__flint_legend_hover_store'; -export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; \ No newline at end of file +export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; + +export const INTERACTION_STORES: readonly string[] = [ + INTERACTION_STORE, + HOVER_STORE, + HIDDEN_STORE, + LEGEND_HIDDEN_STORE, + LEGEND_HOVER_STORE, + LEGEND_SELECTION_STORE, +]; \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index c7d6801f..d4ade00c 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -1,7 +1,7 @@ import { applyCategoryViewports } from '../core/filter-overflow'; import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; import { isCanvasInteraction, type InteractionDef } from '../interactive/interactions'; -import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import type { InteractionDismissPolicy, InteractiveRendererAdapter, ViewportState } from '../interactive/types'; import { assembleVegaLite } from './assemble'; import { addVegaLiteInteractions, @@ -11,6 +11,7 @@ import { withoutSemanticInteractionField, } from './interactions/compile'; import { mountVegaInteractions } from './interactions/runtime'; +import { INTERACTION_STORES } from './interactions/stores'; import { compile } from 'vega-lite'; import { Error as VegaError, parse, View } from 'vega'; import { Handler } from 'vega-tooltip'; @@ -21,6 +22,9 @@ export interface VegaInteractiveRendererOptions { enableSemanticUpdates?: boolean; expressionInterpreter?: unknown; background?: string; + assistDistance?: number; + keyboardTargeting?: boolean; + dismiss?: InteractionDismissPolicy | false; } function windowedInput( @@ -84,7 +88,9 @@ export function createVegaInteractiveRenderer( .filter((axis): axis is NonNullable => !!axis); interactionPlan.reorderAxis = interactionPlan.reorderAxes[0]; } - const source = vegaSpec.data?.find((entry: any) => Array.isArray(entry.values))?.name as string | undefined; + const source = vegaSpec.data + ?.find((entry: any) => Array.isArray(entry.values) && !INTERACTION_STORES.includes(entry.name)) + ?.name as string | undefined; if (viewports.length > 0 && !source) { throw new Error('Compiled chart has no mutable inline data source.'); } @@ -111,6 +117,9 @@ export function createVegaInteractiveRenderer( interactions, interactionPlan.resolve, interactionPlan.presentUpdate ?? ((update) => update), + options.assistDistance ?? 0, + options.keyboardTargeting ?? false, + options.dismiss, ) : undefined; diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 0ffb908f..50718017 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -7,7 +7,6 @@ import { defaultBuildEncodings, setMarkProp, alignStackOrderToColorOrder } from import { fieldsFromEncodingChannels, firstDiscreteEncodingField, - legendMatchedHits, resolveSeriesTarget, targetFromHits, } from '../../core/interaction-semantics'; @@ -32,6 +31,16 @@ function applyInterpolate(vgSpec: any, config?: Record): void { vgSpec.mark = setMarkProp(vgSpec.mark, 'interpolate', config.interpolate); } +function resolveAreaTarget(event: any, context: any, seriesField: string | undefined) { + if (event.role === 'text-label' && seriesField) { + const value = event.hits[0]?.datum?.[seriesField]; + const hits = context.allHits.filter((hit: any) => + hit.markType === 'area' && hit.datum?.[seriesField] === value); + return targetFromHits(hits, context.keyField, { kind: 'path', role: 'text-label' }); + } + return resolveSeriesTarget(event, context, seriesField); +} + /** * A single-series area (no `color` to stack) still gets an implicit zero-offset * stack from Vega-Lite. When such an area is FACETED (`column`/`row`, sharing the @@ -138,13 +147,7 @@ export const areaChartDef: ChartTemplateDef = { legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['area'], renderHoverStyles: { area: { opacity: 'spotlight' } }, - resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; - const hits = event.role === 'legend-item' && legendField - ? legendMatchedHits(event, context, legendField) - : event.hits; - return targetFromHits(hits, context.keyField, { kind: 'path', role: event.role }); - }, + resolve: (event, context) => resolveAreaTarget(event, context, seriesField), presentUpdate: presentAnnotationUpdate( () => annotationCandidates('segment-midpoint'), transitionAnnotationText(resolvedEncodings.y?.field), @@ -225,7 +228,7 @@ export const streamgraphDef: ChartTemplateDef = { legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['area'], renderHoverStyles: { area: { opacity: 'spotlight' } }, - resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + resolve: (event, context) => resolveAreaTarget(event, context, seriesField), presentUpdate: presentAnnotationUpdate( () => annotationCandidates('segment-midpoint', 'center', 'right', 'left'), transitionAnnotationText(resolvedEncodings.y?.field), diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index 3e1454c7..c797c7b7 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -60,7 +60,7 @@ export const barTableDef: ChartTemplateDef = { selectableMarks: ['bar'], renderHoverStyles: { rect: { opacity: 'contrast' } }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index 7781d591..3c91ce63 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -7,8 +7,11 @@ import { makeCartesianPivot } from '../../core/pivot'; import { planBandDodge, resolveDodge } from '../../core/band-dodge'; import { snapToBoundHeuristic } from '../../core/field-semantics'; import { + associateSemanticElementRenderKeys, elementsFromHits, + legendMatchedHits, MUTED_HOVER_STROKE, + semanticElementRenderKeys, type SemanticResolveContext, type SemanticResolveEvent, type SemanticTarget, @@ -94,9 +97,9 @@ function resolveBarTarget( context: SemanticResolveContext, seriesField: string | undefined, ): SemanticTarget | null { - const legendField = event.legendField ?? seriesField; - const hits = event.role === 'legend-item' && legendField && event.legendValue !== undefined - ? context.allHits.filter((hit) => hit.datum[legendField] === event.legendValue) + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField && event.legend + ? legendMatchedHits(event, context, legendField) : event.hits; const elements = elementsFromHits(hits, context.keyField); return elements.length > 0 @@ -104,6 +107,32 @@ function resolveBarTarget( : null; } +function resolveHistogramTarget( + event: SemanticResolveEvent, + context: SemanticResolveContext, + sourceField: string | undefined, + seriesField: string | undefined, +): SemanticTarget | null { + const target = resolveBarTarget(event, context, seriesField); + if (!target || !sourceField) return target; + return { + ...target, + elements: target.elements.map((element) => associateSemanticElementRenderKeys({ + ...element, + value: { + field: sourceField, + range: { + start: element.value.__bin_start, + end: element.value.__bin_end, + }, + ...(seriesField && element.value[seriesField] !== undefined + ? { [seriesField]: element.value[seriesField] } + : {}), + }, + }, semanticElementRenderKeys(element))), + }; +} + function isDivergingHeatmapScheme(scheme: string | undefined): boolean { return scheme === 'blueorange' || scheme === 'redblue'; } @@ -583,15 +612,20 @@ export const histogramDef: ChartTemplateDef = { navigation: {}, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { + const sourceField = resolvedEncodings.x?.field; const colorField = resolvedEncodings.color?.field; const seriesField = discreteField(resolvedEncodings, ['color']); return { fields: [...new Set([colorField, '__bin_start', '__bin_end'].filter((field): field is string => !!field))], + provenanceFields: colorField ? [colorField] : [], + rangeProvenance: sourceField + ? [{ field: sourceField, startField: '__bin_start', endField: '__bin_end' }] + : [], seriesField, legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['bar'], renderHoverStyles: rectHoverStyle(resolvedEncodings), - resolve: (event, context) => resolveBarTarget(event, context, seriesField), + resolve: (event, context) => resolveHistogramTarget(event, context, sourceField, seriesField), presentUpdate: presentAnnotationUpdate( () => barAnnotationCandidates('y'), countAnnotationText, @@ -653,9 +687,10 @@ export const heatmapDef: ChartTemplateDef = { navigation: {}, markCognitiveChannel: 'color', semanticInteractions: ({ resolvedEncodings }) => { - const fields = ['x', 'y'] + const fields = ['x', 'y', 'color'] .map((channel) => resolvedEncodings[channel]?.field) .filter((field): field is string => !!field); + const colorField = resolvedEncodings.color?.field; const categoryField = discreteField(resolvedEncodings, ['x', 'y']); const reorderAxes = (['x', 'y'] as const).flatMap((axis) => { const encoding = resolvedEncodings[axis]; @@ -666,6 +701,7 @@ export const heatmapDef: ChartTemplateDef = { return { fields: [...new Set(fields)], categoryField, + legendFields: colorField ? { color: colorField } : undefined, reorderAxis: reorderAxes[0], reorderAxes, selectableMarks: ['rect'], diff --git a/packages/flint-js/src/vegalite/templates/bullet.ts b/packages/flint-js/src/vegalite/templates/bullet.ts index 86f52b27..c4495f02 100644 --- a/packages/flint-js/src/vegalite/templates/bullet.ts +++ b/packages/flint-js/src/vegalite/templates/bullet.ts @@ -59,11 +59,12 @@ export const bulletChartDef: ChartTemplateDef = { const colorField = resolvedEncodings.color?.field; const actualField = resolvedEncodings.x?.field; const expectedField = resolvedEncodings.goal?.field; + const statusField = !colorField && actualField && expectedField ? '__status' : undefined; return { fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'x', 'goal', 'color', 'column', 'row']), categoryField, seriesField, - legendFields: colorField ? { color: colorField } : undefined, + legendFields: colorField || statusField ? { color: colorField ?? statusField! } : undefined, selectableMarks: ['bar', 'tick'], renderHoverStyles: { bar: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 78a2ba44..9eaceeaa 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -18,7 +18,7 @@ */ import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; -import { MUTED_HOVER_STROKE, targetFromHits } from '../../core/interaction-semantics'; +import { legendMatchedHits, MUTED_HOVER_STROKE, targetFromHits } from '../../core/interaction-semantics'; import { annotationCandidates, categoryValueAnnotationText, @@ -92,12 +92,20 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { return { fields: [WEEK_FIELD, WEEKDAY_FIELD, DATE_FIELD], categoryField: WEEK_FIELD, + legendFields: { color: valueField }, selectableMarks: ['rect'], renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, - resolve: (event, context) => targetFromHits(event.hits, context.keyField, { + renderSelectionStyles: { rect: { boundary: 'contiguous-region' } }, + resolve: (event, context) => targetFromHits( + event.role === 'legend-item' + ? legendMatchedHits(event, context, `sum_${valueField}`) + : event.hits, + context.keyField, + { kind: 'mark', role: 'calendar-day', - }), + }, + ), presentUpdate: presentAnnotationUpdate( () => annotationCandidates('center', 'top', 'right', 'bottom', 'left'), categoryValueAnnotationText(DATE_FIELD, valueField), diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index 3758dace..f9068998 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -86,7 +86,7 @@ export const connectedScatterDef: ChartTemplateDef = { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts index 3d9fa0ff..01cdbcd8 100644 --- a/packages/flint-js/src/vegalite/templates/gantt.ts +++ b/packages/flint-js/src/vegalite/templates/gantt.ts @@ -53,7 +53,7 @@ export const ganttChartDef: ChartTemplateDef = { selectableMarks: ['bar'], renderHoverStyles: { rect: { opacity: 'contrast' } }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts index f4dd6efa..e5bea01b 100644 --- a/packages/flint-js/src/vegalite/templates/jitter.ts +++ b/packages/flint-js/src/vegalite/templates/jitter.ts @@ -38,7 +38,7 @@ export const stripPlotDef: ChartTemplateDef = { selectableMarks: ['circle'], renderHoverStyles: { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 33b480bc..96930b06 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -40,7 +40,7 @@ export const lollipopChartDef: ChartTemplateDef = { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/map.ts b/packages/flint-js/src/vegalite/templates/map.ts index 4bd1f507..0871f139 100644 --- a/packages/flint-js/src/vegalite/templates/map.ts +++ b/packages/flint-js/src/vegalite/templates/map.ts @@ -173,16 +173,19 @@ export const mapDef: ChartTemplateDef = { semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); const colorField = resolvedEncodings.color?.field; + const sizeField = resolvedEncodings.size?.field; return { fields: fieldsFromEncodingChannels(resolvedEncodings, ['longitude', 'latitude', 'color', 'size', 'opacity']), seriesField, - legendFields: colorField ? { color: colorField } : undefined, + legendFields: colorField || sizeField + ? { ...(colorField ? { color: colorField } : {}), ...(sizeField ? { size: sizeField } : {}) } + : undefined, selectableMarks: ['circle'], renderHoverStyles: { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; @@ -319,10 +322,15 @@ export const choroplethDef: ChartTemplateDef = { categoryField: idField, selectableMarks: ['geoshape'], renderHoverStyles: { shape: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, - resolve: (event, context) => targetFromHits(event.hits, context.keyField, { - kind: 'region', - role: 'geographic-region', - }), + resolve: (event, context) => { + const hits = event.role === 'legend-item' && colorField + ? legendMatchedHits(event, context, colorField) + : event.hits; + return targetFromHits(hits, context.keyField, { + kind: 'region', + role: event.role === 'legend-item' ? 'legend-item' : 'geographic-region', + }); + }, presentUpdate: presentAnnotationUpdate( () => annotationCandidates('center'), categoryValueAnnotationText(idField, colorField), diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index fff55365..bb9f65e9 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -34,7 +34,7 @@ export const pieChartDef: ChartTemplateDef = { supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index cbd67e5f..e774bef9 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -98,6 +98,9 @@ function buildRadarLayers( const rawVal = Math.round((v.rawSum / v.count) * 100) / 100; const rad = (angle * Math.PI) / 180; finalData.push({ + [axisField]: axis, + [valueField]: rawVal, + ...(groupField ? { [groupField]: grp } : {}), __group: grp, __axis: axis, __value: normVal, @@ -285,10 +288,12 @@ export const radarChartDef: ChartTemplateDef = { channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { - const groupField = resolvedEncodings.color?.field ? '__group' : undefined; + const axisField = resolvedEncodings.x?.field; + const valueField = resolvedEncodings.y?.field; + const groupField = resolvedEncodings.color?.field; return { - fields: ['__axis', '__raw', ...(groupField ? [groupField] : [])], - categoryField: '__axis', + fields: [axisField, valueField, groupField].filter((field): field is string => !!field), + categoryField: axisField, seriesField: groupField, legendFields: groupField ? { color: groupField } : undefined, selectableMarks: ['line', 'point'], diff --git a/packages/flint-js/src/vegalite/templates/range-area.ts b/packages/flint-js/src/vegalite/templates/range-area.ts index 3eea60a8..397d94bf 100644 --- a/packages/flint-js/src/vegalite/templates/range-area.ts +++ b/packages/flint-js/src/vegalite/templates/range-area.ts @@ -66,7 +66,7 @@ export const rangeAreaChartDef: ChartTemplateDef = { selectableMarks: ['area'], renderHoverStyles: { area: { opacity: 'spotlight' } }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/rose.ts b/packages/flint-js/src/vegalite/templates/rose.ts index 36220358..99bac966 100644 --- a/packages/flint-js/src/vegalite/templates/rose.ts +++ b/packages/flint-js/src/vegalite/templates/rose.ts @@ -59,7 +59,7 @@ export const roseChartDef: ChartTemplateDef = { supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField ?? categoryField; + const legendField = event.legend?.field ?? seriesField ?? categoryField; let hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 267d36a3..f6d51226 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -71,7 +71,7 @@ export const scatterPlotDef: ChartTemplateDef = { symbol: { ...shapeOnlyHover, stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; @@ -135,16 +135,19 @@ export const regressionDef: ChartTemplateDef = { semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); const colorField = resolvedEncodings.color?.field; + const sizeField = resolvedEncodings.size?.field; return { fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'size']), seriesField, - legendFields: colorField ? { color: colorField } : undefined, + legendFields: colorField || sizeField + ? { ...(colorField ? { color: colorField } : {}), ...(sizeField ? { size: sizeField } : {}) } + : undefined, selectableMarks: ['circle'], renderHoverStyles: { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; @@ -257,7 +260,7 @@ export const rangedDotPlotDef: ChartTemplateDef = { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; @@ -328,7 +331,7 @@ export const boxplotDef: ChartTemplateDef = { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; + const legendField = event.legend?.field ?? seriesField; const hits = event.role === 'legend-item' && legendField ? legendMatchedHits(event, context, legendField) : event.hits; diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index dce58fed..0d66e259 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -44,19 +44,20 @@ export const waterfallChartDef: ChartTemplateDef = { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); const colorField = resolvedEncodings.color?.field; + const legendField = colorField ?? '__wf_color'; return { fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'color']), categoryField, seriesField, resolveGroupValue: (element) => element.records?.[0]?.__wf_color, - legendFields: colorField ? { color: colorField } : undefined, + legendFields: { color: legendField }, selectableMarks: ['bar'], annotationMarkType: 'rect', renderHoverStyles: { rect: { opacity: 'contrast' } }, resolve: (event, context) => { - const legendField = event.legendField ?? seriesField; - const hits = event.role === 'legend-item' && legendField - ? legendMatchedHits(event, context, legendField) + const resolvedLegendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && resolvedLegendField + ? legendMatchedHits(event, context, resolvedLegendField) : event.hits; return targetFromHits(hits, context.keyField, { kind: 'mark', diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index 41533820..d3626937 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -19,7 +19,7 @@ import { contrastingInk, parseColor, luminance, mixHex, toHex } from '../core/th import { CONTINUOUS_BAR_STEP_FILL, coverageSizedMarks } from './templates/utils.js'; import { LOCAL_DODGE_LANE_FILL } from './templates/bar.js'; import { CANVAS_FURNITURE_KEY, readCanvasFurniture, type CanvasFurnitureItem } from './canvas-furniture.js'; -import { withInteractionTextLabel } from './interaction-provenance.js'; +import { withInteractionLegendLabel, withInteractionTextLabel } from './interaction-provenance.js'; /** Mark families that carry data values (as opposed to chrome). */ const DATA_MARKS = new Set([ @@ -5143,7 +5143,7 @@ function applySeriesEndLabels( say('legend.placement', `series-end labels dodged by at most ${Math.round(layout.maxDisplacement)}px to avoid overlap`); } - const labelLayer: any = { + const labelLayer: any = withInteractionLegendLabel({ __themeSynthetic: true, transform, mark: { @@ -5163,7 +5163,7 @@ function applySeriesEndLabels( text: { field: textField, type: 'nominal' }, ...(colourEnc?.field ? { color: { ...colourEnc, legend: null } } : {}), }, - }; + }, { channel: 'color', field: seriesField }); appendLayer(body, labelLayer); // The names sit outside the plot rectangle, and no room is reserved for @@ -5352,7 +5352,7 @@ function bandEndLabels( { window: [{ op: 'row_number', as: '__bandDataOrder' }] }, { window: [{ op: 'row_number', as: '__bandEndRank' }], sort: [{ field: '__bandDataOrder', order: 'descending' }], groupby: [seriesField] }, ]; - const endLayer = (inside: boolean): any => ({ + const endLayer = (inside: boolean): any => withInteractionTextLabel({ __themeSynthetic: true, transform: [ ...rankTf, @@ -5378,7 +5378,7 @@ function bandEndLabels( text: { field: '__bandEndLabel', type: 'nominal' }, ...(inside ? knockedOut : inSeriesInk), }, - }); + }, { fields: [seriesField], presentation: 'independent' }); // Exactly one of the two layers is drawn: `outside` is now all of the // series or none of them, never a subset. if (!outside.length) appendLayer(body, endLayer(true)); diff --git a/packages/flint-js/tests/calendar-vegalite.test.ts b/packages/flint-js/tests/calendar-vegalite.test.ts index 091dda6a..f4e1965a 100644 --- a/packages/flint-js/tests/calendar-vegalite.test.ts +++ b/packages/flint-js/tests/calendar-vegalite.test.ts @@ -169,6 +169,8 @@ describe('Vega-Lite Calendar Heatmap', () => { expect(spec.encoding.color.field).toBe('value'); expect(spec.encoding.color.aggregate).toBe('sum'); expect(spec.encoding.color.type).toBe('quantitative'); + expect(spec._interactionSemantics.legendFields).toEqual({ color: 'value' }); + expect(spec._interactionSemantics.rangeLegendChannels).toEqual(['color']); }); it('falls back to a derived per-day count when no value field is given', () => { diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index 7a7a73e0..8ae97c94 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest'; -import { brushAngle, brushX, brushY, clickAnnotate, clickGroupHighlight, clickHighlight, dragReorder, externalInteraction, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { describe, expect, it, vi } from 'vitest'; +import { brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, doubleActivate, dragReorder, externalInteraction, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; import { toCanvasInteractionEvent } from '../src/interactive/canvas-interaction'; import { semanticVisualFamily } from '../src/core/interaction-semantics'; +import { normalizeInspectGuideOptions, normalizeRegionGuideOptions } from '../src/interactive/guides'; import { matchesSemanticTargetSelector, } from '../src/interactive/language/updates'; @@ -11,13 +12,21 @@ import { axisBrushTrigger, angularBrushTrigger, clickTrigger, + contextTrigger, + doubleActivateTrigger, hoverTrigger, + inspectTrigger, + parseInspectMode, + keyboardTrigger, + lassoTrigger, + longPressTrigger, navigationTrigger, rectangleTrigger, xBrushTrigger, yBrushTrigger, } from '../src/interactive/triggers'; import { AngularRegionSession } from '../src/interactive/gestures/angular-region'; +import { angularEditAction, isInteractiveControlTarget, pointInAngularSector } from '../src/vegalite/interactions/gestures/region'; import { cartesianDragDistance, constrainCartesianRegion, @@ -28,20 +37,35 @@ import { PanSession, wheelZoomFactor } from '../src/interactive/gestures/navigat import { guardNavigationDomain } from '../src/vegalite/interactions/navigation-scale'; import { geometryIntersectsRect, + axisIntersectingHits, INTERACTION_KEY, + nearestItemByBounds, + nearestItemOnInspectAxis, + nextItemInDirection, PATH_KEY_SUFFIX, pathHoverPresentationKey, + polarGuideSegment, + polarInspectHits, + tolerantInspectHits, normalizeVegaRegionEvent, + polygonHits, renderHit, sceneItems, } from '../src/vegalite/interactions/hit-adapter'; import { interactionsForHoverPresentation, + domainForPlotGeometry, nearestReorderHit, resolveSupportedOperation, } from '../src/vegalite/interactions/runtime'; -import { reorderOwnedItems } from '../src/vegalite/interactions/presentation/drag-reorder-overlay'; +import { + activeReorderAxis, + eligibleReorderAxes, + eligibleReorderAxesForHit, + reorderOwnedItems, +} from '../src/vegalite/interactions/presentation/drag-reorder-overlay'; import { hoverContrastOpacity } from '../src/vegalite/interactions/presentation/focus-overlay'; +import { inspectGuideLine } from '../src/vegalite/interactions/presentation/inspect-guide-overlay'; import { annotationFacingEdges, annotationLeaderPorts, @@ -125,6 +149,14 @@ function semanticUpdate( } describe('physical region gestures', () => { + it('does not start a region gesture from an interactive control', () => { + const icon = { closest: () => ({ tagName: 'BUTTON' }) } as unknown as EventTarget; + const plot = { closest: () => null } as unknown as EventTarget; + + expect(isInteractiveControlTarget(icon)).toBe(true); + expect(isInteractiveControlTarget(plot)).toBe(false); + }); + it('projects Cartesian regions and measures only the configured axis', () => { const start = { x: 20, y: 30 }; const end = { x: 80, y: 90 }; @@ -166,6 +198,36 @@ describe('physical region gestures', () => { expect(session.sector().endAngle - session.sector().startAngle).toBeCloseTo(Math.PI * 0.2); expect(session.dragDistance()).toBeCloseTo(Math.PI * 20); }); + + it('classifies both handles and the interior of a wrapped angular edit', () => { + const sector = { + center: { x: 0, y: 0 }, innerRadius: 20, outerRadius: 100, + startAngle: Math.PI * 1.75, endAngle: Math.PI * 2.25, + }; + + expect(angularEditAction(sector.startAngle + 0.02, sector)).toBe('resize-leading'); + expect(angularEditAction(sector.endAngle - 0.02, sector)).toBe('resize-trailing'); + expect(angularEditAction(0, sector)).toBe('move'); + expect(angularEditAction(Math.PI, sector)).toBeUndefined(); + }); + + it('requires a stateful angular edit pointer to remain inside the annulus', () => { + const sector = { + center: { x: 100, y: 100 }, innerRadius: 30, outerRadius: 80, + startAngle: 0, endAngle: Math.PI / 2, + }; + const pointAt = (radius: number, angle: number) => ({ + x: sector.center.x + radius * Math.sin(angle), + y: sector.center.y - radius * Math.cos(angle), + }); + + expect(pointInAngularSector(pointAt(60, Math.PI / 4), sector)).toBe(true); + expect(pointInAngularSector(pointAt(60, sector.startAngle), sector)).toBe(true); + expect(pointInAngularSector(pointAt(60, sector.endAngle), sector)).toBe(true); + expect(pointInAngularSector(pointAt(10, Math.PI / 4), sector)).toBe(false); + expect(pointInAngularSector(pointAt(90, Math.PI / 4), sector)).toBe(false); + expect(pointInAngularSector(pointAt(60, Math.PI), sector)).toBe(false); + }); }); describe('public canvas interaction events', () => { @@ -185,7 +247,7 @@ describe('public canvas interaction events', () => { point: { x: 20, y: 30 }, target: { visual: { kind: 'mark', role: 'bar' }, - elements: [{ key: { Country: 'Japan' } }], + elements: [{ value: { Country: 'Japan' } }], }, }, clickTrigger); const legend = toCanvasInteractionEvent({ @@ -195,7 +257,7 @@ describe('public canvas interaction events', () => { point: { x: 200, y: 30 }, target: { visual: { kind: 'widget', role: 'legend-symbol' }, - elements: [{ key: { Country: 'Japan' } }], + elements: [{ value: { Country: 'Japan' } }], }, }, hoverTrigger); @@ -282,7 +344,7 @@ describe('hover presentation policy', () => { const interaction = clickGroupHighlight(); const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ key: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], + elements: [{ value: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], }; const context = { chartType: 'Grouped Bar Chart', @@ -290,15 +352,15 @@ describe('hover presentation policy', () => { seriesField: 'Segment', available: [ ...target.elements, - { key: { key: 'east-a' }, records: [{ Region: 'East', Segment: 'A' }] }, - { key: { key: 'west-b' }, records: [{ Region: 'West', Segment: 'B' }] }, + { value: { key: 'east-a' }, records: [{ Region: 'East', Segment: 'A' }] }, + { value: { key: 'west-b' }, records: [{ Region: 'West', Segment: 'B' }] }, ], }; expect(semanticUpdate(interaction, target, context, { phase: 'preview' })?.ops[0]).toMatchObject({ targets: [{ elements: [ - { key: { key: 'west-a' } }, - { key: { key: 'east-a' } }, + { value: { key: 'west-a' } }, + { value: { key: 'east-a' } }, ] }], }); }); @@ -307,7 +369,7 @@ describe('hover presentation policy', () => { describe('public chart updates', () => { const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ key: { __flint_interaction_key: 'japan' } }], + elements: [{ value: { __flint_interaction_key: 'japan' } }], }; it('uses direct declarative operation JSON', () => { @@ -443,6 +505,54 @@ describe('viewport navigation', () => { }); describe('interaction definitions', () => { + it('resolves a reorder guide from an aggregate target without source records', () => { + const axis = { axis: 'x' as const, field: 'Species' }; + const preview = { + start: { x: 10, y: 20 }, current: { x: 40, y: 20 }, axis: 'x' as const, + source: { + visual: { kind: 'mark' as const, role: 'distribution' }, + elements: [{ value: { Species: 'Adelie' }, records: [] }], + }, + destination: { + visual: { kind: 'mark' as const, role: 'distribution' }, + elements: [{ value: { Species: 'Chinstrap' }, records: [{ Species: 'Chinstrap' }] }], + }, + }; + + expect(activeReorderAxis([axis], preview)).toEqual(axis); + }); + + it('does not start category reorder from a line spanning multiple axis values', () => { + const axes = [{ axis: 'x' as const, field: 'Period' }]; + const line = { + visual: { kind: 'path' as const, role: 'line' }, + elements: [{ + value: { Product: 'Laptop' }, + records: [ + { Period: 2019, Product: 'Laptop', Revenue: 20 }, + { Period: 2024, Product: 'Laptop', Revenue: 62 }, + ], + }], + }; + const point = { + visual: { kind: 'mark' as const, role: 'symbol' }, + elements: [{ + value: { Period: 2019, Product: 'Laptop', Revenue: 20 }, + records: [{ Period: 2019, Product: 'Laptop', Revenue: 20 }], + }], + }; + + expect(eligibleReorderAxes(axes, line)).toEqual([]); + expect(eligibleReorderAxes(axes, point)).toEqual(axes); + expect(eligibleReorderAxesForHit(axes, { + datum: { Period: 2019 }, source: 'mark', markType: 'symbol', + })).toEqual(axes); + expect(eligibleReorderAxesForHit(axes, { + datum: { Period: 2019 }, source: 'mark', markType: 'line', + pathData: line.elements[0].records, + })).toEqual([]); + }); + it('resolves reorder destinations by nearest axis slot, including gaps and plot edges', () => { const items = ['A', 'B', 'C'].map((Category, index) => ({ datum: { [INTERACTION_KEY]: Category, Category }, @@ -464,7 +574,7 @@ describe('interaction definitions', () => { it('lowers a committed bar drag to a category-order update', () => { const interaction = dragReorder(); const elements = ['A', 'B', 'C'].map((Category) => ({ - key: { key: Category }, records: [{ Category }], + value: { key: Category }, records: [{ Category }], })); const update = interaction.handle!({ action: 'drag-element', @@ -486,7 +596,7 @@ describe('interaction definitions', () => { it('composes sequential category reorders against the current order', () => { const interaction = dragReorder(); const elements = ['1', '2', '3', '4', '5'].map((Category) => ({ - key: { key: Category }, records: [{ Category }], + value: { key: Category }, records: [{ Category }], })); const drag = (source: number, destination: number, categoryOrder: readonly string[]) => interaction.handle!({ @@ -510,8 +620,8 @@ describe('interaction definitions', () => { [{ x: 10, y: 70 }, 'y', 'row', ['R2', 'R1']], ] as const)('selects a Heatmap reorder axis from drag direction', (delta, axis, field, orderedValues) => { const interaction = dragReorder(); - const source = { key: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; - const destination = { key: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; const update = interaction.handle!({ action: 'drag-element', phase: 'commit', geometry: { plot: { kind: 'drag', start: { x: 0, y: 0 }, current: delta, delta } }, @@ -530,8 +640,8 @@ describe('interaction definitions', () => { it('keeps a Heatmap drag on its locked axis after the pointer changes direction', () => { const interaction = dragReorder(); - const source = { key: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; - const destination = { key: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; const update = interaction.handle!({ action: 'drag-element', phase: 'commit', geometry: { @@ -557,8 +667,8 @@ describe('interaction definitions', () => { it('keeps a locked Heatmap drag active but commits no reorder over its source slot', () => { const interaction = dragReorder(); - const source = { key: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; - const destination = { key: { key: 'A/R2' }, records: [{ column: 'A', row: 'R2' }] }; + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'A/R2' }, records: [{ column: 'A', row: 'R2' }] }; const update = interaction.handle!({ action: 'drag-element', phase: 'commit', geometry: { @@ -593,18 +703,27 @@ describe('interaction definitions', () => { it('provides reusable trigger descriptors', () => { expect(clickTrigger).toEqual({ type: 'element', gesture: 'click' }); expect(hoverTrigger).toEqual({ type: 'element', gesture: 'hover' }); - expect(rectangleTrigger('contain')).toEqual({ type: 'region', gesture: 'drag', match: 'contain' }); + expect(rectangleTrigger('contain')).toEqual({ + type: 'region', + gesture: 'drag', + match: 'contain', + regionGuide: normalizeRegionGuideOptions(undefined), + }); expect(axisBrushTrigger('x', 'contain')).toEqual({ type: 'region', gesture: 'drag', axis: 'x', match: 'contain', mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(undefined), }); expect(xBrushTrigger()).toEqual({ type: 'region', gesture: 'drag', axis: 'x', match: 'intersect', mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(undefined), }); expect(yBrushTrigger('intersect', 'stateful')).toEqual({ type: 'region', gesture: 'drag', axis: 'y', match: 'intersect', mode: 'stateful', + regionGuide: normalizeRegionGuideOptions(undefined), }); expect(angularBrushTrigger('contain')).toEqual({ type: 'region', gesture: 'drag', regionGeometry: 'angular', match: 'contain', mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(undefined), }); const external = externalInteraction<{ selected: boolean }>({ id: 'story-scroll', @@ -619,7 +738,7 @@ describe('interaction definitions', () => { const context = { chartType: 'Bar Chart', selected: [] }; const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ key: { category: 'A' }, records: [{ category: 'A', value: 4 }] }], + elements: [{ value: { category: 'A' }, records: [{ category: 'A', value: 4 }] }], }; expect(handleSemanticEvent(clickHighlight(), { @@ -659,7 +778,7 @@ describe('interaction definitions', () => { it('applies brush updates only for its configured axis', () => { const target = { visual: { kind: 'region' as const, role: 'region' }, - elements: [{ key: { category: 'A' } }], + elements: [{ value: { category: 'A' } }], }; const context = { chartType: 'Scatter Plot', selected: [] }; const event = { @@ -676,8 +795,15 @@ describe('interaction definitions', () => { }], }); expect(handleSemanticEvent(brushX(), { ...event, axis: 'y' }, context)).toBeNull(); + expect(handleSemanticEvent(brushX(), { ...event, axis: 'angle' }, context)).toEqual({ + id: 'brush-x', + ops: [{ + op: 'set-presentation', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); expect(handleSemanticEvent(brushX({ mode: 'stateful' }), { - ...event, axis: 'x', phase: 'commit', operation: 'clear', target: null, + ...event, axis: 'angle', phase: 'commit', operation: 'clear', target: null, }, context)).toEqual({ id: 'brush-x', ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], @@ -736,7 +862,7 @@ describe('interaction definitions', () => { it('clears selection for an empty rectangle commit', () => { const interaction = select(); - const context = { chartType: 'Waterfall Chart', selected: [{ key: { Step: 'Revenue' } }] }; + const context = { chartType: 'Waterfall Chart', selected: [{ value: { Step: 'Revenue' } }] }; expect(semanticUpdate(interaction, null, context, { source: 'region' })) .toEqual({ id: 'select', @@ -759,7 +885,7 @@ describe('interaction definitions', () => { const interaction = clickHighlight({ dimOpacity: 0.2 }); const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ key: { Region: 'West' } }], + elements: [{ value: { Region: 'West' } }], }; const context = { chartType: 'Bar Chart', selected: [] }; const replace = semanticUpdate(interaction, target, context, { @@ -780,26 +906,26 @@ describe('interaction definitions', () => { it('keeps basic clicks local and lets group clicks propagate to the series', () => { const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ key: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }], + elements: [{ value: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }], }; const context = { chartType: 'Grouped Bar Chart', selected: [], seriesField: 'Segment', available: [ - { key: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }, - { key: { key: 'east-consumer' }, records: [{ Segment: 'Consumer' }] }, - { key: { key: 'west-corporate' }, records: [{ Segment: 'Corporate' }] }, + { value: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }, + { value: { key: 'east-consumer' }, records: [{ Segment: 'Consumer' }] }, + { value: { key: 'west-corporate' }, records: [{ Segment: 'Corporate' }] }, ], }; expect(semanticUpdate(clickHighlight(), target, context)?.ops[0]).toMatchObject({ - targets: [{ elements: [{ key: { key: 'west-consumer' } }] }], + targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], }); expect(semanticUpdate(clickGroupHighlight(), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ - { key: { key: 'west-consumer' } }, - { key: { key: 'east-consumer' } }, + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, ] }], }); }); @@ -808,7 +934,7 @@ describe('interaction definitions', () => { const interaction = clickHighlight(); const target = { visual: { kind: 'mark' as const, role: 'mark' }, - elements: [{ key: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }], + elements: [{ value: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }], }; const context = { chartType: 'Ranged Dot Plot', @@ -817,9 +943,9 @@ describe('interaction definitions', () => { seriesField: 'Sex', available: [ ...target.elements, - { key: { key: 'us-female' }, records: [{ Country: 'United States', Sex: 'Female' }] }, - { key: { key: 'us-connector' }, records: [{ Country: 'United States' }] }, - { key: { key: 'japan-male' }, records: [{ Country: 'Japan', Sex: 'Male' }] }, + { value: { key: 'us-female' }, records: [{ Country: 'United States', Sex: 'Female' }] }, + { value: { key: 'us-connector' }, records: [{ Country: 'United States' }] }, + { value: { key: 'japan-male' }, records: [{ Country: 'Japan', Sex: 'Male' }] }, ], }; @@ -832,16 +958,16 @@ describe('interaction definitions', () => { const target = { visual: { kind: 'mark' as const, role: 'region' }, elements: [ - { key: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }, - { key: { key: 'japan-connector' }, records: [{ Country: 'Japan' }] }, + { value: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }, + { value: { key: 'japan-connector' }, records: [{ Country: 'Japan' }] }, ], }; const selected = [ target.elements[0], - { key: { key: 'us-female' }, records: [{ Country: 'United States', Sex: 'Female' }] }, - { key: { key: 'us-connector' }, records: [{ Country: 'United States' }] }, - { key: { key: 'japan-male' }, records: [{ Country: 'Japan', Sex: 'Male' }] }, - { key: { key: 'japan-female' }, records: [{ Country: 'Japan', Sex: 'Female' }] }, + { value: { key: 'us-female' }, records: [{ Country: 'United States', Sex: 'Female' }] }, + { value: { key: 'us-connector' }, records: [{ Country: 'United States' }] }, + { value: { key: 'japan-male' }, records: [{ Country: 'Japan', Sex: 'Male' }] }, + { value: { key: 'japan-female' }, records: [{ Country: 'Japan', Sex: 'Female' }] }, target.elements[1], ]; const context = { @@ -851,7 +977,7 @@ describe('interaction definitions', () => { seriesField: 'Sex', available: [ ...selected, - { key: { key: 'brazil-male' }, records: [{ Country: 'Brazil', Sex: 'Male' }] }, + { value: { key: 'brazil-male' }, records: [{ Country: 'Brazil', Sex: 'Male' }] }, ], }; @@ -871,7 +997,7 @@ describe('interaction definitions', () => { }); const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ key: { key: 'asia' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }], + elements: [{ value: { key: 'asia' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }], }; const context = { chartType: 'Waterfall Chart', @@ -880,15 +1006,15 @@ describe('interaction definitions', () => { resolveGroupValue: semantics.resolveGroupValue, available: [ ...target.elements, - { key: { key: 'africa' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }, - { key: { key: 'oceania' }, records: [{ Type: 'delta', __wf_color: 'decrease' }] }, + { value: { key: 'africa' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }, + { value: { key: 'oceania' }, records: [{ Type: 'delta', __wf_color: 'decrease' }] }, ], }; expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ - { key: { key: 'asia' } }, - { key: { key: 'africa' } }, + { value: { key: 'asia' } }, + { value: { key: 'africa' } }, ] }], }); }); @@ -898,7 +1024,7 @@ describe('interaction definitions', () => { const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ - key: { key: 'west-consumer' }, + value: { key: 'west-consumer' }, records: [{ Segment: 'Consumer', __wf_color: 'increase' }], }], }; @@ -908,15 +1034,15 @@ describe('interaction definitions', () => { seriesField: 'Segment', available: [ ...target.elements, - { key: { key: 'east-consumer' }, records: [{ Segment: 'Consumer', __wf_color: 'decrease' }] }, - { key: { key: 'west-corporate' }, records: [{ Segment: 'Corporate', __wf_color: 'increase' }] }, + { value: { key: 'east-consumer' }, records: [{ Segment: 'Consumer', __wf_color: 'decrease' }] }, + { value: { key: 'west-corporate' }, records: [{ Segment: 'Corporate', __wf_color: 'increase' }] }, ], }; expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ - { key: { key: 'west-consumer' } }, - { key: { key: 'east-consumer' } }, + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, ] }], }); }); @@ -926,8 +1052,8 @@ describe('interaction definitions', () => { const target = { visual: { kind: 'mark' as const, role: 'legend-item' }, elements: [ - { key: { key: 'blue-circle' }, records: [{ Color: 'Blue', Shape: 'Circle' }] }, - { key: { key: 'orange-circle' }, records: [{ Color: 'Orange', Shape: 'Circle' }] }, + { value: { key: 'blue-circle' }, records: [{ Color: 'Blue', Shape: 'Circle' }] }, + { value: { key: 'orange-circle' }, records: [{ Color: 'Orange', Shape: 'Circle' }] }, ], }; const context = { @@ -936,7 +1062,7 @@ describe('interaction definitions', () => { seriesField: 'Color', available: [ ...target.elements, - { key: { key: 'blue-square' }, records: [{ Color: 'Blue', Shape: 'Square' }] }, + { value: { key: 'blue-square' }, records: [{ Color: 'Blue', Shape: 'Square' }] }, ], }; @@ -949,7 +1075,7 @@ describe('interaction definitions', () => { const interaction = clickGroupHighlight(); const target = { visual: { kind: 'mark' as const, role: 'circle' }, - elements: [{ key: { key: 'control-4.1' }, records: [{ Group: 'Control', Value: 4.1, Color: 'Low' }] }], + elements: [{ value: { key: 'control-4.1' }, records: [{ Group: 'Control', Value: 4.1, Color: 'Low' }] }], }; const context = { chartType: 'Strip Plot', @@ -958,15 +1084,15 @@ describe('interaction definitions', () => { seriesField: 'Color', available: [ ...target.elements, - { key: { key: 'control-5.2' }, records: [{ Group: 'Control', Value: 5.2, Color: 'High' }] }, - { key: { key: 'treatment-4.1' }, records: [{ Group: 'Treatment', Value: 4.1, Color: 'Low' }] }, + { value: { key: 'control-5.2' }, records: [{ Group: 'Control', Value: 5.2, Color: 'High' }] }, + { value: { key: 'treatment-4.1' }, records: [{ Group: 'Treatment', Value: 4.1, Color: 'Low' }] }, ], }; expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ - { key: { key: 'control-4.1' } }, - { key: { key: 'control-5.2' } }, + { value: { key: 'control-4.1' } }, + { value: { key: 'control-5.2' } }, ] }], }); }); @@ -977,7 +1103,7 @@ describe('interaction definitions', () => { }); const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ key: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], + elements: [{ value: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], }; const context = { chartType: 'Grouped Bar Chart', @@ -985,15 +1111,15 @@ describe('interaction definitions', () => { seriesField: 'Segment', available: [ ...target.elements, - { key: { key: 'west-b' }, records: [{ Region: 'West', Segment: 'B' }] }, - { key: { key: 'east-a' }, records: [{ Region: 'East', Segment: 'A' }] }, + { value: { key: 'west-b' }, records: [{ Region: 'West', Segment: 'B' }] }, + { value: { key: 'east-a' }, records: [{ Region: 'East', Segment: 'A' }] }, ], }; expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ - { key: { key: 'west-a' } }, - { key: { key: 'west-b' } }, + { value: { key: 'west-a' } }, + { value: { key: 'west-b' } }, ] }], }); }); @@ -1003,7 +1129,7 @@ describe('interaction definitions', () => { const target = { visual: { kind: 'mark' as const, role: 'circle' }, elements: [{ - key: { key: 'setosa-1.4' }, + value: { key: 'setosa-1.4' }, records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], }], }; @@ -1038,7 +1164,7 @@ describe('interaction definitions', () => { const target = { visual: { kind: 'mark' as const, role: 'task' }, elements: [{ - key: { key: 'launch' }, + value: { key: 'launch' }, records: [{ task: 'Launch', start: Date.UTC(2024, 3, 1), end, phase: 'Release' }], }], }; @@ -1058,7 +1184,7 @@ describe('interaction definitions', () => { it('lets the chart turn annotation intent into a render plan', () => { const element = { - key: { key: 'setosa-1.4' }, + value: { key: 'setosa-1.4' }, records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], }; const presentUpdate = presentAnnotationUpdate(() => ({ @@ -1086,7 +1212,7 @@ describe('interaction definitions', () => { it('lets the chart supply default annotation text', () => { const element = { - key: { key: 'setosa-1.4' }, + value: { key: 'setosa-1.4' }, records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], }; const presentUpdate = presentAnnotationUpdate(() => ({ connection: 'center' })); @@ -1102,7 +1228,7 @@ describe('interaction definitions', () => { it('uses a rendered histogram count instead of an empty raw-field fallback', () => { const element = { - key: { key: '4|4.5' }, + value: { key: '4|4.5' }, records: [{ __bin_start: 4, __bin_end: 4.5, __count: 8 }], }; const presentUpdate = presentAnnotationUpdate( @@ -1124,7 +1250,7 @@ describe('interaction definitions', () => { resolvedEncodings: { x: { field: 'Duration', type: 'quantitative' } }, } as any); const element = { - key: { key: '1.5|2' }, + value: { key: '1.5|2' }, records: [{ __bin_start: 1.5, __bin_end: 2, __count: 9 }], }; @@ -1198,7 +1324,7 @@ describe('interaction definitions', () => { }, } as any); const element = { - key: { key: '37' }, + value: { key: '37' }, records: [{ Country: 'A', Tonnes: 37 }], }; @@ -1225,7 +1351,7 @@ describe('interaction definitions', () => { }, } as any); const element = { - key: { key: 'Asia' }, + value: { key: 'Asia' }, records: [{ Step: 'Asia', __wf_prev_sum: 2536, __wf_sum: 5773 }], }; @@ -1361,7 +1487,7 @@ describe('interaction definitions', () => { ] as const)('formats a clicked %s segment as an endpoint transition', (chartType, chartDef, resolvedEncodings) => { const semantics = chartDef.semanticInteractions!({ resolvedEncodings } as any); const element = { - key: { key: 'Jan' }, + value: { key: 'Jan' }, records: [{ Month: 'Jan', Sales: 10 }, { Month: 'Feb', Sales: 14 }], }; @@ -1379,11 +1505,11 @@ describe('interaction definitions', () => { }, } as any); const pathElement = { - key: { [INTERACTION_KEY]: `A${PATH_KEY_SUFFIX}` }, + value: { [INTERACTION_KEY]: `A${PATH_KEY_SUFFIX}` }, records: [{ Miles: 7200, Price: 2.36 }, { Miles: 7600, Price: 1.78 }], }; const pointElement = { - key: { [INTERACTION_KEY]: 'A' }, + value: { [INTERACTION_KEY]: 'A' }, records: [{ Miles: 7200, Price: 2.36 }], }; @@ -1414,11 +1540,11 @@ describe('interaction definitions', () => { }, } as any); const segment = { - key: { [INTERACTION_KEY]: `A${PATH_KEY_SUFFIX}` }, + value: { [INTERACTION_KEY]: `A${PATH_KEY_SUFFIX}` }, records: [{ Miles: 9800, Price: 2.14 }, { Miles: 10000, Price: 2.53 }], }; const vertex = { - key: { [INTERACTION_KEY]: 'A' }, + value: { [INTERACTION_KEY]: 'A' }, records: [{ Miles: 9800, Price: 2.14 }], }; const pathUpdate = semantics.presentUpdate!( @@ -1603,7 +1729,7 @@ describe('interaction definitions', () => { ], ] as const)('formats a clicked %s interval from its semantic endpoints', (chartType, chartDef, resolvedEncodings, record, text) => { const semantics = chartDef.semanticInteractions!({ resolvedEncodings } as any); - const element = { key: { key: chartType }, records: [record] }; + const element = { value: { key: chartType }, records: [record] }; expect(semantics.presentUpdate!( annotationUpdate(element), @@ -1619,7 +1745,7 @@ describe('interaction definitions', () => { }, } as any); const element = { - key: { key: 'Gentoo' }, + value: { key: 'Gentoo' }, records: [{ Species: 'Gentoo', 'Body mass (g)': 5950 }], }; @@ -1634,7 +1760,7 @@ describe('interaction definitions', () => { 'outer-radial', 'center', ] as const)('preserves the ChartDef %s annotation candidate', (connection) => { - const element = { key: { key: 'datum' } }; + const element = { value: { key: 'datum' } }; const presentUpdate = presentAnnotationUpdate(() => ({ connection })); const update = presentUpdate( annotationUpdate(element, undefined, 'Value'), @@ -1654,4 +1780,694 @@ describe('interaction definitions', () => { { connection: 'top', priority: 2 }, ]); }); -}); \ No newline at end of file +}); +describe('assisted, keyboard, and lasso acquisition', () => { + const boundedItem = (key: string, x1: number, y1: number, x2: number, y2: number) => ({ + mark: { marktype: 'rect' }, + datum: { [INTERACTION_KEY]: key }, + bounds: { x1, y1, x2, y2 }, + }); + const fakeView = (items: readonly any[]) => ({ + scenegraph: () => ({ root: { mark: { marktype: 'group' }, items } }), + }); + + it('acquires the nearest mark within the assist radius', () => { + const near = boundedItem('near', 100, 100, 104, 104); + const far = boundedItem('far', 200, 200, 204, 204); + + expect(nearestItemByBounds([near, far], { x: 110, y: 102 }, 12)).toBe(near); + expect(nearestItemByBounds([near, far], { x: 150, y: 150 }, 12)).toBeUndefined(); + }); + + it('prefers a mark the pointer is already inside over a nearer edge', () => { + const inside = boundedItem('inside', 0, 0, 50, 50); + const edge = boundedItem('edge', 52, 20, 56, 24); + + expect(nearestItemByBounds([inside, edge], { x: 49, y: 22 }, 12)).toBe(inside); + }); + + it('keeps axis inspection on the nearest axis coordinate', () => { + const sameX = boundedItem('same-x', 18, 0, 22, 4); + const nearbyIn2d = boundedItem('nearby-2d', 38, 88, 42, 92); + + expect(nearestItemOnInspectAxis( + [sameX, nearbyIn2d], + { x: 21, y: 90 }, + 'x', + )).toBe(sameX); + }); + + it('inspects every bar crossed by the axis guide', () => { + const short = boundedItem('short', 0, 0, 20, 10); + const long = boundedItem('long', 0, 20, 80, 30); + const later = boundedItem('later', 60, 40, 100, 50); + + expect(axisIntersectingHits([short, long, later], 40, 'x') + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['long']); + expect(axisIntersectingHits([short, long, later], 70, 'x') + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['long', 'later']); + }); + + it('uses tolerance to choose one nearest axis value when exact acquisition is empty', () => { + const left = boundedItem('left', 0, 0, 20, 20); + const right = boundedItem('right', 22, 0, 42, 20); + + expect(tolerantInspectHits([left, right], { x: 21, y: 10 }, 'x', { x: '=' }, { x: 3, y: 0 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + expect(tolerantInspectHits([left, right], { x: 21, y: 10 }, 'x', { x: '=' }, { x: 0, y: 0 })) + .toEqual([]); + expect(tolerantInspectHits([left, right], { x: 50, y: 10 }, 'x', { x: '=' }, { x: 3, y: 0 })) + .toEqual([]); + }); + + it('returns every series mark sharing the chosen axis value', () => { + const left = boundedItem('left', 0, 0, 20, 10); + const rightA = boundedItem('right-a', 22, 0, 42, 10); + const rightB = boundedItem('right-b', 22, 20, 42, 30); + + expect(tolerantInspectHits( + [left, rightA, rightB], { x: 21, y: 5 }, 'x', { x: '=' }, { x: 3, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right-a', 'right-b']); + }); + + it('returns every mark intersected by the chosen axis slice', () => { + const long = boundedItem('long', 0, 0, 80, 10); + const short = boundedItem('short', 0, 20, 60, 30); + const missed = boundedItem('missed', 0, 40, 40, 50); + + expect(tolerantInspectHits( + [long, short, missed], { x: 50, y: 45 }, 'x', { x: '=' }, { x: 3, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['long', 'short']); + }); + + it('intersects every continuous path at the selected axis slice', () => { + const segment = (key: string, y: number) => ({ + bounds: { x1: 20, y1: y, x2: 40, y2: y + 10 }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 20, y }, { x: 40, y: y + 10 }], + }, + datum: { [INTERACTION_KEY]: key }, + mark: { marktype: 'line', name: key }, + }); + + expect(tolerantInspectHits( + [segment('a', 10), segment('b', 30)], { x: 30, y: 0 }, 'x', { x: '=' }, { x: 3, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual([ + `a${PATH_KEY_SUFFIX}`, + `b${PATH_KEY_SUFFIX}`, + ]); + }); + + it('chooses one axis value when adjacent bins share an exact boundary', () => { + const left = boundedItem('left', 0, 0, 20, 20); + const right = boundedItem('right', 20, 0, 40, 20); + + expect(tolerantInspectHits([left, right], { x: 20, y: 10 }, 'x', { x: '=' }, { x: 3, y: 0 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + }); + + it('chooses one nearest xy mark but preserves exact overlaps', () => { + const left = boundedItem('left', 0, 0, 20, 20); + const right = boundedItem('right', 22, 0, 42, 20); + const overlap = boundedItem('overlap', 22, 0, 42, 20); + + expect(tolerantInspectHits([left, right], { x: 21, y: 10 }, 'xy', { x: '=', y: '=' }, { x: 3, y: 3 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + expect(tolerantInspectHits([right, overlap], { x: 30, y: 10 }, 'xy', { x: '=', y: '=' }, { x: 3, y: 3 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right', 'overlap']); + }); + + it('acquires one quarter of the plot with mixed xy inspect predicates', () => { + const items = [ + boundedItem('upper-left', 10, 10, 20, 20), + boundedItem('upper-right', 70, 10, 80, 20), + boundedItem('crosses-right-edge', 45, 10, 55, 20), + boundedItem('lower-left', 10, 70, 20, 80), + boundedItem('lower-right', 70, 70, 80, 80), + boundedItem('nearest-outside', 48, 48, 49, 49), + ]; + + expect(tolerantInspectHits( + items, { x: 50, y: 50 }, 'xy', { x: '>=', y: '<=' }, { x: 0, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['upper-right', 'crosses-right-edge']); + + expect(tolerantInspectHits( + [boundedItem('nearest', 51, 51, 52, 52)], + { x: 50, y: 50 }, 'xy', { x: '>=', y: '<=' }, { x: 10, y: 10 }, + )).toEqual([]); + }); + + it('bounds an x inspection guide to the plot height', () => { + expect(inspectGuideLine('x', 40, { width: 300, height: 180 })).toEqual({ + x1: 40, y1: 0, x2: 40, y2: 180, + }); + }); + + it('draws a polar inspection guide from center to outer radius', () => { + const frame = { center: { x: 100, y: 80 }, outerRadius: 60 }; + + expect(polarGuideSegment(frame, { x: 130, y: 120 })).toEqual({ + start: { x: 100, y: 80 }, + end: { x: 136, y: 128 }, + }); + expect(polarGuideSegment(frame, frame.center)).toEqual({ + start: frame.center, + end: { x: 100, y: 20 }, + }); + }); + + it('normalizes compositional inspect modes', () => { + expect(parseInspectMode('x')).toEqual({ inspect: 'x', predicate: { x: '=' } }); + expect(parseInspectMode('xy<=')).toEqual({ inspect: 'xy', predicate: { x: '<=', y: '<=' } }); + expect(parseInspectMode('x<=;y>=')).toEqual({ inspect: 'xy', predicate: { x: '<=', y: '>=' } }); + expect(() => parseInspectMode('y>=;x<=' as any)).toThrow('Invalid inspect mode'); + }); + + it('acquires the arc crossed by a polar inspection guide', () => { + const arc = (key: string, startAngle: number, endAngle: number) => ({ + x: 100, y: 80, innerRadius: 20, outerRadius: 60, startAngle, endAngle, + datum: { [INTERACTION_KEY]: key }, + mark: { marktype: 'arc', name: key }, + }); + const frame = { center: { x: 100, y: 80 } }; + const items = [arc('right', 0, Math.PI), arc('left', Math.PI, 2 * Math.PI)]; + + expect(polarInspectHits(items, { x: 140, y: 80 }, frame) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + expect(polarInspectHits(items, { x: 60, y: 80 }, frame) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['left']); + }); + + it('captures marks inside a freeform lasso path', () => { + const view = fakeView([ + boundedItem('in', 20, 20, 30, 30), + boundedItem('out', 200, 200, 210, 210), + ]); + const square = [ + { x: 0, y: 0 }, { x: 60, y: 0 }, { x: 60, y: 60 }, { x: 0, y: 60 }, + ]; + + const hits = polygonHits(view, square); + expect(hits.map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['in']); + expect(polygonHits(view, square.slice(0, 2))).toEqual([]); + }); + + it('reports a polygon region as a lasso selection', () => { + const event = toCanvasInteractionEvent({ + type: 'semantic', + source: 'region', + phase: 'commit', + target: null, + region: { points: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }] }, + }, lassoTrigger()); + + expect(event.action).toBe('select-lasso'); + expect(event.geometry.plot).toMatchObject({ kind: 'polygon' }); + }); + + it('reports keyboard target movement as focus without activating', () => { + const event = toCanvasInteractionEvent({ + type: 'semantic', + source: 'element', + phase: 'preview', + target: { visual: { kind: 'mark', role: 'bar' }, elements: [{ value: { key: 'a' } }] }, + }, keyboardTrigger); + + expect(event.action).toBe('focus-element'); + }); + + it('turns a lasso selection into an emphasis update', () => { + const interaction = lassoSelect(); + const target = { + visual: { kind: 'mark' as const, role: 'symbol' }, + elements: [{ value: { key: 'a' } }, { value: { key: 'b' } }], + }; + const context = { chartType: 'Scatter Plot', selected: [] }; + + const update = interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target, + region: { points: [{ x: 0, y: 0 }, { x: 9, y: 0 }, { x: 9, y: 9 }] }, + }, interaction.eventSource), context); + + expect(update?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ elements: target.elements }], + }); + expect(interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target, axis: 'x', + }, interaction.eventSource), context)).toBeNull(); + }); + + it('lets keyboard activation reach the same click presets', () => { + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { key: 'a' } }], + }; + const context = { chartType: 'Bar Chart', selected: [] }; + const activation = { + ...toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, clickTrigger), + action: 'activate-element' as const, + }; + + expect(clickHighlight().handle!(activation, context)?.ops[0]).toMatchObject({ + op: 'set-presentation', + }); + expect(clickAnnotate().handle!(activation, context)?.ops[0]).toMatchObject({ + op: 'set-annotation', + }); + }); +}); + +describe('keyboard spatial navigation', () => { + const at = (key: string, x: number, y: number) => ({ + mark: { marktype: 'symbol' }, + datum: { [INTERACTION_KEY]: key }, + bounds: { x1: x - 3, y1: y - 3, x2: x + 3, y2: y + 3 }, + }); + const grid = [ + at('left', 10, 50), + at('centre', 50, 50), + at('right', 90, 50), + at('above', 50, 10), + at('below', 50, 90), + ]; + const from = { x: 50, y: 50 }; + const keyOf = (item: any) => item?.datum[INTERACTION_KEY]; + + it('moves to the neighbour on the axis the arrow names', () => { + expect(keyOf(nextItemInDirection(grid, from, 'right'))).toBe('right'); + expect(keyOf(nextItemInDirection(grid, from, 'left'))).toBe('left'); + expect(keyOf(nextItemInDirection(grid, from, 'up'))).toBe('above'); + expect(keyOf(nextItemInDirection(grid, from, 'down'))).toBe('below'); + }); + + it('prefers an aligned neighbour over a closer diagonal one', () => { + const items = [at('diagonal', 62, 26), at('aligned', 90, 50)]; + + expect(keyOf(nextItemInDirection(items, from, 'right'))).toBe('aligned'); + }); + + it('stops at the edge instead of wrapping around', () => { + expect(nextItemInDirection(grid, { x: 90, y: 50 }, 'right')).toBeUndefined(); + expect(nextItemInDirection(grid, { x: 10, y: 50 }, 'left')).toBeUndefined(); + }); +}); + +describe('lasso capture semantics', () => { + const mark = (key: string, x1: number, y1: number, x2: number, y2: number) => ({ + mark: { marktype: 'rect' }, + datum: { [INTERACTION_KEY]: key }, + bounds: { x1, y1, x2, y2 }, + }); + const view = (items: readonly any[]) => ({ + scenegraph: () => ({ root: { mark: { marktype: 'group' }, items } }), + }); + const square = [ + { x: 100, y: 100 }, { x: 200, y: 100 }, { x: 200, y: 200 }, { x: 100, y: 200 }, + ]; + const keys = (hits: readonly any[]) => hits.map((hit) => hit.datum[INTERACTION_KEY]).sort(); + + it('captures a mark whose area overlaps the lasso', () => { + const scene = view([ + mark('inside', 120, 120, 140, 140), + mark('straddling', 190, 140, 260, 160), + mark('outside', 300, 300, 320, 320), + ]); + + expect(keys(polygonHits(scene, square))).toEqual(['inside', 'straddling']); + }); + + it('captures a mark the lasso is drawn entirely inside', () => { + // A small loop within one long bar: no corner or centre of the bar is inside. + const scene = view([mark('long-bar', 0, 130, 600, 170)]); + + expect(keys(polygonHits(scene, square))).toEqual(['long-bar']); + }); + + it('requires the whole mark for contain', () => { + const scene = view([ + mark('inside', 120, 120, 140, 140), + mark('straddling', 190, 140, 260, 160), + ]); + + expect(keys(polygonHits(scene, square, true))).toEqual(['inside']); + }); +}); + +describe('legend, inspect, zoom, and touch presets', () => { + const context = { chartType: 'Line Chart', selected: [] }; + const seriesTarget = (name: string) => ({ + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { channel: 'color', field: 'Series', value: name }, + records: [{ Series: name }], + }], + }); + const activate = (interaction: CanvasInteractionDef, target: SemanticTarget | null, ctx: InteractionContext = context) => + interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, interaction.eventSource), ctx); + + it('hides an activated series and restores it when activated again', () => { + const interaction = legendToggle(); + + expect(activate(interaction, seriesTarget('A'))?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ elements: [{ value: { channel: 'color', field: 'Series', value: 'A' } }] }], + value: { visible: false, mutedOpacity: 0.25 }, + }); + expect(activate(interaction, seriesTarget('A'))?.ops[0]).toMatchObject({ + targets: [], + value: { visible: false, mutedOpacity: 0.25 }, + }); + }); + + it('owns the hidden legend affordance opacity', () => { + const interaction = legendToggle({ mutedOpacity: 0.4 }); + + expect(activate(interaction, seriesTarget('A'))?.ops[0]).toMatchObject({ + value: { visible: false, mutedOpacity: 0.4 }, + }); + }); + + it('accumulates several hidden series', () => { + const interaction = legendToggle(); + activate(interaction, seriesTarget('A')); + + expect(activate(interaction, seriesTarget('B'))?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { channel: 'color', field: 'Series', value: 'A' } }, + { value: { channel: 'color', field: 'Series', value: 'B' } }, + ] }], + }); + }); + + it('restores all series when the last visible series is disabled', () => { + const interaction = legendToggle(); + const initialContext = { + chartType: 'Line Chart', selected: [], + legendDomains: { color: ['A', 'B'] }, + available: [ + { value: { Series: 'A' }, records: [{ Series: 'A' }] }, + { value: { Series: 'B' }, records: [{ Series: 'B' }] }, + ], + }; + const domainTarget = (name: string) => ({ + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: 'Series', + domain: { kind: 'value' as const, value: name }, + }, + }], + }); + activate(interaction, domainTarget('A'), initialContext); + + expect(activate(interaction, domainTarget('B'), { + chartType: 'Line Chart', selected: [], + legendDomains: { color: ['A', 'B'] }, + available: [{ value: { Series: 'B' }, records: [{ Series: 'B' }] }], + })?.ops[0]).toMatchObject({ + targets: [], + value: { visible: false, mutedOpacity: 0.25 }, + }); + + expect(activate(interaction, domainTarget('A'), initialContext)?.ops[0]).toMatchObject({ + targets: [{ elements: domainTarget('A').elements }], + }); + }); + + it('does not reset early when a Streamgraph exposes collapsed availability', () => { + const interaction = legendToggle(); + const domainTarget = (name: string) => ({ + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: 'Region', + domain: { kind: 'value' as const, value: name }, + }, + }], + }); + const collapsedContext: InteractionContext = { + chartType: 'Streamgraph', selected: [], + legendDomains: { color: ['Asia', 'Africa'] }, + available: [{ value: { Region: 'Asia' }, records: [{ Region: 'Asia' }] }], + }; + + expect(activate(interaction, domainTarget('Asia'), collapsedContext)?.ops[0]).toMatchObject({ + targets: [{ elements: domainTarget('Asia').elements }], + }); + expect(activate(interaction, domainTarget('Africa'), collapsedContext)?.ops[0]).toMatchObject({ + targets: [], + }); + }); + + it('ignores mark activations so it composes with element click presets', () => { + const interaction = legendToggle(); + const markTarget = { visual: { kind: 'mark' as const, role: 'mark' }, elements: [{ value: { key: 'A' } }] }; + + expect(activate(interaction, markTarget)).toBeNull(); + }); + + it('lets highlight presets opt out of handling observable legend events', () => { + expect(activate(clickHighlight(), seriesTarget('A'))).not.toBeNull(); + expect(activate(clickHighlight({ legend: false }), seriesTarget('A'))).toBeNull(); + expect(activate(clickGroupHighlight(), seriesTarget('A'))).not.toBeNull(); + expect(activate(clickGroupHighlight({ legend: false }), seriesTarget('A'))).toBeNull(); + expect(activate(clickAnnotate(), seriesTarget('A'))).toBeNull(); + }); + + it('reports the resolved role for context, long-press, and double activation', () => { + const target = seriesTarget('A'); + const semantic = { type: 'semantic' as const, source: 'element' as const, phase: 'commit' as const, target }; + + expect(toCanvasInteractionEvent(semantic, contextTrigger).action).toBe('context-legend'); + expect(toCanvasInteractionEvent(semantic, longPressTrigger()).action).toBe('long-press-legend'); + expect(toCanvasInteractionEvent(semantic, doubleActivateTrigger).action).toBe('double-activate-legend'); + }); + + it('preserves an unresolved legend domain for processor expansion', () => { + const target = { + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: '__status', + domain: { kind: 'value' as const, value: 'Meets target' }, + }, + }], + }; + const event = toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, clickTrigger); + + expect(event).toMatchObject({ action: 'click-legend', target }); + expect(activate(clickHighlight(), target)?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ visual: target.visual, elements: target.elements }], + value: { state: 'emphasized' }, + }); + expect(activate(clickGroupHighlight(), target)?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ visual: target.visual, elements: target.elements }], + value: { state: 'emphasized' }, + }); + expect(activate(legendToggle(), target)?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ visual: target.visual, elements: target.elements }], + value: { visible: false }, + }); + }); + + it('reports inspection modes as their own actions', () => { + expect(inspect().eventSource).toEqual(inspectTrigger('xy')); + expect(inspectTrigger('xy').inspectTolerance).toBe(0.02); + expect(inspectTrigger('x').inspectTolerance).toBe(0.01); + expect(inspectTrigger('xy<=').inspectTolerance).toBe(0.01); + expect(inspectTrigger('xy', undefined, 0.03).inspectTolerance).toBe(0.03); + expect(inspect({ + mode: 'x>=;y<=', cycle: ['x>=;y<=', 'x>=;y>=', 'x<=;y>=', 'x<=;y<='], + }).eventSource.inspectCycle).toEqual([ + { inspect: 'xy', predicate: { x: '>=', y: '<=' } }, + { inspect: 'xy', predicate: { x: '>=', y: '>=' } }, + { inspect: 'xy', predicate: { x: '<=', y: '>=' } }, + { inspect: 'xy', predicate: { x: '<=', y: '<=' } }, + ]); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: null, + }, inspectTrigger('x')).action).toBe('inspect-x'); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: null, + }, inspectTrigger('y')).action).toBe('inspect-y'); + }); + + it('normalizes gesture guide visibility and renderer-neutral styles', () => { + expect(normalizeInspectGuideOptions(false)).toMatchObject({ visible: false }); + expect(normalizeInspectGuideOptions({ + style: { color: '#123456', opacity: 2, width: 2, fillOpacity: -1 }, + })).toEqual({ + visible: true, + style: { color: '#123456', opacity: 1, width: 2, fillOpacity: 0 }, + }); + expect(normalizeRegionGuideOptions({ + style: { fillOpacity: -1, strokeOpacity: 2, strokeWidth: 3 }, + })).toMatchObject({ + visible: true, + style: { fillOpacity: 0, strokeOpacity: 1, strokeWidth: 3 }, + }); + }); + + it('configures guides without changing gesture semantics', () => { + const hiddenInspect = inspect({ mode: 'x', guide: false }).eventSource; + expect(hiddenInspect).toMatchObject({ + gesture: 'inspect', inspect: 'x', inspectGuide: { visible: false }, + }); + const hiddenRegion = select({ match: 'contain', guide: false }).eventSource; + expect(hiddenRegion).toMatchObject({ + gesture: 'drag', match: 'contain', regionGuide: { visible: false }, + }); + expect(brushAngle({ guide: false }).eventSource.regionGuide?.visible).toBe(false); + expect(lassoSelect({ guide: false }).eventSource.regionGuide?.visible).toBe(false); + expect(brushZoom({ guide: false }).eventSource.regionGuide?.visible).toBe(false); + }); + + it('turns a brushed region into an absolute viewport', () => { + const interaction = brushZoom(); + const event = { + ...toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target: null, + region: { x: 0, y: 0, width: 10, height: 10 }, axis: 'xy' as const, + }, interaction.eventSource), + geometry: { + domain: { + x: { kind: 'interval' as const, start: 2, end: 8 }, + y: { kind: 'interval' as const, start: 1, end: 5 }, + }, + }, + }; + + expect(interaction.handle!(event, context)).toEqual({ + id: 'brush-zoom', + ops: [{ op: 'set-viewport', axes: 'xy', value: { x: [2, 8], y: [1, 5] } }], + }); + expect(interaction.handle!({ + ...event, + operation: 'clear', + geometry: { + domain: { + x: { kind: 'interval', start: 5, end: 5 }, + y: { kind: 'interval', start: 3, end: 3 }, + }, + }, + }, context)).toBeNull(); + }); + + it('inverts a log-scale brush in plot-local coordinates', () => { + const inverted: number[] = []; + const domain = domainForPlotGeometry({ + kind: 'rect', axis: 'xy', rect: { x: 100, y: 0, width: 200, height: 80 }, + }, { + x: { scale: 'x', signal: 'xDomain', type: 'log' }, + }, () => ({ + invert: (pixel: number) => { + inverted.push(pixel); + return 10 ** (pixel / 100); + }, + })); + + expect(inverted).toEqual([100, 300]); + expect(domain).toEqual({ x: { kind: 'interval', start: 10, end: 1000 } }); + }); + + it('preserves the scale domain direction for a vertically inverted brush range', () => { + const domain = domainForPlotGeometry({ + kind: 'rect', axis: 'y', rect: { x: 0, y: 20, width: 100, height: 40 }, + }, { + y: { scale: 'y', signal: 'yDomain', type: 'linear' }, + }, () => ({ + domain: () => [20, 40], + invert: (pixel: number) => 40 - pixel / 4, + })); + + expect(domain).toEqual({ y: { kind: 'interval', start: 25, end: 35 } }); + }); + + it('preserves a reversed y axis when the scale increases down the screen', () => { + const domain = domainForPlotGeometry({ + kind: 'rect', axis: 'y', rect: { x: 0, y: 20, width: 100, height: 40 }, + }, { + y: { scale: 'y', signal: 'yDomain', type: 'linear' }, + }, () => ({ + invert: (pixel: number) => 20 + pixel / 4, + })); + + expect(domain).toEqual({ y: { kind: 'interval', start: 35, end: 25 } }); + }); + + it('normalizes viewport brush geometry without scanning marks', () => { + const view = { + width: () => 100, + height: () => 80, + scenegraph: () => { throw new Error('scenegraph should not be read'); }, + }; + + const event = normalizeVegaRegionEvent( + view, { x: 10, y: 20 }, { x: 60, y: 70 }, 'preview', 'intersect', + { shift: false, ctrl: false, meta: false }, + 'xy', { width: 100, height: 80 }, 'create', false, + ); + + expect(event.region).toEqual({ x: 10, y: 20, width: 50, height: 50 }); + expect(event.hits).toEqual([]); + }); + + it('ignores a brush that collapsed to a single value', () => { + const interaction = brushZoom({ axes: 'x' }); + const event = { + ...toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target: null, + region: { x: 0, y: 0, width: 0, height: 10 }, axis: 'x' as const, + }, interaction.eventSource), + geometry: { domain: { x: { kind: 'interval' as const, start: 4, end: 4 } } }, + }; + + expect(interaction.handle!(event, context)).toBeNull(); + }); + + it('reports long press and highlights on double activation', () => { + const target = { + visual: { kind: 'mark' as const, role: 'point' }, + elements: [{ value: { category: 'A' } }], + }; + expect(longPress({ holdMs: 250 }).eventSource).toMatchObject({ gesture: 'long-press', holdMs: 250 }); + expect(longPress().handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, longPressTrigger()), context)?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ visual: target.visual, elements: target.elements }], + value: { state: 'emphasized' }, + }); + expect(doubleActivate().handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, doubleActivateTrigger), context)?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ visual: target.visual, elements: target.elements }], + value: { state: 'emphasized' }, + }); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target: null, + }, longPressTrigger()).action).toBe('long-press-element'); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target: null, + }, doubleActivateTrigger).action).toBe('double-activate-element'); + }); + + it('lets the angular brush be edited once committed', () => { + expect(brushAngle({ mode: 'stateful' }).eventSource).toMatchObject({ + regionGeometry: 'angular', mode: 'stateful', + }); + expect(brushAngle().eventSource).toMatchObject({ mode: 'ephemeral' }); + }); +}); diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index cdaf25a4..aefcd89d 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -2,14 +2,22 @@ import { describe, expect, it } from 'vitest'; import { changeset, parse, View } from 'vega'; import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src/vegalite/assemble'; -import { brushAngle, clickAnnotate, clickHighlight, dragReorder, externalInteraction, navigate, select } from '../src/interactive/interactions'; -import type { SemanticElement, SemanticTarget } from '../src/interactive/interactions'; -import { MUTED_HOVER_FILL, MUTED_HOVER_STROKE } from '../src/core/interaction-semantics'; +import { brushAngle, brushX, brushZoom, clickAnnotate, clickHighlight, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; +import type { RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; +import { + associateSemanticElementRenderKeys, + MUTED_HOVER_FILL, + MUTED_HOVER_STROKE, + legendMatchedHits, + semanticElementRenderKeys, + sourceRecordsForRenderedRecords, +} from '../src/core/interaction-semantics'; import { areaChartDef, streamgraphDef } from '../src/vegalite/templates/area'; import { barChartDef, groupedBarChartDef, heatmapDef, + histogramDef, pyramidChartDef, stackedBarChartDef, } from '../src/vegalite/templates/bar'; @@ -30,22 +38,38 @@ import { boundsIntersectRect, clientRectToLayoutRect, clientToPlotPoint, + clientToRendererPoint, clientToLayoutPoint, INTERACTION_KEY, + INTERACTION_LEGEND_CHANNEL, + INTERACTION_LEGEND_FIELD, INTERACTION_ROLE, PATH_KEY_SUFFIX, plotToClientPoint, + legendEntryItemAtPoint, + legendSemanticTarget, + legendTarget, + normalizeVegaElementEvent, + nearestItemByBounds, + nearestInteractiveSceneItem, rendererPlotOrigin, renderHit, sceneItems, + tolerantInspectHits, + continuousLegendSegmentCount, } from '../src/vegalite/interactions/hit-adapter'; import { + HIDDEN_STORE, HOVER_STORE, INTERACTION_STORE, + LEGEND_HIDDEN_STORE, LEGEND_HOVER_STORE, LEGEND_SELECTION_STORE, } from '../src/vegalite/interactions/stores'; -import { mergeContiguousSelectionBounds } from '../src/vegalite/interactions/presentation/focus-overlay'; +import { + mergeContiguousSelectionBounds, + selectionBoundarySegments, +} from '../src/vegalite/interactions/presentation/focus-overlay'; import { annotationBounds, annotationConnectionPoint, @@ -56,6 +80,7 @@ import { THEME_PRESETS } from '../src/core/theme/presets'; import { lineChartDef } from '../src/vegalite/templates/line'; import { bumpChartDef } from '../src/vegalite/templates/bump'; import { slopeChartDef } from '../src/vegalite/templates/slope'; +import { enrichTargetWithSourceProvenance } from '../src/vegalite/interactions/runtime'; import { regressionDef } from '../src/vegalite/templates/scatter'; import { mapDef, choroplethDef } from '../src/vegalite/templates/map'; import { densityPlotDef } from '../src/vegalite/templates/density'; @@ -63,9 +88,17 @@ import { ecdfPlotDef } from '../src/vegalite/templates/ecdf'; import { vlCalendarHeatmapDef } from '../src/vegalite/templates/calendar'; import { sparklineDef } from '../src/vegalite/templates/sparkline'; import { violinPlotDef } from '../src/vegalite/templates/violin'; +import { waterfallChartDef } from '../src/vegalite/templates/waterfall'; import { bulletChartDef } from '../src/vegalite/templates/bullet'; import { kpiCardDef } from '../src/vegalite/templates/kpi-card'; import { radarChartDef } from '../src/vegalite/templates/radar'; +import { createLegendToggleInteraction } from '../src/interactive/presets/legend-toggle'; +import { + resolveLegendPresentationTarget, + resolveRetainedLegendPresentationTarget, + resolveRetainedLegendPresentationTargets, + resolvedLegendInteractionTarget, +} from '../src/vegalite/interactions/runtime'; function annotationUpdate( element: SemanticElement, @@ -99,7 +132,164 @@ function allSceneItems(view: View): any[] { return items; } +function rootSceneBounds(view: View, target: any) { + let result: { x1: number; y1: number; x2: number; y2: number } | undefined; + const visit = (item: any, offsetX = 0, offsetY = 0): void => { + if (!item || result) return; + if (item === target && item.bounds) { + result = { + x1: item.bounds.x1 + offsetX, y1: item.bounds.y1 + offsetY, + x2: item.bounds.x2 + offsetX, y2: item.bounds.y2 + offsetY, + }; + return; + } + const isGroup = item.mark?.marktype === 'group'; + const nextX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const nextY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + item.items?.forEach((child: any) => visit(child, nextX, nextY)); + }; + visit((view.scenegraph() as any).root); + return result; +} + describe('Vega-Lite semantic interactions', () => { + it('keeps transformed values separate from source-record provenance', () => { + const sourceRecords = [ + { OS: 'Android', Share: 71 }, + { OS: 'iOS', Share: 29 }, + ]; + const rendered = [{ OS: 'Android', Share: 71, Share_start: 0, Share_end: 71 }]; + + expect(sourceRecordsForRenderedRecords(rendered, sourceRecords, ['OS', 'Share'])) + .toEqual([{ OS: 'Android', Share: 71 }]); + expect(sourceRecordsForRenderedRecords( + [{ Region: 'West', Sales: 300 }], + [ + { Region: 'West', Sales: 100 }, + { Region: 'West', Sales: 200 }, + { Region: 'East', Sales: 50 }, + ], + ['Region'], + )).toEqual([ + { Region: 'West', Sales: 100 }, + { Region: 'West', Sales: 200 }, + ]); + expect(sourceRecordsForRenderedRecords( + [{ Games: Date.UTC(2012, 0, 1), Country: 'China', Rank: 2 }], + [ + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2016, Country: 'China', Rank: 3 }, + ], + ['Games', 'Country', 'Rank'], + ['Games'], + )).toEqual([{ Games: 2012, Country: 'China', Rank: 2 }]); + }); + + it('resolves a histogram bin to its range, count, and contributing records', () => { + const semantics = histogramDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Duration', type: 'quantitative' } }, + }); + const hit = { + datum: { + [INTERACTION_KEY]: '3|3.5', + __bin_start: 3, + __bin_end: 3.5, + }, + source: 'mark' as const, + }; + const target = semantics.resolve( + { gesture: 'hover', role: 'mark', hits: [hit] }, + { allHits: [hit], keyField: INTERACTION_KEY }, + ); + const enriched = enrichTargetWithSourceProvenance(target, { + sourceRecords: [{ Duration: 2.9 }, { Duration: 3.1 }, { Duration: 3.4 }, { Duration: 3.5 }], + provenanceFields: [], + temporalProvenanceFields: [], + rangeProvenance: [{ field: 'Duration', startField: '__bin_start', endField: '__bin_end' }], + }); + + expect(enriched?.elements[0]).toEqual({ + value: { field: 'Duration', range: { start: 3, end: 3.5 }, count: 2 }, + records: [{ Duration: 3.1 }, { Duration: 3.4 }], + }); + expect(semanticElementRenderKeys(target!.elements[0])).toEqual(['3|3.5']); + expect(semanticElementRenderKeys(enriched!.elements[0])).toEqual(['3|3.5']); + }); + + it('preserves source provenance through an assembled histogram plan', async () => { + const sourceRecords = [1.7, 1.9, 2.1, 2.4, 3.1, 3.4].map((duration) => ({ + 'Duration (min)': duration, + })); + const spec = assembleVegaLite({ + data: { values: sourceRecords }, + semantic_types: { 'Duration (min)': 'Quantity' }, + chart_spec: { chartType: 'Histogram', encodings: { x: 'Duration (min)' } }, + } as never) as any; + const { plan, compiled } = instrument(spec, [inspect({ mode: 'x' })]); + if (!plan?.resolve) throw new Error('Expected an instrumented histogram plan'); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bar = sceneItems(view).find((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const hit = renderHit(bar); + if (!hit) throw new Error('Expected an interactive histogram bar'); + expect(plan.sourceRecords).toHaveLength(sourceRecords.length); + expect(hit.datum).toMatchObject({ + __bin_start: expect.any(Number), + __bin_end: expect.any(Number), + }); + const target = plan.resolve( + { gesture: 'hover', role: 'mark', hits: [hit] }, + { allHits: [hit], keyField: INTERACTION_KEY }, + ); + expect(target?.elements[0].records?.[0]).toMatchObject({ + __bin_start: bar.datum.__bin_start, + __bin_end: bar.datum.__bin_end, + }); + const enriched = enrichTargetWithSourceProvenance(target, plan); + const start = bar.datum.__bin_start as number; + const end = bar.datum.__bin_end as number; + const expectedRecords = sourceRecords + .filter((record) => record['Duration (min)'] >= start && record['Duration (min)'] < end) + .map((record) => ({ 'Duration (min)': record['Duration (min)'] })); + + expect(enriched?.elements[0].value).toEqual({ + field: 'Duration (min)', range: { start, end }, count: expectedRecords.length, + }); + expect(enriched?.elements[0].records).toEqual(expectedRecords); + view.finalize(); + }); + + it('inspect-x chooses one stacked category and returns all of its segments', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Region: 'West', Segment: 'Consumer', Value: 10 }, + { Region: 'West', Segment: 'Corporate', Value: 12 }, + { Region: 'East', Segment: 'Consumer', Value: 8 }, + { Region: 'East', Segment: 'Corporate', Value: 9 }, + ] }, + semantic_types: { Region: 'Category', Segment: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'Region', y: 'Value', color: 'Segment' }, + }, + } as never) as any; + const { compiled } = instrument(spec, [inspect({ mode: 'x' })]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const west = bars.filter((item) => item.datum.Region === 'West'); + const east = bars.filter((item) => item.datum.Region === 'East'); + const westEdge = Math.max(...west.map((item) => item.bounds.x2)); + const eastEdge = Math.min(...east.map((item) => item.bounds.x1)); + const gapPoint = { x: westEdge + (eastEdge - westEdge) / 3, y: 0 }; + const hits = tolerantInspectHits( + bars, gapPoint, 'x', { x: '=' }, { x: Math.abs(eastEdge - westEdge), y: 0 }, + ); + + expect(new Set(hits.map((hit) => hit.datum.Region))).toEqual(new Set(['West'])); + expect(new Set(hits.map((hit) => hit.datum.Segment))).toEqual(new Set(['Consumer', 'Corporate'])); + view.finalize(); + }); it('keeps path fallback connections anchored to the selected segment midpoint', () => { const item = { bounds: { x1: 56, y1: 52, x2: 196, y2: 220 }, @@ -382,6 +572,48 @@ describe('Vega-Lite semantic interactions', () => { ])); }); + it('treats a Bump legend series as one target owning its line and points', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2016, Country: 'China', Rank: 3 }, + { Games: 2020, Country: 'China', Rank: 2 }, + { Games: 2024, Country: 'China', Rank: 2 }, + { Games: 2012, Country: 'Japan', Rank: 6 }, + { Games: 2024, Country: 'Japan', Rank: 3 }, + ] }, + semantic_types: { Games: 'Year', Country: 'Country', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bump Chart', + encodings: { x: 'Games', y: 'Rank', color: 'Country' }, + }, + theme_spec: 'nyt', + } as never) as any; + const { plan, compiled } = instrument(spec, [clickHighlight()]); + if (!plan?.resolve) throw new Error('Expected an instrumented Bump plan'); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const hits = sceneItems(view).map(renderHit).filter((hit): hit is RenderHit => hit !== null); + const legend = { + channel: 'color', field: 'Country', + domain: { kind: 'value' as const, value: 'China' }, + }; + const resolved = enrichTargetWithSourceProvenance(plan.resolve({ + gesture: 'click', role: 'legend-item', hits: [], legend, + }, { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Country' }), plan); + const target = resolvedLegendInteractionTarget(legend, resolved); + const keys = semanticElementRenderKeys(target.elements[0]); + + expect(target.elements).toHaveLength(1); + expect(target.elements[0].records).toHaveLength(4); + const renderedLineKeys = hits + .filter((hit) => hit.markType === 'line' && hit.datum.Country === 'China') + .map((hit) => hit.datum[INTERACTION_KEY]); + expect(keys.filter((key) => key.endsWith(PATH_KEY_SUFFIX))).toEqual(renderedLineKeys); + expect(keys.filter((key) => !key.endsWith(PATH_KEY_SUFFIX))).toHaveLength(4); + view.finalize(); + }); + it('instruments semantic updates without passing external definitions to the renderer', () => { const spec = assembleVegaLite({ chart_spec: { @@ -486,11 +718,11 @@ describe('Vega-Lite semantic interactions', () => { expect(makeSpec('economist')._interactionSemantics.selectionBoundary).toEqual({ color: '#e3120b', - width: 1.5, - opacity: 1, + width: 1.25, + opacity: 0.68, haloColor: '#ffffff', - haloWidth: 3, - haloOpacity: 0.8, + haloWidth: 2.5, + haloOpacity: 0.35, }); expect(makeSpec({ extends: 'economist', @@ -500,7 +732,7 @@ describe('Vega-Lite semantic interactions', () => { width: 2, opacity: 0.9, haloColor: '#fffaf2', - haloWidth: 4, + haloWidth: 0, haloOpacity: 0.7, }, }, @@ -509,7 +741,7 @@ describe('Vega-Lite semantic interactions', () => { width: 2, opacity: 0.9, haloColor: '#fffaf2', - haloWidth: 4, + haloWidth: 0, haloOpacity: 0.7, }); }); @@ -526,6 +758,19 @@ describe('Vega-Lite semantic interactions', () => { ]); }); + it('traces an irregular heatmap selection without boxing in unselected cells', () => { + const segments = selectionBoundarySegments([ + { x1: 0, y1: 0, x2: 10, y2: 10 }, + { x1: 10, y1: 0, x2: 20, y2: 10 }, + { x1: 0, y1: 10, x2: 10, y2: 20 }, + ]); + + expect(segments).toHaveLength(8); + expect(segments).not.toContainEqual({ x1: 20, y1: 10, x2: 20, y2: 20 }); + expect(segments).toContainEqual({ x1: 10, y1: 10, x2: 10, y2: 20 }); + expect(segments).toContainEqual({ x1: 10, y1: 10, x2: 20, y2: 10 }); + }); + it('keeps themed line vertices filled when expanding them for interaction', () => { const spec = assembleVegaLite({ data: { @@ -670,13 +915,65 @@ describe('Vega-Lite semantic interactions', () => { ); expect(target?.visual).toEqual({ kind: 'mark', role: 'text-label' }); - expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual([ + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual([ 'Jan|A', 'Jan|B', 'Jan', ]); }); + it('resolves a stacked-area series-end label to the whole band', async () => { + const rows = [1950, 1970, 1990, 2010, 2020].flatMap((Year, yearIndex) => + Object.entries({ Asia: 4641, Africa: 1361, Europe: 748, Americas: 1023, Oceania: 45 }) + .map(([Region, finalValue]) => ({ + Year, + Region, + Population: Math.round(finalValue * (0.6 + yearIndex * 0.1)), + }))); + const spec = assembleVegaLite({ + data: { values: rows }, + semantic_types: { Year: 'Year', Region: 'Category', Population: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'Year', y: 'Population', color: 'Region' }, + chartProperties: { stackMode: 'stack' }, + }, + theme_spec: { + id: 'series-end-test', + label: 'Series end test', + ink: { + surface: { canvas: '#fff', plot: '#fff' }, + text: { primary: '#111' }, + series: { single: '#111', categorical: ['#011827', '#2251ff', '#00a9f4', '#00c7b1', '#9ca8b3'] }, + }, + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, + }, + } as any) as any; + const resolve = spec._interactionSemantics.resolve; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const scene = allSceneItems(view); + const labelItem = scene.find((item) => + item.mark?.marktype === 'text' && item.datum?.Region === 'Africa' + && item.datum?.[INTERACTION_ROLE] === 'text-label'); + const labelHit = renderHit(labelItem)!; + const allHits = sceneItems(view).map(renderHit).filter(Boolean); + const target = resolve({ + gesture: 'click', role: 'text-label', hits: [labelHit], + }, { + allHits, + keyField: INTERACTION_KEY, + categoryField: 'Year', + seriesField: 'Region', + }); + + expect(labelHit.datum[INTERACTION_KEY]).toBe('Africa'); + expect(target?.visual).toEqual({ kind: 'path', role: 'text-label' }); + expect(target?.elements).toHaveLength(4); + expect(target?.elements.every((element: SemanticElement) => element.value.Region === 'Africa')).toBe(true); + }); + it('formats a Rose sector from its encoded category and value fields', () => { const semantics = roseChartDef.semanticInteractions!({ resolvedEncodings: { @@ -685,7 +982,7 @@ describe('Vega-Lite semantic interactions', () => { }, }); const element = { - key: { [INTERACTION_KEY]: 'Jan' }, + value: { [INTERACTION_KEY]: 'Jan' }, records: [{ Month: 'Jan', 'Rainfall (mm)': 140, @@ -719,11 +1016,11 @@ describe('Vega-Lite semantic interactions', () => { }, }); const barTableElement = { - key: { [INTERACTION_KEY]: 'China' }, + value: { [INTERACTION_KEY]: 'China' }, records: [{ Country: 'China', 'GDP ($T)': 17.8, period_end: 0 }], }; const bulletElement = { - key: { [INTERACTION_KEY]: 'Germany' }, + value: { [INTERACTION_KEY]: 'Germany' }, records: [{ Country: 'Germany', Share: 51.6, Target: 80 }], }; @@ -765,6 +1062,73 @@ describe('Vega-Lite semantic interactions', () => { expect(new Set(roles)).toEqual(new Set(['bullet-actual', 'bullet-expected'])); }); + it('resolves Bullet Chart goal-attainment legend keys to their bars', () => { + const semantics = bulletChartDef.semanticInteractions!({ + resolvedEncodings: { + y: { field: 'Country', type: 'nominal' }, + x: { field: 'Share', type: 'quantitative' }, + goal: { field: 'Target', type: 'quantitative' }, + }, + } as any); + const below = { + datum: { Country: 'Norway', Share: 98, Target: 100, __status: 'Below target' }, + markType: 'bar', + source: 'mark' as const, + }; + const met = { + datum: { Country: 'Brazil', Share: 90, Target: 80, __status: 'Meets target' }, + markType: 'bar', + source: 'mark' as const, + }; + + expect(semantics.legendFields).toEqual({ color: '__status' }); + expect(semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [{ datum: { value: 'Below target' }, source: 'legend-item' }], + legend: { field: '__status', domain: { kind: 'value', value: 'Below target' } }, + }, { + keyField: 'Country', + allHits: [below, met], + } as any)?.elements).toHaveLength(1); + + const unmatched = semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [{ datum: { value: 'Meets target' }, source: 'legend-item' }], + legend: { field: '__status', domain: { kind: 'value', value: 'Meets target' } }, + }, { + keyField: 'Country', + allHits: [below], + } as any); + expect(unmatched).toBeNull(); + expect(legendSemanticTarget({ + channel: 'color', field: '__status', value: 'Meets target', + domain: { kind: 'value', value: 'Meets target' }, + })).toEqual({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', + field: '__status', + domain: { kind: 'value', value: 'Meets target' }, + }, + }], + }); + expect(legendSemanticTarget({ + channel: 'color', field: '__status', value: 'Below target', + domain: { kind: 'value', value: 'Below target' }, + })).toEqual({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: '__status', + domain: { kind: 'value', value: 'Below target' }, + }, + }], + }); + }); + it('presents Choropleth regions and Density segments with semantic values', () => { const choropleth = choroplethDef.semanticInteractions!({ resolvedEncodings: { @@ -778,14 +1142,14 @@ describe('Vega-Lite semantic interactions', () => { const regionUpdate = choropleth.presentUpdate!( annotationUpdate( - { key: { [INTERACTION_KEY]: '35' }, records: [{ State: 'New Mexico', Value: 35 }] }, + { value: { [INTERACTION_KEY]: '35' }, records: [{ State: 'New Mexico', Value: 35 }] }, { kind: 'region', role: 'geographic-region' }, ), { chartType: 'Choropleth', selected: [], categoryField: 'State' }, ); const densityUpdate = density.presentUpdate!( annotationUpdate({ - key: { [INTERACTION_KEY]: 'segment' }, + value: { [INTERACTION_KEY]: 'segment' }, records: [{ value: 72.5, density: 0.33 }, { value: 75, density: 0.32 }], }, { kind: 'path', role: 'area' }), { chartType: 'Density Plot', selected: [] }, @@ -817,19 +1181,19 @@ describe('Vega-Lite semantic interactions', () => { }); const ecdfUpdate = ecdf.presentUpdate!( - annotationUpdate({ key: { [INTERACTION_KEY]: 'step' }, records: [{ Score: 42 }, { Score: 44 }] }), + annotationUpdate({ value: { [INTERACTION_KEY]: 'step' }, records: [{ Score: 42 }, { Score: 44 }] }), { chartType: 'ECDF Plot', selected: [] }, ); const calendarUpdate = calendar.presentUpdate!( annotationUpdate({ - key: { [INTERACTION_KEY]: 'day' }, + value: { [INTERACTION_KEY]: 'day' }, records: [{ __calendar_date: Date.UTC(2026, 7, 27), Commits: 12 }], }), { chartType: 'Calendar Heatmap', selected: [] }, ); const violinUpdate = violin.presentUpdate!( annotationUpdate({ - key: { [INTERACTION_KEY]: 'curve' }, + value: { [INTERACTION_KEY]: 'curve' }, records: [{ Species: 'Setosa', Length: 5.1, density: 0.4 }], }), { chartType: 'Violin Plot', selected: [] }, @@ -879,7 +1243,7 @@ describe('Vega-Lite semantic interactions', () => { }); }); - it('makes generated legend symbols and labels physical click targets', () => { + it('makes generated legend marks physical click targets', () => { const spec = { data: { values: [{ X: 1, Y: 2, Color: 'Blue' }] }, mark: 'point', @@ -898,6 +1262,8 @@ describe('Vega-Lite semantic interactions', () => { const { compiled } = instrument(spec); expect(compiled.legends.length).toBeGreaterThan(0); for (const legend of compiled.legends) { + expect(legend.encode.gradient.interactive).toBe(true); + expect(legend.encode.gradient.update.cursor.value).toBe('pointer'); expect(legend.encode.symbols.interactive).toBe(true); expect(legend.encode.symbols.update.cursor.value).toBe('pointer'); expect(legend.encode.labels.interactive).toBe(true); @@ -1214,6 +1580,29 @@ describe('Vega-Lite semantic interactions', () => { expect(arcIntersectsRect(quarter, { x1: 90, y1: 90, x2: 180, y2: 180 }, true)).toBe(false); }); + it('assists to the nearest donut slice geometry, not the largest arc bounds', () => { + const center = { x: 100, y: 100 }; + const large = { + mark: { marktype: 'arc' }, ...center, + innerRadius: 35, outerRadius: 80, + startAngle: 0, endAngle: 3 * Math.PI / 2, + bounds: { x1: 20, y1: 20, x2: 180, y2: 180 }, + }; + const small = { + mark: { marktype: 'arc' }, ...center, + innerRadius: 35, outerRadius: 80, + startAngle: 3 * Math.PI / 2, endAngle: 17 * Math.PI / 10, + bounds: { x1: 20, y1: 75, x2: 36, y2: 125 }, + }; + const angle = 8 * Math.PI / 5; + const point = { + x: center.x + 60 * Math.sin(angle), + y: center.y - 60 * Math.cos(angle), + }; + + expect(nearestItemByBounds([large, small], point, 10)).toBe(small); + }); + it('tests angular selection across the zero-angle seam and within one polar center', () => { const arc = { mark: { marktype: 'arc' }, x: 100, y: 100, @@ -1268,7 +1657,8 @@ describe('Vega-Lite semantic interactions', () => { resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, }), }; - expect(addVegaLiteInteractions(polar, [brushAngle()])).not.toBeNull(); + const polarPlan = addVegaLiteInteractions(polar, [brushX()]); + expect(polarPlan?.angularXBrush).toBe(true); }); it('does not select adjacent cells that only touch the selection boundary', () => { @@ -1307,6 +1697,25 @@ describe('Vega-Lite semantic interactions', () => { expect(roundTrip.y).toBeCloseTo(0, 6); }); + it('reports the correct plot origin under nonuniform SVG scaling', () => { + const matrix = { a: 0.8, d: 0.5, e: 32, f: 15 }; + + expect(rendererPlotOrigin(matrix, { x: 0, y: 0 })).toEqual({ x: 40, y: 30 }); + + const space = { + rect: { left: 10, top: 20, width: 320, height: 150 } as DOMRect, + logicalWidth: 400, + logicalHeight: 300, + originX: 40, + originY: 30, + plotWidth: 320, + plotHeight: 240, + }; + const plotOrigin = plotToClientPoint({ x: 0, y: 0 }, space); + expect(plotOrigin).toEqual({ x: 42, y: 35 }); + expect(clientToPlotPoint(plotOrigin, space)).toEqual({ x: 0, y: 0 }); + }); + it('round-trips coordinates through SVG scaling and Vega plot padding', () => { const space = { rect: { left: 100, top: 50, width: 250, height: 150 } as DOMRect, @@ -1322,6 +1731,8 @@ describe('Vega-Lite semantic interactions', () => { expect(plot).toEqual({ x: 100, y: 70 }); expect(plotToClientPoint(plot, space)).toEqual({ x: 180, y: 100 }); expect(clientToPlotPoint({ x: 0, y: 0 }, space)).toEqual({ x: 0, y: 0 }); + expect(clientToPlotPoint({ x: 350, y: 100 }, space)).toEqual({ x: 400, y: 70 }); + expect(clientToRendererPoint({ x: 350, y: 100 }, space)).toEqual({ x: 500, y: 100 }); expect(clientToLayoutPoint( { x: 180, y: 100 }, { left: 20, top: 20, width: 320, height: 160 }, @@ -1470,7 +1881,7 @@ describe('Vega-Lite semantic interactions', () => { it('formats bar-family annotations from the primary metric', () => { const element = { - key: { [INTERACTION_KEY]: 'India' }, + value: { [INTERACTION_KEY]: 'India' }, records: [{ Country: 'India', Population: 1428.6, Population_end: 1428.6 }], }; const makeUpdate = (definition: typeof barChartDef, resolvedEncodings: Record) => { @@ -1672,13 +2083,16 @@ describe('Vega-Lite semantic interactions', () => { }, ); - expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual([ + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual([ `Japan|connector${PATH_KEY_SUFFIX}`, 'Japan|81.5|Male', 'Japan|87.6|Female', ]); expect(target?.visual).toEqual({ kind: 'path', role: 'line' }); - expect(target?.elements[0].records).toEqual([connector.datum, connector.endDatum]); + expect(target?.elements[0].records).toEqual([ + { Country: 'Japan', Sex: 'Male', 'Life expectancy': 81.5 }, + connector.endDatum, + ]); expect(semantics.presentUpdate!( annotationUpdate(target!.elements[0], target!.visual), { chartType: 'Ranged Dot Plot', selected: [], categoryField: 'Country', seriesField: 'Sex' }, @@ -1698,7 +2112,7 @@ describe('Vega-Lite semantic interactions', () => { }, }); const element = { - key: { [INTERACTION_KEY]: 'Chrome' }, + value: { [INTERACTION_KEY]: 'Chrome' }, records: [{ Browser: 'Chrome', Share: 65, Share_start: 0, Share_end: 65 }], }; @@ -1733,7 +2147,7 @@ describe('Vega-Lite semantic interactions', () => { { allHits: [male], keyField: INTERACTION_KEY, categoryField: 'Country', seriesField: 'Sex' }, ); - expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['Japan|81.5|Male']); + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['Japan|81.5|Male']); expect(target?.visual).toEqual({ kind: 'mark', role: 'point' }); }); @@ -1761,12 +2175,19 @@ describe('Vega-Lite semantic interactions', () => { expect(bars.every((item) => item.opacity === 1)).toBe(true); let legendItems = allSceneItems(view).filter((item) => item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); - expect(legendItems.filter((item) => item.datum.value === 'Consumer').every((item) => item.opacity === 1)).toBe(true); expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 1)).toBe(true); const legendLabels = legendItems.filter((item) => item.mark.role === 'legend-label'); const consumerLabel = legendLabels.find((item) => item.datum.value === 'Consumer'); const corporateLabel = legendLabels.find((item) => item.datum.value === 'Corporate'); - expect(consumerLabel?.fill).toBe(corporateLabel?.fill); + expect(consumerLabel?.opacity).toBe(1); + expect(consumerLabel?.fontWeight).toBe(600); + expect(corporateLabel?.fontWeight).not.toBe(600); + const consumerSymbol = legendItems.find((item) => + item.mark.role === 'legend-symbol' && item.datum.value === 'Consumer'); + const corporateSymbol = legendItems.find((item) => + item.mark.role === 'legend-symbol' && item.datum.value === 'Corporate'); + expect(consumerSymbol?.opacity).toBe(0.72); + expect(corporateSymbol?.opacity).toBe(1); view.change(LEGEND_HOVER_STORE, changeset().remove(() => true)); view.change(LEGEND_SELECTION_STORE, changeset().insert([{ channel: 'color', value: 'Consumer' }])); @@ -1783,6 +2204,56 @@ describe('Vega-Lite semantic interactions', () => { item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); expect(legendItems.filter((item) => item.datum.value === 'Consumer').every((item) => item.opacity === 1)).toBe(true); expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 0.25)).toBe(true); + + view.change(LEGEND_SELECTION_STORE, changeset().remove(() => true)); + view.change(LEGEND_HIDDEN_STORE, changeset().insert([{ identity: 'color:Consumer', opacity: 0.25 }])); + await view.runAsync(); + + legendItems = allSceneItems(view).filter((item) => + item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); + expect(legendItems.filter((item) => item.datum.value === 'Consumer').every((item) => item.opacity === 0.25)).toBe(true); + expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 1)).toBe(true); + }); + + it('acquires the whitespace between a legend symbol and its label as one entry', () => { + const owner: any = { + datum: { scales: { fill: 'color' } }, + mark: { marktype: 'group' }, + x: 100, + y: 20, + items: [], + }; + const symbol = { + datum: { value: 'Android' }, + mark: { marktype: 'symbol', role: 'legend-symbol', group: owner }, + bounds: { x1: 0, y1: 0, x2: 10, y2: 10 }, + }; + const label = { + datum: { value: 'Android' }, + mark: { marktype: 'text', role: 'legend-label', group: owner }, + bounds: { x1: 20, y1: 0, x2: 70, y2: 10 }, + }; + owner.items = [symbol, label]; + const chartPoint = { + datum: { [INTERACTION_KEY]: 'chart-point' }, + mark: { marktype: 'symbol' }, + bounds: { x1: 90, y1: 30, x2: 100, y2: 40 }, + }; + const view = { scenegraph: () => ({ root: { items: [owner] } }) }; + const viewWithChartPoint = { + scenegraph: () => ({ root: { items: [owner, chartPoint] } }), + }; + + expect(legendEntryItemAtPoint(view, { x: 115, y: 25 })).toBe(symbol); + expect(legendEntryItemAtPoint(view, { x: 90, y: 25 })).toBeNull(); + expect(nearestInteractiveSceneItem(view, { x: 90, y: 25 }, 12)).toBe(symbol); + expect(nearestInteractiveSceneItem( + view, { x: 0, y: 0 }, 12, { x: 90, y: 25 }, + )).toBe(symbol); + expect(nearestInteractiveSceneItem( + viewWithChartPoint, { x: 95, y: 35 }, 12, { x: 115, y: 35 }, false, + )).toBe(symbol); + expect(nearestInteractiveSceneItem(view, { x: 80, y: 25 }, 12)).toBeUndefined(); }); it('calculates keys inside a Bar Table panel with its own named data', () => { @@ -1987,6 +2458,66 @@ describe('Vega-Lite semantic interactions', () => { }); }); + it('acquires a generated series-end label as an exact legend domain', () => { + const item = { + mark: { marktype: 'text', name: 'series-end-label' }, + datum: { + [INTERACTION_KEY]: '2024|China|2', + [INTERACTION_ROLE]: 'legend-label', + [INTERACTION_LEGEND_CHANNEL]: 'color', + [INTERACTION_LEGEND_FIELD]: 'Country', + Country: 'China', + }, + }; + const normalized = normalizeVegaElementEvent( + {}, item, { x: 10, y: 10 }, 'commit', + { shift: false, ctrl: false, meta: false }, { color: 'Country' }, + ); + + expect(normalized.role).toBe('legend-item'); + expect(normalized.legend).toEqual({ + channel: 'color', field: 'Country', value: 'China', + domain: { kind: 'value', value: 'China' }, + }); + expect(legendSemanticTarget(normalized.legend)?.elements[0]).toEqual({ + value: { + channel: 'color', field: 'Country', + domain: { kind: 'value', value: 'China' }, + }, + }); + }); + + it('compiles Bump series-end labels as semantic legend labels', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Games: 2012, Country: 'United States', Rank: 1 }, + { Games: 2024, Country: 'United States', Rank: 1 }, + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2024, Country: 'China', Rank: 2 }, + ] }, + semantic_types: { Games: 'Year', Country: 'Country', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bump Chart', + encodings: { x: 'Games', y: 'Rank', color: 'Country' }, + }, + theme_spec: 'nyt', + } as never) as Record; + const label = spec.layer.find((layer: Record) => + layer[INTERACTION_PROVENANCE]?.role === 'legend-label'); + + expect(label?.[INTERACTION_PROVENANCE]).toMatchObject({ + role: 'legend-label', + legend: { channel: 'color', field: 'Country' }, + }); + instrument(spec, [clickHighlight()]); + expect(label.transform).toEqual(expect.arrayContaining([ + { calculate: "'legend-label'", as: INTERACTION_ROLE }, + { calculate: '"color"', as: INTERACTION_LEGEND_CHANNEL }, + { calculate: '"Country"', as: INTERACTION_LEGEND_FIELD }, + ])); + expect(label.mark.cursor).toBe('pointer'); + }); + it('preserves the second datum of a clicked line segment', () => { const mark = { marktype: 'line', name: 'trend' }; const start = { [INTERACTION_KEY]: 'A', Month: 'Jan', Sales: 10 }; @@ -2010,8 +2541,8 @@ describe('Vega-Lite semantic interactions', () => { expect(target?.visual).toEqual({ kind: 'path', role: 'line' }); expect(target?.elements[0].records).toEqual([ - { ...start, [INTERACTION_KEY]: `A${PATH_KEY_SUFFIX}` }, - end, + { Month: 'Jan', Sales: 10 }, + { Month: 'Feb', Sales: 14 }, ]); }); @@ -2035,7 +2566,7 @@ describe('Vega-Lite semantic interactions', () => { }, ); - expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['West|Consumer']); + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['West|Consumer']); }); it('keeps a mark click local when a series field is declared', () => { @@ -2059,7 +2590,7 @@ describe('Vega-Lite semantic interactions', () => { }, ); - expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['West|Consumer']); + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['West|Consumer']); }); it.each(['click', 'hover'] as const)('lets the template resolver expand a legend %s to its series cohort', (gesture) => { @@ -2075,7 +2606,7 @@ describe('Vega-Lite semantic interactions', () => { const eastConsumer = { datum: { [INTERACTION_KEY]: 'East|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; const target = resolve( - { gesture, role: 'legend-item', hits: [], legendValue: 'Consumer' }, + { gesture, role: 'legend-item', hits: [], legend: { domain: { kind: 'value', value: 'Consumer' } } }, { allHits: [westConsumer, westCorporate, eastConsumer], keyField: INTERACTION_KEY, @@ -2083,12 +2614,173 @@ describe('Vega-Lite semantic interactions', () => { }, ); - expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual([ + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual([ 'West|Consumer', 'East|Consumer', ]); }); + it('retains domain-only legend identity for toggle processing', () => { + const interaction = createLegendToggleInteraction(); + const target = legendSemanticTarget({ + channel: 'color', field: 'Segment', value: 'Consumer', + domain: { kind: 'value', value: 'Consumer' }, + }); + const update = interaction.handle!({ + action: 'click-primary', phase: 'commit', target, + } as any, { available: [], selected: [] } as any); + + expect(update?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [{ + visual: { kind: 'legend', role: 'legend-item' }, + elements: target?.elements, + }], + value: { visible: false }, + }); + + const restored = interaction.handle!({ + action: 'click-primary', phase: 'commit', + target: { + ...target!, + elements: target!.elements.map((element) => ({ + ...element, + records: [{ Segment: 'Consumer' }], + })), + }, + } as any, { available: [], selected: [] } as any); + expect(restored?.ops[0]).toMatchObject({ + op: 'set-presentation', + targets: [], + value: { visible: false }, + }); + }); + + it('resolves an unmatched legend domain to an explicit empty presentation', () => { + const legend = { + channel: 'color', field: '__status', + domain: { kind: 'value' as const, value: 'Meets target' }, + }; + const target = resolveLegendPresentationTarget( + legend, + () => null, + { allHits: [], keyField: INTERACTION_KEY }, + ); + + expect(target).toMatchObject({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ value: legend }], + }); + expect(target.elements[0].records).toBeUndefined(); + expect(semanticElementRenderKeys(target.elements[0])).toHaveLength(1); + }); + + it('retains concrete keys for legend domains already hidden from the scene', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const consumer = { + datum: { [INTERACTION_KEY]: 'West|Consumer', Segment: 'Consumer' }, + source: 'mark' as const, + }; + const corporate = { + datum: { [INTERACTION_KEY]: 'West|Corporate', Segment: 'Corporate' }, + source: 'mark' as const, + }; + const legend = { + channel: 'color', field: 'Segment', + domain: { kind: 'value' as const, value: 'Consumer' }, + }; + const retained = new Map(); + + resolveRetainedLegendPresentationTarget( + legend, resolve, + { allHits: [consumer, corporate], keyField: INTERACTION_KEY, seriesField: 'Segment' }, + retained, + ); + const afterConsumerIsHidden = resolveRetainedLegendPresentationTarget( + legend, resolve, + { allHits: [corporate], keyField: INTERACTION_KEY, seriesField: 'Segment' }, + retained, + ); + + expect(afterConsumerIsHidden.elements.flatMap(semanticElementRenderKeys)).toEqual(['West|Consumer']); + }); + + it('resolves every domain in a combined hidden legend target', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const hits = ['Consumer', 'Corporate'].map((Segment) => ({ + datum: { [INTERACTION_KEY]: `West|${Segment}`, Segment }, + source: 'mark' as const, + })); + const legends = ['Consumer', 'Corporate'].map((value) => ({ + channel: 'color', field: 'Segment', + domain: { kind: 'value' as const, value }, + })); + + const target = resolveRetainedLegendPresentationTargets( + legends, resolve, + { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Segment' }, + new Map(), + ); + + expect(target.elements.flatMap(semanticElementRenderKeys)).toEqual([ + 'West|Consumer', + 'West|Corporate', + ]); + }); + + it('removes a Streamgraph ribbon with the keys resolved from its legend domain', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Year: 2000, Region: 'Asia', Population: 10 }, + { Year: 2010, Region: 'Asia', Population: 12 }, + { Year: 2020, Region: 'Asia', Population: 14 }, + { Year: 2000, Region: 'Africa', Population: 4 }, + { Year: 2010, Region: 'Africa', Population: 6 }, + { Year: 2020, Region: 'Africa', Population: 8 }, + ] }, + semantic_types: { Year: 'Year', Region: 'Category', Population: 'Quantity' }, + chart_spec: { + chartType: 'Streamgraph', + encodings: { x: 'Year', y: 'Population', color: 'Region' }, + }, + } as never) as any; + const { plan, compiled } = instrument(spec, [legendToggle()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const hits = sceneItems(view).map(renderHit).filter((hit): hit is RenderHit => hit !== null); + const target = plan!.resolve!({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { channel: 'color', field: 'Region', domain: { kind: 'value', value: 'Asia' } }, + }, { + allHits: hits, + keyField: INTERACTION_KEY, + seriesField: 'Region', + }); + const keys = target!.elements.flatMap(semanticElementRenderKeys); + + view.change(HIDDEN_STORE, changeset().insert(keys.map((key) => ({ key })))); + await view.runAsync(); + + const remainingRegions = sceneItems(view) + .filter((item) => item.mark.marktype === 'area') + .map((item) => item.datum.Region); + expect(keys).toHaveLength(3); + expect(remainingRegions).not.toContain('Asia'); + expect(remainingRegions).toContain('Africa'); + }); + it('resolves color and shape legends by their own fields', () => { const resolve = scatterPlotDef.semanticInteractions!({ resolvedEncodings: { @@ -2106,16 +2798,16 @@ describe('Vega-Lite semantic interactions', () => { const context = { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Color' }; const color = resolve( - { gesture: 'click', role: 'legend-item', hits: [], legendValue: 'Blue', legendField: 'Color' }, + { gesture: 'click', role: 'legend-item', hits: [], legend: { field: 'Color', domain: { kind: 'value', value: 'Blue' } } }, context, ); const shape = resolve( - { gesture: 'click', role: 'legend-item', hits: [], legendValue: 'Circle', legendField: 'Shape' }, + { gesture: 'click', role: 'legend-item', hits: [], legend: { field: 'Shape', domain: { kind: 'value', value: 'Circle' } } }, context, ); - expect(color?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['a', 'c']); - expect(shape?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['a', 'b']); + expect(color?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['a', 'c']); + expect(shape?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['a', 'b']); }); it('resolves a size legend independently from color', () => { @@ -2133,11 +2825,392 @@ describe('Vega-Lite semantic interactions', () => { { datum: { [INTERACTION_KEY]: 'c', Color: 'Blue', Size: 'Small' }, source: 'mark' as const }, ]; const target = resolve( - { gesture: 'click', role: 'legend-item', hits: [], legendValue: 'Large', legendField: 'Size' }, + { gesture: 'click', role: 'legend-item', hits: [], legend: { field: 'Size', domain: { kind: 'value', value: 'Large' } } }, { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Color' }, ); - expect(target?.elements.map((element) => element.key[INTERACTION_KEY])).toEqual(['a', 'b']); + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['a', 'b']); + }); + + it('keeps color exact while treating size as a range when both legends exist', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { gdp: 1000, life: 66, continent: 'Africa', population: 40 }, + { gdp: 30000, life: 84, continent: 'Asia', population: 1200 }, + ], + }, + semantic_types: { + gdp: 'Quantity', life: 'Quantity', continent: 'Category', population: 'Quantity', + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'gdp', y: 'life', color: 'continent', size: 'population' }, + }, + } as any) as any; + + expect(spec._interactionSemantics.legendFields).toEqual({ + color: 'continent', + size: 'population', + }); + expect(spec._interactionSemantics.rangeLegendChannels).toEqual(['size']); + }); + + it('resolves a sampled quantitative legend anchor to its midpoint range', () => { + const hits = [1, 3, 5, 7, 9].map((Size) => ({ + datum: { [INTERACTION_KEY]: String(Size), Size }, + source: 'mark' as const, + })); + const matched = legendMatchedHits( + { + gesture: 'click', + role: 'legend-item', + hits: [], + legend: { field: 'Size', domain: { kind: 'interval', start: 2.5, end: 7.5 } }, + }, + { allHits: hits, keyField: INTERACTION_KEY }, + 'Size', + ); + + expect(matched.map((hit) => hit.datum.Size)).toEqual([3, 5, 7]); + }); + + it('resolves a smooth legend ramp position to a sampled interval', () => { + const legendEntry: any = { + datum: { scales: { fill: 'color' }, type: 'gradient', vgrad: false }, + mark: { marktype: 'group' }, + x: 100, + y: 20, + items: [], + }; + const gradient = { + datum: legendEntry.datum, + mark: { marktype: 'rect', role: 'legend-gradient', group: legendEntry }, + bounds: { x1: 0, y1: 0, x2: 100, y2: 12 }, + }; + legendEntry.items = [ + gradient, + ...[0, 5, 10].map((value, index) => ({ + datum: { value, index, perc: index / 2 }, + mark: { marktype: 'text', role: 'legend-label', group: legendEntry }, + })), + ]; + const root = { mark: { marktype: 'group' }, items: [legendEntry] }; + const scale = Object.assign((value: number) => value, { + type: 'sequential-linear', + domain: () => [0, 10], + }); + const view = { scenegraph: () => ({ root }), scale: () => scale }; + + const target = legendTarget(gradient, { color: 'Temperature' }, ['color'], view, { x: 190, y: 26 }); + expect(target).toMatchObject({ + channel: 'color', field: 'Temperature', + domain: { kind: 'interval' }, + visualBounds: { x2: 200, y1: 20, y2: 32 }, + }); + expect(target?.value).toBeCloseTo(25 / 3); + expect(target?.domain.kind === 'interval' ? target.domain.start : undefined).toBeCloseTo(20 / 3); + expect(target?.visualBounds?.x1).toBeCloseTo(500 / 3); + }); + + it('adapts smooth legend segments to physical length and value cardinality', () => { + expect(continuousLegendSegmentCount(80)).toBe(3); + expect(continuousLegendSegmentCount(176)).toBe(4); + expect(continuousLegendSegmentCount(220)).toBe(5); + expect(continuousLegendSegmentCount(400)).toBe(7); + expect(continuousLegendSegmentCount(220, 2)).toBe(2); + }); + + it('resolves each discrete legend band to its exact interval', () => { + const legendEntry: any = { + datum: { scales: { fill: 'color' }, type: 'discrete', vgrad: false }, + mark: { marktype: 'group' }, + items: [], + }; + const bands = [-Infinity, 3, 6, 9].map((value, index) => ({ + datum: { value, index, perc: index / 4, perc2: (index + 1) / 4 }, + mark: { marktype: 'rect', role: 'legend-band', group: legendEntry }, + })); + legendEntry.items = bands; + + expect(legendTarget(bands[2], { color: 'Temperature' }, ['color'])) + .toEqual({ + channel: 'color', field: 'Temperature', value: 6, + domain: { kind: 'interval', start: 6, end: 9 }, + }); + expect(legendTarget(bands[0], { color: 'Temperature' }, ['color'])) + .toEqual({ + channel: 'color', field: 'Temperature', value: -Infinity, + domain: { kind: 'interval', end: 3 }, + }); + }); + + it.each([ + ['default', undefined, 'legend-gradient'], + ['quantized theme', THEME_PRESETS.datawrapper.spec, 'legend-band'], + ] as const)('targets the painted Heatmap legend under the %s', async (_name, theme, role) => { + const spec = assembleVegaLite({ + data: { values: [ + { Month: 'Jan', City: 'A', Temperature: -10 }, + { Month: 'Feb', City: 'A', Temperature: 0 }, + { Month: 'Mar', City: 'A', Temperature: 10 }, + ] }, + semantic_types: { Month: 'Category', City: 'Category', Temperature: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'Month', y: 'City', color: 'Temperature' }, + }, + ...(theme ? { theme_spec: theme } : {}), + } as any) as any; + const { compiled, plan } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const item = allSceneItems(view).find((candidate) => candidate.mark?.role === role); + const bounds = rootSceneBounds(view, item); + const point = bounds ? { + x: bounds.x1 + (bounds.x2 - bounds.x1) * 0.75, + y: bounds.y1 + (bounds.y2 - bounds.y1) * 0.5, + } : undefined; + const target = legendTarget(item, { color: 'Temperature' }, ['color'], view, point); + + expect(item).toBeDefined(); + expect(item.mark.interactive).toBe(true); + expect(target).toMatchObject({ channel: 'color', field: 'Temperature' }); + expect(target?.domain.kind).toBe('interval'); + if (role === 'legend-band') { + view.change(LEGEND_SELECTION_STORE, changeset().insert([{ + channel: 'color', + value: item.datum.value, + }])); + await view.runAsync(); + const bands = allSceneItems(view).filter((candidate) => candidate.mark?.role === role); + const selectedBand = bands.find((candidate) => candidate.datum.value === item.datum.value); + expect(selectedBand?.stroke).toBe(plan?.selectionBoundary?.color); + expect(selectedBand?.strokeWidth).toBe(plan?.selectionBoundary?.width); + expect(selectedBand?.strokeOpacity).toBe(plan?.selectionBoundary?.opacity); + expect(bands.filter((candidate) => candidate.datum.value !== item.datum.value) + .every((candidate) => !candidate.strokeWidth)).toBe(true); + } + }); + + it('matches temporal values against numeric legend intervals', () => { + const hits = ['2024-01-01', '2024-02-01', '2024-03-01'].map((date) => ({ + datum: { [INTERACTION_KEY]: date, Date: new Date(`${date}T00:00:00Z`) }, + source: 'mark' as const, + })); + const matched = legendMatchedHits({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { + field: 'Date', + domain: { kind: 'interval', start: Date.UTC(2024, 0, 15), end: Date.UTC(2024, 2, 1) }, + }, + }, { allHits: hits, keyField: INTERACTION_KEY }, 'Date'); + + expect(matched.map((hit) => hit.datum.Date)).toEqual([new Date('2024-02-01T00:00:00Z')]); + }); + + it.each([ + ['Heatmap', heatmapDef, { + x: { field: 'Month', type: 'nominal' }, + y: { field: 'City', type: 'nominal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }], + ['Calendar Heatmap', vlCalendarHeatmapDef, { + x: { field: 'Date', type: 'temporal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }], + ['Choropleth', choroplethDef, { + id: { field: 'State', type: 'nominal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }], + ] as const)('resolves a continuous %s legend interval to matching marks', (_name, chartDef, encodings) => { + const semantics = chartDef.semanticInteractions!({ resolvedEncodings: encodings }); + const hits = [-17, -6, 6, 17].map((Temperature, index) => ({ + datum: { + [INTERACTION_KEY]: String(index), Temperature, + sum_Temperature: Temperature, + Month: `M${index}`, City: 'A', State: `S${index}`, + }, + source: 'mark' as const, + })); + const target = semantics.resolve({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { field: 'Temperature', domain: { kind: 'interval', start: 0, end: 12 } }, + }, { allHits: hits, keyField: INTERACTION_KEY }); + + expect(semantics.legendFields).toEqual({ color: 'Temperature' }); + expect(target?.elements.map((element) => element.value.Temperature)).toEqual([6]); + }); + + it('applies a Calendar legend range to the matching rendered cells', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Date: '2024-01-01', Temperature: 10 }, + { Date: '2024-01-02', Temperature: 50 }, + { Date: '2024-01-03', Temperature: 90 }, + ] }, + semantic_types: { Date: 'Date', Temperature: 'Quantity' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: 'Date', color: 'Temperature' }, + }, + } as any) as any; + expect(spec._interactionSemantics.neutralizeContinuousColor).toBe(false); + expect(spec._interactionSemantics.continuousColorFocus.boundaryWidth).toBeLessThan( + spec._interactionSemantics.selectionBoundary.width, + ); + const { compiled, plan } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const hits = sceneItems(view).map(renderHit).filter((hit): hit is NonNullable => !!hit); + const semantics = vlCalendarHeatmapDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Date', type: 'temporal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }, + }); + const target = semantics.resolve({ + gesture: 'hover', role: 'legend-item', hits: [], + legend: { field: 'Temperature', domain: { kind: 'interval', start: 70, end: 100 } }, + }, { allHits: hits, keyField: INTERACTION_KEY }); + const keys = target?.elements.flatMap(semanticElementRenderKeys) ?? []; + const legendPayload = legendSemanticTarget({ + channel: 'color', field: 'Temperature', value: 90, + domain: { kind: 'interval', start: 70, end: 100 }, + }); + + expect(keys).toHaveLength(1); + expect(legendPayload).toEqual({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ value: { + channel: 'color', field: 'Temperature', + domain: { kind: 'interval', start: 70, end: 100 }, + } }], + }); + expect(semanticElementRenderKeys(legendPayload!.elements[0])).toEqual([]); + view.change(INTERACTION_STORE, changeset().insert(keys.map((key) => ({ key })))); + await view.runAsync(); + const cells = allSceneItems(view).filter((item) => item.mark?.marktype === 'rect' + && item.datum?.sum_Temperature !== undefined); + expect(cells.find((item) => item.datum.sum_Temperature === 90)?.opacity).toBe(1); + expect(cells.filter((item) => item.datum.sum_Temperature !== 90) + .every((item) => item.opacity === plan?.dimOpacity)).toBe(true); + }); + + it('neutralizes muted continuous color only for geographic maps', () => { + const spec = assembleVegaLite({ + data: { values: [ + { lon: -74, lat: 40.7, temperature: 20 }, + { lon: -118, lat: 34, temperature: 35 }, + ] }, + semantic_types: { + lon: 'Longitude', lat: 'Latitude', temperature: 'Quantity', + }, + chart_spec: { + chartType: 'Map', + encodings: { longitude: 'lon', latitude: 'lat', color: 'temperature' }, + }, + } as any) as any; + const style = spec._interactionSemantics.continuousColorFocus; + + expect(spec._interactionSemantics.neutralizeContinuousColor).toBe(true); + instrument(spec); + const points = spec.layer.find((layer: Record) => + layer.mark?.type === 'circle' || layer.mark === 'circle'); + expect(points.encoding.color).toMatchObject({ + condition: { field: 'temperature', type: 'quantitative' }, + value: style.mutedFill, + }); + expect(points.encoding.opacity).toEqual({ value: 1 }); + }); + + it('treats a Map quantitative size key as sampled ranges', () => { + const assembled = assembleVegaLite({ + data: { values: [{ lon: -74, lat: 40.7, pop: 5 }] }, + semantic_types: { lon: 'Longitude', lat: 'Latitude', pop: 'Quantity' }, + chart_spec: { + chartType: 'Map', + encodings: { longitude: 'lon', latitude: 'lat', size: 'pop' }, + }, + } as any) as any; + const semantics = mapDef.semanticInteractions!({ + resolvedEncodings: { + longitude: { field: 'lon', type: 'quantitative' }, + latitude: { field: 'lat', type: 'quantitative' }, + size: { field: 'pop', type: 'quantitative' }, + }, + }); + const legendEntry = { + datum: { scales: { size: 'size' } }, + items: [0, 5, 10, 15].map((value) => ({ datum: { value } })), + }; + const item = { + datum: { value: 5 }, + mark: { + role: 'legend-label', + group: { mark: { group: legendEntry } }, + }, + }; + const rangeLegendChannels = assembled._interactionSemantics.rangeLegendChannels; + const legend = legendTarget(item, semantics.legendFields, rangeLegendChannels); + + expect(semantics.legendFields).toEqual({ size: 'pop' }); + expect(rangeLegendChannels).toEqual(['size']); + expect(legend).toEqual({ + channel: 'size', + field: 'pop', + value: 5, + domain: { kind: 'interval', start: 2.5, end: 7.5 }, + }); + const hits = [2.9, 4.9, 6.3, 7.6, 12.4].map((pop) => ({ + datum: { [INTERACTION_KEY]: String(pop), pop }, + source: 'mark' as const, + })); + const target = semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [], + legend: legend ?? undefined, + }, { + allHits: hits, + keyField: INTERACTION_KEY, + }); + const semanticLegend = legendSemanticTarget(legend); + + expect(semanticLegend?.elements[0].value).toEqual({ + channel: 'size', + field: 'pop', + domain: { kind: 'interval', start: 2.5, end: 7.5 }, + }); + expect(target?.elements.flatMap((element) => element.records ?? []).map((record) => record.pop)) + .toEqual([2.9, 4.9, 6.3]); + }); + + it('resolves Waterfall synthesized legend entries to rendered bar cohorts', () => { + const semantics = waterfallChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Step', type: 'ordinal' }, + y: { field: 'Population', type: 'quantitative' }, + }, + }); + const hits = [ + { datum: { [INTERACTION_KEY]: '1950', Step: '1950', __wf_color: 'total' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'Asia', Step: 'Asia', __wf_color: 'increase' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'Africa', Step: 'Africa', __wf_color: 'increase' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'Oceania', Step: 'Oceania', __wf_color: 'total' }, source: 'mark' as const }, + ]; + + expect(semantics.legendFields).toEqual({ color: '__wf_color' }); + const target = semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [], + legend: { field: '__wf_color', domain: { kind: 'value', value: 'increase' } }, + }, { + allHits: hits, + keyField: INTERACTION_KEY, + }); + expect(target?.elements.flatMap(semanticElementRenderKeys)).toEqual(['Asia', 'Africa']); }); it('compiles grouped-bar semantic fields from its template', () => { @@ -2201,7 +3274,7 @@ describe('Vega-Lite semantic interactions', () => { ['Violin Plot', violinPlotDef, ['area'], 'X'], ['Bullet Chart', bulletChartDef, ['bar', 'tick'], 'Value'], ['KPI Card', kpiCardDef, ['rect'], 'Metric'], - ['Radar Chart', radarChartDef, ['line', 'point'], '__axis'], + ['Radar Chart', radarChartDef, ['line', 'point'], 'X'], ['Choropleth', choroplethDef, ['geoshape'], 'Region'], ] as const)('declares semantic marks and identity for %s', (_name, definition, marks, identityField) => { const semantics = definition.semanticInteractions!({ @@ -2227,6 +3300,75 @@ describe('Vega-Lite semantic interactions', () => { expect(semantics.resolve).toBeTypeOf('function'); }); + it('keeps Radar legend semantics and tuples in authored field names', () => { + const rows = [ + { Food: 'Oats', Nutrient: 'Protein', Amount: 17 }, + { Food: 'Oats', Nutrient: 'Fiber', Amount: 11 }, + { Food: 'Almonds', Nutrient: 'Protein', Amount: 21 }, + { Food: 'Almonds', Nutrient: 'Fiber', Amount: 12 }, + ]; + const spec = assembleVegaLite({ + data: { values: rows }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const lineValues = spec.layer.find((layer: any) => layer.mark?.type === 'line')?.data?.values; + + expect(spec._interactionSemantics).toMatchObject({ + fields: ['Nutrient', 'Amount', 'Food'], + categoryField: 'Nutrient', + seriesField: 'Food', + legendFields: { color: 'Food' }, + }); + expect(lineValues[0]).toMatchObject({ Food: 'Oats', Nutrient: 'Protein', Amount: 17 }); + const oats = spec._interactionSemantics.resolve({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { field: 'Food', domain: { kind: 'value', value: 'Oats' } }, + }, { + allHits: lineValues.map((datum: Record, index: number) => ({ + datum: { ...datum, [INTERACTION_KEY]: String(index) }, + source: 'mark' as const, + })), + keyField: INTERACTION_KEY, + }); + expect(oats?.elements.every((element: SemanticElement) => element.value.Food === 'Oats')).toBe(true); + expect(sourceRecordsForRenderedRecords( + lineValues.filter((datum: Record) => datum.Food === 'Oats'), + rows, + ['Nutrient', 'Amount', 'Food'], + )).toEqual(rows.slice(0, 2)); + }); + + it('includes the closing edge in Radar path interaction geometry', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Food: 'Oats', Nutrient: 'Protein', Amount: 17 }, + { Food: 'Oats', Nutrient: 'Fat', Amount: 7 }, + { Food: 'Oats', Nutrient: 'Carbs', Amount: 66 }, + { Food: 'Oats', Nutrient: 'Fiber', Amount: 11 }, + { Food: 'Oats', Nutrient: 'Sugar', Amount: 1 }, + ] }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const segments = sceneItems(view).filter((item) => + item.mark?.marktype === 'line' && item.datum.Food === 'Oats'); + + expect(segments).toHaveLength(5); + expect(segments.every((segment) => segment.interactionGeometry.closed)).toBe(true); + expect(segments.at(-1)?.datum.Nutrient).toBe('Sugar'); + expect(segments.at(-1)?.interactionGeometry.endDatum.Nutrient).toBe('Protein'); + }); + it('instruments marks inside a nested facet unit spec', () => { const spec: Record = { data: { values: [{ Group: 'A', X: 1, Value: 2 }] }, @@ -2316,4 +3458,151 @@ describe('Vega-Lite semantic interactions', () => { }); view.finalize(); }); -}); \ No newline at end of file +}); +describe('set-presentation visibility', () => { + it('pins the legend domain so a hidden series keeps a key to click', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Series'], + categoryField: 'Category', + legendFields: { color: 'Series' }, + selectableMarks: ['bar'], + }, + data: { + values: [ + { Category: 'A', Series: 'Female', Value: 12 }, + { Category: 'A', Series: 'Male', Value: 8 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Series', type: 'nominal', scale: { scheme: 'tableau10' } }, + }, + }; + + addVegaLiteInteractions(spec, [legendToggle()]); + + expect(spec.encoding.color.scale.domain).toEqual(['Female', 'Male']); + }); + + it('pins an aggregate-sorted donut legend so hidden slices keep their keys', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['OS', 'Users'], + legendFields: { color: 'OS' }, + selectableMarks: ['arc'], + }, + data: { + values: [ + { OS: 'Android', Users: 70 }, + { OS: 'iOS', Users: 28 }, + { OS: 'Other', Users: 2 }, + ], + }, + mark: { type: 'arc', innerRadius: 50 }, + encoding: { + theta: { field: 'Users', type: 'quantitative', aggregate: 'sum' }, + color: { + field: 'OS', type: 'nominal', + sort: { field: 'Users', op: 'sum', order: 'descending' }, + }, + }, + }; + + addVegaLiteInteractions(spec, [legendToggle()]); + + expect(spec.encoding.color.scale.domain).toEqual(['Android', 'iOS', 'Other']); + }); + + it('clips marks when a region interaction drives the viewport', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Year', 'Value'], + selectableMarks: ['line'], + navigationAxes: ['x', 'y'], + }, + data: { values: [{ Year: 2020, Value: 1 }, { Year: 2021, Value: 2 }] }, + mark: { type: 'line', point: true }, + encoding: { + x: { field: 'Year', type: 'quantitative' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }; + + const plan = addVegaLiteInteractions(spec, [brushZoom()]); + + expect(spec.mark).toMatchObject({ type: 'line', clip: true }); + expect(spec.mark.point).toBe(true); + expect(spec.layer).toBeUndefined(); + expect(plan?.semanticStores).toBe(false); + expect(plan?.navigationChannels).toEqual(['x', 'y']); + }); + + it('leaves the legend domain alone when nothing can hide a series', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Series'], + categoryField: 'Category', + legendFields: { color: 'Series' }, + selectableMarks: ['bar'], + }, + data: { values: [{ Category: 'A', Series: 'Female', Value: 12 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Series', type: 'nominal' }, + }, + }; + + addVegaLiteInteractions(spec, [clickHighlight()]); + + expect(spec.encoding.color.scale?.domain).toBeUndefined(); + }); + + it('filters a hidden key out of the data and rescales the remaining rows', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], + categoryField: 'Category', + selectableMarks: ['bar'], + }, + data: { values: [{ Category: 'A', Value: 12 }, { Category: 'B', Value: 8 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }; + + const { compiled } = instrument(spec, [clickHighlight()]); + expect(compiled.data).toContainEqual({ name: HIDDEN_STORE, values: [] }); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + + // The transparent click-to-clear rect carries no key and is not a data mark. + const bars = () => allSceneItems(view) + .filter((item) => item.mark?.marktype === 'rect' && item.datum?.[INTERACTION_KEY]) + .map((item) => item.datum[INTERACTION_KEY]); + const yDomain = () => view.scale('y').domain(); + + expect(bars()).toHaveLength(2); + const tallestKey = bars()[0]; + const fullMax = yDomain()[1]; + + view.change(HIDDEN_STORE, changeset().insert([{ key: tallestKey }])); + await view.runAsync(); + + expect(bars()).toHaveLength(1); + expect(bars()).not.toContain(tallestKey); + expect(yDomain()[1]).toBeLessThan(fullMax); + + view.change(HIDDEN_STORE, changeset().remove(() => true)); + await view.runAsync(); + expect(bars()).toHaveLength(2); + expect(yDomain()[1]).toBe(fullMax); + view.finalize(); + }); +}); diff --git a/site/src/playground/AnnotationLab.tsx b/site/src/playground/AnnotationLab.tsx index 5cfa689a..272d6886 100644 --- a/site/src/playground/AnnotationLab.tsx +++ b/site/src/playground/AnnotationLab.tsx @@ -1,5 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { RotateCcw } from 'lucide-react'; +import { + assembleVegaLite, +} from 'flint-chart'; import { buildInteractiveChart, externalInteraction, @@ -20,6 +23,25 @@ type AnnotationFixture = { text: string; }; +function fixtureSelector( + input: InteractionCase['input'], + fixture: AnnotationFixture, +): Record { + const spec = assembleVegaLite(input) as any; + const fields = spec._interactionSemantics?.fields as string[] | undefined; + const parts = fixture.key.replace(/\|__flint_path$/, '').split('|'); + const sourceRows = input.data.values as readonly Record[]; + return Object.fromEntries((fields ?? []).slice(0, parts.length).map((field, index) => { + const part = parts[index]; + const sourceValue = sourceRows.map((record) => record[field]).find((value) => + String(value) === part + || value instanceof Date && String(value.getTime()) === part + || typeof value === 'string' && String(Date.parse(value)) === part); + const numeric = Number(part); + return [field, sourceValue ?? (Number.isFinite(numeric) ? numeric : part)]; + })); +} + const ANNOTATION_FIXTURES: Record = { 'Area Chart': [ { label: '1995–2000', key: '788918400000|1|__flint_path', visual: { kind: 'path', role: 'area' }, text: '1995–2000: 1% → 7%' }, @@ -161,8 +183,10 @@ async function waitForStableChartLayout(container: HTMLElement): Promise { await waitForStableChartLayout(container); if (!active) return; const target = { - visual: fixture.visual, - elements: [{ key: { __flint_interaction_key: fixture.key } }], + select: { + key: fixtureSelector(themedInput, fixture), + visual: fixture.visual, + }, }; const result = await surface.dispatch('static-annotation-policy', { id: `annotation-lab-${item.id}-${fixture.label}`, diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index 40af5290..ec78e6d6 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { ChevronDown, GripVertical, Layers3, MessageSquareText, MousePointer2, Move, MoveHorizontal, MoveVertical, RotateCcw, RotateCw, Scan } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { Crosshair, EyeOff, GripVertical, Keyboard, Lasso, Layers3, MessageSquarePlus, MessageSquareText, MousePointer2, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; import { genBarTests, @@ -8,13 +9,19 @@ import { } from 'flint-chart/test-data'; import { buildInteractiveChart, - brushAngle, brushX, brushY, + brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, + contextActivate, + doubleActivate, dragReorder, + inspect, + lassoSelect, + legendToggle, + longPress, navigate, select as rectangleSelect, type FlintInteractionEventDetail, @@ -28,7 +35,10 @@ import { navigationDemoCases } from './navigation-demo-data'; import './click-focus-lab.css'; export type InteractionMode = 'element' | 'group' | 'annotate' | 'select' - | 'brush-x' | 'brush-y' | 'brush-angle' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate' | 'drag-reorder'; + | 'brush-x' | 'brush-y' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate' | 'drag-reorder' + | 'lasso' | 'assisted' | 'keyboard' | 'select-comment' + | 'legend-toggle' | 'inspect' | 'inspect-quadrant' | 'inspect-x' + | 'brush-zoom' | 'long-press' | 'double-activate'; type ProbeStatus = 'loading' | 'ready' | 'unsupported' | 'error'; export interface NavigationGuard { @@ -44,13 +54,62 @@ const interactionModes = [ { value: 'select', label: 'Select', icon: Scan }, { value: 'brush-x', label: 'X brush', icon: MoveHorizontal }, { value: 'brush-y', label: 'Y brush', icon: MoveVertical }, - { value: 'brush-angle', label: 'Angular brush', icon: RotateCw }, { value: 'brush-x-stateful', label: 'X brush (edit)', icon: MoveHorizontal }, { value: 'brush-y-stateful', label: 'Y brush (edit)', icon: MoveVertical }, { value: 'navigate', label: 'Pan & zoom', icon: Move }, { value: 'drag-reorder', label: 'Drag reorder', icon: GripVertical }, + { value: 'lasso', label: 'Lasso', icon: Lasso }, + { value: 'select-comment', label: 'Select & comment', icon: MessageSquarePlus }, + { value: 'assisted', label: 'Assisted click', icon: Crosshair }, + { value: 'keyboard', label: 'Keyboard', icon: Keyboard }, + { value: 'legend-toggle', label: 'Legend toggle', icon: EyeOff }, + { value: 'inspect', label: 'Inspect xy', icon: Target }, + { value: 'inspect-quadrant', label: 'Inspect quadrant', icon: Crosshair }, + { value: 'inspect-x', label: 'Inspect x', icon: Ruler }, + { value: 'brush-zoom', label: 'Brush to zoom', icon: ZoomIn }, + { value: 'long-press', label: 'Long press', icon: Timer }, + { value: 'double-activate', label: 'Double click', icon: MousePointerClick }, ] as const; +type MountedInteraction = ReturnType; + +/** + * Modes mount the set of presets a real chart would ship together, not one preset each. + * Legend, press and inspect gestures are only meaningful alongside ordinary element clicks. + */ +function modeInteractions( + mode: InteractionMode, + navigationAxes: 'x' | 'y' | 'xy' | undefined, + navigationGuard: NavigationGuard | undefined, +): MountedInteraction[] { + switch (mode) { + case 'element': return [clickHighlight()]; + case 'group': return [clickGroupHighlight()]; + case 'annotate': return [clickAnnotate()]; + case 'select': return [rectangleSelect()]; + case 'brush-x': return [brushX()]; + case 'brush-y': return [brushY()]; + case 'brush-x-stateful': return [brushX({ mode: 'stateful' })]; + case 'brush-y-stateful': return [brushY({ mode: 'stateful' })]; + case 'drag-reorder': return [dragReorder()]; + case 'lasso': return [lassoSelect()]; + case 'inspect': return [inspect()]; + case 'inspect-quadrant': return [inspect({ + mode: 'x>=;y<=', + cycle: ['x>=;y<=', 'x>=;y>=', 'x<=;y>=', 'x<=;y<='], + dimOpacity: 0.14, + })]; + case 'inspect-x': return [inspect({ mode: 'x' })]; + case 'assisted': case 'keyboard': return [clickHighlight()]; + case 'select-comment': return [rectangleSelect(), contextActivate()]; + case 'legend-toggle': return [clickHighlight({ legend: false }), legendToggle()]; + case 'long-press': return [longPress()]; + case 'double-activate': return [doubleActivate()]; + case 'brush-zoom': return [clickHighlight(), brushZoom()]; + default: return [navigate({ axes: navigationAxes ?? 'available', domainGuard: navigationGuard })]; + } +} + export interface InteractionCase { id: string; input: ChartAssemblyInput; @@ -224,19 +283,113 @@ const reorderAxesByCase = new Map(interactionCases.flatMap((item) => { return axes?.length ? [[item.id, axes] as const] : []; })); -const ITEM_PREVIEW_LIMIT = 5; +function hasContinuousInspectAxes(spec: any): boolean { + if (!spec || typeof spec !== 'object') return false; + const xType = spec.encoding?.x?.type; + const yType = spec.encoding?.y?.type; + if ((xType === 'quantitative' || xType === 'temporal') && yType === 'quantitative') return true; + const children = ['layer', 'hconcat', 'vconcat', 'concat'] + .flatMap((property) => Array.isArray(spec[property]) ? spec[property] : []); + return [...children, spec.spec].some((child) => hasContinuousInspectAxes(child)); +} + +const inspectQuadrantCases = new Set(interactionCases.flatMap((item) => { + const spec = assembleVegaLite(item.input) as any; + return hasContinuousInspectAxes(spec) ? [item.id] : []; +})); + +function hasDiscreteLegendChannel(spec: any, channel: string, field: string): boolean { + if (!spec || typeof spec !== 'object') return false; + const encoding = spec.encoding?.[channel]; + if (encoding?.field === field && (encoding.type === 'nominal' || encoding.type === 'ordinal')) return true; + const children = ['layer', 'hconcat', 'vconcat', 'concat'] + .flatMap((property) => Array.isArray(spec[property]) ? spec[property] : []); + return [...children, spec.spec].some((child) => hasDiscreteLegendChannel(child, channel, field)); +} + +/** Toggling a legend key only means something when its entries are series, not scale ticks. */ +const discreteLegendCases = new Set(interactionCases.flatMap((item) => { + const spec = assembleVegaLite(item.input) as any; + const legendFields = spec._interactionSemantics?.legendFields as Record | undefined; + const discrete = Object.entries(legendFields ?? {}) + .some(([channel, field]) => hasDiscreteLegendChannel(spec, channel, field)); + return discrete ? [item.id] : []; +})); + +type ProbeElement = NonNullable['elements'][number]; + +function compactEntries(record: Record | undefined): string { + return Object.entries(record ?? {}) + .filter(([field, value]) => !field.startsWith('__') && value != null && typeof value !== 'object') + .map(([field, value]) => `${field}=${String(value)}`) + .join(', '); +} + +function describeElement(element: ProbeElement): { value: string; records: string[] } { + const legendChannel = element.value.channel; + const legendField = element.value.field; + const legendDomain = element.value.domain; + const representedRange = element.value.range; + const domainText = legendDomain && typeof legendDomain === 'object' && !Array.isArray(legendDomain) + ? (() => { + const domain = legendDomain as { kind?: unknown; value?: unknown; start?: unknown; end?: unknown }; + if (domain.kind === 'value') return `value=${String(domain.value)}`; + if (domain.kind === 'interval') { + return `domain=${domain.start === undefined ? '(-inf' : `[${String(domain.start)}`}, ${domain.end === undefined ? '+inf)' : `${String(domain.end)})`}`; + } + return undefined; + })() + : undefined; + const rangeText = representedRange && typeof representedRange === 'object' && !Array.isArray(representedRange) + ? (() => { + const range = representedRange as { start?: unknown; end?: unknown }; + const field = typeof element.value.field === 'string' ? element.value.field : 'range'; + const count = typeof element.value.count === 'number' ? `, count=${element.value.count}` : ''; + return `${field}=[${String(range.start)}, ${String(range.end)})${count}`; + })() + : undefined; + const value = typeof legendChannel === 'string' + ? [ + `channel=${legendChannel}`, + ...(typeof legendField === 'string' ? [`field=${legendField}`] : []), + ...(domainText ? [domainText] : []), + ].join(', ') + : rangeText ?? (compactEntries(element.value) || 'none'); + return { + value, + records: element.records?.map((record) => compactEntries(record) || 'empty') ?? [], + }; +} function summarizeElement( - element: NonNullable['elements'][number], + element: ProbeElement, ): string { - const values = Object.entries(element.value ?? {}) - .filter(([field, value]) => !field.startsWith('__') - && !['value', 'density', 'density_start', 'density_end'].includes(field) - && value != null - && typeof value !== 'object') - .slice(0, 3) - .map(([field, value]) => `${field}: ${String(value)}`); - return values.length ? values.join(' · ') : Object.values(element.key).map(String).join(' · '); + const description = describeElement(element); + return `value: ${description.value} · records: ${description.records.length}${description.records.length > 0 + ? ` [${description.records.map((record, index) => `${index + 1}. ${record}`).join(' ; ')}]` + : ''}`; +} + +function SemanticElementRows({ element }: { element: ProbeElement }) { + const description = describeElement(element); + return ( + <> +
    + value + {description.value} +
    +
    + records({description.records.length}) + + {description.records.length > 0 + ? description.records.map((record, index) => ( + {index + 1}. {record} + )) + : 'none'} + +
    + + ); } type InteractionEvent = FlintInteractionEventDetail['event']; @@ -307,6 +460,13 @@ function InteractiveChart({ const containerRef = useRef(null); const statusRef = useRef(onStatus); const semanticEventRef = useRef(onSemanticEvent); + const surfaceRef = useRef | null>(null); + const pointerRef = useRef({ x: 0, y: 0 }); + const selectionRef = useRef(null); + const [contextMenu, setContextMenu] = useState< + { x: number; y: number; detail: FlintInteractionEventDetail } | null + >(null); + const [comment, setComment] = useState(null); statusRef.current = onStatus; semanticEventRef.current = onSemanticEvent; @@ -315,49 +475,129 @@ function InteractiveChart({ if (!container) return; statusRef.current('loading'); const handleInteraction = (event: Event) => { - semanticEventRef.current((event as CustomEvent).detail); + const detail = (event as CustomEvent).detail; + semanticEventRef.current(detail); + const { action, phase, target } = detail.event; + if ((action === 'select-region' || action === 'select-lasso') && phase === 'commit') { + selectionRef.current = target; + } + if (action !== 'context-element') return; + if (!target?.elements.length) { + setContextMenu(null); + return; + } + setContextMenu({ x: pointerRef.current.x, y: pointerRef.current.y, detail }); }; + // Capture runs before the chart's own handler, so the menu opens at the pointer. + const captureContextPoint = (event: MouseEvent) => { + pointerRef.current = { x: event.clientX, y: event.clientY }; + }; + container.addEventListener('contextmenu', captureContextPoint, true); container.addEventListener('flint-interaction', handleInteraction); - const interaction = mode === 'element' - ? clickHighlight() - : mode === 'group' - ? clickGroupHighlight() - : mode === 'annotate' - ? clickAnnotate() - : mode === 'select' - ? rectangleSelect() - : mode === 'brush-x' - ? brushX() - : mode === 'brush-y' - ? brushY() - : mode === 'brush-angle' - ? brushAngle() - : mode === 'brush-x-stateful' - ? brushX({ mode: 'stateful' }) - : mode === 'brush-y-stateful' - ? brushY({ mode: 'stateful' }) - : mode === 'drag-reorder' - ? dragReorder() - : navigate({ axes: navigationAxes ?? 'available', domainGuard: navigationGuard }); + const interactions = modeInteractions(mode, navigationAxes, navigationGuard); const themedInput = themeId ? { ...input, theme_spec: themeId } : input; const surface = buildInteractiveChart(container, themedInput, { backend: 'vegalite', renderer: 'svg', - interactions: [interaction], + interactions, expressionInterpreter, ariaLabel: input.chart_spec.title, + assistedTargeting: mode === 'assisted', + keyboardTargeting: mode === 'keyboard', + dismiss: mode === 'long-press' || mode === 'double-activate' + ? { click: 'any', escape: true } + : undefined, }); + surfaceRef.current = surface; void surface.ready.then(() => statusRef.current('ready')).catch((error) => { const message = error instanceof Error ? error.message : String(error); statusRef.current(message.includes('requires') || message.includes('support') ? 'unsupported' : 'error', message); }); return () => { + container.removeEventListener('contextmenu', captureContextPoint, true); container.removeEventListener('flint-interaction', handleInteraction); + surfaceRef.current = null; + selectionRef.current = null; + setContextMenu(null); + setComment(null); surface.destroy(); }; }, [input, mode, navigationAxes, navigationGuard, resetVersion, themeId]); - return
    ; + const menuTarget = contextMenu?.detail.event.target ?? null; + const menuElement = menuTarget?.elements[0]; + + useEffect(() => { + if (!contextMenu) return; + const dismiss = (event: Event) => { + if ((event.target as Element | null)?.closest?.('.cf-context-menu')) return; + setContextMenu(null); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') setContextMenu(null); + }; + document.addEventListener('pointerdown', dismiss, true); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('pointerdown', dismiss, true); + document.removeEventListener('keydown', onKey); + }; + }, [contextMenu]); + + const addComment = () => { + const surface = surfaceRef.current; + if (!surface || !menuTarget || !menuElement) return; + const text = summarizeElement(menuElement) ?? 'Comment'; + setComment(text); + void surface.applyUpdate({ + id: 'select-comment', + ops: [{ + op: 'set-annotation', + target: { visual: menuTarget.visual, elements: [menuElement] }, + value: { text }, + }], + }); + setContextMenu(null); + }; + const clearComment = () => { + setComment(null); + void surfaceRef.current?.clearUpdate('select-comment'); + setContextMenu(null); + }; + + return ( + <> +
    + {contextMenu && createPortal( +
    + + + +
    , + document.body, + )} + {comment &&

    {comment}

    } + + ); } export function CaseCard({ @@ -376,7 +616,6 @@ export function CaseCard({ const [status, setStatus] = useState('loading'); const [statusMessage, setStatusMessage] = useState('Compiling'); const [lastInteraction, setLastInteraction] = useState(null); - const [itemsExpanded, setItemsExpanded] = useState(false); const title = item.input.chart_spec.title || item.input.chart_spec.chartType; const availableNavigationAxes = navigationAxesByCase.get(item.id); const navigationAxes = item.navigationAxes === 'xy' @@ -389,6 +628,7 @@ export function CaseCard({ : item.expectation; const semanticTarget = lastInteraction?.event.target; const semanticItems = semanticTarget?.elements ?? []; + const semanticRecords = semanticItems.reduce((count, element) => count + (element.records?.length ?? 0), 0); const resolved = semanticItems.length > 0; const geometry = lastInteraction ? summarizeGeometry(lastInteraction.event) : undefined; const responded = resolved || lastInteraction?.event.action.endsWith('-viewport'); @@ -418,10 +658,7 @@ export function CaseCard({ setStatus(nextStatus); setStatusMessage(message ?? (nextStatus === 'ready' ? 'Interactive surface ready' : 'Compiling')); }} - onSemanticEvent={(detail) => { - setLastInteraction(detail); - setItemsExpanded(false); - }} + onSemanticEvent={setLastInteraction} />
    @@ -435,6 +672,9 @@ export function CaseCard({ {!lastInteraction.event.action.endsWith('-viewport') && ( {semanticItems.length} item{semanticItems.length === 1 ? '' : 's'} + {semanticTarget?.visual.kind === 'legend' + ? ` · ${semanticRecords} record${semanticRecords === 1 ? '' : 's'}` + : ''} )} {lastInteraction.event.dropTarget?.elements[0] && ( @@ -444,21 +684,12 @@ export function CaseCard({ {semanticItems.length > 0 && (
      - {semanticItems.slice(0, itemsExpanded ? undefined : ITEM_PREVIEW_LIMIT).map((element, index) => ( -
    • {summarizeElement(element)}
    • + {semanticItems.map((element, index) => ( +
    • + +
    • ))}
    - {semanticItems.length > ITEM_PREVIEW_LIMIT && ( - - )}
    )} @@ -479,12 +710,14 @@ export function ClickFocusLab() { overscrollFraction: 0, }); const [resetVersion, setResetVersion] = useState(0); - const visibleCases = mode === 'brush-angle' - ? interactionCases.filter((item) => ['Donut Chart', 'Pie Chart', 'Rose Chart'].includes(item.chartType)) - : mode === 'navigate' + const visibleCases = mode === 'navigate' || mode === 'brush-zoom' ? navigationCases.filter((item) => navigationAxesByCase.has(item.id)) : mode === 'drag-reorder' ? interactionCases.filter((item) => reorderAxesByCase.has(item.id)) + : mode === 'inspect-quadrant' + ? interactionCases.filter((item) => inspectQuadrantCases.has(item.id)) + : mode === 'legend-toggle' + ? interactionCases.filter((item) => discreteLegendCases.has(item.id)) : interactionCases; return ( @@ -513,9 +746,8 @@ export function ClickFocusLab() {
  • Group: Click a mark to focus related marks in the same category or series.
  • Annotate: Click a mark to search nearby free space and connect its represented value.
  • Select: Drag a rectangle to focus all marks within an area.
  • -
  • X brush: Drag horizontally to focus marks across an X interval.
  • +
  • X brush: Drag across an X interval; polar charts automatically use an angular sector.
  • Y brush: Drag vertically to focus marks across a Y interval.
  • -
  • Angular brush: Drag around the center of a pie, donut, or rose chart.
  • Stateful brush: Move the committed interval, resize either edge, or click outside to clear it.
  • Pan & zoom: Drag continuous axes to pan; use the wheel or trackpad to zoom.
  • diff --git a/site/src/playground/click-focus-lab.css b/site/src/playground/click-focus-lab.css index 05cceb35..ab01513a 100644 --- a/site/src/playground/click-focus-lab.css +++ b/site/src/playground/click-focus-lab.css @@ -208,6 +208,47 @@ overflow-y: hidden; } +.cf-context-menu { + position: fixed; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 132px; + padding: 3px; + border: 1px solid #cfd5da; + border-radius: 6px; + background: #fff; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.16); +} + +.cf-context-menu button { + border: 0; + border-radius: 4px; + padding: 6px 9px; + color: #1f2328; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 12px; + text-align: left; +} + +.cf-context-menu button:hover:not(:disabled) { + background: #eef1f3; +} + +.cf-context-menu button:disabled { + color: #a6aeb5; + cursor: default; +} + +.cf-context-note { + margin: 6px 12px 0; + color: #3f6b57; + font-size: 11px; + font-weight: 600; +} + .cf-mount { display: grid; align-items: center; @@ -263,42 +304,13 @@ white-space: nowrap; } -.cf-items-toggle { - display: inline-flex; - align-items: center; - gap: 3px; - min-height: 0; - border: 0; - padding: 0; - background: transparent; - color: #7a838b; - cursor: pointer; - font: inherit; - font-weight: 500; -} - .cf-probe-event-data { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 8px; + max-height: 156px; margin-top: 6px; padding-top: 6px; border-top: 1px solid #e7eaed; -} - -.cf-items-toggle:hover { - color: #46515a; - text-decoration: underline; - text-underline-offset: 2px; -} - -.cf-items-toggle svg { - transition: transform 120ms ease; -} - -.cf-items-toggle[aria-expanded='true'] svg { - transform: rotate(180deg); + overflow: auto; + scrollbar-gutter: stable; } .cf-probe-event-resolved { @@ -322,16 +334,59 @@ .cf-probe-event-items { display: grid; - gap: 3px; - max-height: 128px; + gap: 5px; margin: 0; - padding: 0 0 0 17px; - overflow: auto; - color: #68737c; + padding: 0; + list-style: none; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; } +.cf-probe-event-items li { + display: grid; + gap: 1px; + min-width: 0; + padding-bottom: 4px; + border-bottom: 1px solid #edf0f2; +} + +.cf-probe-event-items li:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.cf-semantic-row { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + align-items: start; + gap: 5px; + min-width: 0; +} + +.cf-semantic-label { + font-weight: 600; +} + +.cf-probe-event .cf-semantic-content { + min-width: 0; + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; +} + +.cf-semantic-value-row { + color: #315f75; +} + +.cf-semantic-records-row { + color: #765638; +} + +.cf-semantic-record { + display: block; +} + @media (max-width: 820px) { .cf-grid { grid-template-columns: minmax(0, 1fr); From 95ce5a21830807c4ba1923f6a40559534abcd978 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Tue, 1 Sep 2026 00:11:41 -0700 Subject: [PATCH 21/33] improvements --- packages/flint-js/src/README.md | 2 +- .../src/core/interaction-contracts.ts | 15 +- .../src/core/interaction-semantics.ts | 14 +- packages/flint-js/src/interactive/README.md | 47 ++++- .../src/interactive/canvas-interaction.ts | 1 - packages/flint-js/src/interactive/index.ts | 11 +- .../flint-js/src/interactive/interactions.ts | 16 +- .../src/interactive/language/updates.ts | 2 +- .../src/interactive/presets/README.md | 6 +- .../src/interactive/presets/axis-highlight.ts | 22 +++ .../src/interactive/presets/click-annotate.ts | 2 +- .../flint-js/src/interactive/presets/index.ts | 1 + .../src/interactive/presets/legend-toggle.ts | 2 +- .../flint-js/src/interactive/presets/utils.ts | 4 +- packages/flint-js/src/interactive/types.ts | 12 +- packages/flint-js/src/vegalite/assemble.ts | 6 + .../src/vegalite/interactions/compile.ts | 69 ++++++- .../src/vegalite/interactions/contracts.ts | 8 + .../src/vegalite/interactions/hit-adapter.ts | 22 ++- .../presentation/target-feedback-overlay.ts | 159 ++++++++++++++++ .../src/vegalite/interactions/runtime.ts | 178 ++++++++++++++---- .../src/vegalite/interactions/stores.ts | 1 + packages/flint-js/src/vegalite/interactive.ts | 6 +- packages/flint-js/tests/interactions.test.ts | 130 ++++++++++--- .../tests/semantic-interactions.test.ts | 74 +++++++- site/src/playground/AnnotationLab.tsx | 2 +- site/src/playground/ClickFocusLab.tsx | 7 +- site/src/playground/ExternalToChartLab.tsx | 4 +- .../InteractionArchitectureIllustration.tsx | 2 +- .../playground/InteractionDashboardLab.tsx | 4 +- 30 files changed, 718 insertions(+), 111 deletions(-) create mode 100644 packages/flint-js/src/interactive/presets/axis-highlight.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/target-feedback-overlay.ts diff --git a/packages/flint-js/src/README.md b/packages/flint-js/src/README.md index 1347202a..32248992 100644 --- a/packages/flint-js/src/README.md +++ b/packages/flint-js/src/README.md @@ -194,7 +194,7 @@ const countryPicker = externalInteraction<{ country: string }>({ handle: ({ country }) => ({ id: 'country-selection', ops: [{ - op: 'set-presentation', + op: 'set-style', targets: [{ select: { key: { Country: country } } }], value: { state: 'emphasized' }, }], diff --git a/packages/flint-js/src/core/interaction-contracts.ts b/packages/flint-js/src/core/interaction-contracts.ts index 70bbfd9e..a4826854 100644 --- a/packages/flint-js/src/core/interaction-contracts.ts +++ b/packages/flint-js/src/core/interaction-contracts.ts @@ -32,10 +32,16 @@ export interface LegendTargetValue extends Record { domain: LegendDomain; } + export interface AxisTargetValue extends Record { + axis: 'x' | 'y'; + field: string; + value: unknown; + } + /** A semantic subject: its visual role plus represented values and provenance. */ export interface SemanticTarget { visual: { - kind: 'mark' | 'path' | 'region' | 'widget' | 'handle' | 'legend'; + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle' | 'legend' | 'axis'; role: string; }; elements: readonly SemanticElement[]; @@ -114,9 +120,10 @@ export interface AnnotationSpec { subject?: Partial; } -export interface PresentationSpec { +export interface StyleSpec { visible?: boolean; opacity?: number; + fill?: string; stroke?: string; strokeWidth?: number; state?: 'normal' | 'focused' | 'emphasized' | 'muted'; @@ -125,9 +132,9 @@ export interface PresentationSpec { export type ChartUpdateOp = | { - op: 'set-presentation'; + op: 'set-style'; targets: readonly UpdateTarget[]; - value: PresentationSpec; + value: StyleSpec; } | { op: 'set-annotation'; diff --git a/packages/flint-js/src/core/interaction-semantics.ts b/packages/flint-js/src/core/interaction-semantics.ts index cc83a31b..e33283c3 100644 --- a/packages/flint-js/src/core/interaction-semantics.ts +++ b/packages/flint-js/src/core/interaction-semantics.ts @@ -1,6 +1,5 @@ import type { RenderHit, - LegendTargetValue, SemanticElement, SemanticResolveContext, SemanticResolveEvent, @@ -9,6 +8,7 @@ import type { export type { ChartInteractionResolver, + AxisTargetValue, RenderHit, LegendDomain, LegendTargetValue, @@ -146,12 +146,12 @@ export function legendMatchedHits( const domain = event.legend?.domain; if (!domain) return []; const matches = (datum: Record): boolean => { - if (domain.kind === 'value') return datum[field] === domain.value; - const rawValue = datum[field]; - const value = rawValue instanceof Date ? rawValue.getTime() : rawValue; - return typeof value === 'number' - && (domain.start === undefined || value >= domain.start) - && (domain.end === undefined || value < domain.end); + if (domain.kind === 'value') return datum[field] === domain.value; + const rawValue = datum[field]; + const value = rawValue instanceof Date ? rawValue.getTime() : rawValue; + return typeof value === 'number' + && (domain.start === undefined || value >= domain.start) + && (domain.end === undefined || value < domain.end); }; return context.allHits .filter((hit) => matches(hit.datum)) diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index f730e458..e92c5e1d 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -43,7 +43,7 @@ const selectCountry: CanvasInteractionDef = { handle: (event) => event.target ? { id: 'country-selection', ops: [{ - op: 'set-presentation', + op: 'set-style', targets: [event.target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }], @@ -66,7 +66,7 @@ const countryPicker = externalInteraction<{ country: string; selected: boolean } handle: ({ country, selected }) => ({ id: 'country-selection', ops: [{ - op: 'set-presentation', + op: 'set-style', targets: selected ? [{ select: { key: { Country: country } } }] : [], value: { state: selected ? 'emphasized' : 'normal' }, }], @@ -447,7 +447,7 @@ Canvas definitions have two declarative halves: 2. Optional `handle()` declares **what update JSON to produce** after normalization. Target-bearing events first pass through ChartDef semantic resolution. The handler consumes the same `CanvasInteractionEvent` emitted to applications and returns a - `ChartUpdate` containing renderer-neutral `set-presentation`, `set-annotation`, + `ChartUpdate` containing renderer-neutral `set-style`, `set-annotation`, `set-viewport`, or `set-order` operations. The backend mount reads `eventSource`; it does not infer a gesture from pointer motion. It installs the required native listeners, supplies renderer coordinates and hit testing, and runs the recognizer requested by the interaction. This keeps an identical drag stream deterministic and author-controlled. @@ -474,7 +474,7 @@ renderer-neutral gesture-guide styles. Retained guides such as reference lines i to chart presentation state and may be created by effects through chart updates. Chart-specific action processing belongs in the handler. For example, ranged-dot region targets -are expanded to complete category units before producing a `set-presentation` update. Direct ranged-dot +are expanded to complete category units before producing a `set-style` update. Direct ranged-dot clicks already resolve to the complete dumbbell in the owning ChartDef. The coordinator always emits a resolved canvas event and invokes `handle()` only when @@ -663,6 +663,27 @@ Vega interaction code imports its concrete owner directly. Compile instrumentati A custom source may register listeners and emit normalized events. Renderer-specific mounting code may additionally compute renderer geometry and inspect rendered marks. Neither source descriptors nor mounts may resolve semantic targets, contain chart-type behavior, or construct chart updates. +### Target feedback + +Assisted pointer and keyboard targeting share a transient target indicator and a floating semantic tooltip. Keyboard arrows move the indicator and apply the active hover styling; Enter or Space invokes the configured click preset. The tooltip uses the compiled pointer-hover fields, stays clear of the active mark, may extend beyond the chart canvas, and scrolls with the chart. + +Assisted pointer targeting can customize the compact semantic details: + +```ts +buildInteractiveChart(container, input, { + backend: 'vegalite', + interactions: [clickHighlight(), axisHighlight()], + assistedTargeting: { + maxDistance: 16, + indicator: true, + details: { fields: ['country', 'value'], maxRows: 4 }, + }, + keyboardTargeting: true, +}); +``` + +`axisHighlight()` treats native categorical axis ticks as semantic controls. The compiler maps each Vega scale back to its authored field, and the runtime associates a tick with represented mark keys. Quantitative and temporal ticks remain inert until a nearest-value or interval policy is specified. + ## Update Language Presets and applications produce one renderer-neutral `ChartUpdate` format. There is no @@ -675,7 +696,7 @@ interface ChartUpdate { } type ChartUpdateOp = - | { op: 'set-presentation'; targets: readonly UpdateTarget[]; value: PresentationSpec } + | { op: 'set-style'; targets: readonly UpdateTarget[]; value: StyleSpec } | { op: 'set-annotation'; target: UpdateTarget; value: AnnotationSpec | null } | { op: 'set-viewport'; axes: 'x' | 'y' | 'xy'; value: { x?: Domain; y?: Domain } } | { @@ -684,6 +705,16 @@ type ChartUpdateOp = field: string; values: readonly unknown[]; }; + +interface StyleSpec { + visible?: boolean; + opacity?: number; + fill?: string; + stroke?: string; + strokeWidth?: number; + state?: 'normal' | 'focused' | 'emphasized' | 'muted'; + mutedOpacity?: number; +} ``` Operators are plain JSON. Presets and applications construct object literals directly; @@ -718,7 +749,7 @@ future composition modes can extend the API without changing this default behavi Relative gesture data is not update state. Pan deltas, zoom factors, toggle modifiers, and drag positions are reduced by interaction state into absolute `set-viewport`, -`set-presentation`, or `set-order` values. Cancelling a gesture requires no inverse +`set-style`, or `set-order` values. Cancelling a gesture requires no inverse chart command: the interaction drops its preview and sends the prior effective updates. Targets may be exact event-derived refs or unresolved equality selectors: @@ -771,7 +802,7 @@ and apply that precomputed state directly: const result = await surface.applyUpdate({ id: 'external-country-selection', ops: [{ - op: 'set-presentation', + op: 'set-style', targets: [{ select: { key: { Country: 'Japan' }, @@ -802,7 +833,7 @@ const countryPicker = externalInteraction<{ country: string; selected: boolean } handle: ({ country, selected }) => ({ id: 'country-picker', ops: [{ - op: 'set-presentation', + op: 'set-style', targets: selected ? [{ select: { key: { Country: country } } }] : [], value: { state: selected ? 'emphasized' : 'normal' }, }], diff --git a/packages/flint-js/src/interactive/canvas-interaction.ts b/packages/flint-js/src/interactive/canvas-interaction.ts index 49d0f399..7a0ac40d 100644 --- a/packages/flint-js/src/interactive/canvas-interaction.ts +++ b/packages/flint-js/src/interactive/canvas-interaction.ts @@ -3,7 +3,6 @@ import type { InteractionEventSource } from './triggers'; import type { CanvasInteractionAction, CanvasInteractionEvent, - InteractionModifiers, NavigationInteractionEvent, PlotGeometry, SemanticInteractionEvent, diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 01181d28..ddc96c09 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -5,6 +5,8 @@ import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './ty export type { AssistedTargetingOptions, + TargetDetailsOptions, + TargetFeedbackOptions, BuildInteractiveChartOptions, ChartUpdateApplyOptions, ChartUpdateComposition, @@ -36,6 +38,7 @@ export type { BrushOptions, BrushZoomOptions, AngularBrushOptions, + AxisHighlightOptions, ClickAnnotateOptions, ClickGroupHighlightOptions, ClickHighlightOptions, @@ -65,7 +68,7 @@ export type { SemanticElement, SemanticInteractionEvent, SemanticTarget, - PresentationSpec, + StyleSpec, UpdateDomain, UpdateTarget, } from './interactions'; @@ -83,7 +86,7 @@ export type { SemanticTargetSelector, } from './language/updates'; export { matchesSemanticTargetSelector } from './language/updates'; -export { brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; +export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; export type { InteractionEventSource } from './triggers'; export { axisBrushTrigger, @@ -149,6 +152,10 @@ export function buildInteractiveChart( ? (typeof assistedTargeting === 'object' ? assistedTargeting.maxDistance : undefined) ?? DEFAULT_ASSIST_DISTANCE : 0, + targetFeedback: { + assisted: typeof assistedTargeting === 'object' ? assistedTargeting : assistedTargeting ? {} : false, + keyboard: keyboardTargeting ? {} : false, + }, keyboardTargeting, dismiss, }).mount(chartContainer, chartInput); diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index a1e091cf..0c4ba0b3 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -15,6 +15,7 @@ import { createBrushInteraction, createBrushZoomInteraction, createAngularBrushInteraction, + createAxisHighlightInteraction, createClickAnnotateInteraction, createClickGroupHighlightInteraction, createClickHighlightInteraction, @@ -80,7 +81,7 @@ export type { AnnotationSpec, ChartUpdate, ChartUpdateOp, - PresentationSpec, + StyleSpec, SemanticTargetRef, SemanticTargetSelector, UpdateDomain, @@ -93,6 +94,8 @@ export interface CanvasInteractionDef { readonly navigationDomainGuard?: NavigationDomainGuard; /** Claims legend activations exclusively, so a legend click never also reads as an element click. */ readonly claimsLegendActivation?: boolean; + /** Claims native axis tick activations instead of treating them as mark activations. */ + readonly claimsAxisActivation?: boolean; handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdate | null; } @@ -126,6 +129,13 @@ export interface ClickHighlightOptions { legend?: boolean; } +export interface AxisHighlightOptions { + id?: string; + axis?: 'x' | 'y'; + event?: 'hover' | 'click'; + dimOpacity?: number; +} + export interface ClickGroupHighlightOptions extends ClickHighlightOptions { groupBy?: string | ((element: SemanticElement, context: InteractionContext) => unknown); } @@ -208,6 +218,10 @@ export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInter return createClickHighlightInteraction(options); } +export function axisHighlight(options: AxisHighlightOptions = {}): CanvasInteractionDef { + return createAxisHighlightInteraction(options); +} + export function clickGroupHighlight(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { return createClickGroupHighlightInteraction(options); } diff --git a/packages/flint-js/src/interactive/language/updates.ts b/packages/flint-js/src/interactive/language/updates.ts index da6ee192..1ba4a249 100644 --- a/packages/flint-js/src/interactive/language/updates.ts +++ b/packages/flint-js/src/interactive/language/updates.ts @@ -11,7 +11,7 @@ export type { AnnotationSpec, ChartUpdate, ChartUpdateOp, - PresentationSpec, + StyleSpec, SemanticTargetRef, SemanticTargetSelector, UpdateDomain, diff --git a/packages/flint-js/src/interactive/presets/README.md b/packages/flint-js/src/interactive/presets/README.md index f51eeb0d..1aa079a2 100644 --- a/packages/flint-js/src/interactive/presets/README.md +++ b/packages/flint-js/src/interactive/presets/README.md @@ -34,7 +34,7 @@ const legendSelection: CanvasInteractionDef = { id: 'legend-selection', ops: [ { - op: 'set-presentation', targets: [event.target], + op: 'set-style', targets: [event.target], value: { state: 'emphasized' }, }, { op: 'set-annotation', target: event.target, value: { text: 'Selected series' } }, @@ -50,7 +50,7 @@ semantic runtime. Current presets consume the public canvas event shape and prod public update JSON; target resolution and backend application remain internal. Other interactive backends and additional appearance/visibility operations remain future work. -Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, and rose. All three produce the same semantic `set-presentation` operation after the owning ChartDef resolves physical hits. +Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, and rose. All three produce the same semantic `set-style` operation after the owning ChartDef resolves physical hits. ## Emphasis Behavior @@ -87,7 +87,7 @@ The stages remain separate: 1. Trigger normalization reports physical hits. 2. ChartDef resolution converts hits into semantic elements. 3. The coordinator emits the resolved event whether or not a preset is configured. -4. An optional preset emits `set-presentation` with selected elements and muted-peer opacity. +4. An optional preset emits `set-style` with selected elements and muted-peer opacity. 5. ChartDef presentation declares representation-specific focus styling. 6. The renderer applies opacity, proportional line width, or region boundaries mechanically. diff --git a/packages/flint-js/src/interactive/presets/axis-highlight.ts b/packages/flint-js/src/interactive/presets/axis-highlight.ts new file mode 100644 index 00000000..f4869120 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/axis-highlight.ts @@ -0,0 +1,22 @@ +import type { AxisHighlightOptions, CanvasInteractionDef } from '../interactions'; +import { clickTrigger, hoverTrigger } from '../triggers'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +export function createAxisHighlightInteraction(options: AxisHighlightOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'axis-highlight'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: options.event === 'hover' ? hoverTrigger : clickTrigger, + claimsAxisActivation: true, + handle(event, context) { + if (event.action !== 'hover-axis' && event.action !== 'click-axis') return null; + if (event.phase === 'start') return null; + const target = event.target?.visual.kind === 'axis' + && (!options.axis || event.target.elements.some((element) => element.value.axis === options.axis)) + ? event.target + : null; + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts index 104eae93..52e26581 100644 --- a/packages/flint-js/src/interactive/presets/click-annotate.ts +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -19,7 +19,7 @@ export function createClickAnnotateInteraction(options: ClickAnnotateOptions = { id, ops: [ { op: 'set-annotation', target: { select: { key: {} } }, value: null }, - { op: 'set-presentation', targets: [], value: { state: 'normal' } }, + { op: 'set-style', targets: [], value: { state: 'normal' } }, ], }; } diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index c15dd882..7d8a087a 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -1,6 +1,7 @@ export { createBrushInteraction } from './brush'; export { createBrushZoomInteraction } from './brush-zoom'; export { createAngularBrushInteraction } from './angular-brush'; +export { createAxisHighlightInteraction } from './axis-highlight'; export { createClickAnnotateInteraction } from './click-annotate'; export { createClickGroupHighlightInteraction } from './click-group-highlight'; export { createClickHighlightInteraction } from './click-highlight'; diff --git a/packages/flint-js/src/interactive/presets/legend-toggle.ts b/packages/flint-js/src/interactive/presets/legend-toggle.ts index e8680c16..76e02c0c 100644 --- a/packages/flint-js/src/interactive/presets/legend-toggle.ts +++ b/packages/flint-js/src/interactive/presets/legend-toggle.ts @@ -85,7 +85,7 @@ export function createLegendToggleInteraction(options: LegendToggleOptions = {}) return { id, ops: [{ - op: 'set-presentation', + op: 'set-style', targets: hidden.length > 0 ? [{ visual: { kind: 'legend', role: 'legend-item' }, elements: hidden }] : [], diff --git a/packages/flint-js/src/interactive/presets/utils.ts b/packages/flint-js/src/interactive/presets/utils.ts index 862b87c6..362b92df 100644 --- a/packages/flint-js/src/interactive/presets/utils.ts +++ b/packages/flint-js/src/interactive/presets/utils.ts @@ -33,7 +33,7 @@ export function emphasisUpdate( dimOpacity: number, context: InteractionContext, ): ChartUpdate | null { - if (!target) return { id, ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }] }; + if (!target) return { id, ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }] }; if (target.elements.length === 0) return null; const toggle = selectionMode(event.modifiers) === 'toggle'; const targetKeys = new Set(target.elements.map(semanticElementIdentity)); @@ -48,7 +48,7 @@ export function emphasisUpdate( return { id, ops: [{ - op: 'set-presentation', + op: 'set-style', targets: elements.length > 0 ? [{ visual: target.visual, elements }] : [], value: { state: elements.length > 0 ? 'emphasized' : 'normal', mutedOpacity: dimOpacity }, }], diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index a6de4980..897d2f96 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -13,7 +13,17 @@ export interface ViewportGeometry { export type ChartUpdateComposition = 'auto'; /** Pointer acquisition that snaps to a nearby mark instead of requiring a direct hit. */ -export interface AssistedTargetingOptions { +export interface TargetDetailsOptions { + fields?: readonly string[]; + maxRows?: number; +} + +export interface TargetFeedbackOptions { + indicator?: boolean; + details?: boolean | TargetDetailsOptions; +} + +export interface AssistedTargetingOptions extends TargetFeedbackOptions { maxDistance?: number; } diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index bd0d9caf..e4fb1088 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -938,6 +938,12 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { ) === index); result._interactionSemantics = { ...templateSemantics, + axisFields: Object.fromEntries((['x', 'y'] as const).flatMap((axis) => { + const encoding = resolvedEncodings[axis]; + return encoding?.field + ? [[axis, { field: encoding.field, type: encoding.type ?? 'nominal' }]] + : []; + })), sourceRecords: values.map((record) => ({ ...record })), provenanceFields: templateSemantics.provenanceFields ?? provenanceFields, temporalProvenanceFields: templateSemantics.temporalProvenanceFields ?? temporalProvenanceFields, diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index 03fc14f9..8817a8ed 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -29,6 +29,7 @@ import { INTERACTION_STORE, LEGEND_HOVER_STORE, LEGEND_SELECTION_STORE, + STYLE_SIGNAL, } from './stores'; const CLEAR_MARK = '__flint_interaction_clear'; @@ -45,6 +46,7 @@ interface TemplateInteractionSemantics { seriesField?: string; resolveGroupValue?: InteractionContext['resolveGroupValue']; legendFields?: Record; + axisFields?: Partial>; rangeLegendChannels?: readonly string[]; selectableMarks: string[]; annotationMarkType?: string; @@ -63,9 +65,8 @@ interface TemplateInteractionSemantics { export function withoutSemanticInteractionField(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value; - const filtered = { ...(value as Record) }; - delete filtered[INTERACTION_KEY]; - return filtered; + return Object.fromEntries(Object.entries(value as Record) + .filter(([field]) => field !== '_vgsid_' && !field.startsWith('__'))); } function markType(mark: unknown): string | undefined { @@ -473,9 +474,9 @@ export function addVegaLiteInteractions( } as const; const interactionContext = { chartType: 'Unknown', selected: [] }; const update = interaction.handle(toCanvasInteractionEvent(semanticEvent, interaction.eventSource), interactionContext); - const presentation = update?.ops.find((op) => op.op === 'set-presentation'); - return presentation?.op === 'set-presentation' - ? Math.min(value, presentation.value.mutedOpacity ?? DEFAULT_DIM_OPACITY) + const style = update?.ops.find((op) => op.op === 'set-style'); + return style?.op === 'set-style' + ? Math.min(value, style.value.mutedOpacity ?? DEFAULT_DIM_OPACITY) : value; }, DEFAULT_DIM_OPACITY); @@ -510,6 +511,7 @@ export function addVegaLiteInteractions( seriesField: templateSemantics.seriesField, resolveGroupValue: templateSemantics.resolveGroupValue, legendFields: templateSemantics.legendFields, + axisFields: templateSemantics.axisFields, rangeLegendChannels: templateSemantics.rangeLegendChannels, annotationMarkType: templateSemantics.annotationMarkType, semanticStores: instrumented, @@ -580,6 +582,38 @@ export function injectVegaNavigationSignals( return result; } +export function collectVegaAxisTargets( + vegaSpec: Record, + axisFields: VegaInteractionPlan['axisFields'], +): Record { + const targets: Record = {}; + const visit = (scope: Record): void => { + for (const axis of scope.axes ?? []) { + const channel = axis.orient === 'top' || axis.orient === 'bottom' ? 'x' + : axis.orient === 'left' || axis.orient === 'right' ? 'y' : undefined; + const field = channel ? axisFields?.[channel] : undefined; + if (!channel || !field || typeof axis.scale !== 'string') continue; + targets[axis.scale] = { axis: channel, ...field }; + axis.encode = { + ...(axis.encode ?? {}), + labels: { + ...(axis.encode?.labels ?? {}), + interactive: true, + update: { ...(axis.encode?.labels?.update ?? {}), cursor: { value: 'pointer' } }, + }, + ticks: { + ...(axis.encode?.ticks ?? {}), + interactive: true, + update: { ...(axis.encode?.ticks?.update ?? {}), cursor: { value: 'pointer' } }, + }, + }; + } + for (const mark of scope.marks ?? []) visit(mark); + }; + visit(vegaSpec); + return targets; +} + function applyCompiledHoverStyles( marks: Record[], renderHoverStyles: Readonly>, @@ -631,6 +665,24 @@ function applyCompiledHoverStyles( } } +function applyCompiledStyleChannels(marks: Record[]): void { + for (const mark of marks) { + if (Array.isArray(mark.marks)) applyCompiledStyleChannels(mark.marks); + const update = mark.encode?.update; + if (!update || !JSON.stringify(mark.encode).includes(INTERACTION_KEY)) continue; + const key = `datum.${INTERACTION_KEY}`; + for (const channel of ['opacity', 'fill', 'stroke', 'strokeWidth'] as const) { + const existing = update[channel] ?? mark.encode?.enter?.[channel]; + if (existing === undefined) continue; + const styleValue = `${STYLE_SIGNAL}[${key}] && ${STYLE_SIGNAL}[${key}].${channel}`; + update[channel] = [ + { test: `isValid(${styleValue})`, signal: styleValue }, + ...(Array.isArray(existing) ? existing : [existing]), + ]; + } + } +} + export function injectVegaInteractionStore( vegaSpec: Record, plan?: Pick, @@ -646,6 +698,10 @@ export function injectVegaInteractionStore( { name: LEGEND_SELECTION_STORE, values: [] }, ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), ]; + vegaSpec.signals = [ + ...(Array.isArray(vegaSpec.signals) ? vegaSpec.signals : []), + { name: STYLE_SIGNAL, value: {} }, + ]; const instrumentLegends = (scope: Record): void => { for (const legend of scope.legends ?? []) { const scaleChannel = ['fill', 'stroke', 'size', 'shape', 'opacity'] @@ -750,6 +806,7 @@ export function injectVegaInteractionStore( instrumentLegends(vegaSpec); if (!Array.isArray(vegaSpec.marks)) return; if (plan?.renderHoverStyles) applyCompiledHoverStyles(vegaSpec.marks, plan.renderHoverStyles); + applyCompiledStyleChannels(vegaSpec.marks); vegaSpec.marks.unshift({ type: 'rect', name: CLEAR_MARK, diff --git a/packages/flint-js/src/vegalite/interactions/contracts.ts b/packages/flint-js/src/vegalite/interactions/contracts.ts index 3f492c14..90eed99d 100644 --- a/packages/flint-js/src/vegalite/interactions/contracts.ts +++ b/packages/flint-js/src/vegalite/interactions/contracts.ts @@ -46,6 +46,12 @@ export interface VegaReorderAxis { signal: string; } +export interface VegaAxisTarget { + axis: 'x' | 'y'; + field: string; + type: string; +} + export interface VegaInteractionPlan { fields: readonly string[]; sourceRecords: readonly Record[]; @@ -56,6 +62,8 @@ export interface VegaInteractionPlan { seriesField?: string; resolveGroupValue?: InteractionContext['resolveGroupValue']; legendFields?: Readonly>; + axisFields?: Partial>; + axisTargets?: Readonly>; rangeLegendChannels?: readonly string[]; annotationMarkType?: string; /** The compiled spec carries the semantic selection stores. */ diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts index cafff8d6..0d63d99e 100644 --- a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -1,7 +1,5 @@ import { - associateSemanticElementRenderKeys, semanticVisualFamily, - semanticElementRenderKeys, type RenderHit, type SemanticTarget, type LegendTargetValue, @@ -737,6 +735,22 @@ export function legendSemanticTarget( }; } +export function axisTargetIdentity( + item: any, + targets: Readonly> | undefined, +): (import('./contracts').VegaAxisTarget & { value: unknown; role: string }) | null { + const role = item?.mark?.role; + if (role !== 'axis-label' && role !== 'axis-tick') return null; + let group = item?.mark?.group; + while (group && group.mark?.role !== 'axis') group = group.mark?.group; + const scale = group?.datum?.scale; + const target = typeof scale === 'string' ? targets?.[scale] : undefined; + if (!target || (target.type !== 'nominal' && target.type !== 'ordinal') || item?.datum?.value === undefined) { + return null; + } + return { ...target, value: item.datum.value, role }; +} + export interface NormalizedVegaElement { event: ElementInteractionEvent; role: 'mark' | 'legend-item' | 'text-label'; @@ -862,8 +876,10 @@ export function nextItemInDirection( items: readonly any[], from: PlotPoint, direction: SpatialDirection, + discreteAxis?: 'x' | 'y', ): any | undefined { const horizontal = direction === 'left' || direction === 'right'; + const followsDiscreteAxis = discreteAxis === (horizontal ? 'x' : 'y'); let best: { item: any; score: number } | undefined; for (const item of items) { if (!item?.bounds) continue; @@ -873,7 +889,7 @@ export function nextItemInDirection( const along = direction === 'right' ? dx : direction === 'left' ? -dx : direction === 'down' ? dy : -dy; if (along <= 0.5) continue; const across = Math.abs(horizontal ? dy : dx); - const score = along + across * 3; + const score = followsDiscreteAxis ? along + across * 0.001 : along + across * 3; if (!best || score < best.score) best = { item, score }; } return best?.item; diff --git a/packages/flint-js/src/vegalite/interactions/presentation/target-feedback-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/target-feedback-overlay.ts new file mode 100644 index 00000000..92eaa6f9 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/target-feedback-overlay.ts @@ -0,0 +1,159 @@ +import type { SemanticTarget } from '../../../core/interaction-contracts'; +import type { TargetFeedbackOptions } from '../../../interactive/types'; +import { withoutSemanticInteractionField } from '../compile'; +import { clientRectToLayoutRect, type RendererCoordinateSpace } from '../hit-adapter'; + +export interface TargetFeedbackOverlayController { + render(item: any, target: SemanticTarget | null, source: 'assisted' | 'keyboard'): void; + clear(): void; + destroy(): void; +} + +export function targetFeedbackPoint(item: any): { x: number; y: number } | null { + if (!item?.bounds) return null; + if (item.mark?.marktype === 'arc' + && [item.x, item.y, item.innerRadius, item.outerRadius, item.startAngle, item.endAngle] + .every((value) => typeof value === 'number' && Number.isFinite(value))) { + const angle = (item.startAngle + item.endAngle) / 2; + const radius = (item.innerRadius + item.outerRadius) / 2; + return { + x: item.x + radius * Math.sin(angle), + y: item.y - radius * Math.cos(angle), + }; + } + return { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; +} + +export function targetFeedbackDetailsPosition( + anchor: { x: number; y: number }, + size: { width: number; height: number }, + viewport: { width: number; height: number }, +): { left: number; top: number } { + const gap = 14; + const margin = 8; + const left = anchor.x + gap + size.width <= viewport.width - margin + ? anchor.x + gap + : Math.max(margin, anchor.x - gap - size.width); + const top = anchor.y + gap + size.height <= viewport.height - margin + ? anchor.y + gap + : Math.max(margin, anchor.y - gap - size.height); + return { left, top }; +} + +export function targetFeedbackEntries( + item: any, + fallback: Record, +): [string, unknown][] { + const value = withoutSemanticInteractionField(item?.tooltip ?? fallback); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value === undefined || value === null ? [] : [['Value', value]]; + } + return Object.entries(value as Record); +} + +export function createTargetFeedbackOverlay(options: { + container: HTMLElement; + feedback: TargetFeedbackOptions; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +}): TargetFeedbackOverlayController { + const { container, feedback, coordinateSpace, containerLayoutSize } = options; + const layer = document.createElement('div'); + const indicator = document.createElement('div'); + const details = document.createElement('div'); + layer.dataset.flintTargetFeedback = ''; + indicator.dataset.flintTargetIndicator = ''; + details.dataset.flintTargetDetails = ''; + details.setAttribute('role', 'status'); + details.setAttribute('aria-live', 'polite'); + Object.assign(layer.style, { + position: 'absolute', inset: '0', zIndex: '4', pointerEvents: 'none', overflow: 'visible', + }); + Object.assign(indicator.style, { + position: 'absolute', width: '14px', height: '14px', border: '2px solid #20262c', + borderRadius: '50%', background: 'rgba(255,255,255,0.72)', boxSizing: 'border-box', + transform: 'translate(-50%, -50%)', boxShadow: '0 0 0 2px rgba(255,255,255,0.78)', + }); + Object.assign(details.style, { + position: 'absolute', zIndex: '1000', padding: '8px', border: '1px solid #d9d9d9', + borderRadius: '3px', background: 'rgba(255,255,255,0.95)', color: '#000', + font: '11px sans-serif', boxShadow: '2px 2px 4px rgba(0,0,0,0.1)', + }); + layer.append(indicator); + + const clear = (): void => { + layer.remove(); + details.remove(); + }; + const render = (item: any, target: SemanticTarget | null, source: 'assisted' | 'keyboard'): void => { + const element = target?.elements[0]; + const point = targetFeedbackPoint(item); + if (!point || !element) { + clear(); + return; + } + document.querySelectorAll('[data-flint-target-feedback], [data-flint-target-details]') + .forEach((node) => { + if (node !== layer && node !== details) node.remove(); + }); + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg, canvas') as HTMLElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + const scaleX = rendererLayout.width / space.logicalWidth; + const scaleY = rendererLayout.height / space.logicalHeight; + const centerX = rendererLayout.left + (point.x + space.originX) * scaleX; + const centerY = rendererLayout.top + (point.y + space.originY) * scaleY; + const clientX = containerRect.left + centerX; + const clientY = containerRect.top + centerY; + indicator.style.display = feedback.indicator === false ? 'none' : 'block'; + indicator.style.left = `${centerX}px`; + indicator.style.top = `${centerY}px`; + indicator.style.borderStyle = source === 'keyboard' ? 'solid' : 'dashed'; + + const detailsOptions = typeof feedback.details === 'object' ? feedback.details : {}; + const showDetails = feedback.details !== false; + details.style.display = showDetails ? 'block' : 'none'; + if (!showDetails) { + details.remove(); + return; + } + const entries = targetFeedbackEntries(item, element.value) + .filter(([field]) => !detailsOptions.fields || detailsOptions.fields.includes(field)) + .slice(0, detailsOptions.maxRows ?? 4); + details.replaceChildren(...entries.map(([field, value]) => { + const row = document.createElement('div'); + const label = document.createElement('span'); + const content = document.createElement('span'); + Object.assign(row.style, { + display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: '4px', alignItems: 'baseline', + padding: '2px 0', + }); + Object.assign(label.style, { color: '#808080', maxWidth: '150px', textAlign: 'right' }); + Object.assign(content.style, { + display: 'block', maxWidth: '300px', maxHeight: '7em', overflow: 'hidden', textOverflow: 'ellipsis', + }); + label.textContent = field; + content.textContent = String(value); + row.append(label, content); + return row; + })); + if (!details.isConnected) document.body.append(details); + const detailsRect = details.getBoundingClientRect(); + const position = targetFeedbackDetailsPosition( + { x: clientX, y: clientY }, + { width: detailsRect.width, height: detailsRect.height }, + { width: window.innerWidth, height: window.innerHeight }, + ); + details.style.left = `${position.left + window.scrollX}px`; + details.style.top = `${position.top + window.scrollY}px`; + }; + + return { render, clear, destroy: clear }; +} diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index c98d9661..41e6942b 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -35,12 +35,11 @@ import type { ChartUpdateApplyOptions } from '../../interactive/types'; import { INTERACTION_KEY, PATH_KEY_SUFFIX, - axisIntersectingHits, + axisTargetIdentity, clientToPlotPoint, clientToRendererPoint, interactionModifiers, normalizeVegaElementEvent, - nearestItemByBounds, nearestInteractiveSceneItem, nearestSceneItem, nextItemInDirection, @@ -66,6 +65,7 @@ import { eligibleReorderAxesForHit, } from './presentation/drag-reorder-overlay'; import { createFocusOverlay } from './presentation/focus-overlay'; +import { createTargetFeedbackOverlay } from './presentation/target-feedback-overlay'; import { createLegendRangeOverlay } from './presentation/legend-range-overlay'; import { createReorderResetControls } from './presentation/reorder-reset-controls'; import { createViewportResetControl } from './presentation/viewport-reset-control'; @@ -77,6 +77,7 @@ import { INTERACTION_STORE, LEGEND_HOVER_STORE, LEGEND_SELECTION_STORE, + STYLE_SIGNAL, } from './stores'; const EMPTY_SEMANTIC_SELECTION_KEY = '__flint_empty_semantic_selection'; @@ -249,6 +250,34 @@ export function interactionsForHoverPresentation( ); } +function keyboardRepresentativeRank(item: any): [number, number] { + const markType = item?.mark?.marktype; + const width = Math.max(0, (item?.bounds?.x2 ?? 0) - (item?.bounds?.x1 ?? 0)); + const height = Math.max(0, (item?.bounds?.y2 ?? 0) - (item?.bounds?.y1 ?? 0)); + const markRank = markType === 'rect' ? 3 : markType === 'arc' ? 2 : markType === 'rule' ? 0 : 1; + return [markRank, width * height]; +} + +export function keyboardTargetItems(scene: readonly any[]): any[] { + const itemsByKey = new Map(); + for (const item of scene) { + const key = renderHit(item)?.datum[INTERACTION_KEY]; + if (typeof key !== 'string' || !item.bounds) continue; + const existing = itemsByKey.get(key); + if (!existing) { + itemsByKey.set(key, item); + continue; + } + const [rank, area] = keyboardRepresentativeRank(item); + const [existingRank, existingArea] = keyboardRepresentativeRank(existing); + if (rank > existingRank || (rank === existingRank && area > existingArea)) { + itemsByKey.set(key, item); + } + } + return [...itemsByKey.values()].sort((left, right) => + (left.bounds.x1 - right.bounds.x1) || (left.bounds.y1 - right.bounds.y1)); +} + export function enrichTargetWithSourceProvenance( target: SemanticTarget | null, plan: Pick interaction.eventSource.gesture === 'hover') : []; + const axisClickInteractions = clickInteractions.filter((interaction) => interaction.claimsAxisActivation); + const markClickInteractions = clickInteractions.filter((interaction) => !interaction.claimsAxisActivation); + const axisHoverInteractions = hoverInteractions.filter((interaction) => interaction.claimsAxisActivation); + const markHoverInteractions = hoverInteractions.filter((interaction) => !interaction.claimsAxisActivation); const contextInteractions = resolve ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'context') : []; @@ -308,8 +345,8 @@ export function mountVegaInteractions( ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'double') : []; const hoverPresentationInteractions = interactionsForHoverPresentation( - clickInteractions, - hoverInteractions, + markClickInteractions, + markHoverInteractions, ); const regionInteraction = resolve ? canvasInteractions.find((interaction) => interaction.eventSource.gesture === 'drag') @@ -367,6 +404,12 @@ export function mountVegaInteractions( }; const focusOverlay = createFocusOverlay({ view, container, plan, coordinateSpace, containerLayoutSize }); + const targetFeedbackOverlay = createTargetFeedbackOverlay({ + container, + feedback: targetFeedback?.assisted || targetFeedback?.keyboard || {}, + coordinateSpace, + containerLayoutSize, + }); const legendRangeOverlay = createLegendRangeOverlay({ container, coordinateSpace, containerLayoutSize }); const annotationOverlay = createAnnotationOverlay({ view, @@ -535,7 +578,7 @@ export function mountVegaInteractions( let resolvedTargets = 0; const ops: ChartUpdateOp[] = []; for (const op of update.ops) { - if (op.op === 'set-presentation') { + if (op.op === 'set-style') { const targets = op.targets.flatMap((target) => { const resolved = resolveUpdateTarget(target); if (!resolved) { @@ -590,6 +633,8 @@ export function mountVegaInteractions( ]; const hiddenLegendDomains = new Map(); const activeHiddenLegendDomains = new Set(); + const stylesByKey: Record> = {}; selectedElements.clear(); hiddenKeys.clear(); let annotation: Extract | undefined; @@ -600,7 +645,7 @@ export function mountVegaInteractions( } for (const update of displayUpdates) { for (const op of update.ops) { - if (op.op === 'set-presentation' && op.value.visible === false) { + if (op.op === 'set-style' && op.value.visible === false) { for (const target of op.targets) { if ('select' in target) continue; for (const element of target.elements) { @@ -624,12 +669,24 @@ export function mountVegaInteractions( } } } - } else if (op.op === 'set-presentation' - && (op.value.state === 'emphasized' || op.value.state === 'focused')) { + } + if (op.op === 'set-style') { for (const target of op.targets) { if ('select' in target) continue; for (const element of target.elements) { - for (const key of semanticElementRenderKeys(element)) selectedElements.set(key, element); + for (const key of semanticElementRenderKeys(element)) { + if (op.value.state === 'emphasized' || op.value.state === 'focused') { + selectedElements.set(key, element); + } + const style = Object.fromEntries( + (['opacity', 'fill', 'stroke', 'strokeWidth'] as const) + .filter((channel) => op.value[channel] !== undefined) + .map((channel) => [channel, op.value[channel]]), + ); + if (Object.keys(style).length > 0) { + stylesByKey[key] = { ...stylesByKey[key], ...style }; + } + } } } } else if (op.op === 'set-annotation') { @@ -649,6 +706,7 @@ export function mountVegaInteractions( if (keys.length === 0) selectedLegend = null; // A navigation-only chart compiles without the selection stores. if (plan.semanticStores !== false) { + view.signal(STYLE_SIGNAL, stylesByKey); view.change( INTERACTION_STORE, changeset().remove(() => true).insert(keys.map((key) => ({ key }))), @@ -799,6 +857,22 @@ export function mountVegaInteractions( resolveContext(availableHits), )); }; + const resolveAxisTarget = (item: any): SemanticTarget | null => { + const identity = axisTargetIdentity(item, plan.axisTargets); + if (!identity) return null; + const hits = allHits().filter((hit) => Object.is(hit.datum[identity.field], identity.value)); + if (hits.length === 0) return null; + const represented = resolveTarget('click', 'axis-tick', hits); + const records = [...new Set(represented?.elements.flatMap((element) => element.records ?? []) ?? [])]; + const keys = [...new Set(represented?.elements.flatMap(semanticElementRenderKeys) ?? [])]; + return { + visual: { kind: 'axis', role: identity.role }, + elements: [associateSemanticElementRenderKeys({ + value: { axis: identity.axis, field: identity.field, value: identity.value }, + ...(records.length > 0 ? { records } : {}), + }, keys)], + }; + }; let hoveredKeys = '\u0001\u0000'; let hoverActive = false; const setHover = async ( @@ -828,6 +902,7 @@ export function mountVegaInteractions( renderLegendRange(); }; const clearHover = (): void => { + targetFeedbackOverlay.clear(); void setHover([]); if (hoverInteractions.length > 0 && hoverActive) { hoverActive = false; @@ -852,7 +927,7 @@ export function mountVegaInteractions( const direct = normalizeVegaElementEvent( view, item, point, phase, modifiers, plan.legendFields, plan.rangeLegendChannels, rootPoint, ); - if (assistDistance <= 0 || direct.legend || direct.event.hits.length > 0) return direct; + if (assistDistance <= 0 || direct.legend || direct.event.hits.length > 0) return { ...direct, feedbackItem: null }; const rawPlotPoint = { x: rootPoint.x - space.originX, y: rootPoint.y - space.originY }; const overPlot = rawPlotPoint.x >= 0 && rawPlotPoint.x <= space.plotWidth && rawPlotPoint.y >= 0 && rawPlotPoint.y <= space.plotHeight; @@ -860,21 +935,34 @@ export function mountVegaInteractions( view, rawPlotPoint, assistDistance, rootPoint, overPlot, ); return snapped - ? normalizeVegaElementEvent( + ? { ...normalizeVegaElementEvent( view, snapped, point, phase, modifiers, plan.legendFields, plan.rangeLegendChannels, rootPoint, - ) - : direct; + ), feedbackItem: snapped } + : { ...direct, feedbackItem: null }; }; const hoverHandler = (event: MouseEvent, item: any): void => { - if (hoverPresentationInteractions.length === 0 || regionDragging) return; + if ((hoverPresentationInteractions.length === 0 && axisHoverInteractions.length === 0) || regionDragging) return; const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const axisTarget = resolveAxisTarget(item); + if (axisTarget) { + hoverActive = true; + container.style.cursor = 'pointer'; + for (const interaction of axisHoverInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'preview', target: axisTarget, point, + modifiers: interactionModifiers(event), + }); + } + void setHover(axisTarget.elements.flatMap(semanticElementRenderKeys)); + return; + } const normalized = acquire(item, point, rootPoint, 'preview', interactionModifiers(event)); const legend = normalized.legend; if (legend) { if (!regionInteraction && !navigationInteraction) container.style.cursor = 'pointer'; const resolved = legendSemanticTarget(legend); hoverActive = true; - for (const interaction of hoverInteractions) { + for (const interaction of markHoverInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, modifiers: normalized.event.modifiers, @@ -890,6 +978,11 @@ export function mountVegaInteractions( } if (!regionInteraction && !navigationInteraction) container.style.cursor = 'pointer'; const resolved = resolveTarget('hover', normalized.role, normalized.event.hits); + if (normalized.feedbackItem && targetFeedback?.assisted) { + targetFeedbackOverlay.render(normalized.feedbackItem, resolved, 'assisted'); + } else { + targetFeedbackOverlay.clear(); + } hoverActive = true; const interactionContext = context(); const presentationElements = hoverPresentationInteractions.flatMap((interaction) => { @@ -898,12 +991,12 @@ export function mountVegaInteractions( type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, modifiers: normalized.event.modifiers, }, interaction.eventSource), interactionContext); - return preview?.ops.flatMap((op) => op.op === 'set-presentation' + return preview?.ops.flatMap((op) => op.op === 'set-style' && (op.value.state === 'emphasized' || op.value.state === 'focused') ? op.targets.flatMap((target) => 'select' in target ? [] : target.elements) : []) ?? []; }); - for (const interaction of hoverInteractions) { + for (const interaction of markHoverInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, modifiers: normalized.event.modifiers, @@ -916,6 +1009,16 @@ export function mountVegaInteractions( const clickHandler = (event: MouseEvent, item: any): void => { if (clickInteractions.length === 0 || suppressClick) return; const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const axisTarget = resolveAxisTarget(item); + if (axisTarget) { + for (const interaction of axisClickInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', target: axisTarget, point, + modifiers: interactionModifiers(event), + }); + } + return; + } const normalized = acquire(item, point, rootPoint, 'commit', interactionModifiers(event)); const { legend } = normalized; const target = legend ? resolvedLegendInteractionTarget( @@ -923,7 +1026,7 @@ export function mountVegaInteractions( resolveTarget('click', 'legend-item', [], legend), ) : resolveTarget('click', normalized.role, normalized.event.hits); - for (const interaction of clickInteractions) { + for (const interaction of markClickInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target, point, modifiers: normalized.event.modifiers, @@ -1072,7 +1175,7 @@ export function mountVegaInteractions( for (const layer of [retainedUpdates, previewUpdates]) { for (const [id, update] of layer) { const ops = update.ops.filter((op) => - op.op !== 'set-presentation' && op.op !== 'set-annotation'); + op.op !== 'set-style' && op.op !== 'set-annotation'); if (ops.length > 0) layer.set(id, { id, ops }); else layer.delete(id); changed = changed || ops.length !== update.ops.length; @@ -1342,7 +1445,7 @@ export function mountVegaInteractions( previewUpdates.set(mountedRegionInteraction.id, { id: mountedRegionInteraction.id, ops: [{ - op: 'set-presentation', + op: 'set-style', targets: next.size > 0 ? [{ visual: { kind: 'region', role: 'selection' }, elements: [...next].map((key) => associateSemanticElementRenderKeys({ value: {} }, [key])), @@ -1382,18 +1485,7 @@ export function mountVegaInteractions( // One tab stop enters the chart; arrows move to the nearest target in that direction. let activeKeyboardKey: string | undefined; - const keyboardTargets = (): any[] => { - const seen = new Set(); - const items: any[] = []; - for (const item of sceneItems(view)) { - const key = renderHit(item)?.datum[INTERACTION_KEY]; - if (typeof key !== 'string' || seen.has(key) || !item.bounds) continue; - seen.add(key); - items.push(item); - } - return items.sort((left, right) => - (left.bounds.x1 - right.bounds.x1) || (left.bounds.y1 - right.bounds.y1)); - }; + const keyboardTargets = (): any[] => keyboardTargetItems(sceneItems(view)); const keyboardFocus = (item: any) => { const hit = renderHit(item); if (!hit) return undefined; @@ -1409,6 +1501,11 @@ export function mountVegaInteractions( const moveKeyboardTarget = (direction: SpatialDirection): void => { const items = keyboardTargets(); if (items.length === 0) return; + const movementAxis = direction === 'left' || direction === 'right' ? 'x' : 'y'; + const movementType = plan.axisFields?.[movementAxis]?.type; + const discreteAxis = movementType === 'nominal' || movementType === 'ordinal' + ? movementAxis + : undefined; const current = activeKeyboardKey === undefined ? undefined : items.find((item) => renderHit(item)?.datum[INTERACTION_KEY] === activeKeyboardKey); @@ -1416,12 +1513,13 @@ export function mountVegaInteractions( ? nextItemInDirection(items, { x: (current.bounds.x1 + current.bounds.x2) / 2, y: (current.bounds.y1 + current.bounds.y2) / 2, - }, direction) + }, direction, discreteAxis) : direction === 'right' || direction === 'down' ? items[0] : items[items.length - 1]; if (!next) return; const active = keyboardFocus(next); if (!active) return; activeKeyboardKey = typeof active.key === 'string' ? active.key : undefined; + if (targetFeedback?.keyboard) targetFeedbackOverlay.render(next, active.target, 'keyboard'); const interaction = clickInteractions[0]; if (interaction) { emitCanvasInteractionEvent(interaction, toCanvasInteractionEvent({ @@ -1469,15 +1567,23 @@ export function mountVegaInteractions( return; case 'Escape': activeKeyboardKey = undefined; + targetFeedbackOverlay.clear(); void setHover([]); return; default: } }; const keyboardEnabled = keyboardTargeting && clickInteractions.length > 0; + const keyboardFocusOut = (event: FocusEvent): void => { + if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return; + activeKeyboardKey = undefined; + targetFeedbackOverlay.clear(); + void setHover([]); + }; if (keyboardEnabled) { container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; container.addEventListener('keydown', keyboardKeyDown); + container.addEventListener('focusout', keyboardFocusOut); } // Overlays project scenegraph geometry into screen pixels, so every one of @@ -1529,7 +1635,10 @@ export function mountVegaInteractions( if (dismissPolicy.escape && !regionInteraction) { container.removeEventListener('keydown', dismissKeyDown); } - if (keyboardEnabled) container.removeEventListener('keydown', keyboardKeyDown); + if (keyboardEnabled) { + container.removeEventListener('keydown', keyboardKeyDown); + container.removeEventListener('focusout', keyboardFocusOut); + } if (contextInteractions.length > 0) container.removeEventListener('contextmenu', contextHandler); if (longPressInteractions.length > 0) { cancelLongPress(); @@ -1558,6 +1667,7 @@ export function mountVegaInteractions( container.removeEventListener('pointercancel', elementDragCancel, true); } focusOverlay.destroy(); + targetFeedbackOverlay.destroy(); legendRangeOverlay.destroy(); annotationOverlay.destroy(); inspectGuideOverlay.destroy(); diff --git a/packages/flint-js/src/vegalite/interactions/stores.ts b/packages/flint-js/src/vegalite/interactions/stores.ts index a807ded3..3af053c2 100644 --- a/packages/flint-js/src/vegalite/interactions/stores.ts +++ b/packages/flint-js/src/vegalite/interactions/stores.ts @@ -4,6 +4,7 @@ export const HIDDEN_STORE = '__flint_hidden_store'; export const LEGEND_HIDDEN_STORE = '__flint_legend_hidden_store'; export const LEGEND_HOVER_STORE = '__flint_legend_hover_store'; export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; +export const STYLE_SIGNAL = '__flint_style_by_key'; export const INTERACTION_STORES: readonly string[] = [ INTERACTION_STORE, diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index d4ade00c..0d0e39ec 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -1,10 +1,11 @@ import { applyCategoryViewports } from '../core/filter-overflow'; import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; import { isCanvasInteraction, type InteractionDef } from '../interactive/interactions'; -import type { InteractionDismissPolicy, InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import type { InteractionDismissPolicy, InteractiveRendererAdapter, TargetFeedbackOptions, ViewportState } from '../interactive/types'; import { assembleVegaLite } from './assemble'; import { addVegaLiteInteractions, + collectVegaAxisTargets, injectVegaInteractionStore, injectVegaNavigationSignals, injectVegaReorderSignal, @@ -24,6 +25,7 @@ export interface VegaInteractiveRendererOptions { background?: string; assistDistance?: number; keyboardTargeting?: boolean; + targetFeedback?: { assisted: TargetFeedbackOptions | false; keyboard: TargetFeedbackOptions | false }; dismiss?: InteractionDismissPolicy | false; } @@ -76,6 +78,7 @@ export function createVegaInteractiveRenderer( ); const vegaSpec = compile(vlSpec).spec as any; if (interactionPlan) { + interactionPlan.axisTargets = collectVegaAxisTargets(vegaSpec, interactionPlan.axisFields); if (interactionPlan.semanticStores) { injectVegaInteractionStore(vegaSpec, interactionPlan); } @@ -119,6 +122,7 @@ export function createVegaInteractiveRenderer( interactionPlan.presentUpdate ?? ((update) => update), options.assistDistance ?? 0, options.keyboardTargeting ?? false, + options.targetFeedback, options.dismiss, ) : undefined; diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index 8ae97c94..8106b63d 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, doubleActivate, dragReorder, externalInteraction, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, doubleActivate, dragReorder, externalInteraction, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; import { toCanvasInteractionEvent } from '../src/interactive/canvas-interaction'; @@ -55,6 +55,7 @@ import { import { interactionsForHoverPresentation, domainForPlotGeometry, + keyboardTargetItems, nearestReorderHit, resolveSupportedOperation, } from '../src/vegalite/interactions/runtime'; @@ -65,6 +66,11 @@ import { reorderOwnedItems, } from '../src/vegalite/interactions/presentation/drag-reorder-overlay'; import { hoverContrastOpacity } from '../src/vegalite/interactions/presentation/focus-overlay'; +import { + targetFeedbackDetailsPosition, + targetFeedbackEntries, + targetFeedbackPoint, +} from '../src/vegalite/interactions/presentation/target-feedback-overlay'; import { inspectGuideLine } from '../src/vegalite/interactions/presentation/inspect-guide-overlay'; import { annotationFacingEdges, @@ -84,6 +90,7 @@ import { valueEndConnectionPoint, valueSideConnectionPoint, } from '../src/vegalite/interactions/presentation/annotation-overlay'; +import { withoutSemanticInteractionField } from '../src/vegalite/interactions/compile'; import { histogramDef } from '../src/vegalite/templates/bar'; import { areaChartDef } from '../src/vegalite/templates/area'; import { candlestickChartDef } from '../src/vegalite/templates/candlestick'; @@ -374,7 +381,7 @@ describe('public chart updates', () => { it('uses direct declarative operation JSON', () => { const ops: ChartUpdateOp[] = [{ - op: 'set-presentation', + op: 'set-style', targets: [target, { select: { key: { Country: 'Japan' } } }], value: { state: 'emphasized', mutedOpacity: 0.25 }, }, { @@ -385,7 +392,7 @@ describe('public chart updates', () => { op: 'set-order', scope: 'category', field: 'Country', values: ['Japan'], }]; expect(ops).toEqual([{ - op: 'set-presentation', + op: 'set-style', targets: [target, { select: { key: { Country: 'Japan' } } }], value: { state: 'emphasized', mutedOpacity: 0.25 }, }, { @@ -746,7 +753,7 @@ describe('interaction definitions', () => { }, context)).toEqual({ id: 'click-highlight', ops: [{ - op: 'set-presentation', targets: [target], + op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }], }); @@ -755,7 +762,7 @@ describe('interaction definitions', () => { }, context)).toEqual({ id: 'select', ops: [{ - op: 'set-presentation', targets: [target], + op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }], }); @@ -763,6 +770,10 @@ describe('interaction definitions', () => { it('creates preset definitions with stable defaults', () => { expect(clickHighlight()).toMatchObject({ id: 'click-highlight', eventSource: clickTrigger }); + expect(axisHighlight()).toMatchObject({ + id: 'axis-highlight', eventSource: clickTrigger, claimsAxisActivation: true, + }); + expect(axisHighlight({ event: 'hover' }).eventSource).toBe(hoverTrigger); expect(clickGroupHighlight()).toMatchObject({ id: 'click-group-highlight', eventSource: clickTrigger }); expect(clickAnnotate()).toMatchObject({ id: 'click-annotate', eventSource: clickTrigger }); expect(select()).toMatchObject({ @@ -790,7 +801,7 @@ describe('interaction definitions', () => { expect(handleSemanticEvent(brushX(), { ...event, axis: 'x' }, context)).toEqual({ id: 'brush-x', ops: [{ - op: 'set-presentation', targets: [target], + op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }], }); @@ -798,7 +809,7 @@ describe('interaction definitions', () => { expect(handleSemanticEvent(brushX(), { ...event, axis: 'angle' }, context)).toEqual({ id: 'brush-x', ops: [{ - op: 'set-presentation', targets: [target], + op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }], }); @@ -806,12 +817,12 @@ describe('interaction definitions', () => { ...event, axis: 'angle', phase: 'commit', operation: 'clear', target: null, }, context)).toEqual({ id: 'brush-x', - ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }], }); expect(handleSemanticEvent(brushAngle(), { ...event, axis: 'angle' }, context)).toEqual({ id: 'brush-angle', ops: [{ - op: 'set-presentation', targets: [target], + op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }], }); @@ -866,7 +877,7 @@ describe('interaction definitions', () => { expect(semanticUpdate(interaction, null, context, { source: 'region' })) .toEqual({ id: 'select', - ops: [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }], }); }); @@ -896,10 +907,10 @@ describe('interaction definitions', () => { }); expect(replace?.ops[0]).toMatchObject({ - op: 'set-presentation', value: { state: 'emphasized', mutedOpacity: 0.2 }, + op: 'set-style', value: { state: 'emphasized', mutedOpacity: 0.2 }, }); expect(toggle?.ops[0]).toMatchObject({ - op: 'set-presentation', value: { state: 'emphasized' }, + op: 'set-style', value: { state: 'emphasized' }, }); }); @@ -1144,7 +1155,7 @@ describe('interaction definitions', () => { value: {}, }, { - op: 'set-presentation', targets: [target], + op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }, ], @@ -1153,7 +1164,7 @@ describe('interaction definitions', () => { id: 'click-annotate', ops: [ { op: 'set-annotation', target: { select: { key: {} } }, value: null }, - { op: 'set-presentation', targets: [], value: { state: 'normal' } }, + { op: 'set-style', targets: [], value: { state: 'normal' } }, ], }); }); @@ -2011,7 +2022,7 @@ describe('assisted, keyboard, and lasso acquisition', () => { }, interaction.eventSource), context); expect(update?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ elements: target.elements }], }); expect(interaction.handle!(toCanvasInteractionEvent({ @@ -2033,7 +2044,7 @@ describe('assisted, keyboard, and lasso acquisition', () => { }; expect(clickHighlight().handle!(activation, context)?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', }); expect(clickAnnotate().handle!(activation, context)?.ops[0]).toMatchObject({ op: 'set-annotation', @@ -2070,6 +2081,33 @@ describe('keyboard spatial navigation', () => { expect(keyOf(nextItemInDirection(items, from, 'right'))).toBe('aligned'); }); + it('follows the next discrete row even when bar lengths differ', () => { + const bars = [ + { ...at('long', 100, 10), bounds: { x1: 0, y1: 7, x2: 200, y2: 13 } }, + { ...at('short', 25, 30), bounds: { x1: 0, y1: 27, x2: 50, y2: 33 } }, + { ...at('aligned-later', 100, 50), bounds: { x1: 0, y1: 47, x2: 200, y2: 53 } }, + ]; + + expect(keyOf(nextItemInDirection(bars, { x: 100, y: 10 }, 'down', 'y'))).toBe('short'); + }); + + it('uses the box body as the single target for a composite boxplot', () => { + const datum = { [INTERACTION_KEY]: 'Adele' }; + const component = (marktype: string, bounds: Record) => ({ + mark: { marktype }, datum, bounds, + }); + const box = component('rect', { x1: 3, y1: 80, x2: 17, y2: 115 }); + const targets = keyboardTargetItems([ + component('rule', { x1: 9, y1: 64, x2: 11, y2: 121 }), + box, + component('rect', { x1: 3, y1: 109.5, x2: 17, y2: 110.5 }), + component('symbol', { x1: 7, y1: 52, x2: 13, y2: 58 }), + ]); + + expect(targets).toEqual([box]); + expect(targetFeedbackPoint(targets[0])).toEqual({ x: 10, y: 97.5 }); + }); + it('stops at the edge instead of wrapping around', () => { expect(nextItemInDirection(grid, { x: 90, y: 50 }, 'right')).toBeUndefined(); expect(nextItemInDirection(grid, { x: 10, y: 50 }, 'left')).toBeUndefined(); @@ -2118,6 +2156,54 @@ describe('lasso capture semantics', () => { }); describe('legend, inspect, zoom, and touch presets', () => { + it('removes renderer and template-derived fields from tooltip values', () => { + expect(withoutSemanticInteractionField({ + Country: 'United States', + 'GDP ($T)': 27.4, + __flint_interaction_key: 'mark:0', + __bt_sort: 0, + __bt_others: false, + _vgsid_: 3, + })).toEqual({ Country: 'United States', 'GDP ($T)': 27.4 }); + }); + + it('uses the compiled hover tooltip fields for keyboard details', () => { + expect(targetFeedbackEntries({ + tooltip: { Country: 'Norway', Share: 98.6 }, + }, { + Country: 'Norway', Share: 98.6, start: 0, end: 98.6, + })).toEqual([['Country', 'Norway'], ['Share', 98.6]]); + }); + + it('centers target feedback within an arc wedge', () => { + expect(targetFeedbackPoint({ + mark: { marktype: 'arc' }, + x: 100, + y: 100, + innerRadius: 20, + outerRadius: 80, + startAngle: 0, + endAngle: Math.PI / 2, + bounds: { x1: 100, y1: 20, x2: 180, y2: 100 }, + })).toEqual({ + x: 100 + 50 * Math.sin(Math.PI / 4), + y: 100 - 50 * Math.cos(Math.PI / 4), + }); + }); + + it('places target details away from the target and flips at viewport edges', () => { + expect(targetFeedbackDetailsPosition( + { x: 100, y: 80 }, + { width: 120, height: 50 }, + { width: 400, height: 300 }, + )).toEqual({ left: 114, top: 94 }); + expect(targetFeedbackDetailsPosition( + { x: 390, y: 290 }, + { width: 120, height: 50 }, + { width: 400, height: 300 }, + )).toEqual({ left: 256, top: 226 }); + }); + const context = { chartType: 'Line Chart', selected: [] }; const seriesTarget = (name: string) => ({ visual: { kind: 'legend' as const, role: 'legend-item' }, @@ -2135,7 +2221,7 @@ describe('legend, inspect, zoom, and touch presets', () => { const interaction = legendToggle(); expect(activate(interaction, seriesTarget('A'))?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ elements: [{ value: { channel: 'color', field: 'Series', value: 'A' } }] }], value: { visible: false, mutedOpacity: 0.25 }, }); @@ -2265,17 +2351,17 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(event).toMatchObject({ action: 'click-legend', target }); expect(activate(clickHighlight(), target)?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], value: { state: 'emphasized' }, }); expect(activate(clickGroupHighlight(), target)?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], value: { state: 'emphasized' }, }); expect(activate(legendToggle(), target)?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], value: { visible: false }, }); @@ -2445,14 +2531,14 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(longPress().handle!(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'commit', target, }, longPressTrigger()), context)?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], value: { state: 'emphasized' }, }); expect(doubleActivate().handle!(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'commit', target, }, doubleActivateTrigger), context)?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], value: { state: 'emphasized' }, }); diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index aefcd89d..4a249c09 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { changeset, parse, View } from 'vega'; import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src/vegalite/assemble'; -import { brushAngle, brushX, brushZoom, clickAnnotate, clickHighlight, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushZoom, clickAnnotate, clickHighlight, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; import type { RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; import { associateSemanticElementRenderKeys, @@ -27,6 +27,7 @@ import { roseChartDef } from '../src/vegalite/templates/rose'; import { rangedDotPlotDef, scatterPlotDef } from '../src/vegalite/templates/scatter'; import { addVegaLiteInteractions, + collectVegaAxisTargets, injectVegaInteractionStore, injectVegaNavigationSignals, injectVegaReorderSignal, @@ -34,6 +35,7 @@ import { import { angularSectorPath } from '../src/interactive/geometry/angular'; import { arcIntersectsAngularSector, + axisTargetIdentity, arcIntersectsRect, boundsIntersectRect, clientRectToLayoutRect, @@ -65,6 +67,7 @@ import { LEGEND_HIDDEN_STORE, LEGEND_HOVER_STORE, LEGEND_SELECTION_STORE, + STYLE_SIGNAL, } from '../src/vegalite/interactions/stores'; import { mergeContiguousSelectionBounds, @@ -559,7 +562,7 @@ describe('Vega-Lite semantic interactions', () => { handle: ({ category }) => ({ id: 'category-picker', ops: [{ - op: 'set-presentation', + op: 'set-style', targets: [{ select: { key: { category } } }], value: { state: 'emphasized' }, }], @@ -2631,7 +2634,7 @@ describe('Vega-Lite semantic interactions', () => { } as any, { available: [], selected: [] } as any); expect(update?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [{ visual: { kind: 'legend', role: 'legend-item' }, elements: target?.elements, @@ -2650,7 +2653,7 @@ describe('Vega-Lite semantic interactions', () => { }, } as any, { available: [], selected: [] } as any); expect(restored?.ops[0]).toMatchObject({ - op: 'set-presentation', + op: 'set-style', targets: [], value: { visible: false }, }); @@ -3459,7 +3462,68 @@ describe('Vega-Lite semantic interactions', () => { view.finalize(); }); }); -describe('set-presentation visibility', () => { +describe('set-style visibility', () => { + it('injects absolute runtime style channels keyed by semantic identity', () => { + const spec: Record = { + _interactionSemantics: { fields: ['Category'], selectableMarks: ['bar'] }, + data: { values: [{ Category: 'A', Value: 1 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { value: '#4472c4' }, + }, + }; + const { compiled } = instrument(spec); + expect(compiled.signals).toContainEqual({ name: STYLE_SIGNAL, value: {} }); + expect(JSON.stringify(compiled.marks)).toContain(`${STYLE_SIGNAL}[datum.${INTERACTION_KEY}]`); + }); + + it('maps compiled axis scales to authored discrete fields and resolves native ticks', async () => { + const compiled = compile({ + data: { values: [{ Category: 'A', Value: 1 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }).spec as Record; + const targets = collectVegaAxisTargets(compiled, { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const labels = allSceneItems(view).filter((item) => item.mark?.role === 'axis-label'); + const categoryLabel = labels.find((item) => item.datum?.value === 'A'); + const quantityLabel = labels.find((item) => item.datum?.value === 1); + expect(axisTargetIdentity(categoryLabel, targets)).toMatchObject({ + axis: 'x', field: 'Category', value: 'A', role: 'axis-label', + }); + expect(axisTargetIdentity(quantityLabel, targets)).toBeNull(); + expect(JSON.stringify(compiled.axes)).toContain('"interactive":true'); + view.finalize(); + }); + + it('turns an axis target into a style update without accepting mark targets', () => { + const interaction = axisHighlight({ axis: 'x', dimOpacity: 0.2 }); + const context = { chartType: 'Bar Chart', selected: [] }; + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + const update = interaction.handle!({ + action: 'click-axis', phase: 'commit', geometry: {}, target: axisTarget, + }, context); + expect(update?.ops[0]).toMatchObject({ + op: 'set-style', targets: [axisTarget], value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(interaction.handle!({ + action: 'click-element', phase: 'commit', geometry: {}, + target: { visual: { kind: 'mark', role: 'bar' }, elements: [] }, + }, context)).toBeNull(); + }); + it('pins the legend domain so a hidden series keeps a key to click', () => { const spec: Record = { _interactionSemantics: { diff --git a/site/src/playground/AnnotationLab.tsx b/site/src/playground/AnnotationLab.tsx index 272d6886..d0b9643e 100644 --- a/site/src/playground/AnnotationLab.tsx +++ b/site/src/playground/AnnotationLab.tsx @@ -193,7 +193,7 @@ async function waitForStableChartLayout(container: HTMLElement): Promise { ops: [ { op: 'set-annotation', target, value: { text: fixture.text } }, { - op: 'set-presentation', + op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, }, diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index ec78e6d6..ac010670 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -9,6 +9,7 @@ import { } from 'flint-chart/test-data'; import { buildInteractiveChart, + axisHighlight, brushX, brushY, brushZoom, @@ -38,7 +39,7 @@ export type InteractionMode = 'element' | 'group' | 'annotate' | 'select' | 'brush-x' | 'brush-y' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate' | 'drag-reorder' | 'lasso' | 'assisted' | 'keyboard' | 'select-comment' | 'legend-toggle' | 'inspect' | 'inspect-quadrant' | 'inspect-x' - | 'brush-zoom' | 'long-press' | 'double-activate'; + | 'brush-zoom' | 'long-press' | 'double-activate' | 'axis-highlight'; type ProbeStatus = 'loading' | 'ready' | 'unsupported' | 'error'; export interface NavigationGuard { @@ -62,6 +63,7 @@ const interactionModes = [ { value: 'select-comment', label: 'Select & comment', icon: MessageSquarePlus }, { value: 'assisted', label: 'Assisted click', icon: Crosshair }, { value: 'keyboard', label: 'Keyboard', icon: Keyboard }, + { value: 'axis-highlight', label: 'Axis highlight', icon: Ruler }, { value: 'legend-toggle', label: 'Legend toggle', icon: EyeOff }, { value: 'inspect', label: 'Inspect xy', icon: Target }, { value: 'inspect-quadrant', label: 'Inspect quadrant', icon: Crosshair }, @@ -84,6 +86,7 @@ function modeInteractions( ): MountedInteraction[] { switch (mode) { case 'element': return [clickHighlight()]; + case 'axis-highlight': return [axisHighlight()]; case 'group': return [clickGroupHighlight()]; case 'annotate': return [clickAnnotate()]; case 'select': return [rectangleSelect()]; @@ -750,6 +753,8 @@ export function ClickFocusLab() {
  • Y brush: Drag vertically to focus marks across a Y interval.
  • Stateful brush: Move the committed interval, resize either edge, or click outside to clear it.
  • Pan & zoom: Drag continuous axes to pan; use the wheel or trackpad to zoom.
  • +
  • Assisted and keyboard: Move to a target to see a shared indicator and compact semantic details.
  • +
  • Axis highlight: Click a categorical tick or label to focus the marks it represents.
  • {visibleCases.length} test cases
    diff --git a/site/src/playground/ExternalToChartLab.tsx b/site/src/playground/ExternalToChartLab.tsx index 979e59b9..abdfc98a 100644 --- a/site/src/playground/ExternalToChartLab.tsx +++ b/site/src/playground/ExternalToChartLab.tsx @@ -226,11 +226,11 @@ function ExternalDemoRow({ demo }: { demo: ExternalDemo }) { id: interactionId, ops: payload.match ? [{ - op: 'set-presentation', + op: 'set-style', targets: [{ select: { key: selectorKey(payload.match) } }], value: { state: 'emphasized', mutedOpacity: 0.25 }, }] - : [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + : [{ op: 'set-style', targets: [], value: { state: 'normal' } }], }), })], [interactionId]); const handleSurface = useCallback((surface: InteractiveChartSurface | null) => { diff --git a/site/src/playground/InteractionArchitectureIllustration.tsx b/site/src/playground/InteractionArchitectureIllustration.tsx index ef4b4d0b..f057e9bf 100644 --- a/site/src/playground/InteractionArchitectureIllustration.tsx +++ b/site/src/playground/InteractionArchitectureIllustration.tsx @@ -233,7 +233,7 @@ export function InteractionArchitectureIllustration() { title="Chart update spec" lines={[ { text: '- Update target' }, - { text: '- Ops (set-presentation,' }, + { text: '- Ops (set-style,' }, { text: 'set-annotation, set-viewport,', indent: true }, { text: 'set-order)', indent: true }, ]} diff --git a/site/src/playground/InteractionDashboardLab.tsx b/site/src/playground/InteractionDashboardLab.tsx index fa91c056..ac2759a4 100644 --- a/site/src/playground/InteractionDashboardLab.tsx +++ b/site/src/playground/InteractionDashboardLab.tsx @@ -224,11 +224,11 @@ function DashboardPanel({ id: DASHBOARD_SELECTION_ID, ops: targets.length > 0 ? [{ - op: 'set-presentation', + op: 'set-style', targets, value: { state: 'emphasized', mutedOpacity: 0.22 }, }] - : [{ op: 'set-presentation', targets: [], value: { state: 'normal' } }], + : [{ op: 'set-style', targets: [], value: { state: 'normal' } }], }; }, }), From 54083fcf99ee73f9e56a40fa36ecebf01817f85c Mon Sep 17 00:00:00 2001 From: Aaron Chen <122733139+chen1plus@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:33:19 +0800 Subject: [PATCH 22/33] Rename `flint` to `flint-chart` and add documents --- package-lock.json | 2 +- packages/flint-mcp/README.md | 15 +++++++++++++++ packages/flint-mcp/package.json | 2 +- packages/flint-mcp/src/cli.ts | 4 ++-- packages/flint-mcp/src/compile.ts | 18 +++++++++--------- .../flint-mcp/src/{flint.ts => flint-chart.ts} | 2 +- packages/flint-mcp/tests/compile.test.ts | 2 +- packages/flint-mcp/tsup.config.ts | 2 +- 8 files changed, 31 insertions(+), 16 deletions(-) rename packages/flint-mcp/src/{flint.ts => flint-chart.ts} (92%) diff --git a/package-lock.json b/package-lock.json index dba7e640..5a4e36e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10340,7 +10340,7 @@ "zod": "^3.25.1" }, "bin": { - "flint": "dist/flint.js", + "flint-chart": "dist/flint-chart.js", "flint-chart-mcp": "dist/cli.js" }, "devDependencies": { diff --git a/packages/flint-mcp/README.md b/packages/flint-mcp/README.md index ae31bcde..4e1ef0f8 100644 --- a/packages/flint-mcp/README.md +++ b/packages/flint-mcp/README.md @@ -176,6 +176,21 @@ deployment, reject local file references and accept only inline rows: npx -y flint-chart-mcp --disable-file-reference ``` +### Local file compile (`flint-chart`) + +Compile a saved `ChartAssemblyInput` JSON to SVG or PNG without an agent: + +```bash +flint-chart compile chart.json --format svg +flint-chart compile chart.json --backend echarts --format png --output chart.png +cat chart.json | flint-chart compile - --format svg > chart.svg +flint-chart chart.json --format svg --output chart.svg # shorthand, compile is optional +``` + +Options: `--backend ` (default `vegalite`), `--format ` (default `svg` except `chartjs` → `png`), `--output ` / `-o ` (`-` for stdout; default `.` next to input, stdout when input is `-`), `--scale <0.5–4>`, `--background `, `-h/--help`, `-v/--version`. + +Relative `data.url` paths in the input resolve against the input file's directory, or the current working directory when reading from stdin (`-`). + ## Example `render_chart` call ```jsonc diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index 1102cecb..06556d16 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -29,7 +29,7 @@ "type": "module", "bin": { "flint-chart-mcp": "dist/cli.js", - "flint": "dist/flint.js" + "flint-chart": "dist/flint-chart.js" }, "main": "./dist/server.js", "types": "./dist/server.d.ts", diff --git a/packages/flint-mcp/src/cli.ts b/packages/flint-mcp/src/cli.ts index 8562c1ae..b773ba64 100644 --- a/packages/flint-mcp/src/cli.ts +++ b/packages/flint-mcp/src/cli.ts @@ -57,8 +57,8 @@ Example MCP client config: { "command": "npx", "args": ["-y", "flint-chart-mcp"] } Local file compile (no agent needed): - flint compile chart.json --format svg (via the separate "flint" binary) - See "flint --help" for compile options. + flint-chart compile chart.json --format svg (via the separate "flint-chart" binary) + See "flint-chart --help" for compile options. `; // keep runCompile re-export for existing tests importing from cli.js diff --git a/packages/flint-mcp/src/compile.ts b/packages/flint-mcp/src/compile.ts index 413141bb..f1a965ee 100644 --- a/packages/flint-mcp/src/compile.ts +++ b/packages/flint-mcp/src/compile.ts @@ -8,13 +8,13 @@ import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; import { renderChart } from './render/index.js'; import type { RenderBackend, RenderFormat } from './render/types.js'; -const COMPILE_HELP = `flint ${VERSION} +const COMPILE_HELP = `flint-chart ${VERSION} Compile a saved Flint ChartAssemblyInput JSON to SVG or PNG, entirely in-process. Usage: - flint compile [options] - flint [options] (shorthand, same as compile) + flint-chart compile [options] + flint-chart [options] (shorthand, same as compile) Arguments: Path to JSON file containing ChartAssemblyInput, or "-" for stdin. @@ -35,10 +35,10 @@ Note: directory, or against the current working directory when reading from stdin. Examples: - flint compile chart.json --format svg - flint compile chart.json --backend echarts --format png --output chart.png - cat chart.json | flint compile - --format svg > chart.svg - flint chart.json --format svg --output chart.svg + flint-chart compile chart.json --format svg + flint-chart compile chart.json --backend echarts --format png --output chart.png + cat chart.json | flint-chart compile - --format svg > chart.svg + flint-chart chart.json --format svg --output chart.svg `; interface CompileOptions { @@ -108,14 +108,14 @@ function parseCompileArgs(argv: string[]): CompileParseResult { if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); input = arg; } else if (arg.startsWith('-')) { - throw new CompileError(`Unknown compile option: ${arg}\nRun "flint --help" for usage.`); + throw new CompileError(`Unknown compile option: ${arg}\nRun "flint-chart --help" for usage.`); } else { if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); input = arg; } } - if (!input) throw new CompileError('Missing argument.\nRun "flint --help" for usage.'); + if (!input) throw new CompileError('Missing argument.\nRun "flint-chart --help" for usage.'); const resolvedBackend = (backend ?? 'vegalite') as RenderBackend; if (!SUPPORTED_BACKENDS.includes(resolvedBackend as SupportedBackend)) { diff --git a/packages/flint-mcp/src/flint.ts b/packages/flint-mcp/src/flint-chart.ts similarity index 92% rename from packages/flint-mcp/src/flint.ts rename to packages/flint-mcp/src/flint-chart.ts index f62b39ee..d4eada09 100644 --- a/packages/flint-mcp/src/flint.ts +++ b/packages/flint-mcp/src/flint-chart.ts @@ -26,7 +26,7 @@ const isEntry = process.argv[1] !== undefined && (() => { if (isEntry) { main().catch((err) => { - process.stderr.write(`flint failed: ${err?.stack ?? err}\n`); + process.stderr.write(`flint-chart failed: ${err?.stack ?? err}\n`); process.exit(1); }); } diff --git a/packages/flint-mcp/tests/compile.test.ts b/packages/flint-mcp/tests/compile.test.ts index a7935e15..d351f2c7 100644 --- a/packages/flint-mcp/tests/compile.test.ts +++ b/packages/flint-mcp/tests/compile.test.ts @@ -72,7 +72,7 @@ describe('compile: argument parsing', () => { for (const flag of ['--help', '-h']) { const harness = makeIo(); expect(await runCompile([flag], harness.io)).toBe(0); - expect(harness.stdoutText()).toContain('flint compile'); + expect(harness.stdoutText()).toContain('flint-chart compile'); } }); diff --git a/packages/flint-mcp/tsup.config.ts b/packages/flint-mcp/tsup.config.ts index 04316ebc..641ac5ae 100644 --- a/packages/flint-mcp/tsup.config.ts +++ b/packages/flint-mcp/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: { cli: 'src/cli.ts', - flint: 'src/flint.ts', + 'flint-chart': 'src/flint-chart.ts', server: 'src/server.ts', 'render/index': 'src/render/index.ts', }, From bd11407292bd446877e5fd15ac51c8376267f475 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Tue, 1 Sep 2026 18:56:23 -0700 Subject: [PATCH 23/33] updates --- CHANGELOG.md | 5 + .../src/core/interaction-semantics.ts | 2 +- packages/flint-js/src/interactive/README.md | 73 ++- .../flint-js/src/interactive/affordances.ts | 52 ++ .../interactive/gestures/cartesian-region.ts | 23 +- .../src/interactive/gestures/navigation.ts | 30 ++ packages/flint-js/src/interactive/index.ts | 26 +- .../flint-js/src/interactive/interactions.ts | 117 ++++- .../interactive/presentation/annotation.ts | 27 +- .../src/interactive/presets/angular-brush.ts | 1 + .../src/interactive/presets/axis-highlight.ts | 5 + .../src/interactive/presets/brush-zoom.ts | 1 + .../flint-js/src/interactive/presets/brush.ts | 1 + .../src/interactive/presets/click-annotate.ts | 1 + .../presets/click-group-highlight.ts | 25 +- .../interactive/presets/click-highlight.ts | 8 + .../interactive/presets/context-activate.ts | 1 + .../src/interactive/presets/drag-reorder.ts | 20 +- .../interactive/presets/facet-brush-link.ts | 27 ++ .../presets/hover-group-highlight.ts | 31 ++ .../flint-js/src/interactive/presets/index.ts | 3 + .../src/interactive/presets/inspect.ts | 1 + .../src/interactive/presets/label-isolate.ts | 44 ++ .../src/interactive/presets/lasso-select.ts | 1 + .../src/interactive/presets/legend-toggle.ts | 1 + .../src/interactive/presets/long-press.ts | 2 + .../src/interactive/presets/navigate.ts | 1 + .../src/interactive/presets/select.ts | 1 + .../interactive/presets/semantic-cohort.ts | 32 ++ packages/flint-js/src/interactive/triggers.ts | 2 + .../src/vegalite/interactions/compile.ts | 25 +- .../interactions/gestures/navigation.ts | 94 +++- .../vegalite/interactions/gestures/region.ts | 52 +- .../src/vegalite/interactions/hit-adapter.ts | 65 ++- .../presentation/annotation-overlay.ts | 34 +- .../presentation/drag-reorder-overlay.ts | 21 +- .../presentation/focus-overlay.ts | 19 +- .../src/vegalite/interactions/runtime.ts | 292 ++++++++--- packages/flint-js/src/vegalite/interactive.ts | 8 +- .../src/vegalite/templates/calendar.ts | 25 +- .../src/vegalite/templates/scatter.ts | 13 +- .../src/vegalite/templates/sparkline.ts | 12 +- packages/flint-js/tests/interactions.test.ts | 454 +++++++++++++++++- .../tests/semantic-interactions.test.ts | 226 ++++++++- site/src/playground/ChartToExternalLab.tsx | 4 +- site/src/playground/ClickFocusLab.tsx | 263 +++++++--- .../playground/InteractionDashboardLab.tsx | 8 +- site/src/playground/click-focus-lab.css | 50 +- 48 files changed, 1971 insertions(+), 258 deletions(-) create mode 100644 packages/flint-js/src/interactive/affordances.ts create mode 100644 packages/flint-js/src/interactive/presets/facet-brush-link.ts create mode 100644 packages/flint-js/src/interactive/presets/hover-group-highlight.ts create mode 100644 packages/flint-js/src/interactive/presets/label-isolate.ts create mode 100644 packages/flint-js/src/interactive/presets/semantic-cohort.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4919a6f2..40c7ecc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Keyboard targeting now navigates and emits `focus-element` through the + `keyboard-targeting` interaction ID without requiring a click preset. Enter + and Space still invoke configured click presets when present. +- Independently retained `set-annotation` updates now render together by update + and semantic target identity; clearing one update leaves the others visible. - ECharts categorical legends (and their title graphics) are pinned with `legend.right` instead of a design-canvas `left` pixel. Hosts that size the container independently of `_width` and call `chart.resize()` keep the diff --git a/packages/flint-js/src/core/interaction-semantics.ts b/packages/flint-js/src/core/interaction-semantics.ts index e33283c3..c4f717f5 100644 --- a/packages/flint-js/src/core/interaction-semantics.ts +++ b/packages/flint-js/src/core/interaction-semantics.ts @@ -162,7 +162,7 @@ export function legendMatchedHits( : []; return pathData.length > 0 ? [ - ...(hit.markType === 'line' ? [{ ...hit, source: 'legend-item' as const }] : []), + { ...hit, source: 'legend-item' as const }, ...pathData.map((datum) => ({ ...hit, datum, source: 'legend-item' as const })), ] : [{ ...hit, source: 'legend-item' as const }]; diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index e92c5e1d..0664b0c2 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -520,7 +520,7 @@ navigationTrigger() ### Cartesian navigation -`navigate()` combines drag pan, wheel zoom, and reset as one viewport handler. ChartDefs opt in explicitly with `navigation.axes`; assembly then intersects that capability with resolved quantitative or temporal x/y encodings. An explicitly requested unsupported axis is an error. With `axes: 'available'`, categorical axes are omitted automatically. +`navigate()` combines drag pan, wheel or two-finger pinch zoom, and reset as one viewport handler. Pinch zoom is anchored at the moving midpoint between the two touches. ChartDefs opt in explicitly with `navigation.axes`; assembly then intersects that capability with resolved quantitative or temporal x/y encodings. An explicitly requested unsupported axis is an error. With `axes: 'available'`, categorical axes are omitted automatically. The gesture reports incremental pan deltas and zoom anchors as plot fractions. The renderer reduces them to absolute `set-viewport` domains using percentage-based guards: @@ -665,7 +665,7 @@ A custom source may register listeners and emit normalized events. Renderer-spec ### Target feedback -Assisted pointer and keyboard targeting share a transient target indicator and a floating semantic tooltip. Keyboard arrows move the indicator and apply the active hover styling; Enter or Space invokes the configured click preset. The tooltip uses the compiled pointer-hover fields, stays clear of the active mark, may extend beyond the chart canvas, and scrolls with the chart. +Assisted pointer and keyboard targeting share a transient target indicator and a floating semantic tooltip. Keyboard arrows move the indicator, apply the active hover styling, and emit `focus-element` through the `keyboard-targeting` interaction ID even when no click preset is configured. Enter or Space invokes any configured click presets. The tooltip uses the compiled pointer-hover fields, stays clear of the active mark, may extend beyond the chart canvas, and scrolls with the chart. Assisted pointer targeting can customize the compact semantic details: @@ -880,17 +880,78 @@ Callers should provide `chartId` when coordinating charts. Flint generates an ID Chart identity belongs to the transport envelope, not `SemanticTarget`: semantic targets describe visual/data identity, while `chartId` describes event origin or dispatch destination. +## Facet linking + +`facetBrushLink()` expands marks acquired in one facet to every available mark with the same authored semantic key: + +```ts +facetBrushLink({ by: 'Country' }); +facetBrushLink({ by: ['Country', 'Product'], brush: 'lasso' }); +``` + +The key should be represented by a discrete positional channel, `detail`, or `color`. For a +quantitative scatter plot, prefer `detail` when identity should not alter appearance. Continuous +`x`, `y`, or `xy` values are not inferred as identities because measurements can change between +facets or collide. The preset emits the existing `set-style` operation; it does not filter data or +introduce facet-specific chart state. + +`clickGroupFocus()` infers a chart-semantic partition, while +`clickGroupFocus({ groupBy: 'Country' })` uses explicit input-record field-key expansion. Plain strings +always name fields, so `groupBy: 'auto'` selects a field literally named `auto`. `clickMark()` remains local to the acquired mark by default, and +accepts the same explicit partition with `clickMark({ groupBy: ['Country', 'Series'] })`. +`clickAnnotate()` remains local to the acquired mark. + +For programmatic partitions, a `groupBy` function computes a key from the full semantic element +and interaction context. Functions are the TypeScript-only extension to the JSON-safe field and +field-list forms. The preset still owns acquisition, retained state, and presentation. + +```ts +clickMark({ groupBy: (element) => element.records?.[0]?.Region }); +``` + +`hoverGroupFocus({ groupBy: 'Country' })` provides the transient counterpart. Its preview +clears on pointer exit and does not replace retained click or brush state. A default 8-pixel +nearest-mark tolerance keeps the cohort stable across narrow gaps between marks; adjust it with +`tolerance` or set it to zero for direct hits only. + +## Legend and axis controls + +`clickMark()` only claims marks. `legendToggle()` changes visibility, +`clickLegendIsolate()` emphasizes a legend cohort, and `clickAxisIsolate()` emphasizes a +cohort selected from a discrete axis label with `set-style`. + +Only compiler-declared discrete axis ticks are semantic cohorts; continuous ticks do not +implicitly become clickable selections. Continuous legend intervals remain resolvable labels. + +## Interaction affordances + +Canvas interactions declare cursor and hover affordances separately from their update handler. +Renderers combine those declarations for the semantic target under the pointer, so composed +presets share one discoverability policy instead of assigning cursors independently. Exact target +claims (`mark`, `legend-item`, or `axis-label`) take precedence over a plot-wide fallback; priority +resolves conflicts between equally specific claims. Active gesture states such as dragging and +resizing temporarily override the passive result. + +Affordances do not perform chart updates. The interaction handler still owns semantic behavior, +and the renderer still owns presentation. + ## Compatibility -Existing helpers remain presets: +Canonical helpers include: -- `clickHighlight()` -- `clickGroupHighlight()` +- `clickMark()` +- `clickGroupFocus()` - `clickAnnotate()` +- `facetBrushLink()` +- `hoverGroupFocus()` +- `clickLegendIsolate()`, `clickAxisIsolate()`, and `legendToggle()` - `select()`, `brushX()`, `brushY()`, and `brushAngle()` - `navigate()` -They are implemented on the normalized event pipeline. Existing chart resolution and `presentUpdate` hooks remain valid; chart-specific action expansion lives in interaction handlers. +The older `clickHighlight()`, `clickGroupHighlight()`, and `hoverGroupHighlight()` names remain as +deprecated aliases. All presets are implemented on the normalized event pipeline. Existing chart +resolution and `presentUpdate` hooks remain valid; chart-specific action expansion lives in +interaction handlers. The long-term built-in preset set should stay small: hover highlight, click highlight/select, region or brush highlight, and guarded navigation. Specialized diff --git a/packages/flint-js/src/interactive/affordances.ts b/packages/flint-js/src/interactive/affordances.ts new file mode 100644 index 00000000..66975447 --- /dev/null +++ b/packages/flint-js/src/interactive/affordances.ts @@ -0,0 +1,52 @@ +import type { CanvasInteractionDef } from './interactions'; + +export type InteractionAffordanceTarget = 'mark' | 'legend-item' | 'axis-label' | 'plot'; +export type InteractionCursor = 'activate' | 'drag' | 'region' | 'navigate' | 'inspect'; +export type InteractionHoverEffect = 'target' | 'cohort'; + +export interface InteractionAffordance { + readonly target: InteractionAffordanceTarget; + readonly cursor?: InteractionCursor; + readonly hover?: InteractionHoverEffect; + readonly priority?: number; +} + +const CURSOR_PRIORITY: Record = { + activate: 10, + inspect: 20, + navigate: 30, + region: 40, + drag: 50, +}; + +export function resolveInteractionAffordance( + interactions: readonly CanvasInteractionDef[], + target: InteractionAffordanceTarget, + eligibleInteractionIds?: ReadonlySet, +): InteractionAffordance | undefined { + const claims = interactions + .filter((interaction) => !eligibleInteractionIds || eligibleInteractionIds.has(interaction.id)) + .flatMap((interaction) => interaction.affordances ?? []) + .filter((affordance) => affordance.target === target + || (target !== 'plot' && affordance.target === 'plot')); + const exactClaims = claims.filter((claim) => claim.target === target); + const eligibleClaims = exactClaims.length > 0 ? exactClaims : claims; + const priority = (claim: InteractionAffordance): number => + claim.priority ?? (claim.cursor ? CURSOR_PRIORITY[claim.cursor] : 0); + const cursor = eligibleClaims.filter((claim) => claim.cursor) + .sort((left, right) => priority(right) - priority(left))[0]?.cursor; + const hover = eligibleClaims.filter((claim) => claim.hover) + .sort((left, right) => priority(right) - priority(left))[0]?.hover; + return cursor || hover ? { target, ...(cursor ? { cursor } : {}), ...(hover ? { hover } : {}) } : undefined; +} + +export function affordanceCursor(affordance: InteractionAffordance | undefined): string | undefined { + switch (affordance?.cursor) { + case 'activate': return 'pointer'; + case 'drag': return 'grab'; + case 'region': return 'crosshair'; + case 'navigate': return 'grab'; + case 'inspect': return 'crosshair'; + default: return undefined; + } +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/gestures/cartesian-region.ts b/packages/flint-js/src/interactive/gestures/cartesian-region.ts index f73f3e4e..e0637a64 100644 --- a/packages/flint-js/src/interactive/gestures/cartesian-region.ts +++ b/packages/flint-js/src/interactive/gestures/cartesian-region.ts @@ -8,6 +8,11 @@ export interface PlotSize { height: number; } +export interface PlotFrame extends PlotSize { + x: number; + y: number; +} + export interface Interval { leading: number; trailing: number; @@ -17,11 +22,23 @@ export function constrainCartesianRegion( start: PlotPoint, end: PlotPoint, axis: CartesianRegionAxis, - plotSize: PlotSize, + plotSize: PlotSize | PlotFrame, ): { start: PlotPoint; end: PlotPoint } { + const left = 'x' in plotSize ? plotSize.x : 0; + const top = 'y' in plotSize ? plotSize.y : 0; + const right = left + plotSize.width; + const bottom = top + plotSize.height; + const clamp = (value: number, minimum: number, maximum: number): number => + Math.max(minimum, Math.min(maximum, value)); return { - start: { x: axis === 'y' ? 0 : start.x, y: axis === 'x' ? 0 : start.y }, - end: { x: axis === 'y' ? plotSize.width : end.x, y: axis === 'x' ? plotSize.height : end.y }, + start: { + x: axis === 'y' ? left : clamp(start.x, left, right), + y: axis === 'x' ? top : clamp(start.y, top, bottom), + }, + end: { + x: axis === 'y' ? right : clamp(end.x, left, right), + y: axis === 'x' ? bottom : clamp(end.y, top, bottom), + }, }; } diff --git a/packages/flint-js/src/interactive/gestures/navigation.ts b/packages/flint-js/src/interactive/gestures/navigation.ts index 67c57e43..fbd7fdc3 100644 --- a/packages/flint-js/src/interactive/gestures/navigation.ts +++ b/packages/flint-js/src/interactive/gestures/navigation.ts @@ -28,6 +28,36 @@ export class PanSession { } } +export class PinchSession { + private previousDistance: number; + + constructor( + first: PlotPoint, + second: PlotPoint, + private readonly plotSize: PlotSize, + ) { + this.previousDistance = Math.hypot(second.x - first.x, second.y - first.y); + } + + move(first: PlotPoint, second: PlotPoint): { factor: number; anchor: PlotPoint } | null { + const distance = Math.hypot(second.x - first.x, second.y - first.y); + if (distance <= 0 || this.previousDistance <= 0) { + this.previousDistance = distance; + return null; + } + const factor = distance / this.previousDistance; + this.previousDistance = distance; + const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; + return { + factor, + anchor: { + x: this.plotSize.width > 0 ? midpoint.x / this.plotSize.width : 0.5, + y: this.plotSize.height > 0 ? midpoint.y / this.plotSize.height : 0.5, + }, + }; + } +} + export function wheelZoomFactor( deltaY: number, deltaMode: number, diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index ddc96c09..ac2bbe0a 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -28,6 +28,13 @@ export type { LineGestureGuideStyle, RegionGuideOptions, } from './guides'; +export type { + InteractionAffordance, + InteractionAffordanceTarget, + InteractionCursor, + InteractionHoverEffect, +} from './affordances'; +export { affordanceCursor, resolveInteractionAffordance } from './affordances'; export type { AnnotationCandidate, AnnotationConnection, @@ -40,8 +47,18 @@ export type { AngularBrushOptions, AxisHighlightOptions, ClickAnnotateOptions, + ClickAxisIsolateOptions, + ClickGroupFocusOptions, ClickGroupHighlightOptions, ClickHighlightOptions, + ClickLegendIsolateOptions, + ClickMarkOptions, + FacetBrushLinkOptions, + HoverGroupHighlightOptions, + HoverGroupFocusOptions, + GroupBy, + GroupByFunction, + RecordGroupBy, ElementInteractionEvent, FlintInteractionEventDetail, InteractionPhase, @@ -86,7 +103,7 @@ export type { SemanticTargetSelector, } from './language/updates'; export { matchesSemanticTargetSelector } from './language/updates'; -export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; +export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickGroupHighlight, clickHighlight, clickLegendIsolate, clickMark, contextActivate, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, hoverGroupHighlight, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; export type { InteractionEventSource } from './triggers'; export { axisBrushTrigger, @@ -121,6 +138,9 @@ export function buildInteractiveChart( } = options; const interactions = normalizeInteractions(options.interactions); const canvasInteractions = interactions.filter(isCanvasInteraction); + const hoverTolerance = Math.max(0, ...canvasInteractions + .filter((interaction) => interaction.eventSource.gesture === 'hover') + .map((interaction) => interaction.eventSource.targetTolerance ?? 0)); if (backend !== 'vegalite' && interactions.length > 0) { return mountInteractiveChartSurface( container, @@ -145,13 +165,15 @@ export function buildInteractiveChart( renderer, interactions: canvasInteractions, enableSemanticUpdates: canvasInteractions.length < interactions.length - || (updates?.length ?? 0) > 0, + || (updates?.length ?? 0) > 0 + || keyboardTargeting === true, expressionInterpreter, background, assistDistance: assistedTargeting ? (typeof assistedTargeting === 'object' ? assistedTargeting.maxDistance : undefined) ?? DEFAULT_ASSIST_DISTANCE : 0, + hoverTolerance, targetFeedback: { assisted: typeof assistedTargeting === 'object' ? assistedTargeting : assistedTargeting ? {} : false, keyboard: keyboardTargeting ? {} : false, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 0c4ba0b3..425879aa 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -8,6 +8,7 @@ import type { import type { InteractionEventSource } from './triggers'; import type { InspectMode } from './triggers'; import type { InspectGuideOptions, RegionGuideOptions } from './guides'; +import type { InteractionAffordance } from './affordances'; import type { NavigationAxes, } from './language/events'; @@ -28,6 +29,9 @@ import { createSelectInteraction, createNavigateInteraction, createDragReorderInteraction, + createFacetBrushLinkInteraction, + createHoverGroupHighlightInteraction, + createLabelIsolateInteraction, } from './presets'; import type { CanvasInteractionEvent } from './language/events'; export type { @@ -91,11 +95,12 @@ export type { export interface CanvasInteractionDef { readonly id: string; readonly eventSource: InteractionEventSource; + readonly affordances?: readonly InteractionAffordance[]; readonly navigationDomainGuard?: NavigationDomainGuard; /** Claims legend activations exclusively, so a legend click never also reads as an element click. */ readonly claimsLegendActivation?: boolean; - /** Claims native axis tick activations instead of treating them as mark activations. */ - readonly claimsAxisActivation?: boolean; + /** Claims native axis tick activations instead of treating them as mark activations. */ + readonly claimsAxisActivation?: boolean; handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdate | null; } @@ -129,6 +134,14 @@ export interface ClickHighlightOptions { legend?: boolean; } +export type RecordGroupBy = + | string + | readonly string[]; + +export type GroupByFunction = (element: SemanticElement, context: InteractionContext) => unknown; + +export type GroupBy = RecordGroupBy | GroupByFunction; + export interface AxisHighlightOptions { id?: string; axis?: 'x' | 'y'; @@ -136,16 +149,57 @@ export interface AxisHighlightOptions { dimOpacity?: number; } -export interface ClickGroupHighlightOptions extends ClickHighlightOptions { - groupBy?: string | ((element: SemanticElement, context: InteractionContext) => unknown); +export type ClickGroupHighlightOptions = ClickHighlightOptions & { groupBy?: GroupBy }; + +export interface ClickMarkOptions { + id?: string; + dimOpacity?: number; + groupBy?: GroupBy; } +export type ClickGroupFocusOptions = ClickMarkOptions; + export interface ClickAnnotateOptions { id?: string; dimOpacity?: number; format?: (element: SemanticElement, context: InteractionContext) => string; } +export interface FacetBrushLinkOptions extends SelectOptions { + by: string | readonly string[]; + brush?: 'rectangle' | 'lasso'; +} + +export interface HoverGroupHighlightOptions { + id?: string; + groupBy: string | readonly string[]; + dimOpacity?: number; + /** Nearest-mark hover radius in renderer pixels. Defaults to 8. */ + tolerance?: number; + /** Whether legend hover focuses the represented series. Defaults to true. */ + legend?: boolean; +} + +export interface HoverGroupFocusOptions { + id?: string; + groupBy: string | readonly string[]; + dimOpacity?: number; + /** Nearest-mark hover radius in renderer pixels. Defaults to 8. */ + tolerance?: number; +} + +export interface ClickLegendIsolateOptions { + id?: string; + dimOpacity?: number; +} + +export interface ClickAxisIsolateOptions { + id?: string; + dimOpacity?: number; + /** Discrete positional axes to activate. Defaults to both axes. */ + axes?: readonly ('x' | 'y')[]; +} + export interface SelectOptions { id?: string; match?: 'intersect' | 'contain'; @@ -214,22 +268,69 @@ export interface DragReorderOptions { id?: string; } -export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInteractionDef { - return createClickHighlightInteraction(options); +export function clickMark(options: ClickMarkOptions = {}): CanvasInteractionDef { + const configured = { ...options, id: options.id ?? 'click-mark', legend: false }; + return options.groupBy === undefined + ? createClickHighlightInteraction(configured) + : createClickGroupHighlightInteraction(configured); } export function axisHighlight(options: AxisHighlightOptions = {}): CanvasInteractionDef { return createAxisHighlightInteraction(options); } -export function clickGroupHighlight(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { - return createClickGroupHighlightInteraction(options); +export function clickGroupFocus(options: ClickGroupFocusOptions = {}): CanvasInteractionDef { + return createClickGroupHighlightInteraction({ + id: options.id ?? 'click-group-focus', + dimOpacity: options.dimOpacity, + groupBy: options.groupBy, + legend: false, + }); } export function clickAnnotate(options: ClickAnnotateOptions = {}): CanvasInteractionDef { return createClickAnnotateInteraction(options); } +export function facetBrushLink(options: FacetBrushLinkOptions): CanvasInteractionDef { + return createFacetBrushLinkInteraction(options); +} + +export function hoverGroupFocus(options: HoverGroupFocusOptions): CanvasInteractionDef { + return createHoverGroupHighlightInteraction({ ...options, id: options.id ?? 'hover-group-focus', legend: false }); +} + +/** @deprecated Use clickMark(). */ +export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInteractionDef { + return createClickHighlightInteraction(options); +} + +/** @deprecated Use clickGroupFocus(). */ +export function clickGroupHighlight(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { + return createClickGroupHighlightInteraction(options); +} + +/** @deprecated Use hoverGroupFocus(). */ +export function hoverGroupHighlight(options: HoverGroupHighlightOptions): CanvasInteractionDef { + return createHoverGroupHighlightInteraction(options); +} + +export function clickLegendIsolate(options: ClickLegendIsolateOptions = {}): CanvasInteractionDef { + return createLabelIsolateInteraction({ + ...options, + id: options.id ?? 'click-legend-isolate', + targets: ['legend'], + }); +} + +export function clickAxisIsolate(options: ClickAxisIsolateOptions = {}): CanvasInteractionDef { + return createLabelIsolateInteraction({ + ...options, + id: options.id ?? 'click-axis-isolate', + targets: options.axes ?? ['x', 'y'], + }); +} + export function select(options: SelectOptions = {}): CanvasInteractionDef { return createSelectInteraction(options); } diff --git a/packages/flint-js/src/interactive/presentation/annotation.ts b/packages/flint-js/src/interactive/presentation/annotation.ts index c63744cf..5aadb421 100644 --- a/packages/flint-js/src/interactive/presentation/annotation.ts +++ b/packages/flint-js/src/interactive/presentation/annotation.ts @@ -115,7 +115,10 @@ export function rangeAnnotationText( ): (element: SemanticElement) => string | undefined { return (element) => { if (!startField || !endField) return undefined; - const record = element.records?.[0] ?? element.value ?? {}; + const value = element.value ?? {}; + const record = startField in value || endField in value + ? value + : element.records?.[0] ?? value; const start = displayValue(startField, record[startField]); const end = displayValue(endField, record[endField]); return start && end ? `${start} → ${end}` : start ?? end; @@ -197,8 +200,24 @@ function defaultAnnotationText(element: SemanticElement, context: InteractionCon } export function countAnnotationText(element: SemanticElement): string | undefined { - const record = element.records?.[0] ?? element.value ?? {}; - const count = Object.entries(record).find(([field, value]) => /count/i.test(field) - && typeof value === 'number' && Number.isFinite(value)); + const count = [element.value, ...(element.records ?? [])] + .flatMap((record) => Object.entries(record ?? {})) + .find(([field, value]) => /count/i.test(field) + && typeof value === 'number' && Number.isFinite(value)); return displayValue(count?.[0], count?.[1]); +} + +export function boxplotAnnotationText(element: SemanticElement): string | undefined { + const record = element.value ?? element.records?.[0] ?? {}; + const summaryValue = (prefix: string): [string, unknown] | undefined => + Object.entries(record).find(([field, value]) => field.startsWith(prefix) + && typeof value === 'number' && Number.isFinite(value)); + const median = summaryValue('mid_box_'); + const lower = summaryValue('lower_box_'); + const upper = summaryValue('upper_box_'); + const medianText = displayValue(median?.[0], median?.[1]); + const lowerText = displayValue(lower?.[0], lower?.[1]); + const upperText = displayValue(upper?.[0], upper?.[1]); + const iqrText = lowerText && upperText ? `IQR: ${lowerText} → ${upperText}` : undefined; + return [medianText ? `Median: ${medianText}` : undefined, iqrText].filter(Boolean).join('\n') || undefined; } \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/angular-brush.ts b/packages/flint-js/src/interactive/presets/angular-brush.ts index 673b5ad3..846cd6ab 100644 --- a/packages/flint-js/src/interactive/presets/angular-brush.ts +++ b/packages/flint-js/src/interactive/presets/angular-brush.ts @@ -8,6 +8,7 @@ export function createAngularBrushInteraction(options: AngularBrushOptions = {}) return { id, eventSource: angularBrushTrigger(options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], handle(event, context) { if (event.action !== 'brush-angle' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/axis-highlight.ts b/packages/flint-js/src/interactive/presets/axis-highlight.ts index f4869120..f272122e 100644 --- a/packages/flint-js/src/interactive/presets/axis-highlight.ts +++ b/packages/flint-js/src/interactive/presets/axis-highlight.ts @@ -9,6 +9,11 @@ export function createAxisHighlightInteraction(options: AxisHighlightOptions = { id, eventSource: options.event === 'hover' ? hoverTrigger : clickTrigger, claimsAxisActivation: true, + affordances: [{ + target: 'axis-label', + ...(options.event === 'hover' ? {} : { cursor: 'activate' as const }), + hover: 'cohort', + }], handle(event, context) { if (event.action !== 'hover-axis' && event.action !== 'click-axis') return null; if (event.phase === 'start') return null; diff --git a/packages/flint-js/src/interactive/presets/brush-zoom.ts b/packages/flint-js/src/interactive/presets/brush-zoom.ts index ce8c6b2c..faa20d53 100644 --- a/packages/flint-js/src/interactive/presets/brush-zoom.ts +++ b/packages/flint-js/src/interactive/presets/brush-zoom.ts @@ -10,6 +10,7 @@ export function createBrushZoomInteraction(options: BrushZoomOptions = {}): Canv return { id, eventSource: brushZoomTrigger(axes, options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], handle(event) { if (!REGION_ACTIONS.has(event.action) || event.phase !== 'commit' || event.operation === 'clear') return null; const domain = event.geometry.domain; diff --git a/packages/flint-js/src/interactive/presets/brush.ts b/packages/flint-js/src/interactive/presets/brush.ts index e1935fd1..98783439 100644 --- a/packages/flint-js/src/interactive/presets/brush.ts +++ b/packages/flint-js/src/interactive/presets/brush.ts @@ -10,6 +10,7 @@ export function createBrushInteraction(axis: 'x' | 'y', options: BrushOptions = id, axis, eventSource: axisBrushTrigger(axis, options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], handle(event, context) { const acceptsAngular = axis === 'x' && event.action === 'brush-angle'; if ((event.action !== `brush-${axis}` && !acceptsAngular) diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts index 52e26581..7b4d48d8 100644 --- a/packages/flint-js/src/interactive/presets/click-annotate.ts +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -11,6 +11,7 @@ export function createClickAnnotateInteraction(options: ClickAnnotateOptions = { return { id, eventSource: clickTrigger, + affordances: [{ target: 'mark', cursor: 'activate' }], handle(event, context) { if (!isActivationAction(event.action) || event.phase !== 'commit') return null; if (event.target?.visual.role === 'legend-item') return null; diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts index 8facf139..0fe697c9 100644 --- a/packages/flint-js/src/interactive/presets/click-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -4,14 +4,17 @@ import type { CanvasInteractionDef, SemanticElement, SemanticTarget, + GroupBy, } from '../interactions'; +import type { InteractionAffordance } from '../affordances'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; +import { expandElementsByFields } from './semantic-cohort'; function groupValue( element: SemanticElement, context: InteractionContext, - groupBy: ClickGroupHighlightOptions['groupBy'], + groupBy: GroupBy | undefined, ): unknown { if (typeof groupBy === 'function') return groupBy(element, context); const record = element.records?.[0]; @@ -26,9 +29,12 @@ function groupValue( function groupElements( target: SemanticTarget, context: InteractionContext, - groupBy: ClickGroupHighlightOptions['groupBy'], + groupBy: GroupBy | undefined, ): readonly SemanticElement[] { if (target.visual.role === 'legend-item') return target.elements; + if (typeof groupBy === 'string' || Array.isArray(groupBy)) { + return expandElementsByFields(target.elements, context.available, groupBy); + } const source = target.elements[0]; if (!source) return target.elements; const value = groupValue(source, context, groupBy); @@ -38,21 +44,32 @@ function groupElements( const values = new Set(available.map((element) => groupValue(element, context, groupBy))); if (values.size < 2) return target.elements; - const cohort = available.filter((element) => groupValue(element, context, groupBy) === value); + const cohort = available.filter((element) => Object.is( + groupValue(element, context, groupBy), value, + )); return cohort.length > 1 ? cohort : target.elements; } export function createClickGroupHighlightInteraction(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-group-highlight'; const dimOpacity = normalizedOpacity(options.dimOpacity); + const affordances: InteractionAffordance[] = [ + { target: 'mark', cursor: 'activate', hover: 'cohort' }, + ]; + if (options.legend !== false) { + affordances.push({ target: 'legend-item', cursor: 'activate', hover: 'cohort' }); + } return { id, eventSource: clickTrigger, + affordances, handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; if (event.target?.visual.role === 'legend-item' && options.legend === false) return null; const target = event.target - ? { ...event.target, elements: groupElements(event.target, context, options.groupBy) } + ? { ...event.target, elements: groupElements( + event.target, context, options.groupBy, + ) } : null; return emphasisUpdate(id, event, target, dimOpacity, context); }, diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts index acf143d9..ba26cca6 100644 --- a/packages/flint-js/src/interactive/presets/click-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -1,4 +1,5 @@ import type { CanvasInteractionDef, ClickHighlightOptions } from '../interactions'; +import type { InteractionAffordance } from '../affordances'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; import { expandRangedDotTarget } from './ranged-dot-target'; @@ -6,9 +7,16 @@ import { expandRangedDotTarget } from './ranged-dot-target'; export function createClickHighlightInteraction(options: ClickHighlightOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-highlight'; const dimOpacity = normalizedOpacity(options.dimOpacity); + const affordances: InteractionAffordance[] = [ + { target: 'mark', cursor: 'activate', hover: 'target' }, + ]; + if (options.legend !== false) { + affordances.push({ target: 'legend-item', cursor: 'activate', hover: 'cohort' }); + } return { id, eventSource: clickTrigger, + affordances, handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; if (event.target?.visual.role === 'legend-item' && options.legend === false) return null; diff --git a/packages/flint-js/src/interactive/presets/context-activate.ts b/packages/flint-js/src/interactive/presets/context-activate.ts index c7d9c01d..31500c54 100644 --- a/packages/flint-js/src/interactive/presets/context-activate.ts +++ b/packages/flint-js/src/interactive/presets/context-activate.ts @@ -11,5 +11,6 @@ export function createContextActivateInteraction( return { id: options.id ?? 'context-activate', eventSource: contextTrigger, + affordances: [{ target: 'mark', cursor: 'activate' }], }; } diff --git a/packages/flint-js/src/interactive/presets/drag-reorder.ts b/packages/flint-js/src/interactive/presets/drag-reorder.ts index 843523cc..6098266a 100644 --- a/packages/flint-js/src/interactive/presets/drag-reorder.ts +++ b/packages/flint-js/src/interactive/presets/drag-reorder.ts @@ -2,7 +2,9 @@ import type { CanvasInteractionDef, DragReorderOptions, SemanticElement } from ' import { elementDragTrigger } from '../triggers'; function categoryValue(element: SemanticElement | undefined, field: string): unknown { - return element?.records?.[0]?.[field] ?? element?.value?.[field]; + return element?.records?.[0]?.[field] + ?? element?.value?.[field] + ?? (element?.value?.field === field ? element.value.value : undefined); } export function reorderValues( @@ -24,6 +26,10 @@ export function createDragReorderInteraction(options: DragReorderOptions = {}): return { id, eventSource: elementDragTrigger(), + affordances: [ + { target: 'mark', cursor: 'drag', hover: 'target' }, + { target: 'axis-label', cursor: 'drag', hover: 'target' }, + ], handle(event, context) { if (event.action !== 'drag-element' || event.phase !== 'commit' || !event.target || !event.dropTarget) return null; @@ -54,7 +60,17 @@ export function createDragReorderInteraction(options: DragReorderOptions = {}): if (orderedValues.every((value, index) => Object.is(value, values[index]))) return null; return { id, - ops: [{ op: 'set-order', scope: 'category', field, values: orderedValues }], + ops: [ + { op: 'set-order', scope: 'category', field, values: orderedValues }, + ...axes + .filter((candidate) => candidate !== selectedAxis && candidate.field !== field) + .map((candidate) => ({ + op: 'set-order' as const, + scope: 'category' as const, + field: candidate.field, + values: [...candidate.order], + })), + ], }; }, }; diff --git a/packages/flint-js/src/interactive/presets/facet-brush-link.ts b/packages/flint-js/src/interactive/presets/facet-brush-link.ts new file mode 100644 index 00000000..c6b97c8e --- /dev/null +++ b/packages/flint-js/src/interactive/presets/facet-brush-link.ts @@ -0,0 +1,27 @@ +import type { CanvasInteractionDef, FacetBrushLinkOptions } from '../interactions'; +import { lassoTrigger, rectangleTrigger } from '../triggers'; +import { expandElementsByFields } from './semantic-cohort'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +export function createFacetBrushLinkInteraction(options: FacetBrushLinkOptions): CanvasInteractionDef { + const id = options.id ?? 'facet-brush-link'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + const lasso = options.brush === 'lasso'; + return { + id, + eventSource: lasso + ? lassoTrigger(options.match ?? 'intersect', options.guide) + : rectangleTrigger(options.match ?? 'intersect', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], + handle(event, context) { + const expectedAction = lasso ? 'select-lasso' : 'select-region'; + if (event.action !== expectedAction || event.phase === 'start' || event.phase === 'cancel') return null; + if (!event.target) return emphasisUpdate(id, event, null, dimOpacity, context); + const target = { + ...event.target, + elements: expandElementsByFields(event.target.elements, context.available, options.by), + }; + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts new file mode 100644 index 00000000..585ca7c9 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts @@ -0,0 +1,31 @@ +import type { CanvasInteractionDef, HoverGroupHighlightOptions } from '../interactions'; +import type { InteractionAffordance } from '../affordances'; +import { hoverTrigger } from '../triggers'; +import { expandElementsByFields } from './semantic-cohort'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +export function createHoverGroupHighlightInteraction(options: HoverGroupHighlightOptions): CanvasInteractionDef { + const id = options.id ?? 'hover-group-highlight'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + const tolerance = options.tolerance === undefined || !Number.isFinite(options.tolerance) + ? 8 + : Math.max(0, options.tolerance); + const affordances: InteractionAffordance[] = [{ target: 'mark', hover: 'cohort' }]; + if (options.legend !== false) affordances.push({ target: 'legend-item', hover: 'cohort' }); + return { + id, + eventSource: { ...hoverTrigger, targetTolerance: tolerance }, + affordances, + handle(event, context) { + if (!event.action.startsWith('hover-') || event.phase !== 'preview' || !event.target) return null; + if (event.target.visual.role === 'legend-item' && options.legend === false) return null; + const target = event.target.visual.role === 'legend-item' + ? event.target + : { + ...event.target, + elements: expandElementsByFields(event.target.elements, context.available, options.groupBy), + }; + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index 7d8a087a..e7c721d5 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -13,3 +13,6 @@ export { createLegendToggleInteraction } from './legend-toggle'; export { createSelectInteraction } from './select'; export { createNavigateInteraction } from './navigate'; export { createDragReorderInteraction } from './drag-reorder'; +export { createFacetBrushLinkInteraction } from './facet-brush-link'; +export { createHoverGroupHighlightInteraction } from './hover-group-highlight'; +export { createLabelIsolateInteraction } from './label-isolate'; diff --git a/packages/flint-js/src/interactive/presets/inspect.ts b/packages/flint-js/src/interactive/presets/inspect.ts index f64ca8e8..cf9d29b2 100644 --- a/packages/flint-js/src/interactive/presets/inspect.ts +++ b/packages/flint-js/src/interactive/presets/inspect.ts @@ -16,6 +16,7 @@ export function createInspectInteraction(options: InspectOptions = {}): CanvasIn eventSource: inspectTrigger( options.mode ?? 'xy', options.selector, options.tolerance, options.guide, options.cycle, ), + affordances: [{ target: 'plot', cursor: 'inspect' }], handle(event, context) { if (!INSPECT_ACTIONS.has(event.action) || event.phase === 'cancel') return null; if (!event.target) return { id, ops: [] }; diff --git a/packages/flint-js/src/interactive/presets/label-isolate.ts b/packages/flint-js/src/interactive/presets/label-isolate.ts new file mode 100644 index 00000000..0d9258a7 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/label-isolate.ts @@ -0,0 +1,44 @@ +import type { CanvasInteractionDef } from '../interactions'; +import { clickTrigger } from '../triggers'; +import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; + +const DEFAULT_TARGETS = ['legend', 'x', 'y'] as const; + +interface LabelIsolatePresetOptions { + id?: string; + dimOpacity?: number; + targets?: readonly ('legend' | 'x' | 'y')[]; +} + +export function createLabelIsolateInteraction(options: LabelIsolatePresetOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'label-isolate'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + const targets = new Set(options.targets ?? DEFAULT_TARGETS); + const claimsLegend = targets.has('legend'); + const claimsAxis = targets.has('x') || targets.has('y'); + + return { + id, + eventSource: clickTrigger, + claimsLegendActivation: claimsLegend, + claimsAxisActivation: claimsAxis, + affordances: [ + ...(claimsLegend ? [{ target: 'legend-item' as const, cursor: 'activate' as const, hover: 'cohort' as const }] : []), + ...(claimsAxis ? [{ target: 'axis-label' as const, cursor: 'activate' as const, hover: 'cohort' as const }] : []), + ], + handle(event, context) { + if (!isActivationAction(event.action) || event.phase !== 'commit') return null; + const target = event.target; + if (!target) return emphasisUpdate(id, event, null, dimOpacity, context); + if (target.visual.role === 'legend-item') { + return claimsLegend ? emphasisUpdate(id, event, target, dimOpacity, context) : null; + } + if (target.visual.kind !== 'axis') return null; + const eligible = target.elements.some((element) => { + const axis = element.value.axis; + return (axis === 'x' || axis === 'y') && targets.has(axis); + }); + return eligible ? emphasisUpdate(id, event, target, dimOpacity, context) : null; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/lasso-select.ts b/packages/flint-js/src/interactive/presets/lasso-select.ts index 5023674f..838471d1 100644 --- a/packages/flint-js/src/interactive/presets/lasso-select.ts +++ b/packages/flint-js/src/interactive/presets/lasso-select.ts @@ -8,6 +8,7 @@ export function createLassoSelectInteraction(options: LassoSelectOptions = {}): return { id, eventSource: lassoTrigger(options.match ?? 'intersect', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], handle(event, context) { if (event.action !== 'select-lasso' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/legend-toggle.ts b/packages/flint-js/src/interactive/presets/legend-toggle.ts index 76e02c0c..7dff9d65 100644 --- a/packages/flint-js/src/interactive/presets/legend-toggle.ts +++ b/packages/flint-js/src/interactive/presets/legend-toggle.ts @@ -66,6 +66,7 @@ export function createLegendToggleInteraction(options: LegendToggleOptions = {}) id, eventSource: clickTrigger, claimsLegendActivation: true, + affordances: [{ target: 'legend-item', cursor: 'activate', hover: 'cohort' }], handle(event, context) { if (!legendActivation(event)) return null; const elements = event.target?.elements ?? []; diff --git a/packages/flint-js/src/interactive/presets/long-press.ts b/packages/flint-js/src/interactive/presets/long-press.ts index ea4c072e..44233465 100644 --- a/packages/flint-js/src/interactive/presets/long-press.ts +++ b/packages/flint-js/src/interactive/presets/long-press.ts @@ -13,6 +13,7 @@ export function createLongPressInteraction(options: LongPressOptions = {}): Canv return { id, eventSource: longPressTrigger(options.holdMs ?? 500), + affordances: [{ target: 'mark', cursor: 'activate' }], handle(event, context) { if (!event.action.startsWith('long-press-') || event.phase !== 'commit') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); @@ -29,6 +30,7 @@ export function createDoubleActivateInteraction( return { id, eventSource: doubleActivateTrigger, + affordances: [{ target: 'mark', cursor: 'activate' }], handle(event, context) { if (!event.action.startsWith('double-activate-') || event.phase !== 'commit') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/navigate.ts b/packages/flint-js/src/interactive/presets/navigate.ts index 197f2720..5a2fc2e6 100644 --- a/packages/flint-js/src/interactive/presets/navigate.ts +++ b/packages/flint-js/src/interactive/presets/navigate.ts @@ -46,6 +46,7 @@ export function createNavigateInteraction(options: NavigateOptions = {}): Canvas zoom: options.zoom ?? true, wheelSensitivity: options.wheelSensitivity ?? 0.002, }), + affordances: options.pan === false ? [] : [{ target: 'plot', cursor: 'navigate' }], handle(event, context) { const viewport = event.geometry.plot; if (!context.resolveNavigation || viewport?.kind !== 'viewport' || !event.operation) return null; diff --git a/packages/flint-js/src/interactive/presets/select.ts b/packages/flint-js/src/interactive/presets/select.ts index 08877606..c9a90aa3 100644 --- a/packages/flint-js/src/interactive/presets/select.ts +++ b/packages/flint-js/src/interactive/presets/select.ts @@ -8,6 +8,7 @@ export function createSelectInteraction(options: SelectOptions = {}): CanvasInte return { id, eventSource: rectangleTrigger(options.match ?? 'intersect', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], handle(event, context) { if (event.action !== 'select-region' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/semantic-cohort.ts b/packages/flint-js/src/interactive/presets/semantic-cohort.ts new file mode 100644 index 00000000..4de45cac --- /dev/null +++ b/packages/flint-js/src/interactive/presets/semantic-cohort.ts @@ -0,0 +1,32 @@ +import type { SemanticElement } from '../interactions'; + +function fieldValue(element: SemanticElement, field: string): unknown { + const record = element.records?.[0]; + return record && Object.prototype.hasOwnProperty.call(record, field) ? record[field] : undefined; +} + +function fieldKey(element: SemanticElement, fields: readonly string[]): readonly unknown[] | undefined { + const values = fields.map((field) => fieldValue(element, field)); + return values.some((value) => value === undefined) ? undefined : values; +} + +function sameKey(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => Object.is(value, right[index])); +} + +export function expandElementsByFields( + source: readonly SemanticElement[], + available: readonly SemanticElement[] | undefined, + fields: string | readonly string[], +): readonly SemanticElement[] { + const fieldList = typeof fields === 'string' ? [fields] : fields; + if (fieldList.length === 0 || !available?.length) return source; + const keys = source.map((element) => fieldKey(element, fieldList)) + .filter((key): key is readonly unknown[] => key !== undefined); + if (keys.length === 0) return source; + const cohort = available.filter((element) => { + const candidate = fieldKey(element, fieldList); + return candidate !== undefined && keys.some((key) => sameKey(candidate, key)); + }); + return cohort.length > 0 ? cohort : source; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/triggers.ts b/packages/flint-js/src/interactive/triggers.ts index 5386b048..f9de49ad 100644 --- a/packages/flint-js/src/interactive/triggers.ts +++ b/packages/flint-js/src/interactive/triggers.ts @@ -25,6 +25,8 @@ export interface InteractionEventSource { readonly inspectPredicate?: InspectPredicate; readonly inspectCycle?: readonly ReturnType[]; readonly inspectTolerance?: number; + /** Nearest-mark acquisition radius for hover gestures, in renderer pixels. */ + readonly targetTolerance?: number; readonly inspectGuide?: ReturnType; readonly regionGuide?: ReturnType; readonly selector?: SemanticTargetSelector; diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index 8817a8ed..2d6b4996 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -108,7 +108,6 @@ function instrumentNode( dimOpacity: number, continuousColorFocus: ContinuousColorFocusStyle | undefined, selectableMarks: ReadonlySet, - clickCursor: boolean, ): boolean { const type = markType(node.mark); const selectable = !!type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type); @@ -151,11 +150,6 @@ function instrumentNode( node.mark = { ...node.mark }; delete node.mark.opacity; } - if (clickCursor && (selectable || legendLabel)) { - node.mark = typeof node.mark === 'string' - ? { type: node.mark, cursor: 'pointer' } - : { ...node.mark, cursor: node.mark.cursor ?? 'pointer' }; - } const isPath = type === 'line' || type === 'area'; const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; const existingDetail = node.encoding?.detail; @@ -200,7 +194,6 @@ function instrumentMarks( dimOpacity: number, continuousColorFocus: ContinuousColorFocusStyle | undefined, selectableMarks: ReadonlySet, - clickCursor: boolean, ): boolean { const encoding = { ...inherited, ...(spec.encoding ?? {}) }; let instrumented = instrumentNode( @@ -210,7 +203,6 @@ function instrumentMarks( dimOpacity, continuousColorFocus, selectableMarks, - clickCursor, ); for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { if (!Array.isArray(spec[property])) continue; @@ -222,7 +214,6 @@ function instrumentMarks( dimOpacity, continuousColorFocus, selectableMarks, - clickCursor, ) || instrumented; } } @@ -234,7 +225,6 @@ function instrumentMarks( dimOpacity, continuousColorFocus, selectableMarks, - clickCursor, ) || instrumented; } return instrumented; @@ -480,13 +470,11 @@ export function addVegaLiteInteractions( : value; }, DEFAULT_DIM_OPACITY); - const clickCursor = semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'click') - && !semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag'); const instrumented = needsSemanticPresentation ? instrumentMarks( spec, {}, fields, dimOpacity, templateSemantics.neutralizeContinuousColor ? templateSemantics.continuousColorFocus : undefined, - selectableMarks, clickCursor, + selectableMarks, ) : false; if (needsSemanticPresentation && !instrumented) return null; @@ -585,6 +573,7 @@ export function injectVegaNavigationSignals( export function collectVegaAxisTargets( vegaSpec: Record, axisFields: VegaInteractionPlan['axisFields'], + reorderAxes: readonly Pick[] = [], ): Record { const targets: Record = {}; const visit = (scope: Record): void => { @@ -599,12 +588,12 @@ export function collectVegaAxisTargets( labels: { ...(axis.encode?.labels ?? {}), interactive: true, - update: { ...(axis.encode?.labels?.update ?? {}), cursor: { value: 'pointer' } }, + update: { ...(axis.encode?.labels?.update ?? {}) }, }, ticks: { ...(axis.encode?.ticks ?? {}), interactive: true, - update: { ...(axis.encode?.ticks?.update ?? {}), cursor: { value: 'pointer' } }, + update: { ...(axis.encode?.ticks?.update ?? {}) }, }, }; } @@ -640,7 +629,9 @@ function applyCompiledHoverStyles( const authoredOpacity = numericValues.length > 0 ? Math.max(...numericValues) : 1; update.opacity = [ { - test: `!length(data('${INTERACTION_STORE}')) && ${hoverTest}`, + test: value === 'spotlight' && mark.type === 'area' + ? `!length(data('${INTERACTION_STORE}')) && ${hoverTest}` + : hoverTest, value: value === 'spotlight' ? Math.min(authoredOpacity, 0.9) : authoredOpacity < 1 ? 1 : 0.9, @@ -742,7 +733,6 @@ export function injectVegaInteractionStore( interactive: true, update: { ...(encode?.update ?? {}), - cursor: { value: 'pointer' }, opacity: hiddenLegendItem ? [ { test: hiddenLegendItem, signal: `data('${LEGEND_HIDDEN_STORE}')[0].opacity` }, ...(peerOfSelectedLegend ? [ @@ -793,7 +783,6 @@ export function injectVegaInteractionStore( interactive: true, update: { ...(legend.encode?.entries?.update ?? {}), - cursor: { value: 'pointer' }, }, }, gradient: interactiveItem(legend.encode?.gradient, 'gradient'), diff --git a/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts index 75f20e9a..e11992fe 100644 --- a/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts +++ b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts @@ -4,7 +4,7 @@ import type { NavigationInteractionEvent, PlotPoint, } from '../../../interactive/interactions'; -import { PanSession, wheelZoomFactor } from '../../../interactive/gestures/navigation'; +import { PanSession, PinchSession, wheelZoomFactor } from '../../../interactive/gestures/navigation'; import { clientToPlotPoint, interactionModifiers, type RendererCoordinateSpace } from '../hit-adapter'; export interface VegaNavigationGestureOptions { @@ -48,12 +48,13 @@ export function mountVegaNavigationGesture( let session: PanSession | undefined; let pendingDelta: PlotPoint = { x: 0, y: 0 }; let dragged = false; + const touchPointers = new Map(); + let pinchSession: PinchSession | undefined; const previousCursor = container.style.cursor; const previousTouchAction = container.style.touchAction; const previousUserSelect = container.style.userSelect; - if (source.pan) { - container.style.cursor = 'grab'; + if (source.pan || source.zoom) { container.style.touchAction = 'none'; container.style.userSelect = 'none'; } @@ -64,8 +65,41 @@ export function mountVegaNavigationGesture( ); const emit = (event: NavigationInteractionEvent): void => { void dispatch(event); }; + const beginPinch = (event: PointerEvent): void => { + const points = [...touchPointers.values()]; + if (!source.zoom || points.length !== 2) return; + if (session) { + emit({ + type: 'navigation', phase: 'cancel', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + } + const space = coordinateSpace(); + pinchSession = new PinchSession(points[0]!, points[1]!, { + width: space.plotWidth, + height: space.plotHeight, + }); + dragged = true; + setDragging(true); + setSuppressClick(true); + emit({ + type: 'navigation', phase: 'start', operation: 'zoom', axes, + modifiers: interactionModifiers(event), + }); + }; + const pointerDown = (event: PointerEvent): void => { - if (!source.pan || event.button !== 0) return; + if (event.pointerType === 'touch' && source.zoom) { + if (pinchSession && touchPointers.size >= 2) return; + touchPointers.set(event.pointerId, localPoint(event)); + container.setPointerCapture(event.pointerId); + if (touchPointers.size === 2) beginPinch(event); + if (pinchSession || !source.pan) return; + } + if (!source.pan || event.button !== 0 || session) return; const space = coordinateSpace(); session = new PanSession(localPoint(event), { width: space.plotWidth, height: space.plotHeight }); pointerId = event.pointerId; @@ -80,6 +114,20 @@ export function mountVegaNavigationGesture( }); }; const pointerMove = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && touchPointers.has(event.pointerId)) { + touchPointers.set(event.pointerId, localPoint(event)); + if (pinchSession) { + const points = [...touchPointers.values()]; + if (points.length !== 2) return; + const update = pinchSession.move(points[0]!, points[1]!); + if (update) emit({ + type: 'navigation', phase: 'preview', operation: 'zoom', axes, + factor: update.factor, anchor: update.anchor, + modifiers: interactionModifiers(event), + }); + return; + } + } if (!session || pointerId !== event.pointerId) return; const delta = session.move(localPoint(event)); pendingDelta = { x: pendingDelta.x + delta.x, y: pendingDelta.y + delta.y }; @@ -93,6 +141,24 @@ export function mountVegaNavigationGesture( pendingDelta = { x: 0, y: 0 }; }; const finish = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && touchPointers.has(event.pointerId)) { + touchPointers.delete(event.pointerId); + if (pinchSession) { + emit({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes, + modifiers: interactionModifiers(event), + }); + pinchSession = undefined; + touchPointers.clear(); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); + return; + } + } if (!session || pointerId !== event.pointerId) return; if (pendingDelta.x !== 0 || pendingDelta.y !== 0) { emit({ @@ -113,6 +179,24 @@ export function mountVegaNavigationGesture( if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); }; const cancel = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && touchPointers.has(event.pointerId)) { + touchPointers.delete(event.pointerId); + if (pinchSession) { + emit({ + type: 'navigation', phase: 'cancel', operation: 'zoom', axes, + modifiers: interactionModifiers(event), + }); + pinchSession = undefined; + touchPointers.clear(); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + setSuppressClick(false); + return; + } + } if (!session || pointerId !== event.pointerId) return; emit({ type: 'navigation', phase: 'cancel', operation: 'pan', axes, @@ -171,6 +255,8 @@ export function mountVegaNavigationGesture( container.style.cursor = previousCursor; container.style.touchAction = previousTouchAction; container.style.userSelect = previousUserSelect; + touchPointers.clear(); + pinchSession = undefined; setDragging(false); }, }; diff --git a/packages/flint-js/src/vegalite/interactions/gestures/region.ts b/packages/flint-js/src/vegalite/interactions/gestures/region.ts index cc72bb46..b3158303 100644 --- a/packages/flint-js/src/vegalite/interactions/gestures/region.ts +++ b/packages/flint-js/src/vegalite/interactions/gestures/region.ts @@ -18,12 +18,14 @@ import { type CartesianRegionAxis, type Interval, type IntervalOperation, + type PlotFrame, } from '../../../interactive/gestures/cartesian-region'; import { clientRectToLayoutRect, clientToLayoutPoint, clientToPlotPoint, interactionModifiers, + facetPlotFrameAt, normalizeVegaAngularRegionEvent, normalizeVegaLassoEvent, normalizeVegaRegionEvent, @@ -130,6 +132,8 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR let initialInterval: Interval | undefined; let angularSession: AngularRegionSession | undefined; let lassoPoints: PlotPoint[] = []; + let activePlotFrame: PlotFrame | undefined; + let dragPlotFrame: PlotFrame | undefined; const overlay = document.createElement('div'); Object.assign(overlay.style, { @@ -178,25 +182,35 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR const previousCursor = container.style.cursor; if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; container.style.userSelect = 'none'; - container.style.cursor = 'crosshair'; container.append(angularBrush ? angularOverlay : lassoBrush ? lassoOverlay : overlay); container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; const localPoint = (event: PointerEvent): PlotPoint => { return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); }; - const brushPlotSize = (): { width: number; height: number } => { + const rootPlotFrame = (): PlotFrame => { const space = coordinateSpace(); - return { width: space.plotWidth, height: space.plotHeight }; + return { x: 0, y: 0, width: space.plotWidth, height: space.plotHeight }; }; + const brushPlotFrame = (): PlotFrame => dragPlotFrame ?? activePlotFrame ?? rootPlotFrame(); const intervalAxis = (): 'x' | 'y' => regionAxis === 'y' ? 'y' : 'x'; - const axisLimit = (): number => intervalAxis() === 'y' ? brushPlotSize().height : brushPlotSize().width; const intervalForDrag = (point: PlotPoint): Interval => { - return updateInterval(point, dragStart!, intervalAxis(), axisLimit(), dragAction, initialInterval); + const frame = brushPlotFrame(); + const axis = intervalAxis(); + const origin = axis === 'y' ? frame.y : frame.x; + const limit = axis === 'y' ? frame.height : frame.width; + const localPoint = { ...point, [axis]: axisValue(point, axis) - origin }; + const localStart = { ...dragStart!, [axis]: axisValue(dragStart!, axis) - origin }; + const localInitial = initialInterval && { + leading: initialInterval.leading - origin, + trailing: initialInterval.trailing - origin, + }; + const interval = updateInterval(localPoint, localStart, axis, limit, dragAction, localInitial); + return { leading: interval.leading + origin, trailing: interval.trailing + origin }; }; const showRegion = (a: PlotPoint, b: PlotPoint): void => { if (!guide.visible) return; - const constrained = constrainCartesianRegion(a, b, regionAxis, brushPlotSize()); + const constrained = constrainCartesianRegion(a, b, regionAxis, brushPlotFrame()); const space = coordinateSpace(); const leading = plotToClientPoint({ x: Math.min(constrained.start.x, constrained.end.x), @@ -349,7 +363,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR ): void => { const normalized = normalizeVegaRegionEvent( view, start, end, phase, interaction.eventSource.match ?? 'intersect', - interactionModifiers(event), regionAxis, brushPlotSize(), operation, + interactionModifiers(event), regionAxis, brushPlotFrame(), operation, !interaction.eventSource.viewport, ); setSelected(new Set(committed)); @@ -366,6 +380,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR if (event.button !== 0 || isInteractiveControlTarget(event.target)) return; clearHover(); const point = localPoint(event); + const candidateFrame = facetPlotFrameAt(view, point, rootPlotFrame()); if (angularBrush) { const frame = frameAt(point); if (!frame) return; @@ -385,13 +400,18 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR } dragAction = 'create'; initialInterval = activeInterval ? { ...activeInterval } : undefined; + const insideActiveFrame = !activePlotFrame + || point.x >= activePlotFrame.x && point.x <= activePlotFrame.x + activePlotFrame.width + && point.y >= activePlotFrame.y && point.y <= activePlotFrame.y + activePlotFrame.height; + dragPlotFrame = candidateFrame; if (lassoBrush) lassoPoints = [point]; - if (statefulBrush && activeInterval) { + if (statefulBrush && activeInterval && insideActiveFrame) { const value = axisValue(point, intervalAxis()); const edgeTolerance = 8; if (Math.abs(value - activeInterval.leading) <= edgeTolerance) dragAction = 'resize-leading'; else if (Math.abs(value - activeInterval.trailing) <= edgeTolerance) dragAction = 'resize-trailing'; else if (value > activeInterval.leading && value < activeInterval.trailing) dragAction = 'move'; + if (dragAction !== 'create') dragPlotFrame = activePlotFrame; } dragStart = point; pointerId = event.pointerId; @@ -411,12 +431,16 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR return; } if (statefulBrush && activeInterval) { - const value = axisValue(localPoint(event), intervalAxis()); + const point = localPoint(event); + const insideFrame = !activePlotFrame + || point.x >= activePlotFrame.x && point.x <= activePlotFrame.x + activePlotFrame.width + && point.y >= activePlotFrame.y && point.y <= activePlotFrame.y + activePlotFrame.height; + const value = axisValue(point, intervalAxis()); const nearEdge = Math.abs(value - activeInterval.leading) <= 8 || Math.abs(value - activeInterval.trailing) <= 8; - container.style.cursor = nearEdge + container.style.cursor = insideFrame && nearEdge ? regionAxis === 'x' ? 'ew-resize' : 'ns-resize' - : value > activeInterval.leading && value < activeInterval.trailing ? 'grab' : 'crosshair'; + : insideFrame && value > activeInterval.leading && value < activeInterval.trailing ? 'grab' : 'crosshair'; } return; } @@ -497,6 +521,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR dispatchRegion('commit', points.start, points.end, event, dragAction); if (statefulBrush && interval) { activeInterval = interval; + activePlotFrame = dragPlotFrame; showInterval(interval); } } @@ -514,6 +539,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR || axisValue(point, intervalAxis()) > activeInterval.trailing; if (!statefulBrush || clickedOutside) { activeInterval = undefined; + activePlotFrame = undefined; committed.clear(); dispatchRegion('commit', dragStart, point, event, 'clear', null); } @@ -525,6 +551,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR initialSector = undefined; angularAction = 'create'; angularSession = undefined; + dragPlotFrame = undefined; setDragging(false); if (!statefulBrush || !activeInterval) overlay.style.display = 'none'; if (!statefulAngular || !activeSector) angularOverlay.style.display = 'none'; @@ -539,6 +566,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR initialInterval = undefined; angularSession = undefined; lassoPoints = []; + dragPlotFrame = undefined; lassoOverlay.style.display = 'none'; setDragging(false); if (statefulBrush && activeInterval) showInterval(activeInterval); @@ -563,12 +591,14 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR } else { setSelected(new Set()); activeInterval = undefined; + activePlotFrame = undefined; activeSector = undefined; clearAnnotation(); } dragStart = undefined; pointerId = undefined; initialInterval = undefined; + dragPlotFrame = undefined; setDragging(false); overlay.style.display = 'none'; angularOverlay.style.display = 'none'; diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts index 0d63d99e..6bfd9829 100644 --- a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -10,11 +10,16 @@ import type { InteractionPhase, PlotPoint, PlotAngularSector, - RegionAxis, RegionInteractionEvent, RegionOperation, } from '../../interactive/language/events'; import { angularSegments } from '../../interactive/geometry/angular'; +import { + constrainCartesianRegion, + type CartesianRegionAxis, + type PlotFrame, + type PlotSize, +} from '../../interactive/gestures/cartesian-region'; export { clientRectToLayoutRect, clientToLayoutPoint, @@ -213,6 +218,25 @@ export function sceneItems(view: any): any[] { return result; } +export function facetPlotFrameAt(view: any, point: PlotPoint, fallback: PlotFrame): PlotFrame { + const frames: PlotFrame[] = []; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const isGroup = item.mark?.marktype === 'group'; + const x = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const y = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (isGroup && (item.mark?.role === 'cell' || item.mark?.name === 'cell') + && typeof item.width === 'number' && typeof item.height === 'number' + && point.x >= x && point.x <= x + item.width + && point.y >= y && point.y <= y + item.height) { + frames.push({ x, y, width: item.width, height: item.height }); + } + if (Array.isArray(item.items)) item.items.forEach((child: any) => visit(child, x, y)); + }; + visit(view.scenegraph()?.root, 0, 0); + return frames.sort((left, right) => left.width * left.height - right.width * right.height)[0] ?? fallback; +} + export function boundsIntersectRect( bounds: SelectionRect, rect: SelectionRect, @@ -751,6 +775,31 @@ export function axisTargetIdentity( return { ...target, value: item.datum.value, role }; } +export function axisItemAt( + view: any, + point: PlotPoint, + targets: Readonly> | undefined, +): any | undefined { + let found: any | undefined; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const bounds = item.bounds; + if (axisTargetIdentity(item, targets) && bounds + && point.x >= bounds.x1 + offsetX && point.x <= bounds.x2 + offsetX + && point.y >= bounds.y1 + offsetY && point.y <= bounds.y2 + offsetY) { + found = item; + } + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (Array.isArray(item.items)) { + for (const child of item.items) visit(child, childOffsetX, childOffsetY); + } + }; + visit(view.scenegraph()?.root, 0, 0); + return found; +} + export interface NormalizedVegaElement { event: ElementInteractionEvent; role: 'mark' | 'legend-item' | 'text-label'; @@ -1159,19 +1208,13 @@ export function normalizeVegaRegionEvent( phase: InteractionPhase, match: 'intersect' | 'contain', modifiers: InteractionModifiers, - axis: RegionAxis = 'xy', - plotSize: { width: number; height: number } = { width: view.width(), height: view.height() }, + axis: CartesianRegionAxis = 'xy', + plotSize: PlotSize | PlotFrame = { width: view.width(), height: view.height() }, operation: RegionOperation = 'create', collectHits = true, ): RegionInteractionEvent { - const constrainedStart = { - x: axis === 'y' ? 0 : start.x, - y: axis === 'x' ? 0 : start.y, - }; - const constrainedEnd = { - x: axis === 'y' ? plotSize.width : end.x, - y: axis === 'x' ? plotSize.height : end.y, - }; + const { start: constrainedStart, end: constrainedEnd } = + constrainCartesianRegion(start, end, axis, plotSize); return { type: 'region', phase, diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts index fd5fb728..0912019d 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts @@ -211,6 +211,20 @@ export function sourceEdgeAttachment( return fallback; } +export function annotationPrimaryAnchor( + item: any, + source: LayoutRect, + card: LayoutRect, + connection: AnnotationConnection, + fallback: PlotPoint, +): PlotPoint { + if (item?.interactionGeometry + && ['top', 'right', 'bottom', 'left', 'segment-midpoint'].includes(connection)) { + return fallback; + } + return sourceEdgeAttachment(source, card, connection, fallback); +} + function routeIntersectsRect(route: AnnotationLeaderRoute, rect: LayoutRect, ignoreStartTouch = false): boolean { return route.points.slice(1).some((point, index) => segmentIntersectsRect(route.points[index], point, rect, ignoreStartTouch && index === 0)); @@ -451,6 +465,7 @@ export function createAnnotationOverlay({ annotationMarkType, }: AnnotationOverlayOptions): AnnotationOverlayController { const annotationLayer = document.createElement('div'); + annotationLayer.dataset.flintAnnotation = ''; Object.assign(annotationLayer.style, { position: 'absolute', inset: '0', zIndex: '4', pointerEvents: 'none', overflow: 'hidden', }); @@ -458,16 +473,21 @@ export function createAnnotationOverlay({ Object.assign(annotationSvg.style, { position: 'absolute', inset: '0', width: '100%', height: '100%' }); const annotationPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); annotationPath.setAttribute('fill', 'none'); - annotationPath.setAttribute('stroke', 'var(--flint-annotation-color, #6b7280)'); + annotationPath.setAttribute('stroke', 'var(--flint-annotation-line-color, #808080)'); annotationPath.setAttribute('stroke-width', '1.25'); annotationPath.setAttribute('stroke-linecap', 'round'); annotationSvg.append(annotationPath); const annotationCard = document.createElement('div'); Object.assign(annotationCard.style, { - position: 'absolute', color: 'var(--flint-annotation-color, #4b5563)', fontFamily: 'ui-sans-serif, sans-serif', - fontSize: '11px', fontWeight: '600', lineHeight: '1.35', whiteSpace: 'normal', width: 'max-content', - overflowWrap: 'break-word', padding: '2px 3px', borderRadius: '2px', - background: 'var(--flint-annotation-surface, rgba(255, 255, 255, 0.88))', + position: 'absolute', color: 'var(--flint-annotation-color, #000)', + fontFamily: 'var(--flint-annotation-font-family, sans-serif)', + fontSize: 'var(--flint-annotation-font-size, 11px)', + fontWeight: 'var(--flint-annotation-font-weight, 400)', lineHeight: 'normal', letterSpacing: '0', + whiteSpace: 'normal', width: 'max-content', overflowWrap: 'break-word', boxSizing: 'border-box', + padding: '8px', border: '1px solid var(--flint-annotation-border-color, #d9d9d9)', + borderRadius: 'var(--flint-annotation-border-radius, 3px)', + background: 'var(--flint-annotation-surface, rgba(255, 255, 255, 0.95))', + boxShadow: 'var(--flint-annotation-shadow, 2px 2px 4px rgba(0, 0, 0, 0.1))', }); annotationLayer.append(annotationSvg, annotationCard); @@ -503,7 +523,6 @@ export function createAnnotationOverlay({ annotationCard.textContent = annotation.text; annotationCard.style.whiteSpace = annotation.text.includes('\n') ? 'pre-line' : 'normal'; const directValue = annotation.text.length <= 18 && !annotation.text.includes('\n'); - annotationCard.style.padding = directValue ? '1px 3px' : '2px 3px'; const containerRect = container.getBoundingClientRect(); const space = coordinateSpace(); @@ -698,7 +717,8 @@ export function createAnnotationOverlay({ return [toLayout(connection.point)]; }); const fallbackAnchor = toLayout(best.connection.point); - const primaryAnchor = sourceEdgeAttachment( + const primaryAnchor = annotationPrimaryAnchor( + item, markSourceRect, best.card, best.candidate.connection, diff --git a/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts index 3a867551..f560c4b5 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts @@ -13,6 +13,7 @@ export interface DragReorderPreview { current: PlotPoint; axis?: 'x' | 'y'; source: SemanticTarget; + sourceItem?: any; destination: SemanticTarget; } @@ -56,10 +57,18 @@ export function eligibleReorderAxesForHit>( + axes: readonly T[], + identity: Pick, +): T[] { + return axes.filter(({ axis, field }) => axis === identity.axis && field === identity.field); +} + function targetValue(target: SemanticTarget, field: string): unknown { const element = target.elements[0]; return element?.records?.find((record) => record[field] !== undefined)?.[field] - ?? element?.value[field]; + ?? element?.value[field] + ?? (element?.value.field === field ? element.value.value : undefined); } export function activeReorderAxis>( @@ -142,7 +151,7 @@ export function createDragReorderOverlay({ layer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); const renderedElement = (item: any): SVGGraphicsElement | undefined => renderer - ? [...renderer.querySelectorAll('[role="graphics-symbol"]')] + ? [...renderer.querySelectorAll('[role="graphics-symbol"], text')] .find((candidate) => { const datum = (candidate as any).__data__; return datum?.mark === item.mark && datum?.datum === item.datum; @@ -218,6 +227,14 @@ export function createDragReorderOverlay({ layer.append(shape); } + if (preview.source.visual.kind === 'axis' && preview.sourceItem) { + const labelGhost = cloneRenderedElement(preview.sourceItem, delta); + if (labelGhost) { + labelGhost.setAttribute('opacity', '0.72'); + layer.append(labelGhost); + } + } + if (!Object.is(sourceValue, destinationValue)) { const bounds = destinationItems.reduce((result, item) => ({ x1: Math.min(result.x1, item.bounds.x1), y1: Math.min(result.y1, item.bounds.y1), diff --git a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts index f1ec0b98..cae1845a 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts @@ -92,6 +92,16 @@ export function hoverContrastOpacity(authoredOpacity: number): number { return authoredOpacity < 1 ? 1 : 0.9; } +export function areaSpotlightOpacity( + authoredOpacity: number, + currentOpacity: number, + selected: boolean, + hasSelection: boolean, +): number { + if (!hasSelection) return 1; + return selected ? authoredOpacity * 0.9 : currentOpacity; +} + export function createFocusOverlay({ view, container, @@ -213,8 +223,15 @@ export function createFocusOverlay({ shape.setAttribute('points', points.map((plotPoint: PlotPoint) => `${plotPoint.x},${plotPoint.y}`).join(' ')); shape.setAttribute('fill', hoverStyle?.fill ?? visual?.fill ?? item.fill ?? '#4c78a8'); const authoredFillOpacity = visual?.fillOpacity ?? 1; + const currentFillOpacity = (typeof item.opacity === 'number' ? item.opacity : 1) + * (typeof item.fillOpacity === 'number' ? item.fillOpacity : 1); const fillOpacity = hoverStyle?.opacity === 'spotlight' - ? 1 + ? areaSpotlightOpacity( + authoredFillOpacity, + currentFillOpacity, + typeof key === 'string' && selected.has(key), + selected.size > 0, + ) : hoverStyle?.opacity === 'contrast' ? hoverContrastOpacity(authoredFillOpacity) : hoverStyle?.fillOpacity ?? authoredFillOpacity; diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index 41e6942b..0c9adbe8 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -20,6 +20,11 @@ import type { SemanticInteractionEvent, } from '../../interactive/interactions'; import { isCanvasInteraction } from '../../interactive/interactions'; +import { + affordanceCursor, + resolveInteractionAffordance, + type InteractionAffordanceTarget, +} from '../../interactive/affordances'; import type { ChartUpdateResult, SemanticTargetRef, @@ -35,6 +40,7 @@ import type { ChartUpdateApplyOptions } from '../../interactive/types'; import { INTERACTION_KEY, PATH_KEY_SUFFIX, + axisItemAt, axisTargetIdentity, clientToPlotPoint, clientToRendererPoint, @@ -62,6 +68,7 @@ import { createVegaNavigationController } from './navigation-scale'; import { createAnnotationOverlay } from './presentation/annotation-overlay'; import { createDragReorderOverlay, + eligibleReorderAxesForAxis, eligibleReorderAxesForHit, } from './presentation/drag-reorder-overlay'; import { createFocusOverlay } from './presentation/focus-overlay'; @@ -241,13 +248,41 @@ export interface VegaInteractionController { export function interactionsForHoverPresentation( clickInteractions: readonly CanvasInteractionDef[], hoverInteractions: readonly CanvasInteractionDef[], + elementDragInteractions: readonly CanvasInteractionDef[] = [], ): CanvasInteractionDef[] { return [ ...hoverInteractions, - ...clickInteractions.filter((interaction) => interaction.handle), - ].filter((interaction, index, candidates) => - candidates.findIndex((candidate) => candidate.id === interaction.id) === index, - ); + ...clickInteractions, + ...elementDragInteractions, + ].filter((interaction, index, candidates) => interaction.affordances?.some((affordance) => affordance.hover) + && candidates.findIndex((candidate) => candidate.id === interaction.id) === index); +} + +type AnnotationUpdate = Extract; + +export interface EffectiveAnnotationEntry { + key: string; + element: SemanticTarget['elements'][number]; + value: NonNullable; +} + +export function effectiveAnnotationEntries(updates: readonly ChartUpdate[]): EffectiveAnnotationEntry[] { + const entries = new Map(); + for (const update of updates) { + for (const op of update.ops) { + if (op.op !== 'set-annotation' || 'select' in op.target) continue; + const element = op.target.elements[0]; + if (!element) continue; + const renderKeys = semanticElementRenderKeys(element); + const targetIdentity = renderKeys.length > 0 + ? renderKeys.join('\u001f') + : JSON.stringify([element.value, element.records ?? []]); + const key = `${update.id}\u001e${targetIdentity}`; + if (op.value === null) entries.delete(key); + else entries.set(key, { key, element, value: op.value }); + } + } + return [...entries.values()]; } function keyboardRepresentativeRank(item: any): [number, number] { @@ -314,6 +349,7 @@ export function mountVegaInteractions( resolve: ChartInteractionResolver | undefined, presentUpdate: ChartUpdatePresenter, assistDistance = 0, + hoverTolerance = 0, keyboardTargeting = false, targetFeedback: { assisted: import('../../interactive/types').TargetFeedbackOptions | false; @@ -329,9 +365,13 @@ export function mountVegaInteractions( ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'hover') : []; const axisClickInteractions = clickInteractions.filter((interaction) => interaction.claimsAxisActivation); - const markClickInteractions = clickInteractions.filter((interaction) => !interaction.claimsAxisActivation); + const markClickInteractions = clickInteractions.filter((interaction) => + !interaction.claimsAxisActivation || interaction.claimsLegendActivation); const axisHoverInteractions = hoverInteractions.filter((interaction) => interaction.claimsAxisActivation); const markHoverInteractions = hoverInteractions.filter((interaction) => !interaction.claimsAxisActivation); + const axisHoverPresentationInteractions = [...axisClickInteractions, ...axisHoverInteractions] + .filter((interaction) => interaction.affordances?.some((affordance) => + affordance.target === 'axis-label' && affordance.hover)); const contextInteractions = resolve ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'context') : []; @@ -344,19 +384,24 @@ export function mountVegaInteractions( const doubleInteractions = resolve ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'double') : []; + const elementDragInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'drag-element') + : []; const hoverPresentationInteractions = interactionsForHoverPresentation( markClickInteractions, markHoverInteractions, + elementDragInteractions, ); + const hoverPresentationForTarget = (target: InteractionAffordanceTarget): CanvasInteractionDef[] => + hoverPresentationInteractions.filter((interaction) => + resolveInteractionAffordance([interaction], target)?.hover); const regionInteraction = resolve ? canvasInteractions.find((interaction) => interaction.eventSource.gesture === 'drag') : undefined; const navigationInteraction = canvasInteractions.find( (interaction) => interaction.eventSource.type === 'navigation', ); - const elementDragInteraction = resolve - ? canvasInteractions.find((interaction) => interaction.eventSource.gesture === 'drag-element') - : undefined; + const elementDragInteraction = elementDragInteractions[0]; const retainedUpdates = new Map(); const previewUpdates = new Map(); const selectedElements = new Map(); @@ -411,13 +456,17 @@ export function mountVegaInteractions( containerLayoutSize, }); const legendRangeOverlay = createLegendRangeOverlay({ container, coordinateSpace, containerLayoutSize }); - const annotationOverlay = createAnnotationOverlay({ + const annotationOverlayOptions = { view, container, coordinateSpace, containerLayoutSize, annotationMarkType: plan.annotationMarkType, - }); + }; + const annotationOverlays = new Map>(); + const clearAnnotations = (): void => { + for (const overlay of annotationOverlays.values()) overlay.clear(); + }; const inspectGuideOverlay = createInspectGuideOverlay({ container, coordinateSpace, @@ -464,7 +513,6 @@ export function mountVegaInteractions( const selectedKeys = (): Set => new Set(selectedElements.keys()); const renderPathFocus = (): void => focusOverlay.render(selectedKeys(), hoveredPathKeys); const renderLegendRange = (): void => legendRangeOverlay.render(selectedLegend, hoveredLegend); - const clearAnnotation = (): void => annotationOverlay.clear(); renderPathFocus(); const allHits = (): RenderHit[] => sceneItems(view) @@ -637,7 +685,6 @@ export function mountVegaInteractions( 'opacity' | 'fill' | 'stroke' | 'strokeWidth'>> = {}; selectedElements.clear(); hiddenKeys.clear(); - let annotation: Extract | undefined; const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); for (const axis of reorderAxes) view.signal(axis.signal, null); for (const axis of Object.keys(plan.navigationAxes ?? {}) as ('x' | 'y')[]) { @@ -689,8 +736,6 @@ export function mountVegaInteractions( } } } - } else if (op.op === 'set-annotation') { - annotation = op; } else if (op.op === 'set-viewport') { navigationController.apply(op); } else if (op.op === 'set-order' && op.scope === 'category') { @@ -731,16 +776,25 @@ export function mountVegaInteractions( renderLegendRange(); reorderResetControls.layout(); viewportResetControl.layout(); - clearAnnotation(); - if (annotation?.value && !('select' in annotation.target)) { - const element = annotation.target.elements[0]; - if (element && annotation.value.text && annotation.value.candidates) { - annotationOverlay.render(element, { - ...annotation.value, - text: annotation.value.text, - candidates: annotation.value.candidates, - }); + const annotations = effectiveAnnotationEntries(displayUpdates) + .filter((entry) => entry.value.text && entry.value.candidates); + const annotationKeys = new Set(annotations.map((entry) => entry.key)); + for (const [key, overlay] of annotationOverlays) { + if (annotationKeys.has(key)) continue; + overlay.destroy(); + annotationOverlays.delete(key); + } + for (const annotation of annotations) { + let overlay = annotationOverlays.get(annotation.key); + if (!overlay) { + overlay = createAnnotationOverlay(annotationOverlayOptions); + annotationOverlays.set(annotation.key, overlay); } + overlay.render(annotation.element, { + ...annotation.value, + text: annotation.value.text!, + candidates: annotation.value.candidates!, + }); } }; @@ -875,6 +929,9 @@ export function mountVegaInteractions( }; let hoveredKeys = '\u0001\u0000'; let hoverActive = false; + let lastHoverTarget: SemanticTarget | null = null; + let lastHoverPoint: import('../../interactive/interactions').PlotPoint | null = null; + let hoverClearTimer: ReturnType | undefined; const setHover = async ( keys: readonly string[], legend: LegendHitIdentity | null = null, @@ -902,7 +959,13 @@ export function mountVegaInteractions( renderLegendRange(); }; const clearHover = (): void => { + if (hoverClearTimer !== undefined) { + clearTimeout(hoverClearTimer); + hoverClearTimer = undefined; + } targetFeedbackOverlay.clear(); + lastHoverTarget = null; + lastHoverPoint = null; void setHover([]); if (hoverInteractions.length > 0 && hoverActive) { hoverActive = false; @@ -914,6 +977,13 @@ export function mountVegaInteractions( } if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; }; + const scheduleHoverClear = (): void => { + if (hoverClearTimer !== undefined) clearTimeout(hoverClearTimer); + hoverClearTimer = setTimeout(() => { + hoverClearTimer = undefined; + clearHover(); + }, 16); + }; // A pointer that misses every mark still acquires the nearest one, so small // marks stay reachable without changing which action the preset receives. const acquire = ( @@ -922,17 +992,18 @@ export function mountVegaInteractions( rootPoint: import('../../interactive/interactions').PlotPoint, phase: 'preview' | 'commit', modifiers: ReturnType, + tolerance = assistDistance, ) => { const space = coordinateSpace(); const direct = normalizeVegaElementEvent( view, item, point, phase, modifiers, plan.legendFields, plan.rangeLegendChannels, rootPoint, ); - if (assistDistance <= 0 || direct.legend || direct.event.hits.length > 0) return { ...direct, feedbackItem: null }; + if (tolerance <= 0 || direct.legend || direct.event.hits.length > 0) return { ...direct, feedbackItem: null }; const rawPlotPoint = { x: rootPoint.x - space.originX, y: rootPoint.y - space.originY }; const overPlot = rawPlotPoint.x >= 0 && rawPlotPoint.x <= space.plotWidth && rawPlotPoint.y >= 0 && rawPlotPoint.y <= space.plotHeight; const snapped = nearestInteractiveSceneItem( - view, rawPlotPoint, assistDistance, rootPoint, overPlot, + view, rawPlotPoint, tolerance, rootPoint, overPlot, ); return snapped ? { ...normalizeVegaElementEvent( @@ -941,12 +1012,23 @@ export function mountVegaInteractions( : { ...direct, feedbackItem: null }; }; const hoverHandler = (event: MouseEvent, item: any): void => { - if ((hoverPresentationInteractions.length === 0 && axisHoverInteractions.length === 0) || regionDragging) return; + if ((hoverPresentationInteractions.length === 0 && axisHoverPresentationInteractions.length === 0) + || regionDragging) return; + if (hoverClearTimer !== undefined) { + clearTimeout(hoverClearTimer); + hoverClearTimer = undefined; + } const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); const axisTarget = resolveAxisTarget(item); if (axisTarget) { + const identity = axisTargetIdentity(item, plan.axisTargets); + const reorderEligible = !!elementDragInteraction && !!identity + && eligibleReorderAxesForAxis( + plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), + identity, + ).length > 0; + if (axisHoverPresentationInteractions.length === 0 && !reorderEligible) return clearHover(); hoverActive = true; - container.style.cursor = 'pointer'; for (const interaction of axisHoverInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'preview', target: axisTarget, point, @@ -959,10 +1041,12 @@ export function mountVegaInteractions( const normalized = acquire(item, point, rootPoint, 'preview', interactionModifiers(event)); const legend = normalized.legend; if (legend) { - if (!regionInteraction && !navigationInteraction) container.style.cursor = 'pointer'; + const legendHoverInteractions = hoverPresentationForTarget('legend-item'); + if (legendHoverInteractions.length === 0) return clearHover(); const resolved = legendSemanticTarget(legend); hoverActive = true; - for (const interaction of markHoverInteractions) { + for (const interaction of markHoverInteractions.filter((candidate) => + legendHoverInteractions.includes(candidate))) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, modifiers: normalized.event.modifiers, @@ -972,12 +1056,23 @@ export function mountVegaInteractions( return; } const hovered = normalized.event.hits[0]; - if (!hovered) { + const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const reorderEligible = !!hovered && !!elementDragInteraction + && eligibleReorderAxesForHit(reorderAxes, hovered).length > 0; + const directResolved = resolveTarget('hover', normalized.role, normalized.event.hits); + let resolved = directResolved; + if (!resolved && hoverTolerance > 0 && lastHoverTarget && lastHoverPoint + && Math.hypot(rootPoint.x - lastHoverPoint.x, rootPoint.y - lastHoverPoint.y) <= hoverTolerance) { + resolved = lastHoverTarget; + } + if (!resolved) { clearHover(); return; } - if (!regionInteraction && !navigationInteraction) container.style.cursor = 'pointer'; - const resolved = resolveTarget('hover', normalized.role, normalized.event.hits); + if (directResolved) { + lastHoverTarget = directResolved; + lastHoverPoint = rootPoint; + } if (normalized.feedbackItem && targetFeedback?.assisted) { targetFeedbackOverlay.render(normalized.feedbackItem, resolved, 'assisted'); } else { @@ -985,7 +1080,11 @@ export function mountVegaInteractions( } hoverActive = true; const interactionContext = context(); - const presentationElements = hoverPresentationInteractions.flatMap((interaction) => { + const markHoverPresentationInteractions = hoverPresentationForTarget('mark'); + const presentationElements = markHoverPresentationInteractions.flatMap((interaction) => { + if (interaction.eventSource.gesture === 'drag-element') { + return reorderEligible ? resolved?.elements ?? [] : []; + } if (!interaction.handle) return resolved?.elements ?? []; const preview = interaction.handle(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, @@ -996,7 +1095,8 @@ export function mountVegaInteractions( ? op.targets.flatMap((target) => 'select' in target ? [] : target.elements) : []) ?? []; }); - for (const interaction of markHoverInteractions) { + for (const interaction of markHoverInteractions.filter((candidate) => + markHoverPresentationInteractions.includes(candidate))) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, modifiers: normalized.event.modifiers, @@ -1027,6 +1127,7 @@ export function mountVegaInteractions( ) : resolveTarget('click', normalized.role, normalized.event.hits); for (const interaction of markClickInteractions) { + if (!legend && interaction.claimsLegendActivation) continue; void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target, point, modifiers: normalized.event.modifiers, @@ -1184,7 +1285,7 @@ export function mountVegaInteractions( if (changed) void renderUpdates(); }; const dismissOnClick = (event: MouseEvent, item: any): void => { - if (!dismissPolicy.click || isInteractiveControlTarget(event.target)) return; + if (!dismissPolicy.click || suppressClick || isInteractiveControlTarget(event.target)) return; if (consumeDismissClick) { consumeDismissClick = false; return; @@ -1238,6 +1339,7 @@ export function mountVegaInteractions( }; const doubleHandler = (event: MouseEvent): void => { if (doubleInteractions.length === 0) return; + event.preventDefault(); cancelPendingDismiss(); const acquired = pointerTarget(event); for (const interaction of doubleInteractions) { @@ -1263,13 +1365,14 @@ export function mountVegaInteractions( } if (hoverPresentationInteractions.length > 0) { view.addEventListener('mousemove', hoverHandler); - view.addEventListener('mouseout', clearHover); + view.addEventListener('mouseout', scheduleHoverClear); } const previousCursor = container.style.cursor; const previousUserSelect = container.style.userSelect; - const suppressLegendTextSelection = canvasInteractions.some((interaction) => interaction.claimsLegendActivation); - if (suppressLegendTextSelection) container.style.userSelect = 'none'; + const suppressTextSelection = doubleInteractions.length > 0 + || canvasInteractions.some((interaction) => interaction.claimsLegendActivation); + if (suppressTextSelection) container.style.userSelect = 'none'; const localPoint = (event: PointerEvent): { x: number; y: number } => { return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); }; @@ -1281,9 +1384,50 @@ export function mountVegaInteractions( rootPoint: clientToRendererPoint(client, space), }; }; + const cursorInteractions = canvasInteractions.filter((interaction) => + interaction.affordances?.some((affordance) => affordance.cursor)); + const setAffordanceCursor = ( + target: InteractionAffordanceTarget, + reorderEligible: boolean, + ): void => { + const eligibleIds = new Set(cursorInteractions + .filter((interaction) => reorderEligible || interaction !== elementDragInteraction) + .map((interaction) => interaction.id)); + container.style.cursor = affordanceCursor( + resolveInteractionAffordance(cursorInteractions, target, eligibleIds), + ) ?? previousCursor; + }; + const affordanceHandler = (event: MouseEvent, item: any): void => { + if (regionDragging) return; + const axisTarget = resolveAxisTarget(item); + if (axisTarget) { + const identity = axisTargetIdentity(item, plan.axisTargets); + const reorderEligible = !!elementDragInteraction && !!identity + && eligibleReorderAxesForAxis( + plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), + identity, + ).length > 0; + setAffordanceCursor('axis-label', reorderEligible); + return; + } + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const normalized = acquire(item, point, rootPoint, 'preview', interactionModifiers(event), 0); + if (normalized.legend) { + setAffordanceCursor('legend-item', false); + return; + } + const hit = normalized.event.hits[0]; + const reorderEligible = !!hit && !!elementDragInteraction + && eligibleReorderAxesForHit( + plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), hit, + ).length > 0; + setAffordanceCursor(hit ? 'mark' : 'plot', reorderEligible); + }; + if (cursorInteractions.length > 0) view.addEventListener('mousemove', affordanceHandler); let elementDrag: { start: { x: number; y: number }; source: SemanticTarget; + sourceItem?: any; destination: SemanticTarget; moved: boolean; axis?: 'x' | 'y'; @@ -1300,11 +1444,27 @@ export function mountVegaInteractions( && point.y >= bounds.y1 && point.y <= bounds.y2; }); }; - const resolveDraggedTarget = (event: PointerEvent): { hit: RenderHit; target: SemanticTarget } | null => { + const resolveDraggedTarget = ( + event: PointerEvent, + ): { target: SemanticTarget; item: any; eligibleAxes: readonly ('x' | 'y')[] } | null => { + const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const eventItem = (event.target as any)?.__data__; + const axisItem = axisTargetIdentity(eventItem, plan.axisTargets) + ? eventItem + : axisItemAt(view, pointerPoints(event).rootPoint, plan.axisTargets); + const axisIdentity = axisTargetIdentity(axisItem, plan.axisTargets); + if (axisIdentity) { + const eligibleAxes = eligibleReorderAxesForAxis(axes, axisIdentity).map(({ axis }) => axis); + const target = eligibleAxes.length > 0 ? resolveAxisTarget(axisItem) : null; + if (target) return { target, item: axisItem, eligibleAxes }; + } const hit = renderHit(reorderItemAt(event)); if (!hit) return null; const target = resolveTarget('click', hit.layerRole ?? hit.markType ?? 'mark', [hit]); - return target ? { hit, target } : null; + const eligibleAxes = eligibleReorderAxesForHit(axes, hit).map(({ axis }) => axis); + return target && eligibleAxes.length > 0 + ? { target, item: reorderItemAt(event), eligibleAxes } + : null; }; const resolveReorderDestination = ( current: { x: number; y: number }, @@ -1346,28 +1506,28 @@ export function mountVegaInteractions( if (!elementDragInteraction || (event.button !== undefined && event.button !== 0)) return; const source = resolveDraggedTarget(event); if (!source) return; - const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); - const eligibleAxes = eligibleReorderAxesForHit(axes, source.hit).map(({ axis }) => axis); - if (eligibleAxes.length === 0) return; const start = localPoint(event); - elementDrag = { start, source: source.target, destination: source.target, moved: false, eligibleAxes }; + elementDrag = { + start, source: source.target, destination: source.target, + sourceItem: source.item, moved: false, eligibleAxes: source.eligibleAxes, + }; try { container.setPointerCapture?.(event.pointerId); } catch { // Synthetic pointer events have no active pointer to capture. } clearHover(); - clearAnnotation(); + clearAnnotations(); container.style.cursor = 'grab'; void dispatchElementDrag('start', event, start); }; const elementDragMove = (event: PointerEvent): void => { if (!elementDrag) { const source = resolveDraggedTarget(event); - const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); - container.style.cursor = source && eligibleReorderAxesForHit(axes, source.hit).length > 0 - ? 'grab' - : previousCursor; + setAffordanceCursor( + source?.target.visual.kind === 'axis' ? 'axis-label' : source ? 'mark' : 'plot', + Boolean(source), + ); return; } const current = localPoint(event); @@ -1392,7 +1552,8 @@ export function mountVegaInteractions( container.style.cursor = 'grabbing'; dragReorderOverlay.render({ start: elementDrag.start, current, axis: elementDrag.axis, - source: elementDrag.source, destination: elementDrag.destination, + source: elementDrag.source, sourceItem: elementDrag.sourceItem, + destination: elementDrag.destination, }); void dispatchElementDrag('preview', event, current); }; @@ -1459,7 +1620,7 @@ export function mountVegaInteractions( resolveTarget: (gesture, role, hits) => resolveTarget(gesture, role, hits), dispatch: (event) => dispatch(mountedRegionInteraction, event), clearHover, - clearAnnotation, + clearAnnotation: clearAnnotations, sync: renderUpdates, setSuppressClick: (suppress) => { suppressClick = suppress; }, setDragging: (dragging) => { regionDragging = dragging; }, @@ -1474,6 +1635,7 @@ export function mountVegaInteractions( setSuppressClick: (suppress) => { suppressClick = suppress; }, setDragging: (dragging) => { regionDragging = dragging; }, }) : undefined; + setAffordanceCursor('plot', false); const dismissKeyDown = (event: KeyboardEvent): void => { if (regionInteraction || event.key !== 'Escape' || !dismissPolicy.escape) return; selectedLegend = null; @@ -1498,6 +1660,10 @@ export function mountVegaInteractions( key: hit.datum[INTERACTION_KEY], }; }; + const keyboardInteraction: CanvasInteractionDef = { + id: 'keyboard-targeting', + eventSource: keyboardTrigger, + }; const moveKeyboardTarget = (direction: SpatialDirection): void => { const items = keyboardTargets(); if (items.length === 0) return; @@ -1520,13 +1686,10 @@ export function mountVegaInteractions( if (!active) return; activeKeyboardKey = typeof active.key === 'string' ? active.key : undefined; if (targetFeedback?.keyboard) targetFeedbackOverlay.render(next, active.target, 'keyboard'); - const interaction = clickInteractions[0]; - if (interaction) { - emitCanvasInteractionEvent(interaction, toCanvasInteractionEvent({ - type: 'semantic', source: 'element', phase: 'preview', - target: active.target, point: active.point, - }, keyboardTrigger)); - } + emitCanvasInteractionEvent(keyboardInteraction, toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', + target: active.target, point: active.point, + }, keyboardTrigger)); void setHover(activeKeyboardKey ? [activeKeyboardKey] : []); }; const activateKeyboardTarget = (): void => { @@ -1573,7 +1736,7 @@ export function mountVegaInteractions( default: } }; - const keyboardEnabled = keyboardTargeting && clickInteractions.length > 0; + const keyboardEnabled = keyboardTargeting && !!resolve; const keyboardFocusOut = (event: FocusEvent): void => { if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return; activeKeyboardKey = undefined; @@ -1591,7 +1754,7 @@ export function mountVegaInteractions( const syncOverlays = (): void => { renderPathFocus(); renderLegendRange(); - annotationOverlay.sync(); + for (const overlay of annotationOverlays.values()) overlay.sync(); regionGesture?.sync(); reorderResetControls.layout(); }; @@ -1630,8 +1793,10 @@ export function mountVegaInteractions( } if (hoverPresentationInteractions.length > 0) { view.removeEventListener('mousemove', hoverHandler); - view.removeEventListener('mouseout', clearHover); + view.removeEventListener('mouseout', scheduleHoverClear); } + if (cursorInteractions.length > 0) view.removeEventListener('mousemove', affordanceHandler); + if (hoverClearTimer !== undefined) clearTimeout(hoverClearTimer); if (dismissPolicy.escape && !regionInteraction) { container.removeEventListener('keydown', dismissKeyDown); } @@ -1669,7 +1834,8 @@ export function mountVegaInteractions( focusOverlay.destroy(); targetFeedbackOverlay.destroy(); legendRangeOverlay.destroy(); - annotationOverlay.destroy(); + for (const overlay of annotationOverlays.values()) overlay.destroy(); + annotationOverlays.clear(); inspectGuideOverlay.destroy(); dragReorderOverlay.destroy(); reorderResetControls.destroy(); @@ -1680,7 +1846,7 @@ export function mountVegaInteractions( cancelAnimationFrame(syncFrame); syncFrame = undefined; } - if (elementDragInteraction || suppressLegendTextSelection) container.style.userSelect = previousUserSelect; + if (elementDragInteraction || suppressTextSelection) container.style.userSelect = previousUserSelect; if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; }; const clearUpdate = async (id: string): Promise => { diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index 0d0e39ec..3343a00a 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -24,6 +24,7 @@ export interface VegaInteractiveRendererOptions { expressionInterpreter?: unknown; background?: string; assistDistance?: number; + hoverTolerance?: number; keyboardTargeting?: boolean; targetFeedback?: { assisted: TargetFeedbackOptions | false; keyboard: TargetFeedbackOptions | false }; dismiss?: InteractionDismissPolicy | false; @@ -78,7 +79,11 @@ export function createVegaInteractiveRenderer( ); const vegaSpec = compile(vlSpec).spec as any; if (interactionPlan) { - interactionPlan.axisTargets = collectVegaAxisTargets(vegaSpec, interactionPlan.axisFields); + interactionPlan.axisTargets = collectVegaAxisTargets( + vegaSpec, + interactionPlan.axisFields, + interactionPlan.reorderAxes, + ); if (interactionPlan.semanticStores) { injectVegaInteractionStore(vegaSpec, interactionPlan); } @@ -121,6 +126,7 @@ export function createVegaInteractiveRenderer( interactionPlan.resolve, interactionPlan.presentUpdate ?? ((update) => update), options.assistDistance ?? 0, + options.hoverTolerance ?? 0, options.keyboardTargeting ?? false, options.targetFeedback, options.dismiss, diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 9eaceeaa..5fb851f8 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -18,10 +18,14 @@ */ import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; -import { legendMatchedHits, MUTED_HOVER_STROKE, targetFromHits } from '../../core/interaction-semantics'; +import { + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, + type SemanticElement, +} from '../../core/interaction-semantics'; import { annotationCandidates, - categoryValueAnnotationText, presentAnnotationUpdate, } from '../../interactive/presentation/annotation'; @@ -40,6 +44,21 @@ const DATE_FIELD = '__flintCalendarDate'; const WEEK_FIELD = '__flintCalendarWeek'; const WEEKDAY_FIELD = '__flintCalendarWeekday'; +function calendarAnnotationText(valueField: string): (element: SemanticElement) => string | undefined { + return (element) => { + const record = element.value ?? element.records?.[0] ?? {}; + const rawDate = record[DATE_FIELD]; + const rawValue = record[`sum_${valueField}`]; + const date = typeof rawDate === 'number' && Number.isFinite(rawDate) + ? new Intl.DateTimeFormat(undefined, { timeZone: 'UTC' }).format(new Date(rawDate)) + : undefined; + const value = typeof rawValue === 'number' && Number.isFinite(rawValue) + ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 3 }).format(rawValue) + : rawValue === null || rawValue === undefined ? undefined : String(rawValue); + return date && value ? `${date}: ${value}` : date ?? value; + }; +} + function calendarDate(raw: unknown): Date | undefined { if (raw instanceof Date) { return Number.isFinite(raw.getTime()) ? new Date(raw.getTime()) : undefined; @@ -108,7 +127,7 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { ), presentUpdate: presentAnnotationUpdate( () => annotationCandidates('center', 'top', 'right', 'bottom', 'left'), - categoryValueAnnotationText(DATE_FIELD, valueField), + calendarAnnotationText(valueField), ), }; }, diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index f6d51226..6dd0dadb 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -18,9 +18,9 @@ import { } from '../../core/interaction-semantics'; import { annotationCandidates, + boxplotAnnotationText, presentAnnotationUpdate, seriesValuesAnnotationText, - suppressAnnotationUpdate, } from '../../interactive/presentation/annotation'; const isDiscreteType = (t: string | undefined) => t === 'nominal' || t === 'ordinal'; @@ -326,9 +326,9 @@ export const boxplotDef: ChartTemplateDef = { legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['boxplot'], renderHoverStyles: { - rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, - rule: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, - symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + rect: { opacity: 'contrast' }, + rule: { opacity: 'contrast' }, + symbol: { opacity: 'contrast' }, }, resolve: (event, context) => { const legendField = event.legend?.field ?? seriesField; @@ -337,7 +337,10 @@ export const boxplotDef: ChartTemplateDef = { : event.hits; return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'distribution' }); }, - presentUpdate: suppressAnnotationUpdate, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('center', 'top', 'right', 'left'), + boxplotAnnotationText, + ), }; }, declareLayoutMode: (cs, table, chartProperties) => { diff --git a/packages/flint-js/src/vegalite/templates/sparkline.ts b/packages/flint-js/src/vegalite/templates/sparkline.ts index 5bc6336b..2ac4f760 100644 --- a/packages/flint-js/src/vegalite/templates/sparkline.ts +++ b/packages/flint-js/src/vegalite/templates/sparkline.ts @@ -10,7 +10,11 @@ import { } from '../../core/interaction-semantics'; import { formatSpecToVegaExpr } from '../format'; import { interpolateConfigProperty, applyInterpolate } from './line'; -import { suppressAnnotationUpdate } from '../../interactive/presentation/annotation'; +import { + annotationCandidates, + presentAnnotationUpdate, + transitionAnnotationText, +} from '../../interactive/presentation/annotation'; /** * Sparkline — a "sparkline table" / small-multiples strip layout. @@ -116,6 +120,7 @@ export const sparklineDef: ChartTemplateDef = { markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['row', 'color', 'detail']); + const valueField = resolvedEncodings.y?.field; return { fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'row', 'color', 'detail']), categoryField: seriesField, @@ -124,7 +129,10 @@ export const sparklineDef: ChartTemplateDef = { renderHoverStyles: { line: { strokeWidth: 3 } }, renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), - presentUpdate: suppressAnnotationUpdate, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint', 'right', 'left', 'top', 'bottom'), + transitionAnnotationText(valueField), + ), }; }, diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index 8106b63d..f637cfc4 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupHighlight, clickHighlight, doubleActivate, dragReorder, externalInteraction, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickGroupHighlight, clickHighlight, clickLegendIsolate, clickMark, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, hoverGroupHighlight, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { affordanceCursor, resolveInteractionAffordance } from '../src/interactive/affordances'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; import { toCanvasInteractionEvent } from '../src/interactive/canvas-interaction'; @@ -33,11 +34,13 @@ import { intervalPoints, updateInterval, } from '../src/interactive/gestures/cartesian-region'; -import { PanSession, wheelZoomFactor } from '../src/interactive/gestures/navigation'; +import { PanSession, PinchSession, wheelZoomFactor } from '../src/interactive/gestures/navigation'; import { guardNavigationDomain } from '../src/vegalite/interactions/navigation-scale'; import { geometryIntersectsRect, + axisItemAt, axisIntersectingHits, + facetPlotFrameAt, INTERACTION_KEY, nearestItemByBounds, nearestItemOnInspectAxis, @@ -53,6 +56,7 @@ import { sceneItems, } from '../src/vegalite/interactions/hit-adapter'; import { + effectiveAnnotationEntries, interactionsForHoverPresentation, domainForPlotGeometry, keyboardTargetItems, @@ -62,10 +66,11 @@ import { import { activeReorderAxis, eligibleReorderAxes, + eligibleReorderAxesForAxis, eligibleReorderAxesForHit, reorderOwnedItems, } from '../src/vegalite/interactions/presentation/drag-reorder-overlay'; -import { hoverContrastOpacity } from '../src/vegalite/interactions/presentation/focus-overlay'; +import { areaSpotlightOpacity, hoverContrastOpacity } from '../src/vegalite/interactions/presentation/focus-overlay'; import { targetFeedbackDetailsPosition, targetFeedbackEntries, @@ -180,6 +185,31 @@ describe('physical region gestures', () => { expect(cartesianDragDistance(start, end, 'xy')).toBeCloseTo(Math.hypot(60, 60)); }); + it('projects a Cartesian region within its containing facet frame', () => { + const frame = { x: 15, y: 77, width: 80, height: 40 }; + const view = { + scenegraph: () => ({ + root: { + mark: { marktype: 'group' }, x: 5, y: 7, + items: [ + { mark: { marktype: 'group', role: 'scope', name: 'cell' }, x: 10, y: 20, width: 80, height: 40 }, + { mark: { marktype: 'group', role: 'scope', name: 'cell' }, x: 10, y: 70, width: 80, height: 40 }, + ], + }, + }), + }; + + expect(facetPlotFrameAt(view, { x: 30, y: 90 }, { x: 0, y: 0, width: 200, height: 150 })) + .toEqual(frame); + expect(constrainCartesianRegion({ x: 25, y: 90 }, { x: 70, y: 130 }, 'x', frame)).toEqual({ + start: { x: 25, y: 77 }, end: { x: 70, y: 117 }, + }); + expect(normalizeVegaRegionEvent( + view, { x: 25, y: 90 }, { x: 70, y: 130 }, 'commit', 'intersect', + { shift: false, ctrl: false, meta: false }, 'x', frame, 'create', false, + ).region).toEqual({ x: 25, y: 77, width: 45, height: 40 }); + }); + it('creates, moves, and resizes stateful Cartesian intervals', () => { expect(updateInterval({ x: 30, y: 0 }, { x: 70, y: 0 }, 'x', 100, 'create')).toEqual({ leading: 30, trailing: 70, @@ -336,15 +366,19 @@ describe('hover presentation policy', () => { it('computes generic overlay opacity contrast', () => { expect(hoverContrastOpacity(0.6)).toBe(1); expect(hoverContrastOpacity(1)).toBe(0.9); + expect(areaSpotlightOpacity(1, 0.25, true, true)).toBe(0.9); + expect(areaSpotlightOpacity(1, 0.25, false, true)).toBe(0.25); + expect(areaSpotlightOpacity(1, 1, false, false)).toBe(1); }); - it('includes click presets but not output-only click observers', () => { + it('includes only interactions that register hover presentation', () => { const preset = clickHighlight(); const observer: InteractionDef = { id: 'click-observer', eventSource: clickTrigger }; const hover: InteractionDef = { id: 'hover-observer', eventSource: hoverTrigger }; + const reorder = dragReorder(); - expect(interactionsForHoverPresentation([preset, observer], [hover]).map(({ id }) => id)) - .toEqual(['hover-observer', 'click-highlight']); + expect(interactionsForHoverPresentation([preset, observer], [hover], [reorder]).map(({ id }) => id)) + .toEqual(['click-highlight', 'drag-reorder']); }); it('expands group hover presentation to the committed cohort', () => { @@ -437,6 +471,36 @@ describe('viewport navigation', () => { expect(wheelZoomFactor(1, 1, 400, 0.002)).toBeCloseTo(Math.exp(-0.032)); }); + it('normalizes incremental pinch distance around the moving midpoint', () => { + const pinch = new PinchSession( + { x: 20, y: 20 }, + { x: 80, y: 20 }, + { width: 100, height: 200 }, + ); + expect(pinch.move({ x: 10, y: 30 }, { x: 90, y: 30 })).toEqual({ + factor: 4 / 3, + anchor: { x: 0.5, y: 0.15 }, + }); + expect(pinch.move({ x: 30, y: 50 }, { x: 70, y: 50 })).toEqual({ + factor: 0.5, + anchor: { x: 0.5, y: 0.25 }, + }); + }); + + it('ignores a collapsed pinch until the pointers separate', () => { + const pinch = new PinchSession( + { x: 50, y: 50 }, + { x: 50, y: 50 }, + { width: 100, height: 100 }, + ); + expect(pinch.move({ x: 50, y: 50 }, { x: 50, y: 50 })).toBeNull(); + expect(pinch.move({ x: 40, y: 50 }, { x: 60, y: 50 })).toBeNull(); + expect(pinch.move({ x: 30, y: 50 }, { x: 70, y: 50 })).toEqual({ + factor: 2, + anchor: { x: 0.5, y: 0.5 }, + }); + }); + it('resolves normalized navigation input through its viewport handler', () => { const interaction = navigate({ axes: 'xy' }); expect(interaction.eventSource).toEqual(navigationTrigger({ axes: 'xy' })); @@ -560,6 +624,41 @@ describe('interaction definitions', () => { })).toEqual([]); }); + it('matches an axis drag source by its exact axis and field', () => { + const axes = [ + { axis: 'x' as const, field: 'Category' }, + { axis: 'y' as const, field: 'Category' }, + { axis: 'x' as const, field: 'Series' }, + ]; + + expect(eligibleReorderAxesForAxis(axes, { axis: 'x', field: 'Category' })) + .toEqual([axes[0]]); + }); + + it('finds a categorical axis label in a nested Canvas scenegraph', () => { + const axisGroup = { mark: { role: 'axis' }, datum: { scale: 'x' } }; + const label = { + mark: { role: 'axis-label', group: axisGroup }, + datum: { value: 'B' }, + bounds: { x1: 10, x2: 30, y1: 40, y2: 55 }, + }; + const view = { + scenegraph: () => ({ + root: { + mark: { marktype: 'group' }, x: 5, y: 7, + items: [label], + }, + }), + }; + + expect(axisItemAt(view, { x: 20, y: 52 }, { + x: { axis: 'x', field: 'Category', type: 'nominal' }, + })).toBe(label); + expect(axisItemAt(view, { x: 2, y: 2 }, { + x: { axis: 'x', field: 'Category', type: 'nominal' }, + })).toBeUndefined(); + }); + it('resolves reorder destinations by nearest axis slot, including gaps and plot edges', () => { const items = ['A', 'B', 'C'].map((Category, index) => ({ datum: { [INTERACTION_KEY]: Category, Category }, @@ -600,6 +699,35 @@ describe('interaction definitions', () => { }); }); + it('lowers an axis-label drag to the same category-order update', () => { + const interaction = dragReorder(); + const elements = ['A', 'B', 'C'].map((Category) => ({ + value: { key: Category }, records: [{ Category }], + })); + const update = interaction.handle!({ + action: 'drag-element', phase: 'commit', + geometry: { + plot: { + kind: 'drag', start: { x: 10, y: 20 }, current: { x: 80, y: 20 }, + delta: { x: 70, y: 0 }, axis: 'x', + }, + }, + target: { + visual: { kind: 'axis', role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }, + dropTarget: { visual: { kind: 'mark', role: 'bar' }, elements: [elements[2]] }, + }, { + chartType: 'Bar Chart', selected: [], available: elements, + categoryField: 'Category', categoryAxis: 'x', + }); + + expect(update).toEqual({ + id: 'drag-reorder', + ops: [{ op: 'set-order', scope: 'category', field: 'Category', values: ['B', 'C', 'A'] }], + }); + }); + it('composes sequential category reorders against the current order', () => { const interaction = dragReorder(); const elements = ['1', '2', '3', '4', '5'].map((Category) => ({ @@ -645,6 +773,42 @@ describe('interaction definitions', () => { expect(update?.ops[0]).toEqual({ op: 'set-order', scope: 'category', field, values: orderedValues }); }); + it('preserves the column order when a Heatmap row reorder replaces the same retained update', () => { + const interaction = dragReorder(); + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; + const drag = (axis: 'x' | 'y', columnOrder: readonly string[], rowOrder: readonly string[]) => + interaction.handle!({ + action: 'drag-element', phase: 'commit', + geometry: { + plot: { + kind: 'drag', start: { x: 0, y: 0 }, current: { x: 70, y: 70 }, + delta: { x: 70, y: 70 }, axis, + }, + }, + target: { visual: { kind: 'mark', role: 'cell' }, elements: [source] }, + dropTarget: { visual: { kind: 'mark', role: 'cell' }, elements: [destination] }, + }, { + chartType: 'Heatmap', selected: [], + reorderAxes: [ + { axis: 'x', field: 'column', order: columnOrder }, + { axis: 'y', field: 'row', order: rowOrder }, + ], + }); + + const columnUpdate = drag('x', ['A', 'B'], ['R1', 'R2']); + expect(columnUpdate?.ops).toEqual([ + { op: 'set-order', scope: 'category', field: 'column', values: ['B', 'A'] }, + { op: 'set-order', scope: 'category', field: 'row', values: ['R1', 'R2'] }, + ]); + + const rowUpdate = drag('y', ['B', 'A'], ['R1', 'R2']); + expect(rowUpdate?.ops).toEqual([ + { op: 'set-order', scope: 'category', field: 'row', values: ['R2', 'R1'] }, + { op: 'set-order', scope: 'category', field: 'column', values: ['B', 'A'] }, + ]); + }); + it('keeps a Heatmap drag on its locked axis after the pointer changes direction', () => { const interaction = dragReorder(); const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; @@ -707,6 +871,56 @@ describe('interaction definitions', () => { expect(navigate().eventSource).toEqual(navigationTrigger()); }); + it('resolves composed affordances by target and interaction priority', () => { + expect(affordanceCursor(resolveInteractionAffordance([clickHighlight()], 'mark'))).toBe('pointer'); + expect(affordanceCursor(resolveInteractionAffordance( + [clickHighlight(), select()], 'mark', + ))).toBe('pointer'); + expect(affordanceCursor(resolveInteractionAffordance( + [clickHighlight(), select()], 'plot', + ))).toBe('crosshair'); + expect(resolveInteractionAffordance([select()], 'mark')).toMatchObject({ + target: 'mark', cursor: 'region', + }); + expect(affordanceCursor(resolveInteractionAffordance( + [clickHighlight(), dragReorder()], 'mark', new Set(['click-highlight']), + ))).toBe('pointer'); + expect(resolveInteractionAffordance([hoverGroupHighlight({ groupBy: 'Series' })], 'mark')) + .toMatchObject({ hover: 'cohort' }); + }); + + it('declares target-specific affordances for label and legend composition', () => { + expect(resolveInteractionAffordance([axisHighlight()], 'axis-label')) + .toMatchObject({ cursor: 'activate', hover: 'cohort' }); + expect(resolveInteractionAffordance([clickAxisIsolate()], 'axis-label')) + .toMatchObject({ cursor: 'activate', hover: 'cohort' }); + expect(resolveInteractionAffordance([clickLegendIsolate()], 'legend-item')) + .toMatchObject({ cursor: 'activate', hover: 'cohort' }); + expect(resolveInteractionAffordance([clickLegendIsolate()], 'axis-label')) + .toBeUndefined(); + expect(resolveInteractionAffordance([clickHighlight({ legend: false })], 'legend-item')) + .toBeUndefined(); + expect(resolveInteractionAffordance([navigate({ pan: false })], 'plot')) + .toBeUndefined(); + }); + + it('composes click highlight from independent mark, legend, and axis presets', () => { + const interactions = [ + clickMark(), + clickLegendIsolate(), + clickAxisIsolate(), + ]; + + expect(normalizeInteractions(interactions).map((interaction) => interaction.id)).toEqual([ + 'click-mark', + 'click-legend-isolate', + 'click-axis-isolate', + ]); + expect(resolveInteractionAffordance(interactions, 'mark')).toBeDefined(); + expect(resolveInteractionAffordance(interactions, 'legend-item')).toBeDefined(); + expect(resolveInteractionAffordance(interactions, 'axis-label')).toBeDefined(); + }); + it('provides reusable trigger descriptors', () => { expect(clickTrigger).toEqual({ type: 'element', gesture: 'click' }); expect(hoverTrigger).toEqual({ type: 'element', gesture: 'hover' }); @@ -769,6 +983,11 @@ describe('interaction definitions', () => { }); it('creates preset definitions with stable defaults', () => { + expect(clickMark()).toMatchObject({ id: 'click-mark', eventSource: clickTrigger }); + expect(clickGroupFocus()).toMatchObject({ id: 'click-group-focus', eventSource: clickTrigger }); + expect(hoverGroupFocus({ groupBy: 'Series' })).toMatchObject({ + id: 'hover-group-focus', eventSource: hoverTrigger, + }); expect(clickHighlight()).toMatchObject({ id: 'click-highlight', eventSource: clickTrigger }); expect(axisHighlight()).toMatchObject({ id: 'axis-highlight', eventSource: clickTrigger, claimsAxisActivation: true, @@ -914,26 +1133,58 @@ describe('interaction definitions', () => { }); }); - it('keeps basic clicks local and lets group clicks propagate to the series', () => { + it('keeps clickMark local while allowing explicit and automatic mark partitions', () => { const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ value: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }], + elements: [{ value: { key: 'west-consumer' }, records: [{ auto: 'West', Region: 'West', Segment: 'Consumer' }] }], }; const context = { chartType: 'Grouped Bar Chart', selected: [], seriesField: 'Segment', available: [ - { value: { key: 'west-consumer' }, records: [{ Segment: 'Consumer' }] }, - { value: { key: 'east-consumer' }, records: [{ Segment: 'Consumer' }] }, - { value: { key: 'west-corporate' }, records: [{ Segment: 'Corporate' }] }, + { value: { key: 'west-consumer' }, records: [{ auto: 'West', Region: 'West', Segment: 'Consumer' }] }, + { value: { key: 'east-consumer' }, records: [{ auto: 'East', Region: 'East', Segment: 'Consumer' }] }, + { value: { key: 'west-corporate' }, records: [{ auto: 'West', Region: 'West', Segment: 'Corporate' }] }, ], }; expect(semanticUpdate(clickHighlight(), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], }); - expect(semanticUpdate(clickGroupHighlight(), target, context)?.ops[0]).toMatchObject({ + expect(semanticUpdate(clickMark(), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], + }); + expect(semanticUpdate(clickMark({ groupBy: ['Segment'] }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, + ] }], + }); + expect(semanticUpdate(clickMark({ groupBy: ['Region', 'Segment'] }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], + }); + expect(semanticUpdate(clickMark({ + groupBy: (element) => element.records?.[0]?.Region, + }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'west-corporate' } }, + ] }], + }); + expect(semanticUpdate(clickGroupFocus(), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, + ] }], + }); + expect(semanticUpdate(clickGroupFocus({ groupBy: 'auto' }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'west-corporate' } }, + ] }], + }); + expect(semanticUpdate(clickGroupFocus({ groupBy: 'Segment' }), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ { value: { key: 'west-consumer' } }, { value: { key: 'east-consumer' } }, @@ -1135,6 +1386,95 @@ describe('interaction definitions', () => { }); }); + it('links a brushed semantic key across facet panels', () => { + const interaction = facetBrushLink({ by: 'Country' }); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { Country: 'France', Source: 'Fossil' }, records: [{ Country: 'France', Source: 'Fossil', Share: 8 }] }], + }; + const context = { + chartType: 'Bar Chart', + selected: [], + available: [ + ...target.elements, + { value: { Country: 'France', Source: 'Nuclear' }, records: [{ Country: 'France', Source: 'Nuclear', Share: 65 }] }, + { value: { Country: 'France', Source: 'Renewables' }, records: [{ Country: 'France', Source: 'Renewables', Share: 27 }] }, + { value: { Country: 'Germany', Source: 'Fossil' }, records: [{ Country: 'Germany', Source: 'Fossil', Share: 45 }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context, { source: 'region' })?.ops[0]).toMatchObject({ + targets: [{ elements: context.available.slice(0, 3) }], + }); + }); + + it('supports compound link keys and lasso acquisition', () => { + const interaction = facetBrushLink({ by: ['Country', 'Product'], brush: 'lasso' }); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ value: {}, records: [{ Country: 'France', Product: 'A', Year: 2020 }] }], + }; + const linked = { value: {}, records: [{ Country: 'France', Product: 'A', Year: 2024 }] }; + const context = { + chartType: 'Scatter Plot', + selected: [], + available: [ + ...target.elements, + linked, + { value: {}, records: [{ Country: 'France', Product: 'B', Year: 2024 }] }, + ], + }; + + expect(interaction.eventSource.regionGeometry).toBe('lasso'); + expect(handleSemanticEvent(interaction, { + type: 'semantic', + source: 'region', + phase: 'commit', + target, + region: { points: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }] }, + }, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [target.elements[0], linked] }], + }); + }); + + it('keeps the local brush target when the link key is unavailable', () => { + const interaction = facetBrushLink({ by: 'Country' }); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ value: { X: 10 }, records: [{ X: 10, Y: 8.04 }] }], + }; + + expect(semanticUpdate(interaction, target, { + chartType: 'Scatter Plot', + selected: [], + available: target.elements, + }, { source: 'region' })?.ops[0]).toMatchObject({ + targets: [{ elements: target.elements }], + }); + }); + + it('previews a semantic cohort on hover', () => { + const interaction = hoverGroupHighlight({ groupBy: 'Country' }); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ value: {}, records: [{ Country: 'France', Year: 1952 }] }], + }; + const linked = { value: {}, records: [{ Country: 'France', Year: 2007 }] }; + const context = { + chartType: 'Scatter Plot', selected: [], + available: [...target.elements, linked, { value: {}, records: [{ Country: 'Germany', Year: 2007 }] }], + }; + + expect(semanticUpdate(interaction, target, context, { phase: 'preview' })?.ops[0]).toMatchObject({ + targets: [{ elements: [target.elements[0], linked] }], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }); + expect(semanticUpdate(interaction, target, context, { phase: 'commit' })).toBeNull(); + expect(interaction.eventSource.targetTolerance).toBe(8); + expect(hoverGroupHighlight({ groupBy: 'Country', tolerance: 14 }).eventSource.targetTolerance).toBe(14); + expect(hoverGroupHighlight({ groupBy: 'Country', tolerance: -1 }).eventSource.targetTolerance).toBe(0); + }); + it('creates element-level annotation intent without selecting the mark', () => { const interaction = clickAnnotate(); const target = { @@ -2114,6 +2454,43 @@ describe('keyboard spatial navigation', () => { }); }); +describe('effective annotation composition', () => { + const target = (category: string) => ({ + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { category } }], + }); + const annotation = (id: string, category: string) => ({ + id, + ops: [{ + op: 'set-annotation' as const, + target: target(category), + value: { text: category, candidates: [{ connection: 'top' as const }] }, + }], + }); + + it('keeps annotations from independent updates and targets', () => { + const entries = effectiveAnnotationEntries([ + annotation('first', 'A'), + annotation('second', 'B'), + ]); + + expect(entries.map((entry) => entry.value.text)).toEqual(['A', 'B']); + expect(new Set(entries.map((entry) => entry.key)).size).toBe(2); + }); + + it('lets a null operation clear the same update and target', () => { + const retained = annotation('note', 'A'); + expect(effectiveAnnotationEntries([{ + id: retained.id, + ops: [...retained.ops, { + op: 'set-annotation' as const, + target: target('A'), + value: null, + }], + }])).toEqual([]); + }); +}); + describe('lasso capture semantics', () => { const mark = (key: string, x1: number, y1: number, x2: number, y2: number) => ({ mark: { marktype: 'rect' }, @@ -2318,12 +2695,65 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(activate(interaction, markTarget)).toBeNull(); }); + it('isolates discrete axis and legend labels through one preset', () => { + const axisInteraction = clickAxisIsolate({ dimOpacity: 0.2 }); + const legendInteraction = clickLegendIsolate({ dimOpacity: 0.2 }); + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + const markTarget = { + visual: { kind: 'mark' as const, role: 'mark' }, + elements: [{ value: { Category: 'A' } }], + }; + + expect(axisInteraction).toMatchObject({ claimsAxisActivation: true }); + expect(legendInteraction).toMatchObject({ claimsLegendActivation: true }); + expect(activate(axisInteraction, axisTarget)?.ops[0]).toMatchObject({ + op: 'set-style', targets: [{ elements: axisTarget.elements }], + value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(activate(legendInteraction, seriesTarget('A'))?.ops[0]).toMatchObject({ + op: 'set-style', targets: [{ elements: seriesTarget('A').elements }], + value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(activate(axisInteraction, markTarget)).toBeNull(); + expect(activate(legendInteraction, markTarget)).toBeNull(); + }); + + it('isolates a continuous legend interval but not an unrequested axis', () => { + const interaction = clickLegendIsolate(); + const intervalTarget = { + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: 'Temperature', + domain: { kind: 'interval' as const, start: 20, end: 30 }, + }, + }], + }; + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + + expect(activate(interaction, intervalTarget)?.ops[0]).toMatchObject({ + op: 'set-style', targets: [{ elements: intervalTarget.elements }], + }); + expect(activate(interaction, axisTarget)).toBeNull(); + }); + it('lets highlight presets opt out of handling observable legend events', () => { expect(activate(clickHighlight(), seriesTarget('A'))).not.toBeNull(); expect(activate(clickHighlight({ legend: false }), seriesTarget('A'))).toBeNull(); expect(activate(clickGroupHighlight(), seriesTarget('A'))).not.toBeNull(); expect(activate(clickGroupHighlight({ legend: false }), seriesTarget('A'))).toBeNull(); expect(activate(clickAnnotate(), seriesTarget('A'))).toBeNull(); + const hover = (interaction: CanvasInteractionDef) => interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: seriesTarget('A'), + }, interaction.eventSource), context); + expect(hover(hoverGroupHighlight({ groupBy: 'Series' }))).not.toBeNull(); + expect(hover(hoverGroupHighlight({ groupBy: 'Series', legend: false }))).toBeNull(); }); it('reports the resolved role for context, long-press, and double activation', () => { diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index 4a249c09..ca3c8fb3 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -24,7 +24,7 @@ import { import { barTableDef } from '../src/vegalite/templates/bar-table'; import { pieChartDef } from '../src/vegalite/templates/pie'; import { roseChartDef } from '../src/vegalite/templates/rose'; -import { rangedDotPlotDef, scatterPlotDef } from '../src/vegalite/templates/scatter'; +import { boxplotDef, rangedDotPlotDef, scatterPlotDef } from '../src/vegalite/templates/scatter'; import { addVegaLiteInteractions, collectVegaAxisTargets, @@ -42,11 +42,13 @@ import { clientToPlotPoint, clientToRendererPoint, clientToLayoutPoint, + facetPlotFrameAt, INTERACTION_KEY, INTERACTION_LEGEND_CHANNEL, INTERACTION_LEGEND_FIELD, INTERACTION_ROLE, PATH_KEY_SUFFIX, + physicalItemAt, plotToClientPoint, legendEntryItemAtPoint, legendSemanticTarget, @@ -76,6 +78,7 @@ import { import { annotationBounds, annotationConnectionPoint, + annotationPrimaryAnchor, } from '../src/vegalite/interactions/presentation/annotation-overlay'; import { createVegaNavigationController } from '../src/vegalite/interactions/navigation-scale'; import { INTERACTION_PROVENANCE } from '../src/vegalite/interaction-provenance'; @@ -217,6 +220,59 @@ describe('Vega-Lite semantic interactions', () => { }); expect(semanticElementRenderKeys(target!.elements[0])).toEqual(['3|3.5']); expect(semanticElementRenderKeys(enriched!.elements[0])).toEqual(['3|3.5']); + const update = semantics.presentUpdate!( + annotationUpdate(enriched!.elements[0], { kind: 'mark', role: 'mark' }), + { chartType: 'Histogram', selected: [] }, + ); + expect(update.ops[0]).toMatchObject({ + value: { text: '2' }, + }); + expect((update.ops[0] as any).value.candidates[0]).toEqual({ + connection: 'value-end', valueAxis: 'y', priority: 0, + }); + }); + + it('uses contrast rather than outlines for Boxplot hover', () => { + const semantics = boxplotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Species', type: 'nominal' }, + y: { field: 'Body mass', type: 'quantitative' }, + }, + }); + + expect(semantics.renderHoverStyles).toEqual({ + rect: { opacity: 'contrast' }, + rule: { opacity: 'contrast' }, + symbol: { opacity: 'contrast' }, + }); + }); + + it('presents a Boxplot annotation from its computed summary', () => { + const semantics = boxplotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Species', type: 'nominal' }, + y: { field: 'Body mass', type: 'quantitative' }, + }, + }); + const element = { + value: { + Species: 'Gentoo', + lower_box_Body_mass: 4700, + mid_box_Body_mass: 5000, + upper_box_Body_mass: 5400, + }, + }; + + const update = semantics.presentUpdate!( + annotationUpdate(element, { kind: 'mark', role: 'distribution' }), + { chartType: 'Boxplot', selected: [] }, + ); + expect(update.ops[0]).toMatchObject({ + value: { + text: 'Median: 5,000\nIQR: 4,700 → 5,400', + }, + }); + expect((update.ops[0] as any).value.candidates[0]).toEqual({ connection: 'center', priority: 0 }); }); it('preserves source provenance through an assembled histogram plan', async () => { @@ -310,6 +366,13 @@ describe('Vega-Lite semantic interactions', () => { expect(midpoint.point).toEqual({ x: 126, y: 136 }); expect(rightFallback.point).toEqual(midpoint.point); expect(rightFallback.preferredAngle).toBe(0); + expect(annotationPrimaryAnchor( + item, + { left: 56, top: 52, width: 140, height: 168 }, + { left: 220, top: 120, width: 30, height: 20 }, + 'right', + rightFallback.point, + )).toEqual(midpoint.point); }); it('uses a borderless spotlight for area hover', () => { @@ -627,7 +690,10 @@ describe('Vega-Lite semantic interactions', () => { data: { values: [{ category: 'A', value: 2 }] }, }) as any; - expect(addVegaLiteInteractions(spec, [], true)).not.toBeNull(); + const plan = addVegaLiteInteractions(spec, [], true); + expect(plan).not.toBeNull(); + expect(plan?.resolve).toBeTypeOf('function'); + expect(plan?.semanticStores).toBe(true); expect(spec.transform).toEqual(expect.arrayContaining([ expect.objectContaining({ as: INTERACTION_KEY }), ])); @@ -1189,8 +1255,11 @@ describe('Vega-Lite semantic interactions', () => { ); const calendarUpdate = calendar.presentUpdate!( annotationUpdate({ - value: { [INTERACTION_KEY]: 'day' }, - records: [{ __calendar_date: Date.UTC(2026, 7, 27), Commits: 12 }], + value: { + [INTERACTION_KEY]: 'day', + __flintCalendarDate: Date.UTC(2026, 7, 27), + sum_Commits: 12, + }, }), { chartType: 'Calendar Heatmap', selected: [] }, ); @@ -1203,7 +1272,9 @@ describe('Vega-Lite semantic interactions', () => { ); expect(ecdfUpdate.ops[0]).toMatchObject({ value: { text: '42' } }); - expect((calendarUpdate.ops[0] as any).value.text).toContain('12'); + const calendarDate = new Intl.DateTimeFormat(undefined, { timeZone: 'UTC' }) + .format(new Date(Date.UTC(2026, 7, 27))); + expect(calendarUpdate.ops[0]).toMatchObject({ value: { text: `${calendarDate}: 12` } }); expect(violinUpdate.ops[0]).toMatchObject({ value: { text: 'Setosa: 5.1' } }); }); @@ -1266,15 +1337,15 @@ describe('Vega-Lite semantic interactions', () => { expect(compiled.legends.length).toBeGreaterThan(0); for (const legend of compiled.legends) { expect(legend.encode.gradient.interactive).toBe(true); - expect(legend.encode.gradient.update.cursor.value).toBe('pointer'); + expect(legend.encode.gradient.update.cursor).toBeUndefined(); expect(legend.encode.symbols.interactive).toBe(true); - expect(legend.encode.symbols.update.cursor.value).toBe('pointer'); + expect(legend.encode.symbols.update.cursor).toBeUndefined(); expect(legend.encode.labels.interactive).toBe(true); - expect(legend.encode.labels.update.cursor.value).toBe('pointer'); + expect(legend.encode.labels.update.cursor).toBeUndefined(); } }); - it('uses pointer cursors only for marks with click interactions', () => { + it('leaves mark cursors to runtime affordance resolution', () => { const spec = { data: { values: [{ X: 1, Y: 2 }] }, mark: 'point', @@ -1293,7 +1364,7 @@ describe('Vega-Lite semantic interactions', () => { .flatMap((mark: Record) => mark.marks ?? [mark]) .find((mark: Record) => mark.type === 'symbol'); - expect(symbolMark(clickable).encode.update.cursor.value).toBe('pointer'); + expect(symbolMark(clickable).encode.update.cursor).toBeUndefined(); expect(symbolMark(selectable).encode.update.cursor).toBeUndefined(); }); @@ -1353,6 +1424,12 @@ describe('Vega-Lite semantic interactions', () => { expect(hovered?.opacity).toBe(0.9); expect(hovered?.stroke).toBeUndefined(); + + view.change(INTERACTION_STORE, changeset().insert([{ key }])); + await view.runAsync(); + const hoveredWhileSelected = sceneItems(view).find((candidate) => candidate.mark?.marktype === 'area'); + + expect(hoveredWhileSelected?.opacity).toBe(0.25); }); it('uses one area segment for its highlight, endpoint text, and annotation boundary', async () => { @@ -2005,6 +2082,7 @@ describe('Vega-Lite semantic interactions', () => { view.change(INTERACTION_STORE, changeset().insert([{ key: targetKey }])); await view.runAsync(); renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === targetKey)?.opacity).toBe(hoveredOpacity); expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === peerKey)?.opacity).toBe(0.25); }); @@ -2404,7 +2482,7 @@ describe('Vega-Lite semantic interactions', () => { expect(plan).not.toBeNull(); expect(spec.layer[0].encoding.opacity.condition.test).toContain(INTERACTION_STORE); expect(spec.layer[1].encoding.opacity.condition.test).toContain(INTERACTION_STORE); - expect(spec.layer[0].mark.cursor).toBe('pointer'); + expect(spec.layer[0].mark.cursor).toBeUndefined(); expect(spec.layer[1].mark.cursor).toBeUndefined(); expect(spec.layer[2].encoding.opacity).toBeUndefined(); expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); @@ -2518,7 +2596,7 @@ describe('Vega-Lite semantic interactions', () => { { calculate: '"color"', as: INTERACTION_LEGEND_CHANNEL }, { calculate: '"Country"', as: INTERACTION_LEGEND_FIELD }, ])); - expect(label.mark.cursor).toBe('pointer'); + expect(label.mark.cursor).toBeUndefined(); }); it('preserves the second datum of a clicked line segment', () => { @@ -2772,6 +2850,9 @@ describe('Vega-Lite semantic interactions', () => { seriesField: 'Region', }); const keys = target!.elements.flatMap(semanticElementRenderKeys); + const renderedAreaKeys = hits + .filter((hit) => hit.markType === 'area' && hit.datum.Region === 'Asia') + .map((hit) => hit.datum[INTERACTION_KEY]); view.change(HIDDEN_STORE, changeset().insert(keys.map((key) => ({ key })))); await view.runAsync(); @@ -2779,7 +2860,8 @@ describe('Vega-Lite semantic interactions', () => { const remainingRegions = sceneItems(view) .filter((item) => item.mark.marktype === 'area') .map((item) => item.datum.Region); - expect(keys).toHaveLength(3); + expect(keys.filter((key) => key.endsWith(PATH_KEY_SUFFIX))).toEqual(renderedAreaKeys); + expect(keys.filter((key) => !key.endsWith(PATH_KEY_SUFFIX))).toHaveLength(3); expect(remainingRegions).not.toContain('Asia'); expect(remainingRegions).toContain('Africa'); }); @@ -3216,6 +3298,54 @@ describe('Vega-Lite semantic interactions', () => { expect(target?.elements.flatMap(semanticElementRenderKeys)).toEqual(['Asia', 'Africa']); }); + it('presents derived Waterfall ranges after source provenance enrichment', () => { + const semantics = waterfallChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Step', type: 'ordinal' }, + y: { field: 'Population', type: 'quantitative' }, + }, + }); + const element = { + value: { Step: 'Asia', __wf_prev_sum: 2_537, __wf_sum: 5_779 }, + records: [{ Step: 'Asia', Population: 3_242 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(element, { kind: 'mark', role: 'waterfall-step' }), + { chartType: 'Waterfall Chart', selected: [] }, + ).ops[0]).toMatchObject({ value: { text: '2,537 → 5,779' } }); + }); + + it('presents a Sparkline segment transition', () => { + const semantics = sparklineDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Month', type: 'temporal' }, + y: { field: 'Value', type: 'quantitative' }, + row: { field: 'Metric', type: 'nominal' }, + }, + }); + const element = { + value: { Month: 'Mar', Metric: 'Active users', Value: 42 }, + records: [ + { Month: 'Mar', Metric: 'Active users', Value: 42 }, + { Month: 'Apr', Metric: 'Active users', Value: 54 }, + ], + }; + + const update = semantics.presentUpdate!( + annotationUpdate(element, { kind: 'path', role: 'line' }), + { chartType: 'Sparkline', selected: [] }, + ); + expect(update.ops[0]).toMatchObject({ + value: { + text: '42 → 54', + }, + }); + expect((update.ops[0] as any).value.candidates[0]).toEqual({ + connection: 'segment-midpoint', priority: 0, + }); + }); + it('compiles grouped-bar semantic fields from its template', () => { const spec = assembleVegaLite({ data: { @@ -3372,6 +3502,35 @@ describe('Vega-Lite semantic interactions', () => { expect(segments.at(-1)?.interactionGeometry.endDatum.Nutrient).toBe('Protein'); }); + it('acquires the nearest Radar edge across overlapping filled series', async () => { + const values = [ + ['Oats', 17, 7, 66, 11, 1], + ['Almonds', 21, 49, 22, 12, 4], + ].flatMap(([Food, ...amounts]) => ['Protein', 'Fat', 'Carbs', 'Fiber', 'Sugar'] + .map((Nutrient, index) => ({ Food, Nutrient, Amount: amounts[index] }))); + const spec = assembleVegaLite({ + data: { values }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const segments = sceneItems(view).filter((item) => item.mark?.marktype === 'line'); + const oats = segments.find((item) => item.datum.Food === 'Oats'); + const almond = segments.find((item) => item.datum.Food === 'Almonds'); + const [start, end] = almond.interactionGeometry.points; + const point = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + + const acquired = physicalItemAt(view, oats, point); + + expect(acquired.mark).toBe(almond.mark); + expect(acquired.datum.Food).toBe('Almonds'); + }); + it('instruments marks inside a nested facet unit spec', () => { const spec: Record = { data: { values: [{ Group: 'A', X: 1, Value: 2 }] }, @@ -3396,6 +3555,41 @@ describe('Vega-Lite semantic interactions', () => { expect(() => parse(compiled)).not.toThrow(); }); + it('resolves distinct plot frames for rendered row facets', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Class: '1st', Survival: 90, Sex: 'Female' }, + { Class: '2nd', Survival: 80, Sex: 'Female' }, + { Class: '1st', Survival: 30, Sex: 'Male' }, + { Class: '2nd', Survival: 20, Sex: 'Male' }, + ] }, + semantic_types: { Class: 'Category', Survival: 'Number', Sex: 'Category' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Class', y: 'Survival', row: 'Sex' }, + baseSize: { width: 350, height: 240 }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark?.marktype === 'rect'); + const female = bars.find((item) => item.datum.Sex === 'Female'); + const male = bars.find((item) => item.datum.Sex === 'Male'); + const fallback = { x: -1, y: -1, width: 1, height: 1 }; + const frameFor = (item: any) => facetPlotFrameAt(view, { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }, fallback); + + const femaleFrame = frameFor(female); + const maleFrame = frameFor(male); + expect(femaleFrame).not.toEqual(fallback); + expect(maleFrame).not.toEqual(fallback); + expect(femaleFrame.y).not.toBe(maleFrame.y); + expect(femaleFrame.height).toBe(maleFrame.height); + }); + it('does not instrument marks declared as decorative', () => { const spec: Record = { layer: [ @@ -3491,7 +3685,7 @@ describe('set-style visibility', () => { const targets = collectVegaAxisTargets(compiled, { x: { field: 'Category', type: 'nominal' }, y: { field: 'Value', type: 'quantitative' }, - }); + }, [{ axis: 'x', field: 'Category' }]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); const labels = allSceneItems(view).filter((item) => item.mark?.role === 'axis-label'); @@ -3502,6 +3696,10 @@ describe('set-style visibility', () => { }); expect(axisTargetIdentity(quantityLabel, targets)).toBeNull(); expect(JSON.stringify(compiled.axes)).toContain('"interactive":true'); + expect(compiled.axes.find((axis: any) => axis.orient === 'bottom') + ?.encode?.labels?.update?.cursor).toBeUndefined(); + expect(compiled.axes.find((axis: any) => axis.orient === 'left') + ?.encode?.labels?.update?.cursor).toBeUndefined(); view.finalize(); }); diff --git a/site/src/playground/ChartToExternalLab.tsx b/site/src/playground/ChartToExternalLab.tsx index 9e82f771..1c58b1d2 100644 --- a/site/src/playground/ChartToExternalLab.tsx +++ b/site/src/playground/ChartToExternalLab.tsx @@ -4,7 +4,7 @@ import type { InteractionDef, SemanticTarget, } from 'flint-chart/interactive'; -import { clickHighlight, select as rectangleSelect } from 'flint-chart/interactive'; +import { clickMark, select as rectangleSelect } from 'flint-chart/interactive'; import { InteractionDemoChart } from './InteractionDemoChart'; import { countriesFixture, @@ -198,7 +198,7 @@ function OutboundDemoRow({ demo }: { demo: OutboundDemo }) { const interaction: InteractionDef = useMemo( () => demo.gesture === 'select' ? rectangleSelect({ id: `${demo.id}-selection` }) - : clickHighlight({ id: `${demo.id}-element` }), + : clickMark({ id: `${demo.id}-element` }), [demo.gesture, demo.id], ); const interactions = useMemo(() => [interaction], [interaction]); diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index ac010670..c44668fc 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -1,24 +1,29 @@ -import { useEffect, useRef, useState } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Crosshair, EyeOff, GripVertical, Keyboard, Lasso, Layers3, MessageSquarePlus, MessageSquareText, MousePointer2, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; +import { Crosshair, EyeOff, GripVertical, Keyboard, Lasso, Layers3, Link2, Menu, MessageSquareText, MousePointer2, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; import { genBarTests, + genGroupedBarTests, + genStackedBarTests, TEST_GENERATORS, type TestCase, } from 'flint-chart/test-data'; import { buildInteractiveChart, - axisHighlight, brushX, brushY, brushZoom, clickAnnotate, - clickGroupHighlight, - clickHighlight, + clickAxisIsolate, + clickGroupFocus, + clickLegendIsolate, + clickMark, contextActivate, doubleActivate, dragReorder, + facetBrushLink, + hoverGroupFocus, inspect, lassoSelect, legendToggle, @@ -33,13 +38,15 @@ import { BACKENDS } from '../shared/supported-backends'; import { testCaseToAssemblyInput } from '../shared/test-case-utils'; import { ThemePicker } from './ThemePicker'; import { navigationDemoCases } from './navigation-demo-data'; +import { gapminderRows } from './gapminder-dashboard-data'; import './click-focus-lab.css'; -export type InteractionMode = 'element' | 'group' | 'annotate' | 'select' +export type InteractionMode = 'click-mark' | 'click-group-focus' | 'annotate' | 'select' + | 'facet-link' | 'hover-group-focus' | 'brush-x' | 'brush-y' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate' | 'drag-reorder' - | 'lasso' | 'assisted' | 'keyboard' | 'select-comment' - | 'legend-toggle' | 'inspect' | 'inspect-quadrant' | 'inspect-x' - | 'brush-zoom' | 'long-press' | 'double-activate' | 'axis-highlight'; + | 'lasso' | 'click-legend-isolate' | 'click-axis-isolate' | 'inspect' | 'inspect-quadrant' | 'inspect-x' + | 'long-press' | 'double-activate' + | 'click-highlight' | 'assisted-focus' | 'keyboard-focus' | 'select-context' | 'focus-legend-toggle' | 'focus-brush-zoom'; type ProbeStatus = 'loading' | 'ready' | 'unsupported' | 'error'; export interface NavigationGuard { @@ -48,11 +55,13 @@ export interface NavigationGuard { overscrollFraction: number; } -const interactionModes = [ - { value: 'element', label: 'Element', icon: MousePointer2 }, - { value: 'group', label: 'Group', icon: Layers3 }, +const unitInteractionModes = [ + { value: 'click-mark', label: 'Click mark', icon: MousePointer2 }, + { value: 'click-group-focus', label: 'Click group focus', icon: Layers3 }, + { value: 'hover-group-focus', label: 'Hover group focus', icon: Target }, { value: 'annotate', label: 'Annotate', icon: MessageSquareText }, { value: 'select', label: 'Select', icon: Scan }, + { value: 'facet-link', label: 'Facet link', icon: Link2 }, { value: 'brush-x', label: 'X brush', icon: MoveHorizontal }, { value: 'brush-y', label: 'Y brush', icon: MoveVertical }, { value: 'brush-x-stateful', label: 'X brush (edit)', icon: MoveHorizontal }, @@ -60,36 +69,45 @@ const interactionModes = [ { value: 'navigate', label: 'Pan & zoom', icon: Move }, { value: 'drag-reorder', label: 'Drag reorder', icon: GripVertical }, { value: 'lasso', label: 'Lasso', icon: Lasso }, - { value: 'select-comment', label: 'Select & comment', icon: MessageSquarePlus }, - { value: 'assisted', label: 'Assisted click', icon: Crosshair }, - { value: 'keyboard', label: 'Keyboard', icon: Keyboard }, - { value: 'axis-highlight', label: 'Axis highlight', icon: Ruler }, - { value: 'legend-toggle', label: 'Legend toggle', icon: EyeOff }, + { value: 'click-legend-isolate', label: 'Click legend isolate', icon: Layers3 }, + { value: 'click-axis-isolate', label: 'Click axis isolate', icon: Ruler }, { value: 'inspect', label: 'Inspect xy', icon: Target }, { value: 'inspect-quadrant', label: 'Inspect quadrant', icon: Crosshair }, { value: 'inspect-x', label: 'Inspect x', icon: Ruler }, - { value: 'brush-zoom', label: 'Brush to zoom', icon: ZoomIn }, { value: 'long-press', label: 'Long press', icon: Timer }, { value: 'double-activate', label: 'Double click', icon: MousePointerClick }, ] as const; -type MountedInteraction = ReturnType; +const compositionInteractionModes = [ + { value: 'click-highlight', label: 'Click highlight', icon: MousePointerClick }, + { value: 'assisted-focus', label: 'Focus + assist', icon: Crosshair }, + { value: 'keyboard-focus', label: 'Focus + keyboard', icon: Keyboard }, + { value: 'select-context', label: 'Select + context', icon: Menu }, + { value: 'focus-legend-toggle', label: 'Focus + legend toggle', icon: EyeOff }, + { value: 'focus-brush-zoom', label: 'Focus + brush zoom', icon: ZoomIn }, +] as const; + +type MountedInteraction = ReturnType; /** - * Modes mount the set of presets a real chart would ship together, not one preset each. - * Legend, press and inspect gestures are only meaningful alongside ordinary element clicks. + * Each named unit mode mounts its corresponding preset; explicitly named compositions are separate. */ function modeInteractions( mode: InteractionMode, navigationAxes: 'x' | 'y' | 'xy' | undefined, navigationGuard: NavigationGuard | undefined, + linkBy: string | readonly string[] | undefined, ): MountedInteraction[] { switch (mode) { - case 'element': return [clickHighlight()]; - case 'axis-highlight': return [axisHighlight()]; - case 'group': return [clickGroupHighlight()]; + case 'click-mark': return [clickMark()]; + case 'click-legend-isolate': return [clickLegendIsolate()]; + case 'click-axis-isolate': return [clickAxisIsolate()]; + case 'click-highlight': return [clickMark(), clickLegendIsolate(), clickAxisIsolate()]; + case 'click-group-focus': return [clickGroupFocus({ groupBy: typeof linkBy === 'string' ? linkBy : undefined })]; + case 'hover-group-focus': return linkBy ? [hoverGroupFocus({ groupBy: linkBy })] : []; case 'annotate': return [clickAnnotate()]; case 'select': return [rectangleSelect()]; + case 'facet-link': return linkBy ? [facetBrushLink({ by: linkBy })] : []; case 'brush-x': return [brushX()]; case 'brush-y': return [brushY()]; case 'brush-x-stateful': return [brushX({ mode: 'stateful' })]; @@ -103,19 +121,21 @@ function modeInteractions( dimOpacity: 0.14, })]; case 'inspect-x': return [inspect({ mode: 'x' })]; - case 'assisted': case 'keyboard': return [clickHighlight()]; - case 'select-comment': return [rectangleSelect(), contextActivate()]; - case 'legend-toggle': return [clickHighlight({ legend: false }), legendToggle()]; + case 'assisted-focus': case 'keyboard-focus': return [clickMark()]; + case 'select-context': return [rectangleSelect(), contextActivate()]; + case 'focus-legend-toggle': return [clickMark(), legendToggle()]; case 'long-press': return [longPress()]; case 'double-activate': return [doubleActivate()]; - case 'brush-zoom': return [clickHighlight(), brushZoom()]; + case 'focus-brush-zoom': return [clickMark(), brushZoom()]; default: return [navigate({ axes: navigationAxes ?? 'available', domainGuard: navigationGuard })]; } } export interface InteractionCase { id: string; + title?: string; input: ChartAssemblyInput; + linkBy?: string | readonly string[]; navigationAxes?: 'x' | 'y' | 'xy'; chartType: string; expectation: string; @@ -239,10 +259,128 @@ function multiLegendCase(kind: 'shape' | 'size'): InteractionCase { }; } +function realFacetedCases(): InteractionCase[] { + const electricityMix = genStackedBarTests().find((test) => + test.tags?.includes('real') && test.title.includes('Electricity generation mix')); + const titanic = genGroupedBarTests().find((test) => + test.tags?.includes('real') && test.title.includes('Titanic survival')); + if (!electricityMix?.encodingMap.color) throw new Error('Missing real electricity generation fixture'); + if (!titanic?.encodingMap.group) throw new Error('Missing real Titanic survival fixture'); + + const { color: sourceFacet, ...barEncodings } = electricityMix.encodingMap; + const { group: sexFacet, ...titanicEncodings } = titanic.encodingMap; + const barCase = interactionCase({ + ...electricityMix, + chartType: 'Bar Chart', + title: 'Electricity generation mix — faceted by source', + description: `${electricityMix.description} Each source is shown in its own panel.`, + encodingMap: { ...barEncodings, column: sourceFacet }, + chartProperties: { ...electricityMix.chartProperties, facetColumns: 3 }, + }, '-faceted'); + return [ + { + ...barCase, + title: 'Electricity generation mix — faceted by source', + linkBy: 'Country', + }, + { + id: 'Scatter Plot-Gapminder-faceted-years', + chartType: 'Scatter Plot', + title: 'Gapminder — linked countries across 1952 and 2007', + linkBy: 'Country', + expectation: 'Brush countries in either year to highlight the same countries in both panels (Gapminder).', + input: { + semantic_types: { + Country: 'Country', + Continent: 'Category', + Year: 'Year', + Population: 'Quantity', + 'GDP per capita': 'Quantity', + 'Life expectancy': 'Quantity', + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { + x: { field: 'GDP per capita' }, + y: { field: 'Life expectancy' }, + color: { field: 'Continent' }, + detail: { field: 'Country' }, + column: { field: 'Year' }, + }, + chartProperties: { facetColumns: 2, logScale_x: true }, + baseSize: SIZE, + }, + data: { values: gapminderRows.filter(({ Year }) => Year === 1952 || Year === 2007) }, + }, + }, + { + ...interactionCase({ + ...titanic, + chartType: 'Bar Chart', + title: 'Titanic survival — row facets by sex', + description: `${titanic.description} Sex is shown in separate rows.`, + encodingMap: { ...titanicEncodings, row: sexFacet }, + }, '-faceted'), + title: 'Titanic survival — row facets by sex', + linkBy: 'Class', + }, + { + id: 'Scatter Plot-Gapminder-faceted-four-years', + chartType: 'Scatter Plot', + title: 'Gapminder — continents across four years', + linkBy: 'Continent', + expectation: 'Brush a point to link every country in its continent across all four year panels.', + input: { + semantic_types: { + Country: 'Country', Continent: 'Category', Year: 'Year', + Population: 'Quantity', 'GDP per capita': 'Quantity', + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { + x: { field: 'GDP per capita' }, y: { field: 'Population' }, + color: { field: 'Continent' }, detail: { field: 'Country' }, column: { field: 'Year' }, + }, + chartProperties: { facetColumns: 4, logScale_x: true, logScale_y: true }, + baseSize: SIZE, + }, + data: { values: gapminderRows.filter(({ Year }) => [1952, 1972, 1992, 2007].includes(Year)) }, + }, + }, + { + id: 'Scatter Plot-Gapminder-faceted-correlation-grid', + chartType: 'Scatter Plot', + title: 'Gapminder — 4×4 country correlation grid', + linkBy: 'Country', + expectation: 'Brush a country to link it through four years within its continent row.', + input: { + semantic_types: { + Country: 'Country', Continent: 'Category', Year: 'Year', + 'GDP per capita': 'Quantity', 'Life expectancy': 'Quantity', + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { + x: { field: 'GDP per capita' }, y: { field: 'Life expectancy' }, + detail: { field: 'Country' }, column: { field: 'Year' }, row: { field: 'Continent' }, + }, + chartProperties: { facetColumns: 4, logScale_x: true }, + baseSize: { width: 600, height: 480 }, + }, + data: { + values: gapminderRows.filter(({ Continent, Year }) => + Continent !== 'Oceania' && [1952, 1972, 1992, 2007].includes(Year)), + }, + }, + }, + ]; +} + const interactionCases: InteractionCase[] = [ ...representativeCases(), multiLegendCase('shape'), multiLegendCase('size'), + ...realFacetedCases(), ]; const ANNOTATION_CHART_TYPES = [ @@ -447,6 +585,7 @@ function InteractiveChart({ themeId, navigationGuard, navigationAxes, + linkBy, resetVersion, onStatus, onSemanticEvent, @@ -456,6 +595,7 @@ function InteractiveChart({ themeId: string | undefined; navigationGuard: NavigationGuard; navigationAxes?: 'x' | 'y' | 'xy'; + linkBy?: string | readonly string[]; resetVersion: number; onStatus: (status: ProbeStatus, message?: string) => void; onSemanticEvent: (detail: FlintInteractionEventDetail) => void; @@ -497,7 +637,7 @@ function InteractiveChart({ }; container.addEventListener('contextmenu', captureContextPoint, true); container.addEventListener('flint-interaction', handleInteraction); - const interactions = modeInteractions(mode, navigationAxes, navigationGuard); + const interactions = modeInteractions(mode, navigationAxes, navigationGuard, linkBy); const themedInput = themeId ? { ...input, theme_spec: themeId } : input; const surface = buildInteractiveChart(container, themedInput, { backend: 'vegalite', @@ -505,8 +645,8 @@ function InteractiveChart({ interactions, expressionInterpreter, ariaLabel: input.chart_spec.title, - assistedTargeting: mode === 'assisted', - keyboardTargeting: mode === 'keyboard', + assistedTargeting: mode === 'assisted-focus', + keyboardTargeting: mode === 'keyboard-focus', dismiss: mode === 'long-press' || mode === 'double-activate' ? { click: 'any', escape: true } : undefined, @@ -525,7 +665,7 @@ function InteractiveChart({ setComment(null); surface.destroy(); }; - }, [input, mode, navigationAxes, navigationGuard, resetVersion, themeId]); + }, [input, linkBy, mode, navigationAxes, navigationGuard, resetVersion, themeId]); const menuTarget = contextMenu?.detail.event.target ?? null; const menuElement = menuTarget?.elements[0]; @@ -553,7 +693,7 @@ function InteractiveChart({ const text = summarizeElement(menuElement) ?? 'Comment'; setComment(text); void surface.applyUpdate({ - id: 'select-comment', + id: 'select-context', ops: [{ op: 'set-annotation', target: { visual: menuTarget.visual, elements: [menuElement] }, @@ -564,7 +704,7 @@ function InteractiveChart({ }; const clearComment = () => { setComment(null); - void surfaceRef.current?.clearUpdate('select-comment'); + void surfaceRef.current?.clearUpdate('select-context'); setContextMenu(null); }; @@ -619,7 +759,7 @@ export function CaseCard({ const [status, setStatus] = useState('loading'); const [statusMessage, setStatusMessage] = useState('Compiling'); const [lastInteraction, setLastInteraction] = useState(null); - const title = item.input.chart_spec.title || item.input.chart_spec.chartType; + const title = item.title || item.input.chart_spec.title || item.input.chart_spec.chartType; const availableNavigationAxes = navigationAxesByCase.get(item.id); const navigationAxes = item.navigationAxes === 'xy' ? ['x', 'y'] @@ -656,6 +796,7 @@ export function CaseCard({ themeId={themeId} navigationGuard={navigationGuard} navigationAxes={item.navigationAxes} + linkBy={item.linkBy} resetVersion={resetVersion} onStatus={(nextStatus, message) => { setStatus(nextStatus); @@ -705,7 +846,7 @@ export function CaseCard({ } export function ClickFocusLab() { - const [mode, setMode] = useState('element'); + const [mode, setMode] = useState('click-mark'); const [themeId, setThemeId] = useState(undefined); const [navigationGuard, setNavigationGuard] = useState({ minVisibleFraction: 0.02, @@ -713,48 +854,60 @@ export function ClickFocusLab() { overscrollFraction: 0, }); const [resetVersion, setResetVersion] = useState(0); - const visibleCases = mode === 'navigate' || mode === 'brush-zoom' + const visibleCases = mode === 'navigate' || mode === 'focus-brush-zoom' ? navigationCases.filter((item) => navigationAxesByCase.has(item.id)) : mode === 'drag-reorder' ? interactionCases.filter((item) => reorderAxesByCase.has(item.id)) : mode === 'inspect-quadrant' ? interactionCases.filter((item) => inspectQuadrantCases.has(item.id)) - : mode === 'legend-toggle' + : mode === 'facet-link' || mode === 'hover-group-focus' + ? interactionCases.filter((item) => item.linkBy) + : mode === 'focus-legend-toggle' ? interactionCases.filter((item) => discreteLegendCases.has(item.id)) : interactionCases; return (
    - {interactionModes.map(({ value, label, icon: Icon }) => ( - + {[unitInteractionModes, compositionInteractionModes].map((modes, sectionIndex) => ( + + {sectionIndex > 0 &&
    } + {modes.map(({ value, label, icon: Icon }) => ( + + ))} + ))}

    Interaction gallery

    Choose an interaction mode, then try it across the compatible chart cases:

      -
    • Element: Click a mark to focus it and dim the other marks.
    • -
    • Group: Click a mark to focus related marks in the same category or series.
    • +
    • Click mark: Click a mark to focus it and dim the other marks.
    • +
    • Click group focus: Click a mark to focus related marks in the same category or series.
    • +
    • Hover group focus: Hover a mark to preview matching semantic keys without changing retained state.
    • Annotate: Click a mark to search nearby free space and connect its represented value.
    • Select: Drag a rectangle to focus all marks within an area.
    • +
    • Facet link: Brush marks in one panel to highlight matching semantic keys across panels.
    • X brush: Drag across an X interval; polar charts automatically use an angular sector.
    • Y brush: Drag vertically to focus marks across a Y interval.
    • Stateful brush: Move the committed interval, resize either edge, or click outside to clear it.
    • -
    • Pan & zoom: Drag continuous axes to pan; use the wheel or trackpad to zoom.
    • +
    • Pan & zoom: Drag continuous axes to pan; use the wheel, trackpad, or a two-finger pinch to zoom.
    • +
    • Context menu: Select marks or open a mark menu, then let the host application provide contextual actions.
    • Assisted and keyboard: Move to a target to see a shared indicator and compact semantic details.
    • -
    • Axis highlight: Click a categorical tick or label to focus the marks it represents.
    • +
    • Click legend isolate: Click a discrete entry or continuous legend interval to isolate its cohort.
    • +
    • Click axis isolate: Click a categorical X/Y label to isolate its cohort.
    • +
    • Click highlight: Compose mark, legend, and axis click presets.
    {visibleCases.length} test cases
    diff --git a/site/src/playground/InteractionDashboardLab.tsx b/site/src/playground/InteractionDashboardLab.tsx index ac2759a4..208198ad 100644 --- a/site/src/playground/InteractionDashboardLab.tsx +++ b/site/src/playground/InteractionDashboardLab.tsx @@ -9,8 +9,8 @@ import type { } from 'flint-chart/interactive'; import { brushY, - clickGroupHighlight, - clickHighlight, + clickGroupFocus, + clickMark, externalInteraction, select, } from 'flint-chart/interactive'; @@ -156,7 +156,7 @@ function buildDashboardCharts( undefined, { width: 400, height: 230 }, ), - interaction: clickHighlight({ id: DASHBOARD_SELECTION_ID, dimOpacity: 0.22 }), + interaction: clickMark({ id: DASHBOARD_SELECTION_ID, dimOpacity: 0.22 }), }, { id: 'trends', @@ -169,7 +169,7 @@ function buildDashboardCharts( { showPoints: true, logScale_y: metric === 'GDP per capita' }, { width: 400, height: 260 }, ), - interaction: clickGroupHighlight({ + interaction: clickGroupFocus({ id: DASHBOARD_SELECTION_ID, groupBy: 'Country', dimOpacity: 0.22, diff --git a/site/src/playground/click-focus-lab.css b/site/src/playground/click-focus-lab.css index ab01513a..859572c5 100644 --- a/site/src/playground/click-focus-lab.css +++ b/site/src/playground/click-focus-lab.css @@ -159,34 +159,50 @@ } .cf-action-rail { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: minmax(0, 1fr); position: fixed; top: 50%; right: max(12px, env(safe-area-inset-right)); - z-index: 6; + z-index: 7; + width: 152px; + max-height: calc(100dvh - 24px); transform: translateY(-50%); - gap: 2px; + gap: 1px; padding: 3px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; border: 1px solid #cfd5da; border-radius: 6px; background: #fff; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1); +} + +.cf-action-rail::-webkit-scrollbar { + display: none; +} + +.cf-action-divider { + height: 1px; + margin: 4px 5px; + background: #d8dde2; } .cf-action-rail button { display: flex; align-items: center; - gap: 7px; - width: 132px; - height: 32px; + gap: 6px; + width: 100%; + height: 26px; border: 0; border-radius: 4px; - padding: 0 9px; + padding: 0 8px; color: #66707a; background: transparent; cursor: pointer; font: inherit; - font-size: 11px; + font-size: 10px; font-weight: 600; white-space: nowrap; } @@ -394,12 +410,20 @@ } @media (max-width: 520px) { - .cf-heading { - padding-right: 48px; - } - .cf-action-rail { + grid-template-columns: 40px; right: max(8px, env(safe-area-inset-right)); + width: 40px; + max-height: calc(100dvh - 16px); + } + + .cf-action-rail button { + justify-content: center; + padding: 0; + } + + .cf-action-rail button span { + display: none; } .cf-probe-header { From bd743f71b83c98fc411f6fb837d095b5185d256a Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Tue, 1 Sep 2026 23:22:39 -0700 Subject: [PATCH 24/33] cleanup --- packages/flint-js/src/README.md | 6 +- packages/flint-js/src/interactive/README.md | 17 ++- packages/flint-js/src/interactive/index.ts | 7 +- .../flint-js/src/interactive/interactions.ts | 57 ++------- .../presets/click-group-highlight.ts | 17 ++- .../interactive/presets/click-highlight.ts | 13 +- .../presets/hover-group-highlight.ts | 21 ++-- .../flint-js/src/interactive/presets/index.ts | 6 +- packages/flint-js/tests/interactions.test.ts | 117 ++++++------------ .../tests/semantic-interactions.test.ts | 46 +++---- site/src/playground/ClickFocusLab.tsx | 4 +- 11 files changed, 111 insertions(+), 200 deletions(-) diff --git a/packages/flint-js/src/README.md b/packages/flint-js/src/README.md index 32248992..5dc09bc3 100644 --- a/packages/flint-js/src/README.md +++ b/packages/flint-js/src/README.md @@ -168,7 +168,7 @@ Each backend has its own assembly function. All accept the same Interactive renderers are opt-in and shipped separately from the static assembly entry point. The surface owns interaction coordination, viewport state, accessible scroll controls, and renderer lifecycle; the caller supplies only a container and chart input. ```ts -import { buildInteractiveChart, clickHighlight, externalInteraction } from 'flint-chart/interactive'; +import { buildInteractiveChart, clickMark, externalInteraction } from 'flint-chart/interactive'; const surface = buildInteractiveChart( container, @@ -176,7 +176,7 @@ const surface = buildInteractiveChart( { backend: 'vegalite', renderer: 'canvas', - interactions: [clickHighlight()], + interactions: [clickMark()], }, ); @@ -204,7 +204,7 @@ const countryPicker = externalInteraction<{ country: string }>({ await surface.dispatch('country-picker', { country: 'Japan' }); ``` -The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite interactions are enabled explicitly through `interactions`; `clickHighlight()` selects marks, Shift/Ctrl/Meta-click toggles marks, and clicking empty plot space clears. Other backends currently reject semantic interactions. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`; the surface invokes external handlers, while adapters expose interaction context and apply renderer-neutral updates. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. +The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite interactions are enabled explicitly through `interactions`; `clickMark()` selects marks, Shift/Ctrl/Meta-click toggles marks, and clicking empty plot space clears. Other backends currently reject semantic interactions. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`; the surface invokes external handlers, while adapters expose interaction context and apply renderer-neutral updates. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. ### Input types diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index 0664b0c2..e930c144 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -672,7 +672,7 @@ Assisted pointer targeting can customize the compact semantic details: ```ts buildInteractiveChart(container, input, { backend: 'vegalite', - interactions: [clickHighlight(), axisHighlight()], + interactions: [clickMark(), axisHighlight()], assistedTargeting: { maxDistance: 16, indicator: true, @@ -901,14 +901,15 @@ always name fields, so `groupBy: 'auto'` selects a field literally named `auto`. accepts the same explicit partition with `clickMark({ groupBy: ['Country', 'Series'] })`. `clickAnnotate()` remains local to the acquired mark. -For programmatic partitions, a `groupBy` function computes a key from the full semantic element -and interaction context. Functions are the TypeScript-only extension to the JSON-safe field and -field-list forms. The preset still owns acquisition, retained state, and presentation. +For a custom stable partition, derive a field in the input data and name it with `groupBy`: ```ts -clickMark({ groupBy: (element) => element.records?.[0]?.Region }); +clickMark({ groupBy: 'Quadrant' }); ``` +This keeps grouping serializable and reusable by other chart semantics. Event-relative or otherwise +custom interaction logic belongs in a custom `handle`, which can emit the existing style updates. + `hoverGroupFocus({ groupBy: 'Country' })` provides the transient counterpart. Its preview clears on pointer exit and does not replace retained click or brush state. A default 8-pixel nearest-mark tolerance keeps the cohort stable across narrow gaps between marks; adjust it with @@ -948,10 +949,8 @@ Canonical helpers include: - `select()`, `brushX()`, `brushY()`, and `brushAngle()` - `navigate()` -The older `clickHighlight()`, `clickGroupHighlight()`, and `hoverGroupHighlight()` names remain as -deprecated aliases. All presets are implemented on the normalized event pipeline. Existing chart -resolution and `presentUpdate` hooks remain valid; chart-specific action expansion lives in -interaction handlers. +All presets are implemented on the normalized event pipeline. Existing chart resolution and +`presentUpdate` hooks remain valid; chart-specific action expansion lives in interaction handlers. The long-term built-in preset set should stay small: hover highlight, click highlight/select, region or brush highlight, and guarded navigation. Specialized diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index ac2bbe0a..49bdb5c5 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -49,16 +49,11 @@ export type { ClickAnnotateOptions, ClickAxisIsolateOptions, ClickGroupFocusOptions, - ClickGroupHighlightOptions, - ClickHighlightOptions, ClickLegendIsolateOptions, ClickMarkOptions, FacetBrushLinkOptions, - HoverGroupHighlightOptions, HoverGroupFocusOptions, GroupBy, - GroupByFunction, - RecordGroupBy, ElementInteractionEvent, FlintInteractionEventDetail, InteractionPhase, @@ -103,7 +98,7 @@ export type { SemanticTargetSelector, } from './language/updates'; export { matchesSemanticTargetSelector } from './language/updates'; -export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickGroupHighlight, clickHighlight, clickLegendIsolate, clickMark, contextActivate, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, hoverGroupHighlight, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; +export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickLegendIsolate, clickMark, contextActivate, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; export type { InteractionEventSource } from './triggers'; export { axisBrushTrigger, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 425879aa..a4eaad89 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -18,8 +18,8 @@ import { createAngularBrushInteraction, createAxisHighlightInteraction, createClickAnnotateInteraction, - createClickGroupHighlightInteraction, - createClickHighlightInteraction, + createClickGroupFocusInteraction, + createClickMarkInteraction, createContextActivateInteraction, createDoubleActivateInteraction, createInspectInteraction, @@ -30,7 +30,7 @@ import { createNavigateInteraction, createDragReorderInteraction, createFacetBrushLinkInteraction, - createHoverGroupHighlightInteraction, + createHoverGroupFocusInteraction, createLabelIsolateInteraction, } from './presets'; import type { CanvasInteractionEvent } from './language/events'; @@ -127,21 +127,10 @@ export function isExternalInteraction(interaction: InteractionDef): interaction return 'external' in interaction; } -export interface ClickHighlightOptions { - id?: string; - dimOpacity?: number; - /** Whether legend activation focuses the represented series. Defaults to true. */ - legend?: boolean; -} - -export type RecordGroupBy = +export type GroupBy = | string | readonly string[]; -export type GroupByFunction = (element: SemanticElement, context: InteractionContext) => unknown; - -export type GroupBy = RecordGroupBy | GroupByFunction; - export interface AxisHighlightOptions { id?: string; axis?: 'x' | 'y'; @@ -149,8 +138,6 @@ export interface AxisHighlightOptions { dimOpacity?: number; } -export type ClickGroupHighlightOptions = ClickHighlightOptions & { groupBy?: GroupBy }; - export interface ClickMarkOptions { id?: string; dimOpacity?: number; @@ -170,16 +157,6 @@ export interface FacetBrushLinkOptions extends SelectOptions { brush?: 'rectangle' | 'lasso'; } -export interface HoverGroupHighlightOptions { - id?: string; - groupBy: string | readonly string[]; - dimOpacity?: number; - /** Nearest-mark hover radius in renderer pixels. Defaults to 8. */ - tolerance?: number; - /** Whether legend hover focuses the represented series. Defaults to true. */ - legend?: boolean; -} - export interface HoverGroupFocusOptions { id?: string; groupBy: string | readonly string[]; @@ -269,10 +246,10 @@ export interface DragReorderOptions { } export function clickMark(options: ClickMarkOptions = {}): CanvasInteractionDef { - const configured = { ...options, id: options.id ?? 'click-mark', legend: false }; + const configured = { ...options, id: options.id ?? 'click-mark' }; return options.groupBy === undefined - ? createClickHighlightInteraction(configured) - : createClickGroupHighlightInteraction(configured); + ? createClickMarkInteraction(configured) + : createClickGroupFocusInteraction(configured); } export function axisHighlight(options: AxisHighlightOptions = {}): CanvasInteractionDef { @@ -280,11 +257,10 @@ export function axisHighlight(options: AxisHighlightOptions = {}): CanvasInterac } export function clickGroupFocus(options: ClickGroupFocusOptions = {}): CanvasInteractionDef { - return createClickGroupHighlightInteraction({ + return createClickGroupFocusInteraction({ id: options.id ?? 'click-group-focus', dimOpacity: options.dimOpacity, groupBy: options.groupBy, - legend: false, }); } @@ -297,22 +273,7 @@ export function facetBrushLink(options: FacetBrushLinkOptions): CanvasInteractio } export function hoverGroupFocus(options: HoverGroupFocusOptions): CanvasInteractionDef { - return createHoverGroupHighlightInteraction({ ...options, id: options.id ?? 'hover-group-focus', legend: false }); -} - -/** @deprecated Use clickMark(). */ -export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInteractionDef { - return createClickHighlightInteraction(options); -} - -/** @deprecated Use clickGroupFocus(). */ -export function clickGroupHighlight(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { - return createClickGroupHighlightInteraction(options); -} - -/** @deprecated Use hoverGroupFocus(). */ -export function hoverGroupHighlight(options: HoverGroupHighlightOptions): CanvasInteractionDef { - return createHoverGroupHighlightInteraction(options); + return createHoverGroupFocusInteraction({ ...options, id: options.id ?? 'hover-group-focus' }); } export function clickLegendIsolate(options: ClickLegendIsolateOptions = {}): CanvasInteractionDef { diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts index 0fe697c9..f6bf5fd3 100644 --- a/packages/flint-js/src/interactive/presets/click-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -1,5 +1,5 @@ import type { - ClickGroupHighlightOptions, + ClickMarkOptions, InteractionContext, CanvasInteractionDef, SemanticElement, @@ -11,12 +11,13 @@ import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; import { expandElementsByFields } from './semantic-cohort'; +type GroupFocusEngineOptions = ClickMarkOptions; + function groupValue( element: SemanticElement, context: InteractionContext, groupBy: GroupBy | undefined, ): unknown { - if (typeof groupBy === 'function') return groupBy(element, context); const record = element.records?.[0]; if (!record) return undefined; if (typeof groupBy === 'string') return record[groupBy]; @@ -31,8 +32,7 @@ function groupElements( context: InteractionContext, groupBy: GroupBy | undefined, ): readonly SemanticElement[] { - if (target.visual.role === 'legend-item') return target.elements; - if (typeof groupBy === 'string' || Array.isArray(groupBy)) { + if (groupBy !== undefined) { return expandElementsByFields(target.elements, context.available, groupBy); } const source = target.elements[0]; @@ -50,22 +50,19 @@ function groupElements( return cohort.length > 1 ? cohort : target.elements; } -export function createClickGroupHighlightInteraction(options: ClickGroupHighlightOptions = {}): CanvasInteractionDef { - const id = options.id ?? 'click-group-highlight'; +export function createClickGroupFocusInteraction(options: GroupFocusEngineOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'click-group-focus'; const dimOpacity = normalizedOpacity(options.dimOpacity); const affordances: InteractionAffordance[] = [ { target: 'mark', cursor: 'activate', hover: 'cohort' }, ]; - if (options.legend !== false) { - affordances.push({ target: 'legend-item', cursor: 'activate', hover: 'cohort' }); - } return { id, eventSource: clickTrigger, affordances, handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; - if (event.target?.visual.role === 'legend-item' && options.legend === false) return null; + if (event.target?.visual.role === 'legend-item') return null; const target = event.target ? { ...event.target, elements: groupElements( event.target, context, options.groupBy, diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts index ba26cca6..90888694 100644 --- a/packages/flint-js/src/interactive/presets/click-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -1,25 +1,24 @@ -import type { CanvasInteractionDef, ClickHighlightOptions } from '../interactions'; +import type { CanvasInteractionDef, ClickMarkOptions } from '../interactions'; import type { InteractionAffordance } from '../affordances'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { clickTrigger } from '../triggers'; import { expandRangedDotTarget } from './ranged-dot-target'; -export function createClickHighlightInteraction(options: ClickHighlightOptions = {}): CanvasInteractionDef { - const id = options.id ?? 'click-highlight'; +type MarkFocusEngineOptions = Omit; + +export function createClickMarkInteraction(options: MarkFocusEngineOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'click-mark'; const dimOpacity = normalizedOpacity(options.dimOpacity); const affordances: InteractionAffordance[] = [ { target: 'mark', cursor: 'activate', hover: 'target' }, ]; - if (options.legend !== false) { - affordances.push({ target: 'legend-item', cursor: 'activate', hover: 'cohort' }); - } return { id, eventSource: clickTrigger, affordances, handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; - if (event.target?.visual.role === 'legend-item' && options.legend === false) return null; + if (event.target?.visual.role === 'legend-item') return null; const target = expandRangedDotTarget(event.target, context); return emphasisUpdate(id, event, target, dimOpacity, context); }, diff --git a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts index 585ca7c9..1184c257 100644 --- a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts @@ -1,30 +1,29 @@ -import type { CanvasInteractionDef, HoverGroupHighlightOptions } from '../interactions'; +import type { CanvasInteractionDef, HoverGroupFocusOptions } from '../interactions'; import type { InteractionAffordance } from '../affordances'; import { hoverTrigger } from '../triggers'; import { expandElementsByFields } from './semantic-cohort'; import { emphasisUpdate, normalizedOpacity } from './utils'; -export function createHoverGroupHighlightInteraction(options: HoverGroupHighlightOptions): CanvasInteractionDef { - const id = options.id ?? 'hover-group-highlight'; +type HoverGroupFocusEngineOptions = HoverGroupFocusOptions; + +export function createHoverGroupFocusInteraction(options: HoverGroupFocusEngineOptions): CanvasInteractionDef { + const id = options.id ?? 'hover-group-focus'; const dimOpacity = normalizedOpacity(options.dimOpacity); const tolerance = options.tolerance === undefined || !Number.isFinite(options.tolerance) ? 8 : Math.max(0, options.tolerance); const affordances: InteractionAffordance[] = [{ target: 'mark', hover: 'cohort' }]; - if (options.legend !== false) affordances.push({ target: 'legend-item', hover: 'cohort' }); return { id, eventSource: { ...hoverTrigger, targetTolerance: tolerance }, affordances, handle(event, context) { if (!event.action.startsWith('hover-') || event.phase !== 'preview' || !event.target) return null; - if (event.target.visual.role === 'legend-item' && options.legend === false) return null; - const target = event.target.visual.role === 'legend-item' - ? event.target - : { - ...event.target, - elements: expandElementsByFields(event.target.elements, context.available, options.groupBy), - }; + if (event.target.visual.role === 'legend-item') return null; + const target = { + ...event.target, + elements: expandElementsByFields(event.target.elements, context.available, options.groupBy), + }; return emphasisUpdate(id, event, target, dimOpacity, context); }, }; diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index e7c721d5..12a49c84 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -3,8 +3,8 @@ export { createBrushZoomInteraction } from './brush-zoom'; export { createAngularBrushInteraction } from './angular-brush'; export { createAxisHighlightInteraction } from './axis-highlight'; export { createClickAnnotateInteraction } from './click-annotate'; -export { createClickGroupHighlightInteraction } from './click-group-highlight'; -export { createClickHighlightInteraction } from './click-highlight'; +export { createClickGroupFocusInteraction } from './click-group-highlight'; +export { createClickMarkInteraction } from './click-highlight'; export { createContextActivateInteraction } from './context-activate'; export { createInspectInteraction } from './inspect'; export { createDoubleActivateInteraction, createLongPressInteraction } from './long-press'; @@ -14,5 +14,5 @@ export { createSelectInteraction } from './select'; export { createNavigateInteraction } from './navigate'; export { createDragReorderInteraction } from './drag-reorder'; export { createFacetBrushLinkInteraction } from './facet-brush-link'; -export { createHoverGroupHighlightInteraction } from './hover-group-highlight'; +export { createHoverGroupFocusInteraction } from './hover-group-highlight'; export { createLabelIsolateInteraction } from './label-isolate'; diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index f637cfc4..af1ec745 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickGroupHighlight, clickHighlight, clickLegendIsolate, clickMark, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, hoverGroupHighlight, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickLegendIsolate, clickMark, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; import { affordanceCursor, resolveInteractionAffordance } from '../src/interactive/affordances'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; @@ -372,17 +372,17 @@ describe('hover presentation policy', () => { }); it('includes only interactions that register hover presentation', () => { - const preset = clickHighlight(); + const preset = clickMark(); const observer: InteractionDef = { id: 'click-observer', eventSource: clickTrigger }; const hover: InteractionDef = { id: 'hover-observer', eventSource: hoverTrigger }; const reorder = dragReorder(); expect(interactionsForHoverPresentation([preset, observer], [hover], [reorder]).map(({ id }) => id)) - .toEqual(['click-highlight', 'drag-reorder']); + .toEqual(['click-mark', 'drag-reorder']); }); it('expands group hover presentation to the committed cohort', () => { - const interaction = clickGroupHighlight(); + const interaction = clickGroupFocus(); const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ value: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], @@ -861,8 +861,8 @@ describe('interaction definitions', () => { expect(update).toBeNull(); }); it('declares normalized event sources for built-in presets', () => { - expect(clickHighlight().eventSource).toBe(clickTrigger); - expect(clickGroupHighlight().eventSource).toBe(clickTrigger); + expect(clickMark().eventSource).toBe(clickTrigger); + expect(clickGroupFocus().eventSource).toBe(clickTrigger); expect(clickAnnotate().eventSource).toBe(clickTrigger); expect(select().eventSource).toEqual(rectangleTrigger('intersect')); expect(brushX().eventSource).toEqual(xBrushTrigger('intersect', 'ephemeral')); @@ -872,20 +872,20 @@ describe('interaction definitions', () => { }); it('resolves composed affordances by target and interaction priority', () => { - expect(affordanceCursor(resolveInteractionAffordance([clickHighlight()], 'mark'))).toBe('pointer'); + expect(affordanceCursor(resolveInteractionAffordance([clickMark()], 'mark'))).toBe('pointer'); expect(affordanceCursor(resolveInteractionAffordance( - [clickHighlight(), select()], 'mark', + [clickMark(), select()], 'mark', ))).toBe('pointer'); expect(affordanceCursor(resolveInteractionAffordance( - [clickHighlight(), select()], 'plot', + [clickMark(), select()], 'plot', ))).toBe('crosshair'); expect(resolveInteractionAffordance([select()], 'mark')).toMatchObject({ target: 'mark', cursor: 'region', }); expect(affordanceCursor(resolveInteractionAffordance( - [clickHighlight(), dragReorder()], 'mark', new Set(['click-highlight']), + [clickMark(), dragReorder()], 'mark', new Set(['click-mark']), ))).toBe('pointer'); - expect(resolveInteractionAffordance([hoverGroupHighlight({ groupBy: 'Series' })], 'mark')) + expect(resolveInteractionAffordance([hoverGroupFocus({ groupBy: 'Series' })], 'mark')) .toMatchObject({ hover: 'cohort' }); }); @@ -898,7 +898,7 @@ describe('interaction definitions', () => { .toMatchObject({ cursor: 'activate', hover: 'cohort' }); expect(resolveInteractionAffordance([clickLegendIsolate()], 'axis-label')) .toBeUndefined(); - expect(resolveInteractionAffordance([clickHighlight({ legend: false })], 'legend-item')) + expect(resolveInteractionAffordance([clickMark()], 'legend-item')) .toBeUndefined(); expect(resolveInteractionAffordance([navigate({ pan: false })], 'plot')) .toBeUndefined(); @@ -962,10 +962,10 @@ describe('interaction definitions', () => { elements: [{ value: { category: 'A' }, records: [{ category: 'A', value: 4 }] }], }; - expect(handleSemanticEvent(clickHighlight(), { + expect(handleSemanticEvent(clickMark(), { type: 'semantic', source: 'element', phase: 'commit', target, }, context)).toEqual({ - id: 'click-highlight', + id: 'click-mark', ops: [{ op: 'set-style', targets: [target], value: { state: 'emphasized', mutedOpacity: 0.25 }, @@ -988,12 +988,10 @@ describe('interaction definitions', () => { expect(hoverGroupFocus({ groupBy: 'Series' })).toMatchObject({ id: 'hover-group-focus', eventSource: hoverTrigger, }); - expect(clickHighlight()).toMatchObject({ id: 'click-highlight', eventSource: clickTrigger }); expect(axisHighlight()).toMatchObject({ id: 'axis-highlight', eventSource: clickTrigger, claimsAxisActivation: true, }); expect(axisHighlight({ event: 'hover' }).eventSource).toBe(hoverTrigger); - expect(clickGroupHighlight()).toMatchObject({ id: 'click-group-highlight', eventSource: clickTrigger }); expect(clickAnnotate()).toMatchObject({ id: 'click-annotate', eventSource: clickTrigger }); expect(select()).toMatchObject({ id: 'select', @@ -1106,13 +1104,13 @@ describe('interaction definitions', () => { it('rejects duplicate interaction ids', () => { expect(() => normalizeInteractions([ - clickHighlight({ id: 'selection' }), + clickMark({ id: 'selection' }), select({ id: 'selection' }), ])).toThrow('Duplicate interaction id: "selection".'); }); it('produces replace and toggle emphasis updates', () => { - const interaction = clickHighlight({ dimOpacity: 0.2 }); + const interaction = clickMark({ dimOpacity: 0.2 }); const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ value: { Region: 'West' } }], @@ -1149,7 +1147,7 @@ describe('interaction definitions', () => { ], }; - expect(semanticUpdate(clickHighlight(), target, context)?.ops[0]).toMatchObject({ + expect(semanticUpdate(clickMark(), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], }); expect(semanticUpdate(clickMark(), target, context)?.ops[0]).toMatchObject({ @@ -1164,14 +1162,6 @@ describe('interaction definitions', () => { expect(semanticUpdate(clickMark({ groupBy: ['Region', 'Segment'] }), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], }); - expect(semanticUpdate(clickMark({ - groupBy: (element) => element.records?.[0]?.Region, - }), target, context)?.ops[0]).toMatchObject({ - targets: [{ elements: [ - { value: { key: 'west-consumer' } }, - { value: { key: 'west-corporate' } }, - ] }], - }); expect(semanticUpdate(clickGroupFocus(), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ { value: { key: 'west-consumer' } }, @@ -1193,7 +1183,7 @@ describe('interaction definitions', () => { }); it('expands a Ranged Dot Plot unit to its complete interval in element mode', () => { - const interaction = clickHighlight(); + const interaction = clickMark(); const target = { visual: { kind: 'mark' as const, role: 'mark' }, elements: [{ value: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }], @@ -1249,7 +1239,7 @@ describe('interaction definitions', () => { }); it('uses implicit rendered color for Waterfall grouping', () => { - const interaction = clickGroupHighlight(); + const interaction = clickGroupFocus(); const semantics = waterfallChartDef.semanticInteractions!({ resolvedEncodings: { x: { field: 'Region', type: 'ordinal' }, @@ -1282,7 +1272,7 @@ describe('interaction definitions', () => { }); it('does not infer Waterfall grouping from a field name on another chart', () => { - const interaction = clickGroupHighlight(); + const interaction = clickGroupFocus(); const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ @@ -1309,32 +1299,8 @@ describe('interaction definitions', () => { }); }); - it('keeps an already-resolved legend cohort in group mode', () => { - const interaction = clickGroupHighlight(); - const target = { - visual: { kind: 'mark' as const, role: 'legend-item' }, - elements: [ - { value: { key: 'blue-circle' }, records: [{ Color: 'Blue', Shape: 'Circle' }] }, - { value: { key: 'orange-circle' }, records: [{ Color: 'Orange', Shape: 'Circle' }] }, - ], - }; - const context = { - chartType: 'Scatter Plot', - selected: [], - seriesField: 'Color', - available: [ - ...target.elements, - { value: { key: 'blue-square' }, records: [{ Color: 'Blue', Shape: 'Square' }] }, - ], - }; - - expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ - targets: [{ elements: target.elements }], - }); - }); - it('groups Strip Plot points by their categorical jitter lane', () => { - const interaction = clickGroupHighlight(); + const interaction = clickGroupFocus(); const target = { visual: { kind: 'mark' as const, role: 'circle' }, elements: [{ value: { key: 'control-4.1' }, records: [{ Group: 'Control', Value: 4.1, Color: 'Low' }] }], @@ -1359,13 +1325,13 @@ describe('interaction definitions', () => { }); }); - it('allows callers to override how a group is interpreted', () => { - const interaction = clickGroupHighlight({ - groupBy: (element) => element.records?.[0]?.Region, + it('groups by a partition derived in the input records', () => { + const interaction = clickGroupFocus({ + groupBy: 'Partition', }); const target = { visual: { kind: 'mark' as const, role: 'bar' }, - elements: [{ value: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], + elements: [{ value: { key: 'west-a' }, records: [{ Partition: 'West', Segment: 'A' }] }], }; const context = { chartType: 'Grouped Bar Chart', @@ -1373,8 +1339,8 @@ describe('interaction definitions', () => { seriesField: 'Segment', available: [ ...target.elements, - { value: { key: 'west-b' }, records: [{ Region: 'West', Segment: 'B' }] }, - { value: { key: 'east-a' }, records: [{ Region: 'East', Segment: 'A' }] }, + { value: { key: 'west-b' }, records: [{ Partition: 'West', Segment: 'B' }] }, + { value: { key: 'east-a' }, records: [{ Partition: 'East', Segment: 'A' }] }, ], }; @@ -1454,7 +1420,7 @@ describe('interaction definitions', () => { }); it('previews a semantic cohort on hover', () => { - const interaction = hoverGroupHighlight({ groupBy: 'Country' }); + const interaction = hoverGroupFocus({ groupBy: 'Country' }); const target = { visual: { kind: 'mark' as const, role: 'circle' }, elements: [{ value: {}, records: [{ Country: 'France', Year: 1952 }] }], @@ -1471,8 +1437,8 @@ describe('interaction definitions', () => { }); expect(semanticUpdate(interaction, target, context, { phase: 'commit' })).toBeNull(); expect(interaction.eventSource.targetTolerance).toBe(8); - expect(hoverGroupHighlight({ groupBy: 'Country', tolerance: 14 }).eventSource.targetTolerance).toBe(14); - expect(hoverGroupHighlight({ groupBy: 'Country', tolerance: -1 }).eventSource.targetTolerance).toBe(0); + expect(hoverGroupFocus({ groupBy: 'Country', tolerance: 14 }).eventSource.targetTolerance).toBe(14); + expect(hoverGroupFocus({ groupBy: 'Country', tolerance: -1 }).eventSource.targetTolerance).toBe(0); }); it('creates element-level annotation intent without selecting the mark', () => { @@ -2383,7 +2349,7 @@ describe('assisted, keyboard, and lasso acquisition', () => { action: 'activate-element' as const, }; - expect(clickHighlight().handle!(activation, context)?.ops[0]).toMatchObject({ + expect(clickMark().handle!(activation, context)?.ops[0]).toMatchObject({ op: 'set-style', }); expect(clickAnnotate().handle!(activation, context)?.ops[0]).toMatchObject({ @@ -2743,17 +2709,15 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(activate(interaction, axisTarget)).toBeNull(); }); - it('lets highlight presets opt out of handling observable legend events', () => { - expect(activate(clickHighlight(), seriesTarget('A'))).not.toBeNull(); - expect(activate(clickHighlight({ legend: false }), seriesTarget('A'))).toBeNull(); - expect(activate(clickGroupHighlight(), seriesTarget('A'))).not.toBeNull(); - expect(activate(clickGroupHighlight({ legend: false }), seriesTarget('A'))).toBeNull(); + it('assigns observable legend events only to legend interactions', () => { + expect(activate(clickMark(), seriesTarget('A'))).toBeNull(); + expect(activate(clickGroupFocus(), seriesTarget('A'))).toBeNull(); + expect(activate(clickLegendIsolate(), seriesTarget('A'))).not.toBeNull(); expect(activate(clickAnnotate(), seriesTarget('A'))).toBeNull(); const hover = (interaction: CanvasInteractionDef) => interaction.handle!(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'preview', target: seriesTarget('A'), }, interaction.eventSource), context); - expect(hover(hoverGroupHighlight({ groupBy: 'Series' }))).not.toBeNull(); - expect(hover(hoverGroupHighlight({ groupBy: 'Series', legend: false }))).toBeNull(); + expect(hover(hoverGroupFocus({ groupBy: 'Series' }))).toBeNull(); }); it('reports the resolved role for context, long-press, and double activation', () => { @@ -2780,12 +2744,9 @@ describe('legend, inspect, zoom, and touch presets', () => { }, clickTrigger); expect(event).toMatchObject({ action: 'click-legend', target }); - expect(activate(clickHighlight(), target)?.ops[0]).toMatchObject({ - op: 'set-style', - targets: [{ visual: target.visual, elements: target.elements }], - value: { state: 'emphasized' }, - }); - expect(activate(clickGroupHighlight(), target)?.ops[0]).toMatchObject({ + expect(activate(clickMark(), target)).toBeNull(); + expect(activate(clickGroupFocus(), target)).toBeNull(); + expect(activate(clickLegendIsolate(), target)?.ops[0]).toMatchObject({ op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], value: { state: 'emphasized' }, diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index ca3c8fb3..35d5459a 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { changeset, parse, View } from 'vega'; import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src/vegalite/assemble'; -import { axisHighlight, brushAngle, brushX, brushZoom, clickAnnotate, clickHighlight, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushZoom, clickAnnotate, clickMark, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; import type { RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; import { associateSemanticElementRenderKeys, @@ -120,7 +120,7 @@ function annotationUpdate( }; } -function instrument(spec: Record, interactions = [clickHighlight()]) { +function instrument(spec: Record, interactions = [clickMark()]) { const plan = addVegaLiteInteractions(spec, interactions); const compiled = compile(spec as any).spec as Record; if (plan) injectVegaInteractionStore(compiled, plan); @@ -418,7 +418,7 @@ describe('Vega-Lite semantic interactions', () => { data: { values: [{ category: 'A', value: 1 }, { category: 'B', value: 2 }] }, }) as any; - const plan = addVegaLiteInteractions(spec, [clickHighlight()])!; + const plan = addVegaLiteInteractions(spec, [clickMark()])!; expect(plan.reorderAxis).toBeUndefined(); expect(plan.reorderAxes).toEqual([]); @@ -603,12 +603,12 @@ describe('Vega-Lite semantic interactions', () => { }); it('rejects built-in interactions when a chart has no semantic contract', () => { - expect(() => addVegaLiteInteractions({ mark: 'line' }, [clickHighlight()])) + expect(() => addVegaLiteInteractions({ mark: 'line' }, [clickMark()])) .toThrow('requires chart interaction semantics'); expect(() => addVegaLiteInteractions({ mark: 'line', _interactionSemantics: { fields: [], selectableMarks: [], navigationAxes: ['x'] }, - }, [clickHighlight()])).toThrow('requires chart element semantics'); + }, [clickMark()])).toThrow('requires chart element semantics'); }); it('instruments semantic targets for external interactions without adding canvas gestures', () => { @@ -655,7 +655,7 @@ describe('Vega-Lite semantic interactions', () => { }, theme_spec: 'nyt', } as never) as any; - const { plan, compiled } = instrument(spec, [clickHighlight()]); + const { plan, compiled } = instrument(spec, [clickMark()]); if (!plan?.resolve) throw new Error('Expected an instrumented Bump plan'); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); @@ -709,7 +709,7 @@ describe('Vega-Lite semantic interactions', () => { semantic_types: { x: 'Date', y: 'Number' }, data: { values: [{ x: '2025-01-01', y: 2 }, { x: '2025-02-01', y: 4 }] }, }) as any; - addVegaLiteInteractions(spec, [navigate({ pan: false }), clickHighlight()]); + addVegaLiteInteractions(spec, [navigate({ pan: false }), clickMark()]); const marks = (compile(spec).spec as any).marks; const dataMarks = marks.filter((mark: any) => ['line', 'symbol'].includes(mark.type)); @@ -859,7 +859,7 @@ describe('Vega-Lite semantic interactions', () => { theme_spec: THEME_PRESETS.economist.spec, } as any) as any; - addVegaLiteInteractions(spec, [clickHighlight()]); + addVegaLiteInteractions(spec, [clickMark()]); const findPointMark = (node: any): any => { if (node.mark?.type === 'point') return node.mark; for (const property of ['layer', 'hconcat', 'vconcat', 'concat']) { @@ -1384,7 +1384,7 @@ describe('Vega-Lite semantic interactions', () => { }, }; - const plan = addVegaLiteInteractions(spec, [clickHighlight()]); + const plan = addVegaLiteInteractions(spec, [clickMark()]); const compiled = compile(spec as any).spec as Record; injectVegaInteractionStore(compiled, plan ?? undefined); const view = new View(parse(compiled), { renderer: 'none' }); @@ -1526,7 +1526,7 @@ describe('Vega-Lite semantic interactions', () => { }, }; - const plan = addVegaLiteInteractions(spec, [clickHighlight()]); + const plan = addVegaLiteInteractions(spec, [clickMark()]); expect(spec.layer[0].encoding.detail.field).toBe(INTERACTION_KEY); expect(spec.layer[1].encoding.detail.field).toBe(INTERACTION_KEY); @@ -1601,7 +1601,7 @@ describe('Vega-Lite semantic interactions', () => { ]; for (const spec of cases) { - const plan = addVegaLiteInteractions(spec, [clickHighlight()]); + const plan = addVegaLiteInteractions(spec, [clickMark()]); const compiled = compile(spec as any).spec as Record; injectVegaInteractionStore(compiled, plan ?? undefined); const view = new View(parse(compiled), { renderer: 'none' }); @@ -1891,7 +1891,7 @@ describe('Vega-Lite semantic interactions', () => { }, }; - const { plan } = instrument(spec, [clickHighlight(), select()]); + const { plan } = instrument(spec, [clickMark(), select()]); expect(plan).toMatchObject({ fields: ['Region', 'Segment'], @@ -2020,7 +2020,7 @@ describe('Vega-Lite semantic interactions', () => { }, ], }; - const { compiled } = instrument(spec, [clickHighlight()]); + const { compiled } = instrument(spec, [clickMark()]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); const target = sceneItems(view).find((item) => item.mark.marktype === 'rect'); @@ -2063,7 +2063,7 @@ describe('Vega-Lite semantic interactions', () => { opacity: { value: authoredOpacity }, }, }; - const { compiled } = instrument(spec, [clickHighlight()]); + const { compiled } = instrument(spec, [clickMark()]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); @@ -2103,7 +2103,7 @@ describe('Vega-Lite semantic interactions', () => { opacity: { field: 'Confidence', type: 'quantitative', scale: null }, }, }; - const { compiled } = instrument(spec, [clickHighlight()]); + const { compiled } = instrument(spec, [clickMark()]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); @@ -2246,7 +2246,7 @@ describe('Vega-Lite semantic interactions', () => { encodings: { x: 'Region', y: 'Value', color: 'Segment' }, }, } as never) as any; - const { compiled } = instrument(spec, [clickHighlight()]); + const { compiled } = instrument(spec, [clickMark()]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); view.change(LEGEND_HOVER_STORE, changeset().insert([{ channel: 'color', value: 'Consumer' }])); @@ -2388,7 +2388,7 @@ describe('Vega-Lite semantic interactions', () => { }, }; - const { plan, compiled } = instrument(spec, [clickHighlight(), select()]); + const { plan, compiled } = instrument(spec, [clickMark(), select()]); expect(plan).toMatchObject({ fields: ['Horsepower', 'Efficiency'] }); expect(spec).not.toHaveProperty('_interactionSemantics'); @@ -2477,7 +2477,7 @@ describe('Vega-Lite semantic interactions', () => { ], }; - const { plan, compiled } = instrument(spec, [clickHighlight()]); + const { plan, compiled } = instrument(spec, [clickMark()]); expect(plan).not.toBeNull(); expect(spec.layer[0].encoding.opacity.condition.test).toContain(INTERACTION_STORE); @@ -2512,7 +2512,7 @@ describe('Vega-Lite semantic interactions', () => { presentation: 'on-mark', }); - const { compiled } = instrument(spec, [clickHighlight()]); + const { compiled } = instrument(spec, [clickMark()]); expect(generatedLabel.encoding.opacity).toBeUndefined(); expect(generatedLabel.transform).toContainEqual({ @@ -2590,7 +2590,7 @@ describe('Vega-Lite semantic interactions', () => { role: 'legend-label', legend: { channel: 'color', field: 'Country' }, }); - instrument(spec, [clickHighlight()]); + instrument(spec, [clickMark()]); expect(label.transform).toEqual(expect.arrayContaining([ { calculate: "'legend-label'", as: INTERACTION_ROLE }, { calculate: '"color"', as: INTERACTION_LEGEND_CHANNEL }, @@ -3387,7 +3387,7 @@ describe('Vega-Lite semantic interactions', () => { }, }; - expect(addVegaLiteInteractions(spec, [clickHighlight()])).toMatchObject({ + expect(addVegaLiteInteractions(spec, [clickMark()])).toMatchObject({ fields: ['Date', 'Value'], }); expect(spec.encoding).not.toHaveProperty('detail'); @@ -3819,7 +3819,7 @@ describe('set-style visibility', () => { }, }; - addVegaLiteInteractions(spec, [clickHighlight()]); + addVegaLiteInteractions(spec, [clickMark()]); expect(spec.encoding.color.scale?.domain).toBeUndefined(); }); @@ -3839,7 +3839,7 @@ describe('set-style visibility', () => { }, }; - const { compiled } = instrument(spec, [clickHighlight()]); + const { compiled } = instrument(spec, [clickMark()]); expect(compiled.data).toContainEqual({ name: HIDDEN_STORE, values: [] }); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index c44668fc..f6dabc2a 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -58,6 +58,8 @@ export interface NavigationGuard { const unitInteractionModes = [ { value: 'click-mark', label: 'Click mark', icon: MousePointer2 }, { value: 'click-group-focus', label: 'Click group focus', icon: Layers3 }, + { value: 'click-legend-isolate', label: 'Click legend isolate', icon: Layers3 }, + { value: 'click-axis-isolate', label: 'Click axis isolate', icon: Ruler }, { value: 'hover-group-focus', label: 'Hover group focus', icon: Target }, { value: 'annotate', label: 'Annotate', icon: MessageSquareText }, { value: 'select', label: 'Select', icon: Scan }, @@ -69,8 +71,6 @@ const unitInteractionModes = [ { value: 'navigate', label: 'Pan & zoom', icon: Move }, { value: 'drag-reorder', label: 'Drag reorder', icon: GripVertical }, { value: 'lasso', label: 'Lasso', icon: Lasso }, - { value: 'click-legend-isolate', label: 'Click legend isolate', icon: Layers3 }, - { value: 'click-axis-isolate', label: 'Click axis isolate', icon: Ruler }, { value: 'inspect', label: 'Inspect xy', icon: Target }, { value: 'inspect-quadrant', label: 'Inspect quadrant', icon: Crosshair }, { value: 'inspect-x', label: 'Inspect x', icon: Ruler }, From bedc1d50d42f6d3f503dc31adf42d52317806e22 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Wed, 2 Sep 2026 00:42:51 -0700 Subject: [PATCH 25/33] merge click highlightt together --- packages/flint-js/src/README.md | 6 +- packages/flint-js/src/interactive/README.md | 36 +++-- packages/flint-js/src/interactive/index.ts | 20 ++- .../flint-js/src/interactive/interactions.ts | 53 ++----- .../src/interactive/presets/click-annotate.ts | 4 +- .../presets/click-group-highlight.ts | 9 +- .../interactive/presets/click-highlight.ts | 34 +++-- .../interactive/presets/context-activate.ts | 4 +- .../presets/hover-group-highlight.ts | 7 +- .../flint-js/src/interactive/presets/index.ts | 3 +- .../src/interactive/presets/label-isolate.ts | 44 ------ .../src/interactive/presets/long-press.ts | 6 +- packages/flint-js/src/interactive/triggers.ts | 9 ++ packages/flint-js/src/interactive/types.ts | 2 + .../src/vegalite/interactions/compile.ts | 28 +++- .../src/vegalite/interactions/hit-adapter.ts | 4 +- .../src/vegalite/interactions/runtime.ts | 124 ++++++++++++++--- .../src/vegalite/interactions/stores.ts | 2 + packages/flint-js/src/vegalite/interactive.ts | 6 +- .../src/vegalite/templates/scatter.ts | 18 ++- packages/flint-js/tests/interactions.test.ts | 130 +++++++++++++----- .../tests/semantic-interactions.test.ts | 105 ++++++++++++-- site/src/playground/ChartToExternalLab.tsx | 4 +- site/src/playground/ClickFocusLab.tsx | 39 ++---- .../playground/InteractionDashboardLab.tsx | 8 +- 25 files changed, 466 insertions(+), 239 deletions(-) delete mode 100644 packages/flint-js/src/interactive/presets/label-isolate.ts diff --git a/packages/flint-js/src/README.md b/packages/flint-js/src/README.md index 5dc09bc3..f3f71494 100644 --- a/packages/flint-js/src/README.md +++ b/packages/flint-js/src/README.md @@ -168,7 +168,7 @@ Each backend has its own assembly function. All accept the same Interactive renderers are opt-in and shipped separately from the static assembly entry point. The surface owns interaction coordination, viewport state, accessible scroll controls, and renderer lifecycle; the caller supplies only a container and chart input. ```ts -import { buildInteractiveChart, clickMark, externalInteraction } from 'flint-chart/interactive'; +import { buildInteractiveChart, clickHighlight, externalInteraction } from 'flint-chart/interactive'; const surface = buildInteractiveChart( container, @@ -176,7 +176,7 @@ const surface = buildInteractiveChart( { backend: 'vegalite', renderer: 'canvas', - interactions: [clickMark()], + interactions: [clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] })], }, ); @@ -204,7 +204,7 @@ const countryPicker = externalInteraction<{ country: string }>({ await surface.dispatch('country-picker', { country: 'Japan' }); ``` -The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite interactions are enabled explicitly through `interactions`; `clickMark()` selects marks, Shift/Ctrl/Meta-click toggles marks, and clicking empty plot space clears. Other backends currently reject semantic interactions. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`; the surface invokes external handlers, while adapters expose interaction context and apply renderer-neutral updates. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. +The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite interactions are enabled explicitly through `interactions`; `clickHighlight()` focuses configured mark, legend, and discrete-axis targets, Shift/Ctrl/Meta-click toggles targets, and clicking empty plot space clears. Other backends currently reject semantic interactions. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`; the surface invokes external handlers, while adapters expose interaction context and apply renderer-neutral updates. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. ### Input types diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index e930c144..87b9cd84 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -667,14 +667,19 @@ A custom source may register listeners and emit normalized events. Renderer-spec Assisted pointer and keyboard targeting share a transient target indicator and a floating semantic tooltip. Keyboard arrows move the indicator, apply the active hover styling, and emit `focus-element` through the `keyboard-targeting` interaction ID even when no click preset is configured. Enter or Space invokes any configured click presets. The tooltip uses the compiled pointer-hover fields, stays clear of the active mark, may extend beyond the chart canvas, and scrolls with the chart. -Assisted pointer targeting can customize the compact semantic details: +Eligible element presets use modest assisted pointer targeting by default: click, annotation, +context, and double activation use an 8-pixel acquisition radius, hover uses 6 pixels, and long +press uses 12 pixels. Region selection, brushing, navigation, and element dragging never use +assisted acquisition. Set `assistedTargeting: false` to require direct hits globally, or provide +`maxDistance` as a hard override for all eligible presets. Indicator and detail feedback remain +opt-in: ```ts buildInteractiveChart(container, input, { backend: 'vegalite', - interactions: [clickMark(), axisHighlight()], + interactions: [clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] }), axisHighlight()], assistedTargeting: { - maxDistance: 16, + maxDistance: 10, indicator: true, details: { fields: ['country', 'value'], maxRows: 4 }, }, @@ -897,29 +902,30 @@ introduce facet-specific chart state. `clickGroupFocus()` infers a chart-semantic partition, while `clickGroupFocus({ groupBy: 'Country' })` uses explicit input-record field-key expansion. Plain strings -always name fields, so `groupBy: 'auto'` selects a field literally named `auto`. `clickMark()` remains local to the acquired mark by default, and -accepts the same explicit partition with `clickMark({ groupBy: ['Country', 'Series'] })`. +always name fields, so `groupBy: 'auto'` selects a field literally named `auto`. +`clickHighlight({ targets: ['mark'] })` remains local to the acquired mark. `clickAnnotate()` remains local to the acquired mark. For a custom stable partition, derive a field in the input data and name it with `groupBy`: ```ts -clickMark({ groupBy: 'Quadrant' }); +clickGroupFocus({ groupBy: 'Quadrant' }); ``` This keeps grouping serializable and reusable by other chart semantics. Event-relative or otherwise custom interaction logic belongs in a custom `handle`, which can emit the existing style updates. `hoverGroupFocus({ groupBy: 'Country' })` provides the transient counterpart. Its preview -clears on pointer exit and does not replace retained click or brush state. A default 8-pixel -nearest-mark tolerance keeps the cohort stable across narrow gaps between marks; adjust it with -`tolerance` or set it to zero for direct hits only. +clears on pointer exit and does not replace retained click or brush state. Assisted acquisition +finds a nearby mark within the preset's default 6-pixel radius. Separately, the default 8-pixel +`tolerance` keeps the last resolved cohort stable across narrow gaps; set it to zero to disable +that gap retention. -## Legend and axis controls +## Click highlight targets -`clickMark()` only claims marks. `legendToggle()` changes visibility, -`clickLegendIsolate()` emphasizes a legend cohort, and `clickAxisIsolate()` emphasizes a -cohort selected from a discrete axis label with `set-style`. +`clickHighlight()` emphasizes cohorts through one retained interaction. Its `targets` +option accepts `mark`, `legend`, and `discreteAxis`; omitted targets enable all three. +`legendToggle()` remains a separate visibility interaction. Only compiler-declared discrete axis ticks are semantic cohorts; continuous ticks do not implicitly become clickable selections. Continuous legend intervals remain resolvable labels. @@ -940,12 +946,12 @@ and the renderer still owns presentation. Canonical helpers include: -- `clickMark()` +- `clickHighlight()` - `clickGroupFocus()` - `clickAnnotate()` - `facetBrushLink()` - `hoverGroupFocus()` -- `clickLegendIsolate()`, `clickAxisIsolate()`, and `legendToggle()` +- `legendToggle()` - `select()`, `brushX()`, `brushY()`, and `brushAngle()` - `navigate()` diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 49bdb5c5..6f641c5a 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -47,10 +47,9 @@ export type { AngularBrushOptions, AxisHighlightOptions, ClickAnnotateOptions, - ClickAxisIsolateOptions, + ClickHighlightOptions, + ClickHighlightTarget, ClickGroupFocusOptions, - ClickLegendIsolateOptions, - ClickMarkOptions, FacetBrushLinkOptions, HoverGroupFocusOptions, GroupBy, @@ -98,7 +97,7 @@ export type { SemanticTargetSelector, } from './language/updates'; export { matchesSemanticTargetSelector } from './language/updates'; -export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickLegendIsolate, clickMark, contextActivate, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; +export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; export type { InteractionEventSource } from './triggers'; export { axisBrushTrigger, @@ -119,9 +118,6 @@ export { } from './triggers'; export { clampViewportStart, mountInteractiveChartSurface } from './surface'; -/** Snap radius in renderer units when assisted targeting is enabled without a distance. */ -const DEFAULT_ASSIST_DISTANCE = 12; - export function buildInteractiveChart( container: HTMLElement, input: ChartAssemblyInput, @@ -164,10 +160,12 @@ export function buildInteractiveChart( || keyboardTargeting === true, expressionInterpreter, background, - assistDistance: assistedTargeting - ? (typeof assistedTargeting === 'object' ? assistedTargeting.maxDistance : undefined) - ?? DEFAULT_ASSIST_DISTANCE - : 0, + assistDistance: assistedTargeting === false + ? 0 + : typeof assistedTargeting === 'object' + && assistedTargeting.maxDistance !== undefined + ? Math.max(0, assistedTargeting.maxDistance) + : undefined, hoverTolerance, targetFeedback: { assisted: typeof assistedTargeting === 'object' ? assistedTargeting : assistedTargeting ? {} : false, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index a4eaad89..74ed29c9 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -19,7 +19,7 @@ import { createAxisHighlightInteraction, createClickAnnotateInteraction, createClickGroupFocusInteraction, - createClickMarkInteraction, + createClickHighlightInteraction, createContextActivateInteraction, createDoubleActivateInteraction, createInspectInteraction, @@ -31,7 +31,6 @@ import { createDragReorderInteraction, createFacetBrushLinkInteraction, createHoverGroupFocusInteraction, - createLabelIsolateInteraction, } from './presets'; import type { CanvasInteractionEvent } from './language/events'; export type { @@ -96,6 +95,8 @@ export interface CanvasInteractionDef { readonly id: string; readonly eventSource: InteractionEventSource; readonly affordances?: readonly InteractionAffordance[]; + /** Retained updates from interactions in the same group replace one another. */ + readonly retainedStateGroup?: string; readonly navigationDomainGuard?: NavigationDomainGuard; /** Claims legend activations exclusively, so a legend click never also reads as an element click. */ readonly claimsLegendActivation?: boolean; @@ -138,13 +139,20 @@ export interface AxisHighlightOptions { dimOpacity?: number; } -export interface ClickMarkOptions { +export type ClickHighlightTarget = 'mark' | 'legend' | 'discreteAxis'; + +export interface ClickHighlightOptions { id?: string; dimOpacity?: number; - groupBy?: GroupBy; + /** Semantic surfaces activated by this preset. Defaults to all three targets. */ + targets?: readonly ClickHighlightTarget[]; } -export type ClickGroupFocusOptions = ClickMarkOptions; +export interface ClickGroupFocusOptions { + id?: string; + dimOpacity?: number; + groupBy?: GroupBy; +} export interface ClickAnnotateOptions { id?: string; @@ -165,18 +173,6 @@ export interface HoverGroupFocusOptions { tolerance?: number; } -export interface ClickLegendIsolateOptions { - id?: string; - dimOpacity?: number; -} - -export interface ClickAxisIsolateOptions { - id?: string; - dimOpacity?: number; - /** Discrete positional axes to activate. Defaults to both axes. */ - axes?: readonly ('x' | 'y')[]; -} - export interface SelectOptions { id?: string; match?: 'intersect' | 'contain'; @@ -245,11 +241,8 @@ export interface DragReorderOptions { id?: string; } -export function clickMark(options: ClickMarkOptions = {}): CanvasInteractionDef { - const configured = { ...options, id: options.id ?? 'click-mark' }; - return options.groupBy === undefined - ? createClickMarkInteraction(configured) - : createClickGroupFocusInteraction(configured); +export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInteractionDef { + return createClickHighlightInteraction(options); } export function axisHighlight(options: AxisHighlightOptions = {}): CanvasInteractionDef { @@ -276,22 +269,6 @@ export function hoverGroupFocus(options: HoverGroupFocusOptions): CanvasInteract return createHoverGroupFocusInteraction({ ...options, id: options.id ?? 'hover-group-focus' }); } -export function clickLegendIsolate(options: ClickLegendIsolateOptions = {}): CanvasInteractionDef { - return createLabelIsolateInteraction({ - ...options, - id: options.id ?? 'click-legend-isolate', - targets: ['legend'], - }); -} - -export function clickAxisIsolate(options: ClickAxisIsolateOptions = {}): CanvasInteractionDef { - return createLabelIsolateInteraction({ - ...options, - id: options.id ?? 'click-axis-isolate', - targets: options.axes ?? ['x', 'y'], - }); -} - export function select(options: SelectOptions = {}): CanvasInteractionDef { return createSelectInteraction(options); } diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts index 7b4d48d8..c70b791b 100644 --- a/packages/flint-js/src/interactive/presets/click-annotate.ts +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -3,14 +3,14 @@ import type { CanvasInteractionDef, } from '../interactions'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; -import { clickTrigger } from '../triggers'; +import { assistedElementTrigger, clickTrigger } from '../triggers'; export function createClickAnnotateInteraction(options: ClickAnnotateOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-annotate'; const dimOpacity = normalizedOpacity(options.dimOpacity); return { id, - eventSource: clickTrigger, + eventSource: assistedElementTrigger(clickTrigger, 8), affordances: [{ target: 'mark', cursor: 'activate' }], handle(event, context) { if (!isActivationAction(event.action) || event.phase !== 'commit') return null; diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts index f6bf5fd3..c7c2138d 100644 --- a/packages/flint-js/src/interactive/presets/click-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -1,5 +1,5 @@ import type { - ClickMarkOptions, + ClickGroupFocusOptions, InteractionContext, CanvasInteractionDef, SemanticElement, @@ -8,10 +8,10 @@ import type { } from '../interactions'; import type { InteractionAffordance } from '../affordances'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; -import { clickTrigger } from '../triggers'; +import { assistedElementTrigger, clickTrigger } from '../triggers'; import { expandElementsByFields } from './semantic-cohort'; -type GroupFocusEngineOptions = ClickMarkOptions; +type GroupFocusEngineOptions = ClickGroupFocusOptions; function groupValue( element: SemanticElement, @@ -58,7 +58,8 @@ export function createClickGroupFocusInteraction(options: GroupFocusEngineOption ]; return { id, - eventSource: clickTrigger, + eventSource: assistedElementTrigger(clickTrigger, 8), + retainedStateGroup: 'focus', affordances, handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts index 90888694..d4f12f3b 100644 --- a/packages/flint-js/src/interactive/presets/click-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -1,25 +1,37 @@ -import type { CanvasInteractionDef, ClickMarkOptions } from '../interactions'; +import type { CanvasInteractionDef, ClickHighlightOptions, ClickHighlightTarget } from '../interactions'; import type { InteractionAffordance } from '../affordances'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; -import { clickTrigger } from '../triggers'; +import { assistedElementTrigger, clickTrigger } from '../triggers'; import { expandRangedDotTarget } from './ranged-dot-target'; -type MarkFocusEngineOptions = Omit; +const DEFAULT_TARGETS: readonly ClickHighlightTarget[] = ['mark', 'legend', 'discreteAxis']; -export function createClickMarkInteraction(options: MarkFocusEngineOptions = {}): CanvasInteractionDef { - const id = options.id ?? 'click-mark'; +export function createClickHighlightInteraction(options: ClickHighlightOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'click-highlight'; const dimOpacity = normalizedOpacity(options.dimOpacity); - const affordances: InteractionAffordance[] = [ - { target: 'mark', cursor: 'activate', hover: 'target' }, - ]; + const targets = new Set(options.targets ?? DEFAULT_TARGETS); + const affordances: InteractionAffordance[] = []; + if (targets.has('mark')) affordances.push({ target: 'mark', cursor: 'activate', hover: 'target' }); + if (targets.has('legend')) affordances.push({ target: 'legend-item', cursor: 'activate', hover: 'cohort' }); + if (targets.has('discreteAxis')) affordances.push({ target: 'axis-label', cursor: 'activate', hover: 'cohort' }); return { id, - eventSource: clickTrigger, + eventSource: assistedElementTrigger(clickTrigger, 8), + retainedStateGroup: 'focus', + claimsLegendActivation: targets.has('legend'), + claimsAxisActivation: targets.has('discreteAxis'), affordances, handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; - if (event.target?.visual.role === 'legend-item') return null; - const target = expandRangedDotTarget(event.target, context); + if (!event.target) return emphasisUpdate(id, event, null, dimOpacity, context); + const isLegend = event.target.visual.role === 'legend-item'; + const isAxis = event.target.visual.kind === 'axis'; + if (isLegend && !targets.has('legend')) return null; + if (isAxis && !targets.has('discreteAxis')) return null; + if (!isLegend && !isAxis && !targets.has('mark')) return null; + const target = isLegend || isAxis + ? event.target + : expandRangedDotTarget(event.target, context); return emphasisUpdate(id, event, target, dimOpacity, context); }, }; diff --git a/packages/flint-js/src/interactive/presets/context-activate.ts b/packages/flint-js/src/interactive/presets/context-activate.ts index 31500c54..ab7067b0 100644 --- a/packages/flint-js/src/interactive/presets/context-activate.ts +++ b/packages/flint-js/src/interactive/presets/context-activate.ts @@ -1,5 +1,5 @@ import type { CanvasInteractionDef, ContextActivateOptions } from '../interactions'; -import { contextTrigger } from '../triggers'; +import { assistedElementTrigger, contextTrigger } from '../triggers'; /** * Reports a context request on the chart target and leaves the chart unchanged; @@ -10,7 +10,7 @@ export function createContextActivateInteraction( ): CanvasInteractionDef { return { id: options.id ?? 'context-activate', - eventSource: contextTrigger, + eventSource: assistedElementTrigger(contextTrigger, 8), affordances: [{ target: 'mark', cursor: 'activate' }], }; } diff --git a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts index 1184c257..0dc1e8b2 100644 --- a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts @@ -1,6 +1,6 @@ import type { CanvasInteractionDef, HoverGroupFocusOptions } from '../interactions'; import type { InteractionAffordance } from '../affordances'; -import { hoverTrigger } from '../triggers'; +import { assistedElementTrigger, hoverTrigger } from '../triggers'; import { expandElementsByFields } from './semantic-cohort'; import { emphasisUpdate, normalizedOpacity } from './utils'; @@ -15,7 +15,10 @@ export function createHoverGroupFocusInteraction(options: HoverGroupFocusEngineO const affordances: InteractionAffordance[] = [{ target: 'mark', hover: 'cohort' }]; return { id, - eventSource: { ...hoverTrigger, targetTolerance: tolerance }, + eventSource: { + ...assistedElementTrigger(hoverTrigger, 6), + targetTolerance: tolerance, + }, affordances, handle(event, context) { if (!event.action.startsWith('hover-') || event.phase !== 'preview' || !event.target) return null; diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index 12a49c84..20bd8cd5 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -4,7 +4,7 @@ export { createAngularBrushInteraction } from './angular-brush'; export { createAxisHighlightInteraction } from './axis-highlight'; export { createClickAnnotateInteraction } from './click-annotate'; export { createClickGroupFocusInteraction } from './click-group-highlight'; -export { createClickMarkInteraction } from './click-highlight'; +export { createClickHighlightInteraction } from './click-highlight'; export { createContextActivateInteraction } from './context-activate'; export { createInspectInteraction } from './inspect'; export { createDoubleActivateInteraction, createLongPressInteraction } from './long-press'; @@ -15,4 +15,3 @@ export { createNavigateInteraction } from './navigate'; export { createDragReorderInteraction } from './drag-reorder'; export { createFacetBrushLinkInteraction } from './facet-brush-link'; export { createHoverGroupFocusInteraction } from './hover-group-highlight'; -export { createLabelIsolateInteraction } from './label-isolate'; diff --git a/packages/flint-js/src/interactive/presets/label-isolate.ts b/packages/flint-js/src/interactive/presets/label-isolate.ts deleted file mode 100644 index 0d9258a7..00000000 --- a/packages/flint-js/src/interactive/presets/label-isolate.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { CanvasInteractionDef } from '../interactions'; -import { clickTrigger } from '../triggers'; -import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; - -const DEFAULT_TARGETS = ['legend', 'x', 'y'] as const; - -interface LabelIsolatePresetOptions { - id?: string; - dimOpacity?: number; - targets?: readonly ('legend' | 'x' | 'y')[]; -} - -export function createLabelIsolateInteraction(options: LabelIsolatePresetOptions = {}): CanvasInteractionDef { - const id = options.id ?? 'label-isolate'; - const dimOpacity = normalizedOpacity(options.dimOpacity); - const targets = new Set(options.targets ?? DEFAULT_TARGETS); - const claimsLegend = targets.has('legend'); - const claimsAxis = targets.has('x') || targets.has('y'); - - return { - id, - eventSource: clickTrigger, - claimsLegendActivation: claimsLegend, - claimsAxisActivation: claimsAxis, - affordances: [ - ...(claimsLegend ? [{ target: 'legend-item' as const, cursor: 'activate' as const, hover: 'cohort' as const }] : []), - ...(claimsAxis ? [{ target: 'axis-label' as const, cursor: 'activate' as const, hover: 'cohort' as const }] : []), - ], - handle(event, context) { - if (!isActivationAction(event.action) || event.phase !== 'commit') return null; - const target = event.target; - if (!target) return emphasisUpdate(id, event, null, dimOpacity, context); - if (target.visual.role === 'legend-item') { - return claimsLegend ? emphasisUpdate(id, event, target, dimOpacity, context) : null; - } - if (target.visual.kind !== 'axis') return null; - const eligible = target.elements.some((element) => { - const axis = element.value.axis; - return (axis === 'x' || axis === 'y') && targets.has(axis); - }); - return eligible ? emphasisUpdate(id, event, target, dimOpacity, context) : null; - }, - }; -} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/long-press.ts b/packages/flint-js/src/interactive/presets/long-press.ts index 44233465..4a749987 100644 --- a/packages/flint-js/src/interactive/presets/long-press.ts +++ b/packages/flint-js/src/interactive/presets/long-press.ts @@ -3,7 +3,7 @@ import type { DoubleActivateOptions, LongPressOptions, } from '../interactions'; -import { doubleActivateTrigger, longPressTrigger } from '../triggers'; +import { assistedElementTrigger, doubleActivateTrigger, longPressTrigger } from '../triggers'; import { emphasisUpdate, normalizedOpacity } from './utils'; /** Highlights and reports a sustained press on a chart target. */ @@ -12,7 +12,7 @@ export function createLongPressInteraction(options: LongPressOptions = {}): Canv const dimOpacity = normalizedOpacity(options.dimOpacity); return { id, - eventSource: longPressTrigger(options.holdMs ?? 500), + eventSource: assistedElementTrigger(longPressTrigger(options.holdMs ?? 500), 12), affordances: [{ target: 'mark', cursor: 'activate' }], handle(event, context) { if (!event.action.startsWith('long-press-') || event.phase !== 'commit') return null; @@ -29,7 +29,7 @@ export function createDoubleActivateInteraction( const dimOpacity = normalizedOpacity(options.dimOpacity); return { id, - eventSource: doubleActivateTrigger, + eventSource: assistedElementTrigger(doubleActivateTrigger, 8), affordances: [{ target: 'mark', cursor: 'activate' }], handle(event, context) { if (!event.action.startsWith('double-activate-') || event.phase !== 'commit') return null; diff --git a/packages/flint-js/src/interactive/triggers.ts b/packages/flint-js/src/interactive/triggers.ts index f9de49ad..6ace623b 100644 --- a/packages/flint-js/src/interactive/triggers.ts +++ b/packages/flint-js/src/interactive/triggers.ts @@ -27,6 +27,8 @@ export interface InteractionEventSource { readonly inspectTolerance?: number; /** Nearest-mark acquisition radius for hover gestures, in renderer pixels. */ readonly targetTolerance?: number; + /** Preset-owned nearest-mark acquisition radius, in renderer pixels. */ + readonly defaultAssistDistance?: number; readonly inspectGuide?: ReturnType; readonly regionGuide?: ReturnType; readonly selector?: SemanticTargetSelector; @@ -52,6 +54,13 @@ export const hoverTrigger = Object.freeze({ gesture: 'hover', } as const satisfies InteractionEventSource); +export function assistedElementTrigger( + source: InteractionEventSource, + defaultAssistDistance: number, +): InteractionEventSource { + return { ...source, defaultAssistDistance: Math.max(0, defaultAssistDistance) }; +} + export function rectangleTrigger( match: 'intersect' | 'contain' = 'intersect', guide?: RegionGuideOptions | false, diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index 897d2f96..42a6dd6f 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -24,6 +24,7 @@ export interface TargetFeedbackOptions { } export interface AssistedTargetingOptions extends TargetFeedbackOptions { + /** Hard override for eligible preset distances, in renderer pixels. */ maxDistance?: number; } @@ -60,6 +61,7 @@ export interface InteractiveChartSurfaceOptions { chartId?: string; updates?: readonly ChartUpdate[]; interactions?: readonly InteractionDef[]; + /** Presets assist by default; false disables it and maxDistance overrides eligible presets. */ assistedTargeting?: boolean | AssistedTargetingOptions; keyboardTargeting?: boolean; /** How committed presentation and annotation state is cleared. */ diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index 2d6b4996..5b3cf157 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -28,6 +28,7 @@ import { HOVER_STORE, INTERACTION_STORE, LEGEND_HOVER_STORE, + AXIS_HOVER_STORE, LEGEND_SELECTION_STORE, STYLE_SIGNAL, } from './stores'; @@ -574,6 +575,7 @@ export function collectVegaAxisTargets( vegaSpec: Record, axisFields: VegaInteractionPlan['axisFields'], reorderAxes: readonly Pick[] = [], + hoverColor?: string, ): Record { const targets: Record = {}; const visit = (scope: Record): void => { @@ -583,12 +585,35 @@ export function collectVegaAxisTargets( const field = channel ? axisFields?.[channel] : undefined; if (!channel || !field || typeof axis.scale !== 'string') continue; targets[axis.scale] = { axis: channel, ...field }; + const hoveredAxisLabel = hoverColor + ? `length(data('${AXIS_HOVER_STORE}')) && ` + + `data('${AXIS_HOVER_STORE}')[0].scale === ${JSON.stringify(axis.scale)} && ` + + `data('${AXIS_HOVER_STORE}')[0].value === datum.value` + : undefined; + const existingLabelFill = hoverColor + ? axis.encode?.labels?.update?.fill ?? axis.encode?.labels?.enter?.fill ?? { value: '#4a4a4a' } + : undefined; + const existingFontWeight = hoverColor + ? axis.encode?.labels?.update?.fontWeight ?? axis.encode?.labels?.enter?.fontWeight ?? { value: 'normal' } + : undefined; axis.encode = { ...(axis.encode ?? {}), labels: { ...(axis.encode?.labels ?? {}), interactive: true, - update: { ...(axis.encode?.labels?.update ?? {}) }, + update: { + ...(axis.encode?.labels?.update ?? {}), + ...(hoveredAxisLabel && existingLabelFill && existingFontWeight ? { + fill: [ + { test: hoveredAxisLabel, value: hoverColor }, + ...(Array.isArray(existingLabelFill) ? existingLabelFill : [existingLabelFill]), + ], + fontWeight: [ + { test: hoveredAxisLabel, value: 600 }, + ...(Array.isArray(existingFontWeight) ? existingFontWeight : [existingFontWeight]), + ], + } : {}), + }, }, ticks: { ...(axis.encode?.ticks ?? {}), @@ -686,6 +711,7 @@ export function injectVegaInteractionStore( { name: HIDDEN_STORE, values: [] }, { name: LEGEND_HIDDEN_STORE, values: [] }, { name: LEGEND_HOVER_STORE, values: [] }, + { name: AXIS_HOVER_STORE, values: [] }, { name: LEGEND_SELECTION_STORE, values: [] }, ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), ]; diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts index 6bfd9829..7d86075b 100644 --- a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -762,7 +762,7 @@ export function legendSemanticTarget( export function axisTargetIdentity( item: any, targets: Readonly> | undefined, -): (import('./contracts').VegaAxisTarget & { value: unknown; role: string }) | null { +): (import('./contracts').VegaAxisTarget & { scale: string; value: unknown; role: string }) | null { const role = item?.mark?.role; if (role !== 'axis-label' && role !== 'axis-tick') return null; let group = item?.mark?.group; @@ -772,7 +772,7 @@ export function axisTargetIdentity( if (!target || (target.type !== 'nominal' && target.type !== 'ordinal') || item?.datum?.value === undefined) { return null; } - return { ...target, value: item.datum.value, role }; + return { ...target, scale, value: item.datum.value, role }; } export function axisItemAt( diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index 0c9adbe8..56c08280 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -16,6 +16,7 @@ import type { InteractionDef, NavigationInteractionEvent, RenderHit, + SemanticElement, SemanticTarget, SemanticInteractionEvent, } from '../../interactive/interactions'; @@ -83,6 +84,7 @@ import { HOVER_STORE, INTERACTION_STORE, LEGEND_HOVER_STORE, + AXIS_HOVER_STORE, LEGEND_SELECTION_STORE, STYLE_SIGNAL, } from './stores'; @@ -340,6 +342,36 @@ export function enrichTargetWithSourceProvenance( return { ...target, elements }; } +const ASSISTED_GESTURES = new Set(['click', 'hover', 'context', 'long-press', 'double']); + +export function resolveAssistDistance( + interactions: readonly CanvasInteractionDef[], + override?: number, +): number { + const eligible = interactions.filter((interaction) => + interaction.eventSource.type === 'element' + && ASSISTED_GESTURES.has(interaction.eventSource.gesture ?? '')); + if (eligible.length === 0) return 0; + return override ?? Math.max(0, ...eligible.map((interaction) => + interaction.eventSource.defaultAssistDistance ?? 0)); +} + +export function evictRetainedStateSiblings( + interaction: CanvasInteractionDef, + interactions: readonly CanvasInteractionDef[], + retained: Map, + preview: Map, +): CanvasInteractionDef[] { + if (!interaction.retainedStateGroup) return []; + const siblings = interactions.filter((candidate) => candidate.id !== interaction.id + && candidate.retainedStateGroup === interaction.retainedStateGroup); + for (const sibling of siblings) { + retained.delete(sibling.id); + preview.delete(sibling.id); + } + return siblings; +} + export function mountVegaInteractions( view: any, container: HTMLElement, @@ -348,7 +380,7 @@ export function mountVegaInteractions( interactions: readonly InteractionDef[], resolve: ChartInteractionResolver | undefined, presentUpdate: ChartUpdatePresenter, - assistDistance = 0, + assistDistance: number | undefined = undefined, hoverTolerance = 0, keyboardTargeting = false, targetFeedback: { @@ -366,7 +398,8 @@ export function mountVegaInteractions( : []; const axisClickInteractions = clickInteractions.filter((interaction) => interaction.claimsAxisActivation); const markClickInteractions = clickInteractions.filter((interaction) => - !interaction.claimsAxisActivation || interaction.claimsLegendActivation); + resolveInteractionAffordance([interaction], 'mark') + || resolveInteractionAffordance([interaction], 'legend-item')); const axisHoverInteractions = hoverInteractions.filter((interaction) => interaction.claimsAxisActivation); const markHoverInteractions = hoverInteractions.filter((interaction) => !interaction.claimsAxisActivation); const axisHoverPresentationInteractions = [...axisClickInteractions, ...axisHoverInteractions] @@ -402,6 +435,8 @@ export function mountVegaInteractions( (interaction) => interaction.eventSource.type === 'navigation', ); const elementDragInteraction = elementDragInteractions[0]; + const assistDistanceFor = (eligible: readonly CanvasInteractionDef[]): number => + resolveAssistDistance(eligible, assistDistance); const retainedUpdates = new Map(); const previewUpdates = new Map(); const selectedElements = new Map(); @@ -526,7 +561,25 @@ export function mountVegaInteractions( }); const withSourceProvenance = (target: SemanticTarget | null): SemanticTarget | null => enrichTargetWithSourceProvenance(target, plan); - const context = (includeAvailable = true) => { + const selectedForInteraction = (interaction: CanvasInteractionDef): SemanticElement[] => { + if (!interaction.retainedStateGroup) return [...selectedElements.values()]; + const keys = new Set(); + for (const update of [retainedUpdates.get(interaction.id), previewUpdates.get(interaction.id)]) { + if (!update) continue; + for (const op of update.ops) { + if (op.op !== 'set-style' + || (op.value.state !== 'emphasized' && op.value.state !== 'focused')) continue; + for (const target of op.targets) { + if ('select' in target) continue; + for (const element of target.elements) { + for (const key of semanticElementRenderKeys(element)) keys.add(key); + } + } + } + } + return [...selectedElements].flatMap(([key, element]) => keys.has(key) ? [element] : []); + }; + const context = (includeAvailable = true, interaction?: CanvasInteractionDef) => { // Navigation resolves per gesture frame, so the scenegraph scan stays behind this flag. const available = includeAvailable ? (() => { @@ -558,7 +611,7 @@ export function mountVegaInteractions( ])); return { chartType, - selected: [...selectedElements.values()], + selected: interaction ? selectedForInteraction(interaction) : [...selectedElements.values()], available, resolveGroupValue: plan.resolveGroupValue, resolveNavigation: navigationController.resolve, @@ -812,7 +865,7 @@ export function mountVegaInteractions( }; const applyInteractionUpdate = async ( - interaction: InteractionDef, + interaction: CanvasInteractionDef, phase: import('../../interactive/interactions').InteractionPhase, update: ChartUpdate | null, legendSelection: LegendHitIdentity | null = null, @@ -824,6 +877,13 @@ export function mountVegaInteractions( if (update) { const preview = phase === 'start' || phase === 'preview'; if (!preview) previewUpdates.delete(interaction.id); + if (!preview && interaction.retainedStateGroup) { + for (const sibling of evictRetainedStateSiblings( + interaction, canvasInteractions, retainedUpdates, previewUpdates, + )) { + if (sibling.claimsLegendActivation) selectedLegend = null; + } + } await storeUpdate(update, preview ? previewUpdates : retainedUpdates, legendSelection); return; } @@ -873,7 +933,7 @@ export function mountVegaInteractions( legendSelection: LegendHitIdentity | null = null, actionOverride?: CanvasInteractionEvent['action'], ): Promise => { - const interactionContext = context(!interaction.eventSource.viewport); + const interactionContext = context(!interaction.eventSource.viewport, interaction); const base = toCanvasInteractionEvent(event, interaction.eventSource); const domain = domainForGeometry(base.geometry.plot); const withDomain = domain @@ -935,9 +995,11 @@ export function mountVegaInteractions( const setHover = async ( keys: readonly string[], legend: LegendHitIdentity | null = null, + axis: { scale: string; value: unknown } | null = null, ): Promise => { const next = [...new Set(keys)].sort(); - const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}`; + const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}` + + `\u0001${axis?.scale ?? ''}\u0000${String(axis?.value ?? '')}`; if (signature === hoveredKeys) return; hoveredKeys = signature; hoveredPathKeys = new Set(next.filter((key) => key.endsWith(PATH_KEY_SUFFIX))); @@ -954,6 +1016,10 @@ export function mountVegaInteractions( LEGEND_HOVER_STORE, changeset().remove(() => true).insert(legend ? [legend] : []), ); + view.change( + AXIS_HOVER_STORE, + changeset().remove(() => true).insert(axis ? [axis] : []), + ); await view.runAsync(); renderPathFocus(); renderLegendRange(); @@ -992,7 +1058,7 @@ export function mountVegaInteractions( rootPoint: import('../../interactive/interactions').PlotPoint, phase: 'preview' | 'commit', modifiers: ReturnType, - tolerance = assistDistance, + tolerance = 0, ) => { const space = coordinateSpace(); const direct = normalizeVegaElementEvent( @@ -1022,6 +1088,7 @@ export function mountVegaInteractions( const axisTarget = resolveAxisTarget(item); if (axisTarget) { const identity = axisTargetIdentity(item, plan.axisTargets); + if (!identity) return clearHover(); const reorderEligible = !!elementDragInteraction && !!identity && eligibleReorderAxesForAxis( plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), @@ -1035,10 +1102,18 @@ export function mountVegaInteractions( modifiers: interactionModifiers(event), }); } - void setHover(axisTarget.elements.flatMap(semanticElementRenderKeys)); + void setHover( + axisTarget.elements.flatMap(semanticElementRenderKeys), + null, + { scale: identity.scale, value: identity.value }, + ); return; } - const normalized = acquire(item, point, rootPoint, 'preview', interactionModifiers(event)); + const markHoverPresentationInteractions = hoverPresentationForTarget('mark'); + const normalized = acquire( + item, point, rootPoint, 'preview', interactionModifiers(event), + assistDistanceFor(markHoverPresentationInteractions), + ); const legend = normalized.legend; if (legend) { const legendHoverInteractions = hoverPresentationForTarget('legend-item'); @@ -1080,7 +1155,6 @@ export function mountVegaInteractions( } hoverActive = true; const interactionContext = context(); - const markHoverPresentationInteractions = hoverPresentationForTarget('mark'); const presentationElements = markHoverPresentationInteractions.flatMap((interaction) => { if (interaction.eventSource.gesture === 'drag-element') { return reorderEligible ? resolved?.elements ?? [] : []; @@ -1119,7 +1193,10 @@ export function mountVegaInteractions( } return; } - const normalized = acquire(item, point, rootPoint, 'commit', interactionModifiers(event)); + const normalized = acquire( + item, point, rootPoint, 'commit', interactionModifiers(event), + assistDistanceFor(markClickInteractions), + ); const { legend } = normalized; const target = legend ? resolvedLegendInteractionTarget( { channel: legend.channel, field: legend.field, domain: legend.domain }, @@ -1127,7 +1204,8 @@ export function mountVegaInteractions( ) : resolveTarget('click', normalized.role, normalized.event.hits); for (const interaction of markClickInteractions) { - if (!legend && interaction.claimsLegendActivation) continue; + const affordanceTarget = legend ? 'legend-item' : 'mark'; + if (!resolveInteractionAffordance([interaction], affordanceTarget)) continue; void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target, point, modifiers: normalized.event.modifiers, @@ -1139,7 +1217,7 @@ export function mountVegaInteractions( event.preventDefault(); const point = localPoint(event as unknown as PointerEvent); // A zero radius resolves the mark under the pointer; assist widens it. - const item = nearestSceneItem(view, point, Math.max(assistDistance, 0)); + const item = nearestSceneItem(view, point, assistDistanceFor(contextInteractions)); const normalized = normalizeVegaElementEvent( view, item, point, 'commit', interactionModifiers(event), plan.legendFields, plan.rangeLegendChannels, { x: point.x + coordinateSpace().originX, y: point.y + coordinateSpace().originY }, @@ -1243,9 +1321,9 @@ export function mountVegaInteractions( container.addEventListener('contextmenu', inspectContext); } } - const pointerTarget = (event: MouseEvent) => { + const pointerTarget = (event: MouseEvent, eligible: readonly CanvasInteractionDef[]) => { const point = localPoint(event as unknown as PointerEvent); - const item = nearestSceneItem(view, point, Math.max(assistDistance, 0)); + const item = nearestSceneItem(view, point, assistDistanceFor(eligible)); const normalized = normalizeVegaElementEvent( view, item, point, 'commit', interactionModifiers(event), plan.legendFields, plan.rangeLegendChannels, { x: point.x + coordinateSpace().originX, y: point.y + coordinateSpace().originY }, @@ -1291,7 +1369,10 @@ export function mountVegaInteractions( return; } const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); - const normalized = acquire(item, point, rootPoint, 'commit', interactionModifiers(event)); + const normalized = acquire( + item, point, rootPoint, 'commit', interactionModifiers(event), + assistDistanceFor(clickInteractions), + ); const target = normalized.legend ? resolvedLegendInteractionTarget( { channel: normalized.legend.channel, field: normalized.legend.field, domain: normalized.legend.domain }, @@ -1324,7 +1405,7 @@ export function mountVegaInteractions( const holdMs = longPressInteractions[0].eventSource.holdMs ?? 500; longPressTimer = window.setTimeout(() => { longPressTimer = undefined; - const acquired = pointerTarget(event); + const acquired = pointerTarget(event, longPressInteractions); if (!acquired.target) return; consumeDismissClick = true; suppressClick = true; @@ -1341,7 +1422,7 @@ export function mountVegaInteractions( if (doubleInteractions.length === 0) return; event.preventDefault(); cancelPendingDismiss(); - const acquired = pointerTarget(event); + const acquired = pointerTarget(event, doubleInteractions); for (const interaction of doubleInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', @@ -1411,7 +1492,10 @@ export function mountVegaInteractions( return; } const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); - const normalized = acquire(item, point, rootPoint, 'preview', interactionModifiers(event), 0); + const normalized = acquire( + item, point, rootPoint, 'preview', interactionModifiers(event), + assistDistanceFor(cursorInteractions), + ); if (normalized.legend) { setAffordanceCursor('legend-item', false); return; diff --git a/packages/flint-js/src/vegalite/interactions/stores.ts b/packages/flint-js/src/vegalite/interactions/stores.ts index 3af053c2..ec9ce1a5 100644 --- a/packages/flint-js/src/vegalite/interactions/stores.ts +++ b/packages/flint-js/src/vegalite/interactions/stores.ts @@ -3,6 +3,7 @@ export const HOVER_STORE = '__flint_hover_store'; export const HIDDEN_STORE = '__flint_hidden_store'; export const LEGEND_HIDDEN_STORE = '__flint_legend_hidden_store'; export const LEGEND_HOVER_STORE = '__flint_legend_hover_store'; +export const AXIS_HOVER_STORE = '__flint_axis_hover_store'; export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; export const STYLE_SIGNAL = '__flint_style_by_key'; @@ -12,5 +13,6 @@ export const INTERACTION_STORES: readonly string[] = [ HIDDEN_STORE, LEGEND_HIDDEN_STORE, LEGEND_HOVER_STORE, + AXIS_HOVER_STORE, LEGEND_SELECTION_STORE, ]; \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index 3343a00a..a57f7247 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -83,6 +83,10 @@ export function createVegaInteractiveRenderer( vegaSpec, interactionPlan.axisFields, interactionPlan.reorderAxes, + canvasInteractions.some((interaction) => interaction.affordances?.some((affordance) => + affordance.target === 'axis-label' && affordance.hover)) + ? interactionPlan.selectionBoundary?.color ?? '#20262c' + : undefined, ); if (interactionPlan.semanticStores) { injectVegaInteractionStore(vegaSpec, interactionPlan); @@ -125,7 +129,7 @@ export function createVegaInteractiveRenderer( interactions, interactionPlan.resolve, interactionPlan.presentUpdate ?? ((update) => update), - options.assistDistance ?? 0, + options.assistDistance, options.hoverTolerance ?? 0, options.keyboardTargeting ?? false, options.targetFeedback, diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 6dd0dadb..6674251a 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -326,9 +326,21 @@ export const boxplotDef: ChartTemplateDef = { legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['boxplot'], renderHoverStyles: { - rect: { opacity: 'contrast' }, - rule: { opacity: 'contrast' }, - symbol: { opacity: 'contrast' }, + rect: { + opacity: 'contrast', + stroke: MUTED_HOVER_STROKE, + strokeWidth: 2, + }, + rule: { + opacity: 'contrast', + stroke: MUTED_HOVER_STROKE, + strokeWidth: 2, + }, + symbol: { + opacity: 'contrast', + stroke: MUTED_HOVER_STROKE, + strokeWidth: 2, + }, }, resolve: (event, context) => { const legendField = event.legend?.field ?? seriesField; diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index af1ec745..451612d4 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickAxisIsolate, clickGroupFocus, clickLegendIsolate, clickMark, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import type { ClickHighlightOptions } from '../src/interactive/interactions'; import { affordanceCursor, resolveInteractionAffordance } from '../src/interactive/affordances'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; @@ -27,6 +28,9 @@ import { yBrushTrigger, } from '../src/interactive/triggers'; import { AngularRegionSession } from '../src/interactive/gestures/angular-region'; + +const clickMark = (options: Omit = {}) => + clickHighlight({ ...options, id: options.id ?? 'click-mark', targets: ['mark'] }); import { angularEditAction, isInteractiveControlTarget, pointInAngularSector } from '../src/vegalite/interactions/gestures/region'; import { cartesianDragDistance, @@ -57,10 +61,12 @@ import { } from '../src/vegalite/interactions/hit-adapter'; import { effectiveAnnotationEntries, + evictRetainedStateSiblings, interactionsForHoverPresentation, domainForPlotGeometry, keyboardTargetItems, nearestReorderHit, + resolveAssistDistance, resolveSupportedOperation, } from '../src/vegalite/interactions/runtime'; import { @@ -112,6 +118,7 @@ import type { InteractionDef, InteractionModifiers, InteractionPhase, + ChartUpdate, ChartUpdateOp, SemanticInteractionEvent, SemanticElement, @@ -861,9 +868,9 @@ describe('interaction definitions', () => { expect(update).toBeNull(); }); it('declares normalized event sources for built-in presets', () => { - expect(clickMark().eventSource).toBe(clickTrigger); - expect(clickGroupFocus().eventSource).toBe(clickTrigger); - expect(clickAnnotate().eventSource).toBe(clickTrigger); + expect(clickMark().eventSource).toEqual({ ...clickTrigger, defaultAssistDistance: 8 }); + expect(clickGroupFocus().eventSource).toEqual({ ...clickTrigger, defaultAssistDistance: 8 }); + expect(clickAnnotate().eventSource).toEqual({ ...clickTrigger, defaultAssistDistance: 8 }); expect(select().eventSource).toEqual(rectangleTrigger('intersect')); expect(brushX().eventSource).toEqual(xBrushTrigger('intersect', 'ephemeral')); expect(brushY().eventSource).toEqual(yBrushTrigger('intersect', 'ephemeral')); @@ -889,14 +896,14 @@ describe('interaction definitions', () => { .toMatchObject({ hover: 'cohort' }); }); - it('declares target-specific affordances for label and legend composition', () => { + it('declares affordances only for configured click highlight targets', () => { expect(resolveInteractionAffordance([axisHighlight()], 'axis-label')) .toMatchObject({ cursor: 'activate', hover: 'cohort' }); - expect(resolveInteractionAffordance([clickAxisIsolate()], 'axis-label')) + expect(resolveInteractionAffordance([clickHighlight({ targets: ['discreteAxis'] })], 'axis-label')) .toMatchObject({ cursor: 'activate', hover: 'cohort' }); - expect(resolveInteractionAffordance([clickLegendIsolate()], 'legend-item')) + expect(resolveInteractionAffordance([clickHighlight({ targets: ['legend'] })], 'legend-item')) .toMatchObject({ cursor: 'activate', hover: 'cohort' }); - expect(resolveInteractionAffordance([clickLegendIsolate()], 'axis-label')) + expect(resolveInteractionAffordance([clickHighlight({ targets: ['legend'] })], 'axis-label')) .toBeUndefined(); expect(resolveInteractionAffordance([clickMark()], 'legend-item')) .toBeUndefined(); @@ -904,21 +911,23 @@ describe('interaction definitions', () => { .toBeUndefined(); }); - it('composes click highlight from independent mark, legend, and axis presets', () => { - const interactions = [ - clickMark(), - clickLegendIsolate(), - clickAxisIsolate(), - ]; + it('owns all configured focus targets in one click highlight preset', () => { + const interaction = clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] }); + const markAndAxis = clickHighlight({ targets: ['mark', 'discreteAxis'] }); - expect(normalizeInteractions(interactions).map((interaction) => interaction.id)).toEqual([ - 'click-mark', - 'click-legend-isolate', - 'click-axis-isolate', - ]); - expect(resolveInteractionAffordance(interactions, 'mark')).toBeDefined(); - expect(resolveInteractionAffordance(interactions, 'legend-item')).toBeDefined(); - expect(resolveInteractionAffordance(interactions, 'axis-label')).toBeDefined(); + expect(normalizeInteractions([interaction]).map((candidate) => candidate.id)) + .toEqual(['click-highlight']); + expect(resolveInteractionAffordance([interaction], 'mark')).toBeDefined(); + expect(resolveInteractionAffordance([interaction], 'legend-item')).toBeDefined(); + expect(resolveInteractionAffordance([interaction], 'axis-label')).toBeDefined(); + expect(interaction).toMatchObject({ + retainedStateGroup: 'focus', + claimsLegendActivation: true, + claimsAxisActivation: true, + }); + expect(resolveInteractionAffordance([markAndAxis], 'mark')).toBeDefined(); + expect(resolveInteractionAffordance([markAndAxis], 'axis-label')).toBeDefined(); + expect(resolveInteractionAffordance([markAndAxis], 'legend-item')).toBeUndefined(); }); it('provides reusable trigger descriptors', () => { @@ -983,16 +992,28 @@ describe('interaction definitions', () => { }); it('creates preset definitions with stable defaults', () => { - expect(clickMark()).toMatchObject({ id: 'click-mark', eventSource: clickTrigger }); - expect(clickGroupFocus()).toMatchObject({ id: 'click-group-focus', eventSource: clickTrigger }); + expect(clickHighlight()).toMatchObject({ + id: 'click-highlight', + eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + claimsLegendActivation: true, + claimsAxisActivation: true, + }); + expect(clickMark()).toMatchObject({ + id: 'click-mark', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + }); + expect(clickGroupFocus()).toMatchObject({ + id: 'click-group-focus', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + }); expect(hoverGroupFocus({ groupBy: 'Series' })).toMatchObject({ - id: 'hover-group-focus', eventSource: hoverTrigger, + id: 'hover-group-focus', eventSource: { ...hoverTrigger, defaultAssistDistance: 6 }, }); expect(axisHighlight()).toMatchObject({ id: 'axis-highlight', eventSource: clickTrigger, claimsAxisActivation: true, }); expect(axisHighlight({ event: 'hover' }).eventSource).toBe(hoverTrigger); - expect(clickAnnotate()).toMatchObject({ id: 'click-annotate', eventSource: clickTrigger }); + expect(clickAnnotate()).toMatchObject({ + id: 'click-annotate', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + }); expect(select()).toMatchObject({ id: 'select', eventSource: rectangleTrigger('intersect'), @@ -1001,6 +1022,18 @@ describe('interaction definitions', () => { expect(brushY()).toMatchObject({ id: 'brush-y', axis: 'y', eventSource: yBrushTrigger() }); expect(brushX({ mode: 'stateful' }).eventSource).toEqual(xBrushTrigger('intersect', 'stateful')); expect(brushAngle()).toMatchObject({ id: 'brush-angle', eventSource: angularBrushTrigger() }); + expect(lassoSelect().eventSource.defaultAssistDistance).toBeUndefined(); + expect(dragReorder().eventSource.defaultAssistDistance).toBeUndefined(); + expect(longPress().eventSource.defaultAssistDistance).toBe(12); + expect(doubleActivate().eventSource.defaultAssistDistance).toBe(8); + expect(resolveAssistDistance([clickMark()])).toBe(8); + expect(resolveAssistDistance([clickMark()], 0)).toBe(0); + expect(resolveAssistDistance([clickMark()], 20)).toBe(20); + expect(resolveAssistDistance([select()], 20)).toBe(0); + expect(resolveAssistDistance([dragReorder()], 20)).toBe(0); + expect(resolveAssistDistance( + interactionsForHoverPresentation([clickMark()], [], []), + )).toBe(8); }); it('applies brush updates only for its configured axis', () => { @@ -1131,7 +1164,7 @@ describe('interaction definitions', () => { }); }); - it('keeps clickMark local while allowing explicit and automatic mark partitions', () => { + it('keeps mark highlight local while group focus supports explicit and automatic partitions', () => { const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ value: { key: 'west-consumer' }, records: [{ auto: 'West', Region: 'West', Segment: 'Consumer' }] }], @@ -1153,13 +1186,13 @@ describe('interaction definitions', () => { expect(semanticUpdate(clickMark(), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], }); - expect(semanticUpdate(clickMark({ groupBy: ['Segment'] }), target, context)?.ops[0]).toMatchObject({ + expect(semanticUpdate(clickGroupFocus({ groupBy: ['Segment'] }), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [ { value: { key: 'west-consumer' } }, { value: { key: 'east-consumer' } }, ] }], }); - expect(semanticUpdate(clickMark({ groupBy: ['Region', 'Segment'] }), target, context)?.ops[0]).toMatchObject({ + expect(semanticUpdate(clickGroupFocus({ groupBy: ['Region', 'Segment'] }), target, context)?.ops[0]).toMatchObject({ targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], }); expect(semanticUpdate(clickGroupFocus(), target, context)?.ops[0]).toMatchObject({ @@ -2661,9 +2694,9 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(activate(interaction, markTarget)).toBeNull(); }); - it('isolates discrete axis and legend labels through one preset', () => { - const axisInteraction = clickAxisIsolate({ dimOpacity: 0.2 }); - const legendInteraction = clickLegendIsolate({ dimOpacity: 0.2 }); + it('focuses configured discrete-axis and legend targets through one preset', () => { + const axisInteraction = clickHighlight({ targets: ['discreteAxis'], dimOpacity: 0.2 }); + const legendInteraction = clickHighlight({ targets: ['legend'], dimOpacity: 0.2 }); const axisTarget = { visual: { kind: 'axis' as const, role: 'axis-label' }, elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], @@ -2687,8 +2720,33 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(activate(legendInteraction, markTarget)).toBeNull(); }); - it('isolates a continuous legend interval but not an unrequested axis', () => { - const interaction = clickLegendIsolate(); + it('handles mark, legend, and axis activations through one click highlight instance', () => { + const interaction = clickHighlight({ + targets: ['mark', 'legend', 'discreteAxis'], + dimOpacity: 0.2, + }); + const markTarget = { + visual: { kind: 'mark' as const, role: 'mark' }, + elements: [{ value: { Category: 'A' } }], + }; + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + + expect(activate(interaction, markTarget)).toMatchObject({ id: 'click-highlight' }); + expect(activate(interaction, seriesTarget('A'))).toMatchObject({ id: 'click-highlight' }); + expect(activate(interaction, axisTarget)).toMatchObject({ id: 'click-highlight' }); + expect(interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: markTarget, + }, interaction.eventSource), context)).toMatchObject({ + id: 'click-highlight', + ops: [{ op: 'set-style', targets: [{ elements: markTarget.elements }] }], + }); + }); + + it('focuses a continuous legend interval without claiming an axis', () => { + const interaction = clickHighlight({ targets: ['legend'] }); const intervalTarget = { visual: { kind: 'legend' as const, role: 'legend-item' }, elements: [{ @@ -2712,7 +2770,7 @@ describe('legend, inspect, zoom, and touch presets', () => { it('assigns observable legend events only to legend interactions', () => { expect(activate(clickMark(), seriesTarget('A'))).toBeNull(); expect(activate(clickGroupFocus(), seriesTarget('A'))).toBeNull(); - expect(activate(clickLegendIsolate(), seriesTarget('A'))).not.toBeNull(); + expect(activate(clickHighlight({ targets: ['legend'] }), seriesTarget('A'))).not.toBeNull(); expect(activate(clickAnnotate(), seriesTarget('A'))).toBeNull(); const hover = (interaction: CanvasInteractionDef) => interaction.handle!(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'preview', target: seriesTarget('A'), @@ -2746,7 +2804,7 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(event).toMatchObject({ action: 'click-legend', target }); expect(activate(clickMark(), target)).toBeNull(); expect(activate(clickGroupFocus(), target)).toBeNull(); - expect(activate(clickLegendIsolate(), target)?.ops[0]).toMatchObject({ + expect(activate(clickHighlight({ targets: ['legend'] }), target)?.ops[0]).toMatchObject({ op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], value: { state: 'emphasized' }, diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index 35d5459a..908ec446 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { changeset, parse, View } from 'vega'; import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src/vegalite/assemble'; -import { axisHighlight, brushAngle, brushX, brushZoom, clickAnnotate, clickMark, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; -import type { RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushZoom, clickAnnotate, clickHighlight, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; +import type { ClickHighlightOptions, RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; import { associateSemanticElementRenderKeys, MUTED_HOVER_FILL, @@ -63,6 +63,7 @@ import { continuousLegendSegmentCount, } from '../src/vegalite/interactions/hit-adapter'; import { + AXIS_HOVER_STORE, HIDDEN_STORE, HOVER_STORE, INTERACTION_STORE, @@ -106,6 +107,9 @@ import { resolvedLegendInteractionTarget, } from '../src/vegalite/interactions/runtime'; +const clickMark = (options: Omit = {}) => + clickHighlight({ ...options, targets: ['mark'] }); + function annotationUpdate( element: SemanticElement, visual: SemanticTarget['visual'] = { kind: 'mark', role: 'test' }, @@ -232,7 +236,7 @@ describe('Vega-Lite semantic interactions', () => { }); }); - it('uses contrast rather than outlines for Boxplot hover', () => { + it('combines color contrast and outlines for Boxplot hover', () => { const semantics = boxplotDef.semanticInteractions!({ resolvedEncodings: { x: { field: 'Species', type: 'nominal' }, @@ -241,9 +245,9 @@ describe('Vega-Lite semantic interactions', () => { }); expect(semantics.renderHoverStyles).toEqual({ - rect: { opacity: 'contrast' }, - rule: { opacity: 'contrast' }, - symbol: { opacity: 'contrast' }, + rect: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + rule: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + symbol: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, }); }); @@ -1592,9 +1596,9 @@ describe('Vega-Lite semantic interactions', () => { _interactionSemantics: { fields: ['Group'], selectableMarks: ['boxplot'], renderHoverStyles: { - rect: { stroke: '#59636d', strokeWidth: 2 }, - rule: { stroke: '#59636d', strokeWidth: 2 }, - symbol: { stroke: '#59636d', strokeWidth: 2 }, + rect: { opacity: 'contrast', stroke: '#59636d', strokeWidth: 2 }, + rule: { opacity: 'contrast', stroke: '#59636d', strokeWidth: 2 }, + symbol: { opacity: 'contrast', stroke: '#59636d', strokeWidth: 2 }, }, }, }, @@ -1615,6 +1619,50 @@ describe('Vega-Lite semantic interactions', () => { } }); + it('compiles Boxplot color contrast and outlines for every composite submark', () => { + const spec: Record = { + data: { values: [ + { Group: 'A', Value: 1 }, { Group: 'A', Value: 2 }, + { Group: 'A', Value: 3 }, { Group: 'A', Value: 20 }, + ] }, + mark: 'boxplot', + encoding: { + x: { field: 'Group', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Group'], selectableMarks: ['boxplot'], + renderHoverStyles: { + rect: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + rule: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + symbol: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + }, + }; + const plan = addVegaLiteInteractions(spec, [clickMark()]); + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const marks: Record[] = []; + const collect = (items: Record[] = []) => { + for (const item of items) { + marks.push(item); + collect(item.marks); + } + }; + collect(compiled.marks); + + for (const markType of ['rect', 'rule', 'symbol']) { + const styled = marks.filter((mark) => mark.type === markType + && JSON.stringify(mark.encode).includes(INTERACTION_KEY)); + expect(styled.length).toBeGreaterThan(0); + for (const mark of styled) { + expect(JSON.stringify(mark.encode.update.opacity)).toContain(HOVER_STORE); + expect(JSON.stringify(mark.encode.update.stroke)).toContain(MUTED_HOVER_STROKE); + expect(JSON.stringify(mark.encode.update.strokeWidth)).toContain(HOVER_STORE); + } + } + }); + it('leaves native line paint unchanged for segment-local hover', () => { const spec = assembleVegaLite({ chart_spec: { @@ -3703,6 +3751,45 @@ describe('set-style visibility', () => { view.finalize(); }); + it('highlights hovered discrete x and y axis labels', async () => { + const compiled = compile({ + data: { values: [{ Column: 'A', Row: 'R', Value: 1 }] }, + mark: 'rect', + encoding: { + x: { field: 'Column', type: 'nominal' }, + y: { field: 'Row', type: 'nominal' }, + color: { field: 'Value', type: 'quantitative' }, + }, + }).spec as Record; + const targets = collectVegaAxisTargets(compiled, { + x: { field: 'Column', type: 'nominal' }, + y: { field: 'Row', type: 'nominal' }, + }, [], '#123456'); + injectVegaInteractionStore(compiled); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const labels = () => allSceneItems(view).filter((item) => item.mark?.role === 'axis-label'); + const xLabel = () => labels().find((item) => item.datum?.value === 'A'); + const yLabel = () => labels().find((item) => item.datum?.value === 'R'); + const xIdentity = axisTargetIdentity(xLabel(), targets)!; + const yIdentity = axisTargetIdentity(yLabel(), targets)!; + + view.change(AXIS_HOVER_STORE, changeset().remove(() => true).insert([{ + scale: xIdentity.scale, value: xIdentity.value, + }])); + await view.runAsync(); + expect(xLabel()).toMatchObject({ fill: '#123456', fontWeight: 600 }); + expect(yLabel()?.fontWeight).not.toBe(600); + + view.change(AXIS_HOVER_STORE, changeset().remove(() => true).insert([{ + scale: yIdentity.scale, value: yIdentity.value, + }])); + await view.runAsync(); + expect(yLabel()).toMatchObject({ fill: '#123456', fontWeight: 600 }); + expect(xLabel()?.fontWeight).not.toBe(600); + view.finalize(); + }); + it('turns an axis target into a style update without accepting mark targets', () => { const interaction = axisHighlight({ axis: 'x', dimOpacity: 0.2 }); const context = { chartType: 'Bar Chart', selected: [] }; diff --git a/site/src/playground/ChartToExternalLab.tsx b/site/src/playground/ChartToExternalLab.tsx index 1c58b1d2..c8bf1c43 100644 --- a/site/src/playground/ChartToExternalLab.tsx +++ b/site/src/playground/ChartToExternalLab.tsx @@ -4,7 +4,7 @@ import type { InteractionDef, SemanticTarget, } from 'flint-chart/interactive'; -import { clickMark, select as rectangleSelect } from 'flint-chart/interactive'; +import { clickHighlight, select as rectangleSelect } from 'flint-chart/interactive'; import { InteractionDemoChart } from './InteractionDemoChart'; import { countriesFixture, @@ -198,7 +198,7 @@ function OutboundDemoRow({ demo }: { demo: OutboundDemo }) { const interaction: InteractionDef = useMemo( () => demo.gesture === 'select' ? rectangleSelect({ id: `${demo.id}-selection` }) - : clickMark({ id: `${demo.id}-element` }), + : clickHighlight({ id: `${demo.id}-element`, targets: ['mark'] }), [demo.gesture, demo.id], ); const interactions = useMemo(() => [interaction], [interaction]); diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index f6dabc2a..a60ff68b 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -1,6 +1,6 @@ import { Fragment, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Crosshair, EyeOff, GripVertical, Keyboard, Lasso, Layers3, Link2, Menu, MessageSquareText, MousePointer2, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; +import { Crosshair, EyeOff, GripVertical, Keyboard, Lasso, Layers3, Link2, Menu, MessageSquareText, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; import { genBarTests, @@ -15,10 +15,8 @@ import { brushY, brushZoom, clickAnnotate, - clickAxisIsolate, clickGroupFocus, - clickLegendIsolate, - clickMark, + clickHighlight, contextActivate, doubleActivate, dragReorder, @@ -41,12 +39,12 @@ import { navigationDemoCases } from './navigation-demo-data'; import { gapminderRows } from './gapminder-dashboard-data'; import './click-focus-lab.css'; -export type InteractionMode = 'click-mark' | 'click-group-focus' | 'annotate' | 'select' +export type InteractionMode = 'click-highlight' | 'click-group-focus' | 'annotate' | 'select' | 'facet-link' | 'hover-group-focus' | 'brush-x' | 'brush-y' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate' | 'drag-reorder' - | 'lasso' | 'click-legend-isolate' | 'click-axis-isolate' | 'inspect' | 'inspect-quadrant' | 'inspect-x' + | 'lasso' | 'inspect' | 'inspect-quadrant' | 'inspect-x' | 'long-press' | 'double-activate' - | 'click-highlight' | 'assisted-focus' | 'keyboard-focus' | 'select-context' | 'focus-legend-toggle' | 'focus-brush-zoom'; + | 'keyboard-focus' | 'select-context' | 'focus-legend-toggle' | 'focus-brush-zoom'; type ProbeStatus = 'loading' | 'ready' | 'unsupported' | 'error'; export interface NavigationGuard { @@ -56,10 +54,8 @@ export interface NavigationGuard { } const unitInteractionModes = [ - { value: 'click-mark', label: 'Click mark', icon: MousePointer2 }, + { value: 'click-highlight', label: 'Click highlight', icon: MousePointerClick }, { value: 'click-group-focus', label: 'Click group focus', icon: Layers3 }, - { value: 'click-legend-isolate', label: 'Click legend isolate', icon: Layers3 }, - { value: 'click-axis-isolate', label: 'Click axis isolate', icon: Ruler }, { value: 'hover-group-focus', label: 'Hover group focus', icon: Target }, { value: 'annotate', label: 'Annotate', icon: MessageSquareText }, { value: 'select', label: 'Select', icon: Scan }, @@ -79,15 +75,13 @@ const unitInteractionModes = [ ] as const; const compositionInteractionModes = [ - { value: 'click-highlight', label: 'Click highlight', icon: MousePointerClick }, - { value: 'assisted-focus', label: 'Focus + assist', icon: Crosshair }, { value: 'keyboard-focus', label: 'Focus + keyboard', icon: Keyboard }, { value: 'select-context', label: 'Select + context', icon: Menu }, { value: 'focus-legend-toggle', label: 'Focus + legend toggle', icon: EyeOff }, { value: 'focus-brush-zoom', label: 'Focus + brush zoom', icon: ZoomIn }, ] as const; -type MountedInteraction = ReturnType; +type MountedInteraction = ReturnType; /** * Each named unit mode mounts its corresponding preset; explicitly named compositions are separate. @@ -99,10 +93,7 @@ function modeInteractions( linkBy: string | readonly string[] | undefined, ): MountedInteraction[] { switch (mode) { - case 'click-mark': return [clickMark()]; - case 'click-legend-isolate': return [clickLegendIsolate()]; - case 'click-axis-isolate': return [clickAxisIsolate()]; - case 'click-highlight': return [clickMark(), clickLegendIsolate(), clickAxisIsolate()]; + case 'click-highlight': return [clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] })]; case 'click-group-focus': return [clickGroupFocus({ groupBy: typeof linkBy === 'string' ? linkBy : undefined })]; case 'hover-group-focus': return linkBy ? [hoverGroupFocus({ groupBy: linkBy })] : []; case 'annotate': return [clickAnnotate()]; @@ -121,12 +112,12 @@ function modeInteractions( dimOpacity: 0.14, })]; case 'inspect-x': return [inspect({ mode: 'x' })]; - case 'assisted-focus': case 'keyboard-focus': return [clickMark()]; + case 'keyboard-focus': return [clickHighlight({ targets: ['mark'] })]; case 'select-context': return [rectangleSelect(), contextActivate()]; - case 'focus-legend-toggle': return [clickMark(), legendToggle()]; + case 'focus-legend-toggle': return [clickHighlight({ targets: ['mark'] }), legendToggle()]; case 'long-press': return [longPress()]; case 'double-activate': return [doubleActivate()]; - case 'focus-brush-zoom': return [clickMark(), brushZoom()]; + case 'focus-brush-zoom': return [clickHighlight({ targets: ['mark'] }), brushZoom()]; default: return [navigate({ axes: navigationAxes ?? 'available', domainGuard: navigationGuard })]; } } @@ -645,7 +636,6 @@ function InteractiveChart({ interactions, expressionInterpreter, ariaLabel: input.chart_spec.title, - assistedTargeting: mode === 'assisted-focus', keyboardTargeting: mode === 'keyboard-focus', dismiss: mode === 'long-press' || mode === 'double-activate' ? { click: 'any', escape: true } @@ -846,7 +836,7 @@ export function CaseCard({ } export function ClickFocusLab() { - const [mode, setMode] = useState('click-mark'); + const [mode, setMode] = useState('click-highlight'); const [themeId, setThemeId] = useState(undefined); const [navigationGuard, setNavigationGuard] = useState({ minVisibleFraction: 0.02, @@ -893,7 +883,7 @@ export function ClickFocusLab() {

    Interaction gallery

    Choose an interaction mode, then try it across the compatible chart cases:

      -
    • Click mark: Click a mark to focus it and dim the other marks.
    • +
    • Click highlight: Click a mark, legend entry, or categorical axis label to focus its cohort.
    • Click group focus: Click a mark to focus related marks in the same category or series.
    • Hover group focus: Hover a mark to preview matching semantic keys without changing retained state.
    • Annotate: Click a mark to search nearby free space and connect its represented value.
    • @@ -905,9 +895,6 @@ export function ClickFocusLab() {
    • Pan & zoom: Drag continuous axes to pan; use the wheel, trackpad, or a two-finger pinch to zoom.
    • Context menu: Select marks or open a mark menu, then let the host application provide contextual actions.
    • Assisted and keyboard: Move to a target to see a shared indicator and compact semantic details.
    • -
    • Click legend isolate: Click a discrete entry or continuous legend interval to isolate its cohort.
    • -
    • Click axis isolate: Click a categorical X/Y label to isolate its cohort.
    • -
    • Click highlight: Compose mark, legend, and axis click presets.
    {visibleCases.length} test cases
    diff --git a/site/src/playground/InteractionDashboardLab.tsx b/site/src/playground/InteractionDashboardLab.tsx index 208198ad..55974024 100644 --- a/site/src/playground/InteractionDashboardLab.tsx +++ b/site/src/playground/InteractionDashboardLab.tsx @@ -10,7 +10,7 @@ import type { import { brushY, clickGroupFocus, - clickMark, + clickHighlight, externalInteraction, select, } from 'flint-chart/interactive'; @@ -156,7 +156,11 @@ function buildDashboardCharts( undefined, { width: 400, height: 230 }, ), - interaction: clickMark({ id: DASHBOARD_SELECTION_ID, dimOpacity: 0.22 }), + interaction: clickHighlight({ + id: DASHBOARD_SELECTION_ID, + dimOpacity: 0.22, + targets: ['mark'], + }), }, { id: 'trends', From 9c3697e8a801040de4898c81a87a96318d92c50e Mon Sep 17 00:00:00 2001 From: lx9days <571768326@qq.com> Date: Thu, 3 Sep 2026 05:06:07 +0800 Subject: [PATCH 26/33] update: DimpVis in candidate page --- site/src/playground/FlintDimpVisStage.tsx | 518 ++++++++++++++++++ site/src/playground/InteractionCandidates.tsx | 79 ++- site/src/playground/PureD3DimpVisStage.tsx | 518 ++++++++++++++++++ .../src/playground/interaction-candidates.css | 115 ++++ 4 files changed, 1226 insertions(+), 4 deletions(-) create mode 100644 site/src/playground/FlintDimpVisStage.tsx create mode 100644 site/src/playground/PureD3DimpVisStage.tsx diff --git a/site/src/playground/FlintDimpVisStage.tsx b/site/src/playground/FlintDimpVisStage.tsx new file mode 100644 index 00000000..88691481 --- /dev/null +++ b/site/src/playground/FlintDimpVisStage.tsx @@ -0,0 +1,518 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { StyleSpec } from 'flint-chart/interactive'; +import { + buildInteractiveChart, + clickTrigger, + type CanvasInteractionDef, + type FlintInteractionEventDetail, + type InteractiveChartSurface, +} from 'flint-chart/interactive'; + +interface Frame { + year: number; + fertility: number; + life: number; + population: number; +} + +interface CountrySeries { + name: string; + region: string; + frames: Frame[]; +} + +const YEARS = [1955, 1960, 1965, 1970, 1975, 1980, 1985, 1990, 1995, 2000, 2005] as const; + +const SERIES: CountrySeries[] = [ + { + name: 'Afghanistan', + region: 'South Asia', + frames: [ + { year: 1955, fertility: 7.7, life: 30.332, population: 8891209 }, + { year: 1960, fertility: 7.7, life: 31.997, population: 9829450 }, + { year: 1965, fertility: 7.7, life: 34.02, population: 10997885 }, + { year: 1970, fertility: 7.7, life: 36.088, population: 12430623 }, + { year: 1975, fertility: 7.7, life: 38.438, population: 14132019 }, + { year: 1980, fertility: 7.8, life: 39.854, population: 15112149 }, + { year: 1985, fertility: 7.9, life: 40.822, population: 13796928 }, + { year: 1990, fertility: 8, life: 41.674, population: 14669339 }, + { year: 1995, fertility: 8, life: 41.763, population: 20881480 }, + { year: 2000, fertility: 7.4792, life: 42.129, population: 23898198 }, + { year: 2005, fertility: 7.0685, life: 43.828, population: 29928987 }, + ], + }, + { + name: 'Brazil', + region: 'America', + frames: [ + { year: 1955, fertility: 6.1501, life: 53.285, population: 61773546 }, + { year: 1960, fertility: 6.1501, life: 55.665, population: 71694810 }, + { year: 1965, fertility: 5.38, life: 57.632, population: 83092908 }, + { year: 1970, fertility: 4.7175, life: 59.504, population: 95684297 }, + { year: 1975, fertility: 4.305, life: 61.489, population: 108823732 }, + { year: 1980, fertility: 3.8, life: 63.336, population: 122958132 }, + { year: 1985, fertility: 3.1, life: 65.205, population: 137302933 }, + { year: 1990, fertility: 2.6, life: 67.057, population: 151083809 }, + { year: 1995, fertility: 2.45, life: 69.388, population: 163542501 }, + { year: 2000, fertility: 2.345, life: 71.006, population: 175552771 }, + { year: 2005, fertility: 2.245, life: 72.39, population: 186112794 }, + ], + }, + { + name: 'China', + region: 'East Asia & Pacific', + frames: [ + { year: 1955, fertility: 5.59, life: 50.54896, population: 608655000 }, + { year: 1960, fertility: 5.72, life: 44.50136, population: 667070000 }, + { year: 1965, fertility: 6.06, life: 58.38112, population: 715185000 }, + { year: 1970, fertility: 4.86, life: 63.11888, population: 818315000 }, + { year: 1975, fertility: 3.32, life: 63.96736, population: 916395000 }, + { year: 1980, fertility: 2.55, life: 65.525, population: 981235000 }, + { year: 1985, fertility: 2.46, life: 67.274, population: 1051040000 }, + { year: 1990, fertility: 1.92, life: 68.69, population: 1135185000 }, + { year: 1995, fertility: 1.781, life: 70.426, population: 1204855000 }, + { year: 2000, fertility: 1.7, life: 72.028, population: 1262645000 }, + { year: 2005, fertility: 1.725, life: 72.961, population: 1303182268 }, + ], + }, + { + name: 'France', + region: 'Europe & Central Asia', + frames: [ + { year: 1955, fertility: 2.712, life: 68.93, population: 43427669 }, + { year: 1960, fertility: 2.85, life: 70.51, population: 45670000 }, + { year: 1965, fertility: 2.607, life: 71.55, population: 48763000 }, + { year: 1970, fertility: 2.31, life: 72.38, population: 50787000 }, + { year: 1975, fertility: 1.862, life: 73.83, population: 52758427 }, + { year: 1980, fertility: 1.866, life: 74.89, population: 53869743 }, + { year: 1985, fertility: 1.805, life: 76.34, population: 55171224 }, + { year: 1990, fertility: 1.713, life: 77.46, population: 56735161 }, + { year: 1995, fertility: 1.7624, life: 78.64, population: 58149727 }, + { year: 2000, fertility: 1.8833, life: 79.59, population: 59381628 }, + { year: 2005, fertility: 1.8916, life: 80.657, population: 60656178 }, + ], + }, + { + name: 'India', + region: 'South Asia', + frames: [ + { year: 1955, fertility: 5.8961, life: 40.249, population: 393000000 }, + { year: 1960, fertility: 5.8216, life: 43.605, population: 434000000 }, + { year: 1965, fertility: 5.6058, life: 47.193, population: 485000000 }, + { year: 1970, fertility: 5.264, life: 50.651, population: 541000000 }, + { year: 1975, fertility: 4.8888, life: 54.208, population: 607000000 }, + { year: 1980, fertility: 4.4975, life: 56.596, population: 679000000 }, + { year: 1985, fertility: 4.15, life: 58.553, population: 755000000 }, + { year: 1990, fertility: 3.8648, life: 60.223, population: 839000000 }, + { year: 1995, fertility: 3.4551, life: 61.765, population: 927000000 }, + { year: 2000, fertility: 3.1132, life: 62.879, population: 1007702000 }, + { year: 2005, fertility: 2.8073, life: 64.698, population: 1080264388 }, + ], + }, + { + name: 'Japan', + region: 'East Asia & Pacific', + frames: [ + { year: 1955, fertility: 2.08, life: 65.5, population: 89815060 }, + { year: 1960, fertility: 2.02, life: 68.73, population: 94091638 }, + { year: 1965, fertility: 2, life: 71.43, population: 98882534 }, + { year: 1970, fertility: 2.07, life: 73.42, population: 104344973 }, + { year: 1975, fertility: 1.81, life: 75.38, population: 111573116 }, + { year: 1980, fertility: 1.76, life: 77.11, population: 116807309 }, + { year: 1985, fertility: 1.66, life: 78.67, population: 120754335 }, + { year: 1990, fertility: 1.49, life: 79.36, population: 123537399 }, + { year: 1995, fertility: 1.39, life: 80.69, population: 125341354 }, + { year: 2000, fertility: 1.291, life: 82, population: 126699784 }, + { year: 2005, fertility: 1.27, life: 82.603, population: 127417244 }, + ], + }, + { + name: 'Nigeria', + region: 'Sub-Saharan Africa', + frames: [ + { year: 1955, fertility: 6.9, life: 37.802, population: 35458978 }, + { year: 1960, fertility: 6.9, life: 39.36, population: 39914593 }, + { year: 1965, fertility: 6.9, life: 41.04, population: 45020052 }, + { year: 1970, fertility: 6.9, life: 42.821, population: 51027516 }, + { year: 1975, fertility: 6.9, life: 44.514, population: 58522112 }, + { year: 1980, fertility: 6.9, life: 45.826, population: 68550274 }, + { year: 1985, fertility: 6.834, life: 46.886, population: 77573154 }, + { year: 1990, fertility: 6.635, life: 47.472, population: 88510354 }, + { year: 1995, fertility: 6.246, life: 47.464, population: 100960105 }, + { year: 2000, fertility: 5.845, life: 46.608, population: 114306700 }, + { year: 2005, fertility: 5.322, life: 46.859, population: 128765768 }, + ], + }, + { + name: 'United States', + region: 'America', + frames: [ + { year: 1955, fertility: 3.706, life: 69.49, population: 165931000 }, + { year: 1960, fertility: 3.314, life: 70.21, population: 180671000 }, + { year: 1965, fertility: 2.545, life: 70.76, population: 194303000 }, + { year: 1970, fertility: 2.016, life: 71.34, population: 205052000 }, + { year: 1975, fertility: 1.788, life: 73.38, population: 215973000 }, + { year: 1980, fertility: 1.825, life: 74.65, population: 227726463 }, + { year: 1985, fertility: 1.924, life: 75.02, population: 238466283 }, + { year: 1990, fertility: 2.025, life: 76.09, population: 250131894 }, + { year: 1995, fertility: 1.994, life: 76.81, population: 266557091 }, + { year: 2000, fertility: 2.038, life: 77.31, population: 282338631 }, + { year: 2005, fertility: 2.054, life: 78.242, population: 295734134 }, + ], + }, +]; + +const SEMANTIC_TYPES = { + Country: 'Country', + Region: 'Category', + Year: 'Year', + YearLabel: 'Category', + Fertility: 'Quantity', + Life: 'Quantity', + Population: 'Quantity', + Value: 'Quantity', +}; + +const ALL_ROWS = SERIES.flatMap((series) => series.frames.map((frame) => ({ + Country: series.name, + Region: series.region, + Year: frame.year, + YearLabel: String(frame.year), + Fertility: frame.fertility, + Life: frame.life, + Population: frame.population, +}))); + +const GLOBAL_DOMAIN = { + minFertility: Math.min(...ALL_ROWS.map((row) => row.Fertility)), + maxFertility: Math.max(...ALL_ROWS.map((row) => row.Fertility)), + minLife: Math.min(...ALL_ROWS.map((row) => row.Life)), + maxLife: Math.max(...ALL_ROWS.map((row) => row.Life)), +}; + +const MAIN_INTERACTION_ID = 'flint-dimpvis-country'; +const MAIN_MARK_INTERACTION_ID = 'flint-dimpvis-country-mark'; +const MAIN_LEGEND_INTERACTION_ID = 'flint-dimpvis-country-legend'; +const COUNTRY_STYLE_ID = 'flint-dimpvis-country-style'; +const YEAR_STYLE_ID = 'flint-dimpvis-year-style'; +const ANCHOR_STYLE_ID = 'flint-dimpvis-domain-anchors'; + +const MAIN_MARK_CLICK_INTERACTION: CanvasInteractionDef = { + id: MAIN_MARK_INTERACTION_ID, + eventSource: clickTrigger, + affordances: [ + { target: 'mark', cursor: 'activate', hover: 'target' }, + ], + handle() { + return null; + }, +}; + +const MAIN_LEGEND_CLICK_INTERACTION: CanvasInteractionDef = { + id: MAIN_LEGEND_INTERACTION_ID, + eventSource: clickTrigger, + claimsLegendActivation: true, + affordances: [ + { target: 'legend-item', cursor: 'activate', hover: 'cohort' }, + ], + handle() { + return null; + }, +}; + +function chartInput( + data: Record[], + chartType: string, + title: string, + encodings: Record, + chartProperties?: Record, + baseSize?: { width: number; height: number }, +) { + return { + data: { values: data }, + semantic_types: SEMANTIC_TYPES, + chart_spec: { + chartType, + title, + encodings, + baseSize: baseSize ?? { width: 396, height: 220 }, + ...(chartProperties ? { chartProperties } : {}), + }, + }; +} + +function focusedRows(selectedCountry: string, activeYear: number) { + return [ + ...ALL_ROWS.filter((row) => row.Country === selectedCountry || row.Year === activeYear), + { + Country: '__domain-min__', + Region: 'Anchor', + Year: YEARS[0], + YearLabel: String(YEARS[0]), + Fertility: GLOBAL_DOMAIN.minFertility, + Life: GLOBAL_DOMAIN.minLife, + Population: 0, + }, + { + Country: '__domain-max__', + Region: 'Anchor', + Year: YEARS[YEARS.length - 1], + YearLabel: String(YEARS[YEARS.length - 1]), + Fertility: GLOBAL_DOMAIN.maxFertility, + Life: GLOBAL_DOMAIN.maxLife, + Population: 0, + }, + ]; +} + +function interactionTarget(detail: FlintInteractionEventDetail): { + country: string | null; + year?: number; +} { + const element = detail.event.target?.elements[0]; + if (!element) return { country: null }; + const role = detail.event.target?.visual.role; + const pointValue = (element.records?.[0] ?? element.value) as Record | undefined; + const coerceYear = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + }; + if (role === 'symbol') { + const pointCountry = typeof pointValue?.Country === 'string' ? pointValue.Country : null; + const pointYear = coerceYear(pointValue?.Year); + return { + country: pointCountry, + year: pointYear, + }; + } + const semanticRows = [ + ...(element.records as Record[] | undefined ?? []), + ...(element.value ? [element.value as Record] : []), + ]; + const countryValues = [...new Set(semanticRows + .map((row) => row?.Country) + .filter((value): value is string => typeof value === 'string'))]; + const yearValues = [...new Set(semanticRows + .map((row) => coerceYear(row?.Year)) + .filter((value): value is number => value !== undefined))]; + return { + country: countryValues.length === 1 ? countryValues[0] : null, + year: yearValues.length === 1 ? yearValues[0] : undefined, + }; +} + +function legendCountry(detail: FlintInteractionEventDetail): string | null { + if (detail.event.target?.visual.role !== 'legend-item') return null; + const element = detail.event.target.elements[0]; + const value = element?.value as { + field?: unknown; + domain?: { kind?: unknown; value?: unknown }; + } | undefined; + if (value?.field === 'Country' && value.domain?.kind === 'value' && typeof value.domain.value === 'string') { + return value.domain.value; + } + return null; +} + +async function applySelectorStyle( + surface: InteractiveChartSurface | null, + id: string, + key: Record, + value: StyleSpec, +) { + if (!surface) return; + await surface.applyUpdate({ + id, + ops: [{ + op: 'set-style', + targets: [{ select: { key } }], + value, + }], + }); +} + +async function hideDomainAnchors(surface: InteractiveChartSurface | null) { + if (!surface) return; + await surface.applyUpdate({ + id: ANCHOR_STYLE_ID, + ops: [{ + op: 'set-style', + targets: [ + { select: { key: { Country: '__domain-min__' } } }, + { select: { key: { Country: '__domain-max__' } } }, + ], + value: { visible: false }, + }], + }); +} + +export function FlintDimpVisStage() { + const [selectedCountry, setSelectedCountry] = useState('India'); + const [activeYear, setActiveYear] = useState(1980); + const [debugInfo, setDebugInfo] = useState<{ + action: string; + role: string; + elementCount: number; + recordCount: number; + country: string | null; + year?: number; + selectedCountry: string; + activeYear: number; + valueKeys: string[]; + recordKeys: string[]; + valuePreview: string; + recordPreview: string; + } | null>(null); + + const mainMountRef = useRef(null); + + const mainSurfaceRef = useRef(null); + + const mainInput = useMemo(() => chartInput( + focusedRows(selectedCountry, activeYear), + 'Connected Scatter Plot', + `Global health trajectories — active frame ${activeYear}`, + { + x: 'Fertility', + y: 'Life', + order: 'Year', + color: 'Country', + detail: 'Country', + }, + { includeZero_x: false, includeZero_y: false }, + { width: 396, height: 260 }, + ), [activeYear, selectedCountry]); + + useEffect(() => { + const mount = mainMountRef.current; + if (!mount) return undefined; + + const handleInteraction = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail.event.phase !== 'commit') return; + const firstElement = detail.event.target?.elements[0]; + const firstValue = firstElement?.value as Record | undefined; + const firstRecord = firstElement?.records?.[0] as Record | undefined; + const { country, year } = interactionTarget(detail); + const baseDebug = { + role: detail.event.target?.visual.role ?? 'none', + elementCount: detail.event.target?.elements.length ?? 0, + recordCount: firstElement?.records?.length ?? 0, + country, + year, + valueKeys: firstValue ? Object.keys(firstValue) : [], + recordKeys: firstRecord ? Object.keys(firstRecord) : [], + valuePreview: firstValue ? JSON.stringify(firstValue) : 'null', + recordPreview: firstRecord ? JSON.stringify(firstRecord) : 'null', + }; + const legendSelection = legendCountry(detail); + if (legendSelection) { + setDebugInfo({ + action: 'legend-select', + ...baseDebug, + country: legendSelection, + selectedCountry: legendSelection, + activeYear, + }); + setSelectedCountry(legendSelection); + return; + } + if (detail.event.target?.visual.role !== 'symbol') { + setDebugInfo({ + action: 'ignored-non-symbol', + ...baseDebug, + selectedCountry, + activeYear, + }); + return; + } + if (!country || country !== selectedCountry || year === undefined) { + setDebugInfo({ + action: 'ignored-symbol-mismatch', + ...baseDebug, + selectedCountry, + activeYear, + }); + return; + } + setDebugInfo({ + action: 'symbol-year-select', + ...baseDebug, + selectedCountry, + activeYear: year, + }); + setActiveYear(year); + }; + + mount.addEventListener('flint-interaction', handleInteraction); + const surface = buildInteractiveChart(mount, mainInput as any, { + backend: 'vegalite', + renderer: 'svg', + interactions: [MAIN_MARK_CLICK_INTERACTION, MAIN_LEGEND_CLICK_INTERACTION], + ariaLabel: `Discrete Flint DimpVis main chart for ${activeYear}`, + chartId: `flint-dimpvis-main-${activeYear}`, + }); + mainSurfaceRef.current = surface; + + void surface.ready.then(async () => { + await hideDomainAnchors(surface); + await applySelectorStyle(surface, COUNTRY_STYLE_ID, { Country: selectedCountry }, { + state: 'emphasized', + mutedOpacity: 0.22, + }); + await applySelectorStyle(surface, YEAR_STYLE_ID, { Year: activeYear }, { + opacity: 1, + stroke: '#7c2d12', + strokeWidth: 2, + }); + }); + + return () => { + mount.removeEventListener('flint-interaction', handleInteraction); + mainSurfaceRef.current = null; + surface.destroy(); + }; + }, [mainInput, activeYear, selectedCountry]); + + return ( +
    +
    + Discrete Flint approximation + + Use the country legend to switch the active trajectory. Once a trajectory is visible, clicking one + of its points updates the shared current year for every other country node. + +
    +
    + Country: {selectedCountry} + Year: {activeYear} +
    +
    + Debug + + {debugInfo + ? `action=${debugInfo.action} role=${debugInfo.role} elements=${debugInfo.elementCount} records=${debugInfo.recordCount} country=${debugInfo.country ?? 'null'} year=${debugInfo.year ?? 'null'} selected=${debugInfo.selectedCountry} active=${debugInfo.activeYear} +valueKeys=${debugInfo.valueKeys.join(',') || 'none'} +recordKeys=${debugInfo.recordKeys.join(',') || 'none'} +value=${debugInfo.valuePreview} +record0=${debugInfo.recordPreview}` + : 'No interaction captured yet.'} + +
    +
    +
    + Unified trajectory view + + Legend selection changes the active country. Point clicks on that country step the background + snapshot through discrete years without changing which trajectory stays expanded. + +
    +
    +
    +
    + ); +} diff --git a/site/src/playground/InteractionCandidates.tsx b/site/src/playground/InteractionCandidates.tsx index 3f73d1d3..4db2bd37 100644 --- a/site/src/playground/InteractionCandidates.tsx +++ b/site/src/playground/InteractionCandidates.tsx @@ -5,6 +5,8 @@ import { FlaskConical } from 'lucide-react'; import { VegaLiteView } from '../components/VegaLiteView'; import { ScaleToFit } from '../components/ScaleToFit'; import { PREVIEW_CASES, type PreviewCase } from '../shared/preview-cases'; +import { FlintDimpVisStage } from './FlintDimpVisStage'; +import { PureD3DimpVisStage } from './PureD3DimpVisStage'; import './interaction-candidates.css'; type InteractionKind = @@ -30,6 +32,18 @@ type Example = label: string; note: string; } + | { + kind: 'flint-dimpvis'; + id: string; + label: string; + note: string; + } + | { + kind: 'dimpvis'; + id: string; + label: string; + note: string; + } | { kind: 'drilldown'; id: string; @@ -113,6 +127,18 @@ const EXAMPLES: Example[] = [ note: 'Reference treatment for freeform cluster picking that stays entirely in a D3 overlay above the Flint scatterplot.', interaction: 'lasso-points', }, + { + kind: 'flint-dimpvis', + id: 'flint-discrete-dimpvis', + label: 'Flint discrete DimpVis', + note: 'A first Flint-native approximation: use the country legend to switch the expanded trajectory, then click points on that trajectory to move the shared background year.', + }, + { + kind: 'dimpvis', + id: 'pure-d3-dimpvis', + label: 'Pure D3 DimpVis', + note: 'Reference treatment for trajectory-constrained dragging: one country drives continuous interpolation of the full scatterplot state between yearly snapshots.', + }, { kind: 'overview-detail', id: 'overview-detail', @@ -760,10 +786,20 @@ function attachInteraction(kind: InteractionKind, svg: SVGSVGElement, card: HTML } } -function CandidateHeader({ title, label, note }: { title: string; label: string; note: string }) { +function CandidateHeader({ + title, + label, + note, + kicker = 'Hand-authored D3', +}: { + title: string; + label: string; + note: string; + kicker?: string; +}) { return (
    -
    +

    {title}

    {label}

    {note}

    @@ -792,6 +828,37 @@ function SingleCandidateCard({ example }: { example: Extract }) { + return ( +
    + +
    + +
    +
    + ); +} + +function DimpVisCandidateCard({ example }: { example: Extract }) { + return ( +
    + +
    + +
    +
    + ); +} + function OverviewDetailCandidateCard({ example }: { example: Extract }) { const cardRef = useRef(null); const [selection, setSelection] = useState([12, 23]); @@ -1020,6 +1087,8 @@ function DrilldownCandidateCard({ example }: { example: Extract; + if (example.kind === 'dimpvis') return ; if (example.kind === 'overview-detail') return ; if (example.kind === 'drilldown') return ; return ; @@ -1032,8 +1101,10 @@ export function InteractionCandidates() {
    Future interaction references

    Hand-authored interaction candidates

    - Flint compiles each static chart. D3 is attached afterwards on this page only, preserving concrete - interaction treatments we may later move into compiler-owned semantics and rendering. + Most cards on this page compile a static Flint chart and then attach D3 afterwards, preserving + concrete interaction treatments we may later move into compiler-owned semantics and rendering. + The discrete Flint DimpVis card is the current exception: it stays inside Flint's interaction + surface and intentionally tests how far the existing semantic update model can go on its own.

    diff --git a/site/src/playground/PureD3DimpVisStage.tsx b/site/src/playground/PureD3DimpVisStage.tsx new file mode 100644 index 00000000..30bbf6d9 --- /dev/null +++ b/site/src/playground/PureD3DimpVisStage.tsx @@ -0,0 +1,518 @@ +import { useEffect, useRef } from 'react'; +import { + axisBottom, + axisLeft, + drag, + line, + pointer, + scaleLinear, + scaleOrdinal, + select, + schemeTableau10, +} from 'd3'; + +interface Frame { + year: number; + fertility: number; + life: number; + population: number; +} + +interface CountrySeries { + name: string; + region: string; + frames: Frame[]; +} + +const YEARS = [1955, 1960, 1965, 1970, 1975, 1980, 1985, 1990, 1995, 2000, 2005] as const; + +const SERIES: CountrySeries[] = [ + { + name: 'Afghanistan', + region: 'South Asia', + frames: [ + { year: 1955, fertility: 7.7, life: 30.332, population: 8891209 }, + { year: 1960, fertility: 7.7, life: 31.997, population: 9829450 }, + { year: 1965, fertility: 7.7, life: 34.02, population: 10997885 }, + { year: 1970, fertility: 7.7, life: 36.088, population: 12430623 }, + { year: 1975, fertility: 7.7, life: 38.438, population: 14132019 }, + { year: 1980, fertility: 7.8, life: 39.854, population: 15112149 }, + { year: 1985, fertility: 7.9, life: 40.822, population: 13796928 }, + { year: 1990, fertility: 8, life: 41.674, population: 14669339 }, + { year: 1995, fertility: 8, life: 41.763, population: 20881480 }, + { year: 2000, fertility: 7.4792, life: 42.129, population: 23898198 }, + { year: 2005, fertility: 7.0685, life: 43.828, population: 29928987 }, + ], + }, + { + name: 'Brazil', + region: 'America', + frames: [ + { year: 1955, fertility: 6.1501, life: 53.285, population: 61773546 }, + { year: 1960, fertility: 6.1501, life: 55.665, population: 71694810 }, + { year: 1965, fertility: 5.38, life: 57.632, population: 83092908 }, + { year: 1970, fertility: 4.7175, life: 59.504, population: 95684297 }, + { year: 1975, fertility: 4.305, life: 61.489, population: 108823732 }, + { year: 1980, fertility: 3.8, life: 63.336, population: 122958132 }, + { year: 1985, fertility: 3.1, life: 65.205, population: 137302933 }, + { year: 1990, fertility: 2.6, life: 67.057, population: 151083809 }, + { year: 1995, fertility: 2.45, life: 69.388, population: 163542501 }, + { year: 2000, fertility: 2.345, life: 71.006, population: 175552771 }, + { year: 2005, fertility: 2.245, life: 72.39, population: 186112794 }, + ], + }, + { + name: 'China', + region: 'East Asia & Pacific', + frames: [ + { year: 1955, fertility: 5.59, life: 50.54896, population: 608655000 }, + { year: 1960, fertility: 5.72, life: 44.50136, population: 667070000 }, + { year: 1965, fertility: 6.06, life: 58.38112, population: 715185000 }, + { year: 1970, fertility: 4.86, life: 63.11888, population: 818315000 }, + { year: 1975, fertility: 3.32, life: 63.96736, population: 916395000 }, + { year: 1980, fertility: 2.55, life: 65.525, population: 981235000 }, + { year: 1985, fertility: 2.46, life: 67.274, population: 1051040000 }, + { year: 1990, fertility: 1.92, life: 68.69, population: 1135185000 }, + { year: 1995, fertility: 1.781, life: 70.426, population: 1204855000 }, + { year: 2000, fertility: 1.7, life: 72.028, population: 1262645000 }, + { year: 2005, fertility: 1.725, life: 72.961, population: 1303182268 }, + ], + }, + { + name: 'France', + region: 'Europe & Central Asia', + frames: [ + { year: 1955, fertility: 2.712, life: 68.93, population: 43427669 }, + { year: 1960, fertility: 2.85, life: 70.51, population: 45670000 }, + { year: 1965, fertility: 2.607, life: 71.55, population: 48763000 }, + { year: 1970, fertility: 2.31, life: 72.38, population: 50787000 }, + { year: 1975, fertility: 1.862, life: 73.83, population: 52758427 }, + { year: 1980, fertility: 1.866, life: 74.89, population: 53869743 }, + { year: 1985, fertility: 1.805, life: 76.34, population: 55171224 }, + { year: 1990, fertility: 1.713, life: 77.46, population: 56735161 }, + { year: 1995, fertility: 1.7624, life: 78.64, population: 58149727 }, + { year: 2000, fertility: 1.8833, life: 79.59, population: 59381628 }, + { year: 2005, fertility: 1.8916, life: 80.657, population: 60656178 }, + ], + }, + { + name: 'India', + region: 'South Asia', + frames: [ + { year: 1955, fertility: 5.8961, life: 40.249, population: 393000000 }, + { year: 1960, fertility: 5.8216, life: 43.605, population: 434000000 }, + { year: 1965, fertility: 5.6058, life: 47.193, population: 485000000 }, + { year: 1970, fertility: 5.264, life: 50.651, population: 541000000 }, + { year: 1975, fertility: 4.8888, life: 54.208, population: 607000000 }, + { year: 1980, fertility: 4.4975, life: 56.596, population: 679000000 }, + { year: 1985, fertility: 4.15, life: 58.553, population: 755000000 }, + { year: 1990, fertility: 3.8648, life: 60.223, population: 839000000 }, + { year: 1995, fertility: 3.4551, life: 61.765, population: 927000000 }, + { year: 2000, fertility: 3.1132, life: 62.879, population: 1007702000 }, + { year: 2005, fertility: 2.8073, life: 64.698, population: 1080264388 }, + ], + }, + { + name: 'Japan', + region: 'East Asia & Pacific', + frames: [ + { year: 1955, fertility: 2.08, life: 65.5, population: 89815060 }, + { year: 1960, fertility: 2.02, life: 68.73, population: 94091638 }, + { year: 1965, fertility: 2, life: 71.43, population: 98882534 }, + { year: 1970, fertility: 2.07, life: 73.42, population: 104344973 }, + { year: 1975, fertility: 1.81, life: 75.38, population: 111573116 }, + { year: 1980, fertility: 1.76, life: 77.11, population: 116807309 }, + { year: 1985, fertility: 1.66, life: 78.67, population: 120754335 }, + { year: 1990, fertility: 1.49, life: 79.36, population: 123537399 }, + { year: 1995, fertility: 1.39, life: 80.69, population: 125341354 }, + { year: 2000, fertility: 1.291, life: 82, population: 126699784 }, + { year: 2005, fertility: 1.27, life: 82.603, population: 127417244 }, + ], + }, + { + name: 'Nigeria', + region: 'Sub-Saharan Africa', + frames: [ + { year: 1955, fertility: 6.9, life: 37.802, population: 35458978 }, + { year: 1960, fertility: 6.9, life: 39.36, population: 39914593 }, + { year: 1965, fertility: 6.9, life: 41.04, population: 45020052 }, + { year: 1970, fertility: 6.9, life: 42.821, population: 51027516 }, + { year: 1975, fertility: 6.9, life: 44.514, population: 58522112 }, + { year: 1980, fertility: 6.9, life: 45.826, population: 68550274 }, + { year: 1985, fertility: 6.834, life: 46.886, population: 77573154 }, + { year: 1990, fertility: 6.635, life: 47.472, population: 88510354 }, + { year: 1995, fertility: 6.246, life: 47.464, population: 100960105 }, + { year: 2000, fertility: 5.845, life: 46.608, population: 114306700 }, + { year: 2005, fertility: 5.322, life: 46.859, population: 128765768 }, + ], + }, + { + name: 'United States', + region: 'America', + frames: [ + { year: 1955, fertility: 3.706, life: 69.49, population: 165931000 }, + { year: 1960, fertility: 3.314, life: 70.21, population: 180671000 }, + { year: 1965, fertility: 2.545, life: 70.76, population: 194303000 }, + { year: 1970, fertility: 2.016, life: 71.34, population: 205052000 }, + { year: 1975, fertility: 1.788, life: 73.38, population: 215973000 }, + { year: 1980, fertility: 1.825, life: 74.65, population: 227726463 }, + { year: 1985, fertility: 1.924, life: 75.02, population: 238466283 }, + { year: 1990, fertility: 2.025, life: 76.09, population: 250131894 }, + { year: 1995, fertility: 1.994, life: 76.81, population: 266557091 }, + { year: 2000, fertility: 2.038, life: 77.31, population: 282338631 }, + { year: 2005, fertility: 2.054, life: 78.242, population: 295734134 }, + ], + }, +]; + +const MARGIN = { top: 16, right: 170, bottom: 66, left: 52 }; +const PLOT_WIDTH = 520; +const PLOT_HEIGHT = 288; +const TOTAL_WIDTH = PLOT_WIDTH + MARGIN.left + MARGIN.right; +const TOTAL_HEIGHT = PLOT_HEIGHT + MARGIN.top + MARGIN.bottom + 62; +const SLIDER_Y = MARGIN.top + PLOT_HEIGHT + 36; + +const regionOrder = Array.from(new Set(SERIES.map((series) => series.region))); +const maxPopulation = Math.max(...SERIES.flatMap((series) => series.frames.map((frame) => frame.population))); + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function lerp(start: number, end: number, t: number) { + return start + ((end - start) * t); +} + +function interpolateFrame(series: CountrySeries, leftIndex: number, rightIndex: number, t: number) { + const left = series.frames[leftIndex]; + const right = series.frames[rightIndex]; + return { + year: lerp(left.year, right.year, t), + fertility: lerp(left.fertility, right.fertility, t), + life: lerp(left.life, right.life, t), + population: lerp(left.population, right.population, t), + }; +} + +function projectToPolyline( + point: [number, number], + polyline: [number, number][], +) { + let bestDistance = Number.POSITIVE_INFINITY; + let bestIndex = 0; + let bestT = 0; + + for (let index = 0; index < polyline.length - 1; index += 1) { + const start = polyline[index]; + const end = polyline[index + 1]; + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const lengthSquared = (dx * dx) + (dy * dy); + const rawT = lengthSquared === 0 ? 0 : (((point[0] - start[0]) * dx) + ((point[1] - start[1]) * dy)) / lengthSquared; + const t = clamp(rawT, 0, 1); + const projectedX = start[0] + (dx * t); + const projectedY = start[1] + (dy * t); + const distanceSquared = ((point[0] - projectedX) ** 2) + ((point[1] - projectedY) ** 2); + if (distanceSquared < bestDistance) { + bestDistance = distanceSquared; + bestIndex = index; + bestT = t; + } + } + + return { + leftIndex: bestIndex, + rightIndex: bestIndex + 1, + t: bestT, + progress: bestIndex + bestT, + }; +} + +function progressToState(progress: number) { + const clamped = clamp(progress, 0, YEARS.length - 1); + const leftIndex = Math.min(Math.floor(clamped), YEARS.length - 2); + const rightIndex = Math.min(leftIndex + 1, YEARS.length - 1); + const t = leftIndex === rightIndex ? 0 : clamped - leftIndex; + return { leftIndex, rightIndex, t }; +} + +export function PureD3DimpVisStage() { + const mountRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return undefined; + + const xScale = scaleLinear().domain([1, 8.2]).range([0, PLOT_WIDTH]); + const yScale = scaleLinear().domain([25, 84]).range([PLOT_HEIGHT, 0]); + const sliderScale = scaleLinear().domain([0, YEARS.length - 1]).range([0, PLOT_WIDTH]); + const radiusScale = scaleLinear().domain([0, Math.sqrt(maxPopulation)]).range([5, 17]); + const colorScale = scaleOrdinal() + .domain(regionOrder) + .range(schemeTableau10.slice(0, regionOrder.length)); + + const svg = select(mount) + .append('svg') + .attr('class', 'ic-dimpvis-svg') + .attr('viewBox', `0 0 ${TOTAL_WIDTH} ${TOTAL_HEIGHT}`) + .attr('role', 'img') + .attr('aria-label', 'Pure D3 DimpVis example with draggable trajectory and time slider'); + + const frame = svg.append('g').attr('transform', `translate(${MARGIN.left},${MARGIN.top})`); + + frame + .append('rect') + .attr('class', 'ic-dimpvis-plot-bg') + .attr('width', PLOT_WIDTH) + .attr('height', PLOT_HEIGHT) + .attr('rx', 10); + + frame + .append('g') + .attr('class', 'ic-dimpvis-grid') + .attr('transform', `translate(0,${PLOT_HEIGHT})`) + .call(axisBottom(xScale).ticks(6).tickSize(-PLOT_HEIGHT)) + .call((group) => { + group.select('.domain').remove(); + group.selectAll('.tick line').attr('stroke', '#e5e7eb'); + group.selectAll('.tick text').attr('fill', '#66707a').attr('font-size', 11); + }); + + frame + .append('g') + .attr('class', 'ic-dimpvis-grid') + .call(axisLeft(yScale).ticks(6).tickSize(-PLOT_WIDTH)) + .call((group) => { + group.select('.domain').remove(); + group.selectAll('.tick line').attr('stroke', '#e5e7eb'); + group.selectAll('.tick text').attr('fill', '#66707a').attr('font-size', 11); + }); + + frame + .append('text') + .attr('class', 'ic-dimpvis-axis-label') + .attr('x', PLOT_WIDTH / 2) + .attr('y', PLOT_HEIGHT + 42) + .attr('text-anchor', 'middle') + .text('Fertility rate (children per woman)'); + + frame + .append('text') + .attr('class', 'ic-dimpvis-axis-label') + .attr('transform', `translate(-38, ${PLOT_HEIGHT / 2}) rotate(-90)`) + .attr('text-anchor', 'middle') + .text('Life expectancy (years)'); + + const yearStamp = frame + .append('text') + .attr('class', 'ic-dimpvis-year') + .attr('x', PLOT_WIDTH / 2) + .attr('y', PLOT_HEIGHT / 2 + 16); + + const pathLayer = frame.append('g').attr('class', 'ic-dimpvis-path-layer'); + const pointsLayer = frame.append('g').attr('class', 'ic-dimpvis-points-layer'); + const focusLayer = frame.append('g').attr('class', 'ic-dimpvis-focus-layer'); + + const legend = svg.append('g').attr('transform', `translate(${MARGIN.left + PLOT_WIDTH + 22},${MARGIN.top + 10})`); + legend.append('text').attr('class', 'ic-dimpvis-legend-title').text('Region'); + regionOrder.forEach((region, index) => { + const row = legend.append('g').attr('transform', `translate(0, ${22 + (index * 18)})`); + row.append('circle').attr('r', 5).attr('fill', colorScale(region)); + row.append('text').attr('x', 12).attr('y', 4).attr('class', 'ic-dimpvis-legend-label').text(region); + }); + + const instruction = svg + .append('text') + .attr('class', 'ic-dimpvis-instruction') + .attr('x', MARGIN.left + PLOT_WIDTH + 22) + .attr('y', MARGIN.top + 128) + .text('Drag a highlighted point or the slider'); + + const status = svg + .append('text') + .attr('class', 'ic-dimpvis-status') + .attr('x', MARGIN.left + PLOT_WIDTH + 22) + .attr('y', MARGIN.top + 150); + + const slider = svg.append('g').attr('transform', `translate(${MARGIN.left},${SLIDER_Y})`); + slider.append('line').attr('class', 'ic-dimpvis-slider-track').attr('x1', 0).attr('x2', PLOT_WIDTH).attr('y1', 0).attr('y2', 0); + + slider + .selectAll('line.ic-dimpvis-slider-tick') + .data(YEARS) + .join('line') + .attr('class', 'ic-dimpvis-slider-tick') + .attr('x1', (_, index) => sliderScale(index)) + .attr('x2', (_, index) => sliderScale(index)) + .attr('y1', -7) + .attr('y2', 7); + + slider + .selectAll('text.ic-dimpvis-slider-label') + .data(YEARS) + .join('text') + .attr('class', 'ic-dimpvis-slider-label') + .attr('x', (_, index) => sliderScale(index)) + .attr('y', 24) + .attr('text-anchor', 'middle') + .text((year) => year); + + slider + .append('rect') + .attr('class', 'ic-dimpvis-slider-hit') + .attr('x', -10) + .attr('y', -16) + .attr('width', PLOT_WIDTH + 20) + .attr('height', 32) + .attr('fill', 'transparent'); + + const sliderHandle = slider.append('circle').attr('class', 'ic-dimpvis-slider-handle').attr('r', 8); + + const selected = { value: 'India' }; + const state = { leftIndex: 0, rightIndex: 1, t: 0 }; + + function screenPath(series: CountrySeries) { + return series.frames.map((frame) => [xScale(frame.fertility), yScale(frame.life)] as [number, number]); + } + + function activeSeries() { + return SERIES.find((series) => series.name === selected.value) ?? SERIES[0]; + } + + function setProgress(progress: number) { + const nextState = progressToState(progress); + state.leftIndex = nextState.leftIndex; + state.rightIndex = nextState.rightIndex; + state.t = nextState.t; + draw(); + } + + function snapToNearestYear() { + const left = state.leftIndex; + const right = state.rightIndex; + const targetIndex = state.t >= 0.5 ? right : left; + state.leftIndex = Math.min(targetIndex, YEARS.length - 2); + state.rightIndex = Math.min(state.leftIndex + 1, YEARS.length - 1); + state.t = targetIndex === YEARS.length - 1 ? 1 : 0; + draw(); + } + + function draw() { + const currentSeries = activeSeries(); + const currentYear = lerp(YEARS[state.leftIndex], YEARS[state.rightIndex], state.t); + const progress = state.leftIndex + state.t; + const interpolated = SERIES.map((series) => ({ + ...series, + frame: interpolateFrame(series, state.leftIndex, state.rightIndex, state.t), + })); + + yearStamp.text(currentYear.toFixed(1)); + status.text(`${currentSeries.name} — ${currentYear.toFixed(1)}`); + + const selectedPath = screenPath(currentSeries); + pathLayer + .selectAll('path') + .data([selectedPath]) + .join('path') + .attr('class', 'ic-dimpvis-path') + .attr('d', line<[number, number]>()(selectedPath)); + + pathLayer + .selectAll('text.ic-dimpvis-path-label') + .data(currentSeries.frames.map((frame, index) => ({ + frame, + point: selectedPath[index], + }))) + .join('text') + .attr('class', 'ic-dimpvis-path-label') + .attr('x', (datum) => datum.point[0] + 6) + .attr('y', (datum) => datum.point[1] - 7) + .text((datum) => String(datum.frame.year)); + + const circles = pointsLayer + .selectAll('circle') + .data(interpolated, (datum) => datum.name) + .join('circle') + .attr('class', (datum) => datum.name === currentSeries.name ? 'ic-dimpvis-point is-active' : 'ic-dimpvis-point') + .attr('cx', (datum) => xScale(datum.frame.fertility)) + .attr('cy', (datum) => yScale(datum.frame.life)) + .attr('r', (datum) => radiusScale(Math.sqrt(datum.frame.population))) + .attr('fill', (datum) => colorScale(datum.region)) + .attr('stroke', (datum) => datum.name === currentSeries.name ? '#1f2328' : '#ffffff') + .attr('stroke-width', (datum) => datum.name === currentSeries.name ? 2.2 : 1.2) + .style('cursor', 'grab'); + + circles.classed('is-muted', (datum) => datum.name !== currentSeries.name); + + focusLayer + .selectAll('text') + .data([{ + x: xScale(interpolateFrame(currentSeries, state.leftIndex, state.rightIndex, state.t).fertility), + y: yScale(interpolateFrame(currentSeries, state.leftIndex, state.rightIndex, state.t).life), + label: currentSeries.name, + }]) + .join('text') + .attr('class', 'ic-dimpvis-focus-label') + .attr('x', (datum) => datum.x + 10) + .attr('y', (datum) => datum.y - 12) + .text((datum) => datum.label); + + sliderHandle.attr('cx', sliderScale(progress)).attr('cy', 0); + + circles.on('click', (_, datum) => { + selected.value = datum.name; + draw(); + }); + } + + const pointDrag = drag() + .on('start', (_, datum) => { + selected.value = datum.name; + draw(); + }) + .on('drag', (event) => { + const currentSeries = activeSeries(); + const projection = projectToPolyline([event.x, event.y], screenPath(currentSeries)); + setProgress(projection.progress); + }) + .on('end', () => { + snapToNearestYear(); + }); + + const sliderDrag = drag() + .on('drag', (event) => { + setProgress(sliderScale.invert(event.x)); + }) + .on('end', () => { + snapToNearestYear(); + }); + + slider.select('rect.ic-dimpvis-slider-hit') + .on('click', (event) => { + const [x] = pointer(event, slider.node()); + setProgress(sliderScale.invert(x)); + snapToNearestYear(); + }); + + draw(); + pointsLayer.selectAll('circle').call(pointDrag as any); + sliderHandle.call(sliderDrag as any).style('cursor', 'ew-resize'); + + return () => { + svg.remove(); + }; + }, []); + + return ( +
    +
    + Direct manipulation + interpolation + + This one stays entirely in D3. Drag the highlighted country along its trajectory, or scrub the slider to + interpolate the whole scatterplot state between yearly snapshots. + +
    +
    +
    + ); +} diff --git a/site/src/playground/interaction-candidates.css b/site/src/playground/interaction-candidates.css index 635485ae..9e8437b6 100644 --- a/site/src/playground/interaction-candidates.css +++ b/site/src/playground/interaction-candidates.css @@ -189,6 +189,121 @@ stroke-width: 1.6px; } +.ic-dimpvis-shell { + padding: 0 4px 4px; +} + +.ic-dimpvis-svg { + display: block; + width: 100%; + height: auto; +} + +.ic-dimpvis-plot-bg { + fill: #fcfcfd; + stroke: #e7ebef; +} + +.ic-dimpvis-axis-label, +.ic-dimpvis-legend-title, +.ic-dimpvis-status, +.ic-dimpvis-focus-label { + fill: #2d3439; + font-size: 11px; + font-weight: 600; +} + +.ic-dimpvis-legend-label, +.ic-dimpvis-slider-label, +.ic-dimpvis-path-label, +.ic-dimpvis-instruction { + fill: #66707a; + font-size: 10px; +} + +.ic-dimpvis-year { + fill: rgba(56, 79, 68, 0.12); + font-size: 92px; + font-weight: 700; + text-anchor: middle; + letter-spacing: -0.03em; + pointer-events: none; +} + +.ic-dimpvis-path { + fill: none; + stroke: #436856; + stroke-width: 2.25px; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 4 4; +} + +.ic-dimpvis-point { + opacity: 0.92; + transition: opacity 140ms ease; +} + +.ic-dimpvis-point.is-muted { + opacity: 0.28; +} + +.ic-dimpvis-slider-track, +.ic-dimpvis-slider-tick { + stroke: #c7d0d7; + stroke-width: 1.2px; +} + +.ic-dimpvis-slider-handle { + fill: #436856; + stroke: #ffffff; + stroke-width: 2px; +} + +.ic-flint-dimpvis-shell { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 12px 14px; +} + +.ic-flint-dimpvis-panel { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ic-flint-dimpvis-panel-header { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 4px; +} + +.ic-flint-dimpvis-panel-header strong { + color: #2d3439; + font-size: 12px; +} + +.ic-flint-dimpvis-panel-header span { + color: #6c7780; + font-size: 11px; + line-height: 1.45; +} + +.ic-flint-dimpvis-mount { + min-height: 152px; + overflow-x: auto; + overflow-y: hidden; + border: 1px solid #edf0f2; + border-radius: 6px; + background: #fcfcfd; +} + +.ic-flint-dimpvis-mount > * { + min-width: 100%; +} + @media (max-width: 520px) { .ic-card-header { min-height: 0; From 075078a6d04c71fcea1d9052ec215b2c1b725ee5 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Wed, 2 Sep 2026 17:49:19 -0700 Subject: [PATCH 27/33] ok --- packages/flint-js/src/interactive/README.md | 41 ++- packages/flint-js/src/interactive/guides.ts | 15 +- packages/flint-js/src/interactive/index.ts | 8 +- .../flint-js/src/interactive/interactions.ts | 32 +- .../flint-js/src/interactive/presets/index.ts | 3 +- .../src/interactive/presets/inspect-index.ts | 23 ++ .../src/interactive/presets/inspect.ts | 9 +- .../{facet-brush-link.ts => linked-brush.ts} | 10 +- .../src/interactive/presets/long-press.ts | 10 +- packages/flint-js/src/interactive/triggers.ts | 21 ++ .../src/vegalite/interactions/hit-adapter.ts | 118 ++++++++ .../presentation/inspect-guide-overlay.ts | 41 ++- .../src/vegalite/interactions/runtime.ts | 179 +++++++++++- .../flint-js/src/vegalite/templates/bullet.ts | 7 +- packages/flint-js/tests/gantt-bullet.test.ts | 15 + packages/flint-js/tests/interactions.test.ts | 110 ++++++- .../tests/semantic-interactions.test.ts | 74 +++++ site/src/playground/ClickFocusLab.tsx | 273 ++++++++++++++---- site/src/playground/click-focus-lab.css | 13 + 19 files changed, 884 insertions(+), 118 deletions(-) create mode 100644 packages/flint-js/src/interactive/presets/inspect-index.ts rename packages/flint-js/src/interactive/presets/{facet-brush-link.ts => linked-brush.ts} (79%) diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index 87b9cd84..a4f493a8 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -459,7 +459,18 @@ guide never changes acquisition or the emitted semantic event: ```ts inspect({ mode: 'xy', - guide: { style: { color: '#47525c', opacity: 0.5, width: 1 } }, + guide: { + style: { + color: '#47525c', opacity: 0.58, width: 1, + haloColor: '#ffffff', haloOpacity: 0.64, haloWidth: 0.5, + }, + }, +}); + +inspectIndex({ + axis: 'x', + seriesBy: 'Series', + show: 'all', }); brushX({ @@ -473,6 +484,20 @@ Inspect lines, Cartesian regions, angular sectors, and lasso paths use the share renderer-neutral gesture-guide styles. Retained guides such as reference lines instead belong to chart presentation state and may be created by effects through chart updates. +`inspectIndex()` is the general reading preset for line and point charts. A discrete index +snaps to an observed slice; a temporal or quantitative index intersects a line continuously +between observations. Point marks are acquired when the pointer intersects them on the index +axis or comes within the axis-only `tolerance` (a plot-size fraction, default `0.01`). Outside +that bounded assistance radius, the index guide remains but no point value rule is shown. The +index guide and acquired value rules form a crosshair without dimming the chart. `show: 'all'` +returns every series in that slice. `show: 'single'` starts with the first series, while +`show: { series: value }` starts with a preferred series. In either single-series mode, clicking +a legend item switches tracking to that series; marks remain inert and available to other interactions. +The tracked series remains highlighted in its authored colour. Hovering another legend item previews it. +Both single-series policies require the `seriesBy` field. Aggregates such as averages remain custom-handler logic; +the preset does not transform records. Directional predicates on the lower-level +`inspectTrigger()` can support bespoke interactions such as threshold quadrants. + Chart-specific action processing belongs in the handler. For example, ranged-dot region targets are expanded to complete category units before producing a `set-style` update. Direct ranged-dot clicks already resolve to the complete dumbbell in the owning ChartDef. @@ -885,15 +910,19 @@ Callers should provide `chartId` when coordinating charts. Flint generates an ID Chart identity belongs to the transport envelope, not `SemanticTarget`: semantic targets describe visual/data identity, while `chartId` describes event origin or dispatch destination. -## Facet linking +## Linked brushing -`facetBrushLink()` expands marks acquired in one facet to every available mark with the same authored semantic key: +`linkedBrush()` expands brushed marks to every available mark with the same authored semantic group: ```ts -facetBrushLink({ by: 'Country' }); -facetBrushLink({ by: ['Country', 'Product'], brush: 'lasso' }); +linkedBrush({ groupBy: 'Country' }); +linkedBrush({ groupBy: ['Country', 'Product'], brush: 'lasso' }); ``` +The available marks may belong to facets, repeated views, or another renderer-defined +view composition. Group matching uses the same input-record field keys as +`clickGroupFocus({ groupBy })`. + The key should be represented by a discrete positional channel, `detail`, or `color`. For a quantitative scatter plot, prefer `detail` when identity should not alter appearance. Continuous `x`, `y`, or `xy` values are not inferred as identities because measurements can change between @@ -949,7 +978,7 @@ Canonical helpers include: - `clickHighlight()` - `clickGroupFocus()` - `clickAnnotate()` -- `facetBrushLink()` +- `linkedBrush()` - `hoverGroupFocus()` - `legendToggle()` - `select()`, `brushX()`, `brushY()`, and `brushAngle()` diff --git a/packages/flint-js/src/interactive/guides.ts b/packages/flint-js/src/interactive/guides.ts index e70c9624..56326da0 100644 --- a/packages/flint-js/src/interactive/guides.ts +++ b/packages/flint-js/src/interactive/guides.ts @@ -13,6 +13,9 @@ export interface LineGestureGuideStyle { export interface InspectGestureGuideStyle extends LineGestureGuideStyle { fillOpacity: number; + haloColor: string; + haloOpacity: number; + haloWidth: number; } export interface AreaGestureGuideStyle { @@ -33,9 +36,12 @@ export type RegionGuideOptions = GestureGuideOptions; export const DEFAULT_INSPECT_GUIDE_STYLE: Readonly = Object.freeze({ color: '#47525c', - opacity: 0.46, + opacity: 0.58, width: 1, fillOpacity: 0.07, + haloColor: '#ffffff', + haloOpacity: 0.64, + haloWidth: 0.5, }); export const DEFAULT_REGION_GUIDE_STYLE: Readonly = Object.freeze({ @@ -63,6 +69,13 @@ export function normalizeInspectGuideOptions( fillOpacity: Number.isFinite(style?.fillOpacity) ? Math.min(1, Math.max(0, style!.fillOpacity!)) : DEFAULT_INSPECT_GUIDE_STYLE.fillOpacity, + haloColor: style?.haloColor ?? DEFAULT_INSPECT_GUIDE_STYLE.haloColor, + haloOpacity: Number.isFinite(style?.haloOpacity) + ? Math.min(1, Math.max(0, style!.haloOpacity!)) + : DEFAULT_INSPECT_GUIDE_STYLE.haloOpacity, + haloWidth: Number.isFinite(style?.haloWidth) && style!.haloWidth! >= 0 + ? style!.haloWidth! + : DEFAULT_INSPECT_GUIDE_STYLE.haloWidth, }, }; } diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 6f641c5a..1f4355d5 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -50,7 +50,7 @@ export type { ClickHighlightOptions, ClickHighlightTarget, ClickGroupFocusOptions, - FacetBrushLinkOptions, + LinkedBrushOptions, HoverGroupFocusOptions, GroupBy, ElementInteractionEvent, @@ -62,6 +62,7 @@ export type { ExternalInteractionDef, InteractionModifiers, InspectOptions, + InspectIndexOptions, LassoSelectOptions, NavigateOptions, NavigationAxes, @@ -97,8 +98,8 @@ export type { SemanticTargetSelector, } from './language/updates'; export { matchesSemanticTargetSelector } from './language/updates'; -export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, longPress, navigate, select } from './interactions'; -export type { InteractionEventSource } from './triggers'; +export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, hoverGroupFocus, inspect, inspectIndex, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, linkedBrush, longPress, navigate, select } from './interactions'; +export type { InspectIndexShow, InteractionEventSource } from './triggers'; export { axisBrushTrigger, angularBrushTrigger, @@ -108,6 +109,7 @@ export { doubleActivateTrigger, hoverTrigger, inspectTrigger, + inspectIndexTrigger, keyboardTrigger, lassoTrigger, longPressTrigger, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 74ed29c9..2bf47b63 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -6,7 +6,7 @@ import type { SemanticTargetSelector, } from '../core/interaction-contracts'; import type { InteractionEventSource } from './triggers'; -import type { InspectMode } from './triggers'; +import type { InspectIndexShow, InspectMode } from './triggers'; import type { InspectGuideOptions, RegionGuideOptions } from './guides'; import type { InteractionAffordance } from './affordances'; import type { @@ -23,13 +23,14 @@ import { createContextActivateInteraction, createDoubleActivateInteraction, createInspectInteraction, + createInspectIndexInteraction, createLongPressInteraction, createLassoSelectInteraction, createLegendToggleInteraction, createSelectInteraction, createNavigateInteraction, createDragReorderInteraction, - createFacetBrushLinkInteraction, + createLinkedBrushInteraction, createHoverGroupFocusInteraction, } from './presets'; import type { CanvasInteractionEvent } from './language/events'; @@ -160,8 +161,8 @@ export interface ClickAnnotateOptions { format?: (element: SemanticElement, context: InteractionContext) => string; } -export interface FacetBrushLinkOptions extends SelectOptions { - by: string | readonly string[]; +export interface LinkedBrushOptions extends SelectOptions { + groupBy: GroupBy; brush?: 'rectangle' | 'lasso'; } @@ -211,6 +212,20 @@ export interface InspectOptions { dimOpacity?: number; } +export interface InspectIndexOptions { + id?: string; + /** Independent chart axis used to acquire one index slice. */ + axis?: 'x' | 'y'; + /** Near-axis acquisition radius as a plot-size fraction. Defaults to 0.01. */ + tolerance?: number; + /** Which series to present: all, the first series, or a preferred initial series. */ + show?: InspectIndexShow; + /** Record field identifying a series; single-series policies switch through the legend. */ + seriesBy?: string; + guide?: InspectGuideOptions | false; + selector?: SemanticTargetSelector; +} + export interface BrushZoomOptions { id?: string; axes?: 'x' | 'y' | 'xy'; @@ -261,8 +276,8 @@ export function clickAnnotate(options: ClickAnnotateOptions = {}): CanvasInterac return createClickAnnotateInteraction(options); } -export function facetBrushLink(options: FacetBrushLinkOptions): CanvasInteractionDef { - return createFacetBrushLinkInteraction(options); +export function linkedBrush(options: LinkedBrushOptions): CanvasInteractionDef { + return createLinkedBrushInteraction(options); } export function hoverGroupFocus(options: HoverGroupFocusOptions): CanvasInteractionDef { @@ -289,6 +304,10 @@ export function inspect(options: InspectOptions = {}): CanvasInteractionDef { return createInspectInteraction(options); } +export function inspectIndex(options: InspectIndexOptions = {}): CanvasInteractionDef { + return createInspectIndexInteraction(options); +} + export function brushZoom(options: BrushZoomOptions = {}): CanvasInteractionDef { return createBrushZoomInteraction(options); } @@ -309,6 +328,7 @@ export function brushY(options: BrushOptions = {}): CanvasInteractionDef { return createBrushInteraction('y', options); } +/** Select an angular interval on a polar chart. */ export function brushAngle(options: AngularBrushOptions = {}): CanvasInteractionDef { return createAngularBrushInteraction(options); } diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index 20bd8cd5..3834ec39 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -7,11 +7,12 @@ export { createClickGroupFocusInteraction } from './click-group-highlight'; export { createClickHighlightInteraction } from './click-highlight'; export { createContextActivateInteraction } from './context-activate'; export { createInspectInteraction } from './inspect'; +export { createInspectIndexInteraction } from './inspect-index'; export { createDoubleActivateInteraction, createLongPressInteraction } from './long-press'; export { createLassoSelectInteraction } from './lasso-select'; export { createLegendToggleInteraction } from './legend-toggle'; export { createSelectInteraction } from './select'; export { createNavigateInteraction } from './navigate'; export { createDragReorderInteraction } from './drag-reorder'; -export { createFacetBrushLinkInteraction } from './facet-brush-link'; +export { createLinkedBrushInteraction } from './linked-brush'; export { createHoverGroupFocusInteraction } from './hover-group-highlight'; diff --git a/packages/flint-js/src/interactive/presets/inspect-index.ts b/packages/flint-js/src/interactive/presets/inspect-index.ts new file mode 100644 index 00000000..605c57c6 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/inspect-index.ts @@ -0,0 +1,23 @@ +import type { CanvasInteractionDef, InspectIndexOptions } from '../interactions'; +import type { InteractionAffordance } from '../affordances'; +import { inspectIndexTrigger } from '../triggers'; + +/** Reads values at one independent-axis position across one or more series. */ +export function createInspectIndexInteraction(options: InspectIndexOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'inspect-index'; + const axis = options.axis ?? 'x'; + const show = options.show ?? 'all'; + if (show !== 'all' && !options.seriesBy) { + throw new Error('inspectIndex({ show: "single" | { series } }) requires seriesBy.'); + } + const affordances: InteractionAffordance[] = show !== 'all' + ? [{ target: 'legend-item', cursor: 'activate', hover: 'cohort' }] + : [{ target: 'plot', cursor: 'inspect' }]; + return { + id, + eventSource: inspectIndexTrigger( + axis, show, options.seriesBy, options.selector, options.guide, options.tolerance, + ), + affordances, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/inspect.ts b/packages/flint-js/src/interactive/presets/inspect.ts index cf9d29b2..2f028f44 100644 --- a/packages/flint-js/src/interactive/presets/inspect.ts +++ b/packages/flint-js/src/interactive/presets/inspect.ts @@ -19,7 +19,14 @@ export function createInspectInteraction(options: InspectOptions = {}): CanvasIn affordances: [{ target: 'plot', cursor: 'inspect' }], handle(event, context) { if (!INSPECT_ACTIONS.has(event.action) || event.phase === 'cancel') return null; - if (!event.target) return { id, ops: [] }; + if (!event.target) return { + id, + ops: [{ + op: 'set-style', + targets: [], + value: { state: 'emphasized', mutedOpacity: dimOpacity }, + }], + }; return emphasisUpdate(id, event, event.target, dimOpacity, context); }, }; diff --git a/packages/flint-js/src/interactive/presets/facet-brush-link.ts b/packages/flint-js/src/interactive/presets/linked-brush.ts similarity index 79% rename from packages/flint-js/src/interactive/presets/facet-brush-link.ts rename to packages/flint-js/src/interactive/presets/linked-brush.ts index c6b97c8e..2e9bc73f 100644 --- a/packages/flint-js/src/interactive/presets/facet-brush-link.ts +++ b/packages/flint-js/src/interactive/presets/linked-brush.ts @@ -1,10 +1,10 @@ -import type { CanvasInteractionDef, FacetBrushLinkOptions } from '../interactions'; +import type { CanvasInteractionDef, LinkedBrushOptions } from '../interactions'; import { lassoTrigger, rectangleTrigger } from '../triggers'; import { expandElementsByFields } from './semantic-cohort'; import { emphasisUpdate, normalizedOpacity } from './utils'; -export function createFacetBrushLinkInteraction(options: FacetBrushLinkOptions): CanvasInteractionDef { - const id = options.id ?? 'facet-brush-link'; +export function createLinkedBrushInteraction(options: LinkedBrushOptions): CanvasInteractionDef { + const id = options.id ?? 'linked-brush'; const dimOpacity = normalizedOpacity(options.dimOpacity); const lasso = options.brush === 'lasso'; return { @@ -19,9 +19,9 @@ export function createFacetBrushLinkInteraction(options: FacetBrushLinkOptions): if (!event.target) return emphasisUpdate(id, event, null, dimOpacity, context); const target = { ...event.target, - elements: expandElementsByFields(event.target.elements, context.available, options.by), + elements: expandElementsByFields(event.target.elements, context.available, options.groupBy), }; return emphasisUpdate(id, event, target, dimOpacity, context); }, }; -} \ No newline at end of file +} diff --git a/packages/flint-js/src/interactive/presets/long-press.ts b/packages/flint-js/src/interactive/presets/long-press.ts index 4a749987..bf5bf91e 100644 --- a/packages/flint-js/src/interactive/presets/long-press.ts +++ b/packages/flint-js/src/interactive/presets/long-press.ts @@ -13,9 +13,10 @@ export function createLongPressInteraction(options: LongPressOptions = {}): Canv return { id, eventSource: assistedElementTrigger(longPressTrigger(options.holdMs ?? 500), 12), - affordances: [{ target: 'mark', cursor: 'activate' }], + affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], handle(event, context) { - if (!event.action.startsWith('long-press-') || event.phase !== 'commit') return null; + if (!event.action.startsWith('long-press-') + || (event.phase !== 'preview' && event.phase !== 'commit')) return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); }, }; @@ -30,9 +31,10 @@ export function createDoubleActivateInteraction( return { id, eventSource: assistedElementTrigger(doubleActivateTrigger, 8), - affordances: [{ target: 'mark', cursor: 'activate' }], + affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], handle(event, context) { - if (!event.action.startsWith('double-activate-') || event.phase !== 'commit') return null; + if (!event.action.startsWith('double-activate-') + || (event.phase !== 'preview' && event.phase !== 'commit')) return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); }, }; diff --git a/packages/flint-js/src/interactive/triggers.ts b/packages/flint-js/src/interactive/triggers.ts index 6ace623b..6d1193cf 100644 --- a/packages/flint-js/src/interactive/triggers.ts +++ b/packages/flint-js/src/interactive/triggers.ts @@ -9,6 +9,8 @@ export type InspectMode = | `x${InspectOperator}` | `y${InspectOperator}` | `xy${InspectOperator}` | `x${InspectOperator};y${InspectOperator}`; +export type InspectIndexShow = 'all' | 'single' | { series: unknown }; + export interface InspectPredicate { readonly x?: InspectOperator; readonly y?: InspectOperator; @@ -25,6 +27,11 @@ export interface InteractionEventSource { readonly inspectPredicate?: InspectPredicate; readonly inspectCycle?: readonly ReturnType[]; readonly inspectTolerance?: number; + readonly inspectIndex?: { + readonly axis: 'x' | 'y'; + readonly show: InspectIndexShow; + readonly seriesBy?: string; + }; /** Nearest-mark acquisition radius for hover gestures, in renderer pixels. */ readonly targetTolerance?: number; /** Preset-owned nearest-mark acquisition radius, in renderer pixels. */ @@ -142,6 +149,20 @@ export function inspectTrigger( }; } +export function inspectIndexTrigger( + axis: 'x' | 'y' = 'x', + show: InspectIndexShow = 'all', + seriesBy?: string, + selector?: SemanticTargetSelector, + guide?: InspectGuideOptions | false, + tolerance?: number, +): InteractionEventSource { + return { + ...inspectTrigger(axis, selector, tolerance, guide), + inspectIndex: { axis, show, ...(seriesBy ? { seriesBy } : {}) }, + }; +} + /** Touch equivalent of a context request. */ export function longPressTrigger(holdMs = 500): InteractionEventSource { return { type: 'element', gesture: 'long-press', holdMs }; diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts index 7d86075b..babd7fe9 100644 --- a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -1109,6 +1109,124 @@ export function tolerantInspectHits( return render(candidates); } +export interface IndexInspectAcquisition { + hits: RenderHit[]; + coordinate: number; + valueCoordinates: number[]; +} + +export function indexInspectAcquisition( + items: readonly any[], + point: PlotPoint, + axis: 'x' | 'y', + policy: { show: 'all' | { series: unknown }; seriesBy?: string }, + continuousIndex = false, + discreteCoordinates?: readonly number[], + assistDistance = 0, +): IndexInspectAcquisition { + const specificSeries = typeof policy.show === 'object' ? policy.show.series : undefined; + const eligibleItems = typeof policy.show === 'object' + ? items.filter((item) => policy.seriesBy + && Object.is(item?.datum?.[policy.seriesBy], specificSeries)) + : items; + + const itemAnchors = eligibleItems.flatMap((item) => { + const geometry = item?.interactionGeometry; + const points = geometry?.points as readonly PlotPoint[] | undefined; + if (geometry?.kind === 'segment' && points?.length && item.endDatum) { + return [ + { coordinate: axis === 'x' ? points[0].x : points[0].y }, + { coordinate: axis === 'x' ? points[points.length - 1].x : points[points.length - 1].y }, + ]; + } + if (!item?.bounds) return []; + return [{ + item, + coordinate: axis === 'x' + ? (item.bounds.x1 + item.bounds.x2) / 2 + : (item.bounds.y1 + item.bounds.y2) / 2, + }]; + }).filter((anchor) => Number.isFinite(anchor.coordinate)); + const anchors: { item?: any; coordinate: number }[] = !continuousIndex && discreteCoordinates?.length + ? discreteCoordinates.map((coordinate) => ({ coordinate })) + : itemAnchors; + const pointerCoordinate = axis === 'x' ? point.x : point.y; + if (anchors.length === 0) return { hits: [], coordinate: pointerCoordinate, valueCoordinates: [] }; + + const hitsAtCoordinate = (coordinate: number): RenderHit[] => { + const intersecting = axisIntersectingHits(eligibleItems, coordinate, axis); + if (intersecting.length > 0) return intersecting; + return eligibleItems.flatMap((item) => { + const points = item?.interactionGeometry?.points as readonly PlotPoint[] | undefined; + if (!points?.length) return []; + const endpoint = points[points.length - 1]; + const endpointCoordinate = axis === 'x' ? endpoint.x : endpoint.y; + const hit = Math.abs(endpointCoordinate - coordinate) <= 1e-6 ? renderHit(item) : null; + return hit ? [hit] : []; + }); + }; + + const anchor = anchors.reduce((best, candidate) => + Math.abs(candidate.coordinate - pointerCoordinate) < Math.abs(best.coordinate - pointerCoordinate) + ? candidate + : best); + const directHits = continuousIndex ? hitsAtCoordinate(pointerCoordinate) : []; + const anchorDistance = anchor.item?.bounds + ? (() => { + const start = axis === 'x' ? anchor.item.bounds.x1 : anchor.item.bounds.y1; + const end = axis === 'x' ? anchor.item.bounds.x2 : anchor.item.bounds.y2; + return pointerCoordinate < start ? start - pointerCoordinate + : pointerCoordinate > end ? pointerCoordinate - end : 0; + })() + : Math.abs(anchor.coordinate - pointerCoordinate); + if (continuousIndex && directHits.length === 0 && anchorDistance > assistDistance) { + return { hits: [], coordinate: pointerCoordinate, valueCoordinates: [] }; + } + const coordinate = directHits.length > 0 ? pointerCoordinate : anchor.coordinate; + const hits = directHits.length > 0 ? directHits : hitsAtCoordinate(coordinate); + + const hitKeys = new Set(hits.map((hit) => hit.datum[INTERACTION_KEY])); + let candidates = eligibleItems.filter((item) => { + const hit = renderHit(item); + return hit && hitKeys.has(hit.datum[INTERACTION_KEY]); + }); + if (candidates.some((item) => item.interactionGeometry)) { + candidates = candidates.filter((item) => item.interactionGeometry); + } + const finalKeys = new Set(hits.map((hit) => hit.datum[INTERACTION_KEY])); + const valueCoordinates = candidates.flatMap((item) => { + const hit = renderHit(item); + if (!hit || !finalKeys.has(hit.datum[INTERACTION_KEY])) return []; + const points = item.interactionGeometry?.points as readonly PlotPoint[] | undefined; + if (item.interactionGeometry?.kind === 'segment' && points && points.length >= 2) { + const start = points[0]; + const end = points[points.length - 1]; + const alongStart = axis === 'x' ? start.x : start.y; + const alongEnd = axis === 'x' ? end.x : end.y; + if (coordinate < Math.min(alongStart, alongEnd) || coordinate > Math.max(alongStart, alongEnd)) return []; + const ratio = alongEnd === alongStart ? 0 : (coordinate - alongStart) / (alongEnd - alongStart); + return [axis === 'x' + ? start.y + ratio * (end.y - start.y) + : start.x + ratio * (end.x - start.x)]; + } + if (!item.bounds) return []; + return [axis === 'x' + ? (item.bounds.y1 + item.bounds.y2) / 2 + : (item.bounds.x1 + item.bounds.x2) / 2]; + }).filter((value, index, values) => Number.isFinite(value) + && values.findIndex((candidate) => Math.abs(candidate - value) < 0.5) === index); + return { hits, coordinate, valueCoordinates }; +} + +export function indexInspectHits( + items: readonly any[], + point: PlotPoint, + axis: 'x' | 'y', + policy: { show: 'all' | { series: unknown }; seriesBy?: string }, +): RenderHit[] { + return indexInspectAcquisition(items, point, axis, policy).hits; +} + export function nearestItemOnInspectAxis( items: readonly any[], point: PlotPoint, diff --git a/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts index 436729f3..514b2e9a 100644 --- a/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts +++ b/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts @@ -13,6 +13,11 @@ export interface InspectGuideOverlay extends GestureGuideController { end: { x: number; y: number }, style: InspectGestureGuideStyle, ): void; + renderValueRules( + coordinates: readonly number[], + indexAxis: 'x' | 'y', + style: InspectGestureGuideStyle, + ): void; } export interface InspectGuideOverlayOptions { @@ -39,6 +44,7 @@ export function createInspectGuideOverlay({ const previousPosition = container.style.position; const line = document.createElement('div'); const crossLine = document.createElement('div'); + const valueLines: HTMLDivElement[] = []; const baseStyle = { position: 'absolute', display: 'none', zIndex: '4', pointerEvents: 'none', } as const; @@ -47,6 +53,11 @@ export function createInspectGuideOverlay({ if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; container.append(line, crossLine); + const haloShadow = (style: InspectGestureGuideStyle): string => + style.haloWidth > 0 && style.haloOpacity > 0 + ? `0 0 0 ${style.haloWidth}px color-mix(in srgb, ${style.haloColor} ${style.haloOpacity * 100}%, transparent)` + : 'none'; + const renderLine = ( element: HTMLDivElement, mode: 'x' | 'y', @@ -62,14 +73,15 @@ export function createInspectGuideOverlay({ const layoutSize = containerLayoutSize(); const start = clientToLayoutPoint(plotToClientPoint({ x: guide.x1, y: guide.y1 }, space), containerRect, layoutSize); const end = clientToLayoutPoint(plotToClientPoint({ x: guide.x2, y: guide.y2 }, space), containerRect, layoutSize); + const halo = haloShadow(style); Object.assign(element.style, mode === 'x' ? { display: 'block', left: `${start.x - style.width / 2}px`, top: `${start.y}px`, width: `${style.width}px`, height: `${end.y - start.y}px`, transform: 'none', - transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, + transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, boxShadow: halo, } : { display: 'block', left: `${start.x}px`, top: `${start.y - style.width / 2}px`, width: `${end.x - start.x}px`, height: `${style.width}px`, transform: 'none', - transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, + transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, boxShadow: halo, }); }; @@ -100,19 +112,44 @@ export function createInspectGuideOverlay({ display: 'block', left: `${start.x}px`, top: `${start.y - style.width / 2}px`, width: `${length}px`, height: `${style.width}px`, transformOrigin: '0 50%', transform: `rotate(${angle}rad)`, background: style.color, opacity: `${style.opacity}`, + boxShadow: haloShadow(style), + }); + }; + + const renderValueRules = ( + coordinates: readonly number[], + indexAxis: 'x' | 'y', + style: InspectGestureGuideStyle, + ): void => { + crossLine.style.display = 'none'; + while (valueLines.length < coordinates.length) { + const valueLine = document.createElement('div'); + Object.assign(valueLine.style, baseStyle); + valueLines.push(valueLine); + container.append(valueLine); + } + valueLines.forEach((valueLine, index) => { + if (index >= coordinates.length) { + valueLine.style.display = 'none'; + return; + } + renderLine(valueLine, indexAxis === 'x' ? 'y' : 'x', coordinates[index], style); }); }; return { renderAxes, renderSegment, + renderValueRules, clear(): void { line.style.display = 'none'; crossLine.style.display = 'none'; + valueLines.forEach((valueLine) => { valueLine.style.display = 'none'; }); }, destroy(): void { line.remove(); crossLine.remove(); + valueLines.forEach((valueLine) => valueLine.remove()); container.style.position = previousPosition; }, }; diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index 56c08280..5ee36bb7 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -55,6 +55,7 @@ import { polarGuideSegment, polarInspectHits, tolerantInspectHits, + indexInspectAcquisition, legendSemanticTarget, renderHit, rendererPlotOrigin, @@ -251,15 +252,51 @@ export function interactionsForHoverPresentation( clickInteractions: readonly CanvasInteractionDef[], hoverInteractions: readonly CanvasInteractionDef[], elementDragInteractions: readonly CanvasInteractionDef[] = [], + inspectInteractions: readonly CanvasInteractionDef[] = [], ): CanvasInteractionDef[] { return [ ...hoverInteractions, ...clickInteractions, ...elementDragInteractions, + ...inspectInteractions, ].filter((interaction, index, candidates) => interaction.affordances?.some((affordance) => affordance.hover) && candidates.findIndex((candidate) => candidate.id === interaction.id) === index); } +export function initialInspectSeries( + items: readonly any[], + seriesBy: string, + preferred?: unknown, +): unknown { + const values = items.flatMap((item) => item?.datum?.[seriesBy] === undefined + ? [] + : [item.datum[seriesBy]]); + return preferred !== undefined && values.some((value) => Object.is(value, preferred)) + ? preferred + : values[0]; +} + +export function inspectSeriesPresentationKeys( + items: readonly any[], + seriesBy: string, + series: unknown, +): string[] { + return [...new Set(items.flatMap((item) => { + if (!Object.is(item?.datum?.[seriesBy], series)) return []; + const hit = renderHit(item); + const key = hit?.datum?.[INTERACTION_KEY]; + return typeof key === 'string' ? [key] : []; + }))]; +} + +export function longPressMovedBeyond( + start: { x: number; y: number }, + current: { x: number; y: number }, + tolerance = 6, +): boolean { + return Math.hypot(current.x - start.x, current.y - start.y) > tolerance; +} + type AnnotationUpdate = Extract; export interface EffectiveAnnotationEntry { @@ -421,9 +458,10 @@ export function mountVegaInteractions( ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'drag-element') : []; const hoverPresentationInteractions = interactionsForHoverPresentation( - markClickInteractions, + [...markClickInteractions, ...longPressInteractions, ...doubleInteractions], markHoverInteractions, elementDragInteractions, + inspectInteractions, ); const hoverPresentationForTarget = (target: InteractionAffordanceTarget): CanvasInteractionDef[] => hoverPresentationInteractions.filter((interaction) => @@ -447,6 +485,8 @@ export function mountVegaInteractions( let hoveredPathKeys = new Set(); let suppressClick = false; let regionDragging = false; + const inspectSeriesLocks = new Map(); + const inspectSeriesPresentation = new Map>(); const containerLayoutSize = (): { width: number; height: number } => { const rect = container.getBoundingClientRect(); @@ -689,7 +729,7 @@ export function mountVegaInteractions( resolvedTargets += resolved.elements.length; return [resolved]; }); - if (targets.length > 0) ops.push({ ...op, targets }); + if (targets.length > 0 || op.targets.length === 0) ops.push({ ...op, targets }); } else if (op.op === 'set-annotation' && op.value !== null) { const target = resolveUpdateTarget(op.target); if (!target || target.elements.length !== 1) unresolvedTargets.push(op.target); @@ -736,6 +776,7 @@ export function mountVegaInteractions( const activeHiddenLegendDomains = new Set(); const stylesByKey: Record> = {}; + let emptyEmphasisActive = false; selectedElements.clear(); hiddenKeys.clear(); const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); @@ -771,6 +812,10 @@ export function mountVegaInteractions( } } if (op.op === 'set-style') { + if (op.targets.length === 0 + && (op.value.state === 'emphasized' || op.value.state === 'focused')) { + emptyEmphasisActive = true; + } for (const target of op.targets) { if ('select' in target) continue; for (const element of target.elements) { @@ -807,7 +852,9 @@ export function mountVegaInteractions( view.signal(STYLE_SIGNAL, stylesByKey); view.change( INTERACTION_STORE, - changeset().remove(() => true).insert(keys.map((key) => ({ key }))), + changeset().remove(() => true).insert(emptyEmphasisActive && keys.length === 0 + ? [{}] + : keys.map((key) => ({ key }))), ); view.change( HIDDEN_STORE, @@ -997,7 +1044,8 @@ export function mountVegaInteractions( legend: LegendHitIdentity | null = null, axis: { scale: string; value: unknown } | null = null, ): Promise => { - const next = [...new Set(keys)].sort(); + const tracked = [...inspectSeriesPresentation.values()].flatMap((seriesKeys) => [...seriesKeys]); + const next = [...new Set([...tracked, ...keys])].sort(); const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}` + `\u0001${axis?.scale ?? ''}\u0000${String(axis?.value ?? '')}`; if (signature === hoveredKeys) return; @@ -1024,6 +1072,31 @@ export function mountVegaInteractions( renderPathFocus(); renderLegendRange(); }; + const setTrackedInspectSeries = ( + interaction: CanvasInteractionDef, + series: unknown, + items = sceneItems(view), + ): void => { + const seriesBy = interaction.eventSource.inspectIndex?.seriesBy; + if (!seriesBy) return; + inspectSeriesLocks.set(interaction.id, series); + inspectSeriesPresentation.set( + interaction.id, + new Set(inspectSeriesPresentationKeys(items, seriesBy, series)), + ); + }; + for (const interaction of inspectInteractions) { + const policy = interaction.eventSource.inspectIndex; + if (!policy?.seriesBy || (policy.show !== 'single' && typeof policy.show !== 'object')) continue; + const items = sceneItems(view); + const preferred = typeof policy.show === 'object' ? policy.show.series : undefined; + setTrackedInspectSeries( + interaction, + initialInspectSeries(items, policy.seriesBy, preferred), + items, + ); + } + if (inspectSeriesPresentation.size > 0) void setHover([]); const clearHover = (): void => { if (hoverClearTimer !== undefined) { clearTimeout(hoverClearTimer); @@ -1180,8 +1253,12 @@ export function mountVegaInteractions( .flatMap(semanticElementRenderKeys)); }; + const singleSeriesInspectInteractions = inspectInteractions.filter((interaction) => { + const show = interaction.eventSource.inspectIndex?.show; + return show === 'single' || typeof show === 'object'; + }); const clickHandler = (event: MouseEvent, item: any): void => { - if (clickInteractions.length === 0 || suppressClick) return; + if ((clickInteractions.length === 0 && singleSeriesInspectInteractions.length === 0) || suppressClick) return; const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); const axisTarget = resolveAxisTarget(item); if (axisTarget) { @@ -1211,6 +1288,19 @@ export function mountVegaInteractions( modifiers: normalized.event.modifiers, }, legend); } + if (legend) { + for (const interaction of singleSeriesInspectInteractions) { + const policy = interaction.eventSource.inspectIndex!; + if (!policy.seriesBy || legend.field !== policy.seriesBy) continue; + setTrackedInspectSeries(interaction, legend.value); + void setHover([], legend); + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', target, point, + modifiers: normalized.event.modifiers, + }, legend); + inspectHandler(event); + } + } }; const contextHandler = (event: MouseEvent): void => { if (contextInteractions.length === 0) return; @@ -1258,7 +1348,45 @@ export function mountVegaInteractions( : items; const tolerance = interaction.eventSource.inspectTolerance ?? 0.01; const guide = interaction.eventSource.inspectGuide ?? normalizeInspectGuideOptions(undefined); - const hits = polarFrame + const indexPolicy = interaction.eventSource.inspectIndex; + const singleSeries = indexPolicy?.show === 'single' || typeof indexPolicy?.show === 'object'; + if (singleSeries && indexPolicy?.seriesBy && !inspectSeriesLocks.has(interaction.id)) { + const preferred = typeof indexPolicy.show === 'object' ? indexPolicy.show.series : undefined; + setTrackedInspectSeries( + interaction, + initialInspectSeries(eligibleItems, indexPolicy.seriesBy, preferred), + eligibleItems, + ); + void setHover([]); + } + const effectiveShow = singleSeries + ? { series: inspectSeriesLocks.get(interaction.id) } + : indexPolicy?.show; + const indexField = indexPolicy ? plan.axisFields?.[indexPolicy.axis] : undefined; + const continuousIndex = indexField?.type === 'temporal' || indexField?.type === 'quantitative'; + const indexScaleName = indexPolicy && indexField + ? Object.entries(plan.axisTargets ?? {}).find(([, target]) => + target.axis === indexPolicy.axis && target.field === indexField.field)?.[0] + : undefined; + const indexScale = indexScaleName ? view.scale(indexScaleName) : undefined; + const discreteCoordinates = !continuousIndex && indexScale?.domain + ? indexScale.domain().map((value: unknown) => + Number(indexScale(value)) + (Number(indexScale.bandwidth?.()) || 0) / 2) + : undefined; + const indexAcquisition = indexPolicy && !polarFrame + ? indexInspectAcquisition( + eligibleItems, + point, + indexPolicy.axis, + { show: effectiveShow as 'all' | { series: unknown }, seriesBy: indexPolicy.seriesBy }, + continuousIndex, + discreteCoordinates, + (indexPolicy.axis === 'x' ? space.plotWidth : space.plotHeight) * tolerance, + ) + : undefined; + const hits = indexAcquisition + ? indexAcquisition.hits + : polarFrame ? polarInspectHits(eligibleItems, point, polarFrame) : tolerantInspectHits( eligibleItems, @@ -1267,7 +1395,18 @@ export function mountVegaInteractions( activeMode.predicate, { x: space.plotWidth * tolerance, y: space.plotHeight * tolerance }, ); - if (guide.visible && polarFrame) { + if (guide.visible && indexAcquisition) { + const guidePoint = indexPolicy!.axis === 'x' + ? { x: indexAcquisition.coordinate, y: point.y } + : { x: point.x, y: indexAcquisition.coordinate }; + inspectGuideOverlay.renderAxes(guidePoint, indexPolicy!.axis, guide.style); + inspectGuideOverlay.renderValueRules( + indexAcquisition.valueCoordinates, + indexPolicy!.axis, + guide.style, + ); + guideRendered = true; + } else if (guide.visible && polarFrame) { const segment = polarGuideSegment(polarFrame, point); inspectGuideOverlay.renderSegment(segment.start, segment.end, guide.style); guideRendered = true; @@ -1337,6 +1476,7 @@ export function mountVegaInteractions( }; }; let longPressTimer: number | undefined; + let longPressPointer: { id: number; x: number; y: number } | undefined; const dismissPolicy = dismiss === false ? { click: false as const, escape: false } : { click: dismiss?.click ?? 'non-element' as const, escape: dismiss?.escape ?? true, @@ -1395,16 +1535,19 @@ export function mountVegaInteractions( } }; const cancelLongPress = (): void => { - if (longPressTimer === undefined) return; - window.clearTimeout(longPressTimer); + if (longPressTimer !== undefined) window.clearTimeout(longPressTimer); longPressTimer = undefined; + longPressPointer = undefined; }; const longPressStart = (event: PointerEvent): void => { if (longPressInteractions.length === 0 || event.button !== 0) return; + event.preventDefault(); cancelLongPress(); + longPressPointer = { id: event.pointerId, x: event.clientX, y: event.clientY }; const holdMs = longPressInteractions[0].eventSource.holdMs ?? 500; longPressTimer = window.setTimeout(() => { longPressTimer = undefined; + longPressPointer = undefined; const acquired = pointerTarget(event, longPressInteractions); if (!acquired.target) return; consumeDismissClick = true; @@ -1418,6 +1561,13 @@ export function mountVegaInteractions( } }, holdMs); }; + const longPressMove = (event: PointerEvent): void => { + if (!longPressPointer || event.pointerId !== longPressPointer.id) return; + if (longPressMovedBeyond( + longPressPointer, + { x: event.clientX, y: event.clientY }, + )) cancelLongPress(); + }; const doubleHandler = (event: MouseEvent): void => { if (doubleInteractions.length === 0) return; event.preventDefault(); @@ -1433,7 +1583,7 @@ export function mountVegaInteractions( if (longPressInteractions.length > 0) { container.addEventListener('pointerdown', longPressStart, true); container.addEventListener('pointerup', cancelLongPress, true); - container.addEventListener('pointermove', cancelLongPress, true); + container.addEventListener('pointermove', longPressMove, true); container.addEventListener('pointercancel', cancelLongPress, true); } if (doubleInteractions.length > 0) container.addEventListener('dblclick', doubleHandler); @@ -1441,7 +1591,7 @@ export function mountVegaInteractions( if (contextInteractions.length > 0) { container.addEventListener('contextmenu', contextHandler); } - if (clickInteractions.length > 0) { + if (clickInteractions.length > 0 || singleSeriesInspectInteractions.length > 0) { view.addEventListener('click', clickHandler); } if (hoverPresentationInteractions.length > 0) { @@ -1451,6 +1601,8 @@ export function mountVegaInteractions( const previousCursor = container.style.cursor; const previousUserSelect = container.style.userSelect; + const previousTouchAction = container.style.touchAction; + if (longPressInteractions.length > 0) container.style.touchAction = 'none'; const suppressTextSelection = doubleInteractions.length > 0 || canvasInteractions.some((interaction) => interaction.claimsLegendActivation); if (suppressTextSelection) container.style.userSelect = 'none'; @@ -1872,7 +2024,7 @@ export function mountVegaInteractions( observeRenderer(); const destroy = (): void => { - if (clickInteractions.length > 0) { + if (clickInteractions.length > 0 || singleSeriesInspectInteractions.length > 0) { view.removeEventListener('click', clickHandler); } if (hoverPresentationInteractions.length > 0) { @@ -1893,7 +2045,7 @@ export function mountVegaInteractions( cancelLongPress(); container.removeEventListener('pointerdown', longPressStart, true); container.removeEventListener('pointerup', cancelLongPress, true); - container.removeEventListener('pointermove', cancelLongPress, true); + container.removeEventListener('pointermove', longPressMove, true); container.removeEventListener('pointercancel', cancelLongPress, true); } if (doubleInteractions.length > 0) container.removeEventListener('dblclick', doubleHandler); @@ -1931,6 +2083,7 @@ export function mountVegaInteractions( syncFrame = undefined; } if (elementDragInteraction || suppressTextSelection) container.style.userSelect = previousUserSelect; + if (longPressInteractions.length > 0) container.style.touchAction = previousTouchAction; if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; }; const clearUpdate = async (id: string): Promise => { diff --git a/packages/flint-js/src/vegalite/templates/bullet.ts b/packages/flint-js/src/vegalite/templates/bullet.ts index c4495f02..b17089ab 100644 --- a/packages/flint-js/src/vegalite/templates/bullet.ts +++ b/packages/flint-js/src/vegalite/templates/bullet.ts @@ -5,7 +5,6 @@ import { ChartTemplateDef } from '../../core/types'; import { fieldsFromEncodingChannels, firstDiscreteEncodingField, - MUTED_HOVER_STROKE, resolveSeriesTarget, } from '../../core/interaction-semantics'; import { @@ -67,8 +66,10 @@ export const bulletChartDef: ChartTemplateDef = { legendFields: colorField || statusField ? { color: colorField ?? statusField! } : undefined, selectableMarks: ['bar', 'tick'], renderHoverStyles: { - bar: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, - tick: { strokeWidth: 5 }, + // Vega compiles a Vega-Lite bar to a rect. Treat that filled + // area like every other bar: preserve its colour, add no + // border, and distinguish it through opacity contrast. + rect: { opacity: 'contrast' }, }, resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), presentUpdate: presentAnnotationUpdate( diff --git a/packages/flint-js/tests/gantt-bullet.test.ts b/packages/flint-js/tests/gantt-bullet.test.ts index 000b2ae4..50fc6f51 100644 --- a/packages/flint-js/tests/gantt-bullet.test.ts +++ b/packages/flint-js/tests/gantt-bullet.test.ts @@ -6,6 +6,7 @@ import { assembleVegaLite, getChartOptions } from '../src'; import { coerceGanttEndpoint, formatGanttDuration, sortGanttRows } from '../src/chart-types/gantt'; import { genGanttTests, genBulletTests } from '../src/test-data'; import type { TestCase } from '../src/test-data/types'; +import { bulletChartDef } from '../src/vegalite/templates/bullet'; /** * Gantt and Bullet chart types. @@ -224,6 +225,20 @@ describe('Bullet chart', () => { expect(typeof step).toBe('number'); expect(tick.mark.size).toBeLessThanOrEqual(step); }); + + it('uses the rendered rect mark for a clear value-bar hover affordance', () => { + const semantics = bulletChartDef.semanticInteractions!({ + resolvedEncodings: { + y: { field: 'Country', type: 'nominal' }, + x: { field: 'Share', type: 'quantitative' }, + goal: { field: 'Target', type: 'quantitative' }, + }, + } as any); + expect(semantics.renderHoverStyles).toMatchObject({ + rect: { opacity: 'contrast' }, + }); + expect(semantics.renderHoverStyles).not.toHaveProperty('bar'); + }); }); describe('gallery examples compile', () => { diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index 451612d4..43ec1ade 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, doubleActivate, dragReorder, externalInteraction, facetBrushLink, hoverGroupFocus, inspect, lassoSelect, legendToggle, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, doubleActivate, dragReorder, externalInteraction, hoverGroupFocus, inspect, inspectIndex, lassoSelect, legendToggle, linkedBrush, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; import type { ClickHighlightOptions } from '../src/interactive/interactions'; import { affordanceCursor, resolveInteractionAffordance } from '../src/interactive/affordances'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; @@ -62,7 +62,10 @@ import { import { effectiveAnnotationEntries, evictRetainedStateSiblings, + initialInspectSeries, + inspectSeriesPresentationKeys, interactionsForHoverPresentation, + longPressMovedBeyond, domainForPlotGeometry, keyboardTargetItems, nearestReorderHit, @@ -383,9 +386,15 @@ describe('hover presentation policy', () => { const observer: InteractionDef = { id: 'click-observer', eventSource: clickTrigger }; const hover: InteractionDef = { id: 'hover-observer', eventSource: hoverTrigger }; const reorder = dragReorder(); - - expect(interactionsForHoverPresentation([preset, observer], [hover], [reorder]).map(({ id }) => id)) - .toEqual(['click-mark', 'drag-reorder']); + const indexReader = inspectIndex({ show: 'single', seriesBy: 'Series' }); + const sustained = longPress(); + const doubled = doubleActivate(); + + expect(interactionsForHoverPresentation( + [preset, observer, sustained, doubled], [hover], [reorder], [indexReader], + ).map(({ id }) => id)).toEqual([ + 'click-mark', 'long-press', 'double-activate', 'drag-reorder', 'inspect-index', + ]); }); it('expands group hover presentation to the committed cohort', () => { @@ -1385,8 +1394,8 @@ describe('interaction definitions', () => { }); }); - it('links a brushed semantic key across facet panels', () => { - const interaction = facetBrushLink({ by: 'Country' }); + it('broadcasts a brushed semantic group across available views', () => { + const interaction = linkedBrush({ groupBy: 'Country' }); const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [{ value: { Country: 'France', Source: 'Fossil' }, records: [{ Country: 'France', Source: 'Fossil', Share: 8 }] }], @@ -1396,9 +1405,9 @@ describe('interaction definitions', () => { selected: [], available: [ ...target.elements, - { value: { Country: 'France', Source: 'Nuclear' }, records: [{ Country: 'France', Source: 'Nuclear', Share: 65 }] }, - { value: { Country: 'France', Source: 'Renewables' }, records: [{ Country: 'France', Source: 'Renewables', Share: 27 }] }, - { value: { Country: 'Germany', Source: 'Fossil' }, records: [{ Country: 'Germany', Source: 'Fossil', Share: 45 }] }, + { value: { Country: 'France', View: 'detail' }, records: [{ Country: 'France', View: 'detail', Share: 65 }] }, + { value: { Country: 'France', View: 'summary' }, records: [{ Country: 'France', View: 'summary', Share: 27 }] }, + { value: { Country: 'Germany', View: 'detail' }, records: [{ Country: 'Germany', View: 'detail', Share: 45 }] }, ], }; @@ -1408,7 +1417,7 @@ describe('interaction definitions', () => { }); it('supports compound link keys and lasso acquisition', () => { - const interaction = facetBrushLink({ by: ['Country', 'Product'], brush: 'lasso' }); + const interaction = linkedBrush({ groupBy: ['Country', 'Product'], brush: 'lasso' }); const target = { visual: { kind: 'mark' as const, role: 'circle' }, elements: [{ value: {}, records: [{ Country: 'France', Product: 'A', Year: 2020 }] }], @@ -1437,7 +1446,7 @@ describe('interaction definitions', () => { }); it('keeps the local brush target when the link key is unavailable', () => { - const interaction = facetBrushLink({ by: 'Country' }); + const interaction = linkedBrush({ groupBy: 'Country' }); const target = { visual: { kind: 'mark' as const, role: 'circle' }, elements: [{ value: { X: 10 }, records: [{ X: 10, Y: 8.04 }] }], @@ -2836,15 +2845,84 @@ describe('legend, inspect, zoom, and touch presets', () => { expect(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'preview', target: null, }, inspectTrigger('y')).action).toBe('inspect-y'); + expect(inspect({ mode: 'x>=;y<=' }).handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: null, + }, inspectTrigger('x>=;y<=')), { chartType: 'Scatter Plot', selected: [] })) + .toEqual({ + id: 'inspect', + ops: [{ + op: 'set-style', targets: [], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); + }); + + it('declares all and legend-switchable single-series index inspection policies', () => { + expect(inspectIndex()).toMatchObject({ + id: 'inspect-index', + eventSource: { inspectIndex: { axis: 'x', show: 'all' } }, + }); + expect(inspectIndex().handle).toBeUndefined(); + const single = inspectIndex({ axis: 'y', show: 'single', seriesBy: 'Series', tolerance: 0.03 }); + expect(single.eventSource.inspectIndex).toEqual({ axis: 'y', show: 'single', seriesBy: 'Series' }); + expect(single.eventSource.inspectTolerance).toBe(0.03); + expect(single.affordances).toEqual([ + { target: 'legend-item', cursor: 'activate', hover: 'cohort' }, + ]); + expect(inspectIndex({ show: { series: 'Forecast' }, seriesBy: 'Series' }).eventSource.inspectIndex) + .toEqual({ axis: 'x', show: { series: 'Forecast' }, seriesBy: 'Series' }); + expect(() => inspectIndex({ show: 'single' })).toThrow('requires seriesBy'); + expect(() => inspectIndex({ show: { series: 'Forecast' } })) + .toThrow('requires seriesBy'); + }); + + it('starts single-series inspection from the first or preferred available series', () => { + const items = [ + { datum: { Series: 'Bananas' } }, + { datum: { Series: 'Eggs' } }, + { datum: { Series: 'Bananas' } }, + ]; + expect(initialInspectSeries(items, 'Series')).toBe('Bananas'); + expect(initialInspectSeries(items, 'Series', 'Eggs')).toBe('Eggs'); + expect(initialInspectSeries(items, 'Series', 'Missing')).toBe('Bananas'); + }); + + it('collects authored mark keys for the tracked series presentation', () => { + const mark = { marktype: 'symbol', role: 'mark' }; + const items = [ + { mark, datum: { [INTERACTION_KEY]: 'banana-1', Series: 'Bananas' } }, + { mark, datum: { [INTERACTION_KEY]: 'eggs-1', Series: 'Eggs' } }, + { mark, datum: { [INTERACTION_KEY]: 'banana-2', Series: 'Bananas' } }, + ]; + expect(inspectSeriesPresentationKeys(items, 'Series', 'Bananas')) + .toEqual(['banana-1', 'banana-2']); + }); + + it('tolerates small pointer jitter during a long press', () => { + expect(longPressMovedBeyond({ x: 10, y: 10 }, { x: 13, y: 14 })).toBe(false); + expect(longPressMovedBeyond({ x: 10, y: 10 }, { x: 17, y: 10 })).toBe(true); }); it('normalizes gesture guide visibility and renderer-neutral styles', () => { expect(normalizeInspectGuideOptions(false)).toMatchObject({ visible: false }); + expect(normalizeInspectGuideOptions(undefined)).toMatchObject({ + visible: true, + style: { + color: '#47525c', opacity: 0.58, width: 1, + haloColor: '#ffffff', haloOpacity: 0.64, haloWidth: 0.5, + }, + }); expect(normalizeInspectGuideOptions({ - style: { color: '#123456', opacity: 2, width: 2, fillOpacity: -1 }, + style: { + color: '#123456', opacity: 2, width: 2, fillOpacity: -1, + haloColor: '#abcdef', haloOpacity: 2, haloWidth: 0, + }, })).toEqual({ visible: true, - style: { color: '#123456', opacity: 1, width: 2, fillOpacity: 0 }, + style: { + color: '#123456', opacity: 1, width: 2, fillOpacity: 0, + haloColor: '#abcdef', haloOpacity: 1, haloWidth: 0, + }, }); expect(normalizeRegionGuideOptions({ style: { fillOpacity: -1, strokeOpacity: 2, strokeWidth: 3 }, @@ -2977,6 +3055,12 @@ describe('legend, inspect, zoom, and touch presets', () => { elements: [{ value: { category: 'A' } }], }; expect(longPress({ holdMs: 250 }).eventSource).toMatchObject({ gesture: 'long-press', holdMs: 250 }); + expect(longPress().affordances).toEqual([ + { target: 'mark', cursor: 'activate', hover: 'target' }, + ]); + expect(doubleActivate().affordances).toEqual([ + { target: 'mark', cursor: 'activate', hover: 'target' }, + ]); expect(longPress().handle!(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'commit', target, }, longPressTrigger()), context)?.ops[0]).toMatchObject({ diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index 908ec446..ce460a74 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -47,6 +47,8 @@ import { INTERACTION_LEGEND_CHANNEL, INTERACTION_LEGEND_FIELD, INTERACTION_ROLE, + indexInspectAcquisition, + indexInspectHits, PATH_KEY_SUFFIX, physicalItemAt, plotToClientPoint, @@ -353,6 +355,78 @@ describe('Vega-Lite semantic interactions', () => { expect(new Set(hits.map((hit) => hit.datum.Segment))).toEqual(new Set(['Consumer', 'Corporate'])); view.finalize(); }); + + it('index inspection can keep all or one named series', () => { + const item = (key: string, series: string, y: number) => ({ + bounds: { x1: 48, x2: 52, y1: y - 2, y2: y + 2 }, + datum: { [INTERACTION_KEY]: key, Series: series }, + mark: { marktype: 'symbol', role: 'mark' }, + }); + const items = [item('alpha', 'Alpha', 20), item('beta', 'Beta', 80)]; + const point = { x: 50, y: 76 }; + expect(indexInspectHits(items, point, 'x', { show: 'all', seriesBy: 'Series' })) + .toHaveLength(2); + expect(indexInspectHits(items, point, 'x', { + show: { series: 'Alpha' }, seriesBy: 'Series', + })[0].datum.Series).toBe('Alpha'); + }); + + it('interpolates a smooth continuous value-axis rule between line observations', () => { + const segment = { + bounds: { x1: 10, x2: 90, y1: 20, y2: 80 }, + datum: { [INTERACTION_KEY]: 'alpha', Index: 0, Series: 'Alpha' }, + endDatum: { [INTERACTION_KEY]: 'alpha', Index: 10, Series: 'Alpha' }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 10, y: 80 }, { x: 90, y: 20 }], + }, + mark: { marktype: 'line', role: 'mark', items: [] }, + }; + const acquisition = indexInspectAcquisition( + [segment], { x: 50, y: 40 }, 'x', { show: 'all', seriesBy: 'Series' }, true, + ); + + expect(acquisition.coordinate).toBe(50); + expect(acquisition.valueCoordinates).toEqual([50]); + expect(indexInspectAcquisition( + [segment], { x: 90, y: 20 }, 'x', { show: 'all', seriesBy: 'Series' }, true, + )).toMatchObject({ coordinate: 90, valueCoordinates: [20], hits: [expect.any(Object)] }); + }); + + it('snaps a discrete index to supplied band-scale centers', () => { + const segment = { + bounds: { x1: 10, x2: 90, y1: 20, y2: 80 }, + datum: { [INTERACTION_KEY]: 'alpha', Series: 'Alpha' }, + endDatum: { [INTERACTION_KEY]: 'alpha', Series: 'Alpha' }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 10, y: 80 }, { x: 90, y: 20 }], + }, + mark: { marktype: 'line', role: 'mark', items: [] }, + }; + const acquisition = indexInspectAcquisition( + [segment], { x: 38, y: 50 }, 'x', { show: 'all' }, false, [20, 60], + ); + + expect(acquisition.coordinate).toBe(20); + }); + + it('assists nearby continuous point indices without acquiring distant points', () => { + const pointMark = { + bounds: { x1: 48, x2: 52, y1: 28, y2: 32 }, + datum: { [INTERACTION_KEY]: 'alpha', Index: 50, Value: 30 }, + mark: { marktype: 'symbol', role: 'mark' }, + }; + const nearby = indexInspectAcquisition( + [pointMark], { x: 57, y: 80 }, 'x', { show: 'all' }, true, undefined, 5, + ); + const distant = indexInspectAcquisition( + [pointMark], { x: 58, y: 80 }, 'x', { show: 'all' }, true, undefined, 5, + ); + + expect(nearby).toMatchObject({ coordinate: 50, valueCoordinates: [30], hits: [expect.any(Object)] }); + expect(distant).toEqual({ coordinate: 58, valueCoordinates: [], hits: [] }); + }); it('keeps path fallback connections anchored to the selected segment midpoint', () => { const item = { bounds: { x1: 56, y1: 52, x2: 196, y2: 220 }, diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index a60ff68b..d48b1668 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -1,6 +1,6 @@ import { Fragment, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Crosshair, EyeOff, GripVertical, Keyboard, Lasso, Layers3, Link2, Menu, MessageSquareText, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; +import { EyeOff, GripVertical, Keyboard, Lasso, Layers3, Link2, Menu, MessageSquareText, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; import { genBarTests, @@ -11,6 +11,7 @@ import { } from 'flint-chart/test-data'; import { buildInteractiveChart, + brushAngle, brushX, brushY, brushZoom, @@ -20,18 +21,21 @@ import { contextActivate, doubleActivate, dragReorder, - facetBrushLink, hoverGroupFocus, inspect, + inspectIndex, lassoSelect, legendToggle, + linkedBrush, longPress, navigate, select as rectangleSelect, type FlintInteractionEventDetail, + type InspectIndexShow, } from 'flint-chart/interactive'; import { expressionInterpreter } from 'vega-interpreter'; import { ScaleToFit } from '../components/ScaleToFit'; +import foodPrices from '../data/cpi-food-prices.json'; import { BACKENDS } from '../shared/supported-backends'; import { testCaseToAssemblyInput } from '../shared/test-case-utils'; import { ThemePicker } from './ThemePicker'; @@ -40,11 +44,12 @@ import { gapminderRows } from './gapminder-dashboard-data'; import './click-focus-lab.css'; export type InteractionMode = 'click-highlight' | 'click-group-focus' | 'annotate' | 'select' - | 'facet-link' | 'hover-group-focus' - | 'brush-x' | 'brush-y' | 'brush-x-stateful' | 'brush-y-stateful' | 'navigate' | 'drag-reorder' - | 'lasso' | 'inspect' | 'inspect-quadrant' | 'inspect-x' - | 'long-press' | 'double-activate' - | 'keyboard-focus' | 'select-context' | 'focus-legend-toggle' | 'focus-brush-zoom'; + | 'linked-brush' | 'hover-group-focus' + | 'brush-x' | 'brush-y' | 'brush-angle' | 'brush-x-stateful' | 'brush-y-stateful' | 'brush-angle-stateful' + | 'navigate' | 'drag-reorder' + | 'lasso' | 'inspect' | 'inspect-index' + | 'long-press' | 'double-activate' | 'legend-toggle' | 'brush-zoom' + | 'keyboard-focus' | 'select-context'; type ProbeStatus = 'loading' | 'ready' | 'unsupported' | 'error'; export interface NavigationGuard { @@ -59,26 +64,27 @@ const unitInteractionModes = [ { value: 'hover-group-focus', label: 'Hover group focus', icon: Target }, { value: 'annotate', label: 'Annotate', icon: MessageSquareText }, { value: 'select', label: 'Select', icon: Scan }, - { value: 'facet-link', label: 'Facet link', icon: Link2 }, + { value: 'linked-brush', label: 'Linked brush', icon: Link2 }, { value: 'brush-x', label: 'X brush', icon: MoveHorizontal }, { value: 'brush-y', label: 'Y brush', icon: MoveVertical }, + { value: 'brush-angle', label: 'Angle brush', icon: RotateCcw }, { value: 'brush-x-stateful', label: 'X brush (edit)', icon: MoveHorizontal }, { value: 'brush-y-stateful', label: 'Y brush (edit)', icon: MoveVertical }, + { value: 'brush-angle-stateful', label: 'Angle brush (edit)', icon: RotateCcw }, { value: 'navigate', label: 'Pan & zoom', icon: Move }, { value: 'drag-reorder', label: 'Drag reorder', icon: GripVertical }, { value: 'lasso', label: 'Lasso', icon: Lasso }, { value: 'inspect', label: 'Inspect xy', icon: Target }, - { value: 'inspect-quadrant', label: 'Inspect quadrant', icon: Crosshair }, - { value: 'inspect-x', label: 'Inspect x', icon: Ruler }, + { value: 'inspect-index', label: 'Inspect index', icon: Ruler }, { value: 'long-press', label: 'Long press', icon: Timer }, { value: 'double-activate', label: 'Double click', icon: MousePointerClick }, + { value: 'legend-toggle', label: 'Legend toggle', icon: EyeOff }, + { value: 'brush-zoom', label: 'Brush zoom', icon: ZoomIn }, ] as const; const compositionInteractionModes = [ { value: 'keyboard-focus', label: 'Focus + keyboard', icon: Keyboard }, { value: 'select-context', label: 'Select + context', icon: Menu }, - { value: 'focus-legend-toggle', label: 'Focus + legend toggle', icon: EyeOff }, - { value: 'focus-brush-zoom', label: 'Focus + brush zoom', icon: ZoomIn }, ] as const; type MountedInteraction = ReturnType; @@ -90,34 +96,32 @@ function modeInteractions( mode: InteractionMode, navigationAxes: 'x' | 'y' | 'xy' | undefined, navigationGuard: NavigationGuard | undefined, - linkBy: string | readonly string[] | undefined, + groupBy: string | readonly string[] | undefined, + indexInspection: InteractionCase['indexInspection'], ): MountedInteraction[] { switch (mode) { case 'click-highlight': return [clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] })]; - case 'click-group-focus': return [clickGroupFocus({ groupBy: typeof linkBy === 'string' ? linkBy : undefined })]; - case 'hover-group-focus': return linkBy ? [hoverGroupFocus({ groupBy: linkBy })] : []; + case 'click-group-focus': return [clickGroupFocus({ groupBy })]; + case 'hover-group-focus': return groupBy ? [hoverGroupFocus({ groupBy })] : []; case 'annotate': return [clickAnnotate()]; case 'select': return [rectangleSelect()]; - case 'facet-link': return linkBy ? [facetBrushLink({ by: linkBy })] : []; + case 'linked-brush': return groupBy ? [linkedBrush({ groupBy })] : []; case 'brush-x': return [brushX()]; case 'brush-y': return [brushY()]; + case 'brush-angle': return [brushAngle()]; case 'brush-x-stateful': return [brushX({ mode: 'stateful' })]; case 'brush-y-stateful': return [brushY({ mode: 'stateful' })]; + case 'brush-angle-stateful': return [brushAngle({ mode: 'stateful' })]; case 'drag-reorder': return [dragReorder()]; case 'lasso': return [lassoSelect()]; case 'inspect': return [inspect()]; - case 'inspect-quadrant': return [inspect({ - mode: 'x>=;y<=', - cycle: ['x>=;y<=', 'x>=;y>=', 'x<=;y>=', 'x<=;y<='], - dimOpacity: 0.14, - })]; - case 'inspect-x': return [inspect({ mode: 'x' })]; + case 'inspect-index': return indexInspection ? [inspectIndex(indexInspection)] : []; case 'keyboard-focus': return [clickHighlight({ targets: ['mark'] })]; case 'select-context': return [rectangleSelect(), contextActivate()]; - case 'focus-legend-toggle': return [clickHighlight({ targets: ['mark'] }), legendToggle()]; + case 'legend-toggle': return [legendToggle()]; case 'long-press': return [longPress()]; case 'double-activate': return [doubleActivate()]; - case 'focus-brush-zoom': return [clickHighlight({ targets: ['mark'] }), brushZoom()]; + case 'brush-zoom': return [brushZoom()]; default: return [navigate({ axes: navigationAxes ?? 'available', domainGuard: navigationGuard })]; } } @@ -125,8 +129,18 @@ function modeInteractions( export interface InteractionCase { id: string; title?: string; + wide?: boolean; + spacious?: boolean; + stageHeight?: number; + stageScale?: number; input: ChartAssemblyInput; - linkBy?: string | readonly string[]; + groupBy?: string | readonly string[]; + indexInspection?: { + axis?: 'x' | 'y'; + show?: InspectIndexShow; + seriesBy?: string; + tolerance?: number; + }; navigationAxes?: 'x' | 'y' | 'xy'; chartType: string; expectation: string; @@ -134,6 +148,13 @@ export interface InteractionCase { const SIZE = { width: 350, height: 240 }; +function blsFoodPriceSeries(items: readonly string[]): Record[] { + const selected = new Set(items); + return foodPrices.values + .filter(({ item }) => selected.has(item)) + .map(({ month: Index, price: Value, item: Series }) => ({ Index, Value, Series })); +} + function representative(generator: () => TestCase[]): TestCase { const cases = generator(); return cases.find((test) => test.tags?.includes('real') && !test.encodingMap.column?.fieldID && !test.encodingMap.row?.fieldID) @@ -246,10 +267,133 @@ function multiLegendCase(kind: 'shape' | 'size'): InteractionCase { }, } as ChartAssemblyInput, chartType: 'Scatter Plot', - expectation: selected.expectation, + groupBy: selected.color, + expectation: `Interact with marks grouped by ${selected.color.toLowerCase()}.`, }; } +function indexInspectCases(): InteractionCase[] { + const makeCase = ( + id: string, + title: string, + values: Record[], + expectation: string, + show: InspectIndexShow, + indexType: 'Year' | 'Date' | 'Category', + seriesBy: string | undefined = 'Series', + ): InteractionCase => ({ + id, + title, + chartType: 'Line Chart', + expectation, + ...(seriesBy ? { groupBy: seriesBy } : {}), + indexInspection: { axis: 'x', show, ...(seriesBy ? { seriesBy } : {}) }, + input: { + data: { values }, + semantic_types: { Index: indexType, Value: 'Currency', Series: 'Category' }, + field_display_names: { Index: 'Month', Value: 'Average price (USD)', Series: 'Food' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { + x: { field: 'Index' }, y: { field: 'Value' }, + ...(seriesBy ? { color: { field: seriesBy } } : {}), + }, + baseSize: SIZE, + }, + }, + }); + + const makeScatterCase = ( + id: string, + title: string, + values: Record[], + expectation: string, + show: InspectIndexShow, + xField: string, + yField: string, + semanticTypes: Record, + seriesBy?: string, + ): InteractionCase => ({ + id, + title, + chartType: 'Scatter Plot', + expectation, + ...(seriesBy ? { groupBy: seriesBy } : {}), + indexInspection: { axis: 'x', show, ...(seriesBy ? { seriesBy } : {}), tolerance: 0.025 }, + input: { + data: { values }, + semantic_types: semanticTypes, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { + x: { field: xField }, y: { field: yField }, + ...(seriesBy ? { color: { field: seriesBy } } : {}), + }, + baseSize: SIZE, + }, + }, + }); + + return [ + makeCase( + 'inspect-index-line-single', + 'BLS food prices — single line', + blsFoodPriceSeries(['Bananas']), + 'Move along time to inspect the nearest monthly U.S. average banana price from the Bureau of Labor Statistics.', + 'all', 'Date', undefined, + ), + makeCase( + 'inspect-index-line-two', + 'BLS food prices — two lines', + blsFoodPriceSeries(['Eggs', 'White bread']), + 'Tracking starts on White bread. Click Eggs or White bread in the legend to switch the tracked series.', + { series: 'White bread' }, 'Date', + ), + makeCase( + 'inspect-index-line-two-all', + 'BLS food prices — read both lines', + blsFoodPriceSeries(['Eggs', 'White bread']), + 'Move along time to read both foods at once; each series keeps its own horizontal value guide.', + 'all', 'Date', + ), + makeCase( + 'inspect-index-line-multi', + 'BLS food prices — track one series', + blsFoodPriceSeries(['Bananas', 'Eggs', 'Ground beef', 'White bread', 'Whole milk']), + 'Tracking starts on the first food. Click a legend item to switch the tracked series.', + 'single', 'Date', + ), + makeScatterCase( + 'inspect-index-scatter-near-x', + 'Gapminder 2007 — assisted income inspection', + gapminderRows.filter((row) => row.Year === 2007), + 'Move onto or just beside GDP per capita on x to inspect the observed life-expectancy point.', + 'all', + 'GDP per capita', + 'Life expectancy', + { + Country: 'Country', Continent: 'Category', Year: 'Year', Population: 'Quantity', + 'GDP per capita': 'Quantity', 'Life expectancy': 'Quantity', + }, + 'Continent', + ), + makeScatterCase( + 'inspect-index-scatter-shared-x', + 'Gapminder — country life expectancy by year', + gapminderRows.filter((row) => ['Argentina', 'Egypt', 'Japan'].includes(row.Country)), + 'Tracking starts on the first country. Click the legend to switch countries.', + 'single', + 'Year', + 'Life expectancy', + { + Country: 'Country', Continent: 'Category', Year: 'Year', Population: 'Quantity', + 'GDP per capita': 'Quantity', 'Life expectancy': 'Quantity', + }, + 'Country', + ), + ]; +} + function realFacetedCases(): InteractionCase[] { const electricityMix = genStackedBarTests().find((test) => test.tags?.includes('real') && test.title.includes('Electricity generation mix')); @@ -272,13 +416,14 @@ function realFacetedCases(): InteractionCase[] { { ...barCase, title: 'Electricity generation mix — faceted by source', - linkBy: 'Country', + groupBy: 'Country', + wide: true, }, { id: 'Scatter Plot-Gapminder-faceted-years', chartType: 'Scatter Plot', title: 'Gapminder — linked countries across 1952 and 2007', - linkBy: 'Country', + groupBy: 'Country', expectation: 'Brush countries in either year to highlight the same countries in both panels (Gapminder).', input: { semantic_types: { @@ -313,13 +458,17 @@ function realFacetedCases(): InteractionCase[] { encodingMap: { ...titanicEncodings, row: sexFacet }, }, '-faceted'), title: 'Titanic survival — row facets by sex', - linkBy: 'Class', + groupBy: 'Class', }, { id: 'Scatter Plot-Gapminder-faceted-four-years', chartType: 'Scatter Plot', title: 'Gapminder — continents across four years', - linkBy: 'Continent', + groupBy: 'Continent', + wide: true, + spacious: true, + stageHeight: 540, + stageScale: 1.25, expectation: 'Brush a point to link every country in its continent across all four year panels.', input: { semantic_types: { @@ -342,7 +491,11 @@ function realFacetedCases(): InteractionCase[] { id: 'Scatter Plot-Gapminder-faceted-correlation-grid', chartType: 'Scatter Plot', title: 'Gapminder — 4×4 country correlation grid', - linkBy: 'Country', + groupBy: 'Country', + wide: true, + spacious: true, + stageHeight: 820, + stageScale: 1.15, expectation: 'Brush a country to link it through four years within its continent row.', input: { semantic_types: { @@ -371,6 +524,7 @@ const interactionCases: InteractionCase[] = [ ...representativeCases(), multiLegendCase('shape'), multiLegendCase('size'), + ...indexInspectCases(), ...realFacetedCases(), ]; @@ -403,6 +557,8 @@ const navigationCases: InteractionCase[] = navigationDemoCases.map((item) => ({ chartType: item.input.chart_spec.chartType, })); +const polarBrushCases = new Set(['Pie Chart', 'Donut Chart', 'Rose Chart']); + const navigationAxesByCase = new Map([...interactionCases, ...navigationCases].flatMap((item) => { const spec = assembleVegaLite(item.input) as any; const axes = spec._interactionSemantics?.navigationAxes as readonly ('x' | 'y')[] | undefined; @@ -415,21 +571,6 @@ const reorderAxesByCase = new Map(interactionCases.flatMap((item) => { return axes?.length ? [[item.id, axes] as const] : []; })); -function hasContinuousInspectAxes(spec: any): boolean { - if (!spec || typeof spec !== 'object') return false; - const xType = spec.encoding?.x?.type; - const yType = spec.encoding?.y?.type; - if ((xType === 'quantitative' || xType === 'temporal') && yType === 'quantitative') return true; - const children = ['layer', 'hconcat', 'vconcat', 'concat'] - .flatMap((property) => Array.isArray(spec[property]) ? spec[property] : []); - return [...children, spec.spec].some((child) => hasContinuousInspectAxes(child)); -} - -const inspectQuadrantCases = new Set(interactionCases.flatMap((item) => { - const spec = assembleVegaLite(item.input) as any; - return hasContinuousInspectAxes(spec) ? [item.id] : []; -})); - function hasDiscreteLegendChannel(spec: any, channel: string, field: string): boolean { if (!spec || typeof spec !== 'object') return false; const encoding = spec.encoding?.[channel]; @@ -576,7 +717,8 @@ function InteractiveChart({ themeId, navigationGuard, navigationAxes, - linkBy, + groupBy, + indexInspection, resetVersion, onStatus, onSemanticEvent, @@ -586,7 +728,8 @@ function InteractiveChart({ themeId: string | undefined; navigationGuard: NavigationGuard; navigationAxes?: 'x' | 'y' | 'xy'; - linkBy?: string | readonly string[]; + groupBy?: string | readonly string[]; + indexInspection?: InteractionCase['indexInspection']; resetVersion: number; onStatus: (status: ProbeStatus, message?: string) => void; onSemanticEvent: (detail: FlintInteractionEventDetail) => void; @@ -628,7 +771,7 @@ function InteractiveChart({ }; container.addEventListener('contextmenu', captureContextPoint, true); container.addEventListener('flint-interaction', handleInteraction); - const interactions = modeInteractions(mode, navigationAxes, navigationGuard, linkBy); + const interactions = modeInteractions(mode, navigationAxes, navigationGuard, groupBy, indexInspection); const themedInput = themeId ? { ...input, theme_spec: themeId } : input; const surface = buildInteractiveChart(container, themedInput, { backend: 'vegalite', @@ -655,7 +798,7 @@ function InteractiveChart({ setComment(null); surface.destroy(); }; - }, [input, linkBy, mode, navigationAxes, navigationGuard, resetVersion, themeId]); + }, [groupBy, input, mode, navigationAxes, navigationGuard, resetVersion, themeId]); const menuTarget = contextMenu?.detail.event.target ?? null; const menuElement = menuTarget?.elements[0]; @@ -766,7 +909,7 @@ export function CaseCard({ const geometry = lastInteraction ? summarizeGeometry(lastInteraction.event) : undefined; const responded = resolved || lastInteraction?.event.action.endsWith('-viewport'); return ( -
    +

    {title}

    @@ -779,14 +922,21 @@ export function CaseCard({
    - + { setStatus(nextStatus); @@ -844,15 +994,17 @@ export function ClickFocusLab() { overscrollFraction: 0, }); const [resetVersion, setResetVersion] = useState(0); - const visibleCases = mode === 'navigate' || mode === 'focus-brush-zoom' + const visibleCases = mode === 'navigate' || mode === 'brush-zoom' ? navigationCases.filter((item) => navigationAxesByCase.has(item.id)) : mode === 'drag-reorder' ? interactionCases.filter((item) => reorderAxesByCase.has(item.id)) - : mode === 'inspect-quadrant' - ? interactionCases.filter((item) => inspectQuadrantCases.has(item.id)) - : mode === 'facet-link' || mode === 'hover-group-focus' - ? interactionCases.filter((item) => item.linkBy) - : mode === 'focus-legend-toggle' + : mode === 'inspect-index' + ? interactionCases.filter((item) => item.indexInspection) + : mode === 'brush-angle' || mode === 'brush-angle-stateful' + ? interactionCases.filter((item) => polarBrushCases.has(item.chartType)) + : mode === 'linked-brush' || mode === 'hover-group-focus' + ? interactionCases.filter((item) => item.groupBy) + : mode === 'legend-toggle' ? interactionCases.filter((item) => discreteLegendCases.has(item.id)) : interactionCases; @@ -888,9 +1040,10 @@ export function ClickFocusLab() {
  • Hover group focus: Hover a mark to preview matching semantic keys without changing retained state.
  • Annotate: Click a mark to search nearby free space and connect its represented value.
  • Select: Drag a rectangle to focus all marks within an area.
  • -
  • Facet link: Brush marks in one panel to highlight matching semantic keys across panels.
  • +
  • Linked brush: Brush marks to highlight matching semantic groups across available views.
  • X brush: Drag across an X interval; polar charts automatically use an angular sector.
  • Y brush: Drag vertically to focus marks across a Y interval.
  • +
  • Angle brush: Drag an angular sector across a pie, donut, or rose chart.
  • Stateful brush: Move the committed interval, resize either edge, or click outside to clear it.
  • Pan & zoom: Drag continuous axes to pan; use the wheel, trackpad, or a two-finger pinch to zoom.
  • Context menu: Select marks or open a mark menu, then let the host application provide contextual actions.
  • diff --git a/site/src/playground/click-focus-lab.css b/site/src/playground/click-focus-lab.css index 859572c5..a404fe58 100644 --- a/site/src/playground/click-focus-lab.css +++ b/site/src/playground/click-focus-lab.css @@ -112,6 +112,15 @@ background: #fff; } +.cf-probe-wide { + grid-column: 1 / -1; +} + +.cf-probe-spacious { + justify-self: center; + width: min(1200px, calc(100vw - 48px)); +} + .cf-probe-header { display: flex; align-items: flex-start; @@ -407,6 +416,10 @@ .cf-grid { grid-template-columns: minmax(0, 1fr); } + + .cf-probe-spacious { + width: 100%; + } } @media (max-width: 520px) { From 3163c9c7207433312eaa91612f58613e18b53cea Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Wed, 2 Sep 2026 23:52:02 -0700 Subject: [PATCH 28/33] updates --- .../src/core/interaction-contracts.ts | 50 ++ packages/flint-js/src/interactive/README.md | 2 +- packages/flint-js/src/interactive/index.ts | 6 + .../flint-js/src/interactive/interactions.ts | 4 + .../src/interactive/language/events.ts | 28 +- .../src/interactive/language/geometry.ts | 23 + .../src/interactive/language/projections.ts | 17 + .../src/interactive/language/updates.ts | 4 + .../src/interactive/presets/README.md | 2 +- packages/flint-js/src/interactive/triggers.ts | 9 + .../flint-js/src/vegalite/instantiate-spec.ts | 13 +- .../src/vegalite/interactions/compile.ts | 9 +- .../src/vegalite/interactions/contracts.ts | 5 + .../vegalite/interactions/gestures/region.ts | 16 +- .../src/vegalite/interactions/hit-adapter.ts | 120 +++- .../interactions/presentation/data-overlay.ts | 299 ++++++++++ .../src/vegalite/interactions/runtime.ts | 98 +++- packages/flint-js/src/vegalite/interactive.ts | 15 + .../src/vegalite/templates/kpi-card.ts | 52 +- .../flint-js/src/vegalite/templates/radar.ts | 1 + packages/flint-js/tests/data-overlay.test.ts | 42 ++ packages/flint-js/tests/interactions.test.ts | 9 + packages/flint-js/tests/kpi-card.test.ts | 37 ++ packages/flint-js/tests/maps.test.ts | 20 + packages/flint-js/tests/point-size.test.ts | 21 + .../tests/semantic-interactions.test.ts | 139 ++++- packages/flint-mcp/ui/src/styles.css | 4 +- site/src/components/GalleryOptionsBar.tsx | 7 +- site/src/components/SiteRange.tsx | 35 ++ site/src/components/SizingPlayground.tsx | 10 +- site/src/global.css | 12 +- site/src/main.tsx | 2 + site/src/playground/BandExpansionFigure.tsx | 8 +- site/src/playground/BandStretchingLab.tsx | 10 +- site/src/playground/BespokeInteractionLab.tsx | 78 +++ site/src/playground/ClickFocusLab.tsx | 11 +- site/src/playground/ClimatePhaseStage.tsx | 370 ++++++++++++ site/src/playground/ColorDecisionFigure.tsx | 8 +- site/src/playground/ExplodedDetailStage.tsx | 312 ++++++++++ site/src/playground/FisheyeZoomStage.tsx | 353 ++++++++++++ site/src/playground/FlintDimpVisStage.tsx | 545 +++++++++--------- .../playground/InteractionDashboardLab.tsx | 4 +- site/src/playground/PlaygroundShell.tsx | 27 +- .../playground/bespoke-interaction-lab.css | 115 ++++ site/src/playground/climate-phase-data.ts | 50 ++ site/src/playground/exploded-detail-stage.css | 79 +++ site/src/playground/fisheye-zoom-stage.css | 84 +++ .../src/playground/interaction-candidates.css | 3 +- 48 files changed, 2797 insertions(+), 371 deletions(-) create mode 100644 packages/flint-js/src/interactive/language/geometry.ts create mode 100644 packages/flint-js/src/interactive/language/projections.ts create mode 100644 packages/flint-js/src/vegalite/interactions/presentation/data-overlay.ts create mode 100644 packages/flint-js/tests/data-overlay.test.ts create mode 100644 packages/flint-js/tests/kpi-card.test.ts create mode 100644 site/src/components/SiteRange.tsx create mode 100644 site/src/playground/BespokeInteractionLab.tsx create mode 100644 site/src/playground/ClimatePhaseStage.tsx create mode 100644 site/src/playground/ExplodedDetailStage.tsx create mode 100644 site/src/playground/FisheyeZoomStage.tsx create mode 100644 site/src/playground/bespoke-interaction-lab.css create mode 100644 site/src/playground/climate-phase-data.ts create mode 100644 site/src/playground/exploded-detail-stage.css create mode 100644 site/src/playground/fisheye-zoom-stage.css diff --git a/packages/flint-js/src/core/interaction-contracts.ts b/packages/flint-js/src/core/interaction-contracts.ts index a4826854..e38e04d6 100644 --- a/packages/flint-js/src/core/interaction-contracts.ts +++ b/packages/flint-js/src/core/interaction-contracts.ts @@ -130,6 +130,46 @@ export interface StyleSpec { mutedOpacity?: number; } +export interface OverlayStyleSpec { + fill?: string; + fillOpacity?: number; + stroke?: string; + strokeWidth?: number; + strokeDash?: readonly number[]; + opacity?: number; + pointRadius?: number; + fontSize?: number; + fontWeight?: number | 'normal' | 'bold'; + textAlign?: 'start' | 'middle' | 'end'; + dx?: number; + dy?: number; +} + +export type OverlayMark = 'line' | 'point' | 'rule' | 'rect' | 'text'; + +export interface OverlayFieldEncoding { + field: string; +} + +/** A retained visual projected through an existing plot's scales. */ +export interface ChartOverlaySpec { + mark: OverlayMark; + data: { values: readonly Record[] }; + encodings: { + x: OverlayFieldEncoding; + y: OverlayFieldEncoding; + x2?: OverlayFieldEncoding; + y2?: OverlayFieldEncoding; + order?: OverlayFieldEncoding; + color?: OverlayFieldEncoding; + text?: OverlayFieldEncoding; + }; + role: string; + interactive?: boolean; + projectable?: boolean; + style?: OverlayStyleSpec; +} + export type ChartUpdateOp = | { op: 'set-style'; @@ -151,6 +191,16 @@ export type ChartUpdateOp = scope: 'category' | 'series' | 'facet'; field: string; values: readonly unknown[]; + } + | { + op: 'set-overlay'; + name: string; + value: ChartOverlaySpec | null; + } + | { + op: 'set-data'; + source: 'main'; + value: { rows: readonly Record[] }; }; export interface ChartUpdate { diff --git a/packages/flint-js/src/interactive/README.md b/packages/flint-js/src/interactive/README.md index a4f493a8..e6f70979 100644 --- a/packages/flint-js/src/interactive/README.md +++ b/packages/flint-js/src/interactive/README.md @@ -571,7 +571,7 @@ The corresponding `brushX()` and `brushY()` presets apply semantic emphasis to t `angularBrushTrigger()` emits an annular-sector region centered on a rendered polar chart. Pointer angles use the renderer's convention: zero is 12 o'clock and positive angles proceed clockwise. The runtime unwraps pointer motion continuously, so a drag can cross the $0/2\pi$ seam or proceed counterclockwise without jumping to the complementary sector. -The corresponding `brushAngle()` preset is accepted only when the owning ChartDef declares angular-region support. Pie, donut, and rose charts opt in; Cartesian ChartDefs reject the interaction during planning. Arc intersection and containment are evaluated from rendered `startAngle`, `endAngle`, `innerRadius`, and `outerRadius` geometry, while the existing ChartDef resolver retains ownership of semantic identity. +The corresponding `brushAngle()` preset is accepted only when the owning ChartDef declares angular-region support. Pie, donut, rose, and radar charts opt in; Cartesian ChartDefs reject the interaction during planning. Arc intersection and containment use rendered `startAngle`, `endAngle`, `innerRadius`, and `outerRadius` geometry. Radar line segments and points are tested against the same rendered sector, while the existing ChartDef resolver retains ownership of semantic identity. Brushes support two lifecycle modes: diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 1f4355d5..04146f45 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -39,6 +39,7 @@ export type { AnnotationCandidate, AnnotationConnection, AnnotationSpec, + ChartOverlaySpec, ChartUpdate, ChartUpdateOp, ChartUpdatePresenter, @@ -73,6 +74,9 @@ export type { PlotAngularSector, PlotPolygon, PlotRect, + OverlayFieldEncoding, + OverlayMark, + OverlayStyleSpec, RegionAxis, RegionOperation, RenderHit, @@ -89,6 +93,7 @@ export type { CanvasInteractionEvent, DomainCoordinate, DomainGeometry, + PathProjection, PlotGeometry, } from './language/events'; export { toCanvasInteractionEvent } from './canvas-interaction'; @@ -103,6 +108,7 @@ export type { InspectIndexShow, InteractionEventSource } from './triggers'; export { axisBrushTrigger, angularBrushTrigger, + dragTrigger, brushZoomTrigger, clickTrigger, contextTrigger, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 2bf47b63..661e9f5d 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -83,8 +83,12 @@ export type { AnnotationConnection, AnnotationConnectorAnchor, AnnotationSpec, + ChartOverlaySpec, ChartUpdate, ChartUpdateOp, + OverlayFieldEncoding, + OverlayMark, + OverlayStyleSpec, StyleSpec, SemanticTargetRef, SemanticTargetSelector, diff --git a/packages/flint-js/src/interactive/language/events.ts b/packages/flint-js/src/interactive/language/events.ts index eaa36fb6..a6e5bf3a 100644 --- a/packages/flint-js/src/interactive/language/events.ts +++ b/packages/flint-js/src/interactive/language/events.ts @@ -1,28 +1,9 @@ import type { RenderHit, SemanticTarget } from '../../core/interaction-semantics'; +import type { PlotAngularSector, PlotPoint, PlotPolygon, PlotRect } from './geometry'; +import type { VisualProjection } from './projections'; -export interface PlotPoint { - x: number; - y: number; -} - -export interface PlotRect { - x: number; - y: number; - width: number; - height: number; -} - -export interface PlotPolygon { - points: readonly PlotPoint[]; -} - -export interface PlotAngularSector { - center: PlotPoint; - innerRadius: number; - outerRadius: number; - startAngle: number; - endAngle: number; -} +export type { PlotAngularSector, PlotPoint, PlotPolygon, PlotRect } from './geometry'; +export type { PathProjection, VisualProjection } from './projections'; export interface InteractionModifiers { shift: boolean; @@ -152,6 +133,7 @@ export interface CanvasInteractionEvent { geometry: { plot?: PlotGeometry; domain?: DomainGeometry; + projection?: VisualProjection; }; target: SemanticTarget | null; dropTarget?: SemanticTarget | null; diff --git a/packages/flint-js/src/interactive/language/geometry.ts b/packages/flint-js/src/interactive/language/geometry.ts new file mode 100644 index 00000000..1dbd1b10 --- /dev/null +++ b/packages/flint-js/src/interactive/language/geometry.ts @@ -0,0 +1,23 @@ +export interface PlotPoint { + x: number; + y: number; +} + +export interface PlotRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PlotPolygon { + points: readonly PlotPoint[]; +} + +export interface PlotAngularSector { + center: PlotPoint; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/projections.ts b/packages/flint-js/src/interactive/language/projections.ts new file mode 100644 index 00000000..c9f1c211 --- /dev/null +++ b/packages/flint-js/src/interactive/language/projections.ts @@ -0,0 +1,17 @@ +import type { SemanticElement } from '../../core/interaction-semantics'; +import type { PlotPoint } from './geometry'; + +/** Projection supplied by a rendered path visual for a freeform pointer position. */ +export interface PathProjection { + kind: 'path'; + point: PlotPoint; + distance: number; + segment: { + start: SemanticElement; + end: SemanticElement; + t: number; + }; +} + +/** Backend-neutral projection supplied by the visual acquired for a gesture. */ +export type VisualProjection = PathProjection; \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/updates.ts b/packages/flint-js/src/interactive/language/updates.ts index 1ba4a249..336bb1bc 100644 --- a/packages/flint-js/src/interactive/language/updates.ts +++ b/packages/flint-js/src/interactive/language/updates.ts @@ -11,6 +11,10 @@ export type { AnnotationSpec, ChartUpdate, ChartUpdateOp, + ChartOverlaySpec, + OverlayFieldEncoding, + OverlayMark, + OverlayStyleSpec, StyleSpec, SemanticTargetRef, SemanticTargetSelector, diff --git a/packages/flint-js/src/interactive/presets/README.md b/packages/flint-js/src/interactive/presets/README.md index 1aa079a2..e82a3f4f 100644 --- a/packages/flint-js/src/interactive/presets/README.md +++ b/packages/flint-js/src/interactive/presets/README.md @@ -50,7 +50,7 @@ semantic runtime. Current presets consume the public canvas event shape and prod public update JSON; target resolution and backend application remain internal. Other interactive backends and additional appearance/visibility operations remain future work. -Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, and rose. All three produce the same semantic `set-style` operation after the owning ChartDef resolves physical hits. +Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, rose, and radar. Angular brushing resolves arc slices as well as Radar line segments and points contained by or intersecting the sector. All three produce the same semantic `set-style` operation after the owning ChartDef resolves physical hits. ## Emphasis Behavior diff --git a/packages/flint-js/src/interactive/triggers.ts b/packages/flint-js/src/interactive/triggers.ts index 6d1193cf..de3e55c3 100644 --- a/packages/flint-js/src/interactive/triggers.ts +++ b/packages/flint-js/src/interactive/triggers.ts @@ -51,6 +51,15 @@ export function elementDragTrigger(): InteractionEventSource { return { type: 'reorder', gesture: 'drag-element' }; } +/** Freeform pointer drag locked to the semantic visual acquired at pointer-down. */ +export function dragTrigger(targetTolerance = 12): InteractionEventSource { + return { + type: 'element', + gesture: 'drag-element', + targetTolerance: Math.max(0, targetTolerance), + }; +} + export const clickTrigger = Object.freeze({ type: 'element', gesture: 'click', diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts index 6e70a9f5..ffce5380 100644 --- a/packages/flint-js/src/vegalite/instantiate-spec.ts +++ b/packages/flint-js/src/vegalite/instantiate-spec.ts @@ -811,7 +811,16 @@ function vlApplyFieldContext( // Example: individual percentages range 20–50% (no snap), but // they sum to ~100% per group → should snap to 100%. let skipDomain = false; - let effectiveDomainConstraint = cs.domainConstraint; + // Size and color scales must not silently re-normalize when a + // mounted chart replaces its rows. An explicit intrinsic domain is + // the author's stable reference frame for both symbol area and + // quantitative color (including a diverging scale's center). + const declaredScaleDomain = ch === 'size' || ch === 'color' + ? cs.semanticAnnotation?.intrinsicDomain + : undefined; + let effectiveDomainConstraint = declaredScaleDomain + ? { min: declaredScaleDomain[0], max: declaredScaleDomain[1], clamp: true } + : cs.domainConstraint; if (isSumStacked) { // Use explicit intrinsicDomain from annotation, or infer from @@ -873,7 +882,7 @@ function vlApplyFieldContext( } } - if (effectiveDomainConstraint && enc.type === 'quantitative' && (ch === 'x' || ch === 'y') && !enc.bin && !skipDomain) { + if (effectiveDomainConstraint && enc.type === 'quantitative' && (ch === 'x' || ch === 'y' || ch === 'size' || ch === 'color') && !enc.bin && !skipDomain) { if (!enc.scale) enc.scale = {}; let { min } = effectiveDomainConstraint; const { max, clamp } = effectiveDomainConstraint; diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index 5b3cf157..83aa1770 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -281,7 +281,11 @@ function pinChannelDomain( values.sort((left, right) => (left as any) < (right as any) ? -1 : (left as any) > (right as any) ? 1 : 0); if (sort === 'descending') values.reverse(); } - encoding.scale = { ...(encoding.scale ?? {}), domain: values }; + const continuous = encoding.type === 'quantitative' || encoding.type === 'temporal'; + const domain = continuous && values.length > 1 + ? [values[0], values[values.length - 1]] + : values; + encoding.scale = { ...(encoding.scale ?? {}), domain }; } for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { if (!Array.isArray(spec[property])) continue; @@ -373,7 +377,8 @@ export function addVegaLiteInteractions( const templateSemantics = spec._interactionSemantics as TemplateInteractionSemantics | undefined; delete spec._interactionSemantics; const reorderInteraction = canvasInteractions.find( - (interaction) => interaction.eventSource.gesture === 'drag-element', + (interaction) => interaction.eventSource.gesture === 'drag-element' + && interaction.eventSource.type === 'reorder', ); if (!templateSemantics) { if (reorderInteraction) { diff --git a/packages/flint-js/src/vegalite/interactions/contracts.ts b/packages/flint-js/src/vegalite/interactions/contracts.ts index 90eed99d..42e3d549 100644 --- a/packages/flint-js/src/vegalite/interactions/contracts.ts +++ b/packages/flint-js/src/vegalite/interactions/contracts.ts @@ -77,6 +77,11 @@ export interface VegaInteractionPlan { /** Polar templates realize the primary X brush as an angular sector. */ angularXBrush?: boolean; navigationAxes?: Partial>; + /** Unambiguous existing Cartesian scales available to external overlays. */ + overlayScales?: Partial>; + /** Mutable compiled inline source used by `set-data`. */ + mutableDataSource?: string; + initialDataRows?: readonly Record[]; reorderAxis?: VegaReorderAxis; reorderAxes?: readonly VegaReorderAxis[]; resolve?: ChartInteractionResolver; diff --git a/packages/flint-js/src/vegalite/interactions/gestures/region.ts b/packages/flint-js/src/vegalite/interactions/gestures/region.ts index b3158303..68a2f106 100644 --- a/packages/flint-js/src/vegalite/interactions/gestures/region.ts +++ b/packages/flint-js/src/vegalite/interactions/gestures/region.ts @@ -29,6 +29,7 @@ import { normalizeVegaAngularRegionEvent, normalizeVegaLassoEvent, normalizeVegaRegionEvent, + polarFrameFromRadarGrid, plotToClientPoint, sceneItems, type RendererCoordinateSpace, @@ -236,7 +237,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR const points = intervalPoints(interval, intervalAxis()); showRegion(points.start, points.end); }; - const frameAt = (point: PlotPoint): PolarFrame | undefined => { + const frameAt = (point: PlotPoint, plotFrame: PlotFrame): PolarFrame => { const frames = new Map(); for (const item of sceneItems(view)) { if (item.mark?.marktype !== 'arc' || typeof item.x !== 'number' || typeof item.y !== 'number' @@ -253,9 +254,17 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR outerRadius: item.outerRadius, }); } - return [...frames.values()].sort((left, right) => + const arcFrame = [...frames.values()].sort((left, right) => Math.hypot(point.x - left.center.x, point.y - left.center.y) - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; + return arcFrame ?? polarFrameFromRadarGrid(view, point) ?? { + center: { + x: plotFrame.x + plotFrame.width / 2, + y: plotFrame.y + plotFrame.height / 2, + }, + innerRadius: 0, + outerRadius: Math.min(plotFrame.width, plotFrame.height) / 2, + }; }; const showAngularSector = (sector: PlotAngularSector): void => { if (!guide.visible) return; @@ -382,8 +391,7 @@ export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaR const point = localPoint(event); const candidateFrame = facetPlotFrameAt(view, point, rootPlotFrame()); if (angularBrush) { - const frame = frameAt(point); - if (!frame) return; + const frame = frameAt(point, candidateFrame); angularSession = new AngularRegionSession(point, frame); angularAction = 'create'; initialSector = undefined; diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts index babd7fe9..8ca17a27 100644 --- a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -386,6 +386,70 @@ export function arcIntersectsAngularSector( )); } +function pointInSector(point: PlotPoint, sector: PlotAngularSector): boolean { + const dx = point.x - sector.center.x; + const dy = point.y - sector.center.y; + const radius = Math.hypot(dx, dy); + if (radius < sector.innerRadius - 1e-9 || radius > sector.outerRadius + 1e-9) return false; + const angle = ((Math.atan2(dx, -dy) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + return angularSegments(sector.startAngle, sector.endAngle) + .some(([start, end]) => angle >= start - 1e-9 && angle <= end + 1e-9); +} + +function angularSectorPolygon(sector: PlotAngularSector): PlotPoint[] { + const sweep = Math.max(-2 * Math.PI, Math.min(2 * Math.PI, sector.endAngle - sector.startAngle)); + const steps = Math.max(8, Math.ceil(Math.abs(sweep) * sector.outerRadius / 4)); + const pointAt = (radius: number, angle: number): PlotPoint => ({ + x: sector.center.x + radius * Math.sin(angle), + y: sector.center.y - radius * Math.cos(angle), + }); + const points: PlotPoint[] = []; + for (let index = 0; index <= steps; index += 1) { + points.push(pointAt(sector.outerRadius, sector.startAngle + sweep * index / steps)); + } + if (sector.innerRadius <= 0) { + points.push(sector.center); + } else { + for (let index = steps; index >= 0; index -= 1) { + points.push(pointAt(sector.innerRadius, sector.startAngle + sweep * index / steps)); + } + } + return points; +} + +export function pathIntersectsAngularSector( + points: readonly PlotPoint[], + sector: PlotAngularSector, + contain = false, +): boolean { + if (points.length === 0) return false; + if (contain) { + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]; + const end = points[index + 1]; + const steps = Math.max(1, Math.ceil(Math.hypot(end.x - start.x, end.y - start.y) / 4)); + for (let step = 0; step <= steps; step += 1) { + const fraction = step / steps; + if (!pointInSector({ + x: start.x + (end.x - start.x) * fraction, + y: start.y + (end.y - start.y) * fraction, + }, sector)) return false; + } + } + return points.length === 1 ? pointInSector(points[0], sector) : true; + } + if (points.some((point) => pointInSector(point, sector))) return true; + const boundary = angularSectorPolygon(sector); + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]; + const end = points[index + 1]; + for (let edge = 0; edge < boundary.length; edge += 1) { + if (segmentsIntersect(start, end, boundary[edge], boundary[(edge + 1) % boundary.length])) return true; + } + } + return false; +} + export function polarFrameFromItems( items: readonly any[], point?: PlotPoint, @@ -413,6 +477,44 @@ export function polarFrameFromItems( - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; } +/** Infer each rendered Radar frame from its grid spokes, excluding labels and legends. */ +export function polarFrameFromRadarGrid( + view: any, + point?: PlotPoint, +): { center: PlotPoint; innerRadius: number; outerRadius: number } | undefined { + const frames = new Map(); + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (item.mark?.marktype === 'rule' && item.datum?.__type === 'spoke' + && [item.x, item.y, item.x2, item.y2].every( + (value) => typeof value === 'number' && Number.isFinite(value), + )) { + const center = { x: item.x + offsetX, y: item.y + offsetY }; + const end = { x: item.x2 + offsetX, y: item.y2 + offsetY }; + const key = `${center.x}\u0000${center.y}`; + const outerRadius = Math.hypot(end.x - center.x, end.y - center.y); + const existing = frames.get(key); + frames.set(key, { + center, + innerRadius: 0, + outerRadius: Math.max(existing?.outerRadius ?? 0, outerRadius), + }); + } + if (Array.isArray(item.items)) { + for (const child of item.items) visit(child, childOffsetX, childOffsetY); + } + }; + visit(view.scenegraph()?.root, 0, 0); + const available = [...frames.values()].filter((frame) => frame.outerRadius > 0); + if (!point) return available[0]; + return available.sort((left, right) => + Math.hypot(point.x - left.center.x, point.y - left.center.y) + - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; +} + export function polarGuideSegment( frame: { center: PlotPoint; outerRadius: number }, point: PlotPoint, @@ -453,7 +555,17 @@ export function angularRegionHits( contain = false, ): RenderHit[] { return sceneItems(view) - .filter((item) => arcIntersectsAngularSector(item, sector, contain)) + .filter((item) => { + if (arcIntersectsAngularSector(item, sector, contain)) return true; + const markType = item?.mark?.marktype; + if (markType === 'symbol' && typeof item.x === 'number' && typeof item.y === 'number') { + return pointInSector({ x: item.x, y: item.y }, sector); + } + const points = item?.interactionGeometry?.points as readonly PlotPoint[] | undefined; + return markType === 'line' && points + ? pathIntersectsAngularSector(points, sector, contain) + : false; + }) .map(renderHit) .filter((hit): hit is RenderHit => hit !== null); } @@ -500,7 +612,11 @@ export function renderHit(item: any): RenderHit | null { export function physicalItemAt(view: any, item: any, point: PlotPoint): any { const pathItems = item?.mark?.marktype === 'line' || item?.mark?.marktype === 'area' - ? sceneItems(view).filter((candidate) => candidate.mark === item.mark && candidate.interactionGeometry) + ? sceneItems(view).filter((candidate) => + candidate.interactionGeometry + && candidate.mark?.marktype === item.mark.marktype + && (candidate.mark === item.mark + || (item.mark?.name && candidate.mark?.name === item.mark.name))) : []; if (item?.mark?.marktype === 'area') { return pathItems.find((candidate) => pointInPolygon(point, candidate.interactionGeometry.points)); diff --git a/packages/flint-js/src/vegalite/interactions/presentation/data-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/data-overlay.ts new file mode 100644 index 00000000..4ff55cb2 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/data-overlay.ts @@ -0,0 +1,299 @@ +import type { ChartOverlaySpec } from '../../../core/interaction-contracts'; +import type { SemanticTarget } from '../../../core/interaction-semantics'; +import type { PlotPoint } from '../../../interactive/language/geometry'; +import type { PathProjection } from '../../../interactive/language/projections'; +import type { RendererCoordinateSpace } from '../hit-adapter'; + +export interface DataOverlayController { + render(overlays: ReadonlyMap): void; + targetForElement(element: EventTarget | null): { name: string; target: SemanticTarget } | null; + targetAt(point: PlotPoint, maxDistance: number): { name: string; target: SemanticTarget } | null; + project(name: string, point: PlotPoint): PathProjection | undefined; + sync(): void; + destroy(): void; +} + +export interface DataOverlayOptions { + view: any; + container: HTMLElement; + scales: Partial>; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function orderedOverlayRows(spec: ChartOverlaySpec): readonly Record[] { + const rows = [...spec.data.values]; + const field = spec.encodings.order?.field; + if (!field) return rows; + return rows.sort((left, right) => { + const a = left[field]; + const b = right[field]; + if (typeof a === 'number' && typeof b === 'number') return a - b; + return String(a ?? '').localeCompare(String(b ?? '')); + }); +} + +export function projectPointToPath( + point: PlotPoint, + vertices: readonly { point: PlotPoint; record: Record }[], +): PathProjection | undefined { + let nearest: PathProjection | undefined; + for (let index = 0; index < vertices.length - 1; index += 1) { + const start = vertices[index]; + const end = vertices[index + 1]; + const dx = end.point.x - start.point.x; + const dy = end.point.y - start.point.y; + const lengthSquared = dx * dx + dy * dy; + const rawT = lengthSquared > 0 + ? ((point.x - start.point.x) * dx + (point.y - start.point.y) * dy) / lengthSquared + : 0; + const t = Math.max(0, Math.min(1, rawT)); + const projected = { x: start.point.x + dx * t, y: start.point.y + dy * t }; + const distance = Math.hypot(point.x - projected.x, point.y - projected.y); + if (nearest && nearest.distance <= distance) continue; + nearest = { + kind: 'path', + point: projected, + distance, + segment: { + start: { value: start.record, records: [start.record] }, + end: { value: end.record, records: [end.record] }, + t, + }, + }; + } + return nearest; +} + +/** + * Renders data overlays in a sibling SVG plane. It never mutates the assembled + * Vega/Vega-Lite mark tree, so template scale resolution and composition remain intact. + */ +export function createDataOverlay({ + view, + container, + scales, + coordinateSpace, + containerLayoutSize, +}: DataOverlayOptions): DataOverlayController { + const layer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + let current = new Map(); + const projectedVertices = new Map }[]>(); + const targetFor = ( + name: string, + spec: ChartOverlaySpec, + records: readonly Record[], + ): { name: string; target: SemanticTarget } => ({ + name, + target: { + visual: { kind: spec.mark === 'line' ? 'path' : 'mark', role: spec.role }, + elements: [{ value: { overlay: name }, records }], + }, + }); + Object.assign(layer.style, { + position: 'absolute', inset: '0', zIndex: '4', width: '100%', height: '100%', + pointerEvents: 'none', overflow: 'hidden', + }); + + const draw = (): void => { + layer.replaceChildren(); + projectedVertices.clear(); + if (current.size === 0 || !scales.x || !scales.y) { + layer.remove(); + return; + } + const xScale = view.scale(scales.x); + const yScale = view.scale(scales.y); + const colorScale = scales.color ? view.scale(scales.color) : undefined; + if (typeof xScale !== 'function' || typeof yScale !== 'function') { + layer.remove(); + return; + } + const space = coordinateSpace(); + const renderer = container.querySelector('canvas, svg') as HTMLElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const size = containerLayoutSize(); + const scaleX = containerRect.width > 0 ? size.width / containerRect.width : 1; + const scaleY = containerRect.height > 0 ? size.height / containerRect.height : 1; + Object.assign(layer.style, { + inset: 'auto', + left: `${(rendererRect.left - containerRect.left) * scaleX}px`, + top: `${(rendererRect.top - containerRect.top) * scaleY}px`, + width: `${rendererRect.width * scaleX}px`, + height: `${rendererRect.height * scaleY}px`, + }); + layer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + + for (const [name, spec] of current) { + const rows = orderedOverlayRows(spec); + const projected = (row: Record, xField: string, yField: string) => { + const x = xScale(row[xField]); + const y = yScale(row[yField]); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; + }; + const points = rows.flatMap((row) => { + const x = xScale(row[spec.encodings.x.field]); + const y = yScale(row[spec.encodings.y.field]); + return Number.isFinite(x) && Number.isFinite(y) + ? [{ x: x + space.originX, y: y + space.originY }] + : []; + }); + if (points.length === 0) continue; + const identify = (element: SVGElement, rowIndex?: number): void => { + element.setAttribute('data-flint-overlay', name); + element.setAttribute('data-flint-role', spec.role); + if (rowIndex !== undefined) element.setAttribute('data-flint-row', String(rowIndex)); + element.setAttribute('opacity', String(spec.style?.opacity ?? 1)); + // Overlay marks stay click-through. Gesture acquisition uses + // targetAt() against rendered geometry, so the underlying chart + // retains authoritative click/hover semantics. + element.style.pointerEvents = 'none'; + }; + + if (spec.mark === 'line') { + const vertices = rows.flatMap((row) => { + const point = projected(row, spec.encodings.x.field, spec.encodings.y.field); + return point ? [{ point, record: row }] : []; + }); + projectedVertices.set(name, vertices); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + identify(path); + path.setAttribute('d', points.map((point, index) => + `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ')); + path.setAttribute('fill', spec.style?.fill ?? 'none'); + path.setAttribute('fill-opacity', String(spec.style?.fillOpacity ?? 1)); + const colorValue = spec.encodings.color + ? colorScale?.(rows[0]?.[spec.encodings.color.field]) + : undefined; + path.setAttribute('stroke', spec.style?.stroke ?? colorValue ?? '#4c78a8'); + path.setAttribute('stroke-width', String(spec.style?.strokeWidth ?? 2)); + if (spec.style?.strokeDash?.length) { + path.setAttribute('stroke-dasharray', spec.style.strokeDash.join(' ')); + } + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('vector-effect', 'non-scaling-stroke'); + layer.append(path); + continue; + } + + rows.forEach((row, rowIndex) => { + const point = projected(row, spec.encodings.x.field, spec.encodings.y.field); + if (!point) return; + const x = point.x + space.originX; + const y = point.y + space.originY; + if (spec.mark === 'point') { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + identify(circle, rowIndex); + circle.setAttribute('cx', String(x)); + circle.setAttribute('cy', String(y)); + circle.setAttribute('r', String(spec.style?.pointRadius ?? 4)); + circle.setAttribute('fill', spec.style?.fill ?? '#4c78a8'); + circle.setAttribute('fill-opacity', String(spec.style?.fillOpacity ?? 1)); + if (spec.style?.stroke) circle.setAttribute('stroke', spec.style.stroke); + if (spec.style?.strokeWidth !== undefined) circle.setAttribute('stroke-width', String(spec.style.strokeWidth)); + layer.append(circle); + return; + } + if (spec.mark === 'text') { + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + identify(text, rowIndex); + text.setAttribute('x', String(x + (spec.style?.dx ?? 0))); + text.setAttribute('y', String(y + (spec.style?.dy ?? 0))); + text.setAttribute('text-anchor', spec.style?.textAlign ?? 'middle'); + text.setAttribute('font-size', String(spec.style?.fontSize ?? 11)); + text.setAttribute('font-weight', String(spec.style?.fontWeight ?? 'normal')); + const colorValue = spec.encodings.color + ? colorScale?.(row[spec.encodings.color.field]) + : undefined; + text.setAttribute('fill', spec.style?.fill ?? colorValue ?? '#333333'); + text.textContent = String(spec.encodings.text ? row[spec.encodings.text.field] ?? '' : ''); + layer.append(text); + return; + } + + const endPoint = spec.encodings.x2 && spec.encodings.y2 + ? projected(row, spec.encodings.x2.field, spec.encodings.y2.field) + : undefined; + if (!endPoint) return; + const x2 = endPoint.x + space.originX; + const y2 = endPoint.y + space.originY; + if (spec.mark === 'rule') { + const rule = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + identify(rule, rowIndex); + rule.setAttribute('x1', String(x)); + rule.setAttribute('y1', String(y)); + rule.setAttribute('x2', String(x2)); + rule.setAttribute('y2', String(y2)); + rule.setAttribute('stroke', spec.style?.stroke ?? '#4c78a8'); + rule.setAttribute('stroke-width', String(spec.style?.strokeWidth ?? 1)); + layer.append(rule); + return; + } + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + identify(rect, rowIndex); + rect.setAttribute('x', String(Math.min(x, x2))); + rect.setAttribute('y', String(Math.min(y, y2))); + rect.setAttribute('width', String(Math.abs(x2 - x))); + rect.setAttribute('height', String(Math.abs(y2 - y))); + rect.setAttribute('fill', spec.style?.fill ?? '#4c78a8'); + rect.setAttribute('fill-opacity', String(spec.style?.fillOpacity ?? 0.2)); + if (spec.style?.stroke) rect.setAttribute('stroke', spec.style.stroke); + if (spec.style?.strokeWidth !== undefined) rect.setAttribute('stroke-width', String(spec.style.strokeWidth)); + layer.append(rect); + }); + } + if (layer.childElementCount === 0) layer.remove(); + else { + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + } + }; + + return { + render(overlays) { + current = new Map(overlays); + draw(); + }, + targetForElement(element) { + if (!element || typeof (element as Element).getAttribute !== 'function') return null; + const visual = element as Element; + const name = visual.getAttribute('data-flint-overlay') ?? undefined; + const spec = name ? current.get(name) : undefined; + if (!name || !spec?.interactive) return null; + const rows = orderedOverlayRows(spec); + const rowIndex = Number(visual.getAttribute('data-flint-row')); + const records = Number.isInteger(rowIndex) && rowIndex >= 0 && rowIndex < rows.length + ? [rows[rowIndex]] + : rows; + return targetFor(name, spec, records); + }, + targetAt(point, maxDistance) { + let nearest: { name: string; spec: ChartOverlaySpec; distance: number } | undefined; + for (const [name, spec] of current) { + if (!spec.interactive || spec.mark !== 'line') continue; + const projection = projectPointToPath(point, projectedVertices.get(name) ?? []); + if (!projection || projection.distance > maxDistance) continue; + if (!nearest || projection.distance < nearest.distance) { + nearest = { name, spec, distance: projection.distance }; + } + } + return nearest + ? targetFor(nearest.name, nearest.spec, orderedOverlayRows(nearest.spec)) + : null; + }, + project(name, point) { + const spec = current.get(name); + return spec?.mark === 'line' && spec.projectable + ? projectPointToPath(point, projectedVertices.get(name) ?? []) + : undefined; + }, + sync: draw, + destroy() { + current.clear(); + projectedVertices.clear(); + layer.remove(); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index 5ee36bb7..581c660d 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -9,6 +9,7 @@ import { } from '../../core/interaction-semantics'; import type { CanvasInteractionDef, + ChartOverlaySpec, ChartUpdate, ChartUpdateOp, ChartUpdatePresenter, @@ -79,6 +80,7 @@ import { createLegendRangeOverlay } from './presentation/legend-range-overlay'; import { createReorderResetControls } from './presentation/reorder-reset-controls'; import { createViewportResetControl } from './presentation/viewport-reset-control'; import { createInspectGuideOverlay } from './presentation/inspect-guide-overlay'; +import { createDataOverlay } from './presentation/data-overlay'; import { HIDDEN_STORE, LEGEND_HIDDEN_STORE, @@ -473,6 +475,7 @@ export function mountVegaInteractions( (interaction) => interaction.eventSource.type === 'navigation', ); const elementDragInteraction = elementDragInteractions[0]; + const reorderElementDrag = elementDragInteraction?.eventSource.type === 'reorder'; const assistDistanceFor = (eligible: readonly CanvasInteractionDef[]): number => resolveAssistDistance(eligible, assistDistance); const retainedUpdates = new Map(); @@ -552,6 +555,11 @@ export function mountVegaInteractions( reorderAxes: plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), coordinateSpace, containerLayoutSize, }); + const dataOverlay = createDataOverlay({ + view, container, scales: plan.overlayScales ?? {}, coordinateSpace, containerLayoutSize, + }); + const initialDataRows = plan.initialDataRows ?? plan.sourceRecords; + let renderedDataRows: readonly Record[] = initialDataRows; const reorderResetControls = createReorderResetControls({ container, axes: plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), @@ -745,6 +753,12 @@ export function mountVegaInteractions( const supported = resolveSupportedOperation(op, plan); if (supported.unsupported) unsupportedOps.push(op.op); if (supported.op) ops.push(supported.op); + } else if (op.op === 'set-overlay') { + if (!plan.overlayScales?.x || !plan.overlayScales?.y) unsupportedOps.push(op.op); + else ops.push(op); + } else if (op.op === 'set-data') { + if (!plan.mutableDataSource || op.source !== 'main') unsupportedOps.push(op.op); + else ops.push(op); } else { ops.push(op); } @@ -776,6 +790,8 @@ export function mountVegaInteractions( const activeHiddenLegendDomains = new Set(); const stylesByKey: Record> = {}; + const overlays = new Map(); + let dataRows = initialDataRows; let emptyEmphasisActive = false; selectedElements.clear(); hiddenKeys.clear(); @@ -786,6 +802,15 @@ export function mountVegaInteractions( } for (const update of displayUpdates) { for (const op of update.ops) { + if (op.op === 'set-overlay') { + if (op.value === null) overlays.delete(op.name); + else overlays.set(op.name, op.value); + continue; + } + if (op.op === 'set-data') { + dataRows = op.value.rows; + continue; + } if (op.op === 'set-style' && op.value.visible === false) { for (const target of op.targets) { if ('select' in target) continue; @@ -843,6 +868,14 @@ export function mountVegaInteractions( } } const keys = [...selectedKeys()]; + if (plan.mutableDataSource && dataRows !== renderedDataRows) { + view.change( + plan.mutableDataSource, + changeset().remove(() => true).insert([...dataRows]), + ); + renderedDataRows = dataRows; + plan.sourceRecords = dataRows; + } for (const identity of retainedLegendTargets.keys()) { if (!activeHiddenLegendDomains.has(identity)) retainedLegendTargets.delete(identity); } @@ -870,7 +903,11 @@ export function mountVegaInteractions( changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), ); } + // An overlay installed by a click must become acquireable before a + // following pointer-down, even while unrelated Vega work is pending. + dataOverlay.render(overlays); await view.runAsync(); + dataOverlay.render(overlays); observeRenderer(); renderPathFocus(); renderLegendRange(); @@ -1668,6 +1705,7 @@ export function mountVegaInteractions( moved: boolean; axis?: 'x' | 'y'; eligibleAxes: readonly ('x' | 'y')[]; + overlayName?: string; } | undefined; const reorderItemAt = (event: PointerEvent): any => { const eventItem = (event.target as any)?.__data__; @@ -1682,7 +1720,34 @@ export function mountVegaInteractions( }; const resolveDraggedTarget = ( event: PointerEvent, - ): { target: SemanticTarget; item: any; eligibleAxes: readonly ('x' | 'y')[] } | null => { + ): { target: SemanticTarget; item?: any; eligibleAxes: readonly ('x' | 'y')[]; overlayName?: string } | null => { + if (!reorderElementDrag) { + // A retained overlay is a visual enhancement, not a replacement + // for an underlying semantic mark. Prefer a real mark whenever + // pointer-down lands on or near one; fall back to the overlay for + // the rest of its path. + const point = localPoint(event); + const exactItem = reorderItemAt(event); + const item = renderHit(exactItem) + ? exactItem + : nearestSceneItem( + view, + point, + elementDragInteraction?.eventSource.targetTolerance ?? 0, + ); + const hit = renderHit(item); + if (hit) { + const target = resolveTarget('click', hit.layerRole ?? hit.markType ?? 'mark', [hit]); + if (target) return { target, item, eligibleAxes: [] }; + } + const overlay = dataOverlay.targetForElement(event.target) + ?? dataOverlay.targetAt( + point, + elementDragInteraction?.eventSource.targetTolerance ?? 0, + ); + if (overlay) return { target: overlay.target, eligibleAxes: [], overlayName: overlay.name }; + return null; + } const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); const eventItem = (event.target as any)?.__data__; const axisItem = axisTargetIdentity(eventItem, plan.axisTargets) @@ -1716,8 +1781,15 @@ export function mountVegaInteractions( phase: 'start' | 'preview' | 'commit' | 'cancel', event: PointerEvent, current: { x: number; y: number }, + invokeHandler = true, ): Promise => { if (!elementDragInteraction || !elementDrag) return; + if (!elementDrag.overlayName && !reorderElementDrag) { + elementDrag.overlayName = dataOverlay.targetAt( + current, + elementDragInteraction.eventSource.targetTolerance ?? 0, + )?.name; + } const canvasEvent: CanvasInteractionEvent = { action: 'drag-element', phase, @@ -1734,8 +1806,13 @@ export function mountVegaInteractions( dropTarget: elementDrag.destination, modifiers: interactionModifiers(event), }; + if (elementDrag.overlayName) { + canvasEvent.geometry.projection = dataOverlay.project(elementDrag.overlayName, current); + } emitCanvasInteractionEvent(elementDragInteraction, canvasEvent); - const request = elementDragInteraction.handle?.(canvasEvent, context()) ?? null; + const request = invokeHandler + ? elementDragInteraction.handle?.(canvasEvent, context()) ?? null + : null; await applyInteractionUpdate(elementDragInteraction, phase, request); }; const elementDragStart = (event: PointerEvent): void => { @@ -1746,6 +1823,7 @@ export function mountVegaInteractions( elementDrag = { start, source: source.target, destination: source.target, sourceItem: source.item, moved: false, eligibleAxes: source.eligibleAxes, + overlayName: source.overlayName, }; try { container.setPointerCapture?.(event.pointerId); @@ -1771,6 +1849,14 @@ export function mountVegaInteractions( current.x - elementDrag.start.x, current.y - elementDrag.start.y, ) < 4) return; + if (!reorderElementDrag) { + elementDrag.moved = true; + suppressClick = true; + regionDragging = true; + container.style.cursor = 'grabbing'; + void dispatchElementDrag('preview', event, current); + return; + } if (!elementDrag.axis) { const deltaX = Math.abs(current.x - elementDrag.start.x); const deltaY = Math.abs(current.y - elementDrag.start.y); @@ -1797,10 +1883,14 @@ export function mountVegaInteractions( if (!elementDrag) return; const drag = elementDrag; const current = localPoint(event); - const destination = drag.axis ? resolveReorderDestination(current, drag.axis) : null; + const destination = reorderElementDrag && drag.axis + ? resolveReorderDestination(current, drag.axis) + : null; if (destination) drag.destination = destination; dragReorderOverlay.clear(); if (drag.moved) void dispatchElementDrag('commit', event, current); + else if (reorderElementDrag) void dispatchElementDrag('cancel', event, current); + else void dispatchElementDrag('commit', event, current, false); elementDrag = undefined; regionDragging = false; container.style.cursor = previousCursor; @@ -1991,6 +2081,7 @@ export function mountVegaInteractions( renderPathFocus(); renderLegendRange(); for (const overlay of annotationOverlays.values()) overlay.sync(); + dataOverlay.sync(); regionGesture?.sync(); reorderResetControls.layout(); }; @@ -2074,6 +2165,7 @@ export function mountVegaInteractions( annotationOverlays.clear(); inspectGuideOverlay.destroy(); dragReorderOverlay.destroy(); + dataOverlay.destroy(); reorderResetControls.destroy(); viewportResetControl.destroy(); resizeObserver?.disconnect(); diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index a57f7247..e7524ae1 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -9,6 +9,7 @@ import { injectVegaInteractionStore, injectVegaNavigationSignals, injectVegaReorderSignal, + findVegaAxisScale, withoutSemanticInteractionField, } from './interactions/compile'; import { mountVegaInteractions } from './interactions/runtime'; @@ -106,6 +107,20 @@ export function createVegaInteractiveRenderer( if (viewports.length > 0 && !source) { throw new Error('Compiled chart has no mutable inline data source.'); } + if (interactionPlan) { + interactionPlan.overlayScales = { + x: findVegaAxisScale(vegaSpec, 'x')?.name, + y: findVegaAxisScale(vegaSpec, 'y')?.name, + color: vegaSpec.scales?.find((scale: any) => scale.name === 'color')?.name + ?? (() => { + const matches = (vegaSpec.scales ?? []).filter((scale: any) => + typeof scale.name === 'string' && scale.name.endsWith('_color')); + return matches.length === 1 ? matches[0].name : undefined; + })(), + }; + interactionPlan.mutableDataSource = source; + interactionPlan.initialDataRows = firstInput.data.values ?? []; + } const view = new View( parse(vegaSpec, { background: options.background } as any, { ast: true } as any), { diff --git a/packages/flint-js/src/vegalite/templates/kpi-card.ts b/packages/flint-js/src/vegalite/templates/kpi-card.ts index 8b91cba0..61accc7e 100644 --- a/packages/flint-js/src/vegalite/templates/kpi-card.ts +++ b/packages/flint-js/src/vegalite/templates/kpi-card.ts @@ -240,7 +240,10 @@ export const kpiCardDef: ChartTemplateDef = { Math.floor(cardInnerW / Math.max(1, chars * charW)); const valueFontByWidth = fontFitsWidth(maxValueChars, CHAR_W_BOLD); - const captionFontByWidth = fontFitsWidth(maxCaptionChars, CHAR_W_REGULAR); + // Captions may use two lines. Size them for roughly half the longest + // caption rather than shrinking a long title to an unreadable single + // line; the final text mark also has a hard pixel limit as a safety net. + const captionFontByWidth = fontFitsWidth(Math.ceil(maxCaptionChars / 2), CHAR_W_REGULAR); const subFontByWidth = fontFitsWidth(maxSubChars, CHAR_W_REGULAR); // Detect sub-line presence early — used both to size value (more @@ -264,6 +267,42 @@ export const kpiCardDef: ChartTemplateDef = { const captionFont = Math.max(11, Math.min(22, Math.floor(Math.min(valueFont / 3.0, captionFontByWidth)))); const subFont = Math.max(10, Math.min(18, Math.floor(Math.min(captionFont, subFontByWidth)))); + const captionCharsPerLine = Math.max( + 1, + Math.floor(cardInnerW / Math.max(1, captionFont * CHAR_W_REGULAR)), + ); + const wrapCaption = (text: string): { text: string; lines: number } => { + if (text.length <= captionCharsPerLine) return { text, lines: 1 }; + + const words = text.trim().split(/\s+/); + let first = ''; + let splitAt = 0; + for (; splitAt < words.length; splitAt++) { + const candidate = first ? `${first} ${words[splitAt]}` : words[splitAt]; + if (candidate.length > captionCharsPerLine && first) break; + first = candidate; + } + + // A single unbroken token still needs a deterministic hard wrap. + if (splitAt === words.length && first.length > captionCharsPerLine) { + const firstLine = first.slice(0, captionCharsPerLine); + const remainder = first.slice(captionCharsPerLine); + const secondLine = remainder.length > captionCharsPerLine + ? `${remainder.slice(0, Math.max(1, captionCharsPerLine - 1))}…` + : remainder; + return { text: `${firstLine}\n${secondLine}`, lines: 2 }; + } + + const remainder = words.slice(splitAt).join(' '); + const second = remainder.length > captionCharsPerLine + ? `${remainder.slice(0, Math.max(1, captionCharsPerLine - 1)).trimEnd()}…` + : remainder; + return { text: `${first}\n${second}`, lines: 2 }; + }; + const wrappedCaptions = tiles.map(t => wrapCaption(t.caption)); + const captionLines = wrappedCaptions.some(caption => caption.lines === 2) ? 2 : 1; + const captionLineHeight = Math.ceil(captionFont * 1.15); + const padTop = Math.max(4, Math.floor(captionFont * 0.55)); const padBot = Math.max(4, Math.floor(subFont * 0.6)); const gapCV = Math.max(6, Math.floor(captionFont * 0.55)); // caption → value @@ -272,7 +311,7 @@ export const kpiCardDef: ChartTemplateDef = { const barHeight = Math.max(2, Math.floor(subFont * 0.4)); const captionTop = padTop; - const captionBot = captionTop + captionFont; + const captionBot = captionTop + captionFont + (captionLines - 1) * captionLineHeight; const valueTop = captionBot + gapCV; const valueMid = valueTop + Math.floor(valueFont / 2); const valueBot = valueTop + valueFont; @@ -310,8 +349,9 @@ export const kpiCardDef: ChartTemplateDef = { const showCardFrame = config.style !== false; // ── Per-tile spec builder ────────────────────────────────────────── - const buildTile = (t: Tile): any => { + const buildTile = (t: Tile, tileIndex: number): any => { const layers: any[] = []; + const wrappedCaption = wrappedCaptions[tileIndex]; // Card frame (bottom layer) — sized to content, centered with it. if (showCardFrame) { @@ -344,7 +384,11 @@ export const kpiCardDef: ChartTemplateDef = { fill: '#4a4a4a', align: 'center', baseline: 'top', - text: t.caption, + text: wrappedCaption.text, + lineBreak: '\n', + lineHeight: captionLineHeight, + limit: cardInnerW, + ellipsis: '…', tooltip: null, }, encoding: { diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index e774bef9..84e96870 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -297,6 +297,7 @@ export const radarChartDef: ChartTemplateDef = { seriesField: groupField, legendFields: groupField ? { color: groupField } : undefined, selectableMarks: ['line', 'point'], + supportedRegionGestures: ['angular'], renderHoverStyles: { line: { strokeWidth: 3 }, symbol: { strokeWidth: 2 }, diff --git a/packages/flint-js/tests/data-overlay.test.ts b/packages/flint-js/tests/data-overlay.test.ts new file mode 100644 index 00000000..3b859fbb --- /dev/null +++ b/packages/flint-js/tests/data-overlay.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import type { ChartOverlaySpec } from '../src/interactive/language/updates'; +import { orderedOverlayRows, projectPointToPath } from '../src/vegalite/interactions/presentation/data-overlay'; + +describe('retained data overlays', () => { + it('orders a path without mutating application rows', () => { + const values = [ + { Year: 2000, x: 3, y: 4 }, + { Year: 1980, x: 1, y: 2 }, + { Year: 1990, x: 2, y: 3 }, + ]; + const spec: ChartOverlaySpec = { + mark: 'line', + data: { values }, + encodings: { x: { field: 'x' }, y: { field: 'y' }, order: { field: 'Year' } }, + role: 'trajectory', + }; + + expect(orderedOverlayRows(spec).map((row) => row.Year)).toEqual([1980, 1990, 2000]); + expect(values.map((row) => row.Year)).toEqual([2000, 1980, 1990]); + }); + + it('projects a free pointer onto the nearest semantic path segment', () => { + const projection = projectPointToPath( + { x: 7, y: 2 }, + [ + { point: { x: 0, y: 0 }, record: { Year: 1980 } }, + { point: { x: 10, y: 0 }, record: { Year: 1990 } }, + { point: { x: 10, y: 10 }, record: { Year: 2000 } }, + ], + ); + + expect(projection?.point).toEqual({ x: 7, y: 0 }); + expect(projection?.distance).toBe(2); + expect(projection?.segment.start.value.Year).toBe(1980); + expect(projection?.segment.end.value.Year).toBe(1990); + expect(projection?.segment.t).toBeCloseTo(0.7); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index 43ec1ade..e24619c6 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -16,6 +16,7 @@ import { clickTrigger, contextTrigger, doubleActivateTrigger, + dragTrigger, hoverTrigger, inspectTrigger, parseInspectMode, @@ -27,6 +28,14 @@ import { xBrushTrigger, yBrushTrigger, } from '../src/interactive/triggers'; + +describe('generic drag trigger', () => { + it('uses a forgiving visual acquisition tolerance', () => { + expect(dragTrigger().targetTolerance).toBe(12); + expect(dragTrigger(20).targetTolerance).toBe(20); + expect(dragTrigger(-1).targetTolerance).toBe(0); + }); +}); import { AngularRegionSession } from '../src/interactive/gestures/angular-region'; const clickMark = (options: Omit = {}) => diff --git a/packages/flint-js/tests/kpi-card.test.ts b/packages/flint-js/tests/kpi-card.test.ts new file mode 100644 index 00000000..00ffdddb --- /dev/null +++ b/packages/flint-js/tests/kpi-card.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; + +describe('KPI Card captions', () => { + it('wraps long captions and constrains them to the card width', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Metric: 'Renewable electricity (%)', Value: 30.3, Goal: 45 }, + { Metric: 'EV share of car sales (%)', Value: 18, Goal: 40 }, + { Metric: 'World online (%)', Value: 67, Goal: 90 }, + { Metric: 'Electricity access (%)', Value: 91, Goal: 100 }, + ], + }, + semantic_types: { Metric: 'Category', Value: 'Quantity', Goal: 'Quantity' }, + chart_spec: { + chartType: 'KPI Card', + encodings: { metric: 'Metric', value: 'Value', goal: 'Goal' }, + chartProperties: { layout: 'horizontal' }, + baseSize: { width: 560, height: 240 }, + }, + } as any) as any; + + const captionMarks = spec.hconcat.map((tile: any) => + tile.layer.find((layer: any) => + layer.mark?.type === 'text' && String(layer.mark.text).includes('%')), + ); + + expect(captionMarks).toHaveLength(4); + expect(captionMarks.every((layer: any) => layer.mark.limit > 0)).toBe(true); + expect(captionMarks.some((layer: any) => layer.mark.text.includes('\n'))).toBe(true); + expect(captionMarks.every((layer: any) => layer.mark.text.split('\n').length <= 2)).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/maps.test.ts b/packages/flint-js/tests/maps.test.ts index 0b096985..b41dc710 100644 --- a/packages/flint-js/tests/maps.test.ts +++ b/packages/flint-js/tests/maps.test.ts @@ -151,6 +151,26 @@ describe('choropleth maps from names', () => { expect(spec.transform?.[0]?.lookup).toBe('id'); expect(spec.transform?.[0]?.from?.key).toBe('__geo_id'); }); + + it('keeps a declared quantitative color domain stable and centered', () => { + const spec = assembleVegaLite({ + data: { values: [{ state: 'CA', margin: 30 }, { state: 'TX', margin: -14 }] }, + semantic_types: { + state: 'State', + margin: { semanticType: 'Quantity', intrinsicDomain: [-100, 100] }, + }, + chart_spec: { + chartType: 'Choropleth', + encodings: { id: 'state', color: { field: 'margin', scheme: 'redblue' } }, + }, + }) as any; + + expect(spec.encoding.color.scale).toMatchObject({ + domain: [-100, 100], + clamp: true, + scheme: 'redblue', + }); + }); }); describe('choropleth lookup robustness', () => { diff --git a/packages/flint-js/tests/point-size.test.ts b/packages/flint-js/tests/point-size.test.ts index 4f7a8d9b..a57309de 100644 --- a/packages/flint-js/tests/point-size.test.ts +++ b/packages/flint-js/tests/point-size.test.ts @@ -64,6 +64,27 @@ const radarInput = (house: string) => ({ } as never); describe('point size', () => { + it('keeps an explicitly bounded bubble-size domain stable', () => { + const spec = assembleVegaLite({ + data: { values: [ + { X: 1, Y: 2, Population: 10 }, + { X: 2, Y: 3, Population: 40 }, + ] }, + semantic_types: { + X: 'Quantity', + Y: 'Quantity', + Population: { semanticType: 'Quantity', intrinsicDomain: [0, 100] }, + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'X', y: 'Y', size: 'Population' }, + }, + } as never) as any; + + expect(spec.encoding.size.scale.domain).toEqual([0, 100]); + expect(spec.encoding.size.scale.clamp).toBe(true); + }); + it('every house says how big its dots are', () => { // Silence is not a style. A house that never names a size inherits // the renderer's own default, which is nobody's design decision, and diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index ce460a74..c71b456d 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -3,7 +3,8 @@ import { changeset, parse, View } from 'vega'; import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src/vegalite/assemble'; import { axisHighlight, brushAngle, brushX, brushZoom, clickAnnotate, clickHighlight, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; -import type { ClickHighlightOptions, RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; +import type { CanvasInteractionDef, ClickHighlightOptions, RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; +import { dragTrigger } from '../src/interactive/triggers'; import { associateSemanticElementRenderKeys, MUTED_HOVER_FILL, @@ -50,7 +51,9 @@ import { indexInspectAcquisition, indexInspectHits, PATH_KEY_SUFFIX, + pathIntersectsAngularSector, physicalItemAt, + polarFrameFromRadarGrid, plotToClientPoint, legendEntryItemAtPoint, legendSemanticTarget, @@ -566,6 +569,26 @@ describe('Vega-Lite semantic interactions', () => { expect(facetedSemantics.reorderAxis).toBeUndefined(); }); + it('admits generic freeform drag without requiring a reorderable category scale', () => { + const scatter = assembleVegaLite({ + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + }, + semantic_types: { x: 'Number', y: 'Number' }, + data: { values: [{ x: 1, y: 2 }] }, + }) as any; + const interaction: CanvasInteractionDef = { + id: 'freeform-drag', + eventSource: dragTrigger(), + handle: () => null, + }; + + const plan = addVegaLiteInteractions(scatter, [interaction]); + expect(plan).toBeTruthy(); + expect(plan?.reorderAxis).toBeUndefined(); + }); + it.each(['quantitative', 'temporal'] as const)('rejects %s Bar category reorder semantics', (type) => { const semantics = barChartDef.semanticInteractions!({ resolvedEncodings: { @@ -1823,6 +1846,45 @@ describe('Vega-Lite semantic interactions', () => { expect(arcIntersectsAngularSector(arc, { ...sector, innerRadius: 85 })).toBe(false); }); + it('tests Radar line content against angular sectors', () => { + const sector = { + center: { x: 100, y: 100 }, innerRadius: 0, outerRadius: 90, + startAngle: -0.2, endAngle: 0.2, + }; + const contained = [{ x: 95, y: 40 }, { x: 105, y: 40 }]; + const crossing = [{ x: 70, y: 40 }, { x: 130, y: 40 }]; + const outside = [{ x: 140, y: 90 }, { x: 150, y: 110 }]; + + expect(pathIntersectsAngularSector(contained, sector)).toBe(true); + expect(pathIntersectsAngularSector(contained, sector, true)).toBe(true); + expect(pathIntersectsAngularSector(crossing, sector)).toBe(true); + expect(pathIntersectsAngularSector(crossing, sector, true)).toBe(false); + expect(pathIntersectsAngularSector(outside, sector)).toBe(false); + }); + + it('derives the Radar brush radius from grid spokes instead of legends', () => { + const spokeMark = { marktype: 'rule' }; + const legendMark = { marktype: 'rule', name: 'legend-symbol' }; + const view = { + scenegraph: () => ({ + root: { + items: [{ + mark: { marktype: 'group' }, x: 20, y: 30, + items: [ + { mark: spokeMark, datum: { __type: 'spoke' }, x: 100, y: 100, x2: 100, y2: 40 }, + { mark: spokeMark, datum: { __type: 'spoke' }, x: 100, y: 100, x2: 160, y2: 100 }, + { mark: legendMark, x: 220, y: 20, x2: 320, y2: 20 }, + ], + }], + }, + }), + }; + + expect(polarFrameFromRadarGrid(view)).toEqual({ + center: { x: 120, y: 130 }, innerRadius: 0, outerRadius: 60, + }); + }); + it('draws annular angular-brush geometry and admits it only on polar ChartDefs', () => { expect(angularSectorPath({ center: { x: 100, y: 100 }, innerRadius: 30, outerRadius: 80, @@ -1861,6 +1923,24 @@ describe('Vega-Lite semantic interactions', () => { }; const polarPlan = addVegaLiteInteractions(polar, [brushX()]); expect(polarPlan?.angularXBrush).toBe(true); + + const radar = { + mark: 'point', + data: { values: [{ metric: 'Speed', value: 1, series: 'A' }] }, + encoding: { + x: { field: 'metric', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + _interactionSemantics: radarChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'metric', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + }), + }; + expect(addVegaLiteInteractions(radar, [brushAngle()])?.angularXBrush).toBe(true); }); it('does not select adjacent cells that only touch the selection boundary', () => { @@ -3304,6 +3384,32 @@ describe('Vega-Lite semantic interactions', () => { .every((item) => item.opacity === plan?.dimOpacity)).toBe(true); }); + it('pins a themed Calendar continuous legend to its full extent', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Date: '2024-01-01', Activity: 26 }, + { Date: '2024-01-02', Activity: 27 }, + { Date: '2024-01-03', Activity: 28 }, + { Date: '2024-01-04', Activity: 100 }, + ] }, + semantic_types: { Date: 'Date', Activity: 'Quantity' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: 'Date', color: 'Activity' }, + }, + theme_spec: 'pop', + } as any) as any; + const { compiled } = instrument(spec, [legendToggle()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + + const colorScale = compiled.scales.find((scale: any) => + scale.type === 'quantize' && Array.isArray(scale.domain)); + expect(colorScale).toBeDefined(); + expect(colorScale.domain).toEqual([26, 100]); + expect(view.scale(colorScale.name).domain()).toEqual([26, 100]); + }); + it('neutralizes muted continuous color only for geographic maps', () => { const spec = assembleVegaLite({ data: { values: [ @@ -3624,6 +3730,37 @@ describe('Vega-Lite semantic interactions', () => { expect(segments.at(-1)?.interactionGeometry.endDatum.Nutrient).toBe('Protein'); }); + it('derives the angular brush frame from a rendered Radar grid with a legend', async () => { + const values = [ + ['Oats', 17, 7, 66, 11, 1], + ['Almonds', 21, 49, 22, 12, 4], + ].flatMap(([Food, ...amounts]) => ['Protein', 'Fat', 'Carbs', 'Fiber', 'Sugar'] + .map((Nutrient, index) => ({ Food, Nutrient, Amount: amounts[index] }))); + const spec = assembleVegaLite({ + data: { values }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + + const frame = polarFrameFromRadarGrid(view); + const spokes = allSceneItems(view).filter((item) => + item.mark?.marktype === 'rule' && item.datum?.__type === 'spoke'); + + expect(frame).toBeDefined(); + expect(spokes).toHaveLength(5); + expect(frame!.outerRadius).toBeCloseTo(Math.hypot( + spokes[0].x2 - spokes[0].x, + spokes[0].y2 - spokes[0].y, + )); + expect(frame!.outerRadius).toBeLessThan(Math.min(view.width(), view.height()) / 2); + }); + it('acquires the nearest Radar edge across overlapping filled series', async () => { const values = [ ['Oats', 17, 7, 66, 11, 1], diff --git a/packages/flint-mcp/ui/src/styles.css b/packages/flint-mcp/ui/src/styles.css index 2ff8f9a6..7816369c 100644 --- a/packages/flint-mcp/ui/src/styles.css +++ b/packages/flint-mcp/ui/src/styles.css @@ -661,8 +661,8 @@ input[type="range"]::-webkit-slider-runnable-track { border-radius: 999px; background: linear-gradient( to right, - var(--accent) 0 var(--pct, 50%), - rgba(0, 0, 0, 0.16) var(--pct, 50%) 100% + var(--accent) 0 var(--pct, 0%), + rgba(0, 0, 0, 0.16) var(--pct, 0%) 100% ); } diff --git a/site/src/components/GalleryOptionsBar.tsx b/site/src/components/GalleryOptionsBar.tsx index 62b7bef6..46c722dc 100644 --- a/site/src/components/GalleryOptionsBar.tsx +++ b/site/src/components/GalleryOptionsBar.tsx @@ -16,6 +16,7 @@ import type { ChartOption } from 'flint-chart'; import { THEME_PRESETS, DEFAULT_THEME_ICON } from 'flint-chart'; import { siteTheme } from '../shared/theme'; import { chartIconFor } from '../shared/chart-categories'; +import { SiteRange } from './SiteRange'; import { valueKey } from '../shared/chart-options'; import type { ControlSpec, PanelModel, ResolvedAction } from '../shared/chart-options'; import './gallery-options-bar.css'; @@ -340,7 +341,6 @@ function ControlRow(props: { if (spec.type === 'continuous') { const step = spec.step ?? ((spec.max - spec.min) / 100 || 1); const num = typeof value === 'number' ? value : spec.min; - const pct = spec.max > spec.min ? ((num - spec.min) / (spec.max - spec.min)) * 100 : 0; // Reserve enough width for the widest value the slider can show so the // readout never clips (e.g. "50") or reflows as digits change. const readoutCh = Math.max( @@ -350,14 +350,11 @@ function ControlRow(props: { ); control = ( - onChange(Number(e.target.value))} /> diff --git a/site/src/components/SiteRange.tsx b/site/src/components/SiteRange.tsx new file mode 100644 index 00000000..2591c313 --- /dev/null +++ b/site/src/components/SiteRange.tsx @@ -0,0 +1,35 @@ +import type { CSSProperties, InputHTMLAttributes } from 'react'; + +type SiteRangeProps = Omit< + InputHTMLAttributes, + 'type' | 'min' | 'max' | 'value' +> & { + min: number; + max: number; + value: number; +}; + +/** + * Shared range input with a value-driven filled track. + * + * WebKit does not expose a native range-progress pseudo-element, so the site + * track uses `--pct`. Keeping the calculation here prevents controls without + * that custom property from displaying the old, misleading 50% fallback. + */ +export function SiteRange({ min, max, value, className, style, ...props }: SiteRangeProps) { + const percent = max > min + ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) + : 0; + + return ( + + ); +} diff --git a/site/src/components/SizingPlayground.tsx b/site/src/components/SizingPlayground.tsx index 22b5924f..72548790 100644 --- a/site/src/components/SizingPlayground.tsx +++ b/site/src/components/SizingPlayground.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState, type CSSProperties } from 'react'; +import { useMemo, useState } from 'react'; import { assembleVegaLite, assembleECharts, type ChartAssemblyInput } from 'flint-chart'; import { VegaLiteView } from './VegaLiteView'; import { EChartsView } from './EChartsView'; +import { SiteRange } from './SiteRange'; import { siteTheme } from '../shared/theme'; /** @@ -169,8 +170,6 @@ function Slider({ label, value, min, max, step, onChange, suffix }: { onChange: (v: number) => void; suffix?: string; }) { - const percent = max > min ? ((value - min) / (max - min)) * 100 : 0; - return ( - onChange(Number(e.target.value))} - className="site-range" - style={{ '--pct': `${percent}%` } as CSSProperties} /> ); diff --git a/site/src/global.css b/site/src/global.css index 4c7b5b64..c0a01e53 100644 --- a/site/src/global.css +++ b/site/src/global.css @@ -46,8 +46,8 @@ input[type='range'].site-range::-webkit-slider-runnable-track { border-radius: 999px; background: linear-gradient( to right, - #0078d4 0 var(--pct, 50%), - rgba(0, 0, 0, 0.16) var(--pct, 50%) 100% + var(--site-range-color, #0078d4) 0 var(--pct, 0%), + rgba(0, 0, 0, 0.16) var(--pct, 0%) 100% ); } @@ -59,7 +59,7 @@ input[type='range'].site-range::-webkit-slider-thumb { margin-top: -3.5px; border: 0; border-radius: 50%; - background: #0078d4; + background: var(--site-range-color, #0078d4); } input[type='range'].site-range::-moz-range-track { @@ -71,7 +71,7 @@ input[type='range'].site-range::-moz-range-track { input[type='range'].site-range::-moz-range-progress { height: 3px; border-radius: 999px; - background: #0078d4; + background: var(--site-range-color, #0078d4); } input[type='range'].site-range::-moz-range-thumb { @@ -79,11 +79,11 @@ input[type='range'].site-range::-moz-range-thumb { height: 10px; border: 0; border-radius: 50%; - background: #0078d4; + background: var(--site-range-color, #0078d4); } input[type='range'].site-range:focus-visible { - outline: 2px solid #0078d4; + outline: 2px solid var(--site-range-color, #0078d4); outline-offset: 3px; } diff --git a/site/src/main.tsx b/site/src/main.tsx index dca10951..e6b611c5 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -31,6 +31,7 @@ import { InteractionDashboardLab } from './playground/InteractionDashboardLab'; import { InteractionCandidates } from './playground/InteractionCandidates'; import { ExternalToChartLab } from './playground/ExternalToChartLab'; import { ChartToExternalLab } from './playground/ChartToExternalLab'; +import { BespokeInteractionLab } from './playground/BespokeInteractionLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; import { DebugGym } from './playground/DebugGym'; @@ -90,6 +91,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/site/src/playground/BandExpansionFigure.tsx b/site/src/playground/BandExpansionFigure.tsx index 46c700d6..0fc21940 100644 --- a/site/src/playground/BandExpansionFigure.tsx +++ b/site/src/playground/BandExpansionFigure.tsx @@ -5,6 +5,7 @@ import { VegaLiteView } from '../components/VegaLiteView'; import { EChartsView } from '../components/EChartsView'; import { ChartjsView } from '../components/ChartjsView'; import { PlotlyView } from '../components/PlotlyView'; +import { SiteRange } from '../components/SiteRange'; import { siteTheme } from '../shared/theme'; /** @@ -72,7 +73,6 @@ export function BandExpansionFigure() { } }, [backend, count]); - const pct = ((count - 2) / (40 - 2)) * 100; return (
    Categories - setCount(Number(e.target.value))} - className="site-range" - style={{ '--pct': `${pct}%`, flex: 1 } as CSSProperties} + style={{ flex: 1 }} /> {count} diff --git a/site/src/playground/BandStretchingLab.tsx b/site/src/playground/BandStretchingLab.tsx index 1dd91a37..01a002a0 100644 --- a/site/src/playground/BandStretchingLab.tsx +++ b/site/src/playground/BandStretchingLab.tsx @@ -1,11 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useMemo, useState, type CSSProperties } from 'react'; +import { useMemo, useState } from 'react'; import { Check, Copy, RotateCcw } from 'lucide-react'; import { THEME_PRESETS, assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; import { VegaLiteView } from '../components/VegaLiteView'; import { ScaleToFit } from '../components/ScaleToFit'; +import { SiteRange } from '../components/SiteRange'; import { siteTheme } from '../shared/theme'; import './band-stretching-lab.css'; @@ -149,19 +150,16 @@ function Slider({ suffix?: string; onChange: (value: number) => void; }) { - const pct = ((value - min) / (max - min)) * 100; return (
    diff --git a/site/src/playground/ExplodedDetailStage.tsx b/site/src/playground/ExplodedDetailStage.tsx index 09b3d012..6164e716 100644 --- a/site/src/playground/ExplodedDetailStage.tsx +++ b/site/src/playground/ExplodedDetailStage.tsx @@ -1,6 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { ChartAssemblyInput } from 'flint-chart'; -import { buildInteractiveChart } from 'flint-chart/interactive'; +import { + buildInteractiveChart, + inspectTrigger, + type CanvasInteractionDef, + type ChartUpdate, +} from 'flint-chart/interactive'; import { ScaleToFit } from '../components/ScaleToFit'; import { CLIMATE_CITIES, CLIMATE_MONTHS } from './climate-phase-data'; import './exploded-detail-stage.css'; @@ -9,6 +14,8 @@ const FOCUS_RADIUS = 48; const DETAIL_RADIUS = 108; const DETAIL_OFFSET = 220; const DETAIL_SCALE = 1.7; +const DETAIL_MIN_SCALE = 1; +const DETAIL_MAX_SCALE = 3.4; const ROWS = CLIMATE_CITIES.flatMap((city) => CLIMATE_MONTHS.map((month, monthIndex) => ({ City: city.name, @@ -52,6 +59,7 @@ type ScenePoint = PlotPoint & { type VectorScene = { content: string; viewBox: string; + origin: PlotPoint; x: number; y: number; width: number; @@ -97,7 +105,7 @@ function toLocal(source: SVGSVGElement, clientX: number, clientY: number): PlotP } function captureScene(mount: HTMLDivElement): VectorScene | null { - const source = mount.querySelector('figure svg') ?? mount.querySelector('svg'); + const source = mount.querySelector('svg.marks'); if (!source) return null; const sourceRect = source.getBoundingClientRect(); const mountRect = mount.getBoundingClientRect(); @@ -124,9 +132,12 @@ function captureScene(mount: HTMLDivElement): VectorScene | null { const ys = points.map((point) => point.y); const xStep = (Math.max(...xs) - Math.min(...xs)) / 11; const yPad = 28; + const rootFrame = source.querySelector('.mark-group.role-frame.root'); + const rootMatrix = rootFrame?.getCTM(); return { content: clone.innerHTML, viewBox: source.getAttribute('viewBox') ?? `0 0 ${sourceRect.width} ${sourceRect.height}`, + origin: { x: rootMatrix?.e ?? 0, y: rootMatrix?.f ?? 0 }, x: (sourceRect.left - mountRect.left) * scaleX, y: (sourceRect.top - mountRect.top) * scaleY, width: sourceRect.width * scaleX, @@ -155,6 +166,35 @@ function eccentricLabels( }); } +function explodedGeometry(scene: VectorScene, focus: PlotPoint, scale = DETAIL_SCALE) { + const focusRadius = FOCUS_RADIUS * DETAIL_SCALE / scale; + const placeRight = focus.x + DETAIL_OFFSET + DETAIL_RADIUS <= scene.plot.right; + const center = { + x: focus.x + (placeRight ? DETAIL_OFFSET : -DETAIL_OFFSET), + y: Math.min(scene.plot.bottom - DETAIL_RADIUS, Math.max(scene.plot.top + DETAIL_RADIUS, focus.y)), + }; + const points = scene.points.flatMap((point) => { + const dx = point.x - focus.x; + const dy = point.y - focus.y; + return Math.hypot(dx, dy) <= focusRadius + ? [{ ...point, detailX: center.x + dx * scale, detailY: center.y + dy * scale }] + : []; + }); + return { center, focusRadius, points, labels: eccentricLabels(points, center, focus) }; +} + +function recordLatency( + samples: { current: number[] }, + elapsed: number, + setLatency: (latency: number) => void, +) { + samples.current.push(elapsed); + if (samples.current.length > 20) samples.current.shift(); + if (samples.current.length % 5 === 0) { + setLatency(samples.current.reduce((sum, value) => sum + value, 0) / samples.current.length); + } +} + export function ExplodedDetailStage() { const [active, setActive] = useState(false); const [focus, setFocus] = useState({ x: 450, y: 260 }); @@ -162,24 +202,21 @@ export function ExplodedDetailStage() { const mountRef = useRef(null); const sourceRef = useRef(null); const sceneRef = useRef(null); + const presentationStartedRef = useRef(null); + const latencySamplesRef = useRef([]); + const [latency, setLatency] = useState(null); const explosion = useMemo(() => { - if (!scene) return null; - const placeRight = focus.x + DETAIL_OFFSET + DETAIL_RADIUS <= scene.plot.right; - const center = { - x: focus.x + (placeRight ? DETAIL_OFFSET : -DETAIL_OFFSET), - y: Math.min(scene.plot.bottom - DETAIL_RADIUS, Math.max(scene.plot.top + DETAIL_RADIUS, focus.y)), - }; - const points = scene.points.flatMap((point) => { - const dx = point.x - focus.x; - const dy = point.y - focus.y; - return Math.hypot(dx, dy) <= FOCUS_RADIUS - ? [{ ...point, detailX: center.x + dx * DETAIL_SCALE, detailY: center.y + dy * DETAIL_SCALE }] - : []; - }); - return { center, points, labels: eccentricLabels(points, center, focus) }; + return scene ? explodedGeometry(scene, focus) : null; }, [focus, scene]); + useLayoutEffect(() => { + if (presentationStartedRef.current === null) return; + const elapsed = performance.now() - presentationStartedRef.current; + presentationStartedRef.current = null; + recordLatency(latencySamplesRef, elapsed, setLatency); + }, [focus]); + useEffect(() => { const mount = mountRef.current; if (!mount) return undefined; @@ -188,6 +225,7 @@ export function ExplodedDetailStage() { if (!source) return; const point = toLocal(source, event.clientX, event.clientY); if (!point) return; + presentationStartedRef.current = performance.now(); setFocus(point); const plot = sceneRef.current?.plot; setActive(Boolean(plot && point.x >= plot.left && point.x <= plot.right @@ -225,10 +263,11 @@ export function ExplodedDetailStage() {
    - No semantic event data + DOM pointer event Cloned + clipped SVG Eccentric labels 0 Flint updates + React commit {latency === null ? '—' : `${latency.toFixed(1)} ms`}
    @@ -264,7 +303,12 @@ export function ExplodedDetailStage() { )} - + {explosion && ( <> ); } + +function escapeXml(value: string): string { + return value.split('&').join('&').split('<').join('<').split('>').join('>') + .split('"').join('"').split("'").join('''); +} + +function freeformExplodedSvg(scene: VectorScene, focus: PlotPoint, scale: number): string { + const explosion = explodedGeometry(scene, focus, scale); + const plotWidth = scene.plot.right - scene.plot.left; + const plotHeight = scene.plot.bottom - scene.plot.top; + const labels = explosion.labels.map((label) => ` + + + ${escapeXml(label.label)} + `).join(''); + return ` + + ${scene.content} + + + + + + + + + + + ${labels} + `; +} + +function freeformUpdate(scene: VectorScene, focus: PlotPoint, scale: number): ChartUpdate { + return { + id: 'freeform-exploded-detail', + ops: [{ + op: 'set-freeform-overlay', + name: 'exploded-detail', + value: { + coordinateSpace: 'renderer', + body: [{ type: 'svg', content: freeformExplodedSvg(scene, focus, scale) }], + }, + }], + }; +} + +export function FreeformExplodedDetailStage() { + const mountRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return undefined; + let scene: VectorScene | null = null; + let focus: PlotPoint | null = null; + let detailScale = DETAIL_SCALE; + const interaction: CanvasInteractionDef = { + id: 'freeform-exploded-detail', + eventSource: { + ...inspectTrigger('xy', undefined, undefined, false), + zoom: true, + wheelSensitivity: 0.004, + }, + affordances: [{ target: 'plot', cursor: 'inspect' }], + handle(event) { + if (event.phase === 'cancel') return null; + if (event.action === 'zoom-viewport') { + const viewport = event.geometry.plot; + if (viewport?.kind !== 'viewport' || viewport.factor === undefined || !scene || !focus) return null; + detailScale = Math.min( + DETAIL_MAX_SCALE, + Math.max(DETAIL_MIN_SCALE, detailScale * viewport.factor), + ); + return freeformUpdate(scene, focus, detailScale); + } + if (event.action !== 'inspect-xy') return null; + const geometry = event.geometry.plot; + if (geometry?.kind !== 'point') return null; + scene ??= captureScene(mount); + if (!scene) return null; + focus = { + x: geometry.point.x + scene.origin.x, + y: geometry.point.y + scene.origin.y, + }; + return freeformUpdate(scene, focus, detailScale); + }, + }; + const surface = buildInteractiveChart(mount, CHART_INPUT, { + backend: 'vegalite', + renderer: 'svg', + interactions: [interaction], + ariaLabel: 'Seasonal temperature profiles with freeform exploded neighborhood detail', + chartId: 'freeform-exploded-detail-lines', + }); + void surface.ready.then(() => { + scene = captureScene(mount); + }); + return () => { + surface.destroy(); + }; + }, []); + + return ( +
    +
    + The same treatment through set-freeform-overlay + + A standard Flint inspect event produces the identical focus, clone, bubble, bridge, and + eccentric labels as one renderer-space freeform SVG update. Scroll changes the local + magnification without zooming the chart. + +
    +
    + Flint inspect event + InteractionDef → set-freeform-overlay + Scroll to zoom detail +
    +
    + +
    +
    +
    + +
    +
    NASA POWER · MERRA-2 · 1991–2020 monthly climatology
    +
    + ); +} From af3ff319b3450226dca3a6584ac510d19934c9f1 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 3 Sep 2026 17:50:29 -0700 Subject: [PATCH 31/33] ok --- site/src/playground/BespokeInteractionLab.tsx | 19 +- site/src/playground/ExplodedDetailStage.tsx | 4 +- site/src/playground/RetailDrilldownStage.tsx | 148 +++++++++++++++ site/src/playground/SemanticCutawayStage.tsx | 0 .../playground/power-plant-cutaway-data.ts | 175 ++++++++++++++++++ .../src/playground/retail-drilldown-stage.css | 18 ++ 6 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 site/src/playground/RetailDrilldownStage.tsx create mode 100644 site/src/playground/SemanticCutawayStage.tsx create mode 100644 site/src/playground/power-plant-cutaway-data.ts create mode 100644 site/src/playground/retail-drilldown-stage.css diff --git a/site/src/playground/BespokeInteractionLab.tsx b/site/src/playground/BespokeInteractionLab.tsx index 189b303b..82670971 100644 --- a/site/src/playground/BespokeInteractionLab.tsx +++ b/site/src/playground/BespokeInteractionLab.tsx @@ -2,6 +2,7 @@ import { FlintDimpVisStage } from './FlintDimpVisStage'; import { ClimatePhaseStage } from './ClimatePhaseStage'; import { FisheyeZoomStage } from './FisheyeZoomStage'; import { ExplodedDetailStage, FreeformExplodedDetailStage } from './ExplodedDetailStage'; +import { RetailDrilldownStage } from './RetailDrilldownStage'; import './bespoke-interaction-lab.css'; export function BespokeInteractionLab() { @@ -27,7 +28,6 @@ export function BespokeInteractionLab() { Flint in → Flint out
    - Case 01
    @@ -44,7 +44,6 @@ export function BespokeInteractionLab() { Animation: external in → Flint out
    - Case 02 @@ -61,7 +60,6 @@ export function BespokeInteractionLab() { Rendered SVG → custom out
    - Case 03
    @@ -75,6 +73,21 @@ export function BespokeInteractionLab() {
    + +
    +
    +
    +

    Food basket price navigator

    +

    + Explore how five U.S. average food prices compose a one-unit basket over time. +

    +
    + Flint in → temporal window +
    +
    +
    + +
    ); diff --git a/site/src/playground/ExplodedDetailStage.tsx b/site/src/playground/ExplodedDetailStage.tsx index 6164e716..3e3ec7e8 100644 --- a/site/src/playground/ExplodedDetailStage.tsx +++ b/site/src/playground/ExplodedDetailStage.tsx @@ -13,7 +13,7 @@ import './exploded-detail-stage.css'; const FOCUS_RADIUS = 48; const DETAIL_RADIUS = 108; const DETAIL_OFFSET = 220; -const DETAIL_SCALE = 1.7; +const DETAIL_SCALE = DETAIL_RADIUS / FOCUS_RADIUS; const DETAIL_MIN_SCALE = 1; const DETAIL_MAX_SCALE = 3.4; @@ -167,7 +167,7 @@ function eccentricLabels( } function explodedGeometry(scene: VectorScene, focus: PlotPoint, scale = DETAIL_SCALE) { - const focusRadius = FOCUS_RADIUS * DETAIL_SCALE / scale; + const focusRadius = DETAIL_RADIUS / scale; const placeRight = focus.x + DETAIL_OFFSET + DETAIL_RADIUS <= scene.plot.right; const center = { x: focus.x + (placeRight ? DETAIL_OFFSET : -DETAIL_OFFSET), diff --git a/site/src/playground/RetailDrilldownStage.tsx b/site/src/playground/RetailDrilldownStage.tsx new file mode 100644 index 00000000..c8bbcc79 --- /dev/null +++ b/site/src/playground/RetailDrilldownStage.tsx @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { buildInteractiveChart } from 'flint-chart/interactive'; +import foodPrices from '../data/cpi-food-prices.json'; +import './retail-drilldown-stage.css'; + +type DrillRow = Record & { + Month: string; + Price: number; + Key: string; + MonthIndex: number; + Food: string; +}; + +const BASKET_FOODS = new Set(['Bananas', 'Eggs', 'Ground beef', 'White bread', 'Whole milk']); +const MONTHS = [...new Set(foodPrices.values.map(({ month }) => month))].sort(); +const MONTH_INDEX = new Map(MONTHS.map((month, index) => [month, index])); +const ALL_PRICES: DrillRow[] = foodPrices.values + .filter(({ item }) => BASKET_FOODS.has(item)) + .map(({ month, item, price }) => ({ + Month: month.slice(0, 7), + Price: price, + Key: `${month}-${item}`, + MonthIndex: MONTH_INDEX.get(month) ?? 0, + Food: item, + })); +const MONTH_COUNT = MONTHS.length; +const MIN_VISIBLE_MONTHS = 6; + +function chartInput(rows: DrillRow[]): ChartAssemblyInput { + + return { + data: { values: rows }, + semantic_types: { + Month: 'YearMonth', + Price: { semanticType: 'Price', unit: 'USD' }, + Food: 'Category', + }, + field_display_names: { Price: 'U.S. average price', Food: 'Food' }, + theme_spec: { extends: 'datawrapper', geometry: { band: { cornerRadius: 2 } } }, + options: { addTooltips: false, targetBandAR: 0 }, + chart_spec: { + chartType: 'Stacked Bar Chart', + title: 'What is driving the food basket?', + subtitle: 'Monthly U.S. average prices for one unit of each item · BLS, Aug 2015–Aug 2025', + encodings: { x: 'Month', y: 'Price', color: 'Food' }, + baseSize: { width: 560, height: 400 }, + canvasSize: { width: 560, height: 400 }, + }, + }; +} + +export function RetailDrilldownStage() { + const mountRef = useRef(null); + const activeSurfaceRef = useRef<{ + layer: HTMLDivElement; + surface: ReturnType; + }>(); + const [windowRange, setWindowRange] = useState({ start: 0, end: MONTH_COUNT }); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return undefined; + + const rows = ALL_PRICES.filter((row) => ( + row.MonthIndex >= windowRange.start && row.MonthIndex < windowRange.end + )); + const layer = document.createElement('div'); + layer.className = 'retail-drilldown-layer'; + layer.style.visibility = 'hidden'; + mount.append(layer); + const surface = buildInteractiveChart(layer, chartInput(rows), { + backend: 'vegalite', + renderer: 'svg', + interactions: [], + ariaLabel: 'Monthly U.S. food basket price composition with wheel zoom', + chartId: 'food-price-zoom', + }); + let committed = false; + let cancelled = false; + void surface.ready + .then(() => { + if (cancelled || !mount.isConnected) { + surface.destroy(); + layer.remove(); + return; + } + const previous = activeSurfaceRef.current; + layer.style.visibility = 'visible'; + activeSurfaceRef.current = { layer, surface }; + committed = true; + previous?.surface.destroy(); + previous?.layer.remove(); + }) + .catch(() => { + surface.destroy(); + layer.remove(); + }); + return () => { + cancelled = true; + if (!committed) { + surface.destroy(); + layer.remove(); + } + }; + }, [windowRange]); + + useEffect(() => () => { + activeSurfaceRef.current?.surface.destroy(); + activeSurfaceRef.current?.layer.remove(); + activeSurfaceRef.current = undefined; + }, []); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return undefined; + + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + const rect = mount.getBoundingClientRect(); + const anchor = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + setWindowRange((current) => { + const span = current.end - current.start; + const nextSpan = Math.max( + MIN_VISIBLE_MONTHS, + Math.min(MONTH_COUNT, Math.round(span * Math.exp(event.deltaY * 0.0015))), + ); + const anchorMonth = current.start + span * anchor; + let start = Math.round(anchorMonth - nextSpan * anchor); + start = Math.max(0, Math.min(MONTH_COUNT - nextSpan, start)); + return { start, end: start + nextSpan }; + }); + }; + mount.addEventListener('wheel', onWheel, { capture: true, passive: false }); + return () => { + mount.removeEventListener('wheel', onWheel, { capture: true }); + }; + }, []); + + return ( +
    +
    +
    +
    +
    + ); +} \ No newline at end of file diff --git a/site/src/playground/SemanticCutawayStage.tsx b/site/src/playground/SemanticCutawayStage.tsx new file mode 100644 index 00000000..e69de29b diff --git a/site/src/playground/power-plant-cutaway-data.ts b/site/src/playground/power-plant-cutaway-data.ts new file mode 100644 index 00000000..f14f9cc3 --- /dev/null +++ b/site/src/playground/power-plant-cutaway-data.ts @@ -0,0 +1,175 @@ +// Curated snapshot derived from WRI Global Power Plant Database v1.3.0, CC BY 4.0. + +export type PowerPlantCutawayRow = { + readonly id: string; + readonly continent: string; + readonly country: string; + readonly name: string; + readonly capacity: number; + readonly latitude: number; + readonly longitude: number; + readonly fuel: string; +}; + +export const POWER_PLANT_CUTAWAY_ROWS: readonly PowerPlantCutawayRow[] = [ + { id: "WRI1000103", continent: "Africa", country: "Egypt", name: "Kuriemat 2", capacity: 2754, latitude: 29.2693, longitude: 31.224, fuel: "Gas" }, + { id: "WRI1000074", continent: "Africa", country: "Egypt", name: "North Giza", capacity: 2250, latitude: 30.2483, longitude: 30.9471, fuel: "Gas" }, + { id: "WRI1000091", continent: "Africa", country: "Egypt", name: "Nubaria", capacity: 2250, latitude: 30.6993, longitude: 30.6671, fuel: "Gas" }, + { id: "WRI1000099", continent: "Africa", country: "Egypt", name: "Abu Kir", capacity: 2236, latitude: 31.2694, longitude: 30.1409, fuel: "Gas" }, + { id: "WRI1000106", continent: "Africa", country: "Egypt", name: "High Dam", capacity: 2100, latitude: 23.9721, longitude: 32.8828, fuel: "Hydro" }, + { id: "WRI1000100", continent: "Africa", country: "Egypt", name: "Sidi Krir", capacity: 2092, latitude: 31.043, longitude: 29.6652, fuel: "Gas" }, + { id: "WRI1000070", continent: "Africa", country: "Egypt", name: "Cairo North", capacity: 1500, latitude: 30.108, longitude: 31.266, fuel: "Gas" }, + { id: "WRI1000090", continent: "Africa", country: "Egypt", name: "Talkha", capacity: 1460, latitude: 31.0622, longitude: 31.3921, fuel: "Gas" }, + { id: "WRI1023682", continent: "Africa", country: "Morocco", name: "Centrale Thermique de Jorf Lasfar (JLEC)", capacity: 2020, latitude: 33.1041, longitude: -8.6378, fuel: "Coal" }, + { id: "WRI1061195", continent: "Africa", country: "Morocco", name: "Al Wahda Thermal Power station", capacity: 800, latitude: 34.8, longitude: -5.6, fuel: "Gas" }, + { id: "WRI1023681", continent: "Africa", country: "Morocco", name: "Jerada power station", capacity: 515, latitude: 34.3098, longitude: -2.1902, fuel: "Coal" }, + { id: "WRI1023670", continent: "Africa", country: "Morocco", name: "Ain Beni Mathar Centrale Thermosolaire (CCGT)", capacity: 472, latitude: 34.0705, longitude: -2.1049, fuel: "Gas" }, + { id: "WRI1023710", continent: "Africa", country: "Morocco", name: "STEP UR1 *", capacity: 464, latitude: 32.2065, longitude: -6.531, fuel: "Hydro" }, + { id: "WRI1023677", continent: "Africa", country: "Morocco", name: "Centrale a cycle combine de Tahaddart", capacity: 394, latitude: 35.589, longitude: -5.9868, fuel: "Gas" }, + { id: "WRI1023709", continent: "Africa", country: "Morocco", name: "Parc Eolien Tarfaya", capacity: 301, latitude: 35.611, longitude: -5.432, fuel: "Wind" }, + { id: "WRI1023676", continent: "Africa", country: "Morocco", name: "Central Termique de Kenitra", capacity: 300, latitude: 34.286, longitude: -6.5618, fuel: "Oil" }, + { id: "WRI1000030", continent: "Africa", country: "Nigeria", name: "Alaoji", capacity: 1074, latitude: 5.067, longitude: 7.3216, fuel: "Gas" }, + { id: "WRI1000036", continent: "Africa", country: "Nigeria", name: "Kainji", capacity: 760, latitude: 9.8641, longitude: 4.6124, fuel: "Hydro" }, + { id: "WRI1000031", continent: "Africa", country: "Nigeria", name: "Olorunsogo II", capacity: 750, latitude: 6.8982, longitude: 3.2041, fuel: "Gas" }, + { id: "WRI1000035", continent: "Africa", country: "Nigeria", name: "Shiroro", capacity: 600, latitude: 9.9724, longitude: 6.8353, fuel: "Hydro" }, + { id: "WRI1000025", continent: "Africa", country: "Nigeria", name: "Calabar", capacity: 561, latitude: 5.0702, longitude: 8.3394, fuel: "Gas" }, + { id: "WRI1000037", continent: "Africa", country: "Nigeria", name: "Jebba", capacity: 540, latitude: 9.138, longitude: 4.7883, fuel: "Hydro" }, + { id: "WRI1000032", continent: "Africa", country: "Nigeria", name: "Omotosho II", capacity: 500, latitude: 6.7357, longitude: 4.7106, fuel: "Gas" }, + { id: "WRI1000026", continent: "Africa", country: "Nigeria", name: "Ihovbor", capacity: 450, latitude: 6.4065, longitude: 5.6828, fuel: "Gas" }, + { id: "WRI1000125", continent: "Africa", country: "South Africa", name: "Kendal power station", capacity: 4116, latitude: -26.088, longitude: 28.9689, fuel: "Coal" }, + { id: "WRI1000129", continent: "Africa", country: "South Africa", name: "Majuba power station", capacity: 4110, latitude: -27.0955, longitude: 29.7706, fuel: "Coal" }, + { id: "WRI1000130", continent: "Africa", country: "South Africa", name: "Matimba power station", capacity: 3990, latitude: -23.6678, longitude: 27.6128, fuel: "Coal" }, + { id: "WRI1000128", continent: "Africa", country: "South Africa", name: "Lethabo power station", capacity: 3708, latitude: -26.7403, longitude: 27.975, fuel: "Coal" }, + { id: "WRI1000135", continent: "Africa", country: "South Africa", name: "Tutuka power station", capacity: 3654, latitude: -26.7767, longitude: 29.3527, fuel: "Coal" }, + { id: "WRI1000119", continent: "Africa", country: "South Africa", name: "Duvha power station", capacity: 3600, latitude: -25.9595, longitude: 29.3409, fuel: "Coal" }, + { id: "WRI1000131", continent: "Africa", country: "South Africa", name: "Matla power station", capacity: 3600, latitude: -26.2804, longitude: 29.1423, fuel: "Coal" }, + { id: "WRI1000127", continent: "Africa", country: "South Africa", name: "Kriel power station", capacity: 3000, latitude: -26.254, longitude: 29.1801, fuel: "Coal" }, + { id: "WRI1000452", continent: "Asia", country: "China", name: "Three Gorges Dam", capacity: 22500, latitude: 30.8235, longitude: 111.0032, fuel: "Hydro" }, + { id: "WRI1070877", continent: "Asia", country: "China", name: "Baihetan Dam", capacity: 13050, latitude: 28.2606, longitude: 103.6484, fuel: "Hydro" }, + { id: "WRI1000453", continent: "Asia", country: "China", name: "Xiluodu", capacity: 12600, latitude: 28.26, longitude: 103.65, fuel: "Hydro" }, + { id: "WRI1075600", continent: "Asia", country: "China", name: "East Hope Metals Wucaiwan power station", capacity: 7000, latitude: 44.6885, longitude: 89.1138, fuel: "Coal" }, + { id: "WRI1070659", continent: "Asia", country: "China", name: "Datang Tuoketuo power station", capacity: 6720, latitude: 40.1947, longitude: 111.3589, fuel: "Coal" }, + { id: "WRI1000454", continent: "Asia", country: "China", name: "Xiangjiaba", capacity: 6448, latitude: 28.6437, longitude: 104.393, fuel: "Hydro" }, + { id: "WRI1000455", continent: "Asia", country: "China", name: "Longtan", capacity: 6300, latitude: 25.0277, longitude: 107.0431, fuel: "Hydro" }, + { id: "WRI1023878", continent: "Asia", country: "China", name: "Gansu Wind Farm", capacity: 6000, latitude: 40.6876, longitude: 95.7329, fuel: "Wind" }, + { id: "IND0000503", continent: "Asia", country: "India", name: "VINDH_CHAL STPS", capacity: 4760, latitude: 24.0983, longitude: 82.6719, fuel: "Coal" }, + { id: "IND0000278", continent: "Asia", country: "India", name: "MUNDRA TPP", capacity: 4620, latitude: 22.823, longitude: 69.5532, fuel: "Coal" }, + { id: "IND0000279", continent: "Asia", country: "India", name: "MUNDRA UMPP", capacity: 4000, latitude: 22.8158, longitude: 69.5281, fuel: "Coal" }, + { id: "IND0000395", continent: "Asia", country: "India", name: "SASAN UMPP", capacity: 3960, latitude: 23.9784, longitude: 82.6275, fuel: "Coal" }, + { id: "IND0000457", continent: "Asia", country: "India", name: "TIRORA TPP", capacity: 3300, latitude: 21.4129, longitude: 79.9671, fuel: "Coal" }, + { id: "IND0000375", continent: "Asia", country: "India", name: "RIHAND", capacity: 3000, latitude: 24.027, longitude: 82.7915, fuel: "Coal" }, + { id: "IND0000439", continent: "Asia", country: "India", name: "TALCHER STPS", capacity: 3000, latitude: 21.0966, longitude: 85.074, fuel: "Coal" }, + { id: "IND0000417", continent: "Asia", country: "India", name: "SIPAT STPS", capacity: 2980, latitude: 22.13, longitude: 82.293, fuel: "Coal" }, + { id: "WRI1000679", continent: "Asia", country: "Japan", name: "Kashiwazaki Kariwa", capacity: 8212, latitude: 37.4259, longitude: 138.5941, fuel: "Nuclear" }, + { id: "WRI1000621", continent: "Asia", country: "Japan", name: "Futtsu", capacity: 5040, latitude: 35.3421, longitude: 139.8319, fuel: "Gas" }, + { id: "WRI1000617", continent: "Asia", country: "Japan", name: "Higashi Niigata", capacity: 4810, latitude: 37.9963, longitude: 139.2373, fuel: "Gas" }, + { id: "WRI1000636", continent: "Asia", country: "Japan", name: "Kawagoe", capacity: 4802, latitude: 35.0076, longitude: 136.689, fuel: "Gas" }, + { id: "WRI1000684", continent: "Asia", country: "Japan", name: "Ohi", capacity: 4710, latitude: 35.5424, longitude: 135.6544, fuel: "Nuclear" }, + { id: "WRI1000622", continent: "Asia", country: "Japan", name: "Kashima", capacity: 4400, latitude: 35.9409, longitude: 140.6888, fuel: "Oil" }, + { id: "WRI1000623", continent: "Asia", country: "Japan", name: "Hirono", capacity: 4400, latitude: 37.2351, longitude: 141.0162, fuel: "Oil" }, + { id: "WRI1000678", continent: "Asia", country: "Japan", name: "Fukushima Daina", capacity: 4400, latitude: 37.3164, longitude: 141.0265, fuel: "Nuclear" }, + { id: "WRI1000217", continent: "Asia", country: "South Korea", name: "Hanbit", capacity: 5900, latitude: 35.4105, longitude: 126.4175, fuel: "Nuclear" }, + { id: "WRI1000218", continent: "Asia", country: "South Korea", name: "Hanul", capacity: 5900, latitude: 37.0931, longitude: 129.383, fuel: "Nuclear" }, + { id: "WRI1000187", continent: "Asia", country: "South Korea", name: "Yeongheung", capacity: 5080, latitude: 37.2369, longitude: 126.4361, fuel: "Coal" }, + { id: "WRI1000191", continent: "Asia", country: "South Korea", name: "Boryeong (poryang)", capacity: 4000, latitude: 36.402, longitude: 126.49, fuel: "Coal" }, + { id: "WRI1000196", continent: "Asia", country: "South Korea", name: "Taean", capacity: 4000, latitude: 36.904, longitude: 126.233, fuel: "Coal" }, + { id: "WRI1000202", continent: "Asia", country: "South Korea", name: "Hadong", capacity: 4000, latitude: 34.9512, longitude: 127.8213, fuel: "Coal" }, + { id: "WRI1000208", continent: "Asia", country: "South Korea", name: "Dangjin", capacity: 4000, latitude: 37.0543, longitude: 126.5133, fuel: "Coal" }, + { id: "WRI1000214", continent: "Asia", country: "South Korea", name: "Shin-Kori", capacity: 3340, latitude: 35.3271, longitude: 129.3017, fuel: "Nuclear" }, + { id: "WRI1002735", continent: "Europe", country: "France", name: "GRAVELINES", capacity: 5460, latitude: 51.0141, longitude: 2.1332, fuel: "Nuclear" }, + { id: "WRI1002752", continent: "Europe", country: "France", name: "PALUEL", capacity: 5320, latitude: 49.8582, longitude: 0.6354, fuel: "Nuclear" }, + { id: "WRI1002703", continent: "Europe", country: "France", name: "CATTENOM", capacity: 5200, latitude: 49.416, longitude: 6.2169, fuel: "Nuclear" }, + { id: "WRI1002716", continent: "Europe", country: "France", name: "CRUAS", capacity: 3660, latitude: 44.6325, longitude: 4.7546, fuel: "Nuclear" }, + { id: "WRI1002782", continent: "Europe", country: "France", name: "TRICASTIN 1", capacity: 3660, latitude: 44.3311, longitude: 4.7311, fuel: "Nuclear" }, + { id: "WRI1002694", continent: "Europe", country: "France", name: "BLAYAIS", capacity: 3640, latitude: 45.256, longitude: -0.6932, fuel: "Nuclear" }, + { id: "WRI1002706", continent: "Europe", country: "France", name: "CHINON", capacity: 3620, latitude: 47.2254, longitude: 0.1656, fuel: "Nuclear" }, + { id: "WRI1002701", continent: "Europe", country: "France", name: "BUGEY", capacity: 3580, latitude: 45.7973, longitude: 5.2706, fuel: "Nuclear" }, + { id: "WRI1005979", continent: "Europe", country: "Germany", name: "Niederaussem power station", capacity: 3430, latitude: 50.993, longitude: 6.6685, fuel: "Coal" }, + { id: "WRI1005906", continent: "Europe", country: "Germany", name: "Janschwalde power station", capacity: 2790, latitude: 51.8344, longitude: 14.459, fuel: "Coal" }, + { id: "WRI1005616", continent: "Europe", country: "Germany", name: "Boxberg power station", capacity: 2585, latitude: 51.4163, longitude: 14.5619, fuel: "Coal" }, + { id: "WRI1005856", continent: "Europe", country: "Germany", name: "Kernkraft Gundremmingen", capacity: 2572, latitude: 48.515, longitude: 10.4016, fuel: "Nuclear" }, + { id: "WRI1005614", continent: "Europe", country: "Germany", name: "BoA 2", capacity: 2100, latitude: 51.0365, longitude: 6.6133, fuel: "Coal" }, + { id: "WRI1005977", continent: "Europe", country: "Germany", name: "Neurath power station", capacity: 2068, latitude: 51.0395, longitude: 6.615, fuel: "Coal" }, + { id: "WRI1005694", continent: "Europe", country: "Germany", name: "Gersteinwerk", capacity: 2004.5, latitude: 51.6725, longitude: 7.7099, fuel: "Gas" }, + { id: "WRI1005699", continent: "Europe", country: "Germany", name: "GKM (Mannheim) power station", capacity: 1958, latitude: 49.4452, longitude: 8.4891, fuel: "Coal" }, + { id: "WRI1006358", continent: "Europe", country: "Spain", name: "CN ALMARAZ 1", capacity: 2016.9, latitude: 39.807, longitude: -5.6986, fuel: "Nuclear" }, + { id: "WRI1006213", continent: "Europe", country: "Spain", name: "ASCO GR", capacity: 1990.5, latitude: 41.2008, longitude: 0.5679, fuel: "Nuclear" }, + { id: "WRI1006249", continent: "Europe", country: "Spain", name: "BESOS GRUPO 5", capacity: 1670.81, latitude: 41.4196, longitude: 2.2294, fuel: "Gas" }, + { id: "WRI1006203", continent: "Europe", country: "Spain", name: "ARCOS DE LA FRONTERA GRUPO 1", capacity: 1585.3899999999999, latitude: 36.6721, longitude: -5.8164, fuel: "Gas" }, + { id: "WRI1006308", continent: "Europe", country: "Spain", name: "CARTAGENA GRUPO 1", capacity: 1248.82, latitude: 37.5732, longitude: -0.9392, fuel: "Gas" }, + { id: "WRI1006323", continent: "Europe", country: "Spain", name: "CCC SAGUNTO GRUPO 2", capacity: 1232.19, latitude: 39.6426, longitude: -0.2344, fuel: "Gas" }, + { id: "WRI1006178", continent: "Europe", country: "Spain", name: "ALDEADAVILA II 2", capacity: 1226.43, latitude: 41.2117, longitude: -6.6856, fuel: "Hydro" }, + { id: "WRI1006467", continent: "Europe", country: "Spain", name: "ESCOMBRERAS GRUPO 1", capacity: 1199.25, latitude: 7.5732, longitude: -0.9392, fuel: "Gas" }, + { id: "GBR1000372", continent: "Europe", country: "United Kingdom", name: "Pembroke", capacity: 2180, latitude: 51.685, longitude: -4.99, fuel: "Gas" }, + { id: "GBR1000143", continent: "Europe", country: "United Kingdom", name: "West Burton", capacity: 2012, latitude: 53.3604, longitude: -0.8102, fuel: "Coal" }, + { id: "GBR1000142", continent: "Europe", country: "United Kingdom", name: "Cottam", capacity: 2008, latitude: 53.304, longitude: -0.7815, fuel: "Coal" }, + { id: "GBR1000496", continent: "Europe", country: "United Kingdom", name: "Ratcliffe", capacity: 2000, latitude: 52.8653, longitude: -1.255, fuel: "Coal" }, + { id: "GBR0000174", continent: "Europe", country: "United Kingdom", name: "Drax", capacity: 1980, latitude: 53.7356, longitude: -0.9911, fuel: "Coal" }, + { id: "GBR1000147", continent: "Europe", country: "United Kingdom", name: "Eggborough", capacity: 1960, latitude: 53.7116, longitude: -1.1269, fuel: "Coal" }, + { id: "GBR1000151", continent: "Europe", country: "United Kingdom", name: "Dinorwig", capacity: 1800, latitude: 53.1181, longitude: -4.1032, fuel: "Hydro" }, + { id: "GBR1000373", continent: "Europe", country: "United Kingdom", name: "Staythorpe C", capacity: 1772, latitude: 53.073, longitude: -0.8585, fuel: "Gas" }, + { id: "CAN0002223", continent: "North America", country: "Canada", name: "Robert-Bourassa", capacity: 5616, latitude: 53.7818, longitude: -77.5305, fuel: "Hydro" }, + { id: "CAN0002128", continent: "North America", country: "Canada", name: "Churchill Falls", capacity: 5428, latitude: 53.5294, longitude: -63.9651, fuel: "Hydro" }, + { id: "CAN0002043", continent: "North America", country: "Canada", name: "Darlington", capacity: 3740, latitude: 43.8697, longitude: -78.7239, fuel: "Nuclear" }, + { id: "CAN0002031", continent: "North America", country: "Canada", name: "Bruce B", capacity: 3390, latitude: 44.319, longitude: -81.6027, fuel: "Nuclear" }, + { id: "CAN0002030", continent: "North America", country: "Canada", name: "Bruce A", capacity: 3220, latitude: 44.3391, longitude: -81.5747, fuel: "Nuclear" }, + { id: "CAN0002176", continent: "North America", country: "Canada", name: "La Grande-4", capacity: 2779, latitude: 53.8864, longitude: -73.466, fuel: "Hydro" }, + { id: "CAN0002196", continent: "North America", country: "Canada", name: "Mica", capacity: 2746, latitude: 52.0759, longitude: -118.5705, fuel: "Hydro" }, + { id: "CAN0002146", continent: "North America", country: "Canada", name: "G.M. Shrum", capacity: 2730, latitude: 56.0148, longitude: -122.1957, fuel: "Hydro" }, + { id: "MEX0001765", continent: "North America", country: "Mexico", name: "Plutarco El\u00edas Calles (Petacalco)", capacity: 2778.4, latitude: 17.9837, longitude: -102.1154, fuel: "Coal" }, + { id: "MEX0001853", continent: "North America", country: "Mexico", name: "Manuel Moreno Torres (Chicoas\u00e9n)", capacity: 2400, latitude: 16.9428, longitude: -93.1012, fuel: "Hydro" }, + { id: "MEX0001766", continent: "North America", country: "Mexico", name: "Adolfo L\u00f3pez Mateos (Tuxpan)", capacity: 2100, latitude: 21.0151, longitude: -97.3334, fuel: "Oil" }, + { id: "MEX0001767", continent: "North America", country: "Mexico", name: "Francisco P\u00e9rez R\u00edos (Tula)", capacity: 1605.6, latitude: 20.0545, longitude: -99.2764, fuel: "Oil" }, + { id: "MEX0001769", continent: "North America", country: "Mexico", name: "Laguna Verde", capacity: 1510, latitude: 19.7208, longitude: -96.4064, fuel: "Nuclear" }, + { id: "MEX0001768", continent: "North America", country: "Mexico", name: "Carb\u00f3n II", capacity: 1400, latitude: 28.4682, longitude: -100.7003, fuel: "Coal" }, + { id: "MEX0001770", continent: "North America", country: "Mexico", name: "Manuel \u00c1lvarez Moreno (Manzanillo)", capacity: 1300, latitude: 19.0278, longitude: -104.3192, fuel: "Oil" }, + { id: "MEX0001771", continent: "North America", country: "Mexico", name: "Jos\u00e9 L\u00f3pez Portillo (R\u00edo Escondido)", capacity: 1200, latitude: 28.4844, longitude: -100.6897, fuel: "Coal" }, + { id: "USA0006163", continent: "North America", country: "United States of America", name: "Grand Coulee", capacity: 6809, latitude: 47.9575, longitude: -118.9773, fuel: "Hydro" }, + { id: "USA0056407", continent: "North America", country: "United States of America", name: "West County Energy Center", capacity: 4263, latitude: 26.6986, longitude: -80.3747, fuel: "Gas" }, + { id: "USA0006008", continent: "North America", country: "United States of America", name: "Palo Verde", capacity: 4209.6, latitude: 33.3881, longitude: -112.8617, fuel: "Nuclear" }, + { id: "USA0003470", continent: "North America", country: "United States of America", name: "W A Parish", capacity: 4008.4, latitude: 29.4828, longitude: -95.6311, fuel: "Coal" }, + { id: "USA0006257", continent: "North America", country: "United States of America", name: "Scherer", capacity: 3564, latitude: 33.0606, longitude: -83.8075, fuel: "Coal" }, + { id: "USA0000703", continent: "North America", country: "United States of America", name: "Bowen", capacity: 3498.6, latitude: 34.1256, longitude: -84.9222, fuel: "Coal" }, + { id: "USA0000046", continent: "North America", country: "United States of America", name: "Browns Ferry", capacity: 3494, latitude: 34.7042, longitude: -87.1189, fuel: "Nuclear" }, + { id: "USA0000628", continent: "North America", country: "United States of America", name: "Crystal River", capacity: 3449, latitude: 28.9656, longitude: -82.6977, fuel: "Gas" }, + { id: "AUS0000265", continent: "Oceania", country: "Australia", name: "Bayswater", capacity: 2640, latitude: -32.3953, longitude: 150.9491, fuel: "Coal" }, + { id: "AUS0000280", continent: "Oceania", country: "Australia", name: "Liddell", capacity: 2200, latitude: -32.3713, longitude: 150.9776, fuel: "Coal" }, + { id: "AUS0000092", continent: "Oceania", country: "Australia", name: "Loy Yang A", capacity: 2180, latitude: -38.2536, longitude: 146.5746, fuel: "Coal" }, + { id: "AUS0000158", continent: "Oceania", country: "Australia", name: "Gladstone", capacity: 1680, latitude: -23.8508, longitude: 151.2187, fuel: "Coal" }, + { id: "AUS0000087", continent: "Oceania", country: "Australia", name: "Hazelwood", capacity: 1600, latitude: -38.2731, longitude: 146.3923, fuel: "Coal" }, + { id: "AUS0000031", continent: "Oceania", country: "Australia", name: "Tumut 3", capacity: 1500, latitude: -35.6112, longitude: 148.2917, fuel: "Hydro" }, + { id: "AUS0000100", continent: "Oceania", country: "Australia", name: "Yallourn", capacity: 1480, latitude: -38.177, longitude: 146.3428, fuel: "Coal" }, + { id: "AUS0000168", continent: "Oceania", country: "Australia", name: "Stanwell", capacity: 1460, latitude: -23.5097, longitude: 150.3195, fuel: "Coal" }, + { id: "WRI1000304", continent: "Oceania", country: "New Zealand", name: "Manapouri", capacity: 800, latitude: -45.5214, longitude: 167.2778, fuel: "Hydro" }, + { id: "WRI1000300", continent: "Oceania", country: "New Zealand", name: "Ohau A", capacity: 688, latitude: -44.3416, longitude: 170.1813, fuel: "Hydro" }, + { id: "WRI1000301", continent: "Oceania", country: "New Zealand", name: "Benmore", capacity: 540, latitude: -44.564, longitude: 170.1972, fuel: "Hydro" }, + { id: "WRI1000306", continent: "Oceania", country: "New Zealand", name: "Huntly (steam)", capacity: 500, latitude: -37.5444, longitude: 175.15, fuel: "Coal" }, + { id: "WRI1000328", continent: "Oceania", country: "New Zealand", name: "Clyde", capacity: 432, latitude: -45.1793, longitude: 169.307, fuel: "Hydro" }, + { id: "WRI1000305", continent: "Oceania", country: "New Zealand", name: "Huntly (CC)", capacity: 403, latitude: -37.5444, longitude: 175.15, fuel: "Gas" }, + { id: "WRI1000318", continent: "Oceania", country: "New Zealand", name: "Maraetai", capacity: 352, latitude: -38.3521, longitude: 175.7465, fuel: "Hydro" }, + { id: "WRI1000302", continent: "Oceania", country: "New Zealand", name: "Aviemore", capacity: 220, latitude: -44.656, longitude: 170.3551, fuel: "Hydro" }, + { id: "ARG0000046", continent: "South America", country: "Argentina", name: "COSTANERA", capacity: 1982.2, latitude: -34.626, longitude: -58.3393, fuel: "Coal" }, + { id: "ARG0000136", continent: "South America", country: "Argentina", name: "YACYRETA", capacity: 1550, latitude: -27.4827, longitude: -56.7397, fuel: "Hydro" }, + { id: "ARG0000233", continent: "South America", country: "Argentina", name: "PIEDRA DEL AGUILA (CPSA)", capacity: 1400, latitude: -31.6651, longitude: -63.8314, fuel: "Hydro" }, + { id: "ARG0000044", continent: "South America", country: "Argentina", name: "NUEVO PUERTO", capacity: 1217.7, latitude: -34.5721, longitude: -58.3835, fuel: "Gas" }, + { id: "ARG0000255", continent: "South America", country: "Argentina", name: "EL CHOCON", capacity: 1200, latitude: -39.2577, longitude: -68.7483, fuel: "Hydro" }, + { id: "ARG0000230", continent: "South America", country: "Argentina", name: "ALICURA", capacity: 1050, latitude: -40.58, longitude: -70.7489, fuel: "Hydro" }, + { id: "ARG0000144", continent: "South America", country: "Argentina", name: "SALTO GRANDE (MITAD ARGENTINA)", capacity: 945, latitude: -31.3891, longitude: -68.6752, fuel: "Hydro" }, + { id: "ARG0000158", continent: "South America", country: "Argentina", name: "DOCK SUD", capacity: 872.3, latitude: -34.6533, longitude: -58.342, fuel: "Gas" }, + { id: "BRA0002889", continent: "South America", country: "Brazil", name: "Tucuru\u00ed", capacity: 8535, latitude: -3.8322, longitude: -49.6522, fuel: "Hydro" }, + { id: "BRA0001161", continent: "South America", country: "Brazil", name: "Itaipu (Parte Brasileira)", capacity: 7000, latitude: -25.4269, longitude: -54.5931, fuel: "Hydro" }, + { id: "BRA0029736", continent: "South America", country: "Brazil", name: "Jirau", capacity: 3750, latitude: -9.2664, longitude: -64.6478, fuel: "Hydro" }, + { id: "BRA0029707", continent: "South America", country: "Brazil", name: "Santo Ant\u00f4nio", capacity: 3568, latitude: -8.8011, longitude: -63.9497, fuel: "Hydro" }, + { id: "BRA0001120", continent: "South America", country: "Brazil", name: "Ilha Solteira", capacity: 3444, latitude: -20.3822, longitude: -51.3636, fuel: "Hydro" }, + { id: "BRA0030354", continent: "South America", country: "Brazil", name: "Belo Monte", capacity: 3327.45544, latitude: -3.1264, longitude: -51.775, fuel: "Hydro" }, + { id: "BRA0027053", continent: "South America", country: "Brazil", name: "Xing\u00f3", capacity: 3162, latitude: -9.6209, longitude: -37.7924, fuel: "Hydro" }, + { id: "BRA0027050", continent: "South America", country: "Brazil", name: "Paulo Afonso IV", capacity: 2462.4, latitude: -9.4132, longitude: -38.2106, fuel: "Hydro" }, + { id: "CHL0000017", continent: "South America", country: "Chile", name: "TERMOELECTRICA TOCOPILLA (U12)", capacity: 1001.7, latitude: -22.091, longitude: -70.2126, fuel: "Coal" }, + { id: "CHL0000117", continent: "South America", country: "Chile", name: "NEHUENCO", capacity: 874.7, latitude: -32.9366, longitude: -71.3231, fuel: "Gas" }, + { id: "CHL0000002", continent: "South America", country: "Chile", name: "ATACAMA (CC1-CC2)", capacity: 767.8, latitude: -23.0898, longitude: -70.4168, fuel: "Gas" }, + { id: "CHL0000053", continent: "South America", country: "Chile", name: "GUACOLDA", capacity: 760, latitude: -28.4673, longitude: -71.2573, fuel: "Coal" }, + { id: "CHL0001106", continent: "South America", country: "Chile", name: "RALCO", capacity: 690, latitude: -37.9969, longitude: -71.5199, fuel: "Hydro" }, + { id: "CHL0001092", continent: "South America", country: "Chile", name: "PEHUENCHE", capacity: 570, latitude: -35.7338, longitude: -71.1555, fuel: "Hydro" }, + { id: "CHL0000013", continent: "South America", country: "Chile", name: "TERMOELECTRICA ANGAMOS 1(ANG1)_ 2(ANG2)", capacity: 488.3, latitude: -23.0677, longitude: -70.369, fuel: "Coal" }, + { id: "CHL0000057", continent: "South America", country: "Chile", name: "BOCAMINA (I-II)", capacity: 478, latitude: -37.0215, longitude: -73.1673, fuel: "Coal" }, +]; diff --git a/site/src/playground/retail-drilldown-stage.css b/site/src/playground/retail-drilldown-stage.css new file mode 100644 index 00000000..1fbfd952 --- /dev/null +++ b/site/src/playground/retail-drilldown-stage.css @@ -0,0 +1,18 @@ +.retail-drilldown-stage { + padding: 18px; +} + +.retail-drilldown-chart { + overflow: hidden; + overscroll-behavior: contain; +} + +.retail-drilldown-mount { + position: relative; + height: 500px; +} + +.retail-drilldown-layer { + position: absolute; + inset: 0 auto auto 0; +} \ No newline at end of file From 72c24f2d73367a003da171e3ff45bc0af8eabb1f Mon Sep 17 00:00:00 2001 From: lx9days <571768326@qq.com> Date: Fri, 4 Sep 2026 22:51:33 +0800 Subject: [PATCH 32/33] Add index chart bespoke interaction prototype. Introduce a new Flint-based index chart example on the bespoke interaction page with supporting data, model, and styles. --- site/src/data/index-chart-stocks.ts | 63 +++++ site/src/playground/BespokeInteractionLab.tsx | 42 +-- site/src/playground/IndexChartStage.tsx | 263 ++++++++++++++++++ site/src/playground/index-chart-model.ts | 197 +++++++++++++ site/src/playground/index-chart-stage.css | 53 ++++ 5 files changed, 599 insertions(+), 19 deletions(-) create mode 100644 site/src/data/index-chart-stocks.ts create mode 100644 site/src/playground/IndexChartStage.tsx create mode 100644 site/src/playground/index-chart-model.ts create mode 100644 site/src/playground/index-chart-stage.css diff --git a/site/src/data/index-chart-stocks.ts b/site/src/data/index-chart-stocks.ts new file mode 100644 index 00000000..17be5fa6 --- /dev/null +++ b/site/src/data/index-chart-stocks.ts @@ -0,0 +1,63 @@ +export interface IndexChartStockRow { + Symbol: 'AAPL' | 'AMZN' | 'GOOG' | 'IBM' | 'MSFT'; + Date: string; + Close: number; +} + +// Sampled from the D3/Vega index chart reference dataset. +// A few rows are intentionally omitted so the prototype exercises +// nearest-date fallback when a symbol lacks the active reference date. +export const INDEX_CHART_STOCKS: IndexChartStockRow[] = [ + { Symbol: 'AAPL', Date: '2013-05-13', Close: 64.9629 }, + { Symbol: 'AAPL', Date: '2013-11-08', Close: 74.3657 }, + { Symbol: 'AAPL', Date: '2014-05-13', Close: 84.8229 }, + { Symbol: 'AAPL', Date: '2014-11-10', Close: 108.83 }, + { Symbol: 'AAPL', Date: '2015-05-13', Close: 126.01 }, + { Symbol: 'AAPL', Date: '2015-11-10', Close: 116.77 }, + { Symbol: 'AAPL', Date: '2016-05-12', Close: 90.34 }, + { Symbol: 'AAPL', Date: '2016-11-09', Close: 110.88 }, + { Symbol: 'AAPL', Date: '2017-05-12', Close: 156.1 }, + { Symbol: 'AAPL', Date: '2017-11-09', Close: 175.88 }, + + { Symbol: 'AMZN', Date: '2013-05-13', Close: 264.51 }, + { Symbol: 'AMZN', Date: '2013-11-08', Close: 350.31 }, + { Symbol: 'AMZN', Date: '2014-05-13', Close: 304.64 }, + { Symbol: 'AMZN', Date: '2014-11-10', Close: 305.11 }, + { Symbol: 'AMZN', Date: '2015-05-13', Close: 426.87 }, + { Symbol: 'AMZN', Date: '2015-11-10', Close: 659.68 }, + { Symbol: 'AMZN', Date: '2016-05-12', Close: 717.93 }, + { Symbol: 'AMZN', Date: '2016-11-09', Close: 771.88 }, + { Symbol: 'AMZN', Date: '2017-05-12', Close: 961.35 }, + { Symbol: 'AMZN', Date: '2017-11-09', Close: 1129.13 }, + + { Symbol: 'GOOG', Date: '2013-05-13', Close: 435.9297 }, + { Symbol: 'GOOG', Date: '2013-11-08', Close: 504.7322 }, + { Symbol: 'GOOG', Date: '2014-05-13', Close: 530.1748 }, + { Symbol: 'GOOG', Date: '2014-11-10', Close: 544.4961 }, + { Symbol: 'GOOG', Date: '2015-11-10', Close: 728.32 }, + { Symbol: 'GOOG', Date: '2016-05-12', Close: 713.31 }, + { Symbol: 'GOOG', Date: '2016-11-09', Close: 785.31 }, + { Symbol: 'GOOG', Date: '2017-05-12', Close: 932.22 }, + { Symbol: 'GOOG', Date: '2017-11-09', Close: 1031.26 }, + + { Symbol: 'IBM', Date: '2013-05-13', Close: 202.47 }, + { Symbol: 'IBM', Date: '2013-11-08', Close: 179.99 }, + { Symbol: 'IBM', Date: '2014-05-13', Close: 192.19 }, + { Symbol: 'IBM', Date: '2014-11-10', Close: 163.49 }, + { Symbol: 'IBM', Date: '2015-05-13', Close: 172.28 }, + { Symbol: 'IBM', Date: '2015-11-10', Close: 135.47 }, + { Symbol: 'IBM', Date: '2016-05-12', Close: 148.84 }, + { Symbol: 'IBM', Date: '2017-05-12', Close: 150.37 }, + { Symbol: 'IBM', Date: '2017-11-09', Close: 150.3 }, + + { Symbol: 'MSFT', Date: '2013-05-13', Close: 33.03 }, + { Symbol: 'MSFT', Date: '2013-11-08', Close: 37.78 }, + { Symbol: 'MSFT', Date: '2014-05-13', Close: 40.42 }, + { Symbol: 'MSFT', Date: '2014-11-10', Close: 48.89 }, + { Symbol: 'MSFT', Date: '2015-05-13', Close: 47.63 }, + { Symbol: 'MSFT', Date: '2015-11-10', Close: 53.51 }, + { Symbol: 'MSFT', Date: '2016-05-12', Close: 51.51 }, + { Symbol: 'MSFT', Date: '2016-11-09', Close: 60.17 }, + { Symbol: 'MSFT', Date: '2017-05-12', Close: 68.38 }, + { Symbol: 'MSFT', Date: '2017-11-09', Close: 84.09 }, +]; diff --git a/site/src/playground/BespokeInteractionLab.tsx b/site/src/playground/BespokeInteractionLab.tsx index 82670971..9059e9a3 100644 --- a/site/src/playground/BespokeInteractionLab.tsx +++ b/site/src/playground/BespokeInteractionLab.tsx @@ -1,8 +1,8 @@ import { FlintDimpVisStage } from './FlintDimpVisStage'; import { ClimatePhaseStage } from './ClimatePhaseStage'; import { FisheyeZoomStage } from './FisheyeZoomStage'; -import { ExplodedDetailStage, FreeformExplodedDetailStage } from './ExplodedDetailStage'; -import { RetailDrilldownStage } from './RetailDrilldownStage'; +import { ExplodedDetailStage } from './ExplodedDetailStage'; +import { IndexChartStage } from './IndexChartStage'; import './bespoke-interaction-lab.css'; export function BespokeInteractionLab() { @@ -28,6 +28,7 @@ export function BespokeInteractionLab() { Flint in → Flint out
    + Case 01 @@ -44,10 +45,29 @@ export function BespokeInteractionLab() { Animation: external in → Flint out
    + Case 02 +
    +
    +
    +

    Index chart with host-owned reference cursor

    +

    + Re-index the same stock series against a movable date while the overlay owns pointer acquisition + and the active reference marker. +

    +
    + Flint in → Flint out + Host overlay → custom out +
    +
    + Case 03 +
    + +
    +
    @@ -60,6 +80,7 @@ export function BespokeInteractionLab() { Rendered SVG → custom out
    + Case 04
    @@ -68,26 +89,9 @@ export function BespokeInteractionLab() {
    -
    - -
    -
    -
    -
    -

    Food basket price navigator

    -

    - Explore how five U.S. average food prices compose a one-unit basket over time. -

    -
    - Flint in → temporal window -
    -
    -
    - -

    6lvAz~ zDEd^8oS^;X$;HJ*E$Qq0;>{kl@#yHoXo5qs6|xQb+&9x($2#tuQGjkpOiaA}T%IsN zU2ScAe0&*owH-9+L1bwSEH*snj&E)AxRWp-R%m`+T?hHRGjFJGRl^mgU3dM_=D9>z{9+u6M~U@Qj;d5(|lM_KZ;v^c-A zvPPJP$1*XF>OPM?mrpoQTmq~MbdCOz;tS3L$;nB@?+z~@QKq9?Ud^GwTez~Ma!zwx zgB0$AlMa(Y8kcj25+ql>Ex)pK_3sZWHR;jP*t5Jox(Gy({3s^Bc$2HwAsB}KirOVw zEp0ca4#Sg3|7-GBtzi7k&*udXs~>=)S>f_C%}ZaI@WL&97Kf5RaIWNPXYrmVUb43; zf(Kk6EZ`+QQWo?Yplaj>r(R&*%V$Lets7=3xJ-ZtDZCCd#r5+uy@^bQpzF$QRY(rt zm6MJwAVCL~Jv8lo*78*)!>J#=buJl$+X@;(O!u=Y6En%?b3Oow9MLBHf7SAxqu!K% zx`oHhf66tWm;ZkXLf}6wePboxDBVUUiLk31F+tr>993Etp4%tV=&9Hsr2N7+OLe!K zk@%LeU*h*I`(G~L9HPSH@^d%5oXmAJ7N@c2=TM^gGa<4mciV45FLcX7ZmF}|>X0ea z^XaD%^@GvDalJ85oxhy6)KM#(;mi?Ii)$xX`1vAE4WW5tuBhmX#wqRcM4gUD6;nfn zg|rZ`ZA8Upx>fOmqB}q6{<{`k8oCl;26zq7tVY;|d%TedUVnF^?~eOp+XZciJxqE|*SS6|tb^EM@iPd$&5xa$sb1&tFV z-X?L&TjgG0;bV8O&dkS5{Tg4frcp^$u&)c_LS-xAbrgUV|H^NW`M3wwoCdgljXPmg z3+Gf?^kiE`R<*;k@}-?}IZDzrj+mF<%G?u`bar)ex&|x?5@J$gLK?%?N-1{ubJeuN zz4P-!08b4lj!y>1qku1Fmpiw@!&s~K5SA=%2=>9jqg<8Q zEs9ecDx`vr74?IW8m{D_Y8zr5QQd4QLfLywxn#A**RtY}wvm1FS!M0Wab~f2QoDii zjL~Xy%5)Iw6c{ra>b>1Wg@p}xlMnpkWuHppg)1vJnwS0ZSfFNLkS*WuSXaQq5Ba+A z$H{e<3AQBaSeO2pFXqhZXCv_@mX;T&yOp zsBunR<8pGwygb^GQEEBxSM>_7f4Fda9Pbgdg_*En+{0fsDKM6n|x8iywFa$h6O*|J-sXz;mW>S!r-tok z!#{u8q}u{ShV(UN<@!2!_e_qoK-b4%@P0Qp5PL*>^trd@K>wzM!I8Z9~*Iq6j5BOBmLQ1JW-cr;JCj?qXKEXQn;ONZ@M|s=N`G?JO2+9n#JXq|$K+&GRcM7p0#aRFXQz zcoKMm#cENNn%VFE1*@a(5j9OIq3LpWpQmlPyC0Xdr&$yIFE`GJijG*ntxU$aZ#9Uu65|bdp=*C_&;2<|yJ3$HduWqebIlEq6#cn2-%?|tb2$}<^ z*`ApcLJId764M06tW+;CJ5e?gW~fD{CN5e*KtpuaY-&HeJisZX^*9m`jvb%XnNyj< zkM4H0T)ERSQy+zIPl-@!ylXuF@L~u|nypYzdDaED$5f<&AHZyEhnXB7bdv;FW5DD& z-!4RJ*fii>=+zarqbtA1@w(~odTAMMA>obLG%`J7rM$LgbU}{bM|(S@-x_j zf9Tqx5?3QvEWGGQ zb5R+3_my801VCULn;$z_zW%aRgSPT)yjhsUZ$a5Jdcm=s1dUJB4q7QY4S)JmF3HoycW}bc#)pH)b)dZj zKbu)gvV(qVxt%Dhr+SGW+Kqimv=`^(*j35<*PiT&_pYIVuE$CdC>}!DznMdE)ktb- zdYHIJ{aG^z;do%RIxbe_0v5i*V12c`oRq;n>KQjqvc7BBx27~Oc;L>5(HE)mUpGBH z1L*HZv^5s<2TM39BHxWzR-Le^S!l5gR5!%*JcL@EC;S?Vt2PtaESiBENcntFXHxR) zOfz!{n5*SjJmjIUlL~hb^Luo&n#mytWqvZ@MsH^Ywes>Z$k-)&`s~>=Ny)nU`u!l- zX$uhZcB5Hr?z*ek#1vhdN_`tFW{F0lU^(*tyKU!Hh1nwh9evm&OQv znsm5tLEGAQma=Jd5+{o|k|ktraHUq<`Od>s%4)WB_09_4LBZoP1dVGp>|Lw4MTL>7 zhN#KZM(B{QoZ=}Pa3w3{?LZ!xP3i3fK3DKaMSHL_N_y7U>q||@v&UOa>x1Q1m$(C> zz}EbrK90i24Z=uA*%CHBQsH!sBWgvw1bpm2x#m=FM9J@uhPUV(H2M;=o zXQw}kJu4U;E+Hcum|>qdazm4&em)5>U4JG245v%;BX*Zi%s7I8xw$ko#-R&L_qI?u z^YOmuYK~mj#n)sg4af7I*gh=C<3#Q~V|L?cwvI_Xmo3Zm#)aCU#Fdxz?}-(5=$Czq zpZ&DjOZH1qANkcQX2DKS*WcUw`?{lJb+vor&0CxN$eO1~TvVajAEjaV;qh~D?CQ3= zChzW%N*`g~S2+{tT(`Kkr+xaQsNQfeoF6aa<$WqF++g13GbzWKUC@&_4a{ah1*@d` zy$&$I4}5`M-8)&Gfm1A=NGy}ZiQLho|EGJ~-p-5=LMXY&ScP`DFox!hMASIzu8g){ zpBi?ZZJx&IUS>XsrbO zY9YEKTsU0W(Q(VCv*-Q&i7x~I)Oem54Q-`?*Ha(+MOqC_u7So~@;c%jO_=qVG@00# zUN4Qiul8{tfqn5r>3rX_WACc}+4Z@PY$69oV2$9K_H!n>evX54&ogF9MdqY=I{ri~ zytn}cs(i$#o}+8rG$s?wX-_taCEvs8rlxtX(si4J41WIca5oO)&i&|+y=++8-q3)+ z!7A@rU9~CD9ed{N=chaMZ1gaW7~uweFXDCeeHWni)aW%Ph!%v1x+#vhe6p@zy(8nv zuzE%tYvmI_-tP-|JvCe1O2a=0m=4&pWe^+;@&jwc1`0dQ+-ATgxD>leZBO)nyNqPO z_jmNasP&Ap;K|=hNJ!A_@6ftLqtOWI>s8H$*(6`%R?REc@n z1aaEYp8HfMzk4;;pM*` zKYU&ZZy7pkYAVuV90WbYt}6|&pP8SIV)^#y$73tPqEa%_g^b)INVXKLKH3-5my-9< z)O_d}A1w(gmE^J4q0ArmEP>UpWn(%<9^@0l(V!6S-+Zt`$gUFh7$1{irS6v~oovqG z0_X&Zk<_;$lE-2oY!Wt5z>->jan$HtJ4hd)Pmq@-jQF^xfDBCYK)tJ2kNd+1GCVM+ z%pRB*CT?eEmkZfhrs8LGc>i3Y)S!}=mlyDv4Ad)3>FX;VSAFG2*r}58A2jV^>}TyFZ&LihVTsqIfeBumnQ_jp=UAI17Q|GgJm)1`3oH6x z&9JBPMa@jS``Xf1&)EtCZdP3~eC&3)`fcFbj?P;Ha<)LNHVt!SQ(L#QyGZ`W4%%?L z7Tn*y@{~*wj73EyTjpkSY7;g?#d@p7rH6F@30S~FuN;@p)v2oEw-Jz5D6Sw(S3tv) zJsa$@DjC?9x+UY*DJV9Z^un%)y^BAfCa!N)uD#i1Mwpg_{>2x|Nhjfb9Nv*SKv)#7 z_ldAI0W7y%?~_6xI)90uN=aR#uF<<=&?PF`21Hz3?lI=~jGz$LhRHJIG5hjzRh0W9 z1)2Zl0_caHcE4qTyA_3L^|re-Jl+*Cjv!K@cyp>9MbE&93wzhHTB%$T_9fN=blHaf z=+zR7x(hfk6z)ByXg56Dm9H}|TN!>A@Sew&-);Z-dkzqw8qQESnovaMQfobx@O(IN^Hix_F*c9n>~IwK@MV%Y)0yzNd?J%urG zcg&&vT4Q3Zaq3jP*j$fCb4y<1LC`PBy%BA(vgw14nqnqKB2F8dHVL80Gt% z)h5Uzze!VnCK2EvF#Hj_1Q*X<7*pJP1H_8N&U%>)VKhY*;l#IU8kPO6V9pRMx0eE| z`}Z~zfd0+2?b!djKi2T?i0x_M#(7ug?sMWjEPlF|@cBxnINs#J>z?a1JmwzW-#zzQ zWaF6?H~iS83DHSU6`}zz`0t3>;xY;&za_P{=8DjU^hFF*l`Aoa={#WdWh7sX|I0Br z`4!GGJQ@W3Q#AEss9HynoYb3SB(;K^X)1s`6qyQix}V}pSJ?hj$6mbn@Si-^;M_8| zEg7_*JGHgD^OwaWCN7RrnO!v|wI`deSNqb>2FgR}tC!MN9b&6d^eM;b(r;_nnfN8-vyAdB!|O7Cxt6ttP+d1zAHVUC0#}JAr$y zt~s4u9f1^!Q&O%2j%~Msi9btO$FZ^ht4n!k7>0#}uFNNYWQKFH1~I`h?mq-t4Mths zlebmL${9JQc=EwN(aoEm`9hJ3c=-385w2#hSQWxoiqGyDq&eFqs~N)>q{pF(<|0ya zy?6@V+EorIOy@qg5z3fVoHAGdxZnUOJ#v5l@O^nTps4FO{QdjaYc}&ciCRHo9FERp zC;!ovj7!2>e5qqeMsM_;384y)N}zt-9BJM3p?Yo~{KZh%=3k!!7guR%{SuhY z3|pP}vDWxS!F+&A%)+o?T1NY^mw>B~LerON&G2zn#sp6(-se8=gmc{- z!|3rcq^(gz;{|WqoSkDv+ud6(+Baetu1fv4x_ulfh_P17y{oZKW_ zjd6X<-g|F^Y=$^Har_XsSoGf8!gZsg>Z^jnR4?%Ue>e%z5=|Y4TTbsD2aGEJ$+0)4 zCMFd=D3{c6p*|}Nqijxasr2{p`MG_JQ$dG)7aS)vzI6;t#pFn6s&>;m*Rz3pZ3+q` zngS?dqh+UbWsmwM`0iltij_Q~gGrILf2uxl7dw;VL0um;2B?r=Ik%G4xwOG6Ny3qq zM=a-xKJ|W|oaVC9(>=_*gKn6}tlZsm%qxvjCbv$1{SpC;GkKgYLO+rrb;i!G+5a40 z+#09KCdEXhXy_Q6g*Nf{9S?qJ>~yWAYrSrq zK-w!lk-ZS0A84xU`4y}<&FLk8q?msh)5C>ho3pbN4pvae#sUxsO79Q2aF|zbH4oh7 zu-%Ik+q^BGvjhF21$*I7H8q08BJ|2T$AVJAV&hIHw?aZI;Lw$m7)9o38kYk)pqC3J z4bCv&Wb@M=KN?U9hB{F^RMGKZ3MOZ^;gA0y884HVA3<3x{!>@=_w6o}=mbM5=I(mY zzNm{Mn*@`DzXYk@l4RZ7JDVCyJD82n!Ut@elmweq5JYKwdDIpnG4@`n$U49~Mf)))GP(Kd7S*61-`{W}oYZp>hzKjSz_&Mgu3Dip!COq)j zy_Q_#zgCO!_}$giqQK@l!cXxg;XyZ+kiVz?p?B3t`3O5X<4AevkrjGeu}ZDe%=y#j ztZ~*T64R;e(+e;$F&vMhCxKJmEx*v7izs}!VT8bEi%ePER2p2$XO4xyN==dnR}Gx*kvL=ZA} zRxkRpk&aDm4%d~By!OKmHk~V-?#RPq&Wv-C_!b~&px;WZ>M5MH8Q@%I<+;ET-?vt- zzGJ1S;4yeh2Q1%22x&xfZwMuMw{sY-g6JuVi@aVJ={HweVd-(8c`G=6(MNe-@usnj}KqqT8)iX(8-TRYyX>vt8$dvqMKq^ z8XqDD3Yu4(oVkza&i=+l_sWNZkZ30ET)s!o6==~^jg47oKlW1t%BaHPaBV_nKGD&r z#mFunfY=u9cQ@!E2KUq+1c)Ejm<^#!eW={;GnTG!rh1X+9V%AnC4nR84S&^J-D5t7 z>R!5dDbNmVsWMe^1b?fT>3oBiSFw~BE%~FR3ZEI+JQAGxDfg&R$XjCYIpPUwKwfiC zW=uz2L>UjiYw9gh<3)v=tx?a{vKmqF`rF|(p2p0Yl?WG{W##24wEgeh$VNU_U%{|G zjAbnMNDRL+dHECW1ilhTGLKRzH=ua>UVhL3de3|K*a@tt?mTKsPvLTUd~8uRLW@+e zFC@WPsVdB+s+597xxA2k@K2lQT{9etHnf_@i+Gu*-hywZsyhf25-tn%Y~+3MqtYK2 ze(g;furBzPd5vRCeFM|u^` z$!yRKy#&sKKna_BveS*#&Acv1xGg?QGle4V@-8#XCNwpz;j#9+Zxpl-Z4!la6%uD^ zqI6QbYNfKR(zH<|%?lOlzFlbkxDV4ioqYtvpOtJ87TZ|{$Z$J`)+FI^8O3*+>Y zEWfm(I`k6f`BmTC$I+D|u_!c}GQsoPhx|}7O4cC7v+k5B*97_tFzp@%#amZ_R2J_m zv*dPPdHBSrDf|p75BLR8YY(vj-}E)(4Q3r+F-MPcse_dT)Vtg>s;b7`pPi-6{ORxS ze<0sy)^lSGgaehz#9+(EY>gAVF01FVGd`;Y@Jk^rR_FxcP$9>F>p(0K=b=g+*9!9N zBeySu243pKqh{6$4}d1z-IN(YP}y@l$4cmP4j^}YGVx{fnKbq;w6UV0rKP2*sji_N z@l-*FTwmWkZTBt%VB1>ZRFc$%YDY}>n1$?6-;$%}9x6;}>zT3Tax1~#5=g2O2mgfU z0JzO(uryo~DTIF zRM=~8?M-qkJekR-^i$m&>X(oF8tN(7E$V@V0BE(&%tmmpNvx=_b#b6e$o8J7#oVSR zUI$N)>06fOnnal}+)fl}M+dH0{9v`h2&QwtBa45lGmE?(LnRq!J$j0WL+FHlCNX;X z(&utxYAXe`2+d+Uyo0L{<&1MLC;5 z*zr1n!K6!1V_277T75!qLT|7d*!$CoOd1sT!R*-8Oqe(%^(&cgjn|2J$)i5iQ~{a9d`mFtmk}2SNAsFc-y%KTIWrQ zDYEesktvT1saIB1sH?|_$Aw~tc&%-0P}|*%@poFPkgE`oeINrUqu#@tqsyNCv3>q1 zuH=KxOZtP+@o}qkc0TiOop~VP^HABM3E2%9H$cei$%_{QcFzEt0-G1IN{dRk$KytW2eFG<{Rh zuo(Hu&2P3>RMI%ap5@!n)7OlGl-J1adhwk$LqjA zGOed#Vj+BhdkB{p$A!E)jpsia+{{Md|Vkgn>DaQvkiuo4ven^$eq!Jerc?y7-I;>P6Xcstvxkh zOu~L6Slvy=hziRXCZr*>U25v3HBz(JVYp#z4A^RQrzgR|%BU9EJgyoO7vaEXC6T;E z41&Xs%XYce4)*u=JJ4glep7~Ycji7(Panp~9n8KyvRftk5hxP(zg&O>Jxaf3=G%z3 zev-uLLm=75uu|GpIeABq{^88e)^EV#Y~@YMe~lok-s1dK56y_SG_>P3MORaM^|%8_ zWAub-eAo{a&Bv8ZlgQjlE>35TY#B@wQq|GUR}D$Q;@F^4l%>XjH0>TY_dVR89~T<) z#NyaDN~bLh=8yL#*u+dvcXv11rZ*8jub*9WR|oa5Oo- zfL>Ba@&2msw5^Ih^PO`4<@3`)g((-0O;HNSi>R`#hhtO;_)UT>A&BTtI9T4!&P4a+ zgp%CAuEvy_J5-eG*~wWN8k)f7gd2wQ2j33I9Ws~6+ZX=S8gIjb-m#Lt*3Kdy4HdHj zBtIC8n4#N^I_G@D%m1q5PS5jlV@0S;#rOR>hvCD((L+-2WllN9r^3Qw;)0>9d*A!} zBMr)IjZ#WWKfEA&G4Y{TD&EdbJ>3=tIy#5GoIM7TITA?a4a>f2pj1xL`2iGo&W6k- zr;@qZ7w5IX;K9nNdbx7TbT!yo?pEW=R}GDZpfN*bzIMl-6Z)xe>zo-3=Yz$>?{s93 zEQKmW=?QMPm-N_6(qehwpHCem7|rf3mIh9~gd z$B?iDUzTxV6BAtSE&CF76|e8B>|K{Az2}3tLF{m>5zH_o|I0&$j=0u)CZO@mQ3BWOV{Y zE17ymSF^bXd>=VQxG0XTv=COWfj03;86F$(l*{4W3Kkqaw1&tVq$fwAH;nI_hG@%* z^=M3jObkx$`*BR2l?Duhv9daE+4FLdy1)#rh35flOf1db<42p} zV4qCldud`~nY9~|QE9+$bcCz#$__2{9I9;L&V%;g$xS0XQ3MMVr*F;Bisii2zFY)>{V)QNyCfOiygbC2tQ;lC2ctP6?D;>;y=7FCYuGltx1tOo4WiUA4APBs zcO%lhK|pEg#!Z)`gtQDLAxJk!HwZ{~4APA>yw|YT^M23w_xttv!?j#8%za;Zp2v9{ z$H^dg8E8~9Yn85|DS$Vdfp?))1Nd zAq5Zu8A%!ZLx=cAjMjGKr)yIP832uTUAxO{Cy#k}YskBe^z^u~>t5NHWeOZ&?6rj$ z)bPeJZT-0lU{)lH>;jA#EfDreiVE?0A0C-E^X#zT#-uA?A{b~;ZKB_6%)yRx0kT;aB3elpmpowtBF?xy`S+ zgWFBMX?2EJCa08crj)FCQT*`~_892JFje>{n&HSjnalQtC?V(^{&v25di;zczemmJ zvZ|%%DwK^Kq?;^tRhsm&n#h3Sa#KBz2A0yRTM%TuWyl4W@!%6Q^MxPz7kI^!7aiQl z87%|Dq|hOT9s;DaG<@?_aLo1)Hqb=6^I=MRb^&j>7L0wE`D~%yJf5zz=d&3g8j7so^tCMX<=i5XT@R_3g4Uywv|Vpoj3>tWbb z`}K9wfnxDDdJB`o2Zpl`Ggu%CF45`cfDD0z()F@hdm7MMP~lA|CBMse^iozWJ0}jdt1sL=W5m4gZ1@d3K6*br zUOOS$4S`4|%dqa7Qg&}}19*wCnmq|4N5iv!Mk{R{;O1u!Qt@C2)z>yqckqNOoDj24 zH6_+z$xs8!r_^xB+Dt^}`oBrlr}V*wO&RZ^aN#kI$*aX6FQa@z2+kS#BQ!8ZI0b!^ z)e6IJJ$p>FGu-&odaAyC(O~4kYn+mNWhB19uI%6uC9CEM7{v3&HXx}h$pRy88YNp6 z!B5ZwP9+F+fFCw>NrYlunI#bhhtT0)bOJym)XJA|zNDOlC+g&qI$@@0f(S zk;bV`^43*YA`zGHC{ftT>5SG>kL=g6xw2qrMDVtmGuizBQqkTH3~4q9uH_pmt2H@t zwrp&Rg#3D1wL9%7YZvU^nw-nb`{DM-VQsFrmkcG=FlyB@5GZ zwwgQe&+CA^@a%Uj)4uQ!k02F}`4+v5PWl<;+kX8UZ(AnA-yp}Al6u5LEp_s$3GBt` zYD&51ojm>kOp3r;xpGV+e%+Bu_NcFJJ*k>q`@QbBJ^;h1gW(tv1e1+G$H0IqkJuO# z+n#!&Fcp`1UXle09HgX;{?JKnV~|H#M4fqWG*d1*c(f)mFkzm5#qfrQtpfBC)1RBl z)dgJQx;`gY<)n3-pD{6VUW@lTcuWzOV0Fj#Z#r7=;|nWw_W99=vwbbRuIL@fKcV(+ zhzi{k@@E0> zWHmt|?Qz#|h+)>r5+4)H^I&aJ^IGjx=pwdM+m|+wzLTdoH}3j}vC6ey(`>m@ZAlv8 zNzmb=gwVZM?|;O~d|8d2jv+4J)tEnMji5Dpfr0_EB(%t@^hVqpjTSK3(%i=f=)OF? zkXn*MVa0srXE$bE4d*dqTMInoSRXX1E-qG9iy+i$M7i#ddO4NHu&1 zDE;J>NFRQvVRG&{MeKr3(+K^@Q57D)(S!Z*p`&)Tl zmWnmL1clDNyEtPy6{z3Rw700-SV#GeMTO-o38y?!rwQrOGxBbZJ@5F-EpP;mOe!>! z!``e>ak{U83T}QrXNbY`DB7A+vEC@XY>n4!_9ng@JzIS_^`O9 z#(zxdqfx+=?jeh#4+oPJ$2UJUH8npgOLO$jh1fha&$4~_`{Xu__7_U|L&j|SJd?zJ zTJNc9r;Fr>b9}lU13^Gf0>&oQwk_UO!41zNgDq1WnCZf6OP;QVkFsTt5@HTp)tHGrWF77*8S`(at@raDNS+VD*R& zU8IV4az{sd*(@NIhO77m7@McQy!gd(QCpy90L$E=B6qDfJfSYx)=PAm*}Dd~3ea@! zx(Y%h$c092SWB5%fUs$B(7-o?9UG{^uc7BafmvBqHNWZXV%Q!DpekTdu=w#K_ft-u%}q94T@A3N#r*V^+I1`3x95X%g4V^okO zFh*lDX5>GA4Dh#S5+z}GDWMy#L{~c*-w+Y~-cGpuJ(vZnM`+FlE5u@&Ao7nt1}RoR zag1aMhSGDJ6OnpONN$@c!CI#npAx8;N@udw?hOYG-GO&7-{TqIGq= zjAM0E-Q3$?jmbDH8n7A?+qnl7s{B=f7{_r`B6>tX_*CeraM!AA^eY3XD6kN_IarMz zc^}G}V^q3oEOUsRXdV?A*^4Yws+hJB`EdK7V157gk9}fNQsFow<>?mk`AmXrG?lW7 z5+5!4Bri|3(D3Z^e7h6MHReLyB1=dNtg(}z%tE9qhcgie5EOBCH+68GyVP z^HOm`p0E*e#a9^%rc)%7~Fg<6Et&^l1= z)1Cz95E`GPw&wM60&(}tl$eS^lMjDShd2vNddx!D{yrjJUbT+FvW$CdLW*Y6JZks; zJPOC46LrG!V*-8HW6U%X7g zyLe0W;XstZ4{G?O6%s4<(@_e)5JL*$wuCqngNWxHc#96Ud?42;o&4zqwFKml3f{Y{ z_y~C1hs1`@RQ|UeNe9y2!=L=5dQRI9q$Ryw#3xbNZKga&8y{pSFM-Ap`>}3w1ixo= zB`TnNMY7=3mU(NXslvgQu$FL4>|(y~oq0NB&FLo~A*=|A!VtsCmfRm(t`%(^4g+LY z(zD%dS%ig%N>ZEDo*P_Qk8dAYE35b(^q5vj9?Hl3Ei^2$9?%w=P!-&tjlt9(X+)aTZYG)I}w| zbzU3J`}wC!M0EDLnykKF-0-VX7~**3RGHqu5^Q#Mwucf>s7^fxD~pqp zpT!e@m0~8G%=So7I5G)^FM_oi8vIG}g|)XeFHhqNXK zCFMNJB0N9}5zEA^7**Z3IQF^^8G(1?sn{yy2Laq&V!rv71!uurt+Bj(^GP4i!Y?Py z?l0?x-YC)%K`&Pj*Zt9=SDBI`e2i@ccbAtkp%yYKavvR#Yv)pSDH(9C3a$t&tW!@4 zeiK($0cFCu@l98Np_hB}FSs@YaKep;C%-c*jxFe{OKHBGd$)e)cp1>TzOEHt`ZN=F zeH2f{pMB@l^IXDRV))dwQEFwEr`hih$N7=!$t$8OMQ-t;H>^R?oLX%W0D{lf~A!M#S|s`HqncOn2>r_>4{4s==(-S4)3l%+)e_0*N2Pk z7ON<3idhjMf!kgCyWO)y;%<)Hgs$;k*k=im2a=GH8@koM7iz6)J_Q8Wu;X=x z@6WSJK>Py*1Q-b)^kEtlZY5NGLtDXIxU4U))@+ERi?-wGhCE+%bECnqYGL0hMw1SlDMa;CWt*Jyw zOGqzIOAl$|uL|FR7vk+jgs$b9pkkxIQ<@t!M05Xt( z`ykht9vCU(;KXS$bt3=p*m-Qnx)aB+VM17rkHHGAZu}T$;Els~Dy#9{uyl4exID6V zJ49*C%lGR$c09@$v{$B3MWXGkK3jAjP({JMv?G07`^b$r1h_RL_6~&DutP#gE~Nfo z;`}hl_?_AJZ9+JWna1X0ROCuT<*e;5wsXq&*>)Qnn&E7LT4=*zYwhHYsF3oE%7`6t zcRhT5aa_~<&(eHB1)hJ&9CvAOOyeaW#~T{4@GcHY%0yXd#w(y^yfy@iKjH(I{hd)N z8@IE!VlEvuL|{L_AO8qq1UcyA>Ifvy_CmirFvY^BmeudA@-*Imc*HPF zlLX-!toV(8a>z#>v2oj;%3qEFJTP5uxsGJ!#;rQpjQgHAA0wYeSW-GlX~iuKe*+~u zWm-0Yc<;QB*Ua|jvyQG;$YjctuBvZfIniFYQEtLYf12+{YR3o#5-m%u{XSDT?3(x$ zFO;X%PNrE-_Tg@=J@qwqIYFtL)Ln;m(Y43G3MOF>9k8gb9}b^}y2r*)?b8n;MQTII zZ*He=k<#;i%zjdSi4VaWiT(u3uxClFpl1iiUAOZiqDY!1qtikGE%d24j?FuErQezuvohF`uv(?M3{T)G_exBaVS znh&<_REF9kHXlE}3IFNqsRe95XGUaM@+q1JKwJKX!nIaWLd7amzjLFZYr&IRS z)i3+P=VYwB1aEX9Y7xXL>%Y~4|NEWM;K_`bQerIB^$p@DDd=pgI2VWGq}D%qBOo)l zQbZ`O2dth$A<-U3OzKRh%-m$%;~Wr^XM+Q?3^A zkLs-$WF+5#>2#+1?>}gyuVURfeWlWErid+}_S&{46G0Yju00za1nlywL++LK^ zo@@JB1nZ9(RGj&5DV*_FJI`_tt#d1Tv;srzp56<$h5b=}v}ZmOcOqCgr3(?BXcQ2b zd?!%~aIUe#xc`wGYHK_caY*gV_RLlK?5v!ntc%!53c%6?|9#WnBQS@GCFrr+!l)ln z98~dv3AY)oKPdJ{NYw;h-MZz)zhk@0Cn7v;c&Q$b;Swzq99Vq?>3t&26Tt~+l40hath@cpyEX!&4?<+pC z=C6JJ>Kum#f8GU*6{w+qG$lz7i({Jvu=6pBKohzb#2kxdv`nM8=z;w@M2lqN^Y^YG zwWpZiJ?C-mALa`B$t%~b;!Kryvd^*Mr0-2zByb)V4P5|w;qV@}vgIZgjskY4ytoR|cT*8I>Fb_4eV|d&Wv$A9lvO~M(N{y^u=@X|u$iD>F8-CB?9|O09{1!Yvz#D7 ziy!gxW>Rq45ewb0{%_TsnZx>}R(jD^>@4tmWBk7s58Gv?z;C^!4 zj2_2^&gkCd7vBkyRR_KbkK$HQD_xumW^7QKhsRr_n+&e@4{_Zi70ke=m%{sK^E;hM$m zkmZBErgwZA%1lWLz;Y{Ubtw_jebv0}dE>iyb^~DEtv9+_KvK`W6{SAv@}v{I4Z=&z z6<6{?#$D#C zcY$>4ZW#E#%*_=GEu6XiUqz&V@L)MnY&-kM-9%%*VqojV_0oH1UI{A}58c@Ae6hwuYf11p>s9>WVOda69njPQ;Kp#Sh%r3R z5?M|j71Xm_zqh3BcoidmoF_zAEPvHJ_)1ribBi# z<^f6Pcydt7ub-83D>axyXg}7!dLk}$k^mf?lrNw~>`^e9kn`oNb;tuvHMM_r5`&o4 zl(=#I-{1Xj71`NLHQ-&2Z3Ah12S>U}&Ft>@whPz-h*Eq4xpiA4MNhP&g69f@%yIEU zhjn5&UJ!Vpy@x;`8=r@P^6pS`P0OgBoZum!m8r}6${F};yJ{qM1SkW&<5VG*4JGGIJp8c^B?Y`e21KGzCEk@Oi#H*usmL2;OfXp>}_D!uYrFVcBUFwDK>#l2x+ zrhkLK9Rgzx4>?Xqft)xLw!KXT9I}?Kn)56%7nu9kUTnXowfG_(`}d>6RpuBFSYBe9 zg~^4$?95v|NlMH|Toa!JYH1Bvj{D|(Rt)E%{uPcQ76CbpdXw5+M9VoVHq~;yx&Eo1 z@}Oo>LT+ae6aq*#Dzg*@3<(PLHkwu5Fs~T6{lMjq{N~@QtmOZsbS3P${4Za#jUjn| zn+>dejas(&can;RSxE__2rXYT?dG!LO^q_npweF_48VcQf1_k2vwAkCua4Zad9=hf zC9lLK)oh!yk-dYqT9+D$YVf$z5t)C?g4i8+9;(mh7aB?uU%wh|SFc zp!jvHv$?g|$r~Uom}~yY!2eEhW;g1$rvth1TxuSJgdDkaau0cT?0?Fk`qdaQMDxMf z=pGg4nK_9|(0Wh07-RJK3~yVqW+e#!>swGGh=&Fc^d?_6u~rW9p&_MhPvSZlqz&ZX zMy2`No&{^9^(ML#auw6NuE3?UzG>ly%b^YtjT}vrQOmrYtb@&SndbkI2{|V%v1G#| zJlhs~AD434OJb`Mj}uLpE%*r${J{n(Mg18{4unVjH~{d}0gdH+3s8j?{tNS#60@E*zz@i(YMYx6 zZ_Y&1(kj;qwZiB4r8J2RC%e14GK739zq5<-^XsxE#liXc_^!t?TNZT|fq4>Z@Ehvz z^X5exp608)S=pkZ<71~DYJYImePNbH!*3M_qxp2NUalSH_jRy^Bv)Fx^u4|_%o92V z1a7m{d3i4!9*Ij#M%tA=4faDUxq6wInc3Mr&U#=D!BHoArnq%_*>^-xu8`INVUdHT z6!MTo0_h)I8uIs-W=?-Dv%X&@nZ9 z+-1b_#M5W;^nI?*pFVcyZik<~z{nVlpibSS*_mzc^yY`lyl>0ChY=|bvQRfFvK>{^ zp(Xb){{#Z+}J!Dcb0H&3L&~ecnexHH$FT%gv(_=m2w-4H8L1aTV55*XT5`c zr~o*x-%{_kp`SLD^6Ozy%>|#FMyAD;pNu7_8ElUcD{qasUd@H*AMMw^%+hFyO(l5l z@&Zr+{sl@NOlgL;&MKpjqX6QlM)2d?qgkQz-ik3#9kpW)&}tYa;)}%2kK_r`@S+GZ{s@Yh070Kkw62IBwV4*h)J|^S!9?2 zem^-rBQGr+_Mc0V0Wfj$>4>g8YZ7}#(BP7$G7^Sr5lBe^PhV8Z(P{R8zjyAm_>8WOUTaSFfm$DvBaxQ5 zW?6n1ONy>4snpqfbe=i?6Wjs^3q-7%;DddZB!6DG&F z$z%%h7*Ne*_9Eq^+yK-GfB?3C(Fk13{%l<54^YP{K^-ZK;4xc-of*U_G~w%F6n{)amCIj)P*HncRz%LZ~lrc-@c;kCsW8 zO~4!ZW&+4TQYU4cv9!8#!`HyzDX}5;iGB}R_)7-C7)F5@-OhKRv;WpVq+aVj6=B7f zE1C21evY6MXD&!|nbmkADa{8`5#Ff#gp3Ac1STZpZ|6n(dN<^t;Th3wdYapd7;lYO zhrASpM8&Uvptp4m@4Fm(;R|)i?vw)~j@8A^&$7+-MLuTpPMDvLoxxy{wELbZ<1;*XQycgnF4SxK0(o{LQAMA!rh^Q zc;Mr~p)(R35xpCnAv&uPb%W0qbYHB%v9i(XU^hQku2Kr860P)8@*FIgVf7UE>t_-# z`=LkjX(mlz=yDid`1h`aEv`DQ`so-9sk$mc&%h}7;A5l8rhQwgOWy-Oerj4-rfN(E zL4)TJp?2u^qvVB5CFsM+xs!-$!;DtrrvH=k+DNM}0%(jRx#E~mJN)}!cGY7$GGAr$FV0#W1gC;o3#3W6 z>3SZF??G0rzfBk;Hq-R(ZpwJkf%i}2?!(e~G8whJFNwVioQy@@3x4raAaDD2e*k!f za1(?v`3u+4EH7!2kcSdsm}#)~=o=;I`)?3u%?&YG66I%PR@S$tKeV@Al!WkGhWLI? zWNsS$#iD2zWJDfNjrK^69;`|1IutCzQhX|=DC$ijOWo%rRSOz5bEk& zBQydSLO;js880J%AwAW9^{-hqvVl+w8Gq982_q*MXRYA1FZ}76W@Kt6W*mH)T|sq* z8Z)LUy>%wa>ldb)8xU<=>!g%}{VrjT=o84ART+?Y+JrLb;c$xzZr;748-6<&~I zKf2Qcz$>y`D#wH*!)%YK?s;ho6FBWjhKj+#?~h}q^p4h{0zogmz?X>8p+D=8X@3$c z_P*hbr1O25A$Am$+rH8c&Yr%TE|I!XtfiQ)d5Vu&8PMffaBcTYvNK0#4z^- z+rndP4kX-Xb>|XtL%7mQgQ^O3K1q~@r^cDXR7T&2;=pAN&Mgf~{HyAI*NeyPyV_CV;`ejDG+dQ(f?f8ZfSQqj5OA$<*)wfl=g*M~?@{~F=RV@cEV@*3u&{F{ zA^}Y|@?`T{i)`I|x}~pyRUp!{f%eJa>WBn#^RIG5XlO4UyeKsMk8p@{hsM^2R#EBJP=PLZ$$svszGqm(d8{dK_@7! z+Oprb2Zc3G8#Iy7#Hl=ineT}fiVDxPCvsow(q-4;;O%}j&X;A3>vN9a5$Jw zf&)Y{`(l*+(q$imYZ)Z#FtxmYVGieI*<;T%WtsFm8OoOKy)I8w#^mke!!>BgK(!#3!K(5ja-WeciZJcp6wtHe^^XDu&z>kc^ zPbqRHq9XS#(|DbHz7dB)l-+ZKM-eEL0E9E1|8mBj>$)@3`PFX2`0#WQpJ^P$ji&tu z7%bGiJuFi?v!Cl&uh-DFO9venG*`V8ZKQjpIiuhLp)Dmuy*ny7SER^vxc2WH{- zzwaHV6iyvE31-Z#Dvf03OgE=j?aNe*4F^dw$NP=1bJ ztaN1$+#7W~Ds|E+EBa|EFwoFI{pcPx#8na)KxE9}%^UV#8+sR#YSuyRJv70V4Lv_v z;gg&n*?R(Ba`#~!@#V7ozha0&Q`285&)k}I3rYr(fGvI%rgkfU&_YpHH6;nS=xYFU zZm-|rla*kO4s<;`Vjoj#@#UaivbRMPXII)yO_GbJt4g8e-y)-Rn4FLoo1;5{G{3*h zrUj@-oe|y-Kk0_pnxl@O<4YqqN!FgCjc_iORar}U5K4MaCv`Fl>Ob0Eg1c76Tf)bbVd3ODTHYg2+azIyxLc555Z zec6?)%-?vu-zd0Yq)Pfg<$J}D!@6deeQO4L_WJt*81bs@s z8o&SZ2o?2Fq@isK?yWc1cx}`)NSeiYcDk2q_*BwQE~k@MG18`^VtNQ_ZPZU5;lPj2 z)`VjI7KjO8reIwE`r7Dqe-VPabVD=Cw?Mm{UZn}jzl-gLbMwu|{>`MY$de{^5PZ~I z46E=h7EdJp?i+jKS~Z|;2J;jNq9bg{WOXNq+xi`2e)@*7fPZS(DBX z^X>!~mo(7F#3vDDQ){Su+vx^JGn3~%R4ws8^{%*cwEmZz^ZjaEcI3oh=jQ0BjfZ)O z5QkL)#XNT{CJDYUip(JeS`0gyby_txQ6bA4QQa8|7wN|VNtSewEmmv|^ed)UQ3azF zU1jTL`}V#2^ZE=WU zU#SswJG{@1BLv6s4^T*Jw{eeqGI8Vq6@*dJ>*)@O%R(4R87FFg_OCcePA*^e$_vhk z989``3+VATl~8T=+1F&6U`O*ZRdpu6=D+}jEn#=#)=UX0oHby+qM=!!RRkdmK$(r| ze@iUc2G)MSr2Iaqr{sgjd{3w&vpmCXo9#h}5QKs#uMmK>4tXsd^vq%prl(wtZ5xJp zFTG@a7b9M3KC|sPKYW+v-+zeTpf$qsspubke0ewCKr{YH7r^Aa4EP%XQNX2+hlkbF zxJ5-p#YzIpW9-N3qrjaGBmpP?fa?Zf1M+*9X+L*;pxM!r=i^d9#=iE=nL=HN!|2ko zaTd$$&)g52=g^z(9KHUf_ZNHZS{att#HX*9+^^mGyvzn`KlN~s6Ncb&69|O5lT%XM z{V~4V%^A1iehzplwLU-h@3!sld_LU$_?i2Z&6t&i<@W6E4=!q*;XWsj`!#sITYMZc zVA#Jx3EntmINx7LJx%wSoCp&<2C5XL+O9ntP(|V9G%09>F8x3^g+(-_RLHPIC3=>) z4l^;&ftVSFP~5YF$j5aYUyqIb8QQaJSp+sEwPijtcFTFLg5d~@>{tti(a;i0&=IN; zL{#yB%ed5o1CMP&pNVc|s(ZP7pz*HSRJ9l2wh;4B_N|rUFhbpbPaZpnV03uZy`17d z(N8ytGs|{y@|^RiH|WjJN+Qz)u-wS-+&ms9xlFoB{^B-6xPSoXNbpP6Uqu$-nr_P4*lye5(g-WdNK@RrZhi z@OHU|rDPxQTLqG#*NOER(l~t4h7e-XikY7Xu;GVJY$$n1k0C1ITQ1_ZWi90u)>bssF zm?dZ@%-RTl$toBFB0c)V5xmS{b_GLG5s@dnu1#RzGkGp|jdH9pkGeqDo6oKpER*Z` z>c;o(-g@rx_wXLi`*iy4C)Pa4P>GPN|ZP zjMCqVV8X$nD=@HPx4vt38x>lg{EFD?HEXB-oeIGJZ@5&SNbv3qql3|bYCmM;e z;a7>8!s5#9j&LL{j;+^a*v!iUrGC$v1~Q2lR_6X+PlQ$Cd`nwe+?K9<^T$Rsz5yS+ ze%eWw*iQToyR&onDeKT=IE z;CR!1I}{h9-_Az-Z1%i-W8x`%9DnI07ZndAQL$)rrM%$8SRh9o3r1jzea;)P;Q@P0 z;59Gg!7uWXZRL;A+KlG$lz=Z^Ix+FyxJj$R7QHO+O&OSJ>_(iN^2?38_$NkRFk%DB zPr#-2%4(4TnG57%fD-Bp+Dw8K{$>({h?XH4nO_cd1}i|IzIF0BQZ-iW+?5n$v0$Mg zwgK78lKc6or(q&xdXxz%FpC3C3ExQ|=odu!goJG;t;RtwIdWZ_Jir#gl_2OtTd~g# z(7s57`be`OXAz~0w9e3M3GZO`f6x8uv&X0?)6g85PImt;Ihi9Be`DH_lJPy0Wim!` zJzwz@(->r8B5h!fzrJMdPx(xGcWQjwIkiDA5o(@{g@px-fgnJ~@=c3LdCd4`tD&-a%zvw;Jf9hKQy-^r=qGt|pqQjMIBEcA;?3p4y5EPxD{d-=eTQKj?=@ZR@) z*eII$58Sr*q4rZT$G6;#!{+*+i;ojInS-=u9T5ip$W77{RjxB)ftcdE07x`a`A_A{ zHCgVF)YK?8PY&NGc17Q;e%Y5!EErVc196(YmK7JFYgMseq6KMT zm*oafQ10*XP(KO;O?g{??4ALwWa-#ua66s{@Nbc6$`~&Ur3!}~Fo1w=L^w$X<^ZC> z57bU_?*6r3rU=U)f?_16lw07@W)nze)zU6ZRy@J(p#s}!?~mWpKnxhVXY_bbU-qLq zyoq=1@mxQXMZiRl8{z_uK(l96b8{I`(T?x_i8p;y$PGL+l+}UrD{z@&zPF%sGzAkR zoK)+`03xeaClJ#!#y&;chzcqi!6QR z7OzOlO{x3BU|5)61R(#C;8wxt1J$ZB?|1jQN1p(+IFD3E(U{w4&Tu%0d#dacw$$qw zme~}cFylv9P2EMit+>`VO*D(gZ?fpe<3#u^N(T}^I6t=PT45o8ip5Wo!Dc4t#+Q(* z89Ox?t}G4;x&X{tP-)Nc^Aa8wd`2eu+~alyX?f+mRBuB6JN&%`nChmuPRFNhOYCHg zaW6w#Pzdi9IMe#x@RN1eV2%?|;g&+_N89OLn$AH4U&fH1)iU$^WWLtS`i-)aNqr@s zz62zhvs4tLje}Vk>de?kG>?poR8jh<*@xr{Yllkd zo2FRctAYKMA$QGe9Lc+V7h@l>*N67nCl6rkNa;>;{6Yp13<9|{v)#Re@#+ZBCtz0C z=FvmI$^iLC_Qg@f_{>19I;&-M!RB*oR{%k8s$`b`mMl6jegte5yOD`Bi_#r`HFD(%g(pXY`Swtg}_U z4%qNwYn_QqXJMN$@hwAjbA4Md4&4d5L4J6o$%khYrwdlY`?BPvfI6$`WJ za9F9Y?Upt{6zJ6%6l(N=s(t7lP+%V5VZe89b=^~Sg!l&!8s z8hlsft*y^P-kIvTB=*7bA7_H|dlM5w@A*0+Z*G@>`J7Lv-9|Ow5#(7Ke4GaueW`pR zK*Wb> zm<}onEEfL);Pod^6Mq4!VQd?Fx3LL=eqW!%&{w32#xED|u8f=SZbK|8Oj5MSyEA^f z%uo!Ujlo|&Sw$AT48+R{RmkOh$qzz?2^$`Y5LC3l1SjmVT(`5B7d2b(B`;fle64|@mSe=hT;dNxt)Tf$Jb#OJ zPH;I-*tr=@Mh4@1;*N3SOFR(WzB*0Quz6&X7U1sY z?XK%3{f|7FzA{n_uiN({WIJfyYmH6RURvXhz|GMzX&j0iT>q9An$F zNw>LE!~G2+6kRK#0x64?kgur8O^uAROD^ov{0^9J9pDm$kcE<;dfGaenB+_oT{wnY zrmP(G#eyDqSO#nfX)@V%vpjSFweivX=?9mCIzA4)Qm|9zv%whGD8?Xy0@~8|w?x^8 zYQvx5y6j3-ZUY0m}6{O}CEavYS+Le^wb!n%AM!6$r}sx?6ulIvcXK35sfZHtHm& zI?8NWRN3e}2Dk_Qd=v;h@p$m~1=5EuLHEC|7gl(42y4fYg^I{u!<%Wr;CMe(ki^e0 z1B{RotPjS}n2ndo!i*@YnDtJ*D$OLbxKjqswfjE(xkJ;PmG$?!{R?sb2WF-q)?_Lj z(A0X`M=z!%Ij+(C9=u@+D$o^sT;eqEW@@?LNZefU+AFSvNDa$mEmDt+WlebUa3(zEP^b$pt|2hl{$G1?0KP}=CZ zVs0a|_G$vgn%;+lSTP(G|5J*y5bdUP$(H>t`4s*{fr(0ht6+`g*7c6Q1HmKeD+E{V z_CJ>tT(AaibrPeF!K=S0K(~*OgR?ma}^1o99ZoG+^;W3*I zWYepu3C|tns*00r2A3E?nbU0`eSyP}cWtee9b!1_b!x@(91v*6%Zr(01){isrx+vu z93425OB88h)7(ID7B)3pE@h{nqxZT`kzO)Ti=^*LFM@jz!G{$b=2eZUh0AZ|wRqf~ zqc=*|y)%A+0#aO>g=Q+bc|efqHVRH9&^XO^$-@^9v_$H@`!jooYW_meoj4{DNdC>m zVQpt;0M&yHsFPP8=z@;`?Qe|-%6Sm(XBx>=Wv>rlFPB{iX(i zxg?vMmHEw~>FJ0mazw)1@mFEIGioXXw^{AAzS0+(&i*++*CCSDOub|83@gfA@leUve!8yS{vBDFs zbJ<$#2ZWmHFS?7T9ez(MfW;=nt6P}3f2q|7a-g~S8?JBLdUYBZ$;r2DoAz4T+EPZo zJqAY=XH_yXDT5XdyN_Aez`ES_W+?`h?1QuN4$Y5wudieZL^RVplEvb2i!&q8%AM#d zx^YWA8)TceY28IJBS8p_)UCsuIH{e(oUi74h?*4;Y)Fl9&sNLG$S94GUHHxa5F(|>=|*YkkWT5?AYD?@4FZBR(ri*%Bt$~G zQv|%TJ?Gqe|G>QS&a7GSJS(WeKj(TwJq>}OX5OM7APdw-PHJFPZ%~&=KDAsjU81CD z4((zg7zQYUr&(<%AJ5O!XXD&_GTV>-14j3!dK4b0AS6V93w(&&;fNss*n`LlqOk*V z6X5^rh_Q5D7AU=!)7(Q-a=|;Kyp^cF7VuiWPkHsUq$!q5%vr*)THL6-dMqv5lYIr~ z#I&EYV!-XT08IbLa?__j3_qQ0P9=AUN;;UYQF==Fn0Wt~K9NJVyDq%91m`;61*4cg zQs^@`sw34%KfB>#iF%c$5>@LbP# z0eeFR_P%);Qv3%aOQsOCvzYUts+wcy&C=xEMr>_y`Fg$?L!$P2D%>ZinpwGqIqFr- zg#NG69Jpq?CZD4U0vqr|Fry&N0(dUAnhGuC?kaejiRD$V>CH8^pAFNtG8LG$-+oXn z-mZFeNcD$Nz{LL^8OP6EYp^e9+B4v}&y zCq{=?!plLRcu4+pY{Y3Q7#+*%X$iyO$=uZ|rzO)Lo(V<;`=h8}wl4DKqB4=1;^LH( z${g=MAIS56mP7ND51mc=E#fZ3>isVvgw`}5J^pJn~li;tjfI-p ziomMWU;RtMiJTmg%~zdN3-}VEviKa)Gdgkn;|wre4=9YM1VESTuIM1Vx&OzRbelZ4 z^`y(ge6pq00sKuwMimz#ql)U0t=`jdmV}r>k2MBgWVeOpm2L7cJ@0bH-g?he%JUQi zMFa*}mFjxk*vrX!)SXNU-{H*2$-rqG2f=9Wxuw(p; zdfE(l-kvyZ4y9?=Ou!*~Q`~)8Rlrnh%o5F43eqwS2X@{h>wb8@SlW#e$z~^xU8?E z?F=(B)6GE}AR`%Bd{rq359Qp&E!@y+3EU|_k|LPiRWN)$nEh+ECG7tt5E0D6PToI3 zc?qQ)j`;W@3{WQkMP<55)%vT_$`ZXeOfYPi9fIoq4V&L{91wo!mIiJ_(T0|ma0eA zChz#wOm%f8q!?)Y6#p?DwDdcY{AakEWvt&wViNXbj$+ILE5FUeni0oF0u#B>r}wdW z!}+KU1aC?r7oo*Q^8i{rIrXUzNUNHu!E8jAzW?z zba*c0z%lvDc(=lON>~KaghsSWcoR@*bo#E7SBhRa`JdcZt*&aW-MXb?f!M8lIoj3y zyA&>IMfSUKA>b)I`B&UyDuXa#y?p`Pw<;mrbXbI)?2QUr^*PuUap5sSI|>*KKLPXs z8}Avdkpj}-7{@>MTgld~v_n)skE{`7i6eMJd@)4fk*o)fBD6<*T>+3^A08{2$kj0c zHi%Nu&M>p6g0ubp=jdTi6&nfl=9(-n@J+Mw-SxIdC(|7mC7J10nt5fm9|K<^>R%4i zDhp_JZSzx!-vGcuXPUs#vL49NW!B)fwPutL&^T`ITLJv^{O!ATZ;J3f)cB!`%zq%k z%PfwBSv7S@2AaZu31XcYs?edV_Poo7%+_qfWzfk9VVEkaanoXI%Z@&8qF#&#x@>=O zxGsdM?&)sqYBfbur4+dnu>!Y-rtJ%!^u-Irv{HQb$=b5|%JTGIubKbieiZ>E!$_|l zNDcR8FDo7rlVid1m#+|wJ+zjv&ub&YP6Sk8;DXo$$xl9y62nGY29-&@N|rZiyKY&M zKo+Lx1I(d*jER!Vmp;f}N+ z;Br!FI8w#vx35qw`2SG|A@AQ2wzm$nUzWcz$YfS~XE^@e&KU8;qicbwYWw@bFFZyf zx~;576R*9+I`eU7xr@DCCMsW*ot>bj$G6z84zI@=1YyGNcXMkcW7L`ouhnfdY)j^b z4ZJ#&?rR?`5R~GH{n9fZH~7>b*{@y?JG^OBU0d|IwL#W4i2CB zieMb+1%Df0%TnCn)gr<! zUh3o`W#7-^ALhHfXR!f|nj z`smb@RxDZs(2-YUYh*A3 z=Cow->?YdO3*kvdivv1*N1Vkb?4p-{?_It(lw`=quA87){a!}9sgRDBPeBv)p$Jnq zN#}Be$OJorrFKAa)f{a9hx@uu2*;~whdb+SL#+6kdD8@=8#XJC>*^;@#qT%mSw>911wkYPHL&5Xt`evE_#Bg;weqIxq~@WdO1DOduF>N{&dU*c)oTzXsyjpedc7*Z}y&zZ88n$>%%H!|USMc)vp^AeUML2ZI|NZRd4H8xB#sn%k@h z%2}U;c{06ffIXa1l9xRM%Xf_8@ zu9ITUABr(W*QY&6gKATmw=pnpx6aUfBf4ot@LYJFd3C;UdXl{q7L%|3KD%tfO(bU8 z@sl}?4`J0S>e&BDDTo$FBnc|L)6A->)r%8mPFk1wg_HE$E_7dsc!O3w${ZrA z4LGLPIOB`7yRX_i7q^FI+Ev6PwXD6SNh`9_lAXzxN0UJ_b*9L~cKUZ4 zoR2|UwoAWgVo(_fdJPrsN3RQ>1THTfUkCn3D10hloUU7;Ll)n*k?N5XRv{|!Vd0E) zQ*WSLBlVZ-ytAEGy@c?@gB!M)Dxs88Xmluwz0J%N%R<1f!udoorS$Dsx{x}mOJMTs`4#qcA8NMa8Q zJ|(Kuw=0MJvJH-R3|F^Bm4>8FsrtV*<-$KSpmY+!DS4WTJU!_d*u>fo<=&{^21tW1!h&o|xt>Hjy-G08wRMO$vS z(qgptj&>*FkRaG4Gn~GaDmupBsWYw;RSGQ-*(lUO!oiE*`2KROinY@udoyFeoa7$9 zZ+LvBoE(*Y<9MM@mb;bJC`>O-ewwDwzD?Ud5fu*CA%HWayGhOI9NLP-(3o)$RCm>n zEiCeS^G$NztZ-9wn((}DHe<&95>NBv^YPiq$8*xF!R%4u(WW@iJ#Nox70i72evimb zdN8lM`*UAHm0-*NeIskiB&VikOh2DhuWaxzw#e8fsU6{~a11@)tOea03y>C}<$>xyV=YHJvUioyT*%Y>_tU@%C=_mL1}>_n+8kCKz)1NoZS=Rl=HLDYQ~4 z-M^B=rgjLyWujk#bdd>`q(Yun_Yy|klSG&yE+8eDm^7s#8$n^cq-2hDg{sDhj!+`g60ose7W8xXiRov@or?mwVLk7vWU`jmF1T+ zB!aCJ-B>A$(=Eq0;vDo72{C6|-82$pvha_Lk`ui$tKlYw+&3dOOkg5XB}BW4bp<}; zvAg|Kw!1}tRN~@1kdmYBx&&*3ILJIn^3DlS*HM&{oadtm{XsSEboI**uKLC!z^hVR zL^nM}a?d6P5x?#{dWG3a;Of5RyLxm)DSiiS;r{iXtr=*H2ZlVz?q1%eTTe`miv`{q zL?%MZbsYq4-dM847OI#1U(M-EJ7G<93qh)A)LO8_=}=QK|H>7)?;S3E8%DGB^U53j41#{f5IQA~1xi39oS^<^O``%19%_)-MQGmt;wZaDxAxlqk z!+LJaIgcG4ji18n>wk@y_}Y)v>f`H_kWLy$f!MZyBq^6j=*@944`` z*VGOYjm9Pw%rCo09`uMuQP0+`ZlMOzV*~KPgEQ$j?$sttr0;5e?qdP3h$D=8^QvG= z(*4^H2|;0D^<*nQ`^u@;9ezz-TVOuoVH^7yT#*2)IJ`|}GdujLJeZKU)GYR8rB>i+ z%P@mRizNNdD0MmhD&18Xfn-9I6{ZCkTj6!8AAV)@4ER zfXl{SS*vY_fKINLC7A^I!TlsWs%9C?wn{cWTz%qQqKd5TnGsYBH0kwp@yq5M%F>5y zc6-xgFI|9NmQO5FnB&c4d9~C=c!yG{RE=8u>|mic17rvFbB8#33>u#n>IL!wlsDIT zgudq)BH1(=G=?5_irt`l+y|29TGw>Tc(|u?^7E;{fRn>vBFzgw+kIX&%}7EP3(`_w zYaYBD?hjx}iI}Jtf7EGrxl45V0&>><`w%TpsFFm5kr&G!1mV zMWRAfGih=e3SRUQ`2xXR^&5+?x!m{z9xuq@2Tg3RZ|BFJlb`c%Ia*r@iM)(_OkLKU z5jP^~#iTD2{hUx`?!da;GUe2m)QRUzu&55|nLhW8YO!%etM&+Y|#6^=~nZF>LWGPw@>nN|=X=soPH!jZvkgG=3J$vRW6r$U^O z@u3|wIPfw#z;39ouWxUU5@42>R~Td%%pOOtEpz7_UKF45$!HQBfD5oEI$ZXG!HWvv z(BQS=t+Q1e*^4cvb;p=Gjqj}Vf8{c!)>)&5*Qg~rb7UY{lCt>v6E0&jimB*~x)TDS z0dHtiEhPTX#YD=`<9H4`D2PgFQZ+7Le?p)#+;4Oqcep>J}olz2q# zqo6x%-NnAH5JcZiKS}NWIn5$_I3Oc!bP3Lq5t(AT$a*%7n>_NH{Sx@!FF+#*+#Cu* zl=_FeZK~V*hrPYMvonhl6D4=wu|z3U{X#}%2m!}LB)dZGhGrMlS|-cnFMsi)-v2;S z!>$>qVE?D~VNtE`-h9OQU@vP< z;hVQ~DROmUrB0<Yw`rf@Uaxxc`va@4 zN%ei&r25omRF1Gqd3wd-;NQ{JTyFvQlHHJQ{E%-bB0XrRE!)r2bacJ^ZN4AX-xt_U z+${}>d$ntPu2LysW)i4H>d7^*lydZ5C6}_{)@CY6eFUe=f2qxihl%=P57{^0=Qyyg z+PX7FPNa^tEn4pU_kV?NPOHQREEWjs4>#n5&@wcT#47@XiBVc=@T8s5=L8s4E-=FS z`ikKFM6jvGO2yT4dzZR>KQjnJuDbrhWmZ<(ep_te*P>KjQNJB!OgM~rhO>BsPmSlX zO!|+T63eA@Ck`H@mN)EIY(L>!_9=pK(i}PJ77}M-17a*u8y1ksVNE4EbQV2j)oksi z*qZl*II)Y#X&tNXp0vO9$%{=^o^x|F7T@V&uP@$l-J13{9xA7XR>$0HX@xmk9d^hL zCN_$3otti_jjm|!yj_q`wkOskl(Txu_G2@tlSuJqpji92yLT!JQCjwr4E8E$mn-^QU?F`G0pcf`e`^vZTS3TN?C%xS1KATk^jgf6uzFul*eystDyr% zxewX))1ih>_HmRt((7;)n1|TCPq!v=-9lLl8lI2fiHI>m#yJ}37^fq{=sD5(E`ixX z^=W*@_kK1#pi%;LnnFO2ndhbcK`WY)Qvc)0jvoE_w}L;O)3+`!oqCSeiI$km8uwQc zozkx>q^gE}OPEQtZ5N28opx2O^K6A#P(C>`EIgYdHPq+NoAgq~XBuGqX5DE+OmWL0 zeckcE!DKJ#kr>cAS;UxUvgjku@-cern%f-{=?e)e^`=d<;ihtd4l~GrGfw|KU(}G} zBt9r-t9D91`o2`(0gViU;@Pt&3Pmyd=u(mq_{y|JQ!PJ^72K*T?B~BM1XYD_AT68w z1~a#v;L#`VGuQ^MiYcpW1a?a>HZ;b%nQ4mFd{8zrou4mWm#MmtY_c&EyVo81xiH>t z#)sdv+{p#^R&ISvz37x=+;Fee!H9F7+WVvF^sXvidtJXXVKI$}m4slDv3fYtBEBy~ z_ahO$^lDdfvaZoAiwM7s$X6B;JG%U@a^37{qeM(~Z9ZEeQKqed)*HX8asQUu1%k2m^~{8 zzka1xs#3m+HN4lW*C*`sxinlvG9~sz<+@s^J!1ds7dpSiohD#w8?$6c27m8oT9ec9 z{f?_sOTX2m!M#M+EytxcnN3+KtY0H^BT2{Sdbx5`>{sKMt2M?gEPi`{*;jXp5x&Wy z)nNBZS^Uqwh3QDug*zYIWR-Z1E`~Y^3CV;!JTbd~F&0c(s^~sPT52w2bpC9#?@;CO zbYUXdf-Zx6a+Hc6x!Z&S@)AyTTE#p< zZd`P-fVFuxv@n;Z-<0Z2sM|1>^0C@-DBD>6mmJY_)#u^5QKmRkRthw5^zfmVkPP$*TfF=R zTtC6P*<0Xq^WUq!gv!e4msZ#ez`pL+b$>T_30iMIMmmZY4av#T5D-Z@tZnZ+KHJ?@ zu5UEjW!G1C@G8%iTQo}^{h@eOSxmfWo0vu`>%yW5^LZopXKV1h&c@tS{i|Q&baGd1 zNxwdrZCeaKQk~hfpDmy&qRS}_N_A4nuXBt^X3FhP;P|ZF(O`2QPuT-rUIs@-vn|WU zqc#g8`=k*jU)I)XDT4|B0Mlw|U!qsVFgYfgHcs9fWTYD$pgw7t!>U<17?iY_iI{>m zoy@T#kgemI=7hQGtyYM-BXt>6@2 z$=avQy&p;wD5aCiXi+PxO$#|My&gJ;vFb94I$H6zF5RyJWClfjiVaiNZ`{X~%p_Xd z&NeOJ$~oZ~*F*aARnb%&3q#wD&8Y&~Qe8%}enosI1JhTqT&Qox?%Q2F+F6WQC}M<^T5J`VGENwbM=m-VsO_Jp4j>@~ ziJ3fVjSq6ReZm|`2ul?_SX`8EUZIY_E8Q1fnM7I(!YtCEOFZ9oo?JRm%{z`$u$*K? zjh|lhJA75+we0%5xq>rLy+Mx-W)D42w&2|5f0;L2g&KMmvai)3t;}Drf4P36uenJR zCHs51Mt{h}urApfN#)0PTKe|;`2={C)9$&y8Ib||^I|69Ia0=OLbDo0c=|8^iaCV+ zI+daN3necw;k_DOR$cxZE!E_n^nE=i2agU%8Mqa-dEYd5qshgZC6Rz4>Ei<+D5p5ZFPt!kD zK|L2mJExu{((az5_jA8r{r2c89 zuYF}r`l|F7&!=SL>UaE`J&XK=22_GNXU*EQB;dwGFNt-}x^Yt_%h9E=!bhN~k`T(R zzv-nh!Ryy|-=0O?``X&IUX)iakRpA-k;42Cs9^v+Zjd4b6a>(<^ugRJU(o58!}Nk)IqIDIedjEKE zYRc}b&_%QGQBQ@$`+ja139}B08`b2qIz3#Kv)0=FWTUid`g+KUS;5j{o`+x*rHANo zEdxE3R*2B*H}{8zBK_nukdZ5giLc$-t506`g6Y?u{MoJszlxF^c5LI{HT}O(_k~l^UY1H7sPz_*#5WSLVp1cyqTop*Avtp>L!-)iycX`w9X1|FLeT^mT~o65=zy>!=Ll_~R!u7&F=7&)FaWvxw(o#yzy>Muj%Ps6sZ zM=rjRY*?VHnIH`7xx6JLpDcruKkF>iZx{Fl+c=kwWo)$~Xlw9q7&xt`ifuYuZzWXk z{dR4cab$~x^cgf90^gFwvTCGVA39$-?NQ=%vs8vY;u+i%4W2$Tp77;b*z$l#JgRtF8wSU3DaGUhgZLI$g#dJ1Ycl`wwen5INSPuPi3!cqU2iDBN%d9t@txMa4+G$F(YbTvirzKrgDyu@KuK zT_K}CmaNCNt2=_II6-QmzBxoeay5Q|Oxiy6)9~Yrr?(&FKtiL`4w@32Nz{PdnRR6U zd`4(v?7etes3~zmmW9eyB`AwMz(}tDLYUGMNYAB|<{3*Yb&!79MxwU-I_GTHI7$=Q zE_2-1zrtqhyS--8M=wkyXLQ5~j>Fbx^Gol|zlmIlM6G%)hzUP3kw~((bC!4EzZe?* z{@wAb$GaSlgmzQP1V;B4C&Tto^07?I2%4}zYUzZUTVEaCcxAr{qNMP*XzUfNi`r~& z){hgWPc*FBD!0BEKz>V3*ZDaL9SJGGnO0y`0`kl!_#4HgdccATUCd%Bx4wX$v6sJ| zVR@u(NtJ&vv+8GG-d7FJvz%h7+>}gib;K1 zxouqC1}1f+II%1Sc*sZsnB8xI2+BA2XR7sCtq*{gFe+C6IJaxi*^yMav$AQsW(&>I zX)2TLx7@Bl?$McRLR4Vs(`2svIY~-R=E!nubXTRcrUtXJW9}5Erk#NePJGxbpIjrh zoL@8~x}?w<9ZBGE8XnTyY^m*vO%8HJc=5N-YMkLLZBd>iykGBqjK zX4h-EA5u0uH)jZ*GeOeC%h9!NpYa%L^$eDVpJ*$Q!R}=m#_?XN4KWWhM-a?PBCYX% zsiQ`EyPsZcA%nr8D4*iZ*?`FB2U%si-G&BO`p$4t>xEC4s1pCYQC-`_hLuK zv8cUtN>wnu4jq&N)Iwd-FYPbJK3b)UAgx{f)2_~`lD9=WYyCft{QNP@u7}Cv3OSWW z=nsF!Hr8)eH|4Y%^~`Hi=AEDNhzCvd@vuV_)f)7Qr}fuG+D?%{@b!oq6>05O)@|=M zd>48^8H?K?B6eIXfm0e&3g^*va+?Tf&@(dq^vm|KLV_>vdXLoJQNvmym%8VZsl?AW zlF~S+NJw1(SVTfvh=L6Nj~C&T@I)PDGQrxAS!7ST4`kmR`o5`kPKBY#r1-46bq}qG z@rzm6eVI)z57K#HTx3Z$vOq#YddUsm#@k8Qn|h*KUS$Te09T0U57O@CY)5a?+bMof zoNa9PorTSzW@~KTbM%FdP%#Vrc=p`s|o`Kh+JFMQ=0F^=pe=dY4! z0(|NIcc3*264J?l3@~9gCdsn>3H9W;IK4+ne=WSHh45gK>w-1uyx5da4FBphq;t06yYB9tp^^8-+n5e-kiL^TtqUJE)zqd4g^Yk5xspH; zf99u|s(u%aIPjIdwn^{7R7pCehQ|^1&_mzzQzJXO9(x^677eJKB3AfsF>zn&5$KHe zpf)^{;P7LPF8nq3fK~DsIP)V^#F=X(K!&fn+FJz*ZrofUah>HKh6#nfpU)3A%)IBZ zWj319^E{-{x8h55N|KbH^nrdix8_T%D_NIO$|t}=H8w#(`tmHa+a3uiUw)+302N~9 z(c{-FR*EN`h9{s%p`BGjTE*sHvsK-6D6h7lTaPa0Z{$&eewK_ORv8=3CRJ?>H?#c( z(T1rGYT+RvwP+(uojCC0%n~W`H9iZc@!O&{F5ZAQRv8<9tRx_l5GQa^V`6N&qoM%)O*6&gNVCkm7WD2PJpP=sBo;8eA9% zRm+=y8zZJqENa}H==!rUU+%w|FsPa1E|X$gdAnEG`RP^DuW>)Q&m8TqOceu;j$t=k z4@v%}=`A!Ng!$n7wpc@6KSn}Y1496SRJnpKI6gO9s z$ZVRb-`}R8(%3__DJg9Kgmko|=3S6HzAwt1N6`?jPy1hJ7 zGdobnRllnzcc>JVu&ieNo}dq_5o+87P~F#D7twggu+CVovO=)+DrEwLRu8`R$5@ba zJ7t>{YG?oiD+BRl?)dFS&gG!B`4{SUWlJ05@4jKLJb?@`kBvXwXS3~es^eI^j5T{v ztncs@Ef3sXueSK&h&>dTjw}nFH=6GJbpEDviJ{{N+2TdW){jUCrmpC_iBJ#&Hy(T8 z-p};Ro~IO=`g}ZaWR2lqj(hT5ZUzN*0$9gKB_w6h41R(kM<84&QnjLqXK^& zxn84PU;FT;62h_`6{4>@a$4pQ#Uf6hQWA0cO^hKI@9t~f{rNnOzv6nI`Nk%D#BpV~ z?NwH)jJ@?ZZ>qOw7DSQPB1qs_5QE>DJgqV`d2S*-R1|-vo-q;p-|`cL);pF`qK)ep z-vP$R#`etJ`^?=%^nE`ezYVjI!-SEu5s>_#!4Js5Ly{2sZ%z`DB-!!qb=213$B!Iv zvNUlz<5|gmR@SC}j}hnJUrJ-qZ3PI%$X@NJHVYHP(pc)#C0>O#tu|2ArM+*Av?P9P zs`+TTT|Mcf-*%?Re=x!Y@TWujM^uZP?FI-Q8~IE8hx@#BGj zWVQ~^Vk3m#79sqrK!{#ZXs%ec?;kz$$(1<{c&gs_$wY^WjYJ?lx2ai7pTzWI(DRV! zfgRC!1E0Kid8hC<#Cb6yOd)~}?z;{)Y)WWRV0X(Bh_iD6p2?%+bE$7)XCfG@UZ5T{ z|5mE7!o8-DZQ^~x0?Yw6A>HSC@bZRj#1Qi0WNnq*`?G;7lpjm8D!G$+k$lWi$tWJN zn##Y`icqB&EgN}!+r-2LahD}|f3gWh{3f-m+4}TrJc6`QODEpjtEcS?>9#24`Dvra zUC!@k=|0sjk`Js~?{FIRG*SLTdi&=wP#BWKBp;#*4E}R>CF}V1&jNXw+)i;5F~4Qy zt4Wjdcbdwp*>5)dP&v$z%Z}sA`^!v!R`L#4qobPIg{3NlRq6KepG77U!~>-Q5A>}h z7G3OgV^3-*#++pHfPA?20tuFtZ;#PScgi~A?8fHL3r;qzhSesJm@xWaowRD3USnE=Oh>th_CqxzbM`86m7rq+e9UvaR{z*%RgzQ%z zQIzzsy$Vpje|O8#A?;&7wo^!!mxzsvRU$wKEWg@f{}Qv1A>i#e>+ThcCiUWv6ZFx07@Npn8Sc z$Vpbf?{n=2n&Zp8TodjHdD;E0+2j(!Bn^QuSJ8YAdrs2m{MSa)wEN8a`!p=Ncy@c} z1_@1+Y61r&x|3#cbj;vY>;PQ^;u`(IHM$0CA}{W?afqaBi)@4u)Iaf)O4`%y`=8ZW97Yr?4qXlPl`MyNdYms77Akn)5OC7n2b-y#h6^&@(`;P({y7{<@M{eP6)xVeju6AX_Os-Yz+R1MhlePWBi zhQ{9Q>m@Q!USENKd=S59teC4tKU3r#fE<2Ilmsi80cYavADRhlXpxdCRrG1C@%xCF*URAd&dDumei5~AILi0;- z*Yy!(K%xy$*F_2Ur!Iw4g5~sIf}2U3631yaQGv7s}--0~Plu2RWIgl-z3Rfe## z86Yf)ZV`f8o+e7x*>241a6Vt^_ZIYdI*iY=1==S*&1`M24k>+t5?`!l){r}}`2&&t ze3(^POH60@FxQ@WF6#)R%!@(g4(hpQPZ&zJ;3;Av5|G86vNi0n0k?6&gLnbXg{bR( zr&Eim_D~Tq@8d5bhZ1*PlQNaT+61=l;I!{o zx%Yuahv3#2ck1H%u7=kfYzDA5zg&#N9Xea7ws#qS>FJ8I#CL!zzNV{d+nCRG#v z?-|}B{_@?LF9|D0Y1@#^P++})Z@mbq8jZHTy>)!(ko%00)8aXgd}}$`cNaBiP6<~6 zV~B_B-3MhwpFu0NY1>BBv$t7lrFu`nzV5IQze(}}`DaeT%Im6)uCa9tBAMrTiqINg zhZ-jdBPM>^@xqd)k%h!TDkRku^ohJ{GqmD&c^YQ1h6bsUacIg=_uu4G>7Um)!E!gA zh|P_r7mL0`6Wa8ZlZsV@TxG&&sUl5hSAbn=(se|D^>pzf zy0yI!9YQgco2*At<0FStOy4Yr0mS$v4iMum$5hG4jhz$TZ>!$k{vaX}p$Z3`Mq>B+ ztf$Z%iVlG%)2w^*0hNZCb&M2?^J_0dQ3z}b1WEG^#gkmSwCcD( zaf4wpNMAC-A&L~|51avuZot>BdFSszjPS{%Pc1tSMGaZlc~zIR9;-1iQhh|-9B~*R zn8I{83+-w=i90G-qfX0@&YhZbPnQ(my8}OaO$=0T0BLe~6sTH*yHa>1AiT)jzyydQ zVaZmZa?ghOJQ{$K=aL6OS;%R>^+Qm)B!8oqfhspiKR0mRv#cB5p@N@10i{YnG9f;bvQ`kN5z|k$`)y7^p)Va$C0p7@87AfE!y%Xn^kMeU z?v$LUO~XKh6mZlPc+J zgdA!=A6|7VmhW0C%r$8w;xv#AH!DJm%0vZU#c=F;R$Mgc1khb6C?F#QixBZ={1E@t zvYe4;wIozIsDu(`M{3R2rVA>L+F2DlI=2Q+i$I?>KYxEhj7=pb!5nX}4kE6MMb>Ic zm9k_#e3;~!mw|l@oEbP~ZxbScefkVs=9$f6iIw7mDbLUTtEaw_^!UoqQ8j-{xbG)D zM}E~IEe~D2RE>IR+S8T=e7?j}?W5zFgk&Qk|3Z-jx<-0Pbojr6w*&Fzm&0T_QOp?- z@dA=Z29P{0{+s!@(rw*(^Y3vse!9B_!VR6ir%f{`qlwzYOoq`O#YT3hIx6p|L?l}^ z$qAVGt2x5h0U8euA0LQ+iz&c>|Ff#wzHi~%qx8Gi+|(c#G%PBWFkz}~Iw(P_(wwWH z>lG(bhCci^kl=BFQe>gH9*e3KKu1KSJYQM7&=}bP);fVB_U42yU-)Pq{#%Dt=C}of zzW3&gERZdn%8*anHQ-5>pf#FJv2760%7>ty;dZmc0591`tH<01cxTlrw~w`1 zf!l(VkBFX@iBlz;+mA*kHU}nJydkQ~^G_oh0uHmFTZ7+5MV z;X9UdFJIAg?B-$02^qLyMx1ZyD`H|`jmx4C02R{~P-My8J$`Lsg=Cnz(s$7M@m@#M zmjrAdb1v!qODi2JtB3Pn=euj7#Q6A3tgwmuPTb)pC=~9P^U86sDnoB6ijpzG)&kHG zdNu`dYMVE#-}c%>{t!xZq&&=?6IA+CH_4GyM~x0dM4B4|ecEsl1Hl~Sr;oeMsiX=! z?jM8#Suhk>#aK*-s+DH`yM!l*OCaSUk}QS4qIi~z2HM3|ZBXXi0zX8kNOL9|XJg9V zu9GdjAOn{F3CwsT1y~OrmepCZzrRN|P1H9SU`zNjxh7{3;L6)cguCF34(N!PS7rMT zABDb4oDDgry4&oy7cukG01-t&_$EqP~PEr-_sC}2H@{;R~ zrXvg}Af$W(a0MoxIT3JA0RPH?I)K|ayoPk1OzD;w>5m=99~ZJ?@CD#>j@}Bfu`MBy#eebbU#!M%c{V< zy7_>?p=3a)@ihsnjHZP7KjTEVHe)G%XLb-rnF~Xu(JQPQEvX|VlEMC`xJ@t+hlXo6 zQk02dzJ9B8{B5%Rl_ze;tM@N9GpooQoNS9gI4pD-TRJ?=U=o^FK!!QlEGO~nh<#4- z+ePM4+NZW}c)9ja87wVgoSu4p&-2QCaCyx@jQ z!cwsGj1+=NfE7L@dT76s%SK-~BMfbe#bB~}}oe7h(b z8|WmjtCgZg(A5D=?tT7Lb|dh(*ZoG}w&wMdbh)u?QXOLAi|auN=ZadR$xSrh&9h*s z@(6=&LW{6Y9l+uz+|^8M4%KjN;Vo#CfBk7-LEAf*-pT92#ye(Y$aA65iM)wDh3jyZ zlaL=nk*r#NL|sX>w}j~-!B9H7(A>gvgjBem-6Q^nFr z4(H_jo#i>cSJnnKgK|63yl5{23zbAu$|qTl(d-6S@2%LB{KAxO_`m~jT{&4GK@tbr zR;l;T#_KmOpy=J%i$S66KQxFk^bpOV6F7f-wrWc{9;TXU8ePCLiNr7z;uFtF%F3+1 zc@8HOEuJQ*=V>#NN9f!Z{2P?VNcmdHXPnBNP*ROT0ONT$baWnTafKhTB0x%?k2L88 zbxnu%N|yO)-Fg!vUcC7PVG`6glqv9h`%^JHu=4#hwHkF z_ev*<%im$DINCN4V^Z0iS~giv&Ur4~9drjUhD1NZk|W)0eLXyCnIx(FQe{A)@ToMG z?Cq?7J$OoA>cW8D$d^ZSan8X7=;G%E0@IJ`e`cVnp1RIDO#?#va3d~bR13j*+lTQK zc~&>jcz!%v8JNvfm`IQ`~@fpjPu#y>;njQ8*u*9F`7!9hysL5+Wad?>~uQ(R;h8A{9!h5}=fwr2G;-Ff`^!gYa0E1@Gw5-#!^? z{e~S#R!SftE_j-M98GMnWa_;>&|EP)YP5>%P-88y7irCC|C6V#N0Bz~hE;Ib0qPM( zudP%QD{nA?-k46`)Vn#^L5v1gTt&ce;yGXbom&-WmH++lLQG`AZqRx#v7pq6BrQh$e9ehqkw2S{8P&V zc?hzn>ZATzD|IL|7 zXG5x7Qw?Cjp{kOtQaP;fBlCr~46M0lyI9_zZ#L1+uNzSj$k~D7PBqMFiU@|-S4{}m zS2qqlXi)0?#^o$P!J~)F-%TosjIUD+1i~UULa|f`R94q!L540-`^dSh?>MWyZ-a{- zkL#~8T_O(L;0r4&(9}x^O})yowMdi5>DCGCo9GgyHfDixGznd^VP?&4726cJ%EK3A z0QtoMklsJL6r_oAdgUVuk$Yk()&JyndfTAK9Q>l|D8l;w(L_ECySWKyTf}Gk=;e2` z`+N41V2Js}-8Tbw;FmqZnc;erDrxoG2-qaDGkFwsFlH=wm>PlTHRaGJDkQfK6Q&^k z@em>T-E`_coNAFNJ2#Hs#V*Xeo?{I4yrOU$B-0HnFc&pqtNon2RjSKwW_fL#$4zeXne>^0a|F{EJ7KA6uQGn_WoY%{ZsPWT>Bmn@!sXR8#tJtZ6 z&1=m#k7s)xZVps|ERQ5n_$JyOGD#}(>kKR(fVL%u! zN%9|L_SP5yCLtg*5OuW*__{5k!63tTarF(a#SGclCbH-iN|2+yoJ>Eu*O8sQ1Cw${ zXhIYt0*KSgLtb#9@&T0Qt??7EgqezV9tX19eX;kt#1dua@*5G1JP9I)uiG|{D}$;z zg!Y|mOADD|Jp#cER)%n<-_V5kv@fsN^iXe96fe;G0}KHy#eoUlO@}-dh!zxV0f)SD z=-kbWb@g(3W!b0(oU0f0LOM26wl8wkGzLs><4pWm z`RR370A;Z|xJnL=BmgNK?`=CrU5KOCa+&eQ_(Zt#jF?AoNqJkIfd}Q_t}Dee3XB;> zCLx5~f~FUcEEs{-hAW#$FJ~o9>XEI^LN%JdUC1t1OvW%7%yaYnq91rQjWY)*b)+>J z@J^NkeF0DavIkW@7cq_>d*54q-X_Lw*cl@~x66Ftv<#Cs|w>^obzX4#a9mR6x$x*kS9b$%7B|e=Tg?o6yhprh0B@ z5_jI`j88OB)(|daPynbO*<64z-e0ML&Z4dCNig z5RS5J4Zy?OQwv*Fp-p?7&9d0$+t*EsSXP#c1!U0$KU^aKBIE51qAYF73xjnPBhaFf zo`edfhLt1bIrx}BzEuRcCJO3lqzJwN4e z=mcNEb+lNLzd@|R3`fC#8vYZ>0R%lmt>%kYN~m*o)G5Gp+PXk=3gXJ340-@{3@~V& zCRu4AS8)H1^ilzU<68|>!~vvO!@m;UYHeOub7s(0d{B%)zTY0Nk=y1{L8hd5g{MM()r?R!|i|8KwhIQEzQVZX=weB>COx#xNA`-<~C zuj}gQro2W0Gl6|rAv((-Oj$(wJdU##P(_DEZHy`<1kd+UX!xI#iXUJ7A0B%1-e? zm;3-Whdrw=z~XY5oz#C&2lGZ--*PJ^l@T?MPg+ zmEjni4!|vLpXSVVcHVAeLo3u=V?N8qkzzTZkKp5riP*^AaX_~~gVX2Np71y!w`3Xp z9pN>O&`+xivDNs5BESNLv|(xc8ag|~hmXQL-U>=@7Pa0GX%C}VYE!3V-(AB35?C;- zCN(RjZ&}d0d16m^ZMUKKDUM0~-zXaDRWjr<2+UgWuvi2=T9=9VJ)q^)*Svj~RN|6( z6)o%z&8n_{`}HUO{CXZxM*^LtP)H$2sk@)vMKz16V#UV7$G5OaO+0L6FGBa|>AoBf z4*nSpey>sH7S&+ZQ;KWg>IV%rnPpVCS^N)P`eU{<*->aew1g$SD>)C(nwpxG3~{{) zWw7Ce9h<{_zJudapm8&il(fl<2Y^*2CdNi2+gfPj?`9S3Y0JHinQ2(blBL-APgdwi z6FE|q{p5iSpkhY+G0l*ryj~f;+$Y|T8~eXjy*Uqt@yhH~9z8S45J%n zwo_Vkv&=oL_-oKOd5hS#DckrNMGzs_fIlQo4uVa75v|5S5gdAy1d$do)JP~JHJp6z z8>kF&W1({aY~YVMK&$OtTOg1;I^0iU_0*=oza2jg?y&O^$eglKN6hABxB?%}@e7%e<-_7!ftJzFk*5k67d`E!8Dnqdtg(ioS zP&0JaghF)@yu$tnE@`sux93b{|jB{}`iVSaxOPj}|$_x1*#2_bZADsg6; zXx7%5rTyN1cLJJf6?&uq>#K{4c~A*7Op|N3zOE-){OK_{ddBpmhdHS0tIK2q3~Uvb z(BAc+T@WRE8!hSFOpen8A-AK^M5Wp{Y?}Dv$sLwQu_|9KPf{6NAZERHgzXpX#{?2>EV=@LNCNFU_74kHWpsO?2 z_4PGOzmL$y){D{6v$RJa1>Q_=X4t&1@kb&L4>yFz4wz+vzMC2rwJHe^`*nn;rl!*H zo9n>Qrd--Hw>Q83WQ)D+(L47UdH85;d(Y=1LE?8JsOJ4YJpVz;JF(x1plGXkL@Ym9 zuYz6zGfF3P;^jX1b`?Sq22e2+bD6FM|JwiJ+&QL?$qqaS_m!`(VoWuGDTT?$%gm;l>)UINd~qK8oqF{} z@3g87xTv@+)BZU!E-o6Xe@*=rwXD^@n5X+rS&TGnqx&cMlwusbj7@Jt+poqd3-m>X zG1=!R4iMq?{B+af%Osau#ErS_(LcussDNWHA1I)C!gnNJtK1RJrLeUEdr}scWra;U zJzNfF6>k!@&u6H2ai<5M+hE{bt21rXvM7>PU+&LE=Voh$%~WNYERT4WO>RWFL!Bwt zWb$3V??-HRGxzbIX6@aajwySoB)KK5av#esE|0(Bq!y;>Rp7BR6I$!52VNW!LL~P| zed0x+S5tB+Cly}`lsL>1MxS3$u)TZS)YM!yY2RUB;n2)G`Pxr;5${ln`;}qbe*e`n z0Zw0iuV63fIaHnY!wb}HiiDPhpFO)L&*BH3{)c}Dq63Bd;O9VEBFH>a39`?dlmf0+ z4w0_4@)r3Wi7vFN+i2rz6?95~k@7sb>eRbtkz2{oSb~R?a12D8X92;uC>}$v`~b)j zWY}_l%-M{;hi;B!Wo{7<&RE)}R$#tk&zGz0o%g&;qVDWFr@mL9W^QHW>yf1T?EDkgrmhqrNs|qYs{!3nNDa`i^)7d_FbDCoS;GU9Wk=Xp$=)AQ zl~~7wrDMQ-e~CoAnhlG)M=8!QTQlw8PN{1nud6@(;_Tw`{3F9~3INqa4-7_>b@^@* z^d$kDw?Er#T0%^$cy~9zY!X&Em19LuwAlRfuuGZq#VRIE`$q2^xqrBu81GjftJqFx z1q&;iYB}E{8{pMDdMn4n_P>3_51GBiveG0#6d2@EW6D+ihNtlOEE+HRtRvjTTP8~* z_p@!MMezgf$&Iha;ZWbFQ_-09&C^3NMp>oKqkBNrC3CqPJUcTNxW3891G~CdxL^#l zgLFmqQp65KuBZ_;u7z|#~bX%CShnlLsb4g#H|STvxnm$gRh`j5d>=Tid&_OwOGK^@LAMLmOEKukk-Dr{_Y!`s(Ub z^U6^u`%QIClW}!qq}YHvCWzqP>PtgMjT=wJ(OFhmQ7)K^%MYGXL%gSS0A^JwR$As* zdYM~lZ$vG%`A-#$KH=(*#g%aB)9x~btKnR~fbXd(>S7xzKT-)xpEafrJ<4fx_(YPa z2R6$i_QJ$IhzmC``3f6LD=VNt6lSrk%_E-n0E$4pB)@%w>QQ;=<4^^IUXNtsMsfNC zFeoCrWW4vq2E;EWks+ZUGy{!$s;=LF+xgpsIG|$4dUlv-Q5R7rne*!~Yk;wU!EHw+ zjxU?Hjya}*hpyot&0Kb{mmsF0*}T)>i!A! zZ9JTPWc!(u&MfSr?Tstaq{hm~Jp8;2stl-2;#WNolLExJ(z!57 z{NBy|K`>b2;Q0q=HXUI~2j5H;HVTJhLA#J+?3PK?4LJD~T3Mk@$;8Nbbs%#Uk9=Gj zTsbxL=g;5!6FPNzdX#;z&!7jwO4WeGY+J6$Z1CAnC9c<6l*L5yNWf?;@FB z;US(HQ$&^aVA4Zv4bP~P5tz#8;JHerC+VdNGzjIn^DSop*5eH4C7_EPcT^ih~4 zJzP7bH1f@k)ojM{_b98F#*LY8%;`~E|1b}HuQh_ ztS8g>hsTh8M4FX7z`u2(HW9iOYO6L0HjRLdGYEV)TCI0I}pg*T}_T>z< zRK;$clLT-qlPyM>WTYsBYZniefYOeMVSkWqN;WI_Lkf`UG-j}_hd!%SHonFxASVzI zGX4_g2tARCW8OGXT6^IGq7rf(vUTkY*?&MxBJ>t_{(rCl@C6@SvHXh+;OumzrO1hW z+4En|8o>}2j zf@@FtAsQ>x;P!dY1ScJE(+iE}Lr2uZ@Oiws{=Zg?A~3H40o%!pp^AXscWQXk}s=#mjGi z0tv(ic7SrZjm2C(Np4}OQcIy*=h>rUCF2TdnUxeRRf1Nt%{c1p0eG#i3G214sj?4J zsc}>5hlMG|8hBVIv2 zl|H4)dL83NQJ_noOaLD=&fp4uwWVJX;&ROez6a^Tk6W=yd*Sa*jP8h0ght5SNq&|r zpQZ2@St*zd0(zS}!YAzdv77i(wWY)8fm%2N42BI>R8L zUy)4ts*@oL#d`l=zSx`F+xOnh#Ebs1>(Ojz474 z6PxJ~k@iiH|515({jFkF>UTcmvvH~k_R_SPfyZ(U1iNJ->W97(1iB;q7m9PfSG?-P zaDx8_+!~e<57u+kd%b8f}IK8Lp%08z{Y)=A7rZ!0`btW zTHS*COY8WAi4BPO)_NEL_|S*4@C`$VcaGs*lmPa%qmKEK_=IpZedrN~&%3Sv0Ms5C zVsN)g3HzPW!dPfZ6LmADgJ;k|MdrEiQVntl75bVMC28GMf%_nANMaL;{bV87(I2Gl zSEZPeo0OC#8=gF#kd2E{8L`B)9|G{P~Rlm9w{Ztqd)wjK-IpY_zUx`Ra0YdAtBT)|IJ#M1eGY0vHz5+1BhG5FnJT zHj!54(A~5hd3qY1#Ka?#yXp~Zo>Q;V+;$~%wGsTHZO*&X&aXQ7B>JkQzW(L^vLxB= zkV@t>4lwAQgp_n3HMV9G2VVSEkV(_a%WD!^p%un<>>9*+_q4nyO*XL6tf)hfUJX9MMy`4c%&ti|GI|!i+j%xTKJMOO?!SNH?&;p$v?ZGO+aC{k zHM(P7)o92?rHZDQ;xq63@ndeYrQngoqqCFooN-2$eMM2a&+oD$Qm7D#`>}?nZE;J=yp)(v=~L-PjZ;qKl20h%qn4ali}dd{ zryht^GUZQvIteS^l7)jg5C?F^G;yBEyf_<7$-zNTwE?=^?NwhodlIwu%{rd=0$RA> z+{s#Q?S@F-EkAL1T45)3{woa=kdvl+yw^ijsumaNKv9D{20!jcG9#)0b7g}WTr>pQ(y@F<^m7U9} z#Ij~-43xMF5OdtpTEQU2lgWnToaou$_=Lb5h029ABSaNHd&mQ}{zv3EA1_K?Jlg40B=>r5MJm^M1q*X-`awESVZ9X%dJacjnETN+r>8k=x%@ zw|_HVot!x-QeT0k;5;mBgCCKpJi79q&-B!V*!*Vnxda+?kjd+5tD6f5NFE>YiWt&sUXfpZjoBc*V{g|LQ`NQ_fRNXoTg^bN3 zaS%U%>xai37Mabd2d*mjm-kvGH!e53E;kl`y^6X7u!4)10gu$N6uDeV#1Q5BMbL25 z49P-JluTOvddeRWqrIPIxYCEBqMf3F2WJ7EaYC`s?c0? zmWMj+ZZR-3>%vh}4%OAwQx1Q{fypH+Rr2LQaxywo)`5Y}UQLf#UYl|q;e|IeG)y+= zR_5`Hj9u(I#uB`&5owMJuZ(`j?J|qc_wtdRPOo61Y8cW0yS>!8+M!8UX zJMZ1|tpFIkHYeLKZ*3J{&1QaHUW~dbSkz(=X!@J@+~etLyawF|-f*&gRgO>wMrK#n z?EsJkb7+6Ddk;{we+HKWQpvbPR}LzkAyDA(4-NJI5np}PUNtqM7Wl%(C;kLQ*G zBW)hUf8^uT4w-6PY?Sd=n6LJ_wzqbKzzSQKE2sYH|3gpA_4hVV-u;lndX8Iu^cT#` zh3x_F8{66>Xh}A1sWC4fH8!rUvi`BOJIfhBW{3VmML{K~m%v4Fijlc0H_0%n(9_V+ z2<(zzN+{v@vr*+!poyjsCtal$m)?wI6+hL%ds4!t%*Ij-=yxlZV+G9?ncvqD7k3Bf-}3=P*ogASlV^3z)yL2-XLD>3uNgDoqx& zxY?DQHgV$B;SJ+s9~l)Xf0xbQ+G6;z#_k?cdCQ~r!=rCcU+3xe$E0RCJd_Cp!mz7@ z!prGrLPjQL&W*-|gr0h0HV=%;SJjG(dS}8?e`=+PVxm4V^es7+Xmhk*%BOU3btuRw zU9B`?YDa@FYOm-f>lV2xUup-wbop{6VvvyuT1}Xpv@d8rDSIjyGK&XH_zVT92p!g& zd3$*&mIzXDX8c{9aCG$k{Y<+~a?<|62AGvM$(|S1-K~dHLi$3#TKtC>1;d|Wq`YzYGLx0Zuothx>7v+?Q}2r#iH{mjy8RAz{SyBT!=Tl z^e)70_I^Zr!afcfC+@Dy<;lx97SDzR^eTF4DqXQe!#9MPpF=>Mc!N4yEKc(0 z&+6Vn#W1QmktXrWlr8jm&LP9b&HdY=d^ypD0 zZ+!C?6yS*ewK&av?h14OmyEPhri#tX&DAR<8~#jKdu680^t_S!$rA%6Ny9RR{gZKd zectoavEYsENVFs(HS$zU+}7Fov-{T1=L%L)6Mb%?IfaG5>^s8fNtvaK zc*x-_tQo%qxmPGAGT!XJBh@FYtXU{-iVR z4nn)kKCcbdYsdxCvPvcwB&SxN!XfJYT(^2AQWWrtNvgriVtBN?%SnKB|ZMaM+zuuJc_G)7JW1()UKZSb{TIei^vgeZW zwz3L(VUyjN8rksi@L)jZvOwj*p~oQ^oG^|%bGp{JM2hEkc5z^V~aqy=$I4%{DqVPJ#yz#HL|G@&FHZLdh zDVb_^Ol$1Er>_~GdUHRd-_p5M-eBNNw^q>QG3xxog@j6VjDNAK$2C879M7i=t?4BA zQ2V(1uidY)>bD>}lGo-cW&V9IP5^Bk1yubYxP(%W1AbmgMSniAIz+b7p(OM>*8(TR z>Od&u>~^6yq$Tp-i~+J}Jb1lSz^Z}V4}1Nu8Ox3##3IXL;X{a-h^l25Dzv*+h5}MU zj6Ky)z{dj+`#U4{QHrFIaz8WM%2%uNHh%k?h)v>`O$tcpyvyisxxLu`^MC~64NBub z(1b+Mz^rMLlu~0ieVG!3F-&ky+9($zgE~W)dN?k}>Aa9O177ED>pZ}Y0%eB0#=gFo zbI(&jJLMP@7p*9*$y1LVME4YLwq}M?BxpFsnF;U?G;IucccQm0UP>(xma*iwo7b9T z3_=!5=1$fF#q$#tpZaavll8jqf1Bkl$C(sxGHFjOJi7p?t~toxo6V%@!uyGVRYKa? z9b--m#3 z_~b&T%{jhUQ6wN1)ot0*+hgiZe3XT=<1O9YQEl>2qHb|eA2Fav-ruv{EU>ebZN`}Hwk2(b%R(}ef>G+XShXI z6+h&I33y{J60PqL%OO2IJ=@#)z!ATEKwm~l33}}Y110=J*OzDa{=aeq*MJUSHr+lr{pRIo` zn`(jhJTA5&x3M>fFXis(d2rs|)&BMOJnc$D;wX$~Eq@I&}t00iM`xpzUQneSGpt@f9CJ3WvZ0p6pJO zFQ1TW1bf2P%OykO>>tL;$S{`Dr!#sKAbDpd0K$YO!Z6MR;pfW9`|r%FYOAYjYHGB& zz<6Xt-btsX{9W-8(l7y{vC&aY4ULUI=o-gugH5nathFd^!`>A=_458*&q;TS-q3CI zdB}u>=xg_pCl*pc`zzy1)~O_=x1fkWJ8iqsJ>>b-l$&!=xtmG0yffwBkVvr4UQC#XL6Ay*p5LxK|0 zJ~fe!D9X+*5S}arI?jKGCG_>d#f6Eb_oc;IdF(@^}5JQn`p9j>1~#Dv2YWJ_~jr`36uI{$aa=dc@)c|iGt zRl3W*H!Zyf`;=8!SaJi87{~D2v($d>pRm{6H^$iPCpBSFMcnxrHnAIXO#HGp zjfQ;b#64MGdTNxezLdb88JU}-So+RC22|(`jb%T>aBZz#-llO4ckwM~?pi3}r^nSM zj+j@Yj5>UHD@1bz?9PViILv#%98=0CCh4m%xaWO2-MC2OUiGV1me}`#B4WZiXJjX_uu=L>xeKaE;7uZTV0B& z2?yKa%b-1UJS%%Jso1d)W=@wFwLG5XlBoi3C9A45mX-tq(-r^}0#dK{?~UkLrW}s| zYiX2B_wREnD3yHq;1BZZSI=a1wKKWyQUpoS5Pk=0*mqfGdP$?`vgYfQ}-q@K;@ayNEjN3O>>uw)?8Y;qR#ogVV`|K`PYeL{UXv zJ?__eG78hu_T@sfMCJA4+gk$&YYDGL19W_SPU(bwbkBHqclY?XAh9wv^%}kz4r79% z5MSiY0CDVljm$WQ_}RmgHxBDdzf6`s764KCxD&pA>?pQ&<61sN==NgtmOd1up$+R} zg;M@?&C|v4$&QYWNla2a)bYsGl$s|KaMjUlpQyv%h&wc@6201zt53$%ZYA#d{MbNV z9a&r<-F%eGoEvjI8yaAvMJ&o)3&Ef@t9hAxa>=(Wdy!n>BXLI1@=3RCZ>&u;ySMz+ zgcBbRZzHG2_YA7rlNaW@{K2Q(QowMx=<(6f;X-~r1~#&j=rn?3ffTc3Q#KoTdA>S6 zo}A1(VrXt;{CxCY>EjYI!L3D}uHV_D66Oe9K3twUOu7FHBm2%>r7r%OSeNTm&a*bGmu3@FC$Wh#ElRaA z&|L^C!V9>uKdOiP?BVEW=jpk5zLlp4-Tt-|j(1O0)tNr|xzUrnHxgyVm0ZBDpHzjX z1jHxV)3Ujz@cX3O2pZ)@k%JD{{1z_TJ3Ay%@_u~8Lf@vp0bsClipvJSl}zbP2^Caya?e0sGzK;Pa$lAzoeUnUCK@OC<)D1LW4lY@Q|YN z@raE-t*g@;em=hTzdJ-1MzU&>CVW=6&d)KJtRc#~)5KvRd|nf7RxtYGv%olFq}u$K z)B;h4#D6nFjP`8(|0IM);`e3P?`F!_(07lFjJ&C-0kF`tD|hk=>@yzwA!)uk^}WJD zqJy^(>5rd-<_{V<^yYue%%QndzMVdC&qGzlC4NATv$Qe&)PBpE9*09Ee+P7fI3dPb zrw~hTZ&!c2=zhAy<$$y8W{^mfPw}m3tau9S(^}I0IX`y_7C8ACU54a6I9x%NnwPcS z&bI>U8bvCYc!dF1pS9p@fUH_0=nij(bWg(-R~=TZs^U&i%*Pe19Yrkg&YD|Tm>FgP ztaCw2e`TsIN8=X#$2?^*;iVS>*iAi6A zWzi-mJz-b=(x}mjs@U{H>)4Ncb)4eT=f!Z^=S&nu)L2XutsHK>F{tKgq8?tQNasEO zz)YlsBtH5#bDX((SjDYwkF+)_eJTNqshDzb?ci>u_XE^xN#6E0BYix01#fDrg+Q?M z=)$*|(-o7WxG=Dc&!}0;{m0rUV;3(vIy%j_Tm%@rchtEV_MmedDV7179lvwmH6F1X z)Y^XvCih;ERSC^+$uzdtpIclG%kc2?(nSFjqafhO$Ijo|)A7tz=3MzHA$`sl{Sk-8 z{iA6M=Qp*P!>gi+im)2GgpUHR>qdt^f1d9Ktydj>9_>b0SA2Ki1#6S^d0VsD=-uAn z`au6)|zBJ70ejRYZ!PwN)D%ZRWEgdIHP^L7Zwlg$+Z4Gbh zP@a}ph890%956RB68Y?%1bv3Mj0|8gUd4csmu5!h--sFS3PYr0!BSLbL?V|y$rXQ~ zlMok2iBb+A9&6{Jgk>Hzzxz>;>qr?tYU2Oy7UZ0bqSXYzhn>uaXY0P~ru0_r2eh+I z^a*q-H(_Bh%UK0Wt$b$tug80k^tQy0h=J^4iGxx8G*tS;igG;iTel_c?Om7OwUs!n z1}b)?><&4qu^eBza;Zwlsm4<#|E{YVA%P{y84vAw&10 z?7wFOHogGCd-UTwH(GM{`5sx zr0IwJ^d!~&_=;0Z=#3AIN7>T5wWHaZogZt-At!qO&D^Z?v4u=PR>6JEo&k8x0EFJy z%MDu$jJ+0(|6j7Y|MF1(C9D30S^xjN5dp|E7faknC-nkKX5&!GDZOBQL0Ei zj7aHsXMnTxk@g4>;G75EfP@rAf@BkNJd6})Ocpt?YhXf8mUi`FQZTB_xI1=?YgS6 zqR+6wK9>*t*qVSA3!O^ilkR6rQC81PsHp_ME0v&BXo2d)$~(A_LYeF~_TKB*&;Q_$ zvKkp1b8KZ&(iWi^wgf+5`nn(0n)ZgJD1)N*n@X2A#hvRA-Gi<2iS$jZ_ zTa?nlvQkOiy6>qKva7Np0|CFYm&7xSyqbN20{sFAy$}kI^^oG|0|j>;!Pp_jNdX>? zI9=ofRG)GN_Oe{-3o^l_!O2EU%0}*pe2g&6o?!rKSF9y~%-1mF5zi@IC9cINi(5)E z0pc$|?n#+26rsY;KChMnP}h)Su=yY@zG<@9P-DZtQ6~ZQJUF;ZhW1OA`ShV*N7K8n zAW7NVa+24k6wjjg2oz+|%)viqH@^_mU(lq!>s1_Q!1LjlA?;Zg^YeS*( z5I|t1_R@ZbZ%z5MgNkV7*X?FJND7Me4$k|AJ32agdL9xyJbd+Z*3+{A`7xT5d1U14 zL9>c)$-?f{$+E8BQA3009@~o!Jw6e7V(%qIBR{}TU3VscJQ_Q=a4GY5b2H@2v@!B9 zdz2ODahrJW;)CWyT1t2&wF;U-pNYc~=r*05x4-PA87g!uq&ygB%nXX-w5N>k8-I=r z2-x3WrN+Untob6?$*fnEUP0!kxBE-770wbJ2R)QKj7yVhYHn_uV_HpZr;tlhE4vX` zrgBFg&M;6aM3n!2TH^bUzvp*%G#hA3)XSEpO0o)+2Y|--f1W2IPm51fnx#z7)93Ky zr&CEWW!k0h)yx4+Sy>tXqvnY;;JYU+3oFS?OlH#r)h3Gw5EYmRE2LLYXIq=9RVhXk zZ~>IKmuyF=sOB}%HF*W^5{3Pyz)h@=YTVzTPtED`@e&(1n{Rqp3sq?Ik4 z?>KUGDW@omf!G#D>e;MF$KdL!&8RL{di#$bl9H0oC`t$rXdPQritrG@Lp#G1P`29j zZ(qF<6&y5SrN45@8;8G_tUaAy!1WtTN=kx)-N3U7Kw4wO(Ra9{x3+cw1WD9mXqXlJ zw$8jtpHo!}S5#6IX_iDle~NowkG9xDiGf|u%*Ytac7RzP+V|-a2GW>O?LNu9GZbkd zaa>?>@L{h*b$x5=gu`ESseT>voRPT=!hW~(EVFZS(U8eaMxD!Kn{0FMz`%r$Iyr1- zXLyvV94f1-23VtjEsFi%{P=yC3fZu#Gnb7X7Zp8bz+K^1ppka#+{JS0WB{u;m123! zUd#{~sYp&dA-qgkFr+}khLnJJ{X!Z}ICSq+tU_lmdLdXIO(kCJY!oTS^74WB!PGRG zNFM*0Q1%G_NV7>ii0FoRc}xQKe|7g46zl#|RC_;M-fM@{2iLP8jgs(!ebyk zbHJxE0Xn#jyMC`+wAA~L+B@b*Z0?CmFiO!9VNSrqGzR`sxz6yJ(aUTw2ErByom0QeaEn^GVT}dYH(Ge?Hq4czk{u&tn{;K(2G1)qRm9$KSZ}w9KUWG zTC5Nn5l7qTps(9#FOHT(Inqzb@6&%88w8^@->+`1Vh(HPwSPYll1hUvq0vsKaV^PG z`~N8K1yQ1lD9yWoYYQpF%9GqodfoFrzsu>(Y;*2)U-B*3#e)r%-Hf)+o>>ba=zDqf zi}~_mXsF)V#CMOyv5edAa4k3J%N{#>S-sjS8T`+w<^9-``?k*7jS=RsJEeynXdS`e zw9)J?@l|58X^t_0IyZP(yPQ6`-zRsc{=@i(f#bg)X;1*k%P(oHgKdV>Dz2}u1LJ2t z8;=sQi4I8Z{^_P1nP@#;$IBh}bm$f$1(=|jnf|=zKCFM%69-azdgSAXDvdvR;}lE4 ziCGUC__NVuTv=LFtg)%URC#$BR2wiVNh(*c1;6m?^QcJUr{v6tOM~?<@16Qu@OU*J z=(Dn1_{{IF-@AVk2;{C?56*@S5#r%Rm)h4<*L*oK*M6pg)7It>&R(fKj9$3O8E>+} z#;9iV#qn{4fZXvj)$d}F(J~hsW5@u{Iz;Htd~VsGL>CM&LfW+Xc?Fd@06ceWuF_0uXg{WJU~LwjN;pXWCWh_ zS^e}zh6=jdQrY$F*W%lSU0taNm;c*3^TO1t47gxlu9{`8#1J}&6wos}Fc{>zjo(d; z%u)0)HI5d+7wI+kWX^vr$J6kO=hql>ymiVK#YySwi(E}#Re2xa<;7=fY%^IMw6pqp z_{FN8zkM@JkANg);^^2}+=EN;XPauJ35wz7f%X;ZwHmt#E)m)a(c4>FS}KF|&sI$+ zJ%*IC5`hlxU-Rqy_(qAe?(|>P3(Q(a@=gIY_I;@Dn35?6ZOYvJmP*CgOcdyh2 z8ypm}uTIy37l|tse7SUW8hq8&ZTYKhA+X~+fS1fOo>g)w*DKh>p+KnzFv9EkUKNr{ zzL##Id1`EFc%qt|Oj;QNMBxvVD)e6Wl!rYg)h6MjOmk@T`YZa2J$&R&nf=gTd!<9Dd7*ijHG&e}T0Hi3 zQ%}SSxC5D6*cPX=s3J zh+^h^C17y1Q5B=2yuHnf!`b|u4sh7elsA)nbEgi4N7JtLT19#C762ElnoNHq$FdN+ zlIeE?=s--d8ZEcs7#t?+-Ng2uDgG^CWztn=&jVs&83b;T?rL(BnM?Yopx;xgTT`d2 zQE}HjHyy8I!}d4g+GxQJX9=WYUiGJxK?2~ftw(FL!{ge zHC)N(9pG4TxtNF|vnCCq6?6vn^Ywaj)O=VmxN~{MD9^k5~DCjsLSVm>`M5-%D8W zzZa1Vp^<+s7h-u3!Jlh^Ega1F=W5o$VfcHqX92m~-+MNc_(Q>e?ir1<0x|r#C!b0m z{O=ybm|$E!+{?^#)T{Lcmt%TS>5_tK2UzaUr=H_uu?P`=e}7AIp^RbU3fnoM|9x2i zi7)Qhrf1;gS}Hv#Tf!Imq(|`C3BxMY|NUhWv+S9Do$>t<@BI_XxUJKxo*q;e;wAKd zi_Fgm!DEg`m{&D4G@KugNV_^Yg<4*0O>z#^{EUl@i(A3M+c>}OUio<*F#oxf!!OCB zuYUI%x2$L9!+3;Gc7#Mox8O6*WHpf@-4gQfU{_@G-pmX|{CDYAh#(~T)d77-=Yw;_ z3wC0x%Mb+Nk~+lZe|sW~@esxG0VY!Ve*OLikszI&oy~}ni!aM{xe`G)H8u5neLa!$ z?(V$3s;Y{PnwnmPo*_0NLE7)c-pk9&#s;kplNJLLb=2nd>(@a+bofw_;FFrKUqh9B z&UPreaN0)q_V$9VUGFQ#H#IahSI2KgVf;UvB?52A@uA8hQc_BIB?e_a2Xz{?GsG4Y z|2A`USe7YxBKQo21)cF>%|j{0Ex0(YX||2|H=pXJrYMx7Jw|x$xJ*beaj-uP4UJ^b z9d$o40VR^fmlG-^uS2YmdgJcK48@(rM^aOVFeNIvp*3V?W_q@m%|mmcLy%~Rw%%#; z;+TI2X5%;VRhe65LffNc5G76z&Iy%Pj+B|HDOa*ug#q?0`WPA`MsuQ4K`^HZHk%TR z+Sbm_-Fx2s)hiT5wf3ccMD7L~Y0U21YuBJ_Dm-~y=svU=#k&W=^zv7|q_A*=vi#V^ zW^vBXK>D0qGg&&QYczQXkByxj1_NpU$fZA_^WVujky%F4(kRv$_s&RLq0Hih!qU>x z3JVK2HywJ=iOE0=Aj~3%{BfClnWo?`!GnzVj;-d(&x0{!ZGzRQ-+c=PCpEBq7C$>b zKfGw<#Uh%As)t6Gl$5Y)Wz)MZ6M)qZ>_r|d5iF4nAScTVc`e!`BqY8ozg@ddiL46$ zZy(sAlSb&31A>2a^5OIl5;I*8r*5!;31N)dYREMuDF`-AB>eaLWl~f^W{e3+6#_Vi z2w^A60*Dc{5-qI#OY5U=o-lOdpJ|ZL`ediwH)(Lvdqj zi{@;UTza!`{Ee@`8ph&=O)GT&7A~qHZZ8M+IG6-}ARaUfdWU7Pwos-+773p_ZNvTR zWWIlKzCgkz3FCx4go(ja%VXhkph|j8di7ZgiQ=b>1C|Lsx|IPd!iXH}DAS`M z{Rt3OFf)d!?AfoG8Q;?_62GLTCeaVfmu3n=*4vJc2HLUdZIMen?ST{$Jg(oXOk3{$ zZ;j9)@^=S()OZ*n!TZ^YVqeA~kthyz2D-AcOi}68l26K#}-&wf}z;T<<@mQ4o&)|CuTOKmO5Y zDE;4zOSQ_3+ucLQ5>FOp>F0}GNgPvZuI=VPneOb+`)Em{z5m~HZ&b=GZs2tlJP&%k zw2P{(+$Q@VY5SsC}`zBTFg-8d;<#MFqyaB=K$hPPYZJmT?ya+ z&Php4RV&n`mCI-I$Xr=m>|DCPqkam)yZ8tuMD090Jk;54TyQZgBN-B;_*V?M1S1qd zBpWg?nZ=o4(~#ev#6>J*)h9oMZ4wT`taK(PctzVEIrlCu-jx|v2@BN^CLit0I~D43 z6JuRyGQO5mD)_RrPe)D5sD9CQ&z}&r0e$@G&6F&$>^Kk>Jq^)iSbs{bQD=`@TNFCF z9hu6(=}|G!W33;LOb6EEAhG~{%f-hVM|0T08jSm|F)*^vbu2@qBA@*&;{sGK^^W+G z-UuNbwcEK>k)0LwL78Znhfzx`c&nPc^P-;5EAa-2nsIANzdQW)puemgH#gE@qE zuNV~*#VWmjPn2TgC6*Nu5>i;GcoVhQ^oisMGhVhyXClTS$*+wrjKF33G~aoyP!|(F zi7rYx6465;c72T@3s%r6FZFN~k1sLcY_3Ec9*V>xa^ntB=x(D_)*hLLJA5eDZE{-g zkGzikcPiSLnPHmJ)Qx%T{g+sV?JUuR(+P=;;V#Tl*lk0+CMdj-=q?kVx9B8jWo_Pe zm45SP;bew&zWlj=o(Oj9yT3hO%EP`qtdgdoruMD8HmSzKLr*VjZ+G+@v?Dnw{&sgk$yro_;l;kFOmXIs6q+nCL%(p$Ha_eOH$C$_48khU0wa& zTQo15>_X5FGvw3baep7Fk~F4yEwq1q)P}=A_qOTL3C8`aVzLj|t3>TO^`4$bzTT{V zYG&+w5wC9>NG=hv)1@39H)c7N7V)c3!7mIiWg(thVLDH+#vLyM?CN`=(OuS#->J*C zlh3nJFgiDo$gks@q}X93D3BAKSP1xd{|}d<%e7Wf*h9k=kw={G&orsw;|;} zgEbN|Qt}k{x$P9BsI2dSfGrc=A|fIhwk3L)Ys!N-`(d-YycG8^<%S6T$axW^hgFoyn`&-}8 zcP!{P`Nmn!d6?2$xzMT{pwRO-%sET6FI|chCWYPMom<8HX>%GtcFLbclq{jbhsyg| zyzyYhhU-XaJ}+bdvJG+6!y0|4vM+w$i63$vU2q;)b)z0h9f1*Hm-XZ1WMX3ClYWhx zpF!1Rl8C5t0|B>NxWU!rSTh?ZOz;3Ca!5r*WnyGRLG@a!GKQ#Wc(|hT<-0eErl~{{ z2XUb?slnw(7l-Ll0(}dT)B@Ca?ivI2hul94b45GFXE&4{NO!#C>j`jw>*qIHss1$c zHp)MS0l9EsLFxV|YdYuthD^bd*iwbk$2@gNC_6@7T^%Up`D3eh;LIRscXvVi-Knd1 zON-AL{Q~3cY@u$!%nMV~i@TP6x72bu-}7_1M>F$KnvGLGpChK}^df!%f!pr$rQwkg zhJ$5NNNQ{A?fupLO*>G9?L#tq!@`2@_wN^ujtKE=|CF=&b4k0{*_4XTfoglq7uihi zFTjs)Eq^&;B%X_o%SEFV8uLsI_BuAb+?u^k`1&1c{vmBmGGax6jsdyxIjkXaddh(6 zwE3Gp!Em)LA7vJG4(ZoNVsRD`U`2GsVBy@xPGrvnl9{$*dLMy< zYv&ne_3&YVL77lSoN-0?vC^mbk^xKwJVTd+4f>ndggAz+W!BE9wW%|q#VD7$_(KQc z-UV~uyu>Tju1dWE`3is1UrxUD#R9~vrQ6U+m)HcC*3GU`eT&xX=dB)Z(0&d@>NLr_$DLYvReDJ^6tBv zcLMTF+R6Jv?UK=KCN5iG!OIvJ99jD-|F2C2oT^6ud*o~=DRnzvm?`(EB%R0ACUsYjolKs& zt^bsi!tN=SuN^HXA+sy*S5LOx4JdoXK6CFb@o3DICS;uZTNBPDRugZaZ&Q%lJ0vV_ z?A%?wYP#EK#!cjVL6>+NHWXT9o<$zZ>3aqv3Mx=xCw9w^oXDfPTI)$VekJ(ynnFoM z76T(f`fiOCQc$v~ib~5&s;}cd8^s)=Om#=4CH8Sg0j3;MwZq_+4ZdHJ+@!U9<^n{j@iK_Vd>9^Qp0NN%70zJPSIMbz6~Z z*M^b7`uP|XRx{hX#~wOAPA5;T=!^FsXStAB_^taIwy|El6e$0~Z-qY?k1P$kYw|vpIwnYST zN0(HLWs{|oj#~A;A!ljxO~0p?IG3Vo-%O>Yr)OqlRM>?)I{edob(8GcET^TDz2#T(AVQFC|NN&@ zpC)L_+!WacMvYhXnY&}d7d~lGQBiT>5TdrBs(j|l!~UFkCr2OI@xH#k#Y@oj^>wnE zFU09PPqG`j2sw_HmX>>=LD`B@rZry8t$ZMMI#{A@AEVBgga%$~r{$Iu^;}B&;5g4} zjZ=E;winn6;$C=_Wtnun~Y9iGaiB44KOq9rz(%%_`twl-3G=7{F#}r?Pj%{7D(*L#RiI0g)wT zmY4*iAfqu@{H;8!YzA|s)^tpZfYAzqvad5Uy=y!)GVy6X^G z;_#A88~u-IS(n{(cb2{m1PsgrQ_Ec=%VIpuBpnY4wmO()-%L}#mNGUm1F+51w4>JK z?+i~KQTCG*#Pj$${3bfG^xcry-Lf=dDWoBn`7&$6&!~b^7=j!!c z$r|*&50A2?m~$|oKXt1%BI#aZ3ps+H-&-+_xsS1U<8u)|e%z;HV@kE{^05zz4Bfw6 z&=j^XB(sZ(!NzXRE7%~n4Jr3*jr7h@XTf&26~uCfhId)NwY7EdWnbc_NhKm;n&+SM z+>=0BpO@On+v$EAmVElusD5{5ihpr|$DZ=Xty_8o!p^qna572XQ7EG^rg4N^uJZl! zj*!G{k%da9Hp=0vZ-Oy)nAsW_*AeaCAih2ma!f)~PQOh^%{4g&y)~526pIh9POscV z$mysojE&`F54U?i^J-!d+mRH@%o*8FjcLp z3VU|s`{&Q^x3q%bXVVk4ZEIk53Pr5!VW7ksuoZXr6Z@0H1?a{jkjFA^zjCrC3FLsr zns=TpP&iY0fLkgh)v>OWdg}>)cGcCJ(~nL?F5N$ILs22o=LPQXOiA^hyBmLu05iX@ zQ!c|kKHfwlCW8w!(yYg&7ybD0V|fB^U!2Ke*+1)k#@Qbro&(K)$SCc=>&2aHUo{~6 z=r7n0=)k+9=f(LWNYKsFE!s{IpNY5 zShVXKpS|3CkbXRJu7$cvi4o8dp1GxfbAV3&LE>(PwEfCwGAGXF{FA{iJ0eH5rRHr0 zq7g`Pjga-6shaOf1rEkV07sG^@rd7 zv!fAlq!CWXwMI!C-gBe2v$Nx!vkagM0ExxX1ztSNvw}c&wv0zzgLu&_qIo zq*9)f+>Ors?vS8v6{&Nr8$b27CcaXaD&-F<_p^^V4+0n2k|D=12z;vB1T`6W(i`C^ zFC#bmQriu`S{+xR4Bx=s_w26YHOVEW6L>o0Yp>Zo{aY8ZTI1q|Z*N2BK}C;QM@u9F zxybZ=M)rbBaU>K`(I=cE>FigvD>01{K7t4vxm9&X&Xs2*m?cOK^@+)R)GUftxG3@# zIT7wPY1^Nbz707h{8@kCz%{Ilq8oqp8)0W$k-PWYgK@#bDLzd+_evjrr>*nH5fTxR z5s|))UCex?y)xjs&_9{OsdtmMJ@<7Ag4nDz1F zJ4Db8K6SjJg2KrzJmOUp215=;k_CbHivpedrMt--H;<1MD%Nf&4d<^wO+2qj-Lfrs zwtCs3uUi8f=DLd;Zy}0i{j%gTYO*wWj*j))mn&yycs$AlZVnEcqlmiXwYBQ%-;8ES}z&I1&E6MV0oJ&s2Zw3>Gc8k$`;FPqcHsf6-oo|@l&hbJP zMa22hG@)s^NZ6h+`Cht2!RKtx(DVbgQ{$aeo%~h*CLQ_oQhV>l2Tjb=>Sm@LzI2t} z?w#!kaTwae-v^?G_!YQNDh2WVTAZm#D^$E zVmg>k$GvT-Z4deal;A4haE2!XI@qFx$znoT-X*O7yhbbDd=*N^U|o_y|uVN?GLsL3dBUdQxRx0VsSoN2_Ft`icAp z^KyhQjE!Fyn|{pvsIU$WF9-~J7cH+dVO|yFIq{^*sWQr1w?=(vNd5k7>1N+pR{j?t zdGuPc$2ErRZ9lNz{6b=gy=r>0VnZ#VTH|5b6D5#(}SZEPGoJRbGalfaX~wsuM@8(9ejnOd(?J0B#2Y82{>b=I;|*W~Kg zaikRKjm*qC<0=rZ>^7`3W{I8rom}1uE$yK9Vt3)>!*?`(S(tR6A`s8*L}HKWN=*Bh z$}1|CF*uW*Wd91l4eZkfy7|;U_6zttO&g~FP%i7&z99Z*-6@=_v&m|T(_S}oZ_LPU z`&=s|3rn%maa;6~S{zEZUh*AC(8Wbtl3%*e`h?Px8p;`;Uzl&fd?@7m8n^6-c?yBo$B+)GG7 z75_ftJ$`T-OZ(BC7s(hlV+LBUFa6(VO!%K@HX64oX7%SCh5t9f;d8XHc1$$mk_;~!ntZKb@_O|-`90##!bcaNs2)EFCtv)>1+3DZKUXP5YCh#++0uK+O$*`Fh$+X4P&!+(#?T4oPfbP`LL84pNdK-DpKw z&-2{F$Z@_%2x>y*lGG_)b7u(^UN>`xVN9tGM~ZvYwtj0~$&tf%DdgR1kkFretMnKa zC@m@#e|8qS&pBDjhb+l3E_1-f#$oUjW(XEpS)P#mwb$BI}BSYjz$oJuosEcP^Y_Lg*M0$IVM$m_{`GP2@hKNAd;O6H?6!hI+ zynoZt;d-B;Z@1>&W+Ms&$A~Xc?^Xlg_JWjnH2q$X5&QzSIozw8NB2Lm(8l@HSjtQ* z$;0mw6VjMf$(7t=6BP9I#|6KBOS`H6s{=?_JUEcA_ zfq1Yj;ZmV{A0X)P>K1S04t+%9%5rzVKZd<#PwJjLOvb#b%GKE!2r!@>utr~#2z~d( z+26y%%H48h;C+Ad;s@aX)IT)za&sjhJGy89`omjQ$nn1)AQVCi5(5N?=_&%nq(JF= zH9ubs^1j~ud=@3>;+J#dE0<(IG3OJklR!6s9s8qa<(wB1=CTjPf}81)AMR z*~ex2!3-5&l&4IOkgWS_q>CZ=x_Iv)A!rOIgTWsPQjaski{ocVL}^uconNnR;YS&*ux7OyyLy9;b5qt=qO{K`qA`x1~w z1JGs!o+RFr9UZkQKX#|Y?_Q+e3Dm&w&|i!=f!0XE$LlT50jWC7 zvGB=L$y0l0FGokU%#m%-Q~iLun=nMT#`OVs`&Sk2_&}7qe<2qmYoW_uIGUXJ^;9 z5AS&L82}Q()YR1H>V!Z4$JJF|*>sgfD^P+fPdPlO0c>~Q^@v|j0z1g>Q z6=Hft{f3xHs4>^)eUpz2@vo$DS{C+nw#lbQ*Wiztu}qY?t?@q*3_XeQWbDE}|w zJ`4Ci7T+*y4j`B*2iR64N1!{Shgz9B_rDh8zx#|N+UDsA^s%bHfivtUaH^o~o*4o4 zS+Zjx^Z<-`mo zf&wNaHH7Z3{@QHhBQ6+!m{N{I;v?`h+D$RA(PeBmdmY9V3J<_+F$*P9oDM4;yd5P` zTs#-hysCOyJSDORWuVdp38kfPDeWNuUdaCK!2v z{4~A2{ze7N3>Jb3>OGS$cK7hm<(Z58BiH$5cJdFYtDpMeVis$&8IS*u4~phL~$$W{gDi)mVbS8-- zyu?IwbD2Bk*NCJKtb)5WovK$&lnelj z(p`M`%I6uU6esZ{lN@Dx4zS`udme&*YYS}(Jq436 zkq3OUi<-vV6fbLhw>&@w6-AmFxewU6xY;%6Po9!IEpUmQ>)ldt;GwfiZXdncX3}{_ zA57hH9k_Fq$SFnRq1a8G!!@imdwdb!?)5Pit0orqW_-8{qfdkc3vI4GW4Q0+`vkJ* z6CB;10YVQWZw~aFklPRDgVxqDYiidJ+x-4Hu><+eH(bq+yYz$aB@UOou$5 zb!K+?4cm{lw;Y@;=Fb z?0arH{Prtu0sLIztJftF@rVPGm^y8@&8W7V@DA(qJYW>t;89N!q>}V%tJfDAmwya{ z{L&|tLoRbdqG?}=5W`uUc^ZWJU(1#-^AZSA;+~NEf)X)NH}egM>_v&u62f^|Qo{UM z>e6EcD>H0E>b@Sjd-*lD(lotLhyVEM`2xHVov;)*99fSgD_7)d~rJ)S2F7JtIUV$-|!Z zN~CamGUX)brYuMqa!0RetswhxK$ux{Uw-Vkp90Ne^?Jo}`}3sQxV4T&N5G-I?^$HN zN@X%jkNB&(kWR42HB3gR;U$Kj2X)@}r6Py#-!<+JZoQ9>-z2N`!iC-gI^mM^uh~W4 zTgTQoC8=iz(+{+O1~E`+TnQ?TLY+*>GSYI3HyucW$^_sjWU)gN6)yKU?cq zh0ymmU^fz=>cJ7D_1#SLqGyw|W`WL146zwpSbFF(~ybC=~`MHkG84aUmRj!8h~iijVleR*CnsXFTC4&ahj5 z=>WJ1F<%fX+3n&b0h}aqY!zDM;M@}<&{UI>sc)6uNOtG=TYh~_#V?I#>?Lt#cKuhR#?j6UQRe9i|7}OV$yk;qS)!7JCk{gcW}z||wRrbz>i5~+B5n3tpSzOM@Hzih zDRT{{Z+dKlV`GF#=-E9!x-L#G&aV5O{*sXB@}$R`ZhTI4@xnqvhwlJyLbEv$ur=EU zDZ13{X`AluX}PKJep54ChAccvoThpbIkZ(&xkpy$JlyK>-{kfa!(hjWhW2s?KPFtc z=SxbD;zgFNI+9}DPdDj4P&M$l4n# zBYGsn1ws~4qAr)0!CD)JRp#f{x5jnRb`t9)L<9?MF_Nh7hj1lpzA%JflwxMunF`X@ zl*0)lXPpN7P$-zNsh)Vzdn_+}j%h~RJjqDDJCs&gjLWoOXnqf7NGa8NTKE(HM|i=a z@**~-byqq~DorGN8+GQDe|CF&5EwPX=l%505!RM<`2x43r&3aPJDcyDE@zA@-a;U{ zC3*)2NB74T_ZAh}KfOdlX<5B4`_^M(utmb;7s;t!=hJoe|M;=@k}oqWEju%vn(p=9 z&d#zz<@hFfI74xvT?^^dcHOG*K?A_b39YaP`uKQGI5M~)FdM$reEaq_@-gZub#}~k zA2~Bf`V7R-5OCEHql%#aeVj;MMENXo$ni;w)xuh2$}LcgdE5f-ozuBu`O7 z&c0QAgU+Z2^8L_p({EqIitiB*&{eZZ@;nKre*0ADaZEg<*_T$%O>w$+|AQA3bOVpG z)Nh!as<+e0mtsK$d@Z}P!lYTLwV4vLyUH6|#N;y(=PpXpbCrZr?P->JJX&0|%0`QW z;aW61sQ4C*GS;{*{36&h_^|?>9ww8imhR2ko;6iYakx&o-k?t{ImYr1&yq9P5Fir| zzR(oJ=%KtCww>V_sgHM;ksbe8n@c2@4cK}6FwF-zRwV5eC=cEEL+2ll90S~`xE+ne zv-RJ$+dcz2lcQ`MEZIv4qF3>?MPxB?N@jh@1qRpUA;n(LlWJ38f*RvML$iy2-nG{G7G;!i`+au~v*u!_c$)JeMEf1j(5K@E)?xJ3v8lFw<(oyKMm$q( zFG1|7yLEMSX=xNqkF<$xUa9}!J;2u=KiKHK&MFv{jR? zK{Z<{8Aq~Xony9)8+N<}z_?%nE>0ayUt#B#OlJQ00bz96&IfkX;QzRQBrWR^n{}VQ z?re&#C6@;FZQ&S%(k*=Qo)FWFp7T#`$E&-88>r%^klD13NL33;@?PJQ?v2| zugIwU;G+ho%}$~<;G~2m-D68bpZiFJ!zR_^2*YG9Yn-pt&9-n9CGwKWlQo0{(bR5V3r@!Sf1WaV&yO=didjtonMtdYDGd?UubZ$=bJ~?N{N7 zB=s;B)x{_U$`{`onTJ+JHG~6(bewJ>$)Kt{=ypAeBGHiv=E z_+f*}{KH5JkEUF~;oob4T}V^v=;cIS=vBpT4Iolyz&?h&6ePzRjo1he=8Wv}5wooo z_XE-XmCZy5Ed~vHIBZXsCi@43g%yZ9AI|vnG?4yMk_1Xu=#cjv4jU>JV8sj=pfLN> zPuIsZLFt1zQ0Vw)@k6ni3SXx0Tf~Et_N}sczieN#>!SXULn`aMC?pTN%* z<5I^r9Wa=#J1`hGS>n-DkRVjBh^OXsm-fbgFP>YL45=%FkR=`rF5#gj@Gb`41g_7$ zgX{&g59^%#+3+aQ=z`9djkPVpl9HzYx(y6qegF!hKjPNy-OC_Xx)~r3a7jOjXZprv zOL1M%Kag`(Vj81JoBSA{OhQnPM(%HNfP#AIQUGvJ-)#q7v6^B5)#8U9HFEa0V!Vh_ z0X1ySAGqiwcFI485I>I6 zZY(AWFbg*?*Hi10T$1F9g7lbcqdCU4xW_};>>EA58$WsPy_8yXh^~Lx{nMIY)nD)fteVDz}d*5kE(K7BPiUEgv!u+iNuRh z!ggI4ByN(_tF_M62unky#K5}}% zLJi~94md3Xx=_I6e}{qB&&YViT|)P%RcBRnoyt$7`_r_RxE=edvJkFW{802-VR?S7 zm)9{aYBGfE*w5@&y_GS$T7r|pRBvNB^r8>lcYGlczn@USn?xS&EY~)Y;!x{Y$FUmW zJXgey2ux2Ownb^kTRngT2~skI0@E%biGrrnt(0=wpWs>3$^|0PUS`72p6f4N6^6iG z&8b5pBM(fTX8d;dnlTmq$T6p@t$Dn0`eV9&>wJy7AaTBviB3zu23csyR$pP;@{yD5 z8ncwl1nY?hm>_^T95xq9+4Dq4?xVdZlD^a%w_s2Jy%J7sMVX~J?KRI9&C{OFjEM)I znovu$tN9VS4*?UjhRnuRs*Hq*`%G*ioCuE^3_=y^_0dOO5yl(?rir{BUZO`^PeMOF#xNov2*AZ!g3i@9IT!RW(9dB@qvF21|qrcxn7R=tgU`-`D8NhRnVnr)@NTKQq=P z5KVt3T94JqG(y#=%P{~?Or~9*#HjpoLeF?5_6xuUxjgL(gQDVM14BbSePYD+5EP@G* zU{pR?>lj`X8v~?}CKst;`9Nz1g}~WOxVynR6&mSJFE$`a{vn-b9<}5H7;T=4*~3RJ z3TQ_-88DOZpnF+9N}Q{11~mfVgAl%GFgMo_SL)EIANLXi^U@VlG~>uIO+N(=Iu9P6 z5{UIOkxkz1Q;`D)StN5lhFW~>QGk)BwSc6yXQnW1NU-VgmWOj$42e&oSY>>Ln8gYQ zr{rRFJxQRi2a<$f^qD27iL=J&gAR2+gSiOFq#&+!!jjk##1%{-7ixL)I}yk(lXE~S z9631%XsXw53sI((Yc`wlX`HbwFZB~m+JZ<-_RbnyZZ-STCk~lx&i7_UZlnNMOL7?$ z$*g+Bxgq#rDNu`NghAvYM46CaL3)O_aR>;LU!^PBN_ai^3h}@;G31~I$I=x=dAbJ} zH#rZ<8u)IyQ)o8Eh!Tv_{2lXa!16^r(Lrun0R#av-P>f>jl^9mo&r#%LZV0Eo`}U9 z)~%9I3!>%_#?VtE0*~HkQ+t&d%82{im!iGNjGH4pPF?n#I^qxvxaE@&B>174iL6pGrJ(S!)kvwc-|WNEnmMuO*e z`jCm6xLrTNg17H=;)Me;`b!2ntCimztW9;=YbUXcf*gOdz30Q0Z7EX){h zgMlq)lEuS>3KuGqY9gDPFA1A3OAR8aCrgufi?7d6Jt_1A;|eiGTgk4U zcwAIWENI@MP{?%!t0*%*Tcj>nu98?xA}1?kDg0WVOTr$wV8Co42x9$y=~&o7eWZ90 zzyxF!EZ~`Ut+FdBuWb!GZw6=5Oy@Y_*M62r=vZI-zz_96RMQ62y}K8DII;@(x;u=4 zAUh^(hKg+(I0>8;Yc`2lZ-R00+5KvO3ay0c^zQju5s2wqa8gle5eYV0Hy_tfjq{w~ zuvK+O1%VXRAGdgbgHID)xA6ENuQ&g`XA;D@YzXp%asc6V3lUv_#4AfJG0|DukWc&L zGy3Ji#K!g#HV?=MnjBV#@{HP9-ddZg%Ih&>;a!i_D!^pKvi5+M>3Cfl@O3AuCNcNA z3NgcsE5rl@TAQ22BqjAr^l(Dd1A`v_p(L+p4hA7MWXHz?w6ZW)b~dVn@)KB&t%HKg zm3t!F1i(lskI6hL(X+m3B!9zFRe-tT(X6l($*hscc~dYkr`{TpAZpHRCR!lFo{ymu zO_G;hgQFvOc+IQ|g^%u@2?;g4t@oa_vkRspjz)Vneh|io<$v3j&&DPL7}m|rtwEPg ze~9>#OPGxAv?QHLSRaZn6O}bf>NxJBM4)D?>d3GmnQ=13s~V)j?@3TY(@{VtGy{y> zZc*ErzhzH`VVFHLeG!qFT8$=)5p0ee>m{j3BzX5c9H49a-SP1NUt0U!6LLIYUq6Cs`mt)FwVzNNpO_^1AsR#Q-LdUx; zui(Z3C2BG^=p^kTZr1{%0`43P42*u({xCU9TfeK#aaIr&h~a|d*Y>H}TU7$JmVl{! ze8`eA9W)6w6|W{{`!e_&T9D_w{YRw%j^v^GQpW$wC=ik?&%E5^ zC-r6=)@5j`a|5E>*<@i1dtqPnCcGg1)NNIb`hR~Af*J>)*F7QtaN18qvkk8ACpykV zm->`);35&XMz>PqSY)a9ChN5c!DvWQHqe@Urh_xg1k#sy!|*HyjG68}JVxG_#MRI! zHp3a(+mmEI{F2A~(SqgN8Ne8omde{jvO&34e5Euu5@i7G z#{2vX;4j?x6gp(cI|9i5YB%ZOQAeL9#iT?eb~3$8M-=I0x7bpLu(6NfRH~V|h5%hA zE21g7*Fysn~jQaDaITj~4KwG<1%0?LN46>l6h z0j}Z`zoo>eoOr*I7M2?n3TCNw6SOYZ0a1pFqJ3a~j~m{N#ZstEu+=g>BVXnQfy{58 z4ZijD@Tf5;%S-IY;Db}?5EnZ@A81!i|Agg!m#KdDCSc^;t+~CmHs%f$^I;YWP?I!+ zNE!{Qra6;68s-#1MnSEMyQhcmN=xZ=PV#B@vMXxzdq|kUkn;}wgIDy5#4%SBdJhw7 zYePJ1Ey@f=T~V1XrI|@js4hxQtO(X{+8oFg*(8O88xCpu`_fgkyH^eXbJ~}ncUH>Q zvV4S(9IW49FL9I_l!3S#T6Qu2;F%fgbp0T@ky)j4*tQ4R8`Gv2b6XObpGrE0fC@YY zbIldEW_8v;VW~(&B}6D+arR!6CaY~94iPbkUE-EJOk3j2pdifr6q2*lu3e?ehW$p3 zojYpqxnhtvs1x(bZ6nlD@TufeQU1h)F1hlKC@D3<6U-wjC4OT(++Ml5Xe|Picw=s6 z7<2snNx$^6IxvIUyL5MV^Y%-~@qb)EqZh=}lNi-Y)L_VKM9l0f+fvXy4h4Eb)Qh9> zr8|H~3T?@~z330Rc(?=%oe#Nx3khM;pVUsG-@5RC8RkwTr=^t?7P8!g0%Oj7-Zz(* zBd1EY*ZbMeG&JbdmPhZdrrVueTq-Im9K*U6_ZB-l85ptiClLLw;c}6ZpRIt;b0kJg zOig7*-pm}#V*yy4@A~5GF1t}|oCzpeEp*PeW({V_@tY!+6*FOGKnb60V}{oPR4rJk zmtQ#JYZ!6aZGU|OAk^`Iir?%+5x zJ3Bq)sp!+oi&08Kg23?hyORc4^d&SvJ^@I5=-^n<3Mhs58c^1bwY0GeJj5>WjJx*PAzQ1O&!a@GinZBtCA$ z>@x6HSg2;q%22uxB2ZFK6ZG*6Dgl;%`2Z7QoBS$k;u- zZevxB$%A#K0^;r(wYUB*J~~h^FlU)?e*QBO*@o+ozHfDBE1v*y<|GDO{+IAFpca(S zT3qvR=A58xuCzAfX^9^~dwX1k#h8yBosU_;9P4gb6>W7I5MfRee@b;-09d8QCJhdD zvCbmGU(Ix7lJPHhuJ{jDm0vpM)k-r9uXZHuI6Nr5nD z>epz_==?sN9sjnRXFD?XolSxD87k1%jjGdL6}b%R)+QBHmwSxOI1j~WiF6)O6n!Qm z2m))sUgV2(wQ3I`rAZ!XaEBm9%X0He^f*;$?5$#VWRV3c>jH5EfsJfE-ohF*B+r&H zy7S&w4TjnNmYDqHa7Sb=byx8cK+r36BqnMikm$YI^7`G_<`TRCs7N*aK($um*Ho-5 zRXblFW{C_&4$m5(wS54O&I!sVu3r^d4F~P?oGd16u7?3`k+GzkFR35!wKRl(uom6_ zKb*aFRF&)3F1)v(gv6qy7a?6L-QAti(v7gBbJGiuE@^3z5|A$G1qet9NSD%`Qs-Xo z_jlg&jqi*z&iDC;V>pCoJ?nn%=bm$3^SZ7XE}8d_`QHz_^naDZj7$t9$MROB1ATlj zXa8c{=l{99tbR@6MEDjnET2t5a6z@?WX&{y3@1WsA_>MI7~stUs8iz78a#V`73MMt zhE%J=+0k}SF44>tJ;?7$S!PuRd#9p#VmbFS6E0*LHMH4V=y3dNa}!&Q2M&bX!b|pe z16TwA@28}A9m{t;l__@PvzH20p^^8EVW%DrZ9W>KLgnjCJk-*Ip~?$6#T4ma$6DI;WwRRp{lBWXMVl=W=~D!efvD;2mSOhP_vJiLIBsjwyaBVjEOG@^>AGuadJMfY-kq)MFC%upUw8tOo z`khls{(AR=yWgb!3TyOl$=b0mdQ*ZHJSClh<}JT)gcu2I-lD8 z$I-L6EP|P&OyBBWJ*9!9Vet9e#Cz%$G=)ToA9N3s_%dS5^EUZ8V2|(qdVM?8 z_!OBr;9_e=%y&^dqgxV(%%becwXpvRFf#Pw%VhF)icfJA24y*@!S$x=%^%R@$K_iJ zg~D-P>wX}%f^jhrT1YzU!^QCf4bbr(8usD(*`CW$+y>>AXx9LThZ`tAKff&{B{gN; zHyUWf`$u@c$QiPn_1ZR+|t^bEaEEYfM2y_Lgp_BABUms zC92pOC|_^7ZU&D*<3SRt5u40}c_gX>D?_PaIVuP^RBsKO`+FvYl)v;i`(74Rty4!dutm+@aJ(>faojY_4`Yeos(u+=X1D0dIvq{T2or zOIs$0y8nB*FO0UrK!uPwY)#Z5Lsb1AF$Kh8z3{+sy341H;bOJ%#sYJrcJx|oE2k*2 z?$ak17_7M=@Kbe@PHFOy1(dF-GCCKt0&e8h+@jjUT#=Z`V%jK&hT( zloJ-e4sKO@&uf}vi0GpgMyu><7{qL?W*XawBdp{A(P?m z`e%e~v1U2O8(n5GUB#gXq(7Bzp&vi)Tt3tX?STZLn2;X&-Iz_N?4-w>lyp{ zfBpK^-{0@R({$$bB)qm_jjhBsGK)1|mxZX1unDy)?9H8KJ0a5S#M$U(5b%J35~TfM zd$t{!eSLY8-4mdbc6I$bV&xxtRTq)Dwbn#6<^B+;#rv%E+}v)u%;I!0vdXU`$}R4= zO&^Sy*T`4OG@iKoF8KX~=aKUlkJ)mQW8bVl9b%c^&QyUtdJCn^Z(+;Kfg@Z2X1DPf zh6?hsb&zG}4I^xPj5r)qaBH3q`(;f5<#%J^C;)7lWo9MYwl7R04r%}8gLu;Tup>NyK%JS;ec_vX)+$}V z@(Z`eIgD~<8{{F&q11fD9G20h15{6kiTiqq)>DNemQ9>)aN-D1-}=$vWhKj*ZTc*} z7gOhPFQ4scJKn=dP(GwQQr9P^V=MvSX994VK_0grC7^v5*5m^E5)J-M{)P2zs6 zCnI1is#5EH4x@S16|Fj_ zruwNVNU+k-Dpjzwn`du378RWOc_@XYRP7^L%1G`^nTijRV^%ZjM~2S2rS}>fQ99)X zM8H^NK=@SQ$L1es^#=L7un!)POzw? zU>pX38EE6Qnan#6EeEAFl&zrZKjdT&cNOhlgN2pZ>r^@cZ<%f7I6sm%GAqZr(r$7a zDZ2HS`pW%xe^40rZOm5oL`XfQX2EBVr9J4M`f*_F{}i` zqY=hd7}5~D1ubt@{PO@JNqfF;0pGBZF}3-rE1OcBFbwLr3;@_;7-KyaN@SQJh?n5|85j~zpj-?q5Bl()11#-bHTJIel5ZG4{|MJEgcLa zhc+r8?7@Fw(3_644FAkvyi&&P@7#lVmpO!#0#19xa@{xzwb5J@IDg1F#+0Pp2rM&Q z-JQx>kNAb8&S*GD>Ida4SK4S)k{k?;m6BK*%$!@)Z{=%WctKJ4;d9Q zRV8^wH`gmHDkODH6D(ewP}cHJE+Nq=+FhWYWvR=EAzpKf(!tO*0}T&P1ReQ%qDt*S z%Tsj~$4X0SL~^1{!jEZSMaH3!r8Eb+pP48ov)}2cJwSa{S`KqSMZNF^!I(qQDBU=i7raB7-gzPeBW)NW^}Jiu(pctISZW|+fT zkBOGhg~^?O-$acYdI{cjd3^dPKCqBKV_^EJRN$%3ikF}Gb)f?MZHWr`YkJFit0~~C z(dd9@40Z`l`=IB^morsH?BIrN9KR>wM)G{*9`5a}I_+ILIpW9w21oi|j2Xs@2QPzg z;m6oXZ#bto!rz}(v$*D0dom_p9P1Z4C3E1~-a1t^UC%<;SWkq9rxA-p+shVB=C=I| zap`0H$UFUJrmWD9gat-0nOFB>9&8w$uo}K&Z7kPbKh|yL0H3qT_X&e0|200oeB%4r zMx(jyVUC~Ks;m63*;|Fk23p<~XKlHC+KZt`czd{?Ro9I%*mN)U8zaW0 zIvaN2n=46c<>1x6!2U`W_ZeANto*L4t5|)B(^6B;M=*FYj&g)j6k*rSgJwJ}Fa}`T zorKzhV;PCx{%#{bx*kX&0AEK^8W;VeajTPuz{DDeOruHOC{>o|G`DwGt#3j>^pz^% zfB|;iz{7GZsh=^XxGh~mK-*X@+H>1#JE5 z{&4s_e7fQWQ&Wyua)dl?ccSG!QC9l&5V5o(50#S}>yvG5W0#We2;6&e6Oej9Y}GNt zGdcm0sc~LcI-@BVv*mZAm_9P%?0OubekhhONCNr%q`A^CU2}jX;PeaE(uHA#?i1N5 z`#Pg~S5ILwbAVUsGbE5~H#CF`0VH8T@3}A3!jk&A7dVfKY;2xK7?H+4Z8#I(h&CwD z)~s?i3lKnT|M;Ox;i4?)x&l>MQ3H`#G7E2?-+6?ZjB-nOD^djggS0J`RmtDU`aOmAK)9=@r&aUwn<6hAyHY~6bO&C%WcYRUvRx75PoqgCmw z=GLl`I2ZURAwl~U6f5A@Hs(%uU60W|xpTJRW2}*kh7lSOw;s2*DS3iJS(~{{4RX8^p?_YZSnsLj*f=Q|;O^Zbz)V@=S8=zI& z6VCQDEaw+>ZkA*D>(+oeDmkDO0U8|y(dtA~PDF99D@8X~{F``}o`whzP4?e%`#X@F z45d@$q*-2Lm{dBY{A_u5-Ol!mdk<8(?b- zAzKZC@uo{d7fDG;g}O?*y2+e^FACL}CO@W>Zf(iM>6R21AC8AYg1^2>DR(Z3Hn8w~ zZK^Q|dhpWd+~f$x+kho0rmhxi<^W8>L+d&NnRm>sx8rNqcb%OYWm7+97v31_O}o=W z2=hA~Y(5(XWtHs?@Z!ekYr()fq}i||fy6L0FL}G(|o)+Vu3-Sz;A4c#vQ7(3zSN?S0^F2QqynnSg?$9b8_zX zuWf*4weE)@kxI#g_ub8}w%d^G>;>o{T1e;JT_=#_Zx0Sml&Ep57@IE5FU(0PlH!Cb zWTYxSm8KLP0d2c#>ldc0dcx?S)^Ss++z-igB4p~w+%^6(QAe;^<%{9|E3M9|*d z*SINz`0g~%7v^K{GFu8j%2}SvB?)vsT3yu;lpCihBSm%AZDl=|hI{Xt&65YUc zLYt8Rla)1>Op=4@y*kI7RZA!nzMSimux!LC*RQAZyKIROJB_SmtkD2FddQ>nMCSiVguh%{OX|42y>i{T+}uQ8 zGJhZ@4354rIf>9O4qtnTgRWk&2xL+e$+dss@BwI1nP2mjt((AGMeDbYHbw#k1x5ic z#)PQ@Q#w+5UBOfGd^=Awf08&c;bp@(kXr3Cns7Yzcilhgm)ZllG(#^xZ7 z7*^EdLs><(HJ^3K7L{*H+B!|ds6f~o(&_U0^?msSC}3W5lL5+qvxzk8#KFZ}$(|nr z221kLa3>x;Aa6jlO>U0y<6Zy-xtJKBzJ%F=h7V?#d7Z%&H)@e9+yWTz5qjrDXltd} zOD6Y(Ae)z_r1LZkU#A0;IP4S@4hpSpUs*h$*5L~^q zlJXC4fh5XsZZ3AM-k|@I&<<^>@L%hiJdj)Gb0Rr`4d`EOe3rxh?nF7zIP=-ELLYei z+Y!wm{i%9_P3^$OAC?6w?yQ$~S?;rdG_b>>jUJ;OP^>g~)|R&47gBRl%(8~LPzVIA zzj&V}ZWf!0^7mRBjdXkRvRoJLc@vSKVq^gIMh{fgTxUv?<_-MdS^|PjTAaAz@=lGS zUvJD#y9OKEwTj#Sg!6CHj(0|tMzV=FwZb#dua~nSv9ff+HE&t$0fM(j!~Sy!x!CgO z&Z*_SCw3cfif*%vJ@eQ_x&hFxphynWBSVc-lu>=gnX;Y|P2|22jcTI$EyD86x6$c( zA10|+AdSN4&3F7nLVU=2dkdPx*)~bKtOlm{t&rd}EGK)#?2N7=O^%KWbzptK$-Vk! zX-e`rzkq;D-6jYuntoM*WW?e`Vvg3@RdX=ya(^ zC=_qN)*t4@PHyq$Ci6;Zrjk6QO8E$bf&TdI)3R_)gIPC$YO-e-k47Zike)BQU z2Eo#98j(*$v5K0*&=4$d@d9Dell2v;XV2HK4^G!7Ys#yutp{qlxVW+D zxUd$!9hI8VrrW}PL8fKm@~jt|KJ0tH9MJ3U>Ii=|>6}YRRS((>lMH|Gv`wkUnv#N*)2FH6=4B znf5{ic_i%%WqGgZ`S7YM79NPa9?{KBp-Vr%I7v&4TPi=?zDTTkJ{z@1&Hsc`;cE1p z`FI$#QtyCWTiNtZwyGz<|E#>5W*y%bf6!$`JSx&GH7HY6Rffh~<^abF-%o++lV?S} zz>mmil*ZdEA0g&R!Fb6>2;9%uynq)@6wdl=Eh9j^FWUo_gRZ4V`SW5pz-SM92!iEXP1A< znA}mn=g|@}-37#};Xu%Av2@|o^7-@U#@1HtQtf!!VvR1_jGgBG3JaN7oEO4NI~UjT zB*e#Z)&G#!imnf@kzg7$Ir`>AoTEs{Osvzg!s)yynl4{c2vtTb!cCi~4_5RSSHT6dxB( z;6Hi>By6td_dfo{(=ni^Ux)h7afy&1Nbu6-@d6z^YBEuy_LPHzAw~~Te-n6g zC3K8r`P!XTldY#;d^OOi;KUm>fd0K4 z+Our6DRPX1O4Ku`Xy#6&w=R6iDKUL!UHgnjgh+M_z(btG!E!PHDk>NgM*~#|AFyO8 zMI31q+tDPw?LwEJHEqUBW8(f;hZ&odo6DLugx~@jkZ06^H?s);2i`EZ$~-=zw@UWv zs5y^r5y%>WO$o>2jBs;Q&Os)_;|eS#T@26Zvj_s2WNv?c0wefl_WJX^mX?1Ntm}s3 zKv$dW$WJT@_hYmRprZC7ZOqaQXb*Z-f2$R|33GR1^;@RQ^D`?01?&)!`;b)=TY*^? zXREKizl#*8K!3B({3e0m(c(s~10NAJ!=r zN0MXRxB}x&V_@95DoxR!o*EfXFvwHWeANf_O2gfgTahF-#*!wx&0~8x3XwcL%=-)D z{y1%O18`mq#O`%sAvK@gZ~i`mBhSat)?L{S$)hQG|i4W1{6IoX^2MJoKeH`x)ck<8UcqNbuOdqz%bZclYqGMv21nWfjWb6)@~5gK+!V=d40#D()qRw7K8x^8Xh z@QA&I#dCKf(AJ9M{Co^-YsUK3pS++FM6ReZFS)nVZJy}{tj@hBuBfPMt)E5l5Fi^O z$zl9RtRxoX96sN;v7d%h@LcERX(^T#4v&&hiHsAIO!@p#==<}4JEtFj0K*st>BPM5kF4d%-nV8BWD&= z0Dw5@_b@T+VKNQiuL9V^0{Owm425IgQd8I6hXj{Gcw?-^O2$Ae*#+D7O;3 zC5^jOb@5&$M4Tf%#pwhXS$n!H=>yEh=L-;2x3epB1X4)?SF8(M{gnAOe;Z&20dO>Q zn&0*774XLA6iPS~UG6%6O!fJYu@dq6QR$OR5b?xV#Bldn^5|f2*1}S!N??wRx zLr04FdsrVx@jM?O=@w%si4D}%tZQ;JDsmzTm&YDcgg&4prBBFQO+=GF_ve03|FPVY z+jcHlpw_wm9?>{7`&O|}R_Wpc7AMtNN(4f=Yl8|4eOU-vWlXRQn z5QV|!Z^{y&X8czykE0**9!25-hVQ{|d$rSmWT(hyd7CQ#{r{-a(K8CSfF0&5hn5(9 z%Y80W&B2OiwDJ`KFzR_msuweKmFW8|-pw^C-q$|j=pUlO|<0VORXDuDaKjF` z@W+h*FcVK>G)`MzXw+=@M8ch^{w!8Vd>^cn)vs{iHC4!D-fY(slD<%;@1b2_IU7R$ ztTT=LhRIgj>FvZYkD+s#NkI9_TG%;yCi3=?Z9?e_BeKXDg3ic!8hYD-Y;i)B53=>x z@)83j@MX=hu^EW1oJB9^C*fpMT8@IyE@$ZkxVip zC^Y#k|L!F!Kni_3t|3I}T-dGGHK$S#WOOx{ky0xfFI6rOqzGC7s(&^_a|8g@RHM!d z?k%<*i&vKYHp&K*{Ek@j^uYtG{P#!n#-$7Ml%WW!ZoSeLy0ncc#VCO!+#dX&ghF7= zfTap}yZ4FG#UGWnevpMxsvIbG4s%XLEtC{X+31}KF%oF?@>R5$tRQ@;g8*RLtv(P@6-}e0i#-nPzI21Lz3UF z>uxTZ2pXxi8RL0N)+Ud)YMLY!d^&aQO#6$rIRbc+Kc$c7kL~8_aW|gkQ2~<1QwU8^ zVTj(=q1ARPaY4>EEP=a^zeBR(OvLc!kMrv^#_LZoj?><-rN5a<)*YZfvborymt z6u>pW8N;>pmq4u06D;A;$KB{vqTy!ALmNJA*7`+~5>n4-k;imAC7iC+zlpuMmL|Af zoxAs4;!(k8`nL*s+saU|Y)DNReSCo~U`1U61O)K+tNiQKncfvQwYHw#Up3rY2Lz~6 z9e9JP8<6;`Oe-2jN5;7&*1`M=;42ge+R7v(#Ex^{-I0yIh`%tT_vPU@mY0gCjI=RC z7Mi8PIxK4I%5L;J|wk-lK; z!^9R+3hM0a?kn<{DJs8ML68E=uwo6EZ)GnSPrE+^JtI#Armo7vS{%#9oJak3WEd7k z)oB|M0(9Ku73dCNk9Htg@BEP{VvwlkAn$JEzu%pm047XBRUf1B?J}H(R$js>V%I(! zu)8r-?uY<>frs^@wg~D={eJ|&`~~-pr7E4WI)jvx$w>Hq!%TwG5p57wfWj>u-Zdt^ z{27{l$h!$iRx1qfT2TQjEEPakycHh;HcIhQfMZ__lz{xYmN!hcKdx%@8Yyrh_RdLv zBnAY&dh)yL<_4ISg5JuleNmJ}eS=4hNs$mB@v;#M=wySv2SS2J7?N|jCIt*5?OgePSkSExJoIJF&CCMGixt@GFqZ1W#g41K54vsMf z2(1q~5PNGS_Si}ak4nwD0#C%5nHpt+jqv> zzBA!015K}ak#0qLu|Q86r6<2zyjmz1s1+CJ?C8zaE-PA56xvV(Fw0(QDMl+^RnJox ztIGa5@Syd9B0nt)yI<0C#Rp7%S>)slNZ_+Jgb;)3Abm^hZYyqRC>l6_J&ioL0{XTj z=)d|l%!d`)HC=Ci`MTmi2E9j9bIvndEi_8RD0r#O!3jbL-6(-QZx&5uM<_|3igSVO zz^ziiPZd_+Xf6LSouNjKMkS>D;)@>naJU`W2f5>^>U^fI>FDMFrxy?z4fVIssO7&X zQV$9PFwPJFeC7aRu9vB^w%jU1NRc*qGPmw{0$C2zzC*7!J7cJ{YJM`llOkbSC~pZp z5#H)_K(ar?pHM(IxFuqY66kY=#+tG4}+d|9d#^H=?xUt7$nFQDfpa~$wTjNM(^z$e6O>`zI^?<+i_PC*i{601#|C< zO=v_Xx}CbOea$}S%7UQ+?;9cZtY+LPUQeHqVLZi?&h7dZouW9fyl>{FhkVbwxq#U8 z$t+zoGFzX_e=13;+^haqAz1Spb1_Qq zE)jwe)~EC1_;Q4fMV||2V{Hb>?#mdf2sOnPndPmK*R8k=1a!nxa>LNak}X%h>Cu?kZo7paHc<1S&pG zsAv4?U4~%Q#3x2>rF~S*fO6dqg6~=RvmoxQAhmSu2Y?O*NF8y(7Tg(`+02A22%>iC ztnBQw>suh>VbSlE9OHUxT`&fWzy3?iHBZuuJ}%!Ow&^BlM!3~$UjI*(zti8Q;I&XX zOx4H=_g~p7>OPxXz%O_qGZpqM#T^E0u6g#Ug_=e8t7Z%WHDaBO>H)eAJI` z`Be|McBaXFuhCNN0OF*}vY}Y?Tu5kw8Vj^E#+)0})KvTX`?k+A)*8u?%C0%D53+5s z-4ID60bs#c9$L*AyjFOjCFmE1O3No9cgR##Iz=7IQ$;uv1R3k@2WmCUUh_I#A)%d9 z-`i}rDkF}9zao=R8+P~i3U=OQWxag)PO8IfAad<2^61$qjjcSOT!KMZ=&r`NH0z%i zoO!q3oV=+s(3y?~Mb3Uc_Z;=0%mal5r`aIX{$p!@0LhJrDoZbu3l<_PCmb+t;B;d?K+LBQE-