diff --git a/apps/storybook/src/SelectionTool.stories.tsx b/apps/storybook/src/SelectionTool.stories.tsx index 849d7fd7b..fa6b2e7e8 100644 --- a/apps/storybook/src/SelectionTool.stories.tsx +++ b/apps/storybook/src/SelectionTool.stories.tsx @@ -1,12 +1,14 @@ -import type { Rect, Selection } from '@h5web/lib'; +import type { Points, Rect, Selection } from '@h5web/lib'; import { Box, DataToHtml, Pan, ResetZoomButton, SelectionTool, + SvgCircle, SvgElement, SvgLine, + SvgPolyline, SvgRect, VisCanvas, Zoom, @@ -14,6 +16,7 @@ import { import { useThrottledState } from '@react-hookz/web'; import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; +import { Vector3 } from 'three'; import FillHeight from './decorators/FillHeight'; import { getTitleForSelection } from './utils'; @@ -110,7 +113,9 @@ export const PersistedDataSelection = { Box.fromPoints(...html).hasMinSize(50)} onSelectionStart={() => setPersistedDataSelection(undefined)} - onValidSelection={({ data }) => setPersistedDataSelection(data)} + onValidSelection={({ data }) => + setPersistedDataSelection(data as Rect) + } > {({ html: htmlSelection }, _, isValid) => ( @@ -137,6 +142,87 @@ export const PersistedDataSelection = { }, } satisfies Story; +export const PersistedPolylineSelection = { + args: { + minPoints: 3, + }, + + render: (args) => { + const [persistedPolylineSelection, setPersistedPolylineSelection] = + useState(); + + const { maxPoints } = { ...args }; + return ( + + + + + + { + return maxPoints === 1 && html.length === 1 + ? true + : Box.fromPoints(...html).hasMinSize(50); + }} + onSelectionStart={() => setPersistedPolylineSelection(undefined)} + onValidSelection={({ data }) => setPersistedPolylineSelection(data)} + > + {({ html: htmlSelection }, _, isValid, isComplete) => + maxPoints === 1 ? ( + + + + ) : ( + + + + ) + } + + + {persistedPolylineSelection && ( + + {(...htmlSelection) => + maxPoints === 1 ? ( + + + + ) : ( + + + + ) + } + + )} + + ); + }, +} satisfies Story; + export const LineWithLengthValidation = { render: () => { const [isValid, setValid] = useThrottledState( @@ -199,8 +285,8 @@ export const RectWithTransform = { box.expandBySize(-box.size.width / 2, 1); // shrink width of selection by two (equally on both side) const html = box.toRect(); - const world = html.map((pt) => htmlToWorld(camera, pt)) as Rect; - const data = world.map(worldToData) as Rect; + const world = html.map((pt) => htmlToWorld(camera, pt)); + const data = world.map(worldToData); return { html, world, data }; }} > diff --git a/apps/storybook/src/utils.ts b/apps/storybook/src/utils.ts index a1b49cda5..7b9d67dec 100644 --- a/apps/storybook/src/utils.ts +++ b/apps/storybook/src/utils.ts @@ -1,15 +1,16 @@ -import type { CustomDomain, Domain, Rect } from '@h5web/lib'; +import type { CustomDomain, Domain, Points } from '@h5web/lib'; import { format } from 'd3-format'; export const formatCoord = format('.2f'); export const formatDomainValue = format('.3~f'); -export function getTitleForSelection(selection: Rect | undefined) { +export function getTitleForSelection(selection: Points | undefined) { if (!selection) { return 'No selection'; } - const [start, end] = selection; + const start = selection[0]; + const end = selection[selection.length - 1]; return `Selection from (${formatCoord(start.x)}, ${formatCoord( start.y )}) to (${formatCoord(end.x)}, ${formatCoord(end.y)})`; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index c79c83638..b1fc4a4b2 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -73,10 +73,12 @@ export type { DefaultInteractionsConfig } from './interactions/DefaultInteractio // SVG export { default as SvgElement } from './interactions/svg/SvgElement'; export { default as SvgLine } from './interactions/svg/SvgLine'; +export { default as SvgPolyline } from './interactions/svg/SvgPolyline'; export { default as SvgRect } from './interactions/svg/SvgRect'; export { default as SvgCircle } from './interactions/svg/SvgCircle'; export type { SvgElementProps } from './interactions/svg/SvgElement'; export type { SvgLineProps } from './interactions/svg/SvgLine'; +export type { SvgPolylineProps } from './interactions/svg/SvgPolyline'; export type { SvgRectProps } from './interactions/svg/SvgRect'; export type { SvgCircleProps } from './interactions/svg/SvgCircle'; @@ -130,6 +132,7 @@ export type { InteractionInfo, ModifierKey, Selection, + Points, Rect, CanvasEvent, CanvasEventCallbacks, diff --git a/packages/lib/src/interactions/SelectionTool.tsx b/packages/lib/src/interactions/SelectionTool.tsx index bc36a2067..cdca206f8 100644 --- a/packages/lib/src/interactions/SelectionTool.tsx +++ b/packages/lib/src/interactions/SelectionTool.tsx @@ -8,7 +8,7 @@ import { import { useThree } from '@react-three/fiber'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import type { Camera } from 'three'; +import type { Camera, Vector3 } from 'three'; import type { VisCanvasContextValue } from '../vis/shared/VisCanvasProvider'; import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; @@ -20,7 +20,7 @@ import { import type { CanvasEvent, CommonInteractionProps, - Rect, + Points, Selection, } from './models'; import { MouseButton } from './models'; @@ -28,6 +28,12 @@ import { getModifierKeyArray } from './utils'; interface Props extends CommonInteractionProps { id?: string; + /** default = 2, must be >= 1 */ + minPoints?: number; + /** default to minPoints, must be >= minPoints or -1 = unlimited */ + maxPoints?: number; + /** max movement between pointer up/down to ignore, default = 1 */ + maxMovement?: number; transform?: ( rawSelection: Selection, camera: Camera, @@ -45,13 +51,17 @@ interface Props extends CommonInteractionProps { children: ( selection: Selection, rawSelection: Selection, - isValid: boolean + isValid: boolean, + isComple: boolean ) => ReactNode; } function SelectionTool(props: Props) { const { id = 'Selection', + minPoints = 2, + maxPoints = minPoints, + maxMovement = 1, modifierKey, disabled, transform = (selection) => selection, @@ -63,6 +73,16 @@ function SelectionTool(props: Props) { children, } = props; + if (minPoints < 1) { + throw new RangeError(`minPoints must be >= 1, ${minPoints}`); + } + + if (maxPoints < minPoints && maxPoints !== -1) { + throw new RangeError( + `maxPoints must be -1 or >= minPoints, ${maxPoints} cf ${minPoints}` + ); + } + // Wrap callbacks in up-to-date but stable refs so consumers don't have to memoise them const transformRef = useSyncedRef(transform); const validateRef = useSyncedRef(validate); @@ -76,7 +96,9 @@ function SelectionTool(props: Props) { const { canvasBox, htmlToWorld, worldToData } = context; const [rawSelection, setRawSelection] = useRafState(); - const startEvtRef = useRef>(); + const currentPtsRef = useRef(); + const useNewPointRef = useRef(true); + const isCompleteRef = useRef(false); const hasSuccessfullyEndedRef = useRef(false); const modifierKeys = getModifierKeyArray(modifierKey); @@ -88,66 +110,147 @@ function SelectionTool(props: Props) { disabled, }); - const onPointerDown = useCallback( - (evt: CanvasEvent) => { - const { sourceEvent } = evt; - if (!shouldInteract(sourceEvent)) { - return; - } + const setPoints = useCallback( + (html: Vector3[]) => { + const world = html.map((pt) => htmlToWorld(camera, pt)) as Points; + const data = world.map(worldToData) as Points; + setRawSelection({ html, world, data }); + }, + [camera, htmlToWorld, setRawSelection, worldToData] + ); - const { target, pointerId } = sourceEvent; - (target as Element).setPointerCapture(pointerId); + const startSelection = useCallback( + (eTarget: Element, pointerId: number, point: Vector3) => { + if (!useNewPointRef.current) { + useNewPointRef.current = true; + } else { + currentPtsRef.current = [point]; + isCompleteRef.current = false; + eTarget.setPointerCapture(pointerId); + if (maxPoints === 1) { + // no pointer movement necessary for single point + setPoints([point]); + } + } + }, + [maxPoints, setPoints] + ); - startEvtRef.current = evt; + const finishSelection = useCallback( + ( + eTarget: Element, + pointerId: number, + isDown: boolean, + interact: boolean + ) => { + eTarget.releasePointerCapture(pointerId); + useNewPointRef.current = !isDown; // so up is ignored + hasSuccessfullyEndedRef.current = interact; + currentPtsRef.current = undefined; }, - [shouldInteract] + [] ); - const onPointerMove = useCallback( + const onPointerClick = useCallback( (evt: CanvasEvent) => { - if (!startEvtRef.current) { + const { sourceEvent } = evt; + const isDown = sourceEvent.type === 'pointerdown'; + const doInteract = shouldInteract(sourceEvent); + if (isDown && !doInteract) { return; } - const { htmlPt: htmlStart } = startEvtRef.current; - const html: Rect = [htmlStart, canvasBox.clampPoint(evt.htmlPt)]; - const world = html.map((pt) => htmlToWorld(camera, pt)) as Rect; - const data = world.map(worldToData) as Rect; + const { target, pointerId } = sourceEvent; + const eTarget = target as Element; - setRawSelection({ html, world, data }); + const pts = currentPtsRef.current; + const cPt = canvasBox.clampPoint(evt.htmlPt); + if (pts === undefined) { + startSelection(eTarget, pointerId, cPt); + return; + } + const nPts = pts.length; + let done = false; + if (useNewPointRef.current) { + const lPt = pts[nPts - 1]; + const absMovement = + nPts === 1 + ? 0 + : Math.max(Math.abs(lPt.x - cPt.x), Math.abs(lPt.y - cPt.y)); + if (absMovement <= maxMovement) { + // clicking in same spot when complete finishes selection + done = isDown && isCompleteRef.current; + } else { + pts.push(cPt); + useNewPointRef.current = false; + } + } else { + useNewPointRef.current = true; + } + if (done || (nPts >= minPoints && maxPoints > 0 && nPts === maxPoints)) { + finishSelection(eTarget, pointerId, isDown, doInteract); + } }, - [camera, canvasBox, htmlToWorld, worldToData, setRawSelection] + [ + shouldInteract, + canvasBox, + minPoints, + maxPoints, + maxMovement, + startSelection, + finishSelection, + ] ); - const onPointerUp = useCallback( + const onPointerMove = useCallback( (evt: CanvasEvent) => { - if (!startEvtRef.current) { + const pts = currentPtsRef.current; + if (pts === undefined) { return; } - const { sourceEvent } = evt; - const { target, pointerId } = sourceEvent; - (target as Element).releasePointerCapture(pointerId); - - startEvtRef.current = undefined; - hasSuccessfullyEndedRef.current = shouldInteract(sourceEvent); - setRawSelection(undefined); + const nPts = pts.length; + const cPt = canvasBox.clampPoint(evt.htmlPt); + if (useNewPointRef.current) { + pts.push(cPt); + useNewPointRef.current = false; + } else { + pts[nPts - 1] = cPt; + } + setPoints([...pts]); }, - [setRawSelection, shouldInteract] + [canvasBox, setPoints] ); - useCanvasEvents({ onPointerDown, onPointerMove, onPointerUp }); + useCanvasEvents({ + onPointerDown: onPointerClick, + onPointerMove, + onPointerUp: onPointerClick, + }); useKeyboardEvent( 'Escape', () => { - startEvtRef.current = undefined; + currentPtsRef.current = undefined; setRawSelection(undefined); }, [], { event: 'keydown' } ); + useKeyboardEvent( + 'Enter', + () => { + if (isCompleteRef.current) { + hasSuccessfullyEndedRef.current = true; + currentPtsRef.current = undefined; + setRawSelection(undefined); + } + }, + [], + { event: 'keydown' } + ); + // Compute effective selection const selection = useMemo( () => rawSelection && transformRef.current(rawSelection, camera, context), @@ -155,10 +258,14 @@ function SelectionTool(props: Props) { ); // Determine if effective selection respects the minimum size threshold - const isValid = useMemo( - () => !!selection && validateRef.current(selection), - [selection, validateRef] - ); + const isValid = useMemo(() => { + const valid = !!selection && validateRef.current(selection); + if (valid && selection !== undefined) { + const nPts = selection?.html.length; + isCompleteRef.current = nPts >= minPoints; + } + return valid; + }, [isCompleteRef, minPoints, selection, validateRef]); // Keep track of previous effective selection and validity const prevSelection = usePrevious(selection); @@ -215,7 +322,9 @@ function SelectionTool(props: Props) { } assertDefined(rawSelection); - return <>{children(selection, rawSelection, isValid)}; + return ( + <>{children(selection, rawSelection, isValid, isCompleteRef.current)} + ); } export type { Props as SelectionToolProps }; diff --git a/packages/lib/src/interactions/box.ts b/packages/lib/src/interactions/box.ts index d23bc6ac1..569880e97 100644 --- a/packages/lib/src/interactions/box.ts +++ b/packages/lib/src/interactions/box.ts @@ -1,7 +1,7 @@ import { Box3, Vector3 } from 'three'; import type { Size } from '../vis/models'; -import type { Rect } from './models'; +import type { Points } from './models'; const ZERO_VECTOR = new Vector3(0, 0, 0); @@ -77,7 +77,7 @@ class Box extends Box3 { return this.translate(shift); } - public toRect(): Rect { + public toRect(): Points { return [this.min, this.max]; } } diff --git a/packages/lib/src/interactions/models.ts b/packages/lib/src/interactions/models.ts index 5612600a2..ed872ada0 100644 --- a/packages/lib/src/interactions/models.ts +++ b/packages/lib/src/interactions/models.ts @@ -8,11 +8,12 @@ export enum MouseButton { } export type Rect = [Vector3, Vector3]; +export type Points = Vector3[] | Rect; export interface Selection { - html: Rect; - world: Rect; - data: Rect; + html: Points; + world: Points; + data: Points; } export interface CanvasEvent { diff --git a/packages/lib/src/interactions/svg/SvgCircle.tsx b/packages/lib/src/interactions/svg/SvgCircle.tsx index 00be379b6..50486ad9d 100644 --- a/packages/lib/src/interactions/svg/SvgCircle.tsx +++ b/packages/lib/src/interactions/svg/SvgCircle.tsx @@ -1,9 +1,9 @@ import type { SVGProps } from 'react'; -import type { Rect } from '../models'; +import type { Points } from '../models'; interface Props extends SVGProps { - coords: Rect; + coords: Points; } function SvgCircle(props: Props) { diff --git a/packages/lib/src/interactions/svg/SvgLine.tsx b/packages/lib/src/interactions/svg/SvgLine.tsx index 9cffb17f4..4005e2996 100644 --- a/packages/lib/src/interactions/svg/SvgLine.tsx +++ b/packages/lib/src/interactions/svg/SvgLine.tsx @@ -1,9 +1,9 @@ import type { SVGProps } from 'react'; -import type { Rect } from '../models'; +import type { Points } from '../models'; interface Props extends SVGProps { - coords: Rect; + coords: Points; } function SvgLine(props: Props) { diff --git a/packages/lib/src/interactions/svg/SvgPolyline.tsx b/packages/lib/src/interactions/svg/SvgPolyline.tsx new file mode 100644 index 000000000..97e330a4c --- /dev/null +++ b/packages/lib/src/interactions/svg/SvgPolyline.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +import type { Points } from '../models'; + +export interface SvgPolylineProps extends SVGProps { + coords: Points; +} + +function SvgPolyline(props: SvgPolylineProps) { + const { coords, fill = 'none', ...svgProps } = props; + const pts = coords.map((c) => `${c.x},${c.y}`).join(' '); + + return ; +} + +export default SvgPolyline; diff --git a/packages/lib/src/interactions/svg/SvgRect.tsx b/packages/lib/src/interactions/svg/SvgRect.tsx index 47282a37d..0cf4eae33 100644 --- a/packages/lib/src/interactions/svg/SvgRect.tsx +++ b/packages/lib/src/interactions/svg/SvgRect.tsx @@ -1,9 +1,9 @@ import type { SVGProps } from 'react'; -import type { Rect } from '../models'; +import type { Points } from '../models'; interface Props extends SVGProps { - coords: Rect; + coords: Points; strokePosition?: 'inside' | 'outside'; // no effect without `stroke` prop; assumes `strokeWidth` of 1 unless specified explicitely as prop (CSS ignored) strokeWidth?: number; // forbid string }