Skip to content

Commit b5bc2e7

Browse files
authored
feat(components): create new LabwareStackRender component (#15842)
# Overview According to new modal designs for labware/adapter/module stacks on desktop and ODD, we need to render an isometric view of each element of the stack, maintaining the topography of each stacked element. The isometric views are constructed by transforming and aligning the labware SVG (top face) along with two new rects (front and left side). All three are scaled, skewed, and rotated at 30˚ angles or its derivatives ([reference](http://jeroenhoek.nl/articles/svg-and-isometric-projection.html)). The "bottom" labware definition is an optional prop, so we have an affordance for rendering an isometric view of a single labware from the same component. The user can choose to highlight both or none of the labware in the stack, resulting in a fill of blue30. In addition, adapters are handled in the following way: - If the adapter is used as the top definition and there is no bottom definition, we render the non-transformed `LabwareAdapter` svg - if the adapter is used as the bottom definition, we render the isometric transform of the well-less adapter face. _**NOTE**_ In this PR, the adapter's height is maintained, but we may also consider setting the height for a bottom adapter as a constant, rendering a standard-height rectangular prism with well-less face. Closes [PLAT-374](https://opentrons.atlassian.net/browse/PLAT-374), [PLAT-377](https://opentrons.atlassian.net/browse/PLAT-377), [PLAT-394](https://opentrons.atlassian.net/browse/PLAT-394), [PLAT-375](https://opentrons.atlassian.net/browse/PLAT-375)
1 parent 4500e21 commit b5bc2e7

File tree

5 files changed

+212
-6
lines changed

5 files changed

+212
-6
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import * as React from 'react'
2+
import { WellLabels, StaticLabware } from './labwareInternals'
3+
import { LabwareAdapter } from './LabwareAdapter'
4+
import { COLORS } from '../../helix-design-system'
5+
6+
import type { LabwareDefinition2 } from '@opentrons/shared-data'
7+
import type { HighlightedWellLabels } from './labwareInternals/types'
8+
import type { LabwareAdapterLoadName } from './LabwareAdapter'
9+
import type { WellLabelOption } from '../..'
10+
11+
const HIGHLIGHT_COLOR = COLORS.blue30
12+
const STROKE_WIDTH = 1
13+
const SKEW_ANGLE_DEGREES = 30
14+
const SKEW_ANGLE_RADIANS = (SKEW_ANGLE_DEGREES * Math.PI) / 180
15+
const COSINE_SKEW_ANGLE = Math.cos(SKEW_ANGLE_RADIANS)
16+
17+
export interface LabwareStackRenderProps {
18+
/** Labware definitions in stack to render */
19+
definitionTop: LabwareDefinition2
20+
/** option to highlight well labels with specified color */
21+
highlightedWellLabels?: HighlightedWellLabels
22+
/** highlight top labware */
23+
highlightTop: boolean
24+
/** highlight bottom labware if it exists */
25+
highlightBottom: boolean
26+
gRef?: React.RefObject<SVGGElement>
27+
definitionBottom?: LabwareDefinition2
28+
shouldRotateAdapterOrientation?: boolean
29+
/** option to show well labels inside or outside of labware outline */
30+
wellLabelOption?: WellLabelOption
31+
}
32+
33+
export const LabwareStackRender = (
34+
props: LabwareStackRenderProps
35+
): JSX.Element => {
36+
const {
37+
gRef,
38+
definitionTop,
39+
definitionBottom,
40+
highlightTop,
41+
wellLabelOption,
42+
shouldRotateAdapterOrientation,
43+
highlightBottom = false,
44+
} = props
45+
46+
const labwareLoadNameTop = definitionTop.parameters.loadName
47+
const fillColorTop = highlightTop ? HIGHLIGHT_COLOR : COLORS.white
48+
const fillColorBottom = highlightBottom ? HIGHLIGHT_COLOR : COLORS.white
49+
50+
// only one labware (top)
51+
if (definitionBottom == null) {
52+
const { xDimension, yDimension } = definitionTop.dimensions
53+
const isTopAdapter = definitionTop.metadata.displayCategory === 'adapter'
54+
55+
return isTopAdapter ? (
56+
// adapter render
57+
<g
58+
transform={
59+
shouldRotateAdapterOrientation
60+
? `rotate(180, ${xDimension / 2}, ${yDimension / 2})`
61+
: 'rotate(0, 0, 0)'
62+
}
63+
>
64+
<g>
65+
<LabwareAdapter
66+
labwareLoadName={labwareLoadNameTop as LabwareAdapterLoadName}
67+
/>
68+
</g>
69+
</g>
70+
) : (
71+
// isometric view of labware
72+
<svg>
73+
<g
74+
transform={`translate(55, 28) rotate(SKEW_ANGLE_DEGREES) skewX(-${SKEW_ANGLE_DEGREES}) scale(${COSINE_SKEW_ANGLE}, ${COSINE_SKEW_ANGLE})`}
75+
ref={gRef}
76+
>
77+
<StaticLabware definition={definitionTop} fill={fillColorBottom} />
78+
{wellLabelOption != null ? (
79+
<WellLabels
80+
definition={definitionTop}
81+
wellLabelOption={wellLabelOption}
82+
wellLabelColor={fillColorBottom}
83+
highlightedWellLabels={props.highlightedWellLabels}
84+
/>
85+
) : null}
86+
</g>
87+
<rect
88+
width={definitionTop.dimensions.yDimension - STROKE_WIDTH}
89+
height={definitionTop.dimensions.zDimension - STROKE_WIDTH}
90+
transform={`translate(55, 28) rotate(180) skewY(-${SKEW_ANGLE_DEGREES}) scale(${COSINE_SKEW_ANGLE}, ${COSINE_SKEW_ANGLE})`}
91+
strokeWidth={STROKE_WIDTH}
92+
stroke={COLORS.black90}
93+
fill={fillColorTop}
94+
/>
95+
<rect
96+
width={definitionTop.dimensions.xDimension - STROKE_WIDTH}
97+
height={definitionTop.dimensions.zDimension - STROKE_WIDTH}
98+
transform={`translate(55, 28) skewY(${SKEW_ANGLE_DEGREES}) scale(${
99+
COSINE_SKEW_ANGLE * 0.5
100+
}, -${COSINE_SKEW_ANGLE}) `}
101+
strokeWidth={STROKE_WIDTH}
102+
stroke={COLORS.black90}
103+
fill={fillColorTop}
104+
/>
105+
</svg>
106+
)
107+
}
108+
109+
return (
110+
<svg>
111+
{/* bottom labware/adapter */}
112+
<g
113+
transform={`translate(55, ${
114+
28 - definitionTop.dimensions.zDimension * 0.5 - 10
115+
}) rotate(${SKEW_ANGLE_DEGREES}) skewX(-${SKEW_ANGLE_DEGREES}) scale(${COSINE_SKEW_ANGLE}, ${COSINE_SKEW_ANGLE})`}
116+
ref={gRef}
117+
fill={fillColorBottom}
118+
>
119+
<StaticLabware definition={definitionTop} fill={fillColorBottom} />
120+
{wellLabelOption != null &&
121+
definitionTop.metadata.displayCategory !== 'adapter' ? (
122+
<WellLabels
123+
definition={definitionTop}
124+
wellLabelOption={wellLabelOption}
125+
wellLabelColor={fillColorBottom}
126+
highlightedWellLabels={props.highlightedWellLabels}
127+
/>
128+
) : null}
129+
</g>
130+
<rect
131+
width={definitionTop.dimensions.yDimension - STROKE_WIDTH}
132+
height={definitionTop.dimensions.zDimension - STROKE_WIDTH}
133+
transform={`translate(55, ${
134+
28 - definitionTop.dimensions.zDimension * 0.5 - 10
135+
}) rotate(180) skewY(-${SKEW_ANGLE_DEGREES}) scale(${COSINE_SKEW_ANGLE}, ${COSINE_SKEW_ANGLE})`}
136+
strokeWidth={STROKE_WIDTH}
137+
stroke={COLORS.black90}
138+
fill={fillColorBottom}
139+
/>
140+
<rect
141+
width={definitionTop.dimensions.xDimension - STROKE_WIDTH}
142+
height={definitionTop.dimensions.zDimension - STROKE_WIDTH}
143+
transform={`translate(55, ${
144+
28 - definitionTop.dimensions.zDimension * 0.5 - 10
145+
}) skewY(${SKEW_ANGLE_DEGREES}) scale(${
146+
COSINE_SKEW_ANGLE * 0.5
147+
}, -${COSINE_SKEW_ANGLE}) `}
148+
strokeWidth={STROKE_WIDTH}
149+
stroke={COLORS.black90}
150+
fill={fillColorBottom}
151+
/>
152+
{/* top labware/adapter */}
153+
<g
154+
transform={`translate(55, 28) rotate(${SKEW_ANGLE_DEGREES}) skewX(-${SKEW_ANGLE_DEGREES}) scale(${COSINE_SKEW_ANGLE}, ${COSINE_SKEW_ANGLE})`}
155+
ref={gRef}
156+
>
157+
<StaticLabware definition={definitionTop} fill={fillColorTop} />
158+
{wellLabelOption != null &&
159+
definitionTop.metadata.displayCategory !== 'adapter' ? (
160+
<WellLabels
161+
definition={definitionTop}
162+
wellLabelOption={wellLabelOption}
163+
wellLabelColor={fillColorTop}
164+
highlightedWellLabels={props.highlightedWellLabels}
165+
/>
166+
) : null}
167+
</g>
168+
<rect
169+
width={definitionTop.dimensions.yDimension - STROKE_WIDTH}
170+
height={definitionTop.dimensions.zDimension - STROKE_WIDTH}
171+
transform={`translate(55, 28) rotate(180) skewY(-${SKEW_ANGLE_DEGREES}) scale(${COSINE_SKEW_ANGLE}, ${COSINE_SKEW_ANGLE})`}
172+
strokeWidth={STROKE_WIDTH}
173+
stroke={COLORS.black90}
174+
fill={fillColorTop}
175+
/>
176+
<rect
177+
width={definitionTop.dimensions.xDimension - STROKE_WIDTH}
178+
height={definitionTop.dimensions.zDimension - STROKE_WIDTH}
179+
transform={`translate(55, 28) skewY(${SKEW_ANGLE_DEGREES}) scale(${
180+
COSINE_SKEW_ANGLE * 0.5
181+
}, -${COSINE_SKEW_ANGLE}) `}
182+
strokeWidth={STROKE_WIDTH}
183+
stroke={COLORS.black90}
184+
fill={fillColorTop}
185+
/>
186+
</svg>
187+
)
188+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './labwareInternals/index'
22
export * from './LabwareRender'
3+
export * from './LabwareStackRender'
34
export * from './Labware'
45

56
export * from './labwareInternals/types'

components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface LabwareOutlineProps {
1818
highlight?: boolean
1919
/** [legacy] override the border color */
2020
stroke?: CSSProperties['stroke']
21+
fill?: CSSProperties['fill']
2122
}
2223

2324
const OUTLINE_THICKNESS_MM = 1
@@ -30,13 +31,19 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element {
3031
isTiprack = false,
3132
highlight = false,
3233
stroke,
34+
fill,
3335
} = props
3436
const {
3537
parameters = { isTiprack },
3638
dimensions = { xDimension: width, yDimension: height },
3739
} = definition ?? {}
3840

39-
const backgroundFill = parameters.isTiprack ? '#CCCCCC' : COLORS.white
41+
let backgroundFill
42+
if (fill != null) {
43+
backgroundFill = fill
44+
} else {
45+
backgroundFill = parameters.isTiprack ? '#CCCCCC' : COLORS.white
46+
}
4047
return (
4148
<>
4249
{highlight ? (

components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import flatMap from 'lodash/flatMap'
55

66
import { LabwareOutline } from './LabwareOutline'
77
import { Well } from './Well'
8+
import { STYLE_BY_WELL_CONTENTS } from './StyledWells'
9+
import { COLORS } from '../../../helix-design-system'
810

911
import type { LabwareDefinition2, LabwareWell } from '@opentrons/shared-data'
1012
import type { WellMouseEvent } from './types'
11-
import { STYLE_BY_WELL_CONTENTS } from './StyledWells'
12-
import { COLORS } from '../../../helix-design-system'
13+
import type { CSSProperties } from 'styled-components'
1314

1415
export interface StaticLabwareProps {
1516
/** Labware definition to render */
@@ -22,6 +23,7 @@ export interface StaticLabwareProps {
2223
onMouseEnterWell?: (e: WellMouseEvent) => unknown
2324
/** Optional callback to be executed when mouse leaves a well element */
2425
onMouseLeaveWell?: (e: WellMouseEvent) => unknown
26+
fill?: CSSProperties['fill']
2527
}
2628

2729
const TipDecoration = React.memo(function TipDecoration(props: {
@@ -55,13 +57,18 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element {
5557
onLabwareClick,
5658
onMouseEnterWell,
5759
onMouseLeaveWell,
60+
fill,
5861
} = props
5962

6063
const { isTiprack } = definition.parameters
6164
return (
6265
<g onClick={onLabwareClick}>
6366
<LabwareDetailGroup>
64-
<LabwareOutline definition={definition} highlight={highlight} />
67+
<LabwareOutline
68+
definition={definition}
69+
highlight={highlight}
70+
fill={fill}
71+
/>
6572
</LabwareDetailGroup>
6673
<g>
6774
{flatMap(
@@ -78,6 +85,7 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element {
7885
{...(isTiprack
7986
? STYLE_BY_WELL_CONTENTS.tipPresent
8087
: STYLE_BY_WELL_CONTENTS.defaultWell)}
88+
fill={fill}
8189
/>
8290

8391
{isTiprack ? (

components/src/hardware-sim/Labware/labwareInternals/Well.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ export function WellComponent(props: WellProps): JSX.Element {
2626
wellName,
2727
stroke = COLORS.black90,
2828
strokeWidth = 1,
29-
fill = COLORS.white,
29+
fill,
3030
onMouseEnterWell,
3131
onMouseLeaveWell,
3232
isInteractive = onMouseEnterWell != null || onMouseLeaveWell != null,
3333
} = props
3434
const { x, y } = well
3535

36+
const wellFill = fill ?? COLORS.white
37+
3638
const pointerEvents: React.CSSProperties['pointerEvents'] = isInteractive
3739
? 'auto'
3840
: 'none'
@@ -46,7 +48,7 @@ export function WellComponent(props: WellProps): JSX.Element {
4648
onMouseLeaveWell != null
4749
? (event: React.MouseEvent) => onMouseLeaveWell({ wellName, event })
4850
: undefined,
49-
style: { pointerEvents, stroke, strokeWidth, fill },
51+
style: { pointerEvents, stroke, strokeWidth, fill: wellFill },
5052
}
5153

5254
if (well.shape === 'circular') {

0 commit comments

Comments
 (0)