LCOV - code coverage report
Current view: top level - dashql-app/src/views/visualization - vegalite_crossfilter.ts Coverage Total Hit
Test: DashQL 915828b Lines: 95.9 % 219 210
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as arrow from 'apache-arrow';
       2              : import type { Spec as VegaSpec } from 'vega';
       3              : import type { TopLevelSpec } from 'vega-lite';
       4              : 
       5            1 : const SOURCE_DATASET_BASE = '__dashql_source';
       6            1 : const MASK_DATASET_BASE = '__dashql_crossfilter_ids';
       7            1 : const MASK_ACTIVE_BASE = '__dashql_crossfilter_active';
       8            1 : const MASK_SELECTED_FIELD_BASE = '__dashql_crossfilter_selected';
       9            1 : const STABLE_DOMAIN_SIGNAL_BASE = '__dashql_stable_domain';
      10              : 
      11            1 : export const MASK_ROW_ID_FIELD = '__dashql_row_id';
      12              : 
      13              : export interface VegaLiteCrossFilterBinding {
      14              :     spec: TopLevelSpec;
      15              :     sourceDatasetName: string;
      16              :     maskDatasetName: string | null;
      17              :     maskActiveSignalName: string | null;
      18              :     maskSelectedFieldName: string | null;
      19              : }
      20              : 
      21              : export interface VegaCrossFilterView {
      22              :     data(name: string, values: object[]): VegaCrossFilterView;
      23              :     signal(name: string, value: unknown): VegaCrossFilterView;
      24              :     scale(name: string): { domain(): unknown[] };
      25              :     runAsync(): Promise<unknown>;
      26              : }
      27              : 
      28              : export interface VegaStableScaleDomain {
      29              :     scaleName: string;
      30              :     signalName: string;
      31              : }
      32              : 
      33              : export interface VegaStableScaleBinding {
      34              :     spec: VegaSpec;
      35              :     domains: VegaStableScaleDomain[];
      36              : }
      37              : 
      38              : export interface VegaCrossFilterMask {
      39              :     active: boolean;
      40              :     rows: VegaCrossFilterRow[];
      41              : }
      42              : 
      43              : export interface VegaCrossFilterRow {
      44              :     [MASK_ROW_ID_FIELD]: number;
      45              :     [key: string]: number | boolean;
      46              : }
      47              : 
      48            1 : function allocateName(base: string, usedNames: Set<string>): string {
      49            1 :     let name = base;
      50            1 :     let suffix = 2;
      51            1 :     while (usedNames.has(name)) {
      52            1 :         name = `${base}_${suffix++}`;
      53              :     }
      54            1 :     usedNames.add(name);
      55            1 :     return name;
      56            1 : }
      57              : 
      58            1 : function collectRuntimeNames(value: unknown, names: Set<string>): void {
      59            1 :     if (Array.isArray(value)) {
      60            1 :         for (const entry of value) {
      61            1 :             collectRuntimeNames(entry, names);
      62              :         }
      63            1 :         return;
      64            1 :     }
      65            1 :     if (value == null || typeof value !== 'object') {
      66            1 :         return;
      67              :     }
      68            1 :     const object = value as Record<string, unknown>;
      69            1 :     if (typeof object.name === 'string') {
      70            1 :         names.add(object.name);
      71              :     }
      72            1 :     for (const entry of Object.values(object)) {
      73            1 :         collectRuntimeNames(entry, names);
      74              :     }
      75            1 : }
      76              : 
      77            1 : function hasDataReference(value: unknown): boolean {
      78            1 :     if (Array.isArray(value)) {
      79            1 :         return value.some(hasDataReference);
      80              :     }
      81            1 :     if (value == null || typeof value !== 'object') {
      82            1 :         return false;
      83              :     }
      84            1 :     const object = value as Record<string, unknown>;
      85            1 :     if (typeof object.data === 'string') {
      86            1 :         return true;
      87              :     }
      88            0 :     return Object.values(object).some(hasDataReference);
      89            1 : }
      90              : 
      91              : /// Override compiled, data-driven Vega scale domains through initially-null signals.
      92              : /// The updater fills these signals after evaluating the complete source once, so later
      93              : /// cross-filter pulses can recompute marks without changing axes or categorical mappings.
      94            1 : export function injectVegaStableScaleDomains(spec: VegaSpec): VegaStableScaleBinding {
      95            1 :     const input = spec as VegaSpec & {
      96              :         scales?: Array<Record<string, unknown>>;
      97              :         signals?: Array<Record<string, unknown>>;
      98              :     };
      99            1 :     const usedNames = new Set<string>();
     100            1 :     collectRuntimeNames(input, usedNames);
     101            1 :     const domains: VegaStableScaleDomain[] = [];
     102            1 :     const signals = [...(input.signals ?? [])];
     103            1 :     const scales = (input.scales ?? []).map(scale => {
     104            1 :         const scaleName = scale.name;
     105            1 :         if (typeof scaleName !== 'string' || scale.domainRaw != null || !hasDataReference(scale.domain)) {
     106            1 :             return scale;
     107              :         }
     108            1 :         const signalName = allocateName(STABLE_DOMAIN_SIGNAL_BASE, usedNames);
     109            1 :         domains.push({ scaleName, signalName });
     110            1 :         signals.push({ name: signalName, value: null });
     111            1 :         return {
     112            1 :             ...scale,
     113            1 :             domainRaw: { signal: signalName },
     114            1 :         };
     115            1 :     });
     116              : 
     117            1 :     if (domains.length === 0) {
     118            0 :         return { spec, domains };
     119              :     }
     120            1 :     return {
     121            1 :         spec: {
     122            1 :             ...input,
     123            1 :             scales,
     124            1 :             signals,
     125            1 :         },
     126            1 :         domains,
     127            1 :     };
     128            1 : }
     129              : 
     130              : /// Add the renderer-owned row-id relation and membership lookup/filter to a runtime copy
     131              : /// of a Vega-Lite spec. Both are prepended so authored transforms only see selected rows.
     132            1 : export function injectVegaLiteCrossFilter(
     133            1 :     spec: TopLevelSpec,
     134            1 :     rowNumberFieldName: string | null,
     135            1 :     sourceFieldNames: Iterable<string> = [],
     136            1 : ): VegaLiteCrossFilterBinding {
     137            1 :     const input = spec as TopLevelSpec & {
     138              :         datasets?: Record<string, unknown>;
     139              :         params?: Array<{ name?: string }>;
     140              :         transform?: unknown[];
     141              :     };
     142            1 :     const usedNames = new Set<string>(Object.keys(input.datasets ?? {}));
     143            1 :     collectRuntimeNames(input, usedNames);
     144            1 :     for (const fieldName of sourceFieldNames) {
     145            1 :         usedNames.add(fieldName);
     146              :     }
     147            1 :     for (const param of input.params ?? []) {
     148            1 :         if (param.name != null) {
     149            1 :             usedNames.add(param.name);
     150              :         }
     151              :     }
     152              : 
     153            1 :     const sourceDatasetName = allocateName(SOURCE_DATASET_BASE, usedNames);
     154            1 :     const maskDatasetName = rowNumberFieldName != null ? allocateName(MASK_DATASET_BASE, usedNames) : null;
     155            1 :     const maskActiveSignalName = rowNumberFieldName != null ? allocateName(MASK_ACTIVE_BASE, usedNames) : null;
     156            1 :     const maskSelectedFieldName = rowNumberFieldName != null ? allocateName(MASK_SELECTED_FIELD_BASE, usedNames) : null;
     157            1 :     const datasets = { ...(input.datasets ?? {}) };
     158            1 :     const params = [...(input.params ?? [])];
     159            1 :     const transforms = [...(input.transform ?? [])];
     160              : 
     161            1 :     if (rowNumberFieldName != null && maskDatasetName != null && maskActiveSignalName != null && maskSelectedFieldName != null) {
     162            1 :         datasets[maskDatasetName] = [];
     163            1 :         params.push({ name: maskActiveSignalName, value: false });
     164            1 :         transforms.unshift(
     165            1 :             {
     166            1 :                 lookup: rowNumberFieldName,
     167            1 :                 from: {
     168            1 :                     data: { name: maskDatasetName },
     169            1 :                     key: MASK_ROW_ID_FIELD,
     170            1 :                     fields: [maskSelectedFieldName],
     171            1 :                 },
     172            1 :                 as: [maskSelectedFieldName],
     173            1 :                 default: false,
     174            1 :             },
     175            1 :             {
     176            1 :                 filter: `!${maskActiveSignalName} || datum[${JSON.stringify(maskSelectedFieldName)}] === true`,
     177            1 :             },
     178            1 :         );
     179            1 :     }
     180              :     const augmented = {
     181            1 :         ...input,
     182            1 :         data: { name: sourceDatasetName },
     183            1 :         datasets,
     184            1 :         params,
     185            1 :         transform: transforms,
     186              :     } as unknown as TopLevelSpec;
     187              : 
     188            1 :     return {
     189            1 :         spec: augmented,
     190            1 :         sourceDatasetName,
     191            1 :         maskDatasetName,
     192            1 :         maskActiveSignalName,
     193            1 :         maskSelectedFieldName,
     194            1 :     };
     195            1 : }
     196              : 
     197              : /// Convert the one-column DashQL filter table to the small relation consumed by the lookup.
     198            1 : export function filterTableToVegaCrossFilterRows(table: arrow.Table, selectedFieldName: string): VegaCrossFilterRow[] {
     199            1 :     if (table.numCols !== 1) {
     200            1 :         return [];
     201              :     }
     202            1 :     const column = table.getChildAt(0);
     203            1 :     if (column == null) {
     204            0 :         return [];
     205              :     }
     206            1 :     const rows: VegaCrossFilterRow[] = [];
     207            1 :     for (let i = 0; i < column.length; ++i) {
     208            1 :         const value = column.get(i);
     209            1 :         if (value == null) {
     210            0 :             continue;
     211              :         }
     212            1 :         rows.push({
     213            1 :             [MASK_ROW_ID_FIELD]: Number(value),
     214            1 :             [selectedFieldName]: true,
     215            1 :         });
     216            1 :     }
     217            1 :     return rows;
     218            1 : }
     219              : 
     220              : /// Serialize Vega runs and collapse rapid brush results to the newest pending mask.
     221            1 : export class VegaCrossFilterUpdater {
     222            1 :     private view: VegaCrossFilterView | null;
     223            1 :     private sourceRows: object[] | null;
     224            1 :     private readonly sourceDatasetName: string;
     225            1 :     private readonly maskDatasetName: string | null;
     226            1 :     private readonly maskActiveSignalName: string | null;
     227            1 :     private readonly stableScaleDomains: readonly VegaStableScaleDomain[];
     228            1 :     private pending: VegaCrossFilterMask | null = null;
     229            1 :     private running = false;
     230            1 :     private readonly onError: (error: unknown) => void;
     231              : 
     232            1 :     constructor(
     233            1 :         view: VegaCrossFilterView,
     234            1 :         sourceDatasetName: string,
     235            1 :         sourceRows: object[],
     236            1 :         maskDatasetName: string | null,
     237            1 :         maskActiveSignalName: string | null,
     238            1 :         onError: (error: unknown) => void = () => { },
     239            1 :         stableScaleDomains: readonly VegaStableScaleDomain[] = [],
     240            1 :     ) {
     241            1 :         this.view = view;
     242            1 :         this.sourceDatasetName = sourceDatasetName;
     243            1 :         this.sourceRows = sourceRows;
     244            1 :         this.maskDatasetName = maskDatasetName;
     245            1 :         this.maskActiveSignalName = maskActiveSignalName;
     246            1 :         this.onError = onError;
     247            1 :         this.stableScaleDomains = stableScaleDomains;
     248            1 :     }
     249              : 
     250            1 :     update(mask: VegaCrossFilterMask): void {
     251            1 :         if (this.view == null) {
     252            0 :             return;
     253              :         }
     254            1 :         this.pending = mask;
     255            1 :         if (!this.running) {
     256            1 :             void this.flush();
     257              :         }
     258            1 :     }
     259              : 
     260            1 :     dispose(): void {
     261            1 :         this.view = null;
     262            1 :         this.sourceRows = null;
     263            1 :         this.pending = null;
     264            1 :     }
     265              : 
     266            1 :     private async flush(): Promise<void> {
     267            1 :         this.running = true;
     268            1 :         try {
     269            1 :             while (this.view != null && this.pending != null) {
     270            1 :                 const view = this.view;
     271              :                 // Did the run of the scale domains show the source without a mask?
     272            1 :                 let unmaskedRun = false;
     273            1 :                 if (this.sourceRows != null) {
     274            1 :                     view.data(this.sourceDatasetName, this.sourceRows);
     275            1 :                     this.sourceRows = null;
     276            1 :                     if (this.stableScaleDomains.length > 0) {
     277            1 :                         if (this.maskDatasetName != null && this.maskActiveSignalName != null) {
     278            1 :                             view
     279            1 :                                 .data(this.maskDatasetName, [])
     280            1 :                                 .signal(this.maskActiveSignalName, false);
     281              :                         }
     282            1 :                         await view.runAsync();
     283            1 :                         if (this.view !== view) {
     284            0 :                             continue;
     285              :                         }
     286            1 :                         for (const domain of this.stableScaleDomains) {
     287            1 :                             view.signal(domain.signalName, [...view.scale(domain.scaleName).domain()]);
     288              :                         }
     289            1 :                         unmaskedRun = true;
     290            1 :                     }
     291            1 :                 }
     292            1 :                 const mask = this.pending;
     293            1 :                 if (mask == null) {
     294            0 :                     continue;
     295              :                 }
     296            1 :                 this.pending = null;
     297              :                 // A chart without a filter already shows what a second run would show, the domain signals
     298              :                 // take effect with the next run. A second run doubles the work of every chart that mounts.
     299            1 :                 if (unmaskedRun && !mask.active && mask.rows.length === 0) {
     300            1 :                     continue;
     301              :                 }
     302            1 :                 if (this.maskDatasetName != null && this.maskActiveSignalName != null) {
     303            1 :                     view
     304            1 :                         .data(this.maskDatasetName, mask.rows)
     305            1 :                         .signal(this.maskActiveSignalName, mask.active);
     306              :                 }
     307            1 :                 await view.runAsync();
     308            1 :             }
     309            1 :         } catch (error: unknown) {
     310            0 :             this.onError(error);
     311            1 :         } finally {
     312            1 :             this.running = false;
     313            1 :             if (this.view != null && this.pending != null) {
     314            0 :                 void this.flush();
     315              :             }
     316            1 :         }
     317            1 :     }
     318            1 : }
        

Generated by: LCOV version 2.0-1