mirror of
https://github.com/pooyash1998/chartspark.git
synced 2026-07-22 06:53:29 +00:00
fixed refreshing
This commit is contained in:
parent
a5676428f9
commit
4dc9961018
23 changed files with 1066 additions and 259 deletions
22
LICENSE
22
LICENSE
|
|
@ -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
125
README.md
|
|
@ -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) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Product mix (Polar Area) | Team availability (Bar) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
## 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** — 200–1200 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 (200–1200 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
BIN
docs/screenshot-bar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/screenshot-doughnut.png
Normal file
BIN
docs/screenshot-doughnut.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/screenshot-pie.png
Normal file
BIN
docs/screenshot-pie.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/screenshot-polar.png
Normal file
BIN
docs/screenshot-polar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
src/main.ts
45
src/main.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
13
src/types.ts
13
src/types.ts
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
52
src/ui/dataBlockPickerModal.ts
Normal file
52
src/ui/dataBlockPickerModal.ts
Normal 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
61
src/utils/blockFinder.ts
Normal 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
78
src/utils/chartFlow.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
157
styles.css
157
styles.css
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue