/* Copyright 2026 Marimo. All rights reserved. */ // @ts-expect-error - vega-parser is not typed import { parse } from "vega-parser"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { invariant } from "@/utils/invariant"; import { makeSelectable } from "../make-selectable"; import { getPanZoomModifier, getSelectionParamNames } from "../params"; import type { VegaLiteSpec } from "../types"; // Pin the platform so the pan/zoom modifier (and therefore the snapshots // below) is deterministic regardless of the host running the tests. We use a // non-Mac platform, which resolves the modifier to `ctrlKey` — this matches // the Linux/Windows environment from issue #3812. beforeAll(() => { vi.spyOn(window.navigator, "platform", "get").mockReturnValue("Linux x86_64"); }); afterAll(() => { vi.restoreAllMocks(); }); describe("makeSelectable", () => { it("should return correctly if mark is not string", () => { const spec = { mark: { type: "point", }, } as VegaLiteSpec; expect(makeSelectable(spec, {})).toMatchSnapshot(); }); it("should return correctly if mark is a string", () => { const spec = { mark: "point", } as VegaLiteSpec; expect(makeSelectable(spec, {})).toMatchSnapshot(); }); it("should return the same spec if selection is false", () => { const spec = { mark: "point", } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: false, fieldSelection: false, }); expect(newSpec).toEqual(spec); expect(getSelectionParamNames(newSpec)).toEqual([]); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should return the same spec for not-defined and true", () => { const spec = { mark: "point", } as VegaLiteSpec; expect( makeSelectable(spec, { chartSelection: true, fieldSelection: true, }), ).toEqual(makeSelectable(spec, {})); expect(makeSelectable(spec, {})).toMatchSnapshot(); }); it("should return the same spec if mark is not in spec", () => { const spec1 = { mark: "point", } as VegaLiteSpec; const spec2 = { mark: { type: "point", }, } as VegaLiteSpec; expect(makeSelectable(spec1, {})).toEqual(makeSelectable(spec2, {})); expect(makeSelectable(spec1, {})).toMatchSnapshot(); }); it("should return correctly if overlapping encodings", () => { const spec = { config: { view: { continuousHeight: 300, }, }, data: { url: "data/cars.json" }, encoding: { color: { field: "Origin", type: "nominal", }, x: { field: "Horsepower", type: "quantitative", }, y: { field: "Miles_per_Gallon", type: "quantitative", }, }, mark: { type: "point", }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true, fieldSelection: true, }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toEqual([ "legend_selection_Origin", "select_point", "select_interval", "pan_zoom", ]); }); it("should skip field selection if empty or false", () => { const spec = { config: { view: { continuousHeight: 300, }, }, data: { url: "data/cars.json" }, encoding: { color: { field: "Origin", type: "nominal", }, x: { field: "Horsepower", type: "quantitative", }, y: { field: "Miles_per_Gallon", type: "quantitative", }, }, mark: { type: "point", }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true, fieldSelection: false, }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toEqual([ "select_point", "select_interval", "pan_zoom", ]); // These are the same expect( makeSelectable(spec, { chartSelection: true, fieldSelection: false }), ).toEqual( makeSelectable(spec, { chartSelection: true, fieldSelection: [] }), ); }); it("should return correctly with multiple encodings", () => { const spec = { mark: "point", encoding: { color: { field: "colorField", type: "nominal", }, size: { field: "sizeField", type: "quantitative", }, }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toEqual([ "legend_selection_colorField", "legend_selection_sizeField", "select_point", "select_interval", "pan_zoom", ]); }); it("should return correctly if existing legend selection", () => { const spec = { config: { view: { continuousHeight: 300, }, }, data: { url: "https://cdn.jsdelivr.net/npm/vega-datasets@v1.29.0/data/unemployment-across-industries.json", }, encoding: { color: { field: "series", scale: { scheme: "category20b" }, type: "nominal", }, opacity: { condition: { param: "param_1", value: 1 }, value: 0.2, }, x: { axis: { domain: false, format: "%Y", tickSize: 0 }, field: "date", timeUnit: "yearmonth", type: "temporal", }, y: { aggregate: "sum", axis: null, field: "count", stack: "center", type: "quantitative", }, }, mark: { type: "area" }, params: [ { bind: "legend", name: "param_1", select: { fields: ["series"], type: "point" }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toEqual([ "param_1", "select_point", "pan_zoom", ]); }); it("should work for multi-layered charts", () => { const spec = { layer: [ { mark: { type: "errorbar", ticks: true, }, encoding: { x: { field: "yield_center", scale: { zero: false, }, title: "yield", type: "quantitative", }, xError: { field: "yield_error", }, y: { field: "variety", type: "nominal", }, }, }, { mark: { type: "point", color: "black", filled: true, }, encoding: { x: { field: "yield_center", type: "quantitative", }, }, }, ], data: { name: "source" }, width: "container", datasets: { source: [ { yield_error: 7.5522, yield_center: 32.4, }, { yield_error: 6.9775, yield_center: 30.966_67, }, { yield_error: 3.9167, yield_center: 33.966_665, }, { yield_error: 11.9732, yield_center: 30.45, }, ], }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true, }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toEqual([ "pan_zoom", "select_point_1", "select_interval_1", ]); }); it("should work for multi-layered charts with different selections", () => { const spec = { layer: [ { mark: { type: "bar", cornerRadius: 10, height: 10 }, encoding: { x: { aggregate: "min", field: "temp_min", scale: { domain: [-15, 45] }, title: "Temperature (°C)", type: "quantitative", }, x2: { aggregate: "max", field: "temp_max" }, y: { field: "date", timeUnit: "month", title: null, type: "ordinal", }, }, }, { mark: { type: "text", align: "right", dx: -5 }, encoding: { text: { aggregate: "min", field: "temp_min", type: "quantitative", }, x: { aggregate: "min", field: "temp_min", type: "quantitative" }, y: { field: "date", timeUnit: "month", type: "ordinal" }, }, }, { mark: { type: "text", align: "left", dx: 5 }, encoding: { text: { aggregate: "max", field: "temp_max", type: "quantitative", }, x: { aggregate: "max", field: "temp_max", type: "quantitative" }, y: { field: "date", timeUnit: "month", type: "ordinal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true, }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toEqual([ "select_point_0", "select_interval_0", "pan_zoom", "select_point_1", "select_interval_1", "select_point_2", "select_interval_2", ]); }); it("should work for geoshape", () => { const spec = { mark: "geoshape", encoding: { color: { datum: "red", type: "nominal", }, x: { field: "x", type: "quantitative", }, y: { field: "y", type: "quantitative", }, }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toEqual([]); }); it("should work for layered charts, with existing selection", () => { const spec = { data: { name: "data-34c3e7380bd529c27667c64406db8bb8", }, datasets: { "data-34c3e7380bd529c27667c64406db8bb8": [ { Level1: "a", count: 1, stage: "france", }, { Level1: "b", count: 2, stage: "france", }, { Level1: "c", count: 3, stage: "england", }, ], }, layer: [ { encoding: { color: { condition: { field: "stage", param: "param_22", }, value: "lightgray", }, x: { field: "Level1", sort: { order: "descending", }, title: "Subpillar", type: "nominal", }, y: { field: "count", title: "Number of Companies", type: "quantitative", }, }, mark: { type: "bar", }, name: "view_21", }, { encoding: { color: { datum: "england", }, y: { datum: 2, }, }, mark: { strokeDash: [2, 2], type: "rule", }, }, ], params: [ { name: "param_22", select: { encodings: ["x"], type: "point", }, views: ["view_21"], }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toMatchInlineSnapshot(` [ "param_22", ] `); }); it("should work for layered charts, with existing legend selection", () => { const spec = { data: { name: "data-34c3e7380bd529c27667c64406db8bb8", }, datasets: { "data-34c3e7380bd529c27667c64406db8bb8": [ { Level1: "a", count: 1, stage: "france", }, { Level1: "b", count: 2, stage: "france", }, { Level1: "c", count: 3, stage: "england", }, ], }, layer: [ { encoding: { color: { condition: { field: "stage", param: "param_22", }, value: "lightgray", }, x: { field: "Level1", sort: { order: "descending", }, title: "Subpillar", type: "nominal", }, y: { field: "count", title: "Number of Companies", type: "quantitative", }, }, mark: { type: "bar", }, name: "view_21", }, { encoding: { color: { datum: "england", }, y: { datum: 2, }, }, mark: { strokeDash: [2, 2], type: "rule", }, }, ], params: [ { name: "param_22", bind: "legend", select: { fields: ["x"], type: "point", }, views: ["view_21"], }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); expect(getSelectionParamNames(newSpec)).toMatchInlineSnapshot(` [ "param_22", ] `); }); it.each(["errorbar", "errorband", "boxplot"])( "should return the same spec if mark is %s", (mark) => { const spec = { mark, } as unknown as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); expect(newSpec).toEqual(spec); expect(getSelectionParamNames(newSpec)).toEqual([]); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }, ); it("should add legend selection to composite charts (issue #6676)", () => { // Test case from https://github.com/marimo-team/marimo/issues/6676 const spec = { layer: [ { mark: "rule", encoding: { x: { field: "x_value", type: "quantitative" }, y: { field: "upper", type: "quantitative" }, y2: { field: "lower" }, color: { field: "category", type: "nominal", }, }, }, { mark: { type: "point", filled: true, size: 60 }, encoding: { x: { field: "x_value", type: "quantitative" }, y: { field: "value", type: "quantitative" }, color: { field: "category", type: "nominal", }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true, fieldSelection: true, }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); const paramNames = getSelectionParamNames(newSpec); // Should have legend selection for category field expect(paramNames).toContain("legend_selection_category"); // Should NOT have duplicate legend params expect( paramNames.filter((name) => name === "legend_selection_category"), ).toHaveLength(1); }); it("should not duplicate legend params when multiple layers have same color field", () => { const spec = { layer: [ { mark: "line", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, { mark: { type: "point", size: 100 }, encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const paramNames = getSelectionParamNames(newSpec); // Should have exactly one legend_selection_category param const legendParams = paramNames.filter((name) => name.startsWith("legend_selection_category"), ); expect(legendParams).toHaveLength(1); expect(legendParams[0]).toBe("legend_selection_category"); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should add bin_coloring param for binned charts", () => { const spec = { mark: "bar", encoding: { x: { field: "x", bin: true, type: "quantitative" }, y: { aggregate: "count", type: "quantitative" }, }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); const paramNames = getSelectionParamNames(newSpec); // Should have point selection and bin_coloring param expect(paramNames).toContain("select_point"); expect(paramNames).toContain("bin_coloring"); // Should NOT have interval selection for binned charts expect(paramNames).not.toContain("select_interval"); }); it("should add bin_coloring param for 2D binned histogram", () => { const spec = { mark: "rect", encoding: { x: { field: "x", bin: true, type: "quantitative" }, y: { field: "y", bin: true, type: "quantitative" }, color: { aggregate: "count", type: "quantitative" }, }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); const paramNames = getSelectionParamNames(newSpec); // Should have point selection and bin_coloring param expect(paramNames).toContain("select_point"); expect(paramNames).toContain("bin_coloring"); }); it("should add bin_coloring param for layered binned charts", () => { const spec = { layer: [ { mark: "bar", encoding: { x: { field: "x", bin: true, type: "quantitative" }, y: { aggregate: "count", type: "quantitative" }, }, }, { mark: "rule", encoding: { x: { aggregate: "mean", field: "x", type: "quantitative" }, color: { value: "red" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true }); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); const paramNames = getSelectionParamNames(newSpec); // First layer should have point selection and bin_coloring expect(paramNames).toContain("select_point_0"); expect(paramNames).toContain("bin_coloring_0"); }); it("should prefer point selection for binned charts even when chartSelection is true", () => { const spec = { mark: "bar", encoding: { x: { field: "x", bin: { maxbins: 20 }, type: "quantitative" }, y: { aggregate: "count", type: "quantitative" }, }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: true }); const paramNames = getSelectionParamNames(newSpec); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); // Should only have point selection for binned charts (not interval) expect(paramNames).toContain("select_point"); expect(paramNames).not.toContain("select_interval"); expect(paramNames).toContain("bin_coloring"); }); it("should not add bin_coloring when chartSelection is false", () => { const spec = { mark: "bar", encoding: { x: { field: "x", bin: true, type: "quantitative" }, y: { aggregate: "count", type: "quantitative" }, }, } as VegaLiteSpec; const newSpec = makeSelectable(spec, { chartSelection: false }); const paramNames = getSelectionParamNames(newSpec); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); // Should not have any chart selection params expect(paramNames).not.toContain("select_point"); expect(paramNames).not.toContain("bin_coloring"); }); it("should collect legend fields from multiple layers with different fields", () => { const spec = { layer: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, size: { field: "size_field", type: "quantitative" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const paramNames = getSelectionParamNames(newSpec); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); // Should have legend params for both fields expect(paramNames).toContain("legend_selection_category"); expect(paramNames).toContain("legend_selection_size_field"); }); it("should add legend selection to vconcat specs", () => { const spec = { vconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, { mark: "bar", encoding: { x: { field: "x", type: "nominal" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const paramNames = getSelectionParamNames(newSpec); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); // Should have legend selection for category field expect(paramNames).toContain("legend_selection_category"); }); it("should add legend selection to hconcat specs", () => { const spec = { hconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "series", type: "nominal" }, }, }, { mark: "line", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "series", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const paramNames = getSelectionParamNames(newSpec); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); // Should have legend selection for series field expect(paramNames).toContain("legend_selection_series"); }); it("should add legend selection to nested vconcat(hconcat(...)) specs", () => { const spec = { vconcat: [ { hconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, { mark: "bar", encoding: { x: { field: "x", type: "nominal" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], }, { mark: "line", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const paramNames = getSelectionParamNames(newSpec); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); // Should have legend selection for category field expect(paramNames).toContain("legend_selection_category"); }); it("should hoist params to top level in hconcat(vconcat(...)) and apply opacity to nested specs", () => { const spec = { hconcat: [ { vconcat: [ { mark: "point", encoding: { x: { field: "Horsepower", type: "quantitative" }, y: { field: "Miles_per_Gallon", type: "quantitative" }, color: { field: "Origin", type: "nominal" }, }, height: 100, }, { mark: "point", encoding: { x: { field: "Horsepower", type: "quantitative" }, y: { field: "Miles_per_Gallon", type: "quantitative" }, color: { field: "Origin", type: "nominal" }, }, height: 100, }, ], }, { mark: "point", encoding: { x: { field: "Horsepower", type: "quantitative" }, y: { field: "Miles_per_Gallon", type: "quantitative" }, color: { field: "Origin", type: "nominal" }, }, height: 100, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // Params should be hoisted to top level expect(newSpec.params).toBeDefined(); expect(newSpec.params!.length).toBeGreaterThan(0); // Should have legend selection for Origin field const paramNames = getSelectionParamNames(newSpec); expect(paramNames).toContain("legend_selection_Origin"); // Nested specs should NOT have params (they should be hoisted) invariant("hconcat" in newSpec, "expected a horizontal concat spec"); for (const subSpec of newSpec.hconcat) { const innerSpecs = "vconcat" in subSpec ? subSpec.vconcat : [subSpec]; for (const innerSpec of innerSpecs) { expect( "params" in innerSpec ? innerSpec.params : undefined, ).toBeUndefined(); invariant("mark" in innerSpec, "expected a marked nested spec"); expect(innerSpec.encoding?.opacity).toBeDefined(); } } expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should only hoist common params with same select.type and select.encodings", () => { // All point charts with x,y encodings - should hoist point/interval params const spec = { hconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // Common params should be hoisted expect(newSpec.params).toBeDefined(); const topLevelParamNames = getSelectionParamNames(newSpec); // Legend param should be hoisted (common across all) expect(topLevelParamNames).toContain("legend_selection_category"); // Point and interval params should be hoisted (same type and encodings) expect(topLevelParamNames).toContain("select_point"); expect(topLevelParamNames).toContain("select_interval"); // Nested specs should not have params invariant("hconcat" in newSpec, "expected a horizontal concat spec"); for (const subSpec of newSpec.hconcat) { expect("params" in subSpec).toBe(false); } expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should not hoist params when specs have different selection types", () => { // One bar chart (point+interval) and one area chart (point only) - different selection strategies const spec = { hconcat: [ { mark: "bar", encoding: { x: { field: "x", type: "nominal" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, { mark: "area", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // Legend param should be hoisted (common across all) const topLevelParamNames = getSelectionParamNames(newSpec); expect(topLevelParamNames).toContain("legend_selection_category"); // But chart selection params should NOT be hoisted (different types) // Bar gets point+interval with x encoding, area gets point with color encoding invariant("hconcat" in newSpec, "expected a horizontal concat spec"); const subspecParams = newSpec.hconcat.flatMap((subSpec) => "params" in subSpec ? subSpec.params : [], ); expect(subspecParams.length).toBeGreaterThan(0); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should not hoist params when specs have different encodings", () => { // One chart with x,y selection and one with color selection const spec = { hconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, }, }, { mark: "arc", encoding: { theta: { field: "value", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // Chart selection params should NOT be hoisted (different encodings) // Point chart uses x,y encodings, arc chart uses color encoding invariant("hconcat" in newSpec, "expected a horizontal concat spec"); const subspecParams = newSpec.hconcat.flatMap((subSpec) => "params" in subSpec ? subSpec.params : [], ); // Both subspecs should have their own params expect(subspecParams.length).toBeGreaterThan(0); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should hoist only common legend params when chart selections differ", () => { const spec = { hconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, { mark: "bar", encoding: { x: { field: "x", type: "nominal" }, y: { field: "y", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // Common legend param should be hoisted const topLevelParamNames = getSelectionParamNames(newSpec); expect(topLevelParamNames).toContain("legend_selection_category"); // But chart-specific params may or may not be hoisted depending on if they match // The point chart has x,y encodings, bar has x encoding - these differ invariant("hconcat" in newSpec, "expected a horizontal concat spec"); const subspecParams = newSpec.hconcat.flatMap((subSpec) => "params" in subSpec ? subSpec.params : [], ); // Each subspec should have its own chart selection params expect(subspecParams.length).toBeGreaterThan(0); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should handle empty concat specs gracefully", () => { const spec = { hconcat: [], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); expect(newSpec).toEqual(spec); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should handle concat with only one subspec", () => { const spec = { hconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // Should hoist params even with single subspec expect(newSpec.params).toBeDefined(); const topLevelParamNames = getSelectionParamNames(newSpec); expect(topLevelParamNames.length).toBeGreaterThan(0); expect(newSpec).toMatchSnapshot(); expect(parse(newSpec)).toBeDefined(); }); it("should preserve vconcat with existing selection params unchanged (issue #7668)", () => { // This test reproduces the issue from #7668: // Altair charts with cross-view interactions (e.g., brush selection) // should not be modified by makeSelectable to preserve the interaction. const spec = { vconcat: [ { mark: "area", encoding: { x: { field: "x", type: "quantitative", scale: { domain: { param: "brush", encoding: "x" } }, }, y: { field: "y", type: "quantitative" }, }, }, { mark: "area", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, }, }, ], params: [ { name: "brush", select: { type: "interval", encodings: ["x"] }, views: ["view_1"], }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // The spec should be returned unchanged to preserve cross-view interactions expect(newSpec).toEqual(spec); expect(getSelectionParamNames(newSpec)).toEqual(["brush"]); }); it("should preserve hconcat with existing selection params unchanged (issue #7668)", () => { const spec = { hconcat: [ { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, }, }, { mark: "point", encoding: { x: { field: "x", type: "quantitative" }, y: { field: "y", type: "quantitative" }, }, }, ], params: [ { name: "my_selection", select: { type: "point", encodings: ["x", "y"] }, }, ], } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); // The spec should be returned unchanged expect(newSpec).toEqual(spec); expect(getSelectionParamNames(newSpec)).toEqual(["my_selection"]); }); }); describe("pan/zoom modifier key (issue #3812)", () => { afterAll(() => { vi.restoreAllMocks(); }); function mockPlatform(platform: string) { // jsdom's navigator has no `userAgentData`, so the detection helper falls // back to `navigator.platform`, which is what we override here. vi.spyOn(window.navigator, "platform", "get").mockReturnValue(platform); } it("uses metaKey (Command) on macOS", () => { mockPlatform("MacIntel"); expect(getPanZoomModifier()).toBe("metaKey"); const spec = { mark: "point" } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const panZoom = newSpec.params?.find((p) => p.name === "pan_zoom"); invariant(panZoom && "select" in panZoom, "expected pan_zoom param"); const select = panZoom.select as { on: string; zoom: string }; expect(select.on).toContain("event.metaKey"); expect(select.on).not.toContain("event.ctrlKey"); expect(select.zoom).toBe("wheel![event.metaKey]"); }); it.each(["Linux x86_64", "Win32"])( "uses ctrlKey on non-macOS platform %s", (platform) => { mockPlatform(platform); expect(getPanZoomModifier()).toBe("ctrlKey"); const spec = { mark: "point" } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const panZoom = newSpec.params?.find((p) => p.name === "pan_zoom"); invariant(panZoom && "select" in panZoom, "expected pan_zoom param"); const select = panZoom.select as { on: string; zoom: string }; // Must not use metaKey, which maps to the Super/Windows key here and is // captured by the OS/desktop environment before the chart sees it. expect(select.on).toContain("event.ctrlKey"); expect(select.on).not.toContain("event.metaKey"); expect(select.zoom).toBe("wheel![event.ctrlKey]"); }, ); it("keeps point/interval selection gated on the same modifier as pan/zoom", () => { mockPlatform("Linux x86_64"); const spec = { mark: "bar" } as VegaLiteSpec; const newSpec = makeSelectable(spec, {}); const mod = getPanZoomModifier(); const eventSelections = (newSpec.params ?? []) .flatMap((param) => "select" in param && typeof param.select === "object" ? [param.select as { on?: string; translate?: string }] : [], ) .filter( (select): select is { on: string; translate?: string } => typeof select.on === "string" && select.on.includes("event."), ); expect(eventSelections).not.toHaveLength(0); for (const select of eventSelections) { // The selection gating and pan/zoom must agree on the modifier, otherwise // one gesture triggers both. expect(select.on).toContain(`event.${mod}`); } }); });