dashboard = await FileAttachment("output/clam/dashboard-data.csv").csv({typed: true})
stats = await FileAttachment("output/clam/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(
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
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 aucYScaleSel = Inputs.select(["Auto", "Linear", "Log"], {
label: "Y axis scale",
value: "Auto"
})
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
})
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))
)
effectiveSeriesValues = effectiveSeriesCol === null
? []
: [...new Set(
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"))
candidateRows = 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))
)
excludedCandidateRows = candidateRows.filter(isExcluded)
filtered = candidateRows.filter(d => !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) || "(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) {
// Prefer a sample-level initial value when available; samples can be
// distributed across plates with different timepoints. Fall back to
// plate|well when no sample identifier exists.
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
})()
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 measurementVal = activeMeasurementCol === null ? NaN : Number(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 : 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"
? `${d.experiment_dir}|${seriesKey(d)}`
: traceKey(d)
plottedEffective = (() => {
const groups = new Map()
const showIndiv = activeAggregationMode === "Individual replicates" && effectiveSeriesCol !== null
for (const d of plotted) {
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
}))
aucCalcRows = (() => {
const groups = new Map()
for (const d of plotted) {
if (!Number.isFinite(d.time_hr) || !Number.isFinite(d[metricField])) continue
const sampleKey = validGroupValue(d.sample_id_group)
? normalizeTextValue(d.sample_id_group)
: `${d.well_id}`.trim()
const traceId = activePlateSel === "All"
? `${d.experiment_dir}|${sampleKey}`
: `${d.experiment_dir}|${d.plate_id}|${sampleKey}`
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 => ({
...g,
series_id: seriesKey(g),
rep_count: g.values.length,
well_id: g.well_ids.join(", "),
[plotYField]: d3.mean(g.values),
plot_se: g.values.length > 1 ? d3.deviation(g.values) / Math.sqrt(g.values.length) : NaN
}))
.sort((a, b) => a.trace_id.localeCompare(b.trace_id) || (a.time_hr - b.time_hr))
})()
aucData = (() => {
const byTrace = new Map()
for (const d of aucCalcRows) {
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,
series_id: String(first.series_id || "Unknown")
})
}
return out
})()
useAucLogScale = (() => {
const vals = aucData.map(d => Number(d.auc_value)).filter(Number.isFinite)
if (vals.length === 0) return false
const allPositive = vals.every(v => v > 0)
if (aucYScaleSel === "Log") return allPositive
if (aucYScaleSel === "Linear") return false
return allPositive && d3.max(vals) / d3.min(vals) > 100
})()
aucStatsContext = (() => {
const statsArr = Array.isArray(stats) ? stats : []
const currentSeriesValues = effectiveSeriesValues.filter(validGroupValue)
const allCsvStatsRows = statsArr.filter(row =>
row != null &&
String(row.metric || "") === "auc" &&
String(row.group_col || "") === String(activeGroupCol || "") &&
String(row.experiment_dir || "") === String(activeExperimentSel || "") &&
(activePlateSel === "All" || String(row.plate_id || "") === String(activePlateSel || ""))
)
// When viewing "All" plates, prefer experiment-level stats (plate_id null/""/NA)
// over per-plate stats. Fall back to per-plate if no experiment-level rows exist.
const isExpLevel = r => !r.plate_id || String(r.plate_id).trim() === "" || String(r.plate_id).trim() === "NA"
const expLevelRows = allCsvStatsRows.filter(isExpLevel)
const csvStatsRows = (activePlateSel === "All" && expLevelRows.length > 0)
? expLevelRows
: allCsvStatsRows.filter(r => !isExpLevel(r))
if (
aucPlotMode &&
activeGroupCol &&
activeWithinGroupCol === null &&
activeAggregationMode !== "Individual replicates" &&
csvStatsRows.length > 0 &&
currentSeriesValues.length >= 2
) {
const omnibusRow = csvStatsRows.find(r => String(r.comparison_type || "") === "omnibus") || null
let pairwiseStats = csvStatsRows.filter(r =>
String(r.comparison_type || "") === "pairwise" &&
currentSeriesValues.includes(String(r.group1 || "")) &&
currentSeriesValues.includes(String(r.group2 || ""))
)
if (pairwiseStats.length > 0 && omnibusRow !== null) {
// Deduplicate pairwise rows that may come from multiple plates/scope
// when viewing "All" plates. Keep the row with the smallest p_adj
// for each unordered group pair.
const pairMap = new Map()
for (const r of pairwiseStats) {
const key = [String(r.group1), String(r.group2)].sort(naturalCompare).join("||")
const existing = pairMap.get(key)
if (!existing || (Number.isFinite(Number(r.p_adj)) && Number(r.p_adj) < Number(existing.p_adj))) {
pairMap.set(key, r)
}
}
pairwiseStats = Array.from(pairMap.values())
const significantPairs = pairwiseStats.filter(r => Number.isFinite(Number(r.p_adj)) && Number(r.p_adj) <= 0.05)
const significantPairSet = new Set(significantPairs.map(r => [String(r.group1), String(r.group2)].sort(naturalCompare).join("||")))
const significantDegree = new Map(currentSeriesValues.map(groupId => [groupId, 0]))
significantPairs.forEach(row => {
const group1 = String(row.group1)
const group2 = String(row.group2)
significantDegree.set(group1, (significantDegree.get(group1) || 0) + 1)
significantDegree.set(group2, (significantDegree.get(group2) || 0) + 1)
})
const orderedSeries = currentSeriesValues.slice().sort((a, b) => {
const degreeA = significantDegree.get(a) || 0
const degreeB = significantDegree.get(b) || 0
if (degreeA !== degreeB) return degreeB - degreeA
const seriesA = pairwiseStats.filter(r => String(r.group1) === a || String(r.group2) === a).map(r => Number(r.median1)).filter(Number.isFinite)
const seriesB = pairwiseStats.filter(r => String(r.group1) === b || String(r.group2) === b).map(r => Number(r.median1)).filter(Number.isFinite)
const medianA = seriesA.length > 0 ? d3.median(seriesA) : Number.POSITIVE_INFINITY
const medianB = seriesB.length > 0 ? d3.median(seriesB) : Number.POSITIVE_INFINITY
if (medianA !== medianB) return medianA - medianB
return naturalCompare(a, b)
})
const letterGroups = []
const lettersBySeries = new Map(currentSeriesValues.map(groupId => [groupId, []]))
const pairKey = (a, b) => [a, b].sort(naturalCompare).join("||")
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)
letterGroups.push({label, members: new Set([seriesId])})
lettersBySeries.get(seriesId)?.push(label)
} else {
compatibleLetters.forEach(letterGroup => {
letterGroup.members.add(seriesId)
lettersBySeries.get(seriesId)?.push(letterGroup.label)
})
}
}
// Use both CSV-derived medians and the plotted AUC values when
// computing label positions so significance labels don't force the
// axis to expand far beyond the actual plotted points.
const csvAucValues = (metricField === "value")
? csvStatsRows
.filter(r => String(r.comparison_type || "") === "pairwise")
.flatMap(r => [Number(r.median1), Number(r.median2)])
.filter(Number.isFinite)
: []
const dataAucValues = (Array.isArray(aucData) ? aucData.map(d => Number(d.auc_value)).filter(Number.isFinite) : [])
const aucValues = [...csvAucValues, ...dataAucValues].filter(Number.isFinite)
const aucMax = aucValues.length > 0 ? d3.max(aucValues) : null
const aucMin = aucValues.length > 0 ? d3.min(aucValues) : null
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 {
relevantStats: [omnibusRow, ...pairwiseStats],
pairwiseStats,
significantPairs,
kwBest: omnibusRow,
analysisUnitType: String(omnibusRow.analysis_unit_type || "trajectory / AUC"),
aucSignificanceLabels: orderedSeries.map((seriesId, index) => ({
series_id: seriesId,
sig_label: (lettersBySeries.get(seriesId) || []).join(""),
label_y: labelY + index * labelStep
})),
aucSignificanceNote: `Significance notation: groups sharing a letter are not significantly different by ${String(omnibusRow.p_adjust_method || "Tukey")}-adjusted pairwise model comparisons (p_adj > 0.05). Different letters indicate at least one significant pairwise difference (p_adj <= 0.05).`,
modelClass: String(omnibusRow.model_class || "lm"),
pAdjustMethod: String(omnibusRow.p_adjust_method || "Tukey"),
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)
}
}
}
}
if (!aucPlotMode || !activeGroupCol || activeAggregationMode === "Individual replicates") {
return {
relevantStats: [],
pairwiseStats: [],
significantPairs: [],
kwBest: null,
analysisUnitType: "unknown",
aucSignificanceLabels: [],
aucSignificanceNote: null,
modelClass: "unknown",
pAdjustMethod: "tukey",
formatP: v => Number.isFinite(Number(v)) ? (Number(v) < 0.001 ? Number(v).toExponential(2) : Number(v).toFixed(3)) : "NA"
}
}
const groupMap = new Map()
for (const row of aucData) {
const groupId = normalizeTextValue(row.series_id)
if (groupId === "" || groupId.toUpperCase() === "BLANK") continue
if (!groupMap.has(groupId)) groupMap.set(groupId, [])
groupMap.get(groupId).push(Number(row.auc_value))
}
const groupIds = [...groupMap.keys()].sort(naturalCompare)
if (groupIds.length < 2) {
return {
relevantStats: [],
pairwiseStats: [],
significantPairs: [],
kwBest: null,
analysisUnitType: "unknown",
aucSignificanceLabels: [],
aucSignificanceNote: null,
modelClass: "unknown",
pAdjustMethod: "BH",
formatP: v => Number.isFinite(Number(v)) ? (Number(v) < 0.001 ? Number(v).toExponential(2) : Number(v).toFixed(3)) : "NA"
}
}
const groupValues = groupIds.map(groupId => groupMap.get(groupId) || [])
const omnibus = kruskalWallisTest(groupValues)
const pairwiseStats = []
for (let i = 0; i < groupIds.length; i += 1) {
for (let j = i + 1; j < groupIds.length; j += 1) {
const group1 = groupIds[i]
const group2 = groupIds[j]
const test = wilcoxonRankSumTest(groupMap.get(group1) || [], groupMap.get(group2) || [])
pairwiseStats.push({
comparison: "Wilcoxon",
group1,
group2,
statistic: test.statistic,
p_value: test.p_value,
p_adj: test.p_value,
n1: (groupMap.get(group1) || []).length,
n2: (groupMap.get(group2) || []).length,
median1: d3.median(groupMap.get(group1) || []),
median2: d3.median(groupMap.get(group2) || []),
n_wells1: (groupMap.get(group1) || []).length,
n_wells2: (groupMap.get(group2) || []).length,
n_timepoints1: (groupMap.get(group1) || []).length,
n_timepoints2: (groupMap.get(group2) || []).length
})
}
}
const orderedPairs = pairwiseStats
.map((row, index) => ({row, index}))
.sort((a, b) => a.row.p_value - b.row.p_value)
orderedPairs.forEach((item, rank) => {
item.row.p_adj = Math.min(1, item.row.p_value * pairwiseStats.length / (rank + 1))
})
const significantPairs = pairwiseStats.filter(r => Number.isFinite(Number(r.p_adj)) && Number(r.p_adj) <= 0.05)
const significantPairSet = new Set(significantPairs.map(r => [r.group1, r.group2].sort(naturalCompare).join("||")))
const significantDegree = new Map(groupIds.map(groupId => [groupId, 0]))
significantPairs.forEach(row => {
significantDegree.set(row.group1, (significantDegree.get(row.group1) || 0) + 1)
significantDegree.set(row.group2, (significantDegree.get(row.group2) || 0) + 1)
})
const orderedSeries = groupIds.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(groupMap.get(a) || []) ?? Number.POSITIVE_INFINITY
const medianB = d3.median(groupMap.get(b) || []) ?? Number.POSITIVE_INFINITY
if (medianA !== medianB) return medianA - medianB
return naturalCompare(a, b)
})
const letterGroups = []
const lettersBySeries = new Map(groupIds.map(groupId => [groupId, []]))
for (const seriesId of orderedSeries) {
const compatibleLetters = letterGroups.filter(letterGroup =>
[...letterGroup.members].every(member => !significantPairSet.has([seriesId, member].sort(naturalCompare).join("||")))
)
if (compatibleLetters.length === 0) {
const label = significanceLabelForIndex(letterGroups.length)
letterGroups.push({label, members: new Set([seriesId])})
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 {
relevantStats: [
{
comparison: "Kruskal-Wallis",
group1: null,
group2: null,
statistic: omnibus.statistic,
p_value: omnibus.p_value,
p_adj: omnibus.p_value,
n1: null,
n2: null,
median1: null,
median2: null,
n_wells1: null,
n_wells2: null,
n_timepoints1: null,
n_timepoints2: null
},
...pairwiseStats
],
pairwiseStats,
significantPairs,
kwBest: {
comparison: "Kruskal-Wallis",
statistic: omnibus.statistic,
p_value: omnibus.p_value,
p_adj: omnibus.p_value
},
analysisUnitType: activeWithinGroupCol !== null
? `${activeWithinGroupCol} within ${activeGroupCol} = ${activeGroupVal} / AUC`
: "sample_id / well",
modelClass: "Kruskal-Wallis",
pAdjustMethod: "BH",
aucSignificanceLabels: orderedSeries.map((seriesId, index) => ({
series_id: seriesId,
sig_label: (lettersBySeries.get(seriesId) || []).join(""),
label_y: labelY + index * labelStep
})),
aucSignificanceNote: activeWithinGroupCol !== null
? `Significance notation (${activeWithinGroupCol} within ${activeGroupCol} = ${activeGroupVal}): groups sharing a letter are not significantly different after BH-adjusted pairwise Wilcoxon tests (p_adj > 0.05). Different letters indicate at least one significant pairwise difference (p_adj <= 0.05).`
: `Significance notation: groups sharing a letter are not significantly different after BH-adjusted pairwise Wilcoxon tests (p_adj > 0.05). Different letters indicate at least one significant pairwise difference (p_adj <= 0.05).`,
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)
}
}
})()
cumulativeAucData = (() => {
const byTrace = new Map()
for (const d of aucCalcRows) {
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
}
timeSourceRows = plottedSorted
numericTimes = [...new Set(timeSourceRows.map(d => d.time_hr))]
.filter(Number.isFinite)
.sort((a, b) => a - b)
timeValues = numericTimes.map(String)
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})
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]
}))
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)))] )
nonBlankKeys = presentSeries.filter(k => k !== "Blank").sort(naturalCompare)
hasBlankSeries = presentSeries.includes("Blank")
colorDomain = hasBlankSeries ? ["Blank", ...nonBlankKeys] : nonBlankKeys
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)
}
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
}
erfApprox = x => {
const sign = x < 0 ? -1 : 1
const absX = Math.abs(x)
const a1 = 0.254829592
const a2 = -0.284496736
const a3 = 1.421413741
const a4 = -1.453152027
const a5 = 1.061405429
const p = 0.3275911
const t = 1 / (1 + p * absX)
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-absX * absX)
return sign * y
}
normalCdf = x => 0.5 * (1 + erfApprox(x / Math.SQRT2))
rankValues = values => {
const indexed = values.map((value, index) => ({value, index})).sort((a, b) => a.value - b.value)
const ranks = Array(values.length).fill(NaN)
let i = 0
while (i < indexed.length) {
let j = i + 1
while (j < indexed.length && indexed[j].value === indexed[i].value) j += 1
const avgRank = (i + j + 1) / 2
for (let k = i; k < j; k += 1) {
ranks[indexed[k].index] = avgRank
}
i = j
}
return ranks
}
kruskalWallisTest = groups => {
const allValues = []
const groupSizes = []
for (const group of groups) {
const clean = group.filter(v => Number.isFinite(v))
if (clean.length === 0) continue
allValues.push(...clean)
groupSizes.push(clean.length)
}
if (groupSizes.length < 2 || allValues.length < 2) {
return {statistic: NaN, p_value: NaN}
}
const ranks = rankValues(allValues)
let offset = 0
let hSum = 0
for (const size of groupSizes) {
const slice = ranks.slice(offset, offset + size)
const rankSum = d3.sum(slice)
hSum += (rankSum * rankSum) / size
offset += size
}
const n = allValues.length
const k = groupSizes.length
const h = (12 / (n * (n + 1))) * hSum - 3 * (n + 1)
const df = k - 1
// Wilson-Hilferty chi-squared approximation (accurate for df >= 1)
let p = NaN
if (df > 0 && h >= 0) {
const z = ((h / df) ** (1 / 3) - (1 - 2 / (9 * df))) / Math.sqrt(2 / (9 * df))
p = Math.min(1, 1 - normalCdf(z))
}
return {statistic: h, p_value: p}
}
wilcoxonRankSumTest = (values1, values2) => {
const group1 = values1.filter(v => Number.isFinite(v))
const group2 = values2.filter(v => Number.isFinite(v))
if (group1.length === 0 || group2.length === 0) {
return {statistic: NaN, p_value: NaN}
}
const combined = [...group1, ...group2]
const ranks = rankValues(combined)
const n1 = group1.length
const n2 = group2.length
const rankSum1 = d3.sum(ranks.slice(0, n1))
const u1 = rankSum1 - (n1 * (n1 + 1)) / 2
const meanU = (n1 * n2) / 2
const sdU = Math.sqrt((n1 * n2 * (n1 + n2 + 1)) / 12)
const z = sdU > 0 ? (u1 - meanU) / sdU : 0
const p = Math.min(1, 2 * (1 - normalCdf(Math.abs(z))))
return {statistic: u1, p_value: p}
}
aucMetricDescription = dataViewSel === "Delta fluorescence"
? "delta fluorescence"
: (dataViewSel === "Measurement-normalized fluorescence"
? "measurement-normalized fluorescence"
: (dataViewSel === "Paper metabolism (fold-change / selected measurement)"
? "paper metabolism"
: (dataViewSel === "Normalized fluorescence"
? "normalized fluorescence"
: "fluorescence")))
tooltipTitle = 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`,
`${metricField}: ${formatMetricValue(d[metricField])}`
]
if (activeGroupCol) {
parts.unshift(`${activeGroupCol}: ${d[activeGroupCol]}`)
}
if (activeWithinGroupCol) {
parts.unshift(`${activeWithinGroupCol}: ${d[activeWithinGroupCol]}`)
}
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}`,
`Total AUC: ${formatAucValue(d.auc_value)}`,
`Integrated metric: ${aucMetricDescription}`,
`Integration window: first to last available timepoint`,
`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`,
`Running cumulative AUC: ${formatAucValue(d.cumulative_auc)}`,
`Integrated metric: ${aucMetricDescription}`
]
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 = candidateRows.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 Measurement 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 ${aucMetricDescription} 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 aucVals = aucData.map(d => Number(d.auc_value)).filter(Number.isFinite)
const aucLogMax = aucVals.length > 0 ? d3.max(aucVals) : 1
const sigLabels = (useAucLogScale && aucStatsContext.aucSignificanceLabels.length > 0)
? aucStatsContext.aucSignificanceLabels.map((row, i) => ({
...row,
label_y: aucLogMax * Math.pow(1.15, i + 1)
}))
: aucStatsContext.aucSignificanceLabels
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,
...(useAucLogScale ? {type: "log"} : {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
}),
...(sigLabels.length > 0
? [Plot.text(sigLabels, {
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})
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 = activeWithinGroupCol !== null
? `Statistical Comparisons (AUC by ${activeWithinGroupCol} within ${activeGroupCol} = ${activeGroupVal})`
: "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 = activeWithinGroupCol !== null
? `<strong>Tests computed:</strong> ${aucStatsContext.modelClass || "Kruskal-Wallis"} omnibus test and ${aucStatsContext.pAdjustMethod || "BH"}-adjusted pairwise Wilcoxon comparisons of <code>${activeWithinGroupCol}</code> levels within <code>${activeGroupCol} = ${activeGroupVal}</code> (blanks excluded). The summary below answers whether the within-group levels differ overall, and which pairs differ after adjustment.`
: `<strong>Tests computed:</strong> ${aucStatsContext.modelClass || "model-based"} omnibus test across all groups, and ${aucStatsContext.pAdjustMethod || "Tukey"}-adjusted pairwise comparisons (blanks excluded). The replicate unit is <code>sample_id</code> when available; otherwise the analysis falls back to wells. The summary below answers whether the groups differ overall, and which group pairs differ after adjustment.`
statsWrap.appendChild(statDesc)
if (aucStatsContext.relevantStats.length === 0) {
const none = document.createElement("div")
none.textContent = "No statistical results available for the current selection."
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 groupsLabel = effectiveSeriesValues.length > 0 ? effectiveSeriesValues.join(", ") : "groups"
const kwText = aucStatsContext.kwBest
? `Omnibus result (${groupsLabel}): ${Number(aucStatsContext.kwBest.p_adj) < 0.05 ? "groups differ" : "no evidence that groups differ"} (${aucStatsContext.modelClass || "model"} p = ${aucStatsContext.formatP(aucStatsContext.kwBest.p_value)}, ${aucStatsContext.pAdjustMethod || "Tukey"}-adjusted p = ${aucStatsContext.formatP(aucStatsContext.kwBest.p_adj)}).`
: "Omnibus result: unavailable for this selection."
const sigPairText = aucStatsContext.significantPairs.length > 0
? `Pairwise differences after ${aucStatsContext.pAdjustMethod || "Tukey"} correction: ${aucStatsContext.significantPairs.map(r => `${r.group1} vs ${r.group2} (p_adj = ${aucStatsContext.formatP(r.p_adj)})`).join(", ")}.`
: `Pairwise differences after ${aucStatsContext.pAdjustMethod || "Tukey"} correction: none detected at the 0.05 threshold.`
summary.innerHTML = `<strong>${kwText}</strong><br>${sigPairText}<br><span style="color:#6b7280">Analysis unit: ${aucStatsContext.analysisUnitType}.</span>`
statsWrap.appendChild(summary)
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)
aucStatsContext.relevantStats.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 || "-", aucStatsContext.formatP(r.p_value), aucStatsContext.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)
} 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 (${aucMetricDescription})`},
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: {
label: "Time (hours)",
domain: timeValues,
padding: 0.1
},
x: {
type: "band",
domain: boxSeriesOrder,
axis: null
},
y: {
label: yLabel,
grid: true,
zero: true,
nice: true
},
color: {
legend: true,
label: seriesLabel,
domain: colorDomain,
range: colorRange
},
marks: [
Plot.boxY(boxPlotData, {
fx: d => `${d.time_hr}`,
x: "series_id",
y: boxValueField,
fill: "series_id",
fillOpacity: 0.4,
stroke: "series_id",
strokeWidth: 1.5
}),
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",
z: "trace_id",
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})
}
return plotWrap
})()Clam Dashboard
Auto-updated from plate exports in data/clam
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.