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,
 }