From ba846f2130f01782a50f7fd6a7b1377552f7ac93 Mon Sep 17 00:00:00 2001 From: Peter Chang Date: Thu, 17 Aug 2023 16:37:13 +0100 Subject: [PATCH 1/6] Export bits from interactions This will allow an external implementation of a multiclick selection tool --- packages/lib/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 62e8a3bf4..e849599db 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -139,6 +139,8 @@ export type { CanvasEventCallbacks, } from './interactions/models'; export { MouseButton } from './interactions/models'; +export { useInteraction, useModifierKeyPressed } from './interactions/hooks'; +export { getModifierKeyArray } from './interactions/utils'; export type { Domain, From 01d186f486bc736fca6e329c741c2e9165f417ec Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Fri, 18 Aug 2023 08:49:21 +0200 Subject: [PATCH 2/6] Export interaction models --- packages/lib/src/index.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index e849599db..6c556bec9 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -118,7 +118,13 @@ export { export { useVisDomain, useSafeDomain } from './vis/heatmap/hooks'; export { scaleGamma } from './vis/scaleGamma'; -export { useCanvasEvents } from './interactions/hooks'; + +export { + useCanvasEvents, + useInteraction, + useModifierKeyPressed, +} from './interactions/hooks'; +export { getModifierKeyArray } from './interactions/utils'; export { default as Box } from './interactions/box'; export { COLOR_SCALE_TYPES, AXIS_SCALE_TYPES } from '@h5web/shared'; @@ -131,16 +137,16 @@ export { ImageType } from './vis/rgb/models'; export { Notation } from './vis/matrix/models'; export type { - InteractionInfo, ModifierKey, - Selection, + MouseButton, Rect, + Selection, CanvasEvent, CanvasEventCallbacks, + InteractionInfo, + InteractionEntry, + CommonInteractionProps, } from './interactions/models'; -export { MouseButton } from './interactions/models'; -export { useInteraction, useModifierKeyPressed } from './interactions/hooks'; -export { getModifierKeyArray } from './interactions/utils'; export type { Domain, From b994dc7ff46729a21ea2ac975c4a030612fd1a00 Mon Sep 17 00:00:00 2001 From: Peter Chang Date: Fri, 18 Aug 2023 09:19:35 +0100 Subject: [PATCH 3/6] Fix MouseButton export --- packages/lib/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 6c556bec9..28d4b0f96 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -124,6 +124,7 @@ export { useInteraction, useModifierKeyPressed, } from './interactions/hooks'; +export { MouseButton } from './interactions/models'; export { getModifierKeyArray } from './interactions/utils'; export { default as Box } from './interactions/box'; @@ -138,7 +139,6 @@ export { Notation } from './vis/matrix/models'; export type { ModifierKey, - MouseButton, Rect, Selection, CanvasEvent, From 6866b4a6215749d5cf8e188564f0b7afc6fb1876 Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Wed, 9 Aug 2023 14:13:48 +0200 Subject: [PATCH 4/6] Clean up and improve `Annotation` stories --- apps/storybook/src/Annotation.stories.tsx | 208 ++++++++++++---------- 1 file changed, 114 insertions(+), 94 deletions(-) diff --git a/apps/storybook/src/Annotation.stories.tsx b/apps/storybook/src/Annotation.stories.tsx index 6d68a2d1e..021c64b64 100644 --- a/apps/storybook/src/Annotation.stories.tsx +++ b/apps/storybook/src/Annotation.stories.tsx @@ -15,7 +15,11 @@ import { formatCoord } from './utils'; const meta = { title: 'Building Blocks/Annotation', - parameters: { layout: 'fullscreen' }, + component: Annotation, + parameters: { + layout: 'fullscreen', + controls: { sort: 'requiredFirst' }, + }, decorators: [ (Story) => ( ; export default meta; type Story = StoryObj; export const Default = { - render: () => ( - <> - - HTML annotation positioned at (10, 16) - - - Another annotation, centred on (10, 6) - - - <> -

- Annotations don't have to contain just text. You can also draw - shapes with CSS and SVG. -

- - - - + render: (args) => { + const { x, y, overflowCanvas, scaleOnZoom, center, style } = args; + + const features = [ + overflowCanvas ? 'overflows the canvas' : '', + scaleOnZoom ? 'scales on zoom' : '', + ] + .filter((str) => str.length > 0) + .join(' and '); + + return ( + +

+ Annotation {center ? 'centered on' : 'positioned at'} ({x}, {y}) + {features && <> that {features}} +

+ + + +
- - ), + ); + }, + args: { + x: 10, + y: 16, + }, } satisfies Story; -export const WithZoom = { - render: () => ( - <> - - HTML annotation at (10, 16) that scales with zoom. - - - Another annotation that scales with zoom but this time{' '} - centred on (25, 10) - - +export const OverflowCanvas = { + ...Default, + args: { + x: 6, + y: 16, + overflowCanvas: true, + }, +} satisfies Story; + +export const Centered = { + ...Default, + args: { + x: 5, + y: 14, + center: true, + }, +} satisfies Story; + +export const ScaleOnZoom = { + ...Default, + args: { + x: 10, + y: 16, + scaleOnZoom: true, + }, +} satisfies Story; + +export const ScaleOnZoomCentered = { + ...Default, + args: { + x: 10, + y: 16, + scaleOnZoom: true, + center: true, + }, +} satisfies Story; + +export const FollowPointer = { + render: (args) => ( + + {(x, y) => ( + {`x=${formatCoord(x)}, y=${formatCoord(y)}`} + )} + ), + args: { + x: 0, + y: 0, + }, + argTypes: { + x: { control: false }, + y: { control: false }, + }, } satisfies Story; function PointerTracker(props: { @@ -137,17 +171,3 @@ function PointerTracker(props: { // eslint-disable-next-line react/jsx-no-useless-fragment return <>{coords ? children(...coords) : null}; } - -export const FollowPointer = { - render: () => ( - - {(x, y) => ( - {`x=${formatCoord(x)}, y=${formatCoord(y)}`} - )} - - ), -} satisfies Story; From 2fcf77519fef4315f88c8a320d7c01491007642a Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Fri, 18 Aug 2023 11:21:30 +0200 Subject: [PATCH 5/6] Separate out enums and constants from other types in lib export --- packages/lib/src/index.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 28d4b0f96..2b0c2c76e 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -124,30 +124,20 @@ export { useInteraction, useModifierKeyPressed, } from './interactions/hooks'; -export { MouseButton } from './interactions/models'; -export { getModifierKeyArray } from './interactions/utils'; export { default as Box } from './interactions/box'; +// Constants export { COLOR_SCALE_TYPES, AXIS_SCALE_TYPES } from '@h5web/shared'; - -// Models export { INTERPOLATORS } from './vis/heatmap/interpolators'; + +// Enums export { ScaleType } from '@h5web/shared'; export { CurveType, GlyphType } from './vis/line/models'; export { ImageType } from './vis/rgb/models'; export { Notation } from './vis/matrix/models'; +export { MouseButton } from './interactions/models'; -export type { - ModifierKey, - Rect, - Selection, - CanvasEvent, - CanvasEventCallbacks, - InteractionInfo, - InteractionEntry, - CommonInteractionProps, -} from './interactions/models'; - +// Models export type { Domain, VisibleDomains, @@ -176,6 +166,17 @@ export type { export type { D3Interpolator, ColorMap } from './vis/heatmap/models'; export type { ScatterAxisParams } from './vis/scatter/models'; +export type { + ModifierKey, + Rect, + Selection, + CanvasEvent, + CanvasEventCallbacks, + InteractionInfo, + InteractionConfig, + CommonInteractionProps, +} from './interactions/models'; + // Mock data and utilities export { mockMetadata, From a658f47e463b881a2cc5f3f0bf7e98c04820f21f Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Fri, 18 Aug 2023 11:24:28 +0200 Subject: [PATCH 6/6] Simplify and document usage of `useInteraction` and `useModifierKeyPressed` --- apps/storybook/src/Utilities.mdx | 48 +++++++++++++++++++ .../src/interactions/InteractionsProvider.tsx | 42 +++++----------- packages/lib/src/interactions/Pan.tsx | 10 +--- .../lib/src/interactions/SelectionTool.tsx | 6 +-- packages/lib/src/interactions/XAxisZoom.tsx | 5 +- packages/lib/src/interactions/YAxisZoom.tsx | 5 +- packages/lib/src/interactions/Zoom.tsx | 5 +- packages/lib/src/interactions/hooks.ts | 17 +++++-- packages/lib/src/interactions/interaction.ts | 44 +++++++++++++++++ packages/lib/src/interactions/models.ts | 6 +-- packages/lib/src/interactions/utils.ts | 9 ---- 11 files changed, 127 insertions(+), 70 deletions(-) create mode 100644 packages/lib/src/interactions/interaction.ts delete mode 100644 packages/lib/src/interactions/utils.ts diff --git a/apps/storybook/src/Utilities.mdx b/apps/storybook/src/Utilities.mdx index 175bd8bfa..b216407d2 100644 --- a/apps/storybook/src/Utilities.mdx +++ b/apps/storybook/src/Utilities.mdx @@ -243,6 +243,54 @@ const pt = useCameraState( ); ``` +#### useInteraction + +Register an interaction. You must provide a unique ID that is not used by other interactions inside the current `VisCanvas` (pan, zoom, etc.) + +The hook returns a function, conventionally named `shouldInteract`, that allows testing if a given mouse event (`PointerEvent` or `WheelEvent`) +is allowed to start or continue the interaction. It checks whether the event was triggered with the same mouse button and modifier key(s) +with which the interaction was registered and ensures that there is no interaction that is better suited to handle this event. + +```ts +useInteraction( + id: string, + config: InteractionConfig, +): (event: MouseEvent) => boolean + +const shouldInteract = useInteraction('MyInteraction', { + button: MouseButton.Left, + modifierKey: 'Control', +}) + +const onPointerDown = useCallback((evt: CanvasEvent) => { + if (shouldInteract(evt.sourceEvent)) { + /* ... */ + } +}, +[shouldInteract]); + +useCanvasEvents({ onPointerDown }}; +``` + +#### useModifierKeyPressed + +Keeps track of the pressed state of one or more modifier keys. + +The hook removes the need for a mouse event to be fired to know the state of the given modifier keys, which allows reacting to the user releasing +a key at any time, even when the mouse is immobile. + +```ts +useModifierKeyPressed(modifierKey?: ModifierKey | ModifierKey[]): boolean + +const isModifierKeyPressed = useModifierKeyPressed('Shift'); + +const onPointerMove = useCallback((evt: CanvasEvent) => { + if (isModifierKeyPressed) { + return; + } +}, [isModifierKeyPressed]); +``` + ### Mock data The library exposes a utility function to retrieve a mock entity's metadata and a mock dataset's value as ndarray for testing purposes. diff --git a/packages/lib/src/interactions/InteractionsProvider.tsx b/packages/lib/src/interactions/InteractionsProvider.tsx index 7301cb2ce..4bd12ea1e 100644 --- a/packages/lib/src/interactions/InteractionsProvider.tsx +++ b/packages/lib/src/interactions/InteractionsProvider.tsx @@ -1,18 +1,15 @@ import type { ReactNode } from 'react'; import { createContext, useCallback, useContext, useState } from 'react'; -import type { InteractionEntry, ModifierKey, MouseButton } from './models'; +import { Interaction } from './interaction'; +import type { InteractionConfig } from './models'; export interface InteractionsContextValue { - registerInteraction: (id: string, value: InteractionEntry) => void; + registerInteraction: (id: string, config: InteractionConfig) => void; unregisterInteraction: (id: string) => void; shouldInteract: (id: string, event: MouseEvent) => boolean; } -interface MapEntry extends InteractionEntry { - id: string; -} - const InteractionsContext = createContext({} as InteractionsContextValue); export function useInteractionsContext() { @@ -22,14 +19,14 @@ export function useInteractionsContext() { function InteractionsProvider(props: { children: ReactNode }) { const { children } = props; - const [interactionMap] = useState(new Map()); + const [interactionMap] = useState(new Map()); const registerInteraction = useCallback( - (id: string, value: InteractionEntry) => { + (id: string, config: InteractionConfig) => { if (interactionMap.has(id)) { console.warn(`An interaction with ID "${id}" is already registered.`); // eslint-disable-line no-console } else { - interactionMap.set(id, { id, ...value }); + interactionMap.set(id, new Interaction(id, config)); } }, [interactionMap], @@ -45,28 +42,12 @@ function InteractionsProvider(props: { children: ReactNode }) { const shouldInteract = useCallback( (interactionId: string, event: MouseEvent | WheelEvent) => { const registeredInteractions = [...interactionMap.values()]; - - function isButtonPressed(button: MouseButton | MouseButton[] | 'Wheel') { - if (event instanceof WheelEvent) { - return button === 'Wheel'; - } - - return Array.isArray(button) - ? button.includes(event.button) - : event.button === button; - } - - function areKeysPressed(keys: ModifierKey[]) { - return keys.every((k) => event.getModifierState(k)); - } - if (!interactionMap.has(interactionId)) { throw new Error(`Interaction ${interactionId} is not registered`); } const matchingInteractions = registeredInteractions.filter( - ({ modifierKeys: keys, button, disabled }) => - !disabled && isButtonPressed(button) && areKeysPressed(keys), + (interaction) => interaction.matches(event), ); if (matchingInteractions.length === 0) { @@ -77,13 +58,12 @@ function InteractionsProvider(props: { children: ReactNode }) { return matchingInteractions[0].id === interactionId; } - // If conflicting interactions, the one with the most modifier keys take precedence - matchingInteractions.sort( - (a, b) => b.modifierKeys.length - a.modifierKeys.length, + // If conflicting interactions, the one with the most modifier keys takes precedence + const maxKeysInteraction = matchingInteractions.reduce((acc, next) => + next.modifierKeys.length > acc.modifierKeys.length ? next : acc, ); - const [maxKeyInteraction] = matchingInteractions; - return maxKeyInteraction.id === interactionId; + return maxKeysInteraction.id === interactionId; }, [interactionMap], ); diff --git a/packages/lib/src/interactions/Pan.tsx b/packages/lib/src/interactions/Pan.tsx index 03aeb0229..b66ab040f 100644 --- a/packages/lib/src/interactions/Pan.tsx +++ b/packages/lib/src/interactions/Pan.tsx @@ -10,7 +10,6 @@ import { } from './hooks'; import type { CanvasEvent, CommonInteractionProps } from './models'; import { MouseButton } from './models'; -import { getModifierKeyArray } from './utils'; interface Props extends CommonInteractionProps { id?: string; @@ -25,18 +24,13 @@ function Pan(props: Props) { disabled, } = props; - const modifierKeys = getModifierKeyArray(modifierKey); - const shouldInteract = useInteraction(id, { - button, - modifierKeys, - disabled, - }); + const shouldInteract = useInteraction(id, { button, modifierKey, disabled }); const camera = useThree((state) => state.camera); const moveCameraTo = useMoveCameraTo(); const startOffsetPosition = useRef(); // `useRef` to avoid re-renders - const isModifierKeyPressed = useModifierKeyPressed(modifierKeys); + const isModifierKeyPressed = useModifierKeyPressed(modifierKey); const onPointerDown = useCallback( (evt: CanvasEvent) => { diff --git a/packages/lib/src/interactions/SelectionTool.tsx b/packages/lib/src/interactions/SelectionTool.tsx index cfe6ff757..bd62ee466 100644 --- a/packages/lib/src/interactions/SelectionTool.tsx +++ b/packages/lib/src/interactions/SelectionTool.tsx @@ -25,7 +25,6 @@ import type { Selection, } from './models'; import { MouseButton } from './models'; -import { getModifierKeyArray } from './utils'; interface Props extends CommonInteractionProps { id?: string; @@ -80,12 +79,11 @@ function SelectionTool(props: Props) { const startEvtRef = useRef>(); const hasSuccessfullyEndedRef = useRef(false); - const modifierKeys = getModifierKeyArray(modifierKey); - const isModifierKeyPressed = useModifierKeyPressed(modifierKeys); + const isModifierKeyPressed = useModifierKeyPressed(modifierKey); const shouldInteract = useInteraction(id, { button: MouseButton.Left, - modifierKeys, + modifierKey, disabled, }); diff --git a/packages/lib/src/interactions/XAxisZoom.tsx b/packages/lib/src/interactions/XAxisZoom.tsx index ea0901468..d2e187339 100644 --- a/packages/lib/src/interactions/XAxisZoom.tsx +++ b/packages/lib/src/interactions/XAxisZoom.tsx @@ -1,7 +1,6 @@ import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; import type { CommonInteractionProps } from './models'; -import { getModifierKeyArray } from './utils'; type Props = CommonInteractionProps; @@ -10,9 +9,9 @@ function XAxisZoom(props: Props) { const { visRatio } = useVisCanvasContext(); const shouldInteract = useInteraction('XAxisZoom', { - modifierKeys: getModifierKeyArray(modifierKey), - disabled: visRatio !== undefined || disabled, button: 'Wheel', + modifierKey, + disabled: visRatio !== undefined || disabled, }); const isZoomAllowed = (sourceEvent: WheelEvent) => ({ diff --git a/packages/lib/src/interactions/YAxisZoom.tsx b/packages/lib/src/interactions/YAxisZoom.tsx index 58185b66a..dc2cab8f2 100644 --- a/packages/lib/src/interactions/YAxisZoom.tsx +++ b/packages/lib/src/interactions/YAxisZoom.tsx @@ -1,7 +1,6 @@ import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; import type { CommonInteractionProps } from './models'; -import { getModifierKeyArray } from './utils'; type Props = CommonInteractionProps; @@ -10,9 +9,9 @@ function YAxisZoom(props: Props) { const { visRatio } = useVisCanvasContext(); const shouldInteract = useInteraction('YAxisZoom', { - modifierKeys: getModifierKeyArray(modifierKey), - disabled: visRatio !== undefined || disabled, button: 'Wheel', + modifierKey, + disabled: visRatio !== undefined || disabled, }); const isZoomAllowed = (sourceEvent: WheelEvent) => ({ diff --git a/packages/lib/src/interactions/Zoom.tsx b/packages/lib/src/interactions/Zoom.tsx index 2efc00baa..d1731752c 100644 --- a/packages/lib/src/interactions/Zoom.tsx +++ b/packages/lib/src/interactions/Zoom.tsx @@ -1,15 +1,14 @@ import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; import type { CommonInteractionProps } from './models'; -import { getModifierKeyArray } from './utils'; type Props = CommonInteractionProps; function Zoom(props: Props) { const { modifierKey, disabled } = props; const shouldInteract = useInteraction('Zoom', { - modifierKeys: getModifierKeyArray(modifierKey), - disabled, button: 'Wheel', + modifierKey, + disabled, }); const isZoomAllowed = (sourceEvent: WheelEvent) => { diff --git a/packages/lib/src/interactions/hooks.ts b/packages/lib/src/interactions/hooks.ts index 7a0399311..7a286f653 100644 --- a/packages/lib/src/interactions/hooks.ts +++ b/packages/lib/src/interactions/hooks.ts @@ -1,5 +1,6 @@ import { useEventListener, useToggle } from '@react-hookz/web'; import { useThree } from '@react-three/fiber'; +import { castArray } from 'lodash'; import { useCallback, useEffect, useState } from 'react'; import { Vector3 } from 'three'; @@ -9,7 +10,7 @@ import { useInteractionsContext } from './InteractionsProvider'; import type { CanvasEvent, CanvasEventCallbacks, - InteractionEntry, + InteractionConfig, ModifierKey, Selection, } from './models'; @@ -181,14 +182,17 @@ export function useCanvasEvents(callbacks: CanvasEventCallbacks): void { useEventListener(domElement, 'wheel', handleWheel); } -export function useInteraction(id: string, value: InteractionEntry) { +export function useInteraction( + id: string, + config: InteractionConfig, +): (event: MouseEvent) => boolean { const { shouldInteract, registerInteraction, unregisterInteraction } = useInteractionsContext(); useEffect(() => { - registerInteraction(id, value); + registerInteraction(id, config); return () => unregisterInteraction(id); - }, [id, registerInteraction, unregisterInteraction, value]); + }, [id, registerInteraction, unregisterInteraction, config]); return useCallback( (event: MouseEvent) => shouldInteract(id, event), @@ -196,7 +200,10 @@ export function useInteraction(id: string, value: InteractionEntry) { ); } -export function useModifierKeyPressed(modifierKeys: ModifierKey[]): boolean { +export function useModifierKeyPressed( + modifierKey: ModifierKey | ModifierKey[] = [], +): boolean { + const modifierKeys = castArray(modifierKey); const { domElement } = useThree((state) => state.gl); const [pressedKeys] = useState(new Map()); diff --git a/packages/lib/src/interactions/interaction.ts b/packages/lib/src/interactions/interaction.ts new file mode 100644 index 000000000..02311d06f --- /dev/null +++ b/packages/lib/src/interactions/interaction.ts @@ -0,0 +1,44 @@ +import { castArray } from 'lodash'; +import type { ModifierKey } from 'react'; + +import type { InteractionConfig } from './models'; +import { MouseButton } from './models'; + +export class Interaction { + public readonly buttons: MouseButton[]; + public readonly modifierKeys: ModifierKey[]; + public readonly isWheel: boolean; + public readonly isEnabled: boolean; + + public constructor( + public readonly id: string, + config: InteractionConfig, + ) { + const { + button = MouseButton.Left, + modifierKey = [], + disabled = false, + } = config; + + if (button === 'Wheel') { + this.buttons = []; + this.isWheel = true; + } else { + this.buttons = castArray(button); + this.isWheel = false; + } + + this.modifierKeys = castArray(modifierKey); + this.isEnabled = !disabled; + } + + public matches(event: MouseEvent): boolean { + return ( + this.isEnabled && + (event instanceof WheelEvent + ? this.isWheel + : this.buttons.includes(event.button)) && + this.modifierKeys.every((key) => event.getModifierState(key)) + ); + } +} diff --git a/packages/lib/src/interactions/models.ts b/packages/lib/src/interactions/models.ts index 5612600a2..379424b06 100644 --- a/packages/lib/src/interactions/models.ts +++ b/packages/lib/src/interactions/models.ts @@ -34,10 +34,8 @@ export interface InteractionInfo { description: string; } -export interface InteractionEntry { - button: MouseButton | MouseButton[] | 'Wheel'; - modifierKeys: ModifierKey[]; - disabled?: boolean; +export interface InteractionConfig extends CommonInteractionProps { + button?: MouseButton | MouseButton[] | 'Wheel'; } export interface CommonInteractionProps { diff --git a/packages/lib/src/interactions/utils.ts b/packages/lib/src/interactions/utils.ts deleted file mode 100644 index b28dd44bd..000000000 --- a/packages/lib/src/interactions/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { castArray } from 'lodash'; - -import type { ModifierKey } from './models'; - -export function getModifierKeyArray( - keys: ModifierKey | ModifierKey[] | undefined = [], -): ModifierKey[] { - return castArray(keys); -}