/* Copyright 2026 Marimo. All rights reserved. */ import type { TopLevelSpec } from "vega-lite"; import type { NonNormalizedSpec } from "vega-lite/types_unstable/spec/index.js"; import { isPlatformMac } from "@/core/hotkeys/shortcuts"; import { uniq } from "@/utils/arrays"; import { Marks } from "./marks"; import { type Field, type LayerSpec, Mark, type SelectionParameter, type SingleDefUnitChannel, type UnitSpec, type VegaLiteUnitSpec, } from "./types"; export const ParamNames = { point(layerNum: number | undefined) { return layerNum == null ? "select_point" : `select_point_${layerNum}`; }, interval(layerNum: number | undefined) { return layerNum == null ? "select_interval" : `select_interval_${layerNum}`; }, legendSelection(field: string) { return `legend_selection_${field}`; }, /** * Special param for binned charts that controls opacity/coloring. * This param is used for visual feedback only and does NOT send signals to the backend. * The actual selection param (point/interval) handles backend filtering. */ binColoring(layerNum: number | undefined) { return layerNum == null ? "bin_coloring" : `bin_coloring_${layerNum}`; }, HIGHLIGHT: "highlight", PAN_ZOOM: "pan_zoom", hasPoint(names: string[]) { return names.some((name) => name.startsWith("select_point")); }, hasInterval(names: string[]) { return names.some((name) => name.startsWith("select_interval")); }, hasLegend(names: string[]) { return names.some((name) => name.startsWith("legend_selection")); }, hasPanZoom(names: string[]) { return names.some((name) => name.startsWith("pan_zoom")); }, isBinColoring(name: string) { return name.startsWith("bin_coloring"); }, }; /** * The keyboard modifier used to gate pan/zoom (and to exclude it from * point/interval selection). * * On macOS we use the Command key (`metaKey`), which is conflict-free. On other * platforms `metaKey` maps to the Super/Windows key, which GNOME/Wayland and * Windows reserve for system actions (workspace switching, the activities * overview, etc.), so pan/zoom never reaches the chart. We fall back to the * Control key there, matching the common convention for chart pan/zoom on * Linux and Windows. See https://github.com/marimo-team/marimo/issues/3812. */ export function getPanZoomModifier(): "metaKey" | "ctrlKey" { return isPlatformMac() ? "metaKey" : "ctrlKey"; } export const Params = { highlight() { return { name: ParamNames.HIGHLIGHT, select: { type: "point", on: "mouseover" }, }; }, interval( spec: VegaLiteUnitSpec, layerNum: number | undefined, ): SelectionParameter<"interval"> { const mod = getPanZoomModifier(); return { name: ParamNames.interval(layerNum), select: { type: "interval", encodings: getEncodingAxisForMark(spec), mark: { fill: "#669EFF", fillOpacity: 0.07, stroke: "#669EFF", strokeOpacity: 0.4, }, // So this does not conflict with pan/zoom, which uses the same modifier on: `[mousedown[!event.${mod}], mouseup] > mousemove[!event.${mod}]`, translate: `[mousedown[!event.${mod}], mouseup] > mousemove[!event.${mod}]`, }, }; }, point( spec: VegaLiteUnitSpec, layerNum: number | undefined, ): SelectionParameter<"point"> { return { name: ParamNames.point(layerNum), select: { type: "point", encodings: getEncodingAxisForMark(spec), on: `click[!event.${getPanZoomModifier()}]`, }, }; }, /** * Creates a param for binned charts that controls opacity/coloring only. * This param does NOT send signals to the backend - it's purely for visual feedback. * The regular selection param (point/interval) handles backend filtering. */ binColoring(layerNum: number | undefined): SelectionParameter<"point"> { return { name: ParamNames.binColoring(layerNum), select: { type: "point", on: `click[!event.${getPanZoomModifier()}]`, }, }; }, legend(field: string): SelectionParameter<"point"> { return { name: ParamNames.legendSelection(field), select: { type: "point", fields: [field], }, bind: "legend", }; }, panZoom(): SelectionParameter<"interval"> { const mod = getPanZoomModifier(); return { name: ParamNames.PAN_ZOOM, bind: "scales", select: { type: "interval", on: `[mousedown[event.${mod}], window:mouseup] > window:mousemove!`, translate: `[mousedown[event.${mod}], window:mouseup] > window:mousemove!`, zoom: `wheel![event.${mod}]`, }, }; }, }; export function getEncodingAxisForMark( spec: VegaLiteUnitSpec, ): SingleDefUnitChannel[] | undefined { const mark = Marks.getMarkType(spec.mark); switch (mark) { case Mark.image: case Mark.trail: return undefined; case Mark.area: case Mark.arc: return ["color"]; case Mark.bar: { const direction = getDirectionOfBar(spec); return direction === "horizontal" ? ["y"] : direction === "vertical" ? ["x"] : undefined; } case Mark.circle: case Mark.geoshape: case Mark.line: case Mark.point: case Mark.rect: case Mark.rule: case Mark.square: case Mark.text: case Mark.tick: return ["x", "y"]; } } export function getSelectionParamNames( spec: TopLevelSpec | LayerSpec | UnitSpec | NonNormalizedSpec, ): string[] { if ("params" in spec && spec.params && spec.params.length > 0) { const params = spec.params; const paramNames = params // @ts-expect-error TS doesn't know that `param` is an object .filter((param) => { if (param == null) { return false; } // @ts-expect-error TS doesn't know that `param` is an object return "select" in param && param.select !== undefined; }) .map((param) => param.name); return uniq(paramNames); } if ("layer" in spec) { return uniq(spec.layer.flatMap(getSelectionParamNames)); } if ("vconcat" in spec) { return uniq(spec.vconcat.flatMap(getSelectionParamNames)); } if ("hconcat" in spec) { return uniq(spec.hconcat.flatMap(getSelectionParamNames)); } return []; } /** * Returns the direction of the bar chart. */ export function getDirectionOfBar( spec: VegaLiteUnitSpec, ): "horizontal" | "vertical" | undefined { if (!spec || !("mark" in spec)) { return undefined; } const xEncoding = spec.encoding?.x; const yEncoding = spec.encoding?.y; if (xEncoding && "type" in xEncoding && xEncoding.type === "nominal") { return "vertical"; } if (yEncoding && "type" in yEncoding && yEncoding.type === "nominal") { return "horizontal"; } if (xEncoding && "aggregate" in xEncoding) { return "horizontal"; } if (yEncoding && "aggregate" in yEncoding) { return "vertical"; } return undefined; } /** * Returns the binned field names from the spec. * For binned charts, selections need to use fields instead of encodings. */ export function getBinnedFields(spec: VegaLiteUnitSpec): string[] { if (!spec.encoding) { return []; } const fields: string[] = []; for (const channel of Object.values(spec.encoding)) { // Check for binning if ( channel && typeof channel === "object" && "bin" in channel && channel.bin && "field" in channel && typeof channel.field === "string" ) { fields.push(channel.field); } } return fields; }