You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/20 16:22:29 UTC
(superset) 01/01: feat(timeseries): percent-change rebasing with a draggable baseline
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch compare-percent-change
in repository https://gitbox.apache.org/repos/asf/superset.git
commit f351c08e3a8df370060b5dd32fb9cbb52974edee
Author: Claude Code <no...@anthropic.com>
AuthorDate: Mon Jul 20 09:22:14 2026 -0700
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) <no...@anthropic.com>
---
.../src/Timeseries/EchartsTimeseries.tsx | 91 +++++++++++++++++++
.../src/Timeseries/Regular/Line/controlPanel.tsx | 16 ++++
.../src/Timeseries/percentChange.ts | 101 +++++++++++++++++++++
.../src/Timeseries/transformProps.ts | 17 +++-
.../test/Timeseries/percentChange.test.ts | 91 +++++++++++++++++++
.../migrations/shared/migrate_viz/processors.py | 7 ++
.../viz/nvd3_compare_chart_to_echarts_test.py | 2 +
7 files changed, 323 insertions(+), 2 deletions(-)
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
index 7011128b03d..fbf7a6f1687 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -35,6 +35,11 @@ import type GlobalModel from 'echarts/types/src/model/Global';
import type ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart';
+import {
+ rebaseSeriesData,
+ snapToNearestX,
+ SeriesDataPoint,
+} from './percentChange';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series';
import { ExtraControls } from '../components/ExtraControls';
@@ -67,6 +72,92 @@ export default function EchartsTimeseries({
// eslint-disable-next-line no-param-reassign
refs.echartRef = echartRef;
const clickTimer = useRef<ReturnType<typeof setTimeout>>();
+
+ // Draggable percent-change baseline: when the rebase view is active, a
+ // vertical line is drawn on the plot; dragging it re-indexes every series
+ // to the hovered point via the composable rebase, entirely client-side.
+ const rebaseEnabled = Boolean(
+ (formData as { rebasePercentChange?: boolean }).rebasePercentChange,
+ );
+ useEffect(() => {
+ if (!rebaseEnabled) return undefined;
+ const chart = echartRef.current?.getEchartInstance?.();
+ if (!chart) return undefined;
+
+ const option = chart.getOption() as {
+ series?: { data?: SeriesDataPoint[] }[];
+ };
+ const baseSeries = (option.series ?? []).map(s =>
+ Array.isArray(s.data) ? (s.data as SeriesDataPoint[]) : [],
+ );
+ const xs = Array.from(
+ new Set(baseSeries.flat().map(([x]) => Number(x))),
+ ).sort((a, b) => a - b);
+ if (xs.length === 0) return undefined;
+ let baselineX = xs[0];
+
+ const applyBaseline = (newX: number) => {
+ baselineX = newX;
+ chart.setOption({
+ series: baseSeries.map(data => ({
+ data: rebaseSeriesData(data, newX),
+ })),
+ });
+ };
+
+ const drawHandle = () => {
+ const gridRect = { top: 0, height: chart.getHeight() };
+ let px: number;
+ try {
+ [px] = [chart.convertToPixel({ xAxisIndex: 0 }, baselineX) as number];
+ } catch {
+ return;
+ }
+ chart.setOption({
+ graphic: [
+ {
+ id: 'percent-change-baseline',
+ type: 'rect',
+ x: px - 4,
+ y: gridRect.top,
+ shape: { width: 8, height: gridRect.height },
+ style: { fill: 'rgba(0, 0, 0, 0.02)' },
+ cursor: 'ew-resize',
+ draggable: true,
+ z: 100,
+ ondrag(this: { x: number; y: number }) {
+ this.y = gridRect.top;
+ const dataX = chart.convertFromPixel(
+ { xAxisIndex: 0 },
+ this.x + 4,
+ ) as number;
+ const snapped = snapToNearestX(xs, dataX);
+ if (snapped !== undefined && snapped !== baselineX) {
+ applyBaseline(snapped);
+ }
+ },
+ ondragend: () => drawHandle(),
+ children: [
+ {
+ type: 'rect',
+ left: 'center',
+ top: 0,
+ shape: { width: 2, height: gridRect.height },
+ style: { fill: '#666' },
+ },
+ ],
+ },
+ ],
+ });
+ };
+ drawHandle();
+
+ return () => {
+ chart.setOption({
+ graphic: [{ id: 'percent-change-baseline', $action: 'remove' }],
+ });
+ };
+ }, [rebaseEnabled, echartOptions, width, height]);
const extraControlRef = useRef<HTMLDivElement>(null);
const [extraControlHeight, setExtraControlHeight] = useState(0);
useEffect(() => {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
index 63d7fff9a6d..9b9019c4e23 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
@@ -106,6 +106,22 @@ const config: ControlPanelConfig = {
},
},
],
+ [
+ {
+ name: 'rebase_percent_change',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Percent change'),
+ renderTrigger: true,
+ default: false,
+ description: t(
+ 'Rebase each series to its percent change from a baseline ' +
+ 'point, like the legacy Time-series Percent Change chart. ' +
+ 'Drag the vertical baseline on the chart to re-index.',
+ ),
+ },
+ },
+ ],
[
{
name: 'markerEnabled',
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/percentChange.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/percentChange.ts
new file mode 100644
index 00000000000..7cf02f78d33
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/percentChange.ts
@@ -0,0 +1,101 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { DataRecord } from '@superset-ui/core';
+
+/**
+ * Rebases every non-x column of a timeseries result to its percent change
+ * from that column's first non-null value, restoring the view the legacy
+ * nvd3 "Time-series Percent Change" chart computed in the renderer:
+ * y' = y / y0 - 1. Columns whose baseline is 0 (or missing) yield nulls
+ * since a percent change from zero is undefined.
+ */
+export function rebaseToPercentChange(
+ records: DataRecord[],
+ xAxis: string,
+): DataRecord[] {
+ const baselines: Record<string, number> = {};
+ records.forEach(record => {
+ Object.entries(record).forEach(([key, value]) => {
+ if (
+ key !== xAxis &&
+ !(key in baselines) &&
+ typeof value === 'number' &&
+ !Number.isNaN(value)
+ ) {
+ baselines[key] = value;
+ }
+ });
+ });
+
+ return records.map(record => {
+ const rebased: DataRecord = { [xAxis]: record[xAxis] };
+ Object.entries(record).forEach(([key, value]) => {
+ if (key === xAxis) return;
+ const baseline = baselines[key];
+ rebased[key] =
+ typeof value === 'number' &&
+ !Number.isNaN(value) &&
+ baseline !== undefined &&
+ baseline !== 0
+ ? value / baseline - 1
+ : null;
+ });
+ return rebased;
+ });
+}
+
+export type SeriesDataPoint = [number | string, number | null];
+
+/**
+ * Re-indexes already-rebased series data to a new baseline x. Because
+ * percent-change rebasing is composable, v' = (1 + v) / (1 + vk) - 1
+ * derives the new view directly from the currently displayed values,
+ * where vk is the series value at the nearest x at or before the new
+ * baseline (falling back to the first non-null point).
+ */
+export function rebaseSeriesData(
+ data: SeriesDataPoint[],
+ baselineX: number | string,
+): SeriesDataPoint[] {
+ // the non-null point with the largest x at or before the baseline,
+ // falling back to the first non-null point
+ let baselineValue: number | null = null;
+ data.forEach(([x, y]) => {
+ if (y == null) return;
+ if (x <= baselineX || baselineValue === null) {
+ baselineValue = y;
+ }
+ });
+ if (baselineValue === null || baselineValue === -1) {
+ return data.map(([x]) => [x, null]);
+ }
+ const divisor = 1 + baselineValue;
+ return data.map(([x, y]) => [x, y == null ? null : (1 + y) / divisor - 1]);
+}
+
+/** Snaps a dragged x position to the nearest available data x. */
+export function snapToNearestX(
+ xs: number[],
+ target: number,
+): number | undefined {
+ if (xs.length === 0) return undefined;
+ return xs.reduce((best, x) =>
+ Math.abs(x - target) < Math.abs(best - target) ? x : best,
+ );
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
index a2ad93e9578..418f5ba5baf 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -18,6 +18,7 @@
*/
/* eslint-disable camelcase */
import { invert } from 'lodash-es';
+import { rebaseToPercentChange } from './percentChange';
import { t } from '@apache-superset/core/translation';
import {
AnnotationLayer,
@@ -290,7 +291,7 @@ export default function transformProps(
return { ...acc, [entry[0]]: entry[1] };
}, {});
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
- const rebasedData = rebaseForecastDatum(data, verboseMap);
+ const forecastRebasedData = rebaseForecastDatum(data, verboseMap);
let xAxisLabel = getXAxisLabel(chartProps.rawFormData) as string;
if (
isPhysicalColumn(chartProps.rawFormData?.x_axis) &&
@@ -298,6 +299,16 @@ export default function transformProps(
) {
xAxisLabel = verboseMap[xAxisLabel];
}
+ // Restores the legacy nvd3 "Time-series Percent Change" view: every series
+ // rebased client-side to its percent change from the first point. The
+ // baseline can be re-indexed interactively via the draggable line the
+ // chart component installs.
+ const rebasePercentChange = Boolean(
+ (formData as { rebasePercentChange?: boolean }).rebasePercentChange,
+ );
+ const rebasedData = rebasePercentChange
+ ? rebaseToPercentChange(forecastRebasedData, xAxisLabel)
+ : forecastRebasedData;
const isHorizontal = orientation === OrientationType.Horizontal;
const { totalStackedValues, thresholdValues } = extractDataTotalValues(
rebasedData,
@@ -349,7 +360,9 @@ export default function transformProps(
const isAreaExpand = stack === StackControlsValue.Expand;
const series: SeriesOption[] = [];
- const forcePercentFormatter = Boolean(contributionMode || isAreaExpand);
+ const forcePercentFormatter = Boolean(
+ contributionMode || isAreaExpand || rebasePercentChange,
+ );
const percentFormatter = forcePercentFormatter
? getPercentFormatter(yAxisFormat)
: getPercentFormatter(NumberFormats.PERCENT_2_POINT);
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/percentChange.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/percentChange.test.ts
new file mode 100644
index 00000000000..e8497ad23ee
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/percentChange.test.ts
@@ -0,0 +1,91 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+ rebaseToPercentChange,
+ rebaseSeriesData,
+ snapToNearestX,
+} from '../../src/Timeseries/percentChange';
+
+test('rebases each column to percent change from its first value', () => {
+ const rebased = rebaseToPercentChange(
+ [
+ { __timestamp: 1, a: 100, b: 50 },
+ { __timestamp: 2, a: 150, b: 25 },
+ { __timestamp: 3, a: 200, b: null },
+ ],
+ '__timestamp',
+ );
+ expect(rebased).toEqual([
+ { __timestamp: 1, a: 0, b: 0 },
+ { __timestamp: 2, a: 0.5, b: -0.5 },
+ { __timestamp: 3, a: 1, b: null },
+ ]);
+});
+
+test('skips leading nulls when picking the baseline', () => {
+ const rebased = rebaseToPercentChange(
+ [
+ { __timestamp: 1, a: null },
+ { __timestamp: 2, a: 40 },
+ { __timestamp: 3, a: 60 },
+ ],
+ '__timestamp',
+ );
+ expect(rebased[1].a).toBe(0);
+ expect(rebased[2].a).toBeCloseTo(0.5);
+});
+
+test('yields nulls when the baseline is zero', () => {
+ const rebased = rebaseToPercentChange(
+ [
+ { __timestamp: 1, a: 0 },
+ { __timestamp: 2, a: 10 },
+ ],
+ '__timestamp',
+ );
+ expect(rebased.map(r => r.a)).toEqual([null, null]);
+});
+
+test('re-indexing displayed data matches rebasing raw data to that point', () => {
+ // raw 100, 150, 200 rebased to first point: 0, 0.5, 1
+ const displayed: [number, number][] = [
+ [1, 0],
+ [2, 0.5],
+ [3, 1],
+ ];
+ // re-index to x=2 (raw 150): 100/150-1, 0, 200/150-1
+ const reindexed = rebaseSeriesData(displayed, 2);
+ expect(reindexed[0][1]).toBeCloseTo(100 / 150 - 1);
+ expect(reindexed[1][1]).toBeCloseTo(0);
+ expect(reindexed[2][1]).toBeCloseTo(200 / 150 - 1);
+});
+
+test('re-indexing to the current baseline is a no-op', () => {
+ const displayed: [number, number][] = [
+ [1, 0],
+ [2, 0.5],
+ ];
+ expect(rebaseSeriesData(displayed, 1)).toEqual(displayed);
+});
+
+test('snaps to the nearest available x', () => {
+ expect(snapToNearestX([10, 20, 30], 24)).toBe(20);
+ expect(snapToNearestX([10, 20, 30], 26)).toBe(30);
+ expect(snapToNearestX([], 5)).toBeUndefined();
+});
diff --git a/superset/migrations/shared/migrate_viz/processors.py b/superset/migrations/shared/migrate_viz/processors.py
index 8a1a931cff8..a4c2b05ea7c 100644
--- a/superset/migrations/shared/migrate_viz/processors.py
+++ b/superset/migrations/shared/migrate_viz/processors.py
@@ -392,6 +392,13 @@ class MigrateCompareChart(TimeseriesChart):
source_viz_type = "compare"
target_viz_type = "echarts_timeseries_line"
+ def _pre_action(self) -> None:
+ super()._pre_action()
+ # Restore the percent-change view the nvd3 renderer computed
+ # client-side; the ECharts line chart rebases series when this
+ # flag is set and offers a draggable baseline to re-index.
+ self.data["rebase_percent_change"] = True
+
class MigrateAreaChart(TimeseriesChart):
source_viz_type = "area"
diff --git a/tests/unit_tests/migrations/viz/nvd3_compare_chart_to_echarts_test.py b/tests/unit_tests/migrations/viz/nvd3_compare_chart_to_echarts_test.py
index 6107efc44fd..2b41289616e 100644
--- a/tests/unit_tests/migrations/viz/nvd3_compare_chart_to_echarts_test.py
+++ b/tests/unit_tests/migrations/viz/nvd3_compare_chart_to_echarts_test.py
@@ -30,6 +30,8 @@ SOURCE_FORM_DATA: dict[str, Any] = {
TARGET_FORM_DATA: dict[str, Any] = {
"form_data_bak": SOURCE_FORM_DATA,
"viz_type": "echarts_timeseries_line",
+ # the percent-change view the nvd3 renderer computed client-side
+ "rebase_percent_change": True,
}