Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/timeserieschart/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,23 @@ queryIndex: <number>
colorMode: <enum = "fixed" | "fixed-single">
# colorValue is an hexadecimal color code
colorValue: <string>
# lineStyle overrides the panel-level line style for this query's series
lineStyle: <enum = "solid" | "dashed" | "dotted"> # Optional
# areaOpacity overrides the panel-level area opacity for this query's series (between 0 and 1)
areaOpacity: <number> # Optional
# format overrides the panel-level Y-axis format for this query's series, creating a secondary Y axis when the unit differs
format: <Format specification> # 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: <boolean> # Optional
Comment on lines +86 to +88
```

### 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.
Comment on lines +97 to +98
- **Log axis (`yAxis.logBase`)**: not compatible. Logarithmic scales do not support non-positive values; flipped points are dropped from the rendering.
8 changes: 8 additions & 0 deletions timeserieschart/schemas/migrate/migrate.cue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -218,6 +223,9 @@ spec: {
format: unit: #queryUnit
}
}
if property.id == "custom.transform" if property.value == "negative-Y" {
negativeY: true
}
}
},
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"kind": "TimeSeriesChart",
"spec": {
"legend": {
"position": "bottom",
"mode": "list"
},
"visual": {
"lineWidth": 1,
"areaOpacity": 0,
"display": "line"
},
"querySettings": [
{
"queryIndex": 1,
"negativeY": true
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"kind": "TimeSeriesChart",
"spec": {
"visual": {
"display": "line",
"stack": "all",
"areaOpacity": 0.3
},
"querySettings": [
{
"queryIndex": 0
},
{
"queryIndex": 1,
"negativeY": true
}
]
}
}
11 changes: 11 additions & 0 deletions timeserieschart/schemas/tests/valid/time-series-negative-y.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"kind": "TimeSeriesChart",
"spec": {
"querySettings": [
{
"queryIndex": 0,
"negativeY": true
}
]
}
}
1 change: 1 addition & 0 deletions timeserieschart/schemas/time-series.cue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions timeserieschart/sdk/go/time-series.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions timeserieschart/src/QuerySettingsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)}
/>
))
)}
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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<HTMLElement>): void => {
if (availableOptions.length === 1 && availableOptions[0]) {
Expand Down Expand Up @@ -472,6 +502,15 @@ function QuerySettingsInput({
</SettingsSection>
)}

{/* Negative Y section (presence-only flag) */}
{negativeY && (
<SettingsSection label="Negative Y" onRemove={onRemoveNegativeY}>
<Typography variant="body2" color="text.secondary" sx={{ flexGrow: 1, py: 0.5 }}>
Series rendered below the X axis
</Typography>
Comment on lines +508 to +510
</SettingsSection>
)}

{/* Add Options Button - only show if there are available options */}
{availableOptions.length > 0 && (
<>
Expand Down
14 changes: 13 additions & 1 deletion timeserieschart/src/TimeSeriesChartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +281 to +284
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,
});
}

Expand Down
6 changes: 6 additions & 0 deletions timeserieschart/src/time-series-chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment on lines +49 to +53
negativeY?: boolean;
}

export type TimeSeriesChartOptionsEditorProps = OptionsEditorProps<TimeSeriesChartOptions>;
Expand Down
7 changes: 7 additions & 0 deletions timeserieschart/src/utils/data-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading