enarratio-astro 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +154 -2
- package/dist/chart.d.ts +72 -0
- package/dist/chart.js +119 -0
- package/dist/data.d.ts +22 -0
- package/dist/data.js +161 -0
- package/dist/figure.d.ts +6 -0
- package/dist/figure.js +12 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/markdown.d.ts +67 -0
- package/dist/markdown.js +141 -0
- package/dist/once.d.ts +5 -0
- package/dist/once.js +11 -0
- package/dist/styles.d.ts +19 -0
- package/dist/styles.js +15 -0
- package/package.json +64 -4
- package/src/Chart.astro +31 -0
- package/src/Enhance.astro +21 -0
- package/src/chart.ts +195 -0
- package/src/data.ts +156 -0
- package/src/env.d.ts +7 -0
- package/src/figure.ts +14 -0
- package/src/index.ts +17 -0
- package/src/markdown.ts +171 -0
- package/src/once.ts +11 -0
- package/src/styles.ts +36 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to enarratio-astro are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/). Before 1.0.0, a minor version may change the options;
|
|
6
|
+
every such change is listed here.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.4.1] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Draws with Enarratio 0.7.2: timeline text never renders under 12 px.
|
|
15
|
+
- Requires Node.js 22.22 or later (was 22.16), the floor Astro 7.4 sets.
|
|
16
|
+
|
|
17
|
+
## [0.4.0] - 2026-11-01
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Draws with Enarratio 0.7.0 (0.6.0's phone drawings and Play as sound, and 0.7.0's tactile SVG download and
|
|
22
|
+
forced-colors legend swatches).
|
|
23
|
+
|
|
24
|
+
### Release
|
|
25
|
+
|
|
26
|
+
- Staging works: the release job's setup-node moves from v4 to v7, whose v4 placeholder `NODE_AUTH_TOKEN` was sent
|
|
27
|
+
in place of the trusted-publishing token (E401 on 0.2.0, 0.2.1 and 0.3.0, so 0.1.0 is the newest on npm).
|
|
28
|
+
|
|
29
|
+
- Each release's `npm pack` tarball gets a GitHub artifact attestation beside npm's provenance:
|
|
30
|
+
`gh attestation verify enarratio-astro-<version>.tgz --repo DrDustinEdwards/astro` checks a download.
|
|
31
|
+
|
|
32
|
+
## [0.3.0] - 2026-10-31
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Draws with Enarratio 0.5.0: axis titles clear of the ticks, ticks thinned to fit, the y range rounded to the
|
|
37
|
+
drawn ticks, proportional tick figures, text-safe series ink in the Capsomer theme, and Download SVG and PNG
|
|
38
|
+
through `enarratio/enhance`.
|
|
39
|
+
- First release on npm since 0.1.0: 0.2.0 and 0.2.1 were tagged but never reached npm, because trusted
|
|
40
|
+
publishing was not yet set up.
|
|
41
|
+
|
|
42
|
+
## [0.2.1] - 2026-10-31
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Draws with Enarratio 0.4.1: capitalized summaries, ranges said with their unit, day ticks for
|
|
47
|
+
whole-day data, and linked bars whose links sit in the data table.
|
|
48
|
+
|
|
49
|
+
## [0.2.0] - 2026-10-31
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- Draws with Enarratio 0.4.0: a head with the title and the finding, a summary under the chart, a
|
|
54
|
+
CSV download, and wide charts that scroll sideways on a phone. The base styles now set system
|
|
55
|
+
colors under forced colors.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- `subtitle`, `notes`, `source`, `sourceHref`, `byline`, `xUnit` and `yUnit`, as props and as
|
|
60
|
+
Markdown keys.
|
|
61
|
+
|
|
62
|
+
## [0.1.0] - 2026-10-24
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- `<Chart>`: bar, line, stacked area, scatter and heatmap charts drawn on the server by Enarratio
|
|
67
|
+
0.3.1, from rows or from CSV, semicolon- or tab-separated text, with a required description, a
|
|
68
|
+
data table, and light and dark themes. The chart CSS is written once per page.
|
|
69
|
+
- Charts in Markdown: `chart` code blocks, with `satteriCharts()` for Sätteri and `remarkCharts`
|
|
70
|
+
for remark.
|
|
71
|
+
- `<Enhance />` and the `interactive` option: Enarratio's enhancement script, only on pages that
|
|
72
|
+
ask for it.
|
|
73
|
+
- `styles`: `"theme"` (default), `"base"` for sites whose tokens define the colors, or `"none"`.
|
|
74
|
+
- A note in place of a chart that cannot be drawn, saying why.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dustin Edwards
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
1
|
-
#
|
|
1
|
+
# enarratio-astro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Accessible charts for [Astro](https://astro.build): a `<Chart>` component and charts in Markdown,
|
|
4
|
+
drawn on the server by [Enarratio](https://github.com/DrDustinEdwards/enarratio).**
|
|
5
|
+
|
|
6
|
+
A chart arrives in the page as HTML and SVG: complete before any script runs, readable by screen
|
|
7
|
+
readers and search engines, with a data table, and in light and dark. It draws bar (stacked, side
|
|
8
|
+
by side, vertical or horizontal; counting rows when no value column is given), line, stacked area,
|
|
9
|
+
scatter (with an optional regression line and 95% band) and heatmap charts. A chart can be made
|
|
10
|
+
interactive (hover and focus details, keyboard navigation, click-to-filter) with Enarratio's small
|
|
11
|
+
enhancement script, which is added only to pages that ask for it.
|
|
12
|
+
|
|
13
|
+
Supports Astro 6 or later. Status: 0.x, so options may change before 1.0; every change is in the
|
|
14
|
+
[changelog](https://github.com/DrDustinEdwards/astro/blob/main/packages/enarratio-astro/CHANGELOG.md). For EmDash sites,
|
|
15
|
+
[enarratio-emdash](https://github.com/DrDustinEdwards/astro/tree/main/packages/enarratio-emdash#readme) adds the same charts as a block
|
|
16
|
+
in the editor.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install enarratio-astro
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## In a page
|
|
25
|
+
|
|
26
|
+
```astro
|
|
27
|
+
---
|
|
28
|
+
import Chart from "enarratio-astro/Chart.astro";
|
|
29
|
+
|
|
30
|
+
const downloads = [
|
|
31
|
+
{ year: 2024, package: "Core", downloads: 4100 },
|
|
32
|
+
{ year: 2025, package: "Core", downloads: 5300 },
|
|
33
|
+
{ year: 2026, package: "Core", downloads: 6100 },
|
|
34
|
+
{ year: 2024, package: "Plugin", downloads: 1200 },
|
|
35
|
+
{ year: 2025, package: "Plugin", downloads: 1900 },
|
|
36
|
+
{ year: 2026, package: "Plugin", downloads: 2800 },
|
|
37
|
+
];
|
|
38
|
+
---
|
|
39
|
+
<Chart
|
|
40
|
+
type="line"
|
|
41
|
+
data={downloads}
|
|
42
|
+
x="year"
|
|
43
|
+
y="downloads"
|
|
44
|
+
series="package"
|
|
45
|
+
title="Downloads per year"
|
|
46
|
+
alt="Downloads per year, 2024 to 2026: the core grows from 4,100 to 6,100, the plugin from 1,200 to 2,800."
|
|
47
|
+
interactive
|
|
48
|
+
/>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`data` is rows as objects (from code, JSON or a content collection; dates become ISO dates) or
|
|
52
|
+
text: CSV, semicolon-separated, or pasted from a spreadsheet (tab-separated), first row naming the
|
|
53
|
+
columns. The chart CSS is written once per page, however many charts it has.
|
|
54
|
+
|
|
55
|
+
## In Markdown
|
|
56
|
+
|
|
57
|
+
A code block whose language is `chart` becomes a chart: options, a line of three dashes, then the
|
|
58
|
+
data.
|
|
59
|
+
|
|
60
|
+
~~~md
|
|
61
|
+
```chart
|
|
62
|
+
type: bar
|
|
63
|
+
x: team
|
|
64
|
+
y: tickets
|
|
65
|
+
series: status
|
|
66
|
+
layout: grouped
|
|
67
|
+
alt: Support has the most open tickets; Platform has closed the most.
|
|
68
|
+
---
|
|
69
|
+
team,status,tickets
|
|
70
|
+
Support,Open,14
|
|
71
|
+
Support,Closed,22
|
|
72
|
+
Platform,Open,5
|
|
73
|
+
Platform,Closed,31
|
|
74
|
+
```
|
|
75
|
+
~~~
|
|
76
|
+
|
|
77
|
+
Register the plugin for Astro's Markdown processor. Astro 7's default, Sätteri:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
// astro.config.mjs
|
|
81
|
+
import { satteri } from "@astrojs/markdown-satteri";
|
|
82
|
+
import { satteriCharts } from "enarratio-astro/markdown";
|
|
83
|
+
|
|
84
|
+
export default defineConfig({
|
|
85
|
+
markdown: { processor: satteri({ mdastPlugins: [satteriCharts()] }) },
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
With remark (Astro 6, or Astro 7's unified processor):
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
import { remarkCharts } from "enarratio-astro/markdown";
|
|
93
|
+
|
|
94
|
+
export default defineConfig({ markdown: { remarkPlugins: [remarkCharts] } });
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A Markdown chart with `interactive: true` is marked for the enhancement script; include it once in
|
|
98
|
+
the page's layout with `<Enhance />` from `enarratio-astro/Enhance.astro`. It finds every
|
|
99
|
+
interactive chart on the page and works with view transitions.
|
|
100
|
+
|
|
101
|
+
## Options
|
|
102
|
+
|
|
103
|
+
The component's props and a Markdown block's keys are the same:
|
|
104
|
+
|
|
105
|
+
| Option | Used by | Notes |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `type` | all | `bar` (default), `line`, `area` (stacked), `scatter` or `heatmap`. |
|
|
108
|
+
| `data` | all | The data (component only; in Markdown it follows the dashes). Up to 5000 rows and 50 columns. |
|
|
109
|
+
| `alt` | all | **Required.** What the chart shows and finds, in a sentence. It names the chart for screen readers. |
|
|
110
|
+
| `title`, `subtitle` | all | Optional. Shown above the chart. |
|
|
111
|
+
| `caption`, `notes` | all | Optional. The caption, under the chart, is for method or caveats; notes are a smaller line for definitions. |
|
|
112
|
+
| `source`, `sourceHref` | all | Optional. Where the data came from, linked when `sourceHref` is set, beside a CSV download of the data. |
|
|
113
|
+
| `byline` | all | Optional. Who made the chart. |
|
|
114
|
+
| `x` | all | The column along the x axis. Column names match without regard to case. ISO dates make a time axis in line and area charts. |
|
|
115
|
+
| `y` | all | The value column. Bar charts count rows when it is missing. For a heatmap, the row column. |
|
|
116
|
+
| `series` | bar, line, area, scatter | Splits rows into series, told apart by color and by legend, labels or symbols. |
|
|
117
|
+
| `value` | heatmap | The cell value column. |
|
|
118
|
+
| `xLabel`, `yLabel` | all | Axis labels. |
|
|
119
|
+
| `xUnit`, `yUnit` | all | Optional. The unit, shown after the axis label (`Sales (units)`) and in the table and summary. |
|
|
120
|
+
| `layout` | bar | `stacked` (default) or `grouped`. |
|
|
121
|
+
| `orientation` | bar | `vertical` (default) or `horizontal`. |
|
|
122
|
+
| `scale` | line, scatter | `linear` (default) or `log`. |
|
|
123
|
+
| `regression` | scatter | A least-squares line with its 95% band. |
|
|
124
|
+
| `dataTable` | all | `details` (default; behind a "Data table" toggle) or `visually-hidden` (screen readers only). |
|
|
125
|
+
| `id` | all | The figure's id. |
|
|
126
|
+
| `interactive` | all | Adds the enhancement script. Off by default, so pages stay script-free. |
|
|
127
|
+
|
|
128
|
+
A chart that cannot be drawn (a column that is not in the data, text in a number column, a missing
|
|
129
|
+
`alt`) is replaced by a short note saying why, and the reason is logged, so one mistake never
|
|
130
|
+
breaks the page. All text is escaped.
|
|
131
|
+
|
|
132
|
+
## Styles
|
|
133
|
+
|
|
134
|
+
The component takes `styles`, `theme` and `stylesheetOptions`; the Markdown plugins take the same
|
|
135
|
+
as options (`satteriCharts({ styles: "base" })`).
|
|
136
|
+
|
|
137
|
+
- `styles: "theme"` (default): an Enarratio theme (`theme`, default Enarratio's own), in light and
|
|
138
|
+
dark. Charts follow the page's `color-scheme`; for a site that switches with an attribute, pass
|
|
139
|
+
`stylesheetOptions={{ colorScheme: { dark: '[data-theme="dark"]' } }}`.
|
|
140
|
+
- `styles: "base"`: layout rules without colors or fonts, for a site whose own design tokens define
|
|
141
|
+
the `--enarratio-*` properties. [Capsomer](https://github.com/DrDustinEdwards/capsomer)'s tokens do.
|
|
142
|
+
- `styles: "none"`: nothing; the site includes Enarratio's stylesheet itself.
|
|
143
|
+
|
|
144
|
+
Enarratio's [README](https://github.com/DrDustinEdwards/enarratio#themes) covers themes and the
|
|
145
|
+
custom properties.
|
|
146
|
+
|
|
147
|
+
## Rendering cost
|
|
148
|
+
|
|
149
|
+
Bar, line and area charts are drawn by `enarratio/lite`, which builds the SVG as a string with no
|
|
150
|
+
DOM, the cheapest render on Cloudflare Workers. Scatter plots and heatmaps load `enarratio/plot`
|
|
151
|
+
(Observable Plot and linkedom) only when a page has one.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
[MIT](LICENSE). Copyright (c) 2026 Dustin Edwards.
|
package/dist/chart.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** The chart types a {@link ChartSpec} can draw. */
|
|
2
|
+
export declare const CHART_TYPES: readonly ["bar", "line", "area", "scatter", "heatmap"];
|
|
3
|
+
export type ChartType = (typeof CHART_TYPES)[number];
|
|
4
|
+
/**
|
|
5
|
+
* One chart, as an author writes it: in a component's props, a Markdown `chart` block, or an
|
|
6
|
+
* EmDash Chart block. Every field is checked when the chart is drawn, so a spec can come from
|
|
7
|
+
* content as easily as from code.
|
|
8
|
+
*/
|
|
9
|
+
export interface ChartSpec {
|
|
10
|
+
/** Bar (default), line, area (stacked), scatter or heatmap. */
|
|
11
|
+
readonly type?: string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* The data: CSV, semicolon- or tab-separated text whose first row names the columns, or rows
|
|
14
|
+
* as objects (from JSON or a content collection) whose values are numbers, text, dates or empty.
|
|
15
|
+
*/
|
|
16
|
+
readonly data: string | readonly Readonly<Record<string, unknown>>[];
|
|
17
|
+
/** Required: what the chart shows and finds, in a sentence. Names the chart for screen readers. */
|
|
18
|
+
readonly alt?: string | undefined;
|
|
19
|
+
readonly title?: string | undefined;
|
|
20
|
+
/** A line under the title saying what the chart shows or how to read it. */
|
|
21
|
+
readonly subtitle?: string | undefined;
|
|
22
|
+
/** Method or caveats, below the chart. */
|
|
23
|
+
readonly caption?: string | undefined;
|
|
24
|
+
/** Notes under the chart: what a measure means, what was left out. */
|
|
25
|
+
readonly notes?: string | undefined;
|
|
26
|
+
/** Where the data come from, in the source line beside the CSV download. */
|
|
27
|
+
readonly source?: string | undefined;
|
|
28
|
+
/** A link for the source (https). */
|
|
29
|
+
readonly sourceHref?: string | undefined;
|
|
30
|
+
/** Who made the chart, in the source line. */
|
|
31
|
+
readonly byline?: string | undefined;
|
|
32
|
+
/** The column along the x axis. Column names match without regard to case. */
|
|
33
|
+
readonly x?: string | undefined;
|
|
34
|
+
/** The value column. Bar charts count rows without one; for a heatmap, the row column. */
|
|
35
|
+
readonly y?: string | undefined;
|
|
36
|
+
/** A column that splits rows into series. */
|
|
37
|
+
readonly series?: string | undefined;
|
|
38
|
+
/** Heatmap: the cell value column. */
|
|
39
|
+
readonly value?: string | undefined;
|
|
40
|
+
readonly xLabel?: string | undefined;
|
|
41
|
+
readonly yLabel?: string | undefined;
|
|
42
|
+
/** Units added to the axis labels and table headers: "Weight (kg)". */
|
|
43
|
+
readonly xUnit?: string | undefined;
|
|
44
|
+
readonly yUnit?: string | undefined;
|
|
45
|
+
/** Bar: series stacked (default) or side by side ("grouped"). */
|
|
46
|
+
readonly layout?: string | undefined;
|
|
47
|
+
/** Bar: "vertical" (default) or "horizontal". */
|
|
48
|
+
readonly orientation?: string | undefined;
|
|
49
|
+
/** Line and scatter: "linear" (default) or "log" y axis. */
|
|
50
|
+
readonly scale?: string | undefined;
|
|
51
|
+
/** Scatter: a least-squares line with its 95% band. */
|
|
52
|
+
readonly regression?: boolean | undefined;
|
|
53
|
+
/** "details" (default): the data table behind a toggle; "visually-hidden": for screen readers only. */
|
|
54
|
+
readonly dataTable?: string | undefined;
|
|
55
|
+
/** The figure's id, reported as `chartId` in the enhancement layer's events. */
|
|
56
|
+
readonly id?: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
/** A drawn chart's HTML, or why it could not be drawn, in words an author can act on. */
|
|
59
|
+
export type ChartResult = {
|
|
60
|
+
readonly ok: true;
|
|
61
|
+
readonly html: string;
|
|
62
|
+
} | {
|
|
63
|
+
readonly ok: false;
|
|
64
|
+
readonly error: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Draws a chart. Bar, line and area charts come from `enarratio/lite`, which builds the SVG as a
|
|
68
|
+
* string with no DOM, the cheapest render on a Worker; scatter plots and heatmaps load
|
|
69
|
+
* `enarratio/plot` (Observable Plot and linkedom) only when a page has one. Never throws: a chart
|
|
70
|
+
* that cannot be drawn returns the reason instead, so one mistake cannot break a page.
|
|
71
|
+
*/
|
|
72
|
+
export declare function renderChart(spec: ChartSpec): Promise<ChartResult>;
|
package/dist/chart.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { areaChart, barChart, lineChart } from "enarratio/lite";
|
|
2
|
+
import { column, parseTable, tableFromRows } from "./data.js";
|
|
3
|
+
/** The chart types a {@link ChartSpec} can draw. */
|
|
4
|
+
export const CHART_TYPES = ["bar", "line", "area", "scatter", "heatmap"];
|
|
5
|
+
function text(value) {
|
|
6
|
+
const trimmed = value?.trim();
|
|
7
|
+
return trimmed ? trimmed : undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Copies the rows with one column's numbers turned into text, for fields Enarratio reads as names. */
|
|
10
|
+
function asNames(table, name) {
|
|
11
|
+
return table.rows.map((row) => {
|
|
12
|
+
const copy = Object.assign(Object.create(null), row);
|
|
13
|
+
const v = copy[name];
|
|
14
|
+
if (typeof v === "number")
|
|
15
|
+
copy[name] = String(v);
|
|
16
|
+
return copy;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Checks that a column holds only numbers and empty cells. */
|
|
20
|
+
function numeric(table, name) {
|
|
21
|
+
const bad = table.rows.find((row) => typeof row[name] === "string");
|
|
22
|
+
if (bad)
|
|
23
|
+
throw new Error(`The column "${name}" should hold numbers, but has "${bad[name]}".`);
|
|
24
|
+
}
|
|
25
|
+
/** The options every chart type shares: text, the data table and the id. */
|
|
26
|
+
function figure(spec) {
|
|
27
|
+
const alt = text(spec.alt);
|
|
28
|
+
if (!alt)
|
|
29
|
+
throw new Error("Add a description (alt): one sentence on what the chart shows.");
|
|
30
|
+
const title = text(spec.title);
|
|
31
|
+
const source = text(spec.source);
|
|
32
|
+
const sourceHref = text(spec.sourceHref);
|
|
33
|
+
if (sourceHref && !source)
|
|
34
|
+
throw new Error("Name the source (source) that the link (sourceHref) goes to.");
|
|
35
|
+
const id = text(spec.id)?.replace(/[^A-Za-z0-9_-]/g, "");
|
|
36
|
+
const optional = Object.fromEntries(["subtitle", "caption", "notes", "byline", "xLabel", "yLabel", "xUnit", "yUnit"]
|
|
37
|
+
.map((key) => [key, text(spec[key])])
|
|
38
|
+
.filter(([, value]) => value !== undefined));
|
|
39
|
+
return {
|
|
40
|
+
alt,
|
|
41
|
+
...(title ? { title } : {}),
|
|
42
|
+
...optional,
|
|
43
|
+
...(source ? { source: sourceHref ? { name: source, href: sourceHref } : source } : {}),
|
|
44
|
+
...(id ? { id } : {}),
|
|
45
|
+
dataTable: spec.dataTable === "visually-hidden" ? "visually-hidden" : "details",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function draw(spec) {
|
|
49
|
+
const type = (text(spec.type) ?? "bar").toLowerCase();
|
|
50
|
+
if (!CHART_TYPES.includes(type)) {
|
|
51
|
+
throw new Error(`There is no chart type "${spec.type}". Use one of: ${CHART_TYPES.join(", ")}.`);
|
|
52
|
+
}
|
|
53
|
+
const common = figure(spec);
|
|
54
|
+
const table = typeof spec.data === "string" ? parseTable(spec.data) : tableFromRows(spec.data);
|
|
55
|
+
const x = column(table, spec.x, "x axis");
|
|
56
|
+
const seriesName = text(spec.series) ? column(table, spec.series, "series") : undefined;
|
|
57
|
+
const data = seriesName ? asNames(table, seriesName) : [...table.rows];
|
|
58
|
+
const series = seriesName ? { series: seriesName } : {};
|
|
59
|
+
const log = spec.scale === "log" ? { yType: "log" } : {};
|
|
60
|
+
switch (type) {
|
|
61
|
+
case "bar": {
|
|
62
|
+
const y = text(spec.y) ? column(table, spec.y, "values") : undefined;
|
|
63
|
+
if (y)
|
|
64
|
+
numeric(table, y);
|
|
65
|
+
return barChart({
|
|
66
|
+
...common,
|
|
67
|
+
data,
|
|
68
|
+
x,
|
|
69
|
+
...(y ? { y } : {}),
|
|
70
|
+
...series,
|
|
71
|
+
layout: spec.layout === "grouped" ? "grouped" : "stacked",
|
|
72
|
+
orientation: spec.orientation === "horizontal" ? "horizontal" : "vertical",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
case "line":
|
|
76
|
+
case "area": {
|
|
77
|
+
const y = column(table, spec.y, "values");
|
|
78
|
+
numeric(table, y);
|
|
79
|
+
const options = { ...common, data, x, y, ...series };
|
|
80
|
+
return type === "line" ? lineChart({ ...options, ...log }) : areaChart(options);
|
|
81
|
+
}
|
|
82
|
+
case "scatter": {
|
|
83
|
+
const y = column(table, spec.y, "values");
|
|
84
|
+
numeric(table, x);
|
|
85
|
+
numeric(table, y);
|
|
86
|
+
const { scatterPlot } = await import("enarratio/plot");
|
|
87
|
+
return scatterPlot({
|
|
88
|
+
...common,
|
|
89
|
+
data,
|
|
90
|
+
x,
|
|
91
|
+
y,
|
|
92
|
+
...series,
|
|
93
|
+
...log,
|
|
94
|
+
regression: spec.regression === true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
case "heatmap": {
|
|
98
|
+
const y = column(table, spec.y, "rows");
|
|
99
|
+
const value = column(table, spec.value, "cell values");
|
|
100
|
+
numeric(table, value);
|
|
101
|
+
const { heatmap } = await import("enarratio/plot");
|
|
102
|
+
return heatmap({ ...common, data, x, y, value });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Draws a chart. Bar, line and area charts come from `enarratio/lite`, which builds the SVG as a
|
|
108
|
+
* string with no DOM, the cheapest render on a Worker; scatter plots and heatmaps load
|
|
109
|
+
* `enarratio/plot` (Observable Plot and linkedom) only when a page has one. Never throws: a chart
|
|
110
|
+
* that cannot be drawn returns the reason instead, so one mistake cannot break a page.
|
|
111
|
+
*/
|
|
112
|
+
export async function renderChart(spec) {
|
|
113
|
+
try {
|
|
114
|
+
return { ok: true, html: await draw(spec) };
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/data.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A cell after parsing: a number, text, or `null` for an empty cell. */
|
|
2
|
+
export type Cell = number | string | null;
|
|
3
|
+
/** A table parsed from the block's data field. */
|
|
4
|
+
export interface Table {
|
|
5
|
+
readonly columns: readonly string[];
|
|
6
|
+
readonly rows: ReadonlyArray<Readonly<Record<string, Cell>>>;
|
|
7
|
+
}
|
|
8
|
+
/** Limits that keep one chart's render bounded, whatever an editor pastes. */
|
|
9
|
+
export declare const MAX_ROWS = 5000;
|
|
10
|
+
export declare const MAX_COLUMNS = 50;
|
|
11
|
+
/**
|
|
12
|
+
* Parses the data an editor pasted or typed: CSV, semicolon-separated or tab-separated text
|
|
13
|
+
* whose first row names the columns. Throws an Error whose message an editor can act on.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseTable(text: string): Table;
|
|
16
|
+
/**
|
|
17
|
+
* The column an editor named, matched without regard to case or surrounding space. Throws when
|
|
18
|
+
* there is none, listing the columns there are.
|
|
19
|
+
*/
|
|
20
|
+
export declare function column(table: Table, name: string | undefined, role: string): string;
|
|
21
|
+
/** Reads rows given as objects (from JSON or a content collection) into a table. */
|
|
22
|
+
export declare function tableFromRows(input: readonly Readonly<Record<string, unknown>>[]): Table;
|
package/dist/data.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** Limits that keep one chart's render bounded, whatever an editor pastes. */
|
|
2
|
+
export const MAX_ROWS = 5000;
|
|
3
|
+
export const MAX_COLUMNS = 50;
|
|
4
|
+
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
5
|
+
const GROUPED_NUMBER = /^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/;
|
|
6
|
+
/** Reads a cell: numbers (including "1,234") become numbers, an empty cell `null`, the rest text. */
|
|
7
|
+
function cell(raw) {
|
|
8
|
+
const text = raw.trim();
|
|
9
|
+
if (text === "")
|
|
10
|
+
return null;
|
|
11
|
+
if (NUMBER.test(text))
|
|
12
|
+
return Number(text);
|
|
13
|
+
if (GROUPED_NUMBER.test(text))
|
|
14
|
+
return Number(text.replaceAll(",", ""));
|
|
15
|
+
return text;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The delimiter of pasted data: a tab when the header row has one (a spreadsheet paste),
|
|
19
|
+
* otherwise whichever of comma and semicolon appears more often outside quotes in that row.
|
|
20
|
+
*/
|
|
21
|
+
function delimiter(text) {
|
|
22
|
+
const header = text.slice(0, text.search(/\r?\n|$/));
|
|
23
|
+
if (header.includes("\t"))
|
|
24
|
+
return "\t";
|
|
25
|
+
const unquoted = header.replace(/"[^"]*"/g, "");
|
|
26
|
+
const count = (c) => unquoted.split(c).length - 1;
|
|
27
|
+
return count(";") > count(",") ? ";" : ",";
|
|
28
|
+
}
|
|
29
|
+
/** Splits delimited text into records, following RFC 4180 quoting ("" is a literal quote). */
|
|
30
|
+
function records(text, sep) {
|
|
31
|
+
const out = [];
|
|
32
|
+
let record = [];
|
|
33
|
+
let field = "";
|
|
34
|
+
let quoted = false;
|
|
35
|
+
for (let i = 0; i < text.length; i++) {
|
|
36
|
+
const c = text[i];
|
|
37
|
+
if (quoted) {
|
|
38
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
39
|
+
field += '"';
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
else if (c === '"') {
|
|
43
|
+
quoted = false;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
field += c;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (c === '"' && field.trim() === "") {
|
|
50
|
+
quoted = true;
|
|
51
|
+
field = "";
|
|
52
|
+
}
|
|
53
|
+
else if (c === sep) {
|
|
54
|
+
record.push(field);
|
|
55
|
+
field = "";
|
|
56
|
+
}
|
|
57
|
+
else if (c === "\n" || c === "\r") {
|
|
58
|
+
if (c === "\r" && text[i + 1] === "\n")
|
|
59
|
+
i++;
|
|
60
|
+
record.push(field);
|
|
61
|
+
out.push(record);
|
|
62
|
+
record = [];
|
|
63
|
+
field = "";
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
field += c;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (quoted)
|
|
70
|
+
throw new Error("The data has a quote that is never closed.");
|
|
71
|
+
record.push(field);
|
|
72
|
+
out.push(record);
|
|
73
|
+
return out.filter((r) => r.some((f) => f.trim() !== ""));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Parses the data an editor pasted or typed: CSV, semicolon-separated or tab-separated text
|
|
77
|
+
* whose first row names the columns. Throws an Error whose message an editor can act on.
|
|
78
|
+
*/
|
|
79
|
+
export function parseTable(text) {
|
|
80
|
+
const all = records(text, delimiter(text));
|
|
81
|
+
const [header, ...body] = all;
|
|
82
|
+
if (!header || body.length === 0) {
|
|
83
|
+
throw new Error("Add data: a first row naming the columns, then at least one row of values.");
|
|
84
|
+
}
|
|
85
|
+
const columns = header.map((name) => name.trim());
|
|
86
|
+
if (columns.length > MAX_COLUMNS)
|
|
87
|
+
throw new Error(`The data has more than ${MAX_COLUMNS} columns.`);
|
|
88
|
+
if (body.length > MAX_ROWS)
|
|
89
|
+
throw new Error(`The data has more than ${MAX_ROWS} rows.`);
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const name of columns) {
|
|
92
|
+
if (name === "")
|
|
93
|
+
throw new Error("Every column in the first row needs a name.");
|
|
94
|
+
const key = name.toLowerCase();
|
|
95
|
+
if (seen.has(key))
|
|
96
|
+
throw new Error(`Two columns are named "${name}".`);
|
|
97
|
+
seen.add(key);
|
|
98
|
+
}
|
|
99
|
+
const rows = body.map((record, n) => {
|
|
100
|
+
if (record.slice(columns.length).some((f) => f.trim() !== "")) {
|
|
101
|
+
throw new Error(`Row ${n + 2} has more values than there are columns.`);
|
|
102
|
+
}
|
|
103
|
+
// No prototype, so a column named "__proto__" is an ordinary field.
|
|
104
|
+
const row = Object.create(null);
|
|
105
|
+
columns.forEach((name, i) => {
|
|
106
|
+
row[name] = cell(record[i] ?? "");
|
|
107
|
+
});
|
|
108
|
+
return row;
|
|
109
|
+
});
|
|
110
|
+
return { columns, rows };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The column an editor named, matched without regard to case or surrounding space. Throws when
|
|
114
|
+
* there is none, listing the columns there are.
|
|
115
|
+
*/
|
|
116
|
+
export function column(table, name, role) {
|
|
117
|
+
const wanted = name?.trim().toLowerCase() ?? "";
|
|
118
|
+
if (wanted === "")
|
|
119
|
+
throw new Error(`Name the ${role} column.`);
|
|
120
|
+
const found = table.columns.find((c) => c.toLowerCase() === wanted);
|
|
121
|
+
if (!found) {
|
|
122
|
+
throw new Error(`There is no column "${name?.trim()}" for the ${role}. The columns are: ${table.columns.join(", ")}.`);
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
/** Reads one value from a row given as an object: dates as ISO text, true and false as text. */
|
|
127
|
+
function value(raw) {
|
|
128
|
+
if (raw === null || raw === undefined || raw === "")
|
|
129
|
+
return null;
|
|
130
|
+
if (typeof raw === "number")
|
|
131
|
+
return Number.isFinite(raw) ? raw : null;
|
|
132
|
+
if (typeof raw === "string")
|
|
133
|
+
return raw;
|
|
134
|
+
if (raw instanceof Date)
|
|
135
|
+
return raw.toISOString();
|
|
136
|
+
if (typeof raw === "boolean")
|
|
137
|
+
return String(raw);
|
|
138
|
+
throw new Error(`A value in the data is ${Array.isArray(raw) ? "a list" : "an object"}; use numbers or text.`);
|
|
139
|
+
}
|
|
140
|
+
/** Reads rows given as objects (from JSON or a content collection) into a table. */
|
|
141
|
+
export function tableFromRows(input) {
|
|
142
|
+
if (input.length === 0)
|
|
143
|
+
throw new Error("Add data: at least one row.");
|
|
144
|
+
if (input.length > MAX_ROWS)
|
|
145
|
+
throw new Error(`The data has more than ${MAX_ROWS} rows.`);
|
|
146
|
+
const columns = [];
|
|
147
|
+
for (const row of input) {
|
|
148
|
+
for (const key of Object.keys(row))
|
|
149
|
+
if (!columns.includes(key))
|
|
150
|
+
columns.push(key);
|
|
151
|
+
}
|
|
152
|
+
if (columns.length > MAX_COLUMNS)
|
|
153
|
+
throw new Error(`The data has more than ${MAX_COLUMNS} columns.`);
|
|
154
|
+
const rows = input.map((source) => {
|
|
155
|
+
const row = Object.create(null);
|
|
156
|
+
for (const name of columns)
|
|
157
|
+
row[name] = value(source[name]);
|
|
158
|
+
return row;
|
|
159
|
+
});
|
|
160
|
+
return { columns, rows };
|
|
161
|
+
}
|
package/dist/figure.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ChartResult } from "./chart.js";
|
|
2
|
+
/**
|
|
3
|
+
* The markup that goes on the page for a drawn chart, or for one that could not be drawn: the
|
|
4
|
+
* chart in a wrapper (marked when it is interactive), or a note saying why.
|
|
5
|
+
*/
|
|
6
|
+
export declare function chartMarkup(result: ChartResult, interactive: boolean): string;
|
package/dist/figure.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { escapeHtml } from "enarratio";
|
|
2
|
+
/**
|
|
3
|
+
* The markup that goes on the page for a drawn chart, or for one that could not be drawn: the
|
|
4
|
+
* chart in a wrapper (marked when it is interactive), or a note saying why.
|
|
5
|
+
*/
|
|
6
|
+
export function chartMarkup(result, interactive) {
|
|
7
|
+
if (!result.ok) {
|
|
8
|
+
return `<p class="enarratio-chart-error" role="note">This chart could not be drawn. ${escapeHtml(result.error)}</p>`;
|
|
9
|
+
}
|
|
10
|
+
const mark = interactive ? " data-enarratio-interactive-chart" : "";
|
|
11
|
+
return `<div class="enarratio-chart"${mark}>${result.html}</div>`;
|
|
12
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enarratio charts for Astro: a `<Chart>` component (`enarratio-astro/Chart.astro`), charts in
|
|
3
|
+
* Markdown (`enarratio-astro/markdown`), and the functions under both, drawn on the server by
|
|
4
|
+
* Enarratio as accessible HTML and SVG.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
export { CHART_TYPES, type ChartResult, type ChartSpec, type ChartType, renderChart, } from "./chart.js";
|
|
9
|
+
export { type Cell, parseTable, type Table, tableFromRows } from "./data.js";
|
|
10
|
+
export { chartMarkup } from "./figure.js";
|
|
11
|
+
export { chartStyles, type StyleOptions, type Styles } from "./styles.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enarratio charts for Astro: a `<Chart>` component (`enarratio-astro/Chart.astro`), charts in
|
|
3
|
+
* Markdown (`enarratio-astro/markdown`), and the functions under both, drawn on the server by
|
|
4
|
+
* Enarratio as accessible HTML and SVG.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
export { CHART_TYPES, renderChart, } from "./chart.js";
|
|
9
|
+
export { parseTable, tableFromRows } from "./data.js";
|
|
10
|
+
export { chartMarkup } from "./figure.js";
|
|
11
|
+
export { chartStyles } from "./styles.js";
|