viewof patient_selector = {
if (isCustomData) {
return Inputs.select(patients_data, {value: initial_patient || patients_data[0], label: "Select Patient:"});
} else {
// Return a hidden input/placeholder so variable exists but UI is clean
const div = html`<div style="display: none;"></div>`;
div.value = initial_patient; // Default value so 'patient' variable doesn't break
return div;
}
}
viewof minutes = Inputs.range([0,11], {step: 1, value: 0})
html`<div id="notes-toolbar-container" style="position: absolute; right: 20px; top: 50px; display: flex; gap: 10px; z-index: 1000;"></div>`notesTrigger = Generators.observe((notify) => {
const handler = () => notify(Date.now());
window.addEventListener("notes-updated", handler);
// Initial value
notify(Date.now());
// Cleanup
return () => window.removeEventListener("notes-updated", handler);
});
// Load notes and organize by card -> Set(minutes)
currentNotes = {
notesTrigger; // Dependency on the generator
const pid = patient; // Dependency on patient ID
const key = "patient_notes_" + pid;
const raw = localStorage.getItem(key);
const notes = raw ? JSON.parse(raw) : [];
// Helper to parse "MM:SS" to minute index (0-based)
// Logic: 00:01-01:00 -> Index 0. 01:01-02:00 -> Index 1.
const parseMinute = (ts) => {
if (!ts) return -1;
const parts = ts.split(":");
if (parts.length !== 2) return -1;
const m = parseInt(parts[0]);
const s = parseInt(parts[1]);
const totalSeconds = m * 60 + s;
return Math.floor(totalSeconds / 60);
};
const map = {
Sentiment: new Map(),
Face: new Map(),
AudioText: new Map(),
AudioPitch: new Map(),
LikertPlot: new Map()
};
notes.forEach(n => {
const m = parseMinute(n.timestamp);
if (m >= 0) {
// Match tag to key
if (n.tag === "Sentiment Plot") map.Sentiment.set(m, n.content);
else if (n.tag === "Face Plot") map.Face.set(m, n.content);
else if (n.tag === "Audio & Text") map.AudioText.set(m, n.content);
else if (n.tag === "Audio Pitch Plot") map.AudioPitch.set(m, n.content);
else if (n.tag === "Diagnostic") map.LikertPlot.set(m, n.content);
}
});
return map;
}transcript_data = Generators.observe(notify => {
FileAttachment("data_1/Participants/All/allTranscriptMerged.csv").csv({ typed: true }).then(d => notify(d));
const handler = (e) => {
try {
const data = d3.csvParse(e.detail, d3.autoType);
notify(data);
} catch(err) { console.error("Parse error", err); }
};
window.addEventListener("data-imported-transcript", handler);
return () => window.removeEventListener("data-imported-transcript", handler);
})url_params = new URLSearchParams(window.location.search);
url_patient = parseInt(url_params.get("patient_id"));
// Check if this is explicitly requested as an empty dashboard
is_blank_dashboard = window.location.pathname.includes("/blank") || url_params.has("blank");
allowed_patients = [3, 4, 7, 8, 9, 10, 11, 12, 15, 20, 21, 22, 24, 33, 34, 36, 40, 46, 47, 48, 51, 53, 54, 65, 67, 69, 70, 72, 76, 81, 84, 88, 89, 103, 110, 112, 119, 122, 124, 130, 138, 139, 144, 146, 147, 150, 151, 153, 154, 158, 159, 160, 161, 167, 169, 174, 177, 178, 185, 188];
// Extract ALL unique patient IDs from the data (including imported data)
// Filter them to only include allowed IDs (unless custom data is imported), and sort them
patients_data = Array.from(new Set(transcript_data.map(row => row.patient_id)))
.filter(id => isCustomData ? true : allowed_patients.includes(id))
.sort((a, b) => a - b);
// Import Detection
// Default to false. When 'data-imported-transcript' event fires, set to true.
isCustomData = Generators.observe((notify) => {
notify(false); // Default
const handler = () => notify(true);
window.addEventListener("data-imported-transcript", handler);
return () => window.removeEventListener("data-imported-transcript", handler);
});
// Calculate Initial Patient (only relevant if NOT custom data)
// If URL param is provided but invalid, set to null. Otherwise check for blank dashboard or default to 3.
// Note: patients_data depends on transcript_data.
invalid_patient_requested = url_params.has("patient_id") && !patients_data.includes(url_patient);
initial_patient = invalid_patient_requested
? null
: (url_patient
? url_patient
: (is_blank_dashboard ? null : (patients_data.includes(3) ? 3 : (patients_data.length > 0 ? patients_data[0] : null))));
// Determine Active Patient ID
// If custom data is loaded, use the dropdown value.
// Otherwise, use the calculated initial patient.
patient = isCustomData ? patient_selector : initial_patient;
// Expose patient ID to global scope for notes.js
window.dashboardPatientId = patient;filtered_transcript = transcript_data.filter(function(data) {
return data.patient_id == patient;
});
filtered_transcript_final = facePlotting.increaseData(filtered_transcript, patient, transcript_data);
stackedData = filtered_transcript_final.flatMap((row, index) =>
(row.label_count ? JSON.parse(row.label_count) : []).map((label) => ({
Minute: index, // Change to 0-based index
label: label.VALUE,
confidence: row.Confidence,
Count: label.count,
}))
);
conf = filtered_transcript_final.map(d => d.Confidence);viewof sentimentPlot = {
if (!patient) {
if (invalid_patient_requested && !isCustomData) {
return html`<div style="display:flex; justify-content:center; align-items:center; height: 100%; color: #999; font-size: 1.2rem;">The requested Patient ID (${url_params.get("patient_id")}) is not available in our dataset.</div>`;
}
return html`<div style="display:flex; justify-content:center; align-items:center; height: 100%; color: #999; font-size: 1.2rem;">Please import data or specify a patient ID to view the dashboard.</div>`;
}
// Calculate max stack height for the highlight box
const rollup = d3.rollup(stackedData, v => d3.sum(v, d => d.Count), d => d.Minute);
const maxStack = d3.max(rollup.values());
const plot = Plot.plot({
width: cards.sentiment_plot.width,
height: cards.sentiment_plot.height - 67,
x: {label: "Minute", domain: d3.range(0, 12), tickFormat: d => d}, // Domain 0-11
y: {label: "Count", tickFormat: "s", tickSpacing: 50, grid: true, domain: [0, maxStack * 1.3]},
color: {
type: "categorical",
domain: ["ANGER", "ANXIETY", "NEGATIVE EMOTION", "SAD", "POSITIVE EMOTION", "CONFIDENCE"],
range: ["#ef476f", "#f78c6b", "#ffd166", "#118ab2", "#3E5622", "#06d6a0"],
legend: "ramp",
width: cards.sentiment_plot.width,
},
marks: [
// Invisible Click Capture Bars (One per minute)
Plot.barY(d3.range(0, 12), { // Range 0-11
x: d => d,
y: maxStack * 1.2,
fill: "transparent",
title: d => `Select Minute ${d}`,
render: (index, scales, values, dimensions, context, next) => {
const g = next(index, scales, values, dimensions, context);
d3.select(g).selectAll("rect").on("click", function(event, d) {
// Map clicked rect index to minute data
const nodes = Array.from(g.querySelectorAll("rect"));
const i = nodes.indexOf(event.target);
if (i >= 0) {
const clickedMinute = index[i];
viewof minutes.value = clickedMinute;
viewof minutes.dispatchEvent(new Event("input", {bubbles: true}));
}
});
d3.select(g).style("cursor", "pointer");
return g;
}
}),
// Actual stacked bars
Plot.barY(stackedData, {
x: "Minute",
y: "Count",
fill: "label",
title: d => `${d.label}: ${d.Count}\nConfidence: ${d.confidence.toFixed(2)}`,
render: (index, scales, values, dimensions, context, next) => {
const g = next(index, scales, values, dimensions, context);
d3.select(g).style("cursor", "pointer");
// Interaction handled by invisible bars overlay
return g;
}
}),
// Highlight box
Plot.barY([{Minute: minutes}], {
x: "Minute",
y: maxStack * 1.1,
fill: "none",
stroke: "#FF00FF",
strokeWidth: 2,
inset: -4
}),
Plot.barY(
Array.from(
d3.rollup(
stackedData,
rows => ({
Minute: rows[0].Minute,
y: -.2,
confidence: d3.mean(rows, d => d.confidence),
}),
d => d.Minute
).values()
),
{
x: "Minute",
y: "y",
fillOpacity: d => d.confidence,
fill: "#06d6a0",
title: d => `Confidence: ${d.confidence.toFixed(2)}`,
insetLeft: -2,
insetRight: -2,
stroke: "black",
strokeWidth: 0.2,
}
),
// Note Indicators
Plot.text(Array.from(currentNotes.Sentiment, ([m, t]) => ({Minute: m, Note: t})), {
x: "Minute",
y: 0,
text: d => "✏️",
title: "Note", // Bind tooltip
dy: 24,
fontSize: 12
})
]
});
return plot;
}// Reactive: Audio Element
// Custom Audio Source Handler
customAudioUrl = Generators.observe((notify) => {
notify(null); // Default
const handler = (e) => notify(e.detail);
window.addEventListener("data-imported-audio-source", handler);
return () => window.removeEventListener("data-imported-audio-source", handler);
});// Reactive: Audio Element
audioElement = {
if (!patient) return null;
// Use custom URL if available, otherwise default
const audioFile = customAudioUrl || facePlotting.AudioFile(patient);
return html`<audio controls controlsList="nodownload noplaybackrate">
<source src="${audioFile}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>`;
}// Reactive: Formatted Text
formattedText = {
if (!patient) return null;
return facePlotting.formatTextAtMinute(filtered_transcript, minutes, currentNotes.AudioText);
}// Reactive: Audio Position Update (Side Effect)
{
minutes; // Dependency
// Use a small delay/check to ensure metadata is loaded if it's new
if (audioElement && patient) {
const t = (minutes)*60;
// Only seek if difference is significant to avoid stutter or fighting
if (Math.abs(audioElement.currentTime - t) > 0.5) {
audioElement.currentTime = t;
}
}
}coordinates = d3.csv(await FileAttachment("myfeat/resources/neutral_face_coordinates.csv").url());
// Load AU Data
au_csv_path = FileAttachment("data_1/Participants/All/allAUMerged.csv").url();
gaze_csv_path = FileAttachment("data_1/Participants/All/allGazeMerged.csv").url();
au_data = Generators.observe(notify => {
FileAttachment("data_1/Participants/All/allAUMerged.csv").url().then(url => d3.csv(url, d3.autoType)).then(d => notify(d));
const handler = (e) => notify(d3.csvParse(e.detail, d3.autoType));
window.addEventListener("data-imported-au", handler);
return () => window.removeEventListener("data-imported-au", handler);
})
gaze_data = Generators.observe(notify => {
FileAttachment("data_1/Participants/All/allGazeMerged.csv").url().then(url => d3.csv(url, d3.autoType)).then(d => notify(d));
const handler = (e) => notify(d3.csvParse(e.detail, d3.autoType));
window.addEventListener("data-imported-gaze", handler);
return () => window.removeEventListener("data-imported-gaze", handler);
})
myFile = FileAttachment("myfeat/face_plotting.js").url();
facePlotting = await import(myFile);
filtered_au = au_data.filter(d => d.patient_id === patient);
filtered_gaze = gaze_data.filter(d => d.patient_id === patient);
filtered_au_12 = filtered_au.slice(0, 12);
filtered_gaze_12 = filtered_gaze.slice(0, 12);
// Extract AU and Gaze values
au_columns = (filtered_au_12.length > 0) ? Object.keys(filtered_au_12[0]).filter((col) => col.endsWith("_r") || col.endsWith("_c")) : [];
// Extract coordinates
currx = coordinates.map(d => +d.x);
curry = coordinates.map(d => +d.y);
au_values = (filtered_au_12.length > 0) ? filtered_au_12.map((d) => au_columns.map((col) => d[col])) : [];
gaze_values = (filtered_gaze_12.length > 0) ? filtered_gaze_12.map((d) => [d.x_0, d.y_0, d.x_1, d.y_1]) : [];
viewof allFacePlot = {
if (!patient) return html`<div></div>`; // Return mostly empty if no patient
return facePlotting.allFacePlotting({
currx: currx,
curry: curry,
au: filtered_au_12,
gaze: gaze_values,
width: 200,
height: 250,
selectedMinute: minutes,
marginLeft: 40, // Match Sentiment Plot margin
onSelect: (m) => {
// Imperatively update the minutes input and notify OJS
viewof minutes.value = m;
viewof minutes.dispatchEvent(new Event("input", {bubbles: true}));
},
notesSet: currentNotes.Face
});
}
html`<div class="d-flex justify-content-center align-items-center" style="width: 100%; height: 100%; overflow: hidden;">
${viewof allFacePlot}
</div>`filtered_m_au = filtered_au.filter(d =>{
const recordDate = new Date(d.minute);
return recordDate.getMinutes() === minutes;
});
filtered_m_gaze = filtered_gaze.filter(d => {
const recordDate = new Date(d.minute);
return recordDate.getMinutes() === minutes;
});
filtered_au_values = (filtered_m_au.length > 0) ? filtered_m_au.map((d) => au_columns.map((col) => d[col])) : [];
filtered_gaze_values = (filtered_m_gaze.length > 0) ? filtered_m_gaze.map((d) => [d.x_0, d.y_0, d.x_1, d.y_1]) : [];
viewof oneFacePlot = {
if (!patient) return html`<div></div>`;
return facePlotting.oneFacePlotting({
currx: currx,
curry: curry,
minute: minutes,
au: filtered_m_au,
gaze: filtered_gaze_values,
width: 200,
height: 220,
notesSet: currentNotes.Face
});
}
html`<div style="flex: 1 1 0; min-height: 0; width: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center;">
${viewof oneFacePlot}
</div>`stacked_ori = FileAttachment("data_1/Participants/All/PerMinute_Predictions.csv").url();
stacked_data = Generators.observe(notify => {
FileAttachment("data_1/Participants/All/PerMinute_Predictions.csv").url().then(url => d3.csv(url, d3.autoType)).then(d => notify(d));
const handler = (e) => notify(d3.csvParse(e.detail, d3.autoType));
window.addEventListener("data-imported-predictions", handler);
return () => window.removeEventListener("data-imported-predictions", handler);
})
patient;stack_filtered = stacked_data.filter(d => d.patient_id === patient);
val = stack_filtered.length > 0 ? stack_filtered[0].True_Label : null;likert_scale = FileAttachment("data_1/Participants/All/VLM_results.csv").url();
likert_data = Generators.observe(notify => {
FileAttachment("data_1/Participants/All/VLM_results.csv").url().then(url => d3.csv(url, d3.autoType)).then(d => notify(d));
const handler = (e) => notify(d3.csvParse(e.detail, d3.autoType));
window.addEventListener("data-imported-likert", handler);
return () => window.removeEventListener("data-imported-likert", handler);
})
patientId = patient;
row = likert_data.find(d => d.pid === patientId);
// 2) Size from the card this cell lives in
width = (cards?.likert_plot?.width ?? 900);
height = (cards?.likert_plot?.height ?? 185) - 8;
likertPlot = facePlotting.likertPlotting({
row,
width,
height,
bracketSideRoom: 80,
legend: true
});
// Display with safety check
html`<div style="flex: 1 1 0; min-height: 0; width: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center;">
${(patient && row) ? likertPlot : html`<div style="color: #ccc; font-style: italic;">${patient ? `No diagnostic data for patient ${patientId}` : ""}</div>`}
</div>`pitch = FileAttachment("data_1/Participants/All/allAudioMerged.csv").url();
pitch_data = Generators.observe(notify => {
FileAttachment("data_1/Participants/All/allAudioMerged.csv").url().then(url => d3.csv(url, d3.autoType)).then(d => notify(d));
const handler = (e) => notify(d3.csvParse(e.detail, d3.autoType));
window.addEventListener("data-imported-audio", handler);
return () => window.removeEventListener("data-imported-audio", handler);
})
patient;filtered_pitch = pitch_data.filter(d => d.patient_id === patient);
filtered_pitch_12 = filtered_pitch.slice(0, 12);
v1 = (d) => d.F0;
v2 = (d) => d.Rd_conf;
y2 = d3.scaleLinear([0, 1], [0, d3.max(filtered_pitch_12, v1)]);viewof pitchPlot = {
if (!patient) return html`<div></div>`;
return Plot.plot({
width: cards.pitch_plot.width,
height: cards.pitch_plot.height-1,
marks: [
Plot.axisY(
y2.ticks(),
{
color: "green",
anchor: "right",
label: "Confidence Score",
y: y2,
tickFormat: y2.tickFormat()
}),
Plot.ruleY([0]),
Plot.lineY(filtered_pitch_12, {
x: d => d.index - 1, // Shift to 0-based
y: "F0",
stroke: "steelblue",
}),
Plot.lineY(filtered_pitch_12,
Plot.mapY(
(D) => D.map(y2), {
x: d => d.index - 1, // Shift to 0-based
y: v2,
stroke: "green"
})
),
// Highlight Selected Minute
Plot.ruleX(filtered_pitch_12[minutes] ? [filtered_pitch_12[minutes]] : [], {
x: d => d.index - 1, // Shift to 0-based
stroke: "#FF00FF",
strokeWidth: 2
}),
Plot.dot(filtered_pitch_12[minutes] ? [filtered_pitch_12[minutes]] : [], {
x: d => d.index - 1, // Shift to 0-based
y: "F0",
fill: "steelblue",
stroke: "white",
strokeWidth: 2,
r: 5
}),
Plot.dot(filtered_pitch_12[minutes] ? [filtered_pitch_12[minutes]] : [],
Plot.mapY((D) => D.map(y2), {
x: d => d.index - 1, // Shift to 0-based
y: v2,
fill: "green",
stroke: "white",
strokeWidth: 2,
r: 5
})
),
// Note Indicators
Plot.text(Array.from(currentNotes.AudioPitch, ([m, t]) => ({index: m, Note: t})), {
x: "index", // m is already 0-11
y: 0,
text: d => "✏️",
title: "Note", // Bind tooltip
dy: 25,
fontSize: 12
})
],
x: {
label: "Time (minutes)",
domain: [0, 11],
ticks: 12,
tickFormat: d => d.toFixed(0)
},
y: {
axis: "left",
color: "steelblue",
label: "Pitch (F0)",
grid: true,
},
fy: {
label: "Rd_conf",
grid: true,
domain: [0, 1],
},
color: {
legend: true,
},
});
}