Skip to content

Commit f351c08

Browse files
committed
feat(timeseries): percent-change rebasing with a draggable baseline
Restores the legacy nvd3 "Time-series Percent Change" view on the ECharts line chart. A new "Percent change" control rebases every series client-side to its percent change from the first point (y/y0 - 1, computed in transformProps like the nvd3 renderer did), with the y-axis and tooltips switching to percent formatting via the existing forcePercentFormatter path. A draggable vertical baseline is installed on the chart; dragging it re-indexes every series to the hovered point using the composable rebase identity (1+v)/(1+vk) - 1, so no re-query or raw-data retention is needed. MigrateCompareChart sets the flag, so saved compare charts regain their percent-change view instead of degrading to a plain line chart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 65d2ea5 commit f351c08

7 files changed

Lines changed: 323 additions & 2 deletions

File tree

‎superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import type GlobalModel from 'echarts/types/src/model/Global';
3535
import type ComponentModel from 'echarts/types/src/model/Component';
3636
import { EchartsHandler, EventHandlers } from '../types';
3737
import Echart from '../components/Echart';
38+
import {
39+
rebaseSeriesData,
40+
snapToNearestX,
41+
SeriesDataPoint,
42+
} from './percentChange';
3843
import { OrientationType, TimeseriesChartTransformedProps } from './types';
3944
import { formatSeriesName } from '../utils/series';
4045
import { ExtraControls } from '../components/ExtraControls';
@@ -67,6 +72,92 @@ export default function EchartsTimeseries({
6772
// eslint-disable-next-line no-param-reassign
6873
refs.echartRef = echartRef;
6974
const clickTimer = useRef<ReturnType<typeof setTimeout>>();
75+
76+
// Draggable percent-change baseline: when the rebase view is active, a
77+
// vertical line is drawn on the plot; dragging it re-indexes every series
78+
// to the hovered point via the composable rebase, entirely client-side.
79+
const rebaseEnabled = Boolean(
80+
(formData as { rebasePercentChange?: boolean }).rebasePercentChange,
81+
);
82+
useEffect(() => {
83+
if (!rebaseEnabled) return undefined;
84+
const chart = echartRef.current?.getEchartInstance?.();
85+
if (!chart) return undefined;
86+
87+
const option = chart.getOption() as {
88+
series?: { data?: SeriesDataPoint[] }[];
89+
};
90+
const baseSeries = (option.series ?? []).map(s =>
91+
Array.isArray(s.data) ? (s.data as SeriesDataPoint[]) : [],
92+
);
93+
const xs = Array.from(
94+
new Set(baseSeries.flat().map(([x]) => Number(x))),
95+
).sort((a, b) => a - b);
96+
if (xs.length === 0) return undefined;
97+
let baselineX = xs[0];
98+
99+
const applyBaseline = (newX: number) => {
100+
baselineX = newX;
101+
chart.setOption({
102+
series: baseSeries.map(data => ({
103+
data: rebaseSeriesData(data, newX),
104+
})),
105+
});
106+
};
107+
108+
const drawHandle = () => {
109+
const gridRect = { top: 0, height: chart.getHeight() };
110+
let px: number;
111+
try {
112+
[px] = [chart.convertToPixel({ xAxisIndex: 0 }, baselineX) as number];
113+
} catch {
114+
return;
115+
}
116+
chart.setOption({
117+
graphic: [
118+
{
119+
id: 'percent-change-baseline',
120+
type: 'rect',
121+
x: px - 4,
122+
y: gridRect.top,
123+
shape: { width: 8, height: gridRect.height },
124+
style: { fill: 'rgba(0, 0, 0, 0.02)' },
125+
cursor: 'ew-resize',
126+
draggable: true,
127+
z: 100,
128+
ondrag(this: { x: number; y: number }) {
129+
this.y = gridRect.top;
130+
const dataX = chart.convertFromPixel(
131+
{ xAxisIndex: 0 },
132+
this.x + 4,
133+
) as number;
134+
const snapped = snapToNearestX(xs, dataX);
135+
if (snapped !== undefined && snapped !== baselineX) {
136+
applyBaseline(snapped);
137+
}
138+
},
139+
ondragend: () => drawHandle(),
140+
children: [
141+
{
142+
type: 'rect',
143+
left: 'center',
144+
top: 0,
145+
shape: { width: 2, height: gridRect.height },
146+
style: { fill: '#666' },
147+
},
148+
],
149+
},
150+
],
151+
});
152+
};
153+
drawHandle();
154+
155+
return () => {
156+
chart.setOption({
157+
graphic: [{ id: 'percent-change-baseline', $action: 'remove' }],
158+
});
159+
};
160+
}, [rebaseEnabled, echartOptions, width, height]);
70161
const extraControlRef = useRef<HTMLDivElement>(null);
71162
const [extraControlHeight, setExtraControlHeight] = useState(0);
72163
useEffect(() => {

‎superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ const config: ControlPanelConfig = {
106106
},
107107
},
108108
],
109+
[
110+
{
111+
name: 'rebase_percent_change',
112+
config: {
113+
type: 'CheckboxControl',
114+
label: t('Percent change'),
115+
renderTrigger: true,
116+
default: false,
117+
description: t(
118+
'Rebase each series to its percent change from a baseline ' +
119+
'point, like the legacy Time-series Percent Change chart. ' +
120+
'Drag the vertical baseline on the chart to re-index.',
121+
),
122+
},
123+
},
124+
],
109125
[
110126
{
111127
name: 'markerEnabled',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { DataRecord } from '@superset-ui/core';
20+
21+
/**
22+
* Rebases every non-x column of a timeseries result to its percent change
23+
* from that column's first non-null value, restoring the view the legacy
24+
* nvd3 "Time-series Percent Change" chart computed in the renderer:
25+
* y' = y / y0 - 1. Columns whose baseline is 0 (or missing) yield nulls
26+
* since a percent change from zero is undefined.
27+
*/
28+
export function rebaseToPercentChange(
29+
records: DataRecord[],
30+
xAxis: string,
31+
): DataRecord[] {
32+
const baselines: Record<string, number> = {};
33+
records.forEach(record => {
34+
Object.entries(record).forEach(([key, value]) => {
35+
if (
36+
key !== xAxis &&
37+
!(key in baselines) &&
38+
typeof value === 'number' &&
39+
!Number.isNaN(value)
40+
) {
41+
baselines[key] = value;
42+
}
43+
});
44+
});
45+
46+
return records.map(record => {
47+
const rebased: DataRecord = { [xAxis]: record[xAxis] };
48+
Object.entries(record).forEach(([key, value]) => {
49+
if (key === xAxis) return;
50+
const baseline = baselines[key];
51+
rebased[key] =
52+
typeof value === 'number' &&
53+
!Number.isNaN(value) &&
54+
baseline !== undefined &&
55+
baseline !== 0
56+
? value / baseline - 1
57+
: null;
58+
});
59+
return rebased;
60+
});
61+
}
62+
63+
export type SeriesDataPoint = [number | string, number | null];
64+
65+
/**
66+
* Re-indexes already-rebased series data to a new baseline x. Because
67+
* percent-change rebasing is composable, v' = (1 + v) / (1 + vk) - 1
68+
* derives the new view directly from the currently displayed values,
69+
* where vk is the series value at the nearest x at or before the new
70+
* baseline (falling back to the first non-null point).
71+
*/
72+
export function rebaseSeriesData(
73+
data: SeriesDataPoint[],
74+
baselineX: number | string,
75+
): SeriesDataPoint[] {
76+
// the non-null point with the largest x at or before the baseline,
77+
// falling back to the first non-null point
78+
let baselineValue: number | null = null;
79+
data.forEach(([x, y]) => {
80+
if (y == null) return;
81+
if (x <= baselineX || baselineValue === null) {
82+
baselineValue = y;
83+
}
84+
});
85+
if (baselineValue === null || baselineValue === -1) {
86+
return data.map(([x]) => [x, null]);
87+
}
88+
const divisor = 1 + baselineValue;
89+
return data.map(([x, y]) => [x, y == null ? null : (1 + y) / divisor - 1]);
90+
}
91+
92+
/** Snaps a dragged x position to the nearest available data x. */
93+
export function snapToNearestX(
94+
xs: number[],
95+
target: number,
96+
): number | undefined {
97+
if (xs.length === 0) return undefined;
98+
return xs.reduce((best, x) =>
99+
Math.abs(x - target) < Math.abs(best - target) ? x : best,
100+
);
101+
}

‎superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
/* eslint-disable camelcase */
2020
import { invert } from 'lodash-es';
21+
import { rebaseToPercentChange } from './percentChange';
2122
import { t } from '@apache-superset/core/translation';
2223
import {
2324
AnnotationLayer,
@@ -290,14 +291,24 @@ export default function transformProps(
290291
return { ...acc, [entry[0]]: entry[1] };
291292
}, {});
292293
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
293-
const rebasedData = rebaseForecastDatum(data, verboseMap);
294+
const forecastRebasedData = rebaseForecastDatum(data, verboseMap);
294295
let xAxisLabel = getXAxisLabel(chartProps.rawFormData) as string;
295296
if (
296297
isPhysicalColumn(chartProps.rawFormData?.x_axis) &&
297298
isDefined(verboseMap[xAxisLabel])
298299
) {
299300
xAxisLabel = verboseMap[xAxisLabel];
300301
}
302+
// Restores the legacy nvd3 "Time-series Percent Change" view: every series
303+
// rebased client-side to its percent change from the first point. The
304+
// baseline can be re-indexed interactively via the draggable line the
305+
// chart component installs.
306+
const rebasePercentChange = Boolean(
307+
(formData as { rebasePercentChange?: boolean }).rebasePercentChange,
308+
);
309+
const rebasedData = rebasePercentChange
310+
? rebaseToPercentChange(forecastRebasedData, xAxisLabel)
311+
: forecastRebasedData;
301312
const isHorizontal = orientation === OrientationType.Horizontal;
302313
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
303314
rebasedData,
@@ -349,7 +360,9 @@ export default function transformProps(
349360
const isAreaExpand = stack === StackControlsValue.Expand;
350361
const series: SeriesOption[] = [];
351362

352-
const forcePercentFormatter = Boolean(contributionMode || isAreaExpand);
363+
const forcePercentFormatter = Boolean(
364+
contributionMode || isAreaExpand || rebasePercentChange,
365+
);
353366
const percentFormatter = forcePercentFormatter
354367
? getPercentFormatter(yAxisFormat)
355368
: getPercentFormatter(NumberFormats.PERCENT_2_POINT);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import {
20+
rebaseToPercentChange,
21+
rebaseSeriesData,
22+
snapToNearestX,
23+
} from '../../src/Timeseries/percentChange';
24+
25+
test('rebases each column to percent change from its first value', () => {
26+
const rebased = rebaseToPercentChange(
27+
[
28+
{ __timestamp: 1, a: 100, b: 50 },
29+
{ __timestamp: 2, a: 150, b: 25 },
30+
{ __timestamp: 3, a: 200, b: null },
31+
],
32+
'__timestamp',
33+
);
34+
expect(rebased).toEqual([
35+
{ __timestamp: 1, a: 0, b: 0 },
36+
{ __timestamp: 2, a: 0.5, b: -0.5 },
37+
{ __timestamp: 3, a: 1, b: null },
38+
]);
39+
});
40+
41+
test('skips leading nulls when picking the baseline', () => {
42+
const rebased = rebaseToPercentChange(
43+
[
44+
{ __timestamp: 1, a: null },
45+
{ __timestamp: 2, a: 40 },
46+
{ __timestamp: 3, a: 60 },
47+
],
48+
'__timestamp',
49+
);
50+
expect(rebased[1].a).toBe(0);
51+
expect(rebased[2].a).toBeCloseTo(0.5);
52+
});
53+
54+
test('yields nulls when the baseline is zero', () => {
55+
const rebased = rebaseToPercentChange(
56+
[
57+
{ __timestamp: 1, a: 0 },
58+
{ __timestamp: 2, a: 10 },
59+
],
60+
'__timestamp',
61+
);
62+
expect(rebased.map(r => r.a)).toEqual([null, null]);
63+
});
64+
65+
test('re-indexing displayed data matches rebasing raw data to that point', () => {
66+
// raw 100, 150, 200 rebased to first point: 0, 0.5, 1
67+
const displayed: [number, number][] = [
68+
[1, 0],
69+
[2, 0.5],
70+
[3, 1],
71+
];
72+
// re-index to x=2 (raw 150): 100/150-1, 0, 200/150-1
73+
const reindexed = rebaseSeriesData(displayed, 2);
74+
expect(reindexed[0][1]).toBeCloseTo(100 / 150 - 1);
75+
expect(reindexed[1][1]).toBeCloseTo(0);
76+
expect(reindexed[2][1]).toBeCloseTo(200 / 150 - 1);
77+
});
78+
79+
test('re-indexing to the current baseline is a no-op', () => {
80+
const displayed: [number, number][] = [
81+
[1, 0],
82+
[2, 0.5],
83+
];
84+
expect(rebaseSeriesData(displayed, 1)).toEqual(displayed);
85+
});
86+
87+
test('snaps to the nearest available x', () => {
88+
expect(snapToNearestX([10, 20, 30], 24)).toBe(20);
89+
expect(snapToNearestX([10, 20, 30], 26)).toBe(30);
90+
expect(snapToNearestX([], 5)).toBeUndefined();
91+
});

‎superset/migrations/shared/migrate_viz/processors.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,13 @@ class MigrateCompareChart(TimeseriesChart):
392392
source_viz_type = "compare"
393393
target_viz_type = "echarts_timeseries_line"
394394

395+
def _pre_action(self) -> None:
396+
super()._pre_action()
397+
# Restore the percent-change view the nvd3 renderer computed
398+
# client-side; the ECharts line chart rebases series when this
399+
# flag is set and offers a draggable baseline to re-index.
400+
self.data["rebase_percent_change"] = True
401+
395402

396403
class MigrateAreaChart(TimeseriesChart):
397404
source_viz_type = "area"

‎tests/unit_tests/migrations/viz/nvd3_compare_chart_to_echarts_test.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
TARGET_FORM_DATA: dict[str, Any] = {
3131
"form_data_bak": SOURCE_FORM_DATA,
3232
"viz_type": "echarts_timeseries_line",
33+
# the percent-change view the nvd3 renderer computed client-side
34+
"rebase_percent_change": True,
3335
}
3436

3537

0 commit comments

Comments
 (0)