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,