diff --git a/docs/timeserieschart/model.md b/docs/timeserieschart/model.md index 7060225a7..5b3e92e9c 100644 --- a/docs/timeserieschart/model.md +++ b/docs/timeserieschart/model.md @@ -77,4 +77,23 @@ queryIndex: colorMode: # colorValue is an hexadecimal color code colorValue: +# lineStyle overrides the panel-level line style for this query's series +lineStyle: # Optional +# areaOpacity overrides the panel-level area opacity for this query's series (between 0 and 1) +areaOpacity: # Optional +# format overrides the panel-level Y-axis format for this query's series, creating a secondary Y axis when the unit differs +format: # Optional +# negativeY renders the query's series below the X axis. Values are negated for display only; +# legend calculations and CSV export keep the original (positive) values. +negativeY: # Optional ``` + +### Negative Y edge cases & compatibility + +- **Stacking (`visual.stack: "all"`)**: supported. Positive-Y series stack upward from zero, negative-Y series stack downward. +- **Bar charts (`visual.display: "bar"`)**: supported. Bars render below the X axis. +- **Percent thresholds (`thresholds.mode: "percent"`)**: computed against the magnitude of the data, so percent thresholds keep working with negated series. +- **Multi-Y axis (per-query `format`)**: each axis auto-fits independently; the secondary axis renders negatives below zero when applicable. +- **CSV export**: exports the original positive values from the query results. +- **Legend calculations (min / max / mean / …)**: computed from the original positive values. +- **Log axis (`yAxis.logBase`)**: not compatible. Logarithmic scales do not support non-positive values; flipped points are dropped from the rendering. diff --git a/timeserieschart/schemas/migrate/migrate.cue b/timeserieschart/schemas/migrate/migrate.cue index 91ad205f5..4e683fdcd 100644 --- a/timeserieschart/schemas/migrate/migrate.cue +++ b/timeserieschart/schemas/migrate/migrate.cue @@ -189,10 +189,15 @@ spec: { visual: stack: "all" } + #panelTransform: *#panel.fieldConfig.defaults.custom.transform | null + // migrate fixedColor overrides to querySettings when applicable #querySettings: [ for i, target in (*#panel.targets | []) { queryIndex: i + if #panelTransform == "negative-Y" { + negativeY: true + } for override in (*#panel.fieldConfig.overrides | []) if (override.matcher.id == "byName" || override.matcher.id == "byRegexp" || override.matcher.id == "byFrameRefID") && override.matcher.options != _|_ for property in override.properties @@ -218,6 +223,9 @@ spec: { format: unit: #queryUnit } } + if property.id == "custom.transform" if property.value == "negative-Y" { + negativeY: true + } } }, ] diff --git a/timeserieschart/schemas/migrate/tests/negative-y-override/expected.json b/timeserieschart/schemas/migrate/tests/negative-y-override/expected.json new file mode 100644 index 000000000..e3b056903 --- /dev/null +++ b/timeserieschart/schemas/migrate/tests/negative-y-override/expected.json @@ -0,0 +1,20 @@ +{ + "kind": "TimeSeriesChart", + "spec": { + "legend": { + "position": "bottom", + "mode": "list" + }, + "visual": { + "lineWidth": 1, + "areaOpacity": 0, + "display": "line" + }, + "querySettings": [ + { + "queryIndex": 1, + "negativeY": true + } + ] + } +} diff --git a/timeserieschart/schemas/migrate/tests/negative-y-override/input.json b/timeserieschart/schemas/migrate/tests/negative-y-override/input.json new file mode 100644 index 000000000..2f843aff3 --- /dev/null +++ b/timeserieschart/schemas/migrate/tests/negative-y-override/input.json @@ -0,0 +1,51 @@ +{ + "id": 1, + "type": "timeseries", + "title": "Negative Y override", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 0, + "stacking": { + "mode": "none", + "group": "A" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "custom.transform", + "value": "negative-Y" + } + ] + } + ] + }, + "targets": [ + { + "expr": "up", + "legendFormat": "up", + "refId": "A" + }, + { + "expr": "rate(node_network_receive_bytes_total[1m])", + "legendFormat": "rx", + "refId": "B" + } + ], + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + } + } +} diff --git a/timeserieschart/schemas/migrate/tests/negative-y-panel-default/expected.json b/timeserieschart/schemas/migrate/tests/negative-y-panel-default/expected.json new file mode 100644 index 000000000..72d627c88 --- /dev/null +++ b/timeserieschart/schemas/migrate/tests/negative-y-panel-default/expected.json @@ -0,0 +1,24 @@ +{ + "kind": "TimeSeriesChart", + "spec": { + "legend": { + "position": "bottom", + "mode": "list" + }, + "visual": { + "lineWidth": 1, + "areaOpacity": 0, + "display": "line" + }, + "querySettings": [ + { + "queryIndex": 0, + "negativeY": true + }, + { + "queryIndex": 1, + "negativeY": true + } + ] + } +} diff --git a/timeserieschart/schemas/migrate/tests/negative-y-panel-default/input.json b/timeserieschart/schemas/migrate/tests/negative-y-panel-default/input.json new file mode 100644 index 000000000..b400bfa62 --- /dev/null +++ b/timeserieschart/schemas/migrate/tests/negative-y-panel-default/input.json @@ -0,0 +1,39 @@ +{ + "id": 2, + "type": "timeseries", + "title": "Negative Y panel default", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 0, + "transform": "negative-Y", + "stacking": { + "mode": "none", + "group": "A" + } + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "metric_one", + "legendFormat": "one", + "refId": "A" + }, + { + "expr": "metric_two", + "legendFormat": "two", + "refId": "B" + } + ], + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + } + } +} diff --git a/timeserieschart/schemas/tests/valid/time-series-negative-y-stacked.json b/timeserieschart/schemas/tests/valid/time-series-negative-y-stacked.json new file mode 100644 index 000000000..7b91bee81 --- /dev/null +++ b/timeserieschart/schemas/tests/valid/time-series-negative-y-stacked.json @@ -0,0 +1,19 @@ +{ + "kind": "TimeSeriesChart", + "spec": { + "visual": { + "display": "line", + "stack": "all", + "areaOpacity": 0.3 + }, + "querySettings": [ + { + "queryIndex": 0 + }, + { + "queryIndex": 1, + "negativeY": true + } + ] + } +} diff --git a/timeserieschart/schemas/tests/valid/time-series-negative-y.json b/timeserieschart/schemas/tests/valid/time-series-negative-y.json new file mode 100644 index 000000000..de0a6e1b1 --- /dev/null +++ b/timeserieschart/schemas/tests/valid/time-series-negative-y.json @@ -0,0 +1,11 @@ +{ + "kind": "TimeSeriesChart", + "spec": { + "querySettings": [ + { + "queryIndex": 0, + "negativeY": true + } + ] + } +} diff --git a/timeserieschart/schemas/time-series.cue b/timeserieschart/schemas/time-series.cue index 80bdc7c38..a042a04b4 100644 --- a/timeserieschart/schemas/time-series.cue +++ b/timeserieschart/schemas/time-series.cue @@ -66,6 +66,7 @@ spec: close({ lineStyle?: #lineStyle areaOpacity?: #areaOpacity format?: common.#format + negativeY?: bool // render the query's series below the X axis }] #lineStyle: "solid" | "dashed" | "dotted" diff --git a/timeserieschart/sdk/go/time-series.go b/timeserieschart/sdk/go/time-series.go index 3f63ba1c9..dbaabaa35 100644 --- a/timeserieschart/sdk/go/time-series.go +++ b/timeserieschart/sdk/go/time-series.go @@ -127,6 +127,10 @@ type QuerySettingsItem struct { LineStyle string `json:"lineStyle,omitempty" yaml:"lineStyle,omitempty"` AreaOpacity float64 `json:"areaOpacity,omitempty" yaml:"areaOpacity,omitempty"` Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"` + // NegativeY, when true, renders the query's series below the X axis (values + // are negated for display only; legend calculations and CSV export keep the + // original values). + NegativeY bool `json:"negativeY,omitempty" yaml:"negativeY,omitempty"` } type Option func(plugin *Builder) error diff --git a/timeserieschart/src/QuerySettingsEditor.tsx b/timeserieschart/src/QuerySettingsEditor.tsx index 9de8b8d3a..cc16f37a4 100644 --- a/timeserieschart/src/QuerySettingsEditor.tsx +++ b/timeserieschart/src/QuerySettingsEditor.tsx @@ -223,6 +223,18 @@ export function QuerySettingsEditor(props: TimeSeriesChartOptionsEditorProps): R }); }; + const addNegativeY = (i: number): void => { + updateQuerySettings(i, (qs) => { + qs.negativeY = true; + }); + }; + + const removeNegativeY = (i: number): void => { + updateQuerySettings(i, (qs) => { + qs.negativeY = undefined; + }); + }; + const queryCount = useQueryCountContext(); // Compute the list of query indexes for which query settings are not already defined. @@ -287,6 +299,8 @@ export function QuerySettingsEditor(props: TimeSeriesChartOptionsEditorProps): R onAddFormat={() => addFormat(i)} onRemoveFormat={() => removeFormat(i)} onFormatChange={(format) => handleFormatChange(i, format)} + onAddNegativeY={() => addNegativeY(i)} + onRemoveNegativeY={() => removeNegativeY(i)} /> )) )} @@ -319,10 +333,12 @@ interface QuerySettingsInputProps { onAddFormat: () => void; onRemoveFormat: () => void; onFormatChange: (format?: FormatOptions) => void; + onAddNegativeY: () => void; + onRemoveNegativeY: () => void; } function QuerySettingsInput({ - querySettings: { queryIndex, colorMode, colorValue, lineStyle, areaOpacity, format }, + querySettings: { queryIndex, colorMode, colorValue, lineStyle, areaOpacity, format, negativeY }, availableQueryIndexes, onQueryIndexChange, onColorModeChange, @@ -340,6 +356,8 @@ function QuerySettingsInput({ onAddFormat, onRemoveFormat, onFormatChange, + onAddNegativeY, + onRemoveNegativeY, }: QuerySettingsInputProps): ReactElement { // current query index should also be selectable const selectableQueryIndexes = availableQueryIndexes.concat(queryIndex).sort((a, b) => a - b); @@ -354,8 +372,20 @@ function QuerySettingsInput({ if (!lineStyle) options.push({ key: 'lineStyle', label: 'Line Style', action: onAddLineStyle }); if (areaOpacity === undefined) options.push({ key: 'opacity', label: 'Opacity', action: onAddAreaOpacity }); if (format === undefined) options.push({ key: 'format', label: 'Format', action: onAddFormat }); + if (!negativeY) options.push({ key: 'negativeY', label: 'Negative Y', action: onAddNegativeY }); return options; - }, [colorMode, lineStyle, areaOpacity, format, onAddColor, onAddLineStyle, onAddAreaOpacity, onAddFormat]); + }, [ + colorMode, + lineStyle, + areaOpacity, + format, + negativeY, + onAddColor, + onAddLineStyle, + onAddAreaOpacity, + onAddFormat, + onAddNegativeY, + ]); const handleAddMenuClick = (event: React.MouseEvent): void => { if (availableOptions.length === 1 && availableOptions[0]) { @@ -472,6 +502,15 @@ function QuerySettingsInput({ )} + {/* Negative Y section (presence-only flag) */} + {negativeY && ( + + + Series rendered below the X axis + + + )} + {/* Add Options Button - only show if there are available options */} {availableOptions.length > 0 && ( <> diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index e3e6b511c..429ac52a7 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -278,9 +278,21 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement } } + // When negativeY is set on this query, negate the rendered values so the + // series renders below the X axis. The original (positive) values are + // preserved in `timeSeries.values` (used for legend calculations) and in + // `queryResults` (used for CSV export). + const baseValues: TimeSeriesValueTuple[] = getTimeSeriesValues(timeSeries, timeScale); + const renderedValues: TimeSeriesValueTuple[] = querySettings?.negativeY + ? baseValues.map((tuple: TimeSeriesValueTuple): TimeSeriesValueTuple => { + const [t, v] = tuple; + return [t, v === null ? null : -v]; + }) + : baseValues; + timeChartData.push({ name: formattedSeriesName, - values: getTimeSeriesValues(timeSeries, timeScale), + values: renderedValues, }); } diff --git a/timeserieschart/src/time-series-chart-model.ts b/timeserieschart/src/time-series-chart-model.ts index d0458b541..c30ba7d21 100644 --- a/timeserieschart/src/time-series-chart-model.ts +++ b/timeserieschart/src/time-series-chart-model.ts @@ -46,6 +46,12 @@ export interface QuerySettingsOptions { lineStyle?: LineStyleType; areaOpacity?: number; format?: FormatOptions; + /** + * If true, the query's series values are negated for display so they render + * below the X axis. The original (positive) values are preserved for legend + * calculations and CSV export, matching Grafana's `transform: "negative-Y"` behavior. + */ + negativeY?: boolean; } export type TimeSeriesChartOptionsEditorProps = OptionsEditorProps; diff --git a/timeserieschart/src/utils/data-transform.test.ts b/timeserieschart/src/utils/data-transform.test.ts index 87c1decdc..c3239723f 100644 --- a/timeserieschart/src/utils/data-transform.test.ts +++ b/timeserieschart/src/utils/data-transform.test.ts @@ -40,6 +40,13 @@ describe('convertPercentThreshold', () => { const value = convertPercentThreshold(50, MOCK_ECHART_TIME_SERIES_DATA); expect(value).toEqual(0.5 * MAX_VALUE); }); + + it('should use absolute values when finding the max so negated series (negativeY) still resolve correctly', () => { + // Simulates a series that was negated for display (querySettings.negativeY = true) + const negatedSeries: LegacyTimeSeries[] = [{ data: [-10, -30, -80, -50] }, { data: [-20, -MAX_VALUE, -17, -30] }]; + const value = convertPercentThreshold(50, negatedSeries); + expect(value).toEqual(0.5 * MAX_VALUE); + }); }); describe('convertPanelYAxis', () => { diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index 4048737aa..a4057de06 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -206,8 +206,10 @@ function findMax(data: LegacyTimeSeries[] | TimeSeries[]): number { series.values.forEach((valueTuple: TimeSeriesValueTuple) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, value] = valueTuple; - if (typeof value === 'number' && value > max) { - max = value; + // Use the absolute value so percent thresholds compute correctly against + // negated series (e.g. when `querySettings[].negativeY` is enabled). + if (typeof value === 'number' && Math.abs(value) > max) { + max = Math.abs(value); } }); }); @@ -215,8 +217,8 @@ function findMax(data: LegacyTimeSeries[] | TimeSeries[]): number { (data as LegacyTimeSeries[]).forEach((series) => { if (series.data !== undefined) { series.data.forEach((value: EChartsValues) => { - if (typeof value === 'number' && value > max) { - max = value; + if (typeof value === 'number' && Math.abs(value) > max) { + max = Math.abs(value); } }); }