Skip to content

feat(protocol-designer): introduce byVolume curve builder prototype #19114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: edge
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions protocol-designer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"lodash": "4.17.21",
"mixpanel-browser": "2.22.1",
"query-string": "6.2.0",
"plotly.js": "3.1.0-rc.0",
"react": "18.2.0",
"react-color": "2.19.3",
"react-dnd": "16.0.1",
Expand All @@ -49,6 +50,7 @@
"react-error-boundary": "^4.0.10",
"react-hook-form": "7.49.3",
"react-i18next": "14.0.0",
"react-plotly.js": "2.6.0",
"react-redux": "8.1.2",
"redux": "5.0.1",
"redux-actions": "2.2.1",
Expand All @@ -68,6 +70,7 @@
"postcss-loader": "^4.0.4",
"postcss-preset-env": "9.3.0",
"postcss-color-mod-function": "3.0.3",
"victory": "37.1.1",
"yup": "1.3.3"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"add_point": "Add point",
"calculator": {
"calculate": "Calculate",
"interpolated_value": "Interpolated {{type}}",
"volume_to_calculate": "Input volume"
},
"flowRate": {
"axes": {
"x": {
"label": "Volume ({{units}})",
"units": "µl"
},
"y": {
"label": "Flow Rate ({{units}})",
"units": "µl/s"
}
},
"title": "Flow Rate Builder"
},
"instructions": "Drag the points to adjust the curve.",
"reset": "Reset curve",
"type": {
"flowRate": "flow rate"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,9 @@
"OT_PD_ENABLE_JSON_EXPORT": {
"title": "Enable exporting JSON",
"description": "Enables the ability to export JSON for internal usages only"
},
"OT_PD_ENABLE_BY_VOLUME_BUILDER": {
"title": "Enable by-volume builder",
"description": "Enables the ability to build by-volume curves"
}
}
2 changes: 2 additions & 0 deletions protocol-designer/src/assets/localization/en/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import alert from './alert.json'
import application from './application.json'
import button from './button.json'
import by_volume_builder from './by_volume_builder.json'
import card from './card.json'
import context_menu from './context_menu.json'
import deck_configuration from './deck_configuration.json'
Expand All @@ -23,6 +24,7 @@ export const en = {
alert,
application,
button,
by_volume_builder,
card,
context_menu,
deck,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"edit_step": "Edit step",
"ending_deck": "Ending deck",
"engage_height": "Engage height",
"flow_rate_builder": "Flow rate builder",
"flow_type_title": "{{type}} flow rate",
"from": "from",
"heater_shaker": {
Expand Down
2 changes: 2 additions & 0 deletions protocol-designer/src/feature-flags/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const initialFlags: Flags = {
OT_PD_ENABLE_STACKING: process.env.OT_PD_ENABLE_STACKING === '1' || false,
OT_PD_ENABLE_JSON_EXPORT:
process.env.OT_PD_ENABLE_JSON_EXPORT === '1' || false,
OT_PD_ENABLE_BY_VOLUME_BUILDER:
process.env.OT_PD_ENABLE_BY_VOLUME_BUILDER === '1' || false,
}
// @ts-expect-error(sa, 2021-6-10): cannot use string literals as action type
// TODO IMMEDIATELY: refactor this to the old fashioned way if we cannot have type safety: https://github.com/redux-utilities/redux-actions/issues/282#issuecomment-595163081
Expand Down
4 changes: 4 additions & 0 deletions protocol-designer/src/feature-flags/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,7 @@ export const getEnableJsonExport: Selector<boolean> = createSelector(
getFeatureFlagData,
flags => flags.OT_PD_ENABLE_JSON_EXPORT ?? false
)
export const getEnableByVolumeBuilder: Selector<boolean> = createSelector(
getFeatureFlagData,
flags => flags.OT_PD_ENABLE_BY_VOLUME_BUILDER ?? false
)
2 changes: 2 additions & 0 deletions protocol-designer/src/feature-flags/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type FlagTypes =
| 'OT_PD_ENABLE_STACKING'
// this feature is for internal purposes, users should never export JSON
| 'OT_PD_ENABLE_JSON_EXPORT'
| 'OT_PD_ENABLE_BY_VOLUME_BUILDER'
// flags that are not in this list only show in prerelease mode
export const userFacingFlags: FlagTypes[] = [
'OT_PD_DISABLE_MODULE_RESTRICTIONS',
Expand All @@ -61,5 +62,6 @@ export const allFlags: FlagTypes[] = [
'OT_PD_ENABLE_PARTIAL_TIP_SUPPORT',
'OT_PD_ENABLE_STACKING',
'OT_PD_ENABLE_JSON_EXPORT',
'OT_PD_ENABLE_BY_VOLUME_BUILDER',
]
export type Flags = Partial<Record<FlagTypes, boolean | null | undefined>>
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useTranslation } from 'react-i18next'
import Plot from 'react-plotly.js'

import {
AXIS_OFFSET_PERCENTAGE,
BASE_DATA,
BASE_LAYOUT,
CONFIG,
} from './constants'
import { getAnnotations, getShapes } from './utils'

import type { LiquidHandlingPropertyByVolume } from '@opentrons/shared-data'
import type { ByVolumeType, DataPoint } from './types'

export function ByVolumeBuilder(props: {
type: ByVolumeType
dataPoints: DataPoint[]
setDataPoints: (dataPoints: DataPoint[]) => void
byVolume: LiquidHandlingPropertyByVolume
maxX: number
maxY: number
}): JSX.Element {
const { type, dataPoints, setDataPoints, maxX, maxY } = props

const { t } = useTranslation(['by_volume_builder'])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const { t } = useTranslation(['by_volume_builder'])
const { t } = useTranslation('by_volume_builder')


const handleRelayout = (eventData: any): void => {
const updatedPoints = [...dataPoints]
let changed = false

// Handle shape-based editing (when shapes are moved)
for (let i = 0; i < updatedPoints.length; i++) {
const shapeX0Key = `shapes[${i}].x0`
const shapeY0Key = `shapes[${i}].y0`
const shapeX1Key = `shapes[${i}].x1`
const shapeY1Key = `shapes[${i}].y1`

if (
eventData[shapeX0Key] !== undefined &&
eventData[shapeY0Key] !== undefined &&
eventData[shapeX1Key] !== undefined &&
eventData[shapeY1Key] !== undefined
) {
// Calculate center point from shape bounds
const newX = (eventData[shapeX0Key] + eventData[shapeX1Key]) / 2
const newY = (eventData[shapeY0Key] + eventData[shapeY1Key]) / 2

if (updatedPoints[i].x !== newX || updatedPoints[i].y !== newY) {
updatedPoints[i] = {
...updatedPoints[i],
x: Math.min(Math.max(newX, 0), maxX),
y: Math.min(Math.max(newY, 0), maxY),
}
changed = true
}
}
}

// Handle data-based editing (when data points are moved directly)
const xEventData = eventData['data[0].x']
const yEventData = eventData['data[0].y']
if (xEventData != null && yEventData != null) {
const newXValues = xEventData as number[]
const newYValues = yEventData as number[]

for (let i = 0; i < updatedPoints.length; i++) {
const newX = newXValues[i]
const newY = newYValues[i]

if (updatedPoints[i].x !== newX || updatedPoints[i].y !== newY) {
updatedPoints[i] = {
...updatedPoints[i],
x: Math.max(newX, 0),
y: Math.max(newY, 0),
}
changed = true
}
}
}

if (changed) {
const sortedPoints = updatedPoints.sort((a, b) => a.x - b.x)
setDataPoints(sortedPoints)
}
}
const axisOffsetX = maxX * AXIS_OFFSET_PERCENTAGE
const axisOffsetY = maxY * AXIS_OFFSET_PERCENTAGE
const axisRangeX = maxX + 2 * axisOffsetX
const axisRangeY = maxY + 2 * axisOffsetY
return (
<div>
<Plot
data={[
{
...BASE_DATA,
// ensure the curve starts at 0 and ends at maxVolume
x: [0, ...dataPoints.map(p => p.x), maxX],
y: [
dataPoints[0].y,
...dataPoints.map(p => p.y),
dataPoints[dataPoints.length - 1].y,
],
},
]}
layout={{
...BASE_LAYOUT,
title: {
text: t(`by_volume_builder:instructions`),
xanchor: 'right',
},
xaxis: {
title: {
text: t(`by_volume_builder:${type}.axes.x.label`, {
units: t(`by_volume_builder:${type}.axes.x.units`),
}),
editable: false,
},
range: [-1 * axisOffsetX, maxX + axisOffsetX],
},
yaxis: {
title: {
text: t(`by_volume_builder:${type}.axes.y.label`, {
units: t(`by_volume_builder:${type}.axes.y.units`),
}),
editable: false,
},
range: [-1 * axisOffsetY, maxY + axisOffsetY],
},
shapes: getShapes(dataPoints, axisRangeX, axisRangeY),
annotations: getAnnotations(dataPoints),
}}
config={CONFIG}
onRelayout={handleRelayout}
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'

import {
DIRECTION_COLUMN,
Flex,
FLEX_MIN_CONTENT,
JUSTIFY_FLEX_END,
Modal,
PrimaryButton,
SecondaryButton,
SPACING,
} from '@opentrons/components'
import { linearInterpolate } from '@opentrons/shared-data'

import { getMainPagePortalEl } from '../../../../../../components/organisms'
import { ByVolumeBuilder } from './ByVolumeBuilder'
import { ByVolumeCalculator } from './ByVolumeCalculator'
import { getByVolumeMappedToXY } from './utils'

import type { Dispatch, SetStateAction } from 'react'
import type { LiquidHandlingPropertyByVolume } from '@opentrons/shared-data'
import type { ByVolumeType } from './types'

export function ByVolumeBuilderModal(props: {
byVolume: LiquidHandlingPropertyByVolume
setByVolume: Dispatch<SetStateAction<LiquidHandlingPropertyByVolume>>
type: ByVolumeType
onClose: () => void
defaultFlowRates: LiquidHandlingPropertyByVolume
maxX: number
maxY: number
}): JSX.Element {
const {
byVolume = [],
type,
onClose,
setByVolume,
defaultFlowRates,
maxX,
maxY,
} = props

const { t } = useTranslation(['shared', 'by_volume_builder'])

const defaultDataPoints = getByVolumeMappedToXY(byVolume)
const [dataPoints, setDataPoints] = useState(defaultDataPoints)

// adds a new point to the center x value at the interpolated y value
const handleAddPoint = (): void => {
const newXValue = maxX / 2
const newYValue = linearInterpolate(
newXValue,
dataPoints.map(p => [p.x, p.y])
)
if (newYValue != null) {
const newPoints = [...dataPoints, { x: newXValue, y: newYValue }]
const newPointsSorted = newPoints.sort((a, b) => a.x - b.x)
setDataPoints(newPointsSorted)
}
}

return createPortal(
<Modal
title={t(`by_volume_builder:${type}.title`)}
onClose={onClose}
closeOnOutsideClick
width={FLEX_MIN_CONTENT}
>
<Flex flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing8}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry for this nit but since this is a new feature, we should be using css modules!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totally right! thank you

<ByVolumeBuilder
type={type}
dataPoints={dataPoints}
setDataPoints={setDataPoints}
byVolume={byVolume}
maxX={maxX}
maxY={maxY}
/>
<ByVolumeCalculator
type={type}
points={dataPoints.map(p => [p.x, p.y])}
/>
<Flex justifyContent={JUSTIFY_FLEX_END} gridGap={SPACING.spacing4}>
<SecondaryButton
onClick={() => {
setDataPoints(getByVolumeMappedToXY(defaultFlowRates))
}}
>
{t(`by_volume_builder:reset`)}
</SecondaryButton>

<PrimaryButton onClick={handleAddPoint}>
{t('by_volume_builder:add_point')}
</PrimaryButton>
<PrimaryButton
onClick={() => {
setByVolume(dataPoints.map(p => [p.x, p.y]))
onClose()
}}
>
{t('shared:save')}
</PrimaryButton>
</Flex>
</Flex>
</Modal>,
getMainPagePortalEl()
)
}
Loading
Loading