Compare commits

...

6 Commits

Author SHA1 Message Date
Evan Rusackas
801a58ef9f Merge branch 'master' into chore/fc-02-legacy-plugin-components 2026-04-19 15:27:37 -04:00
Evan Rusackas
62d9ee732b revert: restore map-box class components (plugin deleted on master)
The legacy-plugin-chart-map-box has been removed on master. Keeping
the original class component form for these files avoids test failures
that would be rebased away. Other files in this PR still convert as
intended.
2026-04-17 12:18:32 -07:00
Evan Rusackas
eeda9bcef7 fix(map-box): use fitBounds when viewport props are cleared to match class behavior 2026-04-17 12:17:17 -07:00
Evan Rusackas
d081f65df6 fix(map-box): restore viewport props removed during function component conversion 2026-04-17 11:44:08 -07:00
Evan Rusackas
4b8d7c05d4 fix(imports): rewrite stale @apache-superset/core/ui to current subpaths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:56:14 -07:00
Evan Rusackas
8f1b94f251 chore(lint): convert legacy plugin chart components to function components
Converts HorizonChart, HorizonRow, MapBox, ScatterPlotGlowOverlay,
PairedTTest, TTestTable, and WordCloud from class components to
function components. Includes updated TTestTable test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:33:34 -07:00
6 changed files with 879 additions and 604 deletions

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
import { PureComponent } from 'react';
import { memo, useMemo } from 'react';
import { extent as d3Extent } from 'd3-array';
import { ensureIsArray } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
@@ -45,18 +44,6 @@ interface HorizonChartProps {
offsetX?: number;
}
const defaultProps: Partial<HorizonChartProps> = {
className: '',
width: 800,
height: 600,
seriesHeight: 20,
bands: Math.floor(DEFAULT_COLORS.length / 2),
colors: DEFAULT_COLORS,
colorScale: 'series',
mode: 'offset',
offsetX: 0,
};
const StyledDiv = styled.div`
${({ theme }) => `
.superset-legacy-chart-horizon {
@@ -80,24 +67,19 @@ const StyledDiv = styled.div`
`}
`;
class HorizonChart extends PureComponent<HorizonChartProps> {
static defaultProps = defaultProps;
render() {
const {
className,
width,
height,
data,
seriesHeight,
bands,
colors,
colorScale,
mode,
offsetX,
} = this.props;
let yDomain: [number, number] | undefined;
function HorizonChart({
className = '',
width = 800,
height = 600,
seriesHeight = 20,
data,
bands = Math.floor(DEFAULT_COLORS.length / 2),
colors = DEFAULT_COLORS,
colorScale = 'series',
mode = 'offset',
offsetX = 0,
}: HorizonChartProps) {
const yDomain = useMemo((): [number, number] | undefined => {
if (colorScale === 'overall') {
const allValues = data.reduce<DataValue[]>(
(acc, current) => acc.concat(current.values),
@@ -106,35 +88,36 @@ class HorizonChart extends PureComponent<HorizonChartProps> {
const rawExtent = d3Extent(allValues, d => d.y);
// Only set yDomain if we have valid min and max values
if (rawExtent[0] != null && rawExtent[1] != null) {
yDomain = [rawExtent[0], rawExtent[1]];
return [rawExtent[0], rawExtent[1]];
}
}
return undefined;
}, [colorScale, data]);
return (
<StyledDiv>
<div
className={`superset-legacy-chart-horizon ${className}`}
style={{ height }}
>
{data.map(row => (
<HorizonRow
key={row.key.join(',')}
width={width}
height={seriesHeight}
title={ensureIsArray(row.key).join(', ')}
data={row.values}
bands={bands}
colors={colors}
colorScale={colorScale}
mode={mode}
offsetX={offsetX}
yDomain={yDomain}
/>
))}
</div>
</StyledDiv>
);
}
return (
<StyledDiv>
<div
className={`superset-legacy-chart-horizon ${className}`}
style={{ height }}
>
{data.map(row => (
<HorizonRow
key={row.key.join(',')}
width={width}
height={seriesHeight}
title={ensureIsArray(row.key).join(', ')}
data={row.values}
bands={bands}
colors={colors}
colorScale={colorScale}
mode={mode}
offsetX={offsetX}
yDomain={yDomain}
/>
))}
</div>
</StyledDiv>
);
}
export default HorizonChart;
export default memo(HorizonChart);

View File

@@ -17,9 +17,7 @@
* under the License.
*/
/* eslint-disable no-continue, no-bitwise */
/* eslint-disable react/jsx-sort-default-props */
/* eslint-disable react/sort-prop-types */
import { PureComponent } from 'react';
import { useRef, useEffect, useCallback, memo } from 'react';
import { extent as d3Extent } from 'd3-array';
import { scaleLinear } from 'd3-scale';
@@ -52,162 +50,140 @@ interface HorizonRowProps {
yDomain?: [number, number];
}
const defaultProps: Partial<HorizonRowProps> = {
className: '',
width: 800,
height: 20,
bands: DEFAULT_COLORS.length >> 1,
colors: DEFAULT_COLORS,
colorScale: 'series',
mode: 'offset',
offsetX: 0,
title: '',
yDomain: undefined,
};
function HorizonRow({
className = '',
width = 800,
height = 20,
data: rawData,
bands = DEFAULT_COLORS.length >> 1,
colors = DEFAULT_COLORS,
colorScale = 'series',
mode = 'offset',
offsetX = 0,
title = '',
yDomain,
}: HorizonRowProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
class HorizonRow extends PureComponent<HorizonRowProps> {
static defaultProps = defaultProps;
const drawChart = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
private canvas: HTMLCanvasElement | null = null;
const data =
colorScale === 'change' && rawData.length > 0
? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
: rawData;
componentDidMount() {
this.drawChart();
}
const context = canvas.getContext('2d');
if (!context) return;
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
// Reset transform
context.setTransform(1, 0, 0, 1, 0, 0);
context.translate(0.5, 0.5);
componentDidUpdate() {
this.drawChart();
}
const step = width / data.length;
// the data frame currently being shown:
const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
const endIndex = Math.floor(
Math.min(data.length, startIndex + width / step),
);
componentWillUnmount() {
this.canvas = null;
}
// skip drawing if there's no data to be drawn
if (startIndex > data.length) {
return;
}
drawChart() {
if (this.canvas) {
const {
data: rawData,
yDomain,
width = 800,
height = 20,
bands = DEFAULT_COLORS.length >> 1,
colors = DEFAULT_COLORS,
colorScale,
offsetX = 0,
mode,
} = this.props;
// Create y-scale
const [min, max] =
yDomain || (d3Extent(data, d => d.y) as [number, number]);
const y = scaleLinear()
.domain([0, Math.max(-min, max)])
.range([0, height]);
const data =
colorScale === 'change'
? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
: rawData;
// we are drawing positive & negative bands separately to avoid mutating canvas state
// http://www.html5rocks.com/en/tutorials/canvas/performance/
let hasNegative = false;
// draw positive bands
let value: number;
let bExtents: number;
for (let b = 0; b < bands; b += 1) {
context.fillStyle = colors[bands + b];
const context = this.canvas.getContext('2d');
if (!context) return;
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
// Reset transform
context.setTransform(1, 0, 0, 1, 0, 0);
context.translate(0.5, 0.5);
// Adjust the range based on the current band index.
bExtents = (b + 1 - bands) * height;
y.range([bands * height + bExtents, bExtents]);
const step = width / data.length;
// the data frame currently being shown:
const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
const endIndex = Math.floor(
Math.min(data.length, startIndex + width / step),
);
// only the current data frame is being drawn i.e. what's visible:
for (let i = startIndex; i < endIndex; i += 1) {
value = data[i].y;
if (value <= 0) {
hasNegative = true;
continue;
}
if (value !== undefined) {
context.fillRect(
offsetX + i * step,
y(value)!,
step + 1,
y(0)! - y(value)!,
);
}
}
}
// skip drawing if there's no data to be drawn
if (startIndex > data.length) {
return;
// draw negative bands
if (hasNegative) {
// mirror the negative bands, by flipping the canvas
if (mode === 'offset') {
context.translate(0, height);
context.scale(1, -1);
}
// Create y-scale
const [min, max] =
yDomain || (d3Extent(data, d => d.y) as [number, number]);
const y = scaleLinear()
.domain([0, Math.max(-min, max)])
.range([0, height]);
// we are drawing positive & negative bands separately to avoid mutating canvas state
// http://www.html5rocks.com/en/tutorials/canvas/performance/
let hasNegative = false;
// draw positive bands
let value: number;
let bExtents: number;
for (let b = 0; b < bands; b += 1) {
context.fillStyle = colors[bands + b];
context.fillStyle = colors[bands - b - 1];
// Adjust the range based on the current band index.
bExtents = (b + 1 - bands) * height;
y.range([bands * height + bExtents, bExtents]);
// only the current data frame is being drawn i.e. what's visible:
for (let i = startIndex; i < endIndex; i += 1) {
value = data[i].y;
if (value <= 0) {
hasNegative = true;
for (let ii = startIndex; ii < endIndex; ii += 1) {
value = data[ii].y;
if (value >= 0) {
continue;
}
if (value !== undefined) {
context.fillRect(
offsetX + i * step,
y(value)!,
step + 1,
y(0)! - y(value)!,
);
}
}
}
// draw negative bands
if (hasNegative) {
// mirror the negative bands, by flipping the canvas
if (mode === 'offset') {
context.translate(0, height);
context.scale(1, -1);
}
for (let b = 0; b < bands; b += 1) {
context.fillStyle = colors[bands - b - 1];
// Adjust the range based on the current band index.
bExtents = (b + 1 - bands) * height;
y.range([bands * height + bExtents, bExtents]);
// only the current data frame is being drawn i.e. what's visible:
for (let ii = startIndex; ii < endIndex; ii += 1) {
value = data[ii].y;
if (value >= 0) {
continue;
}
context.fillRect(
offsetX + ii * step,
y(-value)!,
step + 1,
y(0)! - y(-value)!,
);
}
context.fillRect(
offsetX + ii * step,
y(-value)!,
step + 1,
y(0)! - y(-value)!,
);
}
}
}
}
}, [
rawData,
yDomain,
width,
height,
bands,
colors,
colorScale,
offsetX,
mode,
]);
render() {
const { className, title, width, height } = this.props;
useEffect(() => {
drawChart();
}, [drawChart]);
return (
<div className={`horizon-row ${className}`}>
<span className="title">{title}</span>
<canvas
ref={c => {
this.canvas = c;
}}
width={width}
height={height}
/>
</div>
);
}
return (
<div className={`horizon-row ${className}`}>
<span className="title">{title}</span>
<canvas ref={canvasRef} width={width} height={height} />
</div>
);
}
export default HorizonRow;
export default memo(HorizonRow);

View File

@@ -17,27 +17,19 @@
* under the License.
*/
/* eslint-disable react/no-array-index-key */
import { PureComponent } from 'react';
import { styled } from '@apache-superset/core/theme';
import TTestTable, { DataEntry } from './TTestTable';
interface PairedTTestProps {
alpha: number;
className: string;
alpha?: number;
className?: string;
data: Record<string, DataEntry[]>;
groups: string[];
liftValPrec: number;
liftValPrec?: number;
metrics: string[];
pValPrec: number;
pValPrec?: number;
}
const defaultProps = {
alpha: 0.05,
className: '',
liftValPrec: 4,
pValPrec: 6,
};
const StyledDiv = styled.div`
${({ theme }) => `
.superset-legacy-chart-paired_ttest .scrollbar-container {
@@ -114,35 +106,36 @@ const StyledDiv = styled.div`
`}
`;
class PairedTTest extends PureComponent<PairedTTestProps> {
static defaultProps = defaultProps;
render() {
const { className, metrics, groups, data, alpha, pValPrec, liftValPrec } =
this.props;
return (
<StyledDiv>
<div className={`superset-legacy-chart-paired-t-test ${className}`}>
<div className="paired-ttest-table">
<div className="scrollbar-content">
{metrics.map((metric, i) => (
<TTestTable
key={i}
metric={metric}
groups={groups}
data={data[metric]}
alpha={alpha}
pValPrec={Math.min(pValPrec, 32)}
liftValPrec={Math.min(liftValPrec, 32)}
/>
))}
</div>
function PairedTTest({
alpha = 0.05,
className = '',
data,
groups,
liftValPrec = 4,
metrics,
pValPrec = 6,
}: PairedTTestProps) {
return (
<StyledDiv>
<div className={`superset-legacy-chart-paired-t-test ${className}`}>
<div className="paired-ttest-table">
<div className="scrollbar-content">
{metrics.map((metric, i) => (
<TTestTable
key={i}
metric={metric}
groups={groups}
data={data[metric]}
alpha={alpha}
pValPrec={Math.min(pValPrec, 32)}
liftValPrec={Math.min(liftValPrec, 32)}
/>
))}
</div>
</div>
</StyledDiv>
);
}
</div>
</StyledDiv>
);
}
export default PairedTTest;

View File

@@ -18,7 +18,7 @@
*/
/* eslint-disable react/no-array-index-key, react/jsx-no-bind */
import dist from 'distributions';
import { Component } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Tr, Td, Thead, Th } from 'reactable';
interface DataPointValue {
@@ -32,279 +32,311 @@ export interface DataEntry {
}
interface TTestTableProps {
alpha: number;
alpha?: number;
data: DataEntry[];
groups: string[];
liftValPrec: number;
liftValPrec?: number;
metric: string;
pValPrec: number;
pValPrec?: number;
}
interface TTestTableState {
control: number;
liftValues: (string | number)[];
pValues: (string | number)[];
}
function TTestTable({
alpha = 0.05,
data,
groups,
liftValPrec = 4,
metric,
pValPrec = 6,
}: TTestTableProps) {
const [control, setControl] = useState(0);
const [liftValues, setLiftValues] = useState<(string | number)[]>([]);
const [pValues, setPValues] = useState<(string | number)[]>([]);
const defaultProps = {
alpha: 0.05,
liftValPrec: 4,
pValPrec: 6,
};
const computeLift = useCallback(
(values: DataPointValue[], controlValues: DataPointValue[]): string => {
// Compute the lift value between two time series
let sumValues = 0;
let sumControl = 0;
values.forEach((value, i) => {
sumValues += value.y;
sumControl += controlValues[i].y;
});
class TTestTable extends Component<TTestTableProps, TTestTableState> {
static defaultProps = defaultProps;
if (sumControl === 0) return 'NaN';
return (((sumValues - sumControl) / sumControl) * 100).toFixed(
liftValPrec,
);
},
[liftValPrec],
);
constructor(props: TTestTableProps) {
super(props);
this.state = {
control: 0,
liftValues: [],
pValues: [],
};
}
componentDidMount() {
const { control } = this.state;
this.computeTTest(control); // initially populate table
}
getLiftStatus(row: number): string {
const { control, liftValues } = this.state;
// Get a css class name for coloring
if (row === control) {
return 'control';
}
const liftVal = liftValues[row];
if (Number.isNaN(liftVal) || !Number.isFinite(liftVal)) {
return 'invalid'; // infinite or NaN values
}
return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on false
}
getPValueStatus(row: number): string {
const { control, pValues } = this.state;
if (row === control) {
return 'control';
}
const pVal = pValues[row];
if (Number.isNaN(pVal) || !Number.isFinite(pVal)) {
return 'invalid';
}
return ''; // p-values won't normally be colored
}
getSignificance(row: number): string | boolean {
const { control, pValues } = this.state;
const { alpha } = this.props;
// Color significant as green, else red
if (row === control) {
return 'control';
}
// p-values significant below set threshold
return Number(pValues[row]) <= alpha;
}
computeLift(values: DataPointValue[], control: DataPointValue[]): string {
const { liftValPrec } = this.props;
// Compute the lift value between two time series
let sumValues = 0;
let sumControl = 0;
values.forEach((value, i) => {
sumValues += value.y;
sumControl += control[i].y;
});
return (((sumValues - sumControl) / sumControl) * 100).toFixed(liftValPrec);
}
computePValue(
values: DataPointValue[],
control: DataPointValue[],
): string | number {
const { pValPrec } = this.props;
// Compute the p-value from Student's t-test
// between two time series
let diffSum = 0;
let diffSqSum = 0;
let finiteCount = 0;
values.forEach((value, i) => {
const diff = control[i].y - value.y;
/* eslint-disable-next-line */
if (isFinite(diff)) {
finiteCount += 1;
diffSum += diff;
diffSqSum += diff * diff;
const computePValue = useCallback(
(
values: DataPointValue[],
controlValues: DataPointValue[],
): string | number => {
// Compute the p-value from Student's t-test
// between two time series
let diffSum = 0;
let diffSqSum = 0;
let finiteCount = 0;
values.forEach((value, i) => {
const diff = controlValues[i].y - value.y;
/* eslint-disable-next-line */
if (isFinite(diff)) {
finiteCount += 1;
diffSum += diff;
diffSqSum += diff * diff;
}
});
const tvalue = -Math.abs(
diffSum *
Math.sqrt(
(finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
),
);
try {
return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
pValPrec,
); // two-sided test
} catch (error) {
return NaN;
}
});
const tvalue = -Math.abs(
diffSum *
Math.sqrt(
(finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
),
);
try {
return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
pValPrec,
); // two-sided test
} catch (error) {
return NaN;
}
}
},
[pValPrec],
);
computeTTest(control: number) {
// Compute lift and p-values for each row
// against the selected control
const { data } = this.props;
const pValues: (string | number)[] = [];
const liftValues: (string | number)[] = [];
if (!data) {
const computeTTest = useCallback(
(controlIndex: number) => {
// Compute lift and p-values for each row
// against the selected control
const newPValues: (string | number)[] = [];
const newLiftValues: (string | number)[] = [];
if (!data) {
return;
}
for (let i = 0; i < data.length; i += 1) {
if (i === controlIndex) {
newPValues.push('control');
newLiftValues.push('control');
} else {
newPValues.push(
computePValue(data[i].values, data[controlIndex].values),
);
newLiftValues.push(
computeLift(data[i].values, data[controlIndex].values),
);
}
}
setControl(controlIndex);
setLiftValues(newLiftValues);
setPValues(newPValues);
},
[data, computeLift, computePValue],
);
// Recompute table when data or control row changes, keeping control index in range
useEffect(() => {
if (!data || data.length === 0) {
setControl(0);
setLiftValues([]);
setPValues([]);
return;
}
for (let i = 0; i < data.length; i += 1) {
if (i === control) {
pValues.push('control');
liftValues.push('control');
} else {
pValues.push(this.computePValue(data[i].values, data[control].values));
liftValues.push(this.computeLift(data[i].values, data[control].values));
}
const safeControlIndex = Math.min(control, data.length - 1);
if (safeControlIndex !== control) {
setControl(safeControlIndex);
computeTTest(safeControlIndex);
} else {
computeTTest(control);
}
this.setState({ control, liftValues, pValues });
}, [computeTTest, control, data]);
const getLiftStatus = useCallback(
(row: number): string => {
// Get a css class name for coloring
if (row === control) {
return 'control';
}
const liftVal = liftValues[row];
const numericLiftVal = Number(liftVal);
if (Number.isNaN(numericLiftVal) || !Number.isFinite(numericLiftVal)) {
return 'invalid'; // infinite or NaN values
}
return numericLiftVal >= 0 ? 'true' : 'false'; // green on true, red on false
},
[control, liftValues],
);
const getPValueStatus = useCallback(
(row: number): string => {
if (row === control) {
return 'control';
}
const pVal = pValues[row];
const numericPVal = Number(pVal);
if (Number.isNaN(numericPVal) || !Number.isFinite(numericPVal)) {
return 'invalid';
}
return ''; // p-values won't normally be colored
},
[control, pValues],
);
const getSignificance = useCallback(
(row: number): string | boolean => {
// Color significant as green, else red
if (row === control) {
return 'control';
}
// p-values significant below set threshold
return Number(pValues[row]) <= alpha;
},
[control, pValues, alpha],
);
const handleRowClick = useCallback(
(rowIndex: number) => {
computeTTest(rowIndex);
},
[computeTTest],
);
if (!Array.isArray(groups) || groups.length === 0) {
throw new Error('Group by param is required');
}
render() {
const { data, metric, groups } = this.props;
const { control, liftValues, pValues } = this.state;
// Render column header for each group
const columns = groups.map((group, i) => (
<Th key={i} column={group}>
{group}
</Th>
));
const numGroups = groups.length;
// Columns for p-value, lift-value, and significance (true/false)
columns.push(
<Th key={numGroups + 1} column="pValue">
p-value
</Th>,
);
columns.push(
<Th key={numGroups + 2} column="liftValue">
Lift %
</Th>,
);
columns.push(
<Th key={numGroups + 3} column="significant">
Significant
</Th>,
);
if (!Array.isArray(groups) || groups.length === 0) {
throw new Error('Group by param is required');
}
// Render column header for each group
const columns = groups.map((group, i) => (
<Th key={i} column={group}>
{group}
</Th>
));
const numGroups = groups.length;
// Columns for p-value, lift-value, and significance (true/false)
columns.push(
<Th key={numGroups + 1} column="pValue">
p-value
</Th>,
const rows = data.map((entry, i) => {
const values = groups.map(
(
group,
j, // group names
) => <Td key={j} column={group} data={entry.group[j]} />,
);
columns.push(
<Th key={numGroups + 2} column="liftValue">
Lift %
</Th>,
values.push(
<Td
key={numGroups + 1}
className={getPValueStatus(i)}
column="pValue"
data={pValues[i]}
/>,
);
columns.push(
<Th key={numGroups + 3} column="significant">
Significant
</Th>,
values.push(
<Td
key={numGroups + 2}
className={getLiftStatus(i)}
column="liftValue"
data={liftValues[i]}
/>,
);
values.push(
<Td
key={numGroups + 3}
className={getSignificance(i).toString()}
column="significant"
data={getSignificance(i)}
/>,
);
const rows = data.map((entry, i) => {
const values = groups.map(
(
group,
j, // group names
) => <Td key={j} column={group} data={entry.group[j]} />,
);
values.push(
<Td
key={numGroups + 1}
className={this.getPValueStatus(i)}
column="pValue"
data={pValues[i]}
/>,
);
values.push(
<Td
key={numGroups + 2}
className={this.getLiftStatus(i)}
column="liftValue"
data={liftValues[i]}
/>,
);
values.push(
<Td
key={numGroups + 3}
className={this.getSignificance(i).toString()}
column="significant"
data={this.getSignificance(i)}
/>,
);
return (
<Tr
key={i}
className={i === control ? 'control' : ''}
onClick={this.computeTTest.bind(this, i)}
>
{values}
</Tr>
);
});
// When sorted ascending, 'control' will always be at top
type SortConfigItem =
| string
| { column: string; sortFunction: (a: string, b: string) => number };
const sortConfig: SortConfigItem[] = (groups as SortConfigItem[]).concat([
{
column: 'pValue',
sortFunction: (a: string, b: string) => {
if (a === 'control') {
return -1;
}
if (b === 'control') {
return 1;
}
return a > b ? 1 : -1; // p-values ascending
},
},
{
column: 'liftValue',
sortFunction: (a: string, b: string) => {
if (a === 'control') {
return -1;
}
if (b === 'control') {
return 1;
}
return parseFloat(a) > parseFloat(b) ? -1 : 1; // lift values descending
},
},
{
column: 'significant',
sortFunction: (a: string, b: string) => {
if (a === 'control') {
return -1;
}
if (b === 'control') {
return 1;
}
return a > b ? -1 : 1; // significant values first
},
},
]);
return (
<div>
<h3>{metric}</h3>
<Table className="table" id={`table_${metric}`} sortable={sortConfig}>
<Thead>{columns}</Thead>
{rows}
</Table>
</div>
<Tr
key={i}
className={i === control ? 'control' : ''}
onClick={() => handleRowClick(i)}
>
{values}
</Tr>
);
}
});
// When sorted ascending, 'control' will always be at top
type SortConfigItem =
| string
| { column: string; sortFunction: (a: string, b: string) => number };
const sortConfig: SortConfigItem[] = useMemo(
() =>
(groups as SortConfigItem[]).concat([
{
column: 'pValue',
sortFunction: (a: string, b: string) => {
if (a === 'control') {
return -1;
}
if (b === 'control') {
return 1;
}
return a > b ? 1 : -1; // p-values ascending
},
},
{
column: 'liftValue',
sortFunction: (a: string, b: string) => {
if (a === 'control') {
return -1;
}
if (b === 'control') {
return 1;
}
return parseFloat(a) > parseFloat(b) ? -1 : 1; // lift values descending
},
},
{
column: 'significant',
sortFunction: (a: string, b: string) => {
if (a === 'control') {
return -1;
}
if (b === 'control') {
return 1;
}
return a > b ? -1 : 1; // significant values first
},
},
]),
[groups],
);
return (
<div>
<h3>{metric}</h3>
<Table className="table" id={`table_${metric}`} sortable={sortConfig}>
<Thead>{columns}</Thead>
{rows}
</Table>
</div>
);
}
export default TTestTable;

View File

@@ -0,0 +1,285 @@
/**
* 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 { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import TTestTable from '../src/TTestTable';
import type { DataEntry } from '../src/TTestTable';
// Mock the distributions module to return a predictable cdf value.
// cdf returns 0.01 so that p-value = 2 * 0.01 = 0.02
jest.mock('distributions', () => {
class MockStudentt {
cdf(_x: number): number {
return 0.01;
}
}
return {
__esModule: true,
default: { Studentt: MockStudentt },
};
});
const mockData: DataEntry[] = [
{
group: ['group-A'],
values: [
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
},
{
group: ['group-B'],
values: [
{ x: 1, y: 15 },
{ x: 2, y: 25 },
],
},
];
const defaultProps = {
alpha: 0.05,
data: mockData,
groups: ['category'],
liftValPrec: 4,
metric: 'revenue',
pValPrec: 6,
};
test('renders the metric name as an h3 heading', async () => {
render(<TTestTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('revenue')).toBeInTheDocument();
});
const heading = screen.getByText('revenue');
expect(heading.tagName).toBe('H3');
});
test('renders a table with the correct column headers', async () => {
render(<TTestTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
expect(screen.getByText('category')).toBeInTheDocument();
expect(screen.getByText('p-value')).toBeInTheDocument();
expect(screen.getByText('Lift %')).toBeInTheDocument();
expect(screen.getByText('Significant')).toBeInTheDocument();
});
test('renders group columns matching the groups prop', async () => {
const multiGroupData: DataEntry[] = [
{
group: ['group-A', 'sub-1'],
values: [{ x: 1, y: 10 }],
},
{
group: ['group-B', 'sub-2'],
values: [{ x: 1, y: 15 }],
},
];
render(
<TTestTable
{...defaultProps}
groups={['category', 'subcategory']}
data={multiGroupData}
/>,
);
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
expect(screen.getByText('category')).toBeInTheDocument();
expect(screen.getByText('subcategory')).toBeInTheDocument();
});
test('first row is treated as control by default and shows "control" for p-value and lift columns', async () => {
render(<TTestTable {...defaultProps} />);
// After componentDidMount, the first row should be control
await waitFor(() => {
const controlTexts = screen.getAllByText('control');
// The control row has "control" in pValue and liftValue columns
expect(controlTexts.length).toBeGreaterThanOrEqual(2);
});
});
test('computes lift values correctly for non-control rows', async () => {
// Control (group-A): sum of y = 10 + 20 = 30
// group-B: sum of y = 15 + 25 = 40
// Lift = ((40 - 30) / 30) * 100 = 33.3333%
// With liftValPrec=4 => "33.3333"
render(<TTestTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('33.3333')).toBeInTheDocument();
});
});
test('computes p-value using the mocked distributions module', async () => {
// Mock cdf returns 0.01, so p-value = 2 * 0.01 = 0.02
// With pValPrec=6 => "0.020000"
render(<TTestTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('0.020000')).toBeInTheDocument();
});
});
test('marks non-control row as significant when p-value is below alpha', async () => {
// p-value = 0.02 < alpha = 0.05, so significance is true
render(<TTestTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('true')).toBeInTheDocument();
});
});
test('marks non-control row as not significant when p-value is above alpha', async () => {
// p-value = 0.02 > alpha = 0.01, so significance is false
render(<TTestTable {...defaultProps} alpha={0.01} />);
await waitFor(() => {
expect(screen.getByText('false')).toBeInTheDocument();
});
});
test('returns NaN lift when control values sum to zero (division by zero guard)', async () => {
const zeroControlData: DataEntry[] = [
{
group: ['zero-group'],
values: [
{ x: 1, y: 0 },
{ x: 2, y: 0 },
],
},
{
group: ['other-group'],
values: [
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
},
];
render(<TTestTable {...defaultProps} data={zeroControlData} />);
// The lift computation: ((sumValues - sumControl) / sumControl) * 100
// = ((30 - 0) / 0) * 100 = Infinity
// Infinity.toFixed(4) in jsdom returns "NaN", and the component renders it.
// The getLiftStatus method classifies this as "invalid" (NaN or non-finite).
await waitFor(() => {
expect(screen.getByText('NaN')).toBeInTheDocument();
});
});
test('throws an error when groups array is empty', () => {
// Suppress React error boundary console output for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
render(<TTestTable {...defaultProps} groups={[]} />);
}).toThrow('Group by param is required');
consoleSpy.mockRestore();
});
test('clicking a non-control row changes it to the new control', async () => {
render(<TTestTable {...defaultProps} />);
// Wait for initial render with group-A as control
await waitFor(() => {
expect(screen.getByText('group-A')).toBeInTheDocument();
expect(screen.getByText('group-B')).toBeInTheDocument();
});
// Initially group-A is control, so its row shows "control" in p-value and lift columns.
// The non-control row (group-B) shows computed values.
await waitFor(() => {
expect(screen.getByText('33.3333')).toBeInTheDocument();
});
// Click the group-B row to make it the new control.
// The row containing "group-B" text is what we need to click.
const groupBCell = screen.getByText('group-B');
const groupBRow = groupBCell.closest('tr');
expect(groupBRow).not.toBeNull();
fireEvent.click(groupBRow!);
// After clicking, group-B becomes control.
// group-A lift: ((30 - 40) / 40) * 100 = -25.0000
await waitFor(() => {
expect(screen.getByText('-25.0000')).toBeInTheDocument();
});
});
test('renders group name data in the table cells', async () => {
render(<TTestTable {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('group-A')).toBeInTheDocument();
expect(screen.getByText('group-B')).toBeInTheDocument();
});
});
test('renders with three data rows and computes values for each non-control row', async () => {
const threeRowData: DataEntry[] = [
{
group: ['control-group'],
values: [
{ x: 1, y: 10 },
{ x: 2, y: 10 },
],
},
{
group: ['test-group-1'],
values: [
{ x: 1, y: 15 },
{ x: 2, y: 15 },
],
},
{
group: ['test-group-2'],
values: [
{ x: 1, y: 20 },
{ x: 2, y: 20 },
],
},
];
render(<TTestTable {...defaultProps} data={threeRowData} />);
await waitFor(() => {
expect(screen.getByText('control-group')).toBeInTheDocument();
expect(screen.getByText('test-group-1')).toBeInTheDocument();
expect(screen.getByText('test-group-2')).toBeInTheDocument();
});
// control-group: sum = 20
// test-group-1: sum = 30, lift = ((30-20)/20)*100 = 50.0000
// test-group-2: sum = 40, lift = ((40-20)/20)*100 = 100.0000
await waitFor(() => {
expect(screen.getByText('50.0000')).toBeInTheDocument();
expect(screen.getByText('100.0000')).toBeInTheDocument();
});
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import cloudLayout from 'd3-cloud';
import { scaleLinear } from 'd3-scale';
import { seed, CategoricalColorNamespace } from '@superset-ui/core';
@@ -81,18 +81,7 @@ export interface WordCloudProps extends WordCloudVisualProps {
colorScheme: string;
}
export interface WordCloudState {
words: Word[];
scaleFactor: number;
}
const defaultProps: Required<WordCloudVisualProps> = {
encoding: {},
rotation: 'flat',
};
type FullWordCloudProps = WordCloudProps &
typeof defaultProps & { theme: SupersetTheme };
type FullWordCloudProps = WordCloudProps & { theme: SupersetTheme };
const SCALE_FACTOR_STEP = 0.5;
const MAX_SCALE_FACTOR = 3;
@@ -196,61 +185,80 @@ class SimpleEncoder {
}
}
class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
static defaultProps = defaultProps;
function WordCloud({
data,
encoding = {},
width,
height,
rotation = 'flat',
sliceId,
colorScheme,
theme,
}: FullWordCloudProps) {
const [words, setWords] = useState<Word[]>([]);
const [scaleFactor] = useState(1);
const isMountedRef = useRef(true);
isComponentMounted = false;
// Store previous props for comparison
const prevPropsRef = useRef<{
data: PlainObject[];
encoding: Partial<WordCloudEncoding>;
width: number;
height: number;
rotation: RotationType;
} | null>(null);
createEncoder = (encoding?: Partial<WordCloudEncoding>): SimpleEncoder =>
new SimpleEncoder(encoding ?? {}, {
color: this.props.theme.colorTextLabel,
fontFamily: this.props.theme.fontFamily,
fontSize: 20,
fontWeight: 'bold',
text: '',
});
const createEncoder = useCallback(
(enc?: Partial<WordCloudEncoding>): SimpleEncoder =>
new SimpleEncoder(enc ?? {}, {
color: theme.colorTextLabel,
fontFamily: theme.fontFamily,
fontSize: 20,
fontWeight: 'bold',
text: '',
}),
[theme.colorTextLabel, theme.fontFamily],
);
constructor(props: FullWordCloudProps) {
super(props);
this.state = {
words: [],
scaleFactor: 1,
};
this.setWords = this.setWords.bind(this);
}
componentDidMount() {
this.isComponentMounted = true;
this.update();
}
componentDidUpdate(prevProps: WordCloudProps) {
const { data, encoding, width, height, rotation } = this.props;
if (
!isEqual(prevProps.data, data) ||
!isEqual(prevProps.encoding, encoding) ||
prevProps.width !== width ||
prevProps.height !== height ||
prevProps.rotation !== rotation
) {
this.update();
const setWordsIfMounted = useCallback((newWords: Word[]) => {
if (isMountedRef.current) {
setWords(newWords);
}
}
}, []);
componentWillUnmount() {
this.isComponentMounted = false;
}
const generateCloud = useCallback(
(
encoder: SimpleEncoder,
currentScaleFactor: number,
isValid: (word: Word[]) => boolean,
) => {
cloudLayout()
.size([width * currentScaleFactor, height * currentScaleFactor])
.words(data.map((d: Word) => ({ ...d })))
.padding(5)
.rotate(ROTATION[rotation] || ROTATION.flat)
.text((d: PlainObject) => encoder.getText(d))
.font((d: PlainObject) => encoder.getFontFamily(d))
.fontWeight((d: PlainObject) => encoder.getFontWeight(d))
.fontSize((d: PlainObject) => encoder.getFontSize(d))
.on('end', (cloudWords: Word[]) => {
if (isValid(cloudWords) || currentScaleFactor > MAX_SCALE_FACTOR) {
setWordsIfMounted(cloudWords);
} else {
generateCloud(
encoder,
currentScaleFactor + SCALE_FACTOR_STEP,
isValid,
);
}
})
.start();
},
[data, width, height, rotation, setWordsIfMounted],
);
setWords(words: Word[]) {
if (this.isComponentMounted) {
this.setState({ words });
}
}
update() {
const { data, encoding } = this.props;
const encoder = this.createEncoder(encoding);
const update = useCallback(() => {
const encoder = createEncoder(encoding);
encoder.setDomainFromDataset(data);
const sortedData = [...data].sort(
@@ -262,73 +270,71 @@ class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
);
const topResults = sortedData.slice(0, topResultsCount);
this.generateCloud(encoder, 1, (words: Word[]) =>
generateCloud(encoder, 1, (cloudWords: Word[]) =>
topResults.every((d: PlainObject) =>
words.find(({ text }) => encoder.getText(d) === text),
cloudWords.find(({ text }) => encoder.getText(d) === text),
),
);
}
}, [data, encoding, createEncoder, generateCloud]);
generateCloud(
encoder: SimpleEncoder,
scaleFactor: number,
isValid: (word: Word[]) => boolean,
) {
const { data, width, height, rotation } = this.props;
// Component mount/unmount tracking
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
cloudLayout()
.size([width * scaleFactor, height * scaleFactor])
.words(data.map((d: Word) => ({ ...d })))
.padding(5)
.rotate(ROTATION[rotation] || ROTATION.flat)
.text((d: PlainObject) => encoder.getText(d))
.font((d: PlainObject) => encoder.getFontFamily(d))
.fontWeight((d: PlainObject) => encoder.getFontWeight(d))
.fontSize((d: PlainObject) => encoder.getFontSize(d))
.on('end', (words: Word[]) => {
if (isValid(words) || scaleFactor > MAX_SCALE_FACTOR) {
this.setWords(words);
} else {
this.generateCloud(encoder, scaleFactor + SCALE_FACTOR_STEP, isValid);
}
})
.start();
}
// Initial update on mount and when dependencies change
useEffect(() => {
const prevProps = prevPropsRef.current;
const shouldUpdate =
!prevProps ||
!isEqual(prevProps.data, data) ||
!isEqual(prevProps.encoding, encoding) ||
prevProps.width !== width ||
prevProps.height !== height ||
prevProps.rotation !== rotation;
render() {
const { scaleFactor, words } = this.state;
const { width, height, encoding, sliceId, colorScheme } = this.props;
if (shouldUpdate) {
update();
}
const encoder = this.createEncoder(encoding);
prevPropsRef.current = { data, encoding, width, height, rotation };
}, [data, encoding, width, height, rotation, update]);
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const viewBoxWidth = width * scaleFactor;
const viewBoxHeight = height * scaleFactor;
const encoder = useMemo(
() => createEncoder(encoding),
[createEncoder, encoding],
);
return (
<svg
width={width}
height={height}
viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
>
<g>
{words.map(w => (
<text
key={w.text}
fontSize={`${w.size}px`}
fontWeight={w.weight}
fontFamily={w.font}
fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
textAnchor="middle"
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
>
{w.text}
</text>
))}
</g>
</svg>
);
}
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const viewBoxWidth = width * scaleFactor;
const viewBoxHeight = height * scaleFactor;
return (
<svg
width={width}
height={height}
viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
>
<g>
{words.map(w => (
<text
key={w.text}
fontSize={`${w.size}px`}
fontWeight={w.weight}
fontFamily={w.font}
fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
textAnchor="middle"
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
>
{w.text}
</text>
))}
</g>
</svg>
);
}
export default withTheme(WordCloud);