viewof minutes = Inputs.range([0,11], {step: 1, value: 0})
// html`<p><strong>PHQ: ${val}</strong></p>`
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;
if (totalSeconds === 0) return 0;
return Math.floor((totalSeconds - 1) / 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);
})
params = new URLSearchParams(window.location.search)
// Example: Get value of `?patient_id=311`
patient = parseInt(params.get("patient_id"))
patient_data = Array.from(new Set(transcript_data.map(row => row.patient_id)));
patients_data = patient_data.filter(id => [354, 421, 311, 309, 405, 308, 347, 348, 351, 319, 321, 332, 381, 412, 346, 453, 367, 384, 388, 389, 372, 414, 440, 441, 353, 376, 426, 461, 339, 365, 369, 370, 432, 446, 448, 449, 455, 456, 462, 464, 472, 477,424, 480, 481, 488, 491, 302, 303, 306, 307, 314, 323, 333, 335, 452, 463, 470, 310, 320].includes(id));
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 + 1,
label: label.VALUE,
confidence: row.Confidence,
Count: label.count,
}))
);
conf = filtered_transcript_final.map(d => d.Confidence);
conf;// FINAL ROBUST IMPLEMENTATION REPLACE
viewof sentimentPlot = {
// 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(1, 13), tickFormat: d => d},
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(1, 13), {
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) {
// 'd' here works if D3 bound it? Plot usually doesn't bind data to __data__.
// But we iterate over indices.
// Let's use the index to retrieve the value from the data array!
// We need to know which rect was clicked.
const nodes = Array.from(g.querySelectorAll("rect"));
const i = nodes.indexOf(event.target);
if (i >= 0) {
const m = index[i]; // logical index? No, index[i] is the index in data array.
// data is d3.range(1, 13) -> [1, 2, ... 12]
// so data[index[i]] is the minute!
const clickedMinute = index[i];
viewof minutes.value = clickedMinute - 1;
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");
d3.select(g).on("click", function(event) {
// Start from the clicked element and find its data
// We can use the index passed to render!
// 'index' here is the array of indices being rendered in this group
// But 'g' contains ALL the bars.
// Let's filter click by position?
// Simpler: Just map the text title!
// The title contains "Confidence".
// But we want the minute.
// The data is bound to the rects in OJS Plot? Rare.
// FALLBACK: Use simple d3.pointer and x-scale domain (1-12)
const [mx] = d3.pointer(event, this); // 'this' is the group
// We need to know the width of the chart area to map x to minute.
// dimensions.width ...
// EASIEST: Just attach data to the element title and parse it, OR
// Use the helper distinct bar overlay below.
});
return g;
}
}),
// Highlight box
Plot.barY([{Minute: minutes + 1}], {
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 + 1, Note: t})), {
x: "Minute",
y: 0,
text: d => "✏️",
title: "Note", // Bind tooltip
dy: 24,
fontSize: 12
})
]
});
return plot;
}formattedText = facePlotting.formatTextAtMinute(filtered_transcript, minutes, currentNotes.AudioText);
phqText = facePlotting.formatPHQText(val);
// audioFile = FileAttachment("data_1/Audio/300_AUDIO.wav").url();
audioFile = facePlotting.AudioFile(patient);
// Output the formatted HTML
// Note: We create audio element separately so it isn't recreated when minutes changes
audioElement = html`<audio controls controlsList="nodownload noplaybackrate">
<source src="${audioFile}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>`;
// Reactive update for audio position - return empty to avoid undefined output
{
minutes; // Dependency
// Use a small delay/check to ensure metadata is loaded if it's new
if (audioElement) {
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;
}
}
return html``;
}// Initialize popovers for text card whenever formattedText updates
{
formattedText; // dependency
// Use a small delay to ensure the DOM elements (from html tagged template) are inserted
setTimeout(() => {
try {
const popovers = document.querySelectorAll(".note-popover-init");
popovers.forEach(el => {
// Dispose existing if any to avoid leaks/dupes (optional but safe)
const existing = bootstrap.Popover.getInstance(el);
if (existing) existing.dispose();
new bootstrap.Popover(el, { trigger: 'focus' });
});
} catch(e) { console.error("Popover init error:", e); }
}, 200);
}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);
// // Parse the data
// au_data = await facePlotting.parseAUData(au_csv_path);
// gaze_data = await facePlotting.parseGazeData(gaze_csv_path);
// filtered_au = au_data.filter(d => d.patient_id === 306);
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]) : [];
// filtered_gaze;
viewof allFacePlot = facePlotting.allFacePlotting({
currx: currx,
curry: curry,
au: filtered_au_12,
gaze: gaze_values,
width: 200,
height: 250,
// parentSelector: "#all_face-14", // Removed to allow detached SVG generation
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 = facePlotting.oneFacePlotting({
currx: currx,
curry: curry,
minute: minutes,
au: filtered_m_au,
gaze: filtered_gaze_values,
width: 200,
height: 220,
// parentSelector: "#single_face-6" // Removed for detached generation
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.map(d => d.True_Label)
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 = (typeof params !== "undefined" && params?.patient != null) ? params.patient : 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
});
// 4) Display
html`<div style="flex: 1 1 0; min-height: 0; width: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center;">
${likertPlot}
</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(d3.extent(filtered_pitch, v2), [0, d3.max(filtered_pitch, v1)]);
y2 = d3.scaleLinear([0, 1], [0, d3.max(filtered_pitch_12, v1)]);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: "index",
y: "F0",
stroke: "steelblue",
}),
Plot.lineY(filtered_pitch_12,
Plot.mapY(
(D) => D.map(y2), {
x: "index",
y: v2,
stroke: "green"
})
),
// Highlight Selected Minute
Plot.ruleX(filtered_pitch_12[minutes] ? [filtered_pitch_12[minutes]] : [], {
x: "index",
stroke: "#FF00FF",
strokeWidth: 2
}),
Plot.dot(filtered_pitch_12[minutes] ? [filtered_pitch_12[minutes]] : [], {
x: "index",
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: "index",
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",
y: 0,
text: d => "✏️",
title: "Note", // Bind tooltip
dy: 25,
fontSize: 12
})
],
x: {
label: "Time (minutes)",
},
y: {
axis: "left",
color: "steelblue",
label: "Pitch (F0)",
grid: true,
// domain: [0, 255],
},
fy: {
label: "Rd_conf",
grid: true,
domain: [0, 1],
},
color: {
legend: true,
},
});