diff --git a/package.json b/package.json index 0853d52fd..b78ecfe9d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "visyn_core", "description": "Core repository for datavisyn applications.", - "version": "14.1.1", + "version": "14.2.0", "author": { "name": "datavisyn GmbH", "email": "contact@datavisyn.io", @@ -145,6 +145,9 @@ "storybook": "^7.6.20", "storybook-addon-swc": "^1.2.0" }, + "resolutions": { + "@swc/core": "1.7.42" + }, "visyn": { "entries": { "app": { diff --git a/src/vis/bar/BarChart.tsx b/src/vis/bar/BarChart.tsx index 0d2f4847f..fe40f911c 100644 --- a/src/vis/bar/BarChart.tsx +++ b/src/vis/bar/BarChart.tsx @@ -7,10 +7,8 @@ import { ListChildComponentProps, VariableSizeList } from 'react-window'; import { BlurredOverlay } from '../../components'; import { useAsync } from '../../hooks/useAsync'; import { categoricalColors10 } from '../../utils/colors'; -import { NAN_REPLACEMENT } from '../general'; -import { DownloadPlotButton } from '../general/DownloadPlotButton'; -import { ErrorMessage } from '../general/ErrorMessage'; -import { getLabelOrUnknown } from '../general/utils'; +import { DownloadPlotButton, ErrorMessage } from '../general'; +import { FastTextMeasure } from '../general/FastTextMeasure'; import { ColumnInfo, EAggregateTypes, EColumnTypes, ICommonVisProps } from '../interfaces'; import { FocusFacetSelector } from './components'; import { EBarDirection, EBarDisplayType, EBarGroupingType, IBarConfig } from './interfaces'; @@ -29,6 +27,8 @@ import { WorkerWrapper, } from './interfaces/internal'; import { SingleEChartsBarChart } from './SingleEChartsBarChart'; +import { getLabelOrUnknown } from '../general/utils'; +import { NAN_REPLACEMENT } from '../general/constants'; type VirtualizedBarChartProps = { aggregatedDataMap: Awaited>; @@ -51,6 +51,8 @@ type VirtualizedBarChartProps = { setConfig: (config: IBarConfig) => void; }; +const textMeasure = new FastTextMeasure('16px Arial'); + export function BarChart({ config, setConfig, @@ -73,6 +75,7 @@ export function BarChart({ config?.facets as ColumnInfo, config?.aggregateColumn as ColumnInfo, ]); + const generateDataTableWorker = React.useCallback(async (...args: Parameters) => WorkerWrapper.generateDataTable(...args), []); const { execute: generateDataTableTrigger, status: dataTableStatus } = useAsync(generateDataTableWorker); @@ -82,12 +85,6 @@ export function BarChart({ ); const { execute: generateAggregatedDataLookupTrigger, status: dataLookupStatus } = useAsync(generateAggregateDataLookupWorker); - const getTruncatedTextMapWorker = React.useCallback( - async (...args: Parameters) => WorkerWrapper.getTruncatedTextMap(...args), - [], - ); - const { execute: getTruncatedTextMapTrigger, status: truncatedTextStatus } = useAsync(getTruncatedTextMapWorker); - const [itemData, setItemData] = React.useState(null); const [dataTable, setDataTable] = React.useState>([]); const [aggregatedDataMap, setAggregatedDataMap] = React.useState> | null>(null); @@ -102,8 +99,8 @@ export function BarChart({ const isLoading = React.useMemo(() => barDataStatus === 'pending' || dataTableStatus === 'pending', [barDataStatus, dataTableStatus]); const isError = React.useMemo( - () => barDataStatus === 'error' || dataTableStatus === 'error' || dataLookupStatus === 'error' || truncatedTextStatus === 'error', - [barDataStatus, dataLookupStatus, dataTableStatus, truncatedTextStatus], + () => barDataStatus === 'error' || dataTableStatus === 'error' || dataLookupStatus === 'error', + [barDataStatus, dataLookupStatus, dataTableStatus], ); const isSuccess = React.useMemo(() => barDataStatus === 'success' && dataTableStatus === 'success', [barDataStatus, dataTableStatus]); @@ -315,19 +312,16 @@ export function BarChart({ ]); useShallowEffect(() => { - const fetchTruncatedTextMap = async () => { - const truncatedTextMap = await getTruncatedTextMapTrigger( - Object.values(aggregatedDataMap?.facets ?? {}) - .map((value) => value?.categoriesList ?? []) - .flat(), - config?.direction === EBarDirection.HORIZONTAL ? Math.max(gridLeft, containerWidth / 3) - 20 : 70, - ); - setLabelsMap(truncatedTextMap.map); - setLongestLabelWidth(truncatedTextMap.longestLabelWidth); - setGridLeft(Math.min(containerWidth / 3, Math.max(longestLabelWidth + 20, 60))); - }; - fetchTruncatedTextMap(); - }, [aggregatedDataMap?.facets, aggregatedDataMap?.facetsList, config?.direction, containerWidth, getTruncatedTextMapTrigger, gridLeft, longestLabelWidth]); + Object.values(aggregatedDataMap?.facets ?? {}) + .map((value) => value?.categoriesList ?? []) + .flat() + .forEach((c) => { + const text = textMeasure.textEllipsis(c, config?.direction === EBarDirection.HORIZONTAL ? Math.max(gridLeft, containerWidth / 3) - 20 : 70); + setLongestLabelWidth((p) => Math.max(p, textMeasure.fastMeasureText(c))); + setLabelsMap((prev) => ({ ...prev, [c]: text })); + }); + setGridLeft(Math.min(containerWidth / 3, Math.max(longestLabelWidth + 20, 60))); + }, [aggregatedDataMap?.facets, config?.direction, containerWidth, gridLeft, longestLabelWidth]); React.useEffect(() => { setItemData({ diff --git a/src/vis/bar/SingleEChartsBarChart.tsx b/src/vis/bar/SingleEChartsBarChart.tsx index daad8b0ec..abda87f75 100644 --- a/src/vis/bar/SingleEChartsBarChart.tsx +++ b/src/vis/bar/SingleEChartsBarChart.tsx @@ -7,7 +7,7 @@ import { BlurredOverlay } from '../../components'; import { type ECOption, useChart } from '../../echarts'; import { useAsync } from '../../hooks'; import { sanitize, selectionColorDark } from '../../utils'; -import { DEFAULT_COLOR, NAN_REPLACEMENT, SELECT_COLOR, VIS_NEUTRAL_COLOR, VIS_UNSELECTED_OPACITY } from '../general'; +import { DEFAULT_COLOR, NAN_REPLACEMENT, SELECT_COLOR, VIS_NEUTRAL_COLOR, VIS_UNSELECTED_OPACITY } from '../general/constants'; import { ErrorMessage } from '../general/ErrorMessage'; import { WarningMessage } from '../general/WarningMessage'; import { ColumnInfo, EAggregateTypes, ICommonVisProps } from '../interfaces'; @@ -681,9 +681,9 @@ function EagerSingleEChartsBarChart({ : ''; const aggregationAxisSortText = config?.direction === EBarDirection.HORIZONTAL - ? SortDirectionMap[config?.sortState?.x as EBarSortState] + ? SortDirectionMap[config?.sortState?.x ?? EBarSortState.NONE] : config?.direction === EBarDirection.VERTICAL - ? SortDirectionMap[config?.sortState?.y as EBarSortState] + ? SortDirectionMap[config?.sortState?.y ?? EBarSortState.NONE] : ''; const aggregationAxisName = `${aggregationAxisNameBase}${aggregationAxisDescription} (${aggregationAxisSortText})`; @@ -695,9 +695,9 @@ function EagerSingleEChartsBarChart({ : ''; const categoricalAxisSortText = config?.direction === EBarDirection.HORIZONTAL - ? SortDirectionMap[config?.sortState?.y as EBarSortState] + ? SortDirectionMap[config?.sortState?.y ?? EBarSortState.NONE] : config?.direction === EBarDirection.VERTICAL - ? SortDirectionMap[config?.sortState?.x as EBarSortState] + ? SortDirectionMap[config?.sortState?.x ?? EBarSortState.NONE] : ''; const categoricalAxisName = `${categoricalAxisNameBase}${categoricalAxisDescription} (${categoricalAxisSortText})`; diff --git a/src/vis/bar/interfaces/internal/helpers/bar-vis.worker.ts b/src/vis/bar/interfaces/internal/helpers/bar-vis.worker.ts index 8b087bfa3..591e1425b 100644 --- a/src/vis/bar/interfaces/internal/helpers/bar-vis.worker.ts +++ b/src/vis/bar/interfaces/internal/helpers/bar-vis.worker.ts @@ -2,9 +2,8 @@ import * as ComLink from 'comlink'; import { generateAggregatedDataLookup } from './generate-aggregated-data-lookup'; import { generateBarSeries } from './generate-bar-series'; import { generateDataTable } from './generate-data-table'; -import { getTruncatedTextMap } from './get-truncated-text-map'; -const exposed = { generateAggregatedDataLookup, generateBarSeries, generateDataTable, getTruncatedTextMap }; +const exposed = { generateAggregatedDataLookup, generateBarSeries, generateDataTable }; export type GenerateAggregatedDataLookup = typeof exposed; diff --git a/src/vis/bar/interfaces/internal/helpers/get-bar-data.ts b/src/vis/bar/interfaces/internal/helpers/get-bar-data.ts index ea48a4249..5aaff3376 100644 --- a/src/vis/bar/interfaces/internal/helpers/get-bar-data.ts +++ b/src/vis/bar/interfaces/internal/helpers/get-bar-data.ts @@ -1,4 +1,4 @@ -import { resolveSingleColumn } from '../../../../general'; +import { resolveSingleColumn } from '../../../../general/layoutUtils'; import { ColumnInfo, EColumnTypes, VisCategoricalValue, VisColumn, VisNumericalValue } from '../../../../interfaces'; import { VisColumnWithResolvedValues } from '../../types'; diff --git a/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.test.ts b/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.test.ts index 13d72b6ad..12abe688f 100644 --- a/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.test.ts +++ b/src/vis/bar/interfaces/internal/helpers/get-data-for-aggregate-type.test.ts @@ -1,4 +1,4 @@ -import { NAN_REPLACEMENT } from '../../../../general'; +import { NAN_REPLACEMENT } from '../../../../general/constants'; import { defaultConfig } from '../../constants'; import { getDataForAggregationType } from './get-data-for-aggregate-type'; diff --git a/src/vis/bar/interfaces/internal/helpers/get-truncated-text-map.ts b/src/vis/bar/interfaces/internal/helpers/get-truncated-text-map.ts deleted file mode 100644 index f085c5f2d..000000000 --- a/src/vis/bar/interfaces/internal/helpers/get-truncated-text-map.ts +++ /dev/null @@ -1,38 +0,0 @@ -export function getTruncatedTextMap(labels: string[], maxWidth: number): { map: Record; longestLabelWidth: number } { - const map: Record = {}; - let longestLabelWidth = 0; - - const canvas = new OffscreenCanvas(0, 0); - const ctx = canvas.getContext('2d'); - - if (ctx) { - ctx.font = '16px Arial'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - - const ellipsis = '...'; - const ellipsisWidth = ctx.measureText(ellipsis).width; - - labels.forEach((value) => { - const renderedTextWidth = ctx.measureText(value).width; - longestLabelWidth = Math.max(longestLabelWidth, renderedTextWidth); - let truncatedText = ''; - let currentWidth = 0; - for (let i = 0; i < value.length; i++) { - const char = value[i]; - const charWidth = ctx.measureText(char as string).width; - - if (currentWidth + charWidth + ellipsisWidth > maxWidth) { - truncatedText += ellipsis; - break; - } else { - truncatedText += char; - currentWidth += charWidth; - } - } - map[value] = truncatedText; - }); - } - - return { map, longestLabelWidth }; -} diff --git a/src/vis/bar/interfaces/internal/helpers/index.ts b/src/vis/bar/interfaces/internal/helpers/index.ts index e115047e9..c6d5eb58e 100644 --- a/src/vis/bar/interfaces/internal/helpers/index.ts +++ b/src/vis/bar/interfaces/internal/helpers/index.ts @@ -5,7 +5,6 @@ export * from './create-bin-lookup'; export * from './generate-aggregated-data-lookup'; export * from './generate-bar-series'; export * from './generate-data-table'; -export * from './get-truncated-text-map'; export * from './get-bar-data'; export * from './get-data-for-aggregate-type'; export * from './median'; diff --git a/src/vis/bar/interfaces/internal/helpers/sort-series.ts b/src/vis/bar/interfaces/internal/helpers/sort-series.ts index 394e536d6..425a80187 100644 --- a/src/vis/bar/interfaces/internal/helpers/sort-series.ts +++ b/src/vis/bar/interfaces/internal/helpers/sort-series.ts @@ -1,5 +1,5 @@ import type { BarSeriesOption } from 'echarts/charts'; -import { NAN_REPLACEMENT } from '../../../../general'; +import { NAN_REPLACEMENT } from '../../../../general/constants'; import { EBarSortState, EBarDirection } from '../../enums'; /** diff --git a/src/vis/correlation/CorrelationMatrix.tsx b/src/vis/correlation/CorrelationMatrix.tsx index 2557deb51..14a7522b7 100644 --- a/src/vis/correlation/CorrelationMatrix.tsx +++ b/src/vis/correlation/CorrelationMatrix.tsx @@ -184,32 +184,34 @@ export function CorrelationMatrix({ return ( {status === 'success' ? ( - + {showDownloadScreenshot ? (
) : null} - - - - - - - {memoizedCorrelationResults?.map((value) => { - return ( - - ); - })} - {labelsDiagonal} - - + + + + + + + + {memoizedCorrelationResults?.map((value) => { + return ( + + ); + })} + {labelsDiagonal} + + +
) : ( diff --git a/src/vis/scatter/FastTextMeasure.ts b/src/vis/general/FastTextMeasure.ts similarity index 100% rename from src/vis/scatter/FastTextMeasure.ts rename to src/vis/general/FastTextMeasure.ts diff --git a/src/vis/general/SortIcon.stories.tsx b/src/vis/general/SortIcon.stories.tsx new file mode 100644 index 000000000..2daaa1105 --- /dev/null +++ b/src/vis/general/SortIcon.stories.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { ComponentStory } from '@storybook/react'; +import { Stack, Text, Group } from '@mantine/core'; +import { ESortStates, SortIcon, ISortIconProps } from './SortIcon'; + +function Wrapper({ props, initialState }: { props?: Omit; initialState?: ESortStates }) { + const [sortState, setSortState] = React.useState(initialState || ESortStates.NONE); + + return ( + + + + + {[2, 1, 5, 3, 4] + .sort((a, b) => { + switch (sortState) { + case ESortStates.ASC: + return a - b; + case ESortStates.DESC: + return b - a; + default: + return 0; + } + }) + .map((i) => ( + {i} + ))} + + + ); +} + +function Docs() { + return ( + + + + Basic example + + + + + + Compact icon + + + + + + With priority + + + + + + Has no unsorted state + + + + + + + Sort descending on first click + + Only for specific use cases - the default is ascending + + + + + ); +} + +// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +export default { + title: 'Components/SortIcon', + component: Docs, + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes +}; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +// eslint-disable-next-line react/function-component-definition +const Template: ComponentStory = (args) => { + return ; +}; + +export const Default: typeof Template = Template.bind({}); diff --git a/src/vis/general/SortIcon.tsx b/src/vis/general/SortIcon.tsx index 2eab16cf7..fc09f43c5 100644 --- a/src/vis/general/SortIcon.tsx +++ b/src/vis/general/SortIcon.tsx @@ -12,26 +12,61 @@ export enum ESortStates { DESC = 'desc', } +export interface ISortIconProps { + /** + * Sort state of the icon + */ + sortState: ESortStates; + /** + * Callback function to update the sort state + */ + setSortState: (sortState: ESortStates, isCtrlKeyPressed: boolean, event: React.MouseEvent) => void; + /** + * Sort priority indicator next to the sort icon. + */ + priority?: number; + /** + * Compact representation of the sort icon. If true, it will use the Mantine size `xs`. Otherwise use the size `sm`. + */ + compact?: boolean; + /** + * Determine the first sort order when coming from an initially unsorted state. + */ + sortStateOnFirstClick?: ESortStates.ASC | ESortStates.DESC; + /** + * If true, the sort order can be unsorted after sorting ascending-descending. If false, the sort state can only switch between ascending and descending. + */ + hasUnsortedState?: boolean; +} + export function SortIcon({ sortState, setSortState, priority = 0, compact = false, -}: { - sortState: ESortStates; - setSortState: (sortState: ESortStates, isCtrlKeyPressed: boolean, event: React.MouseEvent) => void; - priority?: number; - compact?: boolean; -}) { + sortStateOnFirstClick = ESortStates.ASC, + hasUnsortedState = true, +}: ISortIconProps) { const sortIcon = sortState === ESortStates.DESC ? dvSortDesc : sortState === ESortStates.ASC ? dvSortAsc : dvSort; - const getNextSortState = (s) => { - switch (s) { - case ESortStates.ASC: - return ESortStates.DESC; - case ESortStates.DESC: - return ESortStates.NONE; - default: - return ESortStates.ASC; + const getNextSortState = (s: ESortStates) => { + if (sortStateOnFirstClick === ESortStates.DESC) { + switch (s) { + case ESortStates.DESC: + return ESortStates.ASC; + case ESortStates.ASC: + return hasUnsortedState ? ESortStates.NONE : ESortStates.DESC; + default: + return ESortStates.DESC; + } + } else { + switch (s) { + case ESortStates.ASC: + return ESortStates.DESC; + case ESortStates.DESC: + return hasUnsortedState ? ESortStates.NONE : ESortStates.ASC; + default: + return ESortStates.ASC; + } } }; diff --git a/src/vis/general/index.ts b/src/vis/general/index.ts index 1719e64f0..078b9eae7 100644 --- a/src/vis/general/index.ts +++ b/src/vis/general/index.ts @@ -1,5 +1,8 @@ -export * from './WarningMessage'; +// TODO: @dv-usama-ansari: We need to reorganize the exports in this file as well as the files within the `src/vis/general` directory. +export * from './DownloadPlotButton'; export * from './ErrorMessage'; -export * from './layoutUtils'; -export * from './constants'; +export * from './FastTextMeasure'; export * from './SortIcon'; +export * from './WarningMessage'; +export * from './constants'; +export * from './layoutUtils'; diff --git a/src/vis/general/layoutUtils.ts b/src/vis/general/layoutUtils.ts index 010ff913c..55ff843b4 100644 --- a/src/vis/general/layoutUtils.ts +++ b/src/vis/general/layoutUtils.ts @@ -1,5 +1,5 @@ -import { PlotlyTypes } from '../../plotly'; -import { ColumnInfo, PlotlyInfo, VisColumn } from '../interfaces'; +import type { PlotlyTypes } from '../../plotly'; +import type { ColumnInfo, PlotlyInfo, VisColumn } from '../interfaces'; import { VIS_AXIS_LABEL_SIZE, VIS_AXIS_LABEL_SIZE_SMALL, diff --git a/src/vis/general/utils.ts b/src/vis/general/utils.ts index 424ad10f5..f771259e7 100644 --- a/src/vis/general/utils.ts +++ b/src/vis/general/utils.ts @@ -1,5 +1,12 @@ import { NAN_REPLACEMENT, VIS_NEUTRAL_COLOR } from './constants'; +const formatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 4, + maximumSignificantDigits: 4, + notation: 'compact', + compactDisplay: 'short', +}); + /** * * @param label the label to check for undefined, null or empty @@ -7,12 +14,6 @@ import { NAN_REPLACEMENT, VIS_NEUTRAL_COLOR } from './constants'; * @returns the label if it is not undefined, null or empty, otherwise NAN_REPLACEMENT (Unknown) */ export function getLabelOrUnknown(label: string | number | null | undefined, unknownLabel: string = NAN_REPLACEMENT): string { - const formatter = new Intl.NumberFormat('en-US', { - maximumFractionDigits: 4, - maximumSignificantDigits: 4, - notation: 'compact', - compactDisplay: 'short', - }); return label === null || label === 'null' || label === undefined || label === 'undefined' || label === '' ? unknownLabel : Number(label) && !Number.isInteger(label) // if it is a number, but not an integer, apply NumberFormat diff --git a/src/vis/scatter/ScatterVis.tsx b/src/vis/scatter/ScatterVis.tsx index c7b53220b..75653cd1a 100644 --- a/src/vis/scatter/ScatterVis.tsx +++ b/src/vis/scatter/ScatterVis.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import { Center, Group, ScrollArea, Stack, Switch, Tooltip } from '@mantine/core'; import { useElementSize, useWindowEvent } from '@mantine/hooks'; +import uniqueId from 'lodash/uniqueId'; import * as d3v7 from 'd3v7'; import cloneDeep from 'lodash/cloneDeep'; import uniq from 'lodash/uniq'; @@ -71,7 +72,7 @@ export function ScatterVis({ uniquePlotId, showDownloadScreenshot, }: ICommonVisProps) { - const id = `ScatterVis_${React.useId()}`; + const id = React.useMemo(() => uniquePlotId || uniqueId('ScatterVis'), [uniquePlotId]); const [shiftPressed, setShiftPressed] = React.useState(false); const [showLegend, setShowLegend] = React.useState(false); @@ -115,7 +116,13 @@ export function ScatterVis({ // If the useAsync arguments change, clear the internal layout state. // Why not just use the config to compare things? // Because the useAsync takes one render cycle to update the value, and inbetween that, plotly has already updated the internalLayoutRef again with the old one. - if (args?.[1] !== previousArgs.current?.[1] || args?.[5] !== previousArgs.current?.[5]) { + if ( + args?.[1] !== previousArgs.current?.[1] || + args?.[6] !== previousArgs.current?.[6] || + args?.[3] !== previousArgs.current?.[3] || + config?.xAxisScale !== internalLayoutRef.current?.xaxis?.type || + config?.yAxisScale !== internalLayoutRef.current?.yaxis?.type + ) { internalLayoutRef.current = {}; previousArgs.current = args; } @@ -432,7 +439,6 @@ export function ScatterVis({ grid-template-columns: 1fr fit-content(200px); grid-row-gap: 0.5rem; `} - id={id} > {showDragModeOptions || showDownloadScreenshot ? (
diff --git a/src/vis/scatter/ScatterVisSidebar.tsx b/src/vis/scatter/ScatterVisSidebar.tsx index fad89e5ab..0bfbc1fc7 100644 --- a/src/vis/scatter/ScatterVisSidebar.tsx +++ b/src/vis/scatter/ScatterVisSidebar.tsx @@ -1,4 +1,4 @@ -import { Divider } from '@mantine/core'; +import { Divider, Select } from '@mantine/core'; import merge from 'lodash/merge'; import * as React from 'react'; import { useMemo } from 'react'; @@ -131,6 +131,33 @@ export function ScatterVisSidebar({ config, optionsConfig, columns, filterCallba ) : null} + + { + setConfig({ ...config, yAxisScale: value as 'log' | 'linear' }); + }} + /> + {filterCallback && mergedOptionsConfig.filter.enable ? mergedOptionsConfig.filter.customComponent || ( <> diff --git a/src/vis/scatter/interfaces.ts b/src/vis/scatter/interfaces.ts index 975ddc3d8..42ab03eef 100644 --- a/src/vis/scatter/interfaces.ts +++ b/src/vis/scatter/interfaces.ts @@ -18,6 +18,16 @@ export interface IScatterConfig extends BaseVisConfig { regressionLineOptions?: IRegressionLineOptions; showLegend?: boolean; labelColumns?: ColumnInfo[]; + + /** + * Scale type of the x-axis (linear or log) + */ + xAxisScale?: 'linear' | 'log'; + + /** + * Scale type of the y-axis (linear or log) + */ + yAxisScale?: 'linear' | 'log'; } /** diff --git a/src/vis/scatter/useData.tsx b/src/vis/scatter/useData.tsx index fe1219fae..ab73491ef 100644 --- a/src/vis/scatter/useData.tsx +++ b/src/vis/scatter/useData.tsx @@ -80,7 +80,7 @@ export function useData({ yaxis: pair.yref, textposition: subplots.text.map((_, i) => textPositionOptions[i % textPositionOptions.length]), ...(isEmpty(selectedList) ? {} : { selectedpoints: selectedList.map((idx) => subplots.idToIndex.get(idx)) }), - mode: config.showLabels === ELabelingOptions.NEVER ? 'markers' : 'text+markers', + mode: config.showLabels === ELabelingOptions.NEVER || config.xAxisScale === 'log' || config.yAxisScale === 'log' ? 'markers' : 'text+markers', ...(config.showLabels === ELabelingOptions.NEVER ? {} : config.showLabels === ELabelingOptions.ALWAYS @@ -118,7 +118,7 @@ export function useData({ // text: scatter.plotlyData.text, textposition: scatter.plotlyData.text.map((_, i) => textPositionOptions[i % textPositionOptions.length]), ...(isEmpty(selectedList) ? {} : { selectedpoints: selectedList.map((idx) => scatter.idToIndex.get(idx)) }), - mode: config.showLabels === ELabelingOptions.NEVER ? 'markers' : 'text+markers', + mode: config.showLabels === ELabelingOptions.NEVER || config.xAxisScale === 'log' || config.yAxisScale === 'log' ? 'markers' : 'text+markers', ...(config.showLabels === ELabelingOptions.NEVER ? {} : config.showLabels === ELabelingOptions.ALWAYS @@ -162,7 +162,7 @@ export function useData({ y: group.data.y, xaxis: group.xref, yaxis: group.yref, - mode: config.showLabels === ELabelingOptions.NEVER ? 'markers' : 'text+markers', + mode: config.showLabels === ELabelingOptions.NEVER || config.xAxisScale === 'log' || config.yAxisScale === 'log' ? 'markers' : 'text+markers', textposition: group.data.text.map((_, i) => textPositionOptions[i % textPositionOptions.length]), ...(config.showLabels === ELabelingOptions.NEVER ? {} diff --git a/src/vis/scatter/useDataPreparation.ts b/src/vis/scatter/useDataPreparation.ts index 112259ba0..40b66267c 100644 --- a/src/vis/scatter/useDataPreparation.ts +++ b/src/vis/scatter/useDataPreparation.ts @@ -185,21 +185,11 @@ export function useDataPreparation({ return index !== -1 ? index : Infinity; }); - let xDomain: [number, number] | [undefined, undefined] = [0, 1]; - let yDomain: [number, number] | [undefined, undefined] = [0, 1]; - // Get shared range for all plots - xDomain = d3v7.extent(value.validColumns[0].resolvedValues.map((v) => v.val as number)); - yDomain = d3v7.extent(value.validColumns[1].resolvedValues.map((v) => v.val as number)); - - if (xDomain[0] !== undefined && xDomain[1] !== undefined && yDomain[0] !== undefined && yDomain[1] !== undefined) { - const xStretch = xDomain[1] - xDomain[0]; - const yStretch = yDomain[1] - yDomain[0]; - console.log(xStretch, yStretch); - - xDomain = [xDomain[0] - xStretch * 0.5, xDomain[1] + xStretch * 0.5]; - yDomain = [yDomain[0] - yStretch * 0.5, yDomain[1] + yStretch * 0.5]; - } + const { xDomain, yDomain } = getStretchedDomains( + value.validColumns[0].resolvedValues.map((v) => v.val as number), + value.validColumns[1].resolvedValues.map((v) => v.val as number), + ); const resultData = groupedData.map((grouped, index) => { const idToIndex = new Map(); diff --git a/src/vis/scatter/useLayout.tsx b/src/vis/scatter/useLayout.tsx index 0102f2ad8..5d1266255 100644 --- a/src/vis/scatter/useLayout.tsx +++ b/src/vis/scatter/useLayout.tsx @@ -1,11 +1,12 @@ -import * as React from 'react'; import clamp from 'lodash/clamp'; +import isFinite from 'lodash/isFinite'; +import * as React from 'react'; import { PlotlyTypes } from '../../plotly'; import { VIS_NEUTRAL_COLOR, VIS_TRACES_COLOR } from '../general/constants'; -import { IInternalScatterConfig } from './interfaces'; +import { FastTextMeasure } from '../general/FastTextMeasure'; import { getLabelOrUnknown } from '../general/utils'; +import { IInternalScatterConfig } from './interfaces'; import { useDataPreparation } from './useDataPreparation'; -import { FastTextMeasure } from './FastTextMeasure'; const textMeasure = new FastTextMeasure('12px Open Sans'); @@ -55,6 +56,21 @@ function gaps(width: number, height: number, nSubplots: number) { }; } +function toLogRange(axisType: 'linear' | 'log', domain: [number, number] | [undefined, undefined]) { + if (axisType === 'linear') { + return [...domain]; + } + + const e0 = Math.log10(domain[0]); + const e1 = Math.log10(domain[1]); + + if (isFinite(e1)) { + return [isFinite(e0) ? e0 : 0, e1]; + } + + return [0, 1]; +} + export function useLayout({ scatter, facet, @@ -86,7 +102,8 @@ export function useLayout({ subplots.xyPairs.forEach((pair, plotCounter) => { axes[`xaxis${plotCounter > 0 ? plotCounter + 1 : ''}`] = { ...AXIS_TICK_STYLES, - range: pair.xDomain, + range: toLogRange(config.xAxisScale!, pair.xDomain), + type: config.xAxisScale, // Spread the previous layout to keep things like zoom ...(internalLayoutRef.current?.[`xaxis${plotCounter > 0 ? plotCounter + 1 : ''}` as 'xaxis'] || {}), title: { @@ -100,7 +117,8 @@ export function useLayout({ }; axes[`yaxis${plotCounter > 0 ? plotCounter + 1 : ''}`] = { ...AXIS_TICK_STYLES, - range: pair.yDomain, + range: toLogRange(config.yAxisScale!, pair.yDomain), + type: config.yAxisScale, // Spread the previous layout to keep things like zoom ...(internalLayoutRef.current?.[`yaxis${plotCounter > 0 ? plotCounter + 1 : ''}` as 'yaxis'] || {}), title: { @@ -171,8 +189,9 @@ export function useLayout({ ...BASE_LAYOUT, xaxis: { ...AXIS_TICK_STYLES, - range: scatter.xDomain, + range: toLogRange(config.xAxisScale!, scatter.xDomain), ...internalLayoutRef.current?.xaxis, + type: config.xAxisScale, title: { font: { size: 12, @@ -183,8 +202,9 @@ export function useLayout({ }, yaxis: { ...AXIS_TICK_STYLES, - range: scatter.yDomain, + range: toLogRange(config.yAxisScale!, scatter.yDomain), ...internalLayoutRef.current?.yaxis, + type: config.yAxisScale, title: { font: { size: 12, @@ -212,7 +232,8 @@ export function useLayout({ facet.resultData.forEach((group, plotCounter) => { axes[`xaxis${plotCounter > 0 ? plotCounter + 1 : ''}`] = { ...AXIS_TICK_STYLES, - range: facet.xDomain, + range: toLogRange(config.xAxisScale!, facet.xDomain), + type: config.xAxisScale, // Spread the previous layout to keep things like zoom ...(internalLayoutRef.current?.[`xaxis${plotCounter > 0 ? plotCounter + 1 : ''}` as 'xaxis'] || {}), ...(plotCounter > 0 ? { matches: 'x' } : {}), @@ -229,7 +250,8 @@ export function useLayout({ }; axes[`yaxis${plotCounter > 0 ? plotCounter + 1 : ''}`] = { ...AXIS_TICK_STYLES, - range: facet.yDomain, + range: toLogRange(config.yAxisScale!, facet.yDomain), + type: config.yAxisScale, // Spread the previous layout to keep things like zoom ...(internalLayoutRef.current?.[`yaxis${plotCounter > 0 ? plotCounter + 1 : ''}` as 'yaxis'] || {}), ...(plotCounter > 0 ? { matches: 'y' } : {}), diff --git a/src/vis/scatter/utils.ts b/src/vis/scatter/utils.ts index 9a7648982..d707c6ea4 100644 --- a/src/vis/scatter/utils.ts +++ b/src/vis/scatter/utils.ts @@ -42,6 +42,8 @@ export const defaultConfig: IScatterConfig = { lineStyle: defaultRegressionLineStyle, showStats: true, }, + xAxisScale: 'linear', + yAxisScale: 'linear', }; export function scatterMergeDefaultConfig(columns: VisColumn[], config: IScatterConfig): IScatterConfig { diff --git a/src/vis/stories/Vis/Scatter/ScatterIris.stories.tsx b/src/vis/stories/Vis/Scatter/ScatterIris.stories.tsx index dd9859072..46f11052c 100644 --- a/src/vis/stories/Vis/Scatter/ScatterIris.stories.tsx +++ b/src/vis/stories/Vis/Scatter/ScatterIris.stories.tsx @@ -48,6 +48,8 @@ Basic.args = { name: 'Sepal Width', }, ], + xAxisScale: 'log', + yAxisScale: 'log', color: null, facets: null, numColorScaleType: ENumericalColorScaleType.SEQUENTIAL, diff --git a/src/vis/stories/explodedData.ts b/src/vis/stories/explodedData.ts index baedac65b..0574f2aed 100644 --- a/src/vis/stories/explodedData.ts +++ b/src/vis/stories/explodedData.ts @@ -1,4 +1,4 @@ -import { NAN_REPLACEMENT } from '../general'; +import { NAN_REPLACEMENT } from '../general/constants'; import { EColumnTypes, VisColumn } from '../interfaces'; export interface TestItem { diff --git a/src/vis/useCaptureVisScreenshot.ts b/src/vis/useCaptureVisScreenshot.ts index d0489c2ba..cf482000d 100644 --- a/src/vis/useCaptureVisScreenshot.ts +++ b/src/vis/useCaptureVisScreenshot.ts @@ -89,7 +89,7 @@ export function useCaptureVisScreenshot(uniquePlotId: string, visConfig: BaseVis link.remove(); } } else { - const dataUrl = await htmlToImage.toPng(plotElement.querySelector('canvas')!, { + const dataUrl = await htmlToImage.toPng(plotElement, { backgroundColor: 'white', cacheBust: true, }); diff --git a/src/vis/vishooks/hooks/VisHooks.stories.tsx b/src/vis/vishooks/hooks/VisHooks.stories.tsx index f4c06ef02..626a3932a 100644 --- a/src/vis/vishooks/hooks/VisHooks.stories.tsx +++ b/src/vis/vishooks/hooks/VisHooks.stories.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { StoryObj, Meta } from '@storybook/react'; -import { Center, Stack, Paper } from '@mantine/core'; -import { useLasso } from './useLasso'; +import { Center, Stack, Paper, Button, Text, Group } from '@mantine/core'; +import { lassoToSvgPath, useLasso } from './useLasso'; import { SVGLasso } from '../components/SVGLasso'; import { useBrush } from './useBrush'; import { SVGBrush } from '../components'; import { useCanvas } from './useCanvas'; +import { m4 } from '../math'; +import { ZoomTransform } from '../interfaces'; +import { useAnimatedTransform } from './useAnimatedTransform'; function UseLassoComponent() { const { setRef, value } = useLasso(); @@ -62,6 +65,54 @@ function UseCanvasComponent() { ); } +function UseAnimatedTransformComponent() { + const [toggled, setToggled] = React.useState(false); + + const [animatedTransform, setAnimatedTransform] = React.useState(m4.identityMatrix4x4()); + + const { animate } = useAnimatedTransform({ + onIntermediate: (newT) => { + setAnimatedTransform(newT); + }, + }); + + return ( +
+ + + + + Animated transform: + t12: {animatedTransform[12]?.toPrecision(3)} + t13: {animatedTransform[13]?.toPrecision(3)} + + + + + + +
+ ); +} + function VisHooksComponent() { const [element, setElement] = React.useState(); @@ -97,3 +148,9 @@ export const UseCanvas: Story = { return ; }, }; + +export const UseAnimated: Story = { + render: () => { + return ; + }, +}; diff --git a/src/vis/vishooks/hooks/index.ts b/src/vis/vishooks/hooks/index.ts index 203cf3ac5..601c8afa5 100644 --- a/src/vis/vishooks/hooks/index.ts +++ b/src/vis/vishooks/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useBandScale'; export * from './usePan'; export * from './useTransformScale'; +export * from './useAnimatedTransform'; export * from './useZoom'; export * from './useWheel'; export * from './useInteractions'; diff --git a/src/vis/vishooks/hooks/useAnimatedTransform.ts b/src/vis/vishooks/hooks/useAnimatedTransform.ts new file mode 100644 index 000000000..e828d7c60 --- /dev/null +++ b/src/vis/vishooks/hooks/useAnimatedTransform.ts @@ -0,0 +1,83 @@ +/* eslint-disable react-compiler/react-compiler */ +import * as React from 'react'; +import { ZoomTransform } from '../interfaces'; +import { useSyncedRef } from '../../../hooks'; + +function linearInterpolate(startMatrix: ZoomTransform, endMatrix: ZoomTransform, t: number) { + return startMatrix.map((startValue, index) => { + const endValue = endMatrix[index]; + + if (endValue === undefined) { + throw new Error('Supplied matrices are not of the same length'); + } + + const cosT = (1 - Math.cos(t * Math.PI)) / 2; + return startValue * (1 - cosT) + endValue * cosT; + }); +} + +/** + * Hook that returns an animate function that can be used to animate between two zoom transforms (keyframes). + * After calling animate, the onIntermediate callback will be called with the monitors refresh rate (requestAnimationFrame) + * with the intermediate transform values (cosine interpolated). + */ +export function useAnimatedTransform({ onIntermediate }: { onIntermediate: (intermediateTransform: ZoomTransform) => void }) { + const stateRef = React.useRef({ + start: undefined as ZoomTransform | undefined, + end: undefined as ZoomTransform | undefined, + t0: performance.now(), + }); + + const animationFrameRef = React.useRef(undefined); + const onIntermediateRef = useSyncedRef(onIntermediate); + + const requestFrame = () => { + animationFrameRef.current = requestAnimationFrame((t1) => { + if (stateRef.current.start && stateRef.current.end) { + const t = (t1 - stateRef.current.t0) / 1000; + // End of animation + if (t >= 1) { + animationFrameRef.current = undefined; + onIntermediateRef.current(stateRef.current.end); + return; + } + + const newMatrix = linearInterpolate(stateRef.current.start, stateRef.current.end, t); + onIntermediateRef.current(newMatrix); + + requestFrame(); + } + }); + }; + + const requestFrameRef = useSyncedRef(requestFrame); + + const animate = React.useCallback( + (start: ZoomTransform, end: ZoomTransform) => { + stateRef.current = { + start, + end, + t0: performance.now(), + }; + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + requestFrameRef.current(); + }, + [requestFrameRef], + ); + + React.useEffect(() => { + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + return { + animate, + }; +} diff --git a/src/vis/vishooks/hooks/usePan.ts b/src/vis/vishooks/hooks/usePan.ts index 4f8c804fa..d49e268cf 100644 --- a/src/vis/vishooks/hooks/usePan.ts +++ b/src/vis/vishooks/hooks/usePan.ts @@ -1,6 +1,7 @@ +/* eslint-disable react-compiler/react-compiler */ import * as React from 'react'; import { Direction, ZoomTransform } from '../interfaces'; -import { useInteractions } from './useInteractions'; +import { useInteractions, UseInteractionsProps } from './useInteractions'; import { useControlledUncontrolled } from './useControlledUncontrolled'; import { defaultConstraint } from '../transform'; import { m4 } from '../math'; @@ -20,6 +21,8 @@ interface UsePanProps { */ direction?: Direction; + onClick?: NonNullable; + skip?: boolean; } @@ -34,8 +37,9 @@ export function usePan(options: UsePanProps = {}) { const zoomRef = React.useRef(zoom); zoomRef.current = zoom; - const { ref, setRef } = useInteractions({ + const { ref, setRef, state } = useInteractions({ skip: options.skip, + onClick: options.onClick, onDrag: (event) => { let newMatrix = m4.clone(zoomRef.current); @@ -59,5 +63,5 @@ export function usePan(options: UsePanProps = {}) { }, }); - return { ref, setRef, value: zoom, setValue: setZoom }; + return { ref, setRef, value: zoom, setValue: setZoom, state }; } diff --git a/visyn_core/settings/utils.py b/visyn_core/settings/utils.py index 0968eac2f..ec4802b52 100644 --- a/visyn_core/settings/utils.py +++ b/visyn_core/settings/utils.py @@ -45,3 +45,23 @@ def get_default_postgres_url( Returns a default postgres url, including the default values for `driver`, `user`, `password`, `host`, `port` and `database`. """ return f"{driver}://{user}:{password}@{host or host_fallback}:{port or port_fallback}/{database}" + + +def get_default_redis_url( + *, + host: str | None = os.getenv("REDIS_HOSTNAME"), + host_fallback: str = "localhost", + port: int | str | None = os.getenv("REDIS_PORT"), + port_fallback: int = 6379, + db: int = 1, + timeout: int = 60 * 60 * 24, +) -> dict: + """ + Returns a default Redis configuration dictionary with `host`, `port`, `db`, and `timeout`. + """ + return { + "host": host or host_fallback, + "port": int(port) if port else port_fallback, + "db": db, + "timeout": timeout, + }