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/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 Math.max(...v);
117
+ case 'min': return Math.min(...v);
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,171 @@
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, decimals)).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 || a >= 100) return 0;
38
+ if (a >= 10) return 1;
39
+ if (a >= 1) return 2;
40
+ return Math.min(4, 1 - Math.floor(Math.log10(a)) + 1);
41
+ }
42
+
43
+ export function formatCompact(v, locale = 'es', decimals) {
44
+ const l = loc(locale);
45
+ const a = Math.abs(v);
46
+ for (const [n, suf] of COMPACT[l]) {
47
+ if (a >= n) {
48
+ const s = v / n;
49
+ const d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
50
+ return formatNumber(s, d, l, decimals === undefined) + suf;
51
+ }
52
+ }
53
+ return formatNumber(v, decimals ?? smartDecimals(v), l, decimals === undefined);
54
+ }
55
+
56
+ const isYearLike = (vals) =>
57
+ vals.length > 0 && vals.every((v) => Number.isInteger(v) && v >= 1000 && v <= 2999);
58
+
59
+ /**
60
+ * Crea un formateador de valores a partir de la opción `format` del spec.
61
+ * 'number' | 'compact' | 'percent' | 'currency' | 'integer' | 'year'
62
+ * { type, prefix, suffix, decimals, currency }
63
+ * `values` permite autodetectar (p. ej. porcentajes ya multiplicados por 100).
64
+ */
65
+ export function makeFormat(fmt, locale = 'es', values = []) {
66
+ const o = typeof fmt === 'string' ? { type: fmt } : { ...(fmt || {}) };
67
+ const l = loc(locale);
68
+ const nums = values.filter((v) => Number.isFinite(v));
69
+ const max = nums.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
70
+ let type = o.type;
71
+ if (!type) type = isYearLike(nums) ? 'year' : max >= 1e5 ? 'compact' : 'number';
72
+ const pre = o.prefix ?? (type === 'currency' ? currencyPrefix(o.currency, l) : '');
73
+ const suf = o.suffix ?? '';
74
+ const d = o.decimals;
75
+
76
+ let core;
77
+ switch (type) {
78
+ case 'year':
79
+ core = (v) => String(Math.round(v));
80
+ break;
81
+ case 'integer':
82
+ core = (v) => formatNumber(v, 0, l);
83
+ break;
84
+ case 'percent': {
85
+ const scale = o.scale ?? (max > 1.5 ? 1 : 100); // 0,25 → 25%; 25 → 25%
86
+ core = (v) => formatNumber(v * scale, d ?? smartDecimals(v * scale), l, d === undefined) + '%';
87
+ break;
88
+ }
89
+ case 'compact':
90
+ core = (v) => formatCompact(v, l, d);
91
+ break;
92
+ case 'currency':
93
+ core = (v) => (max >= 1e6 ? formatCompact(v, l, d) : formatNumber(v, d ?? (max >= 100 || nums.every(Number.isInteger) ? 0 : 2), l));
94
+ break;
95
+ default:
96
+ core = (v) => formatNumber(v, d ?? smartDecimals(v), l, d === undefined);
97
+ }
98
+ const f = (v) => {
99
+ if (v === null || v === undefined || Number.isNaN(v)) return '';
100
+ const s = core(v);
101
+ if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
102
+ return pre + s + suf;
103
+ };
104
+ f.type = type;
105
+ f.tick = (step) => tickFormat(step, o, type, l, pre, suf, max, d);
106
+ return f;
107
+ }
108
+
109
+ function currencyPrefix(cur, l) {
110
+ if (!cur) return '$';
111
+ const c = String(cur).toUpperCase();
112
+ return { USD: 'US$', CLP: '$', EUR: '€', UF: 'UF ', GBP: '£', PEN: 'S/ ', MXN: 'MX$', BRL: 'R$' }[c] || c + ' ';
113
+ }
114
+
115
+ /** Formateador de marcas de eje: decimales según el paso, nunca ruido. */
116
+ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
117
+ // Decimales mínimos para escribir el paso exacto (2,5 → 1; 0,25 → 2).
118
+ const decFor = (s) => {
119
+ const a = Math.abs(s) || 1;
120
+ let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
121
+ while (d < 6 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
122
+ return Math.min(6, d);
123
+ };
124
+ let core;
125
+ if (type === 'year') core = (v) => String(Math.round(v));
126
+ else if (type === 'percent') {
127
+ const scale = o.scale ?? (max > 1.5 ? 1 : 100);
128
+ core = (v) => formatNumber(v * scale, decFor(step * scale), l) + '%';
129
+ } else if (type === 'compact' || max >= 1e4 && type !== 'integer') {
130
+ core = (v) => {
131
+ const a = Math.abs(v);
132
+ const unit = a >= 1e12 ? 1e12 : a >= 1e9 ? 1e9 : a >= 1e6 ? 1e6 : a >= 1e3 ? 1e3 : 1;
133
+ if (unit === 1) return formatNumber(v, decFor(step), l);
134
+ return formatCompact(v, l, decFor(step / unit));
135
+ };
136
+ } else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
137
+ return (v) => {
138
+ const s = core(v);
139
+ if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
140
+ return pre + s + suf;
141
+ };
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Fechas
146
+
147
+ const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/;
148
+
149
+ /** Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC). */
150
+ export function parseDate(v) {
151
+ if (v instanceof Date) return v.getTime();
152
+ if (typeof v === 'string') {
153
+ const m = ISO.exec(v.trim());
154
+ if (m) return Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
155
+ }
156
+ return NaN;
157
+ }
158
+
159
+ export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && ISO.test(v.trim()));
160
+
161
+ export function formatDate(ms, unit, locale = 'es', withYear = true) {
162
+ const d = new Date(ms);
163
+ const l = loc(locale);
164
+ const y = d.getUTCFullYear();
165
+ const m = MONTHS[l][d.getUTCMonth()];
166
+ if (unit === 'year') return String(y);
167
+ if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
168
+ if (unit === 'month') return withYear ? `${m} ${y}` : m;
169
+ const day = d.getUTCDate();
170
+ return l === 'en' ? `${m} ${day}${withYear ? ', ' + y : ''}` : `${day} ${m}${withYear ? ' ' + y : ''}`;
171
+ }
package/src/frame.js ADDED
@@ -0,0 +1,157 @@
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 } 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
+ let height = Math.max(160, +spec.height || Math.round((defaults.height || 420) * (1 + 0.55 * (XS - 1))));
22
+ // Responsivo: en pantallas angostas, menos margen y títulos algo menores
23
+ // (nunca bajo 11 px en ejes). El ancho útil es lo que más importa en móvil.
24
+ const k = width < 420 ? 0.86 : width < 560 ? 0.93 : 1;
25
+ const T0 = { ...SIZES, ...(theme.type || {}) };
26
+ 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) };
27
+ const pad = spec.padding ?? (width < 420 ? theme.layout?.paddingSmall ?? 12 : width < 640 ? 16 : theme.layout?.padding ?? 24);
28
+ const innerW = width - pad * 2;
29
+ const parts = [];
30
+ let y = pad;
31
+
32
+ const tw = T.titleWeight ?? 600;
33
+ const titleLines = spec.title ? wrap(spec.title, innerW, T.title, tw, 5) : [];
34
+ const subLines = spec.subtitle ? wrap(spec.subtitle, innerW, T.subtitle, 400, 5) : [];
35
+ const header = [];
36
+ const X = ctx.textScale; // alturas de línea crecen con la escala de texto
37
+ const tlh = Math.round(T.title * X * 1.3), slh = Math.round(T.subtitle * X * 1.42);
38
+ titleLines.forEach((l, i) => {
39
+ header.push(text(pad, y + T.title * X * 0.9 + i * tlh, l, { fill: theme.ink, fontSize: T.title, fontWeight: tw, letterSpacing: '-0.01em' }));
40
+ });
41
+ if (titleLines.length) y += titleLines.length * tlh + 2;
42
+ subLines.forEach((l, i) => {
43
+ header.push(text(pad, y + T.subtitle * X * 1.0 + i * slh, l, { fill: theme.lede, fontSize: T.subtitle }));
44
+ });
45
+ if (subLines.length) y += subLines.length * slh + 2;
46
+ if (titleLines.length || subLines.length) y += 10;
47
+
48
+ const sourceH = spec.source ? Math.round(18 * Math.max(1, X * 0.95)) : 0;
49
+
50
+ const frame = {
51
+ width,
52
+ pad,
53
+ theme,
54
+ get height() { return height; },
55
+ top: y,
56
+ get bottom() { return height - pad - sourceH; },
57
+ /** Alto extra fuera del área de trazado (pie + margen). */
58
+ chrome: pad + sourceH,
59
+ /** Dibuja la leyenda y devuelve el área de trazado restante. */
60
+ layout(legend = [], opts = {}) {
61
+ let ly = y;
62
+ if (legend && legend.length) {
63
+ const { svg, h } = drawLegend(legend, pad, ly, innerW, theme, { ...opts, size: T.legend });
64
+ header.push(svg);
65
+ ly += h + 10; // la leyenda pegada al gráfico que explica
66
+ }
67
+ // Banda extra bajo el área para etiquetas y títulos de eje agrandados.
68
+ return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - Math.max(0, (XS - 1) * 22)) };
69
+ },
70
+ /** Permite a gráficos de alto automático (kpi) fijar el alto final. */
71
+ setHeight(h) { height = h; },
72
+ finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
73
+ if (spec.source) {
74
+ const src = /^fuente|^source/i.test(spec.source) ? spec.source : (String(spec.locale || 'es').startsWith('en') ? 'Source: ' : 'Fuente: ') + spec.source;
75
+ parts.push(text(width - pad, height - pad - 2, src, { fill: theme.muted, fontSize: T.source, textAnchor: 'end' }));
76
+ }
77
+ const id = 'pa' + Math.abs(hash((spec.title || '') + type + width)).toString(36);
78
+ const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
79
+ const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}`
80
+ + (/^(choropleth|symbolmap)$/.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}' : '');
81
+ return el(
82
+ 'svg',
83
+ {
84
+ xmlns: 'http://www.w3.org/2000/svg',
85
+ viewBox: `0 0 ${width} ${r2(height)}`,
86
+ width,
87
+ height: r2(height),
88
+ role: 'img',
89
+ 'aria-labelledby': `${id}-t ${id}-d`,
90
+ fontFamily: spec.fontFamily || theme.font?.family || FONT,
91
+ class: 'pa-chart',
92
+ 'data-pa-type': type,
93
+ 'data-pa-mode': theme.mode,
94
+ 'data-pa-lang': String(spec.locale || 'es').slice(0, 2),
95
+ style: 'max-width:100%;height:auto;font-variant-numeric:lining-nums',
96
+ },
97
+ [
98
+ el('title', { id: `${id}-t` }, esc(spec.title || type)),
99
+ el('desc', { id: `${id}-d` }, esc(desc)),
100
+ el('style', {}, style),
101
+ defs ? el('defs', {}, defs) : '',
102
+ spec.background === false ? '' : el('rect', { width, height: r2(height), fill: theme.surface }),
103
+ g({ class: 'pa-header' }, header),
104
+ g({ class: 'pa-body' }, body),
105
+ parts.join(''),
106
+ ]
107
+ );
108
+ },
109
+ };
110
+ return frame;
111
+ }
112
+
113
+ function drawLegend(items, x0, y0, maxW, theme, opts) {
114
+ const out = [];
115
+ const size = opts.size ?? SIZES.legend;
116
+ const X = ctx.textScale;
117
+ const rowH = Math.round(20 * X);
118
+ let x = x0, y = y0, rows = 1;
119
+ for (const it of items) {
120
+ if (it.type === 'ramp') {
121
+ const w = Math.min(220, maxW);
122
+ const gid = 'pa-ramp-' + Math.abs(hash(it.stops.join(''))).toString(36);
123
+ const stops = it.stops.map((c, i) => el('stop', { offset: `${(i / (it.stops.length - 1)) * 100}%`, stopColor: c }));
124
+ out.push(el('defs', {}, el('linearGradient', { id: gid }, stops)));
125
+ if (it.title) { out.push(text(x, y + 10, it.title, { fill: theme.lede, fontSize: size })); x += measure(it.title, size) + 10; }
126
+ out.push(text(x, y + 10, it.min, { fill: theme.muted, fontSize: SIZES.axis }));
127
+ x += measure(it.min, SIZES.axis) + 6;
128
+ out.push(el('rect', { x, y: y + 2, width: w * 0.6, height: 10, rx: 5, fill: `url(#${gid})` }));
129
+ x += w * 0.6 + 6;
130
+ out.push(text(x, y + 10, it.max, { fill: theme.muted, fontSize: SIZES.axis }));
131
+ x += measure(it.max, SIZES.axis) + 18;
132
+ continue;
133
+ }
134
+ const label = String(it.label);
135
+ const w = 18 * X + measure(label, size) + 18;
136
+ if (x + w > x0 + maxW && x > x0) { x = x0; y += rowH; rows++; }
137
+ const cy = y + 6.5 * X;
138
+ out.push(`<g class="pa-key" data-color="${esc(it.key ?? it.color)}" style="cursor:pointer">`);
139
+ out.push(el('rect', { x: x - 3, y: y - 3, width: w - 10, height: rowH - 1, rx: 4, fill: 'transparent' }));
140
+ const sw = 10 * Math.min(1.6, X);
141
+ // Entradas punteadas: línea discontinua o cuadro rayado con borde discontinuo.
142
+ 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 }));
143
+ 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' }));
144
+ else if (it.shape === 'square') out.push(el('rect', { x, y: cy - sw / 2, width: sw, height: sw, rx: 2.5, fill: it.color }));
145
+ else out.push(el('circle', { cx: x + sw / 2, cy, r: sw / 2, fill: it.color }));
146
+ out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size }));
147
+ out.push('</g>');
148
+ x += w;
149
+ }
150
+ return { svg: g({ class: 'pa-legend' }, out), h: rows * rowH - 6 * X };
151
+ }
152
+
153
+ export function hash(s) {
154
+ let h = 0;
155
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
156
+ return h;
157
+ }
@@ -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;