# Chart Hands Chart Hands draws interactive [Plotly](https://plotly.com/javascript/) charts in your Obsidian notes from short `plotly` code blocks, with no JavaScript needed. You can hover over a chart to read values and drag to zoom. Charts fit the note width, and they fit the page when you export a note to PDF. Chart Hands also gives LLM agents a pair of "hands": tools that build and check chart blocks before an agent writes them into a note. You don't need an agent to use the plugin. It is a fork of [obsidian-plotly](https://github.com/Dmitriy-Shulha/obsidian-plotly) by Dmitriy Shulha (MIT), updated to Plotly.js 4 and D3 7. It adds width fitting, PDF export support and the agent tools. It works on desktop and mobile. **Contents:** [Quick start](#quick-start) · [Writing a plotly block](#writing-a-plotly-block) · [Using examples from plotly.com](#using-examples-from-plotlycom) · [Charts from vault data (DataviewJS)](#charts-from-vault-data-dataviewjs) · [When a chart doesn't show](#when-a-chart-doesnt-show) · [Settings](#settings) · [Examples](#examples) · [Tools for LLM agents](#tools-for-llm-agents) · [Installation](#installation) ## Quick start 1. Install and enable Chart Hands (see [Installation](#installation)). 2. Open a note, press `Ctrl+P` (`Cmd+P` on macOS) and run **Chart Hands: New Plotly chart**, or paste this block: ```` ```plotly data: - x: [0, 1, 2] y: [0, 1, 0] ``` ```` 3. Move the cursor out of the block in Live Preview, or switch to Reading view, and a line chart appears. The first chart can take a moment, because Plotly loads the first time a chart is drawn. A "loading Plotly and D3…" notice shows while that happens. Next, try a bar chart with a title and axis labels: ```` ```plotly data: - type: bar x: ["Mon", "Tue", "Wed", "Thu", "Fri"] y: [3, 5, 2, 6, 4] layout: title: {text: "Hours of focus"} xaxis: {title: {text: "Day"}} yaxis: {title: {text: "Hours"}} ``` ```` Change `type: bar` to `type: scatter` to draw a line instead. When that works, copy any chart from [Examples](#examples) and replace the numbers with your own. ## Writing a `plotly` block The block is YAML or JSON (both are read the same way) with up to four top-level keys: | Key | What it holds | | --- | --- | | `data` | A list of traces. Each trace is one series: its `type` (`bar`, `scatter`, `pie`, `heatmap`, …) and its values (`x`, `y`, `labels`, `values`, …). Without a `type`, a trace is drawn as `scatter`. | | `layout` | Optional. The title, axes, legend, size, margins, reference lines (`shapes`) and notes (`annotations`). | | `config` | Optional. Interaction options such as `displaylogo: false`, `displayModeBar: false` or `scrollZoom: true`. | | `fit` | Optional. `off`, `shrink` or `fill`, to override the **Fit chart width** setting for this chart only. | These are the objects that Plotly.js takes in `Plotly.newPlot(div, data, layout, config)`. Every option is listed in the [Plotly.js reference](https://plotly.com/javascript/reference/). A few YAML rules cause most of the problems: - **Quote colours**: write `color: "#2563EB"`. Without quotes, `#` starts a YAML comment and the colour is lost. - **Quote dates**: write `x: ["2026-01-05", "2026-01-12"]`, so the values stay as text and Plotly reads them as dates. - **Write titles as objects**: write `title: {text: "Sales"}`. Plotly.js 4 doesn't show a title written as a plain string. - **Indent with spaces**, never tabs, and start each trace with `- `. - **No JavaScript**: a `plotly` block holds values only, so functions, variables and `new Date()` don't work. For computed data, use [DataviewJS](#charts-from-vault-data-dataviewjs). ## Using examples from plotly.com The [Plotly.js examples](https://plotly.com/javascript/) are written in JavaScript. When an example only builds objects from plain values, copy those objects into a `plotly` block. This example: ```js var trace1 = {x: [1, 2, 3, 4], y: [10, 15, 13, 17], type: 'scatter'}; var trace2 = {x: [1, 2, 3, 4], y: [16, 5, 11, 9], type: 'scatter'}; var layout = {title: {text: 'Two lines'}}; Plotly.newPlot('myDiv', [trace1, trace2], layout); ``` becomes: ```` ```plotly data: - {x: [1, 2, 3, 4], y: [10, 15, 13, 17], type: scatter} - {x: [1, 2, 3, 4], y: [16, 5, 11, 9], type: scatter} layout: title: {text: "Two lines"} ``` ```` If the example computes its values (loops, `Math.random()`, `d3.csv(...)`), run it in a DataviewJS block instead. ## Charts from vault data (DataviewJS) For a chart computed from your notes, or a plotly.com example that runs code, use a `dataviewjs` block. This needs the [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin with **Enable JavaScript queries** turned on in its settings. Run **Chart Hands: New Plotly chart (Dataviewjs)** to insert a starter block. When you paste a plotly.com example, replace `Plotly.newPlot` with `window.renderPlotly` and draw into `this.container`: ```js // plotly.com Plotly.newPlot('myDiv', data, layout, config); // DataviewJS in Obsidian window.renderPlotly(this.container, data, layout, config); ``` This block counts the notes created in each month: ```` ```dataviewjs const counts = {}; for (const page of dv.pages()) { const month = page.file.ctime.toFormat('yyyy-MM'); counts[month] = (counts[month] || 0) + 1; } const months = Object.keys(counts).sort(); window.renderPlotly(this.container, [{type: 'bar', x: months, y: months.map(m => counts[m]), marker: {color: '#2563EB'}}], {title: {text: 'Notes created per month'}}); ``` ```` `window.renderPlotly` waits for Plotly to load by itself. An optional fifth argument (`'off'`, `'shrink'` or `'fill'`) sets the width fitting for that chart. Plotly.js and D3 are bundled, so unlike obsidian-plotly you don't need to download D3 into your vault. Code that calls `Plotly` or `d3` directly must load them first: ```js const { Plotly, d3 } = await window.loadPlotlyLibraries(); ``` ## When a chart doesn't show | What you see | What to check | | --- | --- | | The block stays as code | The plugin is enabled, and the fence language is exactly `plotly` in lowercase. In Live Preview, move the cursor outside the block. | | `Couldn't render plot:` followed by a YAML error | Indentation (spaces, not tabs), a missing `- ` before a trace, or an unclosed quote or bracket. The message shows the line. | | `The only valid keys are data, layout, config and fit.` | A key such as `title` or `type` is at the top level. Move it under `layout` or into a trace. | | `There should be a valid JSON in this block.` | The block is empty, or it holds only comments. | | The chart is drawn but a colour or title is missing | Quote `#` colours, and write titles as `title: {text: "..."}`. | | A "could not load Plotly" notice | Reopen the note. Chart Hands tries to load Plotly again the next time a chart is drawn. | | A DataviewJS block draws nothing | Dataview is installed with JavaScript queries enabled, and the code calls `window.renderPlotly(this.container, ...)` rather than `Plotly.newPlot`. | ## Settings Open *Settings → Chart Hands*: - **Fit chart width**: *Shrink only when wider* (the default) narrows charts that are wider than the note. *Always fill the width* stretches every chart to the note width. *Off* keeps the original size (`layout.width`, or Plotly's 700 px). To change one chart, add `fit: off`, `fit: shrink` or `fit: fill` to its block. - **Keep aspect ratio when shrinking** (on by default): scales the height along with the width. - **Fit the page width when exporting to PDF** (on by default): shrinks charts that are wider than the page, so they aren't cut off when you export to PDF or print. ## Examples Copy any block below into a note. All of them are YAML except the last, which shows the same format in JSON. The colours come from the Chart Hands palette, so you only need to replace the sample numbers and labels with your own. | To show | Example | | --- | --- | | A trend over time | [Line chart with a target line](#line-chart-with-a-target-line), [Time series with a range slider](#time-series-with-a-range-slider-and-an-event-band) | | A comparison between categories | [Grouped bars](#grouped-bars-with-value-labels), [Stacked bars](#stacked-bars), [Bars and a line on a secondary axis](#bars-and-a-line-on-a-secondary-axis) | | Parts of a whole | [Donut chart](#donut-chart), [Sunburst](#sunburst) | | How values are spread | [Box plots](#box-plots) | | A relationship between measures | [Bubble chart](#bubble-chart) | | A grid of values | [Heatmap habit tracker](#heatmap-habit-tracker) | | Flows and running totals | [Sankey diagram](#sankey-diagram), [Waterfall](#waterfall) | | Key numbers | [KPI row with a gauge](#kpi-row-with-a-gauge) | | Items rated on several criteria | [Radar chart](#radar-chart) | | Switching views | [Buttons that switch views](#buttons-that-switch-views) | | The JSON format | [JSON and the fit key](#json-and-the-fit-key) | ### Line chart with a target line Two series, a dashed line for last year, and a reference line drawn with `shapes`. ```` ```plotly data: - type: scatter mode: lines+markers name: "2025" x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] y: [32, 35, 31, 40, 43, 47] line: {color: "#64748B", dash: dash} - type: scatter mode: lines+markers name: "2026" x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] y: [38, 41, 44, 46, 52, 55] line: {color: "#2563EB", width: 3} layout: title: {text: "Monthly active users"} hovermode: "x unified" yaxis: {title: {text: "Users (thousands)"}, rangemode: tozero} shapes: - {type: line, xref: paper, x0: 0, x1: 1, y0: 50, y1: 50, line: {color: "#C2410C", dash: dot}} annotations: - {xref: paper, x: 0, y: 50, text: "Target", showarrow: false, xanchor: left, yanchor: bottom} config: responsive: true displaylogo: false ``` ```` ### Grouped bars with value labels ```` ```plotly data: - {type: bar, name: "Plan", x: ["Q1", "Q2", "Q3", "Q4"], y: [120, 140, 160, 180], marker: {color: "#64748B"}} - type: bar name: "Actual" x: ["Q1", "Q2", "Q3", "Q4"] y: [115, 152, 171, 168] marker: {color: "#2563EB"} texttemplate: "%{y}" textposition: outside cliponaxis: false layout: title: {text: "Plan vs actual"} barmode: group yaxis: {title: {text: "Amount (M KRW)"}} ``` ```` ### Stacked bars `barmode: stack` piles the categories of each month on top of each other. ```` ```plotly data: - {type: bar, name: "Housing", x: ["Jul", "Aug", "Sep", "Oct"], y: [900, 900, 900, 900], marker: {color: "#2563EB"}} - {type: bar, name: "Food", x: ["Jul", "Aug", "Sep", "Oct"], y: [420, 465, 390, 440], marker: {color: "#0F766E"}} - {type: bar, name: "Transport", x: ["Jul", "Aug", "Sep", "Oct"], y: [150, 180, 140, 160], marker: {color: "#7C3AED"}} - {type: bar, name: "Other", x: ["Jul", "Aug", "Sep", "Oct"], y: [230, 310, 205, 260], marker: {color: "#64748B"}} layout: title: {text: "Monthly spending by category"} barmode: stack yaxis: {title: {text: "Amount (1,000 KRW)"}} ``` ```` ### Bars and a line on a secondary axis Use a second y axis only when the two series have different units. ```` ```plotly data: - {type: bar, name: "Orders", x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], y: [320, 340, 390, 410, 460, 480], marker: {color: "#2563EB"}} - type: scatter mode: lines+markers name: "Return rate" x: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] y: [4.2, 3.9, 4.5, 3.6, 3.1, 2.8] yaxis: y2 line: {color: "#C2410C"} layout: title: {text: "Orders and return rate"} yaxis: {title: {text: "Orders"}} yaxis2: {title: {text: "Return rate (%)"}, overlaying: "y", side: right, showgrid: false, tickmode: auto, rangemode: tozero} legend: {orientation: "h", x: 0, y: -0.15} ``` ```` ### Donut chart ```` ```plotly data: - type: pie hole: 0.55 labels: ["Deep work", "Meetings", "Email", "Admin", "Learning"] values: [18, 9, 6, 4, 3] sort: false textinfo: "label+percent" marker: colors: ["#2563EB", "#C2410C", "#64748B", "#7C3AED", "#0F766E"] line: {color: "#FFFFFF", width: 2} layout: title: {text: "Where the week went"} showlegend: false annotations: - {text: "40 h", showarrow: false, font: {size: 22}, xref: paper, yref: paper, x: 0.5, y: 0.5} ``` ```` ### Bubble chart Marker size carries a third measure; `hovertemplate` shows all three. ```` ```plotly data: - type: scatter mode: markers+text x: [12, 25, 8, 30, 18] y: [4.1, 3.6, 4.6, 3.2, 4.3] text: ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"] customdata: [120, 340, 80, 510, 220] textposition: "top center" marker: {size: [16, 30, 12, 40, 22], color: "#2563EB", opacity: 0.6, line: {color: "#FFFFFF", width: 1}} hovertemplate: "%{text}
Price %{x} USD
Rating %{y}
Sold %{customdata}" layout: title: {text: "Price, rating and units sold"} xaxis: {title: {text: "Price (USD)"}} yaxis: {title: {text: "Rating"}} ``` ```` ### Box plots Compare distributions; `boxpoints: all` also shows every value. ```` ```plotly data: - {type: box, name: "Weekdays", y: [6.1, 6.5, 6.8, 7.0, 6.2, 5.9, 6.6, 7.1, 6.4, 6.0], boxmean: true, boxpoints: all, jitter: 0.3, marker: {color: "#2563EB"}} - {type: box, name: "Weekends", y: [7.5, 8.2, 7.8, 8.6, 9.0, 7.2], boxmean: true, boxpoints: all, jitter: 0.3, marker: {color: "#0F766E"}} layout: title: {text: "Hours of sleep"} yaxis: {title: {text: "Hours"}} showlegend: false ``` ```` ### Heatmap habit tracker `z` is a list of rows, one per `y` value. `autorange: reversed` keeps week 1 on top. ```` ```plotly data: - type: heatmap x: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] y: ["Week 1", "Week 2", "Week 3", "Week 4"] z: - [30, 45, 0, 60, 20, 90, 0] - [40, 30, 50, 0, 35, 120, 60] - [0, 55, 45, 50, 40, 0, 75] - [60, 50, 40, 70, 0, 100, 90] colorscale: Blues texttemplate: "%{z}" xgap: 3 ygap: 3 colorbar: {title: {text: "Minutes"}} layout: title: {text: "Reading minutes per day"} yaxis: {autorange: reversed} ``` ```` ### Sunburst `parents` links each label to its parent (`""` for the root). With `branchvalues: total` each parent's value is the sum of its children. Change `type` to `treemap` or `icicle` to draw the same data another way. ```` ```plotly data: - type: sunburst labels: ["Projects", "Work", "Personal", "Report", "Website", "Garden", "Language"] parents: ["", "Projects", "Projects", "Work", "Work", "Personal", "Personal"] values: [60, 40, 20, 24, 16, 8, 12] branchvalues: total textinfo: "label+value" layout: title: {text: "Hours by project this month"} margin: {l: 10, r: 10, t: 60, b: 10} ``` ```` ### Sankey diagram `link.source` and `link.target` are 0-based indexes into `node.label`. ```` ```plotly data: - type: sankey node: label: ["Salary", "Side income", "Budget", "Housing", "Living", "Savings", "Investing"] color: ["#2563EB", "#7C3AED", "#64748B", "#C2410C", "#C2410C", "#0F766E", "#0F766E"] pad: 18 thickness: 16 link: source: [0, 1, 2, 2, 2, 2] target: [2, 2, 3, 4, 5, 6] value: [3000, 500, 1000, 1200, 800, 500] color: "rgba(100, 116, 139, 0.25)" layout: title: {text: "Where the money goes (1,000 KRW)"} ``` ```` ### Waterfall `measure` marks each step as `absolute` (a starting level), `relative` (a change) or `total` (the running total; its `y` is ignored). ```` ```plotly data: - type: waterfall x: ["Opening", "Salary", "Rent", "Living", "Savings", "Closing"] measure: [absolute, relative, relative, relative, relative, total] y: [1200, 3000, -1000, -1200, -800, 0] texttemplate: "%{delta:,.0f}" textposition: outside increasing: {marker: {color: "#0F766E"}} decreasing: {marker: {color: "#C2410C"}} totals: {marker: {color: "#2563EB"}} connector: {line: {color: "#64748B", dash: dot}} layout: title: {text: "Cash this month (1,000 KRW)"} showlegend: false ``` ```` ### Time series with a range slider and an event band Quote the dates so they stay strings. The grey band marks a period; the buttons above the chart zoom to the last month or three. ```` ```plotly data: - type: scatter mode: lines+markers name: "Distance" x: ["2026-01-05", "2026-01-12", "2026-01-19", "2026-01-26", "2026-02-02", "2026-02-09", "2026-02-16", "2026-02-23", "2026-03-02", "2026-03-09", "2026-03-16", "2026-03-23"] y: [18, 22, 25, 21, 27, 8, 5, 12, 20, 26, 30, 28] line: {color: "#2563EB", width: 2} layout: title: {text: "Weekly running distance"} xaxis: rangeslider: {visible: true} rangeselector: buttons: - {count: 1, label: "1M", step: month, stepmode: backward} - {count: 3, label: "3M", step: month, stepmode: backward} - {step: all, label: "All"} yaxis: {title: {text: "km"}} shapes: - {type: rect, xref: x, yref: paper, x0: "2026-02-06", x1: "2026-02-20", y0: 0, y1: 1, fillcolor: "rgba(100, 116, 139, 0.15)", line: {width: 0}, layer: below} annotations: - {xref: x, yref: paper, x: "2026-02-13", y: 1, text: "Knee injury", showarrow: false, yanchor: bottom} ``` ```` ### KPI row with a gauge Indicators are placed in a `grid` with `domain`. ```` ```plotly data: - type: indicator mode: number+delta value: 1284 title: {text: "Visitors"} delta: {reference: 1130} domain: {row: 0, column: 0} - type: indicator mode: number+delta value: 0.042 number: {valueformat: ".1%"} title: {text: "Conversion"} delta: {reference: 0.038, valueformat: ".1%"} domain: {row: 0, column: 1} - type: indicator mode: gauge+number value: 87 title: {text: "Satisfaction"} gauge: axis: {range: [0, 100]} bar: {color: "#2563EB"} threshold: {value: 90, line: {color: "#C2410C", width: 3}, thickness: 0.8} domain: {row: 0, column: 2} layout: grid: {rows: 1, columns: 3, pattern: independent} height: 260 margin: {l: 30, r: 30, t: 50, b: 20} ``` ```` ### Radar chart Repeat the first point at the end to close each outline. ```` ```plotly data: - type: scatterpolar name: "Laptop A" r: [4, 3, 5, 4, 2, 4] theta: ["Speed", "Battery", "Screen", "Keyboard", "Weight", "Speed"] fill: toself line: {color: "#2563EB"} - type: scatterpolar name: "Laptop B" r: [3, 5, 3, 4, 4, 3] theta: ["Speed", "Battery", "Screen", "Keyboard", "Weight", "Speed"] fill: toself line: {color: "#C2410C", dash: dash} layout: title: {text: "Laptop comparison (1 to 5)"} polar: {radialaxis: {range: [0, 5]}} ``` ```` ### Buttons that switch views `updatemenus` change the chart without code: `method: restyle` shows or hides traces, `method: relayout` changes the layout. ```` ```plotly data: - {type: bar, name: "2025", x: ["North", "South", "East", "West"], y: [380, 290, 340, 300], marker: {color: "#64748B"}} - {type: bar, name: "2026", x: ["North", "South", "East", "West"], y: [420, 310, 380, 290], marker: {color: "#2563EB"}} layout: title: {text: "Revenue by region (M KRW)", x: 0, xanchor: left} margin: {t: 100} updatemenus: - type: buttons direction: right x: 0 y: 1.12 xanchor: left yanchor: bottom buttons: - {label: "Both", method: restyle, args: [{visible: [true, true]}]} - {label: "2025", method: restyle, args: [{visible: [true, false]}]} - {label: "2026", method: restyle, args: [{visible: [false, true]}]} - type: buttons direction: right x: 1 y: 1.12 xanchor: right yanchor: bottom buttons: - {label: "Grouped", method: relayout, args: [{barmode: group}]} - {label: "Stacked", method: relayout, args: [{barmode: stack}]} ``` ```` ### JSON and the `fit` key The block can also be JSON. `fit: fill` stretches this chart to the note width, whatever the plugin setting. ```` ```plotly { "data": [{"type": "bar", "x": ["A", "B", "C"], "y": [3, 5, 2], "marker": {"color": "#2563EB"}}], "layout": {"title": {"text": "Written in JSON"}}, "fit": "fill" } ``` ```` ## Exploring a CSV yourself (no LLM needed) The CSV exploration tools (`eda_profile` and `eda_analyze`, described under [CSV exploration for agents](#csv-exploration-for-agents)) are ordinary functions, so you can call them from a `dataviewjs` block and draw the results with Chart Hands. This needs Dataview with **Enable JavaScript queries** turned on, the same as [Charts from vault data](#charts-from-vault-data-dataviewjs). The tools only read the CSV; they never change it. Put this helper at the top of a `dataviewjs` block. `data/survey.csv` is a vault-relative path; replace it with your own file: ```` ```dataviewjs const provider = app.plugins.plugins['chart-hands'][Symbol.for('dev.agentinterop.capability-provider')]; const eda = async (name, args) => (await provider.callTool(name, args)).structuredContent; const path = 'data/survey.csv'; ``` ```` The examples below each continue in the same block, below those three lines. ### 1. Check the data first (profile) Which columns are numbers, how many cells are empty, which values repeat? `eda_profile` returns five columns per call, so loop with `nextFrom` to see them all: ```js const rows = []; let from = 1; while (from) { const p = await eda('eda_profile', { path, from }); for (const c of p.columns) { rows.push([c.name, c.type, `${(c.missingRate * 100).toFixed(1)}%`, c.distinct, c.statistics ? `${c.statistics.min} to ${c.statistics.max}` : c.top.map(t => t.value).join(', ')]); } from = p.nextFrom; } dv.table(['Column', 'Type', 'Missing', 'Distinct', 'Range or top values'], rows); ``` Look for these first: a `text` column that should be numeric (one stray value such as `N/A` or `1,200` turns the whole column into text), a high missing rate, and a `distinct` count of 1 (a column that never varies). ### 2. Distribution of one column (histogram) ```js const h = await eda('eda_analyze', { path, analysis: { type: 'histogram', columns: ['score'], bins: 10 } }); window.renderPlotly(this.container, [{ type: 'bar', x: h.bins.map(b => `${b.lower.toFixed(1)}–${b.upper.toFixed(1)}`), y: h.bins.map(b => b.count), marker: { color: '#2563EB' } }], { title: { text: `score (${h.validRows} rows, ${h.excludedRows} excluded)` }, bargap: 0.05 }); ``` A single tall bar suggests an outlier stretching the range; two humps suggest two groups mixed together. Always read `excludedRows`: it counts blank and nonnumeric cells that were left out. ### 3. Do two columns move together? (correlation) ```js const pairs = [['duration', 'score'], ['duration', 'age'], ['score', 'age']]; const out = []; for (const [a, b] of pairs) { const r = await eda('eda_analyze', { path, analysis: { type: 'correlation', columns: [a, b] } }); out.push([a, b, r.correlation === null ? 'n/a' : r.correlation.toFixed(2), r.validPairs]); } dv.table(['A', 'B', 'Pearson r', 'Pairs used'], out); ``` `r` runs from -1 to 1; near 0 means no straight-line relationship. It says nothing about cause, and it does not detect curved relationships, so draw a scatter chart of any pair that matters before you conclude anything. `n/a` means fewer than two usable pairs, or one column never varies. ### 4. A quick summary of one column ```js const s = (await eda('eda_analyze', { path, analysis: { type: 'summary', columns: ['score'] } })).statistics; dv.paragraph(`n=${s.count}, mean ${s.mean?.toFixed(2)}, median ${s.median}, sd ${s.stddevSample?.toFixed(2)}, range ${s.min} to ${s.max}`); ``` When the mean and the median differ a lot, the data is skewed or has outliers. ### A typical session 1. Save the CSV in your vault, for example `data/survey.csv`, with a header row of unique column names. 2. Run the profile (example 1). Fix problems in the CSV: fill or delete blank cells, remove units and thousands separators from number columns. 3. Draw a histogram for each column you care about (example 2). 4. Check correlations between pairs of numeric columns (example 3). 5. Turn what you found into a chart for your note. Use the `plotly` block from [Examples](#examples), or pass the same numbers to `window.renderPlotly`. ### Limits - A CSV needs a header, at most 100 columns and 100,000 data rows, and at most 5 MiB. Fields are separated by commas only; quoted fields and newlines inside them are supported. - A column counts as numeric only when every nonblank cell is a plain number (`12`, `-3.5`, `1e3`). Values such as `1,200`, `$5`, `50%` or `N/A` make it text. - Only Pearson correlation, summary and histogram (1 to 50 bins) are available. For anything else, use the profile results to decide what to compute in your own DataviewJS. - This calls the plugin's agent interface directly. It is not a separate commands-palette feature, and its `app.plugins.plugins['chart-hands']` lookup assumes the plugin folder is named `chart-hands`. ## Tools for LLM agents You can skip this section if you don't use an AI agent in Obsidian. Chart Hands exposes an agentinterop v1 capability provider on the plugin instance. A compatible agent host can read the tool definitions and call them, once you approve them in the host: - `get_chart_guide`: returns writing guidance by topic, so the agent reads only what it needs. `basics` maps the question a chart answers (trend, comparison, share, distribution, relationship, matrix, hierarchy, flow, stages, profile, KPI) to a chart type; `statistical`, `hierarchy`, `timeseries`, `scientific`, `maps` and `layout` give checked examples of Plotly's advanced charts (box and violin, correlation heatmaps, splom, treemap and sunburst, sankey, waterfall, candlestick with range buttons, gauges, contour and 3D, polar, ternary, parallel coordinates, maps, subplots, buttons and sliders). `style`, `examples`, `dataviewjs` and `errors` cover the look, basic examples, DataviewJS and fixes. - `build_chart`: builds a ready-to-insert `plotly` block from plain data (labels and series), with the Chart Hands palette, and checks it. Types: line, area, bar, barh, combo (bars and lines with an optional secondary axis), scatter, bubble, pie, donut, treemap, sunburst, waterfall, funnel, histogram, box, violin, heatmap, radar and indicator. Line, area, bar and scatter charts can add a range slider. Compatible compact inputs also return `staticBlock` for HWPX export. The `chart-hands-static` block previews in Chart Hands; a compatible HWPX Exporter renders it to PNG from its data. It accepts JSON data only. Text including `<`, `>`, `&` and `$` is displayed literally, not interpreted as HTML or math. Control characters and external sources are unsupported. If the block is absent, this path is unavailable. Saving still requires the export tool's approval flow. - `check_chart`: validates the content of a `plotly` block: YAML/JSON syntax, top-level keys, the `fit` value, common mistakes (unquoted `#` colours, dates, plain-string titles, JavaScript-like values, mismatched array lengths), the data shapes of advanced traces (treemap parents, sankey links, candlestick prices, waterfall measures, grid `z`, removed mapbox types) and Plotly's own attribute check. - `read_charts`: finds the `plotly` blocks in a note and reports line ranges, traces, titles and problems, 20 charts at a time. None of the tools edits a note or draws a chart. The agent inserts the returned block with the host's own note-editing tools. The plugin works the same without an agent host. ### CSV exploration for agents A compatible agent host can call `eda_profile` with a vault-relative CSV `path` to inspect inferred column types, missing counts and rates, distinct counts, frequent text values, and numeric quartiles, mean, and sample standard deviation. Results contain five columns per page; continue with `nextFrom`. CSV files must have a header with unique nonempty names, at most 100 columns and 100,000 data rows, and be no larger than 5 MiB. Quoted fields and newlines are supported. Blank or whitespace-only cells are missing. A column is numeric only when every nonblank value is a finite number; other missing-value conventions are not inferred. Call `eda_analyze` with `path` and an analysis specification, for example `{"type":"correlation","columns":["duration","score"],"method":"pearson"}` or `{"type":"summary","columns":["score"]}`. It reports the number of excluded nonnumeric or blank rows; correlation uses complete pairs and is `null` if fewer than two pairs or either variable has no variation. Quartiles use linear interpolation and standard deviation is the sample statistic. These tools read the vault without changing it, do not execute SQL or code from the file, and do not load Plotly or D3. The result is separate from a chart definition; an agent can use `build_chart` or write a Plotly block using the results. `build_chart` performs basic checks without loading Plotly; use `check_chart` for full Plotly schema validation. For a distribution, use `{"type":"histogram","columns":["score"],"bins":10}`; bins are left-inclusive and right-exclusive, except that the last includes the maximum. A constant column produces one bin. ### About the agentinterop v1 provider agentinterop v1 is the author's own convention, not an official or widely recognized standard. It lets an Obsidian plugin that runs an AI agent discover tools offered by other plugins through an MCP-like API, without those plugins knowing about any particular agent host. How Chart Hands implements it: - The plugin instance holds the provider under the key `Symbol.for('dev.agentinterop.capability-provider')`. The provider has `protocol: 'agentinterop'`, `protocolVersion: 1`, `describe()` (returns the provider info, instructions and tool definitions with JSON Schema inputs) and `callTool(name, args)` (returns `content` plus `structuredContent`, or an error with a code). - Tool definitions carry `_meta` hints under the `dev.agentinterop/` prefix: `effect` (`pure` or `read`), `group`, `keywords`, `resourceTypes` and `maxResultChars`. A host shows these tools to the model only when a request matches a keyword such as "plotly", "chart" or "graph". - The plugin triggers the `agentinterop:providers-changed` workspace event on load and unload so a host can refresh its tool list. - All tools are read-only: they never modify notes or draw charts. ## Installation **From the community plugins list** (once Chart Hands is listed): open *Settings → Community plugins*, turn off Restricted mode if it is on, select **Browse**, search for **Chart Hands**, then select **Install** and **Enable**. **Manually**: 1. Download `main.js`, `manifest.json` and `styles.css` from the [latest release](https://github.com/EverydayMind/obsidian-chart-hands/releases/latest). 2. Create the folder `/.obsidian/plugins/chart-hands/` and put the three files in it. The `.obsidian` folder is hidden on some systems. 3. Restart Obsidian, or reload the community plugins list, then enable **Chart Hands** in *Settings → Community plugins*. ## Development ``` npm ci npm run build # bundles src/main.js with Plotly.js and D3 into main.js npm test npm run lint ``` ## License MIT. Copyright (c) 2021 Dmitriy Shulha, (c) 2026 everydaymind. Plotly.js and D3 are bundled under their own MIT and ISC licenses.