From e0ddcb97adaa5432f7231ea6d9b17bedc6335645 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 12 Feb 2026 08:55:42 +0000 Subject: [PATCH 1/6] Add comprehensive chart widget_options configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend chart widget_options with multi-series support, custom colors, units/number formatting, stacking, horizontal bars, legend/axis config, data labels, sorting, and data limiting — all backward compatible with existing saved queries. - Add TypeScript interfaces for chart config (ChartWidgetOptions, etc.) - Create shared chart-config.helper.ts for Chart.js config building - Refactor chart-widget and chart-preview to use shared helper - Add full configuration UI to chart-edit with series management, display options, units/formatting, axis config, and data options - Update tests to match new seriesList-based API Co-Authored-By: Claude Opus 4.6 --- .../chart-edit/chart-edit.component.css | 139 +++++ .../chart-edit/chart-edit.component.html | 565 +++++++++++++----- .../chart-edit/chart-edit.component.spec.ts | 20 +- .../charts/chart-edit/chart-edit.component.ts | 265 +++++++- .../chart-preview.component.html | 25 +- .../chart-preview/chart-preview.component.ts | 193 ++---- .../chart-widget/chart-widget.component.html | 3 +- .../chart-widget/chart-widget.component.ts | 197 ++---- frontend/src/app/lib/chart-config.helper.ts | 407 +++++++++++++ frontend/src/app/models/saved-query.ts | 55 ++ 10 files changed, 1371 insertions(+), 498 deletions(-) create mode 100644 frontend/src/app/lib/chart-config.helper.ts diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css index 660f2cef7..e64105bf8 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css @@ -255,3 +255,142 @@ vertical-align: middle; margin-right: 4px; } + +/* Series section */ +.series-section { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.series-section .section-header h4 { + margin: 0; + font-size: 14px; + font-weight: 500; +} + +.add-series-button { + font-size: 13px; +} + +.add-series-button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.series-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 12px; +} + +@media (prefers-color-scheme: dark) { + .series-card { + border-color: rgba(255, 255, 255, 0.12); + } +} + +.series-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.series-number { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .series-number { + color: rgba(255, 255, 255, 0.54); + } +} + +.remove-series-button { + width: 28px !important; + height: 28px !important; + line-height: 28px !important; +} + +.remove-series-button mat-icon { + font-size: 16px; + width: 16px; + height: 16px; +} + +.series-fields { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.series-field { + flex: 1; + min-width: 120px; +} + +.series-field--color { + max-width: 200px; +} + +.series-field--small { + max-width: 100px; + min-width: 80px; +} + +.series-options { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + margin-top: 4px; +} + +/* Advanced options accordion */ +.advanced-options { + margin-top: 8px; +} + +.option-group { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.option-field { + min-width: 120px; +} + +.inline-fields { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.inline-fields .option-field { + flex: 1; +} + +.axis-label { + margin: 8px 0 0; + font-size: 13px; + font-weight: 500; + color: rgba(0, 0, 0, 0.6); +} + +@media (prefers-color-scheme: dark) { + .axis-label { + color: rgba(255, 255, 255, 0.6); + } +} + +.palette-field { + width: 100%; +} diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index f753ee79d..788f91d59 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -15,161 +15,452 @@

{{ isEditMode() ? 'Edit Widget' : 'Create Widget' }}

-
- -
- -
-
- - Query Name - - - - - Description (optional) - - + @if (loading()) { +
+
+ } -
-
-
-

SQL Query

- -
-
- - -
+ @if (!loading()) { +
+
+ + Query Name + + + + + Description (optional) + +
-
-
+
+
-

Chart Configuration

- - Executed in {{ executionTime() }}ms - +

SQL Query

+
- -
- - Chart Type - - - {{ type.label }} - - - - - - Label Column - - - {{ col }} - - - - - - Label Type - - - {{ type.label }} - - - - - - Value Column - - - {{ col }} - - - +
+ +
-
-
-

Chart Preview

+ @if (showResults()) { +
+
+
+

Chart Configuration

+ @if (executionTime() !== null) { + + Executed in {{ executionTime() }}ms + + } +
+ + +
+ + Chart Type + + @for (type of chartTypes; track type.value) { + {{ type.label }} + } + + + + + Label Column + + @for (col of resultColumns(); track col) { + {{ col }} + } + + + + @if (showLabelTypeOption()) { + + Label Type + + @for (type of labelTypes; track type.value) { + {{ type.label }} + } + + + } +
+ + +
+
+

Data Series

+ @if (!isPieType() || seriesList().length === 0) { + + } +
+ + @for (series of seriesList(); track $index; let i = $index) { +
+
+ Series {{ i + 1 }} + @if (seriesList().length > 1) { + + } +
+ +
+ + Value Column + + @for (col of resultColumns(); track col) { + {{ col }} + } + + + + + Label + + + + @if (!isPieType()) { + + Color + + + } + + @if (!isPieType() && chartType() !== 'bar') { + + Point Style + + @for (ps of pointStyles; track ps.value) { + {{ ps.label }} + } + + + } +
+ + @if (!isPieType()) { +
+ @if (chartType() === 'line' || series.type === 'line') { + + Fill area + + } + + @if (chartType() === 'line' || series.type === 'line') { + + Tension + + + } + + @if (seriesList().length > 1) { + + Type Override + + Default + Bar + Line + + + } +
+ } +
+ } +
+
+ + + + + + + Display Options + + +
+ @if (!isPieType()) { + + Stacked + + } + + @if (chartType() === 'bar') { + + Horizontal + + } + + + Show data labels + + + + Show legend + + + @if (legendShow()) { + + Legend Position + + @for (pos of legendPositions; track pos.value) { + {{ pos.label }} + } + + + } +
+
+ + + + + Units & Formatting + + +
+
+ + Unit Text + + + + + Unit Position + + Prefix ($100) + Suffix (100ms) + + +
+ +
+ + Decimal Places + + +
+ + + Thousands separator + + + + Compact notation (1K, 1M, 1B) + +
+
+ + + @if (!isPieType()) { + + + Axis Configuration + + +
+

Y-Axis

+
+ + Title + + + + + Scale Type + + @for (st of scaleTypes; track st.value) { + {{ st.label }} + } + + +
+ +
+ + Min + + + + + Max + + +
+ + + Begin at zero + + +

X-Axis

+ + Title + + +
+
+ } + + + + + Data Options + + +
+
+ + Sort By + + @for (opt of sortOptions; track opt.value) { + {{ opt.label }} + } + + + + + Limit + + +
+
+
+ + + + + Custom Color Palette + + +
+ + Colors (one per line) + + +
+
+
+ +
+
+

Chart Preview

+
+ + @if (hasChartData()) { +
+ + +
+ } + + @if (!hasChartData() && testResults().length > 0) { +
+

Add at least one data series to display the chart

+
+ } +
+ } +
-
- - + @if (showResults() && testResults().length > 0) { +
+
+

Query Results ({{ testResults().length }} rows)

+
+ + @for (column of resultColumns(); track column) { + + + + + } -
-

Select label and value columns to display the chart

+
+ +
{{ column }}{{ row[column] }}
-
-
+ } -
-
-

Query Results ({{ testResults().length }} rows)

-
-
- - - - - - - - -
{{ column }}{{ row[column] }}
-
-
- -
- info -

Query executed successfully but returned no results.

+ @if (showResults() && testResults().length === 0) { +
+ info +

Query executed successfully but returned no results.

+
+ }
-
+ } -
- - -
+ @if (!loading()) { +
+ + +
+ }
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 12449a77e..305d57388 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' }, }); }); @@ -269,19 +269,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 0b01a4d7d..c62e76712 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'; @@ -15,7 +17,16 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2 } from 'angulartics2'; import { finalize } from 'rxjs/operators'; -import { ChartType, TestQueryResult } from 'src/app/models/saved-query'; +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'; @@ -32,6 +43,8 @@ import { ChartPreviewComponent } from '../chart-preview/chart-preview.component' FormsModule, RouterModule, MatButtonModule, + MatCheckboxModule, + MatExpansionModule, MatIconModule, MatInputModule, MatFormFieldModule, @@ -62,11 +75,49 @@ 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 seriesList = 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 unitsText = signal(''); + protected unitsPosition = signal<'prefix' | 'suffix'>('suffix'); + + // 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 colorPaletteText = signal(''); + public chartTypes: { value: ChartType; label: string }[] = [ { value: 'bar', label: 'Bar Chart' }, { value: 'line', label: 'Line Chart' }, @@ -80,10 +131,37 @@ 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' }, + ]; + 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', @@ -100,12 +178,72 @@ 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(); + 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(), + }; + + 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.unitsText()) { + options.units = { text: this.unitsText(), position: this.unitsPosition() }; + } + + 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 paletteText = this.colorPaletteText().trim(); + if (paletteText) { + const colors = paletteText + .split('\n') + .map((c) => c.trim()) + .filter((c) => c); + if (colors.length > 0) options.color_palette = colors; + } + + return options; + }); private _savedQueries = inject(SavedQueriesService); private _connections = inject(ConnectionsService); @@ -118,7 +256,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'; @@ -150,24 +287,78 @@ 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?.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 }]); } - if (query.widget_options['value_column']) { - this.valueColumn.set(query.widget_options['value_column'] as string); + + // 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) { + this.unitsText.set(opts.units.text); + this.unitsPosition.set(opts.units.position); } - if (query.widget_options['label_type']) { - this.labelType.set(query.widget_options['label_type'] as 'values' | 'datetime'); + + // 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); + } + + // 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.colorPaletteText.set(opts.color_palette.join('\n')); } } - // 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(); } }); @@ -197,8 +388,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] }]); } } }); @@ -208,6 +400,25 @@ export class ChartEditComponent implements OnInit { }); } + 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; + }); + } + saveQuery(): void { if (!this.queryName().trim() || !this.queryText().trim()) { return; @@ -215,17 +426,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(), @@ -233,7 +434,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()) { 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..90fd80e4f 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,71 @@ 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); + + const plugin = buildDataLabelsPlugin(options); + this.chartPlugins = plugin ? [plugin] : []; + } + + 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 +94,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/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 @@ + [options]="chartOptions()" + [plugins]="chartPlugins()">
} @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..2c65791d8 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,20 @@ 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; + const plugin = buildDataLabelsPlugin(widgetOptions); + return plugin ? [plugin] : []; + }); ngOnInit(): void { if (this.preloadedQuery) { @@ -145,54 +69,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 +100,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/lib/chart-config.helper.ts b/frontend/src/app/lib/chart-config.helper.ts new file mode 100644 index 000000000..c13157135 --- /dev/null +++ b/frontend/src/app/lib/chart-config.helper.ts @@ -0,0 +1,407 @@ +import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js'; +import { + ChartAxisConfig, + ChartNumberFormatConfig, + ChartSeriesConfig, + ChartType, + ChartUnitConfig, + ChartWidgetOptions, +} from '../models/saved-query'; + +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 { + 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) 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; +} + +export function buildChartData( + rawData: Record[], + chartType: ChartType, + options: ChartWidgetOptions, +): ChartData | null { + if (!rawData.length) return null; + + const palette = options.color_palette ?? DEFAULT_COLOR_PALETTE; + const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType); + 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 = 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 (options.horizontal && !isPieType) { + (chartOptions as Record)['indexAxis'] = 'y'; + } + + // 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 | null { + if (!options.show_data_labels) return null; + + 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) { + 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 = 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..b01b51f61 100644 --- a/frontend/src/app/models/saved-query.ts +++ b/frontend/src/app/models/saved-query.ts @@ -2,6 +2,61 @@ export type DashboardWidgetType = 'table' | 'chart' | 'counter' | 'text'; export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea'; +export interface ChartUnitConfig { + text: string; + position: 'prefix' | 'suffix'; +} + +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[]; + 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; From 9c919580693acec7a3199f0213332c903622e332 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 12 Feb 2026 09:03:54 +0000 Subject: [PATCH 2/6] Add smart unit auto-conversion using the convert package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the existing `convert` dependency to auto-format chart values into the best human-readable unit (e.g. 1500000 bytes → 1.5 MB, 3600000 ms → 1 h). Adds a unit mode selector in chart-edit: - None: no unit formatting - Custom text: manual prefix/suffix ($, %, EUR) - Auto-convert: pick a source unit, values auto-scale Supports time, data, length, mass, temperature, frequency, power, energy, pressure, and volume unit families via grouped dropdown. Co-Authored-By: Claude Opus 4.6 --- .../chart-edit/chart-edit.component.html | 77 ++++++++---- .../charts/chart-edit/chart-edit.component.ts | 117 +++++++++++++++++- frontend/src/app/lib/chart-config.helper.ts | 14 ++- frontend/src/app/models/saved-query.ts | 5 +- 4 files changed, 183 insertions(+), 30 deletions(-) diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index 788f91d59..633caeb15 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -255,37 +255,66 @@

Data Series

-
- - Unit Text - - + + Unit Mode + + @for (mode of unitModes; track mode.value) { + {{ mode.label }} + } + + + @if (unitMode() === 'custom') { +
+ + Unit Text + + + + + Unit Position + + Prefix ($100) + Suffix (100ms) + + +
+ } + + @if (unitMode() === 'convert') { - Unit Position - - Prefix ($100) - Suffix (100ms) + Source Unit + + @for (group of convertUnitPresets; track group.group) { + + @for (unit of group.units; track unit.value) { + {{ unit.label }} + } + + } + Values auto-convert to the best readable unit -
+ } -
- - Decimal Places - - -
+ @if (unitMode() !== 'convert') { +
+ + Decimal Places + + +
- - Thousands separator - + + Thousands separator + - - Compact notation (1K, 1M, 1B) - + + Compact notation (1K, 1M, 1B) + + }
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 c62e76712..100dd01ec 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 @@ -93,8 +93,10 @@ export class ChartEditComponent implements OnInit { 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); @@ -159,6 +161,107 @@ export class ChartEditComponent implements OnInit { { 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())); @@ -208,8 +311,10 @@ export class ChartEditComponent implements OnInit { }; } - if (this.unitsText()) { + 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 = {}; @@ -319,8 +424,14 @@ export class ChartEditComponent implements OnInit { // Units if (opts.units) { - this.unitsText.set(opts.units.text); - this.unitsPosition.set(opts.units.position); + 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); + } } // Number format diff --git a/frontend/src/app/lib/chart-config.helper.ts b/frontend/src/app/lib/chart-config.helper.ts index c13157135..1094170ca 100644 --- a/frontend/src/app/lib/chart-config.helper.ts +++ b/frontend/src/app/lib/chart-config.helper.ts @@ -1,4 +1,5 @@ import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js'; +import convert from 'convert'; import { ChartAxisConfig, ChartNumberFormatConfig, @@ -26,6 +27,17 @@ export function formatChartValue( 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) { @@ -38,7 +50,7 @@ export function formatChartValue( }).format(value); } - if (!units) return formatted; + if (!units?.text) return formatted; return units.position === 'prefix' ? `${units.text}${formatted}` : `${formatted}${units.text}`; } diff --git a/frontend/src/app/models/saved-query.ts b/frontend/src/app/models/saved-query.ts index b01b51f61..9bfab0c29 100644 --- a/frontend/src/app/models/saved-query.ts +++ b/frontend/src/app/models/saved-query.ts @@ -3,8 +3,9 @@ export type DashboardWidgetType = 'table' | 'chart' | 'counter' | 'text'; export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea'; export interface ChartUnitConfig { - text: string; - position: 'prefix' | 'suffix'; + text?: string; + position?: 'prefix' | 'suffix'; + convert_unit?: string; } export interface ChartNumberFormatConfig { From ca850fbd4838c6ebf09af41a7da06708d4cbcb76 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 12 Feb 2026 09:09:20 +0000 Subject: [PATCH 3/6] Fix horizontal toggle not reverting chart to vertical Always set indexAxis explicitly ('y' for horizontal, 'x' for vertical) so Chart.js doesn't cache the previous value when toggling off. Co-Authored-By: Claude Opus 4.6 --- frontend/src/app/lib/chart-config.helper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/lib/chart-config.helper.ts b/frontend/src/app/lib/chart-config.helper.ts index 1094170ca..073ac402c 100644 --- a/frontend/src/app/lib/chart-config.helper.ts +++ b/frontend/src/app/lib/chart-config.helper.ts @@ -303,8 +303,8 @@ export function buildChartOptions(chartType: ChartType, options: ChartWidgetOpti }, }; - if (options.horizontal && !isPieType) { - (chartOptions as Record)['indexAxis'] = 'y'; + if (!isPieType) { + (chartOptions as Record)['indexAxis'] = options.horizontal ? 'y' : 'x'; } // Scales for non-pie types From 3021b9117e710d1063966455b7ea1740e433aebc Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 12 Feb 2026 10:32:33 +0000 Subject: [PATCH 4/6] Replace color text inputs with native color picker widgets Series color and custom palette now use pickers instead of text inputs. Added rgba-to-hex conversion for display, and changed palette from a textarea to a visual array of color swatches with add/remove controls. Co-Authored-By: Claude Opus 4.6 --- .../chart-edit/chart-edit.component.css | 118 +++++++++++++++++- .../chart-edit/chart-edit.component.html | 45 +++++-- .../charts/chart-edit/chart-edit.component.ts | 41 ++++-- 3 files changed, 176 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css index e64105bf8..158978ea6 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css @@ -335,8 +335,118 @@ min-width: 120px; } -.series-field--color { - max-width: 200px; +/* Color picker */ +.series-color-picker { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 80px; +} + +.color-picker-label { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); +} + +@media (prefers-color-scheme: dark) { + .color-picker-label { + color: rgba(255, 255, 255, 0.6); + } +} + +.color-picker-row { + display: flex; + align-items: center; + gap: 6px; +} + +.color-input { + width: 36px; + height: 36px; + padding: 2px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + cursor: pointer; + background: none; +} + +@media (prefers-color-scheme: dark) { + .color-input { + border-color: rgba(255, 255, 255, 0.12); + } +} + +.color-input::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.color-input::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.color-input::-moz-color-swatch { + border: none; + border-radius: 2px; +} + +.color-reset-button { + width: 24px !important; + height: 24px !important; + line-height: 24px !important; +} + +.color-reset-button mat-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + +.color-auto-label { + font-size: 12px; + color: rgba(0, 0, 0, 0.38); +} + +@media (prefers-color-scheme: dark) { + .color-auto-label { + color: rgba(255, 255, 255, 0.38); + } +} + +/* Palette colors */ +.palette-colors { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.palette-color-item { + display: flex; + align-items: center; + gap: 2px; +} + +.color-remove-button { + width: 24px !important; + height: 24px !important; + line-height: 24px !important; +} + +.color-remove-button mat-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + +.add-color-button { + font-size: 13px; + margin-top: 4px; +} + +.add-color-button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; } .series-field--small { @@ -390,7 +500,3 @@ color: rgba(255, 255, 255, 0.6); } } - -.palette-field { - width: 100%; -} diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index 633caeb15..ba2f215d9 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -154,10 +154,23 @@

Data Series

@if (!isPieType()) { - - Color - - +
+ Color +
+ + @if (series.color) { + + } + @if (!series.color) { + Auto + } +
+
} @if (!isPieType() && chartType() !== 'bar') { @@ -407,14 +420,22 @@

X-Axis

- - Colors (one per line) - - +
+ @for (color of colorPalette(); track $index; let i = $index) { +
+ + +
+ } +
+
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 100dd01ec..f5fdeb468 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 @@ -118,7 +118,7 @@ export class ChartEditComponent implements OnInit { protected limit = signal(null); // Color palette - protected colorPaletteText = signal(''); + protected colorPalette = signal([]); public chartTypes: { value: ChartType; label: string }[] = [ { value: 'bar', label: 'Bar Chart' }, @@ -338,14 +338,8 @@ export class ChartEditComponent implements OnInit { if (this.sortBy() !== 'none') options.sort_by = this.sortBy(); if (this.limit() !== null && this.limit()! > 0) options.limit = this.limit()!; - const paletteText = this.colorPaletteText().trim(); - if (paletteText) { - const colors = paletteText - .split('\n') - .map((c) => c.trim()) - .filter((c) => c); - if (colors.length > 0) options.color_palette = colors; - } + const palette = this.colorPalette(); + if (palette.length > 0) options.color_palette = palette; return options; }); @@ -465,7 +459,7 @@ export class ChartEditComponent implements OnInit { // Color palette if (opts.color_palette?.length) { - this.colorPaletteText.set(opts.color_palette.join('\n')); + this.colorPalette.set([...opts.color_palette]); } } @@ -530,6 +524,33 @@ export class ChartEditComponent implements OnInit { }); } + 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; + }); + } + saveQuery(): void { if (!this.queryName().trim() || !this.queryText().trim()) { return; From 26de59832a7c20f3a2194a9edb38775edf36b223 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 12 Feb 2026 14:01:03 +0000 Subject: [PATCH 5/6] Fix actions bar to span full width outside chart-edit-page Move .actions div outside .chart-edit-page so it sits directly in .chart-edit-main, matching the main branch layout where the sticky bottom bar spans the full panel width. Co-Authored-By: Claude Opus 4.6 --- .../chart-edit/chart-edit.component.html | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index 698eb98c6..14f4a35db 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -491,21 +491,21 @@

Query results ({{ testResults().length }} rows)

}
} - - @if (!loading()) { -
- Back - -
- }
+ + @if (!loading()) { +
+ Back + +
+ }
From 3e9aa5935b8c4bee4a79f04664199890ec701e65 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 13 Feb 2026 12:35:03 +0000 Subject: [PATCH 6/6] Add series column support for auto-pivoted grouped bar charts Allow users to pick a categorical column that automatically creates one dataset per unique value (pivot operation), instead of manually adding series. Also export DEFAULT_COLOR_PALETTE and add a "Load defaults" button in the color palette section. Co-Authored-By: Claude Opus 4.6 --- .../chart-edit/chart-edit.component.css | 19 +++- .../chart-edit/chart-edit.component.html | 51 ++++++++- .../charts/chart-edit/chart-edit.component.ts | 28 ++++- .../chart-preview/chart-preview.component.ts | 3 +- .../chart-widget/chart-widget.component.ts | 3 +- frontend/src/app/lib/chart-config.helper.ts | 105 ++++++++++++++++-- frontend/src/app/models/saved-query.ts | 1 + 7 files changed, 190 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css index 07f2c048a..f94ab0391 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css @@ -452,11 +452,28 @@ height: 14px; } -.add-color-button { +.series-mode-field { + min-width: 160px; font-size: 13px; +} + +.series-column-config { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.palette-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; margin-top: 4px; } +.add-color-button { + font-size: 13px; +} + .add-color-button mat-icon { font-size: 18px; width: 18px; diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index 14f4a35db..f37748f0b 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -117,12 +117,47 @@

Chart configuration

Data series

+ @if (!isPieType()) { + + Series mode + + Manual + Series from column + + + } +
+ + @if (seriesMode() === 'column' && !isPieType()) { +
+ + Series column + + @for (col of resultColumns(); track col) { + {{ col }} + } + + Categorical column to split into datasets + + + + Value column + + @for (col of resultColumns(); track col) { + {{ col }} + } + + Numeric column to chart + +
+ } + + @if (seriesMode() === 'manual' || isPieType()) { @if (!isPieType() || seriesList().length === 0) { } -
@for (series of seriesList(); track $index; let i = $index) {
@@ -213,6 +248,7 @@

Data series

}
} + } @@ -430,9 +466,16 @@

X-Axis

} - +
+ + @if (colorPalette().length === 0) { + + } +
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 11d34a271..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 @@ -18,6 +18,7 @@ import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2 } from 'angulartics2'; import posthog from 'posthog-js'; import { finalize } from 'rxjs/operators'; +import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper'; import { ChartAxisConfig, ChartLegendConfig, @@ -82,7 +83,10 @@ export class ChartEditComponent implements OnInit { 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); @@ -286,6 +290,9 @@ export class ChartEditComponent implements OnInit { 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; }); @@ -296,9 +303,14 @@ export class ChartEditComponent implements OnInit { label_type: this.labelType(), }; - const series = this.seriesList(); - if (series.length > 0) { - options.series = series; + 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; @@ -399,7 +411,11 @@ export class ChartEditComponent implements OnInit { if (opts.label_type) this.labelType.set(opts.label_type); // Load series - if (opts.series?.length) { + 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 @@ -553,6 +569,10 @@ export class ChartEditComponent implements OnInit { }); } + initializeDefaultPalette(): void { + this.colorPalette.set([...DEFAULT_COLOR_PALETTE]); + } + saveQuery(): void { if (!this.queryName().trim() || !this.queryText().trim()) { return; 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 90fd80e4f..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 @@ -67,8 +67,7 @@ export class ChartPreviewComponent implements OnChanges { this.chartData = buildChartData(this.data, this.chartType, options); this.chartOptions = buildChartOptions(this.chartType, options); - const plugin = buildDataLabelsPlugin(options); - this.chartPlugins = plugin ? [plugin] : []; + this.chartPlugins = [buildDataLabelsPlugin(options)]; } private _resolveOptions(): ChartWidgetOptions { 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 2c65791d8..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 @@ -55,8 +55,7 @@ export class ChartWidgetComponent implements OnInit { if (!query) return []; const widgetOptions = (query.widget_options ?? {}) as unknown as ChartWidgetOptions; - const plugin = buildDataLabelsPlugin(widgetOptions); - return plugin ? [plugin] : []; + return [buildDataLabelsPlugin(widgetOptions)]; }); ngOnInit(): void { diff --git a/frontend/src/app/lib/chart-config.helper.ts b/frontend/src/app/lib/chart-config.helper.ts index 073ac402c..df5f95b10 100644 --- a/frontend/src/app/lib/chart-config.helper.ts +++ b/frontend/src/app/lib/chart-config.helper.ts @@ -9,7 +9,7 @@ import { ChartWidgetOptions, } from '../models/saved-query'; -const DEFAULT_COLOR_PALETTE = [ +export const DEFAULT_COLOR_PALETTE = [ 'rgba(99, 102, 241, 0.8)', 'rgba(168, 85, 247, 0.8)', 'rgba(236, 72, 153, 0.8)', @@ -108,6 +108,91 @@ function sortData( 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, @@ -115,8 +200,14 @@ export function buildChartData( ): ChartData | null { if (!rawData.length) return null; - const palette = options.color_palette ?? DEFAULT_COLOR_PALETTE; 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; @@ -284,7 +375,7 @@ export function buildChartOptions(chartType: ChartType, options: ChartWidgetOpti callbacks: { label: (context) => { const seriesIndex = context.datasetIndex ?? 0; - const seriesConfig = seriesConfigs[seriesIndex]; + 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; @@ -358,9 +449,7 @@ export function buildChartOptions(chartType: ChartType, options: ChartWidgetOpti return chartOptions; } -export function buildDataLabelsPlugin(options: ChartWidgetOptions): Plugin | null { - if (!options.show_data_labels) return null; - +export function buildDataLabelsPlugin(options: ChartWidgetOptions): Plugin { const globalUnits = options.units; const globalFormat = options.number_format; @@ -373,6 +462,8 @@ export function buildDataLabelsPlugin(options: ChartWidgetOptions): Plugin | nul return { id: 'customDataLabels', afterDatasetsDraw(chart) { + if (!options.show_data_labels) return; + const { ctx } = chart; ctx.save(); ctx.font = '11px sans-serif'; @@ -381,7 +472,7 @@ export function buildDataLabelsPlugin(options: ChartWidgetOptions): Plugin | nul chart.data.datasets.forEach((dataset, datasetIndex) => { const meta = chart.getDatasetMeta(datasetIndex); - const seriesConfig = seriesConfigs[datasetIndex]; + const seriesConfig = options.series_column ? undefined : seriesConfigs[datasetIndex]; const units = seriesConfig?.units ?? globalUnits; const numFormat = seriesConfig?.number_format ?? globalFormat; diff --git a/frontend/src/app/models/saved-query.ts b/frontend/src/app/models/saved-query.ts index 9bfab0c29..b9b914c7e 100644 --- a/frontend/src/app/models/saved-query.ts +++ b/frontend/src/app/models/saved-query.ts @@ -45,6 +45,7 @@ export interface ChartWidgetOptions { value_column?: string; label_type?: 'values' | 'datetime'; series?: ChartSeriesConfig[]; + series_column?: string; units?: ChartUnitConfig; number_format?: ChartNumberFormatConfig; color_palette?: string[];