feat(chart): Chart view + csv-chart block with best-fit and formula overlay

New Chart view mode (scatter/line of any numeric column pair, X/Y pickers
persisted per file) plus a csv-chart code block for embedding charts — or
pure y=f(x) plots — in notes. Least-squares fit line with equation + R²
(per-day trend phrasing on date X), formula overlay via a new eval-free
expression parser (src/formula.ts). Chart.js lazy loader extracted from the
dashboard into src/chartjs-loader.ts and shared by all three consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
SVM0N 2026-07-11 15:32:30 +00:00
parent b12da235ea
commit 6cb2deccb2
12 changed files with 1072 additions and 37 deletions

39
main.js

File diff suppressed because one or more lines are too long

12
main.ts
View file

@ -32,9 +32,11 @@ import { renderToolbar, availableModes } from "./src/view/toolbar";
import { renderRandomCard } from "./src/random-block";
import { renderDashboard } from "./src/view/dashboard";
import { renderStats, hasStatsColumns } from "./src/view/stats";
import { renderChart, hasChartColumns } from "./src/view/chart";
import { renderFocus } from "./src/view/focus";
import { renderTasks, hasTaskColumns, taskProjectCol, taskTypeCol, taskPriorityCol } from "./src/view/tasks";
import { registerCsvViewBlock } from "./src/inline-view";
import { registerCsvChartBlock } from "./src/chart-block";
// World-map SVG asset, loaded lazily from the plugin dir and cached for the
// session (undefined = not yet read, null = read failed/missing).
@ -111,6 +113,7 @@ export class CardView extends FileView {
if ((needsCategory && !effectiveGroupCol(this)) || (needsDate && !this.hasDateColumn())
|| (this.mode === "travel" && !this.isTravelFile())
|| (this.mode === "stats" && !hasStatsColumns(this))
|| (this.mode === "chart" && !hasChartColumns(this))
|| (this.mode === "tasks" && !hasTaskColumns(this))) {
this.mode = "table";
}
@ -628,6 +631,7 @@ export class CardView extends FileView {
else if (this.mode === "library") renderLibrary(this, content);
else if (this.mode === "kanban-genre") renderKanbanGenre(this, content);
else if (this.mode === "stats") renderStats(this, content);
else if (this.mode === "chart") void renderChart(this, content);
else if (this.mode === "focus") renderFocus(this, content);
else if (this.mode === "tasks") renderTasks(this, content);
else renderTable(this, content);
@ -982,6 +986,14 @@ export default class CardViewPlugin extends Plugin {
(lang, handler) => this.registerMarkdownCodeBlockProcessor(lang, handler),
);
// csv-chart: an inline read-only chart of a CSV (or a pure y = f(x)
// formula plot) — scatter/line, best-fit, formula overlay. See
// src/chart-block.ts.
registerCsvChartBlock(
this.app,
(lang, handler) => this.registerMarkdownCodeBlockProcessor(lang, handler),
);
// Palette commands — operate on the active CSV view, so they're
// hotkey-able (Settings → Hotkeys → filter "CSV Card View").
this.addCommand({

201
src/chart-block.ts Normal file
View file

@ -0,0 +1,201 @@
// ─── csv-chart code block: an inline chart in any note ───────────────────────
//
// Embeds a Chart.js scatter/line plot of a CSV's columns — or a pure formula
// curve with no data at all — inside a note, the way csv-view embeds a table.
//
// ```csv-chart
// file: ../health.csv (sibling / ../ walked / vault-relative, like csv-view;
// omit entirely for a formula-only plot)
// x: date (optional — default: date column → first numeric)
// y: weight (optional — default: first numeric column ≠ x)
// fit: linear (optional best-fit line with equation + R²)
// formula: 0.5x + 2 (optional y = f(x) overlay; the whole plot when
// there's no file. See src/formula.ts for syntax.)
// xmin: -10 (formula-only plots: domain, default -10 … 10)
// xmax: 10
// height: 280 (optional px height)
// ```
//
// Read-only: it re-renders off the vault `modify` event when the source CSV
// changes (edited in a DataDeck tab, a csv-view block, or external sync), but
// never writes. Reuses the extraction / fit / config core from src/view/chart.ts
// and the shared lazy Chart.js loader. Covered by test-view-smoke.mjs.
import { App, MarkdownPostProcessorContext, MarkdownRenderChild, TFile } from "obsidian";
import { CSVRow } from "./types";
import { parseCSV, resolvePath } from "./utils";
import { isDateCol } from "./field-types";
import { loadChart } from "./chartjs-loader";
import {
buildChartConfig, extractPoints, numericColumns, resolveChartColors,
ChartSpec,
} from "./view/chart";
interface ChartBlockOptions {
file: string;
x: string;
y: string;
fit: "none" | "linear";
formula: string;
xmin: number | null;
xmax: number | null;
height: number | null;
}
/** Parse the `key: value` lines of a csv-chart block. Forgiving, like csv-view. */
function parseBlockSource(source: string): ChartBlockOptions {
const lines = source.split("\n").map(l => l.trim()).filter(Boolean);
const opt = (key: string) =>
lines.find(l => l.toLowerCase().startsWith(key + ":"))?.slice(key.length + 1).trim() ?? "";
const num = (key: string): number | null => {
const n = parseFloat(opt(key));
return Number.isFinite(n) ? n : null;
};
return {
file: opt("file"),
x: opt("x"),
y: opt("y"),
fit: opt("fit").toLowerCase() === "linear" ? "linear" : "none",
formula: opt("formula"),
xmin: num("xmin"),
xmax: num("xmax"),
height: num("height"),
};
}
/** Duck-typed TFile check — mirrors inline-view.ts (cross-bundle instanceof is unreliable). */
function asFile(f: unknown): TFile | null {
return f && typeof f === "object" && "basename" in (f as object) ? (f as TFile) : null;
}
function parseIsoDate(s: string): Date | null {
const m = (s ?? "").trim().match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return null;
return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]));
}
class ChartBlock extends MarkdownRenderChild {
private chart: { destroy(): void } | null = null;
constructor(
containerEl: HTMLElement,
private app: App,
private opts: ChartBlockOptions,
) {
super(containerEl);
}
async onload(): Promise<void> {
this.containerEl.addClass("csv-chart-block");
await this.render();
if (this.opts.file) {
this.registerEvent(this.app.vault.on("modify", (f) => {
if (f.path === this.opts.file) void this.render();
}));
this.registerEvent(this.app.vault.on("rename", (f, oldPath) => {
if (oldPath === this.opts.file) this.opts.file = f.path;
}));
}
}
onunload(): void {
if (this.chart) { this.chart.destroy(); this.chart = null; }
}
private renderError(msg: string): void {
this.containerEl.empty();
this.containerEl.createEl("p", { text: `csv-chart: ${msg}`, cls: "csv-add-error" });
}
private async render(): Promise<void> {
const root = this.containerEl;
if (this.chart) { this.chart.destroy(); this.chart = null; }
root.empty();
let spec: ChartSpec;
let skipped = 0;
if (this.opts.file) {
const file = asFile(this.app.vault.getAbstractFileByPath(this.opts.file));
if (!file) return this.renderError(`File not found: ${this.opts.file}`);
let headers: string[], rows: CSVRow[];
try {
({ headers, rows } = parseCSV(await this.app.vault.read(file)));
} catch (e) {
return this.renderError(`Error reading file: ${e instanceof Error ? e.message : String(e)}`);
}
const findCol = (name: string) => headers.find(h => h.toLowerCase() === name.toLowerCase()) ?? null;
const numCols = numericColumns(headers, rows);
// Same date heuristic as the views: a date-named column, or a first
// column whose sample values are yyyy-mm-dd.
const dateCol = headers.find(h => isDateCol(h))
?? (rows.slice(0, 5).length && rows.slice(0, 5).every(r => parseIsoDate(r[headers[0]] ?? "")) ? headers[0] : null);
const xCol = this.opts.x ? findCol(this.opts.x) : (dateCol ?? numCols[0] ?? null);
if (this.opts.x && !xCol) return this.renderError(`No column "${this.opts.x}" in ${file.basename}`);
const yCol = this.opts.y ? findCol(this.opts.y) : numCols.find(c => c !== xCol) ?? null;
if (this.opts.y && !yCol) return this.renderError(`No column "${this.opts.y}" in ${file.basename}`);
if (!xCol || !yCol) return this.renderError(`Couldn't auto-pick x/y columns — add "x:" and "y:" lines`);
const isDateX = xCol === dateCol || isDateCol(xCol);
const extracted = extractPoints(rows, xCol, yCol, isDateX, parseIsoDate, r => r[headers[0]] ?? "");
skipped = extracted.skipped;
if (!extracted.points.length) return this.renderError(`No rows with numeric "${xCol}" and "${yCol}" values`);
spec = {
points: extracted.points,
xIsDate: isDateX,
xLabel: xCol,
yLabel: yCol,
connect: isDateX,
fit: this.opts.fit,
formula: this.opts.formula,
};
} else {
// Formula-only plot: no data, just the curve over an explicit domain.
if (!this.opts.formula.trim()) return this.renderError(`Give a "file:" line, a "formula:" line, or both`);
spec = {
points: [],
xIsDate: false,
xLabel: "x",
yLabel: "y",
connect: false,
fit: "none",
formula: this.opts.formula,
xMin: this.opts.xmin ?? -10,
xMax: this.opts.xmax ?? 10,
};
}
const built = buildChartConfig(spec, resolveChartColors(root));
if (built.formulaError) return this.renderError(`formula: ${built.formulaError}`);
const wrap = root.createDiv({ cls: "csv-chart-wrap" });
if (this.opts.height) wrap.style.height = this.opts.height + "px";
const canvas = wrap.createEl("canvas", { cls: "csv-chart-canvas" });
if (built.fitText || skipped > 0) {
const footer = root.createDiv({ cls: "csv-chart-footer" });
if (built.fitText) footer.createSpan({ cls: "csv-chart-fit-text", text: built.fitText });
if (skipped > 0) footer.createSpan({ cls: "csv-chart-skipped", text: `${skipped} row${skipped === 1 ? "" : "s"} skipped (no numeric value)` });
}
const { Chart } = await loadChart();
if (!canvas.isConnected) return;
this.chart = new Chart(canvas, built.config);
}
}
/** csv-chart block processor. Each block gets its own child tied to the block's lifecycle. */
export function registerCsvChartBlock(
app: App,
register: (lang: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void,
): void {
register("csv-chart", (source, el, ctx) => {
const opts = parseBlockSource(source);
const noteFolder = app.vault.getAbstractFileByPath(ctx.sourcePath)?.parent?.path ?? "";
const resolved = opts.file ? resolvePath(opts.file, noteFolder) : opts.file;
ctx.addChild(new ChartBlock(el, app, { ...opts, file: resolved }));
});
}

23
src/chartjs-loader.ts Normal file
View file

@ -0,0 +1,23 @@
// Shared lazy Chart.js loader. Chart.js is ~200KB of init cost, so it's only
// paid the first time a chart actually renders (dashboard progress chart,
// Chart view, or a csv-chart block) — sessions that never chart never load it.
// Registers the superset of components those three consumers use; registering
// is idempotent so the union is cheaper than per-consumer bookkeeping.
// Covered by test-view-smoke.mjs (with a chart.js stub).
type ChartModule = typeof import("chart.js");
let chartModule: ChartModule | null = null;
export async function loadChart(): Promise<ChartModule> {
if (chartModule) return chartModule;
const mod = await import("chart.js");
mod.Chart.register(
mod.LineController, mod.ScatterController,
mod.LineElement, mod.PointElement,
mod.LinearScale, mod.CategoryScale,
mod.Filler, mod.Tooltip, mod.Legend,
);
chartModule = mod;
return mod;
}

176
src/formula.ts Normal file
View file

@ -0,0 +1,176 @@
// Safe y = f(x) expression compiler for the Chart view and csv-chart blocks.
// A tiny tokenizer + recursive-descent parser — no eval/Function, so it's
// community-plugin-store safe and can't touch anything outside pure math.
//
// Supported: numbers, x, pi/e, + - * / % ^ (right-assoc), unary minus,
// parentheses, one/two-arg functions (sin cos tan asin acos atan sqrt log ln
// exp abs floor ceil round sign min max pow atan2), and implicit
// multiplication ("2x", "3(x+1)", "x sin(x)") so formulas read naturally.
// Compiles once into a closure tree; evaluation per sample is just calls.
// Covered by test-view-smoke.mjs.
export type CompiledFormula = (x: number) => number;
const CONSTANTS: Record<string, number> = { pi: Math.PI, e: Math.E };
const FUNCS_1: Record<string, (a: number) => number> = {
sin: Math.sin, cos: Math.cos, tan: Math.tan,
asin: Math.asin, acos: Math.acos, atan: Math.atan,
sqrt: Math.sqrt, log: Math.log10, ln: Math.log, exp: Math.exp,
abs: Math.abs, floor: Math.floor, ceil: Math.ceil,
round: Math.round, sign: Math.sign,
};
const FUNCS_2: Record<string, (a: number, b: number) => number> = {
min: Math.min, max: Math.max, pow: Math.pow, atan2: Math.atan2,
};
type Token =
| { kind: "num"; value: number }
| { kind: "ident"; name: string }
| { kind: "op"; op: string } // + - * / % ^
| { kind: "lparen" } | { kind: "rparen" } | { kind: "comma" };
function tokenize(src: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < src.length) {
const ch = src[i];
if (/\s/.test(ch)) { i++; continue; }
if (/[0-9.]/.test(ch)) {
const m = src.slice(i).match(/^\d*\.?\d+(?:[eE][+-]?\d+)?/);
if (!m || m[0] === "") throw new Error(`Bad number at "${src.slice(i, i + 8)}"`);
tokens.push({ kind: "num", value: parseFloat(m[0]) });
i += m[0].length;
continue;
}
if (/[a-zA-Z_]/.test(ch)) {
const m = src.slice(i).match(/^[a-zA-Z_][a-zA-Z_0-9]*/)!;
tokens.push({ kind: "ident", name: m[0].toLowerCase() });
i += m[0].length;
continue;
}
if ("+-*/%^".includes(ch)) { tokens.push({ kind: "op", op: ch }); i++; continue; }
if (ch === "(") { tokens.push({ kind: "lparen" }); i++; continue; }
if (ch === ")") { tokens.push({ kind: "rparen" }); i++; continue; }
if (ch === ",") { tokens.push({ kind: "comma" }); i++; continue; }
throw new Error(`Unexpected character "${ch}"`);
}
return tokens;
}
/**
* Compile an expression like "2x^2 - 3x + 1" or "10 sin(x/2)" into a plain
* (x) => number. Throws Error with a human-readable message on bad input
* callers show it inline next to the formula field.
*/
export function compileFormula(source: string): CompiledFormula {
// Allow a leading "y =" / "f(x) =" so users can paste equations verbatim.
const cleaned = source.trim().replace(/^(y|f\s*\(\s*x\s*\))\s*=\s*/i, "");
if (!cleaned) throw new Error("Empty formula");
const tokens = tokenize(cleaned);
let pos = 0;
const peek = (): Token | null => tokens[pos] ?? null;
const next = (): Token | null => tokens[pos++] ?? null;
// expr := term (('+'|'-') term)*
function parseExpr(): CompiledFormula {
let left = parseTerm();
for (;;) {
const t = peek();
if (t?.kind === "op" && (t.op === "+" || t.op === "-")) {
pos++;
const right = parseTerm();
const l = left, op = t.op;
left = op === "+" ? (x) => l(x) + right(x) : (x) => l(x) - right(x);
} else return left;
}
}
// term := unary (('*'|'/'|'%'| implicit-mult) unary)*
function parseTerm(): CompiledFormula {
let left = parseUnary();
for (;;) {
const t = peek();
if (t?.kind === "op" && (t.op === "*" || t.op === "/" || t.op === "%")) {
pos++;
const right = parseUnary();
const l = left, op = t.op;
left = op === "*" ? (x) => l(x) * right(x)
: op === "/" ? (x) => l(x) / right(x)
: (x) => l(x) % right(x);
} else if (t && (t.kind === "num" || t.kind === "ident" || t.kind === "lparen")) {
// implicit multiplication: 2x, 3(x+1), x sin(x)
const right = parseUnary();
const l = left;
left = (x) => l(x) * right(x);
} else return left;
}
}
// unary := '-' unary | power
function parseUnary(): CompiledFormula {
const t = peek();
if (t?.kind === "op" && t.op === "-") {
pos++;
const operand = parseUnary();
return (x) => -operand(x);
}
if (t?.kind === "op" && t.op === "+") { pos++; return parseUnary(); }
return parsePower();
}
// power := atom ('^' unary)? — right-associative, and -x^2 = -(x^2)
function parsePower(): CompiledFormula {
const base = parseAtom();
const t = peek();
if (t?.kind === "op" && t.op === "^") {
pos++;
const exp = parseUnary();
return (x) => Math.pow(base(x), exp(x));
}
return base;
}
// atom := number | x | constant | func '(' expr (',' expr)? ')' | '(' expr ')'
function parseAtom(): CompiledFormula {
const t = next();
if (!t) throw new Error("Unexpected end of formula");
if (t.kind === "num") { const v = t.value; return () => v; }
if (t.kind === "lparen") {
const inner = parseExpr();
if (next()?.kind !== "rparen") throw new Error("Missing closing )");
return inner;
}
if (t.kind === "ident") {
if (t.name === "x") return (x) => x;
if (t.name in CONSTANTS) { const v = CONSTANTS[t.name]; return () => v; }
const isCall = peek()?.kind === "lparen";
if (t.name in FUNCS_1 || t.name in FUNCS_2) {
if (!isCall) throw new Error(`${t.name} needs parentheses, e.g. ${t.name}(x)`);
pos++; // consume (
const a = parseExpr();
if (t.name in FUNCS_2) {
if (next()?.kind !== "comma") throw new Error(`${t.name}(a, b) needs two arguments`);
const b = parseExpr();
if (next()?.kind !== "rparen") throw new Error("Missing closing )");
const fn = FUNCS_2[t.name];
return (x) => fn(a(x), b(x));
}
if (next()?.kind !== "rparen") throw new Error("Missing closing )");
const fn = FUNCS_1[t.name];
return (x) => fn(a(x));
}
throw new Error(`Unknown name "${t.name}" — use x, pi, e, or a function like sin()`);
}
throw new Error(`Unexpected "${t.kind === "op" ? t.op : t.kind}"`);
}
const compiled = parseExpr();
if (pos < tokens.length) {
const t = tokens[pos];
throw new Error(`Unexpected "${t.kind === "op" ? t.op : t.kind === "ident" ? t.name : t.kind}" after end of expression`);
}
return compiled;
}

View file

@ -1,7 +1,7 @@
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CSVRow { [key: string]: string; }
export type ViewMode = "kanban-genre" | "table" | "dashboard" | "library" | "travel" | "stats" | "focus" | "tasks";
export type ViewMode = "kanban-genre" | "table" | "dashboard" | "library" | "travel" | "stats" | "focus" | "tasks" | "chart";
// ─── Residency / threshold rules (travel view) ──────────────────────────────
// A declarative rule: count days a person was in `scope` within `window`,
@ -63,6 +63,11 @@ export interface FileConfig {
// the row context menu. Keyed by value (like
// collapsedGroups) rather than a row id — the
// CSV has no stable identity column.
chartXCol?: string; // Chart view X column. Unset = date col → first
// numeric → row number (see src/view/chart.ts).
chartYCol?: string; // Chart view Y column. Unset = first numeric ≠ X.
chartFit?: "none" | "linear"; // Chart view best-fit line toggle.
chartFormula?: string; // Chart view y = f(x) overlay (src/formula.ts).
}
export type LibrarySort = "status" | "title" | "rating" | "year";

402
src/view/chart.ts Normal file
View file

@ -0,0 +1,402 @@
// Chart view renderer — scatter/line plots of one column against another,
// with an optional least-squares fit line (equation + R² shown below) and an
// optional y = f(x) formula overlay (safe evaluator, see src/formula.ts).
// Chart.js is lazy-loaded via src/chartjs-loader.ts, same deal as the
// dashboard: the controls render synchronously, the canvas paints when the
// module arrives. X/Y/fit/formula picks persist per file in fileCfg.
// The csv-chart code block (src/chart-block.ts) reuses the extraction +
// config-building core below. Covered by test-view-smoke.mjs (chart.js stub).
import type { ChartConfiguration, TooltipItem } from "chart.js";
import type { CardView } from "../../main";
import { CSVRow } from "../types";
import { loadChart } from "../chartjs-loader";
import { compileFormula } from "../formula";
// ── Numeric parsing / column detection ──────────────────────────────────────
/**
* Parse a cell into a finite number, or null. Tolerates thousands separators
* ("1,234" / "1 234") and a European decimal comma ("3,5") data typed on a
* Swedish keyboard should chart without a cleanup pass.
*/
export function parseNumeric(raw: string): number | null {
let s = (raw ?? "").trim();
if (!s) return null;
if (/^-?\d{1,3}(?:[,\s]\d{3})+(?:\.\d+)?$/.test(s)) s = s.replace(/[,\s]/g, "");
else if (/^-?\d+,\d+$/.test(s)) s = s.replace(",", ".");
if (!/^-?\d*\.?\d+(?:[eE][+-]?\d+)?$/.test(s)) return null;
const n = parseFloat(s);
return Number.isFinite(n) ? n : null;
}
/**
* Headers that are chartable as numbers: at least two non-empty values and a
* 70%+ numeric hit rate (so an odd "n/a" doesn't disqualify a column).
*/
export function numericColumns(headers: string[], rows: CSVRow[]): string[] {
return headers.filter(h => {
let nonEmpty = 0, numeric = 0;
for (const r of rows) {
const v = (r[h] ?? "").trim();
if (!v) continue;
nonEmpty++;
if (parseNumeric(v) !== null) numeric++;
}
return numeric >= 2 && numeric / nonEmpty >= 0.7;
});
}
// ── Fit + config building (shared with the csv-chart block) ─────────────────
export interface LinearFit { slope: number; intercept: number; r2: number; }
/** Least-squares linear regression. Null when <2 points or X has no spread. */
export function linearFit(pts: { x: number; y: number }[]): LinearFit | null {
const n = pts.length;
if (n < 2) return null;
let sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0;
for (const p of pts) { sx += p.x; sy += p.y; sxx += p.x * p.x; sxy += p.x * p.y; syy += p.y * p.y; }
const denom = n * sxx - sx * sx;
if (denom === 0) return null;
const slope = (n * sxy - sx * sy) / denom;
const intercept = (sy - slope * sx) / n;
// R² = 1 - SSres/SStot (1 when Y is constant — the flat line fits exactly)
const meanY = sy / n;
let ssRes = 0, ssTot = 0;
for (const p of pts) {
const e = p.y - (slope * p.x + intercept);
ssRes += e * e;
ssTot += (p.y - meanY) * (p.y - meanY);
}
return { slope, intercept, r2: ssTot === 0 ? 1 : 1 - ssRes / ssTot };
}
export interface ChartPoint { x: number; y: number; label?: string; }
export interface ChartSpec {
points: ChartPoint[];
xIsDate: boolean; // X values are ms timestamps → date-formatted ticks
xLabel: string;
yLabel: string;
connect: boolean; // draw the series as a line (sorted by x) vs dots
fit: "none" | "linear";
formula: string; // raw y=f(x) text, "" = none
// Domain for a pure-formula plot with no data points.
xMin?: number;
xMax?: number;
}
/** Theme colors resolved from CSS variables at render time (canvas needs concrete values). */
export interface ChartColors { accent: string; muted: string; grid: string; fitLine: string; formula: string; }
export function resolveChartColors(el: HTMLElement): ChartColors {
// Via the element's own window — a bare getComputedStyle global doesn't
// exist in the jsdom smoke tests (and popout windows have their own).
const css = el.ownerDocument.defaultView?.getComputedStyle(el);
const v = (name: string, fallback: string) => css?.getPropertyValue(name).trim() || fallback;
return {
accent: v("--interactive-accent", "#378ADD"),
muted: v("--text-muted", "#888888"),
grid: v("--background-modifier-border", "rgba(128,128,128,0.25)"),
fitLine: v("--text-faint", "#999999"),
formula: v("--color-orange", "#e0883a"),
};
}
const fmtNum = (n: number): string => {
if (!Number.isFinite(n)) return String(n);
const abs = Math.abs(n);
return abs !== 0 && (abs >= 1e6 || abs < 1e-3) ? n.toExponential(2) : String(Math.round(n * 1000) / 1000);
};
const fmtDate = (ms: number): string => {
const d = new Date(ms);
return isNaN(d.getTime()) ? String(ms) : d.toISOString().slice(0, 10);
};
export interface BuiltChart {
config: ChartConfiguration;
/** "y = 2.35x + 1.2 · R² = 0.91" (or the per-day phrasing for date X), null when no fit drawn. */
fitText: string | null;
/** Compile error for the formula overlay, null when it parsed (or was empty). */
formulaError: string | null;
}
/**
* Build a Chart.js scatter config from a spec: data points, optional fit-line
* dataset, optional formula-overlay dataset. Pure no DOM, no Chart.js
* import so the smoke tests can assert on it without a canvas.
*/
export function buildChartConfig(spec: ChartSpec, colors: ChartColors): BuiltChart {
const datasets: ChartConfiguration<"scatter" | "line">["data"]["datasets"] = [];
const pts = spec.connect ? [...spec.points].sort((a, b) => a.x - b.x) : spec.points;
if (pts.length) {
datasets.push({
type: spec.connect ? "line" : "scatter",
label: spec.yLabel,
data: pts,
backgroundColor: colors.accent,
borderColor: colors.accent,
borderWidth: 1.5,
pointRadius: spec.connect ? 3 : 4,
pointHoverRadius: 6,
tension: 0.3,
});
}
const xs = pts.map(p => p.x);
const xMin = pts.length ? Math.min(...xs) : (spec.xMin ?? 0);
const xMax = pts.length ? Math.max(...xs) : (spec.xMax ?? 10);
let fitText: string | null = null;
if (spec.fit === "linear") {
const fit = linearFit(pts);
if (fit && xMax > xMin) {
datasets.push({
type: "line",
label: "Best fit",
data: [
{ x: xMin, y: fit.slope * xMin + fit.intercept },
{ x: xMax, y: fit.slope * xMax + fit.intercept },
],
borderColor: colors.fitLine,
borderDash: [6, 4],
borderWidth: 1.5,
pointRadius: 0,
pointHitRadius: 0,
});
const r2 = ` · R² = ${(Math.round(fit.r2 * 1000) / 1000).toFixed(3)}`;
if (spec.xIsDate) {
// Slope is per millisecond — meaningless to read. Phrase it per day.
const perDay = fit.slope * 86_400_000;
fitText = `Trend: ${perDay >= 0 ? "+" : ""}${fmtNum(perDay)} ${spec.yLabel}/day${r2}`;
} else {
const sign = fit.intercept >= 0 ? "+" : "";
fitText = `y = ${fmtNum(fit.slope)}x ${sign} ${fmtNum(Math.abs(fit.intercept))}${r2}`;
}
}
}
let formulaError: string | null = null;
if (spec.formula.trim()) {
try {
const f = compileFormula(spec.formula);
const samples: ChartPoint[] = [];
const steps = 160;
for (let i = 0; i <= steps; i++) {
const x = xMin + ((xMax - xMin) * i) / steps;
const y = f(x);
if (Number.isFinite(y)) samples.push({ x, y });
}
if (samples.length) {
datasets.push({
type: "line",
label: spec.formula.trim(),
data: samples,
borderColor: colors.formula,
borderWidth: 1.5,
pointRadius: 0,
pointHitRadius: 0,
tension: 0,
});
}
} catch (e) {
formulaError = e instanceof Error ? e.message : String(e);
}
}
const config: ChartConfiguration = {
type: "scatter",
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: "linear",
title: { display: !spec.xIsDate && !!spec.xLabel, text: spec.xLabel, color: colors.muted },
ticks: {
color: colors.muted,
...(spec.xIsDate ? { callback: (v: unknown) => fmtDate(Number(v)), maxTicksLimit: 8 } : {}),
},
grid: { color: colors.grid },
},
y: {
title: { display: !!spec.yLabel, text: spec.yLabel, color: colors.muted },
ticks: { color: colors.muted },
grid: { color: colors.grid },
},
},
plugins: {
legend: {
display: datasets.length > 1,
labels: { color: colors.muted, boxWidth: 12 },
},
tooltip: {
callbacks: {
label: (item: TooltipItem<"scatter">) => {
const p = item.raw as ChartPoint;
const x = spec.xIsDate ? fmtDate(p.x) : fmtNum(p.x);
const head = p.label ? `${p.label}: ` : "";
return `${head}(${x}, ${fmtNum(p.y)})`;
},
},
},
},
} as ChartConfiguration["options"],
};
return { config, fitText, formulaError };
}
// ── Data extraction ──────────────────────────────────────────────────────────
const ROW_INDEX = "(row number)";
/**
* Rows points for an x/y column pair. `xCol` may be a date column (values
* parsed via `parseDate`), a numeric column, or ROW_INDEX. Rows where either
* side doesn't parse are skipped and counted.
*/
export function extractPoints(
rows: CSVRow[],
xCol: string,
yCol: string,
isDateX: boolean,
parseDate: (s: string) => Date | null,
labelOf: (row: CSVRow) => string,
): { points: ChartPoint[]; skipped: number } {
const points: ChartPoint[] = [];
let skipped = 0;
rows.forEach((r, i) => {
const y = parseNumeric(r[yCol] ?? "");
let x: number | null;
if (xCol === ROW_INDEX) x = i + 1;
else if (isDateX) {
const d = parseDate(r[xCol] ?? "");
x = d ? d.getTime() : null;
} else x = parseNumeric(r[xCol] ?? "");
if (x === null || y === null) { skipped++; return; }
points.push({ x, y, label: labelOf(r) });
});
return { points, skipped };
}
// ── The Chart view ───────────────────────────────────────────────────────────
/** True when the file has a column pair worth plotting (≥1 numeric column). */
export function hasChartColumns(view: CardView): boolean {
return view.rows.length >= 2 && numericColumns(view.headers, view.rows).length >= 1;
}
export async function renderChart(view: CardView, container: HTMLElement): Promise<void> {
const rows = view.getFilteredRows();
const numCols = numericColumns(view.headers, view.rows);
if (!numCols.length) {
container.createEl("p", { text: "No numeric column to chart.", cls: "csv-empty-state" });
return;
}
if (view.searchQuery.trim()) {
container.createDiv({ cls: "csv-search-results", text: `Chart over ${rows.length} of ${view.rows.length} entries` });
}
const cfg = view.fileCfg;
const dateCol = view.getDateCol();
// X candidates: the date column first (time series is the common case),
// then every numeric column, then plain row order as a fallback.
const xOptions = [...(dateCol ? [dateCol] : []), ...numCols.filter(c => c !== dateCol), ROW_INDEX];
let xCol = cfg.chartXCol && xOptions.includes(cfg.chartXCol) ? cfg.chartXCol : xOptions[0];
const yOptions = numCols;
let yCol = cfg.chartYCol && yOptions.includes(cfg.chartYCol)
? cfg.chartYCol
: yOptions.find(c => c !== xCol) ?? yOptions[0];
const fit: "none" | "linear" = cfg.chartFit === "linear" ? "linear" : "none";
const formula = cfg.chartFormula ?? "";
const wrap = container.createDiv({ cls: "csv-chart-view" });
// ── Controls ──────────────────────────────────────────────────────────────
const controls = wrap.createDiv({ cls: "csv-chart-controls" });
const save = (patch: Partial<typeof cfg>) => {
view.saveFileCfg({ ...view.fileCfg, ...patch });
view.renderViewPreservingScroll();
};
const labeledSelect = (label: string, options: string[], value: string, onChange: (v: string) => void) => {
const group = controls.createDiv({ cls: "csv-chart-control" });
group.createSpan({ cls: "csv-chart-control-label", text: label });
const sel = group.createEl("select", { cls: "csv-chart-select", attr: { "aria-label": label } });
options.forEach(o => {
const opt = sel.createEl("option", { text: o, value: o });
if (o === value) opt.selected = true;
});
sel.addEventListener("change", () => onChange(sel.value));
return sel;
};
labeledSelect("X", xOptions, xCol, v => save({ chartXCol: v }));
labeledSelect("Y", yOptions, yCol, v => save({ chartYCol: v }));
const fitBtn = controls.createEl("button", {
cls: `csv-cfg-btn csv-chart-fit-btn ${fit === "linear" ? "active" : ""}`,
text: "Best fit",
title: "Toggle a least-squares fit line",
});
fitBtn.addEventListener("click", () => save({ chartFit: fit === "linear" ? "none" : "linear" }));
// Formula applies on Enter/blur, not per keystroke — the whole view
// re-renders on apply, which would eat the input focus mid-typing.
const formulaWrap = controls.createDiv({ cls: "csv-chart-control csv-chart-formula-wrap" });
formulaWrap.createSpan({ cls: "csv-chart-control-label", text: "y =" });
const formulaInput = formulaWrap.createEl("input", {
cls: "csv-chart-formula-input",
type: "text",
value: formula,
placeholder: "overlay, e.g. 2x + 1",
attr: { spellcheck: "false", autocomplete: "off", enterkeyhint: "done" },
});
const applyFormula = () => {
if (formulaInput.value.trim() === formula.trim()) return;
save({ chartFormula: formulaInput.value });
};
formulaInput.addEventListener("change", applyFormula);
formulaInput.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); formulaInput.blur(); } });
// ── Chart ─────────────────────────────────────────────────────────────────
const isDateX = xCol !== ROW_INDEX && (xCol === dateCol || view.isDateCol(xCol));
const { points, skipped } = extractPoints(rows, xCol, yCol, isDateX, s => view.parseDate(s), r => view.getTitle(r));
const canvasWrap = wrap.createDiv({ cls: "csv-chart-wrap" });
const canvas = canvasWrap.createEl("canvas", { cls: "csv-chart-canvas" });
const footer = wrap.createDiv({ cls: "csv-chart-footer" });
if (!points.length) {
canvasWrap.remove();
footer.remove();
wrap.createEl("p", { text: `No rows with both "${xCol}" and "${yCol}" values to plot.`, cls: "csv-empty-state" });
return;
}
const spec: ChartSpec = {
points,
xIsDate: isDateX,
xLabel: xCol === ROW_INDEX ? "row" : xCol,
yLabel: yCol,
connect: isDateX, // time series read better connected; numeric pairs as dots
fit,
formula,
};
const built = buildChartConfig(spec, resolveChartColors(container));
if (built.fitText) footer.createSpan({ cls: "csv-chart-fit-text", text: built.fitText });
if (built.formulaError) footer.createSpan({ cls: "csv-chart-formula-error", text: `formula: ${built.formulaError}` });
if (skipped > 0) footer.createSpan({ cls: "csv-chart-skipped", text: `${skipped} row${skipped === 1 ? "" : "s"} skipped (no numeric value)` });
// Same lazy-load + stale-canvas guard as the dashboard chart.
if (view.chartInstance) { view.chartInstance.destroy(); view.chartInstance = null; }
const { Chart } = await loadChart();
if (!canvas.isConnected) return;
view.chartInstance = new Chart(canvas, built.config);
}

View file

@ -6,19 +6,9 @@
import type { CardView } from "../../main";
import { CSVRow } from "../types";
import { titleCase } from "../utils";
// Lazy-load Chart.js + register the bits we use. Only paid when the dashboard
// view first renders. Sessions that only touch books/movies/quotes/dictionary
// never load Chart.js at all.
type ChartModule = typeof import("chart.js");
let chartModule: ChartModule | null = null;
async function loadChart(): Promise<ChartModule> {
if (chartModule) return chartModule;
const mod = await import("chart.js");
mod.Chart.register(mod.LineController, mod.LineElement, mod.PointElement, mod.LinearScale, mod.CategoryScale, mod.Filler, mod.Tooltip);
chartModule = mod;
return mod;
}
// Chart.js stays a lazy load (shared with the Chart view / csv-chart blocks) —
// only paid when a chart actually renders. See src/chartjs-loader.ts.
import { loadChart } from "../chartjs-loader";
export async function renderDashboard(view: CardView, container: HTMLElement): Promise<void> {
const dateCol = view.getDateCol();

View file

@ -10,6 +10,7 @@ import { FileConfigModal, AutoDetectedRoles } from "../modals";
import { generateMobileFiles } from "./mobile";
import { syncToAnki, autoAnkiFrontCol } from "./anki";
import { hasStatsColumns } from "./stats";
import { hasChartColumns } from "./chart";
import { hasTaskColumns } from "./tasks";
import { effectiveGroupCol } from "./kanban";
import { TITLE_COL_ALIASES, CATEGORY_COL_ALIASES, STATUS_COL_ALIASES, NOTES_COL_ALIASES, IMAGE_COL_ALIASES } from "../utils";
@ -43,6 +44,9 @@ export function availableModes(view: CardView): {id: ViewMode, label: string}[]
// render fine just made the dropdown feel arbitrarily short.)
if (view.rows.length > 0) modes.push({id: "focus", label: "Focus"});
if (hasStatsColumns(view)) modes.push({id: "stats", label: "Stats"});
// Chart: scatter/line plots of numeric column pairs, with best-fit and
// formula overlays. Needs at least one numeric column (see hasChartColumns).
if (hasChartColumns(view)) modes.push({id: "chart", label: "Chart"});
return modes;
}

View file

@ -4135,3 +4135,82 @@ select.csv-add-row-control {
transition: left 0.15s ease;
}
.csv-toggle.is-on .csv-toggle-knob { left: 18px; }
/* ─── Chart view + csv-chart block ─────────────────────────────────────── */
.csv-chart-view {
max-width: 900px;
margin: 0 auto;
padding: 8px 4px;
}
.csv-chart-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
margin-bottom: 12px;
}
.csv-chart-control {
display: flex;
align-items: center;
gap: 6px;
}
.csv-chart-control-label {
font-size: 12px;
font-weight: 600;
color: var(--csv-text-muted);
}
.csv-chart-select {
font-size: 13px;
max-width: 180px;
}
.csv-chart-fit-btn.active {
background: var(--csv-accent);
color: var(--text-on-accent, #fff);
border-color: var(--csv-accent);
}
.csv-chart-formula-wrap { flex: 1 1 180px; min-width: 160px; }
.csv-chart-formula-input {
flex: 1;
min-width: 0;
font-size: 13px;
font-family: var(--font-monospace);
padding: 4px 8px;
}
.csv-chart-wrap {
height: 340px;
position: relative;
}
.csv-chart-block .csv-chart-wrap { height: 280px; }
.csv-chart-canvas {
width: 100%;
height: 100%;
}
.csv-chart-footer {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
margin-top: 8px;
font-size: 12px;
color: var(--csv-text-muted);
}
.csv-chart-fit-text { font-family: var(--font-monospace); }
.csv-chart-formula-error { color: var(--text-error, #e05252); }
@media (max-width: 600px) {
.csv-chart-wrap { height: 260px; }
.csv-chart-select { max-width: 130px; }
}

View file

@ -1,11 +1,19 @@
// Minimal "chart.js" stub for the dashboard smoke test — jsdom has no canvas
// 2D context, so the real Chart would throw. loadChart() calls Chart.register
// and `new Chart(canvas, cfg)`; both are no-ops here.
export class Chart { constructor() {} destroy() {} static register() {} }
// Minimal "chart.js" stub for the dashboard/chart smoke tests — jsdom has no
// canvas 2D context, so the real Chart would throw. loadChart() calls
// Chart.register and `new Chart(canvas, cfg)`; both are no-ops here, but the
// last-constructed config is kept so tests can assert on datasets/scales.
export class Chart {
static lastConfig = null;
constructor(_canvas, config) { Chart.lastConfig = config ?? null; }
destroy() {}
static register() {}
}
export class LineController {}
export class ScatterController {}
export class LineElement {}
export class PointElement {}
export class LinearScale {}
export class CategoryScale {}
export class Filler {}
export class Tooltip {}
export class Legend {}

View file

@ -1489,6 +1489,140 @@ await test("anki: surfaces a transport failure instead of throwing", async () =>
delete globalThis.__ankiRequestUrl;
});
// ── Formula evaluator ────────────────────────────────────────────────────────
const { compileFormula } = await load("./src/formula.ts");
await test("formula: polynomial, implicit multiplication, constants, functions", async () => {
assert(compileFormula("2x^2 - 3x + 1")(2) === 3, "2x^2-3x+1 at x=2");
assert(compileFormula("3(x+1)")(2) === 9, "implicit mult over parens");
assert(Math.abs(compileFormula("sin(pi/2)")(0) - 1) < 1e-12, "sin(pi/2) = 1");
assert(compileFormula("2^3^2")(0) === 512, "^ is right-associative");
assert(compileFormula("y = -x^2")(3) === -9, "leading y= allowed; -x^2 = -(x^2)");
assert(compileFormula("max(x, 5)")(2) === 5, "two-arg function");
assert(compileFormula("10 sin(x)")(0) === 0, "implicit mult before function call");
});
await test("formula: bad input throws readable errors", async () => {
for (const bad of ["", "2 +", "(x", "foo(x)", "x $ 2", "sin x"]) {
let threw = false;
try { compileFormula(bad); } catch { threw = true; }
assert(threw, `"${bad}" should throw`);
}
});
// ── Chart view ───────────────────────────────────────────────────────────────
const { renderChart, hasChartColumns, parseNumeric, numericColumns, linearFit, buildChartConfig } =
await load("./src/view/chart.ts");
await test("chart: parseNumeric handles separators and rejects text", async () => {
assert(parseNumeric("1,234") === 1234, "thousands comma");
assert(parseNumeric("1 234") === 1234, "thousands space");
assert(parseNumeric("3,5") === 3.5, "decimal comma");
assert(parseNumeric("-2.5e3") === -2500, "scientific");
assert(parseNumeric("abc") === null, "text rejected");
assert(parseNumeric("") === null, "empty rejected");
});
await test("chart: numericColumns needs 2+ values at a 70%+ hit rate", async () => {
const rows = [
{ title: "A", score: "1", year: "n/a" },
{ title: "B", score: "2", year: "1999" },
{ title: "C", score: "3", year: "" },
];
const cols = numericColumns(["title", "score", "year"], rows);
assert(cols.join(",") === "score", `only score qualifies (got ${cols})`);
});
await test("chart: linearFit recovers an exact line with R² = 1", async () => {
const fit = linearFit([{ x: 0, y: 1 }, { x: 1, y: 3 }, { x: 2, y: 5 }]);
assert(Math.abs(fit.slope - 2) < 1e-12 && Math.abs(fit.intercept - 1) < 1e-12, "y = 2x + 1");
assert(fit.r2 === 1, "perfect fit");
assert(linearFit([{ x: 1, y: 1 }, { x: 1, y: 2 }]) === null, "no X spread → null");
});
const chartColors = { accent: "#38d", muted: "#888", grid: "#ccc", fitLine: "#999", formula: "#e83" };
await test("chart: buildChartConfig adds fit + formula datasets and fit text", async () => {
const built = buildChartConfig({
points: [{ x: 0, y: 1 }, { x: 1, y: 3 }, { x: 2, y: 5 }],
xIsDate: false, xLabel: "x", yLabel: "score", connect: false,
fit: "linear", formula: "2x + 1",
}, chartColors);
assert(built.config.data.datasets.length === 3, "points + fit + formula datasets");
assert(built.fitText.startsWith("y = 2x + 1"), `fit equation (got "${built.fitText}")`);
assert(built.fitText.includes("R² = 1.000"), "R² shown");
assert(built.formulaError === null, "formula compiled");
});
await test("chart: date X gets per-day trend text; bad formula surfaces error", async () => {
const day = 86_400_000;
const built = buildChartConfig({
points: [{ x: 0, y: 0 }, { x: day, y: 2 }, { x: 2 * day, y: 4 }],
xIsDate: true, xLabel: "date", yLabel: "km", connect: true,
fit: "linear", formula: "foo(x)",
}, chartColors);
assert(built.fitText.startsWith("Trend: +2 km/day"), `per-day phrasing (got "${built.fitText}")`);
assert(built.formulaError !== null, "bad formula reported, not thrown");
});
function chartView(rows, cfg = {}) {
let savedCfg = null;
const view = {
rows, headers: Object.keys(rows[0] ?? {}), searchQuery: "",
getFilteredRows: () => rows,
fileCfg: cfg, saveFileCfg: (c) => { savedCfg = c; view.fileCfg = c; },
getDateCol: () => (Object.keys(rows[0] ?? {}).includes("date") ? "date" : null),
isDateCol: (h) => h === "date",
parseDate: (s) => (/^\d{4}-\d{2}-\d{2}$/.test(s) ? new Date(s + "T00:00:00") : null),
getTitle: (r) => r[Object.keys(r)[0]] ?? "—",
chartInstance: null,
renderView: () => {}, renderViewPreservingScroll: () => {},
getSavedCfg: () => savedCfg,
};
return view;
}
await test("chart: renders controls, canvas, and fit text over a numeric file", async () => {
const rows = [
{ title: "A", year: "2000", rating: "2" },
{ title: "B", year: "2010", rating: "3" },
{ title: "C", year: "2020", rating: "4" },
];
const view = chartView(rows, { chartXCol: "year", chartYCol: "rating", chartFit: "linear" });
assert(hasChartColumns(view), "file is chartable");
const c = document.body.createDiv();
await renderChart(view, c);
assert(c.querySelectorAll(".csv-chart-select").length === 2, "X and Y selects present");
assert(c.querySelector(".csv-chart-fit-btn.active"), "fit toggle reflects saved state");
assert(c.querySelector(".csv-chart-formula-input"), "formula input present");
assert(c.querySelector("canvas.csv-chart-canvas"), "canvas present");
assert(c.querySelector(".csv-chart-fit-text"), "fit equation shown");
assert(view.chartInstance, "Chart instance created");
});
await test("chart: changing the Y select persists to fileCfg and re-renders", async () => {
const rows = [
{ title: "A", pages: "100", rating: "2" },
{ title: "B", pages: "200", rating: "5" },
];
const view = chartView(rows);
const c = document.body.createDiv();
await renderChart(view, c);
const ySel = c.querySelectorAll(".csv-chart-select")[1];
ySel.value = "rating";
ySel.dispatchEvent(new window.Event("change", { bubbles: true }));
assert(view.getSavedCfg()?.chartYCol === "rating", "Y pick saved to fileCfg");
});
await test("chart: non-numeric file is not chartable; empty pair shows empty state", async () => {
assert(!hasChartColumns(chartView([{ title: "A" }, { title: "B" }])), "no numeric column");
const view = chartView([{ title: "A", score: "1" }, { title: "B", score: "2" }], { chartFormula: "" });
view.getFilteredRows = () => []; // search filtered everything out
const c = document.body.createDiv();
await renderChart(view, c);
assert(c.querySelector(".csv-empty-state"), "empty state when nothing plots");
});
console.log(`\n${"=".repeat(50)}`);
console.log(`View smoke tests: ${passed} passed, ${failed} failed`);
console.log(`${"=".repeat(50)}`);