diff --git a/protocol-designer/src/assets/images/tip_side_bottom_layer.svg b/protocol-designer/src/assets/images/tip_side_bottom_layer.svg new file mode 100644 index 000000000000..717282ed9514 --- /dev/null +++ b/protocol-designer/src/assets/images/tip_side_bottom_layer.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_side_mid_layer.svg b/protocol-designer/src/assets/images/tip_side_mid_layer.svg new file mode 100644 index 000000000000..097f163b705f --- /dev/null +++ b/protocol-designer/src/assets/images/tip_side_mid_layer.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_side_top_layer.svg b/protocol-designer/src/assets/images/tip_side_top_layer.svg new file mode 100644 index 000000000000..adf16b116917 --- /dev/null +++ b/protocol-designer/src/assets/images/tip_side_top_layer.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_top_bottom_layer.svg b/protocol-designer/src/assets/images/tip_top_bottom_layer.svg new file mode 100644 index 000000000000..9def3aa184df --- /dev/null +++ b/protocol-designer/src/assets/images/tip_top_bottom_layer.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_top_mid_layer.svg b/protocol-designer/src/assets/images/tip_top_mid_layer.svg new file mode 100644 index 000000000000..7e97da22d380 --- /dev/null +++ b/protocol-designer/src/assets/images/tip_top_mid_layer.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/images/tip_top_top_layer.svg b/protocol-designer/src/assets/images/tip_top_top_layer.svg new file mode 100644 index 000000000000..d5c7edbbc9fc --- /dev/null +++ b/protocol-designer/src/assets/images/tip_top_top_layer.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/protocol-designer/src/assets/localization/en/modal.json b/protocol-designer/src/assets/localization/en/modal.json index ebf4e0d9b804..c7d749f57537 100644 --- a/protocol-designer/src/assets/localization/en/modal.json +++ b/protocol-designer/src/assets/localization/en/modal.json @@ -86,7 +86,7 @@ }, "body": { "blowout_z_offset": "Change from where in the well the robot emits blowout", - "aspirate_mmFromBottom": "Change from where in the well the robot aspirates", + "aspirate_mmFromBottom": "Change where in the well the robot aspirates from.", "dispense_mmFromBottom": "Change from where in the well the robot dispenses", "mix_mmFromBottom": "Change from where in the well the robot aspirates and dispenses during the mix", "aspirate_touchTip_mmFromBottom": "Change from where in the well the robot performs touch tip", diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 8151c1e32704..4b8c40c6e5b7 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -107,6 +107,7 @@ "reject": "Reject", "reset_hints_and_tips": "Reset all hints and tips notifications", "reset_hints": "Reset hints", + "reset_to_default": "Reset to default", "review_our_privacy_policy": "You can adjust this setting at any time by clicking on the settings icon. Find detailed information in our privacy policy.", "right": "Right", "save": "Save", @@ -121,6 +122,7 @@ "step_count": "Step {{current}}", "step": "Step {{current}} / {{max}}", "consent_to_eula": "By using Protocol Designer, you consent to the Opentrons EULA.", + "swap_view": "Swap view", "temperaturemoduletype": "Temperature Module", "thermocyclermoduletype": "Thermocycler Module", "trashBin": "Trash Bin", diff --git a/protocol-designer/src/organisms/TipPositionModal/TipPositionSideView.tsx b/protocol-designer/src/organisms/TipPositionModal/TipPositionSideView.tsx new file mode 100644 index 000000000000..9be4fc6e2719 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/TipPositionSideView.tsx @@ -0,0 +1,77 @@ +import round from 'lodash/round' +import { useTranslation } from 'react-i18next' +import { + Box, + COLORS, + OVERFLOW_HIDDEN, + POSITION_ABSOLUTE, + POSITION_RELATIVE, + StyledText, +} from '@opentrons/components' +import BOTTOM_LAYER from '../../assets/images/tip_side_bottom_layer.svg' +import MID_LAYER from '../../assets/images/tip_side_mid_layer.svg' +import TOP_LAYER from '../../assets/images/tip_side_top_layer.svg' + +const WELL_HEIGHT_PIXELS = 71 +const WELL_WIDTH_PIXELS = 70 +const PIXEL_DECIMALS = 2 + +interface TipPositionAllVizProps { + mmFromBottom: number + xPosition: number + wellDepthMm: number + xWidthMm: number +} + +export function TipPositionSideView( + props: TipPositionAllVizProps +): JSX.Element { + const { mmFromBottom, xPosition, wellDepthMm, xWidthMm } = props + const { t } = useTranslation('application') + const fractionOfWellHeight = mmFromBottom / wellDepthMm + const pixelsFromBottom = + Number(fractionOfWellHeight) * WELL_HEIGHT_PIXELS - WELL_HEIGHT_PIXELS + const roundedPixelsFromBottom = round(pixelsFromBottom, PIXEL_DECIMALS) + const bottomPx = wellDepthMm + ? roundedPixelsFromBottom * 2 + : mmFromBottom - WELL_HEIGHT_PIXELS + + const xPx = (WELL_WIDTH_PIXELS / xWidthMm) * xPosition + const roundedXPx = round(xPx, PIXEL_DECIMALS) + + return ( + + + + + {wellDepthMm !== null && ( + + + {round(wellDepthMm, 0)} + {t('units.millimeter')} + + + )} + {xWidthMm !== null && ( + + + {xWidthMm} + {t('units.millimeter')} + + + )} + + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/TipPositionTopView.tsx b/protocol-designer/src/organisms/TipPositionModal/TipPositionTopView.tsx new file mode 100644 index 000000000000..4235aedcce42 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/TipPositionTopView.tsx @@ -0,0 +1,61 @@ +import round from 'lodash/round' +import { useTranslation } from 'react-i18next' +import { + Box, + COLORS, + OVERFLOW_HIDDEN, + POSITION_ABSOLUTE, + POSITION_RELATIVE, + StyledText, +} from '@opentrons/components' +import BOTTOM_LAYER from '../../assets/images/tip_top_bottom_layer.svg' +import MID_LAYER from '../../assets/images/tip_top_mid_layer.svg' +import TOP_LAYER from '../../assets/images/tip_top_top_layer.svg' + +const WELL_WIDTH_PIXELS = 70 +const PIXEL_DECIMALS = 2 + +interface TipPositionAllVizProps { + xPosition: number + xWidthMm: number + yPosition: number + yWidthMm: number +} + +export function TipPositionTopView(props: TipPositionAllVizProps): JSX.Element { + const { yPosition, xPosition, yWidthMm, xWidthMm } = props + const { t } = useTranslation('application') + + const xPx = (WELL_WIDTH_PIXELS / xWidthMm) * xPosition + const yPx = (WELL_WIDTH_PIXELS / yWidthMm) * yPosition + + const roundedXPx = round(xPx, PIXEL_DECIMALS) + const roundedYPx = round(yPx, PIXEL_DECIMALS) + const translateY = roundedYPx < 0 ? Math.abs(roundedYPx) : -roundedYPx + return ( + + + + + {xWidthMm !== null && ( + + + {xWidthMm} + {t('units.millimeter')} + + + )} + + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/constants.ts b/protocol-designer/src/organisms/TipPositionModal/constants.ts new file mode 100644 index 000000000000..528d9a0262e8 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/constants.ts @@ -0,0 +1,5 @@ +export const DECIMALS_ALLOWED = 1 +export const SMALL_STEP_MM = 1 +export const LARGE_STEP_MM = 10 +export const TOO_MANY_DECIMALS: 'TOO_MANY_DECIMALS' = 'TOO_MANY_DECIMALS' +export const PERCENT_RANGE_TO_SHOW_WARNING = 0.9 diff --git a/protocol-designer/src/organisms/TipPositionModal/index.tsx b/protocol-designer/src/organisms/TipPositionModal/index.tsx new file mode 100644 index 000000000000..16656190e6f6 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/index.tsx @@ -0,0 +1,360 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { + DIRECTION_COLUMN, + Flex, + SPACING, + Modal, + JUSTIFY_SPACE_BETWEEN, + ALIGN_CENTER, + Btn, + JUSTIFY_END, + SecondaryButton, + PrimaryButton, + StyledText, + Banner, + InputField, + TYPOGRAPHY, +} from '@opentrons/components' +import { getMainPagePortalEl } from '../../components/portals/MainPageModalPortal' +import { getIsTouchTipField } from '../../form-types' +import { BUTTON_LINK_STYLE } from '../../atoms' +import { TOO_MANY_DECIMALS, PERCENT_RANGE_TO_SHOW_WARNING } from './constants' +import * as utils from './utils' +import { TipPositionTopView } from './TipPositionTopView' +import { TipPositionSideView } from './TipPositionSideView' + +import type { StepFieldName } from '../../form-types' + +type Offset = 'x' | 'y' | 'z' +interface PositionSpec { + name: StepFieldName + value: number | null + updateValue: (val?: number | null) => void +} +export type PositionSpecs = Record + +interface TipPositionModalProps { + closeModal: () => void + specs: PositionSpecs + wellDepthMm: number + wellXWidthMm: number + wellYWidthMm: number + isIndeterminate?: boolean +} + +export const TipPositionModal = ( + props: TipPositionModalProps +): JSX.Element | null => { + const { + isIndeterminate, + specs, + wellDepthMm, + wellXWidthMm, + wellYWidthMm, + closeModal, + } = props + const { t } = useTranslation([ + 'modal', + 'button', + 'tooltip', + 'shared', + 'application', + ]) + const [view, setView] = React.useState<'top' | 'side'>('side') + const zSpec = specs.z + const ySpec = specs.y + const xSpec = specs.x + + if (zSpec == null || xSpec == null || ySpec == null) { + console.error( + 'expected to find specs for one of the positions but could not' + ) + } + + const defaultMmFromBottom = utils.getDefaultMmFromBottom({ + name: zSpec.name, + wellDepthMm, + }) + + const [zValue, setZValue] = React.useState( + zSpec?.value == null ? String(defaultMmFromBottom) : String(zSpec?.value) + ) + const [yValue, setYValue] = React.useState( + ySpec?.value == null ? null : String(ySpec?.value) + ) + const [xValue, setXValue] = React.useState( + xSpec?.value == null ? null : String(xSpec?.value) + ) + + // in this modal, pristinity hides the OUT_OF_BOUNDS error only. + const [isPristine, setPristine] = React.useState(true) + const getMinMaxMmFromBottom = (): { + maxMmFromBottom: number + minMmFromBottom: number + } => { + if (getIsTouchTipField(zSpec?.name ?? '')) { + return { + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: utils.roundValue(wellDepthMm / 2, 'up'), + } + } + return { + maxMmFromBottom: utils.roundValue(wellDepthMm, 'up'), + minMmFromBottom: 0, + } + } + + const { maxMmFromBottom, minMmFromBottom } = getMinMaxMmFromBottom() + const { minValue: yMinWidth, maxValue: yMaxWidth } = utils.getMinMaxWidth( + wellYWidthMm + ) + const { minValue: xMinWidth, maxValue: xMaxWidth } = utils.getMinMaxWidth( + wellXWidthMm + ) + + const createErrors = ( + value: string | null, + min: number, + max: number + ): utils.Error[] => { + return utils.getErrors({ minMm: min, maxMm: max, value }) + } + const zErrors = createErrors(zValue, minMmFromBottom, maxMmFromBottom) + const xErrors = createErrors(xValue, xMinWidth, xMaxWidth) + const yErrors = createErrors(yValue, yMinWidth, yMaxWidth) + + const hasErrors = + zErrors.length > 0 || xErrors.length > 0 || yErrors.length > 0 + const hasVisibleErrors = isPristine + ? zErrors.includes(TOO_MANY_DECIMALS) || + xErrors.includes(TOO_MANY_DECIMALS) || + yErrors.includes(TOO_MANY_DECIMALS) + : hasErrors + + const createErrorText = ( + errors: utils.Error[], + min: number, + max: number + ): string | null => { + return utils.getErrorText({ errors, minMm: min, maxMm: max, isPristine, t }) + } + + const roundedXMin = utils.roundValue(xMinWidth, 'up') + const roundedYMin = utils.roundValue(yMinWidth, 'up') + const roundedXMax = utils.roundValue(xMaxWidth, 'down') + const roundedYMax = utils.roundValue(yMaxWidth, 'down') + + const zErrorText = createErrorText(zErrors, minMmFromBottom, maxMmFromBottom) + const xErrorText = createErrorText(xErrors, roundedXMin, roundedXMax) + const yErrorText = createErrorText(yErrors, roundedYMin, roundedYMax) + + const handleDone = (): void => { + if (!hasErrors) { + zSpec?.updateValue(zValue === null ? null : Number(zValue)) + xSpec?.updateValue(xValue === null ? null : Number(xValue)) + ySpec?.updateValue(yValue === null ? null : Number(yValue)) + closeModal() + } + } + + const handleCancel = (): void => { + closeModal() + } + + const handleZChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^.0-9]/, '') + : String(newValueRaw) + + if (newValue === '.') { + setZValue('0.') + } else { + setZValue(Number(newValue) >= 0 ? newValue : '0') + } + } + + const handleZInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleZChange(e.currentTarget.value) + setPristine(false) + } + + const handleXChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/g, '') + : String(newValueRaw) + + if (newValue === '.') { + setXValue('0.') + } else { + setXValue(newValue) + } + } + + const handleXInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleXChange(e.currentTarget.value) + setPristine(false) + } + + const handleYChange = (newValueRaw: string | number): void => { + // if string, strip non-number characters from string and cast to number + const newValue = + typeof newValueRaw === 'string' + ? newValueRaw.replace(/[^-.0-9]/g, '') + : String(newValueRaw) + + if (newValue === '.') { + setYValue('0.') + } else { + setYValue(newValue) + } + } + + const handleYInputFieldChange = ( + e: React.ChangeEvent + ): void => { + handleYChange(e.currentTarget.value) + setPristine(false) + } + const isXValueNearEdge = + xValue != null && + (parseInt(xValue) > PERCENT_RANGE_TO_SHOW_WARNING * xMaxWidth || + parseInt(xValue) < PERCENT_RANGE_TO_SHOW_WARNING * xMinWidth) + const isYValueNearEdge = + yValue != null && + (parseInt(yValue) > PERCENT_RANGE_TO_SHOW_WARNING * yMaxWidth || + parseInt(yValue) < PERCENT_RANGE_TO_SHOW_WARNING * yMinWidth) + const isZValueAtBottom = zValue != null && zValue === '0' + + return createPortal( + + { + setXValue('0') + setYValue('0') + setZValue('1') + }} + css={BUTTON_LINK_STYLE} + > + {t('shared:reset_to_default')} + + + + {t('shared:cancel')} + + + {t('shared:save')} + + + + } + > + {isXValueNearEdge || isYValueNearEdge || isZValueAtBottom ? ( + + + {t('tip_position.warning')} + + + ) : null} + + + + {t(`tip_position.body.${zSpec?.name}`)} + + + + + + + + + {view === 'side' ? 'Side view' : 'Top view'} + + { + setView(view === 'side' ? 'top' : 'side') + }} + > + {t('shared:swap_view')} + + + {view === 'side' ? ( + + ) : ( + + )} + + + , + getMainPagePortalEl() + ) +} diff --git a/protocol-designer/src/organisms/TipPositionModal/utils.tsx b/protocol-designer/src/organisms/TipPositionModal/utils.tsx new file mode 100644 index 000000000000..b5fbb377eea1 --- /dev/null +++ b/protocol-designer/src/organisms/TipPositionModal/utils.tsx @@ -0,0 +1,125 @@ +import floor from 'lodash/floor' +import round from 'lodash/round' +import { getIsTouchTipField } from '../../form-types' +import { + DEFAULT_MM_FROM_BOTTOM_ASPIRATE, + DEFAULT_MM_FROM_BOTTOM_DISPENSE, + DEFAULT_MM_TOUCH_TIP_OFFSET_FROM_TOP, +} from '../../constants' +import { DECIMALS_ALLOWED, TOO_MANY_DECIMALS } from './constants' +import type { StepFieldName } from '../../form-types' + +// TODO: Ian + Brian 2019-02-13 this should switch on stepType, not use field +// name to infer step type! +// +// TODO(IL, 2021-03-10): after resolving #7470, use this util instead +// of directly using these constants, wherever these constants are used. See also #7469 +export function getDefaultMmFromBottom(args: { + name: StepFieldName + wellDepthMm: number +}): number { + const { name, wellDepthMm } = args + + switch (name) { + case 'aspirate_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_ASPIRATE + + case 'aspirate_delay_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_ASPIRATE + + case 'dispense_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_DISPENSE + + case 'dispense_delay_mmFromBottom': + return DEFAULT_MM_FROM_BOTTOM_DISPENSE + + case 'mix_mmFromBottom': + // TODO: Ian 2018-11-131 figure out what offset makes most sense for mix + return DEFAULT_MM_FROM_BOTTOM_DISPENSE + + default: + // touch tip fields + console.assert( + getIsTouchTipField(name), + `getDefaultMmFromBottom fn does not know what to do with field ${name}` + ) + return DEFAULT_MM_TOUCH_TIP_OFFSET_FROM_TOP + wellDepthMm + } +} + +export const roundValue = ( + value: number | string | null, + direction: 'up' | 'down' +): number => { + if (value === null) return 0 + + switch (direction) { + case 'up': { + return round(Number(value), DECIMALS_ALLOWED) + } + case 'down': { + return floor(Number(value), DECIMALS_ALLOWED) + } + } +} + +const OUT_OF_BOUNDS: 'OUT_OF_BOUNDS' = 'OUT_OF_BOUNDS' +export type Error = typeof TOO_MANY_DECIMALS | typeof OUT_OF_BOUNDS + +export const getErrorText = (args: { + errors: Error[] + maxMm: number + minMm: number + isPristine: boolean + t: any +}): string | null => { + const { errors, minMm, maxMm, isPristine, t } = args + + if (errors.includes(TOO_MANY_DECIMALS)) { + return t('tip_position.errors.TOO_MANY_DECIMALS') + } else if (!isPristine && errors.includes(OUT_OF_BOUNDS)) { + return t('tip_position.errors.OUT_OF_BOUNDS', { + minMm, + maxMm, + }) + } else { + return null + } +} + +export const getErrors = (args: { + value: string | null + maxMm: number + minMm: number +}): Error[] => { + const { value: rawValue, maxMm, minMm } = args + const errors: Error[] = [] + + const value = Number(rawValue) + if (rawValue === null || Number.isNaN(value)) { + // blank or otherwise invalid should show this error as a fallback + return [OUT_OF_BOUNDS] + } + const incorrectDecimals = round(value, DECIMALS_ALLOWED) !== value + const outOfBounds = value > maxMm || value < minMm +console.log(outOfBounds) + if (incorrectDecimals) { + errors.push(TOO_MANY_DECIMALS) + } + if (outOfBounds) { + errors.push(OUT_OF_BOUNDS) + } + return errors +} + +interface MinMaxValues { + minValue: number + maxValue: number +} + +export const getMinMaxWidth = (width: number): MinMaxValues => { + return { + minValue: -width * 0.5, + maxValue: width * 0.5, + } +} diff --git a/protocol-designer/src/organisms/index.ts b/protocol-designer/src/organisms/index.ts index cda71137c57e..f01b0f43cfe5 100644 --- a/protocol-designer/src/organisms/index.ts +++ b/protocol-designer/src/organisms/index.ts @@ -15,3 +15,4 @@ export * from './ProtocolMetadataNav' export * from './SelectWellsModal' export * from './SlotDetailsContainer' export * from './SlotInformation' +export * from './TipPositionModal' diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx index 5023dca0a3f6..0708edb2170e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/PipetteFields/PositionField.tsx @@ -13,18 +13,19 @@ import { useHoverTooltip, } from '@opentrons/components' import { getWellsDepth, getWellDimension } from '@opentrons/shared-data' +import { TipPositionModal } from '../../../../../organisms' import { getIsDelayPositionField } from '../../../../../form-types' +import { getDefaultMmFromBottom } from '../../../../../organisms/TipPositionModal/utils' import { selectors as stepFormSelectors } from '../../../../../step-forms' -import { TipPositionModal } from '../../../../../components/StepEditForm/fields/TipPositionField/TipPositionModal' -import { getDefaultMmFromBottom } from '../../../../../components/StepEditForm/fields/TipPositionField/utils' import { ZTipPositionModal } from '../../../../../components/StepEditForm/fields/TipPositionField/ZTipPositionModal' import type { TipXOffsetFields, TipYOffsetFields, TipZOffsetFields, } from '../../../../../form-types' +import type { PositionSpecs } from '../../../../../organisms' + import type { FieldPropsByName } from '../types' -import type { PositionSpecs } from '../../../../../components/StepEditForm/fields/TipPositionField/TipPositionModal' interface PositionFieldProps { prefix: 'aspirate' | 'dispense' | 'mix' propsForFields: FieldPropsByName @@ -91,6 +92,7 @@ export function PositionField(props: PositionFieldProps): JSX.Element { } const isDelayPositionField = getIsDelayPositionField(zName) let zValue: string | number = '0' + const mmFromBottom = typeof rawZValue === 'number' ? rawZValue : null if (wellDepthMm !== null) { // show default value for field in parens if no mmFromBottom value is selected