Dash-M5H
Dashboard for Multi-Modal, Multi-Model Mental Health
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;
}
jquery = require("jquery");
window.jQuery = jquery;
window.$ = jquery;
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``; 
}
html`${audioElement}<br>
${formattedText}`;
// 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,
    },
});