diff --git a/playwright/scatterPlot/04_color.spec.ts b/playwright/scatterPlot/04_color.spec.ts index 9c67fda23..72227621d 100644 --- a/playwright/scatterPlot/04_color.spec.ts +++ b/playwright/scatterPlot/04_color.spec.ts @@ -4,7 +4,7 @@ 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 }) => { @@ -12,7 +12,12 @@ test('color selected', async ({ page }) => { 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) => { diff --git a/playwright/scatterPlot/05_shape.spec.ts b/playwright/scatterPlot/05_shape.spec.ts index 3030c4e45..66d93de3e 100644 --- a/playwright/scatterPlot/05_shape.spec.ts +++ b/playwright/scatterPlot/05_shape.spec.ts @@ -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(); }); diff --git a/src/hooks/useAsync.tsx b/src/hooks/useAsync.tsx index 645b14fa7..ce298b4b6 100644 --- a/src/hooks/useAsync.tsx +++ b/src/hooks/useAsync.tsx @@ -37,6 +37,7 @@ export const useAsync = any, E = Error, T = Await ) => { const [status, setStatus] = React.useState('idle'); const [value, setValue] = React.useState(null); + const [args, setArgs] = React.useState | null>(null); const [error, setError] = React.useState(null); const latestPromiseRef = React.useRef | null>(); const mountedRef = React.useRef(false); @@ -53,6 +54,7 @@ export const useAsync = 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) => { setStatus('pending'); // Do not unset the value, as we mostly want to retain the last value to avoid flickering, i.e. for "silent" updates. @@ -62,6 +64,7 @@ export const useAsync = any, E = Error, T = Await .then((response: T) => { if (mountedRef.current && currentPromise === latestPromiseRef.current) { setValue(response); + setArgs(args); setStatus('success'); } return response; @@ -69,6 +72,7 @@ export const useAsync = any, E = Error, T = Await .catch((e: E) => { if (mountedRef.current && currentPromise === latestPromiseRef.current) { setValue(null); + setArgs(args); setError(e); setStatus('error'); } @@ -93,5 +97,5 @@ export const useAsync = any, E = Error, T = Await } }, [execute, immediate]); - return { execute, status, value, error }; + return { execute, status, value, error, args }; }; diff --git a/src/jstat.d.ts b/src/jstat.d.ts new file mode 100644 index 000000000..f7227816f --- /dev/null +++ b/src/jstat.d.ts @@ -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; +} diff --git a/src/vis/EagerVis.tsx b/src/vis/EagerVis.tsx index 9d9704a06..23008d758 100644 --- a/src/vis/EagerVis.tsx +++ b/src/vis/EagerVis.tsx @@ -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']; @@ -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, @@ -363,7 +341,6 @@ export function EagerVis({ selectedMap={selectedMap} selectedList={selected} columns={columns} - scales={scales} showSidebar={showSidebar} showCloseButton={showCloseButton} closeButtonCallback={closeCallback} diff --git a/src/vis/general/InvalidCols.tsx b/src/vis/general/InvalidCols.tsx index 356a6c11d..41b185c2a 100644 --- a/src/vis/general/InvalidCols.tsx +++ b/src/vis/general/InvalidCols.tsx @@ -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 ( - -
- + +
+ {bodyMessage}
diff --git a/src/vis/general/layoutUtils.ts b/src/vis/general/layoutUtils.ts index 687cbf386..010ff913c 100644 --- a/src/vis/general/layoutUtils.ts +++ b/src/vis/general/layoutUtils.ts @@ -167,6 +167,7 @@ export async function resolveSingleColumn(column: VisColumn | null) { if (!column) { return null; } + return { ...column, resolvedValues: await column.values(), diff --git a/src/vis/general/utils.ts b/src/vis/general/utils.ts index e62480c93..424ad10f5 100644 --- a/src/vis/general/utils.ts +++ b/src/vis/general/utils.ts @@ -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(); } /** diff --git a/src/vis/interfaces.ts b/src/vis/interfaces.ts index e405cb187..d9065c2f1 100644 --- a/src/vis/interfaces.ts +++ b/src/vis/interfaces.ts @@ -165,7 +165,6 @@ export interface ICommonVisProps { selectedList?: string[]; showCloseButton?: boolean; closeButtonCallback?: () => void; - scales?: Scales; enableSidebar?: boolean; showSidebar?: boolean; showSidebarDefault?: boolean; diff --git a/src/vis/scatter/FastTextMeasure.ts b/src/vis/scatter/FastTextMeasure.ts new file mode 100644 index 000000000..c293313e9 --- /dev/null +++ b/src/vis/scatter/FastTextMeasure.ts @@ -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)}...`; + } +} diff --git a/src/vis/scatter/Regression.tsx b/src/vis/scatter/Regression.tsx index e8b03d7c5..3d33ac337 100644 --- a/src/vis/scatter/Regression.tsx +++ b/src/vis/scatter/Regression.tsx @@ -26,6 +26,7 @@ export function RegressionLineOptions({ callback, currentSelected, showColorPick