/** * CSV Analysis Library * * Method summary: * * 1. PARSING * - parseCSVHeaders(text): reads only the first line, splits on comma * (handles double-quoted fields with embedded commas). * - parseCSV(text): full parse into array-of-objects. Each row is * { [headerName]: value, ... }. Empty trailing fields are preserved as ''. * * 2. UNIQUE VALUES * - getUniqueNoteValues(rows, noteCol): scans each row's noteCol, * trims whitespace, ignores empty strings, returns sorted unique values. * * 3. ANALYSIS (runAnalysis) * Input: * rows — full parsed rows * timestampCol — column name to sort by (numeric sort; falls back to row order) * noteCol — column containing note values * classifications — plain object: { noteValue: categoryName } * unclassified notes are included in the sequence * but excluded from category counts and run stats. * * Steps: * a) Filter to rows where noteCol is non-empty. * b) Sort by timestampCol (parseFloat; NaN values go to the end). * c) Map each row to { note, category, timestamp }. * d) CATEGORY COUNTS: for each category, count total occurrences * and per-note-value breakdown. * e) RUN-LENGTH ENCODING (consecutive stat): * Walk the category sequence. Each time the category changes, * start a new run. Uncategorised notes are skipped (do not break * or continue a run). This gives an ordered list of * { category, length } objects representing every streak. * f) CONSECUTIVE STATS per category: * From the runs list, group run lengths per category and compute * totalRuns, maxRun, avgRun, and the full runLengths array. * * Output: { totalNoteRows, sequence, categoryCounts, runs, consecutiveStats } */ // ── CSV parsing ──────────────────────────────────────────────────────────────── function splitCSVLine(line) { const fields = []; let field = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { if (inQuotes && line[i + 1] === '"') { field += '"'; i++; } else { inQuotes = !inQuotes; } } else if (ch === ',' && !inQuotes) { fields.push(field); field = ''; } else { field += ch; } } fields.push(field); return fields; } /** Return column names from the first line of a CSV string. */ export function parseCSVHeaders(text) { const firstLine = text.split(/\r?\n/)[0]; return splitCSVLine(firstLine); } /** Parse the full CSV into { headers, rows }. */ export function parseCSV(text) { const lines = text.split(/\r?\n/); const headers = splitCSVLine(lines[0]); const rows = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]; if (!line.trim()) continue; const values = splitCSVLine(line); const row = {}; for (let j = 0; j < headers.length; j++) { row[headers[j]] = values[j] ?? ''; } rows.push(row); } return { headers, rows }; } // ── Unique note values ───────────────────────────────────────────────────────── /** Return sorted unique non-empty values found in noteCol. */ export function getUniqueNoteValues(rows, noteCol) { const seen = new Set(); for (const row of rows) { const v = (row[noteCol] ?? '').trim(); if (v) seen.add(v); } return [...seen].sort(); } // ── Frame gap detection ──────────────────────────────────────────────────────── /** * Check whether a numeric column is strictly consecutive (no gaps, no duplicates). * Returns { consecutive: bool, gaps: [{index, expected, actual, jump}], duplicates: [{index, value}] } * Only examines rows where the column value parses as an integer. */ export function checkFrameConsecutive(rows, frameCol) { const entries = []; for (let i = 0; i < rows.length; i++) { const v = rows[i][frameCol]; if (v == null || String(v).trim() === '') continue; const n = parseInt(v, 10); if (isNaN(n)) continue; entries.push({ rowIndex: i, value: n }); } const gaps = []; const duplicates = []; for (let i = 1; i < entries.length; i++) { const diff = entries[i].value - entries[i - 1].value; if (diff === 0) { duplicates.push({ rowIndex: entries[i].rowIndex, value: entries[i].value }); } else if (diff !== 1) { gaps.push({ rowIndex: entries[i].rowIndex, expected: entries[i - 1].value + 1, actual: entries[i].value, jump: diff, }); } } return { consecutive: gaps.length === 0 && duplicates.length === 0, gaps, duplicates, total: entries.length }; } // ── Analysis ─────────────────────────────────────────────────────────────────── /** * Run the full analysis. * * @param {object[]} rows - Parsed CSV rows * @param {string} timestampCol - Column to sort by * @param {string} noteCol - Column with note values * @param {object} classifications - { noteValue: categoryName } * @returns {object} Analysis result */ export function runAnalysis(rows, timestampCol, noteCol, classifications) { // Compute session end from ALL rows (not just note rows) let sessionEndTs = null; for (const r of rows) { const t = parseFloat(r[timestampCol]); if (!isNaN(t) && (sessionEndTs === null || t > sessionEndTs)) sessionEndTs = t; } // a) Filter non-empty notes const noteRows = rows.filter(r => (r[noteCol] ?? '').trim() !== ''); // b) Sort by timestamp (numeric); preserve original order for equal/NaN timestamps noteRows.sort((a, b) => { const ta = parseFloat(a[timestampCol]); const tb = parseFloat(b[timestampCol]); if (isNaN(ta) && isNaN(tb)) return 0; if (isNaN(ta)) return 1; if (isNaN(tb)) return -1; return ta - tb; }); // c) Build sequence const sequence = noteRows.map(r => ({ note: r[noteCol].trim(), category: classifications[r[noteCol].trim()] ?? null, timestamp: r[timestampCol], })); // d) Category counts const categoryCounts = {}; for (const { note, category } of sequence) { if (!category) continue; if (!categoryCounts[category]) categoryCounts[category] = { total: 0, byNote: {} }; categoryCounts[category].total++; categoryCounts[category].byNote[note] = (categoryCounts[category].byNote[note] || 0) + 1; } // e) Run-length encoding (skip uncategorised entries — they neither break nor extend a run) const runs = []; for (const { category } of sequence) { if (!category) continue; if (runs.length === 0 || runs[runs.length - 1].category !== category) { runs.push({ category, length: 1 }); } else { runs[runs.length - 1].length++; } } // f) Consecutive stats per category const consecutiveStats = {}; for (const { category, length } of runs) { if (!consecutiveStats[category]) { consecutiveStats[category] = { runLengths: [], totalRuns: 0, maxRun: 0, avgRun: 0 }; } const s = consecutiveStats[category]; s.runLengths.push(length); s.totalRuns++; if (length > s.maxRun) s.maxRun = length; } for (const cat of Object.keys(consecutiveStats)) { const s = consecutiveStats[cat]; s.avgRun = s.runLengths.reduce((a, b) => a + b, 0) / s.runLengths.length; } return { totalNoteRows: noteRows.length, sessionEndTs, sequence, categoryCounts, runs, consecutiveStats, }; } // ── Numeric extraction (for numerical buckets) ──────────────────────────────── /** * Extract the first signed decimal from a string. Returns null if none found. * Examples: "L25.5" -> 25.5, "R-12" -> -12, "abc" -> null */ export function extractNumber(str) { if (str == null) return null; const m = String(str).match(/-?\d+(\.\d+)?/); return m ? parseFloat(m[0]) : null; } /** * Build a numeric-vs-x series for a numerical bucket. * * @param {object[]} rows - Parsed CSV rows * @param {string} xColName - Column to plot on the x-axis (frame col, or timestamp as fallback) * @param {string} noteCol - Column containing note values * @param {object} bucket - { name, type:'numerical', notes: string[] } * @returns {{points: {x:number,value:number,raw:string}[], avg:number|null, skipped:number, total:number, xLabel:string}} */ export function computeNumericSeries(rows, xColName, noteCol, bucket) { const noteSet = new Set(bucket.notes); const points = []; let skipped = 0; let total = 0; for (const r of rows) { const note = String(r[noteCol] ?? '').trim(); if (!noteSet.has(note)) continue; total++; const value = extractNumber(note); const x = parseFloat(r[xColName]); if (value == null || Number.isNaN(x)) { skipped++; continue; } points.push({ x, value, raw: note }); } points.sort((a, b) => a.x - b.x); const avg = points.length === 0 ? null : points.reduce((s, p) => s + p.value, 0) / points.length; return { points, avg, skipped, total, xLabel: xColName }; }