dashboard = await FileAttachment("output/resazurin/dashboard-data.csv").csv({typed: true})
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(selectionRows.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(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
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", "Cumulative AUC over time"], {
label: "Plot style",
value: "Line plot"
})
boxPlotMode = plotStyleSel === "Box plot"
aucPlotMode = plotStyleSel === "AUC summary"
cumAucPlotMode = plotStyleSel === "Cumulative AUC over time"
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(seriesRows.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)
? allGroupsDisplayed || 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 || 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 (script-style): fold-change / ${activeMeasurementCol || "selected 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)" || activeMeasurementCol !== null
initialValueByWell = (() => {
const out = new Map()
const sorted = filtered
.filter(d => Number.isFinite(d.time_hr) && 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
)
for (const d of sorted) {
const key = `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
if (!out.has(key)) out.set(key, d.value)
}
return out
})()
paperMetabolismValue = d => {
const initKey = `${d.experiment_dir}|${d.plate_id}|${d.well_id}`
const initVal = initialValueByWell.get(initKey)
const measurementVal = activeMeasurementCol === null ? NaN : d[activeMeasurementCol]
if (!Number.isFinite(d.value) || !Number.isFinite(initVal) || initVal === 0 || !Number.isFinite(measurementVal) || measurementVal <= 0) {
return NaN
}
const foldChange = d.value / initVal
return foldChange / measurementVal
}
plottedBase = dataViewSel === "Measurement-normalized fluorescence"
? filtered.map(d => {
const denom = activeMeasurementCol === null ? NaN : 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"
? `${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 = (() => {
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)
hasBlankSeries = presentSeries.includes("Blank")
// 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)
boxHasBlankSeries = boxPresentSeries.includes("Blank")
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)
}
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}`,
`Plate: ${d.plate_id}`,
`Sample: ${d.sample_id_group}`,
`Wells: ${d.well_id}`,
`AUC: ${formatAucValue(d.auc_value)}`,
`Timepoints used: ${d.n_timepoints}`
]
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}`,
`Plate: ${d.plate_id}`,
`Sample: ${d.sample_id_group}`,
`Wells: ${d.well_id}`,
`Time: ${d.time_hr}h`,
`Cumulative AUC: ${formatAucValue(d.cumulative_auc)}`
]
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 || 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.txt 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 Measurement column and ensure selected wells have valid initial values."
: aucUnavailable
? "AUC summary unavailable for this selection. At least two finite timepoints per trajectory are required after filtering."
: cumAucUnavailable
? "Cumulative AUC over time unavailable for this selection. Ensure finite values exist for the selected 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 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)
}
plotWrap.appendChild(subtitleWrap)
plotWrap.innerHTML = ""
plotWrap.appendChild(subtitleWrap)
if (aucPlotMode) {
plotWrap.appendChild(Plot.plot({
height: 420,
style: {
color: "#111827",
background: "#ffffff"
},
x: {
type: "band",
domain: colorDomain,
label: seriesLabel
},
y: {
label: "Metabolism (AUC)",
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
})
]
}))
} else if (cumAucPlotMode) {
plotWrap.appendChild(Plot.plot({
height: 420,
style: {
color: "#111827",
background: "#ffffff"
},
x: {label: "Time (hours)", tickValues: timeValues},
y: {label: "Metabolism (Cumulative AUC)"},
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
})
]
}))
} else if (boxPlotMode) {
plotWrap.appendChild(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
})
]
}))
} else {
plotWrap.appendChild(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
})
]
}))
}
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.