mirror of
https://github.com/svm0n/datadeck.git
synced 2026-07-22 08:31:46 +00:00
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:
parent
b12da235ea
commit
6cb2deccb2
12 changed files with 1072 additions and 37 deletions
39
main.js
39
main.js
File diff suppressed because one or more lines are too long
12
main.ts
12
main.ts
|
|
@ -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
201
src/chart-block.ts
Normal 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
23
src/chartjs-loader.ts
Normal 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
176
src/formula.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
402
src/view/chart.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
79
styles.css
79
styles.css
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue