dashboard = await FileAttachment("output/resazurin/dashboard-data.csv").csv({typed: true})
// Optional stats generated server-side: pairwise and omnibus tests on AUC
stats = await FileAttachment("output/resazurin/dashboard-stats.csv").csv({typed: true}).catch(() => [])
cleanDashboard = dashboard.filter(d =>
d.experiment_dir != null &&
d.plate_id != null &&
d.well_id != null &&
d.well_id !== "" &&
Number.isFinite(d.time_hr) &&
Number.isFinite(d.value)
)
experimentValues = [...new Set(cleanDashboard.map(d => d.experiment_dir))]
experimentSortKey = (name) => {
const m = /^([0-9]{8})/.exec(name)
if (!m) return Number.NEGATIVE_INFINITY
return Number.parseInt(m[1], 10)
}
mostRecentExperiment = experimentValues
.slice()
.sort((a, b) => {
const ka = experimentSortKey(a)
const kb = experimentSortKey(b)
if (ka !== kb) return kb - ka
return a.localeCompare(b)
})[0]
experiments = ["All", ...experimentValues]
viewof experimentSel = Inputs.select(experiments, {
label: "Experiment",
value: mostRecentExperiment || "All"
})
activeExperimentSel = experiments.includes(experimentSel) ? experimentSel : (mostRecentExperiment || "All")
plateCandidates = cleanDashboard
.filter(d => activeExperimentSel === "All" || d.experiment_dir === activeExperimentSel)
plates = ["All", ...new Set(plateCandidates.map(d => d.plate_id))]
defaultPlateSel = "All"
viewof plateSel = Inputs.select(plates, {
label: "Plate",
value: defaultPlateSel
})
activePlateSel = plates.includes(plateSel) ? plateSel : "All"
selectionRows = cleanDashboard
.filter(d => (activeExperimentSel === "All" || d.experiment_dir === activeExperimentSel) &&
(activePlateSel === "All" || d.plate_id === activePlateSel))
wells = [...new Set(selectionRows.map(d => d.well_id))].sort()
wellsWithAll = ["All", ...wells]
viewof wellSel = Inputs.select(wellsWithAll, {
label: "Well(s)",
multiple: true,
value: ["All"]
})
rawWellSel = Array.isArray(wellSel) ? wellSel : [wellSel]
validWellSel = rawWellSel.filter(w => wellsWithAll.includes(w))
allWellsSelected = validWellSel.length === 0 || validWellSel.includes("All")
selectedWells = allWellsSelected
? wells
: validWellSel.filter(w => w !== "All")
normalizeTextValue = v => {
if (v == null) return ""
const trimmed = `${v}`.trim()
return trimmed.replace(/^['\"](.*)['\"]$/, "$1").trim()
}
nonEmptyString = v => normalizeTextValue(v) !== ""
invalidGroupTokens = new Set(["NA", "N/A", "NULL", "NAN", "<NA>"])
validGroupValue = v => {
const normalized = normalizeTextValue(v)
if (normalized === "") return false
const norm = normalized.toUpperCase()
return !invalidGroupTokens.has(norm)
}
isExcluded = d => {
const raw = d.exclude_from_analysis
if (raw === true || raw === 1) return true
const txt = normalizeTextValue(raw).toUpperCase()
return ["TRUE", "T", "1", "YES", "Y"].includes(txt)
}
groupColumns = selectionRows.length > 0
? Object.keys(selectionRows[0]).filter(k => /_group$/.test(k) && selectionRows.some(d => validGroupValue(d[k])))
: []
noneGroupOption = "None (plot by well)"
viewof groupColSel = Inputs.select(
[noneGroupOption, ...groupColumns],
{
label: "Group column",
value: noneGroupOption
}
)
activeGroupCol = groupColumns.includes(groupColSel) ? groupColSel : null
noneWithinGroupOption = "None (no within-group split)"
withinGroupColumns = activeGroupCol === null
? []
: groupColumns.filter(
k => k !== activeGroupCol && selectionRows.some(d => validGroupValue(d[k]))
)
viewof withinGroupColSel = Inputs.select(
[noneWithinGroupOption, ...withinGroupColumns],
{
label: "Within-group split",
value: noneWithinGroupOption,
disabled: activeGroupCol === null
}
)
activeWithinGroupCol = withinGroupColumns.includes(withinGroupColSel) ? withinGroupColSel : null
effectiveSeriesCol = activeWithinGroupCol === null ? activeGroupCol : activeWithinGroupCol
naturalCompare = (a, b) => `${a}`.localeCompare(`${b}`, undefined, {
numeric: true,
sensitivity: "base"
})
groupValues = activeGroupCol === null
? []
: [...new Set(
// Exclude wells marked as blanks when constructing available group values
selectionRows
.filter(d => !isBlank(d))
.map(d => normalizeTextValue(d[activeGroupCol]))
.filter(validGroupValue)
)].sort(naturalCompare)
viewof groupValSel = Inputs.select(
["All", ...groupValues],
{
label: "Group value",
value: "All"
}
)
measurementColumns = selectionRows.length > 0
? Object.keys(selectionRows[0]).filter(
k => /_measur(?:e)?ment$/.test(k) && selectionRows.some(d => Number.isFinite(Number(d[k])))
)
: []
viewof measurementColSel = Inputs.select(
measurementColumns.length > 0 ? measurementColumns : ["(none available)"],
{
label: "Measurement column",
value: measurementColumns.length > 0 ? measurementColumns[0] : "(none available)"
}
)
activeMeasurementCol = measurementColumns.includes(measurementColSel) ? measurementColSel : null
viewof aucSizeColSel = Inputs.select(
measurementColumns.length > 0 ? measurementColumns : ["(none available)"],
{
label: "AUC size column",
value: measurementColumns.includes("length_mm_measurement")
? "length_mm_measurement"
: (measurementColumns.includes("area_mm2_measurement")
? "area_mm2_measurement"
: (measurementColumns.length > 0 ? measurementColumns[0] : "(none available)"))
}
)
activeAucSizeCol = measurementColumns.includes(aucSizeColSel) ? aucSizeColSel : null
viewModes = [
"Raw fluorescence",
"Delta fluorescence",
"Normalized fluorescence",
"Measurement-normalized fluorescence",
"Paper metabolism (fold-change / selected measurement)"
]
viewof dataViewSel = Inputs.select(viewModes, {
label: "Data view",
value: "Raw fluorescence"
})
viewof errorBarSel = Inputs.select(["Hide error bars", "Show error bars"], {
label: "Error bars",
value: "Hide error bars"
})
viewof plotStyleSel = Inputs.select(["Line plot", "Box plot", "AUC summary (total area per trajectory)", "Cumulative AUC over time (running area)"], {
label: "Plot style",
value: "Line plot"
})
boxPlotMode = plotStyleSel === "Box plot"
aucPlotMode = plotStyleSel === "AUC summary (total area per trajectory)"
cumAucPlotMode = plotStyleSel === "Cumulative AUC over time (running area)"
viewof aggregationSel = (boxPlotMode || aucPlotMode)
? Inputs.select(["All", ...effectiveSeriesValues], {
label: "Group display",
multiple: true,
value: ["All"],
disabled: effectiveSeriesCol === null
})
: Inputs.radio(["Mean", "Individual replicates"], {
label: "Group display",
value: "Mean",
disabled: effectiveSeriesCol === null // Only relevant when grouping
})
activeGroupVal = groupValues.includes(groupValSel) ? groupValSel : "All"
seriesRows = selectionRows.filter(d =>
(allWellsSelected || selectedWells.includes(d.well_id)) &&
(activeGroupCol === null || validGroupValue(d[activeGroupCol])) &&
(activeGroupCol === null ||
activeGroupVal === "All" ||
normalizeTextValue(d[activeGroupCol]) === normalizeTextValue(activeGroupVal)) &&
!isExcluded(d)
)
effectiveSeriesValues = effectiveSeriesCol === null
? []
: [...new Set(
// Exclude wells explicitly marked as blanks when constructing group values
seriesRows
.filter(d => !isBlank(d))
.map(d => normalizeTextValue(d[effectiveSeriesCol]))
.filter(validGroupValue)
)].sort(naturalCompare)
rawGroupDisplaySel = Array.isArray(aggregationSel) ? aggregationSel : [aggregationSel]
validGroupDisplaySel = rawGroupDisplaySel.filter(v => v === "All" || effectiveSeriesValues.includes(v))
allGroupsDisplayed = validGroupDisplaySel.length === 0 || validGroupDisplaySel.includes("All")
selectedDisplayGroups = allGroupsDisplayed
? effectiveSeriesValues
: validGroupDisplaySel.filter(v => v !== "All")
activeAggregationMode = boxPlotMode
? "Mean"
: (aucPlotMode
? "Mean"
: (aggregationSel === "Individual replicates" ? "Individual replicates" : "Mean"))
filtered = selectionRows.filter(d =>
(allWellsSelected || selectedWells.includes(d.well_id)) &&
(activeGroupCol === null || validGroupValue(d[activeGroupCol])) &&
(activeGroupCol === null || activeGroupVal === "All" ||
normalizeTextValue(d[activeGroupCol]) === normalizeTextValue(activeGroupVal)) &&
(effectiveSeriesCol === null || validGroupValue(d[effectiveSeriesCol])) &&
(effectiveSeriesCol === null ||
((boxPlotMode || aucPlotMode)
// When "All" is selected, interpret that as "all effective series values" (which
// already excludes blank wells via `effectiveSeriesValues`) rather than literally
// allowing every raw group value.
? (allGroupsDisplayed
? effectiveSeriesValues.includes(normalizeTextValue(d[effectiveSeriesCol]))
: selectedDisplayGroups.includes(normalizeTextValue(d[effectiveSeriesCol])))
: true))
&& !isExcluded(d)
)
excludedCandidateRows = selectionRows.filter(d =>
(allWellsSelected || selectedWells.includes(d.well_id)) &&
(activeGroupCol === null || validGroupValue(d[activeGroupCol])) &&
(activeGroupCol === null || activeGroupVal === "All" ||
normalizeTextValue(d[activeGroupCol]) === normalizeTextValue(activeGroupVal)) &&
(effectiveSeriesCol === null || validGroupValue(d[effectiveSeriesCol])) &&
(effectiveSeriesCol === null ||
((boxPlotMode || aucPlotMode)
? (allGroupsDisplayed
? effectiveSeriesValues.includes(normalizeTextValue(d[effectiveSeriesCol]))
: selectedDisplayGroups.includes(normalizeTextValue(d[effectiveSeriesCol])))
: true)) &&
isExcluded(d)
)
excludedSummaries = (() => {
const byWell = new Map()
for (const d of excludedCandidateRows) {
const key = `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
if (!byWell.has(key)) {
byWell.set(key, {
plate_id: d.plate_id,
well_id: d.well_id,
sample: normalizeTextValue(d.sample_label) || normalizeTextValue(d.sample_id_group) || "(no sample label)",
reason: normalizeTextValue(d.exclude_reason) || "no reason provided"
})
}
}
return [...byWell.values()].sort((a, b) => `${a.plate_id}|${a.well_id}`.localeCompare(`${b.plate_id}|${b.well_id}`, undefined, {numeric: true, sensitivity: "base"}))
})()
excludedCount = excludedSummaries.length
excludedPreview = excludedSummaries
.slice(0, 8)
.map(d => `${d.plate_id}:${d.well_id} (${d.sample}; ${d.reason})`)
.join(", ")
excludedOverflow = Math.max(0, excludedCount - 8)
excludedMessage = excludedCount > 0
? `Not analyzed (${excludedCount} well${excludedCount === 1 ? "" : "s"}): ${excludedPreview}${excludedOverflow > 0 ? `, +${excludedOverflow} more` : ""}`
: null
groupingMessage = activeGroupCol === null
? "Grouping: Well (no group column selected)"
: (activeWithinGroupCol === null
? `Grouping: ${activeGroupCol}`
: `Grouping: ${activeWithinGroupCol} within ${activeGroupCol} = ${activeGroupVal}`)
allPlateMessage = activePlateSel === "All"
? "Plate = All: data include wells from multiple plates. Select a specific plate for one trajectory per well."
: null
plotSubtitle = allPlateMessage === null
? groupingMessage
: `${groupingMessage}\n${allPlateMessage}`
yField = dataViewSel === "Normalized fluorescence"
? "normalized_value"
: (dataViewSel === "Measurement-normalized fluorescence"
? "measurement_normalized_value"
: (dataViewSel === "Paper metabolism (fold-change / selected measurement)"
? "paper_metabolism_value"
: "value"))
metricField = dataViewSel === "Delta fluorescence" ? "value" : yField
plotYField = "plot_y"
yLabel = dataViewSel === "Delta fluorescence"
? "Delta fluorescence (current - previous timepoint)"
: (dataViewSel === "Normalized fluorescence"
? "Normalized fluorescence (value / mean blank at same plate and time)"
: (dataViewSel === "Measurement-normalized fluorescence"
? `Fluorescence normalized by ${activeMeasurementCol || "selected measurement"}`
: (dataViewSel === "Paper metabolism (fold-change / selected measurement)"
? `Paper metabolism: blank-corrected fold-change / ${activeAucSizeCol || "selected size measurement"}`
: "Fluorescence")))
isBlank = d => d.is_blank === true || d.is_blank === "TRUE" || d.is_blank === 1 || d.is_blank === "1"
groupMode = effectiveSeriesCol !== null
groupValue = d => normalizeTextValue(d[effectiveSeriesCol])
seriesLabel = groupMode ? `Group (${effectiveSeriesCol})` : "Well"
sampleTraceKey = d => validGroupValue(d.sample_id_group)
? normalizeTextValue(d.sample_id_group)
: `${d.well_id}`.trim()
seriesKey = d => {
if (effectiveSeriesCol === null) {
return isBlank(d) ? "Blank" : `${d.well_id}`.trim()
}
const rawGroupVal = d[effectiveSeriesCol]
const groupVal = validGroupValue(rawGroupVal) ? normalizeTextValue(rawGroupVal) : "NA"
if (groupVal.toUpperCase() === "BLANK") return "Blank"
if (activeAggregationMode === "Individual replicates" && effectiveSeriesCol !== null) {
return `${groupVal}: ${sampleTraceKey(d)}`
}
return groupVal
}
traceKey = d => groupMode
? `${d.experiment_dir}|${d.plate_id}|${seriesKey(d)}`
: `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
measurementReady = dataViewSel !== "Measurement-normalized fluorescence" || activeMeasurementCol !== null
paperMeasurementReady = dataViewSel !== "Paper metabolism (fold-change / selected measurement)" || activeAucSizeCol !== null
aucSizeReady = dataViewSel !== "Paper metabolism (fold-change / selected measurement)" || activeAucSizeCol !== null
initialValueByWell = (() => {
const out = new Map()
const sorted = selectionRows
.filter(d => !isExcluded(d))
.filter(d => Number.isFinite(d.time_hr) && Number.isFinite(d.value))
.slice()
.sort((a, b) =>
a.experiment_dir.localeCompare(b.experiment_dir) ||
(a.time_hr - b.time_hr) ||
a.plate_id.localeCompare(b.plate_id) ||
a.well_id.localeCompare(b.well_id)
)
for (const d of sorted) {
// Prefer a sample-level initial value when available (samples are often
// spread across plates at different timepoints). Fall back to a
// plate|well key when sample identifier is missing.
const sampleKey = d.sample_id_group == null || d.sample_id_group === ''
? null
: `${d.experiment_dir}|sample|${d.sample_id_group}`
const plateKey = `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
if (sampleKey !== null && !out.has(sampleKey)) out.set(sampleKey, d.value)
if (!out.has(plateKey)) out.set(plateKey, d.value)
}
return out
})()
blankFoldChangeByPlateTime = (() => {
const blankRows = selectionRows
.filter(d => isBlank(d) && Number.isFinite(d.time_hr) && Number.isFinite(d.value))
.filter(d => !isExcluded(d))
.map(d => {
const plateKey = `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
const initVal = initialValueByWell.get(plateKey)
const foldChange = Number.isFinite(initVal) && initVal !== 0 ? d.value / initVal : NaN
return {
key: `${d.experiment_dir}|${d.plate_id}|${d.time_hr}`,
foldChange
}
})
.filter(d => Number.isFinite(d.foldChange))
const grouped = d3.group(blankRows, d => d.key)
return new Map([...grouped.entries()].map(([key, rows]) => [key, d3.mean(rows, d => d.foldChange)]))
})()
paperMetabolismValue = d => {
const sampleKey = d.sample_id_group == null || d.sample_id_group === ''
? null
: `${d.experiment_dir}|sample|${d.sample_id_group}`
const plateKey = `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
const initVal = (sampleKey !== null && initialValueByWell.has(sampleKey))
? initialValueByWell.get(sampleKey)
: initialValueByWell.get(plateKey)
const sizeVal = activeAucSizeCol === null ? NaN : Number(d[activeAucSizeCol])
if (!Number.isFinite(d.value) || !Number.isFinite(initVal) || initVal === 0 || !Number.isFinite(sizeVal) || sizeVal <= 0) {
return NaN
}
const foldChange = d.value / initVal
const blankKey = `${d.experiment_dir}|${d.plate_id}|${d.time_hr}`
const blankFoldChange = blankFoldChangeByPlateTime.has(blankKey)
? blankFoldChangeByPlateTime.get(blankKey)
: 0
return (foldChange - blankFoldChange) / sizeVal
}
plottedBase = dataViewSel === "Measurement-normalized fluorescence"
? filtered.map(d => {
const denom = activeMeasurementCol === null ? NaN : Number(d[activeMeasurementCol])
const valid = Number.isFinite(d.value) && d.value > 0 && Number.isFinite(denom) && denom > 0
return {
...d,
measurement_normalized_value: valid ? d.value / denom : NaN
}
})
: (dataViewSel === "Paper metabolism (fold-change / selected measurement)"
? filtered.map(d => ({
...d,
paper_metabolism_value: paperMetabolismValue(d)
}))
: filtered)
plotted = plottedBase.filter(d => Number.isFinite(d[metricField]))
traceKeyEffective = d => activePlateSel === "All"
? (aucPlotMode || cumAucPlotMode)
? (validGroupValue(d.sample_id_group)
? `${d.experiment_dir}|${normalizeTextValue(d.sample_id_group)}`
: `${d.experiment_dir}|${seriesKey(d)}`)
: `${d.experiment_dir}|${seriesKey(d)}`
: traceKey(d)
// Ensure one point per trace+time before plotting/delta. This avoids
// within-time subtraction artifacts when grouping collapses multiple wells.
plottedEffective = (() => {
const groups = new Map()
const showIndiv = activeAggregationMode === "Individual replicates" && effectiveSeriesCol !== null
for (const d of plotted) {
// If showIndiv is true, key by the full per-replicate series label.
// This prevents collapsing many wells into one trace when sample_id_group is NA-like.
const indivSeriesKey = seriesKey(d)
const traceId = showIndiv
? (activePlateSel === "All"
? `${d.experiment_dir}|${indivSeriesKey}`
: `${d.experiment_dir}|${d.plate_id}|${indivSeriesKey}`)
: traceKeyEffective(d)
const key = `${traceId}|${d.time_hr}`
if (!groups.has(key)) {
groups.set(key, {
...d,
trace_id: traceId,
well_ids: [d.well_id],
values: [d[metricField]]
})
} else {
const g = groups.get(key)
g.values.push(d[metricField])
if (!g.well_ids.includes(d.well_id)) g.well_ids.push(d.well_id)
}
}
return [...groups.values()].map(g => {
const sId = seriesKey(g)
return {
...g,
series_id: sId,
rep_count: g.values.length,
[metricField]: d3.mean(g.values),
well_id: g.well_ids.join(", "),
metric_se: g.values.length > 1 ? d3.deviation(g.values) / Math.sqrt(g.values.length) : NaN
}
})
})()
sortedEffective = plottedEffective
.slice()
.sort((a, b) =>
a.trace_id.localeCompare(b.trace_id) ||
(a.time_hr - b.time_hr)
)
plottedSorted = dataViewSel === "Delta fluorescence"
? (() => {
const prevByTrace = new Map()
return sortedEffective.map(d => {
const tkey = d.trace_id
const prev = prevByTrace.get(tkey)
const cur = d[metricField]
const delta = prev && Number.isFinite(prev.mean) ? (cur - prev.mean) : 0
const curSe = d.metric_se
const deltaSe = prev && Number.isFinite(curSe) && Number.isFinite(prev.se)
? Math.sqrt((curSe * curSe) + (prev.se * prev.se))
: NaN
prevByTrace.set(tkey, {mean: cur, se: curSe})
return {
...d,
[plotYField]: delta,
plot_se: deltaSe
}
})
})()
: sortedEffective.map(d => ({
...d,
[plotYField]: d[metricField],
plot_se: d.metric_se
}))
aucData = (() => {
// Compute from the currently plotted metric so AUC follows the selected
// data view and, for paper metabolism, the selected size column.
const byTrace = new Map()
for (const d of plottedSorted) {
if (!Number.isFinite(d.time_hr) || !Number.isFinite(d[plotYField])) continue
if (!byTrace.has(d.trace_id)) byTrace.set(d.trace_id, [])
byTrace.get(d.trace_id).push(d)
}
const out = []
for (const [traceId, rows] of byTrace.entries()) {
const sorted = rows
.slice()
.sort((a, b) => a.time_hr - b.time_hr)
if (sorted.length < 2) continue
let aucValue = 0
for (let i = 1; i < sorted.length; i += 1) {
const prev = sorted[i - 1]
const cur = sorted[i]
const dt = cur.time_hr - prev.time_hr
if (!Number.isFinite(dt) || dt <= 0) continue
aucValue += dt * ((cur[plotYField] + prev[plotYField]) / 2)
}
if (!Number.isFinite(aucValue)) continue
const first = sorted[0]
out.push({
...first,
trace_id: traceId,
auc_value: aucValue,
n_timepoints: sorted.length
})
}
return out
})()
cumulativeAucData = (() => {
const byTrace = new Map()
for (const d of plottedSorted) {
if (!Number.isFinite(d.time_hr) || !Number.isFinite(d[plotYField])) continue
if (!byTrace.has(d.trace_id)) byTrace.set(d.trace_id, [])
byTrace.get(d.trace_id).push(d)
}
const out = []
for (const [traceId, rows] of byTrace.entries()) {
const sorted = rows.slice().sort((a, b) => a.time_hr - b.time_hr)
if (sorted.length === 0) continue
let runningAuc = 0
out.push({
...sorted[0],
trace_id: traceId,
cumulative_auc: 0
})
for (let i = 1; i < sorted.length; i += 1) {
const prev = sorted[i - 1]
const cur = sorted[i]
const dt = cur.time_hr - prev.time_hr
if (Number.isFinite(dt) && dt > 0) {
runningAuc += dt * ((cur[plotYField] + prev[plotYField]) / 2)
}
out.push({
...cur,
trace_id: traceId,
cumulative_auc: runningAuc
})
}
}
return out
})()
errorBarData = plottedSorted.filter(d =>
d.rep_count > 1 &&
Number.isFinite(d[plotYField]) &&
Number.isFinite(d.plot_se) &&
d.plot_se > 0
)
showErrorBars = errorBarSel === "Show error bars" && errorBarData.length > 0
hashString = s => {
let hash = 0
for (const ch of `${s}`) {
hash = Math.imul(31, hash) + ch.charCodeAt(0) | 0
}
return hash >>> 0
}
// 1. Create the numeric version for math (no const, no semicolons)
numericTimes = [...new Set(plottedSorted.map(d => d.time_hr))]
.filter(Number.isFinite)
.sort((a, b) => a - b)
// 2. Main timeValues for the Axis (Strings)
timeValues = numericTimes.map(String)
// 3. Use numericTimes for the spacing calculation
timeSpacing = numericTimes.length > 1
? Math.min(...numericTimes.slice(1).map((t, i) => t - numericTimes[i]).filter(d => d > 0))
: 1
boxValueField = dataViewSel === "Delta fluorescence" ? "box_delta_value" : metricField
boxPlotData = dataViewSel === "Delta fluorescence"
? (() => {
const prevByWell = new Map()
const sortedRaw = plottedBase
.filter(d => Number.isFinite(d.value))
.slice()
.sort((a, b) =>
a.experiment_dir.localeCompare(b.experiment_dir) ||
a.plate_id.localeCompare(b.plate_id) ||
a.well_id.localeCompare(b.well_id) ||
a.time_hr - b.time_hr
)
return sortedRaw.map(d => {
const key = `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
const prev = prevByWell.get(key)
const deltaValue = prev && Number.isFinite(prev.value) ? (d.value - prev.value) : NaN
prevByWell.set(key, {value: d.value})
// Construct the object first, then add the dynamic property
const result = {
...d,
series_id: seriesKey(d)
}
result[boxValueField] = deltaValue
return result
}).filter(d => Number.isFinite(d[boxValueField]))
})()
: plottedBase
.filter(d => Number.isFinite(d[metricField]))
.map(d => ({
...d,
series_id: seriesKey(d),
[boxValueField]: d[metricField]
}))
// Get all unique series_ids present in the CURRENT data
presentSeries = aucPlotMode
? [...new Set(aucData.map(d => String(d.series_id)))]
: (cumAucPlotMode
? [...new Set(cumulativeAucData.map(d => String(d.series_id)))]
: [...new Set(plottedSorted.map(d => String(d.series_id)))] )
// Separate blanks and non-blanks
nonBlankKeys = presentSeries.filter(k => k !== "Blank").sort(naturalCompare)
// Only treat a present "Blank" series as a real series when NOT grouping by a group column.
// When grouping (groupMode === true) we want blanks to be excluded from the group domain.
hasBlankSeries = presentSeries.includes("Blank") && !groupMode
// Final domain for the color scale
colorDomain = hasBlankSeries ? ["Blank", ...nonBlankKeys] : nonBlankKeys
// colorRange: defined later using the Tableau palette and explicit blank color
// Use boxPlotData here instead of boxSummaryData
boxPresentSeries = [...new Set(boxPlotData.map(d => d.series_id))]
boxNonBlankKeys = boxPresentSeries.filter(k => k !== "Blank").sort(naturalCompare)
// Same rule for box plots: only show explicit "Blank" group when NOT grouping mode.
boxHasBlankSeries = boxPresentSeries.includes("Blank") && !groupMode
boxSeriesOrder = boxHasBlankSeries ?
["Blank", ...boxNonBlankKeys] : boxNonBlankKeys
groupedSpan = Math.max(0.14, Math.min(0.9, (Number.isFinite(timeSpacing) ? timeSpacing : 1) * 0.85))
groupStep = boxSeriesOrder.length > 1 ? groupedSpan / (boxSeriesOrder.length - 1) : 0
seriesOffset = new Map(
boxSeriesOrder.map((key, idx) => [key, (idx - (boxSeriesOrder.length - 1) / 2) * groupStep])
)
groupOffset = d => seriesOffset.get(d.series_id ?? seriesKey(d)) ?? 0
jitterRadius = boxSeriesOrder.length > 1
? Math.max(0.01, groupStep * 0.32)
: Math.max(0.03, (Number.isFinite(timeSpacing) ? timeSpacing : 1) * 0.06)
jitterOffset = d => {
const seed = `${d.trace_id}|${d.series_id}|${d.time_hr}|${d.well_id}`
const value = hashString(seed) / 4294967295
return (value - 0.5) * jitterRadius * 2
}
tableau = d3.schemeTableau10
nonBlankColors = nonBlankKeys.map((_, i) => tableau[i % tableau.length])
colorRange = hasBlankSeries
? ["#000000", ...nonBlankColors.slice(0, nonBlankKeys.length)]
: nonBlankColors.slice(0, nonBlankKeys.length)
formatMetricValue = v => {
if (!Number.isFinite(v)) return "NA"
if (
dataViewSel === "Measurement-normalized fluorescence" ||
dataViewSel === "Normalized fluorescence" ||
dataViewSel === "Paper metabolism (fold-change / selected measurement)"
) {
if (v !== 0 && Math.abs(v) < 0.01) return v.toExponential(2)
return v.toFixed(4)
}
return v.toFixed(2)
}
formatAucValue = v => {
if (!Number.isFinite(v)) return "NA"
if (
dataViewSel === "Measurement-normalized fluorescence" ||
dataViewSel === "Normalized fluorescence" ||
dataViewSel === "Paper metabolism (fold-change / selected measurement)"
) {
if (v !== 0 && Math.abs(v) < 0.01) return v.toExponential(2)
return v.toFixed(4)
}
return v.toFixed(2)
}
significanceLabelForIndex = index => {
const alphabet = "abcdefghijklmnopqrstuvwxyz"
let n = index
let label = ""
do {
label = alphabet[n % 26] + label
n = Math.floor(n / 26) - 1
} while (n >= 0)
return label
}
aucStatsContext = (() => {
const statsArr = Array.isArray(stats) ? stats : Array.from(stats || [])
const statsGroupCol = activeGroupCol || (groupColumns.length > 0 ? groupColumns[0] : null)
const statsMatchCurrentAuc = dataViewSel === "Paper metabolism (fold-change / selected measurement)" && activeAucSizeCol !== null
const relevantStats = statsArr.filter(s => {
if (!statsMatchCurrentAuc) return false
if (!statsGroupCol) return false
if (String(s.metric || "") !== "paper_metabolism_auc") return false
if (String(s.size_col || "") !== String(activeAucSizeCol)) return false
if (String(s.group_col) !== String(statsGroupCol)) return false
if (activeExperimentSel !== "All" && String(s.experiment_dir) !== String(activeExperimentSel)) return false
if (activePlateSel !== "All" && s.plate_id && String(s.plate_id) !== String(activePlateSel)) return false
return true
})
const kwStats = relevantStats.filter(r => r.comparison_type === "omnibus")
const pairwiseStats = relevantStats.filter(r => r.comparison_type === "pairwise")
const significantPairs = pairwiseStats.filter(r => Number.isFinite(Number(r.p_adj)) && Number(r.p_adj) <= 0.05)
const kwBest = kwStats[0] || null
const analysisUnitType = relevantStats.find(r => r.analysis_unit_type)?.analysis_unit_type || (statsGroupCol ? "sample_id / well" : "unknown")
const modelClass = relevantStats.find(r => r.model_class)?.model_class || "model"
const pAdjustMethod = relevantStats.find(r => r.p_adjust_method)?.p_adjust_method || "adjusted"
const formatP = v => {
if (v == null || v === "" || Number.isNaN(Number(v))) return "NA"
const n = Number(v)
if (!Number.isFinite(n)) return "NA"
if (n < 0.001) return n.toExponential(2)
return n.toFixed(3)
}
const aucSignificanceLabels = (() => {
if (!aucPlotMode) return []
if (!activeGroupCol || activeWithinGroupCol !== null || activeAggregationMode === "Individual replicates") return []
if (significantPairs.length === 0 || aucData.length === 0) return []
const aucSeriesIds = [...new Set(aucData.map(d => String(d.series_id)).filter(v => v !== "" && v !== "Blank"))]
if (aucSeriesIds.length < 2) return []
const pairKey = (a, b) => [a, b].sort(naturalCompare).join("||")
const aucBySeries = new Map()
const significantPairSet = new Set()
for (const row of significantPairs) {
significantPairSet.add(pairKey(String(row.group1), String(row.group2)))
}
for (const row of aucData) {
const seriesId = String(row.series_id)
if (!aucBySeries.has(seriesId)) aucBySeries.set(seriesId, [])
aucBySeries.get(seriesId).push(Number(row.auc_value))
}
const significantDegree = new Map(aucSeriesIds.map(seriesId => [seriesId, 0]))
for (const row of significantPairs) {
const group1 = String(row.group1)
const group2 = String(row.group2)
if (significantDegree.has(group1)) significantDegree.set(group1, significantDegree.get(group1) + 1)
if (significantDegree.has(group2)) significantDegree.set(group2, significantDegree.get(group2) + 1)
}
const orderedSeries = aucSeriesIds
.slice()
.sort((a, b) => {
const degreeA = significantDegree.get(a) || 0
const degreeB = significantDegree.get(b) || 0
if (degreeA !== degreeB) return degreeB - degreeA
const medianA = d3.median(aucBySeries.get(a) || []) ?? Number.POSITIVE_INFINITY
const medianB = d3.median(aucBySeries.get(b) || []) ?? Number.POSITIVE_INFINITY
if (medianA !== medianB) return medianA - medianB
return naturalCompare(a, b)
})
const letterGroups = []
const lettersBySeries = new Map(aucSeriesIds.map(seriesId => [seriesId, []]))
for (const seriesId of orderedSeries) {
const compatibleLetters = letterGroups.filter(letterGroup =>
[...letterGroup.members].every(member => !significantPairSet.has(pairKey(seriesId, member)))
)
if (compatibleLetters.length === 0) {
const label = significanceLabelForIndex(letterGroups.length)
const newLetterGroup = {label, members: new Set([seriesId])}
letterGroups.push(newLetterGroup)
lettersBySeries.get(seriesId)?.push(label)
} else {
compatibleLetters.forEach(letterGroup => {
letterGroup.members.add(seriesId)
lettersBySeries.get(seriesId)?.push(letterGroup.label)
})
}
}
const aucMax = d3.max(aucData, d => Number(d.auc_value))
const aucMin = d3.min(aucData, d => Number(d.auc_value))
const aucSpan = Number.isFinite(aucMax) && Number.isFinite(aucMin) ? Math.max(aucMax - aucMin, Math.abs(aucMax) || 1) : 1
const labelY = Number.isFinite(aucMax) ? aucMax + aucSpan * 0.08 : 1
const labelStep = aucSpan * 0.018
return orderedSeries.map((seriesId, index) => ({
series_id: seriesId,
sig_label: (lettersBySeries.get(seriesId) || []).join(""),
label_y: labelY + index * labelStep
}))
})()
const aucSignificanceNote = aucSignificanceLabels.length > 0
? `Significance notation: groups sharing a letter are not significantly different by ${pAdjustMethod}-adjusted pairwise model comparisons (p_adj > 0.05). Different letters indicate at least one significant pairwise difference (p_adj <= 0.05).`
: null
return {
statsArr,
statsGroupCol,
statsMatchCurrentAuc,
relevantStats,
kwStats,
pairwiseStats,
significantPairs,
kwBest,
analysisUnitType,
modelClass,
pAdjustMethod,
formatP,
aucSignificanceLabels,
aucSignificanceNote
}
})()
aucUsesPrecomputed = false
cumAucUsesPrecomputed = false
aucMetricDescription = dataViewSel === "Delta fluorescence"
? "delta fluorescence"
: (dataViewSel === "Measurement-normalized fluorescence"
? "measurement-normalized fluorescence"
: (dataViewSel === "Paper metabolism (fold-change / selected measurement)"
? `paper metabolism using ${activeAucSizeCol || "selected size measurement"}`
: (dataViewSel === "Normalized fluorescence"
? "normalized fluorescence"
: "fluorescence")))
cumAucMetricDescription = cumAucUsesPrecomputed ? "blank-normalized fluorescence" : aucMetricDescription
tooltipTitle = d => {
const parts = [
`Experiment: ${d.experiment_dir}`,
`Plate: ${d.plate_id}`,
`Sample: ${d.sample_id_group}`,
`Wells: ${d.well_id}`, // Now shows all wells contributing to this sample line
`Time: ${d.time_hr}h`,
`${metricField}: ${formatMetricValue(d[metricField])}`
]
if (activeGroupCol) {
parts.unshift(`${activeGroupCol}: ${d[activeGroupCol]}`)
}
if (activeWithinGroupCol) {
parts.unshift(`${activeWithinGroupCol}: ${d[activeWithinGroupCol]}`)
}
// Show if this line is an average of multiple technical replicates (wells)
if (d.rep_count > 1) {
parts.push(`Wells averaged: ${d.rep_count}`)
}
return parts.join("\n")
}
aucTooltipTitle = d => {
const parts = [
`Experiment: ${d.experiment_dir}`,
`Wells: ${d.well_id}`,
`Total AUC: ${formatAucValue(d.auc_value)}`,
`Integrated metric: ${aucMetricDescription}`,
`Integration window: first to last available timepoint`,
`Timepoints used: ${d.n_timepoints}`
]
// Only show plate/sample details if not grouped by a group column
if (!activeGroupCol) {
parts.splice(2, 0, `Plate: ${d.plate_id}`, `Sample: ${d.sample_id_group}`)
}
if (activeGroupCol) {
parts.unshift(`${activeGroupCol}: ${d[activeGroupCol]}`)
}
if (activeWithinGroupCol) {
parts.unshift(`${activeWithinGroupCol}: ${d[activeWithinGroupCol]}`)
}
return parts.join("\n")
}
cumAucTooltipTitle = d => {
const parts = [
`Experiment: ${d.experiment_dir}`,
`Time: ${d.time_hr}h`,
`Running cumulative AUC: ${formatAucValue(d.cumulative_auc)}`,
`Integrated metric: ${cumAucMetricDescription}`
]
// Only show plate/sample details if not grouped by a group column
if (!activeGroupCol) {
parts.splice(1, 0, `Plate: ${d.plate_id}`, `Sample: ${d.sample_id_group}`, `Wells: ${d.well_id}`)
}
if (activeGroupCol) {
parts.unshift(`${activeGroupCol}: ${d[activeGroupCol]}`)
}
if (activeWithinGroupCol) {
parts.unshift(`${activeWithinGroupCol}: ${d[activeWithinGroupCol]}`)
}
return parts.join("\n")
}
normalizedUnavailable = dataViewSel === "Normalized fluorescence" && plottedSorted.length === 0
measurementUnavailable = dataViewSel === "Measurement-normalized fluorescence" && (!measurementReady || plottedSorted.length === 0)
paperUnavailable = dataViewSel === "Paper metabolism (fold-change / selected measurement)" && (!paperMeasurementReady || !aucSizeReady || plottedSorted.length === 0)
aucUnavailable = aucPlotMode && aucData.length === 0
cumAucUnavailable = cumAucPlotMode && cumulativeAucData.length === 0
groupUnavailable = activeGroupCol !== null && filtered.length === 0
excludedOnlyUnavailable = excludedCandidateRows.length > 0 && filtered.length === 0
groupUnavailable
? `No data available for ${activeGroupCol} under the current selection. Choose a different Group column/value or adjust Experiment, Plate, and Well filters.`
: excludedOnlyUnavailable
? "All wells matching this selection are marked exclude_from_analysis in layout metadata."
: normalizedUnavailable
? "Normalized fluorescence unavailable for this selection. Add a layout.csv with blank wells marked (is_blank = TRUE), then rebuild dashboard data."
: measurementUnavailable
? "Measurement-normalized fluorescence unavailable for this selection. Ensure a non-zero numeric value exists in the selected *.measurement (or *.measurment) column for these wells."
: paperUnavailable
? "Paper metabolism unavailable for this selection. Choose a numeric non-zero AUC size column and ensure selected wells have valid initial values."
: aucUnavailable
? "AUC summary unavailable. This view plots total area under each trajectory from first to last available timepoint; at least two finite timepoints per trajectory are required."
: cumAucUnavailable
? "Cumulative AUC unavailable. This view plots running area under each trajectory across time; ensure finite values exist for the integrated metric."
: (() => {
const plotWrap = document.createElement("div")
const subtitleWrap = document.createElement("div")
subtitleWrap.style.marginBottom = "0.35rem"
subtitleWrap.style.color = "#374151"
subtitleWrap.style.fontSize = "0.95rem"
subtitleWrap.style.lineHeight = "1.35"
subtitleWrap.style.fontWeight = "600"
const contextBar = document.createElement("div")
contextBar.style.display = "flex"
contextBar.style.flexWrap = "wrap"
contextBar.style.gap = "0.4rem"
contextBar.style.marginBottom = "0.45rem"
const contextPill = (label, value) => {
const pill = document.createElement("span")
pill.style.display = "inline-flex"
pill.style.alignItems = "center"
pill.style.gap = "0.25rem"
pill.style.padding = "0.22rem 0.5rem"
pill.style.border = "1px solid #d1d5db"
pill.style.borderRadius = "6px"
pill.style.background = "#f9fafb"
pill.style.color = "#111827"
pill.style.fontSize = "0.86rem"
pill.style.fontWeight = "600"
pill.style.maxWidth = "100%"
const labelEl = document.createElement("span")
labelEl.textContent = `${label}:`
labelEl.style.color = "#4b5563"
labelEl.style.fontWeight = "500"
const valueEl = document.createElement("span")
valueEl.textContent = value
valueEl.style.overflowWrap = "anywhere"
pill.appendChild(labelEl)
pill.appendChild(valueEl)
return pill
}
contextBar.appendChild(contextPill("Experiment", activeExperimentSel))
contextBar.appendChild(contextPill("Plate", activePlateSel))
contextBar.appendChild(contextPill("View", dataViewSel))
subtitleWrap.appendChild(contextBar)
const groupingLine = document.createElement("div")
groupingLine.textContent = groupingMessage
subtitleWrap.appendChild(groupingLine)
if (allPlateMessage !== null) {
const plateLine = document.createElement("div")
plateLine.textContent = allPlateMessage
plateLine.style.fontWeight = "400"
subtitleWrap.appendChild(plateLine)
}
if (excludedMessage !== null) {
const excludedLine = document.createElement("div")
excludedLine.textContent = excludedMessage
excludedLine.style.fontWeight = "400"
excludedLine.style.color = "#7c2d12"
subtitleWrap.appendChild(excludedLine)
}
if (aucPlotMode) {
const aucLine = document.createElement("div")
aucLine.textContent = `AUC summary: each dot is one trajectory's total area (trapezoidal integration) of ${aucMetricDescription} from first to last available timepoint.`
aucLine.style.fontWeight = "400"
aucLine.style.color = "#1f2937"
subtitleWrap.appendChild(aucLine)
if (aucStatsContext.aucSignificanceNote !== null) {
const sigLine = document.createElement("div")
sigLine.textContent = aucStatsContext.aucSignificanceNote
sigLine.style.fontWeight = "400"
sigLine.style.color = "#1f2937"
subtitleWrap.appendChild(sigLine)
}
}
if (cumAucPlotMode) {
const cumAucLine = document.createElement("div")
cumAucLine.textContent = `Cumulative AUC: each line shows running integrated area (trapezoidal) of ${cumAucMetricDescription} over time for each trajectory.`
cumAucLine.style.fontWeight = "400"
cumAucLine.style.color = "#1f2937"
subtitleWrap.appendChild(cumAucLine)
}
plotWrap.appendChild(subtitleWrap)
plotWrap.innerHTML = ""
plotWrap.appendChild(subtitleWrap)
const attachHoverEmphasis = (plotNode, markData = {}) => {
let svg = null
if (plotNode?.tagName?.toLowerCase() === "svg") {
svg = plotNode
} else {
const svgs = [...(plotNode?.querySelectorAll?.("svg") || [])]
svg = svgs.sort(
(a, b) =>
b.querySelectorAll("path, line, polyline, rect, circle").length -
a.querySelectorAll("path, line, polyline, rect, circle").length
)[0] || null
}
if (!svg) return
const plotTag = plotNode?.tagName?.toLowerCase()
const plotRoot = plotTag === "svg" ? (svg.parentElement || svg) : (plotNode || svg.parentElement || svg)
const normalizeLegendText = value => `${value ?? ""}`.trim()
const selectableMarks = () => {
const preferred = [
...svg.querySelectorAll(
"g[aria-label='line'] path, g[aria-label='dot'] circle, g[aria-label='box'] path, g[aria-label='rule'] line"
)
]
if (preferred.length > 0) return preferred
return [...svg.querySelectorAll("path, line, polyline, rect, circle")]
}
const bindListeners = (attempt = 0) => {
const marks = selectableMarks()
if (marks.length < 2 && attempt < 20) {
requestAnimationFrame(() => bindListeners(attempt + 1))
return
}
if (marks.length === 0) return
marks.forEach(node => {
const tag = node.tagName.toLowerCase()
// Make lines easier to hover directly
if (tag === "path" || tag === "line" || tag === "polyline") {
node.style.pointerEvents = "stroke"
} else {
node.style.pointerEvents = "all"
}
})
const linePaths = marks.filter(node =>
node.tagName.toLowerCase() === "path" &&
node.closest("g[aria-label='line']") &&
Array.isArray(node.__data__)
)
const linePathSet = new Set(linePaths)
const pointToLinePath = new Map()
linePaths.forEach(path => {
path.__data__.forEach(index => {
pointToLinePath.set(index, path)
})
})
const dataForNode = node => {
const label = node.closest("g[aria-label]")?.getAttribute("aria-label")
if (label === "line") return markData.line || markData.data || []
if (label === "dot") return markData.dot || markData.data || []
if (label === "box") return markData.box || markData.data || []
if (label === "rule") return markData.rule || markData.data || []
return markData.data || []
}
const firstBoundIndex = value => {
if (typeof value === "number") return value
if (Array.isArray(value)) return value.find(index => Number.isFinite(index))
return null
}
const datumForNode = node => {
const bound = node.__data__
if (bound && typeof bound === "object" && !Array.isArray(bound)) {
return bound
}
const index = firstBoundIndex(bound)
const data = dataForNode(node)
return Number.isFinite(index) ? data[index] : null
}
const getNodeSeriesId = node => {
const d = datumForNode(node)
if (d?.series_id != null) return String(d.series_id)
if (d?.series != null) return String(d.series)
if (d?.group != null) return String(d.group)
if (d?.id != null) return String(d.id)
return null
}
const getSeriesIdentity = node => {
if (!node) return null
if (linePathSet.has(node)) return node
if (
node.closest("g[aria-label='dot']") &&
typeof node.__data__ === "number" &&
pointToLinePath.has(node.__data__)
) {
return pointToLinePath.get(node.__data__)
}
const d = datumForNode(node)
if (d?.trace_id != null) return `trace:${d.trace_id}`
if (d?.series_id != null) return `series:${d.series_id}`
if (d?.series != null) return `series:${d.series}`
if (d?.group != null) return `group:${d.group}`
if (d?.id != null) return `id:${d.id}`
return node
}
const clear = () => {
marks.forEach(node => {
node.style.opacity = ""
node.style.strokeWidth = ""
node.style.filter = ""
})
legendItems().forEach(item => {
item.style.opacity = ""
item.style.filter = ""
})
}
const emphasizeIdentities = identities => {
marks.forEach(node => {
const isMatch = identities.has(getSeriesIdentity(node))
node.style.transition =
"opacity 120ms ease, stroke-width 120ms ease, filter 120ms ease"
if (isMatch) {
node.style.opacity = "1"
if (node.tagName.toLowerCase() !== "circle") {
node.style.strokeWidth = "3.5px"
}
node.style.filter = "brightness(1.1)"
} else {
node.style.opacity = "0.12"
node.style.filter = ""
}
})
}
const identitiesForSeries = seriesId => {
const identities = new Set()
marks.forEach(node => {
if (getNodeSeriesId(node) === seriesId) {
identities.add(getSeriesIdentity(node))
}
})
return identities
}
const legendItems = () => [
...plotRoot.querySelectorAll(
"span[class*='-swatch'], label[class*='-swatch'], [style*='--color']"
)
].filter(item => normalizeLegendText(item.textContent) !== "")
const closestLegendItem = target => {
const items = legendItems()
return items.find(item => item === target || item.contains(target)) || null
}
const emphasizeLegendItem = activeItem => {
legendItems().forEach(item => {
item.style.transition = "opacity 120ms ease, filter 120ms ease"
item.style.opacity = item === activeItem ? "1" : "0.35"
item.style.filter = item === activeItem ? "brightness(1.05)" : ""
})
}
let lastIdentity = null
let lastLegendSeries = null
const distanceToPath = (node, x, y) => {
const total = node.getTotalLength?.()
const ctm = node.getScreenCTM?.()
if (!Number.isFinite(total) || total <= 0 || !ctm) return Infinity
const step = Math.max(2, total / 140)
let best = Infinity
for (let length = 0; length <= total; length += step) {
const point = node.getPointAtLength(length)
const screenX = ctm.a * point.x + ctm.c * point.y + ctm.e
const screenY = ctm.b * point.x + ctm.d * point.y + ctm.f
const dx = screenX - x
const dy = screenY - y
const score = dx * dx + dy * dy
if (score < best) best = score
}
return best
}
const findHoveredNode = ev => {
// Direct hit test first
const direct = document
.elementsFromPoint(ev.clientX, ev.clientY)
.find(el => marks.includes(el))
if (direct) return direct
// Fallback proximity search for thin line strokes.
let best = null
let bestScore = Infinity
const hoverDistance = 10
marks.forEach(node => {
const tag = node.tagName.toLowerCase()
if (!["path", "line", "polyline"].includes(tag)) return
const box = node.getBoundingClientRect()
if (
ev.clientX < box.left - hoverDistance ||
ev.clientX > box.right + hoverDistance ||
ev.clientY < box.top - hoverDistance ||
ev.clientY > box.bottom + hoverDistance
) {
return
}
const score = distanceToPath(node, ev.clientX, ev.clientY)
if (score > hoverDistance * hoverDistance || score >= bestScore) return
best = node
bestScore = score
})
return best
}
const onMove = ev => {
const hovered = findHoveredNode(ev)
if (!hovered) {
if (lastIdentity !== null) {
lastIdentity = null
clear()
}
return
}
const identity = getSeriesIdentity(hovered)
if (identity === lastIdentity) return
lastIdentity = identity
lastLegendSeries = null
emphasizeIdentities(new Set([identity]))
}
svg.addEventListener("mousemove", onMove)
svg.addEventListener("mouseleave", () => {
lastIdentity = null
clear()
})
legendItems().forEach(item => {
item.style.cursor = "pointer"
})
plotRoot.addEventListener("mousemove", ev => {
const item = closestLegendItem(ev.target)
if (!item) {
if (lastLegendSeries !== null) {
lastLegendSeries = null
clear()
}
return
}
const seriesId = normalizeLegendText(item.textContent)
const identities = identitiesForSeries(seriesId)
if (identities.size === 0) return
if (seriesId === lastLegendSeries) return
lastIdentity = null
lastLegendSeries = seriesId
emphasizeIdentities(identities)
emphasizeLegendItem(item)
})
plotRoot.addEventListener("mouseleave", () => {
if (lastLegendSeries !== null) {
lastLegendSeries = null
clear()
}
})
}
requestAnimationFrame(() => bindListeners())
}
if (aucPlotMode) {
const plotNode = Plot.plot({
height: 420,
style: {
color: "#111827",
background: "#ffffff"
},
x: {
type: "band",
domain: colorDomain,
label: seriesLabel
},
y: {
label: `Total AUC (${aucMetricDescription})`,
grid: true,
zero: true,
nice: true
},
color: {
legend: true,
label: seriesLabel,
domain: colorDomain,
range: colorRange
},
marks: [
Plot.boxY(aucData, {
x: "series_id",
y: "auc_value",
fill: "series_id",
fillOpacity: 0.4,
stroke: "series_id",
strokeWidth: 1.5
}),
Plot.dot(aucData, {
x: "series_id",
y: "auc_value",
fill: "series_id",
r: 3,
opacity: 0.75,
stroke: "#ffffff",
strokeWidth: 0.5,
title: aucTooltipTitle,
tip: true
}),
...(aucStatsContext.aucSignificanceLabels.length > 0
? [Plot.text(aucStatsContext.aucSignificanceLabels, {
x: "series_id",
y: "label_y",
text: "sig_label",
fill: "#111827",
fontWeight: 700,
textAnchor: "middle",
dy: -6
})]
: [])
]
})
plotWrap.appendChild(plotNode)
attachHoverEmphasis(plotNode, {data: aucData, box: aucData, dot: aucData})
} else if (cumAucPlotMode) {
const plotNode = Plot.plot({
height: 420,
style: {
color: "#111827",
background: "#ffffff"
},
x: {label: "Time (hours)", tickValues: timeValues},
y: {label: `Running cumulative AUC (${cumAucMetricDescription})`},
color: {
legend: true,
label: seriesLabel,
domain: colorDomain,
range: colorRange
},
marks: [
Plot.gridX({stroke: "#d1d5db", strokeOpacity: 1}),
Plot.gridY({stroke: "#d1d5db", strokeOpacity: 1}),
Plot.line(cumulativeAucData, {
x: "time_hr",
y: "cumulative_auc",
stroke: "series_id",
z: "trace_id",
strokeWidth: activeAggregationMode === "Individual replicates" ? 1.2 : 2.2,
strokeOpacity: activeAggregationMode === "Individual replicates" ? 0.6 : 1.0,
curve: "linear"
}),
Plot.dot(cumulativeAucData, {
x: "time_hr",
y: "cumulative_auc",
fill: "series_id",
r: 2.5,
title: cumAucTooltipTitle,
tip: true
})
]
})
plotWrap.appendChild(plotNode)
attachHoverEmphasis(plotNode, {data: cumulativeAucData, line: cumulativeAucData, dot: cumulativeAucData})
} else if (boxPlotMode) {
const plotNode = Plot.plot({
height: 420,
style: {
color: "#111827",
background: "#ffffff"
},
// fx creates the 'Time' columns as categorical buckets
fx: {
label: "Time (hours)",
domain: timeValues, // Ensure this is using the string array
padding: 0.1
},
// x handles the internal grouping of series within each time bucket
x: {
type: "band",
domain: boxSeriesOrder,
axis: null
},
y: {
label: yLabel,
grid: true,
zero: true, // Ensures the axis includes 0
nice: true // Rounds the axis range up to accommodate the highest data points
},
color: {
legend: true,
label: seriesLabel,
domain: colorDomain,
range: colorRange
},
marks: [
Plot.boxY(boxPlotData, {
// Convert time_hr to a string to force categorical buckets
fx: d => `${d.time_hr}`,
x: "series_id",
y: boxValueField,
fill: "series_id",
fillOpacity: 0.4,
stroke: "series_id",
strokeWidth: 1.5
}),
// Dots are now centered exactly on the series group
Plot.dot(boxPlotData, {
fx: d => `${d.time_hr}`,
x: "series_id",
y: boxValueField,
fill: "series_id",
r: 3,
opacity: 0.7,
stroke: "#ffffff",
strokeWidth: 0.5,
title: tooltipTitle,
tip: true
})
]
})
plotWrap.appendChild(plotNode)
attachHoverEmphasis(plotNode, {data: boxPlotData, box: boxPlotData, dot: boxPlotData})
} else {
const plotNode = Plot.plot({
height: 420,
style: {
color: "#111827",
background: "#ffffff"
},
x: {label: "Time (hours)", tickValues: timeValues},
y: {label: yLabel},
color: {
legend: true,
label: seriesLabel,
domain: colorDomain,
range: colorRange
},
marks: [
Plot.gridX({stroke: "#d1d5db", strokeOpacity: 1}),
Plot.gridY({stroke: "#d1d5db", strokeOpacity: 1}),
...(showErrorBars
? [Plot.ruleX(errorBarData, {
x: "time_hr",
y1: d => d[plotYField] - d.plot_se,
y2: d => d[plotYField] + d.plot_se,
stroke: seriesKey,
strokeOpacity: 0.6,
strokeWidth: 1.2
})]
: []),
Plot.line(plottedSorted, {
x: "time_hr",
y: plotYField,
stroke: "series_id", // Use the string key name
z: "trace_id", // Use the string key name
strokeWidth: activeAggregationMode === "Individual replicates" ? 1.2 : 2.2,
strokeOpacity: activeAggregationMode === "Individual replicates" ? 0.6 : 1.0,
curve: "linear"
}),
Plot.dot(plottedSorted, {
x: "time_hr",
y: plotYField,
fill: "series_id",
r: 2.5,
title: tooltipTitle,
tip: true
})
]
})
plotWrap.appendChild(plotNode)
attachHoverEmphasis(plotNode, {data: plottedSorted, line: plottedSorted, dot: plottedSorted, rule: errorBarData})
}
// Render server-side stats table (if available) for the active grouping
const statsWrap = document.createElement("div")
statsWrap.style.marginTop = "0.6rem"
const statsHeader = document.createElement("div")
statsHeader.style.fontWeight = "600"
statsHeader.style.marginBottom = "0.35rem"
statsHeader.style.marginTop = "1rem"
statsHeader.style.fontSize = "1rem"
statsHeader.textContent = "Statistical Comparisons (AUC by Group)"
statsWrap.appendChild(statsHeader)
const statDesc = document.createElement("div")
statDesc.style.marginBottom = "0.6rem"
statDesc.style.color = "#374151"
statDesc.style.fontSize = "0.92rem"
statDesc.style.lineHeight = "1.4"
statDesc.innerHTML = aucStatsContext.statsMatchCurrentAuc
? `<strong>Tests computed:</strong> paper-style AUC model (${aucStatsContext.modelClass}) with pairwise post hoc comparisons (${aucStatsContext.pAdjustMethod} adjustment). The response is fold-change from initial fluorescence, blank-corrected by plate/time, divided by <code>${activeAucSizeCol}</code>, then integrated by trapezoidal AUC.`
: `<strong>Tests hidden:</strong> model-based AUC statistics are only displayed for Paper metabolism with a selected AUC size column.`
statsWrap.appendChild(statDesc)
const statsGroupCol = aucStatsContext.statsGroupCol
const relevantStats = aucStatsContext.relevantStats
const kwStats = aucStatsContext.kwStats
const pairwiseStats = aucStatsContext.pairwiseStats
const significantPairs = aucStatsContext.significantPairs
const kwBest = aucStatsContext.kwBest
const analysisUnitType = aucStatsContext.analysisUnitType
const formatP = aucStatsContext.formatP
// Reuse the precomputed significance labels and note from `aucStatsContext`.
// The heavy stats computation is performed once in `aucStatsContext` above.
if (relevantStats.length === 0) {
const none = document.createElement("div")
none.textContent = aucStatsContext.statsMatchCurrentAuc
? "No statistical results available for the current selection."
: "Statistical results are hidden because the current view is not the paper-metabolism AUC metric used for model-based tests."
none.style.color = "#6b7280"
statsWrap.appendChild(none)
} else {
const summary = document.createElement("div")
summary.style.marginBottom = "0.55rem"
summary.style.padding = "0.6rem 0.75rem"
summary.style.border = "1px solid #e5e7eb"
summary.style.borderRadius = "8px"
summary.style.background = "#f9fafb"
summary.style.color = "#111827"
summary.style.fontSize = "0.92rem"
summary.style.lineHeight = "1.45"
const kwText = kwBest
? `Omnibus result: ${Number(kwBest.p_adj) < 0.05 ? "groups differ" : "no evidence that groups differ"} (${kwBest.comparison} p = ${formatP(kwBest.p_value)}).`
: "Omnibus result: unavailable for this selection."
const sigPairText = significantPairs.length > 0
? `Pairwise differences after ${aucStatsContext.pAdjustMethod} correction: ${significantPairs.map(r => `${r.group1} vs ${r.group2} (p_adj = ${formatP(r.p_adj)})`).join(", ")}.`
: `Pairwise differences after ${aucStatsContext.pAdjustMethod} correction: none detected at the 0.05 threshold.`
summary.innerHTML = `<strong>${kwText}</strong><br>${sigPairText}<br><span style="color:#6b7280">Analysis unit: ${analysisUnitType}; size column: ${activeAucSizeCol}.</span>`
statsWrap.appendChild(summary)
if (!activeGroupCol && statsGroupCol) {
const hint = document.createElement("div")
hint.textContent = `Showing statistical comparisons for group: ${statsGroupCol} (no Group column selected — using first available group).`
hint.style.color = "#6b7280"
hint.style.marginBottom = "0.25rem"
statsWrap.appendChild(hint)
}
const tbl = document.createElement("table")
tbl.style.borderCollapse = "collapse"
tbl.style.width = "100%"
tbl.style.marginTop = "0.25rem"
const hdr = document.createElement("tr")
;["Test", "Group 1", "Group 2", "p (raw)", "p (adj)", "n1", "n2", "median1", "median2"].forEach(h => {
const th = document.createElement("th")
th.textContent = h
th.style.textAlign = "left"
th.style.padding = "6px 8px"
th.style.borderBottom = "1px solid #e5e7eb"
th.style.fontWeight = "600"
hdr.appendChild(th)
})
tbl.appendChild(hdr)
pairwiseStats.forEach(r => {
const tr = document.createElement("tr")
if (Number.isFinite(Number(r.p_adj)) && Number(r.p_adj) <= 0.05) {
tr.style.fontWeight = "700"
}
const values = [r.comparison, r.group1 || "-", r.group2 || "-", formatP(r.p_value), formatP(r.p_adj), r.n1 || "-", r.n2 || "-", (r.median1 != null ? Number(r.median1).toFixed(4) : "-"), (r.median2 != null ? Number(r.median2).toFixed(4) : "-")]
values.forEach(v => {
const td = document.createElement("td")
td.textContent = v
td.style.padding = "6px 8px"
td.style.borderBottom = "1px solid #f3f4f6"
td.style.fontSize = "0.9rem"
tr.appendChild(td)
})
tbl.appendChild(tr)
})
statsWrap.appendChild(tbl)
}
plotWrap.appendChild(statsWrap)
return plotWrap
})()SORMI Resazurin Dashboard
Auto-updated from plate exports in Resazurin
What This Shows
- Interactive exploration of trajectories across experiments, plates, and wells.
- Optional filtering by any non-empty
*.groupmetadata column. - Five data views: raw fluorescence, delta fluorescence, blank-normalized fluorescence, measurement-normalized fluorescence, and paper-style metabolism.
Interactive Explorer
Use these selectors to focus on one experiment, one plate, or specific wells.