plotagents 0.0.0-stage → 0.7.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 (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
package/src/data.js ADDED
@@ -0,0 +1,137 @@
1
+ // Preparación de datos: validación con mensajes útiles para agentes,
2
+ // inferencia de campos, formato ancho → largo y agregación.
3
+ import { isDateLike, parseDate } from './format.js';
4
+
5
+ export class PlotError extends Error {
6
+ constructor(message, hint) {
7
+ super(hint ? `${message}\n→ ${hint}` : message);
8
+ this.name = 'PlotError';
9
+ this.hint = hint;
10
+ }
11
+ }
12
+
13
+ /** Convierte a número de forma tolerante ("1.234,5", "12%", "$ 3.000"). */
14
+ export function num(v) {
15
+ if (typeof v === 'number') return v;
16
+ if (v === null || v === undefined || v === '' || typeof v === 'boolean') return NaN;
17
+ let s = String(v).trim().replace(/[\s$€£%]|US\$|CLP|UF/g, '').replace('−', '-');
18
+ const hasC = s.includes(','), hasD = s.includes('.');
19
+ if (hasC && hasD) s = s.lastIndexOf(',') > s.lastIndexOf('.') ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');
20
+ else if (hasC) s = /,\d{3}(?!\d)/.test(s) && !/,\d{1,2}$/.test(s) ? s.replace(/,/g, '') : s.replace(',', '.');
21
+ else if (hasD && /^\-?\d{1,3}(\.\d{3})+$/.test(s)) s = s.replace(/\./g, '');
22
+ const n = Number(s);
23
+ return Number.isFinite(n) ? n : NaN;
24
+ }
25
+
26
+ export function fieldsOf(data) {
27
+ const set = new Set();
28
+ for (const row of data.slice(0, 50)) if (row && typeof row === 'object') Object.keys(row).forEach((k) => set.add(k));
29
+ return [...set];
30
+ }
31
+
32
+ export function ensureData(spec) {
33
+ let data = spec.data;
34
+ if (!Array.isArray(data)) {
35
+ if (data && typeof data === 'object') {
36
+ // { "Biobío": 10, "Ñuble": 5 } → filas
37
+ data = Object.entries(data).map(([k, v]) => ({ label: k, value: v }));
38
+ } else throw new PlotError('`data` debe ser un arreglo de objetos (filas).', 'Ejemplo: data: [{ region: "Biobío", ventas: 120 }]');
39
+ }
40
+ if (data.length && typeof data[0] !== 'object') {
41
+ data = data.map((v, i) => ({ index: i + 1, value: v }));
42
+ }
43
+ return data.filter((r) => r && typeof r === 'object');
44
+ }
45
+
46
+ export function requireField(data, field, role) {
47
+ if (field === undefined || field === null) return;
48
+ const fields = fieldsOf(data);
49
+ if (!fields.includes(field)) {
50
+ throw new PlotError(
51
+ `El campo "${field}" (${role}) no existe en los datos.`,
52
+ `Campos disponibles: ${fields.map((f) => `"${f}"`).join(', ') || '(ninguno)'}`
53
+ );
54
+ }
55
+ }
56
+
57
+ const isNumericField = (data, f) => {
58
+ let n = 0, ok = 0;
59
+ for (const r of data.slice(0, 200)) {
60
+ const v = r[f];
61
+ if (v === null || v === undefined || v === '') continue;
62
+ n++;
63
+ if (typeof v === 'number' || (typeof v === 'string' && !isDateLike(v) && Number.isFinite(num(v)))) ok++;
64
+ }
65
+ return n > 0 && ok / n > 0.9;
66
+ };
67
+
68
+ export const fieldKind = (data, f) => {
69
+ const vals = data.map((r) => r[f]).filter((v) => v !== null && v !== undefined && v !== '');
70
+ if (!vals.length) return 'empty';
71
+ if (vals.every((v) => isDateLike(v))) return 'date';
72
+ if (isNumericField(data, f)) {
73
+ const ns = vals.map(num);
74
+ if (ns.every((v) => Number.isInteger(v) && v >= 1000 && v <= 2999)) return 'year';
75
+ return 'number';
76
+ }
77
+ return 'category';
78
+ };
79
+
80
+ /** Elige x / y si el agente no los indicó. */
81
+ export function inferXY(spec, data) {
82
+ const fields = fieldsOf(data);
83
+ const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
84
+ let { x, y } = spec;
85
+ const nums = fields.filter((f) => kinds[f] === 'number');
86
+ if (!x) x = fields.find((f) => kinds[f] === 'date' || kinds[f] === 'year') || fields.find((f) => kinds[f] === 'category') || fields.find((f) => f !== y);
87
+ if (!y) y = nums.find((f) => f !== x) || fields.find((f) => f !== x);
88
+ return { x, y, kinds };
89
+ }
90
+
91
+ /**
92
+ * y: ['ventas', 'costos'] → formato largo con un campo de serie.
93
+ * Así los agentes pueden pasar tablas anchas sin transformarlas.
94
+ */
95
+ export function melt(data, x, ys, seriesName = '__series', valueName = '__value', labels = {}) {
96
+ const out = [];
97
+ for (const r of data) for (const f of ys) out.push({ ...r, [x]: r[x], [seriesName]: labels[f] || humanize(f), [valueName]: r[f] });
98
+ return out;
99
+ }
100
+
101
+ export function uniq(arr) {
102
+ const seen = new Set();
103
+ const out = [];
104
+ for (const v of arr) {
105
+ const k = v instanceof Date ? v.getTime() : v;
106
+ if (!seen.has(k)) { seen.add(k); out.push(v); }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ export function aggregate(values, how = 'sum') {
112
+ const v = values.filter((n) => Number.isFinite(n));
113
+ if (!v.length) return NaN;
114
+ switch (how) {
115
+ case 'mean': case 'avg': return v.reduce((a, b) => a + b, 0) / v.length;
116
+ case 'max': return v.reduce((a, b) => (b > a ? b : a), -Infinity); // sin spread: no desborda con 150 mil filas
117
+ case 'min': return v.reduce((a, b) => (b < a ? b : a), Infinity);
118
+ case 'count': return v.length;
119
+ case 'last': return v[v.length - 1];
120
+ case 'first': return v[0];
121
+ default: return v.reduce((a, b) => a + b, 0);
122
+ }
123
+ }
124
+
125
+ /** Valor de x normalizado: fecha → ms, año → número, si no, texto. */
126
+ export function xValue(v, kind) {
127
+ if (kind === 'date') return parseDate(v);
128
+ if (kind === 'year' || kind === 'number') return num(v);
129
+ return v === null || v === undefined ? '' : String(v);
130
+ }
131
+
132
+ export const humanize = (f) =>
133
+ String(f ?? '')
134
+ .replace(/^__.*/, '')
135
+ .replace(/[_-]+/g, ' ')
136
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
137
+ .replace(/^\w/, (c) => c.toUpperCase());
package/src/facet.js ADDED
@@ -0,0 +1,84 @@
1
+ // Small multiples (seaborn: col / col_wrap): un panel por valor de `facet`,
2
+ // con escalas y colores compartidos y una sola leyenda. Es la alternativa
3
+ // correcta a 8 líneas enredadas o a un doble eje.
4
+ import { el, g } from './svg.js';
5
+ import { createFrame } from './frame.js';
6
+ import { colorMap } from './theme.js';
7
+ import { ensureData, requireField, uniq, fieldKind, num } from './data.js';
8
+ import { niceDomain } from './scale.js';
9
+
10
+ const LINE_LIKE = new Set(['line', 'area', 'kde', 'ecdf', 'point', 'regplot', 'residplot']);
11
+
12
+ export function facet(spec, theme, renderOne) {
13
+ const data = ensureData(spec);
14
+ const f = spec.facet ?? spec.col;
15
+ requireField(data, f, 'facet');
16
+ let keys = uniq(data.map((r) => String(r[f])));
17
+ const fk = fieldKind(data, f);
18
+ if (fk === 'year' || fk === 'number') keys.sort((a, b) => num(a) - num(b));
19
+ if (spec.facetOrder) keys = [...spec.facetOrder.map(String).filter((k) => keys.includes(k)), ...keys.filter((k) => !spec.facetOrder.map(String).includes(k))];
20
+
21
+ // Colores globales: la misma serie tiene el mismo color en todos los paneles.
22
+ const sf = spec.series ?? spec.hue ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : null);
23
+ const colors = { ...(spec.colors || {}) };
24
+ let legend = [];
25
+ if (sf && data.length && Object.prototype.hasOwnProperty.call(data[0], sf)) {
26
+ const sk = uniq(data.map((r) => String(r[sf])));
27
+ const cm = colorMap(sk, theme, spec.colors);
28
+ for (const k of sk) colors[k] = cm.get(k);
29
+ if (sk.length > 1) legend = sk.map((k) => ({ label: k, color: cm.get(k), shape: LINE_LIKE.has(spec.type) ? 'line' : 'square' }));
30
+ }
31
+
32
+ const frame = createFrame({ ...spec, height: 200 }, theme, { width: 720 });
33
+ const area = frame.layout(legend);
34
+ const gap = spec.panelGap ?? 14;
35
+ // Columnas pedidas, pero nunca paneles de menos de ~150 px (en móvil se apilan).
36
+ const fit = Math.max(1, Math.floor((area.w + gap) / 150));
37
+ const want = spec.columns ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
38
+ const cols = Math.max(1, Math.min(keys.length, want, fit));
39
+ const pw = (area.w - gap * (cols - 1)) / cols;
40
+ const ph = spec.panelHeight ?? Math.round(Math.min(300, Math.max(160, pw * 0.66)));
41
+ const rows = Math.ceil(keys.length / cols);
42
+ frame.setHeight(area.y + rows * ph + (rows - 1) * gap + frame.chrome);
43
+
44
+ const panelTheme = { ...theme, type: { ...(theme.type || {}), title: Math.min(13.5, theme.type?.title ?? 13.5), titleWeight: 600 } };
45
+ const multiSeries = legend.length > 1;
46
+ const base = {
47
+ ...spec, facet: undefined, col: undefined, title: undefined, subtitle: undefined, source: undefined, refLines: spec.refLines,
48
+ width: Math.floor(pw), height: ph, padding: 0, colors, legend: false, theme: panelTheme, palette: undefined,
49
+ labels: spec.panelLabels ?? (multiSeries && LINE_LIKE.has(spec.type) ? false : spec.labels),
50
+ };
51
+ const subsets = keys.map((k) => data.filter((r) => String(r[f]) === k));
52
+
53
+ // Escalas compartidas: primera pasada para conocer el dominio de cada panel.
54
+ const shareY = spec.sharey ?? spec.shareY ?? true;
55
+ const shareX = spec.sharex ?? spec.shareX ?? true;
56
+ let yDomain = spec.yDomain, xDomain = spec.xDomain;
57
+ if ((shareY && !yDomain) || (shareX && !xDomain)) {
58
+ const ds = subsets.map((d, i) => { const _domains = {}; try { renderOne({ ...base, data: d, title: keys[i], _domains }); } catch { /* panel vacío */ } return _domains; });
59
+ const ys = ds.filter((d) => d.y).map((d) => d.y), xs = ds.filter((d) => d.x).map((d) => d.x);
60
+ if (shareY && !yDomain && ys.length) {
61
+ const lo = Math.min(...ys.map((d) => d[0])), hi = Math.max(...ys.map((d) => d[1]));
62
+ yDomain = spec.yScale === 'log' ? [lo, hi] : (({ min, max }) => [min, max])(niceDomain(lo, hi, 4));
63
+ }
64
+ if (shareX && !xDomain && xs.length) xDomain = [Math.min(...xs.map((d) => d[0])), Math.max(...xs.map((d) => d[1]))];
65
+ }
66
+
67
+ const body = [];
68
+ keys.forEach((k, i) => {
69
+ const c = i % cols, r = Math.floor(i / cols);
70
+ const px = area.x + c * (pw + gap), py = area.y + r * (ph + gap);
71
+ let svg;
72
+ try {
73
+ svg = renderOne({ ...base, data: subsets[i], title: k, yDomain, xDomain });
74
+ } catch (e) {
75
+ svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pw}" height="${ph}"></svg>`;
76
+ }
77
+ // Panel anidado: se quita el estilo responsivo del SVG raíz del panel.
78
+ svg = svg.replace(/^<svg /, `<svg x="${px.toFixed(1)}" y="${py.toFixed(1)}" `).replace(/ style="[^"]*"/, '');
79
+ body.push(svg);
80
+ });
81
+ const en = String(spec.locale || 'es').startsWith('en');
82
+ const desc = (en ? `Small multiples by ${f}: ` : `Paneles por ${f}: `) + keys.join(', ') + '.';
83
+ return frame.finish(g({ class: 'pa-facets' }, body), { desc, type: 'facet' });
84
+ }
package/src/format.js ADDED
@@ -0,0 +1,203 @@
1
+ // Formato de números y fechas. Por defecto en español de Chile:
2
+ // punto de miles, coma decimal y porcentaje pegado al número (56,1%).
3
+
4
+ const SEP = {
5
+ es: { group: '.', dec: ',' },
6
+ en: { group: ',', dec: '.' },
7
+ };
8
+
9
+ const COMPACT = {
10
+ es: [[1e12, ' B'], [1e6, ' M'], [1e3, ' mil']],
11
+ en: [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
12
+ };
13
+
14
+ export const MONTHS = {
15
+ es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
16
+ en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
17
+ };
18
+
19
+ const loc = (l) => (l && String(l).toLowerCase().startsWith('en') ? 'en' : 'es');
20
+
21
+ /** Número con separadores. `decimals` fijo; `trim` quita ceros finales. */
22
+ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
23
+ if (v === null || v === undefined || Number.isNaN(v)) return '';
24
+ if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
25
+ const { group, dec } = SEP[loc(locale)];
26
+ const neg = v < 0;
27
+ let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))).split('.');
28
+ int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
29
+ if (trim) frac = frac.replace(/0+$/, '');
30
+ const out = frac ? int + dec + frac : int;
31
+ return (neg && /[1-9]/.test(out) ? '−' : '') + out;
32
+ }
33
+
34
+ /** Decimales "inteligentes" según la magnitud. */
35
+ export function smartDecimals(v) {
36
+ const a = Math.abs(v);
37
+ if (a === 0 || !Number.isFinite(a) || a >= 100) return 0;
38
+ if (a >= 10) return 1;
39
+ if (a >= 1) return 2;
40
+ // Bajo 1: tres cifras significativas (0,123 · 0,00012), nunca "0".
41
+ return Math.min(20, 2 - Math.floor(Math.log10(a)));
42
+ }
43
+
44
+ export function formatCompact(v, locale = 'es', decimals) {
45
+ const l = loc(locale);
46
+ const a = Math.abs(v);
47
+ for (const [n, suf] of COMPACT[l]) {
48
+ if (a >= n) {
49
+ const s = v / n;
50
+ const d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
51
+ return formatNumber(s, d, l, decimals === undefined) + suf;
52
+ }
53
+ }
54
+ return formatNumber(v, decimals ?? smartDecimals(v), l, decimals === undefined);
55
+ }
56
+
57
+ const isYearLike = (vals) =>
58
+ vals.length > 0 && vals.every((v) => Number.isInteger(v) && v >= 1000 && v <= 2999);
59
+
60
+ /**
61
+ * Crea un formateador de valores a partir de la opción `format` del spec.
62
+ * 'number' | 'compact' | 'percent' | 'currency' | 'integer' | 'year'
63
+ * { type, prefix, suffix, decimals, currency }
64
+ * `values` permite autodetectar (p. ej. porcentajes ya multiplicados por 100).
65
+ */
66
+ export function makeFormat(fmt, locale = 'es', values = []) {
67
+ const o = typeof fmt === 'string' ? { type: fmt } : { ...(fmt || {}) };
68
+ const l = loc(locale);
69
+ const nums = values.filter((v) => Number.isFinite(v));
70
+ const max = nums.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
71
+ let type = o.type;
72
+ if (!type) type = isYearLike(nums) ? 'year' : max >= 1e5 ? 'compact' : 'number';
73
+ const pre = o.prefix ?? (type === 'currency' ? currencyPrefix(o.currency, l) : '');
74
+ const suf = o.suffix ?? '';
75
+ const d = o.decimals;
76
+
77
+ let core;
78
+ switch (type) {
79
+ case 'year':
80
+ core = (v) => String(Math.round(v));
81
+ break;
82
+ case 'integer':
83
+ core = (v) => formatNumber(v, 0, l);
84
+ break;
85
+ case 'percent': {
86
+ const scale = o.scale ?? (max > 1.5 ? 1 : 100); // 0,25 → 25%; 25 → 25%
87
+ core = (v) => formatNumber(v * scale, d ?? smartDecimals(v * scale), l, d === undefined) + '%';
88
+ break;
89
+ }
90
+ case 'compact':
91
+ core = (v) => formatCompact(v, l, d);
92
+ break;
93
+ case 'currency':
94
+ core = (v) => (max >= 1e6 ? formatCompact(v, l, d) : formatNumber(v, d ?? (max >= 100 || nums.every(Number.isInteger) ? 0 : 2), l));
95
+ break;
96
+ default:
97
+ core = (v) => formatNumber(v, d ?? smartDecimals(v), l, d === undefined);
98
+ }
99
+ const f = (v) => {
100
+ if (v === null || v === undefined || Number.isNaN(v)) return '';
101
+ const s = core(v);
102
+ if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
103
+ return pre + s + suf;
104
+ };
105
+ f.type = type;
106
+ // `ticks` (opcional): las marcas del eje, o la mayor |marca|. Fija una sola
107
+ // unidad compacta para todo el eje (sin "500 M" junto a "1.000,0 M").
108
+ f.tick = (step, ticks) => tickFormat(step, o, type, l, pre, suf, max, d, ticks);
109
+ return f;
110
+ }
111
+
112
+ function currencyPrefix(cur, l) {
113
+ if (!cur) return '$';
114
+ const c = String(cur).toUpperCase();
115
+ return { USD: 'US$', CLP: '$', EUR: '€', UF: 'UF ', GBP: '£', PEN: 'S/ ', MXN: 'MX$', BRL: 'R$' }[c] || c + ' ';
116
+ }
117
+
118
+ /** Formateador de marcas de eje: decimales según el paso, nunca ruido. */
119
+ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
120
+ // Decimales mínimos para escribir el paso exacto (2,5 → 1; 0,25 → 2).
121
+ const decFor = (s) => {
122
+ const a = Math.abs(s) || 1;
123
+ let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
124
+ while (d < 6 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
125
+ return Math.min(6, d);
126
+ };
127
+ let core;
128
+ if (type === 'year') core = (v) => String(Math.round(v));
129
+ else if (type === 'percent') {
130
+ const scale = o.scale ?? (max > 1.5 ? 1 : 100);
131
+ core = (v) => formatNumber(v * scale, decFor(step * scale), l) + '%';
132
+ } else if (type === 'compact' || max >= 1e4 && type !== 'integer') {
133
+ // Una sola unidad por eje, la de la mayor marca, entre las que tiene el idioma
134
+ // (en español no hay "mil millones": 1.000 M). Sin ceros finales.
135
+ let top;
136
+ if (Array.isArray(ticks)) top = ticks.reduce((m, t) => (Number.isFinite(t) ? Math.max(m, Math.abs(t)) : m), 0);
137
+ else if (Number.isFinite(ticks)) top = Math.abs(ticks);
138
+ else top = step > 0 ? Math.ceil(max / step - 1e-9) * step : max;
139
+ const [unit, sufx] = COMPACT[l].find(([n]) => top >= n) || [1, ''];
140
+ core = unit === 1
141
+ ? (v) => formatNumber(v, decFor(step), l)
142
+ : (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
143
+ } else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
144
+ return (v) => {
145
+ if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
146
+ const s = core(v);
147
+ if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
148
+ return pre + s + suf;
149
+ };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Fechas
154
+
155
+ const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?)?/i;
156
+
157
+ /**
158
+ * Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC).
159
+ * Sin zona horaria, la hora se toma tal cual (como UTC); con `Z` o `±hh:mm`
160
+ * se respeta el desfase, así datos con zonas distintas se ordenan bien.
161
+ */
162
+ export function parseDate(v) {
163
+ if (v instanceof Date) return v.getTime();
164
+ if (typeof v === 'string') {
165
+ const m = ISO.exec(v.trim());
166
+ if (m) {
167
+ const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
168
+ let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
169
+ const z = m[8];
170
+ if (z && z.toUpperCase() !== 'Z') {
171
+ const sign = z[0] === '-' ? -1 : 1;
172
+ const hh = +z.slice(1, 3), mm = +(z.slice(1).replace(':', '').slice(2, 4) || 0);
173
+ t -= sign * (hh * 60 + mm) * 6e4;
174
+ }
175
+ return t;
176
+ }
177
+ }
178
+ return NaN;
179
+ }
180
+
181
+ export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && ISO.test(v.trim()));
182
+
183
+ /**
184
+ * Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
185
+ * En 'hour'/'minute', `withYear`: true → fecha completa + "HH:mm";
186
+ * 'day' → fecha sin año + hora; false → solo "HH:mm".
187
+ */
188
+ export function formatDate(ms, unit, locale = 'es', withYear = true) {
189
+ const d = new Date(ms);
190
+ const l = loc(locale);
191
+ const y = d.getUTCFullYear();
192
+ const m = MONTHS[l][d.getUTCMonth()];
193
+ if (unit === 'hour' || unit === 'minute') {
194
+ const hm = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
195
+ if (withYear === false) return hm;
196
+ return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
197
+ }
198
+ if (unit === 'year') return String(y);
199
+ if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
200
+ if (unit === 'month') return withYear ? `${m} ${y}` : m;
201
+ const day = d.getUTCDate();
202
+ return l === 'en' ? `${m} ${day}${withYear ? ', ' + y : ''}` : `${day} ${m}${withYear ? ' ' + y : ''}`;
203
+ }
package/src/frame.js ADDED
@@ -0,0 +1,226 @@
1
+ // Marco común de cada gráfico: título (un hecho), bajada, leyenda, área de
2
+ // trazado y línea de fuente chica a la derecha. Nada de cajas ni filetes.
3
+ import { el, esc, g, text, r2 } from './svg.js';
4
+ import { measure, wrap, truncate } from './text.js';
5
+ import { FONT, FONT_CSS } from './theme.js';
6
+ import { ctx } from './context.js';
7
+
8
+ export const SIZES = {
9
+ title: 22,
10
+ subtitle: 14.5,
11
+ label: 12,
12
+ axis: 12,
13
+ legend: 12.5,
14
+ source: 11,
15
+ };
16
+
17
+ export function createFrame(spec, theme, defaults = {}) {
18
+ const width = Math.max(240, +spec.width || defaults.width || 720);
19
+ // Con textos más grandes (textScale), el alto por defecto crece para no aplastar los datos.
20
+ const XS = ctx.textScale;
21
+ // En gráficos anchos el alto por defecto crece con el ancho (hasta ~2,8:1),
22
+ // para que una lámina de 1500 px no quede como una franja vacía.
23
+ const h0 = defaults.height || 420;
24
+ const hW = defaults.fixedHeight ? h0 : Math.max(h0, Math.min(Math.round(h0 * 1.35), Math.round(width * 0.36)));
25
+ let height = Math.max(160, +spec.height || Math.round(hW * (1 + 0.55 * (XS - 1))));
26
+ // Responsivo: en pantallas angostas, menos margen y títulos algo menores
27
+ // (nunca bajo 11 px en ejes). El ancho útil es lo que más importa en móvil.
28
+ const k = width < 420 ? 0.86 : width < 560 ? 0.93 : 1;
29
+ const T0 = { ...SIZES, ...(theme.type || {}) };
30
+ const T = { ...T0, title: Math.round(T0.title * k * 2) / 2, subtitle: Math.max(12, Math.round(T0.subtitle * (k < 1 ? 0.95 : 1) * 2) / 2) };
31
+ const pad = spec.padding ?? (width < 420 ? theme.layout?.paddingSmall ?? 12 : width < 640 ? 16 : theme.layout?.padding ?? 24);
32
+ const innerW = width - pad * 2;
33
+ const parts = [];
34
+ let y = pad;
35
+
36
+ const tw = T.titleWeight ?? 600;
37
+ const titleLines = spec.title ? wrap(spec.title, innerW, T.title, tw, 5) : [];
38
+ const subLines = spec.subtitle ? wrap(spec.subtitle, innerW, T.subtitle, 400, 5) : [];
39
+ const header = [];
40
+ const X = ctx.textScale; // alturas de línea crecen con la escala de texto
41
+ const tlh = Math.round(T.title * X * 1.3), slh = Math.round(T.subtitle * X * 1.42);
42
+ titleLines.forEach((l, i) => {
43
+ header.push(text(pad, y + T.title * X * 0.9 + i * tlh, l, { fill: theme.ink, fontSize: T.title, fontWeight: tw, letterSpacing: '-0.01em' }));
44
+ });
45
+ if (titleLines.length) y += titleLines.length * tlh + 2;
46
+ subLines.forEach((l, i) => {
47
+ header.push(text(pad, y + T.subtitle * X * 1.0 + i * slh, l, { fill: theme.lede, fontSize: T.subtitle }));
48
+ });
49
+ if (subLines.length) y += subLines.length * slh + 2;
50
+ if (titleLines.length || subLines.length) y += 10;
51
+
52
+ const srcText = spec.source ? (/^fuente|^source/i.test(spec.source) ? spec.source : (String(spec.locale || 'es').startsWith('en') ? 'Source: ' : 'Fuente: ') + spec.source) : '';
53
+ // Una fuente larga salta a una segunda línea en vez de salirse por la izquierda.
54
+ const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
55
+ const srcLH = Math.round(T.source * X * 1.35);
56
+ const sourceH = srcText ? Math.round(18 * Math.max(1, X * 0.95)) + (srcLines.length - 1) * srcLH : 0;
57
+ const legendPos = legendPosition(spec, width);
58
+ let legendBottom = null; // leyenda inferior: se dibuja en finish(), cuando el alto es definitivo
59
+
60
+ const frame = {
61
+ width,
62
+ pad,
63
+ theme,
64
+ legendPosition: legendPos,
65
+ get height() { return height; },
66
+ top: y,
67
+ get bottom() { return height - pad - sourceH - (legendBottom ? legendBottom.h + 12 : 0); },
68
+ /** Alto extra fuera del área de trazado (pie + margen + leyenda inferior). */
69
+ get chrome() { return pad + sourceH + (legendBottom ? legendBottom.h + 12 : 0); },
70
+ /**
71
+ * Dibuja la leyenda y devuelve el área de trazado restante. La leyenda va
72
+ * arriba por defecto; `legend: "bottom" | "left" | "right"` la mueve.
73
+ */
74
+ layout(legend = [], opts = {}) {
75
+ let ly = y;
76
+ const items = withExcluded(legend, spec, theme);
77
+ const extraBottom = Math.max(0, (XS - 1) * 22);
78
+ if (items.length && (legendPos === 'left' || legendPos === 'right')) {
79
+ const colW = Math.min(innerW * 0.32, Math.max(90, legendWidth(items, T.legend)));
80
+ const { svg } = drawLegend(items, legendPos === 'left' ? pad : width - pad - colW, ly + 2, colW, theme, { ...opts, size: T.legend, vertical: true });
81
+ header.push(svg);
82
+ const gap = 16;
83
+ return { x: legendPos === 'left' ? pad + colW + gap : pad, y: ly, w: innerW - colW - gap, h: Math.max(40, frame.bottom - ly - extraBottom) };
84
+ }
85
+ if (items.length && legendPos === 'bottom') {
86
+ const probe = drawLegend(items, pad, 0, innerW, theme, { ...opts, size: T.legend });
87
+ legendBottom = { items, opts, h: probe.h };
88
+ return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
89
+ }
90
+ if (items.length) {
91
+ const { svg, h } = drawLegend(items, pad, ly, innerW, theme, { ...opts, size: T.legend });
92
+ header.push(svg);
93
+ ly += h + 10; // la leyenda pegada al gráfico que explica
94
+ }
95
+ // Banda extra bajo el área para etiquetas y títulos de eje agrandados.
96
+ return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
97
+ },
98
+ /** Permite a gráficos de alto automático (kpi) fijar el alto final. */
99
+ setHeight(h) { height = h; },
100
+ finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
101
+ if (legendBottom) {
102
+ const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
103
+ header.push(svg);
104
+ }
105
+ srcLines.forEach((l, i) => parts.push(text(width - pad, height - pad - 2 - (srcLines.length - 1 - i) * srcLH, l, { fill: theme.muted, fontSize: T.source, textAnchor: 'end' })));
106
+ // Id estable pero distinto entre gráficos sin título del mismo tipo (aria-labelledby).
107
+ const id = 'pa' + Math.abs(hash((spec.title || '') + type + width + desc)).toString(36);
108
+ const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
109
+ const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}@media (prefers-reduced-motion:reduce){.pa-m{transition:none}}.pa-chart:focus-visible{outline:2px solid ${theme.categorical[0]};outline-offset:2px}`
110
+ + (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
111
+ + (/^(choropleth|symbolmap|pointmap)$/.test(type) ? '.pa-zoom path,.pa-zoom circle,.pa-zoom rect{vector-effect:non-scaling-stroke}.pa-zoom-hide{transition:opacity .2s ease}.pa-zoomed .pa-zoom-hide{opacity:0}' : '');
112
+ return el(
113
+ 'svg',
114
+ {
115
+ xmlns: 'http://www.w3.org/2000/svg',
116
+ viewBox: `0 0 ${width} ${r2(height)}`,
117
+ width,
118
+ height: r2(height),
119
+ role: 'img',
120
+ 'aria-labelledby': `${id}-t ${id}-d`,
121
+ fontFamily: spec.fontFamily || theme.font?.family || FONT,
122
+ class: 'pa-chart',
123
+ 'data-pa-type': type,
124
+ 'data-pa-mode': theme.mode,
125
+ 'data-pa-lang': String(spec.locale || 'es').slice(0, 2),
126
+ 'data-pa-top': r2(frame.top),
127
+ 'data-pa-tip': theme.tooltip && (theme.tooltip.bg || theme.tooltip.ink) ? `${theme.tooltip.bg || ''},${theme.tooltip.ink || ''}` : null,
128
+ style: 'max-width:100%;height:auto;font-variant-numeric:lining-nums',
129
+ },
130
+ [
131
+ el('title', { id: `${id}-t` }, esc(spec.title || type)),
132
+ el('desc', { id: `${id}-d` }, esc(desc)),
133
+ el('style', {}, style),
134
+ defs ? el('defs', {}, defs) : '',
135
+ spec.background === false ? '' : el('rect', { width, height: r2(height), fill: theme.surface }),
136
+ g({ class: 'pa-header' }, header),
137
+ g({ class: 'pa-body' }, body),
138
+ parts.join(''),
139
+ ]
140
+ );
141
+ },
142
+ };
143
+ return frame;
144
+ }
145
+
146
+ /** Posición de la leyenda: `legend: "top" | "bottom" | "left" | "right"` (o { position }). */
147
+ export function legendPosition(spec, width = 720) {
148
+ const v = typeof spec.legend === 'string' ? spec.legend : spec.legend && typeof spec.legend === 'object' ? spec.legend.position : spec.legendPosition;
149
+ const p = String(v || 'top').toLowerCase();
150
+ const pos = { arriba: 'top', abajo: 'bottom', izquierda: 'left', derecha: 'right' }[p] || p;
151
+ if (!['top', 'bottom', 'left', 'right'].includes(pos)) return 'top';
152
+ // En pantallas angostas una columna lateral roba demasiado ancho: va abajo.
153
+ if ((pos === 'left' || pos === 'right') && width < 520) return 'bottom';
154
+ return pos;
155
+ }
156
+
157
+ /** Entradas excluidas con shift + clic (spec.exclude): siguen en la leyenda, apagadas, para volver a sumarlas. */
158
+ function withExcluded(items, spec, theme) {
159
+ const ex = spec._excluded;
160
+ if (!ex || !ex.length || !items.length) return items;
161
+ const have = new Set(items.map((it) => String(it.value ?? it.label)));
162
+ const extra = ex.filter((v) => !have.has(String(v))).map((v) => ({ label: String(v), value: v, color: (spec.colors && spec.colors[v]) || theme.dim, shape: items[0]?.shape === 'line' ? 'line' : items[0]?.shape || 'square', off: true }));
163
+ return [...items, ...extra];
164
+ }
165
+
166
+ function legendWidth(items, size) {
167
+ const X = ctx.textScale;
168
+ return Math.max(...items.map((it) => (it.type === 'ramp' ? 220 : 18 * X + measure(String(it.label), size) + 12)));
169
+ }
170
+
171
+ function drawLegend(items, x0, y0, maxW, theme, opts) {
172
+ const out = [];
173
+ const size = opts.size ?? SIZES.legend;
174
+ const X = ctx.textScale;
175
+ const rowH = Math.round((opts.vertical ? 22 : 20) * X);
176
+ const vertical = !!opts.vertical;
177
+ let x = x0, y = y0, rows = 1;
178
+ for (const it of items) {
179
+ if (it.type === 'ramp') {
180
+ if (vertical && x > x0) { x = x0; y += rowH; rows++; }
181
+ const w = Math.min(220, maxW);
182
+ const gid = 'pa-ramp-' + Math.abs(hash(it.stops.join(''))).toString(36);
183
+ const stops = it.stops.map((c, i) => el('stop', { offset: `${(i / (it.stops.length - 1)) * 100}%`, stopColor: c }));
184
+ out.push(el('defs', {}, el('linearGradient', { id: gid }, stops)));
185
+ if (it.title) {
186
+ out.push(text(x, y + 10, it.title, { fill: theme.lede, fontSize: size }));
187
+ if (vertical) { y += rowH; rows++; } else x += measure(it.title, size) + 10;
188
+ }
189
+ out.push(text(x, y + 10, it.min, { fill: theme.muted, fontSize: SIZES.axis }));
190
+ x += measure(it.min, SIZES.axis) + 6;
191
+ const rw = vertical ? Math.max(40, Math.min(w * 0.6, maxW - (x - x0) - measure(it.max, SIZES.axis) - 6)) : w * 0.6;
192
+ out.push(el('rect', { x, y: y + 2, width: rw, height: 10, rx: 5, fill: `url(#${gid})` }));
193
+ x += rw + 6;
194
+ out.push(text(x, y + 10, it.max, { fill: theme.muted, fontSize: SIZES.axis }));
195
+ x += measure(it.max, SIZES.axis) + 18;
196
+ continue;
197
+ }
198
+ const sw = 10 * Math.min(1.6, X);
199
+ let label = String(it.label);
200
+ if (vertical) label = truncate(label, maxW - sw - 10, size);
201
+ const w = 18 * X + measure(label, size) + 18;
202
+ if ((vertical || x + w > x0 + maxW) && x > x0) { x = x0; y += rowH; rows++; }
203
+ const cy = y + 6.5 * X;
204
+ const v = it.value !== undefined ? it.value : it.key ? undefined : it.label;
205
+ const val = v !== undefined ? ` data-value="${esc(String(v))}"` : '';
206
+ const faded = it.off ? 0.45 : it.active === false ? 0.5 : null;
207
+ out.push(`<g class="pa-key${it.off ? ' pa-key-off' : ''}" data-color="${esc(it.key ?? it.color)}"${val}${it.active ? ' aria-pressed="true"' : ''}${faded ? ` opacity="${faded}"` : ''} style="cursor:pointer">`);
208
+ out.push(el('rect', { x: x - 3, y: y - 3, width: w - 10, height: rowH - 1, rx: 4, fill: 'transparent' }));
209
+ // Entradas punteadas: línea discontinua o cuadro rayado con borde discontinuo.
210
+ if (it.off) out.push(el('rect', { x: x + 0.75, y: cy - sw / 2 + 0.75, width: sw - 1.5, height: sw - 1.5, rx: 2, fill: 'none', stroke: it.color, strokeWidth: 1.5 }));
211
+ else if (it.shape === 'line') out.push(el('line', { x1: x, x2: x + sw * 1.2, y1: cy, y2: cy, stroke: it.color, strokeWidth: 2.5, strokeLinecap: it.dashed ? 'butt' : 'round', strokeDasharray: it.dashed ? '4 2.5' : null }));
212
+ else if (it.shape === 'square' && it.dashed) out.push(el('rect', { x: x + 0.6, y: cy - sw / 2 + 0.6, width: sw - 1.2, height: sw - 1.2, rx: 2, fill: it.fill || it.color, stroke: it.stroke || it.color, strokeWidth: 1.2, strokeDasharray: '3 2' }));
213
+ else if (it.shape === 'square') out.push(el('rect', { x, y: cy - sw / 2, width: sw, height: sw, rx: 2.5, fill: it.color }));
214
+ else out.push(el('circle', { cx: x + sw / 2, cy, r: sw / 2, fill: it.color }));
215
+ out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size, textDecoration: it.off ? 'line-through' : null }));
216
+ out.push('</g>');
217
+ x += w;
218
+ }
219
+ return { svg: g({ class: 'pa-legend' }, out), h: rows * rowH - 6 * X };
220
+ }
221
+
222
+ export function hash(s) {
223
+ let h = 0;
224
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
225
+ return h;
226
+ }
@@ -0,0 +1,7 @@
1
+ // Registra las 16 regiones de Chile como 'chile' (alias 'chile-regiones').
2
+ import { registerGeo } from './registry.js';
3
+ import regiones, { attribution } from './data/chile-regiones.js';
4
+
5
+ registerGeo('chile', regiones, ['chile-regiones', 'regiones']);
6
+ export { regiones, attribution };
7
+ export default regiones;