diff --git a/web-common/src/components/icons/Donut.svelte b/web-common/src/components/icons/Donut.svelte new file mode 100644 index 00000000000..c6fe11cb8d2 --- /dev/null +++ b/web-common/src/components/icons/Donut.svelte @@ -0,0 +1,26 @@ + + + + + + diff --git a/web-common/src/components/icons/Heatmap.svelte b/web-common/src/components/icons/Heatmap.svelte new file mode 100644 index 00000000000..29c237471f8 --- /dev/null +++ b/web-common/src/components/icons/Heatmap.svelte @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/web-common/src/components/vega/VegaLiteRenderer.svelte b/web-common/src/components/vega/VegaLiteRenderer.svelte index c4d6139f122..762e0697c8a 100644 --- a/web-common/src/components/vega/VegaLiteRenderer.svelte +++ b/web-common/src/components/vega/VegaLiteRenderer.svelte @@ -52,7 +52,7 @@ bind:contentRect class:bg-white={canvasDashboard} class:px-2={canvasDashboard} - class="overflow-hidden size-full flex flex-col items-center justify-center" + class="overflow-y-auto overflow-x-hidden size-full flex flex-col items-center" > {#if error}
Config = ( }, range: { category: COMPARIONS_COLORS, + heatmap: { + scheme: "tealblues", // TODO: Generate this from theme + }, }, numberFormat: "s", tooltipFormat: { diff --git a/web-common/src/features/canvas/AddComponentDropdown.svelte b/web-common/src/features/canvas/AddComponentDropdown.svelte index c45b9fe5ff3..c577bca459b 100644 --- a/web-common/src/features/canvas/AddComponentDropdown.svelte +++ b/web-common/src/features/canvas/AddComponentDropdown.svelte @@ -1,8 +1,8 @@ diff --git a/web-common/src/features/canvas/components/BaseCanvasComponent.ts b/web-common/src/features/canvas/components/BaseCanvasComponent.ts index fa893681cae..c5f86008b00 100644 --- a/web-common/src/features/canvas/components/BaseCanvasComponent.ts +++ b/web-common/src/features/canvas/components/BaseCanvasComponent.ts @@ -12,19 +12,19 @@ import type { V1Resource, V1TimeRange, } from "@rilldata/web-common/runtime-client"; +import type { ComponentType, SvelteComponent } from "svelte"; import { derived, get, writable, type Writable } from "svelte/store"; -import { CanvasComponentState } from "../stores/canvas-component"; -import type { CanvasEntity, ComponentPath } from "../stores/canvas-entity"; -import type { - ComparisonTimeRangeState, - TimeRangeState, -} from "../../dashboards/time-controls/time-control-store"; +import { mergeFilters } from "../../dashboards/pivot/pivot-merge-filters"; import { buildValidMetricsViewFilter, createAndExpression, } from "../../dashboards/stores/filter-utils"; -import { mergeFilters } from "../../dashboards/pivot/pivot-merge-filters"; -import type { ComponentType, SvelteComponent } from "svelte"; +import type { + ComparisonTimeRangeState, + TimeRangeState, +} from "../../dashboards/time-controls/time-control-store"; +import { CanvasComponentState } from "../stores/canvas-component"; +import type { CanvasEntity, ComponentPath } from "../stores/canvas-entity"; export abstract class BaseCanvasComponent { id: string; diff --git a/web-common/src/features/canvas/components/charts/BaseChart.ts b/web-common/src/features/canvas/components/charts/BaseChart.ts new file mode 100644 index 00000000000..fa722aff02e --- /dev/null +++ b/web-common/src/features/canvas/components/charts/BaseChart.ts @@ -0,0 +1,202 @@ +import { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent"; +import { CHART_CONFIG } from "@rilldata/web-common/features/canvas/components/charts"; +import { + commonOptions, + createComponent, + getFilterOptions, +} from "@rilldata/web-common/features/canvas/components/util"; +import type { + AllKeys, + ComponentInputParam, + InputParams, +} from "@rilldata/web-common/features/canvas/inspector/types"; +import type { CanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; +import type { TimeAndFilterStore } from "@rilldata/web-common/features/canvas/stores/types"; +import type { + V1MetricsViewSpec, + V1Resource, +} from "@rilldata/web-common/runtime-client"; +import { get, writable, type Readable, type Writable } from "svelte/store"; +import type { CanvasEntity, ComponentPath } from "../../stores/canvas-entity"; +import type { + ComponentCommonProperties, + ComponentFilterProperties, +} from "../types"; +import Chart from "./Chart.svelte"; +import type { + ChartDataQuery, + ChartFieldsMap, + ChartType, + CommonChartProperties, + FieldConfig, +} from "./types"; + +// Base interface for all chart configurations +export type BaseChartConfig = ComponentFilterProperties & + ComponentCommonProperties & + CommonChartProperties; + +export abstract class BaseChart< + TConfig extends BaseChartConfig, +> extends BaseCanvasComponent { + minSize = { width: 4, height: 4 }; + defaultSize = { width: 6, height: 4 }; + resetParams = []; + type: ChartType; + chartType: Writable; + component = Chart; + + constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) { + const baseSpec: BaseChartConfig = { + metrics_view: "", + title: "", + description: "", + }; + super(resource, parent, path, baseSpec as TConfig); + + this.type = resource.component?.state?.validSpec?.renderer as ChartType; + this.chartType = writable(this.type); + } + + isValid(spec: TConfig): boolean { + return typeof spec.metrics_view === "string"; + } + + inputParams(): InputParams { + return { + options: { + metrics_view: { type: "metrics", label: "Metrics view" }, + tooltip: { type: "tooltip", label: "Tooltip", showInUI: false }, + vl_config: { type: "config", showInUI: false }, + ...this.getChartSpecificOptions(), + ...commonOptions, + }, + filter: getFilterOptions(false), + }; + } + + abstract getChartSpecificOptions(): Record< + AllKeys, + ComponentInputParam + >; + + abstract createChartDataQuery( + ctx: CanvasStore, + timeAndFilterStore: Readable, + ): ChartDataQuery; + + abstract chartTitle(fields: ChartFieldsMap): string; + + protected getDefaultFieldConfig(): Partial { + return { + showAxisTitle: true, + zeroBasedOrigin: true, + showNull: false, + }; + } + + updateChartType( + key: ChartType, + metricsViewSpec: V1MetricsViewSpec | undefined, + ) { + if (!this.parent.fileArtifact) return; + + const currentSpec = get(this.specStore); + const parentPath = this.pathInYAML.slice(0, -1); + + const parseDocumentStore = this.parent.parsedContent; + const parsedDocument = get(parseDocumentStore); + const { updateEditorContent } = this.parent.fileArtifact; + + const newSpecForKey = CHART_CONFIG[key].component.newComponentSpec( + currentSpec.metrics_view, + metricsViewSpec, + ); + + const commonProps = this.extractCommonProperties( + currentSpec, + this.type, + key, + ); + const mergedSpec = { + ...newSpecForKey, + ...commonProps, + }; + + const newResource = this.parent.createOptimisticResource({ + type: key, + row: this.pathInYAML[1], + column: this.pathInYAML[3], + metricsViewName: currentSpec.metrics_view, + metricsViewSpec, + spec: mergedSpec, + }); + + const newComponent = createComponent( + newResource, + this.parent, + this.pathInYAML, + ); + + this.parent.components.set(newComponent.id, newComponent); + this.parent.selectedComponent.set(newComponent.id); + this.parent._rows.refresh(); + + // Preserve the width from the current chart + const width = parsedDocument.getIn([...parentPath, "width"]); + + parsedDocument.setIn(parentPath, { [key]: mergedSpec, width }); + + updateEditorContent(parsedDocument.toString(), false, true); + + this.chartType.set(key); + } + + private extractCommonProperties( + spec: TConfig, + sourceType: ChartType, + targetType: ChartType, + ): Partial { + const { + metrics_view, + title, + description, + vl_config, + time_filters, + dimension_filters, + } = spec; + + const sourceChartParams = + CHART_CONFIG[sourceType].component.chartInputParams || {}; + const targetChartParams = + CHART_CONFIG[targetType].component.chartInputParams || {}; + + // Check for common keys and type match first + const commonProps = Object.keys(sourceChartParams).filter((key) => { + const isKeyAndTypeMatch = + targetChartParams?.[key]?.type === sourceChartParams[key]?.type; + const isFieldTypeMatch = + targetChartParams?.[key]?.meta?.chartFieldInput?.type === + sourceChartParams[key]?.meta?.chartFieldInput?.type; + return isKeyAndTypeMatch && isFieldTypeMatch; + }); + + const commonPropsObject = commonProps.reduce( + (acc, key) => { + acc[key] = spec[key]; + return acc; + }, + {} as Record, + ); + + return { + metrics_view, + title, + description, + vl_config, + time_filters, + dimension_filters, + ...commonPropsObject, + }; + } +} diff --git a/web-common/src/features/canvas/components/charts/Chart.svelte b/web-common/src/features/canvas/components/charts/Chart.svelte index 8eb34e81204..f7d674e8ad8 100644 --- a/web-common/src/features/canvas/components/charts/Chart.svelte +++ b/web-common/src/features/canvas/components/charts/Chart.svelte @@ -1,24 +1,20 @@
@@ -88,7 +87,7 @@ {:else} @@ -105,10 +104,8 @@ data={{ "metrics-view": data }} {spec} renderer={isChartLineLike(chartType) ? "svg" : "canvas"} - expressionFunctions={{ - [measureName]: { fn: (val) => measureFormatter(val) }, - }} - {config} + {expressionFunctions} + config={getRillTheme(true)} /> {/if} {/if} diff --git a/web-common/src/features/canvas/components/charts/builder.ts b/web-common/src/features/canvas/components/charts/builder.ts index 67a7ef7d78c..8294699bb4a 100644 --- a/web-common/src/features/canvas/components/charts/builder.ts +++ b/web-common/src/features/canvas/components/charts/builder.ts @@ -1,11 +1,16 @@ -import type { ChartDataResult } from "@rilldata/web-common/features/canvas/components/charts/selector"; +import type { ChartSpec } from "@rilldata/web-common/features/canvas/components/charts"; +import type { CartesianChartSpec } from "@rilldata/web-common/features/canvas/components/charts/cartesian-charts/CartesianChart"; import type { - ChartConfig, + FieldConfig, TooltipValue, } from "@rilldata/web-common/features/canvas/components/charts/types"; -import { sanitizeFieldName } from "@rilldata/web-common/features/canvas/components/charts/util"; +import { + mergedVlConfig, + sanitizeFieldName, +} from "@rilldata/web-common/features/canvas/components/charts/util"; import { sanitizeValueForVega } from "@rilldata/web-common/features/templates/charts/utils"; import type { VisualizationSpec } from "svelte-vega"; +import type { Config } from "vega-lite"; import type { ColorDef, Field, @@ -14,6 +19,8 @@ import type { import type { Encoding } from "vega-lite/build/src/encoding"; import type { TopLevelParameter } from "vega-lite/build/src/spec/toplevel"; import type { TopLevelUnitSpec } from "vega-lite/build/src/spec/unit"; +import type { ExprRef, SignalRef } from "vega-typings"; +import type { ChartDataResult } from "./types"; export function createMultiLayerBaseSpec() { const baseSpec: VisualizationSpec = { @@ -27,7 +34,7 @@ export function createMultiLayerBaseSpec() { } export function createSingleLayerBaseSpec( - mark: "line" | "bar" | "point", + mark: "line" | "bar" | "point" | "area" | "arc" | "rect", ): TopLevelUnitSpec { return { $schema: "https://vega.github.io/schema/vega-lite/v5.json", @@ -39,73 +46,53 @@ export function createSingleLayerBaseSpec( }; } -export function createXEncoding( - config: ChartConfig, +export function createPositionEncoding( + field: FieldConfig | undefined, data: ChartDataResult, ): PositionDef { - if (!config.x) return {}; - const metaData = data.fields[config.x.field]; + if (!field) return {}; + const metaData = data.fields[field.field]; return { - field: sanitizeValueForVega(config.x.field), - title: metaData?.displayName || config.x.field, - type: config.x.type, + field: sanitizeValueForVega(field.field), + title: metaData?.displayName || field.field, + type: field.type, ...(metaData && "timeUnit" in metaData && { timeUnit: metaData.timeUnit }), - ...(config.x.sort && - config.x.type !== "temporal" && { sort: config.x.sort }), - axis: { - ...(config.x.type === "quantitative" && { - formatType: sanitizeFieldName(config.x.field), + ...(field.sort && field.type !== "temporal" && { sort: field.sort }), + ...(field.type === "quantitative" && + field.zeroBasedOrigin !== true && { + scale: { + zero: false, + }, }), - ...(metaData && "format" in metaData && { format: metaData.format }), - ...(!config.x.showAxisTitle && { title: null }), - }, - }; -} - -export function createYEncoding( - config: ChartConfig, - data: ChartDataResult, -): PositionDef { - if (!config.y) return {}; - const metaData = data.fields[config.y.field]; - return { - field: sanitizeValueForVega(config.y.field), - title: metaData?.displayName || config.y.field, - type: config.y.type, - ...(config.y.zeroBasedOrigin !== true && { - scale: { - zero: false, - }, - }), axis: { - ...(config.y.type === "quantitative" && { - formatType: sanitizeFieldName(config.y.field), + ...(field.labelAngle !== undefined && { labelAngle: field.labelAngle }), + ...(field.type === "quantitative" && { + formatType: sanitizeFieldName(field.field), }), - ...(!config.y.showAxisTitle && { title: null }), ...(metaData && "format" in metaData && { format: metaData.format }), + ...(!field.showAxisTitle && { title: null }), }, - ...(metaData && "timeUnit" in metaData && { timeUnit: metaData.timeUnit }), }; } export function createColorEncoding( - config: ChartConfig, + colorField: FieldConfig | string | undefined, data: ChartDataResult, ): ColorDef { - if (!config.color) return {}; - if (typeof config.color === "object") { - const metaData = data.fields[config.color.field]; + if (!colorField) return {}; + if (typeof colorField === "object") { + const metaData = data.fields[colorField.field]; return { - field: sanitizeValueForVega(config.color.field), - title: metaData?.displayName || config.color.field, - type: config.color.type, + field: sanitizeValueForVega(colorField.field), + title: metaData?.displayName || colorField.field, + type: colorField.type, ...(metaData && "timeUnit" in metaData && { timeUnit: metaData.timeUnit }), }; } - if (typeof config.color === "string") { - return { value: config.color }; + if (typeof colorField === "string") { + return { value: colorField }; } return {}; } @@ -138,52 +125,49 @@ export function createLegendParam( } export function createDefaultTooltipEncoding( - config: ChartConfig, + fields: Array, data: ChartDataResult, -) { +): TooltipValue[] { const tooltip: TooltipValue[] = []; - if (config.x) { - tooltip.push({ - field: sanitizeValueForVega(config.x.field), - title: data.fields[config.x.field]?.displayName || config.x.field, - type: config.x.type, - ...(config.x.type === "quantitative" && { - formatType: sanitizeFieldName(config.x.field), - }), - ...(config.x.type === "temporal" && { format: "%b %d, %Y %H:%M" }), - }); - } - if (config.y) { - tooltip.push({ - field: sanitizeValueForVega(config.y.field), - title: data.fields[config.y.field]?.displayName || config.y.field, - type: config.y.type, - ...(config.y.type === "quantitative" && { - formatType: sanitizeFieldName(config.y.field), - }), - ...(config.y.type === "temporal" && { format: "%b %d, %Y %H:%M" }), - }); - } - if (typeof config.color === "object" && config.color.field) { - tooltip.push({ - field: sanitizeValueForVega(config.color.field), - title: data.fields[config.color.field]?.displayName || config.color.field, - type: config.color.type, - }); + for (const field of fields) { + if (!field) continue; + + if (typeof field === "object") { + tooltip.push({ + field: sanitizeValueForVega(field.field), + title: data.fields[field.field]?.displayName || field.field, + type: field.type, + ...(field.type === "quantitative" && { + formatType: sanitizeFieldName(field.field), + }), + ...(field.type === "temporal" && { format: "%b %d, %Y %H:%M" }), + }); + } } return tooltip; } +export function createConfig( + config: ChartSpec, + chartVLConfig?: Config | undefined, +): Config | undefined { + const userProvidedConfig = config.vl_config; + return mergedVlConfig(userProvidedConfig, chartVLConfig); +} + export function createEncoding( - config: ChartConfig, + config: CartesianChartSpec, data: ChartDataResult, ): Encoding { return { - x: createXEncoding(config, data), - y: createYEncoding(config, data), - color: createColorEncoding(config, data), - tooltip: createDefaultTooltipEncoding(config, data), + x: createPositionEncoding(config.x, data), + y: createPositionEncoding(config.y, data), + color: createColorEncoding(config.color, data), + tooltip: createDefaultTooltipEncoding( + [config.x, config.y, config.color], + data, + ), }; } diff --git a/web-common/src/features/canvas/components/charts/cartesian-charts/CartesianChart.ts b/web-common/src/features/canvas/components/charts/cartesian-charts/CartesianChart.ts new file mode 100644 index 00000000000..ea8166e8820 --- /dev/null +++ b/web-common/src/features/canvas/components/charts/cartesian-charts/CartesianChart.ts @@ -0,0 +1,268 @@ +import { getFilterWithNullHandling } from "@rilldata/web-common/features/canvas/components/charts/util"; +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 type { TimeAndFilterStore } from "@rilldata/web-common/features/canvas/stores/types"; +import { mergeFilters } from "@rilldata/web-common/features/dashboards/pivot/pivot-merge-filters"; +import { createInExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { + getQueryServiceMetricsViewAggregationQueryOptions, + type V1Expression, + type V1MetricsViewAggregationDimension, + type V1MetricsViewAggregationMeasure, + type V1MetricsViewAggregationSort, + type V1MetricsViewSpec, + type V1Resource, +} from "@rilldata/web-common/runtime-client"; +import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; +import { derived, get, type Readable } from "svelte/store"; +import type { + CanvasEntity, + ComponentPath, +} from "../../../stores/canvas-entity"; +import { BaseChart, type BaseChartConfig } from "../BaseChart"; +import type { + ChartDataQuery, + ChartFieldsMap, + ChartSortDirection, + FieldConfig, +} from "../types"; + +export type CartesianChartSpec = BaseChartConfig & { + x?: FieldConfig; + y?: FieldConfig; + color?: FieldConfig | string; +}; + +export class CartesianChartComponent extends BaseChart { + static chartInputParams: Record = { + x: { + type: "positional", + label: "X-axis", + meta: { + chartFieldInput: { + type: "dimension", + axisTitleSelector: true, + sortSelector: true, + limitSelector: true, + nullSelector: true, + labelAngleSelector: true, + }, + }, + }, + y: { + type: "positional", + label: "Y-axis", + meta: { + chartFieldInput: { + type: "measure", + axisTitleSelector: true, + originSelector: true, + }, + }, + }, + color: { type: "mark", label: "Color", meta: { type: "color" } }, + }; + + constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) { + super(resource, parent, path); + } + + getChartSpecificOptions(): Record { + return CartesianChartComponent.chartInputParams; + } + + createChartDataQuery( + ctx: CanvasStore, + timeAndFilterStore: Readable, + ): ChartDataQuery { + const config = get(this.specStore); + + let measures: V1MetricsViewAggregationMeasure[] = []; + let dimensions: V1MetricsViewAggregationDimension[] = []; + + if (config.y?.type === "quantitative" && config.y?.field) { + measures = [{ name: config.y?.field }]; + } + + let sort: V1MetricsViewAggregationSort | undefined; + let limit: number | undefined; + let hasColorDimension = false; + + const dimensionName = config.x?.field; + + if (config.x?.type === "nominal" && dimensionName) { + limit = config.x.limit ?? 100; + sort = this.vegaSortToAggregationSort(config.x?.sort, config); + dimensions = [{ name: dimensionName }]; + } else if (config.x?.type === "temporal" && dimensionName) { + dimensions = [{ name: dimensionName }]; + } + + if (typeof config.color === "object" && config.color?.field) { + dimensions = [...dimensions, { name: config.color.field }]; + hasColorDimension = true; + } + + // Create topN query options store + const topNQueryOptionsStore = derived( + [ctx.runtime, timeAndFilterStore], + ([runtime, $timeAndFilterStore]) => { + const { timeRange, where } = $timeAndFilterStore; + const enabled = + !!timeRange?.start && + !!timeRange?.end && + hasColorDimension && + config.x?.type === "nominal"; + + const topNWhere = getFilterWithNullHandling(where, config.x); + + return getQueryServiceMetricsViewAggregationQueryOptions( + runtime.instanceId, + config.metrics_view, + { + measures, + dimensions: [{ name: dimensionName }], + sort: sort ? [sort] : undefined, + where: topNWhere, + timeRange, + limit: limit?.toString(), + }, + { + query: { + enabled, + placeholderData: keepPreviousData, + }, + }, + ); + }, + ); + + const topNQuery = createQuery(topNQueryOptionsStore); + + const queryOptionsStore = derived( + [ctx.runtime, timeAndFilterStore, topNQuery], + ([runtime, $timeAndFilterStore, $topNQuery]) => { + const { timeRange, where, timeGrain } = $timeAndFilterStore; + const topNData = $topNQuery?.data?.data; + const enabled = + !!timeRange?.start && + !!timeRange?.end && + (hasColorDimension && config.x?.type === "nominal" + ? !!topNData?.length + : true); + + let combinedWhere: V1Expression | undefined = getFilterWithNullHandling( + where, + config.x, + ); + if (topNData?.length && dimensionName) { + const topValues = topNData.map((d) => d[dimensionName] as string); + const filterForTopValues = createInExpression( + dimensionName, + topValues, + ); + + combinedWhere = mergeFilters(where, filterForTopValues); + } + + // Update dimensions with timeGrain if temporal + if (config.x?.type === "temporal" && timeGrain) { + dimensions = dimensions.map((d) => + d.name === dimensionName ? { ...d, timeGrain } : d, + ); + } + + return getQueryServiceMetricsViewAggregationQueryOptions( + runtime.instanceId, + config.metrics_view, + { + measures, + dimensions, + sort: sort ? [sort] : undefined, + where: combinedWhere, + timeRange, + limit: hasColorDimension || !limit ? "5000" : limit?.toString(), + }, + { + query: { + enabled, + placeholderData: keepPreviousData, + }, + }, + ); + }, + ); + + const query = createQuery(queryOptionsStore); + return query; + } + + static newComponentSpec( + metricsViewName: string, + metricsViewSpec: V1MetricsViewSpec | undefined, + ): CartesianChartSpec { + // Randomly select a measure and dimension if available + const measures = metricsViewSpec?.measures || []; + const timeDimension = metricsViewSpec?.timeDimension; + const dimensions = metricsViewSpec?.dimensions || []; + + const randomMeasure = measures[Math.floor(Math.random() * measures.length)] + ?.name as string; + + let randomDimension = ""; + if (!timeDimension) { + randomDimension = dimensions[ + Math.floor(Math.random() * dimensions.length) + ]?.name as string; + } + + return { + metrics_view: metricsViewName, + color: "hsl(246, 66%, 50%)", + x: { + type: timeDimension ? "temporal" : "nominal", + field: timeDimension || randomDimension, + sort: "-y", + limit: 20, + }, + y: { + type: "quantitative", + field: randomMeasure, + zeroBasedOrigin: true, + }, + }; + } + + private vegaSortToAggregationSort( + sort: ChartSortDirection | undefined, + config: CartesianChartSpec, + ): V1MetricsViewAggregationSort | undefined { + if (!sort) return undefined; + const field = + sort === "x" || sort === "-x" ? config.x?.field : config.y?.field; + if (!field) return undefined; + + return { + name: field, + desc: sort === "-x" || sort === "-y", + }; + } + + chartTitle(fields: ChartFieldsMap) { + const config = get(this.specStore); + const { x, y, color } = 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 preposition = xLabel === "Time" ? "over" : "per"; + + return colorLabel + ? `${yLabel} ${preposition} ${xLabel} split by ${colorLabel}` + : `${yLabel} ${preposition} ${xLabel}`; + } +} diff --git a/web-common/src/features/canvas/components/charts/area/spec.ts b/web-common/src/features/canvas/components/charts/cartesian-charts/area/spec.ts similarity index 81% rename from web-common/src/features/canvas/components/charts/area/spec.ts rename to web-common/src/features/canvas/components/charts/cartesian-charts/area/spec.ts index 7883833fd84..712f2150192 100644 --- a/web-common/src/features/canvas/components/charts/area/spec.ts +++ b/web-common/src/features/canvas/components/charts/cartesian-charts/area/spec.ts @@ -1,31 +1,32 @@ -import type { - ChartConfig, - TooltipValue, -} from "@rilldata/web-common/features/canvas/components/charts/types"; +import type { TooltipValue } from "@rilldata/web-common/features/canvas/components/charts/types"; import { sanitizeFieldName } from "@rilldata/web-common/features/canvas/components/charts/util"; import { sanitizeValueForVega } from "@rilldata/web-common/features/templates/charts/utils"; import type { VisualizationSpec } from "svelte-vega"; import { createColorEncoding, + createConfig, createDefaultTooltipEncoding, createMultiLayerBaseSpec, - createXEncoding, - createYEncoding, -} from "../builder"; -import type { ChartDataResult } from "../selector"; - + createPositionEncoding, +} from "../../builder"; +import type { ChartDataResult } from "../../types"; +import type { CartesianChartSpec } from "../CartesianChart"; export function generateVLAreaChartSpec( - config: ChartConfig, + config: CartesianChartSpec, data: ChartDataResult, ): VisualizationSpec { const spec = createMultiLayerBaseSpec(); + const vegaConfig = createConfig(config); const colorField = typeof config.color === "object" ? config.color.field : undefined; const xField = sanitizeValueForVega(config.x?.field); const yField = sanitizeValueForVega(config.y?.field); - const defaultTooltipChannel = createDefaultTooltipEncoding(config, data); + const defaultTooltipChannel = createDefaultTooltipEncoding( + [config.x, config.y, config.color], + data, + ); let multiValueTooltipChannel: TooltipValue[] | undefined; if (colorField && config.x && yField) { @@ -45,13 +46,13 @@ export function generateVLAreaChartSpec( multiValueTooltipChannel = multiValueTooltipChannel.slice(0, 50); } - spec.encoding = { x: createXEncoding(config, data) }; + spec.encoding = { x: createPositionEncoding(config.x, data) }; spec.layer = [ { encoding: { - y: { ...createYEncoding(config, data), stack: "zero" }, - color: createColorEncoding(config, data), + y: { ...createPositionEncoding(config.y, data), stack: "zero" }, + color: createColorEncoding(config.color, data), }, layer: [ { mark: "area" }, @@ -135,5 +136,8 @@ export function generateVLAreaChartSpec( }, ]; - return spec; + return { + ...spec, + ...(vegaConfig && { config: vegaConfig }), + }; } diff --git a/web-common/src/features/canvas/components/charts/bar-chart/spec.ts b/web-common/src/features/canvas/components/charts/cartesian-charts/bar-chart/spec.ts similarity index 58% rename from web-common/src/features/canvas/components/charts/bar-chart/spec.ts rename to web-common/src/features/canvas/components/charts/cartesian-charts/bar-chart/spec.ts index e28db74aaf2..5145459f599 100644 --- a/web-common/src/features/canvas/components/charts/bar-chart/spec.ts +++ b/web-common/src/features/canvas/components/charts/cartesian-charts/bar-chart/spec.ts @@ -1,14 +1,19 @@ -import type { ChartConfig } from "@rilldata/web-common/features/canvas/components/charts/types"; import type { VisualizationSpec } from "svelte-vega"; -import { createEncoding, createSingleLayerBaseSpec } from "../builder"; -import type { ChartDataResult } from "../selector"; +import { + createConfig, + createEncoding, + createSingleLayerBaseSpec, +} from "../../builder"; +import type { ChartDataResult } from "../../types"; +import type { CartesianChartSpec } from "../CartesianChart"; export function generateVLBarChartSpec( - config: ChartConfig, + config: CartesianChartSpec, data: ChartDataResult, ): VisualizationSpec { const spec = createSingleLayerBaseSpec("bar"); const baseEncoding = createEncoding(config, data); + const vegaConfig = createConfig(config); if (config.color && typeof config.color === "object" && config.x) { baseEncoding.xOffset = { @@ -18,5 +23,8 @@ export function generateVLBarChartSpec( } spec.encoding = baseEncoding; - return spec; + return { + ...spec, + ...(vegaConfig && { config: vegaConfig }), + }; } diff --git a/web-common/src/features/canvas/components/charts/line-chart/spec.ts b/web-common/src/features/canvas/components/charts/cartesian-charts/line-chart/spec.ts similarity index 81% rename from web-common/src/features/canvas/components/charts/line-chart/spec.ts rename to web-common/src/features/canvas/components/charts/cartesian-charts/line-chart/spec.ts index 6794320f243..4b354aa64f9 100644 --- a/web-common/src/features/canvas/components/charts/line-chart/spec.ts +++ b/web-common/src/features/canvas/components/charts/cartesian-charts/line-chart/spec.ts @@ -1,31 +1,33 @@ -import type { - ChartConfig, - TooltipValue, -} from "@rilldata/web-common/features/canvas/components/charts/types"; +import type { TooltipValue } from "@rilldata/web-common/features/canvas/components/charts/types"; import { sanitizeFieldName } from "@rilldata/web-common/features/canvas/components/charts/util"; import { sanitizeValueForVega } from "@rilldata/web-common/features/templates/charts/utils"; import type { VisualizationSpec } from "svelte-vega"; import { createColorEncoding, + createConfig, createDefaultTooltipEncoding, createMultiLayerBaseSpec, - createXEncoding, - createYEncoding, -} from "../builder"; -import type { ChartDataResult } from "../selector"; + createPositionEncoding, +} from "../../builder"; +import type { ChartDataResult } from "../../types"; +import type { CartesianChartSpec } from "../CartesianChart"; export function generateVLLineChartSpec( - config: ChartConfig, + config: CartesianChartSpec, data: ChartDataResult, ): VisualizationSpec { const spec = createMultiLayerBaseSpec(); + const vegaConfig = createConfig(config); const colorField = typeof config.color === "object" ? config.color.field : undefined; const xField = sanitizeValueForVega(config.x?.field); const yField = sanitizeValueForVega(config.y?.field); - const defaultTooltipChannel = createDefaultTooltipEncoding(config, data); + const defaultTooltipChannel = createDefaultTooltipEncoding( + [config.x, config.y, config.color], + data, + ); let multiValueTooltipChannel: TooltipValue[] | undefined; if (colorField && config.x && yField) { @@ -45,13 +47,13 @@ export function generateVLLineChartSpec( multiValueTooltipChannel = multiValueTooltipChannel.slice(0, 50); } - spec.encoding = { x: createXEncoding(config, data) }; + spec.encoding = { x: createPositionEncoding(config.x, data) }; spec.layer = [ { encoding: { - y: createYEncoding(config, data), - color: createColorEncoding(config, data), + y: createPositionEncoding(config.y, data), + color: createColorEncoding(config.color, data), }, layer: [ { mark: "line" }, @@ -132,5 +134,8 @@ export function generateVLLineChartSpec( }, ]; - return spec; + return { + ...spec, + ...(vegaConfig && { config: vegaConfig }), + }; } diff --git a/web-common/src/features/canvas/components/charts/cartesian-charts/stacked-bar/default.ts b/web-common/src/features/canvas/components/charts/cartesian-charts/stacked-bar/default.ts new file mode 100644 index 00000000000..80b0ba06bcb --- /dev/null +++ b/web-common/src/features/canvas/components/charts/cartesian-charts/stacked-bar/default.ts @@ -0,0 +1,23 @@ +import type { VisualizationSpec } from "svelte-vega"; +import { + createConfig, + createEncoding, + createSingleLayerBaseSpec, +} from "../../builder"; +import type { ChartDataResult } from "../../types"; +import type { CartesianChartSpec } from "../CartesianChart"; + +export function generateVLStackedBarChartSpec( + config: CartesianChartSpec, + data: ChartDataResult, +): VisualizationSpec { + const spec = createSingleLayerBaseSpec("bar"); + spec.encoding = createEncoding(config, data); + + const vegaConfig = createConfig(config); + + return { + ...spec, + ...(vegaConfig && { config: vegaConfig }), + }; +} diff --git a/web-common/src/features/canvas/components/charts/stacked-bar/normalized.ts b/web-common/src/features/canvas/components/charts/cartesian-charts/stacked-bar/normalized.ts similarity index 75% rename from web-common/src/features/canvas/components/charts/stacked-bar/normalized.ts rename to web-common/src/features/canvas/components/charts/cartesian-charts/stacked-bar/normalized.ts index 6dbe18858f2..06d713dcd09 100644 --- a/web-common/src/features/canvas/components/charts/stacked-bar/normalized.ts +++ b/web-common/src/features/canvas/components/charts/cartesian-charts/stacked-bar/normalized.ts @@ -1,21 +1,21 @@ -import type { - ChartConfig, - TooltipValue, -} from "@rilldata/web-common/features/canvas/components/charts/types"; +import type { TooltipValue } from "@rilldata/web-common/features/canvas/components/charts/types"; import type { VisualizationSpec } from "svelte-vega"; import { + createConfig, createDefaultTooltipEncoding, createEncoding, createSingleLayerBaseSpec, -} from "../builder"; -import type { ChartDataResult } from "../selector"; +} from "../../builder"; +import type { ChartDataResult } from "../../types"; +import type { CartesianChartSpec } from "../CartesianChart"; export function generateVLStackedBarNormalizedSpec( - config: ChartConfig, + config: CartesianChartSpec, data: ChartDataResult, ): VisualizationSpec { const spec = createSingleLayerBaseSpec("bar"); const baseEncoding = createEncoding(config, data); + const vegaConfig = createConfig(config); if (baseEncoding.y && config.y?.field) { const yField = config.y.field; @@ -53,7 +53,10 @@ export function generateVLStackedBarNormalizedSpec( ]; // Add percentage to tooltip - const tooltipValues = createDefaultTooltipEncoding(config, data); + const tooltipValues = createDefaultTooltipEncoding( + [config.x, config.y, config.color], + data, + ); baseEncoding.tooltip = tooltipValues .map((t: TooltipValue) => { if (t.field === yField) { @@ -76,5 +79,8 @@ export function generateVLStackedBarNormalizedSpec( } spec.encoding = baseEncoding; - return spec; + return { + ...spec, + ...(vegaConfig && { config: vegaConfig }), + }; } diff --git a/web-common/src/features/canvas/components/charts/circular-charts/CircularChart.ts b/web-common/src/features/canvas/components/charts/circular-charts/CircularChart.ts new file mode 100644 index 00000000000..dbe7e82e2f9 --- /dev/null +++ b/web-common/src/features/canvas/components/charts/circular-charts/CircularChart.ts @@ -0,0 +1,171 @@ +import type { + ChartFieldsMap, + FieldConfig, +} from "@rilldata/web-common/features/canvas/components/charts/types"; +import { getFilterWithNullHandling } from "@rilldata/web-common/features/canvas/components/charts/util"; +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 type { TimeAndFilterStore } from "@rilldata/web-common/features/canvas/stores/types"; +import type { + V1MetricsViewSpec, + V1Resource, +} from "@rilldata/web-common/runtime-client"; +import { + getQueryServiceMetricsViewAggregationQueryOptions, + type V1MetricsViewAggregationDimension, + type V1MetricsViewAggregationMeasure, +} from "@rilldata/web-common/runtime-client"; +import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; +import { derived, get, type Readable } from "svelte/store"; +import type { + CanvasEntity, + ComponentPath, +} from "../../../stores/canvas-entity"; +import { BaseChart, type BaseChartConfig } from "../BaseChart"; +import type { ChartDataQuery } from "../types"; + +type CircularChartEncoding = { + measure?: FieldConfig; + color?: FieldConfig; + innerRadius?: number; +}; +export type CircularChartSpec = BaseChartConfig & CircularChartEncoding; + +export class CircularChartComponent extends BaseChart { + static chartInputParams: Record = { + color: { + type: "positional", + label: "Color", + meta: { + chartFieldInput: { + type: "dimension", + nullSelector: true, + limitSelector: true, + hideTimeDimension: true, + }, + }, + }, + measure: { + type: "positional", + label: "Measure", + meta: { + chartFieldInput: { + type: "measure", + }, + }, + }, + innerRadius: { + type: "number", + label: "Inner Radius", + }, + }; + + constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) { + super(resource, parent, path); + } + + getChartSpecificOptions(): Record { + return CircularChartComponent.chartInputParams; + } + + createChartDataQuery( + ctx: CanvasStore, + timeAndFilterStore: Readable, + ): ChartDataQuery { + const config = get(this.specStore); + + let measures: V1MetricsViewAggregationMeasure[] = []; + let dimensions: V1MetricsViewAggregationDimension[] = []; + + if (config.measure?.field) { + measures = [{ name: config.measure.field }]; + } + + let limit: number; + if (config.color?.field) { + limit = config.color.limit ?? 20; + dimensions = [{ name: config.color.field }]; + } + + const queryOptionsStore = derived( + [ctx.runtime, timeAndFilterStore], + ([runtime, $timeAndFilterStore]) => { + const { timeRange, where } = $timeAndFilterStore; + const enabled = !!timeRange?.start && !!timeRange?.end; + + const nullHandledWhere = getFilterWithNullHandling(where, config.color); + + const queryOptions = getQueryServiceMetricsViewAggregationQueryOptions( + runtime.instanceId, + config.metrics_view, + { + measures, + dimensions, + where: nullHandledWhere, + sort: [ + ...(config.measure?.field + ? [{ name: config.measure.field, desc: true }] + : []), + ], + timeRange, + limit: limit.toString(), + }, + { + query: { + enabled, + placeholderData: keepPreviousData, + }, + }, + ); + + return queryOptions; + }, + ); + + const query = createQuery(queryOptionsStore); + return query; + } + + chartTitle(fields: ChartFieldsMap) { + const config = get(this.specStore); + const { measure, color } = config; + const measureLabel = measure?.field + ? fields[measure.field]?.displayName || measure.field + : ""; + const colorLabel = color?.field + ? fields[color.field]?.displayName || color.field + : ""; + + return colorLabel ? `${measureLabel} split by ${colorLabel}` : measureLabel; + } + + static newComponentSpec( + metricsViewName: string, + metricsViewSpec: V1MetricsViewSpec | undefined, + ): CircularChartSpec { + // Randomly select a measure and dimension if available + const measures = metricsViewSpec?.measures || []; + const dimensions = metricsViewSpec?.dimensions || []; + + const randomMeasure = measures[Math.floor(Math.random() * measures.length)] + ?.name as string; + + const randomDimension = dimensions[ + Math.floor(Math.random() * dimensions.length) + ]?.name as string; + + return { + metrics_view: metricsViewName, + innerRadius: 0, + color: { + type: "nominal", + field: randomDimension, + limit: 10, + }, + measure: { + type: "quantitative", + field: randomMeasure, + }, + }; + } +} diff --git a/web-common/src/features/canvas/components/charts/circular-charts/pie.ts b/web-common/src/features/canvas/components/charts/circular-charts/pie.ts new file mode 100644 index 00000000000..5584c8951a3 --- /dev/null +++ b/web-common/src/features/canvas/components/charts/circular-charts/pie.ts @@ -0,0 +1,51 @@ +import type { VisualizationSpec } from "svelte-vega"; +import type { Config } from "vega-lite"; +import { + createColorEncoding, + createConfig, + createDefaultTooltipEncoding, + createPositionEncoding, + createSingleLayerBaseSpec, +} from "../builder"; +import type { ChartDataResult } from "../types"; +import type { CircularChartSpec } from "./CircularChart"; + +/** + * The layout property is not typed in the current version of Vega-Lite. + * This will be fixed when we upgrade to Svelte 5 and subseqent Vega-Lite versions. + */ +export function generateVLPieChartSpec( + config: CircularChartSpec, + data: ChartDataResult, +): VisualizationSpec { + const spec = createSingleLayerBaseSpec("arc"); + const vegaConfig = createConfig(config, { + legend: { + orient: "right", + layout: { + right: { anchor: "middle" }, + }, + }, + } as unknown as Config); + + spec.mark = { + type: "arc", + innerRadius: config.innerRadius || 0, + }; + const theta = createPositionEncoding(config.measure, data); + const color = createColorEncoding(config.color, data); + const tooltip = createDefaultTooltipEncoding( + [config.measure, config.color], + data, + ); + + return { + ...spec, + encoding: { + theta, + color, + tooltip, + }, + ...(vegaConfig && { config: vegaConfig }), + }; +} diff --git a/web-common/src/features/canvas/components/charts/heatmap-charts/HeatmapChart.ts b/web-common/src/features/canvas/components/charts/heatmap-charts/HeatmapChart.ts new file mode 100644 index 00000000000..eb3debc0a24 --- /dev/null +++ b/web-common/src/features/canvas/components/charts/heatmap-charts/HeatmapChart.ts @@ -0,0 +1,285 @@ +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 type { TimeAndFilterStore } from "@rilldata/web-common/features/canvas/stores/types"; +import { mergeFilters } from "@rilldata/web-common/features/dashboards/pivot/pivot-merge-filters"; +import { createInExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { + getQueryServiceMetricsViewAggregationQueryOptions, + type V1Expression, + type V1MetricsViewAggregationDimension, + type V1MetricsViewAggregationMeasure, + type V1MetricsViewSpec, + type V1Resource, +} from "@rilldata/web-common/runtime-client"; +import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; +import { derived, get, type Readable } from "svelte/store"; +import type { + CanvasEntity, + ComponentPath, +} from "../../../stores/canvas-entity"; +import { BaseChart, type BaseChartConfig } from "../BaseChart"; +import type { ChartDataQuery, ChartFieldsMap, FieldConfig } from "../types"; +import { getFilterWithNullHandling } from "../util"; + +export type HeatmapChartSpec = BaseChartConfig & { + x?: FieldConfig; + y?: FieldConfig; + color?: FieldConfig; +}; + +export class HeatmapChartComponent extends BaseChart { + static chartInputParams: Record = { + x: { + type: "positional", + label: "X-axis", + meta: { + chartFieldInput: { + type: "dimension", + limitSelector: true, + axisTitleSelector: true, + nullSelector: true, + labelAngleSelector: true, + }, + }, + }, + y: { + type: "positional", + label: "Y-axis", + meta: { + chartFieldInput: { + type: "dimension", + limitSelector: true, + axisTitleSelector: true, + nullSelector: true, + }, + }, + }, + color: { + type: "positional", + label: "Color", + meta: { + chartFieldInput: { + type: "measure", + }, + }, + }, + }; + + constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) { + super(resource, parent, path); + } + + getChartSpecificOptions(): Record { + return HeatmapChartComponent.chartInputParams; + } + + createChartDataQuery( + ctx: CanvasStore, + timeAndFilterStore: Readable, + ): ChartDataQuery { + const config = get(this.specStore); + + let measures: V1MetricsViewAggregationMeasure[] = []; + let dimensions: V1MetricsViewAggregationDimension[] = []; + + if (config.color?.field) { + measures = [{ name: config.color.field }]; + } + + // Create top level options store for X axis + const xAxisQueryOptionsStore = derived( + [ctx.runtime, timeAndFilterStore], + ([runtime, $timeAndFilterStore]) => { + const { timeRange, where } = $timeAndFilterStore; + const enabled = + !!timeRange?.start && !!timeRange?.end && !!config.x?.field; + + const xWhere = getFilterWithNullHandling(where, config.x); + + let limit = "100"; + if (config.x?.limit && config.x.type !== "temporal") { + limit = config.x.limit.toString(); + } + + return getQueryServiceMetricsViewAggregationQueryOptions( + runtime.instanceId, + config.metrics_view, + { + measures, + dimensions: [{ name: config.x?.field }], + sort: + config.x?.type === "nominal" + ? [{ name: config.x?.field, desc: true }] + : [], + where: xWhere, + timeRange, + limit, + }, + { + query: { + enabled, + placeholderData: keepPreviousData, + }, + }, + ); + }, + ); + + // Create top level options store for Y axis + const yAxisQueryOptionsStore = derived( + [ctx.runtime, timeAndFilterStore], + ([runtime, $timeAndFilterStore]) => { + const { timeRange, where } = $timeAndFilterStore; + const enabled = + !!timeRange?.start && !!timeRange?.end && !!config.y?.field; + + const yWhere = getFilterWithNullHandling(where, config.y); + + let limit = "100"; + if (config.y?.limit && config.y.type !== "temporal") { + limit = config.y.limit.toString(); + } + + return getQueryServiceMetricsViewAggregationQueryOptions( + runtime.instanceId, + config.metrics_view, + { + measures, + dimensions: [{ name: config.y?.field }], + sort: + config.y?.type === "nominal" + ? [{ name: config.y?.field, desc: true }] + : [], + where: yWhere, + timeRange, + limit, + }, + { + query: { + enabled, + placeholderData: keepPreviousData, + }, + }, + ); + }, + ); + + const xAxisQuery = createQuery(xAxisQueryOptionsStore); + const yAxisQuery = createQuery(yAxisQueryOptionsStore); + + const queryOptionsStore = derived( + [ctx.runtime, timeAndFilterStore, xAxisQuery, yAxisQuery], + ([runtime, $timeAndFilterStore, $xAxisQuery, $yAxisQuery]) => { + const { timeRange, where } = $timeAndFilterStore; + const xTopNData = $xAxisQuery?.data?.data; + const yTopNData = $yAxisQuery?.data?.data; + + const enabled = + !!timeRange?.start && + !!timeRange?.end && + !!xTopNData?.length && + !!yTopNData?.length; + + let combinedWhere: V1Expression | undefined = where; + + if (xTopNData?.length && config.x?.field) { + const xField = config.x.field; + const xTopValues = xTopNData.map((d) => d[xField] as string); + const xFilterForTopValues = createInExpression(xField, xTopValues); + combinedWhere = mergeFilters(combinedWhere, xFilterForTopValues); + } + + if (yTopNData?.length && config.y?.field) { + const yField = config.y.field; + const yTopValues = yTopNData.map((d) => d[yField] as string); + const yFilterForTopValues = createInExpression(yField, yTopValues); + combinedWhere = mergeFilters(combinedWhere, yFilterForTopValues); + } + + if (config.x?.field) { + dimensions = [...dimensions, { name: config.x.field }]; + } + + if (config.y?.field) { + dimensions = [...dimensions, { name: config.y.field }]; + } + + return getQueryServiceMetricsViewAggregationQueryOptions( + runtime.instanceId, + config.metrics_view, + { + measures, + dimensions, + sort: + config.x?.type === "nominal" + ? [{ name: config.x?.field, desc: true }] + : undefined, + where: combinedWhere, + timeRange, + limit: "5000", // Higher limit for heatmap to show more data points + }, + { + query: { + enabled, + placeholderData: keepPreviousData, + }, + }, + ); + }, + ); + + return createQuery(queryOptionsStore); + } + + static newComponentSpec( + metricsViewName: string, + metricsViewSpec: V1MetricsViewSpec | undefined, + ): HeatmapChartSpec { + // Select two dimensions and one measure if available + const measures = metricsViewSpec?.measures || []; + const dimensions = metricsViewSpec?.dimensions || []; + + const randomMeasure = measures[Math.floor(Math.random() * measures.length)] + ?.name as string; + + // Get two random dimensions + const availableDimensions = [...dimensions]; + const randomDimension1 = availableDimensions.splice( + Math.floor(Math.random() * availableDimensions.length), + 1, + )[0]?.name as string; + const randomDimension2 = availableDimensions[ + Math.floor(Math.random() * availableDimensions.length) + ]?.name as string; + + return { + metrics_view: metricsViewName, + x: { + type: "nominal", + field: randomDimension1, + limit: 20, + }, + y: { + type: "nominal", + field: randomDimension2, + limit: 20, + }, + color: { + type: "quantitative", + field: randomMeasure, + }, + }; + } + + chartTitle(fields: ChartFieldsMap) { + const config = get(this.specStore); + const { x, y, color } = config; + const xLabel = x?.field ? fields[x.field]?.displayName || x.field : ""; + const yLabel = y?.field ? fields[y.field]?.displayName || y.field : ""; + const colorLabel = color?.field + ? fields[color.field]?.displayName || color.field + : ""; + + return `${colorLabel} by ${xLabel} and ${yLabel}`; + } +} diff --git a/web-common/src/features/canvas/components/charts/heatmap-charts/spec.ts b/web-common/src/features/canvas/components/charts/heatmap-charts/spec.ts new file mode 100644 index 00000000000..0ecd0b440f0 --- /dev/null +++ b/web-common/src/features/canvas/components/charts/heatmap-charts/spec.ts @@ -0,0 +1,44 @@ +import type { Field } from "vega-lite/build/src/channeldef"; +import type { TopLevelUnitSpec } from "vega-lite/build/src/spec/unit"; +import { + createColorEncoding, + createConfig, + createDefaultTooltipEncoding, + createPositionEncoding, + createSingleLayerBaseSpec, +} from "../builder"; +import type { ChartDataResult } from "../types"; +import type { HeatmapChartSpec } from "./HeatmapChart"; + +export function generateVLHeatmapSpec( + config: HeatmapChartSpec, + data: ChartDataResult, +): TopLevelUnitSpec { + const spec = createSingleLayerBaseSpec("rect"); + + const vegaConfig = createConfig(config, { + legend: { + orient: "bottom", + }, + axis: { grid: true, tickBand: "extent" }, + axisX: { + grid: true, + gridDash: [], + tickBand: "extent", + }, + }); + + return { + ...spec, + encoding: { + x: createPositionEncoding(config.x, data), + y: createPositionEncoding(config.y, data), + color: createColorEncoding(config.color, data), + tooltip: createDefaultTooltipEncoding( + [config.x, config.y, config.color], + data, + ), + }, + ...(vegaConfig && { config: vegaConfig }), + }; +} diff --git a/web-common/src/features/canvas/components/charts/index.ts b/web-common/src/features/canvas/components/charts/index.ts index e8855dba373..d86f2d1896a 100644 --- a/web-common/src/features/canvas/components/charts/index.ts +++ b/web-common/src/features/canvas/components/charts/index.ts @@ -1,128 +1,113 @@ -import { BaseCanvasComponent } from "@rilldata/web-common/features/canvas/components/BaseCanvasComponent"; -import type { - ChartConfig, - ChartType, -} from "@rilldata/web-common/features/canvas/components/charts/types"; +import BarChart from "@rilldata/web-common/components/icons/BarChart.svelte"; +import Donut from "@rilldata/web-common/components/icons/Donut.svelte"; +import Heatmap from "@rilldata/web-common/components/icons/Heatmap.svelte"; +import LineChart from "@rilldata/web-common/components/icons/LineChart.svelte"; +import StackedArea from "@rilldata/web-common/components/icons/StackedArea.svelte"; +import StackedBar from "@rilldata/web-common/components/icons/StackedBar.svelte"; +import StackedBarFull from "@rilldata/web-common/components/icons/StackedBarFull.svelte"; +import type { BaseCanvasComponentConstructor } from "@rilldata/web-common/features/canvas/components/util"; +import type { ComponentType, SvelteComponent } from "svelte"; +import type { VisualizationSpec } from "svelte-vega"; +import { generateVLAreaChartSpec } from "./cartesian-charts/area/spec"; +import { generateVLBarChartSpec } from "./cartesian-charts/bar-chart/spec"; +import type { CartesianChartSpec } from "./cartesian-charts/CartesianChart"; +import { CartesianChartComponent } from "./cartesian-charts/CartesianChart"; +import { generateVLLineChartSpec } from "./cartesian-charts/line-chart/spec"; +import { generateVLStackedBarChartSpec } from "./cartesian-charts/stacked-bar/default"; +import { generateVLStackedBarNormalizedSpec } from "./cartesian-charts/stacked-bar/normalized"; import { - commonOptions, - getFilterOptions, -} from "@rilldata/web-common/features/canvas/components/util"; -import type { InputParams } from "@rilldata/web-common/features/canvas/inspector/types"; -import { defaultPrimaryColors } from "@rilldata/web-common/features/themes/color-config"; -import type { - V1MetricsViewSpec, - V1Resource, -} from "@rilldata/web-common/runtime-client"; -import type { - ComponentCommonProperties, - ComponentFilterProperties, -} from "../types"; -import type { CanvasEntity, ComponentPath } from "../../stores/canvas-entity"; -import Chart from "./Chart.svelte"; -import { get, writable, type Writable } from "svelte/store"; + CircularChartComponent, + type CircularChartSpec, +} from "./circular-charts/CircularChart"; +import { generateVLPieChartSpec } from "./circular-charts/pie"; +import { + HeatmapChartComponent, + type HeatmapChartSpec, +} from "./heatmap-charts/HeatmapChart"; +import { generateVLHeatmapSpec } from "./heatmap-charts/spec"; +import type { ChartDataResult, ChartType } from "./types"; export { default as Chart } from "./Chart.svelte"; -export type ChartSpec = ComponentFilterProperties & - ComponentCommonProperties & - ChartConfig; - -export class ChartComponent extends BaseCanvasComponent { - minSize = { width: 4, height: 4 }; - defaultSize = { width: 6, height: 4 }; - resetParams = []; - type: ChartType; - chartType: Writable; - component = Chart; - - constructor(resource: V1Resource, parent: CanvasEntity, path: ComponentPath) { - const defaultSpec: ChartSpec = { - metrics_view: "", - title: "", - description: "", - }; - - super(resource, parent, path, defaultSpec); - - this.type = resource.component?.state?.validSpec?.renderer as ChartType; - this.chartType = writable(this.type); - } - - isValid(spec: ChartSpec): boolean { - return typeof spec.metrics_view === "string" && Boolean(spec.x || spec.y); - } - - inputParams(): InputParams { - return { - options: { - metrics_view: { type: "metrics", label: "Metrics view" }, - x: { type: "positional", label: "X-axis" }, - y: { type: "positional", label: "Y-axis" }, - color: { type: "mark", label: "Color", meta: { type: "color" } }, - tooltip: { type: "tooltip", label: "Tooltip", showInUI: false }, - vl_config: { type: "config", showInUI: false }, - ...commonOptions, - }, - filter: getFilterOptions(false), - }; - } - - static newComponentSpec( - metricsViewName: string, - metricsViewSpec: V1MetricsViewSpec | undefined, - ): ChartSpec { - // Randomly select a measure and dimension if available - const measures = metricsViewSpec?.measures || []; - - const timeDimension = metricsViewSpec?.timeDimension; - const dimensions = metricsViewSpec?.dimensions || []; - - const randomMeasure = measures[Math.floor(Math.random() * measures.length)] - ?.name as string; - - let randomDimension = ""; - if (!timeDimension) { - randomDimension = dimensions[ - Math.floor(Math.random() * dimensions.length) - ]?.name as string; - } - - const spec: ChartSpec = { - metrics_view: metricsViewName, - x: { - type: timeDimension ? "temporal" : "nominal", - field: timeDimension || randomDimension, - sort: "-y", - limit: 20, - }, - y: { - type: "quantitative", - field: randomMeasure, - zeroBasedOrigin: true, - }, - color: `hsl(${defaultPrimaryColors[500].split(" ").join(",")})`, - }; - - return spec; +export type ChartComponent = + | typeof CartesianChartComponent + | typeof CircularChartComponent + | typeof HeatmapChartComponent; + +export type ChartSpec = + | CartesianChartSpec + | CircularChartSpec + | HeatmapChartSpec; + +export function getChartComponent( + type: ChartType, +): BaseCanvasComponentConstructor { + switch (type) { + case "bar_chart": + case "line_chart": + case "area_chart": + case "stacked_bar": + case "stacked_bar_normalized": + return CartesianChartComponent; + case "pie_chart": + return CircularChartComponent; + case "heatmap": + return HeatmapChartComponent; + default: + throw new Error(`Unsupported chart type: ${type}`); } +} - updateChartType(key: ChartType) { - if (!this.parent.fileArtifact) return; - const currentSpec = get(this.specStore); - - const parentPath = this.pathInYAML.slice(0, -1); - - this.chartType.set(key); - - const parseDocumentStore = this.parent.parsedContent; - const parsedDocument = get(parseDocumentStore); - - const { updateEditorContent } = this.parent.fileArtifact; - - const width = parsedDocument.getIn([...parentPath, "width"]); - - parsedDocument.setIn(parentPath, { [key]: currentSpec, width }); - - updateEditorContent(parsedDocument.toString(), false, true); - } +export interface ChartMetadataConfig { + title: string; + icon: ComponentType; + component: BaseCanvasComponentConstructor; + generateSpec: (config: ChartSpec, data: ChartDataResult) => VisualizationSpec; } + +export const CHART_CONFIG: Record = { + bar_chart: { + title: "Bar", + icon: BarChart, + component: CartesianChartComponent, + generateSpec: generateVLBarChartSpec, + }, + line_chart: { + title: "Line", + icon: LineChart, + component: CartesianChartComponent, + generateSpec: generateVLLineChartSpec, + }, + area_chart: { + title: "Stacked Area", + icon: StackedArea, + component: CartesianChartComponent, + generateSpec: generateVLAreaChartSpec, + }, + stacked_bar: { + title: "Stacked Bar", + icon: StackedBar, + component: CartesianChartComponent, + generateSpec: generateVLStackedBarChartSpec, + }, + stacked_bar_normalized: { + title: "Stacked Bar Normalized", + icon: StackedBarFull, + component: CartesianChartComponent, + generateSpec: generateVLStackedBarNormalizedSpec, + }, + pie_chart: { + title: "Pie", + icon: Donut, + component: CircularChartComponent, + generateSpec: generateVLPieChartSpec, + }, + heatmap: { + title: "Heatmap", + icon: Heatmap, + component: HeatmapChartComponent, + generateSpec: generateVLHeatmapSpec, + }, +}; + +export const CHART_TYPES = Object.keys(CHART_CONFIG) as ChartType[]; diff --git a/web-common/src/features/canvas/components/charts/query.ts b/web-common/src/features/canvas/components/charts/query.ts deleted file mode 100644 index 57fe27ab1db..00000000000 --- a/web-common/src/features/canvas/components/charts/query.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { - ChartConfig, - ChartSortDirection, -} from "@rilldata/web-common/features/canvas/components/charts/types"; -import type { ComponentFilterProperties } from "@rilldata/web-common/features/canvas/components/types"; -import type { CanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; -import type { TimeAndFilterStore } from "@rilldata/web-common/features/canvas/stores/types"; -import { mergeFilters } from "@rilldata/web-common/features/dashboards/pivot/pivot-merge-filters"; -import { createInExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import { - createQueryServiceMetricsViewAggregation, - type V1Expression, - type V1MetricsViewAggregationDimension, - type V1MetricsViewAggregationMeasure, - type V1MetricsViewAggregationResponse, - type V1MetricsViewAggregationResponseDataItem, - type V1MetricsViewAggregationSort, -} from "@rilldata/web-common/runtime-client"; -import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; -import { - keepPreviousData, - type CreateQueryResult, -} from "@tanstack/svelte-query"; -import { derived, readable, type Readable } from "svelte/store"; - -export function createChartDataQuery( - ctx: CanvasStore, - config: ChartConfig & ComponentFilterProperties, - timeAndFilterStore: Readable, -): Readable<{ - isFetching: boolean; - error: HTTPError | null; - data: V1MetricsViewAggregationResponseDataItem[] | undefined; -}> { - let measures: V1MetricsViewAggregationMeasure[] = []; - let dimensions: V1MetricsViewAggregationDimension[] = []; - - if (config.y?.type === "quantitative" && config.y?.field) { - measures = [{ name: config.y?.field }]; - } - - let sort: V1MetricsViewAggregationSort | undefined; - let limit: number | undefined; - let hasColorDimension = false; - - return derived( - [ctx.runtime, timeAndFilterStore], - ([runtime, $timeAndFilterStore], set) => { - const { timeRange, where, timeGrain } = $timeAndFilterStore; - - let outerWhere = where; - - if (config.x?.type === "nominal" && config.x?.field) { - limit = config.x.limit; - sort = vegaSortToAggregationSort(config.x?.sort, config); - dimensions = [{ name: config.x?.field }]; - - const showNull = !!config.x.showNull; - if (!showNull) { - const excludeNullFilter = createInExpression( - config.x?.field, - [null], - true, - ); - outerWhere = mergeFilters(where, excludeNullFilter); - } - } else if (config.x?.type === "temporal" && timeGrain) { - dimensions = [{ name: config.x?.field, timeGrain }]; - } - - if (typeof config.color === "object" && config.color?.field) { - dimensions = [...dimensions, { name: config.color.field }]; - hasColorDimension = true; - } - - let topNQuery: - | Readable - | CreateQueryResult = - readable(null); - - const enabled = !!timeRange?.start && !!timeRange?.end; - - if (limit && hasColorDimension) { - topNQuery = createQueryServiceMetricsViewAggregation( - runtime.instanceId, - config.metrics_view, - { - measures, - dimensions: [{ name: config.x?.field }], - sort: sort ? [sort] : undefined, - where: outerWhere, - timeRange, - limit: limit.toString(), - }, - { - query: { - enabled, - placeholderData: keepPreviousData, - }, - }, - ctx.queryClient, - ); - } - - return derived(topNQuery, ($topNQuery, topNSet) => { - if ($topNQuery !== null && !$topNQuery?.data) { - return topNSet({ - isFetching: $topNQuery.isFetching, - error: $topNQuery.error, - data: undefined, - }); - } - - const dimensionName = config.x?.field; - - let combinedWhere: V1Expression | undefined = outerWhere; - if ($topNQuery?.data?.data?.length && dimensionName) { - const topValues = $topNQuery?.data?.data.map( - (d) => d[dimensionName] as string, - ); - const filterForTopValues = createInExpression( - dimensionName, - topValues, - ); - - combinedWhere = mergeFilters(where, filterForTopValues); - } - - const dataQuery = createQueryServiceMetricsViewAggregation( - runtime.instanceId, - config.metrics_view, - { - measures, - dimensions, - sort: sort ? [sort] : undefined, - where: combinedWhere, - timeRange, - limit: hasColorDimension || !limit ? "5000" : limit.toString(), - }, - { - query: { - enabled, - placeholderData: keepPreviousData, - }, - }, - ctx.queryClient, - ); - - return derived(dataQuery, ($dataQuery) => { - return { - isFetching: $dataQuery.isFetching, - error: $dataQuery.error, - data: $dataQuery?.data?.data, - }; - }).subscribe(topNSet); - }).subscribe(set); - }, - ); -} - -function vegaSortToAggregationSort( - sort: ChartSortDirection | undefined, - config: ChartConfig, -): V1MetricsViewAggregationSort | undefined { - if (!sort) return undefined; - const field = - sort === "x" || sort === "-x" ? config.x?.field : config.y?.field; - if (!field) return undefined; - - return { - name: field, - desc: sort === "-x" || sort === "-y", - }; -} diff --git a/web-common/src/features/canvas/components/charts/selector.ts b/web-common/src/features/canvas/components/charts/selector.ts index 9c8fc1f4b6e..ec7c96ae0c2 100644 --- a/web-common/src/features/canvas/components/charts/selector.ts +++ b/web-common/src/features/canvas/components/charts/selector.ts @@ -1,78 +1,58 @@ -import { - validateDimensions, - validateMeasures, -} from "@rilldata/web-common/features/canvas/components/validators"; +import type { ChartSpec } from "@rilldata/web-common/features/canvas/components/charts"; +import type { BaseChart } from "@rilldata/web-common/features/canvas/components/charts/BaseChart"; import type { CanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; import type { TimeAndFilterStore } from "@rilldata/web-common/features/canvas/stores/types"; import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; import { type MetricsViewSpecDimension, type MetricsViewSpecMeasure, - type V1MetricsViewAggregationResponseDataItem, } from "@rilldata/web-common/runtime-client"; -import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; import { derived, type Readable } from "svelte/store"; -import type { ChartSpec } from "./"; -import { createChartDataQuery } from "./query"; -import type { ChartConfig } from "./types"; -import { timeGrainToVegaTimeUnitMap } from "./util"; - -export type ChartDataResult = { - data: V1MetricsViewAggregationResponseDataItem[]; - isFetching: boolean; - fields: Record< - string, - | MetricsViewSpecMeasure - | MetricsViewSpecDimension - | TimeDimensionDefinition - | undefined - >; - error?: HTTPError | null; -}; - -export interface TimeDimensionDefinition { - field: string; - displayName: string; - timeUnit?: string; - format?: string; -} +import type { ChartDataResult, TimeDimensionDefinition } from "./types"; +import { + adjustDataForTimeZone, + getFieldsByType, + timeGrainToVegaTimeUnitMap, +} from "./util"; export function getChartData( ctx: CanvasStore, - config: ChartConfig, + component: BaseChart, + config: ChartSpec, timeAndFilterStore: Readable, ): Readable { - const chartDataQuery = createChartDataQuery(ctx, config, timeAndFilterStore); + const chartDataQuery = component.createChartDataQuery( + ctx, + timeAndFilterStore, + ); const { spec } = ctx.canvasEntity; - const fields: { name: string; type: "measure" | "dimension" | "time" }[] = []; - if (config.y?.field) fields.push({ name: config.y.field, type: "measure" }); - if (config.x?.field) - fields.push({ - name: config.x.field, - type: config.x.type === "temporal" ? "time" : "dimension", - }); - if (typeof config.color === "object" && config.color?.field) { - fields.push({ name: config.color.field, type: "dimension" }); - } + const { measures, dimensions, timeDimensions } = getFieldsByType(config); + + // Combine all fields with their types + const allFields = [ + ...measures.map((field) => ({ field, type: "measure" })), + ...dimensions.map((field) => ({ field, type: "dimension" })), + ...timeDimensions.map((field) => ({ field, type: "time" })), + ]; // Match each field to its corresponding measure or dimension spec. - const fieldReadableMap = fields.map((field) => { + const fieldReadableMap = allFields.map((field) => { if (field.type === "measure") { - return spec.getMeasureForMetricView(field.name, config.metrics_view); + return spec.getMeasureForMetricView(field.field, config.metrics_view); } else if (field.type === "dimension") { - return spec.getDimensionForMetricView(field.name, config.metrics_view); + return spec.getDimensionForMetricView(field.field, config.metrics_view); } else { - return getTimeDimensionDefinition(field.name, timeAndFilterStore); + return getTimeDimensionDefinition(field.field, timeAndFilterStore); } }); return derived( - [chartDataQuery, ...fieldReadableMap], - ([chartData, ...fieldMap]) => { - const fieldSpecMap = fields.reduce( + [chartDataQuery, timeAndFilterStore, ...fieldReadableMap], + ([chartData, $timeAndFilterStore, ...fieldMap]) => { + const fieldSpecMap = allFields.reduce( (acc, field, index) => { - acc[field.name] = fieldMap?.[index]; + acc[field.field] = fieldMap?.[index]; return acc; }, {} as Record< @@ -83,10 +63,21 @@ export function getChartData( | undefined >, ); + + let data = chartData?.data?.data; + + if (timeDimensions?.length && $timeAndFilterStore.timeGrain) { + data = adjustDataForTimeZone( + data, + timeDimensions, + $timeAndFilterStore.timeGrain, + $timeAndFilterStore.timeRange.timeZone || "UTC", + ); + } return { - data: chartData.data || [], - isFetching: chartData.isFetching, - error: chartData.error, + data: data || [], + isFetching: chartData?.isFetching ?? false, + error: chartData?.error, fields: fieldSpecMap, }; }, @@ -117,67 +108,3 @@ export function getTimeDimensionDefinition( }; }); } - -export function validateChartSchema( - ctx: CanvasStore, - chartSpec: ChartSpec, -): Readable<{ - isValid: boolean; - error?: string; - isLoading?: boolean; -}> { - const { metrics_view, x, y, color } = chartSpec; - let measures: string[] = []; - let dimensions: string[] = []; - - if (y?.field) measures = [y.field]; - if (typeof color === "object" && color?.field) - dimensions = [...dimensions, color.field]; - - return derived( - ctx.canvasEntity.spec.getMetricsViewFromName(metrics_view), - (metricsViewQuery) => { - if (metricsViewQuery.isLoading) { - return { - isValid: true, - isLoading: true, - }; - } - const metricsView = metricsViewQuery.metricsView; - if (!metricsView) { - return { - isValid: false, - error: `Metrics view ${metrics_view} not found`, - }; - } - - const timeDimension = metricsView.timeDimension; - if (x?.field && x.field !== timeDimension) dimensions = [x.field]; - - const validateMeasuresRes = validateMeasures(metricsView, measures); - if (!validateMeasuresRes.isValid) { - const invalidMeasures = validateMeasuresRes.invalidMeasures.join(", "); - return { - isValid: false, - error: `Invalid measure ${invalidMeasures} selected`, - }; - } - - const validateDimensionsRes = validateDimensions(metricsView, dimensions); - - if (!validateDimensionsRes.isValid) { - const invalidDimensions = - validateDimensionsRes.invalidDimensions.join(", "); - - return { - isValid: false, - error: `Invalid dimension(s) ${invalidDimensions} selected`, - }; - } - return { - isValid: true, - error: undefined, - }; - }, - ); -} diff --git a/web-common/src/features/canvas/components/charts/stacked-bar/default.ts b/web-common/src/features/canvas/components/charts/stacked-bar/default.ts deleted file mode 100644 index 41266717583..00000000000 --- a/web-common/src/features/canvas/components/charts/stacked-bar/default.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ChartConfig } from "@rilldata/web-common/features/canvas/components/charts/types"; -import type { VisualizationSpec } from "svelte-vega"; -import { createEncoding, createSingleLayerBaseSpec } from "../builder"; -import type { ChartDataResult } from "../selector"; - -export function generateVLStackedBarChartSpec( - config: ChartConfig, - data: ChartDataResult, -): VisualizationSpec { - const spec = createSingleLayerBaseSpec("bar"); - spec.encoding = createEncoding(config, data); - return spec; -} diff --git a/web-common/src/features/canvas/components/charts/types.ts b/web-common/src/features/canvas/components/charts/types.ts index 29a8b1db8b4..163cb46abc6 100644 --- a/web-common/src/features/canvas/components/charts/types.ts +++ b/web-common/src/features/canvas/components/charts/types.ts @@ -1,4 +1,52 @@ -import type { ComponentType, SvelteComponent } from "svelte"; +import type { + V1Expression, + V1MetricsViewAggregationDimension, + V1MetricsViewAggregationMeasure, + V1MetricsViewAggregationResponse, + V1MetricsViewAggregationSort, +} from "@rilldata/web-common/runtime-client"; +import { + type MetricsViewSpecDimension, + type MetricsViewSpecMeasure, + type V1MetricsViewAggregationResponseDataItem, +} from "@rilldata/web-common/runtime-client"; +import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; +import type { CreateQueryResult } from "@tanstack/svelte-query"; + +export type ChartType = + | "bar_chart" + | "line_chart" + | "area_chart" + | "stacked_bar" + | "stacked_bar_normalized" + | "pie_chart" + | "heatmap"; + +export type ChartDataQuery = CreateQueryResult< + V1MetricsViewAggregationResponse, + HTTPError +>; + +export type ChartFieldsMap = Record< + string, + | MetricsViewSpecMeasure + | MetricsViewSpecDimension + | TimeDimensionDefinition + | undefined +>; +export type ChartDataResult = { + data: V1MetricsViewAggregationResponseDataItem[]; + isFetching: boolean; + fields: ChartFieldsMap; + error?: HTTPError | null; +}; + +export interface TimeDimensionDefinition { + field: string; + displayName: string; + timeUnit?: string; + format?: string; +} export type ChartSortDirection = "x" | "y" | "-x" | "-y"; @@ -11,10 +59,17 @@ export interface FieldConfig { sort?: ChartSortDirection; limit?: number; showNull?: boolean; + labelAngle?: number; } -export interface ChartConfig { +export interface CommonChartProperties { metrics_view: string; + tooltip?: FieldConfig; + vl_config?: string; +} + +// TODO: Remove this once we have a better way to handle chart config +export interface ChartConfig extends CommonChartProperties { x?: FieldConfig; y?: FieldConfig; color?: FieldConfig | string; @@ -22,19 +77,6 @@ export interface ChartConfig { vl_config?: string; } -export type ChartType = - | "line_chart" - | "bar_chart" - | "stacked_bar" - | "stacked_bar_normalized" - | "area_chart"; - -export interface ChartMetadata { - type: ChartType; - icon: ComponentType; - title: string; -} - /** Temporary solution for the lack of vega lite type exports */ export interface TooltipValue { title?: string; @@ -43,3 +85,11 @@ export interface TooltipValue { formatType?: string; type: "quantitative" | "ordinal" | "temporal" | "nominal"; } + +export interface ChartQueryConfig { + measures: V1MetricsViewAggregationMeasure[]; + dimensions: V1MetricsViewAggregationDimension[]; + sort?: V1MetricsViewAggregationSort[]; + where?: V1Expression; + limit?: string; +} diff --git a/web-common/src/features/canvas/components/charts/util.ts b/web-common/src/features/canvas/components/charts/util.ts index 9c63ded96f6..ff78cf08ee6 100644 --- a/web-common/src/features/canvas/components/charts/util.ts +++ b/web-common/src/features/canvas/components/charts/util.ts @@ -1,95 +1,55 @@ -import BarChart from "@rilldata/web-common/components/icons/BarChart.svelte"; -import LineChart from "@rilldata/web-common/components/icons/LineChart.svelte"; -import StackedArea from "@rilldata/web-common/components/icons/StackedArea.svelte"; -import StackedBar from "@rilldata/web-common/components/icons/StackedBar.svelte"; -import StackedBarFull from "@rilldata/web-common/components/icons/StackedBarFull.svelte"; -import { getRillTheme } from "@rilldata/web-common/components/vega/vega-config"; +import { mergeFilters } from "@rilldata/web-common/features/dashboards/pivot/pivot-merge-filters"; +import { createInExpression } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { sanitizeValueForVega } from "@rilldata/web-common/features/templates/charts/utils"; -import { V1TimeGrain } from "@rilldata/web-common/runtime-client"; +import { adjustOffsetForZone } from "@rilldata/web-common/lib/convertTimestampPreview"; +import { timeGrainToDuration } from "@rilldata/web-common/lib/time/grains"; +import { + V1TimeGrain, + type V1Expression, + type V1MetricsViewAggregationResponseDataItem, +} from "@rilldata/web-common/runtime-client"; import merge from "deepmerge"; import type { Config } from "vega-lite"; -import { generateVLAreaChartSpec } from "./area/spec"; -import { generateVLBarChartSpec } from "./bar-chart/spec"; -import { generateVLLineChartSpec } from "./line-chart/spec"; -import type { ChartDataResult } from "./selector"; -import { generateVLStackedBarChartSpec } from "./stacked-bar/default"; -import { generateVLStackedBarNormalizedSpec } from "./stacked-bar/normalized"; -import type { ChartConfig, ChartMetadata, ChartType } from "./types"; +import { CHART_CONFIG, type ChartSpec } from "./"; +import type { ChartDataResult, ChartType, FieldConfig } from "./types"; export function generateSpec( chartType: ChartType, - chartConfig: ChartConfig, + rillChartSpec: ChartSpec, data: ChartDataResult, ) { if (data.isFetching || data.error) return {}; - switch (chartType) { - case "bar_chart": - return generateVLBarChartSpec(chartConfig, data); - case "stacked_bar": - return generateVLStackedBarChartSpec(chartConfig, data); - case "stacked_bar_normalized": - return generateVLStackedBarNormalizedSpec(chartConfig, data); - case "line_chart": - return generateVLLineChartSpec(chartConfig, data); - case "area_chart": - return generateVLAreaChartSpec(chartConfig, data); - } + return CHART_CONFIG[chartType].generateSpec(rillChartSpec, data); } -export const chartMetadata: ChartMetadata[] = [ - { type: "line_chart", title: "Line", icon: LineChart }, - { type: "bar_chart", title: "Bar", icon: BarChart }, - { type: "stacked_bar", title: "Stacked Bar", icon: StackedBar }, - { - type: "stacked_bar_normalized", - title: "Stacked Bar Normalized", - icon: StackedBarFull, - }, - { type: "area_chart", title: "Stacked Area", icon: StackedArea }, -]; - export function isChartLineLike(chartType: ChartType) { return chartType === "line_chart" || chartType === "area_chart"; } -export function mergedVlConfig(config: string): Config { - const defaultConfig = getRillTheme(true); +export function mergedVlConfig( + userProvidedConfig: string | undefined, + specConfig: Config | undefined, +): Config | undefined { + if (!userProvidedConfig) return specConfig; + + const validSpecConfig = specConfig || {}; let parsedConfig: Config; try { - parsedConfig = JSON.parse(config) as Config; + parsedConfig = JSON.parse(userProvidedConfig) as Config; } catch { console.warn("Invalid JSON config"); - return defaultConfig; + return specConfig; } - const reverseArrayMerge = ( + const replaceByClonedSource = ( destinationArray: unknown[], sourceArray: unknown[], - ) => [...sourceArray, ...destinationArray]; + ) => sourceArray; - return merge(defaultConfig, parsedConfig, { arrayMerge: reverseArrayMerge }); -} - -export function getChartTitle(config: ChartConfig, data: ChartDataResult) { - const xLabel = config.x?.field - ? data.fields[config.x.field]?.displayName || config.x.field - : ""; - - const yLabel = config.y?.field - ? data.fields[config.y.field]?.displayName || config.y.field - : ""; - - const colorLabel = - typeof config.color === "object" && config.color?.field - ? data.fields[config.color.field]?.displayName || config.color.field - : ""; - - const preposition = xLabel === "Time" ? "over" : "per"; - - return colorLabel - ? `${yLabel} ${preposition} ${xLabel} split by ${colorLabel}` - : `${yLabel} ${preposition} ${xLabel}`; + return merge(validSpecConfig, parsedConfig, { + arrayMerge: replaceByClonedSource, + }); } export const timeGrainToVegaTimeUnitMap: Record = { @@ -116,3 +76,86 @@ export function sanitizeFieldName(fieldName: string) { */ return `rill_${sanitizedFieldName}`; } + +export interface FieldsByType { + measures: string[]; + dimensions: string[]; + timeDimensions: string[]; +} + +export function getFieldsByType(spec: ChartSpec): FieldsByType { + const measures: string[] = []; + const dimensions: string[] = []; + const timeDimensions: string[] = []; + + // Recursively check all properties for FieldConfig objects + const checkFields = (obj: unknown): void => { + if (!obj || typeof obj !== "object") { + return; + } + + // Check if current object is a FieldConfig with type and field + if ("type" in obj && "field" in obj && typeof obj.field === "string") { + const type = obj.type as string; + const field = obj.field; + + switch (type) { + case "quantitative": + measures.push(field); + break; + case "nominal": + dimensions.push(field); + break; + case "temporal": + timeDimensions.push(field); + break; + } + return; + } + + Object.values(obj).forEach((value) => { + if (typeof value === "object" && value !== null) { + checkFields(value); + } + }); + }; + + checkFields(spec); + return { + measures, + dimensions, + timeDimensions, + }; +} + +export function getFilterWithNullHandling( + where: V1Expression | undefined, + fieldConfig: FieldConfig | undefined, +): V1Expression | undefined { + if (!fieldConfig || fieldConfig.showNull || fieldConfig.type !== "nominal") { + return where; + } + + const excludeNullFilter = createInExpression(fieldConfig.field, [null], true); + return mergeFilters(where, excludeNullFilter); +} + +export function adjustDataForTimeZone( + data: V1MetricsViewAggregationResponseDataItem[] | undefined, + timeFields: string[], + timeGrain: V1TimeGrain, + selectedTimezone: string, +) { + if (!data) return data; + + return data.map((datum) => { + timeFields.forEach((timeField) => { + datum[timeField] = adjustOffsetForZone( + datum[timeField] as string, + selectedTimezone, + timeGrainToDuration(timeGrain), + ); + }); + return datum; + }); +} diff --git a/web-common/src/features/canvas/components/charts/validate.ts b/web-common/src/features/canvas/components/charts/validate.ts new file mode 100644 index 00000000000..9afa53ce6d1 --- /dev/null +++ b/web-common/src/features/canvas/components/charts/validate.ts @@ -0,0 +1,74 @@ +import type { ChartSpec } from "@rilldata/web-common/features/canvas/components/charts"; +import { getFieldsByType } from "@rilldata/web-common/features/canvas/components/charts/util"; +import { + validateDimensions, + validateMeasures, +} from "@rilldata/web-common/features/canvas/components/validators"; +import type { CanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; +import { derived, type Readable } from "svelte/store"; + +export function validateChartSchema( + ctx: CanvasStore, + chartSpec: ChartSpec, +): Readable<{ + isValid: boolean; + error?: string; + isLoading?: boolean; +}> { + const { metrics_view } = chartSpec; + + const { measures, dimensions, timeDimensions } = getFieldsByType(chartSpec); + + return derived( + ctx.canvasEntity.spec.getMetricsViewFromName(metrics_view), + (metricsViewQuery) => { + if (metricsViewQuery.isLoading) { + return { + isValid: true, + isLoading: true, + }; + } + const metricsView = metricsViewQuery.metricsView; + if (!metricsView) { + return { + isValid: false, + error: `Metrics view ${metrics_view} not found`, + }; + } + + const timeDimension = metricsView.timeDimension; + + if (timeDimensions.length > 0 && timeDimension !== timeDimensions[0]) { + return { + isValid: false, + error: `Invalid time dimension ${timeDimension} selected`, + }; + } + + const validateMeasuresRes = validateMeasures(metricsView, measures); + if (!validateMeasuresRes.isValid) { + const invalidMeasures = validateMeasuresRes.invalidMeasures.join(", "); + return { + isValid: false, + error: `Invalid measure ${invalidMeasures} selected`, + }; + } + + const validateDimensionsRes = validateDimensions(metricsView, dimensions); + + if (!validateDimensionsRes.isValid) { + const invalidDimensions = + validateDimensionsRes.invalidDimensions.join(", "); + + return { + isValid: false, + error: `Invalid dimension(s) ${invalidDimensions} selected`, + }; + } + return { + isValid: true, + error: undefined, + }; + }, + ); +} diff --git a/web-common/src/features/canvas/components/types.ts b/web-common/src/features/canvas/components/types.ts index d95e5f36138..ebab2168bf2 100644 --- a/web-common/src/features/canvas/components/types.ts +++ b/web-common/src/features/canvas/components/types.ts @@ -1,15 +1,17 @@ +import type { CartesianChartSpec } from "@rilldata/web-common/features/canvas/components/charts/cartesian-charts/CartesianChart"; +import type { CircularChartSpec } from "@rilldata/web-common/features/canvas/components/charts/circular-charts/CircularChart"; import type { KPIGridSpec } from "@rilldata/web-common/features/canvas/components/kpi-grid"; -import type { ChartConfig, ChartType } from "./charts/types"; +import type { ChartType } from "./charts/types"; import type { ImageSpec } from "./image"; import type { KPISpec } from "./kpi"; import type { LeaderboardSpec } from "./leaderboard"; import type { MarkdownSpec } from "./markdown"; import type { PivotSpec, TableSpec } from "./pivot"; -import type { ChartSpec } from "./charts"; // First, let's create a union type for all possible specs export type ComponentSpec = - | ChartSpec + | CartesianChartSpec + | CircularChartSpec | PivotSpec | ImageSpec | TableSpec @@ -55,15 +57,15 @@ export type CanvasComponentType = | "leaderboard"; interface LineChart { - line_chart: ChartConfig; + line_chart: CartesianChartSpec; } interface AreaChart { - area_chart: ChartConfig; + area_chart: CartesianChartSpec; } interface BarChart { - bar_chart: ChartConfig; + bar_chart: CartesianChartSpec; } export type ChartTemplates = LineChart | BarChart | AreaChart; diff --git a/web-common/src/features/canvas/components/util.ts b/web-common/src/features/canvas/components/util.ts index 00ffc4dd017..a49133f7be4 100644 --- a/web-common/src/features/canvas/components/util.ts +++ b/web-common/src/features/canvas/components/util.ts @@ -1,3 +1,5 @@ +import { getChartComponent } from "@rilldata/web-common/features/canvas/components/charts"; +import { CartesianChartComponent } from "@rilldata/web-common/features/canvas/components/charts/cartesian-charts/CartesianChart"; import { KPIGridComponent } from "@rilldata/web-common/features/canvas/components/kpi-grid"; import type { ComponentInputParam, @@ -13,7 +15,6 @@ import type { import type { QueryObserverResult } from "@tanstack/svelte-query"; import type { CanvasEntity, ComponentPath } from "../stores/canvas-entity"; import type { BaseCanvasComponent } from "./BaseCanvasComponent"; -import { ChartComponent } from "./charts"; import { ImageComponent } from "./image"; import { LeaderboardComponent } from "./leaderboard"; import { MarkdownCanvasComponent } from "./markdown"; @@ -65,6 +66,8 @@ const CHART_TYPES = [ "stacked_bar", "stacked_bar_normalized", "area_chart", + "pie_chart", + "heatmap", ] as const; const NON_CHART_TYPES = [ "markdown", @@ -80,7 +83,7 @@ const ALL_COMPONENT_TYPES = [...CHART_TYPES, ...NON_CHART_TYPES] as const; type ChartType = (typeof CHART_TYPES)[number]; type TableType = (typeof TABLE_TYPES)[number]; -interface BaseCanvasComponentConstructor< +export interface BaseCanvasComponentConstructor< T extends ComponentSpec = ComponentSpec, > { new ( @@ -89,6 +92,8 @@ interface BaseCanvasComponentConstructor< path: ComponentPath, ): BaseCanvasComponent; + chartInputParams?: Record; + newComponentSpec( metricsViewName: string, metricsViewSpec?: V1MetricsViewSpec, @@ -96,36 +101,41 @@ interface BaseCanvasComponentConstructor< } // Component type to class mapping -export const COMPONENT_CLASS_MAP: Record< - CanvasComponentType, - BaseCanvasComponentConstructor -> = { +const baseComponentMap = { markdown: MarkdownCanvasComponent, kpi_grid: KPIGridComponent, image: ImageComponent, leaderboard: LeaderboardComponent, table: PivotCanvasComponent, pivot: PivotCanvasComponent, - bar_chart: ChartComponent, - line_chart: ChartComponent, - stacked_bar: ChartComponent, - stacked_bar_normalized: ChartComponent, - area_chart: ChartComponent, +} as const; + +const chartComponentMap = Object.fromEntries( + CHART_TYPES.map((type) => [type, getChartComponent(type)]), +) as Record; + +export const COMPONENT_CLASS_MAP = { + ...baseComponentMap, + ...chartComponentMap, } as const; // Component display names mapping -const DISPLAY_MAP: Record = { +const baseDisplayMap = { kpi_grid: "KPI Grid", markdown: "Markdown", table: "Table", pivot: "Pivot", image: "Image", leaderboard: "Leaderboard", - bar_chart: "Chart", - line_chart: "Chart", - stacked_bar: "Chart", - stacked_bar_normalized: "Chart", - area_chart: "Chart", +} as const; + +const chartDisplayMap = Object.fromEntries( + CHART_TYPES.map((type) => [type, "Chart"]), +) as Record; + +const DISPLAY_MAP = { + ...baseDisplayMap, + ...chartDisplayMap, } as const; export function createComponent( @@ -139,7 +149,7 @@ export function createComponent( if (ComponentClass) { return new ComponentClass(resource, parent, path); } - return new ChartComponent(resource, parent, path); + return new CartesianChartComponent(resource, parent, path); } export function isCanvasComponentType( diff --git a/web-common/src/features/canvas/inspector/ParamMapper.svelte b/web-common/src/features/canvas/inspector/ParamMapper.svelte index 52e323c8f94..4cdc758efef 100644 --- a/web-common/src/features/canvas/inspector/ParamMapper.svelte +++ b/web-common/src/features/canvas/inspector/ParamMapper.svelte @@ -2,6 +2,9 @@ import Input from "@rilldata/web-common/components/forms/Input.svelte"; import InputLabel from "@rilldata/web-common/components/forms/InputLabel.svelte"; import Switch from "@rilldata/web-common/components/forms/Switch.svelte"; + import { BaseChart } from "@rilldata/web-common/features/canvas/components/charts/BaseChart"; + import type { BaseCanvasComponent } from "../components/BaseCanvasComponent"; + import { PivotCanvasComponent } from "../components/pivot"; import type { ComponentSpec } from "../components/types"; import AlignmentInput from "./AlignmentInput.svelte"; import ChartTypeSelector from "./chart/ChartTypeSelector.svelte"; @@ -13,9 +16,6 @@ import SingleFieldInput from "./SingleFieldInput.svelte"; import SparklineInput from "./SparklineInput.svelte"; import TableTypeSelector from "./TableTypeSelector.svelte"; - import type { BaseCanvasComponent } from "../components/BaseCanvasComponent"; - import { ChartComponent } from "../components/charts"; - import { PivotCanvasComponent } from "../components/pivot"; import type { AllKeys, ComponentInputParam } from "./types"; export let component: BaseCanvasComponent; @@ -44,7 +44,7 @@ ][]; -{#if component instanceof ChartComponent} +{#if component instanceof BaseChart} {/if} diff --git a/web-common/src/features/canvas/inspector/chart/ChartTypeSelector.svelte b/web-common/src/features/canvas/inspector/chart/ChartTypeSelector.svelte index 826d462e547..db2648debfd 100644 --- a/web-common/src/features/canvas/inspector/chart/ChartTypeSelector.svelte +++ b/web-common/src/features/canvas/inspector/chart/ChartTypeSelector.svelte @@ -3,37 +3,51 @@ import InputLabel from "@rilldata/web-common/components/forms/InputLabel.svelte"; import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte"; import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte"; - import type { ChartMetadata } from "@rilldata/web-common/features/canvas/components/charts/types"; - import { chartMetadata } from "@rilldata/web-common/features/canvas/components/charts/util"; - import type { ChartComponent } from "../../components/charts"; + import { + CHART_CONFIG, + CHART_TYPES, + type ChartSpec, + } from "@rilldata/web-common/features/canvas/components/charts"; + import type { BaseChart } from "@rilldata/web-common/features/canvas/components/charts/BaseChart"; + import type { ChartType } from "@rilldata/web-common/features/canvas/components/charts/types"; - export let component: ChartComponent; + export let component: BaseChart; - $: ({ chartType } = component); + $: ({ + parent: { + spec: { getMetricsViewFromName }, + }, + chartType, + specStore, + } = component); + + $: _metricViewSpec = getMetricsViewFromName($specStore.metrics_view); + $: metricsViewSpec = $_metricViewSpec.metricsView; $: type = $chartType; - function selectChartType(chartType: ChartMetadata) { - component.updateChartType(chartType.type); + function selectChartType(chartType: ChartType) { + component.updateChartType(chartType, metricsViewSpec); }
- {#each chartMetadata as chart, i (i)} + {#each CHART_TYPES as chart, i (i)} - {chart.title} + {CHART_CONFIG[chart].title} {/each} diff --git a/web-common/src/features/canvas/inspector/chart/FieldConfigDropdown.svelte b/web-common/src/features/canvas/inspector/chart/FieldConfigDropdown.svelte index c4909396ca9..3a94b5cd508 100644 --- a/web-common/src/features/canvas/inspector/chart/FieldConfigDropdown.svelte +++ b/web-common/src/features/canvas/inspector/chart/FieldConfigDropdown.svelte @@ -9,15 +9,19 @@ ChartSortDirection, FieldConfig, } from "@rilldata/web-common/features/canvas/components/charts/types"; + import type { ChartFieldInput } from "@rilldata/web-common/features/canvas/inspector/types"; - export let key: string; export let fieldConfig: FieldConfig; export let onChange: (property: keyof FieldConfig, value: any) => void; + export let chartFieldInput: ChartFieldInput | undefined = undefined; + export let label: string; - $: isDimension = key === "x"; - $: isTemporal = fieldConfig?.type === "temporal"; + $: isDimension = fieldConfig?.type === "nominal"; + $: isMeasure = fieldConfig?.type === "quantitative"; let limit = fieldConfig?.limit || 5000; + let labelAngle = + fieldConfig?.labelAngle ?? (fieldConfig?.type === "temporal" ? 0 : -90); let isDropdownOpen = false; const sortOptions: { label: string; value: ChartSortDirection }[] = [ @@ -26,6 +30,13 @@ { label: "Y-axis ascending", value: "y" }, { label: "Y-axis descending", value: "-y" }, ]; + + $: showAxisTitle = chartFieldInput?.axisTitleSelector ?? false; + $: showOrigin = chartFieldInput?.originSelector ?? false; + $: showSort = chartFieldInput?.sortSelector ?? false; + $: showLimit = chartFieldInput?.limitSelector ?? false; + $: showNull = chartFieldInput?.nullSelector ?? false; + $: showLabelAngle = chartFieldInput?.labelAngleSelector ?? false; @@ -36,68 +47,92 @@
- {isDimension ? "X-axis" : "Y-axis"} Configuration + {label} Configuration
-
- Show axis title - { - onChange("showAxisTitle", !fieldConfig?.showAxisTitle); - }} - /> -
- {#if isDimension && !isTemporal} + {#if showAxisTitle}
- Show null values + Show axis title { - onChange("showNull", !fieldConfig?.showNull); + onChange("showAxisTitle", !fieldConfig?.showAxisTitle); }} />
+ {/if} + {#if isDimension} + {#if showNull} +
+ Show null values + { + onChange("showNull", !fieldConfig?.showNull); + }} + /> +
+ {/if} + {#if showSort} +
+ Sort + { + onChange("limit", limit); + }} + onEnter={() => { + onChange("limit", limit); + }} + /> +
+ {/if} + {/if} + {#if isMeasure && showOrigin}
- Sort - { - onChange("limit", limit); + onChange("labelAngle", labelAngle); }} onEnter={() => { - onChange("limit", limit); - }} - /> -
- {/if} - {#if !isDimension} -
- Zero based origin - { - onChange("zeroBasedOrigin", !fieldConfig?.zeroBasedOrigin); + onChange("labelAngle", labelAngle); }} />
diff --git a/web-common/src/features/canvas/inspector/chart/PositionalFieldConfig.svelte b/web-common/src/features/canvas/inspector/chart/PositionalFieldConfig.svelte index 7559d2ee5b2..a84db93b8fb 100644 --- a/web-common/src/features/canvas/inspector/chart/PositionalFieldConfig.svelte +++ b/web-common/src/features/canvas/inspector/chart/PositionalFieldConfig.svelte @@ -2,11 +2,12 @@ import InputLabel from "@rilldata/web-common/components/forms/InputLabel.svelte"; import type { FieldConfig } from "@rilldata/web-common/features/canvas/components/charts/types"; import SingleFieldInput from "@rilldata/web-common/features/canvas/inspector/SingleFieldInput.svelte"; + import type { ComponentInputParam } from "@rilldata/web-common/features/canvas/inspector/types"; import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; import FieldConfigDropdown from "./FieldConfigDropdown.svelte"; export let key: string; - export let config: { label?: string }; + export let config: ComponentInputParam; export let metricsView: string; export let fieldConfig: FieldConfig; export let canvasName: string; @@ -19,7 +20,10 @@ }, } = getCanvasStore(canvasName)); - $: isDimension = key === "x"; + $: chartFieldInput = config.meta?.chartFieldInput; + + $: isDimension = chartFieldInput?.type === "dimension"; + $: timeDimension = getTimeDimensionForMetricView(metricsView); function updateFieldConfig(fieldName: string) { @@ -56,7 +60,16 @@
- + {#if Object.keys(chartFieldInput ?? {}).length > 1} + {#key fieldConfig} + + {/key} + {/if}
{ updateFieldConfig(field); diff --git a/web-common/src/features/canvas/inspector/types.ts b/web-common/src/features/canvas/inspector/types.ts index abfdbae16ba..35e7e512b29 100644 --- a/web-common/src/features/canvas/inspector/types.ts +++ b/web-common/src/features/canvas/inspector/types.ts @@ -17,6 +17,17 @@ export type FilterInputTypes = "time_filters" | "dimension_filters"; export type FieldType = "measure" | "dimension" | "time"; +export type ChartFieldInput = { + type: FieldType; + axisTitleSelector?: boolean; + hideTimeDimension?: boolean; + originSelector?: boolean; + sortSelector?: boolean; + limitSelector?: boolean; + nullSelector?: boolean; + labelAngleSelector?: boolean; +}; + export interface ComponentInputParam { type: InputType; label?: string; @@ -26,6 +37,7 @@ export interface ComponentInputParam { meta?: { allowedTypes?: FieldType[]; // Specify which field types are allowed for multi-field selection defaultAlignment?: ComponentAlignment; + chartFieldInput?: ChartFieldInput; [key: string]: any; }; } diff --git a/web-common/src/features/canvas/layout-util.ts b/web-common/src/features/canvas/layout-util.ts index a1b83d7570c..56df8876ab7 100644 --- a/web-common/src/features/canvas/layout-util.ts +++ b/web-common/src/features/canvas/layout-util.ts @@ -12,12 +12,15 @@ import { ResourceKind } from "../entity-management/resource-selectors"; import type { CanvasComponentType } from "./components/types"; import { COMPONENT_CLASS_MAP } from "./components/util"; +// TODO: Move this individual component class export const initialHeights: Record = { line_chart: 320, bar_chart: 320, area_chart: 320, stacked_bar: 320, stacked_bar_normalized: 320, + pie_chart: 320, + heatmap: 320, markdown: 40, kpi_grid: 128, image: 80, diff --git a/web-common/src/features/canvas/stores/canvas-entity.ts b/web-common/src/features/canvas/stores/canvas-entity.ts index e30292d7604..4c9e3304fb5 100644 --- a/web-common/src/features/canvas/stores/canvas-entity.ts +++ b/web-common/src/features/canvas/stores/canvas-entity.ts @@ -17,22 +17,22 @@ import { type Readable, type Unsubscriber, } from "svelte/store"; -import { Filters } from "./filters"; -import { CanvasResolvedSpec } from "./spec"; -import { TimeControls } from "./time-control"; +import { parseDocument } from "yaml"; +import type { FileArtifact } from "../../entity-management/file-artifact"; +import { fileArtifacts } from "../../entity-management/file-artifacts"; +import { ResourceKind } from "../../entity-management/resource-selectors"; import type { BaseCanvasComponent } from "../components/BaseCanvasComponent"; +import type { CanvasComponentType, ComponentSpec } from "../components/types"; import { COMPONENT_CLASS_MAP, createComponent, isChartComponentType, isTableComponentType, } from "../components/util"; -import type { FileArtifact } from "../../entity-management/file-artifact"; -import { parseDocument } from "yaml"; -import { fileArtifacts } from "../../entity-management/file-artifacts"; -import type { CanvasComponentType } from "../components/types"; -import { ResourceKind } from "../../entity-management/resource-selectors"; +import { Filters } from "./filters"; import { Grid } from "./grid"; +import { CanvasResolvedSpec } from "./spec"; +import { TimeControls } from "./time-control"; export class CanvasEntity { name: string; @@ -225,13 +225,16 @@ export class CanvasEntity { column: number; metricsViewName: string; metricsViewSpec: V1MetricsViewSpec | undefined; + spec?: ComponentSpec; }): V1Resource => { const { type, row, column, metricsViewName, metricsViewSpec } = options; - const spec = COMPONENT_CLASS_MAP[type].newComponentSpec( - metricsViewName, - metricsViewSpec, - ); + const spec = + options.spec ?? + COMPONENT_CLASS_MAP[type].newComponentSpec( + metricsViewName, + metricsViewSpec, + ); return { meta: { @@ -286,9 +289,28 @@ function areSameType( newType: CanvasComponentType, existingType: CanvasComponentType, ) { - return ( - newType === existingType || - (isTableComponentType(existingType) && isTableComponentType(newType)) || - (isChartComponentType(existingType) && isChartComponentType(newType)) - ); + if (newType === existingType) return true; + + // For chart types, check if they use the same component class + if (isChartComponentType(existingType) && isChartComponentType(newType)) { + const cartesian = [ + "bar_chart", + "line_chart", + "area_chart", + "stacked_bar", + "stacked_bar_normalized", + ]; + + if (cartesian.includes(existingType) && cartesian.includes(newType)) { + return true; + } + return false; + + // FIXME: The below causes a fatal crash through a dependency cycle + // const newComponent = CHART_CONFIG[newType].component; + // const existingComponent = CHART_CONFIG[existingType].component; + // return newComponent.name === existingComponent.name; + } + + return isTableComponentType(existingType) && isTableComponentType(newType); } diff --git a/web-common/src/features/workspaces/CanvasWorkspace.svelte b/web-common/src/features/workspaces/CanvasWorkspace.svelte index 607f3021152..c2051d5c294 100644 --- a/web-common/src/features/workspaces/CanvasWorkspace.svelte +++ b/web-common/src/features/workspaces/CanvasWorkspace.svelte @@ -6,6 +6,7 @@ import VisualCanvasEditing from "@rilldata/web-common/features/canvas/inspector/VisualCanvasEditing.svelte"; import { getNameFromFile } from "@rilldata/web-common/features/entity-management/entity-mappers"; import type { FileArtifact } from "@rilldata/web-common/features/entity-management/file-artifact"; + import { getCanvasStore } from "@rilldata/web-common/features/canvas/state-managers/state-managers"; import { resourceIsLoading, ResourceKind, @@ -41,6 +42,10 @@ hasUnsavedChanges, } = fileArtifact); + $: ({ + canvasEntity: { _rows }, + } = getCanvasStore(canvasName)); + $: resourceQuery = getResource(queryClient, instanceId); $: ({ data } = $resourceQuery); @@ -135,12 +140,15 @@ {/if} - + + {#key $_rows} + + {/key} + {/key} diff --git a/web-local/tests/canvas/charts.spec.ts b/web-local/tests/canvas/charts.spec.ts new file mode 100644 index 00000000000..ab5b131d5b9 --- /dev/null +++ b/web-local/tests/canvas/charts.spec.ts @@ -0,0 +1,26 @@ +import { gotoNavEntry } from "web-local/tests/utils/waitHelpers"; +import { test } from "../setup/base"; + +test.describe("canvas charts", () => { + test.use({ project: "AdBids" }); + + test("switch between charts", async ({ page }) => { + await page.getByLabel("/dashboards").click(); + await gotoNavEntry(page, "/dashboards/AdBids_metrics_canvas.yaml"); + + await page.locator("#AdBids_metrics_canvas--component-1-0 canvas").click(); + + await page.locator(".chart-icons").getByLabel("Heatmap").click(); + + await page + .getByLabel("A rect chart with embedded") + .locator("canvas") + .click(); + + await page.locator(".chart-icons").getByLabel("Pie").click(); + await page + .getByLabel("A arc chart with embedded") + .locator("canvas") + .click(); + }); +});