Skip to content

Commit b8ea2d4

Browse files
committed
refinements pt 2
1 parent 78afd98 commit b8ea2d4

9 files changed

Lines changed: 234 additions & 141 deletions

File tree

src/Plate/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,15 @@ import { FlowDirection } from './types';
22

33
/** Used internally when rendering the Plate component. */
44
export const PLATE_FLOW = 'row' as const satisfies FlowDirection;
5+
6+
/** Width of the row label column (scales with font size) */
7+
export const ROW_LABEL_WIDTH = '3ch';
8+
9+
/** Well column width in uniform mode (proportional spacing) */
10+
export const WELL_COLUMN_WIDTH_UNIFORM = '4fr';
11+
12+
/** Well column width in compact mode (fits content) */
13+
export const WELL_COLUMN_WIDTH_COMPACT = 'auto';
14+
15+
/** Gap between grid cells */
16+
export const GRID_GAP = '3px';

src/Plate/index.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export default {
1717
args: {
1818
isDraggable: false,
1919
loading: false,
20+
wellSizing: 'uniform',
21+
},
22+
argTypes: {
23+
wellSizing: {
24+
control: { type: 'select' },
25+
options: ['uniform', 'compact'],
26+
description:
27+
"Controls well sizing. Row labels always use 3ch. 'uniform' gives equal proportional width (spacious), 'compact' fits content (minimal width).",
28+
},
2029
},
2130
};
2231

src/Plate/index.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { MllSpinnerIcon } from '../Spinner';
77
import { ColumnLabel } from './ColumnLabel';
88
import { RowLabel } from './RowLabel';
99
import { Well } from './Well';
10-
import { PLATE_FLOW } from './constants';
10+
import {
11+
GRID_GAP,
12+
PLATE_FLOW,
13+
ROW_LABEL_WIDTH,
14+
WELL_COLUMN_WIDTH_COMPACT,
15+
WELL_COLUMN_WIDTH_UNIFORM,
16+
} from './constants';
1117
import { CoordinateSystem, PlateProps } from './types';
1218
import {
1319
allCoordinateSystemPositions,
@@ -31,6 +37,7 @@ export function Plate<TCoordinateSystem extends CoordinateSystem>({
3137
dndContextProps,
3238
isDraggable,
3339
loading,
40+
wellSizing = 'uniform',
3441
}: PlateProps<TCoordinateSystem>) {
3542
if (data) {
3643
assertUniquePositions(data);
@@ -51,10 +58,14 @@ export function Plate<TCoordinateSystem extends CoordinateSystem>({
5158
<div
5259
style={{
5360
display: 'grid',
54-
gridTemplateColumns: `auto${' auto'.repeat(
55-
coordinateSystem.columns.length,
56-
)}`,
57-
gridGap: '3px',
61+
gridTemplateColumns: `${ROW_LABEL_WIDTH} repeat(${
62+
coordinateSystem.columns.length
63+
}, ${
64+
wellSizing === 'compact'
65+
? WELL_COLUMN_WIDTH_COMPACT
66+
: WELL_COLUMN_WIDTH_UNIFORM
67+
})`,
68+
gridGap: GRID_GAP,
5869
}}
5970
>
6071
{/* takes up the space in the upper left corner between A and 1 */}

src/Plate/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,11 @@ export type PlateProps<TCoordinateSystem extends CoordinateSystem> = {
2828
isDraggable?: Maybe<boolean>;
2929
/** Do not add props.dndContextProps conditionally, as it leads to problems. Use props.isDraggable instead. */
3030
dndContextProps?: Maybe<Props>;
31+
/**
32+
* Controls the sizing behavior for well columns.
33+
* Row label column always uses ROW_LABEL_WIDTH (scales with font size).
34+
* - 'uniform': Wells have equal proportional width (spacious layout) - default
35+
* - 'compact': Wells fit their content (minimal width)
36+
*/
37+
wellSizing?: 'uniform' | 'compact';
3138
};

src/TecanDeckView/LabwareDetailItem.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import * as React from 'react';
22

33
import { PALETTE } from '../theme';
44

5-
/* eslint-disable @mll-lab/no-color-literals */
5+
/* eslint-disable-next-line @mll-lab/no-color-literals -- Hardware-specific color must match physical label appearance */
66
const LABEL_BACKGROUND_COLOR = '#e0e559';
7-
/* eslint-enable @mll-lab/no-color-literals */
87

98
const LABEL_STYLE = {
109
backgroundColor: LABEL_BACKGROUND_COLOR,

src/TecanDeckView/TecanDeckView.tsx

Lines changed: 85 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,18 @@ import { PALETTE } from '../theme';
44

55
import { EmptyLabware } from './EmptyLabware';
66
import { LabwareDetailItem } from './LabwareDetailItem';
7-
import { LABWARE_METADATA } from './labwareMetadata';
8-
import { LabwareConfig, LabwareKey, TecanLabwares } from './types';
7+
import {
8+
GRID_LAYOUT,
9+
isLeftColumn,
10+
isRightColumn,
11+
LABWARE_METADATA,
12+
} from './labwareMetadata';
13+
import {
14+
LabwareConfig,
15+
LabwareKey,
16+
LabwareWithStyle,
17+
TecanLabwares,
18+
} from './types';
919

1020
// Responsive scaling constants
1121
const GRID_PADDING = 8; // Padding around the grid container (from style.padding)
@@ -15,21 +25,22 @@ const GRID_OVERHEAD = GRID_PADDING * 2 + GRID_GAP * (COLUMN_COUNT - 1); // Total
1525
const COLUMN_OVERHEAD = 60; // Estimated width per column for borders and padding
1626
const BASE_CONTENT_WIDTH = 1400; // Designed content width at 1.0 scale factor
1727
const SCALE_SAFETY_MARGIN = 0.95; // 5% safety margin to prevent overflow
28+
const MIN_SCALE_FACTOR = 0.1; // Minimum scale to keep content readable
29+
const MAX_SCALE_FACTOR = 1.0; // Maximum scale (100% of designed size)
30+
const INITIAL_SCALE_FACTOR = 0.7; // Initial scale before container width is measured
1831

1932
// Static styles
2033
const CONTAINER_STYLE = {
2134
width: '100%',
2235
} as const;
2336

24-
const GRID_CONTAINER_BASE_STYLE = {
37+
const GRID_CONTAINER_STYLE = {
2538
display: 'grid',
26-
gridTemplateColumns: 'auto auto auto auto auto',
2739
gap: '8px',
28-
gridTemplateAreas: `
29-
"mmPlate aPlate nemoDilution destPcr rightColumn"
30-
"mmPlate bPlate nemoDestPcr2 destLc rightColumn"
31-
"mmPlate nemoWater nemoDestTaqMan fluidX rightColumn"
32-
`,
40+
gridTemplateAreas: GRID_LAYOUT.map((row) => `"${row.join(' ')}"`).join(
41+
'\n ',
42+
),
43+
gridTemplateRows: 'auto auto auto',
3344
padding: '8px',
3445
backgroundColor: PALETTE.gray1,
3546
borderRadius: '4px',
@@ -40,18 +51,27 @@ const LABWARE_ITEM_BASE_STYLE = {
4051
justifySelf: 'center',
4152
} as const;
4253

43-
const RIGHT_COLUMN_STYLE = {
44-
gridArea: 'rightColumn',
54+
// Shared style for columns that span all 3 grid rows
55+
const SPANNING_COLUMN_BASE_STYLE = {
4556
display: 'grid',
46-
gridTemplateRows: 'auto auto',
4757
alignSelf: 'center',
4858
gap: '8px',
4959
} as const;
5060

61+
const LEFT_COLUMN_STYLE = {
62+
...SPANNING_COLUMN_BASE_STYLE,
63+
gridArea: 'leftColumn',
64+
} as const;
65+
66+
const RIGHT_COLUMN_STYLE = {
67+
...SPANNING_COLUMN_BASE_STYLE,
68+
gridArea: 'rightColumn',
69+
} as const;
70+
5171
export function TecanDeckView({ labwares }: { labwares: TecanLabwares }) {
5272
const containerRef = React.useRef<HTMLDivElement>(null);
5373
const [availableWidth, setAvailableWidth] = React.useState<number>();
54-
const [scaleFactor, setScaleFactor] = React.useState(0.7);
74+
const [scaleFactor, setScaleFactor] = React.useState(INITIAL_SCALE_FACTOR);
5575

5676
React.useEffect(() => {
5777
const container = containerRef.current;
@@ -84,49 +104,48 @@ export function TecanDeckView({ labwares }: { labwares: TecanLabwares }) {
84104
SCALE_SAFETY_MARGIN;
85105

86106
const calculatedScale = Math.max(
87-
0.1,
88-
Math.min(1.0, availableContentWidth / BASE_CONTENT_WIDTH),
107+
MIN_SCALE_FACTOR,
108+
Math.min(MAX_SCALE_FACTOR, availableContentWidth / BASE_CONTENT_WIDTH),
89109
);
90110
setScaleFactor(calculatedScale);
91111
}, [availableWidth]);
92112

93-
const allLabwares: Array<LabwareConfig> = React.useMemo(() => {
94-
const allLabwareKeys = Object.keys(LABWARE_METADATA) as Array<LabwareKey>;
95-
return allLabwareKeys.map((key) => ({
96-
key,
97-
...LABWARE_METADATA[key],
98-
content: labwares[key]?.content,
99-
}));
100-
}, [labwares]);
101-
102-
const isRightColumnAndNeedContainer = (key: LabwareKey) =>
103-
key === 'destPcr1' || key === 'destPcr2';
104-
105-
const ROWS = [
106-
{
107-
keys: new Set<LabwareKey>([
108-
'aPlate',
109-
'nemoDilution',
110-
'destPcr',
111-
'destPcr1',
112-
'destPcr2',
113-
]),
114-
alignment: 'start' as const,
115-
},
116-
{
117-
keys: new Set<LabwareKey>(['bPlate', 'nemoDestPcr2', 'destLc']),
118-
alignment: 'center' as const,
119-
},
120-
{
121-
keys: new Set<LabwareKey>(['nemoWater', 'nemoDestTaqMan', 'fluidX']),
122-
alignment: 'end' as const,
123-
},
124-
];
125-
126-
const getVerticalAlignment = (key: LabwareKey): string => {
127-
const row = ROWS.find((r) => r.keys.has(key));
128-
return row?.alignment ?? 'center';
129-
};
113+
// Group labwares into spanning columns and main grid in a single pass
114+
const { leftColumnLabwares, rightColumnLabwares, mainGridLabwares } =
115+
React.useMemo(() => {
116+
const allLabwareKeys = Object.keys(LABWARE_METADATA) as Array<LabwareKey>;
117+
118+
return allLabwareKeys.reduce(
119+
(acc, key) => {
120+
const metadata = LABWARE_METADATA[key];
121+
const labware: LabwareWithStyle = {
122+
key,
123+
...metadata,
124+
content: labwares[key]?.content,
125+
style: {
126+
...LABWARE_ITEM_BASE_STYLE,
127+
gridArea: key,
128+
alignSelf: metadata.gridPosition.alignment,
129+
},
130+
};
131+
132+
if (isLeftColumn(key)) {
133+
acc.leftColumnLabwares.push(labware);
134+
} else if (isRightColumn(key)) {
135+
acc.rightColumnLabwares.push(labware);
136+
} else {
137+
acc.mainGridLabwares.push(labware);
138+
}
139+
140+
return acc;
141+
},
142+
{
143+
leftColumnLabwares: [] as Array<LabwareWithStyle>,
144+
rightColumnLabwares: [] as Array<LabwareWithStyle>,
145+
mainGridLabwares: [] as Array<LabwareWithStyle>,
146+
},
147+
);
148+
}, [labwares]);
130149

131150
const renderLabware = (labware: LabwareConfig) =>
132151
labware.content ? (
@@ -140,46 +159,25 @@ export function TecanDeckView({ labwares }: { labwares: TecanLabwares }) {
140159
<EmptyLabware shortLabel={labware.shortLabel} />
141160
);
142161

143-
// Check if a row contains only empty labware
144-
const isRowEmpty = (rowKeys: Set<LabwareKey>): boolean =>
145-
Array.from(rowKeys).every((key) => !labwares[key]?.content);
146-
147-
const EMPTY_ROW_SIZE = '0.3fr';
148-
const CONTENT_ROW_SIZE = '1fr';
149-
150-
const gridTemplateRows = ROWS.map((row) =>
151-
isRowEmpty(row.keys) ? EMPTY_ROW_SIZE : CONTENT_ROW_SIZE,
152-
).join(' ');
153-
154162
return (
155163
<div ref={containerRef} style={CONTAINER_STYLE}>
156-
<div
157-
style={{
158-
...GRID_CONTAINER_BASE_STYLE,
159-
gridTemplateRows,
160-
}}
161-
>
162-
{allLabwares
163-
.filter((labware) => !isRightColumnAndNeedContainer(labware.key))
164-
.map((labware) => (
165-
<div
166-
key={labware.key}
167-
style={{
168-
...LABWARE_ITEM_BASE_STYLE,
169-
gridArea: labware.key,
170-
alignSelf: getVerticalAlignment(labware.key),
171-
}}
172-
>
173-
{renderLabware(labware)}
174-
</div>
164+
<div style={GRID_CONTAINER_STYLE}>
165+
<div style={LEFT_COLUMN_STYLE}>
166+
{leftColumnLabwares.map((labware) => (
167+
<div key={labware.key}>{renderLabware(labware)}</div>
175168
))}
169+
</div>
170+
171+
{mainGridLabwares.map((labware) => (
172+
<div key={labware.key} style={labware.style}>
173+
{renderLabware(labware)}
174+
</div>
175+
))}
176176

177177
<div style={RIGHT_COLUMN_STYLE}>
178-
{allLabwares
179-
.filter((labware) => isRightColumnAndNeedContainer(labware.key))
180-
.map((labware) => (
181-
<div key={labware.key}>{renderLabware(labware)}</div>
182-
))}
178+
{rightColumnLabwares.map((labware) => (
179+
<div key={labware.key}>{renderLabware(labware)}</div>
180+
))}
183181
</div>
184182
</div>
185183
</div>

0 commit comments

Comments
 (0)