Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vis-type: scatter): improve rendering of scatter plot #530

Merged
merged 59 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
75373f2
Initialize branch
dvmoritzschoefl Sep 19, 2024
1d71f7c
Fix reset
puehringer Sep 19, 2024
29c1254
Simplify merge of finalLayout
puehringer Sep 19, 2024
e6db893
Make sure that range is used from previous layout
puehringer Sep 19, 2024
8f1208d
Regression for all plot types. Added scatter plot case
dvmoritzschoefl Sep 19, 2024
1e5ea3a
display modebar
dvmoritzschoefl Sep 19, 2024
51606e0
fixed selections
dvmoritzschoefl Sep 19, 2024
a2d2a33
added shift selection
dvmoritzschoefl Sep 19, 2024
3fc90f1
refactored hooks a bit, fixed two issues regarding text
dvmoritzschoefl Sep 20, 2024
c7fa0a0
removed text
dvmoritzschoefl Sep 20, 2024
ede8d90
experimental force layout
dvmoritzschoefl Sep 20, 2024
6778640
reverted text behavior to previous plot
dvmoritzschoefl Sep 23, 2024
3617fc1
readded trace error
dvmoritzschoefl Sep 23, 2024
b69fa34
added jstate module stabs
dvmoritzschoefl Sep 23, 2024
333ce34
all axis share the same style
dvmoritzschoefl Sep 23, 2024
1c5ff0e
restored color legends
dvmoritzschoefl Sep 23, 2024
007571e
activaed efl
dvmoritzschoefl Sep 23, 2024
af5c420
removed s
dvmoritzschoefl Sep 23, 2024
4f9e363
cleanup
dvmoritzschoefl Sep 23, 2024
1e3e358
cleanup
dvmoritzschoefl Sep 23, 2024
6089939
enabled show legend switch
dvmoritzschoefl Sep 23, 2024
a5ada29
enabled show legend switch
dvmoritzschoefl Sep 23, 2024
89a49fa
enabled show legend switch
dvmoritzschoefl Sep 23, 2024
ca9a90c
enabled show legend switch
dvmoritzschoefl Sep 23, 2024
5485f02
facet text
dvmoritzschoefl Sep 24, 2024
788910d
calc facet layout by rows, not columns
dvchristianbors Sep 25, 2024
2d8184e
tweak facet layout
dvchristianbors Sep 25, 2024
fcc97a4
use `idToLabelMapper` for plot labels
dvchristianbors Sep 25, 2024
3e8f3fc
do not facet one category
dvchristianbors Sep 25, 2024
6202cc1
cleanup
dvchristianbors Sep 26, 2024
c4500f9
truncate/round float values in tooltips
dvchristianbors Sep 26, 2024
6414a56
fix: set height always to 100% as updating the style dynamically does…
oltionchampari Sep 26, 2024
deed2fb
Merge branch 'develop' into mh/scatterplot_new
dvchristianbors Oct 1, 2024
791ea82
filter out missing values for regression line
dvchristianbors Oct 1, 2024
c872701
refactor: rename ScatterVisNew to ScatterVis
thinkh Oct 1, 2024
1213372
Used dimensions from parent to resize plotly
dvmoritzschoefl Oct 4, 2024
ad207fd
feat: Used dimensions from parent to resize plotly (#553)
thinkh Oct 4, 2024
1653a2c
Merge branch 'develop' into mh/scatterplot_new
thinkh Oct 4, 2024
a20d80b
various fixes
dvmoritzschoefl Oct 8, 2024
09ca3d1
fix: eslint
thinkh Oct 8, 2024
7f0294b
feedback from core meeting
dvmoritzschoefl Oct 9, 2024
9621e0a
Merge branch 'develop' into mh/scatterplot_new
thinkh Oct 9, 2024
cfdd35e
only calculate regressions for lower triangular part of SPLOM
dvmoritzschoefl Oct 10, 2024
380c30f
Merge branch 'mh/scatterplot_new' of github.com:datavisyn/visyn_core …
dvmoritzschoefl Oct 10, 2024
28977b3
added the ability to control subplots in the scattervis by supplying …
dvmoritzschoefl Oct 10, 2024
9dd25eb
fixed typing
dvmoritzschoefl Oct 10, 2024
5cc2885
Merge branch 'develop' into mh/scatterplot_new
thinkh Oct 10, 2024
8107ee9
fixed columns
dvmoritzschoefl Oct 10, 2024
e086261
Merge branch 'mh/scatterplot_new' of github.com:datavisyn/visyn_core …
dvmoritzschoefl Oct 10, 2024
5b3fb9b
removed scales from vis
dvmoritzschoefl Oct 15, 2024
2859229
style optional
dvmoritzschoefl Oct 15, 2024
2675da7
style optional
dvmoritzschoefl Oct 15, 2024
361dcff
positioned legend switch
dvmoritzschoefl Oct 15, 2024
4da432d
automated text measuring in facet/subplot mode
dvmoritzschoefl Oct 16, 2024
07685a1
added FastTextMeasure class for fast ellipsis calculation using a loo…
dvmoritzschoefl Oct 16, 2024
ba6121a
fixed color scales
dvmoritzschoefl Oct 16, 2024
49096fc
Merge branch 'develop' into mh/scatterplot_new
puehringer Oct 16, 2024
ce8d75b
Merge remote-tracking branch 'origin/develop' into mh/scatterplot_new
thinkh Oct 16, 2024
76dff94
tests: fix legend in playwright tests
thinkh Oct 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading