fixed refreshing

This commit is contained in:
Pooyash1998 2026-04-26 11:31:27 +02:00
parent a5676428f9
commit 4dc9961018
23 changed files with 1066 additions and 259 deletions

22
LICENSE
View file

@ -1,5 +1,21 @@
Copyright (C) 2020-2025 by Dynalist Inc.
MIT License
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
Copyright (c) 2025 Pooyash1998
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

125
README.md
View file

@ -1,77 +1,126 @@
# ChartSpark
# ChartSpark
Instantly turn selected text in your Obsidian notes into beautiful, interactive charts — no copy-paste, no external tools.
Turn your Obsidian notes into interactive charts in seconds. Place your cursor inside any table, checkbox list, or key:value block — press a shortcut — and a live, auto-refreshing chart is inserted directly into your note.
## Screenshots
| Monthly revenue (Pie) | Sprint progress (Doughnut) |
|---|---|
| ![Monthly revenue pie chart](docs/screenshot-pie.png) | ![Sprint progress doughnut](docs/screenshot-doughnut.png) |
| Product mix (Polar Area) | Team availability (Bar) |
|---|---|
| ![Product mix polar chart](docs/screenshot-polar.png) | ![Team availability bar chart](docs/screenshot-bar.png) |
## Features
- **Select → Chart** — select a checkbox list, markdown table, JSON block, or key:value list, right-click (or run a command), and get an interactive chart inserted in seconds
- **4 chart types** — pie, doughnut, bar, line with a live preview picker
- **Auto-refresh** — charts re-render when their source note is modified
- **Vault scan** — aggregate data across your entire vault (or a specific folder) into one chart
- **Export PNG** — download any chart with one click
- **Theme-aware** — respects Obsidian's light/dark theme via CSS variables
- **Six chart types** — Bar, Line, Pie, Doughnut, Radar, Polar Area
- **Smart table support** — column picker lets you choose which column is the category axis and which columns are the value series
- **Axis swap** — transpose the data matrix with one click (rows ↔ columns)
- **Horizontal bar** — toggle any bar chart to horizontal orientation
- **Multi-block picker** — when a note has multiple data blocks, a picker shows previews of each so you select exactly what to chart
- **Live auto-refresh** — charts update in real time as you edit the source data; each chart tracks its own source block by ID, so multiple charts in the same note refresh independently
- **Versatile parsing** — handles booleans (`true`/`false`), currency (`$1,200`), percentages (`42%`), yes/no, and mixed tables; strips trailing empty columns automatically
- **Delete button** — hover any chart to reveal a × button that removes the block in one click
- **Vault scan** — aggregate checkbox data across your entire vault or a folder into a single chart
- **PNG export** — download any chart as an image
- **Theme-aware** — adapts to Obsidian's light and dark themes via CSS variables
## Supported input formats
| Format | Example |
|--------|---------|
| Checkbox list | `- [x] Task 1` / `- [ ] Task 2` |
| Markdown table | `\| Item \| Value \|` |
| JSON object | `{"Sales": 120, "Returns": 20}` |
| JSON array | `[{"name":"A","count":5}]` |
| Key-value list | `Revenue: 500` / `Cost: 300` |
| Markdown table | `\| Item \| Sales \|` |
| Checkbox list | `- [x] Write tests` |
| JSON object | `{"Revenue": 500, "Cost": 300}` |
| JSON array | `[{"name": "A", "count": 5}]` |
| Key : value | `Revenue: 500` |
## Quick start
Tables can contain numbers, booleans (`true`/`false`, `yes`/`no`), currency, or percentages — any column whose values can be converted to a number is offered as a value series.
1. Select text containing a recognizable pattern
2. Right-click → **Generate chart from selection**
_or_ open the command palette → **ChartSpark: Generate chart from selection**
3. Pick a chart type in the preview modal
4. Click **Insert chart** — a `chartspark` code block is added to your note
## Usage
### Generating a chart
1. Press **`Cmd/Ctrl + Shift + C`** (or right-click anywhere in the editor → **Generate chart from this note**)
2. If the note has multiple data blocks, a picker appears — click the one you want
3. For tables, choose the **label column** (category axis) and **value columns** (series)
4. Use **⇄ Swap axes** to transpose rows and columns
5. Select a chart type from the tabs; use **↔ H-Bar** for horizontal bars
6. Click **Insert chart** — a `chartspark` block is inserted directly below the source data
### Auto-refresh
Charts re-render automatically when the source data changes. Each chart stores a unique ID and scans backwards from its own position to find its source block — so editing one table only refreshes the chart that belongs to it.
### Manual refresh
Enable **Show refresh button** in settings to display a ↻ Refresh button on each chart.
### Deleting a chart
Hover over any chart and click the **×** button in the top-left corner.
## Manual chart block
You can write or edit a `chartspark` code block directly:
Charts are stored as standard fenced code blocks and can be written or edited by hand:
````markdown
```chartspark
{
"type": "pie",
"type": "bar",
"data": {
"labels": ["Completed", "Remaining"],
"datasets": [{"label": "Tasks", "data": [7, 3]}]
"labels": ["Q1", "Q2", "Q3", "Q4"],
"datasets": [{"label": "Revenue", "data": [120, 95, 140, 180]}]
}
}
```
````
Any valid [Chart.js](https://www.chartjs.org/) configuration can be placed in the `data` and `options` fields.
## Commands
| Command | Description |
|---------|-------------|
| `Generate chart from selection` | Convert selected text to a chart |
| `Scan vault and generate chart` | Aggregate data from all notes |
| `Export active chart as PNG` | Download the visible chart |
| Command | Shortcut | Description |
|---------|----------|-------------|
| Quick chart | `Cmd/Ctrl + Shift + C` | Generate a chart from data in the active note |
| Generate chart (with preview) | — | Same, but opens the full column-picker preview |
| Scan vault and generate chart | — | Aggregate checkbox data across vault |
| Export active chart as PNG | — | Save the focused chart as an image |
| Insert chart template | — | Insert a blank chart block to edit manually |
## Settings
- **Default chart type** — pie / doughnut / bar / line
- **Auto-refresh charts** — re-render on note modification
- **Max chart width** — 2001200 px
- **Show refresh button** — manual ↻ button on each chart
| Setting | Default | Description |
|---------|---------|-------------|
| Default chart type | Pie | Chart type pre-selected in the preview modal |
| Auto-refresh charts | On | Re-render when source data changes |
| Max chart width | 600 px | Maximum rendered width (2001200 px) |
| Show refresh button | On | Display a manual ↻ button on each chart |
## Installation (manual)
## Installation
1. `npm install && npm run build`
2. Copy `main.js`, `manifest.json`, `styles.css` to
`<Vault>/.obsidian/plugins/chartspark/`
### From the Obsidian community plugin list
1. Open **Settings → Community plugins → Browse**
2. Search for **ChartSpark**
3. Click **Install**, then **Enable**
### Manual installation
1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/Pooyash1998/chartspark/releases)
2. Copy them to `<Vault>/.obsidian/plugins/chartspark/`
3. Reload Obsidian and enable **ChartSpark** in **Settings → Community plugins**
## Development
```bash
npm install
npm run dev # watch mode
npm test # run unit tests
npm run dev # watch mode with hot reload
npm run build # production build
npm test # run unit test suite
```
## License
[MIT](LICENSE)

BIN
docs/screenshot-bar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
docs/screenshot-pie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
docs/screenshot-polar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -1,7 +1,7 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"name": "chartspark",
"version": "0.1.0",
"description": "Turn Obsidian notes into interactive charts — tables, checkboxes, JSON, key:value.",
"main": "main.js",
"type": "module",
"scripts": {

View file

@ -1,44 +1,20 @@
import { Editor, Notice } from 'obsidian';
import { Editor } from 'obsidian';
import ChartSparkPlugin from '../main';
import { launchChartFlow } from '../utils/chartFlow';
import { ChartConfig } from '../types';
import { ChartPreviewModal } from '../ui/chartPreviewModal';
export function registerGenerateChartCommand(plugin: ChartSparkPlugin): void {
plugin.addCommand({
id: 'generate-chart-from-selection',
name: 'Generate chart from selection',
name: 'Generate chart (with preview)',
editorCallback: (editor: Editor) => {
const selection = editor.getSelection().trim();
if (!selection) {
new Notice('ChartSpark: Select some text first.');
return;
}
const parser = plugin.registry.findParser(selection);
if (!parser) {
new Notice('ChartSpark: No recognizable pattern in selection (try a checkbox list, table, or JSON).');
return;
}
// Capture source location before the modal opens
const startLine = editor.getCursor('from').line;
const endLine = editor.getCursor('to').line;
const sourceFile = plugin.app.workspace.getActiveFile()?.path ?? '';
const parsed = parser.parse(selection);
new ChartPreviewModal(
plugin.app,
parsed,
plugin.settings.defaultChartType,
(config: ChartConfig) => {
config.meta = {
...config.meta,
source: { file: sourceFile, startLine, endLine },
};
insertChartBlock(editor, config);
},
).open();
void launchChartFlow({
app: plugin.app,
editor,
registry: plugin.registry,
defaultChartType: plugin.settings.defaultChartType,
mode: 'preview',
});
},
});
}

View file

@ -1,41 +1,20 @@
import { Editor, Notice } from 'obsidian';
import { Editor } from 'obsidian';
import ChartSparkPlugin from '../main';
import { ChartTypeModal } from '../ui/chartTypeModal';
import { KPICalculator } from '../kpi/calculator';
import { ChartType } from '../types';
import { insertChartBlock } from './generateChart';
import { launchChartFlow } from '../utils/chartFlow';
export function registerQuickChartCommand(plugin: ChartSparkPlugin): void {
plugin.addCommand({
id: 'quick-chart',
name: 'Quick chart from selection (pick type, no preview)',
name: 'Quick chart (pick type)',
hotkeys: [{ modifiers: ['Mod', 'Shift'], key: 'c' }],
editorCallback: (editor: Editor) => {
const selection = editor.getSelection().trim();
if (!selection) {
new Notice('ChartSpark: Select some text first.');
return;
}
const parser = plugin.registry.findParser(selection);
if (!parser) {
new Notice('ChartSpark: No recognizable pattern in selection.');
return;
}
const startLine = editor.getCursor('from').line;
const endLine = editor.getCursor('to').line;
const sourceFile = plugin.app.workspace.getActiveFile()?.path ?? '';
const parsed = parser.parse(selection);
new ChartTypeModal(plugin.app, (type: ChartType) => {
const config = new KPICalculator().buildChartConfig(parsed, type);
config.meta = {
...config.meta,
source: { file: sourceFile, startLine, endLine },
};
insertChartBlock(editor, config);
}).open();
void launchChartFlow({
app: plugin.app,
editor,
registry: plugin.registry,
defaultChartType: plugin.settings.defaultChartType,
mode: 'quick',
});
},
});
}

View file

@ -1,29 +1,29 @@
import { ParsedData, ChartConfig, ChartType } from '../types';
import { getColors, withOpacity } from '../utils/colors';
import { parseNumericValue } from '../parsers/tableParser';
const RADIAL_TYPES = new Set<ChartType>(['pie', 'doughnut', 'polarArea']);
export class KPICalculator {
buildChartConfig(parsed: ParsedData, chartType: ChartType): ChartConfig {
const isRadial = RADIAL_TYPES.has(chartType);
const colors = getColors(parsed.labels.length);
const borderColors = colors;
const bgColors = chartType === 'line'
? colors.map(c => withOpacity(c, 0.3))
: colors.map(c => withOpacity(c, 0.85));
const dataset = {
label: parsed.metadata.title ?? parsed.type,
data: parsed.values,
backgroundColor: bgColors,
borderColor: borderColors,
backgroundColor: isRadial || chartType === 'radar'
? colors.map(c => withOpacity(c, chartType === 'radar' ? 0.3 : 0.85))
: colors.map(c => withOpacity(c, 0.85)),
borderColor: colors,
borderWidth: 2,
...(chartType === 'line' ? { fill: true, tension: 0.4 } : {}),
...(chartType === 'radar' ? { fill: true } : {}),
};
return {
type: chartType,
data: {
labels: parsed.labels,
datasets: [dataset],
},
data: { labels: parsed.labels, datasets: [dataset] },
options: this.defaultOptions(chartType),
meta: {
generated: new Date().toISOString(),
@ -32,16 +32,174 @@ export class KPICalculator {
};
}
private defaultOptions(type: ChartType): Record<string, unknown> {
buildFromTableColumns(
columns: string[],
rows: string[][],
labelColIndex: number,
valueColIndices: number[],
chartType: ChartType,
): ChartConfig {
const labels = rows.map(r => (r[labelColIndex] ?? '').trim());
const isRadial = RADIAL_TYPES.has(chartType);
const isRadar = chartType === 'radar';
const seriesColors = getColors(Math.max(valueColIndices.length, labels.length));
// Radial charts with multiple columns: sum all selected columns per row
// into one dataset so slices = labels and legend = label names (correct UX).
// Use Radar or Bar for multi-series comparison instead.
if (isRadial && valueColIndices.length > 1) {
const aggregated = rows.map(r =>
valueColIndices.reduce((s, ci) => s + (parseNumericValue(r[ci] ?? '') ?? 0), 0)
);
const sliceColors = getColors(labels.length);
return {
type: chartType,
data: {
labels,
datasets: [{
label: valueColIndices.map(i => columns[i] ?? '').join(' + '),
data: aggregated,
backgroundColor: sliceColors.map(c => withOpacity(c, 0.85)),
borderColor: sliceColors,
borderWidth: 2,
}],
},
options: this.defaultOptions(chartType),
meta: {
generated: new Date().toISOString(),
patternType: 'table',
columnSelection: { labelColIndex, valueColIndices: [...valueColIndices] },
},
};
}
const datasets = valueColIndices.map((colIdx, seriesIdx) => {
const data = rows.map(r => parseNumericValue(r[colIdx] ?? '') ?? 0);
const seriesColor = seriesColors[seriesIdx] ?? '#4c9be8';
const sliceColors = getColors(data.length);
const bg: string | string[] = isRadial
? sliceColors.map(c => withOpacity(c, 0.85))
: isRadar
? withOpacity(seriesColor, 0.25)
: withOpacity(seriesColor, 0.85);
const border: string | string[] = isRadial ? sliceColors : seriesColor;
return {
label: columns[colIdx] ?? `Column ${colIdx + 1}`,
data,
backgroundColor: bg,
borderColor: border,
borderWidth: 2,
...(chartType === 'line' ? { fill: true, tension: 0.4 } : {}),
...(isRadar ? { fill: true } : {}),
};
});
return {
type: chartType,
data: { labels, datasets },
options: this.defaultOptions(chartType),
meta: {
generated: new Date().toISOString(),
patternType: 'table',
columnSelection: { labelColIndex, valueColIndices: [...valueColIndices] },
},
};
}
/**
* Transpose: rows become series, value-column names become categories.
* e.g. original (label=Fruits, values=Ali,Mamad)
* transposed (label=Ali/Mamad, series=Kir/Kos/Kun)
*/
buildTransposed(
columns: string[],
rows: string[][],
labelColIndex: number,
valueColIndices: number[],
chartType: ChartType,
): ChartConfig {
// New X-axis labels = value column names
const newLabels = valueColIndices.map(i => columns[i] ?? `Col ${i}`);
// New series = one per original row, named by the label column
const originalRowLabels = rows.map(r => (r[labelColIndex] ?? '').trim());
const isRadial = RADIAL_TYPES.has(chartType);
const isRadar = chartType === 'radar';
const seriesColors = getColors(originalRowLabels.length);
if (isRadial && originalRowLabels.length > 1) {
// Aggregate rows into one dataset
const aggregated = valueColIndices.map(ci =>
rows.reduce((s, r) => s + (parseNumericValue(r[ci] ?? '') ?? 0), 0)
);
const sliceColors = getColors(newLabels.length);
return {
type: chartType,
data: {
labels: newLabels,
datasets: [{
label: originalRowLabels.join(' + '),
data: aggregated,
backgroundColor: sliceColors.map(c => withOpacity(c, 0.85)),
borderColor: sliceColors,
borderWidth: 2,
}],
},
options: this.defaultOptions(chartType),
meta: {
generated: new Date().toISOString(),
patternType: 'table',
columnSelection: { labelColIndex, valueColIndices: [...valueColIndices], transposed: true },
},
};
}
const datasets = originalRowLabels.map((rowLabel, rowIdx) => {
const data = valueColIndices.map(ci => parseNumericValue(rows[rowIdx]?.[ci] ?? '') ?? 0);
const seriesColor = seriesColors[rowIdx] ?? '#4c9be8';
const sliceColors = getColors(data.length);
const bg: string | string[] = isRadial
? sliceColors.map(c => withOpacity(c, 0.85))
: isRadar ? withOpacity(seriesColor, 0.25) : withOpacity(seriesColor, 0.85);
const border: string | string[] = isRadial ? sliceColors : seriesColor;
return {
label: rowLabel,
data,
backgroundColor: bg,
borderColor: border,
borderWidth: 2,
...(chartType === 'line' ? { fill: true, tension: 0.4 } : {}),
...(isRadar ? { fill: true } : {}),
};
});
return {
type: chartType,
data: { labels: newLabels, datasets },
options: this.defaultOptions(chartType),
meta: {
generated: new Date().toISOString(),
patternType: 'table',
columnSelection: { labelColIndex, valueColIndices: [...valueColIndices], transposed: true },
},
};
}
private defaultOptions(type: ChartType): Record<string, unknown> {
const base = {
responsive: true,
plugins: {
legend: { position: 'top' },
title: { display: false },
},
...(type === 'bar' || type === 'line'
? { scales: { y: { beginAtZero: true } } }
: {}),
};
if (type === 'bar' || type === 'line') return { ...base, scales: { y: { beginAtZero: true } } };
if (type === 'radar') return { ...base, scales: { r: { beginAtZero: true } } };
return base;
}
}

View file

@ -1,15 +1,15 @@
import { Plugin, MarkdownPostProcessorContext, Notice } from 'obsidian';
import { Plugin, MarkdownPostProcessorContext } from 'obsidian';
import { ChartSparkSettings, DEFAULT_SETTINGS, ChartSparkSettingTab } from './settings';
import { ParserRegistry } from './parsers/parserRegistry';
import { ChartRenderer } from './renderer/chartRenderer';
import { ChartBlockComponent } from './renderer/chartBlockComponent';
import { registerGenerateChartCommand, insertChartBlock } from './commands/generateChart';
import { registerGenerateChartCommand } from './commands/generateChart';
import { registerQuickChartCommand } from './commands/quickChart';
import { registerScanVaultCommand } from './commands/scanVault';
import { registerExportChartCommand } from './commands/exportChart';
import { registerInsertTemplateCommand } from './commands/insertTemplate';
import { launchChartFlow } from './utils/chartFlow';
import { ChartConfig } from './types';
import { ChartPreviewModal } from './ui/chartPreviewModal';
export default class ChartSparkPlugin extends Plugin {
settings!: ChartSparkSettings;
@ -34,39 +34,18 @@ export default class ChartSparkPlugin extends Plugin {
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor) => {
const selection = editor.getSelection().trim();
if (!selection) return;
menu.addItem(item =>
item
.setTitle('Generate chart from selection')
.setTitle('Generate chart from this note')
.setIcon('bar-chart')
.onClick(() => {
const parser = this.registry.findParser(selection);
if (!parser) {
new Notice(
'ChartSpark: Selection not recognized.\nSupported: checkbox list, markdown table, JSON, or key: value pairs.'
);
return;
}
const startLine = editor.getCursor('from').line;
const endLine = editor.getCursor('to').line;
const sourceFile = this.app.workspace.getActiveFile()?.path ?? '';
const parsed = parser.parse(selection);
new ChartPreviewModal(
this.app,
parsed,
this.settings.defaultChartType,
(config: ChartConfig) => {
config.meta = {
...config.meta,
source: { file: sourceFile, startLine, endLine },
};
insertChartBlock(editor, config);
},
).open();
void launchChartFlow({
app: this.app,
editor,
registry: this.registry,
defaultChartType: this.settings.defaultChartType,
mode: 'preview',
});
})
);
})
@ -97,10 +76,8 @@ export default class ChartSparkPlugin extends Plugin {
return;
}
// Render immediately — synchronous, guaranteed before any lifecycle hooks fire
const chartInstance = this.renderer.render(config, el);
// Component handles vault listener, delete button, and cleanup only
ctx.addChild(new ChartBlockComponent(
this.app,
config,

View file

@ -2,13 +2,29 @@ import { Parser, ParsedData } from '../types';
const SEPARATOR_RE = /^\|?[\s|:\-]+\|$/;
/** Converts any cell value to a number, or returns null if not chartable. */
export function parseNumericValue(raw: string): number | null {
const s = raw.trim().toLowerCase();
if (s === 'true' || s === 'yes') return 1;
if (s === 'false' || s === 'no') return 0;
// Strip currency symbols, percent signs, commas
const stripped = s.replace(/[$€£¥,%]/g, '').replace(/,/g, '').trim();
if (stripped === '' || stripped === '-') return null;
const n = parseFloat(stripped);
return isNaN(n) ? null : n;
}
function isChartableColumn(rows: string[][], col: number): boolean {
if (rows.length === 0) return false;
return rows.every(r => parseNumericValue(r[col] ?? '') !== null);
}
export class TableParser implements Parser {
name = 'table';
canParse(text: string): boolean {
const lines = text.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) return false;
// At least 60% of lines must contain a pipe character
const pipeLines = lines.filter(l => l.includes('|'));
return pipeLines.length >= 2 && pipeLines.length / lines.length >= 0.6;
}
@ -16,20 +32,28 @@ export class TableParser implements Parser {
parse(text: string): ParsedData {
const lines = text.trim().split('\n').filter(l => l.trim() && l.includes('|'));
const headers = this.parseCells(lines[0] ?? '');
// Parse headers and strip trailing empty columns
const rawHeaders = this.parseCells(lines[0] ?? '');
while (rawHeaders.length > 0 && rawHeaders[rawHeaders.length - 1] === '') rawHeaders.pop();
const headers = rawHeaders;
const colCount = headers.length;
// Skip separator row if present (e.g. |---|---|)
// Skip separator row; clamp each data row to colCount columns
const dataStartIndex = lines[1] && SEPARATOR_RE.test(lines[1].trim()) ? 2 : 1;
const rows = lines.slice(dataStartIndex).map(l => this.parseCells(l));
const rows = lines
.slice(dataStartIndex)
.map(l => this.parseCells(l).slice(0, colCount));
// Find first chartable column; fall back to column 1
let numericColIndex = -1;
for (let col = 0; col < colCount; col++) {
if (isChartableColumn(rows, col)) { numericColIndex = col; break; }
}
if (numericColIndex === -1) numericColIndex = Math.min(1, colCount - 1);
const numericColIndex = this.findNumericColumn(rows, headers);
const labelColIndex = numericColIndex === 0 ? 1 : 0;
const labels = rows.map(r => r[labelColIndex] ?? '');
const values = rows.map(r => {
const raw = r[numericColIndex] ?? '0';
return parseFloat(raw.replace(/[^0-9.-]/g, '')) || 0;
});
const labels = rows.map(r => (r[labelColIndex] ?? '').trim());
const values = rows.map(r => parseNumericValue(r[numericColIndex] ?? '') ?? 0);
return {
type: 'table',
@ -38,6 +62,7 @@ export class TableParser implements Parser {
metadata: {
totalItems: rows.length,
columns: headers,
rawRows: rows,
title: headers[numericColIndex] ?? 'Values',
},
};
@ -49,15 +74,4 @@ export class TableParser implements Parser {
.map(c => c.trim())
.filter((_, i, arr) => i > 0 && i < arr.length - 1);
}
private findNumericColumn(rows: string[][], headers: string[]): number {
for (let col = 0; col < headers.length; col++) {
const isNumeric = rows.every(r => {
const val = (r[col] ?? '').replace(/[^0-9.-]/g, '');
return val !== '' && !isNaN(parseFloat(val));
});
if (isNumeric) return col;
}
return 1;
}
}

View file

@ -5,6 +5,12 @@ import { ChartRenderer } from './chartRenderer';
import { ParserRegistry } from '../parsers/parserRegistry';
import { KPICalculator } from '../kpi/calculator';
const LINE_PREDICATES: Array<(l: string) => boolean> = [
(l) => l.includes('|'),
(l) => /^\s*-\s*\[[ xX]\]/.test(l),
(l) => /^[^\n:]+:\s*\S/.test(l),
];
export class ChartBlockComponent extends MarkdownRenderChild {
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
@ -13,7 +19,7 @@ export class ChartBlockComponent extends MarkdownRenderChild {
private config: ChartConfig,
private rawSource: string,
container: HTMLElement,
private chartInstance: Chart, // already rendered by processChartBlock
private chartInstance: Chart,
private renderer: ChartRenderer,
private registry: ParserRegistry,
private showRefreshBtn: boolean,
@ -22,7 +28,6 @@ export class ChartBlockComponent extends MarkdownRenderChild {
}
onload(): void {
// Chart is already in the DOM — just add the × and refresh buttons
const chartContainer = this.containerEl.querySelector<HTMLElement>('.chartspark-container');
if (chartContainer) {
const deleteBtn = chartContainer.createEl('button', {
@ -35,11 +40,8 @@ export class ChartBlockComponent extends MarkdownRenderChild {
if (this.showRefreshBtn) {
const controls = this.containerEl.createDiv({ cls: 'chartspark-controls' });
const btn = controls.createEl('button', {
cls: 'chartspark-refresh-btn',
text: '↻ Refresh',
});
btn.addEventListener('click', () => { void this.refresh(); });
controls.createEl('button', { cls: 'chartspark-refresh-btn', text: '↻ Refresh' })
.addEventListener('click', () => { void this.refresh(); });
}
const sourcePath = this.config.meta?.source?.file;
@ -63,26 +65,80 @@ export class ChartBlockComponent extends MarkdownRenderChild {
}
async refresh(file?: TFile): Promise<void> {
const source = this.config.meta?.source;
if (!source?.file) return;
const sourcePath = this.config.meta?.source?.file;
if (!sourcePath) return;
const target = file ?? this.app.vault.getAbstractFileByPath(source.file);
const target = file ?? this.app.vault.getAbstractFileByPath(sourcePath);
if (!(target instanceof TFile)) return;
const content = await this.app.vault.cachedRead(target);
const lines = content.split('\n');
const section = lines
.slice(source.startLine ?? 0, (source.endLine ?? lines.length - 1) + 1)
.join('\n');
const section = this.findSourceSection(content);
if (!section) return;
const parser = this.registry.findParser(section);
if (!parser) return;
const parsed = parser.parse(section);
const freshConfig = new KPICalculator().buildChartConfig(parsed, this.config.type);
const colSel = this.config.meta?.columnSelection;
const calc = new KPICalculator();
const freshConfig = colSel && parsed.metadata.columns && parsed.metadata.rawRows
? colSel.transposed
? calc.buildTransposed(parsed.metadata.columns, parsed.metadata.rawRows, colSel.labelColIndex, colSel.valueColIndices, this.config.type)
: calc.buildFromTableColumns(parsed.metadata.columns, parsed.metadata.rawRows, colSel.labelColIndex, colSel.valueColIndices, this.config.type)
: calc.buildChartConfig(parsed, this.config.type);
this.renderer.update(this.chartInstance, freshConfig);
}
/**
* Locate this chart's block in the file by its unique chartId, then scan
* backwards to find the data block immediately above it.
* Falls back to stored line numbers for charts created before chartId existed.
*/
private findSourceSection(content: string): string | null {
const lines = content.split('\n');
const chartId = this.config.meta?.chartId;
let chartFenceLine = -1;
if (chartId) {
// Walk through chartspark blocks and find the one with our ID
for (let i = 0; i < lines.length; i++) {
if (lines[i]?.trimEnd() !== '```chartspark') continue;
// Scan inside the block for the chartId
let j = i + 1;
while (j < lines.length && lines[j]?.trimEnd() !== '```') {
if ((lines[j] ?? '').includes(chartId)) { chartFenceLine = i; break; }
j++;
}
if (chartFenceLine >= 0) break;
}
}
if (chartFenceLine >= 0) {
// Scan backwards past blank lines to find the data block
let i = chartFenceLine - 1;
while (i >= 0 && (lines[i] ?? '').trim() === '') i--;
if (i < 0) return null;
const anchorLine = lines[i] ?? '';
const predicate = LINE_PREDICATES.find(p => p(anchorLine));
if (!predicate) return null;
let top = i;
while (top > 0 && predicate(lines[top - 1] ?? '')) top--;
return lines.slice(top, i + 1).join('\n').trim();
}
// Fallback: use stored line numbers (old charts without chartId)
const source = this.config.meta?.source;
if (!source) return null;
return lines
.slice(source.startLine ?? 0, (source.endLine ?? lines.length - 1) + 1)
.join('\n');
}
private async deleteBlock(): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;

View file

@ -1,4 +1,4 @@
import { Chart, registerables, ChartConfiguration, ChartData } from 'chart.js';
import { Chart, registerables, ChartConfiguration } from 'chart.js';
import { ChartConfig } from '../types';
Chart.register(...registerables);
@ -17,21 +17,12 @@ export class ChartRenderer {
return new Chart(canvas, chartConfig);
}
// Patch labels + dataset values in-place — no destroy, no animation
update(instance: Chart, freshConfig: ChartConfig): void {
const fresh = freshConfig.data as ChartData;
instance.data.labels = fresh.labels;
const src = fresh.datasets[0];
const dst = instance.data.datasets[0];
if (src && dst) {
dst.data = src.data;
if (src.backgroundColor) dst.backgroundColor = src.backgroundColor;
if (src.borderColor) dst.borderColor = src.borderColor;
}
instance.data.labels = freshConfig.data.labels as Chart['data']['labels'];
instance.data.datasets = freshConfig.data.datasets as Chart['data']['datasets'];
instance.update('none');
}
// Convenience: create a canvas inside container and return the instance
render(config: ChartConfig, container: HTMLElement): Chart {
container.empty();
const wrapper = container.createDiv({ cls: 'chartspark-container' });

View file

@ -1,12 +1,79 @@
import { App } from 'obsidian';
import { Editor } from 'obsidian';
export class SelectionHandler {
constructor(private app: App) {}
getSelection(): string | null {
const editor = this.app.workspace.activeEditor?.editor;
if (!editor) return null;
const sel = editor.getSelection();
return sel.trim() || null;
}
export interface TextWithRange {
text: string;
startLine: number;
endLine: number;
}
type LinePredicate = (line: string) => boolean;
const PREDICATES: LinePredicate[] = [
(l) => l.includes('|'),
(l) => /^\s*-\s*\[[ xX]\]/.test(l),
(l) => /^[^\n:]+:\s*\S/.test(l),
];
function expandBlock(editor: Editor, anchorLine: number, predicate: LinePredicate): TextWithRange {
const lineCount = editor.lineCount();
let top = anchorLine;
let bottom = anchorLine;
while (top > 0 && predicate(editor.getLine(top - 1))) top--;
while (bottom < lineCount - 1 && predicate(editor.getLine(bottom + 1))) bottom++;
const lines: string[] = [];
for (let i = top; i <= bottom; i++) lines.push(editor.getLine(i));
return { text: lines.join('\n').trim(), startLine: top, endLine: bottom };
}
/**
* In Live Preview, Obsidian renders tables/checkboxes as CM6 widget decorations.
* editor.getSelection() returns empty for these widgets even when visually selected.
* Reading from CM6 state directly gives the actual underlying markdown text.
*/
function getCM6SelectionText(editor: Editor): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cm = (editor as any).cm;
if (!cm?.state) return '';
const { from, to } = cm.state.selection.main as { from: number; to: number };
if (from === to) return '';
return (cm.state.doc.sliceString(from, to) as string).trim();
}
/**
* Returns the text to chart and its line range.
*
* Priority:
* 1. Drag-selection via standard API (source mode)
* 2. Drag-selection via CM6 state (Live Preview handles rendered widgets)
* 3. Auto-detect the data block the cursor is sitting in (fallback when no selection)
*/
export function getTextForChart(editor: Editor): TextWithRange | null {
// 1 & 2 — explicit selection
const sel = editor.getSelection().trim() || getCM6SelectionText(editor);
if (sel) {
return {
text: sel,
startLine: editor.getCursor('from').line,
endLine: editor.getCursor('to').line,
};
}
// 3 — cursor-based auto-detect (searches up to 5 lines away)
const cursorLine = editor.getCursor().line;
const lineCount = editor.lineCount();
for (let offset = 0; offset <= 5; offset++) {
const candidates = offset === 0 ? [cursorLine] : [cursorLine - offset, cursorLine + offset];
for (const line of candidates) {
if (line < 0 || line >= lineCount) continue;
const content = editor.getLine(line);
for (const predicate of PREDICATES) {
if (!predicate(content)) continue;
const result = expandBlock(editor, line, predicate);
if (result.text) return result;
}
}
}
return null;
}

View file

@ -1,5 +1,5 @@
export type PatternType = 'checkbox' | 'table' | 'json' | 'keyvalue';
export type ChartType = 'pie' | 'bar' | 'line' | 'doughnut';
export type ChartType = 'pie' | 'bar' | 'line' | 'doughnut' | 'radar' | 'polarArea';
export interface ParsedData {
type: PatternType;
@ -9,14 +9,15 @@ export interface ParsedData {
title?: string;
totalItems: number;
columns?: string[];
rawRows?: string[][]; // full table rows (all columns), set by TableParser
};
}
export interface ChartDataset {
label: string;
data: number[];
backgroundColor?: string[];
borderColor?: string[];
backgroundColor?: string | string[];
borderColor?: string | string[];
borderWidth?: number;
fill?: boolean;
tension?: number;
@ -37,6 +38,12 @@ export interface ChartConfig {
};
generated?: string;
patternType?: PatternType;
chartId?: string;
columnSelection?: {
labelColIndex: number;
valueColIndices: number[];
transposed?: boolean;
};
};
}

View file

@ -2,12 +2,27 @@ import { App, Modal } from 'obsidian';
import { ChartConfig, ChartType, ParsedData } from '../types';
import { KPICalculator } from '../kpi/calculator';
import { ChartRenderer } from '../renderer/chartRenderer';
import { parseNumericValue } from '../parsers/tableParser';
const CHART_TYPES: ChartType[] = ['pie', 'doughnut', 'bar', 'line'];
const CHART_TYPES: { type: ChartType; label: string }[] = [
{ type: 'bar', label: 'Bar' },
{ type: 'line', label: 'Line' },
{ type: 'pie', label: 'Pie' },
{ type: 'doughnut', label: 'Doughnut' },
{ type: 'radar', label: 'Radar' },
{ type: 'polarArea', label: 'Polar' },
];
export class ChartPreviewModal extends Modal {
private currentType: ChartType;
private horizontalBar = false;
private swapAxes = false;
private previewContainer!: HTMLElement;
private tabsContainer!: HTMLElement;
// Table column selection
private labelColIndex = 0;
private valueColIndices: Set<number> = new Set();
constructor(
app: App,
@ -17,6 +32,28 @@ export class ChartPreviewModal extends Modal {
) {
super(app);
this.currentType = defaultType;
this.initColumnState();
}
private initColumnState(): void {
if (this.parsed.type !== 'table') return;
const columns = this.parsed.metadata.columns ?? [];
const rows = this.parsed.metadata.rawRows ?? [];
const numericCols = new Set<number>();
for (let col = 0; col < columns.length; col++) {
if (rows.every(r => parseNumericValue(r[col] ?? '') !== null)) {
numericCols.add(col);
}
}
this.labelColIndex = 0;
for (let i = 0; i < columns.length; i++) {
if (!numericCols.has(i)) { this.labelColIndex = i; break; }
}
this.valueColIndices = new Set(numericCols);
this.valueColIndices.delete(this.labelColIndex);
}
onOpen(): void {
@ -24,39 +61,145 @@ export class ChartPreviewModal extends Modal {
contentEl.empty();
contentEl.createEl('h3', { text: 'Preview chart' });
// Type selector tabs
const tabs = contentEl.createDiv({ cls: 'chartspark-tabs' });
for (const type of CHART_TYPES) {
const tab = tabs.createEl('button', {
cls: 'chartspark-tab' + (type === this.currentType ? ' is-active' : ''),
text: type.charAt(0).toUpperCase() + type.slice(1),
});
tab.addEventListener('click', () => {
tabs.querySelectorAll('.chartspark-tab').forEach(t => t.removeClass('is-active'));
tab.addClass('is-active');
this.currentType = type;
this.renderPreview();
});
}
this.tabsContainer = contentEl.createDiv({ cls: 'chartspark-tabs' });
this.renderTabs();
if (this.parsed.type === 'table') this.renderColumnPicker(contentEl);
this.previewContainer = contentEl.createDiv({ cls: 'chartspark-preview' });
this.renderPreview();
const footer = contentEl.createDiv({ cls: 'chartspark-modal-footer' });
const insertBtn = footer.createEl('button', {
cls: 'mod-cta',
text: 'Insert chart',
footer.createEl('button', { cls: 'mod-cta', text: 'Insert chart' })
.addEventListener('click', () => { this.close(); this.onInsert(this.buildConfig()); });
}
private renderTabs(): void {
this.tabsContainer.empty();
for (const { type, label } of CHART_TYPES) {
const tab = this.tabsContainer.createEl('button', {
cls: 'chartspark-tab' + (type === this.currentType ? ' is-active' : ''),
text: label,
});
tab.addEventListener('click', () => {
this.currentType = type;
this.horizontalBar = false;
this.renderTabs();
this.renderPreview();
});
}
// Axis-swap toggle — only for bar
if (this.currentType === 'bar') {
const toggle = this.tabsContainer.createEl('button', {
cls: 'chartspark-tab chartspark-axis-toggle' + (this.horizontalBar ? ' is-active' : ''),
text: '↔ H-Bar',
});
toggle.title = 'Switch to horizontal bars';
toggle.addEventListener('click', () => {
this.horizontalBar = !this.horizontalBar;
this.renderTabs();
this.renderPreview();
});
}
}
private renderColumnPicker(container: HTMLElement): void {
const columns = this.parsed.metadata.columns ?? [];
if (columns.length === 0) return;
const picker = container.createDiv({ cls: 'chartspark-column-picker' });
// Swap-axes toggle
const swapRow = picker.createDiv({ cls: 'chartspark-col-row chartspark-swap-row' });
const swapBtn = swapRow.createEl('button', {
cls: 'chartspark-swap-btn' + (this.swapAxes ? ' is-active' : ''),
text: '⇄ Swap axes',
});
insertBtn.addEventListener('click', () => {
const config = new KPICalculator().buildChartConfig(this.parsed, this.currentType);
this.close();
this.onInsert(config);
swapBtn.title = 'Transpose: row labels become series, column names become categories';
swapBtn.addEventListener('click', () => {
this.swapAxes = !this.swapAxes;
swapBtn.toggleClass('is-active', this.swapAxes);
this.renderPreview();
});
// Label column
const labelRow = picker.createDiv({ cls: 'chartspark-col-row' });
labelRow.createEl('label', { text: 'Label column (categories)' });
const select = labelRow.createEl('select', { cls: 'chartspark-col-select' });
columns.forEach((col, i) => {
const opt = select.createEl('option', { text: col, value: String(i) });
if (i === this.labelColIndex) opt.selected = true;
});
select.addEventListener('change', () => {
this.labelColIndex = parseInt(select.value);
this.valueColIndices.delete(this.labelColIndex);
this.renderCheckboxes(picker, columns);
this.renderPreview();
});
this.renderCheckboxes(picker, columns);
}
private renderCheckboxes(picker: HTMLElement, columns: string[]): void {
picker.querySelector('.chartspark-col-checkboxes')?.remove();
const row = picker.createDiv({ cls: 'chartspark-col-checkboxes chartspark-col-row' });
row.createEl('label', { text: 'Value columns' });
const boxes = row.createDiv({ cls: 'chartspark-checkbox-group' });
columns.forEach((col, i) => {
if (i === this.labelColIndex) return;
const lbl = boxes.createEl('label', { cls: 'chartspark-checkbox-label' });
const cb = lbl.createEl('input');
cb.type = 'checkbox';
cb.checked = this.valueColIndices.has(i);
lbl.createSpan({ text: col });
cb.addEventListener('change', () => {
if (cb.checked) this.valueColIndices.add(i);
else this.valueColIndices.delete(i);
this.renderPreview();
});
});
}
private buildConfig(): ChartConfig {
let config: ChartConfig;
if (
this.parsed.type === 'table' &&
this.parsed.metadata.columns &&
this.parsed.metadata.rawRows &&
this.valueColIndices.size > 0
) {
const cols = this.parsed.metadata.columns;
const rows = this.parsed.metadata.rawRows;
const valueIdxArr = [...this.valueColIndices].sort((a, b) => a - b);
const calc = new KPICalculator();
config = this.swapAxes
? calc.buildTransposed(cols, rows, this.labelColIndex, valueIdxArr, this.currentType)
: calc.buildFromTableColumns(cols, rows, this.labelColIndex, valueIdxArr, this.currentType);
} else {
config = new KPICalculator().buildChartConfig(this.parsed, this.currentType);
}
if (this.horizontalBar && this.currentType === 'bar') {
config.options = { ...(config.options ?? {}), indexAxis: 'y' };
}
return config;
}
private renderPreview(): void {
const config = new KPICalculator().buildChartConfig(this.parsed, this.currentType);
new ChartRenderer().render(config, this.previewContainer);
if (this.valueColIndices.size === 0 && this.parsed.type === 'table') {
this.previewContainer.empty();
this.previewContainer.createEl('p', {
text: 'Select at least one value column.',
cls: 'chartspark-col-hint',
});
return;
}
new ChartRenderer().render(this.buildConfig(), this.previewContainer);
}
onClose(): void {

View file

@ -2,10 +2,12 @@ import { App, Modal } from 'obsidian';
import { ChartType } from '../types';
const CHART_OPTIONS: { type: ChartType; label: string; icon: string }[] = [
{ type: 'pie', label: 'Pie', icon: '🥧' },
{ type: 'doughnut', label: 'Doughnut', icon: '🍩' },
{ type: 'bar', label: 'Bar', icon: '📊' },
{ type: 'line', label: 'Line', icon: '📈' },
{ type: 'bar', label: 'Bar', icon: '📊' },
{ type: 'line', label: 'Line', icon: '📈' },
{ type: 'pie', label: 'Pie', icon: '🥧' },
{ type: 'doughnut', label: 'Doughnut', icon: '🍩' },
{ type: 'radar', label: 'Radar', icon: '🕸️' },
{ type: 'polarArea', label: 'Polar', icon: '🎯' },
];
export class ChartTypeModal extends Modal {

View file

@ -0,0 +1,52 @@
import { App, Modal } from 'obsidian';
import { DataBlock } from '../utils/blockFinder';
const TYPE_META: Record<string, { icon: string; label: string }> = {
table: { icon: '⊞', label: 'Table' },
checkbox: { icon: '☑', label: 'Checkbox list' },
keyvalue: { icon: '≡', label: 'Key : Value' },
};
export class DataBlockPickerModal extends Modal {
constructor(
app: App,
private blocks: DataBlock[],
private onPick: (block: DataBlock) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h3', { text: 'Choose data to chart' });
const list = contentEl.createDiv({ cls: 'chartspark-block-list' });
for (const block of this.blocks) {
const meta = TYPE_META[block.parserName] ?? { icon: '◻', label: block.parserName };
const card = list.createDiv({ cls: 'chartspark-block-card' });
const header = card.createDiv({ cls: 'chartspark-block-card-header' });
header.createSpan({ cls: 'chartspark-block-type-icon', text: meta.icon });
header.createSpan({ cls: 'chartspark-block-type-label', text: meta.label });
header.createSpan({
cls: 'chartspark-block-lines',
text: `Lines ${block.startLine + 1}${block.endLine + 1}`,
});
// Show first 4 lines as preview
const preview = block.text.split('\n').slice(0, 4).join('\n');
card.createEl('pre', { cls: 'chartspark-block-preview', text: preview });
card.addEventListener('click', () => {
this.close();
this.onPick(block);
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

61
src/utils/blockFinder.ts Normal file
View file

@ -0,0 +1,61 @@
import { ParserRegistry } from '../parsers/parserRegistry';
export interface DataBlock {
text: string;
startLine: number;
endLine: number;
parserName: string;
}
const LINE_PREDICATES: Array<(l: string) => boolean> = [
(l) => l.includes('|'),
(l) => /^\s*-\s*\[[ xX]\]/.test(l),
(l) => /^[^\n:]+:\s*\S/.test(l),
];
export function findAllDataBlocks(content: string, registry: ParserRegistry): DataBlock[] {
const lines = content.split('\n');
const blocks: DataBlock[] = [];
let i = 0;
let inCodeBlock = false;
// Skip YAML frontmatter
if (lines[0]?.trim() === '---') {
i = 1;
while (i < lines.length && lines[i]?.trim() !== '---') i++;
i++;
}
while (i < lines.length) {
const line = lines[i] ?? '';
// Track fenced code blocks so we don't parse chartspark/code blocks as data
if (/^```/.test(line.trim())) {
inCodeBlock = !inCodeBlock;
i++;
continue;
}
if (inCodeBlock) { i++; continue; }
let advanced = false;
for (const predicate of LINE_PREDICATES) {
if (!predicate(line)) continue;
// Expand to end of contiguous block
let j = i;
while (j < lines.length - 1 && predicate(lines[j + 1] ?? '')) j++;
const text = lines.slice(i, j + 1).join('\n').trim();
const parser = registry.findParser(text);
if (parser) {
blocks.push({ text, startLine: i, endLine: j, parserName: parser.name });
}
i = j + 1;
advanced = true;
break;
}
if (!advanced) i++;
}
return blocks;
}

78
src/utils/chartFlow.ts Normal file
View file

@ -0,0 +1,78 @@
import { App, Editor, Notice } from 'obsidian';
import { ChartConfig, ChartType } from '../types';
import { KPICalculator } from '../kpi/calculator';
import { ChartPreviewModal } from '../ui/chartPreviewModal';
import { ChartTypeModal } from '../ui/chartTypeModal';
import { DataBlockPickerModal } from '../ui/dataBlockPickerModal';
import { findAllDataBlocks, DataBlock } from './blockFinder';
import { ParserRegistry } from '../parsers/parserRegistry';
export interface ChartFlowOptions {
app: App;
editor: Editor;
registry: ParserRegistry;
defaultChartType: ChartType;
/** 'preview' shows the full column-picker modal; 'quick' shows only the type picker */
mode: 'preview' | 'quick';
}
export async function launchChartFlow(opts: ChartFlowOptions): Promise<void> {
const { app, editor, registry, defaultChartType, mode } = opts;
const file = app.workspace.getActiveFile();
if (!file) { new Notice('ChartSpark: No active file.'); return; }
const content = await app.vault.cachedRead(file);
const blocks = findAllDataBlocks(content, registry);
if (blocks.length === 0) {
new Notice('ChartSpark: No data found in this note (table, checkbox list, or key:value).');
return;
}
const handle = (block: DataBlock) => openChartModal(block, file.path, editor, app, registry, defaultChartType, mode);
if (blocks.length === 1 && blocks[0]) {
handle(blocks[0]);
} else {
new DataBlockPickerModal(app, blocks, handle).open();
}
}
function openChartModal(
block: DataBlock,
filePath: string,
editor: Editor,
app: App,
registry: ParserRegistry,
defaultChartType: ChartType,
mode: 'preview' | 'quick',
): void {
const parser = registry.findParser(block.text);
if (!parser) return;
const parsed = parser.parse(block.text);
const meta = { source: { file: filePath, startLine: block.startLine, endLine: block.endLine } };
const insertAfterBlock = (config: ChartConfig) => {
config.meta = {
...config.meta,
...meta,
chartId: Math.random().toString(36).slice(2, 10),
};
const json = JSON.stringify(config, null, 2);
const insertPos = { line: block.endLine + 1, ch: 0 };
editor.replaceRange(`\n\`\`\`chartspark\n${json}\n\`\`\`\n`, insertPos);
};
// Tables always go through the column-picker preview so the user controls
// which column is the label and which are the value series.
if (mode === 'preview' || parsed.type === 'table') {
new ChartPreviewModal(app, parsed, defaultChartType, insertAfterBlock).open();
} else {
new ChartTypeModal(app, (type: ChartType) => {
const config = new KPICalculator().buildChartConfig(parsed, type);
insertAfterBlock(config);
}).open();
}
}

View file

@ -127,6 +127,91 @@
font-weight: 500;
}
/* Column picker */
.chartspark-column-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: 0.75rem;
}
.chartspark-col-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.chartspark-col-row > label {
font-size: 0.85em;
font-weight: 600;
color: var(--text-muted);
min-width: 160px;
white-space: nowrap;
}
.chartspark-col-select {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 2px 6px;
color: var(--text-normal);
font-size: 0.9em;
cursor: pointer;
}
.chartspark-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chartspark-checkbox-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.9em;
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
}
.chartspark-checkbox-label:hover {
background: var(--interactive-hover);
}
.chartspark-swap-row {
justify-content: flex-end;
margin-bottom: 0.25rem;
}
.chartspark-swap-btn {
font-size: 0.82em;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
color: var(--text-muted);
cursor: pointer;
}
.chartspark-swap-btn.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.chartspark-col-hint {
color: var(--text-muted);
font-size: 0.85em;
text-align: center;
padding: 2rem 0;
}
/* Preview modal */
.chartspark-tabs {
display: flex;
@ -150,6 +235,18 @@
color: var(--text-on-accent);
}
.chartspark-axis-toggle {
margin-left: auto;
font-size: 0.8em;
opacity: 0.75;
}
.chartspark-axis-toggle.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
opacity: 1;
}
.chartspark-preview {
min-height: 200px;
}
@ -159,3 +256,63 @@
justify-content: flex-end;
margin-top: 1rem;
}
/* Data block picker modal */
.chartspark-block-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.25rem 0;
min-width: 340px;
}
.chartspark-block-card {
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 0.75rem 1rem;
cursor: pointer;
background: var(--background-secondary);
transition: background 0.12s, border-color 0.12s;
}
.chartspark-block-card:hover {
background: var(--interactive-hover);
border-color: var(--interactive-accent);
}
.chartspark-block-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.chartspark-block-type-icon {
font-size: 1.1em;
}
.chartspark-block-type-label {
font-weight: 600;
font-size: 0.9em;
color: var(--text-normal);
}
.chartspark-block-lines {
margin-left: auto;
font-size: 0.75em;
color: var(--text-faint);
}
.chartspark-block-preview {
font-size: 0.78em;
color: var(--text-muted);
background: transparent;
border: none;
margin: 0;
padding: 0;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
max-height: 4.5em;
font-family: var(--font-monospace);
}

View file

@ -1,20 +1,29 @@
import { TableParser } from '../../src/parsers/tableParser';
import { TableParser, parseNumericValue } from '../../src/parsers/tableParser';
describe('parseNumericValue', () => {
it('parses integers', () => expect(parseNumericValue('42')).toBe(42));
it('parses floats', () => expect(parseNumericValue('3.14')).toBe(3.14));
it('parses true as 1', () => expect(parseNumericValue('true')).toBe(1));
it('parses false as 0', () => expect(parseNumericValue('false')).toBe(0));
it('parses TRUE case-insensitively', () => expect(parseNumericValue('TRUE')).toBe(1));
it('parses yes/no', () => { expect(parseNumericValue('yes')).toBe(1); expect(parseNumericValue('no')).toBe(0); });
it('parses currency', () => expect(parseNumericValue('$1,200')).toBe(1200));
it('parses percentages', () => expect(parseNumericValue('42%')).toBe(42));
it('returns null for text', () => expect(parseNumericValue('Apple')).toBeNull());
it('returns null for empty', () => expect(parseNumericValue('')).toBeNull());
});
describe('TableParser', () => {
const parser = new TableParser();
const TABLE = '| Item | Sales |\n|------|-------|\n| Apples | 120 |\n| Bananas | 95 |';
const BOOL_TABLE = '| Name | Done |\n|------|------|\n| Alice | true |\n| Bob | false |';
const TRAILING_COL = '| Ali | Fruits | |\n| --- | ------ | --- |\n| 12 | Apple | |\n| 4 | Birnen | |';
describe('canParse', () => {
it('detects a markdown table', () => {
expect(parser.canParse(TABLE)).toBe(true);
});
it('rejects plain text', () => {
expect(parser.canParse('hello world')).toBe(false);
});
it('accepts table without separator row (Live Preview selections omit it)', () => {
expect(parser.canParse('| A | B |\n| 1 | 2 |')).toBe(true);
});
it('detects a markdown table', () => expect(parser.canParse(TABLE)).toBe(true));
it('rejects plain text', () => expect(parser.canParse('hello world')).toBe(false));
it('accepts table without separator row', () => expect(parser.canParse('| A | B |\n| 1 | 2 |')).toBe(true));
});
describe('parse', () => {
@ -24,8 +33,23 @@ describe('TableParser', () => {
expect(result.values).toEqual([120, 95]);
});
it('sets totalItems', () => {
expect(parser.parse(TABLE).metadata.totalItems).toBe(2);
it('sets totalItems', () => expect(parser.parse(TABLE).metadata.totalItems).toBe(2));
it('parses boolean columns as 1/0', () => {
const result = parser.parse(BOOL_TABLE);
expect(result.values).toEqual([1, 0]);
expect(result.labels).toEqual(['Alice', 'Bob']);
});
it('strips trailing empty columns', () => {
const result = parser.parse(TRAILING_COL);
expect(result.metadata.columns).toEqual(['Ali', 'Fruits']);
expect(result.labels).toEqual(['Apple', 'Birnen']);
expect(result.values).toEqual([12, 4]);
});
it('stores rawRows in metadata', () => {
expect(parser.parse(TABLE).metadata.rawRows).toBeDefined();
});
});
});