From f503b056bc163c3710be62bf86d5f944e9bbee0b Mon Sep 17 00:00:00 2001 From: Peter Chang Date: Tue, 30 Sep 2025 09:16:25 +0100 Subject: [PATCH 1/3] Allow selection types to be specify for a plot with a maximum number --- client/component/src/HeatmapPlot.tsx | 2 + client/component/src/ImagePlot.tsx | 2 + client/component/src/LinePlot.tsx | 2 + .../src/PlotCustomizationContext.tsx | 52 +++++++++++++- client/component/src/PlotToolbar.tsx | 22 +++++- client/component/src/ScatterPlot.tsx | 2 + client/component/src/SelectionComponent.tsx | 71 ++++++++++++------- client/component/src/models.ts | 3 + client/component/src/selections/utils.tsx | 14 ++++ client/example/src/App.tsx | 18 +++++ client/example/src/index.css | 10 +-- client/example/src/vite-env.d.ts | 8 +-- client/example/vite.config.ts | 2 +- storybook/stories/LinePlot.stories.tsx | 14 ++++ 14 files changed, 184 insertions(+), 38 deletions(-) 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; + /** 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 */ @@ -172,6 +184,7 @@ export function PlotCustomizationContextProvider( const [selectionType, setSelectionType] = useState( SelectionType.line ); + const [selectionMax, setSelectionMax] = useState(0); const [xCustomDomain, setXCustomDomain] = useState([ null, null, @@ -218,6 +231,34 @@ export function PlotCustomizationContextProvider( useEffect(() => { if (props.plotConfig.yScale) setYScaleType(props.plotConfig.yScale); }, [props.plotConfig.yScale, setYScaleType]); + useEffect(() => { + const selectionOptions = props.selectionOptions; + if (selectionOptions) { + const entries = Object.entries(selectionOptions); + if (entries.length > 0) { + const e = entries.at(0); + if (e !== undefined) { + setSelectionType(toSelectionType(e[0])); + setSelectionMax(e[1]); + } + return; + } + } + }, [props.selectionOptions, setSelectionType, setSelectionMax]); + + const newSetSelectionType = useCallback( + (t: SelectionType) => { + const selectionOptions = props.selectionOptions; + setSelectionType(t); + if (selectionOptions) { + const m = selectionOptions[t]; + if (m !== undefined) { + setSelectionMax(m); + } + } + }, + [setSelectionType, setSelectionMax, props.selectionOptions] + ); const batonProps = useMemo( () => props.batonProps ?? defaultBatonProps, @@ -333,8 +374,10 @@ export function PlotCustomizationContextProvider( dDomain, dCustomDomain, setDCustomDomain, + selectionOptions: props.selectionOptions, + selectionMax, selectionType, - setSelectionType, + setSelectionType: newSetSelectionType, setHistogram, selections, setSelections, @@ -374,8 +417,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..2066ad8b 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, + SelectionType, + toSelectionType, +} from './selections/utils'; import SelectionConfig from './SelectionConfig'; import SelectionIDDropdown from './SelectionIDDropdown'; import { InteractionModeType } from './utils'; @@ -178,6 +183,20 @@ function PlotToolbar(props: PropsWithChildren): JSX.Element { const bareModals: JSX.Element[] = []; + const dropdownOptions = useMemo(() => { + const selectionOptions = value.selectionOptions; + let options = undefined; + if (selectionOptions) { + console.log('selection options:', selectionOptions); + + options = [] as SelectionType[]; + for (const k of Object.keys(selectionOptions)) { + options.push(toSelectionType(k)); + } + } + return options; + }, [value.selectionOptions]); + if (canSelect && value.selectionType !== undefined) { bareModals.push( ); } 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..7d59280e 100644 --- a/client/example/src/App.tsx +++ b/client/example/src/App.tsx @@ -170,6 +170,8 @@ class AppMain extends React.Component { Line + Line (selection option) + Line (selection options) Heatmap Heatmap (selection) @@ -178,6 +180,22 @@ 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, From 5c276a34384a665c35f3d2ce945ef69529de1f5e Mon Sep 17 00:00:00 2001 From: Peter Chang Date: Fri, 31 Oct 2025 12:47:16 +0000 Subject: [PATCH 2/3] Remove dropdown when there's no choices and also hide select region button --- .../component/src/InteractionModeToggle.tsx | 34 +++++++-------- .../src/PlotCustomizationContext.tsx | 28 +++++-------- client/component/src/PlotToolbar.tsx | 41 ++++++++++--------- client/example/src/App.tsx | 9 ++++ 4 files changed, 59 insertions(+), 53 deletions(-) diff --git a/client/component/src/InteractionModeToggle.tsx b/client/component/src/InteractionModeToggle.tsx index 31ee6fe0..8c5f4b01 100644 --- a/client/component/src/InteractionModeToggle.tsx +++ b/client/component/src/InteractionModeToggle.tsx @@ -16,6 +16,7 @@ interface InteractionModeToggleProps { onModeChange: (value: InteractionModeType) => 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,22 @@ function InteractionModeToggle(props: InteractionModeToggleProps) { icon={TbZoomInArea as IIconType} value={InteractionModeType.selectToZoom} /> -
- -
+ {canSelect ? +
+ +
: <>} ); diff --git a/client/component/src/PlotCustomizationContext.tsx b/client/component/src/PlotCustomizationContext.tsx index 3f29dd8d..596dc499 100644 --- a/client/component/src/PlotCustomizationContext.tsx +++ b/client/component/src/PlotCustomizationContext.tsx @@ -182,7 +182,7 @@ export function PlotCustomizationContextProvider( InteractionModeType.panAndWheelZoom ); const [selectionType, setSelectionType] = useState( - SelectionType.line + SelectionType.unknown ); const [selectionMax, setSelectionMax] = useState(0); const [xCustomDomain, setXCustomDomain] = useState([ @@ -232,29 +232,21 @@ export function PlotCustomizationContextProvider( if (props.plotConfig.yScale) setYScaleType(props.plotConfig.yScale); }, [props.plotConfig.yScale, setYScaleType]); useEffect(() => { - const selectionOptions = props.selectionOptions; - if (selectionOptions) { - const entries = Object.entries(selectionOptions); - if (entries.length > 0) { - const e = entries.at(0); - if (e !== undefined) { - setSelectionType(toSelectionType(e[0])); - setSelectionMax(e[1]); - } - return; - } + 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) => { - const selectionOptions = props.selectionOptions; setSelectionType(t); - if (selectionOptions) { - const m = selectionOptions[t]; - if (m !== undefined) { - setSelectionMax(m); - } + const selectionOptions = props.selectionOptions ?? {}; + const m = selectionOptions[t]; + if (m !== undefined) { + setSelectionMax(m); } }, [setSelectionType, setSelectionMax, props.selectionOptions] diff --git a/client/component/src/PlotToolbar.tsx b/client/component/src/PlotToolbar.tsx index 2066ad8b..5c8a2c23 100644 --- a/client/component/src/PlotToolbar.tsx +++ b/client/component/src/PlotToolbar.tsx @@ -33,8 +33,8 @@ import SelectionTypeDropdown from './SelectionTypeDropdown'; import { undashSelection, dashSelection, - SelectionType, toSelectionType, + SelectionType, } from './selections/utils'; import SelectionConfig from './SelectionConfig'; import SelectionIDDropdown from './SelectionIDDropdown'; @@ -185,28 +185,30 @@ function PlotToolbar(props: PropsWithChildren): JSX.Element { const dropdownOptions = useMemo(() => { const selectionOptions = value.selectionOptions; - let options = undefined; - if (selectionOptions) { - console.log('selection options:', selectionOptions); - - options = [] as SelectionType[]; - for (const k of Object.keys(selectionOptions)) { - options.push(toSelectionType(k)); - } + if (selectionOptions === undefined) { + return undefined; } - return options; + 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; @@ -340,6 +342,7 @@ function PlotToolbar(props: PropsWithChildren): JSX.Element { value={value.mode} onModeChange={value.setMode} hasBaton={selectBaton} + canSelect={canAddSelection} /> {canSelect && } {bareModals} diff --git a/client/example/src/App.tsx b/client/example/src/App.tsx index 7d59280e..0103c3d8 100644 --- a/client/example/src/App.tsx +++ b/client/example/src/App.tsx @@ -172,6 +172,7 @@ class AppMain extends React.Component { Line Line (selection option) Line (selection options) + Line (no selection options) Heatmap Heatmap (selection) @@ -196,6 +197,14 @@ class AppMain extends React.Component { />
+ +
+ +
+
Date: Fri, 31 Oct 2025 13:50:17 +0000 Subject: [PATCH 3/3] Reformat --- client/component/src/InteractionModeToggle.tsx | 7 +++++-- client/component/src/PlotToolbar.tsx | 8 ++++++-- client/example/src/App.tsx | 5 +---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/client/component/src/InteractionModeToggle.tsx b/client/component/src/InteractionModeToggle.tsx index 8c5f4b01..bfa32a97 100644 --- a/client/component/src/InteractionModeToggle.tsx +++ b/client/component/src/InteractionModeToggle.tsx @@ -55,7 +55,7 @@ function InteractionModeToggle(props: InteractionModeToggleProps) { icon={TbZoomInArea as IIconType} value={InteractionModeType.selectToZoom} /> - {canSelect ? + {canSelect ? (
-
: <>} +
+ ) : ( + <> + )} ); diff --git a/client/component/src/PlotToolbar.tsx b/client/component/src/PlotToolbar.tsx index 5c8a2c23..83940bec 100644 --- a/client/component/src/PlotToolbar.tsx +++ b/client/component/src/PlotToolbar.tsx @@ -191,11 +191,15 @@ function PlotToolbar(props: PropsWithChildren): JSX.Element { return Object.keys(selectionOptions).map((k) => toSelectionType(k)); }, [value.selectionOptions]); - const canAddSelection = dropdownOptions === undefined || dropdownOptions.length !== 0; + const canAddSelection = + dropdownOptions === undefined || dropdownOptions.length !== 0; if (canSelect && value.selectionType !== undefined) { let selectionType = value.selectionType; - if (selectionType === SelectionType.unknown && dropdownOptions === undefined) { + if ( + selectionType === SelectionType.unknown && + dropdownOptions === undefined + ) { selectionType = SelectionType.line; } if (canAddSelection) { diff --git a/client/example/src/App.tsx b/client/example/src/App.tsx index 0103c3d8..77325aca 100644 --- a/client/example/src/App.tsx +++ b/client/example/src/App.tsx @@ -199,10 +199,7 @@ class AppMain extends React.Component {
- +