Skip to content

Commit

Permalink
feat(vis-type: scatter): improve rendering of scatter plot (#530)
Browse files Browse the repository at this point in the history
Co-authored-by: Michael Puehringer <[email protected]>
Co-authored-by: Christian Bors <[email protected]>
Co-authored-by: oltionchampari <[email protected]>
Co-authored-by: Holger Stitz <[email protected]>
Co-authored-by: Michael Pühringer <[email protected]>
  • Loading branch information
6 people authored Oct 16, 2024
1 parent 77e5a0a commit 913d5ea
Show file tree
Hide file tree
Showing 23 changed files with 1,653 additions and 858 deletions.
9 changes: 7 additions & 2 deletions playwright/scatterPlot/04_color.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ test('no color selected', async ({ page }, testInfo) => {
await page.goto('/');
await page.getByTestId('SingleSelectCloseButton').last().click();
await expect(page.getByLabel('Legend')).toBeDisabled();
await expect(page.locator('g[class="legend"]')).not.toBeVisible();
await expect(page.getByTestId('PlotLegend')).not.toBeVisible();
});

test('color selected', async ({ page }) => {
await page.goto('/');
await page.getByTestId('SingleSelectColor').click();
await page.getByRole('option', { name: 'Cellularity' }).click();
await expect(page.getByLabel('Legend')).not.toBeDisabled();
await expect(page.locator('g[class="legend"]')).toBeVisible();

const toggleLegend = await page.getByTestId('ToggleLegend');
const parentElement = await toggleLegend.evaluateHandle((node) => node.parentElement);
await parentElement.click();

await expect(page.getByTestId('PlotLegend')).toBeVisible();
});

test('show color scale', async ({ page }, testInfo) => {
Expand Down
7 changes: 6 additions & 1 deletion playwright/scatterPlot/05_shape.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@ test('shape selected', async ({ page }) => {
await page.getByTestId('SingleSelectShape').click();
await page.getByRole('option', { name: 'Breast Surgery Type Sparse' }).click();
await expect(page.getByLabel('Legend')).not.toBeDisabled();
expect(await page.locator('g[class="legend"]')).toBeVisible();

const toggleLegend = await page.getByTestId('ToggleLegend');
const parentElement = await toggleLegend.evaluateHandle((node) => node.parentElement);
await parentElement.click();

await expect(page.getByTestId('PlotLegend')).toBeVisible();
});
6 changes: 5 additions & 1 deletion src/hooks/useAsync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
) => {
const [status, setStatus] = React.useState<useAsyncStatus>('idle');
const [value, setValue] = React.useState<T | null>(null);
const [args, setArgs] = React.useState<Parameters<F> | null>(null);
const [error, setError] = React.useState<E | null>(null);
const latestPromiseRef = React.useRef<Promise<T> | null>();
const mountedRef = React.useRef<boolean>(false);
Expand All @@ -53,6 +54,7 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
// useCallback ensures the below useEffect is not called
// on every render, but only if asyncFunction changes.
const execute = React.useCallback(
// eslint-disable-next-line @typescript-eslint/no-shadow
(...args: Parameters<typeof asyncFunction>) => {
setStatus('pending');
// Do not unset the value, as we mostly want to retain the last value to avoid flickering, i.e. for "silent" updates.
Expand All @@ -62,13 +64,15 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
.then((response: T) => {
if (mountedRef.current && currentPromise === latestPromiseRef.current) {
setValue(response);
setArgs(args);
setStatus('success');
}
return response;
})
.catch((e: E) => {
if (mountedRef.current && currentPromise === latestPromiseRef.current) {
setValue(null);
setArgs(args);
setError(e);
setStatus('error');
}
Expand All @@ -93,5 +97,5 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
}
}, [execute, immediate]);

return { execute, status, value, error };
return { execute, status, value, error, args };
};
9 changes: 9 additions & 0 deletions src/jstat.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* No @types/jstat exist yet, this is a temporary workaround.
*/
declare module 'jstat' {
export function corrcoeff(a: number[], b: number[]): number;
export function spearmancoeff(a: number[], b: number[]): number;
export function tukeyhsd(a: number[][]): number;
export function ftest(a: number, b: number, c: number): number;
}
25 changes: 1 addition & 24 deletions src/vis/EagerVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ import { SankeyVisSidebar } from './sankey/SankeyVisSidebar';
import { ISankeyConfig } from './sankey/interfaces';
import { sankeyMergeDefaultConfig } from './sankey/utils';
import { scatterMergeDefaultConfig } from './scatter';
import { ScatterVis } from './scatter/ScatterVis';
import { ScatterVisSidebar } from './scatter/ScatterVisSidebar';
import { IScatterConfig } from './scatter/interfaces';
import { ViolinVis, violinBoxMergeDefaultConfig } from './violin';
import { ViolinVisSidebar } from './violin/ViolinVisSidebar';
import { IViolinConfig } from './violin/interfaces';
import { ScatterVis } from './scatter/ScatterVis';

const DEFAULT_SHAPES = ['circle', 'square', 'triangle-up', 'star'];

Expand Down Expand Up @@ -280,28 +280,6 @@ export function EagerVis({
return currMap;
}, [selected]);

const scales: Scales = React.useMemo(
() => ({
color: d3v7
.scaleOrdinal()
.range(
colors || [
getCssValue('visyn-c1'),
getCssValue('visyn-c2'),
getCssValue('visyn-c3'),
getCssValue('visyn-c4'),
getCssValue('visyn-c5'),
getCssValue('visyn-c6'),
getCssValue('visyn-c7'),
getCssValue('visyn-c8'),
getCssValue('visyn-c9'),
getCssValue('visyn-c10'),
],
),
}),
[colors],
);

const commonProps = {
showSidebar,
setShowSidebar,
Expand Down Expand Up @@ -363,7 +341,6 @@ export function EagerVis({
selectedMap={selectedMap}
selectedList={selected}
columns={columns}
scales={scales}
showSidebar={showSidebar}
showCloseButton={showCloseButton}
closeButtonCallback={closeCallback}
Expand Down
10 changes: 5 additions & 5 deletions src/vis/general/InvalidCols.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Alert, Center, Stack, rem } from '@mantine/core';
import { Alert, Center, Stack } from '@mantine/core';
import * as React from 'react';

export function InvalidCols({ headerMessage, bodyMessage }: { headerMessage: string; bodyMessage: string }) {
export function InvalidCols({ headerMessage, bodyMessage, style }: { headerMessage: string; bodyMessage: string; style?: React.CSSProperties }) {
return (
<Stack style={{ height: '100%' }}>
<Center style={{ height: '100%', width: '100%' }}>
<Alert title={headerMessage} color="yellow" miw={rem(420)}>
<Stack h="100%" style={style}>
<Center h="100%">
<Alert title={headerMessage} color="yellow">
{bodyMessage}
</Alert>
</Center>
Expand Down
1 change: 1 addition & 0 deletions src/vis/general/layoutUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export async function resolveSingleColumn(column: VisColumn | null) {
if (!column) {
return null;
}

return {
...column,
resolvedValues: await column.values(),
Expand Down
8 changes: 4 additions & 4 deletions src/vis/general/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ export function getLabelOrUnknown(label: string | number | null | undefined, unk
notation: 'compact',
compactDisplay: 'short',
});
return [null, 'null', undefined, 'undefined', ''].includes(label as string)
return label === null || label === 'null' || label === undefined || label === 'undefined' || label === ''
? unknownLabel
: Number.isNaN(Number(label))
? (label as string)
: formatter.format(label as number);
: Number(label) && !Number.isInteger(label) // if it is a number, but not an integer, apply NumberFormat
? formatter.format(label as number)
: label.toString();
}

/**
Expand Down
1 change: 0 additions & 1 deletion src/vis/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ export interface ICommonVisProps<T> {
selectedList?: string[];
showCloseButton?: boolean;
closeButtonCallback?: () => void;
scales?: Scales;
enableSidebar?: boolean;
showSidebar?: boolean;
showSidebarDefault?: boolean;
Expand Down
69 changes: 69 additions & 0 deletions src/vis/scatter/FastTextMeasure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Class that can measure text for a given font very efficiently (about ~1000 times faster than canvas measuretext).
* It uses a precomputed table of character widths for a given font and size.
*
* Also supports utility functions like ellipsis.
*/
export class FastTextMeasure {
table = new Float32Array(127);

meanWidth = 0;

constructor(private font: string) {
this.computeTable();
}

// Computes the whole size table
computeTable() {
const textWidthCanvas = document.createElement('canvas');
const textWidthContext = textWidthCanvas.getContext('2d');

if (!textWidthContext) {
throw new Error('Could not get 2d context');
}

textWidthContext.font = this.font;

for (let i = 1; i < 127; i++) {
this.table[i] = textWidthContext.measureText(String.fromCharCode(i)).width;
this.meanWidth += this.table[i]!;
}

this.meanWidth /= 127;
}

// Measures the width of a given text
fastMeasureText(text: string) {
let width = 0;

for (let i = 0; i < text.length; i++) {
const ascii = text.charCodeAt(i);
if (ascii < 127) {
width += this.table[ascii]!;
} else {
width += this.meanWidth;
}
}

return width;
}

// Cuts off text and adds ellipsis if it exceeds the given width
textEllipsis(text: string, maxWidth: number) {
let width = this.fastMeasureText(text);

if (width <= maxWidth) {
return text;
}

const ellipsisWidth = this.fastMeasureText('...');
let ellipsisCount = 0;

while (width + ellipsisWidth > maxWidth) {
ellipsisCount++;
width -= this.table[text.charCodeAt(text.length - ellipsisCount)]!;
}

return `${text.slice(0, text.length - ellipsisCount)}...`;
}
}
38 changes: 19 additions & 19 deletions src/vis/scatter/Regression.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function RegressionLineOptions({ callback, currentSelected, showColorPick
<Select
data-testid="RegressionLineSelect"
searchable
clearable
label={
<HelpHoverCard
title={
Expand Down Expand Up @@ -100,8 +101,8 @@ export function RegressionLineOptions({ callback, currentSelected, showColorPick
* @return {number} - The r^2 value, or NaN if one cannot be calculated.
*/
function determinationCoefficient(data: RegressionData, results: RegressionData) {
const predictions = [];
const observations = [];
const predictions: number[][] = [];
const observations: number[][] = [];

data.forEach((d, i) => {
if (d[1] !== null) {
Expand Down Expand Up @@ -236,7 +237,7 @@ const methods = {
stats: {
r2: round(r2, options.precision),
n: len,
pValue: Number.isNaN(pValue) ? null : pValue,
pValue: Number.isNaN(pValue) ? undefined : pValue,
},
equation: intercept === 0 ? `y = ${gradient}x` : `y = ${gradient}x + ${intercept}`,
svgPath: `M ${min} ${predict(min)[1]} L ${max} ${predict(max)[1]}`,
Expand Down Expand Up @@ -315,7 +316,7 @@ const methods = {
.join(' ');

const r2 = determinationCoefficient(data, points);
const pValue = null; // did not define p-value for exponential regression
const pValue: number = null; // did not define p-value for exponential regression

return {
stats: {
Expand All @@ -335,27 +336,26 @@ const regressionMethodsMapping = {
};

export const fitRegressionLine = (
data: Partial<Plotly.PlotData>,
data: { x: number[]; y: number[] },
method: ERegressionLineType,
xref: string,
yref: string,
options: IRegressionFitOptions = DEFAULT_CURVE_FIT_OPTIONS,
): IRegressionResult => {
// Filter out null or undefined values (equivalent to pd.dropna())
const filteredPairs = (data.x as number[]).map((value, index) => ({ x: value, y: data.y[index] })).filter((pair) => pair.x != null && pair.y != null);
const x = filteredPairs.map((pair) => pair.x);
const y = filteredPairs.map((pair) => pair.y);

const pearsonRho = round(corrcoeff(x, y), options.precision);
const spearmanRho = round(spearmancoeff(x, y), options.precision);
const regressionResult = regressionMethodsMapping[method]
? methods[regressionMethodsMapping[method]](
x.map((val, i) => [val, y[i]]),
options,
)
: null;
const pearsonRho = round(corrcoeff(data.x, data.y), options.precision);
const spearmanRho = round(spearmancoeff(data.x, data.y), options.precision);
const fnc = method === ERegressionLineType.LINEAR ? methods.linear : methods.polynomial;

const regressionResult = fnc(
data.x.map((val, i) => [val, data.y[i]!]),
options,
);

return {
...regressionResult,
stats: { ...regressionResult.stats, pearsonRho, spearmanRho },
xref: data.xaxis,
yref: data.yaxis,
xref,
yref,
};
};
Loading

0 comments on commit 913d5ea

Please sign in to comment.