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

Release 16.1.0 #721

Merged
merged 5 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "visyn_core",
"description": "Core repository for datavisyn applications.",
"version": "16.0.1",
"version": "16.1.0",
"author": {
"name": "datavisyn GmbH",
"email": "[email protected]",
Expand Down Expand Up @@ -130,7 +130,6 @@
"react-plotly.js": "^2.6.0",
"react-spring": "^9.7.5",
"react-window": "^1.8.11",
"use-deep-compare-effect": "^1.8.1",
"visyn_scripts": "^12.0.1"
},
"devDependencies": {
Expand Down
33 changes: 18 additions & 15 deletions src/app/VisynAppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { loadClientConfig } from '../base/clientConfig';
import { useAsync, useInitVisynApp, useVisynUser } from '../hooks';
import { VisynAppContext } from './VisynAppContext';
import { DEFAULT_MANTINE6_PROVIDER_PROPS, DEFAULT_MANTINE_PROVIDER_PROPS } from './constants';
import type { IUser } from '../security/interfaces';
import { VisProvider } from '../vis/Provider';

import '@mantine/code-highlight/styles.css';
Expand All @@ -30,6 +31,7 @@ export function VisynAppProvider({
mantineNotificationsProviderProps,
sentryInitOptions = {},
sentryOptions = {},
waitForClientConfig = true,
}: {
/**
* Set this to true to disable the MantineProvider of Mantine 6. Use only if no Mantine 6 components are used.
Expand All @@ -56,25 +58,24 @@ export function VisynAppProvider({
*/
setUser?: boolean;
};
/**
* Set this to false to skip the wait for the client config. This is useful if the app works even without the client config.
* @default `true`
*/
waitForClientConfig?: boolean;
}) {
const user = useVisynUser();
const { status: initStatus } = useInitVisynApp();

const { value: clientConfig, status: clientConfigStatus, execute } = useAsync(loadClientConfig);
// Add the user as argument such that whenever the user changes, we want to reload the client config to get the latest permissions.
const loadClientConfigCallback = React.useCallback((_: IUser | null) => loadClientConfig(), []);
const { value: clientConfig, status: clientConfigStatus } = useAsync(loadClientConfigCallback, [user]);
// Once the client config is loaded, we can set the successful client config init.
// This is required as when the user changes, we reload the client config but don't want to trigger a complete unmount.
const [successfulClientConfigInit, setSuccessfulClientConfigInit] = React.useState<boolean>(false);

React.useEffect(() => {
// Once the client config is loaded, we can set the successful client config init.
// This is required as when the user changes, we reload the client config but don't want to trigger a complete unmount.
if (clientConfigStatus === 'success') {
setSuccessfulClientConfigInit(true);
}
}, [clientConfigStatus]);

React.useEffect(() => {
// Whenever the user changes, we want to reload the client config to get the latest permissions.
execute();
}, [user, execute]);
if (clientConfigStatus === 'success' && !successfulClientConfigInit) {
setSuccessfulClientConfigInit(true);
}

const context = React.useMemo(
() => ({
Expand Down Expand Up @@ -139,7 +140,9 @@ export function VisynAppProvider({

// Extract as variable to more easily make LazyMantine6Provider optional
const visynAppContext = (
<VisynAppContext.Provider value={context}>{initStatus === 'success' && successfulClientConfigInit ? children : null}</VisynAppContext.Provider>
<VisynAppContext.Provider value={context}>
{initStatus === 'success' && (!waitForClientConfig || successfulClientConfigInit) ? children : null}
</VisynAppContext.Provider>
);

return (
Expand Down
2 changes: 1 addition & 1 deletion src/demo/index.initialize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { VisynAppProvider } from '../app/VisynAppProvider';
// create a new instance of the app
createRoot(document.getElementById('main')!).render(
<React.StrictMode>
<VisynAppProvider appName="Demo App" disableMantine6>
<VisynAppProvider appName="Demo App" disableMantine6 waitForClientConfig={false}>
<MainApp />
</VisynAppProvider>
</React.StrictMode>,
Expand Down
39 changes: 19 additions & 20 deletions src/hooks/useAsync.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import * as React from 'react';

import useDeepCompareEffect from 'use-deep-compare-effect';

// https://stackoverflow.com/questions/48011353/how-to-unwrap-type-of-a-promise
type Awaited<T> = T extends PromiseLike<infer U> ? { 0: Awaited<U>; 1: U }[U extends PromiseLike<any> ? 0 : 1] : T;
import { useDeepEffect } from './useDeepEffect';

// eslint-disable-next-line @typescript-eslint/naming-convention
export type useAsyncStatus = 'idle' | 'pending' | 'success' | 'error';
Expand Down Expand Up @@ -36,10 +33,13 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
asyncFunction: F,
immediate: Parameters<F> | null = null,
) => {
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 [state, setState] = React.useState<{
status: useAsyncStatus;
value: T | null;
error: E | null;
args: Parameters<F> | null;
}>({ status: 'idle', value: null, error: null, args: null });

const latestPromiseRef = React.useRef<Promise<T> | null>();
const mountedRef = React.useRef<boolean>(false);

Expand All @@ -57,25 +57,24 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
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.
// setValue(null);
setError(null);
setState((oldState) => ({ ...oldState, status: 'pending', error: null }));

const currentPromise = Promise.resolve(asyncFunction(...args))
.then((response: T) => {
if (mountedRef.current && currentPromise === latestPromiseRef.current) {
setValue(response);
setArgs(args);
setStatus('success');
setState((oldState) => ({ ...oldState, status: 'success', value: response, args }));
}
return response;
})
.catch((e: E) => {
if (mountedRef.current && currentPromise === latestPromiseRef.current) {
setValue(null);
setArgs(args);
setError(e);
setStatus('error');
setState({
status: 'error',
value: null,
error: e,
args,
});
}
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw e;
Expand All @@ -88,7 +87,7 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
// Call execute if we want to fire it right away.
// Otherwise execute can be called later, such as
// in an onClick handler.
useDeepCompareEffect(() => {
useDeepEffect(() => {
if (immediate) {
try {
execute(...immediate);
Expand All @@ -98,5 +97,5 @@ export const useAsync = <F extends (...args: any[]) => any, E = Error, T = Await
}
}, [execute, immediate]);

return { execute, status, value, error, args };
return { execute, status: state.status, value: state.value, error: state.error, args: state.args };
};
14 changes: 14 additions & 0 deletions src/utils/indicesOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function indicesOf<T>(array: T[], predicate: (value: T, index: number) => boolean): number[] {
const indices = new Array<number>(array.length);
let count = 0;

for (let i = 0; i < array.length; i++) {
if (predicate(array[i]!, i)) {
indices[count++] = i;
}
}

indices.length = count;

return indices;
}
87 changes: 36 additions & 51 deletions src/vis/scatter/ScatterVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ import { VIS_NEUTRAL_COLOR } from '../general/constants';
import { EColumnTypes, ENumericalColorScaleType, EScatterSelectSettings, ICommonVisProps } from '../interfaces';
import { BrushOptionButtons } from '../sidebar/BrushOptionButtons';

function Legend({ categories, colorMap, onClick }: { categories: string[]; colorMap: (v: number | string) => string; onClick: (string) => void }) {
function Legend({
categories,
hiddenCategoriesSet,
colorMap,
onClick,
}: {
categories: string[];
hiddenCategoriesSet?: Set<string>;
colorMap: (v: number | string) => string;
onClick: (category: string) => void;
}) {
return (
<ScrollArea
data-testid="PlotLegend"
Expand All @@ -40,9 +50,9 @@ function Legend({ categories, colorMap, onClick }: { categories: string[]; color
`}
>
<Stack gap={0}>
{categories.map((c) => {
return <LegendItem key={c} color={colorMap(c)} label={c} onClick={() => onClick(c)} filtered={false} />;
})}
{categories.map((c) => (
<LegendItem key={c} color={colorMap(c)} label={c} onClick={() => onClick(c)} filtered={hiddenCategoriesSet?.has(c) ?? false} />
))}
</Stack>
</ScrollArea>
);
Expand Down Expand Up @@ -79,6 +89,8 @@ export function ScatterVis({
const [shiftPressed, setShiftPressed] = React.useState(false);
const [showLegend, setShowLegend] = React.useState(false);

const [hiddenCategoriesSet, setHiddenCategoriesSet] = React.useState<Set<string>>(new Set<string>());

// const [ref, { width, height }] = useResizeObserver();
const { ref, width, height } = useElementSize();

Expand Down Expand Up @@ -130,10 +142,11 @@ export function ScatterVis({
}

const { subplots, scatter, splom, facet, shapeScale } = useDataPreparation({
value,
hiddenCategoriesSet,
numColorScaleType: config.numColorScaleType,
status,
uniqueSymbols,
numColorScaleType: config.numColorScaleType,
value,
});

const regressions = React.useMemo<{
Expand Down Expand Up @@ -295,49 +308,6 @@ export function ScatterVis({
return undefined;
}

/* const legendPlots: PlotlyTypes.Data[] = [];

if (value.shapeColumn) {
legendPlots.push({
x: [null],
y: [null],
type: 'scatter',
mode: 'markers',
showlegend: true,
legendgroup: 'shape',
hoverinfo: 'all',

hoverlabel: {
namelength: 10,
bgcolor: 'black',
align: 'left',
bordercolor: 'black',
},
// @ts-ignore
legendgrouptitle: {
text: truncateText(value.shapeColumn.info.name, true, 20),
},
marker: {
line: {
width: 0,
},
symbol: value.shapeColumn ? value.shapeColumn.resolvedValues.map((v) => shapeScale(v.val as string)) : 'circle',
color: VIS_NEUTRAL_COLOR,
},
transforms: [
{
type: 'groupby',
groups: value.shapeColumn.resolvedValues.map((v) => getLabelOrUnknown(v.val)),
styles: [
...[...new Set<string>(value.shapeColumn.resolvedValues.map((v) => getLabelOrUnknown(v.val)))].map((c) => {
return { target: c, value: { name: c } };
}),
],
},
],
});
}
*/
if (value.colorColumn && value.colorColumn.type === EColumnTypes.CATEGORICAL) {
// Get distinct values
const colorValues = uniq(value.colorColumn.resolvedValues.map((v) => v.val ?? 'Unknown') as string[]);
Expand Down Expand Up @@ -529,7 +499,7 @@ export function ScatterVis({
}

if (scatter) {
const ids = event.points.map((point) => scatter.ids[point.pointIndex]) as string[];
const ids = event.points.map((point) => scatter.ids[scatter.filter[point.pointIndex]!]) as string[];
mergeIntoSelection(ids);
}

Expand Down Expand Up @@ -570,7 +540,22 @@ export function ScatterVis({

{status === 'success' && layout && legendData?.color.mapping && showLegend ? (
<div style={{ gridArea: 'legend', overflow: 'hidden' }}>
<Legend categories={legendData.color.categories} colorMap={legendData.color.mappingFunction} onClick={() => {}} />
<Legend
categories={legendData.color.categories}
colorMap={legendData.color.mappingFunction}
hiddenCategoriesSet={hiddenCategoriesSet}
onClick={(category: string) => {
setHiddenCategoriesSet((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
}}
/>
</div>
) : null}
</div>
Expand Down
49 changes: 34 additions & 15 deletions src/vis/scatter/useData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,35 +119,54 @@ export function useData({
{
...BASE_DATA,
type: 'scattergl',
x: scatter.plotlyData.x,
y: scatter.plotlyData.y,
x: scatter.filter.map((index) => scatter.plotlyData.x[index]),
y: scatter.filter.map((index) => scatter.plotlyData.y[index]),
// text: scatter.plotlyData.text,
textposition: scatter.plotlyData.text.map((_, i) => textPositionOptions[i % textPositionOptions.length]),
...(isEmpty(selectedSet) ? {} : { selectedpoints: selectedList.map((idx) => scatter.idToIndex.get(idx)) }),
textposition: scatter.filter.map((index) => textPositionOptions[index % textPositionOptions.length]),
...(isEmpty(selectedSet)
? {}
: { selectedpoints: selectedList.map((idx) => scatter.idToIndex.get(idx)).filter((v) => v !== undefined && v !== null) }),
mode: config.showLabels === ELabelingOptions.NEVER || config.xAxisScale === 'log' || config.yAxisScale === 'log' ? 'markers' : 'text+markers',
...(config.showLabels === ELabelingOptions.NEVER
? {}
: config.showLabels === ELabelingOptions.ALWAYS
? {
text: scatter.plotlyData.text.map((t) => truncateText(value.idToLabelMapper(t), true, 10)),
// textposition: 'top center',
text: scatter.filter.map((index) => truncateText(value.idToLabelMapper(scatter.plotlyData.text[index]!), true, 10)),
}
: {
text: scatter.plotlyData.text.map((t, i) => (visibleLabelsSet.has(scatter.ids[i]!) ? truncateText(value.idToLabelMapper(t), true, 10) : '')),
// textposition: 'top center',
text: scatter.filter.map((index, i) =>
visibleLabelsSet.has(scatter.ids[index]!)
? truncateText(value.idToLabelMapper(value.idToLabelMapper(scatter.plotlyData.text[index]!)), true, 10)
: '',
),
}),
hovertext: value.validColumns[0].resolvedValues.map((v, i) =>
`${value.idToLabelMapper(v.id)}
${(value.resolvedLabelColumns ?? []).map((l) => `<br />${columnNameWithDescription(l.info)}: ${getLabelOrUnknown(l.resolvedValues[i]?.val)}`)}
${value.colorColumn ? `<br />${columnNameWithDescription(value.colorColumn.info)}: ${getLabelOrUnknown(value.colorColumn.resolvedValues[i]?.val)}` : ''}
${value.shapeColumn && value.shapeColumn.info.id !== value.colorColumn?.info.id ? `<br />${columnNameWithDescription(value.shapeColumn.info)}: ${getLabelOrUnknown(value.shapeColumn.resolvedValues[i]?.val)}` : ''}`.trim(),
),
hovertext: scatter.filter.map((i) => {
const resolvedLabelString =
value.resolvedLabelColumns?.length > 0
? value.resolvedLabelColumns.map((l) => `<b>${columnNameWithDescription(l.info)}</b>: ${getLabelOrUnknown(l.resolvedValues[i]?.val)}<br />`)
: '';
const idString = `<b>${value.idToLabelMapper(scatter.plotlyData.text[i]!)}</b><br />`;
const xString = `<b>${columnNameWithDescription(value.validColumns[0]!.info)}</b>: ${getLabelOrUnknown(value.validColumns[0]!.resolvedValues[i]?.val)}<br />`;
const yString = `<b>${columnNameWithDescription(value.validColumns[1]!.info)}</b>: ${getLabelOrUnknown(value.validColumns[1]!.resolvedValues[i]?.val)}<br />`;
const colorColumnString = value.colorColumn
? `<b>${columnNameWithDescription(value.colorColumn.info)}</b>: ${getLabelOrUnknown(value.colorColumn.resolvedValues[i]?.val)}<br />`
: '';
const shapeColumnString =
value.shapeColumn && value.shapeColumn.info.id !== value.colorColumn?.info.id
? `<b>${columnNameWithDescription(value.shapeColumn.info)}</b>: ${getLabelOrUnknown(value.shapeColumn.resolvedValues[i]?.val)}<br />`
: '';

return `${idString}${xString}${yString}${resolvedLabelString}${colorColumnString}${shapeColumnString}`;
}),
marker: {
textfont: {
color: VIS_NEUTRAL_COLOR,
},
size: 8,
color: value.colorColumn && mappingFunction ? value.colorColumn.resolvedValues.map((v) => mappingFunction(v.val)) : VIS_NEUTRAL_COLOR,
color:
value.colorColumn && mappingFunction
? scatter.filter.map((index) => mappingFunction(value.colorColumn.resolvedValues[index]!.val as string))
: VIS_NEUTRAL_COLOR,
symbol: value.shapeColumn ? value.shapeColumn.resolvedValues.map((v) => shapeScale(v.val as string)) : 'circle',
opacity: fullOpacityOrAlpha,
},
Expand Down
Loading