diff --git a/apps/storybook/src/Pan.stories.tsx b/apps/storybook/src/Pan.stories.tsx index ca4e03a95..cca525bc9 100644 --- a/apps/storybook/src/Pan.stories.tsx +++ b/apps/storybook/src/Pan.stories.tsx @@ -16,8 +16,9 @@ const Template: Story = (args) => { - + diff --git a/apps/storybook/src/SelectToZoom.stories.tsx b/apps/storybook/src/SelectToZoom.stories.tsx index 9a0ea9da6..f2b9ab09b 100644 --- a/apps/storybook/src/SelectToZoom.stories.tsx +++ b/apps/storybook/src/SelectToZoom.stories.tsx @@ -30,10 +30,15 @@ const Template: Story = (args) => { - + diff --git a/apps/storybook/src/Selection.stories.tsx b/apps/storybook/src/Selection.stories.tsx index 63747de38..d80312f56 100644 --- a/apps/storybook/src/Selection.stories.tsx +++ b/apps/storybook/src/Selection.stories.tsx @@ -53,6 +53,11 @@ const Template: Story = (args) => { } abscissaConfig={{ visDomain: [-10, 0], showGrid: true }} ordinateConfig={{ visDomain: [50, 100], showGrid: true }} + interactionKeys={{ + Pan: true, + Zoom: true, + Selection: modifierKey || true, + }} > @@ -60,7 +65,6 @@ const Template: Story = (args) => { setActiveSelection(undefined)} - modifierKey={modifierKey} > {(selection) => } @@ -132,6 +136,11 @@ export const PersistSelection: Story = (args) => { } abscissaConfig={{ visDomain: [-10, 0], showGrid: true }} ordinateConfig={{ visDomain: [50, 100], showGrid: true }} + interactionKeys={{ + Pan: true, + Zoom: true, + Selection: modifierKey || true, + }} > @@ -140,7 +149,6 @@ export const PersistSelection: Story = (args) => { setPersistedSelection(undefined); }} onSelectionEnd={setPersistedSelection} - modifierKey={modifierKey} > {(selection) => } diff --git a/apps/storybook/src/XZoom.stories.tsx b/apps/storybook/src/XZoom.stories.tsx index 9f4129521..5e28ac8be 100644 --- a/apps/storybook/src/XZoom.stories.tsx +++ b/apps/storybook/src/XZoom.stories.tsx @@ -16,9 +16,10 @@ const Template: Story = (args) => { - + ); diff --git a/apps/storybook/src/YZoom.stories.tsx b/apps/storybook/src/YZoom.stories.tsx index 1e9618ad1..295ddac54 100644 --- a/apps/storybook/src/YZoom.stories.tsx +++ b/apps/storybook/src/YZoom.stories.tsx @@ -16,9 +16,10 @@ const Template: Story = (args) => { - + ); diff --git a/apps/storybook/src/Zoom.stories.tsx b/apps/storybook/src/Zoom.stories.tsx index b61f1df0c..c697dfdbd 100644 --- a/apps/storybook/src/Zoom.stories.tsx +++ b/apps/storybook/src/Zoom.stories.tsx @@ -16,9 +16,10 @@ const Template: Story = (args) => { - + ); diff --git a/packages/lib/src/interactions/Pan.tsx b/packages/lib/src/interactions/Pan.tsx index 00347a9b8..2913a5517 100644 --- a/packages/lib/src/interactions/Pan.tsx +++ b/packages/lib/src/interactions/Pan.tsx @@ -2,17 +2,17 @@ import { useThree } from '@react-three/fiber'; import { useRef, useCallback } from 'react'; import type { Vector3 } from 'three'; +import { useAxisSystemContext } from '../vis/shared/AxisSystemContext'; import { useCanvasEvents, useMoveCameraTo } from './hooks'; -import type { CanvasEvent, ModifierKey } from './models'; -import { checkModifierKey } from './utils'; +import type { CanvasEvent } from './models'; interface Props { disabled?: boolean; - modifierKey?: ModifierKey; } function Pan(props: Props) { - const { disabled, modifierKey } = props; + const { disabled } = props; + const { shouldInteract } = useAxisSystemContext(); const camera = useThree((state) => state.camera); @@ -29,13 +29,12 @@ function Pan(props: Props) { return; } - const isPanAllowed = checkModifierKey(modifierKey, sourceEvent); - if (isPanAllowed) { + if (shouldInteract('Pan', sourceEvent)) { (target as Element).setPointerCapture(pointerId); // https://stackoverflow.com/q/28900077/758806 startOffsetPosition.current = unprojectedPoint.clone(); } }, - [disabled, modifierKey] + [shouldInteract, disabled] ); const onPointerUp = useCallback((evt: CanvasEvent) => { diff --git a/packages/lib/src/interactions/SelectToZoom.tsx b/packages/lib/src/interactions/SelectToZoom.tsx index 954cd5436..d2f064cb4 100644 --- a/packages/lib/src/interactions/SelectToZoom.tsx +++ b/packages/lib/src/interactions/SelectToZoom.tsx @@ -4,16 +4,15 @@ import { useAxisSystemContext } from '../vis/shared/AxisSystemContext'; import SelectionRect from './SelectionRect'; import SelectionTool from './SelectionTool'; import { useMoveCameraTo } from './hooks'; -import type { ModifierKey, Selection } from './models'; +import type { Selection } from './models'; import { getRatioEndPoint } from './utils'; interface Props { - modifierKey?: ModifierKey; keepRatio?: boolean; } function SelectToZoom(props: Props) { - const { modifierKey = 'Control', keepRatio } = props; + const { keepRatio } = props; const { dataToWorld } = useAxisSystemContext(); const moveCameraTo = useMoveCameraTo(); @@ -53,7 +52,7 @@ function SelectToZoom(props: Props) { }; return ( - + {({ startPoint, endPoint }) => ( <> void; onSelectionChange?: (points: Selection) => void; onSelectionEnd?: (points: Selection) => void; - modifierKey?: ModifierKey; + id?: string; children: (points: Selection) => ReactElement; } @@ -24,11 +24,12 @@ function SelectionTool(props: Props) { onSelectionStart, onSelectionChange, onSelectionEnd, - modifierKey, + id = 'Selection', } = props; const camera = useThree((state) => state.camera); - const { worldToData } = useAxisSystemContext(); + const { worldToData, shouldInteract, getModifierKey } = + useAxisSystemContext(); const [startPoint, setStartPoint] = useState(); const [endPoint, setEndPoint] = useRafState(undefined); @@ -37,7 +38,7 @@ function SelectionTool(props: Props) { const onPointerDown = useCallback( (evt: CanvasEvent) => { const { unprojectedPoint, sourceEvent } = evt; - if (!checkModifierKey(modifierKey, sourceEvent)) { + if (!shouldInteract(id, sourceEvent)) { return; } @@ -49,7 +50,7 @@ function SelectionTool(props: Props) { onSelectionStart(); } }, - [modifierKey, onSelectionStart, worldToData] + [id, onSelectionStart, shouldInteract, worldToData] ); const onPointerMove = useCallback( @@ -62,7 +63,7 @@ function SelectionTool(props: Props) { const point = worldToData(boundPointToFOV(unprojectedPoint, camera)); setEndPoint(point); - if (onSelectionChange && checkModifierKey(modifierKey, sourceEvent)) { + if (onSelectionChange && shouldInteract(id, sourceEvent)) { onSelectionChange({ startPoint, endPoint: point, @@ -70,12 +71,13 @@ function SelectionTool(props: Props) { } }, [ - camera, - modifierKey, startPoint, - onSelectionChange, - setEndPoint, worldToData, + camera, + setEndPoint, + onSelectionChange, + shouldInteract, + id, ] ); @@ -92,18 +94,27 @@ function SelectionTool(props: Props) { setStartPoint(undefined); setEndPoint(undefined); - if (onSelectionEnd && checkModifierKey(modifierKey, sourceEvent)) { + if (onSelectionEnd && shouldInteract(id, sourceEvent)) { onSelectionEnd({ startPoint, endPoint: worldToData(boundPointToFOV(unprojectedPoint, camera)), }); } }, - [camera, modifierKey, startPoint, onSelectionEnd, setEndPoint, worldToData] + [ + startPoint, + setEndPoint, + onSelectionEnd, + shouldInteract, + id, + worldToData, + camera, + ] ); useCanvasEvents({ onPointerDown, onPointerMove, onPointerUp }); + const modifierKey = getModifierKey(id); useKeyboardEvent(modifierKey, () => toggleVisible(), [], { event: 'keyup' }); useKeyboardEvent( modifierKey, diff --git a/packages/lib/src/interactions/XAxisZoom.tsx b/packages/lib/src/interactions/XAxisZoom.tsx index 0fda80880..9e8a302ac 100644 --- a/packages/lib/src/interactions/XAxisZoom.tsx +++ b/packages/lib/src/interactions/XAxisZoom.tsx @@ -1,16 +1,17 @@ +import { useAxisSystemContext } from '../vis/shared/AxisSystemContext'; import { useCanvasEvents, useZoomOnWheel } from './hooks'; -import type { ModifierKey } from './models'; interface Props { disabled?: boolean; - modifierKey?: ModifierKey; } function XAxisZoom(props: Props) { - const { disabled, modifierKey = 'Alt' } = props; + const { disabled } = props; + + const { shouldInteract } = useAxisSystemContext(); const isZoomAllowed = (sourceEvent: WheelEvent) => ({ - x: sourceEvent.getModifierState(modifierKey), + x: shouldInteract('XAxisZoom', sourceEvent), y: false, }); diff --git a/packages/lib/src/interactions/YAxisZoom.tsx b/packages/lib/src/interactions/YAxisZoom.tsx index 9a3e3631d..732bf7555 100644 --- a/packages/lib/src/interactions/YAxisZoom.tsx +++ b/packages/lib/src/interactions/YAxisZoom.tsx @@ -1,17 +1,17 @@ +import { useAxisSystemContext } from '../vis/shared/AxisSystemContext'; import { useCanvasEvents, useZoomOnWheel } from './hooks'; -import type { ModifierKey } from './models'; interface Props { disabled?: boolean; - modifierKey?: ModifierKey; } function YAxisZoom(props: Props) { - const { disabled, modifierKey = 'Shift' } = props; + const { disabled } = props; + const { shouldInteract } = useAxisSystemContext(); const isZoomAllowed = (sourceEvent: WheelEvent) => ({ x: false, - y: sourceEvent.getModifierState(modifierKey), + y: shouldInteract('YAxisZoom', sourceEvent), }); useCanvasEvents({ onWheel: useZoomOnWheel(isZoomAllowed, disabled) }); diff --git a/packages/lib/src/interactions/Zoom.tsx b/packages/lib/src/interactions/Zoom.tsx index 8d520bd2a..cb2e57a08 100644 --- a/packages/lib/src/interactions/Zoom.tsx +++ b/packages/lib/src/interactions/Zoom.tsx @@ -1,17 +1,16 @@ +import { useAxisSystemContext } from '../vis/shared/AxisSystemContext'; import { useCanvasEvents, useZoomOnWheel } from './hooks'; -import type { ModifierKey } from './models'; -import { checkModifierKey } from './utils'; interface Props { disabled?: boolean; - modifierKey?: ModifierKey; } function Zoom(props: Props) { - const { disabled, modifierKey } = props; + const { disabled } = props; + const { shouldInteract } = useAxisSystemContext(); const isZoomAllowed = (sourceEvent: WheelEvent) => { - const shouldZoom = checkModifierKey(modifierKey, sourceEvent); + const shouldZoom = shouldInteract('Zoom', sourceEvent); return { x: shouldZoom, y: shouldZoom }; }; diff --git a/packages/lib/src/interactions/models.ts b/packages/lib/src/interactions/models.ts index 9153faaf3..79e7a09a7 100644 --- a/packages/lib/src/interactions/models.ts +++ b/packages/lib/src/interactions/models.ts @@ -7,7 +7,7 @@ export interface Selection { endPoint: Vector2; } -export interface CanvasEvent { +export interface CanvasEvent { unprojectedPoint: Vector3; sourceEvent: T; } @@ -23,3 +23,5 @@ export interface Interaction { shortcut: string; description: string; } + +export type InteractionKeys = Record; diff --git a/packages/lib/src/vis/heatmap/HeatmapVis.tsx b/packages/lib/src/vis/heatmap/HeatmapVis.tsx index 94f59f854..eb9a1e9af 100644 --- a/packages/lib/src/vis/heatmap/HeatmapVis.tsx +++ b/packages/lib/src/vis/heatmap/HeatmapVis.tsx @@ -9,16 +9,21 @@ import { import type { NdArray } from 'ndarray'; import type { ReactElement, ReactNode } from 'react'; -import { XAxisZoom, YAxisZoom } from '../..'; import Pan from '../../interactions/Pan'; import ResetZoomButton from '../../interactions/ResetZoomButton'; import SelectToZoom from '../../interactions/SelectToZoom'; +import XAxisZoom from '../../interactions/XAxisZoom'; +import YAxisZoom from '../../interactions/YAxisZoom'; import Zoom from '../../interactions/Zoom'; import { useAxisDomain, useValueToIndexScale } from '../hooks'; import type { AxisParams, VisScaleType } from '../models'; import TooltipMesh from '../shared/TooltipMesh'; import VisCanvas from '../shared/VisCanvas'; -import { DEFAULT_DOMAIN, formatNumType } from '../utils'; +import { + DEFAULT_DOMAIN, + DEFAULT_INTERACTIONS_KEYS, + formatNumType, +} from '../utils'; import ColorBar from './ColorBar'; import HeatmapMesh from './HeatmapMesh'; import styles from './HeatmapVis.module.css'; @@ -103,6 +108,7 @@ function HeatmapVis(props: Props) { label: ordinateLabel, flip: flipYAxis, }} + interactionKeys={DEFAULT_INTERACTIONS_KEYS} > diff --git a/packages/lib/src/vis/line/LineVis.tsx b/packages/lib/src/vis/line/LineVis.tsx index 7a6da4f63..e6e7a3b37 100644 --- a/packages/lib/src/vis/line/LineVis.tsx +++ b/packages/lib/src/vis/line/LineVis.tsx @@ -22,7 +22,12 @@ import { useAxisDomain, useCustomColors, useValueToIndexScale } from '../hooks'; import type { AxisParams, CustomColor } from '../models'; import TooltipMesh from '../shared/TooltipMesh'; import VisCanvas from '../shared/VisCanvas'; -import { extendDomain, DEFAULT_DOMAIN, formatNumType } from '../utils'; +import { + extendDomain, + DEFAULT_DOMAIN, + formatNumType, + DEFAULT_INTERACTIONS_KEYS, +} from '../utils'; import DataCurve from './DataCurve'; import styles from './LineVis.module.css'; import type { TooltipData } from './models'; @@ -130,6 +135,7 @@ function LineVis(props: Props) { scaleType, label: ordinateLabel, }} + interactionKeys={DEFAULT_INTERACTIONS_KEYS} > diff --git a/packages/lib/src/vis/rgb/RgbVis.tsx b/packages/lib/src/vis/rgb/RgbVis.tsx index 8566fa6b3..a41c179a1 100644 --- a/packages/lib/src/vis/rgb/RgbVis.tsx +++ b/packages/lib/src/vis/rgb/RgbVis.tsx @@ -13,6 +13,7 @@ import Zoom from '../../interactions/Zoom'; import styles from '../heatmap/HeatmapVis.module.css'; import type { Layout } from '../heatmap/models'; import VisCanvas from '../shared/VisCanvas'; +import { DEFAULT_INTERACTIONS_KEYS } from '../utils'; import RgbMesh from './RgbMesh'; import { ImageType } from './models'; import { toRgbSafeNdArray } from './utils'; @@ -58,6 +59,7 @@ function RgbVis(props: Props) { isIndexAxis: true, flip: true, }} + interactionKeys={DEFAULT_INTERACTIONS_KEYS} > diff --git a/packages/lib/src/vis/scatter/ScatterVis.tsx b/packages/lib/src/vis/scatter/ScatterVis.tsx index 305db3a1f..50165f877 100644 --- a/packages/lib/src/vis/scatter/ScatterVis.tsx +++ b/packages/lib/src/vis/scatter/ScatterVis.tsx @@ -69,6 +69,11 @@ function ScatterVis(props: Props) { label: ordinateLabel, }} title={title} + interactionKeys={{ + Pan: true, + Zoom: true, + SelectToZoom: 'Control', + }} > diff --git a/packages/lib/src/vis/shared/AxisSystemContext.tsx b/packages/lib/src/vis/shared/AxisSystemContext.tsx index 2289e7e07..9de64f57d 100644 --- a/packages/lib/src/vis/shared/AxisSystemContext.tsx +++ b/packages/lib/src/vis/shared/AxisSystemContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext } from 'react'; import type { Vector2, Vector3 } from 'three'; +import type { ModifierKey } from '../../interactions/models'; import type { AxisConfig, AxisScale, Size } from '../models'; export interface AxisSystemParams { @@ -12,6 +13,8 @@ export interface AxisSystemParams { dataToWorld: (vec: Vector2 | Vector3) => Vector2; worldToData: (vec: Vector2 | Vector3) => Vector2; worldToHtml: (vec: Vector2 | Vector3) => Vector2; + shouldInteract: (id: string, event: MouseEvent) => boolean; + getModifierKey: (id: string) => ModifierKey | undefined; } export const AxisSystemContext = createContext( diff --git a/packages/lib/src/vis/shared/AxisSystemProvider.tsx b/packages/lib/src/vis/shared/AxisSystemProvider.tsx index b98e06c38..08387e37f 100644 --- a/packages/lib/src/vis/shared/AxisSystemProvider.tsx +++ b/packages/lib/src/vis/shared/AxisSystemProvider.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react'; import { useCallback, useMemo } from 'react'; import { Matrix4, Vector2, Vector3 } from 'three'; +import type { InteractionKeys, ModifierKey } from '../../interactions/models'; import type { AxisConfig } from '../models'; import { getCanvasScale, getSizeToFit } from '../utils'; import { AxisSystemContext } from './AxisSystemContext'; @@ -11,10 +12,17 @@ interface Props { visRatio: number | undefined; abscissaConfig: AxisConfig; ordinateConfig: AxisConfig; + interactionKeys: InteractionKeys; } function AxisSystemProvider(props: PropsWithChildren) { - const { visRatio, abscissaConfig, ordinateConfig, children } = props; + const { + visRatio, + abscissaConfig, + ordinateConfig, + children, + interactionKeys, + } = props; const availableSize = useThree((state) => state.size); const visSize = getSizeToFit(availableSize, visRatio); @@ -46,6 +54,40 @@ function AxisSystemProvider(props: PropsWithChildren) { [camera, cameraToHtmlMatrix] ); + const registeredKeys = Object.values(interactionKeys).filter( + (k) => k !== true + ) as ModifierKey[]; + const keySet = new Set(registeredKeys); + if (keySet.size !== registeredKeys.length) { + throw new Error('Two interactions were registered on the same key !'); + } + + const getModifierKey = useCallback( + (id: string) => { + if (!Object.keys(interactionKeys).includes(id)) { + throw new Error(`Interaction ${id} was not registered in VisCanvas.`); + } + + const interactionKey = interactionKeys[id]; + + return interactionKey === true ? undefined : interactionKey; + }, + [interactionKeys] + ); + + const shouldInteract = useCallback( + (id: string, event: MouseEvent) => { + const interactionKey = getModifierKey(id); + if (interactionKey !== undefined) { + return event.getModifierState(interactionKey); + } + + // Check that there is no conflicting interaction + return registeredKeys.every((key) => !event.getModifierState(key)); + }, + [getModifierKey, registeredKeys] + ); + return ( ) { dataToWorld, worldToHtml, visSize, + shouldInteract, + getModifierKey, }} > {children} diff --git a/packages/lib/src/vis/shared/VisCanvas.tsx b/packages/lib/src/vis/shared/VisCanvas.tsx index 8cd74fd36..2a33efc9b 100644 --- a/packages/lib/src/vis/shared/VisCanvas.tsx +++ b/packages/lib/src/vis/shared/VisCanvas.tsx @@ -2,6 +2,7 @@ import { useMeasure } from '@react-hookz/web'; import { Canvas } from '@react-three/fiber'; import type { PropsWithChildren } from 'react'; +import type { InteractionKeys } from '../../interactions/models'; import type { AxisConfig } from '../models'; import { getSizeToFit, getAxisOffsets } from '../utils'; import AxisSystem from './AxisSystem'; @@ -15,6 +16,7 @@ interface Props { visRatio?: number | undefined; abscissaConfig: AxisConfig; ordinateConfig: AxisConfig; + interactionKeys?: InteractionKeys; } function VisCanvas(props: PropsWithChildren) { @@ -25,6 +27,7 @@ function VisCanvas(props: PropsWithChildren) { abscissaConfig, ordinateConfig, children, + interactionKeys = {}, } = props; const shouldMeasure = !!canvasRatio; @@ -68,6 +71,7 @@ function VisCanvas(props: PropsWithChildren) { visRatio={visRatio} abscissaConfig={abscissaConfig} ordinateConfig={ordinateConfig} + interactionKeys={interactionKeys} > {children} diff --git a/packages/lib/src/vis/utils.ts b/packages/lib/src/vis/utils.ts index 9764d7a98..b4f0b18dc 100644 --- a/packages/lib/src/vis/utils.ts +++ b/packages/lib/src/vis/utils.ts @@ -16,6 +16,7 @@ import { clamp } from 'lodash'; import type { IUniform } from 'three'; import { Vector3 } from 'three'; +import type { InteractionKeys } from '../interactions/models'; import type { Size, AxisScale, @@ -34,6 +35,14 @@ export const CAMERA_TOP_RIGHT = new Vector3(1, 1, 0); const AXIS_OFFSETS = { vertical: 72, horizontal: 40, fallback: 16 }; +export const DEFAULT_INTERACTIONS_KEYS: InteractionKeys = { + Pan: true, + Zoom: true, + XAxisZoom: 'Alt', + YAxisZoom: 'Shift', + SelectToZoom: 'Control', +}; + export const adaptedNumTicks: ScaleLinear = scaleLinear({ domain: [300, 900], range: [3, 10],