export type ProteinTableMetricDto = {
label: string
@@ -77,13 +77,13 @@ export function ProteinTable
({ fractionRows, m
})
}, [retainedRows, proteinSearch])
- const getSortValue = (row: P, key: string) => {
+ const getSortValue = useCallback((row: P, key: string) => {
if (key === 'molecularWeight') {
return row.molecularWeight
}
const metric = metrics.find(m => m.label === key)
return metric ? metric.value(row) : 0
- }
+ }, [metrics])
const sortedRetainedRows = useMemo(() => {
const rows = [...filteredRetainedRows]
@@ -94,7 +94,7 @@ export function ProteinTable
({ fractionRows, m
})
return rows
- }, [filteredRetainedRows, proteinSort, metrics])
+ }, [filteredRetainedRows, proteinSort, getSortValue])
const pagedProteins = useMemo(() => {
const start = proteinPage * proteinRowsPerPage
diff --git a/frontend/jbio-app/src/pages/AffinityChromatography.tsx b/frontend/jbio-app/src/pages/AffinityChromatography.tsx
new file mode 100644
index 00000000..d6074452
--- /dev/null
+++ b/frontend/jbio-app/src/pages/AffinityChromatography.tsx
@@ -0,0 +1,361 @@
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ FormControl,
+ FormControlLabel,
+ InputLabel,
+ MenuItem,
+ Select,
+ Slider,
+ Switch,
+ Typography,
+} from '@mui/material'
+import { BarElement, CategoryScale, Chart as ChartJS, Legend, LineElement, LinearScale, LogarithmicScale, PointElement, Tooltip } from 'chart.js'
+import React, { useMemo, useState } from 'react'
+import { Chromatogram, ChromatogramFractionDto } from '../components/chromatography/graphs/Chromatogram'
+import { ChromatographyHitGraph } from '../components/chromatography/graphs/HitGraph'
+import { HitTable } from '../components/chromatography/tables/HitTable'
+import { ProteinTable, ProteinTableProteinDto } from '../components/chromatography/tables/ProteinTable'
+import NumberField from '../components/ui/NumberField'
+import { API_URL } from '../config'
+import { FractionationResponse } from '../components/chromatography/types'
+
+/*
+ * Register the chart components we need for Chart.js.
+ * Without this, the Line chart and axes will not render properly.
+ */
+ChartJS.register(CategoryScale, LinearScale, LogarithmicScale, BarElement, LineElement, PointElement, Tooltip, Legend)
+
+/*
+ * Protein object returned by the backend for each protein in a fraction.
+ */
+type ProteinDto = ProteinTableProteinDto & {
+ id: string
+ name: string
+ description: string
+ sequence: string
+ molecularWeight: number
+ amount: number
+}
+
+/*
+ * One output fraction returned by the backend.
+ */
+type FractionDto = ChromatogramFractionDto & {
+ fractionIndex: number
+ proteinCount?: number
+ absorbanceScore?: number
+ hitCount?: number
+ hitProteinIds?: string[]
+ proteins: ProteinDto[]
+}
+
+/*
+ * Parameter metadata returned by the backend for the completed run.
+ */
+type ParamsDto = {
+ fractions: number
+ prosite_strong: string
+ prosite_weak: string
+ noise: number
+}
+
+/*
+ * Summary counts returned by the backend.
+ */
+type CountsDto = {
+ total: number
+ strongly_bound: number
+ weakly_bound: number
+ unbound: number
+ skipped: number
+}
+
+/*
+ * Fractioon counts returned by the backend.
+ */
+type FractionCountsDto = {
+ wash_fractions: number
+ weak_fractions: number
+ strong_fractions: number
+}
+
+/*
+ * Full API response shape for a successful HIC run.
+ */
+type AffinityResponse = FractionationResponse & {
+ ok: boolean
+ params: ParamsDto
+ counts: CountsDto
+ fraction_counts: FractionCountsDto
+ fractions: FractionDto[]
+ error?: string
+}
+
+type MethodData = Readonly<{
+ name: string
+ prosite_strong: string
+ prosite_weak: string
+ elute: string
+ elute_concentrations: readonly [number, number, number]
+ elute_concentration_unit: string
+}>
+
+const methods = {
+ IMAC: {
+ name: "Immobilized Metal",
+ prosite_strong: "H-H-H-H-H-H",
+ prosite_weak: "H-H-H",
+ elute: "Imidazole",
+ elute_concentrations: [0, 100, 500],
+ elute_concentration_unit: "mM"
+ },
+ GST: {
+ name: "Glutathione",
+ prosite_strong: "G-S-T-G-S-T",
+ prosite_weak: "G-S-T",
+ elute: "Glutathione",
+ elute_concentrations: [0, 5, 20],
+ elute_concentration_unit: "mM"
+ },
+ STREP_TAG: {
+ name: "Strep-tag",
+ prosite_strong: "W-S-H-P-Q-F-E-K",
+ prosite_weak: "W-S-H-P",
+ elute: "Desthiobiotin",
+ elute_concentrations: [0, 1, 5],
+ elute_concentration_unit: "mM"
+ },
+ FLAG: {
+ name: "FLAG",
+ prosite_strong: "D-Y-K-D-D-D-D-K",
+ prosite_weak: "D-Y-K-D",
+ elute: "FLAG peptide",
+ elute_concentrations: [0, 1, 10],
+ elute_concentration_unit: "mM"
+ },
+ MBP: {
+ name: "Maltose-Binding Protein",
+ prosite_strong: "D-E-E-D",
+ prosite_weak: "D-E",
+ elute: "Maltose",
+ elute_concentrations: [0, 10, 50],
+ elute_concentration_unit: "mM"
+ },
+ AVITAG_BIOTIN: {
+ name: "Biotinylated AviTag",
+ prosite_strong: "G-L-N-D-I-F-E-A-Q-K-I-E-W-H-E",
+ prosite_weak: "G-L-N-D-I-F-E-A-Q-K",
+ elute: "Biotin",
+ elute_concentrations: [0, 1, 10],
+ elute_concentration_unit: "mM"
+ },
+} as const satisfies { [index: string]: MethodData }
+
+const AffinityChromatography: React.FC = () => {
+ /*
+ * Raw FASTA text loaded from the uploaded file.
+ * This is sent directly to the backend.
+ */
+ const [fastaText, setFastaText] = useState('')
+
+ /**
+ * User-selected HIC parameters.
+ */
+ const [method, setMethod] = useState(methods.IMAC)
+ const [methodLastRan, setMethodLastRan] = useState(method);
+
+ /**
+ * Fractionation controls.
+ */
+ const [fractionCount, setFractionCount] = useState(80)
+ const [noise, setNoise] = useState(0.1)
+
+ /*
+ * Stores the full backend response after a successful run.
+ * The graphs and table are derived from this object.
+ */
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+
+ /*
+ * Stores the full backend response after a successful run.
+ * The graphs and table are derived from this object.
+ */
+ const [data, setData] = useState(null)
+
+ /*
+ * Reads an uploaded FASTA file into text and stores it in state.
+ */
+ const handleLoadFasta = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ const text = await file.text()
+ setFastaText(text)
+
+ // Clear the file input so the same file can be uploaded again if needed.
+ event.target.value = ''
+ }
+
+ /*
+ * Sends the current form values and FASTA content to the backend.
+ * On success, stores the returned HIC result in `data`.
+ */
+ const handleRun = async () => {
+ setLoading(true)
+ setError('')
+
+ try {
+ const response = await fetch(`${API_URL}/affinity_chromatography/process`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ fasta_content: fastaText,
+ fraction_count: fractionCount,
+ prosite_strong: method?.prosite_strong,
+ prosite_weak: method?.prosite_weak,
+ noise,
+ }),
+ })
+
+ const json = (await response.json()) as AffinityResponse
+ if (!response.ok || !json.ok || json.error) {
+ throw new Error(json.error || 'Failed to process Affinity Chromatography fractionation.')
+ }
+
+ setData(json)
+ setMethodLastRan(method)
+ } catch (err) {
+ setData(null)
+ setError(err instanceof Error ? err.message : 'Unexpected error')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Keep fraction count at least 1
+ const handleFractionCountChange = (value: number | null) => {
+ if (value == null) {
+ setFractionCount(1)
+ return
+ }
+
+ setFractionCount(Math.max(1, Math.floor(value)))
+ }
+
+ /**
+ * Generates step-wise elution concentration graph points
+ * @param data the fractions we're generating points based off of
+ * @param elution_concentrations the elute concentrations to use for the graph
+ * @returns array (x,y) representing a step elution gradient for these fractions
+ */
+ function elutionConcentrationPoints(
+ data: { fractions: Array<{ fractionIndex: number }>, fraction_counts: { wash_fractions: number, weak_fractions: number, strong_fractions: number }},
+ elution_concentrations: readonly [number, number, number]
+ ): Array<{ x: number; y: number }> {
+ const fractions = data.fractions ?? []
+
+ return fractions.map((f) => {
+ return {
+ x: f.fractionIndex,
+ y: f.fractionIndex <= data.fraction_counts.wash_fractions ? elution_concentrations[0]
+ : f.fractionIndex <= data.fraction_counts.weak_fractions + data.fraction_counts.wash_fractions ? elution_concentrations[1]
+ : elution_concentrations[2]
+ }
+ })
+ }
+
+ /*
+ * Convenience alias for the returned fractions.
+ * Used throughout the graphs and table.
+ */
+ const fractionRows = useMemo(() => data?.fractions ?? [], [data])
+
+ const elutionGradient = useMemo(() => (data && methodLastRan ? elutionConcentrationPoints(data, methodLastRan.elute_concentrations) : []), [data, methodLastRan])
+
+ const [showLineGraph, setShowLineGraph] = useState(true)
+ const [useLogScale, setUseLogScale] = useState(true)
+
+ return (
+
+
+
+
+ {/* Main control row for user inputs */}
+
+
+ Method
+
+
+
+
+
+
+
+ {/* Noise / overlap slider */}
+
+ Noise / Overlap
+ setNoise(v as number)} valueLabelDisplay='auto' />
+
+
+ {/* Main action buttons */}
+
+
+
+
+
+
+ {/* Show backend or request errors */}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {data && fractionRows && (
+ <>
+
+
+
+
+ setShowLineGraph(checked)} />} label='Line' sx={{ marginBottom: '0.25rem' }} />
+ setUseLogScale(checked)} />} label='Log Scale' sx={{ marginBottom: '0.25rem' }} />
+
+
+
+
+
+
+ Total: {data.counts.total} | Strongly bound: {data.counts.strongly_bound} | Weakly bound: {data.counts.weakly_bound} | Unbound: {data.counts.unbound} | Skipped: {data.counts.skipped}
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+export default AffinityChromatography
diff --git a/frontend/jbio-app/src/pages/HydrophobicInteractionFractionation.tsx b/frontend/jbio-app/src/pages/HydrophobicInteractionFractionation.tsx
index 1c8aaded..61d3a690 100644
--- a/frontend/jbio-app/src/pages/HydrophobicInteractionFractionation.tsx
+++ b/frontend/jbio-app/src/pages/HydrophobicInteractionFractionation.tsx
@@ -191,36 +191,6 @@ const HydrophobicInteractionFractionation: React.FC = () => {
}
}
- // Keep salt start non-negative, but do not force any relationship to salt end
- const handleSaltStartChange = (value: number | null) => {
- if (value == null) {
- setSaltStart(0)
- return
- }
-
- setSaltStart(Math.max(0, value))
- }
-
- // Keep salt end non-negative, but do not force any relationship to salt start
- const handleSaltEndChange = (value: number | null) => {
- if (value == null) {
- setSaltEnd(0)
- return
- }
-
- setSaltEnd(Math.max(0, value))
- }
-
- // Keep salt alpha non-negative
- const handleSaltAlphaChange = (value: number | null) => {
- if (value == null) {
- setSaltAlpha(0)
- return
- }
-
- setSaltAlpha(Math.max(0, value))
- }
-
// Keep fraction count at least 1
const handleFractionCountChange = (value: number | null) => {
if (value == null) {
@@ -315,7 +285,7 @@ const HydrophobicInteractionFractionation: React.FC = () => {
setShowWash(checked)} />} label='Show Wash' sx={{ marginBottom: '0.25rem' }} />
-
+
diff --git a/frontend/jbio-app/src/pages/IonExchangeFractionation.tsx b/frontend/jbio-app/src/pages/IonExchangeFractionation.tsx
index 78f5c5e1..b3acd3d9 100644
--- a/frontend/jbio-app/src/pages/IonExchangeFractionation.tsx
+++ b/frontend/jbio-app/src/pages/IonExchangeFractionation.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useRef, useState } from 'react'
+import React, { useMemo, useState } from 'react'
import {
Alert,
Box,
@@ -206,7 +206,7 @@ const IonExchangeFractionation: React.FC = () => {
setUseLogScale(checked)} />} label='Log Scale' sx={{ marginBottom: '0.25rem' }} />
setShowWash(checked)} />} label='Show Wash' sx={{ marginBottom: '0.25rem' }} />
-
+