Skip to content

Commit 1fcca30

Browse files
lhawkman27lhawkinsclaudeEthan Bienstock
authored
feat: Add Percentile-Based Bounds for colorByValue (#319)
* Allow lowerBound, upperBound, and breakpoints to accept percentage strings (e.g. "25%") that resolve to percentiles of the actual data distribution, in addition to static numeric values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * When colorByValue uses percentage-based bounds, legend labels now display ≤/> notation instead of plain values with +, making it clear the bounds represent percentiles. Also removes duplicate utility functions from the merge and properly types raw config with string bounds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * #196 fix: remove duplicate NumberOrPercent class and fix formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * #196 feat: add V5 schema with percentile support for colorByValue bounds Introduces GeoSetLayerV5Schema that extends colorByValue bounds and breakpoints to accept percentile strings (e.g. "10%", "90%") via NumberOrPercent. Simplifies V1 ColorByValueSchema to numeric-only, keeping the percentile logic cleanly in V5. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * update chart configs to schema version 5 and simplify V5 test fixture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review findings for percentile bounds and dynamic sizing - Use copy.deepcopy in V5 schema upgrade (was shallow copy) - Pass resolved metric config instead of raw colorByValue to fix type safety - Remove unnecessary `as any` casts for sizeValue on GeoJsonFeature - Extract shared formatBoundLabel utility to eliminate duplication across 4 components - Add NaN guard in computeSizeScale for defense-in-depth - Remove file-level rules-of-hooks eslint disable in MultiLegend - Simplify isCombinedMetricSize from IIFE to plain boolean expression - Remove non-null assertion on sizeScale (already narrowed by if) - Tighten V5 upgrade immutability test to check nested object identity - Add clarifying comment in V5 validator about mixed-type validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: use isPercentString utility for percent bound detection Replace inline typeof/endsWith checks with the existing isPercentString utility for consistency and stricter validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove unused `import re` from GeoSetLayerV1Schema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: import SizeLegend type instead of inlining it in transformProps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add console warnings for inverted bounds and expand V5 schema test coverage Add console.warn when resolved percentile bounds produce lower > upper for both colorByValue and pointSize. Add tests for all-percentage config happy path and mixed-type breakpoint ordering skip behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor: extract resolveNumericBounds to deduplicate metric/size percentile logic The metric coloring and point sizing blocks in transformProps shared identical logic for value extraction, sorting, bound resolution, warnings, and percent-bound detection. Consolidates into a single resolveNumericBounds helper in colors.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: map breakpoint segments to correct slice of global color gradient Each breakpoint segment was independently cycling through the full color range because the local interpolation factor (t) was passed directly to the full-range interpolator. Replace interp(t) with interp((i + t) / numSegments) so each segment maps to its proportional slice of the start→end color ramp. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: simplify V5 schema validation and update docs for percentile colorByValue bounds Flatten the branching validation logic in ColorByValueSchemaV5 into three linear steps (bounds ordering, breakpoint ordering, breakpoints-in-range) and extract _is_pct/_to_float helpers. Update wiki pages and README to document percentile string support for colorByValue bounds and breakpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add frontend unit tests and extract shared schema bound validation - Add 63 unit tests covering isPercentString, percentile, resolvePercentOrNumber, resolveNumericBounds, computeMetricColorScaleUnified, computeSizeScale, formatLegendNumber, and formatBoundLabel - Extract validate_bound_ordering, _is_pct, _to_float from V4 schema as shared helpers; V5 now imports them instead of duplicating - Breakpoint color test verifies monotonic gradient (no segment reset) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * cleanup: default param, rename private helpers, add JSDoc note - Default usesPercentBounds to false in formatBoundLabel - Rename _is_pct/_to_float to is_pct/to_float (now cross-module public) - Add JSDoc note on inverted bounds degenerate case in resolveNumericBounds Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: lhawkins <lhawkins@teamraft.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Ethan Bienstock <ethan.bienstock@acf.hhs.gov>
1 parent d599ead commit 1fcca30

27 files changed

Lines changed: 1467 additions & 163 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ GeoSet bridges the gap between Superset and GIS tooling. This is accomplished by
3434
- Single and multilayer maps
3535
- Visibility toggling by zoom
3636
- Hover over and additional details pane
37-
- Color by category or value (either can be used with Point Size Scaling)
37+
- Color by category or value (number or percentile bounds; either can be used with Point Size Scaling)
3838
- Point size scaling by value (number or percentile bounds)
3939
- Collapsible legend with layer toggling and dynamic iconography
4040
- Native dashboard integration
@@ -87,7 +87,7 @@ GeoSet is an extension of Superset. Everything that can be done within Superset
8787

8888
## Quick Start
8989

90-
There is a Docker Compose file at the root of the repository. This file is based off [docker-compose-light.yml](https://github.com/apache/superset/blob/master/docker-compose-light.yml) in the upstream Apache Superset repository. We add a PostGIS database service to the stack and preload it with example data.
90+
There is a Docker Compose file at the root of the repository. This file is based off [docker-compose-light.yml](https://github.com/apache/superset/blob/master/docker-compose-light.yml) in the upstream Apache Superset repository. We add a PostGIS database service to the stack and preload it with example data.
9191

9292
### Step 1 - Create a Copy of docker/.env.example
9393

superset-frontend/plugins/geoset-map-chart/src/GeoSetMultiMap/Multi.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ const DeckMulti = (props: DeckMultiProps) => {
413413
upper: ml.max,
414414
startColor: ml.startColor,
415415
endColor: ml.endColor,
416+
usesPercentBounds: ml.usesPercentBounds,
416417
},
417418
sizeEntry: isCombined ? buildSizeEntry() : undefined,
418419
isCombinedMetricSize: isCombined,

superset-frontend/plugins/geoset-map-chart/src/components/CategorySizeGrid.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import React, { Fragment } from 'react';
1+
import type { FC, ReactNode } from 'react';
2+
import { Fragment } from 'react';
23
import { RGBAColor } from '../utils/colors';
34
import { getColoredSvgUrl } from '../utils/svgIcons';
4-
import { formatLegendNumber } from '../utils/formatNumber';
5+
import { formatBoundLabel, formatLegendNumber } from '../utils/formatNumber';
56

67
export type CategorySizeGridItem = {
78
key: string;
@@ -16,14 +17,14 @@ export type CategorySizeGridProps = {
1617
upper: number;
1718
icon?: string;
1819
usesPercentBounds?: boolean;
19-
renderLabel: (item: CategorySizeGridItem) => React.ReactNode;
20+
renderLabel: (item: CategorySizeGridItem) => ReactNode;
2021
};
2122

2223
const T_VALUES = [0, 0.5, 1];
2324
const MIN_R = 3;
2425
const MAX_R = 13;
2526

26-
const CategorySizeGrid: React.FC<CategorySizeGridProps> = ({
27+
const CategorySizeGrid: FC<CategorySizeGridProps> = ({
2728
categories,
2829
lower,
2930
upper,
@@ -88,19 +89,17 @@ const CategorySizeGrid: React.FC<CategorySizeGridProps> = ({
8889
{/* Size value labels */}
8990
<span />
9091
{values.map((val, i) => {
91-
let text = formatLegendNumber(val);
92-
if (hasRange && i === 0 && usesPercentBounds) {
93-
text = `≤\u2009${text}`;
94-
}
95-
if (hasRange && i === 2) {
96-
text = usesPercentBounds ? `>\u2009${text}` : `${text}+`;
97-
}
92+
const position = i === 0 ? 'lower' : i === 2 ? 'upper' : undefined;
93+
const text = position
94+
? formatBoundLabel(val, position, hasRange, usesPercentBounds)
95+
: formatLegendNumber(val);
9896
return (
9997
<span
10098
key={i}
10199
style={{
102100
textAlign: 'center',
103101
fontSize: 11,
102+
// eslint-disable-next-line theme-colors/no-literal-colors
104103
color: 'var(--ant-color-text-secondary, #888)',
105104
}}
106105
>

superset-frontend/plugins/geoset-map-chart/src/components/GraduatedIcons.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import React from 'react';
1+
import type { FC } from 'react';
22
import { RGBAColor, lerpColorCss, lerpColorRgba } from '../utils/colors';
33
import { getColoredSvgUrl } from '../utils/svgIcons';
4-
import { formatLegendNumber } from '../utils/formatNumber';
4+
import { formatBoundLabel } from '../utils/formatNumber';
55

66
export interface GraduatedIconsProps {
77
lower: number;
@@ -16,7 +16,7 @@ export interface GraduatedIconsProps {
1616

1717
const T_VALUES = [0, 0.33, 0.67, 1];
1818

19-
const GraduatedIcons: React.FC<GraduatedIconsProps> = ({
19+
const GraduatedIcons: FC<GraduatedIconsProps> = ({
2020
lower,
2121
upper,
2222
startColor,
@@ -48,15 +48,18 @@ const GraduatedIcons: React.FC<GraduatedIconsProps> = ({
4848

4949
const iconName = icon?.replace('-icon', '') || 'circle';
5050
const hasRange = lower !== upper;
51-
const lowerLabel =
52-
hasRange && usesPercentBounds
53-
? `≤\u2009${formatLegendNumber(lower)}`
54-
: formatLegendNumber(lower);
55-
const upperLabel = hasRange
56-
? usesPercentBounds
57-
? `>\u2009${formatLegendNumber(upper)}`
58-
: `${formatLegendNumber(upper)}+`
59-
: formatLegendNumber(upper);
51+
const lowerLabel = formatBoundLabel(
52+
lower,
53+
'lower',
54+
hasRange,
55+
usesPercentBounds,
56+
);
57+
const upperLabel = formatBoundLabel(
58+
upper,
59+
'upper',
60+
hasRange,
61+
usesPercentBounds,
62+
);
6063

6164
if (responsive) {
6265
const vw = 180;
@@ -99,6 +102,7 @@ const GraduatedIcons: React.FC<GraduatedIconsProps> = ({
99102
display: 'block',
100103
maxWidth: 180,
101104
fontSize: 11,
105+
// eslint-disable-next-line theme-colors/no-literal-colors
102106
fill: 'var(--ant-color-text-secondary, #888)',
103107
}}
104108
>

superset-frontend/plugins/geoset-map-chart/src/components/Legend.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { formatNumber, styled } from '@superset-ui/core';
2626
import { MetricLegend, RGBAColor, toRGBA } from '../utils/colors';
2727
import { rgbaArrayToCssString } from '../utils/colorsFallback';
2828
import { Swatch } from '../utils/legendSwatch';
29-
import { formatLegendNumber } from '../utils/formatNumber';
29+
import { formatBoundLabel } from '../utils/formatNumber';
3030
import CategorySizeGrid, { CategorySizeGridItem } from './CategorySizeGrid';
3131
import GraduatedIcons from './GraduatedIcons';
3232

@@ -166,11 +166,23 @@ const Legend = ({
166166
/>
167167
<div className="legend-labels">
168168
<span>
169-
{metricLegend.min != null ? formatLegendNumber(metricLegend.min) : ''}
169+
{metricLegend.min != null
170+
? formatBoundLabel(
171+
metricLegend.min,
172+
'lower',
173+
metricLegend.min !== metricLegend.max,
174+
!!metricLegend.usesPercentBounds,
175+
)
176+
: ''}
170177
</span>
171178
<span>
172179
{metricLegend.max != null
173-
? `${formatLegendNumber(metricLegend.max)}${metricLegend.min !== metricLegend.max ? '+' : ''}`
180+
? formatBoundLabel(
181+
metricLegend.max,
182+
'upper',
183+
metricLegend.min !== metricLegend.max,
184+
!!metricLegend.usesPercentBounds,
185+
)
174186
: ''}
175187
</span>
176188
</div>
@@ -242,14 +254,12 @@ const Legend = ({
242254
? (() => {
243255
const { lower, upper } = sizeLegend!;
244256
const gridItems: CategorySizeGridItem[] = categoryEntries.map(
245-
([k, v]) => {
246-
return {
247-
key: k,
248-
label: String(v.legend_name || formatCategoryLabel(k)),
249-
fillColor: toRGBA(v.color ?? undefined, [0, 0, 0, 255]),
250-
enabled: v.enabled !== false,
251-
};
252-
},
257+
([k, v]) => ({
258+
key: k,
259+
label: String(v.legend_name || formatCategoryLabel(k)),
260+
fillColor: toRGBA(v.color ?? undefined, [0, 0, 0, 255]),
261+
enabled: v.enabled !== false,
262+
}),
253263
);
254264

255265
return (

superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import MapIcon from '@material-ui/icons/MapTwoTone';
44
import { RGBAColor } from '../utils/colors';
55
import type { LegendEntry, LegendGroup } from '../types';
66
import { Swatch } from '../utils/legendSwatch';
7-
import { formatLegendNumber } from '../utils/formatNumber';
7+
import { formatBoundLabel } from '../utils/formatNumber';
88
import GraduatedIcons from './GraduatedIcons';
99
import CategorySizeGrid, { CategorySizeGridItem } from './CategorySizeGrid';
1010

@@ -392,8 +392,22 @@ const LegendEntryContent: React.FC<{
392392
)`}
393393
/>
394394
<Bounds>
395-
<div>{formatLegendNumber(legendEntry.metric.lower)}</div>
396-
<div>{`${formatLegendNumber(legendEntry.metric.upper)}${legendEntry.metric.lower !== legendEntry.metric.upper ? '+' : ''}`}</div>
395+
<div>
396+
{formatBoundLabel(
397+
legendEntry.metric.lower,
398+
'lower',
399+
legendEntry.metric.lower !== legendEntry.metric.upper,
400+
!!legendEntry.metric.usesPercentBounds,
401+
)}
402+
</div>
403+
<div>
404+
{formatBoundLabel(
405+
legendEntry.metric.upper,
406+
'upper',
407+
legendEntry.metric.lower !== legendEntry.metric.upper,
408+
!!legendEntry.metric.usesPercentBounds,
409+
)}
410+
</div>
397411
</Bounds>
398412
</MetricScale>
399413
</MetricBlock>

superset-frontend/plugins/geoset-map-chart/src/layers/GeoSetLayer/GeoSetLayer.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,7 @@ export function getLayer(
411411
let iconName = pointType.replace('-icon', '');
412412
if (!iconName) iconName = 'circle';
413413

414-
const hasDynamicSize = sortedFeatures.some(
415-
f => (f as any).sizeValue != null,
416-
);
414+
const hasDynamicSize = sortedFeatures.some(f => f.sizeValue != null);
417415

418416
return new IconLayer({
419417
id: `icon-layer-${fd.slice_id}-${sortedFeatures.length}`,
@@ -431,7 +429,7 @@ export function getLayer(
431429
};
432430
},
433431
getSize: hasDynamicSize
434-
? (f: any) => (f as any).sizeValue ?? iconSize
432+
? (f: any) => f.sizeValue ?? iconSize
435433
: () => iconSize,
436434
sizeScale: 2,
437435
sizeUnits: 'pixels',
@@ -451,9 +449,7 @@ export function getLayer(
451449
});
452450
}
453451

454-
const hasDynamicRadius = sortedFeatures.some(
455-
f => (f as any).sizeValue != null,
456-
);
452+
const hasDynamicRadius = sortedFeatures.some(f => f.sizeValue != null);
457453

458454
return new ScatterplotLayer({
459455
id: `point-layer-${fd.slice_id}`,
@@ -466,7 +462,7 @@ export function getLayer(
466462
getLineColor: () => strokeColorArray,
467463
getLineWidth: lineWidth ?? (fd.lineWidth || 1),
468464
getRadius: hasDynamicRadius
469-
? (f: any) => (f as any).sizeValue ?? iconSize
465+
? (f: any) => f.sizeValue ?? iconSize
470466
: () => iconSize,
471467
radiusUnits: 'pixels',
472468
radiusMinPixels: 1,
@@ -1153,6 +1149,7 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
11531149
propVisualConfig?.metric?.valueColumn ||
11541150
payload.data.metricLabels?.[0] ||
11551151
'Value',
1152+
usesPercentBounds: metricLegendObject.usesPercentBounds,
11561153
};
11571154
}
11581155

superset-frontend/plugins/geoset-map-chart/src/layers/common.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { JsonObject, JsonValue, QueryFormData } from '@superset-ui/core';
2323
import sandboxedEval from '../utils/sandbox';
2424
import { TooltipProps } from '../components/Tooltip';
2525

26-
export const CURRENT_VERSION = 4;
26+
export const CURRENT_VERSION = 5;
2727

2828
const d3array: Record<string, any> = d3arrayModule as Record<string, any>;
2929

0 commit comments

Comments
 (0)