From f4ecce41ff2c350fb61922a40e0b775731546391 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Mon, 20 May 2024 12:30:11 -0400 Subject: [PATCH] feat(app): create well selection component (#15207) creates ODD well selection component for use in quick transfer. implements basic source and destination quick transfer well selection. based on SelectableLabware component in protocol designer. closes PLAT-172, PLAT-175 --- app/src/assets/localization/en/shared.json | 1 + .../QuickTransferFlow/SelectDestWells.tsx | 61 +++++--- .../QuickTransferFlow/SelectSourceWells.tsx | 58 +++++-- .../organisms/WellSelection/SelectionRect.tsx | 119 ++++++++++++++ app/src/organisms/WellSelection/index.tsx | 147 ++++++++++++++++++ app/src/organisms/WellSelection/types.ts | 20 +++ app/src/organisms/WellSelection/utils.ts | 80 ++++++++++ .../hardware-sim/Labware/LabwareRender.tsx | 9 +- .../labwareInternals/StaticLabware.tsx | 40 +++-- .../Labware/labwareInternals/Well.tsx | 4 +- 10 files changed, 489 insertions(+), 50 deletions(-) create mode 100644 app/src/organisms/WellSelection/SelectionRect.tsx create mode 100644 app/src/organisms/WellSelection/index.tsx create mode 100644 app/src/organisms/WellSelection/types.ts create mode 100644 app/src/organisms/WellSelection/utils.ts diff --git a/app/src/assets/localization/en/shared.json b/app/src/assets/localization/en/shared.json index 899adfc5a31..996ed8326d2 100644 --- a/app/src/assets/localization/en/shared.json +++ b/app/src/assets/localization/en/shared.json @@ -54,6 +54,7 @@ "refresh": "refresh", "remember_my_selection_and_do_not_ask_again": "Remember my selection and don't ask again", "reset_all": "Reset all", + "reset": "Reset", "restart": "restart", "resume": "resume", "return": "return", diff --git a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx index 9ac3c3430ce..0182ead0f32 100644 --- a/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectDestWells.tsx @@ -1,8 +1,9 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Flex, SPACING } from '@opentrons/components' +import { Flex, POSITION_FIXED, SPACING } from '@opentrons/components' import { ChildNavigation } from '../ChildNavigation' +import { WellSelection } from '../../organisms/WellSelection' import type { SmallButton } from '../../atoms/buttons' import type { @@ -13,47 +14,69 @@ import type { interface SelectDestWellsProps { onNext: () => void onBack: () => void - exitButtonProps: React.ComponentProps state: QuickTransferWizardState dispatch: React.Dispatch } export function SelectDestWells(props: SelectDestWellsProps): JSX.Element { - const { onNext, onBack, exitButtonProps, state, dispatch } = props + const { onNext, onBack, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const destinationWells = state.destinationWells ?? [] + const destinationWellGroup = destinationWells.reduce((acc, well) => { + return { ...acc, [well]: null } + }, {}) + + const [selectedWells, setSelectedWells] = React.useState(destinationWellGroup) + const handleClickNext = (): void => { - // until well selection is implemented, select all wells and proceed to the next step - if (state.destination === 'source' && state.source != null) { - dispatch({ - type: 'SET_DEST_WELLS', - wells: Object.keys(state.source.wells), - }) - } else if (state.destination !== 'source' && state.destination != null) { - dispatch({ - type: 'SET_DEST_WELLS', - wells: Object.keys(state.destination.wells), - }) - } + dispatch({ + type: 'SET_DEST_WELLS', + wells: Object.keys(selectedWells), + }) onNext() } + + const resetButtonProps: React.ComponentProps = { + buttonType: 'tertiaryLowLight', + buttonText: t('shared:reset'), + onClick: () => { + setSelectedWells({}) + }, + } + return ( - + <> - TODO: Add destination well selection deck map + {state.destination != null && state.source != null ? ( + { + setSelectedWells(prevWells => ({ ...prevWells, ...wellGroup })) + }} + channels={state.pipette?.channels ?? 1} + /> + ) : null} - + ) } diff --git a/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx index 0c8b6aafd37..0a5edc181e5 100644 --- a/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx +++ b/app/src/organisms/QuickTransferFlow/SelectSourceWells.tsx @@ -1,8 +1,9 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Flex, SPACING } from '@opentrons/components' +import { Flex, POSITION_FIXED, SPACING } from '@opentrons/components' -import { ChildNavigation } from '../ChildNavigation' +import { ChildNavigation } from '../../organisms/ChildNavigation' +import { WellSelection } from '../../organisms/WellSelection' import type { SmallButton } from '../../atoms/buttons' import type { @@ -13,42 +14,67 @@ import type { interface SelectSourceWellsProps { onNext: () => void onBack: () => void - exitButtonProps: React.ComponentProps state: QuickTransferWizardState dispatch: React.Dispatch } export function SelectSourceWells(props: SelectSourceWellsProps): JSX.Element { - const { onNext, onBack, exitButtonProps, state, dispatch } = props + const { onNext, onBack, state, dispatch } = props const { i18n, t } = useTranslation(['quick_transfer', 'shared']) + const sourceWells = state.sourceWells ?? [] + const sourceWellGroup = sourceWells.reduce((acc, well) => { + return { ...acc, [well]: null } + }, {}) + + const [selectedWells, setSelectedWells] = React.useState(sourceWellGroup) + const handleClickNext = (): void => { - // until well selection is implemented, select all wells and proceed to the next step - if (state.source?.wells != null) { - dispatch({ - type: 'SET_SOURCE_WELLS', - wells: Object.keys(state.source.wells), - }) - onNext() - } + dispatch({ + type: 'SET_SOURCE_WELLS', + wells: Object.keys(selectedWells), + }) + onNext() } + + const resetButtonProps: React.ComponentProps = { + buttonType: 'tertiaryLowLight', + buttonText: t('shared:reset'), + onClick: () => { + setSelectedWells({}) + }, + } + return ( - + <> - TODO: Add source well selection deck map + {state.source != null ? ( + { + setSelectedWells(prevWells => ({ ...prevWells, ...wellGroup })) + }} + channels={state.pipette?.channels ?? 1} + /> + ) : null} - + ) } diff --git a/app/src/organisms/WellSelection/SelectionRect.tsx b/app/src/organisms/WellSelection/SelectionRect.tsx new file mode 100644 index 00000000000..35ac28f7930 --- /dev/null +++ b/app/src/organisms/WellSelection/SelectionRect.tsx @@ -0,0 +1,119 @@ +import * as React from 'react' +import { Flex, JUSTIFY_CENTER } from '@opentrons/components' + +import type { DragRect, GenericRect } from './types' + +interface SelectionRectProps { + onSelectionMove?: (rect: GenericRect) => void + onSelectionDone?: (rect: GenericRect) => void + children?: React.ReactNode +} + +export function SelectionRect(props: SelectionRectProps): JSX.Element { + const { onSelectionMove, onSelectionDone, children } = props + + const [positions, setPositions] = React.useState(null) + const parentRef = React.useRef(null) + + const getRect = (args: DragRect): GenericRect => { + const { xStart, yStart, xDynamic, yDynamic } = args + return { + x0: Math.min(xStart, xDynamic), + x1: Math.max(xStart, xDynamic), + y0: Math.min(yStart, yDynamic), + y1: Math.max(yStart, yDynamic), + } + } + + const handleDrag = React.useCallback( + (e: TouchEvent | MouseEvent): void => { + let xDynamic: number + let yDynamic: number + if (e instanceof TouchEvent) { + const touch = e.touches[0] + xDynamic = touch.clientX + yDynamic = touch.clientY + } else { + xDynamic = e.clientX + yDynamic = e.clientY + } + setPositions(prevPositions => { + if (prevPositions != null) { + const nextRect = { + ...prevPositions, + xDynamic, + yDynamic, + } + const rect = getRect(nextRect) + onSelectionMove != null && onSelectionMove(rect) + + return nextRect + } + return prevPositions + }) + }, + [onSelectionMove] + ) + + const handleDragEnd = React.useCallback( + (e: TouchEvent | MouseEvent): void => { + if (!(e instanceof TouchEvent) && !(e instanceof MouseEvent)) { + return + } + const finalRect = positions != null ? getRect(positions) : null + setPositions(prevPositions => { + return prevPositions === positions ? null : prevPositions + }) + // call onSelectionDone callback with {x0, x1, y0, y1} of final selection rectangle + onSelectionDone != null && finalRect != null && onSelectionDone(finalRect) + }, + [onSelectionDone, positions] + ) + + const handleTouchStart: React.TouchEventHandler = e => { + const touch = e.touches[0] + setPositions({ + xStart: touch.clientX, + xDynamic: touch.clientX, + yStart: touch.clientY, + yDynamic: touch.clientY, + }) + } + + const handleMouseDown: React.MouseEventHandler = e => { + setPositions({ + xStart: e.clientX, + xDynamic: e.clientX, + yStart: e.clientY, + yDynamic: e.clientY, + }) + } + + React.useEffect(() => { + document.addEventListener('touchmove', handleDrag) + document.addEventListener('touchend', handleDragEnd) + document.addEventListener('mousemove', handleDrag) + document.addEventListener('mouseup', handleDragEnd) + return () => { + document.removeEventListener('touchmove', handleDrag) + document.removeEventListener('touchend', handleDragEnd) + document.removeEventListener('mousemove', handleDrag) + document.removeEventListener('mouseup', handleDragEnd) + } + }, [handleDrag, handleDragEnd]) + + return ( + { + parentRef.current = ref + }} + justifyContent={JUSTIFY_CENTER} + width="100%" + > + {children} + + ) +} diff --git a/app/src/organisms/WellSelection/index.tsx b/app/src/organisms/WellSelection/index.tsx new file mode 100644 index 00000000000..7b20abae75c --- /dev/null +++ b/app/src/organisms/WellSelection/index.tsx @@ -0,0 +1,147 @@ +import * as React from 'react' +import reduce from 'lodash/reduce' + +import { + COLORS, + LabwareRender, + RobotCoordinateSpace, + WELL_LABEL_OPTIONS, +} from '@opentrons/components' +import { + arrayToWellGroup, + getCollidingWells, + getWellSetForMultichannel, +} from './utils' +import { SelectionRect } from './SelectionRect' + +import type { WellFill, WellGroup, WellStroke } from '@opentrons/components' +import type { + LabwareDefinition2, + PipetteChannels, +} from '@opentrons/shared-data' +import type { GenericRect } from './types' + +interface WellSelectionProps { + definition: LabwareDefinition2 + selectedPrimaryWells: WellGroup + selectWells: (wellGroup: WellGroup) => unknown + channels: PipetteChannels +} + +export function WellSelection(props: WellSelectionProps): JSX.Element { + const { definition, selectedPrimaryWells, selectWells, channels } = props + + const [highlightedWells, setHighlightedWells] = React.useState({}) + + const _wellsFromSelected: ( + selectedWells: WellGroup + ) => WellGroup = selectedWells => { + // Returns PRIMARY WELLS from the selection. + if (channels === 8 || channels === 96) { + // for the wells that have been highlighted, + // get all 8-well well sets and merge them + const primaryWells: WellGroup = reduce( + selectedWells, + (acc: WellGroup, _, wellName: string): WellGroup => { + const wellSet = getWellSetForMultichannel( + definition, + wellName, + channels + ) + if (!wellSet) return acc + return { ...acc, [wellSet[0]]: null } + }, + {} + ) + return primaryWells + } + + // single-channel or ingred selection mode + return selectedWells + } + + const _getWellsFromRect: (rect: GenericRect) => WellGroup = rect => { + const selectedWells = getCollidingWells(rect) + return _wellsFromSelected(selectedWells) + } + + const handleSelectionMove: (rect: GenericRect) => void = rect => { + if (channels === 8 || channels === 96) { + const selectedWells = _getWellsFromRect(rect) + const allWellsForMulti: WellGroup = reduce( + selectedWells, + (acc: WellGroup, _, wellName: string): WellGroup => { + const wellSetForMulti = + getWellSetForMultichannel(definition, wellName, channels) || [] + const channelWells = arrayToWellGroup(wellSetForMulti) + return { + ...acc, + ...channelWells, + } + }, + {} + ) + setHighlightedWells(allWellsForMulti) + } else { + setHighlightedWells(_getWellsFromRect(rect)) + } + } + + const handleSelectionDone: (rect: GenericRect) => void = rect => { + const wells = _wellsFromSelected(_getWellsFromRect(rect)) + + selectWells(wells) + setHighlightedWells({}) + } + + // For rendering, show all wells not just primary wells + const allSelectedWells = + channels === 8 || channels === 96 + ? reduce( + selectedPrimaryWells, + (acc, _, wellName): WellGroup => { + const wellSet = getWellSetForMultichannel( + definition, + wellName, + channels + ) + if (!wellSet) return acc + return { ...acc, ...arrayToWellGroup(wellSet) } + }, + {} + ) + : selectedPrimaryWells + + const wellFill: WellFill = {} + const wellStroke: WellStroke = {} + Object.keys(definition.wells).forEach(wellName => { + wellFill[wellName] = COLORS.blue35 + wellStroke[wellName] = COLORS.transparent + }) + Object.keys(allSelectedWells).forEach(wellName => { + wellFill[wellName] = COLORS.blue50 + wellStroke[wellName] = COLORS.transparent + }) + Object.keys(highlightedWells).forEach(wellName => { + wellFill[wellName] = COLORS.blue50 + wellStroke[wellName] = COLORS.transparent + }) + + return ( + + + + + + ) +} diff --git a/app/src/organisms/WellSelection/types.ts b/app/src/organisms/WellSelection/types.ts new file mode 100644 index 00000000000..6f8500b2194 --- /dev/null +++ b/app/src/organisms/WellSelection/types.ts @@ -0,0 +1,20 @@ +export interface DragRect { + xStart: number + yStart: number + xDynamic: number + yDynamic: number +} + +export interface GenericRect { + x0: number + x1: number + y0: number + y1: number +} + +export interface BoundingRect { + x: number + y: number + width: number + height: number +} diff --git a/app/src/organisms/WellSelection/utils.ts b/app/src/organisms/WellSelection/utils.ts new file mode 100644 index 00000000000..1fb5e66f678 --- /dev/null +++ b/app/src/organisms/WellSelection/utils.ts @@ -0,0 +1,80 @@ +import { + INTERACTIVE_WELL_DATA_ATTRIBUTE, + makeWellSetHelpers, +} from '@opentrons/shared-data' + +import type { WellGroup } from '@opentrons/components' +import type { WellSetHelpers } from '@opentrons/shared-data' +import type { BoundingRect, GenericRect } from './types' + +// Collision detection for SelectionRect / WellSelection +export const rectCollision = ( + rect1: BoundingRect, + rect2: BoundingRect +): boolean => + rect1.x < rect2.x + rect2.width && + rect1.x + rect1.width > rect2.x && + rect1.y < rect2.y + rect2.height && + rect1.height + rect1.y > rect2.y + +export function clientRectToBoundingRect(rect: ClientRect): BoundingRect { + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + } +} + +export const getCollidingWells = (rectPositions: GenericRect): WellGroup => { + // Returns set of selected wells under a collision rect + const { x0, y0, x1, y1 } = rectPositions + const selectionBoundingRect = { + x: Math.min(x0, x1), + y: Math.min(y0, y1), + width: Math.abs(x1 - x0), + height: Math.abs(y1 - y0), + } + // NOTE: querySelectorAll returns a NodeList, so you need to unpack it as an Array to do .filter + const selectableElems: HTMLElement[] = [ + ...document.querySelectorAll( + `[${INTERACTIVE_WELL_DATA_ATTRIBUTE}]` + ), + ] + const collidedElems = selectableElems.filter((selectableElem, i) => + rectCollision( + selectionBoundingRect, + clientRectToBoundingRect(selectableElem.getBoundingClientRect()) + ) + ) + const collidedWellData = collidedElems.reduce( + (acc: WellGroup, elem): WellGroup => { + if ( + INTERACTIVE_WELL_DATA_ATTRIBUTE.replace('data-', '') in elem.dataset + ) { + const wellName = elem.dataset.wellname + return wellName != null ? { ...acc, [wellName]: null } : acc + } + + return acc + }, + {} + ) + return collidedWellData +} + +export const arrayToWellGroup = (w: string[]): WellGroup => + w.reduce((acc, wellName) => ({ ...acc, [wellName]: null }), {}) + +// memoization of well set utils +const wellSetHelpers: WellSetHelpers = makeWellSetHelpers() +const { + canPipetteUseLabware, + getAllWellSetsForLabware, + getWellSetForMultichannel, +} = wellSetHelpers +export { + canPipetteUseLabware, + getAllWellSetsForLabware, + getWellSetForMultichannel, +} diff --git a/components/src/hardware-sim/Labware/LabwareRender.tsx b/components/src/hardware-sim/Labware/LabwareRender.tsx index 9b2365b668c..c3049464851 100644 --- a/components/src/hardware-sim/Labware/LabwareRender.tsx +++ b/components/src/hardware-sim/Labware/LabwareRender.tsx @@ -57,10 +57,14 @@ export interface LabwareRenderProps { onMouseLeaveWell?: (e: WellMouseEvent) => unknown gRef?: React.RefObject onLabwareClick?: () => void + /** Hide labware outline */ + hideOutline?: boolean + /** Provides well data attribute */ + isInteractive?: boolean } export const LabwareRender = (props: LabwareRenderProps): JSX.Element => { - const { gRef, definition } = props + const { gRef, definition, hideOutline, isInteractive } = props const cornerOffsetFromSlot = definition.cornerOffsetFromSlot const labwareLoadName = definition.parameters.loadName @@ -94,12 +98,15 @@ export const LabwareRender = (props: LabwareRenderProps): JSX.Element => { transform={`translate(${cornerOffsetFromSlot.x}, ${cornerOffsetFromSlot.y})`} ref={gRef} > + {/* TODO(bh, 2024-05-13): refactor rendering of wells - multiple layers of styled wells, DOM ordering determines which are visible */} {props.wellStroke != null ? ( unknown /** Optional callback to be executed when mouse leaves a well element */ onMouseLeaveWell?: (e: WellMouseEvent) => unknown + /** Provides well data attribute */ + isInteractive?: boolean } const TipDecoration = React.memo(function TipDecoration(props: { @@ -49,34 +53,44 @@ const LabwareDetailGroup = styled.g` ` export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element { - const { isTiprack } = props.definition.parameters + const { + definition, + hideOutline = false, + highlight, + isInteractive, + onLabwareClick, + onMouseEnterWell, + onMouseLeaveWell, + } = props + + const { isTiprack } = definition.parameters return ( - - - - + + {!hideOutline ? ( + + + + ) : null} {flatMap( - props.definition.ordering, + definition.ordering, (row: string[], i: number, c: string[][]) => { return row.map(wellName => { return ( {isTiprack ? ( - + ) : null} ) diff --git a/components/src/hardware-sim/Labware/labwareInternals/Well.tsx b/components/src/hardware-sim/Labware/labwareInternals/Well.tsx index 53d3dcdf688..5a7d8760646 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/Well.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/Well.tsx @@ -16,6 +16,8 @@ export interface WellProps extends StyleProps { /** Optional callback, called with WellMouseEvent args onMouseOver */ onMouseEnterWell?: (e: WellMouseEvent) => unknown onMouseLeaveWell?: (e: WellMouseEvent) => unknown + /** Provides well data attribute */ + isInteractive?: boolean } export function WellComponent(props: WellProps): JSX.Element { @@ -27,10 +29,10 @@ export function WellComponent(props: WellProps): JSX.Element { fill = COLORS.white, onMouseEnterWell, onMouseLeaveWell, + isInteractive = onMouseEnterWell != null || onMouseLeaveWell != null, } = props const { x, y } = well - const isInteractive = onMouseEnterWell != null || onMouseLeaveWell != null const pointerEvents: React.CSSProperties['pointerEvents'] = isInteractive ? 'auto' : 'none'