Dash-M5H
Dashboard for Multi-Modal, Multi-Model Mental Health
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;
}
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);
})
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;
     }
  }
}
// Reactive: Display Output
{
  if (!patient) return html`<div></div>`;
  return html`${audioElement}<br>${formattedText}`;
}
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,
    },
    });
}