-
-
-
-
-
+ @if (!loading()) {
+
+
+
+ Query name
+
+
+
+
+ Description (optional)
+
+
-
-
+
+
-
-
-
- Chart type
-
-
- {{ type.label }}
-
-
-
-
-
- Label column
-
-
- {{ col }}
-
-
-
-
-
- Label type
-
-
- {{ type.label }}
-
-
-
-
-
- Value column
-
-
- {{ col }}
-
-
-
+
+
+
-
-
-
-
-
+ @if (showResults() && testResults().length > 0) {
+
+
+
+
+ @for (column of resultColumns(); track column) {
+
+ | {{ column }} |
+ {{ row[column] }} |
+
+ }
- 0">
-
Select label and value columns to display the chart
+
|
+
|
+
-
-
-
-
0">
-
-
-
-
- | {{ column }} |
- {{ row[column] }} |
-
-
-
-
-
-
-
+ }
-
-
info
-
Query executed successfully but returned no results.
+ @if (showResults() && testResults().length === 0) {
+
+
info
+
Query executed successfully but returned no results.
+
+ }
-
-
+ }
-
-
Back
-
-
+ @if (!loading()) {
+
+
Back
+
+
+ }
diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts
index 7b200ebfa..9e24c7cdf 100644
--- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts
+++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts
@@ -9,7 +9,7 @@ import { RouterTestingModule } from '@angular/router/testing';
import { CodeEditorModule } from '@ngstack/code-editor';
import { Angulartics2Module } from 'angulartics2';
import { of } from 'rxjs';
-import { ChartType, SavedQuery } from 'src/app/models/saved-query';
+import { ChartSeriesConfig, ChartType, SavedQuery } from 'src/app/models/saved-query';
import { ConnectionsService } from 'src/app/services/connections.service';
import { SavedQueriesService } from 'src/app/services/saved-queries.service';
import { UiSettingsService } from 'src/app/services/ui-settings.service';
@@ -27,7 +27,7 @@ type ChartEditComponentTestable = ChartEditComponent & {
resultColumns: WritableSignal
;
executionTime: WritableSignal;
labelColumn: WritableSignal;
- valueColumn: WritableSignal;
+ seriesList: WritableSignal;
chartType: WritableSignal;
canSave: Signal;
canTest: Signal;
@@ -212,12 +212,12 @@ describe('ChartEditComponent', () => {
expect(testable.executionTime()).toBe(50);
});
- it('should auto-select label and value columns', () => {
+ it('should auto-select label column and first series', () => {
const testable = component as ChartEditComponentTestable;
testable.queryText.set('SELECT * FROM users');
component.testQuery();
expect(testable.labelColumn()).toBe('name');
- expect(testable.valueColumn()).toBe('count');
+ expect(testable.seriesList()).toEqual([{ value_column: 'count' }]);
});
});
@@ -234,7 +234,7 @@ describe('ChartEditComponent', () => {
query_text: 'SELECT 1',
widget_type: 'chart',
chart_type: 'bar',
- widget_options: { label_type: 'values' },
+ widget_options: { label_column: '', label_type: 'values' },
});
});
@@ -262,19 +262,19 @@ describe('ChartEditComponent', () => {
expect(testable.hasChartData()).toBe(false);
});
- it('should return false when no columns selected', () => {
+ it('should return false when no series configured', () => {
const testable = component as ChartEditComponentTestable;
testable.testResults.set([{ name: 'John' }]);
- testable.labelColumn.set('');
- testable.valueColumn.set('');
+ testable.labelColumn.set('name');
+ testable.seriesList.set([]);
expect(testable.hasChartData()).toBe(false);
});
- it('should return true when results and columns are set', () => {
+ it('should return true when results, label and series are set', () => {
const testable = component as ChartEditComponentTestable;
testable.testResults.set([{ name: 'John', count: 10 }]);
testable.labelColumn.set('name');
- testable.valueColumn.set('count');
+ testable.seriesList.set([{ value_column: 'count' }]);
expect(testable.hasChartData()).toBe(true);
});
});
diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts
index a6d5538ae..8d591f034 100644
--- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts
+++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts
@@ -3,6 +3,8 @@ import { Component, computed, effect, inject, OnInit, signal } from '@angular/co
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
@@ -16,7 +18,17 @@ import { CodeEditorModule } from '@ngstack/code-editor';
import { Angulartics2 } from 'angulartics2';
import posthog from 'posthog-js';
import { finalize } from 'rxjs/operators';
-import { ChartType, TestQueryResult } from 'src/app/models/saved-query';
+import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper';
+import {
+ ChartAxisConfig,
+ ChartLegendConfig,
+ ChartNumberFormatConfig,
+ ChartSeriesConfig,
+ ChartType,
+ ChartUnitConfig,
+ ChartWidgetOptions,
+ TestQueryResult,
+} from 'src/app/models/saved-query';
import { ConnectionsService } from 'src/app/services/connections.service';
import { SavedQueriesService } from 'src/app/services/saved-queries.service';
import { UiSettingsService } from 'src/app/services/ui-settings.service';
@@ -33,6 +45,8 @@ import { ChartPreviewComponent } from '../chart-preview/chart-preview.component'
FormsModule,
RouterModule,
MatButtonModule,
+ MatCheckboxModule,
+ MatExpansionModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
@@ -63,11 +77,54 @@ export class ChartEditComponent implements OnInit {
protected executionTime = signal(null);
protected showResults = signal(false);
+ // Basic chart config
protected chartType = signal('bar');
protected labelColumn = signal('');
- protected valueColumn = signal('');
protected labelType = signal<'values' | 'datetime'>('values');
+ // Series config
+ protected seriesMode = signal<'manual' | 'column'>('manual');
+ protected seriesList = signal([]);
+ protected seriesColumn = signal('');
+ protected seriesValueColumn = signal('');
+
+ // Display options
+ protected stacked = signal(false);
+ protected horizontal = signal(false);
+ protected showDataLabels = signal(false);
+
+ // Legend
+ protected legendShow = signal(true);
+ protected legendPosition = signal<'top' | 'bottom' | 'left' | 'right'>('top');
+
+ // Units
+ protected unitMode = signal<'none' | 'custom' | 'convert'>('none');
+ protected unitsText = signal('');
+ protected unitsPosition = signal<'prefix' | 'suffix'>('suffix');
+ protected convertUnit = signal('');
+
+ // Number format
+ protected decimalPlaces = signal(null);
+ protected thousandsSeparator = signal(true);
+ protected compact = signal(false);
+
+ // Y-axis
+ protected yAxisTitle = signal('');
+ protected yAxisMin = signal(null);
+ protected yAxisMax = signal(null);
+ protected yAxisBeginAtZero = signal(true);
+ protected yAxisScaleType = signal<'linear' | 'logarithmic'>('linear');
+
+ // X-axis
+ protected xAxisTitle = signal('');
+
+ // Sort & limit
+ protected sortBy = signal<'label_asc' | 'label_desc' | 'value_asc' | 'value_desc' | 'none'>('none');
+ protected limit = signal(null);
+
+ // Color palette
+ protected colorPalette = signal([]);
+
public chartTypes: { value: ChartType; label: string }[] = [
{ value: 'bar', label: 'Bar Chart' },
{ value: 'line', label: 'Line Chart' },
@@ -81,10 +138,138 @@ export class ChartEditComponent implements OnInit {
{ value: 'datetime', label: 'Datetime' },
];
+ public legendPositions: { value: string; label: string }[] = [
+ { value: 'top', label: 'Top' },
+ { value: 'bottom', label: 'Bottom' },
+ { value: 'left', label: 'Left' },
+ { value: 'right', label: 'Right' },
+ ];
+
+ public sortOptions: { value: string; label: string }[] = [
+ { value: 'none', label: 'None' },
+ { value: 'label_asc', label: 'Label (A-Z)' },
+ { value: 'label_desc', label: 'Label (Z-A)' },
+ { value: 'value_asc', label: 'Value (Low-High)' },
+ { value: 'value_desc', label: 'Value (High-Low)' },
+ ];
+
+ public scaleTypes: { value: string; label: string }[] = [
+ { value: 'linear', label: 'Linear' },
+ { value: 'logarithmic', label: 'Logarithmic' },
+ ];
+
+ public pointStyles: { value: string; label: string }[] = [
+ { value: 'circle', label: 'Circle' },
+ { value: 'rect', label: 'Rectangle' },
+ { value: 'triangle', label: 'Triangle' },
+ { value: 'cross', label: 'Cross' },
+ { value: 'none', label: 'None' },
+ ];
+
+ public unitModes: { value: string; label: string }[] = [
+ { value: 'none', label: 'None' },
+ { value: 'custom', label: 'Custom text' },
+ { value: 'convert', label: 'Auto-convert' },
+ ];
+
+ public convertUnitPresets: { group: string; units: { value: string; label: string }[] }[] = [
+ {
+ group: 'Time',
+ units: [
+ { value: 'ms', label: 'Milliseconds (ms)' },
+ { value: 's', label: 'Seconds (s)' },
+ { value: 'min', label: 'Minutes (min)' },
+ { value: 'h', label: 'Hours (h)' },
+ { value: 'd', label: 'Days (d)' },
+ ],
+ },
+ {
+ group: 'Data',
+ units: [
+ { value: 'B', label: 'Bytes (B)' },
+ { value: 'KB', label: 'Kilobytes (KB)' },
+ { value: 'MB', label: 'Megabytes (MB)' },
+ { value: 'GB', label: 'Gigabytes (GB)' },
+ { value: 'TB', label: 'Terabytes (TB)' },
+ ],
+ },
+ {
+ group: 'Length',
+ units: [
+ { value: 'mm', label: 'Millimeters (mm)' },
+ { value: 'cm', label: 'Centimeters (cm)' },
+ { value: 'm', label: 'Meters (m)' },
+ { value: 'km', label: 'Kilometers (km)' },
+ { value: 'in', label: 'Inches (in)' },
+ { value: 'ft', label: 'Feet (ft)' },
+ { value: 'mi', label: 'Miles (mi)' },
+ ],
+ },
+ {
+ group: 'Mass',
+ units: [
+ { value: 'mg', label: 'Milligrams (mg)' },
+ { value: 'g', label: 'Grams (g)' },
+ { value: 'kg', label: 'Kilograms (kg)' },
+ { value: 'oz', label: 'Ounces (oz)' },
+ { value: 'lb', label: 'Pounds (lb)' },
+ ],
+ },
+ {
+ group: 'Temperature',
+ units: [
+ { value: 'C', label: 'Celsius (C)' },
+ { value: 'F', label: 'Fahrenheit (F)' },
+ { value: 'K', label: 'Kelvin (K)' },
+ ],
+ },
+ {
+ group: 'Frequency',
+ units: [
+ { value: 'Hz', label: 'Hertz (Hz)' },
+ { value: 'kHz', label: 'Kilohertz (kHz)' },
+ { value: 'MHz', label: 'Megahertz (MHz)' },
+ { value: 'GHz', label: 'Gigahertz (GHz)' },
+ ],
+ },
+ {
+ group: 'Power',
+ units: [
+ { value: 'W', label: 'Watts (W)' },
+ { value: 'kW', label: 'Kilowatts (kW)' },
+ { value: 'MW', label: 'Megawatts (MW)' },
+ ],
+ },
+ {
+ group: 'Energy',
+ units: [
+ { value: 'J', label: 'Joules (J)' },
+ { value: 'Wh', label: 'Watt-hours (Wh)' },
+ { value: 'kWh', label: 'Kilowatt-hours (kWh)' },
+ ],
+ },
+ {
+ group: 'Pressure',
+ units: [
+ { value: 'Pa', label: 'Pascals (Pa)' },
+ { value: 'bar', label: 'Bar' },
+ { value: 'psi', label: 'PSI' },
+ { value: 'atm', label: 'Atmospheres (atm)' },
+ ],
+ },
+ {
+ group: 'Volume',
+ units: [
+ { value: 'mL', label: 'Milliliters (mL)' },
+ { value: 'L', label: 'Liters (L)' },
+ { value: 'gal', label: 'Gallons (gal)' },
+ ],
+ },
+ ];
+
protected showLabelTypeOption = computed(() => ['bar', 'line'].includes(this.chartType()));
+ protected isPieType = computed(() => ['pie', 'doughnut', 'polarArea'].includes(this.chartType()));
- // Use a signal for codeModel to ensure change detection works on load
- // Only update this signal when loading a query, not during typing (to preserve cursor position)
protected codeModel = signal({
language: 'sql',
uri: 'query.sql',
@@ -101,12 +286,76 @@ export class ChartEditComponent implements OnInit {
public codeEditorTheme = 'vs-dark';
protected canSave = computed(() => !!this.queryName().trim() && !!this.queryText().trim() && !this.saving());
-
protected canTest = computed(() => !!this.queryText().trim() && !this.testing());
- protected hasChartData = computed(
- () => this.testResults().length > 0 && !!this.labelColumn() && !!this.valueColumn(),
- );
+ protected hasChartData = computed(() => {
+ const hasLabel = !!this.labelColumn();
+ if (this.seriesMode() === 'column') {
+ return this.testResults().length > 0 && hasLabel && !!this.seriesColumn() && !!this.seriesValueColumn();
+ }
+ const hasSeries = this.seriesList().length > 0 && this.seriesList().some((s) => !!s.value_column);
+ return this.testResults().length > 0 && hasLabel && hasSeries;
+ });
+
+ protected currentWidgetOptions = computed(() => {
+ const options: ChartWidgetOptions = {
+ label_column: this.labelColumn(),
+ label_type: this.labelType(),
+ };
+
+ if (this.seriesMode() === 'column' && this.seriesColumn()) {
+ options.series_column = this.seriesColumn();
+ options.value_column = this.seriesValueColumn();
+ } else {
+ const series = this.seriesList();
+ if (series.length > 0) {
+ options.series = series;
+ }
+ }
+
+ if (this.stacked()) options.stacked = true;
+ if (this.horizontal()) options.horizontal = true;
+ if (this.showDataLabels()) options.show_data_labels = true;
+
+ if (!this.legendShow() || this.legendPosition() !== 'top') {
+ options.legend = {
+ show: this.legendShow(),
+ position: this.legendPosition(),
+ };
+ }
+
+ if (this.unitMode() === 'custom' && this.unitsText()) {
+ options.units = { text: this.unitsText(), position: this.unitsPosition() };
+ } else if (this.unitMode() === 'convert' && this.convertUnit()) {
+ options.units = { convert_unit: this.convertUnit() };
+ }
+
+ const numberFormat: ChartNumberFormatConfig = {};
+ if (this.decimalPlaces() !== null) numberFormat.decimal_places = this.decimalPlaces()!;
+ if (!this.thousandsSeparator()) numberFormat.thousands_separator = false;
+ if (this.compact()) numberFormat.compact = true;
+ if (Object.keys(numberFormat).length > 0) options.number_format = numberFormat;
+
+ const yAxis: ChartAxisConfig = {};
+ if (this.yAxisTitle()) yAxis.title = this.yAxisTitle();
+ if (this.yAxisMin() !== null) yAxis.min = this.yAxisMin()!;
+ if (this.yAxisMax() !== null) yAxis.max = this.yAxisMax()!;
+ if (!this.yAxisBeginAtZero()) yAxis.begin_at_zero = false;
+ if (this.yAxisScaleType() !== 'linear') yAxis.scale_type = this.yAxisScaleType();
+ if (Object.keys(yAxis).length > 0) options.y_axis = yAxis;
+
+ const xAxis: ChartAxisConfig = {};
+ if (this.xAxisTitle()) xAxis.title = this.xAxisTitle();
+ if (Object.keys(xAxis).length > 0) options.x_axis = xAxis;
+
+ if (this.sortBy() !== 'none') options.sort_by = this.sortBy();
+ if (this.limit() !== null && this.limit()! > 0) options.limit = this.limit()!;
+
+ const palette = this.colorPalette();
+ if (palette.length > 0) options.color_palette = palette;
+
+ return options;
+ });
private _savedQueries = inject(SavedQueriesService);
private _connections = inject(ConnectionsService);
@@ -119,7 +368,6 @@ export class ChartEditComponent implements OnInit {
private connectionTitle = toSignal(this._connections.getCurrentConnectionTitle(), { initialValue: '' });
constructor() {
- // Page title effect
effect(() => {
const title = this.connectionTitle();
const pageTitle = this.isEditMode() ? 'Edit Query' : 'Create Query';
@@ -151,24 +399,88 @@ export class ChartEditComponent implements OnInit {
this.queryName.set(query.name);
this.queryDescription.set(query.description || '');
this.queryText.set(query.query_text);
- // Load chart configuration
+
if (query.chart_type) {
this.chartType.set(query.chart_type);
}
+
if (query.widget_options) {
- if (query.widget_options['label_column']) {
- this.labelColumn.set(query.widget_options['label_column'] as string);
+ const opts = query.widget_options as Partial;
+
+ if (opts.label_column) this.labelColumn.set(opts.label_column);
+ if (opts.label_type) this.labelType.set(opts.label_type);
+
+ // Load series
+ if (opts.series_column) {
+ this.seriesMode.set('column');
+ this.seriesColumn.set(opts.series_column);
+ if (opts.value_column) this.seriesValueColumn.set(opts.value_column);
+ } else if (opts.series?.length) {
+ this.seriesList.set([...opts.series]);
+ } else if (opts.value_column) {
+ // Legacy: convert single value_column to series
+ this.seriesList.set([{ value_column: opts.value_column }]);
+ }
+
+ // Display options
+ if (opts.stacked) this.stacked.set(true);
+ if (opts.horizontal) this.horizontal.set(true);
+ if (opts.show_data_labels) this.showDataLabels.set(true);
+
+ // Legend
+ if (opts.legend) {
+ if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show);
+ if (opts.legend.position) this.legendPosition.set(opts.legend.position);
+ }
+
+ // Units
+ if (opts.units) {
+ if (opts.units.convert_unit) {
+ this.unitMode.set('convert');
+ this.convertUnit.set(opts.units.convert_unit);
+ } else if (opts.units.text) {
+ this.unitMode.set('custom');
+ this.unitsText.set(opts.units.text);
+ if (opts.units.position) this.unitsPosition.set(opts.units.position);
+ }
}
- if (query.widget_options['value_column']) {
- this.valueColumn.set(query.widget_options['value_column'] as string);
+
+ // Number format
+ if (opts.number_format) {
+ if (opts.number_format.decimal_places !== undefined) {
+ this.decimalPlaces.set(opts.number_format.decimal_places);
+ }
+ if (opts.number_format.thousands_separator !== undefined) {
+ this.thousandsSeparator.set(opts.number_format.thousands_separator);
+ }
+ if (opts.number_format.compact) this.compact.set(true);
+ }
+
+ // Y-axis
+ if (opts.y_axis) {
+ if (opts.y_axis.title) this.yAxisTitle.set(opts.y_axis.title);
+ if (opts.y_axis.min !== undefined) this.yAxisMin.set(opts.y_axis.min);
+ if (opts.y_axis.max !== undefined) this.yAxisMax.set(opts.y_axis.max);
+ if (opts.y_axis.begin_at_zero !== undefined) this.yAxisBeginAtZero.set(opts.y_axis.begin_at_zero);
+ if (opts.y_axis.scale_type) this.yAxisScaleType.set(opts.y_axis.scale_type);
+ }
+
+ // X-axis
+ if (opts.x_axis) {
+ if (opts.x_axis.title) this.xAxisTitle.set(opts.x_axis.title);
}
- if (query.widget_options['label_type']) {
- this.labelType.set(query.widget_options['label_type'] as 'values' | 'datetime');
+
+ // Sort & limit
+ if (opts.sort_by) this.sortBy.set(opts.sort_by);
+ if (opts.limit) this.limit.set(opts.limit);
+
+ // Color palette
+ if (opts.color_palette?.length) {
+ this.colorPalette.set([...opts.color_palette]);
}
}
- // Set codeModel value for Monaco editor (only on load, not during typing)
+
this.codeModel.set({ language: 'sql', uri: 'query.sql', value: query.query_text });
- // Automatically test the query to show chart preview
this.testQuery();
}
});
@@ -198,8 +510,9 @@ export class ChartEditComponent implements OnInit {
if (this.resultColumns().length > 0 && !this.labelColumn()) {
this.labelColumn.set(this.resultColumns()[0]);
}
- if (this.resultColumns().length > 1 && !this.valueColumn()) {
- this.valueColumn.set(this.resultColumns()[1]);
+ // Auto-add first series if none exist
+ if (this.seriesList().length === 0 && this.resultColumns().length > 1) {
+ this.seriesList.set([{ value_column: this.resultColumns()[1] }]);
}
}
});
@@ -210,6 +523,56 @@ export class ChartEditComponent implements OnInit {
posthog.capture('Charts: test query executed');
}
+ addSeries(): void {
+ const cols = this.resultColumns();
+ const usedCols = new Set(this.seriesList().map((s) => s.value_column));
+ const nextCol = cols.find((c) => c !== this.labelColumn() && !usedCols.has(c)) || cols[0] || '';
+ this.seriesList.update((list) => [...list, { value_column: nextCol }]);
+ }
+
+ removeSeries(index: number): void {
+ this.seriesList.update((list) => list.filter((_, i) => i !== index));
+ }
+
+ updateSeries(index: number, field: keyof ChartSeriesConfig, value: unknown): void {
+ this.seriesList.update((list) => {
+ const updated = [...list];
+ updated[index] = { ...updated[index], [field]: value };
+ return updated;
+ });
+ }
+
+ toHex(color: string | undefined): string {
+ if (!color) return '#6366f1';
+ if (color.startsWith('#')) return color.substring(0, 7);
+ const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ if (!match) return '#000000';
+ const r = parseInt(match[1]).toString(16).padStart(2, '0');
+ const g = parseInt(match[2]).toString(16).padStart(2, '0');
+ const b = parseInt(match[3]).toString(16).padStart(2, '0');
+ return `#${r}${g}${b}`;
+ }
+
+ addPaletteColor(): void {
+ this.colorPalette.update((list) => [...list, '#6366f1']);
+ }
+
+ removePaletteColor(index: number): void {
+ this.colorPalette.update((list) => list.filter((_, i) => i !== index));
+ }
+
+ updatePaletteColor(index: number, value: string): void {
+ this.colorPalette.update((list) => {
+ const updated = [...list];
+ updated[index] = value;
+ return updated;
+ });
+ }
+
+ initializeDefaultPalette(): void {
+ this.colorPalette.set([...DEFAULT_COLOR_PALETTE]);
+ }
+
saveQuery(): void {
if (!this.queryName().trim() || !this.queryText().trim()) {
return;
@@ -217,17 +580,7 @@ export class ChartEditComponent implements OnInit {
this.saving.set(true);
- // Build widget_options with column selections
- const widgetOptions: Record = {};
- if (this.labelColumn()) {
- widgetOptions['label_column'] = this.labelColumn();
- }
- if (this.valueColumn()) {
- widgetOptions['value_column'] = this.valueColumn();
- }
- if (this.labelType() && this.showLabelTypeOption()) {
- widgetOptions['label_type'] = this.labelType();
- }
+ const widgetOptions = this.currentWidgetOptions();
const payload = {
name: this.queryName(),
@@ -235,7 +588,7 @@ export class ChartEditComponent implements OnInit {
query_text: this.queryText(),
widget_type: 'chart' as const,
chart_type: this.chartType(),
- widget_options: Object.keys(widgetOptions).length > 0 ? widgetOptions : undefined,
+ widget_options: widgetOptions as unknown as Record,
};
if (this.isEditMode()) {
@@ -262,5 +615,4 @@ export class ChartEditComponent implements OnInit {
posthog.capture('Charts: saved query created');
}
}
-
}
diff --git a/frontend/src/app/components/charts/chart-preview/chart-preview.component.html b/frontend/src/app/components/charts/chart-preview/chart-preview.component.html
index adf13ac54..0f21f88fa 100644
--- a/frontend/src/app/components/charts/chart-preview/chart-preview.component.html
+++ b/frontend/src/app/components/charts/chart-preview/chart-preview.component.html
@@ -1,11 +1,14 @@
-
-
-
-
-
-
No chart data available
-
+@if (chartData) {
+
+
+
+} @else {
+
+
No chart data available
+
+}
diff --git a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts b/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts
index f7d6e3443..a78d60397 100644
--- a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts
+++ b/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts
@@ -1,9 +1,15 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { ChartConfiguration, ChartData, ChartType as ChartJsType } from 'chart.js';
+import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js';
import 'chartjs-adapter-date-fns';
import { BaseChartDirective } from 'ng2-charts';
-import { ChartType } from 'src/app/models/saved-query';
+import {
+ buildChartData,
+ buildChartOptions,
+ buildDataLabelsPlugin,
+ getMappedChartType,
+} from 'src/app/lib/chart-config.helper';
+import { ChartType, ChartWidgetOptions } from 'src/app/models/saved-query';
@Component({
selector: 'app-chart-preview',
@@ -14,56 +20,70 @@ import { ChartType } from 'src/app/models/saved-query';
export class ChartPreviewComponent implements OnChanges {
@Input() chartType: ChartType = 'bar';
@Input() data: Record[] = [];
+ @Input() widgetOptions: ChartWidgetOptions | null = null;
+
+ // Legacy inputs for backward compatibility
@Input() labelColumn = '';
@Input() valueColumn = '';
@Input() labelType: 'values' | 'datetime' = 'values';
public chartData: ChartData | null = null;
public chartOptions: ChartConfiguration['options'] = this._getDefaultOptions();
-
- private colorPalette = [
- 'rgba(99, 102, 241, 0.8)',
- 'rgba(168, 85, 247, 0.8)',
- 'rgba(236, 72, 153, 0.8)',
- 'rgba(244, 63, 94, 0.8)',
- 'rgba(251, 146, 60, 0.8)',
- 'rgba(234, 179, 8, 0.8)',
- 'rgba(34, 197, 94, 0.8)',
- 'rgba(6, 182, 212, 0.8)',
- 'rgba(59, 130, 246, 0.8)',
- 'rgba(139, 92, 246, 0.8)',
- ];
+ public chartPlugins: Plugin[] = [];
ngOnChanges(changes: SimpleChanges): void {
if (
changes['data'] ||
+ changes['widgetOptions'] ||
changes['labelColumn'] ||
changes['valueColumn'] ||
changes['chartType'] ||
changes['labelType']
) {
- this.updateChartData();
+ this._updateChart();
}
}
get mappedChartType(): ChartJsType {
- return this.chartType as ChartJsType;
+ const options = this._resolveOptions();
+ return getMappedChartType(this.chartType, options);
}
- private _getDefaultOptions(): ChartConfiguration['options'] {
+ private _updateChart(): void {
+ const options = this._resolveOptions();
+
+ if (!this.data.length || !options.label_column) {
+ this.chartData = null;
+ return;
+ }
+
+ // Check that at least one value source exists
+ const hasValues = options.series?.length || options.value_column;
+ if (!hasValues) {
+ this.chartData = null;
+ return;
+ }
+
+ this.chartData = buildChartData(this.data, this.chartType, options);
+ this.chartOptions = buildChartOptions(this.chartType, options);
+
+ this.chartPlugins = [buildDataLabelsPlugin(options)];
+ }
+
+ private _resolveOptions(): ChartWidgetOptions {
+ if (this.widgetOptions) {
+ return this.widgetOptions;
+ }
+
+ // Legacy mode: build options from individual inputs
return {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- display: true,
- position: 'top',
- },
- },
+ label_column: this.labelColumn,
+ value_column: this.valueColumn,
+ label_type: this.labelType,
};
}
- private _getTimeScaleOptions(): ChartConfiguration['options'] {
+ private _getDefaultOptions(): ChartConfiguration['options'] {
return {
responsive: true,
maintainAspectRatio: false,
@@ -73,124 +93,6 @@ export class ChartPreviewComponent implements OnChanges {
position: 'top',
},
},
- scales: {
- x: {
- type: 'time',
- time: {
- tooltipFormat: 'MMM d, yyyy',
- displayFormats: {
- day: 'MMM d',
- week: 'MMM d',
- month: 'MMM yyyy',
- year: 'yyyy',
- },
- },
- title: {
- display: true,
- text: this.labelColumn,
- },
- },
- y: {
- beginAtZero: true,
- title: {
- display: true,
- text: this.valueColumn,
- },
- },
- },
};
}
-
- private updateChartData(): void {
- if (!this.data.length || !this.labelColumn || !this.valueColumn) {
- this.chartData = null;
- return;
- }
-
- const isPieType = ['pie', 'doughnut', 'polarArea'].includes(this.chartType);
- const useTimeScale = this.labelType === 'datetime' && !isPieType;
-
- // Update options based on whether we're using time scale
- this.chartOptions = useTimeScale ? this._getTimeScaleOptions() : this._getDefaultOptions();
-
- if (useTimeScale) {
- // For time scale, use {x, y} data points
- const dataPoints = this.data
- .map((row) => {
- const dateVal = row[this.labelColumn];
- const numVal = row[this.valueColumn];
- const date = this._parseDate(dateVal);
- if (!date) return null;
- return {
- x: date.getTime(),
- y: typeof numVal === 'number' ? numVal : parseFloat(String(numVal)) || 0,
- };
- })
- .filter((point): point is { x: number; y: number } => point !== null)
- .sort((a, b) => a.x - b.x);
-
- this.chartData = {
- datasets: [
- {
- label: this.valueColumn,
- data: dataPoints,
- backgroundColor: this.colorPalette[0],
- borderColor: this.colorPalette[0].replace('0.8', '1'),
- borderWidth: 1,
- fill: this.chartType === 'line',
- spanGaps: false,
- },
- ],
- };
- } else {
- // For categorical scale, use labels + values
- const labels = this.data.map((row) => String(row[this.labelColumn] ?? ''));
- const values = this.data.map((row) => {
- const val = row[this.valueColumn];
- return typeof val === 'number' ? val : parseFloat(String(val)) || 0;
- });
-
- if (isPieType) {
- this.chartData = {
- labels,
- datasets: [
- {
- data: values,
- backgroundColor: this.colorPalette.slice(0, values.length),
- borderColor: this.colorPalette.slice(0, values.length).map((c) => c.replace('0.8', '1')),
- borderWidth: 1,
- },
- ],
- };
- } else {
- this.chartData = {
- labels,
- datasets: [
- {
- label: this.valueColumn,
- data: values,
- backgroundColor: this.colorPalette[0],
- borderColor: this.colorPalette[0].replace('0.8', '1'),
- borderWidth: 1,
- fill: this.chartType === 'line',
- },
- ],
- };
- }
- }
- }
-
- private _parseDate(value: unknown): Date | null {
- if (!value) return null;
-
- try {
- const date = new Date(value as string | number | Date);
- if (isNaN(date.getTime())) {
- return null;
- }
- return date;
- } catch {
- return null;
- }
- }
}
diff --git a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.css b/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.css
index f24a3a4fc..0e8ea7d7b 100644
--- a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.css
+++ b/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.css
@@ -18,11 +18,21 @@
fill: rgba(99, 102, 241, 0.7);
}
-.bar-1 { fill: rgba(99, 102, 241, 0.8); }
-.bar-2 { fill: rgba(168, 85, 247, 0.8); }
-.bar-3 { fill: rgba(236, 72, 153, 0.8); }
-.bar-4 { fill: rgba(59, 130, 246, 0.8); }
-.bar-5 { fill: rgba(34, 197, 94, 0.8); }
+.bar-1 {
+ fill: rgba(99, 102, 241, 0.8);
+}
+.bar-2 {
+ fill: rgba(168, 85, 247, 0.8);
+}
+.bar-3 {
+ fill: rgba(236, 72, 153, 0.8);
+}
+.bar-4 {
+ fill: rgba(59, 130, 246, 0.8);
+}
+.bar-5 {
+ fill: rgba(34, 197, 94, 0.8);
+}
/* Line Chart */
.line-path {
@@ -54,11 +64,21 @@
stroke-width: 1;
}
-.slice-1 { fill: rgba(99, 102, 241, 0.85); }
-.slice-2 { fill: rgba(168, 85, 247, 0.85); }
-.slice-3 { fill: rgba(236, 72, 153, 0.85); }
-.slice-4 { fill: rgba(251, 146, 60, 0.85); }
-.slice-5 { fill: rgba(34, 197, 94, 0.85); }
+.slice-1 {
+ fill: rgba(99, 102, 241, 0.85);
+}
+.slice-2 {
+ fill: rgba(168, 85, 247, 0.85);
+}
+.slice-3 {
+ fill: rgba(236, 72, 153, 0.85);
+}
+.slice-4 {
+ fill: rgba(251, 146, 60, 0.85);
+}
+.slice-5 {
+ fill: rgba(34, 197, 94, 0.85);
+}
/* Doughnut Chart */
.doughnut-slice {
diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css
index 642e01865..95ce24298 100644
--- a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css
+++ b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css
@@ -90,7 +90,9 @@
display: flex;
flex-direction: column;
overflow: hidden;
- transition: box-shadow 0.2s ease, transform 0.2s ease;
+ transition:
+ box-shadow 0.2s ease,
+ transform 0.2s ease;
}
.dashboard-card:hover {
diff --git a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.css b/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.css
index e96f8753e..be5437aca 100644
--- a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.css
+++ b/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.css
@@ -10,7 +10,9 @@
}
.dashboards-sidebar_initialized {
- transition: width 0.2s ease-in-out, min-width 0.2s ease-in-out;
+ transition:
+ width 0.2s ease-in-out,
+ min-width 0.2s ease-in-out;
}
@media (prefers-color-scheme: dark) {
diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html
index 292e310f6..3daf9afaa 100644
--- a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html
+++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html
@@ -3,7 +3,8 @@
} @else {
diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts
index 53f267340..92da12189 100644
--- a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts
+++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts
@@ -1,11 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component, computed, Input, OnInit, signal } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { ChartConfiguration, ChartData, ChartType as ChartJsType } from 'chart.js';
+import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js';
import 'chartjs-adapter-date-fns';
import { BaseChartDirective } from 'ng2-charts';
+import {
+ buildChartData,
+ buildChartOptions,
+ buildDataLabelsPlugin,
+ getMappedChartType,
+} from 'src/app/lib/chart-config.helper';
import { DashboardWidget } from 'src/app/models/dashboard';
-import { SavedQuery } from 'src/app/models/saved-query';
+import { ChartWidgetOptions, SavedQuery } from 'src/app/models/saved-query';
@Component({
selector: 'app-chart-widget',
@@ -27,81 +33,10 @@ export class ChartWidgetComponent implements OnInit {
const query = this.savedQuery();
if (!data.length || !query) return null;
- const labelColumn = this._getLabelColumn(query, data);
- const valueColumn = this._getValueColumn(query, data);
- const labelType = (query.widget_options?.['label_type'] as 'values' | 'datetime') || 'values';
-
- if (!labelColumn || !valueColumn) return null;
-
const chartType = query.chart_type || 'bar';
- const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType);
- const useTimeScale = labelType === 'datetime' && !isPieType;
-
- if (useTimeScale) {
- // For time scale, use {x, y} data points
- const dataPoints = data
- .map((row) => {
- const dateVal = row[labelColumn];
- const numVal = row[valueColumn];
- const date = this._parseDate(dateVal);
- if (!date) return null;
- return {
- x: date.getTime(),
- y: typeof numVal === 'number' ? numVal : parseFloat(String(numVal)) || 0,
- };
- })
- .filter((point): point is { x: number; y: number } => point !== null)
- .sort((a, b) => a.x - b.x);
-
- return {
- datasets: [
- {
- label: valueColumn,
- data: dataPoints,
- backgroundColor: this.colorPalette[0],
- borderColor: this.colorPalette[0].replace('0.8', '1'),
- borderWidth: 1,
- fill: chartType === 'line',
- spanGaps: false,
- },
- ],
- };
- } else {
- // For categorical scale, use labels + values
- const labels = data.map((row) => String(row[labelColumn] ?? ''));
- const values = data.map((row) => {
- const val = row[valueColumn];
- return typeof val === 'number' ? val : parseFloat(String(val)) || 0;
- });
-
- if (isPieType) {
- return {
- labels,
- datasets: [
- {
- data: values,
- backgroundColor: this.colorPalette.slice(0, values.length),
- borderColor: this.colorPalette.slice(0, values.length).map((c) => c.replace('0.8', '1')),
- borderWidth: 1,
- },
- ],
- };
- } else {
- return {
- labels,
- datasets: [
- {
- label: valueColumn,
- data: values,
- backgroundColor: this.colorPalette[0],
- borderColor: this.colorPalette[0].replace('0.8', '1'),
- borderWidth: 1,
- fill: chartType === 'line',
- },
- ],
- };
- }
- }
+ const widgetOptions = this._resolveWidgetOptions(query, data);
+
+ return buildChartData(data, chartType, widgetOptions);
});
protected chartOptions = computed
(() => {
@@ -109,31 +44,19 @@ export class ChartWidgetComponent implements OnInit {
const data = this.data();
if (!query || !data.length) return this._getDefaultOptions();
- const labelColumn = this._getLabelColumn(query, data);
- const valueColumn = this._getValueColumn(query, data);
- const labelType = (query.widget_options?.['label_type'] as 'values' | 'datetime') || 'values';
const chartType = query.chart_type || 'bar';
- const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType);
- const useTimeScale = labelType === 'datetime' && !isPieType;
+ const widgetOptions = this._resolveWidgetOptions(query, data);
- if (useTimeScale) {
- return this._getTimeScaleOptions(labelColumn || '', valueColumn || '');
- }
- return this._getDefaultOptions();
+ return buildChartOptions(chartType, widgetOptions);
});
- private colorPalette = [
- 'rgba(99, 102, 241, 0.8)',
- 'rgba(168, 85, 247, 0.8)',
- 'rgba(236, 72, 153, 0.8)',
- 'rgba(244, 63, 94, 0.8)',
- 'rgba(251, 146, 60, 0.8)',
- 'rgba(234, 179, 8, 0.8)',
- 'rgba(34, 197, 94, 0.8)',
- 'rgba(6, 182, 212, 0.8)',
- 'rgba(59, 130, 246, 0.8)',
- 'rgba(139, 92, 246, 0.8)',
- ];
+ protected chartPlugins = computed(() => {
+ const query = this.savedQuery();
+ if (!query) return [];
+
+ const widgetOptions = (query.widget_options ?? {}) as unknown as ChartWidgetOptions;
+ return [buildDataLabelsPlugin(widgetOptions)];
+ });
ngOnInit(): void {
if (this.preloadedQuery) {
@@ -145,54 +68,28 @@ export class ChartWidgetComponent implements OnInit {
}
get mappedChartType(): ChartJsType {
- return (this.savedQuery()?.chart_type || 'bar') as ChartJsType;
+ const query = this.savedQuery();
+ const chartType = query?.chart_type || 'bar';
+ const widgetOptions = (query?.widget_options ?? {}) as unknown as ChartWidgetOptions;
+ return getMappedChartType(chartType, widgetOptions);
}
- private _getLabelColumn(query: SavedQuery, data: Record[]): string | null {
- const labelCol = query.widget_options?.['label_column'] as string | undefined;
- if (labelCol) return labelCol;
-
- if (!data.length) return null;
- return Object.keys(data[0])[0] || null;
- }
+ private _resolveWidgetOptions(query: SavedQuery, data: Record[]): ChartWidgetOptions {
+ const raw = (query.widget_options ?? {}) as Partial;
- private _getValueColumn(query: SavedQuery, data: Record[]): string | null {
- const valueCol = query.widget_options?.['value_column'] as string | undefined;
- if (valueCol) return valueCol;
+ // Fallback label/value columns from data keys
+ const labelColumn = raw.label_column || (data.length ? Object.keys(data[0])[0] : '') || '';
+ const valueColumn =
+ raw.value_column || (data.length ? Object.keys(data[0])[1] || Object.keys(data[0])[0] : '') || '';
- if (!data.length) return null;
- const keys = Object.keys(data[0]);
- return keys[1] || keys[0] || null;
- }
-
- private _parseDate(value: unknown): Date | null {
- if (!value) return null;
-
- try {
- const date = new Date(value as string | number | Date);
- if (isNaN(date.getTime())) {
- return null;
- }
- return date;
- } catch {
- return null;
- }
- }
-
- private _getDefaultOptions(): ChartConfiguration['options'] {
return {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- display: true,
- position: 'top',
- },
- },
+ ...raw,
+ label_column: labelColumn,
+ value_column: valueColumn,
};
}
- private _getTimeScaleOptions(labelColumn: string, valueColumn: string): ChartConfiguration['options'] {
+ private _getDefaultOptions(): ChartConfiguration['options'] {
return {
responsive: true,
maintainAspectRatio: false,
@@ -202,31 +99,6 @@ export class ChartWidgetComponent implements OnInit {
position: 'top',
},
},
- scales: {
- x: {
- type: 'time',
- time: {
- tooltipFormat: 'MMM d, yyyy',
- displayFormats: {
- day: 'MMM d',
- week: 'MMM d',
- month: 'MMM yyyy',
- year: 'yyyy',
- },
- },
- title: {
- display: true,
- text: labelColumn,
- },
- },
- y: {
- beginAtZero: true,
- title: {
- display: true,
- text: valueColumn,
- },
- },
- },
};
}
}
diff --git a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.css b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.css
index 48926ad2b..17534d645 100644
--- a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.css
+++ b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.css
@@ -10,7 +10,9 @@
}
.profile-sidebar_initialized {
- transition: width 0.2s ease-in-out, min-width 0.2s ease-in-out;
+ transition:
+ width 0.2s ease-in-out,
+ min-width 0.2s ease-in-out;
}
@media (prefers-color-scheme: dark) {
diff --git a/frontend/src/app/lib/chart-config.helper.ts b/frontend/src/app/lib/chart-config.helper.ts
new file mode 100644
index 000000000..df5f95b10
--- /dev/null
+++ b/frontend/src/app/lib/chart-config.helper.ts
@@ -0,0 +1,510 @@
+import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js';
+import convert from 'convert';
+import {
+ ChartAxisConfig,
+ ChartNumberFormatConfig,
+ ChartSeriesConfig,
+ ChartType,
+ ChartUnitConfig,
+ ChartWidgetOptions,
+} from '../models/saved-query';
+
+export const DEFAULT_COLOR_PALETTE = [
+ 'rgba(99, 102, 241, 0.8)',
+ 'rgba(168, 85, 247, 0.8)',
+ 'rgba(236, 72, 153, 0.8)',
+ 'rgba(244, 63, 94, 0.8)',
+ 'rgba(251, 146, 60, 0.8)',
+ 'rgba(234, 179, 8, 0.8)',
+ 'rgba(34, 197, 94, 0.8)',
+ 'rgba(6, 182, 212, 0.8)',
+ 'rgba(59, 130, 246, 0.8)',
+ 'rgba(139, 92, 246, 0.8)',
+];
+
+export function formatChartValue(
+ value: number,
+ units?: ChartUnitConfig,
+ numberFormat?: ChartNumberFormatConfig,
+): string {
+ // Smart unit conversion using the `convert` package
+ if (units?.convert_unit) {
+ try {
+ const best = convert(value, units.convert_unit as Parameters[1]).to('best');
+ const quantity = parseFloat(best.quantity.toFixed(2));
+ return `${quantity} ${best.unit}`;
+ } catch {
+ // Fall through to manual formatting if unit is invalid
+ }
+ }
+
+ let formatted: string;
+
+ if (numberFormat?.compact) {
+ formatted = compactNumber(value);
+ } else {
+ formatted = new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: numberFormat?.decimal_places,
+ maximumFractionDigits: numberFormat?.decimal_places,
+ useGrouping: numberFormat?.thousands_separator ?? true,
+ }).format(value);
+ }
+
+ if (!units?.text) return formatted;
+ return units.position === 'prefix' ? `${units.text}${formatted}` : `${formatted}${units.text}`;
+}
+
+function compactNumber(value: number): string {
+ const abs = Math.abs(value);
+ if (abs >= 1e9) return (value / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
+ if (abs >= 1e6) return (value / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
+ if (abs >= 1e3) return (value / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
+ return String(value);
+}
+
+function solidColor(rgba: string): string {
+ return rgba.replace('0.8)', '1)');
+}
+
+function parseDate(value: unknown): Date | null {
+ if (!value) return null;
+ try {
+ const date = new Date(value as string | number | Date);
+ return isNaN(date.getTime()) ? null : date;
+ } catch {
+ return null;
+ }
+}
+
+function extractNumericValue(val: unknown): number {
+ return typeof val === 'number' ? val : parseFloat(String(val)) || 0;
+}
+
+function sortData(
+ data: Record[],
+ sortBy: string,
+ labelColumn: string,
+ valueColumn?: string,
+): Record[] {
+ const sorted = [...data];
+ switch (sortBy) {
+ case 'label_asc':
+ sorted.sort((a, b) => String(a[labelColumn] ?? '').localeCompare(String(b[labelColumn] ?? '')));
+ break;
+ case 'label_desc':
+ sorted.sort((a, b) => String(b[labelColumn] ?? '').localeCompare(String(a[labelColumn] ?? '')));
+ break;
+ case 'value_asc':
+ if (valueColumn) {
+ sorted.sort((a, b) => extractNumericValue(a[valueColumn]) - extractNumericValue(b[valueColumn]));
+ }
+ break;
+ case 'value_desc':
+ if (valueColumn) {
+ sorted.sort((a, b) => extractNumericValue(b[valueColumn]) - extractNumericValue(a[valueColumn]));
+ }
+ break;
+ }
+ return sorted;
+}
+
+function buildPivotedChartData(
+ rawData: Record[],
+ options: ChartWidgetOptions,
+): ChartData | null {
+ const palette = options.color_palette ?? DEFAULT_COLOR_PALETTE;
+ const labelColumn = options.label_column;
+ const seriesColumn = options.series_column!;
+ const valueColumn = options.value_column!;
+ const labelType = options.label_type ?? 'values';
+ const useTimeScale = labelType === 'datetime';
+
+ // Pre-process data: sort and limit
+ let processedData = [...rawData];
+ if (options.sort_by && options.sort_by !== 'none') {
+ processedData = sortData(processedData, options.sort_by, labelColumn, valueColumn);
+ }
+ if (options.limit) {
+ processedData = processedData.slice(0, options.limit);
+ }
+
+ // Extract unique labels preserving first-appearance order
+ const labelSet = new Set();
+ const seriesSet = new Set();
+ for (const row of processedData) {
+ labelSet.add(String(row[labelColumn] ?? ''));
+ seriesSet.add(String(row[seriesColumn] ?? ''));
+ }
+ const labels = Array.from(labelSet);
+ const seriesValues = Array.from(seriesSet);
+
+ // Build lookup: label -> seriesValue -> sum
+ const lookup = new Map>();
+ for (const row of processedData) {
+ const label = String(row[labelColumn] ?? '');
+ const series = String(row[seriesColumn] ?? '');
+ const value = extractNumericValue(row[valueColumn]);
+ if (!lookup.has(label)) lookup.set(label, new Map());
+ const inner = lookup.get(label)!;
+ inner.set(series, (inner.get(series) ?? 0) + value);
+ }
+
+ if (useTimeScale) {
+ const datasets = seriesValues.map((sv, i) => {
+ const color = palette[i % palette.length];
+ const dataPoints = labels
+ .map((label) => {
+ const date = parseDate(label);
+ if (!date) return null;
+ return {
+ x: date.getTime(),
+ y: lookup.get(label)?.get(sv) ?? 0,
+ };
+ })
+ .filter((p): p is { x: number; y: number } => p !== null)
+ .sort((a, b) => a.x - b.x);
+
+ return {
+ label: sv,
+ data: dataPoints,
+ backgroundColor: color,
+ borderColor: solidColor(color),
+ borderWidth: 1,
+ fill: false,
+ tension: 0,
+ };
+ });
+ return { datasets };
+ }
+
+ // Categorical scale
+ const datasets = seriesValues.map((sv, i) => {
+ const color = palette[i % palette.length];
+ const values = labels.map((label) => lookup.get(label)?.get(sv) ?? 0);
+ return {
+ label: sv,
+ data: values,
+ backgroundColor: color,
+ borderColor: solidColor(color),
+ borderWidth: 1,
+ };
+ });
+
+ return { labels, datasets };
+}
+
+export function buildChartData(
+ rawData: Record[],
+ chartType: ChartType,
+ options: ChartWidgetOptions,
+): ChartData | null {
+ if (!rawData.length) return null;
+
+ const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType);
+
+ // Pivot mode: series_column is set with a value_column (not for pie types)
+ if (options.series_column && options.value_column && !isPieType) {
+ return buildPivotedChartData(rawData, options);
+ }
+
+ const palette = options.color_palette ?? DEFAULT_COLOR_PALETTE;
+ const labelType = options.label_type ?? 'values';
+ const useTimeScale = labelType === 'datetime' && !isPieType;
+ const labelColumn = options.label_column;
+
+ if (!labelColumn) return null;
+
+ // Resolve series
+ const seriesConfigs: ChartSeriesConfig[] = options.series?.length
+ ? options.series
+ : options.value_column
+ ? [{ value_column: options.value_column }]
+ : [];
+
+ if (!seriesConfigs.length) return null;
+
+ // Pre-process data: sort and limit
+ let processedData = [...rawData];
+ if (options.sort_by && options.sort_by !== 'none') {
+ processedData = sortData(processedData, options.sort_by, labelColumn, seriesConfigs[0]?.value_column);
+ }
+ if (options.limit) {
+ processedData = processedData.slice(0, options.limit);
+ }
+
+ if (useTimeScale) {
+ const datasets = seriesConfigs.map((s, i) => {
+ const color = s.color ?? palette[i % palette.length];
+ const dataPoints = processedData
+ .map((row) => {
+ const date = parseDate(row[labelColumn]);
+ if (!date) return null;
+ return {
+ x: date.getTime(),
+ y: extractNumericValue(row[s.value_column]),
+ };
+ })
+ .filter((point): point is { x: number; y: number } => point !== null)
+ .sort((a, b) => a.x - b.x);
+
+ return {
+ label: s.label ?? s.value_column,
+ data: dataPoints,
+ backgroundColor: color,
+ borderColor: solidColor(color),
+ borderWidth: 1,
+ fill: s.fill ?? false,
+ tension: s.tension ?? 0,
+ pointStyle:
+ s.point_style === 'none'
+ ? ('circle' as const)
+ : ((s.point_style ?? 'circle') as 'circle' | 'rect' | 'triangle' | 'cross'),
+ pointRadius: s.point_style === 'none' ? 0 : undefined,
+ spanGaps: false,
+ type: s.type as ChartJsType | undefined,
+ };
+ });
+
+ return { datasets };
+ }
+
+ // Categorical scale
+ const labels = processedData.map((row) => String(row[labelColumn] ?? ''));
+
+ if (isPieType) {
+ // Pie/doughnut/polarArea: single dataset with per-slice colors
+ const s = seriesConfigs[0];
+ const values = processedData.map((row) => extractNumericValue(row[s.value_column]));
+ const sliceColors = s.colors ?? palette.slice(0, values.length);
+ const borderColors = sliceColors.map((c) => solidColor(c));
+
+ // If palette is shorter than values, cycle
+ const bgColors = values.map((_, i) => sliceColors[i % sliceColors.length]);
+ const bdColors = values.map((_, i) => borderColors[i % borderColors.length]);
+
+ return {
+ labels,
+ datasets: [
+ {
+ label: s.label ?? s.value_column,
+ data: values,
+ backgroundColor: bgColors,
+ borderColor: bdColors,
+ borderWidth: 1,
+ },
+ ],
+ };
+ }
+
+ // Bar/line: multi-series
+ const datasets = seriesConfigs.map((s, i) => {
+ const color = s.color ?? palette[i % palette.length];
+ const values = processedData.map((row) => extractNumericValue(row[s.value_column]));
+
+ return {
+ label: s.label ?? s.value_column,
+ data: values,
+ backgroundColor: color,
+ borderColor: solidColor(color),
+ borderWidth: 1,
+ fill: s.fill ?? false,
+ tension: s.tension ?? 0,
+ pointStyle:
+ s.point_style === 'none'
+ ? ('circle' as const)
+ : ((s.point_style ?? 'circle') as 'circle' | 'rect' | 'triangle' | 'cross'),
+ pointRadius: s.point_style === 'none' ? 0 : undefined,
+ type: s.type as ChartJsType | undefined,
+ };
+ });
+
+ return { labels, datasets };
+}
+
+function buildAxisConfig(
+ axisConfig: ChartAxisConfig | undefined,
+ defaults?: { beginAtZero?: boolean },
+): Record {
+ const config: Record = {};
+
+ if (axisConfig?.title) {
+ config['title'] = { display: true, text: axisConfig.title };
+ }
+ if (axisConfig?.min !== undefined) {
+ config['min'] = axisConfig.min;
+ }
+ if (axisConfig?.max !== undefined) {
+ config['max'] = axisConfig.max;
+ }
+
+ const beginAtZero = axisConfig?.begin_at_zero ?? defaults?.beginAtZero;
+ if (beginAtZero !== undefined) {
+ config['beginAtZero'] = beginAtZero;
+ }
+
+ if (axisConfig?.scale_type === 'logarithmic') {
+ config['type'] = 'logarithmic';
+ }
+
+ return config;
+}
+
+export function buildChartOptions(chartType: ChartType, options: ChartWidgetOptions): ChartConfiguration['options'] {
+ const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType);
+ const labelType = options.label_type ?? 'values';
+ const useTimeScale = labelType === 'datetime' && !isPieType;
+
+ const seriesConfigs: ChartSeriesConfig[] = options.series?.length
+ ? options.series
+ : options.value_column
+ ? [{ value_column: options.value_column }]
+ : [];
+
+ const globalUnits = options.units;
+ const globalFormat = options.number_format;
+
+ const chartOptions: ChartConfiguration['options'] = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: options.legend?.show ?? true,
+ position: options.legend?.position ?? 'top',
+ },
+ tooltip: {
+ callbacks: {
+ label: (context) => {
+ const seriesIndex = context.datasetIndex ?? 0;
+ const seriesConfig = options.series_column ? undefined : seriesConfigs[seriesIndex];
+ const units = seriesConfig?.units ?? globalUnits;
+ const numFormat = seriesConfig?.number_format ?? globalFormat;
+ const raw = context.parsed?.y ?? context.parsed ?? context.raw;
+ const value =
+ typeof raw === 'number'
+ ? raw
+ : typeof raw === 'object' && raw !== null
+ ? ((raw as { y?: number }).y ?? 0)
+ : 0;
+ const label = context.dataset.label ?? '';
+ const formatted = formatChartValue(value, units, numFormat);
+ return `${label}: ${formatted}`;
+ },
+ },
+ },
+ },
+ };
+
+ if (!isPieType) {
+ (chartOptions as Record)['indexAxis'] = options.horizontal ? 'y' : 'x';
+ }
+
+ // Scales for non-pie types
+ if (!isPieType) {
+ const scales: Record = {};
+
+ if (useTimeScale) {
+ scales['x'] = {
+ type: 'time',
+ time: {
+ tooltipFormat: 'MMM d, yyyy',
+ displayFormats: {
+ day: 'MMM d',
+ week: 'MMM d',
+ month: 'MMM yyyy',
+ year: 'yyyy',
+ },
+ },
+ ...buildAxisConfig(options.x_axis),
+ ...(options.stacked ? { stacked: true } : {}),
+ };
+ } else {
+ const xConfig = buildAxisConfig(options.x_axis);
+ scales['x'] = {
+ ...xConfig,
+ ...(options.stacked ? { stacked: true } : {}),
+ };
+ }
+
+ const yConfig = buildAxisConfig(options.y_axis, { beginAtZero: true });
+ scales['y'] = {
+ ...yConfig,
+ ...(options.stacked ? { stacked: true } : {}),
+ };
+
+ // Tick formatting for value axis
+ const tickAxis = options.horizontal ? 'x' : 'y';
+ if (globalUnits || globalFormat) {
+ const axisObj = scales[tickAxis] as Record;
+ axisObj['ticks'] = {
+ callback: (tickValue: string | number) => {
+ const value = typeof tickValue === 'number' ? tickValue : parseFloat(tickValue);
+ return formatChartValue(value, globalUnits, globalFormat);
+ },
+ };
+ }
+
+ (chartOptions as Record)['scales'] = scales;
+ }
+
+ return chartOptions;
+}
+
+export function buildDataLabelsPlugin(options: ChartWidgetOptions): Plugin {
+ const globalUnits = options.units;
+ const globalFormat = options.number_format;
+
+ const seriesConfigs: ChartSeriesConfig[] = options.series?.length
+ ? options.series
+ : options.value_column
+ ? [{ value_column: options.value_column }]
+ : [];
+
+ return {
+ id: 'customDataLabels',
+ afterDatasetsDraw(chart) {
+ if (!options.show_data_labels) return;
+
+ const { ctx } = chart;
+ ctx.save();
+ ctx.font = '11px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'bottom';
+
+ chart.data.datasets.forEach((dataset, datasetIndex) => {
+ const meta = chart.getDatasetMeta(datasetIndex);
+ const seriesConfig = options.series_column ? undefined : seriesConfigs[datasetIndex];
+ const units = seriesConfig?.units ?? globalUnits;
+ const numFormat = seriesConfig?.number_format ?? globalFormat;
+
+ meta.data.forEach((element, index) => {
+ const raw = dataset.data[index];
+ let value: number;
+ if (typeof raw === 'number') {
+ value = raw;
+ } else if (raw && typeof raw === 'object' && 'y' in raw) {
+ value = (raw as { y: number }).y;
+ } else {
+ return;
+ }
+
+ const formatted = formatChartValue(value, units, numFormat);
+ const { x, y } = element.tooltipPosition(false);
+ ctx.fillStyle = '#666';
+ ctx.fillText(formatted, x, y - 5);
+ });
+ });
+
+ ctx.restore();
+ },
+ };
+}
+
+export function getMappedChartType(chartType: ChartType, options: ChartWidgetOptions): ChartJsType {
+ // For mixed charts, the base type is used but individual datasets may override
+ const hasMixedTypes = options.series?.some((s) => s.type) ?? false;
+ if (hasMixedTypes) {
+ // Use 'bar' as base for mixed charts - individual series override via dataset.type
+ return 'bar' as ChartJsType;
+ }
+ return chartType as ChartJsType;
+}
diff --git a/frontend/src/app/models/saved-query.ts b/frontend/src/app/models/saved-query.ts
index 910d396d6..b9b914c7e 100644
--- a/frontend/src/app/models/saved-query.ts
+++ b/frontend/src/app/models/saved-query.ts
@@ -2,6 +2,63 @@ export type DashboardWidgetType = 'table' | 'chart' | 'counter' | 'text';
export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea';
+export interface ChartUnitConfig {
+ text?: string;
+ position?: 'prefix' | 'suffix';
+ convert_unit?: string;
+}
+
+export interface ChartNumberFormatConfig {
+ decimal_places?: number;
+ thousands_separator?: boolean;
+ compact?: boolean;
+}
+
+export interface ChartLegendConfig {
+ show?: boolean;
+ position?: 'top' | 'bottom' | 'left' | 'right';
+}
+
+export interface ChartAxisConfig {
+ title?: string;
+ min?: number;
+ max?: number;
+ begin_at_zero?: boolean;
+ scale_type?: 'linear' | 'logarithmic';
+}
+
+export interface ChartSeriesConfig {
+ value_column: string;
+ label?: string;
+ color?: string;
+ colors?: string[];
+ units?: ChartUnitConfig;
+ number_format?: ChartNumberFormatConfig;
+ fill?: boolean;
+ tension?: number;
+ point_style?: 'circle' | 'rect' | 'triangle' | 'cross' | 'none';
+ type?: 'bar' | 'line';
+}
+
+export interface ChartWidgetOptions {
+ label_column: string;
+ value_column?: string;
+ label_type?: 'values' | 'datetime';
+ series?: ChartSeriesConfig[];
+ series_column?: string;
+ units?: ChartUnitConfig;
+ number_format?: ChartNumberFormatConfig;
+ color_palette?: string[];
+ legend?: ChartLegendConfig;
+ stacked?: boolean;
+ horizontal?: boolean;
+ show_data_labels?: boolean;
+ y_axis?: ChartAxisConfig;
+ x_axis?: ChartAxisConfig;
+ sort_by?: 'label_asc' | 'label_desc' | 'value_asc' | 'value_desc' | 'none';
+ limit?: number;
+}
+
export interface SavedQuery {
id: string;
name: string;
diff --git a/shared-code/src/data-access-layer/data-access-objects/data-access-object-ibmdb2.ts b/shared-code/src/data-access-layer/data-access-objects/data-access-object-ibmdb2.ts
index 9668d97fa..b228d7bfb 100644
--- a/shared-code/src/data-access-layer/data-access-objects/data-access-object-ibmdb2.ts
+++ b/shared-code/src/data-access-layer/data-access-objects/data-access-object-ibmdb2.ts
@@ -1,13 +1,15 @@
+import { Readable, Stream } from 'node:stream';
import * as csv from 'csv';
import getPort from 'get-port';
import { Database, Pool, SQLParam } from 'ibm_db';
import { nanoid } from 'nanoid';
-import { Readable, Stream } from 'node:stream';
import { LRUStorage } from '../../caching/lru-storage.js';
import { DAO_CONSTANTS } from '../../helpers/data-access-objects-constants.js';
import { ERROR_MESSAGES } from '../../helpers/errors/error-messages.js';
import { getTunnel } from '../../helpers/get-ssh-tunnel.js';
import { tableSettingsFieldValidator } from '../../helpers/validation/table-settings-validator.js';
+import { FilterCriteriaEnum } from '../../shared/enums/filter-criteria.enum.js';
+import { IDataAccessObject } from '../../shared/interfaces/data-access-object.interface.js';
import { AutocompleteFieldsDS } from '../shared/data-structures/autocomplete-fields.ds.js';
import { ConnectionParams } from '../shared/data-structures/connections-params.ds.js';
import { FilteringFieldsDS } from '../shared/data-structures/filtering-fields.ds.js';
@@ -16,13 +18,11 @@ import { FoundRowsDS } from '../shared/data-structures/found-rows.ds.js';
import { PrimaryKeyDS } from '../shared/data-structures/primary-key.ds.js';
import { ReferencedTableNamesAndColumnsDS } from '../shared/data-structures/referenced-table-names-columns.ds.js';
import { RowsPaginationDS } from '../shared/data-structures/rows-pagination.ds.js';
+import { TableDS } from '../shared/data-structures/table.ds.js';
import { TableSettingsDS } from '../shared/data-structures/table-settings.ds.js';
import { TableStructureDS } from '../shared/data-structures/table-structure.ds.js';
-import { TableDS } from '../shared/data-structures/table.ds.js';
import { TestConnectionResultDS } from '../shared/data-structures/test-result-connection.ds.js';
import { ValidateTableSettingsDS } from '../shared/data-structures/validate-table-settings.ds.js';
-import { FilterCriteriaEnum } from '../../shared/enums/filter-criteria.enum.js';
-import { IDataAccessObject } from '../../shared/interfaces/data-access-object.interface.js';
import { BasicDataAccessObject } from './basic-data-access-object.js';
interface IbmDb2Row {