plotagents 0.0.0-stage → 0.5.0

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.
Files changed (60) hide show
  1. package/AGENTS.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -2
  4. package/bin/plotagents.js +49 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +7954 -0
  7. package/docs/api.md +154 -0
  8. package/docs/charts.md +218 -0
  9. package/docs/getting-started.md +138 -0
  10. package/docs/rendering.md +47 -0
  11. package/docs/themes.md +86 -0
  12. package/index.d.ts +213 -0
  13. package/package.json +74 -4
  14. package/schema.json +895 -0
  15. package/src/axes.js +144 -0
  16. package/src/charts/bar.js +339 -0
  17. package/src/charts/categorical.js +471 -0
  18. package/src/charts/chord.js +218 -0
  19. package/src/charts/choropleth.js +559 -0
  20. package/src/charts/dist.js +513 -0
  21. package/src/charts/donut.js +232 -0
  22. package/src/charts/flowchart.js +407 -0
  23. package/src/charts/heatmap.js +76 -0
  24. package/src/charts/kpi.js +97 -0
  25. package/src/charts/line.js +419 -0
  26. package/src/charts/network.js +344 -0
  27. package/src/charts/pyramid.js +177 -0
  28. package/src/charts/radar.js +152 -0
  29. package/src/charts/regplot.js +364 -0
  30. package/src/charts/sankey.js +133 -0
  31. package/src/charts/scatter.js +136 -0
  32. package/src/charts/sunburst.js +135 -0
  33. package/src/charts/treemap.js +194 -0
  34. package/src/context.js +10 -0
  35. package/src/dashed.js +90 -0
  36. package/src/data.js +137 -0
  37. package/src/facet.js +84 -0
  38. package/src/format.js +171 -0
  39. package/src/frame.js +157 -0
  40. package/src/geo/chile.js +7 -0
  41. package/src/geo/data/chile-regiones.js +12 -0
  42. package/src/geo/data/usa-estados.js +12 -0
  43. package/src/geo/data/world.js +12 -0
  44. package/src/geo/projection.js +152 -0
  45. package/src/geo/registry.js +31 -0
  46. package/src/geo/usa.js +8 -0
  47. package/src/geo/world.js +7 -0
  48. package/src/hierarchy.js +139 -0
  49. package/src/index.js +271 -0
  50. package/src/infer.js +49 -0
  51. package/src/interact.js +362 -0
  52. package/src/lint.js +137 -0
  53. package/src/palette.js +59 -0
  54. package/src/runtime.js +304 -0
  55. package/src/scale.js +171 -0
  56. package/src/schema.js +181 -0
  57. package/src/stats.js +246 -0
  58. package/src/svg.js +85 -0
  59. package/src/text.js +61 -0
  60. package/src/theme.js +224 -0
package/src/schema.js ADDED
@@ -0,0 +1,181 @@
1
+ // JSON Schema of the spec: use it to declare plotagents as an agent tool, or
2
+ // to validate an LLM's structured output. Descriptions are in English because
3
+ // agents read them; chart text follows `locale`.
4
+ import { TYPES, ALIASES } from './infer.js';
5
+
6
+ const field = (d) => ({ type: 'string', description: d });
7
+ const fieldOrList = (d) => ({ anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: d });
8
+ const strOrList = { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] };
9
+
10
+ export const schema = {
11
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
12
+ $id: 'https://unpkg.com/plotagents/schema.json',
13
+ title: 'plotagents spec',
14
+ description:
15
+ 'Declarative chart spec. Data as rows (tidy) plus the field name for each role. Everything else has well-designed defaults.',
16
+ type: 'object',
17
+ required: ['data'],
18
+ properties: {
19
+ type: {
20
+ type: 'string',
21
+ enum: ['auto', ...TYPES, ...Object.keys(ALIASES)],
22
+ description:
23
+ 'Compare: bar, count. Time: line, area. Relationships: scatter, bubble, regplot, residplot, jointplot, radar. Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi. seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
24
+ },
25
+ data: {
26
+ description: 'Array of rows, e.g. [{ "region": "Biobío", "sales": 1240 }]. For kpi it can be omitted in favor of `items`.',
27
+ anyOf: [{ type: 'array', items: { type: 'object' } }, { type: 'object' }],
28
+ },
29
+ title: { type: 'string', description: 'The finding as a full sentence, ideally with the key number, e.g. "Biobío accounts for 32% of sales".' },
30
+ subtitle: { type: 'string', description: 'How to read the chart: measure, unit and period, e.g. "Sales by region, CLP millions, 2025".' },
31
+ source: { type: 'string', description: 'Data source, shown at the bottom right.' },
32
+ locale: { type: 'string', enum: ['es', 'en'], description: 'Language and number format. Default "es" (1.234,5); use "en" for 1,234.5.' },
33
+ x: field('x-axis or category field (bar, line, area, scatter, heatmap, box, violin…).'),
34
+ y: fieldOrList('Value field. For line/bar a list of fields (wide table) turns each one into a series.'),
35
+ series: field('Field that splits the data into colored series. Alias: hue.'),
36
+ hue: field('Alias of series (seaborn naming).'),
37
+ value: field('Value field for treemap, sunburst, donut, heatmap, sankey and radar.'),
38
+ label: field('Name field (treemap, donut, point labels in scatter/regplot).'),
39
+ group: field('Level-1 group field for a two-level treemap (shortcut for path).'),
40
+ path: { type: 'array', items: { type: 'string' }, description: 'Hierarchy for treemap/sunburst, any depth: [level1, level2, …].' },
41
+ size: field('Bubble size field (area-proportional).'),
42
+ from: field('Sankey source field.'),
43
+ to: field('Sankey target field.'),
44
+ axis: field('radar: field with the axis names (long format).'),
45
+ axes: { type: 'array', items: { type: 'string' }, description: 'radar: fields to use as axes (wide format, one row per series).' },
46
+ items: {
47
+ type: 'array',
48
+ description: 'kpi: the indicators to show.',
49
+ items: {
50
+ type: 'object',
51
+ required: ['label', 'value'],
52
+ properties: {
53
+ label: { type: 'string' },
54
+ value: { type: 'number' },
55
+ delta: { type: 'number', description: 'Relative change as a fraction (0.12 = +12%).' },
56
+ previous: { type: 'number', description: 'Previous value; when given, the change is computed.' },
57
+ period: { type: 'string', description: 'What it is compared against, e.g. "vs 2025".' },
58
+ good: { type: 'string', enum: ['up', 'down'], description: 'Whether going up is good (up) or bad (down, e.g. costs).' },
59
+ trend: { type: 'array', items: { type: 'number' }, description: 'Short series for the sparkline.' },
60
+ target: { type: 'number', description: 'Target: draws a progress meter.' },
61
+ format: { type: 'string' },
62
+ },
63
+ },
64
+ },
65
+
66
+ // Maps, networks, flows
67
+ geo: { description: 'choropleth/symbolmap: "chile" (16 regions, default), "usa" (50 states + DC, Albers with Alaska/Hawaii insets; Puerto Rico when it has data), "world" (countries), a name registered with registerGeo(), or a GeoJSON FeatureCollection.', anyOf: [{ type: 'string' }, { type: 'object' }] },
68
+ key: field('choropleth/symbolmap: data field with the region, state or country name/code to match (US: "TX", "Texas", "06").'),
69
+ projection: { type: 'string', enum: ['mercator', 'equalEarth', 'albersUsa'], description: 'choropleth/symbolmap: map projection (picked automatically).' },
70
+ values: { type: 'boolean', description: 'choropleth with state codes (usa): false shows only the codes, without values.' },
71
+ featureKey: field('choropleth: feature property to match against (default tries code, id, ISO-3, name and aliases).'),
72
+ midpoint: { type: 'number', description: 'choropleth/heatmap: diverging color scale around this value (e.g. 0 for changes).' },
73
+ classes: { type: 'integer', description: 'choropleth: number of color classes (default 5).' },
74
+ nodes: { type: 'array', items: { type: 'object' }, description: 'network: node rows (id, group, size). flowchart: steps (id, label, type: start|end|process|decision|data|note, lane).' },
75
+ edges: { type: 'array', items: { type: 'object' }, description: 'flowchart: connections (from, to, label). Same as `data`.' },
76
+ layout: { type: 'string', enum: ['force', 'circle'], description: 'network layout.' },
77
+ matrix: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'chord: square flow matrix (with `names`).' },
78
+ names: { type: 'array', items: { type: 'string' }, description: 'chord: group names for `matrix`.' },
79
+ directed: { type: 'boolean', description: 'chord: treat flows as directed (default false).' },
80
+ direction: { type: 'string', enum: ['TB', 'LR'], description: 'flowchart: top-to-bottom or left-to-right (automatic by default).' },
81
+ age: field('pyramid: age-group field.'),
82
+ sex: field('pyramid: field with exactly two sides (e.g. sex).'),
83
+ percent: { type: 'boolean', description: 'pyramid: show shares of the total population.' },
84
+
85
+ // Layout and interaction
86
+ facet: field('One panel per value of this field (small multiples, seaborn col). Shared scales and colors.'),
87
+ columns: { type: 'integer', description: 'facet: panels per row (seaborn col_wrap). Reduced automatically on narrow screens.' },
88
+ sharey: { type: 'boolean', description: 'facet: same y scale in every panel (default true).' },
89
+ time: field('Time field (d3plus-style): shows one period at a time (the latest by default) and, in the browser, a slider with play. Not used by line/area, which get a range slider.'),
90
+ timeValue: { description: 'Period to show when `time` is set.' },
91
+ slider: { type: 'boolean', description: 'Browser: false removes the time slider (the chart shows `timeValue` or the latest period).' },
92
+ play: { type: 'boolean', description: 'Browser: false keeps the time slider but hides the play button.' },
93
+ compare: { description: 'With `time`: period to compute changes against: "previous" (default), "first", a specific period, or false.', anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] },
94
+ colorBy: { type: 'string', enum: ['group', 'change', 'label'], description: 'treemap: "change" colors tiles by % change vs the comparison period (diverging, growth cool / decline warm); "label" colors a single-level treemap by item.' },
95
+ shade: { type: 'boolean', description: 'treemap/sunburst: subtle lightness steps inside each category (default false: one solid color per category).' },
96
+ xRange: { type: 'array', description: 'line/area: visible x range [from, to] (what the range slider sets).' },
97
+ focus: { description: 'treemap/sunburst: path to drill into, e.g. ["Mining"]. network: a node id to center and highlight with its neighbors. In the browser, clicking does it.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
98
+ depth: { type: 'integer', description: 'treemap/sunburst: how many levels to show.' },
99
+
100
+ // Scales and axes
101
+ xScale: { type: 'string', enum: ['linear', 'log'] },
102
+ yScale: { type: 'string', enum: ['linear', 'log'] },
103
+ xDomain: { type: 'array', items: { type: 'number' }, description: 'Fixed x domain [min, max].' },
104
+ yDomain: { type: 'array', items: { type: 'number' }, description: 'Fixed value-axis domain [min, max].' },
105
+ zero: { type: 'boolean', description: 'line: force (or prevent) a y axis starting at zero.' },
106
+ xLabel: { type: 'string', description: 'x-axis title.' },
107
+ yLabel: { type: 'string', description: 'y-axis title (drawn horizontally above the axis).' },
108
+ format: {
109
+ description: 'Value format: "number" | "compact" | "percent" | "currency" | "integer", or { type, prefix, suffix, decimals, currency }.',
110
+ anyOf: [
111
+ { type: 'string', enum: ['number', 'compact', 'percent', 'currency', 'integer'] },
112
+ { type: 'object', properties: { type: { type: 'string' }, prefix: { type: 'string' }, suffix: { type: 'string' }, decimals: { type: 'integer' }, currency: { type: 'string' } } },
113
+ ],
114
+ },
115
+
116
+ // Emphasis and annotation
117
+ highlight: { description: 'Emphasize one or more categories/series and mute the rest.', ...strOrList },
118
+ dashed: { description: 'Draw some shapes dashed (hatched fill + dashed outline; dashed stroke for lines) to mark estimates, projections or provisional figures. A category/series/region name or a list of them, a data field whose truthy rows are dashed, { field, equals }, or true for all.', anyOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'array', items: { type: 'string' } }, { type: 'object', properties: { field: { type: 'string' }, equals: {} } }] },
119
+ dashedFrom: { description: 'line/area/bar on a time axis: x value from which marks are dashed (a projection). Lines turn dashed from the last firm point, over a soft labeled band.', anyOf: [{ type: 'string' }, { type: 'number' }] },
120
+ dashedLabel: { description: 'What the dashed shapes mean ("Projection", "Preliminary"): legend entry, band label and tooltip line. false hides the band label.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
121
+ labels: { description: 'Value labels: true, false or "auto". In scatter/regplot: a number of points or a list of names to label.', anyOf: [{ type: 'boolean' }, { type: 'string' }, { type: 'number' }, { type: 'array', items: { type: 'string' } }] },
122
+ refLines: { type: 'array', description: 'Reference lines (targets, averages).', items: { type: 'object', properties: { y: { type: 'number' }, x: { type: 'number' }, value: { type: 'number' }, label: { type: 'string' } } } },
123
+ annotations: { type: 'array', description: 'line/area: events on the x axis.', items: { type: 'object', properties: { x: {}, label: { type: 'string' } } } },
124
+
125
+ // Bars and lines
126
+ stack: { description: 'bar/area: true stacks; "percent" stacks to 100%.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['percent'] }] },
127
+ horizontal: { type: 'boolean', description: 'Force horizontal orientation (otherwise chosen from label length and count).' },
128
+ sort: { type: 'string', enum: ['desc', 'asc', 'none', 'alpha'], description: 'bar: category order (desc by default unless the categories are time).' },
129
+ top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other".' },
130
+ index: { type: 'boolean', description: 'line: rebase each series to 100 at its first value (compare growth without a dual axis).' },
131
+ curve: { type: 'string', enum: ['linear', 'smooth', 'step'] },
132
+ aggregate: { type: 'string', enum: ['sum', 'mean', 'max', 'min', 'count', 'last', 'first'], description: 'How to combine repeated rows. Default sum.' },
133
+ ci: { description: 'line/bar/regplot: confidence interval (true = 95, or a number). In line/bar it switches the estimator to the mean.', anyOf: [{ type: 'boolean' }, { type: 'number' }] },
134
+ errorbar: { type: 'string', enum: ['ci', 'sd', 'se', 'pi', 'none'], description: 'Error bar or band type.' },
135
+ estimator: { type: 'string', enum: ['mean', 'median', 'sum', 'count'], description: 'How to summarize several observations per category.' },
136
+
137
+ // Statistics (seaborn)
138
+ bins: { type: 'integer', description: 'hist: number of bins.' },
139
+ binWidth: { type: 'number', description: 'hist: bin width.' },
140
+ stat: { type: 'string', enum: ['count', 'percent', 'density', 'probability'], description: 'hist: what the y axis measures.' },
141
+ kde: { type: 'boolean', description: 'hist: overlay a density curve.' },
142
+ multiple: { type: 'string', enum: ['layer', 'stack', 'dodge', 'fill'], description: 'hist/kde with hue: how groups are combined.' },
143
+ fill: { type: 'boolean', description: 'kde: fill the area. line: false removes the gradient under a single line.' },
144
+ order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
145
+ lowess: { type: 'boolean', description: 'regplot: local regression instead of a straight line.' },
146
+ logx: { type: 'boolean', description: 'regplot: fit y ~ log(x) with a log x axis.' },
147
+ trend: { type: 'boolean', description: 'scatter: add a least-squares line and R².' },
148
+ annotate: { type: 'boolean', description: 'regplot: show R² and n.' },
149
+ kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot.' },
150
+ marginal: { type: 'string', enum: ['hist', 'kde'], description: 'jointplot: marginal plots.' },
151
+ inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'none'], description: 'violin: what goes inside.' },
152
+ split: { type: 'boolean', description: 'violin: with two hue levels, one half each.' },
153
+ points: { type: 'boolean', description: 'box: overlay the observations. line: markers on every point.' },
154
+ normalize: { type: 'boolean', description: 'radar: scale each axis 0–1 by its own maximum.' },
155
+
156
+ // Part-to-whole
157
+ half: { type: 'boolean', description: 'donut: semicircle.' },
158
+
159
+ // Appearance
160
+ colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities: { "Biobío": "#3A47B8" }.' },
161
+ palette: { description: 'Palette shortcut: a name ("cdia", "okabe-ito", "tableau10", "udec") or a list of colors in fixed order.', ...strOrList },
162
+ ramp: { description: 'Sequential ramp: a name ("indigo", "magenta", "teal", "amber") or a list of colors from light to dark.', ...strOrList },
163
+ theme: {
164
+ description: 'Theme: "papel" (light, default), "noche" (dark), or a JSON object deep-merged over `extends`. Keys: surface, ink, lede, muted, grid, axis, tint, dim, other, categorical (list or palette name), sequential, diverging, status {good, warning, critical}, font {family, mono, import}, type {title, subtitle, label, axis, legend, source, titleWeight}, marks {barRadius, barMax, lineWidth, pointRadius, gap, ring, areaOpacity, bandOpacity, dotOpacity}, axes {inside, grid}, layout {padding}.',
165
+ anyOf: [{ type: 'string', enum: ['papel', 'noche', 'light', 'dark'] }, { type: 'object' }],
166
+ },
167
+ font: { description: 'Typeface for the whole chart: a Google Fonts name ("Poppins"), a CSS stack ("\'Inter\', sans-serif") or { family, import }. configure({ font }) sets it for every chart.', anyOf: [{ type: 'string' }, { type: 'object' }] },
168
+ textScale: { type: 'number', description: 'Multiplies every text size (1.5, 2…); the layout is recomputed so nothing overlaps. Aliases: fontScale, labelScale. configure({ textScale }) sets it for every chart.' },
169
+ width: { type: 'number', description: 'Width in px (default 720; render() uses the container width).' },
170
+ height: { type: 'number', description: 'Height in px. Automatic for horizontal bars, heatmaps, KPI tiles, and narrow treemaps, sunbursts and radars.' },
171
+ },
172
+ };
173
+
174
+ /** Tool definition ready for Claude (Anthropic) or OpenAI. */
175
+ export function toolDefinition({ name = 'plot', flavor = 'anthropic' } = {}) {
176
+ const description =
177
+ 'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, scatter, bubble, regplot, residplot, jointplot, hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, choropleth (Chile regions / world countries built in), symbolmap, network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` adds a per-period slider. Set `locale: "en"` for English number formats.';
178
+ const { $schema, $id, title, ...params } = schema;
179
+ if (flavor === 'openai') return { type: 'function', function: { name, description, parameters: params } };
180
+ return { name, description, input_schema: params };
181
+ }
package/src/stats.js ADDED
@@ -0,0 +1,246 @@
1
+ // Estadística liviana para los gráficos tipo seaborn: cuantiles, intervalos
2
+ // de confianza, histogramas, densidad (KDE), regresiones y ECDF.
3
+ // Determinista (sin bootstrap aleatorio) para que el SVG sea reproducible.
4
+
5
+ export const finite = (vs) => vs.filter((v) => Number.isFinite(v));
6
+ export const sum = (vs) => vs.reduce((a, b) => a + b, 0);
7
+ export const mean = (vs) => (vs.length ? sum(vs) / vs.length : NaN);
8
+
9
+ export function variance(vs) {
10
+ const n = vs.length;
11
+ if (n < 2) return 0;
12
+ const m = mean(vs);
13
+ return vs.reduce((a, v) => a + (v - m) ** 2, 0) / (n - 1);
14
+ }
15
+ export const sd = (vs) => Math.sqrt(variance(vs));
16
+
17
+ /** Cuantil con interpolación lineal (tipo 7, como numpy/R). `sorted` ordenado. */
18
+ export function quantile(sorted, p) {
19
+ if (!sorted.length) return NaN;
20
+ const h = (sorted.length - 1) * p;
21
+ const lo = Math.floor(h), hi = Math.ceil(h);
22
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (h - lo);
23
+ }
24
+ export const median = (vs) => quantile([...vs].sort((a, b) => a - b), 0.5);
25
+
26
+ /** Cuantil de la normal estándar (Acklam). */
27
+ export function zQuantile(p) {
28
+ const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239];
29
+ const b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572];
30
+ const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
31
+ const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
32
+ const pl = 0.02425;
33
+ let q, r;
34
+ if (p < pl) { q = Math.sqrt(-2 * Math.log(p)); return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1); }
35
+ if (p > 1 - pl) { q = Math.sqrt(-2 * Math.log(1 - p)); return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1); }
36
+ q = p - 0.5; r = q * q;
37
+ return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
38
+ }
39
+
40
+ /** Cuantil de la t de Student (expansión de Cornish-Fisher; exacta a ~1e-3 para df ≥ 3). */
41
+ export function tQuantile(df, p = 0.975) {
42
+ if (df <= 0) return NaN;
43
+ if (df === 1) return Math.tan(Math.PI * (p - 0.5));
44
+ if (df === 2) { const a = 4 * p * (1 - p); return (2 * p - 1) * Math.sqrt(2 / a); }
45
+ const z = zQuantile(p);
46
+ const z3 = z ** 3, z5 = z ** 5, z7 = z ** 7, z9 = z ** 9;
47
+ return z + (z3 + z) / (4 * df) + (5 * z5 + 16 * z3 + 3 * z) / (96 * df ** 2) + (3 * z7 + 19 * z5 + 17 * z3 - 15 * z) / (384 * df ** 3) + (79 * z9 + 776 * z7 + 1482 * z5 - 1920 * z3 - 945 * z) / (92160 * df ** 4);
48
+ }
49
+
50
+ /** Intervalo de confianza de la media (t de Student). */
51
+ export function meanCI(vs, level = 0.95) {
52
+ const v = finite(vs);
53
+ const m = mean(v);
54
+ if (v.length < 2) return [m, m];
55
+ const h = tQuantile(v.length - 1, 0.5 + level / 2) * sd(v) / Math.sqrt(v.length);
56
+ return [m - h, m + h];
57
+ }
58
+
59
+ /** Resumen para cajas: cuartiles, bigotes a 1,5·RIC y atípicos. */
60
+ export function fiveNumber(vs) {
61
+ const s = finite(vs).sort((a, b) => a - b);
62
+ const q1 = quantile(s, 0.25), med = quantile(s, 0.5), q3 = quantile(s, 0.75);
63
+ const iqr = q3 - q1;
64
+ const loF = q1 - 1.5 * iqr, hiF = q3 + 1.5 * iqr;
65
+ const inside = s.filter((v) => v >= loF && v <= hiF);
66
+ return {
67
+ n: s.length, min: s[0], max: s[s.length - 1], q1, median: med, q3, iqr, mean: mean(s),
68
+ whiskerLo: inside.length ? inside[0] : q1, whiskerHi: inside.length ? inside[inside.length - 1] : q3,
69
+ outliers: s.filter((v) => v < loF || v > hiF),
70
+ };
71
+ }
72
+
73
+ const niceBinStep = (span, n) => {
74
+ const raw = span / Math.max(1, n);
75
+ const mag = 10 ** Math.floor(Math.log10(raw));
76
+ const f = raw / mag;
77
+ return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * mag;
78
+ };
79
+
80
+ /**
81
+ * Histograma con anchos "bonitos". Por defecto, regla de Freedman–Diaconis
82
+ * acotada a 5–50 barras. Opciones: bins (número), binWidth, domain [a,b].
83
+ */
84
+ export function histogram(vs, { bins, binWidth, domain } = {}) {
85
+ const s = finite(vs).sort((a, b) => a - b);
86
+ if (!s.length) return [];
87
+ let [lo, hi] = domain || [s[0], s[s.length - 1]];
88
+ if (lo === hi) { lo -= 0.5; hi += 0.5; }
89
+ let step = binWidth;
90
+ if (!step) {
91
+ let n = bins;
92
+ if (!n) {
93
+ const iqr = quantile(s, 0.75) - quantile(s, 0.25);
94
+ const fd = iqr > 0 ? 2 * iqr / Math.cbrt(s.length) : 0;
95
+ n = fd > 0 ? Math.ceil((hi - lo) / fd) : Math.ceil(Math.log2(s.length) + 1);
96
+ n = Math.max(5, Math.min(50, n));
97
+ }
98
+ step = bins ? (hi - lo) / bins : niceBinStep(hi - lo, n);
99
+ }
100
+ const start = bins ? lo : Math.floor(lo / step + 1e-9) * step;
101
+ const count = Math.max(1, Math.ceil((hi - start) / step - 1e-9));
102
+ const out = Array.from({ length: count }, (_, i) => ({ x0: start + i * step, x1: start + (i + 1) * step, count: 0 }));
103
+ for (const v of s) {
104
+ let i = Math.floor((v - start) / step);
105
+ if (i >= count) i = count - 1;
106
+ if (i >= 0) out[i].count++;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** Ancho de banda de Scott (el de seaborn por defecto). */
112
+ export function scottBandwidth(vs) {
113
+ const v = finite(vs);
114
+ const s = sd(v);
115
+ const iqr = quantile([...v].sort((a, b) => a - b), 0.75) - quantile([...v].sort((a, b) => a - b), 0.25);
116
+ const sigma = Math.min(s, iqr / 1.349) || s || 1;
117
+ return 1.06 * sigma * v.length ** -0.2;
118
+ }
119
+
120
+ /**
121
+ * Densidad por kernel gaussiano. Devuelve [[x, densidad], ...] en una grilla.
122
+ * `cut` extiende la grilla `cut` anchos de banda más allá de los extremos.
123
+ */
124
+ export function kde(vs, { bandwidth, adjust = 1, gridSize = 160, extent, cut = 3, clip } = {}) {
125
+ const v = finite(vs);
126
+ if (!v.length) return [];
127
+ const bw = (bandwidth ?? scottBandwidth(v)) * adjust || 1;
128
+ let [lo, hi] = extent || [Math.min(...v) - cut * bw, Math.max(...v) + cut * bw];
129
+ if (clip) { lo = Math.max(lo, clip[0]); hi = Math.min(hi, clip[1]); }
130
+ const k = 1 / (v.length * bw * Math.sqrt(2 * Math.PI));
131
+ const out = [];
132
+ for (let i = 0; i < gridSize; i++) {
133
+ const x = lo + ((hi - lo) * i) / (gridSize - 1);
134
+ let d = 0;
135
+ for (const xi of v) { const u = (x - xi) / bw; if (u > -6 && u < 6) d += Math.exp(-0.5 * u * u); }
136
+ out.push([x, d * k]);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** Función de distribución empírica: [[x, proporción acumulada], ...]. */
142
+ export function ecdf(vs) {
143
+ const s = finite(vs).sort((a, b) => a - b);
144
+ return s.map((x, i) => [x, (i + 1) / s.length]);
145
+ }
146
+
147
+ // ── Regresión ──────────────────────────────────────────────────────────────
148
+
149
+ function solve(A, b) {
150
+ // Gauss-Jordan con pivoteo parcial. Devuelve x tal que A·x = b.
151
+ const n = A.length;
152
+ const M = A.map((row, i) => [...row, b[i]]);
153
+ for (let c = 0; c < n; c++) {
154
+ let p = c;
155
+ for (let r = c + 1; r < n; r++) if (Math.abs(M[r][c]) > Math.abs(M[p][c])) p = r;
156
+ [M[c], M[p]] = [M[p], M[c]];
157
+ const d = M[c][c] || 1e-12;
158
+ for (let k = c; k <= n; k++) M[c][k] /= d;
159
+ for (let r = 0; r < n; r++) if (r !== c) { const f = M[r][c]; for (let k = c; k <= n; k++) M[r][k] -= f * M[c][k]; }
160
+ }
161
+ return M.map((row) => row[n]);
162
+ }
163
+
164
+ function inverse(A) {
165
+ const n = A.length;
166
+ return A.map((_, j) => solve(A, A.map((__, i) => (i === j ? 1 : 0)))).reduce((acc, col, j) => {
167
+ col.forEach((v, i) => { acc[i][j] = v; });
168
+ return acc;
169
+ }, A.map(() => new Array(n).fill(0)));
170
+ }
171
+
172
+ /**
173
+ * Regresión polinomial por mínimos cuadrados (order 1 = lineal), con banda de
174
+ * confianza para la media de y. Devuelve { coef, predict(x), band(x) → [lo,hi], r2, n }.
175
+ */
176
+ export function polyfit(xs, ys, { order = 1, level = 0.95 } = {}) {
177
+ const pts = xs.map((x, i) => [x, ys[i]]).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y));
178
+ const n = pts.length;
179
+ const p = order + 1;
180
+ if (n <= p) {
181
+ const my = mean(pts.map((q) => q[1]));
182
+ return { coef: [my], predict: () => my, band: () => [my, my], r2: 0, n };
183
+ }
184
+ // Centrar x mejora la estabilidad numérica.
185
+ const mx = mean(pts.map((q) => q[0]));
186
+ const row = (x) => Array.from({ length: p }, (_, k) => (x - mx) ** k);
187
+ const X = pts.map(([x]) => row(x));
188
+ const XtX = Array.from({ length: p }, (_, i) => Array.from({ length: p }, (_, j) => X.reduce((a, r) => a + r[i] * r[j], 0)));
189
+ const Xty = Array.from({ length: p }, (_, i) => X.reduce((a, r, k) => a + r[i] * pts[k][1], 0));
190
+ const beta = solve(XtX, Xty);
191
+ const predict = (x) => row(x).reduce((a, v, k) => a + v * beta[k], 0);
192
+ const resid = pts.map(([x, y]) => y - predict(x));
193
+ const sse = sum(resid.map((r) => r * r));
194
+ const my = mean(pts.map((q) => q[1]));
195
+ const sst = sum(pts.map(([, y]) => (y - my) ** 2));
196
+ const s2 = sse / (n - p);
197
+ const inv = inverse(XtX);
198
+ const t = tQuantile(n - p, 0.5 + level / 2);
199
+ const band = (x) => {
200
+ const r = row(x);
201
+ let v = 0;
202
+ for (let i = 0; i < p; i++) for (let j = 0; j < p; j++) v += r[i] * inv[i][j] * r[j];
203
+ const h = t * Math.sqrt(Math.max(0, s2 * v));
204
+ const yh = predict(x);
205
+ return [yh - h, yh + h];
206
+ };
207
+ return { coef: beta, center: mx, predict, band, r2: sst ? 1 - sse / sst : 0, n };
208
+ }
209
+
210
+ /** Pearson r. */
211
+ export function pearson(xs, ys) {
212
+ const pts = xs.map((x, i) => [x, ys[i]]).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y));
213
+ const mx = mean(pts.map((p) => p[0])), my = mean(pts.map((p) => p[1]));
214
+ let sxy = 0, sxx = 0, syy = 0;
215
+ for (const [x, y] of pts) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2; }
216
+ return sxx && syy ? sxy / Math.sqrt(sxx * syy) : 0;
217
+ }
218
+
219
+ /** LOWESS (Cleveland): suavizado local robusto. Devuelve [[x, y], ...] ordenado. */
220
+ export function lowess(xs, ys, { frac = 2 / 3, iters = 2 } = {}) {
221
+ const pts = xs.map((x, i) => [x, ys[i]]).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)).sort((a, b) => a[0] - b[0]);
222
+ const n = pts.length;
223
+ if (n < 3) return pts;
224
+ const r = Math.max(2, Math.ceil(frac * n));
225
+ let robust = new Array(n).fill(1);
226
+ let fit = new Array(n).fill(0);
227
+ for (let it = 0; it <= iters; it++) {
228
+ for (let i = 0; i < n; i++) {
229
+ const x0 = pts[i][0];
230
+ const d = pts.map(([x]) => Math.abs(x - x0)).sort((a, b) => a - b);
231
+ const h = d[Math.min(n - 1, r - 1)] || 1e-12;
232
+ let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;
233
+ for (let j = 0; j < n; j++) {
234
+ const u = Math.abs(pts[j][0] - x0) / h;
235
+ const w = u < 1 ? (1 - u ** 3) ** 3 * robust[j] : 0;
236
+ sw += w; swx += w * pts[j][0]; swy += w * pts[j][1]; swxx += w * pts[j][0] ** 2; swxy += w * pts[j][0] * pts[j][1];
237
+ }
238
+ const den = sw * swxx - swx * swx;
239
+ fit[i] = Math.abs(den) < 1e-12 ? swy / (sw || 1) : ((swxx * swy - swx * swxy) + (sw * swxy - swx * swy) * x0) / den;
240
+ }
241
+ const res = pts.map(([, y], i) => Math.abs(y - fit[i]));
242
+ const m = median(res) || 1e-12;
243
+ robust = res.map((e) => { const u = e / (6 * m); return u < 1 ? (1 - u * u) ** 2 : 0; });
244
+ }
245
+ return pts.map(([x], i) => [x, fit[i]]);
246
+ }
package/src/svg.js ADDED
@@ -0,0 +1,85 @@
1
+ // Constructor mínimo de SVG como texto (funciona en Node y en el navegador).
2
+
3
+ import { ctx } from './context.js';
4
+
5
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
6
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
7
+
8
+ /** Redondea a 2 decimales para SVG compactos. */
9
+ export const r2 = (n) => Math.round(n * 100) / 100;
10
+
11
+ const ATTR_ALIAS = { className: 'class' };
12
+ const KEEP_CASE = new Set(['viewBox', 'preserveAspectRatio', 'gradientUnits', 'gradientTransform', 'patternUnits', 'patternTransform', 'patternContentUnits', 'clipPathUnits', 'refX', 'refY', 'markerWidth', 'markerHeight', 'markerUnits', 'textLength', 'lengthAdjust', 'startOffset']);
13
+
14
+ export function el(tag, attrs = {}, children) {
15
+ let s = '<' + tag;
16
+ for (const [k, v] of Object.entries(attrs)) {
17
+ if (v === undefined || v === null || v === false) continue;
18
+ const name = ATTR_ALIAS[k] || (KEEP_CASE.has(k) ? k : k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()));
19
+ // Escala de texto global: todo font-size numérico se multiplica aquí.
20
+ const val = typeof v === 'number' ? r2(k === 'fontSize' ? v * ctx.textScale : v) : v;
21
+ s += ` ${name}="${esc(val)}"`;
22
+ }
23
+ if (children === undefined || children === null || children === '') return s + '/>';
24
+ const inner = Array.isArray(children) ? children.flat(Infinity).filter(Boolean).join('') : children;
25
+ return s + '>' + inner + '</' + tag + '>';
26
+ }
27
+
28
+ export const g = (attrs, children) => el('g', attrs, children);
29
+
30
+ /** Texto SVG. `children` se escapa. */
31
+ export function text(x, y, str, attrs = {}) {
32
+ return el('text', { x, y, ...attrs }, esc(str));
33
+ }
34
+
35
+ /**
36
+ * Barra con extremo de dato redondeado (4px) y base recta.
37
+ * dir: hacia dónde crece la barra: 'up' | 'down' | 'right' | 'left'.
38
+ */
39
+ export function barPath(x, y, w, h, dir = 'up', radius = 4) {
40
+ if (w <= 0 || h <= 0) return '';
41
+ const r = Math.max(0, Math.min(radius, (dir === 'up' || dir === 'down' ? w : h) / 2, dir === 'up' || dir === 'down' ? h : w));
42
+ const R = r2;
43
+ const x1 = x + w, y1 = y + h;
44
+ if (r === 0) return `M${R(x)},${R(y)}H${R(x1)}V${R(y1)}H${R(x)}Z`;
45
+ switch (dir) {
46
+ case 'up':
47
+ return `M${R(x)},${R(y1)}V${R(y + r)}Q${R(x)},${R(y)} ${R(x + r)},${R(y)}H${R(x1 - r)}Q${R(x1)},${R(y)} ${R(x1)},${R(y + r)}V${R(y1)}Z`;
48
+ case 'down':
49
+ return `M${R(x)},${R(y)}V${R(y1 - r)}Q${R(x)},${R(y1)} ${R(x + r)},${R(y1)}H${R(x1 - r)}Q${R(x1)},${R(y1)} ${R(x1)},${R(y1 - r)}V${R(y)}Z`;
50
+ case 'right':
51
+ return `M${R(x)},${R(y)}H${R(x1 - r)}Q${R(x1)},${R(y)} ${R(x1)},${R(y + r)}V${R(y1 - r)}Q${R(x1)},${R(y1)} ${R(x1 - r)},${R(y1)}H${R(x)}Z`;
52
+ case 'left':
53
+ return `M${R(x1)},${R(y)}H${R(x + r)}Q${R(x)},${R(y)} ${R(x)},${R(y + r)}V${R(y1 - r)}Q${R(x)},${R(y1)} ${R(x + r)},${R(y1)}H${R(x1)}Z`;
54
+ default:
55
+ return `M${R(x)},${R(y)}H${R(x1)}V${R(y1)}H${R(x)}Z`;
56
+ }
57
+ }
58
+
59
+ /** Texto de tooltip: líneas unidas por \n en un atributo data-tip. */
60
+ export const tip = (...lines) => lines.filter((l) => l !== undefined && l !== null && l !== '').join('\n');
61
+
62
+ /**
63
+ * Capa de puntos. Con pocos puntos, un <circle> por punto (tooltip propio).
64
+ * Con muchos (> threshold), un solo <path> por estilo —los círculos como
65
+ * arcos— y una capa invisible que busca el punto más cercano al pasar el
66
+ * mouse. Es tan rápido como Canvas y conserva lo vectorial (ver docs).
67
+ */
68
+ export function dotLayer(circles, { threshold = 1500, maxTips = 20000 } = {}) {
69
+ if (circles.length <= threshold) return circles.map((c) => el('circle', c)).join('');
70
+ const groups = new Map();
71
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
72
+ const near = [];
73
+ for (const c of circles) {
74
+ const k = [c.fill, c.fillOpacity, c.r, c.stroke, c.strokeWidth].join('|');
75
+ if (!groups.has(k)) groups.set(k, { attrs: c, d: [] });
76
+ const r = +c.r, cx = +c.cx, cy = +c.cy;
77
+ groups.get(k).d.push(`M${r2(cx - r)},${r2(cy)}a${r},${r} 0 1,0 ${r2(2 * r)},0a${r},${r} 0 1,0 ${r2(-2 * r)},0`);
78
+ x0 = Math.min(x0, cx - r); y0 = Math.min(y0, cy - r); x1 = Math.max(x1, cx + r); y1 = Math.max(y1, cy + r);
79
+ if (near.length < maxTips) near.push([r2(cx), r2(cy), c['data-tip'] || '', c.fill]);
80
+ }
81
+ const paths = [...groups.values()].map(({ attrs, d }) =>
82
+ el('path', { d: d.join(''), fill: attrs.fill, fillOpacity: attrs.fillOpacity, stroke: attrs.stroke && attrs.stroke !== 'none' ? attrs.stroke : null, strokeWidth: attrs.stroke && attrs.stroke !== 'none' ? Math.min(+attrs.strokeWidth || 1, 0.75) : null }));
83
+ const hover = el('rect', { class: 'pa-near', x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), fill: 'transparent', 'data-near': JSON.stringify(near) });
84
+ return g({ class: 'pa-dense' }, [...paths, hover]);
85
+ }
package/src/text.js ADDED
@@ -0,0 +1,61 @@
1
+ // Medición aproximada de texto sin DOM (Node, workers, SSR).
2
+ // Calibrada para sans humanistas (Mozilla Text, Inter, system-ui).
3
+ // Respeta la escala de texto global (textScale): el layout se calcula con el
4
+ // tamaño real con que se dibuja el texto.
5
+ import { ctx } from './context.js';
6
+
7
+ const W = {};
8
+ const set = (chars, w) => { for (const c of chars) W[c] = w; };
9
+ set('abcdeghknopqsuvxyzáéóúñü', 0.54);
10
+ set('ijlíï', 0.25);
11
+ set('frt', 0.36);
12
+ set('mw', 0.82);
13
+ set('ABCDEFGHKNOPQRSTUVXYZÁÉÍÓÚÑ', 0.64);
14
+ set('IJ', 0.3);
15
+ set('MW', 0.86);
16
+ set('0123456789', 0.56);
17
+ set(' ', 0.27);
18
+ set('.,:;\'!|', 0.26);
19
+ set('-–()[]/', 0.36);
20
+ set('%', 0.82);
21
+ set('$€', 0.58);
22
+ set('+=<>±×', 0.58);
23
+ set('—', 0.9);
24
+ set('▲▼■●', 0.9);
25
+ set('·', 0.3);
26
+ set('→', 0.9);
27
+
28
+ export function measure(str, size = 12, weight = 400) {
29
+ let w = 0;
30
+ for (const c of String(str ?? '')) w += W[c] ?? 0.56;
31
+ const bold = weight >= 600 ? 1.06 : weight >= 500 ? 1.03 : 1;
32
+ return w * size * ctx.textScale * bold * 1.05; // margen: Mozilla Text es un poco más ancha
33
+ }
34
+
35
+ /** Recorta con elipsis para caber en `max` px. */
36
+ export function truncate(str, max, size = 12, weight = 400) {
37
+ str = String(str ?? '');
38
+ if (measure(str, size, weight) <= max) return str;
39
+ let s = str;
40
+ while (s.length > 1 && measure(s + '…', size, weight) > max) s = s.slice(0, -1);
41
+ return s.trimEnd() + '…';
42
+ }
43
+
44
+ /** Ajusta texto en líneas de ancho `max`. Devuelve un arreglo de líneas. */
45
+ export function wrap(str, max, size = 12, weight = 400, maxLines = Infinity) {
46
+ const words = String(str ?? '').split(/\s+/).filter(Boolean);
47
+ const lines = [];
48
+ let cur = '';
49
+ for (const w of words) {
50
+ const next = cur ? cur + ' ' + w : w;
51
+ if (measure(next, size, weight) <= max || !cur) cur = next;
52
+ else { lines.push(cur); cur = w; }
53
+ }
54
+ if (cur) lines.push(cur);
55
+ if (lines.length > maxLines) {
56
+ const kept = lines.slice(0, maxLines);
57
+ kept[maxLines - 1] = truncate(lines.slice(maxLines - 1).join(' '), max, size, weight);
58
+ return kept;
59
+ }
60
+ return lines;
61
+ }