diff --git a/web-common/src/components/icons/DotPlot.svelte b/web-common/src/components/icons/DotPlot.svelte
new file mode 100644
index 00000000000..7fcea1b466a
--- /dev/null
+++ b/web-common/src/components/icons/DotPlot.svelte
@@ -0,0 +1,65 @@
+
+
+
diff --git a/web-common/src/features/canvas/components/charts/index.ts b/web-common/src/features/canvas/components/charts/index.ts
index eca25d2d6d8..c20da65b498 100644
--- a/web-common/src/features/canvas/components/charts/index.ts
+++ b/web-common/src/features/canvas/components/charts/index.ts
@@ -24,6 +24,10 @@ import {
HeatmapChartComponent,
type HeatmapCanvasChartSpec,
} from "./variants/HeatmapChart";
+import {
+ DotPlotChartComponent,
+ type DotPlotCanvasChartSpec,
+} from "./variants/DotPlotChart";
export { default as Chart } from "./CanvasChart.svelte";
@@ -32,14 +36,16 @@ export type ChartComponent =
| typeof CircularChartComponent
| typeof FunnelChartComponent
| typeof HeatmapChartComponent
- | typeof ComboChartComponent;
+ | typeof ComboChartComponent
+ | typeof DotPlotChartComponent;
export type CanvasChartSpec =
| CartesianCanvasChartSpec
| CircularCanvasChartSpec
| FunnelCanvasChartSpec
| HeatmapCanvasChartSpec
- | ComboCanvasChartSpec;
+ | ComboCanvasChartSpec
+ | DotPlotCanvasChartSpec;
export function getCanvasChartComponent(
type: ChartType,
@@ -60,6 +66,8 @@ export function getCanvasChartComponent(
return HeatmapChartComponent;
case "combo_chart":
return ComboChartComponent;
+ case "dot_plot":
+ return DotPlotChartComponent;
default:
throw new Error("Unsupported chart type: " + type);
}
diff --git a/web-common/src/features/canvas/components/charts/variants/DotPlotChart.ts b/web-common/src/features/canvas/components/charts/variants/DotPlotChart.ts
new file mode 100644
index 00000000000..401fd207578
--- /dev/null
+++ b/web-common/src/features/canvas/components/charts/variants/DotPlotChart.ts
@@ -0,0 +1,185 @@
+import type { ComponentInputParam } from "@rilldata/web-common/features/canvas/inspector/types";
+import type { CanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers";
+import {
+ DotPlotChartProvider,
+ type DotPlotChartSpec as DotPlotChartSpecBase,
+} from "@rilldata/web-common/features/components/charts/dot-plot/DotPlotChartProvider";
+import {
+ type ChartDataQuery,
+ type ChartFieldsMap,
+} from "@rilldata/web-common/features/components/charts/types";
+import type { TimeAndFilterStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store";
+import {
+ MetricsViewSpecDimensionType,
+ type V1MetricsViewSpec,
+ type V1Resource,
+} from "@rilldata/web-common/runtime-client";
+import { type Readable } from "svelte/store";
+import type {
+ CanvasEntity,
+ ComponentPath,
+} from "../../../stores/canvas-entity";
+import { BaseChart, type BaseChartConfig } from "../BaseChart";
+
+export type DotPlotCanvasChartSpec = BaseChartConfig & DotPlotChartSpecBase;
+
+const DEFAULT_NOMINAL_LIMIT = 20;
+const DEFAULT_SPLIT_LIMIT = 10;
+const DEFAULT_SORT = "-x";
+
+export class DotPlotChartComponent extends BaseChart {
+ private provider: DotPlotChartProvider;
+
+ static chartInputParams: Record = {
+ y: {
+ type: "positional",
+ label: "Y-axis (Dimension)",
+ meta: {
+ chartFieldInput: {
+ type: "dimension",
+ axisTitleSelector: true,
+ sortSelector: {
+ enable: true,
+ defaultSort: DEFAULT_SORT,
+ options: ["y", "-y", "x", "-x", "custom"],
+ },
+ limitSelector: { defaultLimit: DEFAULT_NOMINAL_LIMIT },
+ nullSelector: true,
+ labelAngleSelector: true,
+ },
+ },
+ },
+ x: {
+ type: "positional",
+ label: "X-axis (Measure)",
+ meta: {
+ chartFieldInput: {
+ type: "measure",
+ axisTitleSelector: true,
+ axisRangeSelector: true,
+ },
+ },
+ },
+ jitter: {
+ type: "boolean",
+ label: "Jitter points",
+ meta: {
+ invertBoolean: false,
+ },
+ },
+ detail: {
+ type: "positional",
+ label: "Detail (Dimension for dots)",
+ meta: {
+ chartFieldInput: {
+ type: "dimension",
+ nullSelector: true,
+ },
+ },
+ },
+ color: {
+ type: "mark",
+ label: "Color",
+ showInUI: true,
+ meta: {
+ type: "color",
+ chartFieldInput: {
+ type: "dimension",
+ defaultLegendOrientation: "top",
+ limitSelector: { defaultLimit: DEFAULT_SPLIT_LIMIT },
+ colorMappingSelector: { enable: true },
+ nullSelector: true,
+ },
+ },
+ },
+ };
+
+ constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) {
+ super(resource, parent, path);
+
+ this.provider = new DotPlotChartProvider(this.specStore, {
+ nominalLimit: DEFAULT_NOMINAL_LIMIT,
+ splitLimit: DEFAULT_SPLIT_LIMIT,
+ sort: DEFAULT_SORT,
+ });
+
+ this.provider.combinedWhere.subscribe((where) => {
+ this.componentFilters = where;
+ });
+ }
+
+ getChartSpecificOptions(): Record {
+ return { ...DotPlotChartComponent.chartInputParams };
+ }
+
+ createChartDataQuery(
+ ctx: CanvasStore,
+ timeAndFilterStore: Readable,
+ ): ChartDataQuery {
+ return this.provider.createChartDataQuery(ctx.runtime, timeAndFilterStore);
+ }
+
+ static newComponentSpec(
+ metricsViewName: string,
+ metricsViewSpec: V1MetricsViewSpec | undefined,
+ ): DotPlotCanvasChartSpec {
+ const measures = metricsViewSpec?.measures || [];
+ const dimensions = [...(metricsViewSpec?.dimensions || [])].filter(
+ (d) => d.type === MetricsViewSpecDimensionType.DIMENSION_TYPE_CATEGORICAL,
+ );
+
+ const randomMeasure =
+ measures.length > 0
+ ? measures[Math.floor(Math.random() * measures.length)]?.name
+ : undefined;
+
+ const randomYDimension =
+ dimensions.length > 0
+ ? dimensions[Math.floor(Math.random() * dimensions.length)]?.name
+ : undefined;
+
+ const remainingDimensions = dimensions.filter(
+ (d) => d.name !== randomYDimension,
+ );
+ const randomDetailDimension =
+ remainingDimensions.length > 0
+ ? remainingDimensions[
+ Math.floor(Math.random() * remainingDimensions.length)
+ ]?.name
+ : undefined;
+
+ return {
+ metrics_view: metricsViewName,
+ color: "primary",
+ jitter: false,
+ ...(randomYDimension && {
+ y: {
+ type: "nominal",
+ field: randomYDimension,
+ sort: DEFAULT_SORT,
+ limit: DEFAULT_NOMINAL_LIMIT,
+ },
+ }),
+ ...(randomMeasure && {
+ x: {
+ type: "quantitative",
+ field: randomMeasure,
+ },
+ }),
+ ...(randomDetailDimension && {
+ detail: {
+ type: "nominal",
+ field: randomDetailDimension,
+ },
+ }),
+ };
+ }
+
+ chartTitle(fields: ChartFieldsMap) {
+ return this.provider.chartTitle(fields);
+ }
+
+ getChartDomainValues() {
+ return this.provider.getChartDomainValues();
+ }
+}
diff --git a/web-common/src/features/canvas/components/util.ts b/web-common/src/features/canvas/components/util.ts
index 7456b958a75..8276db6e768 100644
--- a/web-common/src/features/canvas/components/util.ts
+++ b/web-common/src/features/canvas/components/util.ts
@@ -81,6 +81,7 @@ const CHART_TYPES = [
"heatmap",
"funnel_chart",
"combo_chart",
+ "dot_plot",
] as const;
const NON_CHART_TYPES = [
"markdown",
@@ -155,14 +156,22 @@ export function createComponent(
resource: V1Resource,
parent: CanvasEntity,
path: ComponentPath,
-): BaseCanvasComponent {
+): BaseCanvasComponent {
const type = resource.component?.spec?.renderer as CanvasComponentType;
const ComponentClass =
COMPONENT_CLASS_MAP[type as keyof typeof COMPONENT_CLASS_MAP];
if (ComponentClass) {
- return new ComponentClass(resource, parent, path);
+ return new ComponentClass(
+ resource,
+ parent,
+ path,
+ ) as BaseCanvasComponent;
}
- return new CartesianChartComponent(resource, parent, path);
+ return new CartesianChartComponent(
+ resource,
+ parent,
+ path,
+ ) as BaseCanvasComponent;
}
export function isCanvasComponentType(
diff --git a/web-common/src/features/canvas/layout-util.ts b/web-common/src/features/canvas/layout-util.ts
index 6c107efe026..bb788fe0127 100644
--- a/web-common/src/features/canvas/layout-util.ts
+++ b/web-common/src/features/canvas/layout-util.ts
@@ -24,6 +24,7 @@ export const initialHeights: Record = {
heatmap: 320,
funnel_chart: 320,
combo_chart: 320,
+ dot_plot: 320,
markdown: 40,
kpi_grid: 128,
image: 80,
diff --git a/web-common/src/features/components/charts/config.ts b/web-common/src/features/components/charts/config.ts
index 3f40ac6be43..46cc2de43ae 100644
--- a/web-common/src/features/components/charts/config.ts
+++ b/web-common/src/features/components/charts/config.ts
@@ -1,5 +1,6 @@
import BarChart from "@rilldata/web-common/components/icons/BarChart.svelte";
import Donut from "@rilldata/web-common/components/icons/Donut.svelte";
+import DotPlot from "@rilldata/web-common/components/icons/DotPlot.svelte";
import Funnel from "@rilldata/web-common/components/icons/Funnel.svelte";
import Heatmap from "@rilldata/web-common/components/icons/Heatmap.svelte";
import LineChart from "@rilldata/web-common/components/icons/LineChart.svelte";
@@ -28,6 +29,8 @@ import { FunnelChartProvider } from "./funnel/FunnelChartProvider";
import { generateVLFunnelChartSpec } from "./funnel/spec";
import { HeatmapChartProvider } from "./heatmap/HeatmapChartProvider";
import { generateVLHeatmapSpec } from "./heatmap/spec";
+import { DotPlotChartProvider } from "./dot-plot/DotPlotChartProvider";
+import { generateVLDotPlotSpec } from "./dot-plot/spec";
import type {
ChartDataResult,
ChartProvider,
@@ -143,6 +146,12 @@ export const CHART_CONFIG: Record = {
provider: ComboChartProvider,
generateSpec: generateVLComboChartSpec,
},
+ dot_plot: {
+ title: "Dot Plot",
+ icon: DotPlot,
+ provider: DotPlotChartProvider,
+ generateSpec: generateVLDotPlotSpec,
+ },
};
export const CHART_TYPES = Object.keys(CHART_CONFIG) as ChartType[];
diff --git a/web-common/src/features/components/charts/dot-plot/DotPlotChartProvider.ts b/web-common/src/features/components/charts/dot-plot/DotPlotChartProvider.ts
new file mode 100644
index 00000000000..36d9e283fa7
--- /dev/null
+++ b/web-common/src/features/components/charts/dot-plot/DotPlotChartProvider.ts
@@ -0,0 +1,331 @@
+import type {
+ ChartDataQuery,
+ ChartDomainValues,
+ ChartFieldsMap,
+ ChartSortDirection,
+ FieldConfig,
+} from "@rilldata/web-common/features/components/charts/types";
+import { isFieldConfig } from "@rilldata/web-common/features/components/charts/util";
+import { mergeFilters } from "@rilldata/web-common/features/dashboards/pivot/pivot-merge-filters";
+import { createInExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils";
+import type { TimeAndFilterStore } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store";
+import {
+ getQueryServiceMetricsViewAggregationQueryOptions,
+ type V1Expression,
+ type V1MetricsViewAggregationDimension,
+ type V1MetricsViewAggregationMeasure,
+ type V1MetricsViewAggregationSort,
+} from "@rilldata/web-common/runtime-client";
+import type { Runtime } from "@rilldata/web-common/runtime-client/runtime-store";
+import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
+import {
+ derived,
+ get,
+ writable,
+ type Readable,
+ type Writable,
+} from "svelte/store";
+import {
+ getFilterWithNullHandling,
+ vegaSortToAggregationSort,
+} from "../query-util";
+
+export type DotPlotChartSpec = {
+ metrics_view: string;
+ y?: FieldConfig<"nominal">;
+ x?: FieldConfig<"quantitative">;
+ detail?: FieldConfig<"nominal">;
+ color?: FieldConfig<"nominal"> | string;
+ jitter?: boolean;
+};
+
+export type DotPlotChartDefaultOptions = {
+ nominalLimit?: number;
+ splitLimit?: number;
+ sort?: ChartSortDirection;
+};
+
+const DEFAULT_NOMINAL_LIMIT = 20;
+const DEFAULT_SPLIT_LIMIT = 10;
+const DEFAULT_SORT = "-x" as ChartSortDirection;
+
+export class DotPlotChartProvider {
+ private spec: Readable;
+ defaultNominalLimit = DEFAULT_NOMINAL_LIMIT;
+ defaultSplitLimit = DEFAULT_SPLIT_LIMIT;
+ defaultSort = DEFAULT_SORT;
+
+ customSortYItems: string[] = [];
+ customColorValues: string[] = [];
+
+ combinedWhere: Writable = writable(undefined);
+
+ constructor(
+ spec: Readable,
+ defaultOptions?: DotPlotChartDefaultOptions,
+ ) {
+ this.spec = spec;
+ if (defaultOptions) {
+ this.defaultNominalLimit =
+ defaultOptions.nominalLimit || DEFAULT_NOMINAL_LIMIT;
+ this.defaultSplitLimit = defaultOptions.splitLimit || DEFAULT_SPLIT_LIMIT;
+ this.defaultSort = defaultOptions.sort || DEFAULT_SORT;
+ }
+ }
+
+ createChartDataQuery(
+ runtime: Writable,
+ timeAndFilterStore: Readable,
+ ): ChartDataQuery {
+ const config = get(this.spec);
+
+ const measures: V1MetricsViewAggregationMeasure[] = [];
+ const dimensions: V1MetricsViewAggregationDimension[] = [];
+
+ if (config.x?.type === "quantitative" && config.x?.field) {
+ measures.push({ name: config.x.field });
+ }
+
+ const yDimensionName = config.y?.field;
+ if (config.y?.type === "nominal" && yDimensionName) {
+ dimensions.push({ name: yDimensionName });
+ }
+
+ const detailDimensionName = config.detail?.field;
+ if (config.detail?.type === "nominal" && detailDimensionName) {
+ dimensions.push({ name: detailDimensionName });
+ }
+
+ let hasColorDimension = false;
+ let colorDimensionName = "";
+ let colorLimit: number | undefined;
+
+ if (isFieldConfig(config.color)) {
+ colorDimensionName = config.color.field;
+ colorLimit = config.color.limit ?? this.defaultSplitLimit;
+ dimensions.push({ name: colorDimensionName });
+ hasColorDimension = true;
+ }
+
+ let yAxisSort: V1MetricsViewAggregationSort | undefined;
+ const limit = config.y?.limit ?? this.defaultNominalLimit;
+
+ if (config.y?.type === "nominal" && yDimensionName) {
+ yAxisSort = vegaSortToAggregationSort("y", config, this.defaultSort);
+ }
+
+ const topNYQueryOptionsStore = derived(
+ [runtime, timeAndFilterStore],
+ ([$runtime, $timeAndFilterStore]) => {
+ const { timeRange, where } = $timeAndFilterStore;
+ const instanceId = $runtime.instanceId;
+ const enabled =
+ !!timeRange?.start &&
+ !!timeRange?.end &&
+ config.y?.type === "nominal" &&
+ !Array.isArray(config.y?.sort) &&
+ !!yDimensionName;
+
+ const topNWhere = getFilterWithNullHandling(where, config.y);
+
+ return getQueryServiceMetricsViewAggregationQueryOptions(
+ instanceId,
+ config.metrics_view,
+ {
+ measures,
+ dimensions: [{ name: yDimensionName }],
+ sort: yAxisSort ? [yAxisSort] : undefined,
+ where: topNWhere,
+ timeRange,
+ limit: limit?.toString(),
+ },
+ {
+ query: {
+ enabled,
+ },
+ },
+ );
+ },
+ );
+
+ const topNYQuery = createQuery(topNYQueryOptionsStore);
+
+ const topNColorQueryOptionsStore = derived(
+ [runtime, timeAndFilterStore],
+ ([$runtime, $timeAndFilterStore]) => {
+ const { timeRange, where } = $timeAndFilterStore;
+ const enabled =
+ !!timeRange?.start &&
+ !!timeRange?.end &&
+ hasColorDimension &&
+ !!colorDimensionName &&
+ !!colorLimit;
+
+ const topNWhere = getFilterWithNullHandling(
+ where,
+ typeof config.color === "object" ? config.color : undefined,
+ );
+
+ return getQueryServiceMetricsViewAggregationQueryOptions(
+ $runtime.instanceId,
+ config.metrics_view,
+ {
+ measures,
+ dimensions: [{ name: colorDimensionName }],
+ sort: config?.x?.field
+ ? [{ name: config.x.field, desc: true }]
+ : undefined,
+ where: topNWhere,
+ timeRange,
+ limit: colorLimit?.toString(),
+ },
+ {
+ query: {
+ enabled,
+ },
+ },
+ );
+ },
+ );
+
+ const topNColorQuery = createQuery(topNColorQueryOptionsStore);
+
+ const queryOptionsStore = derived(
+ [runtime, timeAndFilterStore, topNYQuery, topNColorQuery],
+ ([$runtime, $timeAndFilterStore, $topNYQuery, $topNColorQuery]) => {
+ const { timeRange, where } = $timeAndFilterStore;
+ const topNYData = $topNYQuery?.data?.data;
+ const topNColorData = $topNColorQuery?.data?.data;
+
+ const enabled =
+ !!timeRange?.start &&
+ !!timeRange?.end &&
+ !!measures?.length &&
+ !!dimensions?.length &&
+ (config.y?.type === "nominal" &&
+ !Array.isArray(config.y?.sort) &&
+ yDimensionName
+ ? topNYData !== undefined
+ : true) &&
+ (hasColorDimension && colorDimensionName && colorLimit
+ ? topNColorData !== undefined
+ : true);
+
+ let combinedWhere: V1Expression | undefined = getFilterWithNullHandling(
+ where,
+ config.y,
+ );
+
+ let includedYValues: string[] = [];
+
+ if (Array.isArray(config.y?.sort)) {
+ includedYValues = config.y.sort;
+ } else if (topNYData?.length && yDimensionName) {
+ includedYValues = topNYData.map((d) => d[yDimensionName] as string);
+ }
+
+ if (yDimensionName) {
+ this.customSortYItems = includedYValues;
+ const filterForTopYValues = createInExpression(
+ yDimensionName,
+ includedYValues,
+ );
+ combinedWhere = mergeFilters(combinedWhere, filterForTopYValues);
+ }
+
+ if (topNColorData?.length && colorDimensionName) {
+ const topColorValues = topNColorData.map(
+ (d) => d[colorDimensionName] as string,
+ );
+ this.customColorValues = topColorValues;
+ const filterForTopColorValues = createInExpression(
+ colorDimensionName,
+ topColorValues,
+ );
+ combinedWhere = mergeFilters(combinedWhere, filterForTopColorValues);
+ }
+
+ this.combinedWhere.set(combinedWhere);
+
+ const hasDetailDimension = !!detailDimensionName;
+ let queryLimit: string;
+ if (hasDetailDimension || hasColorDimension) {
+ queryLimit = "5000";
+ } else if (!limit) {
+ queryLimit = "5000";
+ } else {
+ queryLimit = limit.toString();
+ }
+
+ return getQueryServiceMetricsViewAggregationQueryOptions(
+ $runtime.instanceId,
+ config.metrics_view,
+ {
+ measures,
+ dimensions,
+ sort: yAxisSort ? [yAxisSort] : undefined,
+ where: combinedWhere,
+ timeRange,
+ limit: queryLimit,
+ },
+ {
+ query: {
+ enabled,
+ placeholderData: keepPreviousData,
+ },
+ },
+ );
+ },
+ );
+
+ const query = createQuery(queryOptionsStore);
+ return query;
+ }
+
+ getChartDomainValues(): ChartDomainValues {
+ const config = get(this.spec);
+ const result: Record = {};
+
+ if (config.y?.field) {
+ result[config.y.field] =
+ this.customSortYItems.length > 0
+ ? [...this.customSortYItems]
+ : undefined;
+ }
+
+ if (isFieldConfig(config.color)) {
+ result[config.color.field] =
+ this.customColorValues.length > 0
+ ? [...this.customColorValues]
+ : undefined;
+ }
+
+ return result;
+ }
+
+ chartTitle(fields: ChartFieldsMap): string {
+ const config = get(this.spec);
+ const { x, y, color, detail } = config;
+ const xLabel = x?.field ? fields[x.field]?.displayName || x.field : "";
+ const yLabel = y?.field ? fields[y.field]?.displayName || y.field : "";
+
+ const colorLabel =
+ typeof color === "object" && color?.field
+ ? fields[color.field]?.displayName || color.field
+ : "";
+
+ const detailLabel = detail?.field
+ ? fields[detail.field]?.displayName || detail.field
+ : "";
+
+ if (colorLabel && detailLabel) {
+ return `${xLabel} by ${yLabel} and ${detailLabel} (colored by ${colorLabel})`;
+ } else if (colorLabel) {
+ return `${xLabel} by ${yLabel} (colored by ${colorLabel})`;
+ } else if (detailLabel) {
+ return `${xLabel} by ${yLabel} and ${detailLabel}`;
+ } else {
+ return `${xLabel} by ${yLabel}`;
+ }
+ }
+}
diff --git a/web-common/src/features/components/charts/dot-plot/spec.ts b/web-common/src/features/components/charts/dot-plot/spec.ts
new file mode 100644
index 00000000000..8db2d45060b
--- /dev/null
+++ b/web-common/src/features/components/charts/dot-plot/spec.ts
@@ -0,0 +1,231 @@
+import {
+ sanitizeFieldName,
+ sanitizeValueForVega,
+} from "@rilldata/web-common/components/vega/util";
+import type { ChartDataResult } from "@rilldata/web-common/features/components/charts";
+import {
+ createColorEncoding,
+ createConfigWithLegend,
+ createDefaultTooltipEncoding,
+ createMultiLayerBaseSpec,
+ createPositionEncoding,
+} from "@rilldata/web-common/features/components/charts/builder";
+import type { VisualizationSpec } from "svelte-vega";
+import type { Field } from "vega-lite/build/src/channeldef";
+import type { LayerSpec } from "vega-lite/build/src/spec/layer";
+import type { UnitSpec } from "vega-lite/build/src/spec/unit";
+import type { DotPlotChartSpec } from "./DotPlotChartProvider";
+
+export function generateVLDotPlotSpec(
+ config: DotPlotChartSpec,
+ data: ChartDataResult,
+): VisualizationSpec {
+ if (!config.y?.field || !config.x?.field) {
+ throw new Error(
+ "Dot plot requires both y (dimension) and x (measure) fields",
+ );
+ }
+
+ const spec = createMultiLayerBaseSpec();
+ const vegaConfig = createConfigWithLegend(config, config.color);
+
+ const yField = sanitizeValueForVega(config.y.field);
+ const xField = sanitizeValueForVega(config.x.field);
+ const detailField = sanitizeValueForVega(config.detail?.field);
+ const colorField =
+ typeof config.color === "object" ? config.color.field : undefined;
+
+ spec.encoding = {
+ y: createPositionEncoding(config.y, data),
+ x: createPositionEncoding(config.x, data),
+ };
+
+ const layers: Array | UnitSpec> = [];
+
+ const rangeLayer: UnitSpec = {
+ mark: {
+ type: "rect",
+ cornerRadius: 4,
+ opacity: 0.2,
+ stroke: data.theme.primary.hex(),
+ strokeWidth: 1,
+ fill: data.theme.primary.hex(),
+ height: { band: 0.75 },
+ },
+ transform: [
+ {
+ aggregate: [
+ {
+ op: "min",
+ field: xField,
+ as: "min_x",
+ },
+ {
+ op: "max",
+ field: xField,
+ as: "max_x",
+ },
+ {
+ op: "mean",
+ field: xField,
+ as: "avg_x",
+ },
+ ],
+ groupby: [yField],
+ },
+ ],
+ encoding: {
+ y: {
+ field: yField,
+ type: "nominal",
+ axis: { title: config.y?.showAxisTitle ? yField : null },
+ },
+ x: {
+ field: "min_x",
+ type: "quantitative",
+ axis: { title: config.x?.showAxisTitle ? xField : null },
+ scale: { zero: false },
+ },
+ x2: {
+ field: "max_x",
+ type: "quantitative",
+ },
+ tooltip: [
+ {
+ title: data.fields[yField]?.displayName || yField,
+ field: yField,
+ type: "nominal",
+ },
+ {
+ title: "Min",
+ field: "min_x",
+ type: "quantitative",
+ formatType: sanitizeFieldName(xField),
+ },
+ {
+ title: "Max",
+ field: "max_x",
+ type: "quantitative",
+ formatType: sanitizeFieldName(xField),
+ },
+ {
+ title: "Avg",
+ field: "avg_x",
+ type: "quantitative",
+ formatType: sanitizeFieldName(xField),
+ },
+ ],
+ },
+ };
+
+ const jitterEnabled = config.jitter === true;
+
+ const dotsLayerTransforms = [
+ ...(jitterEnabled
+ ? [
+ {
+ calculate: "(random() - 0.5) * 0.4",
+ as: "jitter_y",
+ },
+ ]
+ : []),
+ ...(!detailField
+ ? [
+ {
+ calculate: `datum.${yField} + '_' + datum.${xField} + '_' + (datum.row_number || random())`,
+ as: "detail_id",
+ },
+ ]
+ : []),
+ ];
+
+ const dotsLayer: UnitSpec = {
+ mark: {
+ type: "circle",
+ filled: true,
+ size: 60,
+ opacity: 0.7,
+ },
+ ...(dotsLayerTransforms.length > 0 && { transform: dotsLayerTransforms }),
+ encoding: {
+ y: {
+ field: yField,
+ type: "nominal",
+ axis: { title: config.y?.showAxisTitle ? yField : null },
+ },
+ ...(jitterEnabled && {
+ yOffset: {
+ field: "jitter_y",
+ type: "quantitative",
+ },
+ }),
+ x: {
+ field: xField,
+ type: "quantitative",
+ axis: { title: config.x?.showAxisTitle ? xField : null },
+ scale: { zero: false },
+ },
+ detail: detailField
+ ? {
+ field: detailField,
+ type: "nominal",
+ }
+ : {
+ field: "detail_id",
+ type: "nominal",
+ },
+ ...(colorField && {
+ color: createColorEncoding(config.color, data),
+ }),
+ tooltip: (() => {
+ const tooltipFields = createDefaultTooltipEncoding(
+ [config.y, config.x, config.detail, config.color].filter(Boolean),
+ data,
+ );
+ if (tooltipFields.length > 0) {
+ tooltipFields[0] = { ...tooltipFields[0], title: undefined };
+ }
+ return tooltipFields;
+ })(),
+ },
+ };
+
+ const avgLayer: UnitSpec = {
+ mark: {
+ type: "tick",
+ stroke: data.theme.primary.hex(),
+ strokeWidth: 2,
+ opacity: 0.8,
+ height: { band: 0.75 },
+ },
+ transform: [
+ {
+ aggregate: [
+ {
+ op: "mean",
+ field: xField,
+ as: "mean_x",
+ },
+ ],
+ groupby: [yField],
+ },
+ ],
+ encoding: {
+ y: {
+ field: yField,
+ type: "nominal",
+ },
+ x: {
+ field: "mean_x",
+ type: "quantitative",
+ },
+ },
+ };
+
+ layers.push(rangeLayer, avgLayer, dotsLayer);
+
+ spec.layer = layers;
+ spec.config = vegaConfig;
+
+ return spec;
+}
diff --git a/web-common/src/features/components/charts/index.ts b/web-common/src/features/components/charts/index.ts
index 3662c2f4b0f..5eb9dd188c1 100644
--- a/web-common/src/features/components/charts/index.ts
+++ b/web-common/src/features/components/charts/index.ts
@@ -33,6 +33,12 @@ export type {
HeatmapChartSpec,
} from "./heatmap/HeatmapChartProvider";
+export { DotPlotChartProvider } from "./dot-plot/DotPlotChartProvider";
+export type {
+ DotPlotChartDefaultOptions,
+ DotPlotChartSpec,
+} from "./dot-plot/DotPlotChartProvider";
+
// Types
export type {
ChartDataResult,
diff --git a/web-common/src/features/components/charts/types.ts b/web-common/src/features/components/charts/types.ts
index 46ea50fb3e5..3809885c9aa 100644
--- a/web-common/src/features/components/charts/types.ts
+++ b/web-common/src/features/components/charts/types.ts
@@ -5,6 +5,8 @@ import type {
CircularChartSpec,
ComboChartProvider,
ComboChartSpec,
+ DotPlotChartProvider,
+ DotPlotChartSpec,
FunnelChartProvider,
FunnelChartSpec,
HeatmapChartProvider,
@@ -32,6 +34,7 @@ export type ChartProvider =
| CartesianChartProvider
| CircularChartProvider
| ComboChartProvider
+ | DotPlotChartProvider
| FunnelChartProvider
| HeatmapChartProvider;
@@ -40,7 +43,8 @@ export type ChartSpecBase =
| CircularChartSpec
| FunnelChartSpec
| HeatmapChartSpec
- | ComboChartSpec;
+ | ComboChartSpec
+ | DotPlotChartSpec;
export type ChartSpec = ChartSpecBase & {
vl_config?: string;
@@ -78,7 +82,8 @@ export type ChartType =
| "pie_chart"
| "heatmap"
| "funnel_chart"
- | "combo_chart";
+ | "combo_chart"
+ | "dot_plot";
export type ChartDataQuery = CreateQueryResult<
V1MetricsViewAggregationResponse,