diff --git a/client/component/src/HeatmapPlot.tsx b/client/component/src/HeatmapPlot.tsx index 204a4573..133e0f96 100644 --- a/client/component/src/HeatmapPlot.tsx +++ b/client/component/src/HeatmapPlot.tsx @@ -38,6 +38,7 @@ export function HeatmapVisCanvas({ xValues, yValues, values }: Props) { aspect, batonProps, canSelect, + selectionMax, selectionType, updateSelection, selections, @@ -90,6 +91,7 @@ export function HeatmapVisCanvas({ xValues, yValues, values }: Props) { void; /** If client holds baton */ hasBaton: boolean; + canSelect: boolean; } /** @@ -24,7 +25,7 @@ interface InteractionModeToggleProps { * @returns {JSX.Element} The rendered component. */ function InteractionModeToggle(props: InteractionModeToggleProps) { - const { value, onModeChange, hasBaton } = props; + const { value, onModeChange, hasBaton, canSelect } = props; useEffect(() => { if (!hasBaton && value === InteractionModeType.selectRegion) { @@ -54,21 +55,25 @@ function InteractionModeToggle(props: InteractionModeToggleProps) { icon={TbZoomInArea as IIconType} value={InteractionModeType.selectToZoom} /> -
- -
+ {canSelect ? ( +
+ +
+ ) : ( + <> + )} ); diff --git a/client/component/src/LinePlot.tsx b/client/component/src/LinePlot.tsx index fb6d9a6e..19f37780 100644 --- a/client/component/src/LinePlot.tsx +++ b/client/component/src/LinePlot.tsx @@ -140,6 +140,7 @@ export function LineVisCanvas(props: Props) { setCurrentLineKey, batonProps, canSelect, + selectionMax, selectionType, updateSelection, selections, @@ -209,6 +210,7 @@ export function LineVisCanvas(props: Props) { modifierKey={[] as ModifierKey[]} batonProps={batonProps} disabled={mode !== InteractionModeType.selectRegion} + selectionMax={selectionMax} selectionType={selectionType} updateSelection={updateSelection} selections={selections} diff --git a/client/component/src/PlotCustomizationContext.tsx b/client/component/src/PlotCustomizationContext.tsx index a016342b..596dc499 100644 --- a/client/component/src/PlotCustomizationContext.tsx +++ b/client/component/src/PlotCustomizationContext.tsx @@ -21,8 +21,16 @@ import { import { defaultBatonProps, type BatonProps } from './models'; import { InteractionModeType } from './utils'; -import type { SelectionBase, SelectionHandler } from './selections/utils'; -import { SelectionType, useSelections } from './selections/utils'; +import type { + SelectionBase, + SelectionHandler, + SelectionOptions, +} from './selections/utils'; +import { + SelectionType, + toSelectionType, + useSelections, +} from './selections/utils'; import { LineParams, LinePlotCustomizationProps } from './LinePlot'; import { ImagePlotCustomizationProps } from './ImagePlot'; import { HeatmapPlotCustomizationProps } from './HeatmapPlot'; @@ -84,6 +92,8 @@ export interface PlotCustomizationContextValue { aspect: Aspect; /** A function that sets the aspect value */ setAspect: (a: Aspect) => void; + /** Selection upper limit (on current type) */ + selectionMax: number; /** A selection type */ selectionType: SelectionType; /** A function that sets the selection type */ @@ -106,6 +116,8 @@ export interface PlotCustomizationContextValue { invertColourMap: boolean; /** A function that toggles the color map inversion */ toggleInvertColourMap: () => void; + /** Selection options */ + selectionOptions?: SelectionOptions; /** Selections */ selections: SelectionBase[]; /** A function that sets the selections */ @@ -170,8 +182,9 @@ export function PlotCustomizationContextProvider( InteractionModeType.panAndWheelZoom ); const [selectionType, setSelectionType] = useState( - SelectionType.line + SelectionType.unknown ); + const [selectionMax, setSelectionMax] = useState(0); const [xCustomDomain, setXCustomDomain] = useState([ null, null, @@ -218,6 +231,26 @@ export function PlotCustomizationContextProvider( useEffect(() => { if (props.plotConfig.yScale) setYScaleType(props.plotConfig.yScale); }, [props.plotConfig.yScale, setYScaleType]); + useEffect(() => { + const entries = Object.entries(props.selectionOptions ?? {}); + const [k, v] = entries[0] ?? []; + if (k !== undefined && v !== undefined) { + setSelectionType(toSelectionType(k)); + setSelectionMax(v); + } + }, [props.selectionOptions, setSelectionType, setSelectionMax]); + + const newSetSelectionType = useCallback( + (t: SelectionType) => { + setSelectionType(t); + const selectionOptions = props.selectionOptions ?? {}; + const m = selectionOptions[t]; + if (m !== undefined) { + setSelectionMax(m); + } + }, + [setSelectionType, setSelectionMax, props.selectionOptions] + ); const batonProps = useMemo( () => props.batonProps ?? defaultBatonProps, @@ -333,8 +366,10 @@ export function PlotCustomizationContextProvider( dDomain, dCustomDomain, setDCustomDomain, + selectionOptions: props.selectionOptions, + selectionMax, selectionType, - setSelectionType, + setSelectionType: newSetSelectionType, setHistogram, selections, setSelections, @@ -374,8 +409,11 @@ export function PlotCustomizationContextProvider( invertColourMap, mode, newUpdateSelection, + newSetSelectionType, plotType, + props.selectionOptions, scatterPointSize, + selectionMax, selectionType, selections, setSelections, diff --git a/client/component/src/PlotToolbar.tsx b/client/component/src/PlotToolbar.tsx index 2b6abddd..83940bec 100644 --- a/client/component/src/PlotToolbar.tsx +++ b/client/component/src/PlotToolbar.tsx @@ -30,7 +30,12 @@ import LineKeyDropdown from './LineKeyDropdown'; import type { IIconType } from './Modal'; import Modal from './Modal'; import SelectionTypeDropdown from './SelectionTypeDropdown'; -import { undashSelection, dashSelection } from './selections/utils'; +import { + undashSelection, + dashSelection, + toSelectionType, + SelectionType, +} from './selections/utils'; import SelectionConfig from './SelectionConfig'; import SelectionIDDropdown from './SelectionIDDropdown'; import { InteractionModeType } from './utils'; @@ -178,15 +183,36 @@ function PlotToolbar(props: PropsWithChildren): JSX.Element { const bareModals: JSX.Element[] = []; + const dropdownOptions = useMemo(() => { + const selectionOptions = value.selectionOptions; + if (selectionOptions === undefined) { + return undefined; + } + return Object.keys(selectionOptions).map((k) => toSelectionType(k)); + }, [value.selectionOptions]); + + const canAddSelection = + dropdownOptions === undefined || dropdownOptions.length !== 0; + if (canSelect && value.selectionType !== undefined) { - bareModals.push( - - ); + let selectionType = value.selectionType; + if ( + selectionType === SelectionType.unknown && + dropdownOptions === undefined + ) { + selectionType = SelectionType.line; + } + if (canAddSelection) { + bareModals.push( + + ); + } } const allLineParams = value.allLineParams; @@ -320,6 +346,7 @@ function PlotToolbar(props: PropsWithChildren): JSX.Element { value={value.mode} onModeChange={value.setMode} hasBaton={selectBaton} + canSelect={canAddSelection} /> {canSelect && } {bareModals} diff --git a/client/component/src/ScatterPlot.tsx b/client/component/src/ScatterPlot.tsx index 73cee899..e29169f8 100644 --- a/client/component/src/ScatterPlot.tsx +++ b/client/component/src/ScatterPlot.tsx @@ -37,6 +37,7 @@ export function ScatterVisCanvas({ x, y, values }: Props) { mode, batonProps, canSelect, + selectionMax, selectionType, updateSelection, selections, @@ -91,6 +92,7 @@ export function ScatterVisCanvas({ x, y, values }: Props) { { + if (selectionMax === 0) { + return false; + } + const n = selections.reduce( + (a, v) => a + (getSelectionType(v) === selectionType ? 1 : 0), + 0 + ); + return n >= selectionMax; + }, [selectionMax, selectionType, selections]); + + const clicks = useMemo(() => getClicks(selectionType), [selectionType]); return ( <> - {updateSelection && (batonProps?.hasBaton ?? true) && !disabled && ( - validateHtml(html, selectionType)} - onValidSelection={({ data }) => { - const s = pointsToSelection(selections, selectionType, data, alpha); - return updateSelection(s); - }} - minPoints={clicks[0]} - maxPoints={clicks[1]} - > - {({ html }, _, isValid) => - pointsToShape( - selectionType, - html, - isFlipped, - alpha, - size, - isValid ? undefined : '#cc6677' // orangered, - ) - } - - )} + {updateSelection && + (batonProps?.hasBaton ?? true) && + !disabled && + !maxReached && ( + validateHtml(html, selectionType)} + onValidSelection={({ data }) => { + const s = pointsToSelection( + selections, + selectionType, + data, + alpha + ); + return updateSelection(s); + }} + minPoints={clicks[0]} + maxPoints={clicks[1]} + > + {({ html }, _, isValid) => + pointsToShape( + selectionType, + html, + isFlipped, + alpha, + size, + isValid ? undefined : '#cc6677' // orangered, + ) + } + + )} {shapes} ); diff --git a/client/component/src/models.ts b/client/component/src/models.ts index 86a6d50e..bd69409f 100644 --- a/client/component/src/models.ts +++ b/client/component/src/models.ts @@ -4,6 +4,7 @@ import type { SelectionHandler, SelectionBase, SelectionsEventListener, + SelectionOptions, } from './selections/utils'; import { AxisScaleType } from '@h5web/lib'; @@ -69,6 +70,8 @@ export interface PlotSelectionProps { batonProps?: BatonProps; /** Selections event listener */ selectionsListener?: SelectionsEventListener; + /** Selection type options and limits (0 = limitless) */ + selectionOptions?: SelectionOptions; } /** diff --git a/client/component/src/selections/utils.tsx b/client/component/src/selections/utils.tsx index 09c842aa..de214046 100644 --- a/client/component/src/selections/utils.tsx +++ b/client/component/src/selections/utils.tsx @@ -43,6 +43,18 @@ enum SelectionType { unknown = 'unknown', } +type SelectionOptions = { + [K in SelectionType]?: number; +}; + +/** + * @param t string type + * @returns selection type + */ +function toSelectionType(t: string) { + return SelectionType[t as keyof typeof SelectionType]; +} + /** * Function called when drag handles are moved */ @@ -647,6 +659,7 @@ export { SelectionsEventType, dashSelection, undashSelection, + toSelectionType, }; export type { @@ -654,4 +667,5 @@ export type { SelectionBase, SelectionHandler, SelectionsEventListener, + SelectionOptions, }; diff --git a/client/example/src/App.tsx b/client/example/src/App.tsx index d972c518..77325aca 100644 --- a/client/example/src/App.tsx +++ b/client/example/src/App.tsx @@ -170,6 +170,9 @@ class AppMain extends React.Component { Line + Line (selection option) + Line (selection options) + Line (no selection options) Heatmap Heatmap (selection) @@ -178,6 +181,27 @@ class AppMain extends React.Component { + +
+ +
+
+ +
+ +
+
+ +
+ +
+
interface ImportMetaEnv { - readonly VITE_WS_HOST: string - readonly VITE_WS_PORT: string + readonly VITE_WS_HOST: string; + readonly VITE_WS_PORT: string; // more env variables... } interface ImportMeta { - readonly env: ImportMetaEnv -} \ No newline at end of file + readonly env: ImportMetaEnv; +} diff --git a/client/example/vite.config.ts b/client/example/vite.config.ts index bb221c1b..dbbe5af8 100644 --- a/client/example/vite.config.ts +++ b/client/example/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ global: 'window', // this fixes global is not defined }, build: { - outDir: "../../server/example-client/sdist", + outDir: '../../server/example-client/sdist', emptyOutDir: true, }, }); diff --git a/storybook/stories/LinePlot.stories.tsx b/storybook/stories/LinePlot.stories.tsx index 1783ad83..65ad4e81 100644 --- a/storybook/stories/LinePlot.stories.tsx +++ b/storybook/stories/LinePlot.stories.tsx @@ -88,6 +88,20 @@ export const DisableSelections: Story = { }, }; +export const SingleSelectionOption: Story = { + args: { + ...Single.args, + selectionOptions: { verticalAxis: 2 }, + }, +}; + +export const MultipleSelectionOption: Story = { + args: { + ...Single.args, + selectionOptions: { verticalAxis: 2, line: 0 }, + }, +}; + export const NoToolbar: Story = { args: { ...Single.args,