diff --git a/changes/change_GLT-3764.md b/changes/change_GLT-3764.md new file mode 100644 index 00000000..4442596c --- /dev/null +++ b/changes/change_GLT-3764.md @@ -0,0 +1 @@ +Collapse lane-level values to reduce run metric visual clutter \ No newline at end of file diff --git a/ts/case-report.ts b/ts/case-report.ts index ebdfce85..b5d1bcbf 100644 --- a/ts/case-report.ts +++ b/ts/case-report.ts @@ -4,6 +4,7 @@ import { TableBuilder, TableDefinition } from "./component/table-builder"; import { Metric, MetricCategory, MetricSubcategory } from "./data/assay"; import { Case, Qcable } from "./data/case"; import { qcStatuses } from "./data/qc-status"; +import { makeTextDiv } from "./util/html-utils"; import { getMetricValue, getRequisitionMetricCellHighlight, @@ -24,7 +25,7 @@ import { Sample, subcategoryApplies as sampleSubcategoryApplies, } from "./data/sample"; -import { addTextDiv, makeNameDiv, makeTextDiv } from "./util/html-utils"; +import { addTextDiv, makeNameDiv } from "./util/html-utils"; import { getMetricRequirementText } from "./util/metrics"; import { get } from "./util/requests"; import { siteConfig } from "./util/site-config"; @@ -202,7 +203,7 @@ const sampleGateMetricsDefinition: TableDefinition = { headingClass: "print-width-20", child: true, addChildContents(object, parent, fragment) { - addMetricValueContents(parent.sample, [object], fragment, false); + addMetricValueContents(parent.sample, [object], fragment, false, false); }, getCellHighlight(reportSample, metric) { if (metric == null) { @@ -488,6 +489,7 @@ async function loadCase(caseId: string) { new TableBuilder(sampleGateMetricsDefinition, "receiptTableContainer").build( receipts ); + const extractions = getReportSamples( data, data.tests.flatMap((test) => test.extractions), @@ -497,6 +499,7 @@ async function loadCase(caseId: string) { sampleGateMetricsDefinition, "extractionTableContainer" ).build(extractions); + const libraryPreps = getReportSamples( data, data.tests.flatMap((test) => test.libraryPreparations), @@ -506,6 +509,7 @@ async function loadCase(caseId: string) { sampleGateMetricsDefinition, "libraryPreparationTableContainer" ).build(libraryPreps); + const libraryQualifications = getReportSamples( data, data.tests.flatMap((test) => test.libraryQualifications), @@ -515,6 +519,7 @@ async function loadCase(caseId: string) { sampleGateMetricsDefinition, "libraryQualificationTableContainer" ).build(libraryQualifications); + const fullDepths = getReportSamples( data, data.tests.flatMap((test) => test.fullDepthSequencings), diff --git a/ts/component/table-builder.ts b/ts/component/table-builder.ts index 24776c17..e8871dc1 100644 --- a/ts/component/table-builder.ts +++ b/ts/component/table-builder.ts @@ -880,4 +880,4 @@ function getElement(element?: Type) { export const legendAction: StaticAction = { title: "Legend", handler: toggleLegend, -}; +}; \ No newline at end of file diff --git a/ts/data/case.ts b/ts/data/case.ts index 2d840804..a172b90d 100644 --- a/ts/data/case.ts +++ b/ts/data/case.ts @@ -37,9 +37,9 @@ export interface Donor { export interface Qcable { qcPassed?: boolean; qcReason?: string; - qcNote?: string; qcUser?: string; qcDate?: string; + qcNote?: string; dataReviewPassed?: boolean; dataReviewUser?: string; dataReviewDate?: string; diff --git a/ts/data/sample.ts b/ts/data/sample.ts index f46a5a83..68cfb1cf 100644 --- a/ts/data/sample.ts +++ b/ts/data/sample.ts @@ -14,7 +14,7 @@ import { import { Tooltip } from "../component/tooltip"; import { urls } from "../util/urls"; import { Metric, MetricCategory, MetricSubcategory } from "./assay"; -import { Donor, Qcable, Run } from "./case"; +import { Donor, Lane, Qcable, Run } from "./case"; import { QcStatus, qcStatuses } from "./qc-status"; import { anyFail, @@ -574,7 +574,8 @@ export function addMetricValueContents( sample: Sample, metrics: Metric[], fragment: DocumentFragment, - addTooltip: boolean + addTooltip: boolean, + shouldCollapse: boolean = true ) { const metricNames = metrics .map((metric) => metric.name) @@ -586,16 +587,23 @@ export function addMetricValueContents( // handle metrics that have multiple values switch (metricName) { case METRIC_LABEL_Q30: - addQ30Contents(sample, metrics, fragment, addTooltip); + addQ30Contents(sample, metrics, fragment, addTooltip, shouldCollapse); return; case METRIC_LABEL_CLUSTERS_PF_1: case METRIC_LABEL_CLUSTERS_PF_2: - addClustersPfContents(sample, metrics, fragment, addTooltip); + addClustersPfContents( + sample, + metrics, + fragment, + addTooltip, + shouldCollapse + ); return; case METRIC_LABEL_PHIX: - addPhixContents(sample, metrics, fragment, addTooltip); + addPhixContents(sample, metrics, fragment, addTooltip, shouldCollapse); return; } + if (metrics.every((metric) => metric.thresholdType === "BOOLEAN")) { // pass/fail based on QC status if (sample.qcPassed) { @@ -646,11 +654,47 @@ export function addMetricValueContents( } } +function createCollapseButton(contentWrapper: HTMLElement): HTMLButtonElement { + const toggleButton = document.createElement("button"); + toggleButton.classList.add("fa-solid", "fa-caret-down", "text-sm"); + toggleButton.classList.add("active:text-green-200"); + + toggleButton.addEventListener("click", () => { + const isExpanded = contentWrapper.classList.toggle("hidden"); + toggleButton.classList.toggle("fa-caret-down", isExpanded); + toggleButton.classList.toggle("fa-caret-up", !isExpanded); + }); + + return toggleButton; +} + +function handleCollapse( + metricDisplay: HTMLElement, + contentWrapper: HTMLElement, + fragment: DocumentFragment, + shouldCollapse: boolean +) { + const metricWrapper = document.createElement("div"); + metricWrapper.className = "flex space-x-1"; + + metricWrapper.appendChild(metricDisplay); + + if (shouldCollapse) { + const toggleButton = createCollapseButton(contentWrapper); + metricWrapper.appendChild(toggleButton); + contentWrapper.classList.add("hidden"); + } + + fragment.appendChild(metricWrapper); + fragment.appendChild(contentWrapper); +} + function addQ30Contents( sample: Sample, metrics: Metric[], fragment: DocumentFragment, - addTooltip: boolean + addTooltip: boolean, + shouldCollapse: boolean = true ) { // run-level value is checked, but run and lane-level are both displayed if (!sample.run || !sample.run.percentOverQ30) { @@ -661,33 +705,34 @@ function addQ30Contents( } return; } - fragment.appendChild( - makeMetricDisplay(sample.run.percentOverQ30, metrics, addTooltip) + + const metricDisplay = makeMetricDisplay( + sample.run.percentOverQ30, + metrics, + addTooltip ); - sample.run.lanes.forEach((lane) => { - if (!lane.percentOverQ30Read1) { - return; - } + + const contentWrapper = document.createElement("div"); + sample.run!.lanes.forEach((lane) => { + if (!lane.percentOverQ30Read1) return; let text = sample.run?.lanes.length === 1 ? "" : `L${lane.laneNumber} `; text += `R1: ${lane.percentOverQ30Read1}`; - if (lane.percentOverQ30Read2) { - text += ";"; - } - if (lane.percentOverQ30Read2) { - text += ` R2: ${lane.percentOverQ30Read2}`; - } + if (lane.percentOverQ30Read2) text += `; R2: ${lane.percentOverQ30Read2}`; const div = document.createElement("div"); div.classList.add("whitespace-nowrap", "print-hanging"); div.appendChild(document.createTextNode(text)); - fragment.appendChild(div); + contentWrapper.appendChild(div); }); + + handleCollapse(metricDisplay, contentWrapper, fragment, shouldCollapse); } function addClustersPfContents( sample: Sample, metrics: Metric[], fragment: DocumentFragment, - addTooltip: boolean + addTooltip: boolean, + shouldCollapse: boolean = true ) { // For joined flowcells, run-level is checked // For non-joined, each lane is checked @@ -700,17 +745,20 @@ function addClustersPfContents( } return; } + const separatedMetrics = separateRunVsLaneMetrics(metrics, sample.run); const perRunMetrics = separatedMetrics[0]; const perLaneMetrics = separatedMetrics[1]; const tooltip = Tooltip.getInstance(); const runDiv = document.createElement("div"); const divisorUnit = getDivisorUnit(metrics); + runDiv.innerText = formatMetricValue( sample.run.clustersPf, metrics, divisorUnit ); + if (addTooltip && perRunMetrics.length) { // whether originally or not, these metrics are per run const addContents = (fragment: DocumentFragment) => { @@ -720,16 +768,17 @@ function addClustersPfContents( }; tooltip.addTarget(runDiv, addContents); } - fragment.appendChild(runDiv); if (sample.run.lanes.length > 1) { + const contentWrapper = document.createElement("div"); const addContents = (fragment: DocumentFragment) => { // these metrics are per lane perLaneMetrics.forEach((metric) => addMetricRequirementText(metric, fragment) ); }; - sample.run.lanes.forEach((lane) => { + + sample.run!.lanes.forEach((lane) => { if (lane.clustersPf) { const laneDiv = document.createElement("div"); laneDiv.classList.add("whitespace-nowrap", "print-hanging"); @@ -741,9 +790,13 @@ function addClustersPfContents( if (addTooltip && perLaneMetrics.length) { tooltip.addTarget(laneDiv, addContents); } - fragment.appendChild(laneDiv); + contentWrapper.appendChild(laneDiv); } }); + + handleCollapse(runDiv, contentWrapper, fragment, shouldCollapse); + } else { + fragment.appendChild(runDiv); } } @@ -836,7 +889,8 @@ function addPhixContents( sample: Sample, metrics: Metric[], fragment: DocumentFragment, - addTooltip: boolean + addTooltip: boolean, + shouldCollapse: boolean = true ) { // There is no run-level metric, so we check each read of each lane if ( @@ -859,30 +913,46 @@ function addPhixContents( }; const multipleLanes = sample.run.lanes.length > 1; + + const minPhixValue = Math.min( + ...sample.run.lanes + .flatMap((lane) => [lane.percentPfixRead1, lane.percentPfixRead2]) + .filter((value) => typeof value === "number") + ); + + const contentWrapper = document.createElement("div"); + sample.run.lanes.forEach((lane) => { const laneDiv = document.createElement("div"); laneDiv.classList.add("whitespace-nowrap", "print-hanging"); - let text = multipleLanes ? `L${lane.laneNumber}` : ""; + + if (multipleLanes) { + const laneLabel = document.createTextNode(`L${lane.laneNumber}: `); + laneDiv.appendChild(laneLabel); + } + if (nullOrUndefined(lane.percentPfixRead1)) { - const span = document.createElement("span"); - span.innerText = text + ": "; - laneDiv.appendChild(span); laneDiv.appendChild(makeNotFoundIcon()); } else { - if (multipleLanes) { - text += " "; - } - text += `R1: ${lane.percentPfixRead1}`; - if (!nullOrUndefined(lane.percentPfixRead2)) { - text += `; R2: ${lane.percentPfixRead2}`; - } - laneDiv.innerText = text; + const text = + `R1: ${lane.percentPfixRead1}` + + (!nullOrUndefined(lane.percentPfixRead2) + ? `; R2: ${lane.percentPfixRead2}` + : ""); + const textNode = document.createTextNode(text); + laneDiv.appendChild(textNode); + if (addTooltip) { tooltip.addTarget(laneDiv, addContents); } } - fragment.appendChild(laneDiv); + contentWrapper.appendChild(laneDiv); }); + + const minPhixDiv = document.createElement("div"); + minPhixDiv.innerText = `${minPhixValue.toFixed(2)}+/R`; + + handleCollapse(minPhixDiv, contentWrapper, fragment, shouldCollapse); } function getPhixHighlight(