Skip to content

Commit 0adb1b5

Browse files
committed
add depth and breadth support for coverage plot
1 parent 7120011 commit 0adb1b5

2 files changed

Lines changed: 116 additions & 60 deletions

File tree

frontend/src/modules/analysis/analysis-data/analysis-data.component.tsx

Lines changed: 105 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
1-
import React, { FunctionComponent, useEffect, useState } from "react";
1+
import React, { FunctionComponent, useEffect, useState, useMemo } from "react";
22
import { IAnalysisDataProps } from "./analysis-data.interfaces";
33
import { socket } from "../../../app.component";
44
import { Chart } from "react-google-charts";
55
import axios from "axios";
6+
import { Dropdown, Modal, Button } from "react-bootstrap";
67
import './analysis-data.component.css';
78

9+
const POLLING_INTERVAL_MS = 10000;
10+
811
const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data }) => {
912
const [analysisData, setAnalysisData] = useState(data);
1013
const [coverageData, setCoverageData] = useState<any[]>([]);
14+
const [coverageMap, setCoverageMap] = useState(new Map<string, any>());
1115
const [listenerRunning, setListenerRunning] = useState(false);
12-
const [error, setError] = useState("");
13-
const [metric, setMetric] = useState<'fold_coverage'>('fold_coverage');
16+
const [error, setError] = useState<string | null>(null);
17+
const [fetchError, setFetchError] = useState<string | null>(null);
18+
const [metric, setMetric] = useState<'depth' | 'breadth'>('depth');
1419
const [showConfirmModal, setShowConfirmModal] = useState(false);
20+
type TimeUnit = 'seconds' | 'minutes' | 'hours' | 'days';
21+
const [timeUnit, setTimeUnit] = useState<TimeUnit>('seconds');
1522

16-
// Extract threshold from the first query, default to 100 if invalid
1723
const threshold = data.data.queries[0]?.threshold ? parseFloat(data.data.queries[0].threshold) : 100;
1824

25+
const unitLabels: Record<TimeUnit, string> = {
26+
seconds: 's',
27+
minutes: 'min',
28+
hours: 'h',
29+
days: 'd'
30+
};
31+
1932
useEffect(() => {
2033
socket.emit('check_fastq_file_listener', { projectId: analysisData.data.projectId });
2134

@@ -25,13 +38,13 @@ const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data })
2538
const handleListenerStarted = (data: { projectId: string }) => {
2639
if (data.projectId === analysisData.data.projectId) {
2740
setListenerRunning(true);
28-
setError("");
41+
setError(null);
2942
}
3043
};
3144
const handleListenerStopped = (data: { projectId: string }) => {
3245
if (data.projectId === analysisData.data.projectId) {
3346
setListenerRunning(false);
34-
setError("");
47+
setError(null);
3548
}
3649
};
3750
const handleListenerError = (data: { projectId: string; error: string }) => {
@@ -47,21 +60,30 @@ const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data })
4760
socket.on('fastq_file_listener_error', handleListenerError);
4861

4962
const fetchData = async () => {
63+
setFetchError(null);
5064
try {
5165
const coverageRes = await axios.get(`http://localhost:5007/get_coverage?projectId=${analysisData.data.projectId}`);
52-
setCoverageData(coverageRes.data);
66+
const newCoverageData = coverageRes.data;
67+
setCoverageData(newCoverageData);
68+
const map = new Map<string, any>();
69+
newCoverageData.forEach((entry: { timestamp: any; reference: any }) => {
70+
const key = `${entry.timestamp}-${entry.reference}`;
71+
map.set(key, entry);
72+
});
73+
setCoverageMap(map);
5374

5475
const analysisRes = await axios.get(`http://localhost:5007/get_analysis_info?uid=${analysisData.data.projectId}`);
5576
if (analysisRes.data.status === 200) {
5677
setAnalysisData(analysisRes.data);
5778
}
5879
} catch (err) {
5980
console.error("Error fetching data:", err);
81+
setFetchError("Failed to fetch data. Please try again later.");
6082
}
6183
};
6284

6385
fetchData();
64-
const interval = setInterval(fetchData, 10000);
86+
const interval = setInterval(fetchData, POLLING_INTERVAL_MS);
6587

6688
return () => {
6789
clearInterval(interval);
@@ -75,30 +97,52 @@ const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data })
7597
const formatCoverageData = () => {
7698
const refs = [...new Set(coverageData.map(d => d.reference))];
7799
const times = [...new Set(coverageData.map(d => d.timestamp))].sort();
100+
if (times.length === 0) return [];
101+
78102
const startTime = new Date(times[0]).getTime();
103+
const conversionFactors: Record<TimeUnit, number> = {
104+
seconds: 1,
105+
minutes: 60,
106+
hours: 3600,
107+
days: 86400
108+
};
109+
const factor = conversionFactors[timeUnit] || 1;
79110

80-
const header = [{ type: 'number', label: 'Elapsed Time (s)', role: '' }];
111+
const header = [{ type: 'number', label: `Elapsed Time (${unitLabels[timeUnit]})`, role: '' }];
81112
refs.forEach(ref => {
82113
header.push({ type: 'number', label: ref, role: '' });
83114
header.push({ type: 'string', label: 'for', role: 'tooltip' });
84115
});
116+
if (metric === "depth" && !isNaN(threshold)) {
117+
header.push({ type: 'number', label: 'Threshold', role: '' });
118+
header.push({ type: 'string', label: 'for', role: 'tooltip' });
119+
}
85120

86121
const rows = times.map(time => {
87122
const elapsedSeconds = (new Date(time).getTime() - startTime) / 1000;
88-
const row: (number | string)[] = [elapsedSeconds];
123+
const elapsedTime = elapsedSeconds / factor;
124+
const row: (number | string)[] = [elapsedTime];
89125
refs.forEach(ref => {
90-
const entry = coverageData.find(d => d.timestamp === time && d.reference === ref);
91-
const y = entry ? entry.fold_coverage : 0;
92-
const tooltip = `Time: ${time}\n${ref}: ${y.toFixed(2)}x`;
126+
const key = `${time}-${ref}`;
127+
const entry = coverageMap.get(key);
128+
const y = entry ? (metric === "depth" ? entry.depth : entry.breadth) : 0;
129+
const unit = metric === "depth" ? 'X' : '%';
130+
const tooltip = `Time: ${time}\nElapsed:${elapsedTime.toFixed(2)} ${unitLabels[timeUnit]}\n${ref}: ${y.toFixed(2)}${unit}`;
93131
row.push(y);
94132
row.push(tooltip);
95133
});
134+
if (metric === "depth" && !isNaN(threshold)) {
135+
row.push(threshold);
136+
row.push(`Threshold: ${threshold}`);
137+
}
96138
return row;
97139
});
98140

99141
return [header, ...rows];
100142
};
101143

144+
const formattedData = useMemo(() => formatCoverageData(), [coverageMap, metric, timeUnit]);
145+
102146
const getSankeyData = () => {
103147
if (coverageData.length === 0) return [['From', 'To', 'Weight']];
104148
const timestamps = coverageData.map(d => new Date(d.timestamp).getTime());
@@ -137,20 +181,19 @@ const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data })
137181
setShowConfirmModal(false);
138182
};
139183

140-
// Compute number of references for series indexing
141184
const refs = [...new Set(coverageData.map(d => d.reference))];
142185
const numRefs = refs.length;
143186

144187
const chartOptions = {
145-
title: 'Fold Coverage Over Time',
146-
hAxis: { title: 'Elapsed Time (s)' },
147-
vAxis: { title: 'Fold Coverage (x)', minValue: 0 },
188+
title: `${metric === "depth" ? "Depth of Coverage" : "Breadth of Coverage"} Over Time`,
189+
hAxis: { title: `Elapsed Time (${unitLabels[timeUnit]})` },
190+
vAxis: { title: metric === "depth" ? 'Depth (X)' : 'Breadth (%)', minValue: 0 },
148191
legend: { position: 'bottom' },
149192
colors: ['#00B0BD', '#004E5A', '#FF6A45', '#27AE60'],
150193
chartArea: { width: '80%', height: '70%' },
151194
animation: { startup: true, duration: 1000, easing: 'out' },
152-
series: !isNaN(threshold) ? {
153-
[numRefs]: { lineDashStyle: [4, 4], color: 'red', lineWidth: 2 }
195+
series: metric === "depth" && !isNaN(threshold) ? {
196+
[refs.length]: { lineDashStyle: [4, 4], color: 'red', lineWidth: 2, pointSize: 0 }
154197
} : {}
155198
};
156199

@@ -183,6 +226,7 @@ const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data })
183226
</div>
184227
</div>
185228
{error && <div className="ont-alert nano-alert-danger">{error}</div>}
229+
{fetchError && <div className="ont-alert nano-alert-danger pl-2">{fetchError}</div>}
186230
<div className="nano-actions">
187231
{listenerRunning ? (
188232
<button className="btn btn-danger nano-btn" onClick={handleStopFileListener}>
@@ -202,22 +246,37 @@ const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data })
202246

203247
{/* Coverage Plot */}
204248
<div className="ont-card nano-chart-card">
205-
<div className="nano-card-header">
249+
<div className="nano-card-header d-flex justify-content-between align-items-center">
206250
<h3 className="nano-card-title">Coverage Over Time</h3>
207-
<select
208-
value={metric}
209-
onChange={(e) => setMetric(e.target.value as 'fold_coverage')}
210-
className="form-control w-auto d-inline-block ml-2"
211-
>
212-
<option value="fold_coverage">Average Depth</option>
213-
<option value="breadth">Breadth of Coverage (%)</option>
214-
</select>
251+
<div className="d-flex gap-2">
252+
<Dropdown>
253+
<Dropdown.Toggle variant="secondary" id="metricDropdown" size="sm">
254+
{metric.charAt(0).toUpperCase() + metric.slice(1)}
255+
</Dropdown.Toggle>
256+
<Dropdown.Menu>
257+
<Dropdown.Item onClick={() => setMetric('depth')}>Depth</Dropdown.Item>
258+
<Dropdown.Item onClick={() => setMetric('breadth')}>Breadth</Dropdown.Item>
259+
</Dropdown.Menu>
260+
</Dropdown>
261+
<Dropdown>
262+
<Dropdown.Toggle variant="secondary" id="timeUnitDropdown" size="sm">
263+
{timeUnit.charAt(0).toUpperCase() + timeUnit.slice(1)}
264+
</Dropdown.Toggle>
265+
<Dropdown.Menu>
266+
<Dropdown.Item onClick={() => setTimeUnit('seconds')}>Seconds</Dropdown.Item>
267+
<Dropdown.Item onClick={() => setTimeUnit('minutes')}>Minutes</Dropdown.Item>
268+
<Dropdown.Item onClick={() => setTimeUnit('hours')}>Hours</Dropdown.Item>
269+
<Dropdown.Item onClick={() => setTimeUnit('days')}>Days</Dropdown.Item>
270+
</Dropdown.Menu>
271+
</Dropdown>
272+
</div>
215273
</div>
216274
<div className="nano-card-body">
217-
{coverageData.length > 0 ? (
275+
{timeUnit && coverageData.length > 0 ? (
218276
<Chart
277+
key={metric}
219278
chartType="LineChart"
220-
data={formatCoverageData()}
279+
data={formattedData}
221280
options={chartOptions}
222281
width="100%"
223282
height="400px"
@@ -267,26 +326,22 @@ const AnalysisDataComponent: FunctionComponent<IAnalysisDataProps> = ({ data })
267326
</div>
268327

269328
{/* Confirmation Modal */}
270-
{showConfirmModal && (
271-
<div className="nano-modal-overlay">
272-
<div className="nano-modal">
273-
<div className="nano-modal-header">
274-
<h4 className="nano-modal-title">Confirm Removal</h4>
275-
</div>
276-
<div className="nano-modal-body">
277-
<p>Are you sure you want to remove this analysis? This action cannot be undone.</p>
278-
</div>
279-
<div className="nano-modal-footer">
280-
<button className="btn btn-outline-secondary nano-btn" onClick={cancelRemoveAnalysis}>
281-
Cancel
282-
</button>
283-
<button className="btn btn-danger nano-btn" onClick={confirmRemoveAnalysis}>
284-
Remove
285-
</button>
286-
</div>
287-
</div>
288-
</div>
289-
)}
329+
<Modal show={showConfirmModal} onHide={cancelRemoveAnalysis}>
330+
<Modal.Header closeButton>
331+
<Modal.Title>Confirm Removal</Modal.Title>
332+
</Modal.Header>
333+
<Modal.Body>
334+
<p>Are you sure you want to remove this analysis? This action cannot be undone.</p>
335+
</Modal.Body>
336+
<Modal.Footer>
337+
<Button variant="outline-secondary" onClick={cancelRemoveAnalysis}>
338+
Cancel
339+
</Button>
340+
<Button variant="danger" onClick={confirmRemoveAnalysis}>
341+
Remove
342+
</Button>
343+
</Modal.Footer>
344+
</Modal>
290345
</div>
291346
);
292347
};

server/app/main/routes.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def get_analysis_info():
139139
break
140140

141141
if not found:
142-
return json.dumps({'status': 404, 'message': "Couldn't find the analysis data with UID: " + uid})
142+
return json.dumps({'status': 404, 'message': "Couldn't find the analysis data with UID: " + str(uid)})
143143
else:
144144

145145
alert_cfg_file = os.path.join(nanocas_path, 'alertinfo.cfg')
@@ -173,7 +173,7 @@ def analysis():
173173
# if nanocas_location exists
174174
if subprocess.call(['ls', nanocas_location + 'alertinfo.cfg']) == 0:
175175
# if minion location exists
176-
if subprocess.call(['ls', minion]) == 0:
176+
if minion is not None and subprocess.call(['ls', minion]) == 0:
177177
# locations are valid
178178

179179
# is another user already on that page? If so, bounce this user
@@ -255,12 +255,13 @@ def get_coverage():
255255
lines = f.readlines()[1:] # Skip header
256256
data = []
257257
for line in lines:
258-
timestamp, ref, fold_coverage, read_count = line.strip().split(',')
258+
timestamp, ref, depth, breadth, read_count = line.strip().split(',')
259259
name = ref_to_name.get(ref, ref) # Map reference to alert sequence name
260260
data.append({
261261
'timestamp': timestamp,
262262
'reference': name,
263-
'fold_coverage': float(fold_coverage),
263+
'depth': float(depth),
264+
'breadth': float(breadth),
264265
'read_count': int(read_count)
265266
})
266267
return jsonify(data)
@@ -270,18 +271,18 @@ def get_coverage():
270271

271272
@main.route('/index_devices', methods=['GET'])
272273
def index_devices():
273-
if (request.method == 'GET'):
274+
if request.method == 'GET':
274275
devices = []
275276
indexed_devices = LinuxNotification.index_devices()
276-
if len(indexed_devices) > 0:
277+
if indexed_devices:
277278
for device in indexed_devices:
278-
if device.state != "STATE_HARDWARE_REMOVED" \
279-
or device.state != "STATE_HARDWARE_ERROR" \
280-
or device.state != "STATE_SOFTWARE_ERROR":
279+
if device.state not in ["STATE_HARDWARE_REMOVED", "STATE_HARDWARE_ERROR", "STATE_SOFTWARE_ERROR"]:
281280
devices.append(device.name)
282281
LinuxNotification.send_notification(device.name, "Device discovered by nanocas", severity=1)
283-
282+
# Always return a valid JSON response
284283
return json.dumps(devices)
284+
# Explicitly return an empty list if not GET (should not happen)
285+
return json.dumps([])
285286

286287
def validate_cache(cache_path=CACHE_PATH):
287288
if not os.path.isfile(cache_path):

0 commit comments

Comments
 (0)