Skip to content

Commit 753b999

Browse files
authored
feat: add support for 'factorWithMinimum' pipetting loss type
1 parent f40f6ef commit 753b999

File tree

6 files changed

+162
-51
lines changed

6 files changed

+162
-51
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,5 @@
130130
"typescript": "^5.3.2",
131131
"webpack": "^5.89.0"
132132
},
133-
"packageManager": "[email protected].2"
133+
"packageManager": "[email protected].4"
134134
}

src/MasterMix/index.stories.tsx

Lines changed: 65 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,74 @@
1-
import { Story } from '@storybook/react';
2-
import React from 'react';
1+
import React, { ReactElement } from 'react';
32

4-
import { MasterMixIngredient, MasterMixProps } from './types';
3+
import { MasterMixIngredient, MasterMixProps, PipettingLoss } from './types';
54

65
import { MasterMix } from './index';
76

7+
const INGREDIENTS: Array<MasterMixIngredient> = [
8+
{ key: 1, title: 'Water', volume: 12 },
9+
{ key: 2, title: 'Primer Forward', volume: 1 },
10+
{ key: 3, title: 'Primer Reverse', volume: 1 },
11+
{ key: 4, title: 'Probe', volume: 1 },
12+
];
13+
814
export default {
915
title: 'MasterMix',
16+
component: MasterMix,
17+
args: {
18+
name: 'Example Mix',
19+
count: 20,
20+
ingredients: INGREDIENTS,
21+
lossType: 'factorWithMinimum',
22+
lossValue: 0.1,
23+
minPositions: 2,
24+
},
25+
argTypes: {
26+
name: { control: 'text' },
27+
count: { control: { type: 'number', min: 1 } },
28+
ingredients: { control: 'object' },
29+
lossType: {
30+
control: 'radio',
31+
options: ['absolute', 'factor', 'factorWithMinimum'],
32+
},
33+
lossValue: {
34+
control: { type: 'number', min: 0, step: 0.1 },
35+
},
36+
minPositions: {
37+
control: { type: 'number', min: 0 },
38+
if: { arg: 'lossType', eq: 'factorWithMinimum' },
39+
},
40+
pipettingLoss: {
41+
table: {
42+
disable: true,
43+
},
44+
},
45+
},
1046
};
1147

12-
const ingredients: Array<MasterMixIngredient> = [
13-
{ key: 1, title: 'Water', volume: 79.5 },
14-
{ key: 2, title: 'Primer Fordward', volume: 9.2 },
15-
{ key: 3, title: 'Primer Reverse', volume: 9 },
16-
{ key: 4, title: 'Probe', volume: 2.5 },
17-
];
18-
const name = 'Example';
19-
const count = 7;
20-
21-
export const AbsolutePipettingLoss: Story<MasterMixProps> = function Default() {
22-
return (
23-
<MasterMix
24-
name={name}
25-
count={count}
26-
ingredients={ingredients}
27-
pipettingLoss={{ type: 'absolute', count: 2 }}
28-
/>
29-
);
30-
};
48+
export function Default({
49+
lossType = 'factorWithMinimum',
50+
lossValue = 0.1,
51+
minPositions = 2,
52+
...props
53+
}: MasterMixProps & {
54+
lossType: 'absolute' | 'factor' | 'factorWithMinimum';
55+
lossValue: number;
56+
minPositions?: number;
57+
}): ReactElement {
58+
const pipettingLoss = ((): PipettingLoss => {
59+
switch (lossType) {
60+
case 'absolute':
61+
return { type: lossType, count: lossValue };
62+
case 'factor':
63+
return { type: lossType, factor: lossValue };
64+
case 'factorWithMinimum':
65+
return {
66+
type: lossType,
67+
factor: lossValue,
68+
minPositions,
69+
};
70+
}
71+
})();
3172

32-
export const PipettingLossByFactor: Story<MasterMixProps> = function Default() {
33-
return (
34-
<MasterMix
35-
name={name}
36-
count={count}
37-
ingredients={ingredients}
38-
pipettingLoss={{ type: 'factor', factor: 0.1 }}
39-
/>
40-
);
41-
};
73+
return <MasterMix {...props} pipettingLoss={pipettingLoss} />;
74+
}

src/MasterMix/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
PipettingLoss,
1616
PipettingLossAbsolute,
1717
PipettingLossByFactor,
18+
PipettingLossFactorWithMinimum,
1819
} from './types';
1920

2021
const TOTAL_VOLUME_ROW_CLASS = 'total-volume-row';

src/MasterMix/pipettingLossTableColumn.test.tsx

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,46 +5,72 @@ import { pipettingLossTableColumn } from './pipettingLossTableColumn';
55

66
describe('pipettingLossTableColumn', () => {
77
describe('with "absolute" pipetting loss type', () => {
8-
it('should display the correct tooltip content', () => {
8+
it('should render the total volume and title correctly', () => {
99
const column = pipettingLossTableColumn({
1010
count: 2,
1111
pipettingLoss: { type: 'absolute', count: 1 },
1212
});
13+
render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}</>);
1314
render(<>{column.title}</>);
1415

16+
expect(screen.getByText('30.0 µl')).toBeInTheDocument();
1517
expect(screen.getByText('2x Ansätze + 1x (PV)')).toBeInTheDocument();
1618
});
19+
});
1720

18-
it('should render the total volume correctly', () => {
21+
describe('with "factor" pipetting loss type', () => {
22+
it('should render the total volume and title correctly', () => {
1923
const column = pipettingLossTableColumn({
2024
count: 2,
21-
pipettingLoss: { type: 'absolute', count: 1 },
25+
pipettingLoss: { type: 'factor', factor: 0.1 },
2226
});
2327
render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}</>);
28+
render(<>{column.title}</>);
2429

25-
expect(screen.getByText('30.0 µl')).toBeInTheDocument();
30+
expect(screen.getByText('22.0 µl')).toBeInTheDocument();
31+
expect(screen.getByText('2x Ansätze + 10% (PV)')).toBeInTheDocument();
2632
});
2733
});
2834

29-
describe('with "factor" pipetting loss type', () => {
30-
it('should display the correct tooltip content', () => {
35+
describe('with "factorWithMinimum" pipetting loss type', () => {
36+
it('should use minimum positions when factor loss is slightly below minimum positions', () => {
37+
// 19 ansätze × 10µl = 190µl
38+
// factor loss: 190µl × 10% = 19µl
39+
// min positions loss: 2 × 10µl = 20µl
40+
// result: 190µl + max(19µl, 20µl) = 210µl
3141
const column = pipettingLossTableColumn({
32-
count: 2,
33-
pipettingLoss: { type: 'factor', factor: 0.1 },
42+
count: 19,
43+
pipettingLoss: {
44+
type: 'factorWithMinimum',
45+
factor: 0.1,
46+
minPositions: 2,
47+
},
3448
});
3549
render(<>{column.title}</>);
50+
render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}</>);
3651

37-
expect(screen.getByText('2x Ansätze + 10% (PV)')).toBeInTheDocument();
52+
expect(screen.getByText('210.0 µl')).toBeInTheDocument();
53+
expect(screen.getByText('19x Ansätze + 2x (PV)')).toBeInTheDocument();
3854
});
3955

40-
it('should render the total volume correctly', () => {
56+
it('should use factor loss when it is slightly above minimum positions', () => {
57+
// 21 ansätze × 10µl = 210µl
58+
// factor loss: 210µl × 10% = 21µl
59+
// min positions loss: 2 × 10µl = 20µl
60+
// result: 210µl + max(21µl, 20µl) = 231µl
4161
const column = pipettingLossTableColumn({
42-
count: 2,
43-
pipettingLoss: { type: 'factor', factor: 0.1 },
62+
count: 21,
63+
pipettingLoss: {
64+
type: 'factorWithMinimum',
65+
factor: 0.1,
66+
minPositions: 2,
67+
},
4468
});
69+
render(<>{column.title}</>);
4570
render(<>{column.render(null, { volume: 10, title: '', key: 1 }, 1)}</>);
4671

47-
expect(screen.getByText('22.0 µl')).toBeInTheDocument();
72+
expect(screen.getByText('231.0 µl')).toBeInTheDocument();
73+
expect(screen.getByText('21x Ansätze + 10% (PV)')).toBeInTheDocument();
4874
});
4975
});
5076
});

src/MasterMix/pipettingLossTableColumn.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,46 @@ import { Tooltip } from '../Tooltip';
55
import {
66
IngredientWithStringOrNumberKey,
77
PipettingLoss,
8+
PipettingLossFactorWithMinimum,
89
PipettingLossTableColumn,
910
PipettingLossTableColumnArgs,
1011
} from './types';
1112

12-
function pipettingLossTitle(pipettingLoss: PipettingLoss): string {
13+
type PipettingLosses = {
14+
factorLoss: number;
15+
minPositionsLoss: number;
16+
};
17+
18+
function calculatePipettingLosses(
19+
count: number,
20+
pipettingLoss: PipettingLossFactorWithMinimum,
21+
volume = 1,
22+
): PipettingLosses {
23+
const baseVolume = volume * count;
24+
return {
25+
factorLoss: baseVolume * pipettingLoss.factor,
26+
minPositionsLoss: volume * pipettingLoss.minPositions,
27+
};
28+
}
29+
30+
function pipettingLossTitle(
31+
pipettingLoss: PipettingLoss,
32+
count: number,
33+
): string {
1334
switch (pipettingLoss.type) {
1435
case 'absolute':
1536
return `${pipettingLoss.count}x`;
1637
case 'factor':
1738
return `${pipettingLoss.factor * 100}%`;
39+
case 'factorWithMinimum': {
40+
const { factorLoss, minPositionsLoss } = calculatePipettingLosses(
41+
count,
42+
pipettingLoss,
43+
);
44+
return factorLoss > minPositionsLoss
45+
? `${(pipettingLoss.factor * 100).toFixed(0)}%`
46+
: `${pipettingLoss.minPositions}x`;
47+
}
1848
}
1949
}
2050

@@ -32,6 +62,16 @@ function totalVolume(
3262
record.volume * args.count +
3363
record.volume * args.count * args.pipettingLoss.factor
3464
).toFixed(1);
65+
case 'factorWithMinimum': {
66+
const baseVolume = record.volume * args.count;
67+
const { factorLoss, minPositionsLoss } = calculatePipettingLosses(
68+
args.count,
69+
args.pipettingLoss,
70+
record.volume,
71+
);
72+
const pipettingLoss = Math.max(factorLoss, minPositionsLoss);
73+
return (baseVolume + pipettingLoss).toFixed(1);
74+
}
3575
}
3676
}
3777

@@ -41,7 +81,8 @@ export function pipettingLossTableColumn(
4181
return {
4282
title: (
4383
<Tooltip title="Pipettierverlust">
44-
{args.count}x Ansätze + {pipettingLossTitle(args.pipettingLoss)} (PV)
84+
{args.count}x Ansätze +{' '}
85+
{pipettingLossTitle(args.pipettingLoss, args.count)} (PV)
4586
</Tooltip>
4687
),
4788
render: (_: unknown, record: IngredientWithStringOrNumberKey) => (

src/MasterMix/types.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,25 @@ export type MasterMixIngredient = {
77
title: string | NonNullable<ReactNode>;
88
volume: number;
99
};
10+
1011
export type MasterMixProps = {
1112
name: string;
1213
count: number;
1314
ingredients: Array<MasterMixIngredient>;
1415
pipettingLoss: PipettingLoss;
1516
};
16-
export type PipettingLossByFactor = { type: 'factor'; factor: number };
17+
1718
export type PipettingLossAbsolute = { type: 'absolute'; count: number };
18-
export type PipettingLoss = PipettingLossByFactor | PipettingLossAbsolute;
19+
export type PipettingLossByFactor = { type: 'factor'; factor: number };
20+
export type PipettingLossFactorWithMinimum = {
21+
type: 'factorWithMinimum';
22+
factor: number;
23+
minPositions: number;
24+
};
25+
export type PipettingLoss =
26+
| PipettingLossByFactor
27+
| PipettingLossAbsolute
28+
| PipettingLossFactorWithMinimum;
1929

2030
export type IngredientWithStringOrNumberKey = Modify<
2131
MasterMixIngredient,
@@ -36,5 +46,5 @@ export type PipettingLossTableColumn = Modify<
3646
>;
3747
export type PipettingLossTableColumnArgs = {
3848
count: number;
39-
pipettingLoss: PipettingLossByFactor | PipettingLossAbsolute;
49+
pipettingLoss: PipettingLoss;
4050
};

0 commit comments

Comments
 (0)