plotagents 0.7.0 → 0.8.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.
- package/AGENTS.md +5 -1
- package/README.md +18 -1
- package/bin/plotagents.js +3 -1
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +1857 -110
- package/docs/charts.md +86 -1
- package/docs/es/agents.md +102 -0
- package/docs/es/api.md +172 -0
- package/docs/es/charts.md +436 -0
- package/docs/es/colorscales.md +46 -0
- package/docs/es/dashboards.md +79 -0
- package/docs/es/getting-started.md +142 -0
- package/docs/es/rendering.md +47 -0
- package/docs/es/themes.md +116 -0
- package/index.d.ts +92 -8
- package/package.json +3 -2
- package/schema.json +359 -9
- package/skills/plotagents/SKILL.md +105 -0
- package/src/charts/bar.js +13 -5
- package/src/charts/chord.js +3 -1
- package/src/charts/choropleth.js +3 -1
- package/src/charts/corr.js +512 -0
- package/src/charts/dist.js +2 -1
- package/src/charts/donut.js +3 -1
- package/src/charts/kpi.js +2 -2
- package/src/charts/line.js +19 -7
- package/src/charts/network.js +13 -8
- package/src/charts/pointmap.js +9 -6
- package/src/charts/regplot.js +9 -3
- package/src/charts/sankey.js +1 -1
- package/src/charts/scatter.js +4 -2
- package/src/charts/sunburst.js +2 -2
- package/src/charts/table.js +882 -0
- package/src/charts/treemap.js +2 -2
- package/src/charts/wordcloud.js +17 -3
- package/src/colorscales.js +12 -1
- package/src/dashboard.js +21 -8
- package/src/data.js +2 -0
- package/src/facet.js +4 -3
- package/src/format.js +36 -13
- package/src/frame.js +8 -5
- package/src/geo/projection.js +4 -2
- package/src/hierarchy.js +6 -0
- package/src/i18n.js +107 -0
- package/src/index.js +17 -4
- package/src/infer.js +9 -1
- package/src/lint.js +66 -4
- package/src/runtime.js +94 -6
- package/src/scale.js +3 -2
- package/src/schema.js +54 -11
- package/src/stats.js +5 -3
- package/src/svg.js +4 -1
- package/src/text.js +5 -0
package/src/charts/treemap.js
CHANGED
|
@@ -10,7 +10,7 @@ import { formatNumber } from '../format.js';
|
|
|
10
10
|
import { humanize } from '../data.js';
|
|
11
11
|
import { createFrame } from '../frame.js';
|
|
12
12
|
import { ctx } from '../context.js';
|
|
13
|
-
import { buildHierarchy,
|
|
13
|
+
import { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
|
|
14
14
|
|
|
15
15
|
/** Algoritmo squarified (Bruls, Huizing & van Wijk, 2000). */
|
|
16
16
|
export function squarify(items, x, y, w, h) {
|
|
@@ -57,7 +57,7 @@ export function treemap(spec, theme) {
|
|
|
57
57
|
const locale = spec.locale || 'es';
|
|
58
58
|
const en = String(locale).startsWith('en');
|
|
59
59
|
const { root, fields, valueField } = buildHierarchy(spec);
|
|
60
|
-
const focus =
|
|
60
|
+
const focus = focusNode(root, spec.focus);
|
|
61
61
|
const levels = height(focus);
|
|
62
62
|
// El color sigue a la entidad: se asigna sobre el árbol completo, así al
|
|
63
63
|
// entrar a un grupo sus elementos conservan la familia de color del padre.
|
package/src/charts/wordcloud.js
CHANGED
|
@@ -31,15 +31,26 @@ const STOP = new Set((
|
|
|
31
31
|
+ ' a about above after again all am an and any are as at be because been before being below between both but by can could did do does doing down during each few for from further had has have having he her here hers him his how i if in into is it its itself just me more most my no nor not now of off on once only or other our ours out over own same she should so some such than that the their them then there these they this those through to too under until up very was we were what when where which while who whom why will with would you your yours also get got really much many one two'
|
|
32
32
|
).split(/\s+/));
|
|
33
33
|
|
|
34
|
+
// Palabras vacías extra por idioma (se suman a es/en solo si el locale las pide).
|
|
35
|
+
const STOP_BY = {
|
|
36
|
+
pt: 'não uma uns umas dos das nas nos pelo pela pelos pelas com para por que mas como mais muito já também são foi era tem têm ele ela eles elas isso isto esse essa este esta aos àquele seu sua seus suas meu minha nós você vocês ser estar ter há então quando onde',
|
|
37
|
+
fr: 'les des une dans pour que qui est sont mais comme plus par sur pas avec ces cette cet aux ils elles nous vous ont été être avoir fait très aussi donc ainsi sans sous entre leur leurs son ses mes tes notre votre',
|
|
38
|
+
de: 'der die das den dem des ein eine einer einem einen und oder aber nicht mit von zu für auf ist sind war waren wird werden hat haben auch als wie bei nach aus über sich es sie wir ihr ich du er an im am zum zur dass noch nur sehr',
|
|
39
|
+
};
|
|
40
|
+
// Compara sin tildes: "tambien" y "también" son la misma palabra vacía.
|
|
41
|
+
const plain = (w) => w.normalize('NFD').replace(/\p{M}/gu, '');
|
|
42
|
+
const STOP_PLAIN = new Set([...STOP].map(plain));
|
|
43
|
+
|
|
34
44
|
/** Texto crudo → [{ text, value }] por frecuencia. */
|
|
35
45
|
export function countWords(input, { locale, minLength = 3, stopwords = [] } = {}) {
|
|
36
46
|
const extra = new Set([].concat(stopwords).map((w) => String(w).toLowerCase()));
|
|
47
|
+
for (const w of (STOP_BY[String(locale || '').slice(0, 2)] || '').split(' ')) if (w) extra.add(w);
|
|
37
48
|
const counts = new Map(), shown = new Map();
|
|
38
49
|
const docs = [].concat(input).filter((d) => typeof d === 'string');
|
|
39
50
|
for (const d of docs) for (const m of d.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)) {
|
|
40
51
|
const raw = m[0].replace(/['’-]+$/, '');
|
|
41
52
|
const k = raw.toLowerCase();
|
|
42
|
-
if (k.length < minLength || STOP.has(k) || extra.has(k) || /^\d+$/.test(k)) continue;
|
|
53
|
+
if (k.length < minLength || STOP.has(k) || STOP_PLAIN.has(plain(k)) || extra.has(k) || /^\d+$/.test(k)) continue;
|
|
43
54
|
counts.set(k, (counts.get(k) || 0) + 1);
|
|
44
55
|
// Se muestra la forma más común (respeta "Biobío", "IA", "ChatGPT").
|
|
45
56
|
const f = shown.get(k) || new Map();
|
|
@@ -76,9 +87,12 @@ export function wordcloud(spec, theme) {
|
|
|
76
87
|
if (!t) continue;
|
|
77
88
|
const v = valueF ? num(r[valueF]) : 1;
|
|
78
89
|
if (!Number.isFinite(v) || v <= 0) continue;
|
|
79
|
-
|
|
90
|
+
// Con `series`, la misma palabra en dos grupos son dos entradas (no se funde con el primero).
|
|
91
|
+
const gk = groupF ? String(r[groupF]) : null;
|
|
92
|
+
const key = gk === null ? t : gk + '\u0000' + t;
|
|
93
|
+
const e = acc.get(key) || { text: t, value: 0, group: gk };
|
|
80
94
|
e.value += v;
|
|
81
|
-
acc.set(
|
|
95
|
+
acc.set(key, e);
|
|
82
96
|
}
|
|
83
97
|
words = [...acc.values()];
|
|
84
98
|
}
|
package/src/colorscales.js
CHANGED
|
@@ -141,6 +141,17 @@ const LOWER = new Map();
|
|
|
141
141
|
for (const k of [...Object.keys(COLORMAPS), ...Object.keys(QUALITATIVE)]) LOWER.set(k.toLowerCase(), k);
|
|
142
142
|
const unpack = (s) => s.match(/.{6}/g).map((h) => '#' + h);
|
|
143
143
|
const isHex = (c) => typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim());
|
|
144
|
+
// Nombres CSS comunes en listas de colores (["white", "steelblue"]): antes se
|
|
145
|
+
// descartaban en silencio y la escala caía a la por defecto.
|
|
146
|
+
const NAMED = {
|
|
147
|
+
black: '#000000', white: '#ffffff', gray: '#808080', grey: '#808080', lightgray: '#d3d3d3', lightgrey: '#d3d3d3', darkgray: '#a9a9a9', darkgrey: '#a9a9a9', silver: '#c0c0c0',
|
|
148
|
+
red: '#ff0000', darkred: '#8b0000', crimson: '#dc143c', tomato: '#ff6347', salmon: '#fa8072', pink: '#ffc0cb', maroon: '#800000',
|
|
149
|
+
orange: '#ffa500', darkorange: '#ff8c00', gold: '#ffd700', yellow: '#ffff00', khaki: '#f0e68c', beige: '#f5f5dc', ivory: '#fffff0', wheat: '#f5deb3', brown: '#a52a2a',
|
|
150
|
+
green: '#008000', darkgreen: '#006400', lime: '#00ff00', limegreen: '#32cd32', olive: '#808000', seagreen: '#2e8b57', forestgreen: '#228b22', teal: '#008080',
|
|
151
|
+
cyan: '#00ffff', aqua: '#00ffff', turquoise: '#40e0d0', lightblue: '#add8e6', skyblue: '#87ceeb', steelblue: '#4682b4', blue: '#0000ff', darkblue: '#00008b', navy: '#000080', royalblue: '#4169e1', cornflowerblue: '#6495ed', dodgerblue: '#1e90ff',
|
|
152
|
+
purple: '#800080', indigo: '#4b0082', violet: '#ee82ee', magenta: '#ff00ff', fuchsia: '#ff00ff', orchid: '#da70d6', lavender: '#e6e6fa', plum: '#dda0dd',
|
|
153
|
+
};
|
|
154
|
+
const toHex = (c) => (isHex(c) ? c.trim() : typeof c === 'string' ? NAMED[c.trim().toLowerCase()] : undefined);
|
|
144
155
|
|
|
145
156
|
/** Nombres disponibles, por tipo. */
|
|
146
157
|
export function colorScaleNames() {
|
|
@@ -161,7 +172,7 @@ export function colorScaleNames() {
|
|
|
161
172
|
export function resolveColorScale(v) {
|
|
162
173
|
if (!v) return null;
|
|
163
174
|
if (Array.isArray(v)) {
|
|
164
|
-
const stops = v.filter(
|
|
175
|
+
const stops = v.map(toHex).filter(Boolean);
|
|
165
176
|
return stops.length >= 2 ? { name: 'custom', stops, kind: 'sequential' } : null;
|
|
166
177
|
}
|
|
167
178
|
if (typeof v === 'object') {
|
package/src/dashboard.js
CHANGED
|
@@ -30,12 +30,19 @@ let core = null;
|
|
|
30
30
|
/** index.js registra plot/resolve/lint aquí (evita una importación circular). */
|
|
31
31
|
export function _bindDashboard(fns) { core = fns; }
|
|
32
32
|
|
|
33
|
+
/** Spec tolerante: charts/kpis/filters siempre listas de objetos (los nulos y valores sueltos se descartan). */
|
|
34
|
+
function tidy(spec) {
|
|
35
|
+
const list = (v) => (v == null || v === false ? [] : [].concat(v)).filter((x) => x != null);
|
|
36
|
+
return { ...spec, charts: list(spec.charts).filter((c) => typeof c === 'object'), kpis: list(spec.kpis), filters: list(spec.filters).filter((f) => typeof f === 'string' || (typeof f === 'object' && typeof f.field === 'string')) };
|
|
37
|
+
}
|
|
38
|
+
const hasField = (r, f) => r !== null && typeof r === 'object' && f in r;
|
|
39
|
+
|
|
33
40
|
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
34
41
|
|
|
35
42
|
// Ancho (de 12) según el tipo: lo que se lee de izquierda a derecha, ancho.
|
|
36
43
|
const WIDE = new Set(['line', 'area', 'gantt', 'heatmap', 'sankey', 'flowchart', 'network', 'jointplot']);
|
|
37
44
|
const NARROW = new Set(['donut', 'pie', 'radar', 'kpi', 'hist', 'kde', 'ecdf', 'count']);
|
|
38
|
-
const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart']);
|
|
45
|
+
const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart', 'table']);
|
|
39
46
|
|
|
40
47
|
function spanFor(s) {
|
|
41
48
|
if (s.size === 'full' || s.span === 12) return 12;
|
|
@@ -131,6 +138,7 @@ const colorField = (s) => s.series ?? s.hue ?? (Array.isArray(s.path) ? s.path[0
|
|
|
131
138
|
export function planDashboard(spec = {}) {
|
|
132
139
|
if (!core) throw new PlotError('plotagents no está inicializado.');
|
|
133
140
|
if (!spec || typeof spec !== 'object') throw new PlotError('El dashboard debe ser un objeto.', 'dashboard({ title, kpis, charts: [...] })');
|
|
141
|
+
spec = tidy(spec);
|
|
134
142
|
const locale = spec.locale || 'es';
|
|
135
143
|
const shared = spec.data ? ensureData(spec) : null;
|
|
136
144
|
const base = {};
|
|
@@ -191,7 +199,7 @@ export function planDashboard(spec = {}) {
|
|
|
191
199
|
const filters = [].concat(spec.filters || []).map((f) => {
|
|
192
200
|
const o = typeof f === 'string' ? { field: f } : { ...f };
|
|
193
201
|
const pool = [...(shared || []), ...panels.flatMap((p) => (Array.isArray(p.spec.data) && p.spec.data !== shared ? p.spec.data : []))];
|
|
194
|
-
const vals = uniq(pool.map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
|
|
202
|
+
const vals = uniq(pool.filter((r) => hasField(r, o.field)).map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
|
|
195
203
|
const kind = pool.length ? fieldKind(pool, o.field) : 'category';
|
|
196
204
|
vals.sort((a, b) => (kind === 'date' ? parseDate(a) - parseDate(b) : kind === 'year' || kind === 'number' ? num(a) - num(b) : 0));
|
|
197
205
|
return { field: o.field, label: o.label ?? o.field.charAt(0).toUpperCase() + o.field.slice(1), values: vals.map(String), control: o.control ?? (vals.length <= 6 ? 'buttons' : 'select'), value: o.value ?? o.default ?? null, all: o.all ?? T(locale, 'Todos', 'All') };
|
|
@@ -205,6 +213,7 @@ export function planDashboard(spec = {}) {
|
|
|
205
213
|
|
|
206
214
|
// ── Revisión (lint) ────────────────────────────────────────────────────────
|
|
207
215
|
export function lintDashboard(spec = {}) {
|
|
216
|
+
spec = tidy(spec && typeof spec === 'object' ? spec : {});
|
|
208
217
|
const locale = spec.locale || 'es';
|
|
209
218
|
const L = (es, en) => T(locale, es, en);
|
|
210
219
|
const errors = [], warnings = [], suggestions = [];
|
|
@@ -226,7 +235,7 @@ export function lintDashboard(spec = {}) {
|
|
|
226
235
|
if (untitled) suggestions.push({ code: 'dash-chart-titles', message: L(`${untitled} gráfico(s) sin título.`, `${untitled} chart(s) without a title.`), fix: L('Cada panel necesita su hallazgo como título.', 'Every panel needs its finding as the title.') });
|
|
227
236
|
for (const f of [].concat(spec.filters || [])) {
|
|
228
237
|
const field = typeof f === 'string' ? f : f.field;
|
|
229
|
-
const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => r
|
|
238
|
+
const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => hasField(r, field)));
|
|
230
239
|
if (!has) warnings.push({ code: 'dash-filter-field', message: L(`El filtro "${field}" no existe en los datos de ningún gráfico.`, `The filter "${field}" is not in any chart's data.`), fix: L('Usa un campo de `data`.', 'Use a field of `data`.') });
|
|
231
240
|
}
|
|
232
241
|
// Revisión de cada gráfico, con su número y título.
|
|
@@ -291,7 +300,9 @@ export function kpiMarkup(plan, rows) {
|
|
|
291
300
|
if (k.trendBy && !k.trend && rows && rows.length) {
|
|
292
301
|
const by = new Map();
|
|
293
302
|
for (const r of cmp ? inPeriod(cmp.current) : rows) { const key = r[k.trendBy]; if (!by.has(key)) by.set(key, []); by.get(key).push(r); }
|
|
294
|
-
|
|
303
|
+
// Periodos numéricos (1…12) se ordenan como números; el resto, como texto (fechas ISO).
|
|
304
|
+
const allNum = [...by.keys()].every((x) => Number.isFinite(num(x)));
|
|
305
|
+
const ks = [...by.keys()].sort((x, y) => (allNum ? num(x) - num(y) : String(x) < String(y) ? -1 : String(x) > String(y) ? 1 : 0));
|
|
295
306
|
k = { ...k, trend: ks.map((x) => computeKpi(k, by.get(x))) };
|
|
296
307
|
}
|
|
297
308
|
const fmt = makeFormat(k.format, locale, [v]);
|
|
@@ -344,7 +355,7 @@ export function filterSpec(spec, plan, active, locale) {
|
|
|
344
355
|
const v = active[i];
|
|
345
356
|
if (v === null || v === undefined || v === '') return;
|
|
346
357
|
if (s.time === f.field) { s = { ...s, timeValue: v }; return; }
|
|
347
|
-
if (Array.isArray(s.data) && s.data.some((r) => r
|
|
358
|
+
if (Array.isArray(s.data) && s.data.some((r) => hasField(r, f.field))) { s = { ...s, data: s.data.filter((r) => hasField(r, f.field) && String(r[f.field]) === String(v)) }; tags.push(`${f.label}: ${v}`); }
|
|
348
359
|
});
|
|
349
360
|
if (tags.length) s = { ...s, subtitle: [s.subtitle, tags.join(' · ')].filter(Boolean).join(' · ') };
|
|
350
361
|
return s;
|
|
@@ -368,7 +379,7 @@ export function mountDashboard(root, plan) {
|
|
|
368
379
|
}
|
|
369
380
|
// KPIs calculados: se recalculan con los filtros.
|
|
370
381
|
if (plan.data && plan.kpis.some((k) => k.value === undefined)) {
|
|
371
|
-
const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !(f.field
|
|
382
|
+
const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !hasField(r, f.field) || String(r[f.field]) === String(active[i])));
|
|
372
383
|
const slot = root.querySelector('.pa-kslot');
|
|
373
384
|
if (slot) slot.innerHTML = kpiMarkup(plan, rows);
|
|
374
385
|
}
|
|
@@ -430,9 +441,11 @@ ${bundle ? `<script>${bundle.replace(/<\/(script)/gi, '<\\/$1')}</script>
|
|
|
430
441
|
|
|
431
442
|
/** Nunca lanza: { ok, html, plan, errors, warnings, suggestions } (para agentes). */
|
|
432
443
|
export function safeDashboard(spec) {
|
|
433
|
-
|
|
444
|
+
let rep;
|
|
445
|
+
try { rep = lintDashboard(spec || {}); }
|
|
446
|
+
catch (e) { return { ok: false, html: null, plan: null, errors: [{ code: 'render', message: String(e.message).split('\n')[0], fix: e.hint || 'Revisa que `charts` sea una lista de specs y `kpis` una lista de objetos.' }], warnings: [], suggestions: [] }; }
|
|
434
447
|
if (!rep.ok) return { ...rep, html: null, plan: null };
|
|
435
448
|
try { const html = dashboard(spec); return { ...rep, ok: true, html, plan: planDashboard(spec) }; }
|
|
436
|
-
catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint }] }; }
|
|
449
|
+
catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint || 'Revisa `charts`, `kpis` y `data` del dashboard.' }] }; }
|
|
437
450
|
}
|
|
438
451
|
|
package/src/data.js
CHANGED
|
@@ -15,6 +15,8 @@ export function num(v) {
|
|
|
15
15
|
if (typeof v === 'number') return v;
|
|
16
16
|
if (v === null || v === undefined || v === '' || typeof v === 'boolean') return NaN;
|
|
17
17
|
let s = String(v).trim().replace(/[\s$€£%]|US\$|CLP|UF/g, '').replace('−', '-');
|
|
18
|
+
// Solo símbolos ("%", "$", " ") no es el 0; y nada de "0x10" ni "Infinity".
|
|
19
|
+
if (!/^[+-]?(\d[\d.,]*|[.,]\d[\d.,]*)(e[+-]?\d+)?$/i.test(s)) return NaN;
|
|
18
20
|
const hasC = s.includes(','), hasD = s.includes('.');
|
|
19
21
|
if (hasC && hasD) s = s.lastIndexOf(',') > s.lastIndexOf('.') ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');
|
|
20
22
|
else if (hasC) s = /,\d{3}(?!\d)/.test(s) && !/,\d{1,2}$/.test(s) ? s.replace(/,/g, '') : s.replace(',', '.');
|
package/src/facet.js
CHANGED
|
@@ -32,9 +32,10 @@ export function facet(spec, theme, renderOne) {
|
|
|
32
32
|
const frame = createFrame({ ...spec, height: 200 }, theme, { width: 720 });
|
|
33
33
|
const area = frame.layout(legend);
|
|
34
34
|
const gap = spec.panelGap ?? 14;
|
|
35
|
-
// Columnas pedidas, pero nunca paneles
|
|
36
|
-
|
|
37
|
-
const
|
|
35
|
+
// Columnas pedidas, pero nunca paneles más angostos que el mínimo de un gráfico
|
|
36
|
+
// (240 px, ver createFrame): más angostos se montarían unos sobre otros.
|
|
37
|
+
const fit = Math.max(1, Math.floor((area.w + gap) / 240));
|
|
38
|
+
const want = (typeof spec.columns === 'number' ? spec.columns : undefined) ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
|
|
38
39
|
const cols = Math.max(1, Math.min(keys.length, want, fit));
|
|
39
40
|
const pw = (area.w - gap * (cols - 1)) / cols;
|
|
40
41
|
const ph = spec.panelHeight ?? Math.round(Math.min(300, Math.max(160, pw * 0.66)));
|
package/src/format.js
CHANGED
|
@@ -4,19 +4,29 @@
|
|
|
4
4
|
const SEP = {
|
|
5
5
|
es: { group: '.', dec: ',' },
|
|
6
6
|
en: { group: ',', dec: '.' },
|
|
7
|
+
pt: { group: '.', dec: ',' },
|
|
8
|
+
de: { group: '.', dec: ',' },
|
|
9
|
+
fr: { group: '\u202F', dec: ',' }, // espacio fino: 1 234,5
|
|
7
10
|
};
|
|
8
11
|
|
|
9
12
|
const COMPACT = {
|
|
10
13
|
es: [[1e12, ' B'], [1e6, ' M'], [1e3, ' mil']],
|
|
11
14
|
en: [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
|
|
15
|
+
pt: [[1e12, ' tri'], [1e9, ' bi'], [1e6, ' mi'], [1e3, ' mil']],
|
|
16
|
+
fr: [[1e12, ' Bn'], [1e9, ' Md'], [1e6, ' M'], [1e3, ' k']],
|
|
17
|
+
de: [[1e12, ' Bio.'], [1e9, ' Mrd.'], [1e6, ' Mio.'], [1e3, ' Tsd.']],
|
|
12
18
|
};
|
|
13
19
|
|
|
14
20
|
export const MONTHS = {
|
|
15
21
|
es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
|
|
16
22
|
en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
23
|
+
pt: ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'],
|
|
24
|
+
fr: ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'],
|
|
25
|
+
de: ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'],
|
|
17
26
|
};
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
/** Idioma de formato: es, en, pt, fr o de ("pt-BR" → pt); cualquier otro, es. */
|
|
29
|
+
const loc = (l) => { const k = String(l || 'es').toLowerCase().slice(0, 2); return k in SEP ? k : 'es'; };
|
|
20
30
|
|
|
21
31
|
/** Número con separadores. `decimals` fijo; `trim` quita ceros finales. */
|
|
22
32
|
export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
@@ -24,7 +34,8 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
|
24
34
|
if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
|
|
25
35
|
const { group, dec } = SEP[loc(locale)];
|
|
26
36
|
const neg = v < 0;
|
|
27
|
-
|
|
37
|
+
const fixed = Math.abs(v) >= 1e21 ? BigInt(Math.abs(v)).toString() : Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))); // toFixed pasa a "1e+21"
|
|
38
|
+
let [int, frac = ''] = fixed.split('.');
|
|
28
39
|
int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
|
|
29
40
|
if (trim) frac = frac.replace(/0+$/, '');
|
|
30
41
|
const out = frac ? int + dec + frac : int;
|
|
@@ -44,10 +55,16 @@ export function smartDecimals(v) {
|
|
|
44
55
|
export function formatCompact(v, locale = 'es', decimals) {
|
|
45
56
|
const l = loc(locale);
|
|
46
57
|
const a = Math.abs(v);
|
|
47
|
-
|
|
58
|
+
const tbl = COMPACT[l];
|
|
59
|
+
for (let i = 0; i < tbl.length; i++) {
|
|
60
|
+
let [n, suf] = tbl[i];
|
|
48
61
|
if (a >= n) {
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
let s = v / n;
|
|
63
|
+
let d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
|
|
64
|
+
// 999.999 → "1.000 mil" no: al redondear llega a 1000, sube a la unidad siguiente (1 M).
|
|
65
|
+
if (i > 0 && tbl[i - 1][0] / n === 1000 && Math.abs(+Math.abs(s).toFixed(d)) >= 1000) {
|
|
66
|
+
[n, suf] = tbl[i - 1]; s = v / n; d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
|
|
67
|
+
}
|
|
51
68
|
return formatNumber(s, d, l, decimals === undefined) + suf;
|
|
52
69
|
}
|
|
53
70
|
}
|
|
@@ -109,7 +126,7 @@ export function makeFormat(fmt, locale = 'es', values = []) {
|
|
|
109
126
|
return f;
|
|
110
127
|
}
|
|
111
128
|
|
|
112
|
-
function currencyPrefix(cur, l) {
|
|
129
|
+
export function currencyPrefix(cur, l) {
|
|
113
130
|
if (!cur) return '$';
|
|
114
131
|
const c = String(cur).toUpperCase();
|
|
115
132
|
return { USD: 'US$', CLP: '$', EUR: '€', UF: 'UF ', GBP: '£', PEN: 'S/ ', MXN: 'MX$', BRL: 'R$' }[c] || c + ' ';
|
|
@@ -121,8 +138,8 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
|
|
|
121
138
|
const decFor = (s) => {
|
|
122
139
|
const a = Math.abs(s) || 1;
|
|
123
140
|
let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
|
|
124
|
-
while (d <
|
|
125
|
-
return Math.min(
|
|
141
|
+
while (d < 12 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
|
|
142
|
+
return Math.min(12, d);
|
|
126
143
|
};
|
|
127
144
|
let core;
|
|
128
145
|
if (type === 'year') core = (v) => String(Math.round(v));
|
|
@@ -141,9 +158,12 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
|
|
|
141
158
|
? (v) => formatNumber(v, decFor(step), l)
|
|
142
159
|
: (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
|
|
143
160
|
} else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
|
|
161
|
+
// Una marca entera se escribe sin ",0" (600, no 600,0); las fraccionarias
|
|
162
|
+
// conservan sus decimales (0 · 0,5 · 1 · 1,5).
|
|
163
|
+
const zeroFrac = new RegExp('\\' + SEP[l].dec + '0+(?!\\d)');
|
|
144
164
|
return (v) => {
|
|
145
165
|
if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
|
|
146
|
-
const s = core(v);
|
|
166
|
+
const s = core(v).replace(zeroFrac, '');
|
|
147
167
|
if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
|
|
148
168
|
return pre + s + suf;
|
|
149
169
|
};
|
|
@@ -163,7 +183,8 @@ export function parseDate(v) {
|
|
|
163
183
|
if (v instanceof Date) return v.getTime();
|
|
164
184
|
if (typeof v === 'string') {
|
|
165
185
|
const m = ISO.exec(v.trim());
|
|
166
|
-
|
|
186
|
+
// Rangos válidos: "2020-21" (temporada) o "2024-13" no son fechas.
|
|
187
|
+
if (m && +m[2] >= 1 && +m[2] <= 12 && +(m[3] || 1) >= 1 && +(m[3] || 1) <= new Date(Date.UTC(+m[1], +m[2], 0)).getUTCDate() && +(m[4] || 0) < 24 && +(m[5] || 0) < 60 && +(m[6] || 0) < 60) {
|
|
167
188
|
const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
|
|
168
189
|
let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
|
|
169
190
|
const z = m[8];
|
|
@@ -178,7 +199,7 @@ export function parseDate(v) {
|
|
|
178
199
|
return NaN;
|
|
179
200
|
}
|
|
180
201
|
|
|
181
|
-
export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' &&
|
|
202
|
+
export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && Number.isFinite(parseDate(v)));
|
|
182
203
|
|
|
183
204
|
/**
|
|
184
205
|
* Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
|
|
@@ -196,8 +217,10 @@ export function formatDate(ms, unit, locale = 'es', withYear = true) {
|
|
|
196
217
|
return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
|
|
197
218
|
}
|
|
198
219
|
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');
|
|
220
|
+
if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' || l === 'de' ? 'Q' : 'T');
|
|
200
221
|
if (unit === 'month') return withYear ? `${m} ${y}` : m;
|
|
201
222
|
const day = d.getUTCDate();
|
|
202
|
-
|
|
223
|
+
if (l === 'en') return `${m} ${day}${withYear ? ', ' + y : ''}`;
|
|
224
|
+
if (l === 'de') return `${day}. ${m}${withYear ? ' ' + y : ''}`;
|
|
225
|
+
return `${day} ${m}${withYear ? ' ' + y : ''}`;
|
|
203
226
|
}
|
package/src/frame.js
CHANGED
|
@@ -4,6 +4,7 @@ import { el, esc, g, text, r2 } from './svg.js';
|
|
|
4
4
|
import { measure, wrap, truncate } from './text.js';
|
|
5
5
|
import { FONT, FONT_CSS } from './theme.js';
|
|
6
6
|
import { ctx } from './context.js';
|
|
7
|
+
import { words } from './i18n.js';
|
|
7
8
|
|
|
8
9
|
export const SIZES = {
|
|
9
10
|
title: 22,
|
|
@@ -49,7 +50,7 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
49
50
|
if (subLines.length) y += subLines.length * slh + 2;
|
|
50
51
|
if (titleLines.length || subLines.length) y += 10;
|
|
51
52
|
|
|
52
|
-
const srcText = spec.source ? (/^fuente
|
|
53
|
+
const srcText = spec.source ? (/^(fuente|source|fonte|quelle)/i.test(spec.source) ? spec.source : words(spec.locale).source + spec.source) : '';
|
|
53
54
|
// Una fuente larga salta a una segunda línea en vez de salirse por la izquierda.
|
|
54
55
|
const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
|
|
55
56
|
const srcLH = Math.round(T.source * X * 1.35);
|
|
@@ -97,7 +98,7 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
97
98
|
},
|
|
98
99
|
/** Permite a gráficos de alto automático (kpi) fijar el alto final. */
|
|
99
100
|
setHeight(h) { height = h; },
|
|
100
|
-
finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
|
|
101
|
+
finish(body, { desc = '', type = 'chart', defs = '', role = 'img', css = '' } = {}) {
|
|
101
102
|
if (legendBottom) {
|
|
102
103
|
const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
|
|
103
104
|
header.push(svg);
|
|
@@ -108,7 +109,8 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
108
109
|
const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
|
|
109
110
|
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
111
|
+ (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
|
+
+ (/^(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}' : '')
|
|
113
|
+
+ css;
|
|
112
114
|
return el(
|
|
113
115
|
'svg',
|
|
114
116
|
{
|
|
@@ -116,8 +118,9 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
116
118
|
viewBox: `0 0 ${width} ${r2(height)}`,
|
|
117
119
|
width,
|
|
118
120
|
height: r2(height),
|
|
119
|
-
role
|
|
120
|
-
'aria-labelledby': `${id}-t ${id}-d`,
|
|
121
|
+
role,
|
|
122
|
+
'aria-labelledby': role === 'img' ? `${id}-t ${id}-d` : `${id}-t`,
|
|
123
|
+
'aria-describedby': role === 'img' ? null : `${id}-d`,
|
|
121
124
|
fontFamily: spec.fontFamily || theme.font?.family || FONT,
|
|
122
125
|
class: 'pa-chart',
|
|
123
126
|
'data-pa-type': type,
|
package/src/geo/projection.js
CHANGED
|
@@ -95,8 +95,10 @@ export const projections = {
|
|
|
95
95
|
*/
|
|
96
96
|
export function makeProjection(p) {
|
|
97
97
|
if (!p) return null;
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Sin distinguir mayúsculas ni guiones: "equalearth", "Equal-Earth" y "equalEarth" son lo mismo.
|
|
99
|
+
const find = (n) => { const k = String(n ?? '').toLowerCase().replace(/[-_\s]/g, ''); const hit = Object.keys(projections).find((x) => x.toLowerCase().replace(/[-_\s]/g, '') === k); return hit ? projections[hit] : null; };
|
|
100
|
+
if (typeof p === 'string') { const f = projections[p] || find(p); return f ? f() : null; }
|
|
101
|
+
if (typeof p === 'object') { const f = projections[p.type || p.name] || find(p.type || p.name); return f ? f(p) : null; }
|
|
100
102
|
return null;
|
|
101
103
|
}
|
|
102
104
|
|
package/src/hierarchy.js
CHANGED
|
@@ -66,6 +66,12 @@ export function findNode(root, focus = []) {
|
|
|
66
66
|
return node;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Nodo de foco para dibujar: una hoja no se puede "abrir" (quedaría vacío), se usa su padre. */
|
|
70
|
+
export function focusNode(root, focus = []) {
|
|
71
|
+
const n = findNode(root, focus);
|
|
72
|
+
return !n.children.length && n.parent ? n.parent : n;
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
export const height = (n) => (n.children.length ? 1 + Math.max(...n.children.map(height)) : 0);
|
|
70
76
|
export const descendants = (n) => [n, ...n.children.flatMap(descendants)];
|
|
71
77
|
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Idioma: reconocimiento a partir del texto del spec y las pocas palabras que
|
|
2
|
+
// la biblioteca escribe por su cuenta (fuente, total, buscar…). Lo demás
|
|
3
|
+
// (paginación, flechas, ✓) se dibuja con símbolos que no dependen del idioma.
|
|
4
|
+
//
|
|
5
|
+
// detectLocale(['Ventas por región, 2025', 'Biobío']) → 'es'
|
|
6
|
+
// detectLocale(['Sales grew 12% in the South']) → 'en'
|
|
7
|
+
// locale: 'auto' en cualquier spec (y por defecto en `table`) lo usa.
|
|
8
|
+
|
|
9
|
+
export const LANGS = ['es', 'en', 'pt', 'fr', 'de'];
|
|
10
|
+
|
|
11
|
+
const WORDS = {
|
|
12
|
+
es: { source: 'Fuente: ', total: 'Total', search: 'Buscar', none: 'Sin resultados', clear: 'Borrar', page: 'Página', prev: 'Anterior', next: 'Siguiente', sort: 'Ordenar por', yes: 'Sí', no: 'No', rows: 'filas', table: 'Tabla', of: 'de' },
|
|
13
|
+
en: { source: 'Source: ', total: 'Total', search: 'Search', none: 'No results', clear: 'Clear', page: 'Page', prev: 'Previous', next: 'Next', sort: 'Sort by', yes: 'Yes', no: 'No', rows: 'rows', table: 'Table', of: 'of' },
|
|
14
|
+
pt: { source: 'Fonte: ', total: 'Total', search: 'Buscar', none: 'Sem resultados', clear: 'Limpar', page: 'Página', prev: 'Anterior', next: 'Próxima', sort: 'Ordenar por', yes: 'Sim', no: 'Não', rows: 'linhas', table: 'Tabela', of: 'de' },
|
|
15
|
+
fr: { source: 'Source : ', total: 'Total', search: 'Rechercher', none: 'Aucun résultat', clear: 'Effacer', page: 'Page', prev: 'Précédente', next: 'Suivante', sort: 'Trier par', yes: 'Oui', no: 'Non', rows: 'lignes', table: 'Tableau', of: 'sur' },
|
|
16
|
+
de: { source: 'Quelle: ', total: 'Gesamt', search: 'Suchen', none: 'Keine Treffer', clear: 'Löschen', page: 'Seite', prev: 'Zurück', next: 'Weiter', sort: 'Sortieren nach', yes: 'Ja', no: 'Nein', rows: 'Zeilen', table: 'Tabelle', of: 'von' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Código de idioma soportado ('es', 'en', 'pt', 'fr', 'de') a partir de cualquier etiqueta ('pt-BR', 'en_US'…). */
|
|
20
|
+
export function langOf(locale) {
|
|
21
|
+
const k = String(locale || 'es').toLowerCase().slice(0, 2);
|
|
22
|
+
return LANGS.includes(k) ? k : 'es';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Palabras de interfaz en el idioma del gráfico. */
|
|
26
|
+
export const words = (locale) => WORDS[langOf(locale)];
|
|
27
|
+
|
|
28
|
+
// Palabras funcionales frecuentes de cada idioma. Una palabra compartida
|
|
29
|
+
// (de, la, que…) reparte su peso entre los idiomas que la usan.
|
|
30
|
+
const STOP = {
|
|
31
|
+
es: 'de la el los las y en por con para del que una un se al más es son sus su según entre como año años sin hasta desde ventas región regiones total mil millones porcentaje hogares personas este esta sobre cada ha han fue lidera mayor menor tasa crecimiento meta comuna comunas mes meses semana proyecto estado avance',
|
|
32
|
+
en: 'the of and in by for with to is are from on at than more its as per was were this that which share sales region regions total thousand million percent households people over each has have had leads largest smallest rate growth target month months week project status progress revenue year years',
|
|
33
|
+
pt: 'de da do das dos e em por com para que uma um ao mais é são sua seu entre como ano anos não até desde pelo pela no na nos nas vendas região total mil milhões taxa crescimento meta mês meses projeto estado cada foi tem',
|
|
34
|
+
fr: 'de la le les et en par pour avec du des que une un au aux plus est sont sa son ses entre comme année années sans jusqu depuis sur dans ventes région total mille millions taux croissance objectif mois semaine projet état chaque été',
|
|
35
|
+
de: 'der die das und in von mit für zu ist sind den dem des ein eine einer im am mehr als nach bei auf aus nicht jahr jahre umsatz region gesamt tausend millionen anteil wachstum ziel monat monate woche projekt status jede wurde hat',
|
|
36
|
+
};
|
|
37
|
+
const INDEX = new Map();
|
|
38
|
+
for (const [lang, list] of Object.entries(STOP)) for (const w of list.split(' ')) { if (!INDEX.has(w)) INDEX.set(w, []); INDEX.get(w).push(lang); }
|
|
39
|
+
|
|
40
|
+
// Letras que delatan un idioma (se suman a las palabras).
|
|
41
|
+
const CHARS = [
|
|
42
|
+
[/[ñ¿¡]/g, { es: 3 }],
|
|
43
|
+
[/[ãõ]/g, { pt: 3 }],
|
|
44
|
+
[/ç/g, { pt: 1.5, fr: 1.5 }],
|
|
45
|
+
[/[èêëàâùûœî]/g, { fr: 2 }],
|
|
46
|
+
[/[äöüß]/g, { de: 3 }],
|
|
47
|
+
[/[áíóú]/g, { es: 1, pt: 1 }],
|
|
48
|
+
[/é/g, { es: 0.5, pt: 0.5, fr: 0.8 }],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Idioma más probable de unos textos (títulos, encabezados, categorías), o
|
|
53
|
+
* null si no hay señal suficiente. Determinista y sin diccionarios grandes.
|
|
54
|
+
*/
|
|
55
|
+
export function detectLocale(texts) {
|
|
56
|
+
const s = [].concat(texts || []).filter((t) => typeof t === 'string' && t).join(' \n ').toLowerCase().slice(0, 6000);
|
|
57
|
+
if (!s) return null;
|
|
58
|
+
const score = { es: 0, en: 0, pt: 0, fr: 0, de: 0 };
|
|
59
|
+
for (const tok of s.split(/[^\p{L}]+/u)) {
|
|
60
|
+
const langs = tok && INDEX.get(tok);
|
|
61
|
+
if (langs) for (const l of langs) score[l] += 1 / langs.length;
|
|
62
|
+
}
|
|
63
|
+
for (const [re, w] of CHARS) {
|
|
64
|
+
const n = (s.match(re) || []).length;
|
|
65
|
+
if (n) for (const [l, v] of Object.entries(w)) score[l] += Math.min(6, n) * v;
|
|
66
|
+
}
|
|
67
|
+
const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
|
|
68
|
+
const [[best, a], [, b]] = ranked;
|
|
69
|
+
if (a < 1 || a < b * 1.25 + 0.25) return null;
|
|
70
|
+
return best;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Idioma del documento en el navegador (`<html lang>` o el del sistema), o null. */
|
|
74
|
+
export function browserLocale() {
|
|
75
|
+
// Solo en un navegador: Node 21+ también define `navigator`, y el SVG del servidor no debe depender de él.
|
|
76
|
+
if (typeof document === 'undefined') return null;
|
|
77
|
+
try {
|
|
78
|
+
const l = (document.documentElement && document.documentElement.lang) || (typeof navigator !== 'undefined' && navigator.language) || '';
|
|
79
|
+
const k = String(l).toLowerCase().slice(0, 2);
|
|
80
|
+
return LANGS.includes(k) ? k : null;
|
|
81
|
+
} catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Textos de un spec que delatan su idioma: título, bajada, fuente, nombres de
|
|
86
|
+
* columna y algunas categorías.
|
|
87
|
+
*/
|
|
88
|
+
export function specTexts(spec, data) {
|
|
89
|
+
const out = [spec.title, spec.subtitle, spec.source, spec.dashedLabel];
|
|
90
|
+
for (const c of [].concat(Array.isArray(spec.columns) ? spec.columns : [])) {
|
|
91
|
+
if (typeof c === 'string') out.push(c);
|
|
92
|
+
else if (c && typeof c === 'object') out.push(c.label, c.field);
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(data)) {
|
|
95
|
+
const rows = data.slice(0, 40);
|
|
96
|
+
const keys = new Set();
|
|
97
|
+
for (const r of rows) if (r && typeof r === 'object') for (const [k, v] of Object.entries(r)) { keys.add(k); if (typeof v === 'string' && v.length > 2 && !/^[\d\s.,%$€£-]+$/.test(v)) out.push(v); }
|
|
98
|
+
// Nombres de campo con guiones bajos o camelCase: "ventas_region" → "ventas region".
|
|
99
|
+
for (const k of keys) out.push(String(k).replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2'));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resuelve `locale: "auto"`: idioma del texto, luego el del navegador, luego español. */
|
|
105
|
+
export function autoLocale(spec, data) {
|
|
106
|
+
return detectLocale(specTexts(spec, data)) || browserLocale() || 'es';
|
|
107
|
+
}
|
package/src/index.js
CHANGED
|
@@ -31,8 +31,11 @@ import './geo/chile.js';
|
|
|
31
31
|
import './geo/world.js';
|
|
32
32
|
import './geo/usa.js';
|
|
33
33
|
import { heatmap } from './charts/heatmap.js';
|
|
34
|
+
import { corr, pairplot, correlations } from './charts/corr.js';
|
|
34
35
|
import { sankey } from './charts/sankey.js';
|
|
35
36
|
import { kpi } from './charts/kpi.js';
|
|
37
|
+
import { table } from './charts/table.js';
|
|
38
|
+
import { autoLocale, detectLocale } from './i18n.js';
|
|
36
39
|
import { ensureData, PlotError, fieldKind, uniq, num } from './data.js';
|
|
37
40
|
import { facet } from './facet.js';
|
|
38
41
|
import { parseDate } from './format.js';
|
|
@@ -59,8 +62,11 @@ const RENDERERS = {
|
|
|
59
62
|
donut: (s, t) => donut(s, t),
|
|
60
63
|
pie: (s, t) => pie(s, t),
|
|
61
64
|
heatmap: (s, t) => heatmap(s, t),
|
|
65
|
+
corr: (s, t) => corr(s, t),
|
|
66
|
+
pairplot: (s, t) => pairplot(s, t),
|
|
62
67
|
sankey: (s, t) => sankey(s, t),
|
|
63
68
|
kpi: (s, t) => kpi(s, t),
|
|
69
|
+
table: (s, t) => table(s, t),
|
|
64
70
|
hist: (s, t) => hist(s, t),
|
|
65
71
|
kde: (s, t) => kde(s, t),
|
|
66
72
|
ecdf: (s, t) => ecdf(s, t),
|
|
@@ -93,7 +99,7 @@ export function resolve(spec = {}) {
|
|
|
93
99
|
// refLines / annotations: un objeto suelto vale como lista de uno.
|
|
94
100
|
for (const k of ['refLines', 'annotations']) if (s[k] != null && !Array.isArray(s[k]) && typeof s[k] === 'object') s[k] = [s[k]];
|
|
95
101
|
if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
|
|
96
|
-
let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
|
|
102
|
+
let type = s.type && s.type !== 'auto' ? String(s.type).trim().toLowerCase() : null;
|
|
97
103
|
if (type) {
|
|
98
104
|
const [t, extra] = normalizeType(type);
|
|
99
105
|
type = t;
|
|
@@ -105,6 +111,10 @@ export function resolve(spec = {}) {
|
|
|
105
111
|
}
|
|
106
112
|
if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
|
|
107
113
|
s.type = type;
|
|
114
|
+
// Idioma: `locale: "auto"` (y las tablas sin `locale`) lo reconocen del texto del spec.
|
|
115
|
+
if (s.locale === 'auto' || (s.locale === undefined && type === 'table')) {
|
|
116
|
+
s.locale = autoLocale(s, Array.isArray(s.data) ? s.data : []);
|
|
117
|
+
}
|
|
108
118
|
// Colormap divergente (RdBu, coolwarm…): centrada en 0 si los datos lo cruzan; si no, en el medio.
|
|
109
119
|
const cs = resolveColorScale(s.colorScale ?? s.cmap ?? s.colormap);
|
|
110
120
|
if (cs && cs.kind === 'diverging' && s.midpoint === undefined && s.diverging === undefined) {
|
|
@@ -260,7 +270,7 @@ export function safePlot(spec) {
|
|
|
260
270
|
// Se dibujó (con valores de respaldo), pero los errores de lint igual se devuelven: ok = sin errores.
|
|
261
271
|
return { ok: report.errors.length === 0, svg, type: report.type, description: describeSvg(svg), errors: report.errors, warnings: report.warnings, suggestions: report.suggestions };
|
|
262
272
|
} catch (e) {
|
|
263
|
-
const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
|
|
273
|
+
const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint || 'Revisa los campos del spec contra el schema (`toolDefinition()`): textos donde van textos, listas donde van listas.' }];
|
|
264
274
|
return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
|
|
265
275
|
}
|
|
266
276
|
}
|
|
@@ -299,7 +309,7 @@ export function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 }
|
|
|
299
309
|
${[...new Set([FONT_CSS, ...list.map((s) => themeFor(withDefaults(s)).font?.import).filter(Boolean)])].map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join('\n')}
|
|
300
310
|
<style>
|
|
301
311
|
:root{color-scheme:${dark ? 'dark' : 'light'}}
|
|
302
|
-
body{margin:0;background:${bg};font-family:${
|
|
312
|
+
body{margin:0;background:${cssText(bg)};font-family:${cssText(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
|
|
303
313
|
main{max-width:${columns > 1 ? 1500 : 820}px;margin:0 auto;padding:24px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,${columns > 1 ? 560 : 320}px),1fr));gap:24px}
|
|
304
314
|
figure.pa{margin:0;min-width:0}
|
|
305
315
|
figure.pa svg{width:100%;height:auto;display:block}
|
|
@@ -311,6 +321,9 @@ ${boot}
|
|
|
311
321
|
</body></html>`;
|
|
312
322
|
}
|
|
313
323
|
|
|
324
|
+
/** Texto dentro de <style>: las entidades HTML no se decodifican ahí (esc() rompería las comillas), así que se quitan los caracteres que cierran la regla o la etiqueta. */
|
|
325
|
+
const cssText = (v) => String(v ?? '').replace(/[<>{};\\&]/g, '');
|
|
326
|
+
|
|
314
327
|
/** Código de la biblioteca en un archivo (para incrustar en HTML). */
|
|
315
328
|
export function bundleSource(file = 'plotagents.js') {
|
|
316
329
|
try {
|
|
@@ -327,7 +340,7 @@ _bindDashboard({ plot, resolve, lint, bundleSource, resolveDefaults: withDefault
|
|
|
327
340
|
_setProbe((s) => plot(s));
|
|
328
341
|
export { render };
|
|
329
342
|
|
|
330
|
-
export { countWords, dashboardSchema };
|
|
343
|
+
export { countWords, dashboardSchema, detectLocale, correlations };
|
|
331
344
|
export { resolveColorScale, colorScaleNames, interpolateColor, COLORMAPS, QUALITATIVE };
|
|
332
345
|
export { dashboard, renderDashboard, planDashboard, lintDashboard, safeDashboard, mountDashboard, packRows, balancedRows };
|
|
333
346
|
export { RENDERERS, ALIASES, registerGeo, getGeo, registeredGeo, toFeatureCollection, loadGeo };
|