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/06/25 19:23:41 UTC

(superset) branch master updated: fix(deck.gl): use interval notation for Polygon legend bucket labels (#41400)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new aac02ab6793 fix(deck.gl): use interval notation for Polygon legend bucket labels (#41400)
aac02ab6793 is described below

commit aac02ab679356c61e86cada0b42785a2973b1776
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Thu Jun 25 12:23:34 2026 -0700

    fix(deck.gl): use interval notation for Polygon legend bucket labels (#41400)
    
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 .../src/components/Legend.test.tsx                 | 71 ++++++++++++++++++++++
 .../preset-chart-deckgl/src/components/Legend.tsx  | 36 +++++++++++
 .../plugins/preset-chart-deckgl/src/utils.test.ts  | 47 +++++++++++++-
 .../plugins/preset-chart-deckgl/src/utils.ts       |  9 ++-
 4 files changed, 161 insertions(+), 2 deletions(-)

diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.test.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.test.tsx
new file mode 100644
index 00000000000..009d612a6f2
--- /dev/null
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.test.tsx
@@ -0,0 +1,71 @@
+/**
+ * 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.
+ */
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { render, screen } from '@testing-library/react';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import '@testing-library/jest-dom';
+import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
+import type { ReactElement } from 'react';
+import Legend from './Legend';
+
+const renderWithTheme = (component: ReactElement) =>
+  render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
+
+test('formats interval-notation labels while preserving brackets', () => {
+  renderWithTheme(
+    <Legend
+      format=",.2f"
+      categories={{
+        '[1, 81)': { enabled: true, color: [0, 0, 0] },
+        '[81, 212)': { enabled: true, color: [0, 0, 0] },
+        '[212, 369]': { enabled: true, color: [0, 0, 0] },
+      }}
+    />,
+  );
+
+  expect(screen.getByText('[1.00, 81.00)')).toBeInTheDocument();
+  expect(screen.getByText('[81.00, 212.00)')).toBeInTheDocument();
+  expect(screen.getByText('[212.00, 369.00]')).toBeInTheDocument();
+});
+
+test('still formats legacy "a - b" delimiter labels', () => {
+  renderWithTheme(
+    <Legend
+      format=",.1f"
+      categories={{
+        '0 - 100000': { enabled: true, color: [0, 0, 0] },
+        '100001 - 200000': { enabled: true, color: [0, 0, 0] },
+      }}
+    />,
+  );
+
+  expect(screen.getByText('0.0 - 100,000.0')).toBeInTheDocument();
+  expect(screen.getByText('100,001.0 - 200,000.0')).toBeInTheDocument();
+});
+
+test('leaves labels untouched when no format is provided', () => {
+  renderWithTheme(
+    <Legend
+      format={null}
+      categories={{ '[1, 81)': { enabled: true, color: [0, 0, 0] } }}
+    />,
+  );
+
+  expect(screen.getByText('[1, 81)')).toBeInTheDocument();
+});
diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx
index 32b995d08d8..96518a3fa38 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx
@@ -59,6 +59,33 @@ const StyledLegend = styled.div`
 
 const categoryDelimiter = ' - ';
 
+const OPENING_BRACKETS = '[(';
+const CLOSING_BRACKETS = '])';
+
+// Recognize half-open interval labels like "[1, 81)" or "[81, 212]" emitted by
+// getBuckets: brackets on the ends, two comma-separated bounds in between.
+// Returns the parsed pieces, or null when the label isn't interval notation.
+const parseInterval = (label: string) => {
+  const open = label[0];
+  const close = label[label.length - 1];
+  if (!OPENING_BRACKETS.includes(open) || !CLOSING_BRACKETS.includes(close)) {
+    return null;
+  }
+
+  const bounds = label.slice(1, -1).split(',');
+  if (bounds.length !== 2) {
+    return null;
+  }
+
+  const lower = bounds[0].trim();
+  const upper = bounds[1].trim();
+  if (!lower || !upper) {
+    return null;
+  }
+
+  return { open, lower, upper, close };
+};
+
 export type LegendProps = {
   format: string | null;
   forceCategorical?: boolean;
@@ -91,6 +118,15 @@ const Legend = ({
       return k;
     }
 
+    // Format each numeric bound of an interval label while preserving the
+    // brackets and separator, e.g. "[1, 81)" -> "[1.00, 81.00)".
+    const interval = parseInterval(k);
+    if (interval) {
+      const { open, lower, upper, close } = interval;
+
+      return `${open}${format(lower)}, ${format(upper)}${close}`;
+    }
+
     if (k.includes(categoryDelimiter)) {
       const values = k.split(categoryDelimiter);
 
diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utils.test.ts b/superset-frontend/plugins/preset-chart-deckgl/src/utils.test.ts
index 717c6f68ab8..41cdd37d789 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/utils.test.ts
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/utils.test.ts
@@ -16,7 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
+import { JsonObject, QueryFormData } from '@superset-ui/core';
+import {
+  getColorBreakpointsBuckets,
+  getBreakPoints,
+  getBuckets,
+  BucketsWithColorScale,
+} from './utils';
 import { ColorBreakpointType } from './types';
 
 describe('getColorBreakpointsBuckets', () => {
@@ -488,3 +494,42 @@ describe('getBreakPoints', () => {
     });
   });
 });
+
+describe('getBuckets', () => {
+  const accessor = (d: JsonObject) => d.value;
+
+  const buildFeatures = (values: number[]) => values.map(value => ({ value }));
+
+  test('produces non-overlapping bucket labels (no shared endpoints)', () => {
+    // With break points [1, 81, 212, 369] the legacy behavior produced
+    // "1 - 81", "81 - 212", "212 - 369" where each interior breakpoint
+    // (81, 212) appeared in two adjacent labels, reading as overlapping
+    // ranges. Labels should instead form a clean, non-overlapping partition.
+    const fd: QueryFormData & BucketsWithColorScale = {
+      datasource: '1__table',
+      viz_type: 'deck_polygon',
+      break_points: ['1', '81', '212', '369'],
+      num_buckets: '3',
+      linear_color_scheme: ['#000000', '#ffffff'],
+      opacity: 100,
+      metric: 'count',
+    };
+    const features = buildFeatures([1, 50, 100, 200, 300, 369]);
+
+    const buckets = getBuckets(fd, features, accessor);
+    const labels = Object.keys(buckets);
+
+    // Three buckets for four breakpoints
+    expect(labels).toHaveLength(3);
+
+    // Interval notation: half-open everywhere except the last bucket, which
+    // is closed so the maximum value is included.
+    expect(labels).toEqual(['[1, 81)', '[81, 212)', '[212, 369]']);
+
+    // No numeric endpoint should appear as both an upper bound of one bucket
+    // and a lower bound of the next in an ambiguous "a - b" form.
+    labels.forEach(label => {
+      expect(label).not.toMatch(/^\d+(\.\d+)?\s-\s\d+(\.\d+)?$/);
+    });
+  });
+});
diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts b/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts
index 2c42b918b7f..b33dad7517e 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts
@@ -200,8 +200,15 @@ export function getBuckets(
     string,
     { color: Color | undefined; enabled: boolean }
   > = {};
+  const lastBucketIndex = breakPoints.length - 2;
   breakPoints.slice(1).forEach((_, i) => {
-    const range = `${breakPoints[i]} - ${breakPoints[i + 1]}`;
+    // Use half-open interval notation so adjacent buckets don't share an
+    // ambiguous endpoint. This mirrors the d3 `scaleThreshold` binning used
+    // for coloring, where each breakpoint is a half-open cut point. The final
+    // bucket is closed on the right so the maximum value is included.
+    const isLastBucket = i === lastBucketIndex;
+    const closingBracket = isLastBucket ? ']' : ')';
+    const range = `[${breakPoints[i]}, ${breakPoints[i + 1]}${closingBracket}`;
     const mid =
       0.5 * (parseFloat(breakPoints[i]) + parseFloat(breakPoints[i + 1]));
     // fix polygon doesn't show