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/dist/plotagents.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! plotagents 0.
|
|
1
|
+
/*! plotagents 0.8.0 · MIT · https://github.com/carlosnavarreteliz/plotagents */
|
|
2
2
|
(function (global) {
|
|
3
3
|
'use strict';
|
|
4
4
|
var __defs = {}, __cache = {};
|
|
@@ -31,8 +31,11 @@ const { registerGeo, getGeo, registeredGeo, toFeatureCollection } = __req("src/g
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
const { heatmap } = __req("src/charts/heatmap.js");
|
|
34
|
+
const { corr, pairplot, correlations } = __req("src/charts/corr.js");
|
|
34
35
|
const { sankey } = __req("src/charts/sankey.js");
|
|
35
36
|
const { kpi } = __req("src/charts/kpi.js");
|
|
37
|
+
const { table } = __req("src/charts/table.js");
|
|
38
|
+
const { autoLocale, detectLocale } = __req("src/i18n.js");
|
|
36
39
|
const { ensureData, PlotError, fieldKind, uniq, num } = __req("src/data.js");
|
|
37
40
|
const { facet } = __req("src/facet.js");
|
|
38
41
|
const { parseDate } = __req("src/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),
|
|
@@ -92,7 +98,7 @@ function resolve(spec = {}) {
|
|
|
92
98
|
const s = withDefaults(spec);
|
|
93
99
|
for (const k of ['refLines', 'annotations']) if (s[k] != null && !Array.isArray(s[k]) && typeof s[k] === 'object') s[k] = [s[k]];
|
|
94
100
|
if (s.color && !s.series && typeof s.color === 'string' && !s.color.startsWith('#')) s.series = s.color;
|
|
95
|
-
let type = s.type && s.type !== 'auto' ? String(s.type).toLowerCase() : null;
|
|
101
|
+
let type = s.type && s.type !== 'auto' ? String(s.type).trim().toLowerCase() : null;
|
|
96
102
|
if (type) {
|
|
97
103
|
const [t, extra] = normalizeType(type);
|
|
98
104
|
type = t;
|
|
@@ -104,6 +110,9 @@ function resolve(spec = {}) {
|
|
|
104
110
|
}
|
|
105
111
|
if (!RENDERERS[type]) throw new PlotError(`Tipo de gráfico "${s.type}" no soportado.`, `Usa uno de: ${TYPES.join(', ')} o "auto".`);
|
|
106
112
|
s.type = type;
|
|
113
|
+
if (s.locale === 'auto' || (s.locale === undefined && type === 'table')) {
|
|
114
|
+
s.locale = autoLocale(s, Array.isArray(s.data) ? s.data : []);
|
|
115
|
+
}
|
|
107
116
|
const cs = resolveColorScale(s.colorScale ?? s.cmap ?? s.colormap);
|
|
108
117
|
if (cs && cs.kind === 'diverging' && s.midpoint === undefined && s.diverging === undefined) {
|
|
109
118
|
const vf = typeof s.colorScale === 'object' && s.colorScale.mid !== undefined ? null : s.value;
|
|
@@ -251,7 +260,7 @@ function safePlot(spec) {
|
|
|
251
260
|
const svg = plot(spec);
|
|
252
261
|
return { ok: report.errors.length === 0, svg, type: report.type, description: describeSvg(svg), errors: report.errors, warnings: report.warnings, suggestions: report.suggestions };
|
|
253
262
|
} catch (e) {
|
|
254
|
-
const errors = report.errors.length ? report.errors : [{ code: 'render', message: e.message.split('\n')[0], fix: e.hint }];
|
|
263
|
+
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.' }];
|
|
255
264
|
return { ok: false, svg: null, type: report.type, description: '', errors, warnings: report.warnings, suggestions: report.suggestions };
|
|
256
265
|
}
|
|
257
266
|
}
|
|
@@ -289,7 +298,7 @@ function toHTML(specs, { title = 'Gráficos', lang = 'es', columns = 1 } = {}) {
|
|
|
289
298
|
${[...new Set([FONT_CSS, ...list.map((s) => themeFor(withDefaults(s)).font?.import).filter(Boolean)])].map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join('\n')}
|
|
290
299
|
<style>
|
|
291
300
|
:root{color-scheme:${dark ? 'dark' : 'light'}}
|
|
292
|
-
body{margin:0;background:${bg};font-family:${
|
|
301
|
+
body{margin:0;background:${cssText(bg)};font-family:${cssText(themeFor(withDefaults(list[0])).font?.family || "'Mozilla Text',system-ui,sans-serif")}}
|
|
293
302
|
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}
|
|
294
303
|
figure.pa{margin:0;min-width:0}
|
|
295
304
|
figure.pa svg{width:100%;height:auto;display:block}
|
|
@@ -301,6 +310,9 @@ ${boot}
|
|
|
301
310
|
</body></html>`;
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
/** 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. */
|
|
314
|
+
const cssText = (v) => String(v ?? '').replace(/[<>{};\\&]/g, '');
|
|
315
|
+
|
|
304
316
|
/** Código de la biblioteca en un archivo (para incrustar en HTML). */
|
|
305
317
|
function bundleSource(file = 'plotagents.js') {
|
|
306
318
|
try {
|
|
@@ -324,7 +336,7 @@ _setProbe((s) => plot(s));
|
|
|
324
336
|
|
|
325
337
|
|
|
326
338
|
|
|
327
|
-
return { "resolve": resolve, "plot": plot, "configure": configure, "timeValues": timeValues, "safePlot": safePlot, "describe": describe, "toHTML": toHTML, "bundleSource": bundleSource, "default": plot, "render": render, "countWords": countWords, "dashboardSchema": dashboardSchema, "resolveColorScale": resolveColorScale, "colorScaleNames": colorScaleNames, "interpolateColor": interpolateColor, "COLORMAPS": COLORMAPS, "QUALITATIVE": QUALITATIVE, "dashboard": dashboard, "renderDashboard": renderDashboard, "planDashboard": planDashboard, "lintDashboard": lintDashboard, "safeDashboard": safeDashboard, "mountDashboard": mountDashboard, "packRows": packRows, "balancedRows": balancedRows, "RENDERERS": RENDERERS, "ALIASES": ALIASES, "registerGeo": registerGeo, "getGeo": getGeo, "registeredGeo": registeredGeo, "toFeatureCollection": toFeatureCollection, "loadGeo": loadGeo, "registerTheme": registerTheme, "resolveDefaults": withDefaults, "embedTiles": embedTiles, "TILE_PROVIDERS": TILE_PROVIDERS, "lint": lint, "schema": schema, "toolDefinition": toolDefinition, "themes": themes, "palettes": palettes, "ramps": ramps, "validatePalette": validatePalette, "getTheme": getTheme, "themeFor": themeFor, "attach": attach, "PlotError": PlotError, "TYPES": TYPES, "squarify": squarify, "makeFormat": makeFormat, "formatNumber": formatNumber, "formatCompact": formatCompact };
|
|
339
|
+
return { "resolve": resolve, "plot": plot, "configure": configure, "timeValues": timeValues, "safePlot": safePlot, "describe": describe, "toHTML": toHTML, "bundleSource": bundleSource, "default": plot, "render": render, "countWords": countWords, "dashboardSchema": dashboardSchema, "detectLocale": detectLocale, "correlations": correlations, "resolveColorScale": resolveColorScale, "colorScaleNames": colorScaleNames, "interpolateColor": interpolateColor, "COLORMAPS": COLORMAPS, "QUALITATIVE": QUALITATIVE, "dashboard": dashboard, "renderDashboard": renderDashboard, "planDashboard": planDashboard, "lintDashboard": lintDashboard, "safeDashboard": safeDashboard, "mountDashboard": mountDashboard, "packRows": packRows, "balancedRows": balancedRows, "RENDERERS": RENDERERS, "ALIASES": ALIASES, "registerGeo": registerGeo, "getGeo": getGeo, "registeredGeo": registeredGeo, "toFeatureCollection": toFeatureCollection, "loadGeo": loadGeo, "registerTheme": registerTheme, "resolveDefaults": withDefaults, "embedTiles": embedTiles, "TILE_PROVIDERS": TILE_PROVIDERS, "lint": lint, "schema": schema, "toolDefinition": toolDefinition, "themes": themes, "palettes": palettes, "ramps": ramps, "validatePalette": validatePalette, "getTheme": getTheme, "themeFor": themeFor, "attach": attach, "PlotError": PlotError, "TYPES": TYPES, "squarify": squarify, "makeFormat": makeFormat, "formatNumber": formatNumber, "formatCompact": formatCompact };
|
|
328
340
|
});
|
|
329
341
|
__def("src/theme.js", function () {
|
|
330
342
|
|
|
@@ -742,6 +754,15 @@ const LOWER = new Map();
|
|
|
742
754
|
for (const k of [...Object.keys(COLORMAPS), ...Object.keys(QUALITATIVE)]) LOWER.set(k.toLowerCase(), k);
|
|
743
755
|
const unpack = (s) => s.match(/.{6}/g).map((h) => '#' + h);
|
|
744
756
|
const isHex = (c) => typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim());
|
|
757
|
+
const NAMED = {
|
|
758
|
+
black: '#000000', white: '#ffffff', gray: '#808080', grey: '#808080', lightgray: '#d3d3d3', lightgrey: '#d3d3d3', darkgray: '#a9a9a9', darkgrey: '#a9a9a9', silver: '#c0c0c0',
|
|
759
|
+
red: '#ff0000', darkred: '#8b0000', crimson: '#dc143c', tomato: '#ff6347', salmon: '#fa8072', pink: '#ffc0cb', maroon: '#800000',
|
|
760
|
+
orange: '#ffa500', darkorange: '#ff8c00', gold: '#ffd700', yellow: '#ffff00', khaki: '#f0e68c', beige: '#f5f5dc', ivory: '#fffff0', wheat: '#f5deb3', brown: '#a52a2a',
|
|
761
|
+
green: '#008000', darkgreen: '#006400', lime: '#00ff00', limegreen: '#32cd32', olive: '#808000', seagreen: '#2e8b57', forestgreen: '#228b22', teal: '#008080',
|
|
762
|
+
cyan: '#00ffff', aqua: '#00ffff', turquoise: '#40e0d0', lightblue: '#add8e6', skyblue: '#87ceeb', steelblue: '#4682b4', blue: '#0000ff', darkblue: '#00008b', navy: '#000080', royalblue: '#4169e1', cornflowerblue: '#6495ed', dodgerblue: '#1e90ff',
|
|
763
|
+
purple: '#800080', indigo: '#4b0082', violet: '#ee82ee', magenta: '#ff00ff', fuchsia: '#ff00ff', orchid: '#da70d6', lavender: '#e6e6fa', plum: '#dda0dd',
|
|
764
|
+
};
|
|
765
|
+
const toHex = (c) => (isHex(c) ? c.trim() : typeof c === 'string' ? NAMED[c.trim().toLowerCase()] : undefined);
|
|
745
766
|
|
|
746
767
|
/** Nombres disponibles, por tipo. */
|
|
747
768
|
function colorScaleNames() {
|
|
@@ -762,7 +783,7 @@ function colorScaleNames() {
|
|
|
762
783
|
function resolveColorScale(v) {
|
|
763
784
|
if (!v) return null;
|
|
764
785
|
if (Array.isArray(v)) {
|
|
765
|
-
const stops = v.filter(
|
|
786
|
+
const stops = v.map(toHex).filter(Boolean);
|
|
766
787
|
return stops.length >= 2 ? { name: 'custom', stops, kind: 'sequential' } : null;
|
|
767
788
|
}
|
|
768
789
|
if (typeof v === 'object') {
|
|
@@ -977,13 +998,17 @@ function bar(spec, theme) {
|
|
|
977
998
|
|
|
978
999
|
const cmap = colorMap(seriesKeys, theme, spec.colors);
|
|
979
1000
|
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
1001
|
+
const hlSeries = hl && seriesKeys.some((s) => hl.has(s));
|
|
980
1002
|
const colorFor = (c, si) => {
|
|
981
|
-
if (multi)
|
|
1003
|
+
if (multi) {
|
|
1004
|
+
if (hl && (hlSeries ? !hl.has(seriesKeys[si]) : !hl.has(c))) return theme.dim;
|
|
1005
|
+
return cmap.get(seriesKeys[si]);
|
|
1006
|
+
}
|
|
982
1007
|
if (spec.colors && spec.colors[c]) return spec.colors[c];
|
|
983
1008
|
if (hl) return hl.has(c) ? theme.categorical[0] : theme.dim;
|
|
984
1009
|
return c === 'Otros' || c === 'Other' ? theme.other : theme.categorical[0];
|
|
985
1010
|
};
|
|
986
|
-
const legend = multi && spec.legend !== false ? seriesKeys.map((s) => ({ label: s, color: cmap.get(s), shape: 'square' })) : [];
|
|
1011
|
+
const legend = multi && spec.legend !== false ? seriesKeys.map((s) => ({ label: s, color: hlSeries && !hl.has(s) ? theme.dim : cmap.get(s), shape: 'square' })) : [];
|
|
987
1012
|
|
|
988
1013
|
const H = hatcher(theme, 'pa-b' + Math.abs(hash(String(spec.title) + theme.mode + frame.width)).toString(36));
|
|
989
1014
|
const dm = dashedMatcher(spec, m.data, m.x, m.series);
|
|
@@ -1016,7 +1041,8 @@ function bar(spec, theme) {
|
|
|
1016
1041
|
if (spec._domains) spec._domains.y = [dom.min, dom.max];
|
|
1017
1042
|
const leftM = hideAxis ? 0 : ticksWidth(linear(nd.min, nd.max, 0, 1).ticks(nd.step), fmt.tick(nd.step, linear(nd.min, nd.max, 0, 1).ticks(nd.step))) + 10;
|
|
1018
1043
|
const top = showLabels ? 16 : 6;
|
|
1019
|
-
const
|
|
1044
|
+
const negPad = showLabels && vmin < 0 ? 16 : 0;
|
|
1045
|
+
const inner = { x: area.x + leftM, y: area.y + top * ctx.textScale, w: area.w - leftM, h: area.h - top * ctx.textScale - 26 * ctx.textScale - negPad };
|
|
1020
1046
|
const yS = linear(dom.min, dom.max, inner.y + inner.h, inner.y);
|
|
1021
1047
|
const xb = band(cats, inner.x, inner.x + inner.w, { inner: multi && !stack ? 0.2 : 0.3, outer: 0.1 });
|
|
1022
1048
|
if (!hideAxis) body.push(gridY(yS.ticks(nd.step), yS, inner, theme, fmt.tick(nd.step, yS.ticks(nd.step))));
|
|
@@ -1078,7 +1104,7 @@ function bar(spec, theme) {
|
|
|
1078
1104
|
body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: Math.round(y0) + 0.5, y2: Math.round(y0) + 0.5, stroke: theme.axis, strokeWidth: 1 }));
|
|
1079
1105
|
const dUnit = m.kind === 'date' ? dateUnit(cats.map((c) => parseDate(c))) : null;
|
|
1080
1106
|
const items = cats.map((c) => ({ pos: xb.center(c), label: m.nominal ? truncate(c, Math.max(xb.step - 6, 40), 12) : dUnit ? formatDate(parseDate(c), dUnit, locale) : c }));
|
|
1081
|
-
body.push(xLabels(items, inner, theme, { y: inner.y + inner.h + 18, size: 12, color: theme.lede }));
|
|
1107
|
+
body.push(xLabels(items, inner, theme, { y: inner.y + inner.h + 18 + negPad, size: 12, color: theme.lede }));
|
|
1082
1108
|
body.push(refLinesY(refs, yS, inner, theme, fmt));
|
|
1083
1109
|
} else {
|
|
1084
1110
|
const step = Math.max(multi && !stack ? Math.min(maxBar, 16) * k + 2 * (k - 1) + 10 : maxBar + 9, 12.5 * ctx.textScale * 1.45);
|
|
@@ -1112,12 +1138,13 @@ function bar(spec, theme) {
|
|
|
1112
1138
|
const y0b = by + (bh - h) / 2;
|
|
1113
1139
|
const sg = segs.get(c);
|
|
1114
1140
|
const lastPos = sg.map((s, i) => (s.vv > 0 ? i : -1)).filter((i) => i >= 0).pop();
|
|
1141
|
+
const lastNeg = sg.map((s, i) => (s.vv < 0 ? i : -1)).filter((i) => i >= 0).pop();
|
|
1115
1142
|
sg.forEach((s, i) => {
|
|
1116
1143
|
if (!s.vv) return;
|
|
1117
1144
|
const xa = xS(s.from), xb2 = xS(s.to);
|
|
1118
1145
|
let left = Math.min(xa, xb2), w = Math.abs(xb2 - xa);
|
|
1119
1146
|
if (s.from !== 0) { if (s.vv > 0) left += 2; w -= 2; }
|
|
1120
|
-
const d = barPath(left, y0b, Math.max(0, w), h, s.vv > 0 ? 'right' : 'left', i === lastPos ||
|
|
1147
|
+
const d = barPath(left, y0b, Math.max(0, w), h, s.vv > 0 ? 'right' : 'left', i === lastPos || i === lastNeg ? RAD : 0);
|
|
1121
1148
|
const fill = colorFor(c, i);
|
|
1122
1149
|
const lab = pct ? fmt(s.vv) : '';
|
|
1123
1150
|
const fits = lab && measure(lab, 11, 600) + 10 < w;
|
|
@@ -1194,7 +1221,8 @@ __def("src/svg.js", function () {
|
|
|
1194
1221
|
const { ctx } = __req("src/context.js");
|
|
1195
1222
|
|
|
1196
1223
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
1197
|
-
const
|
|
1224
|
+
const BAD_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
1225
|
+
const esc = (s) => String(s ?? '').replace(BAD_XML, '').replace(/[&<>"']/g, (c) => ESC[c]);
|
|
1198
1226
|
|
|
1199
1227
|
/** Redondea a 2 decimales para SVG compactos. */
|
|
1200
1228
|
const r2 = (n) => Math.round(n * 100) / 100;
|
|
@@ -1299,6 +1327,11 @@ set('—', 0.9);
|
|
|
1299
1327
|
set('▲▼■●', 0.9);
|
|
1300
1328
|
set('·', 0.3);
|
|
1301
1329
|
set('→', 0.9);
|
|
1330
|
+
set('\u202F', 0.17); // espacio fino (miles en francés)
|
|
1331
|
+
set('\u00A0', 0.27);
|
|
1332
|
+
set('✓✗', 0.7);
|
|
1333
|
+
set('‹›', 0.32);
|
|
1334
|
+
set('#', 0.6);
|
|
1302
1335
|
|
|
1303
1336
|
function measure(str, size = 12, weight = 400) {
|
|
1304
1337
|
let w = 0;
|
|
@@ -1342,19 +1375,29 @@ __def("src/format.js", function () {
|
|
|
1342
1375
|
const SEP = {
|
|
1343
1376
|
es: { group: '.', dec: ',' },
|
|
1344
1377
|
en: { group: ',', dec: '.' },
|
|
1378
|
+
pt: { group: '.', dec: ',' },
|
|
1379
|
+
de: { group: '.', dec: ',' },
|
|
1380
|
+
fr: { group: '\u202F', dec: ',' }, // espacio fino: 1 234,5
|
|
1345
1381
|
};
|
|
1346
1382
|
|
|
1347
1383
|
const COMPACT = {
|
|
1348
1384
|
es: [[1e12, ' B'], [1e6, ' M'], [1e3, ' mil']],
|
|
1349
1385
|
en: [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
|
|
1386
|
+
pt: [[1e12, ' tri'], [1e9, ' bi'], [1e6, ' mi'], [1e3, ' mil']],
|
|
1387
|
+
fr: [[1e12, ' Bn'], [1e9, ' Md'], [1e6, ' M'], [1e3, ' k']],
|
|
1388
|
+
de: [[1e12, ' Bio.'], [1e9, ' Mrd.'], [1e6, ' Mio.'], [1e3, ' Tsd.']],
|
|
1350
1389
|
};
|
|
1351
1390
|
|
|
1352
1391
|
const MONTHS = {
|
|
1353
1392
|
es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
|
|
1354
1393
|
en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
1394
|
+
pt: ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'],
|
|
1395
|
+
fr: ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'],
|
|
1396
|
+
de: ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'],
|
|
1355
1397
|
};
|
|
1356
1398
|
|
|
1357
|
-
|
|
1399
|
+
/** Idioma de formato: es, en, pt, fr o de ("pt-BR" → pt); cualquier otro, es. */
|
|
1400
|
+
const loc = (l) => { const k = String(l || 'es').toLowerCase().slice(0, 2); return k in SEP ? k : 'es'; };
|
|
1358
1401
|
|
|
1359
1402
|
/** Número con separadores. `decimals` fijo; `trim` quita ceros finales. */
|
|
1360
1403
|
function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
@@ -1362,7 +1405,8 @@ function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
|
1362
1405
|
if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
|
|
1363
1406
|
const { group, dec } = SEP[loc(locale)];
|
|
1364
1407
|
const neg = v < 0;
|
|
1365
|
-
|
|
1408
|
+
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"
|
|
1409
|
+
let [int, frac = ''] = fixed.split('.');
|
|
1366
1410
|
int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
|
|
1367
1411
|
if (trim) frac = frac.replace(/0+$/, '');
|
|
1368
1412
|
const out = frac ? int + dec + frac : int;
|
|
@@ -1381,10 +1425,15 @@ function smartDecimals(v) {
|
|
|
1381
1425
|
function formatCompact(v, locale = 'es', decimals) {
|
|
1382
1426
|
const l = loc(locale);
|
|
1383
1427
|
const a = Math.abs(v);
|
|
1384
|
-
|
|
1428
|
+
const tbl = COMPACT[l];
|
|
1429
|
+
for (let i = 0; i < tbl.length; i++) {
|
|
1430
|
+
let [n, suf] = tbl[i];
|
|
1385
1431
|
if (a >= n) {
|
|
1386
|
-
|
|
1387
|
-
|
|
1432
|
+
let s = v / n;
|
|
1433
|
+
let d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
|
|
1434
|
+
if (i > 0 && tbl[i - 1][0] / n === 1000 && Math.abs(+Math.abs(s).toFixed(d)) >= 1000) {
|
|
1435
|
+
[n, suf] = tbl[i - 1]; s = v / n; d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
|
|
1436
|
+
}
|
|
1388
1437
|
return formatNumber(s, d, l, decimals === undefined) + suf;
|
|
1389
1438
|
}
|
|
1390
1439
|
}
|
|
@@ -1455,8 +1504,8 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
|
|
|
1455
1504
|
const decFor = (s) => {
|
|
1456
1505
|
const a = Math.abs(s) || 1;
|
|
1457
1506
|
let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
|
|
1458
|
-
while (d <
|
|
1459
|
-
return Math.min(
|
|
1507
|
+
while (d < 12 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
|
|
1508
|
+
return Math.min(12, d);
|
|
1460
1509
|
};
|
|
1461
1510
|
let core;
|
|
1462
1511
|
if (type === 'year') core = (v) => String(Math.round(v));
|
|
@@ -1473,9 +1522,10 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
|
|
|
1473
1522
|
? (v) => formatNumber(v, decFor(step), l)
|
|
1474
1523
|
: (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
|
|
1475
1524
|
} else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
|
|
1525
|
+
const zeroFrac = new RegExp('\\' + SEP[l].dec + '0+(?!\\d)');
|
|
1476
1526
|
return (v) => {
|
|
1477
1527
|
if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
|
|
1478
|
-
const s = core(v);
|
|
1528
|
+
const s = core(v).replace(zeroFrac, '');
|
|
1479
1529
|
if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
|
|
1480
1530
|
return pre + s + suf;
|
|
1481
1531
|
};
|
|
@@ -1493,7 +1543,7 @@ function parseDate(v) {
|
|
|
1493
1543
|
if (v instanceof Date) return v.getTime();
|
|
1494
1544
|
if (typeof v === 'string') {
|
|
1495
1545
|
const m = ISO.exec(v.trim());
|
|
1496
|
-
if (m) {
|
|
1546
|
+
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) {
|
|
1497
1547
|
const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
|
|
1498
1548
|
let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
|
|
1499
1549
|
const z = m[8];
|
|
@@ -1508,7 +1558,7 @@ function parseDate(v) {
|
|
|
1508
1558
|
return NaN;
|
|
1509
1559
|
}
|
|
1510
1560
|
|
|
1511
|
-
const isDateLike = (v) => v instanceof Date || (typeof v === 'string' &&
|
|
1561
|
+
const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && Number.isFinite(parseDate(v)));
|
|
1512
1562
|
|
|
1513
1563
|
/**
|
|
1514
1564
|
* Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
|
|
@@ -1526,13 +1576,15 @@ function formatDate(ms, unit, locale = 'es', withYear = true) {
|
|
|
1526
1576
|
return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
|
|
1527
1577
|
}
|
|
1528
1578
|
if (unit === 'year') return String(y);
|
|
1529
|
-
if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
|
|
1579
|
+
if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' || l === 'de' ? 'Q' : 'T');
|
|
1530
1580
|
if (unit === 'month') return withYear ? `${m} ${y}` : m;
|
|
1531
1581
|
const day = d.getUTCDate();
|
|
1532
|
-
|
|
1582
|
+
if (l === 'en') return `${m} ${day}${withYear ? ', ' + y : ''}`;
|
|
1583
|
+
if (l === 'de') return `${day}. ${m}${withYear ? ' ' + y : ''}`;
|
|
1584
|
+
return `${day} ${m}${withYear ? ' ' + y : ''}`;
|
|
1533
1585
|
}
|
|
1534
1586
|
|
|
1535
|
-
return { "formatNumber": formatNumber, "smartDecimals": smartDecimals, "formatCompact": formatCompact, "makeFormat": makeFormat, "parseDate": parseDate, "formatDate": formatDate, "MONTHS": MONTHS, "isDateLike": isDateLike };
|
|
1587
|
+
return { "formatNumber": formatNumber, "smartDecimals": smartDecimals, "formatCompact": formatCompact, "makeFormat": makeFormat, "currencyPrefix": currencyPrefix, "parseDate": parseDate, "formatDate": formatDate, "MONTHS": MONTHS, "isDateLike": isDateLike };
|
|
1536
1588
|
});
|
|
1537
1589
|
__def("src/scale.js", function () {
|
|
1538
1590
|
const { formatDate } = __req("src/format.js");
|
|
@@ -1574,7 +1626,7 @@ function niceDomain(min, max, count = 5, { integer = false } = {}) {
|
|
|
1574
1626
|
step = st(hi - lo);
|
|
1575
1627
|
lo = Math.floor(min / step + 1e-9) * step;
|
|
1576
1628
|
hi = Math.ceil(max / step - 1e-9) * step;
|
|
1577
|
-
return { min: lo, max: hi, step };
|
|
1629
|
+
return { min: +lo.toPrecision(15), max: +hi.toPrecision(15), step: +step.toPrecision(15) };
|
|
1578
1630
|
}
|
|
1579
1631
|
|
|
1580
1632
|
function linear(d0, d1, r0, r1) {
|
|
@@ -1591,7 +1643,7 @@ function linear(d0, d1, r0, r1) {
|
|
|
1591
1643
|
if ((hi - lo) / st > 1000) st = niceStep(hi - lo, 5);
|
|
1592
1644
|
const start = Math.ceil(lo / st - 1e-9) * st;
|
|
1593
1645
|
if (start + st === start) return [lo];
|
|
1594
|
-
for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(
|
|
1646
|
+
for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(15));
|
|
1595
1647
|
return out;
|
|
1596
1648
|
};
|
|
1597
1649
|
return s;
|
|
@@ -1786,6 +1838,7 @@ function num(v) {
|
|
|
1786
1838
|
if (typeof v === 'number') return v;
|
|
1787
1839
|
if (v === null || v === undefined || v === '' || typeof v === 'boolean') return NaN;
|
|
1788
1840
|
let s = String(v).trim().replace(/[\s$€£%]|US\$|CLP|UF/g, '').replace('−', '-');
|
|
1841
|
+
if (!/^[+-]?(\d[\d.,]*|[.,]\d[\d.,]*)(e[+-]?\d+)?$/i.test(s)) return NaN;
|
|
1789
1842
|
const hasC = s.includes(','), hasD = s.includes('.');
|
|
1790
1843
|
if (hasC && hasD) s = s.lastIndexOf(',') > s.lastIndexOf('.') ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');
|
|
1791
1844
|
else if (hasC) s = /,\d{3}(?!\d)/.test(s) && !/,\d{1,2}$/.test(s) ? s.replace(/,/g, '') : s.replace(',', '.');
|
|
@@ -2062,6 +2115,7 @@ const { el, esc, g, text, r2 } = __req("src/svg.js");
|
|
|
2062
2115
|
const { measure, wrap, truncate } = __req("src/text.js");
|
|
2063
2116
|
const { FONT, FONT_CSS } = __req("src/theme.js");
|
|
2064
2117
|
const { ctx } = __req("src/context.js");
|
|
2118
|
+
const { words } = __req("src/i18n.js");
|
|
2065
2119
|
|
|
2066
2120
|
const SIZES = {
|
|
2067
2121
|
title: 22,
|
|
@@ -2102,7 +2156,7 @@ function createFrame(spec, theme, defaults = {}) {
|
|
|
2102
2156
|
if (subLines.length) y += subLines.length * slh + 2;
|
|
2103
2157
|
if (titleLines.length || subLines.length) y += 10;
|
|
2104
2158
|
|
|
2105
|
-
const srcText = spec.source ? (/^fuente
|
|
2159
|
+
const srcText = spec.source ? (/^(fuente|source|fonte|quelle)/i.test(spec.source) ? spec.source : words(spec.locale).source + spec.source) : '';
|
|
2106
2160
|
const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
|
|
2107
2161
|
const srcLH = Math.round(T.source * X * 1.35);
|
|
2108
2162
|
const sourceH = srcText ? Math.round(18 * Math.max(1, X * 0.95)) + (srcLines.length - 1) * srcLH : 0;
|
|
@@ -2148,7 +2202,7 @@ function createFrame(spec, theme, defaults = {}) {
|
|
|
2148
2202
|
},
|
|
2149
2203
|
/** Permite a gráficos de alto automático (kpi) fijar el alto final. */
|
|
2150
2204
|
setHeight(h) { height = h; },
|
|
2151
|
-
finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
|
|
2205
|
+
finish(body, { desc = '', type = 'chart', defs = '', role = 'img', css = '' } = {}) {
|
|
2152
2206
|
if (legendBottom) {
|
|
2153
2207
|
const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
|
|
2154
2208
|
header.push(svg);
|
|
@@ -2158,7 +2212,8 @@ function createFrame(spec, theme, defaults = {}) {
|
|
|
2158
2212
|
const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
|
|
2159
2213
|
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}`
|
|
2160
2214
|
+ (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
|
|
2161
|
-
+ (/^(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}' : '')
|
|
2215
|
+
+ (/^(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}' : '')
|
|
2216
|
+
+ css;
|
|
2162
2217
|
return el(
|
|
2163
2218
|
'svg',
|
|
2164
2219
|
{
|
|
@@ -2166,8 +2221,9 @@ function createFrame(spec, theme, defaults = {}) {
|
|
|
2166
2221
|
viewBox: `0 0 ${width} ${r2(height)}`,
|
|
2167
2222
|
width,
|
|
2168
2223
|
height: r2(height),
|
|
2169
|
-
role
|
|
2170
|
-
'aria-labelledby': `${id}-t ${id}-d`,
|
|
2224
|
+
role,
|
|
2225
|
+
'aria-labelledby': role === 'img' ? `${id}-t ${id}-d` : `${id}-t`,
|
|
2226
|
+
'aria-describedby': role === 'img' ? null : `${id}-d`,
|
|
2171
2227
|
fontFamily: spec.fontFamily || theme.font?.family || FONT,
|
|
2172
2228
|
class: 'pa-chart',
|
|
2173
2229
|
'data-pa-type': type,
|
|
@@ -2275,6 +2331,105 @@ function hash(s) {
|
|
|
2275
2331
|
|
|
2276
2332
|
return { "createFrame": createFrame, "legendPosition": legendPosition, "hash": hash, "SIZES": SIZES };
|
|
2277
2333
|
});
|
|
2334
|
+
__def("src/i18n.js", function () {
|
|
2335
|
+
|
|
2336
|
+
const LANGS = ['es', 'en', 'pt', 'fr', 'de'];
|
|
2337
|
+
|
|
2338
|
+
const WORDS = {
|
|
2339
|
+
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' },
|
|
2340
|
+
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' },
|
|
2341
|
+
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' },
|
|
2342
|
+
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' },
|
|
2343
|
+
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' },
|
|
2344
|
+
};
|
|
2345
|
+
|
|
2346
|
+
/** Código de idioma soportado ('es', 'en', 'pt', 'fr', 'de') a partir de cualquier etiqueta ('pt-BR', 'en_US'…). */
|
|
2347
|
+
function langOf(locale) {
|
|
2348
|
+
const k = String(locale || 'es').toLowerCase().slice(0, 2);
|
|
2349
|
+
return LANGS.includes(k) ? k : 'es';
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** Palabras de interfaz en el idioma del gráfico. */
|
|
2353
|
+
const words = (locale) => WORDS[langOf(locale)];
|
|
2354
|
+
|
|
2355
|
+
const STOP = {
|
|
2356
|
+
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',
|
|
2357
|
+
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',
|
|
2358
|
+
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',
|
|
2359
|
+
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é',
|
|
2360
|
+
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',
|
|
2361
|
+
};
|
|
2362
|
+
const INDEX = new Map();
|
|
2363
|
+
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); }
|
|
2364
|
+
|
|
2365
|
+
const CHARS = [
|
|
2366
|
+
[/[ñ¿¡]/g, { es: 3 }],
|
|
2367
|
+
[/[ãõ]/g, { pt: 3 }],
|
|
2368
|
+
[/ç/g, { pt: 1.5, fr: 1.5 }],
|
|
2369
|
+
[/[èêëàâùûœî]/g, { fr: 2 }],
|
|
2370
|
+
[/[äöüß]/g, { de: 3 }],
|
|
2371
|
+
[/[áíóú]/g, { es: 1, pt: 1 }],
|
|
2372
|
+
[/é/g, { es: 0.5, pt: 0.5, fr: 0.8 }],
|
|
2373
|
+
];
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* Idioma más probable de unos textos (títulos, encabezados, categorías), o
|
|
2377
|
+
* null si no hay señal suficiente. Determinista y sin diccionarios grandes.
|
|
2378
|
+
*/
|
|
2379
|
+
function detectLocale(texts) {
|
|
2380
|
+
const s = [].concat(texts || []).filter((t) => typeof t === 'string' && t).join(' \n ').toLowerCase().slice(0, 6000);
|
|
2381
|
+
if (!s) return null;
|
|
2382
|
+
const score = { es: 0, en: 0, pt: 0, fr: 0, de: 0 };
|
|
2383
|
+
for (const tok of s.split(/[^\p{L}]+/u)) {
|
|
2384
|
+
const langs = tok && INDEX.get(tok);
|
|
2385
|
+
if (langs) for (const l of langs) score[l] += 1 / langs.length;
|
|
2386
|
+
}
|
|
2387
|
+
for (const [re, w] of CHARS) {
|
|
2388
|
+
const n = (s.match(re) || []).length;
|
|
2389
|
+
if (n) for (const [l, v] of Object.entries(w)) score[l] += Math.min(6, n) * v;
|
|
2390
|
+
}
|
|
2391
|
+
const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
|
|
2392
|
+
const [[best, a], [, b]] = ranked;
|
|
2393
|
+
if (a < 1 || a < b * 1.25 + 0.25) return null;
|
|
2394
|
+
return best;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
/** Idioma del documento en el navegador (`<html lang>` o el del sistema), o null. */
|
|
2398
|
+
function browserLocale() {
|
|
2399
|
+
if (typeof document === 'undefined') return null;
|
|
2400
|
+
try {
|
|
2401
|
+
const l = (document.documentElement && document.documentElement.lang) || (typeof navigator !== 'undefined' && navigator.language) || '';
|
|
2402
|
+
const k = String(l).toLowerCase().slice(0, 2);
|
|
2403
|
+
return LANGS.includes(k) ? k : null;
|
|
2404
|
+
} catch { return null; }
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* Textos de un spec que delatan su idioma: título, bajada, fuente, nombres de
|
|
2409
|
+
* columna y algunas categorías.
|
|
2410
|
+
*/
|
|
2411
|
+
function specTexts(spec, data) {
|
|
2412
|
+
const out = [spec.title, spec.subtitle, spec.source, spec.dashedLabel];
|
|
2413
|
+
for (const c of [].concat(Array.isArray(spec.columns) ? spec.columns : [])) {
|
|
2414
|
+
if (typeof c === 'string') out.push(c);
|
|
2415
|
+
else if (c && typeof c === 'object') out.push(c.label, c.field);
|
|
2416
|
+
}
|
|
2417
|
+
if (Array.isArray(data)) {
|
|
2418
|
+
const rows = data.slice(0, 40);
|
|
2419
|
+
const keys = new Set();
|
|
2420
|
+
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); }
|
|
2421
|
+
for (const k of keys) out.push(String(k).replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2'));
|
|
2422
|
+
}
|
|
2423
|
+
return out;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
/** Resuelve `locale: "auto"`: idioma del texto, luego el del navegador, luego español. */
|
|
2427
|
+
function autoLocale(spec, data) {
|
|
2428
|
+
return detectLocale(specTexts(spec, data)) || browserLocale() || 'es';
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
return { "langOf": langOf, "detectLocale": detectLocale, "browserLocale": browserLocale, "specTexts": specTexts, "autoLocale": autoLocale, "LANGS": LANGS, "words": words };
|
|
2432
|
+
});
|
|
2278
2433
|
__def("src/stats.js", function () {
|
|
2279
2434
|
|
|
2280
2435
|
const finite = (vs) => vs.filter((v) => Number.isFinite(v));
|
|
@@ -2386,11 +2541,12 @@ function histogram(vs, { bins, binWidth, domain } = {}) {
|
|
|
2386
2541
|
}
|
|
2387
2542
|
step = bins ? (hi - lo) / bins : niceBinStep(hi - lo, n);
|
|
2388
2543
|
}
|
|
2389
|
-
const start = bins ? lo : Math.floor(lo / step + 1e-9) * step;
|
|
2544
|
+
const start = bins ? lo : +(Math.floor(lo / step + 1e-9) * step).toPrecision(15);
|
|
2390
2545
|
const count = Math.max(1, Math.ceil((hi - start) / step - 1e-9));
|
|
2391
|
-
const out = Array.from({ length: count }, (_, i) => ({ x0: start + i * step, x1: start + (i + 1) * step, count: 0 }));
|
|
2546
|
+
const out = Array.from({ length: count }, (_, i) => ({ x0: +(start + i * step).toPrecision(15), x1: +(start + (i + 1) * step).toPrecision(15), count: 0 }));
|
|
2392
2547
|
for (const v of s) {
|
|
2393
|
-
|
|
2548
|
+
if (domain && (v < domain[0] || v > domain[1])) continue;
|
|
2549
|
+
let i = Math.floor((v - start) / step + 1e-9);
|
|
2394
2550
|
if (i >= count) i = count - 1;
|
|
2395
2551
|
if (i >= 0) out[i].count++;
|
|
2396
2552
|
}
|
|
@@ -2766,6 +2922,13 @@ function line(spec, theme, variant = 'line') {
|
|
|
2766
2922
|
}
|
|
2767
2923
|
}
|
|
2768
2924
|
|
|
2925
|
+
if (spec.yScale === 'log' && !stack && m.lines.some((l) => l.points.some((p) => p.y > 0))) {
|
|
2926
|
+
for (const l of m.lines) for (const p of l.points) {
|
|
2927
|
+
if (!(p.y > 0)) p.y = NaN;
|
|
2928
|
+
if (!(p.lo > 0)) p.lo = NaN;
|
|
2929
|
+
if (!(p.hi > 0)) p.hi = NaN;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2769
2932
|
const allY = [], rawY = [];
|
|
2770
2933
|
for (const l of m.lines) for (const p of l.points) {
|
|
2771
2934
|
if (Number.isFinite(p.y)) allY.push(p.y);
|
|
@@ -2834,7 +2997,7 @@ function line(spec, theme, variant = 'line') {
|
|
|
2834
2997
|
const intY = !pct && !spec.index && rawY.length > 0 && rawY.every(Number.isInteger);
|
|
2835
2998
|
const nd = yLog ? logDomain(ymin, ymax) : spec.yDomain ? { min: ymin, max: ymax, step: niceStep(ymax - ymin, Math.max(3, Math.round(area.h / 64))) } : niceDomain(ymin, ymax, Math.max(3, Math.round(area.h / 64)), { integer: intY });
|
|
2836
2999
|
const yTicksArr = (yLog ? log(nd.min, nd.max, 0, 1) : linear(nd.min, nd.max, 0, 1)).ticks(nd.step);
|
|
2837
|
-
const tickF = yLog ? (v) => fmt.tick(v)(v) : fmt.tick(nd.step, yTicksArr);
|
|
3000
|
+
const tickF = yLog ? (v) => fmt.tick(v, v)(v) : fmt.tick(nd.step, yTicksArr);
|
|
2838
3001
|
const leftM = yAxisMargin(yTicksArr, tickF, theme, inside);
|
|
2839
3002
|
const yTop = area.y + yAxisTop(theme, inside) + (spec.yLabel ? 16 : 0);
|
|
2840
3003
|
const inner = { x: area.x + leftM, y: yTop, w: Math.max(80, area.w - leftM - endW), h: area.y + area.h - yTop - 24 * ctx.textScale - (spec.xLabel ? 16 * ctx.textScale : 0) };
|
|
@@ -2879,7 +3042,7 @@ function line(spec, theme, variant = 'line') {
|
|
|
2879
3042
|
xItems = m.cats.map((c, i) => ({ pos: xS(i), label: c })).filter((it, i) => xsAll.includes(i));
|
|
2880
3043
|
} else if (xLog) {
|
|
2881
3044
|
const lf = makeFormat(spec.xFormat, locale, xsAll);
|
|
2882
|
-
xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t)(t) }));
|
|
3045
|
+
xItems = xS.ticks().map((t) => ({ pos: xS(t), label: lf.tick(t, t)(t) }));
|
|
2883
3046
|
} else {
|
|
2884
3047
|
let st = niceStep(x1 - x0, Math.max(2, Math.floor(inner.w / 78)));
|
|
2885
3048
|
const ints = xsAll.every(Number.isInteger);
|
|
@@ -2933,6 +3096,7 @@ function line(spec, theme, variant = 'line') {
|
|
|
2933
3096
|
if (spec.dashedLabel !== false) body.push(text(cutX + 6, inner.y + 11, dashedLabel, { fill: theme.muted, fontSize: 11.5, fontWeight: 500, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
|
|
2934
3097
|
}
|
|
2935
3098
|
}
|
|
3099
|
+
const clipD = spec.xDomain || spec.yDomain ? 'pa-clip-' + Math.abs(hash(String(spec.title) + m.y + inner.w + inner.h)).toString(36) : null;
|
|
2936
3100
|
const gidD = 'pa-dash-' + Math.abs(hash(String(spec.title) + m.y + inner.w + theme.mode)).toString(36);
|
|
2937
3101
|
const H = hatcher(theme, gidD);
|
|
2938
3102
|
const clipL = `${gidD}-l`, clipR = `${gidD}-r`;
|
|
@@ -3002,7 +3166,7 @@ function line(spec, theme, variant = 'line') {
|
|
|
3002
3166
|
else for (const sg of segs) if (sg.length === 1 && sg[0] !== pts[pts.length - 1]) parts.push(dot(sg[0], (M.pointRadius ?? 4) - 0.5));
|
|
3003
3167
|
}
|
|
3004
3168
|
if (focus(l.key) && !stack) parts.push(dot(pts[pts.length - 1], (M.pointRadius ?? 4) + 0.5));
|
|
3005
|
-
body.push(g({ class: 'pa-m', 'data-series': l.key, ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
|
|
3169
|
+
body.push(g({ class: 'pa-m', 'data-series': l.key, ...(clipD ? { 'clip-path': `url(#${clipD})` } : {}), ...(dashedSeries(l.key) ? { 'data-dashed': '1' } : {}) }, parts));
|
|
3006
3170
|
}
|
|
3007
3171
|
|
|
3008
3172
|
body.push(refLinesY(refs, yS, inner, theme, fmt));
|
|
@@ -3012,7 +3176,8 @@ function line(spec, theme, variant = 'line') {
|
|
|
3012
3176
|
let labs = labeled.map((l) => {
|
|
3013
3177
|
const last = lastOf(l);
|
|
3014
3178
|
const t = endText(l);
|
|
3015
|
-
const
|
|
3179
|
+
const yy0 = stack ? yS((last.y + last.y0) / 2) : yS(last.y);
|
|
3180
|
+
const yy = Math.min(inner.y + inner.h, Math.max(inner.y, yy0)); // fuera del dominio y: pegada al borde
|
|
3016
3181
|
const px = xS(last.x) + (singleX && isArea ? Math.min(inner.w * 0.2, 40) : 0);
|
|
3017
3182
|
return { ...t, key: l.key, y: yy, y0: yy, px, color: colorOf(l.key) };
|
|
3018
3183
|
}).sort((a, b) => a.y0 - b.y0);
|
|
@@ -3037,10 +3202,10 @@ function line(spec, theme, variant = 'line') {
|
|
|
3037
3202
|
}
|
|
3038
3203
|
}
|
|
3039
3204
|
|
|
3040
|
-
let crossXs = xsAll;
|
|
3041
|
-
if (
|
|
3205
|
+
let crossXs = xsAll.filter((xv) => xv >= x0 && xv <= x1);
|
|
3206
|
+
if (crossXs.length > inner.w) {
|
|
3042
3207
|
const byCol = new Map();
|
|
3043
|
-
for (const xv of
|
|
3208
|
+
for (const xv of crossXs) byCol.set(Math.round(xS(xv)), xv);
|
|
3044
3209
|
crossXs = [...byCol.values()];
|
|
3045
3210
|
}
|
|
3046
3211
|
const focusLines = m.lines.filter((l) => focus(l.key)).map((l) => ({ l, at: new Map(l.points.map((q) => [q.x, q])) }));
|
|
@@ -3057,6 +3222,7 @@ function line(spec, theme, variant = 'line') {
|
|
|
3057
3222
|
body.push(el('rect', { class: 'pa-cross', x: inner.x - 4, y: inner.y, width: inner.w + 8, height: inner.h, fill: 'transparent', 'data-cross': JSON.stringify(cross), 'data-y0': inner.y, 'data-y1': inner.y + inner.h }));
|
|
3058
3223
|
|
|
3059
3224
|
const desc = describeLine(m, fmt, xFmt, locale);
|
|
3225
|
+
if (clipD) defs += el('clipPath', { id: clipD }, el('rect', { x: inner.x - 6, y: inner.y - 6, width: inner.w + 12, height: inner.h + 12 }));
|
|
3060
3226
|
if (cutX !== null) defs += el('clipPath', { id: clipL }, el('rect', { x: 0, y: 0, width: cutX, height: frame.height })) + el('clipPath', { id: clipR }, el('rect', { x: cutX, y: 0, width: frame.width, height: frame.height }));
|
|
3061
3227
|
return frame.finish(body, { desc, type: isArea ? 'area' : 'line', defs: defs + H.defs() });
|
|
3062
3228
|
}
|
|
@@ -3290,7 +3456,8 @@ function scatter(spec, theme) {
|
|
|
3290
3456
|
const sorted = [...pts].sort((a, b) => (focus(a) - focus(b)) || b.s - a.s);
|
|
3291
3457
|
const MAX_TIPS = 20000;
|
|
3292
3458
|
const hx = humanize(x), hy = humanize(y), hs = size ? humanize(size) : '';
|
|
3293
|
-
const
|
|
3459
|
+
const vis = (p) => { const cx = xS(p.x), cy = yS(p.y); return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1; };
|
|
3460
|
+
const circles = sorted.filter(vis).map((p, i) => {
|
|
3294
3461
|
let t = '';
|
|
3295
3462
|
if (i < MAX_TIPS || sorted.length <= 1500) {
|
|
3296
3463
|
const lines = [p.name || '', `${hx}: ${fx(p.x)}`, `${hy}: ${fy(p.y)}`];
|
|
@@ -3344,7 +3511,7 @@ function scatter(spec, theme) {
|
|
|
3344
3511
|
}
|
|
3345
3512
|
const placed = [];
|
|
3346
3513
|
for (const p of chosen) {
|
|
3347
|
-
if (!p.name) continue;
|
|
3514
|
+
if (!p.name || !vis(p)) continue;
|
|
3348
3515
|
const w = measure(p.name, 12, 500);
|
|
3349
3516
|
const r = rOf(p);
|
|
3350
3517
|
let lx = xS(p.x) + r + 4, ly = yS(p.y) + 4, anchor = 'start';
|
|
@@ -3373,7 +3540,7 @@ const { formatNumber } = __req("src/format.js");
|
|
|
3373
3540
|
const { humanize } = __req("src/data.js");
|
|
3374
3541
|
const { createFrame } = __req("src/frame.js");
|
|
3375
3542
|
const { ctx } = __req("src/context.js");
|
|
3376
|
-
const { buildHierarchy,
|
|
3543
|
+
const { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } = __req("src/hierarchy.js");
|
|
3377
3544
|
|
|
3378
3545
|
/** Algoritmo squarified (Bruls, Huizing & van Wijk, 2000). */
|
|
3379
3546
|
function squarify(items, x, y, w, h) {
|
|
@@ -3418,7 +3585,7 @@ function treemap(spec, theme) {
|
|
|
3418
3585
|
const locale = spec.locale || 'es';
|
|
3419
3586
|
const en = String(locale).startsWith('en');
|
|
3420
3587
|
const { root, fields, valueField } = buildHierarchy(spec);
|
|
3421
|
-
const focus =
|
|
3588
|
+
const focus = focusNode(root, spec.focus);
|
|
3422
3589
|
const levels = height(focus);
|
|
3423
3590
|
const single = height(root) <= 1 && spec.colorBy !== 'label';
|
|
3424
3591
|
assignColors(root, theme, spec, { single });
|
|
@@ -3613,6 +3780,12 @@ function findNode(root, focus = []) {
|
|
|
3613
3780
|
return node;
|
|
3614
3781
|
}
|
|
3615
3782
|
|
|
3783
|
+
/** Nodo de foco para dibujar: una hoja no se puede "abrir" (quedaría vacío), se usa su padre. */
|
|
3784
|
+
function focusNode(root, focus = []) {
|
|
3785
|
+
const n = findNode(root, focus);
|
|
3786
|
+
return !n.children.length && n.parent ? n.parent : n;
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3616
3789
|
const height = (n) => (n.children.length ? 1 + Math.max(...n.children.map(height)) : 0);
|
|
3617
3790
|
const descendants = (n) => [n, ...n.children.flatMap(descendants)];
|
|
3618
3791
|
|
|
@@ -3684,14 +3857,14 @@ function breadcrumb(focusNode, x, y, w, theme, locale = 'es') {
|
|
|
3684
3857
|
return { svg: g({ class: 'pa-breadcrumb' }, out), h: 26 };
|
|
3685
3858
|
}
|
|
3686
3859
|
|
|
3687
|
-
return { "hierarchyFields": hierarchyFields, "buildHierarchy": buildHierarchy, "findNode": findNode, "mix": mix, "assignColors": assignColors, "shareFmt": shareFmt, "breadcrumb": breadcrumb, "height": height, "descendants": descendants, "SEP": SEP };
|
|
3860
|
+
return { "hierarchyFields": hierarchyFields, "buildHierarchy": buildHierarchy, "findNode": findNode, "focusNode": focusNode, "mix": mix, "assignColors": assignColors, "shareFmt": shareFmt, "breadcrumb": breadcrumb, "height": height, "descendants": descendants, "SEP": SEP };
|
|
3688
3861
|
});
|
|
3689
3862
|
__def("src/charts/donut.js", function () {
|
|
3690
3863
|
const { el, g, text, tip } = __req("src/svg.js");
|
|
3691
3864
|
const { measure, truncate } = __req("src/text.js");
|
|
3692
3865
|
const { makeFormat, formatNumber } = __req("src/format.js");
|
|
3693
3866
|
const { colorMap, textOn } = __req("src/theme.js");
|
|
3694
|
-
const { ensureData, requireField, num, fieldsOf, fieldKind, humanize } = __req("src/data.js");
|
|
3867
|
+
const { ensureData, requireField, num, fieldsOf, fieldKind, humanize, PlotError } = __req("src/data.js");
|
|
3695
3868
|
const { createFrame, hash } = __req("src/frame.js");
|
|
3696
3869
|
const { dashedMatcher, hatcher, dashedDefaultLabel, DASH } = __req("src/dashed.js");
|
|
3697
3870
|
const { ctx } = __req("src/context.js");
|
|
@@ -3725,6 +3898,7 @@ function donut(spec, theme) {
|
|
|
3725
3898
|
const k = String(r[labelF]);
|
|
3726
3899
|
sums.set(k, (sums.get(k) || 0) + v);
|
|
3727
3900
|
}
|
|
3901
|
+
if (!sums.size) throw new PlotError(en ? `No positive values in \`${valueF}\`.` : `No hay valores positivos en \`${valueF}\`.`, en ? 'A donut or pie needs at least one value greater than 0 (zero, negative and empty values are skipped).' : 'La dona o torta necesita al menos un valor mayor que 0 (se omiten ceros, negativos y vacíos).');
|
|
3728
3902
|
let slices = [...sums.entries()].map(([k, v]) => ({ k, v }));
|
|
3729
3903
|
if ((spec.sort ?? 'desc') === 'desc') slices.sort((a, b) => b.v - a.v);
|
|
3730
3904
|
const max = spec.maxSlices ?? 6;
|
|
@@ -3915,7 +4089,7 @@ const { makeFormat } = __req("src/format.js");
|
|
|
3915
4089
|
const { textOn } = __req("src/theme.js");
|
|
3916
4090
|
const { humanize } = __req("src/data.js");
|
|
3917
4091
|
const { createFrame } = __req("src/frame.js");
|
|
3918
|
-
const { buildHierarchy,
|
|
4092
|
+
const { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } = __req("src/hierarchy.js");
|
|
3919
4093
|
|
|
3920
4094
|
const TAU = Math.PI * 2;
|
|
3921
4095
|
const f = (n) => n.toFixed(2);
|
|
@@ -3932,7 +4106,7 @@ function sunburst(spec, theme) {
|
|
|
3932
4106
|
const locale = spec.locale || 'es';
|
|
3933
4107
|
const en = String(locale).startsWith('en');
|
|
3934
4108
|
const { root, fields, valueField } = buildHierarchy(spec);
|
|
3935
|
-
const focus =
|
|
4109
|
+
const focus = focusNode(root, spec.focus);
|
|
3936
4110
|
const levels = Math.max(1, height(focus));
|
|
3937
4111
|
assignColors(root, theme, spec); // colores estables al navegar
|
|
3938
4112
|
const vals = [];
|
|
@@ -4150,7 +4324,7 @@ function hist(spec, theme) {
|
|
|
4150
4324
|
const locale = spec.locale || 'es';
|
|
4151
4325
|
const isLog = (horizontal ? spec.yScale : spec.xScale) === 'log';
|
|
4152
4326
|
const tr = T(isLog);
|
|
4153
|
-
const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values }));
|
|
4327
|
+
const groups = groupValues(data, field, hue).map((gr) => ({ ...gr, values: isLog ? gr.values.filter((v) => v > 0) : gr.values })).filter((gr) => gr.values.length);
|
|
4154
4328
|
const all = groups.flatMap((gr) => gr.values);
|
|
4155
4329
|
if (!all.length) throw noNumbers(field, en, isLog);
|
|
4156
4330
|
const N = all.length;
|
|
@@ -4608,7 +4782,7 @@ function fitGroup(gp, spec, { xLog, xMin, xMax, steps = 90 }) {
|
|
|
4608
4782
|
const pts = lw.map(([t, v]) => [spec.logx ? 10 ** t : t, v]);
|
|
4609
4783
|
return { line: pts, band: null, r2: null, n: gp.length };
|
|
4610
4784
|
}
|
|
4611
|
-
const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: (spec.ci
|
|
4785
|
+
const fit = polyfit(xs, ys, { order: spec.order ?? 1, level: typeof spec.ci === 'number' ? (spec.ci > 1 ? spec.ci / 100 : spec.ci) : 0.95 });
|
|
4612
4786
|
const line = grid.map((v) => [v, fit.predict(fx(v))]);
|
|
4613
4787
|
const band = spec.ci === false || spec.ci === null || gp.length < (spec.order ?? 1) + 3 ? null : grid.map((v) => [v, ...fit.band(fx(v))]);
|
|
4614
4788
|
return { line, band, r2: fit.r2, n: fit.n, fit };
|
|
@@ -4672,7 +4846,7 @@ function regplot(spec, theme) {
|
|
|
4672
4846
|
});
|
|
4673
4847
|
if (spec.scatter !== false) {
|
|
4674
4848
|
const order = [...pts].sort((a, b) => (focusKey(a.k) ? 1 : 0) - (focusKey(b.k) ? 1 : 0));
|
|
4675
|
-
const dots = order.map((p) => ({
|
|
4849
|
+
const dots = order.filter((p) => visible(p, xS, yS, inner)).map((p) => ({
|
|
4676
4850
|
class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: K === 1 ? (M.dotOpacity ?? 0.85) * 0.75 : M.dotOpacity ?? 0.85,
|
|
4677
4851
|
stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25,
|
|
4678
4852
|
'data-tip': tip(p.name || p.k || '', `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : ''),
|
|
@@ -4891,7 +5065,7 @@ function jointplot(spec, theme) {
|
|
|
4891
5065
|
body.push(el('path', { d: L(f.line), fill: 'none', stroke: K === 1 ? theme.ink : colorOf(k), strokeWidth: M.lineWidth ?? 2, opacity: 0.9 }));
|
|
4892
5066
|
});
|
|
4893
5067
|
}
|
|
4894
|
-
body.push(g({}, dotLayer(pts.map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
|
|
5068
|
+
body.push(g({}, dotLayer(pts.filter((p) => visible(p, xS, yS, inner)).map((p) => ({ class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r, fill: colorOf(p.k), fillOpacity: M.dotOpacity ?? 0.85, stroke: pts.length > 400 ? 'none' : theme.surface, strokeWidth: 1.25, 'data-tip': tip(p.name || p.k, `${humanize(P.x)}: ${fx(p.x)}`, `${humanize(P.y)}: ${fy(p.y)}`, p.name && p.k ? p.k : '') })))));
|
|
4895
5069
|
body.push(pointLabels(pts, spec, xS, yS, r, inner, theme, P));
|
|
4896
5070
|
body.push(gridX(xAx.ticks, xS, inner, theme, tickFmt(fx, xAx), { lines: false }));
|
|
4897
5071
|
body.push(yTickLabels(yAx.ticks, yS, inner, theme, yt));
|
|
@@ -4903,6 +5077,12 @@ function jointplot(spec, theme) {
|
|
|
4903
5077
|
return frame.finish(body, { desc, type: 'jointplot' });
|
|
4904
5078
|
}
|
|
4905
5079
|
|
|
5080
|
+
/** Con xDomain/yDomain, los puntos fuera del rango no se dibujan fuera del área. */
|
|
5081
|
+
function visible(p, xS, yS, inner) {
|
|
5082
|
+
const cx = xS(p.x), cy = yS(p.y);
|
|
5083
|
+
return cx >= inner.x - 1 && cx <= inner.x + inner.w + 1 && cy >= inner.y - 1 && cy <= inner.y + inner.h + 1;
|
|
5084
|
+
}
|
|
5085
|
+
|
|
4906
5086
|
function hashStr(s) {
|
|
4907
5087
|
let h = 0;
|
|
4908
5088
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
|
@@ -5799,13 +5979,15 @@ function spanningTree(nodes, edges, byId, root) {
|
|
|
5799
5979
|
}
|
|
5800
5980
|
const pos = new Map();
|
|
5801
5981
|
let leaf = 0;
|
|
5802
|
-
const
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5982
|
+
const stack = [[root.id, 0]];
|
|
5983
|
+
while (stack.length) {
|
|
5984
|
+
const top = stack[stack.length - 1];
|
|
5985
|
+
const ch = children.get(top[0]);
|
|
5986
|
+
if (!ch.length) { pos.set(top[0], leaf++); stack.pop(); continue; }
|
|
5987
|
+
if (top[1] < ch.length) { stack.push([ch[top[1]++], 0]); continue; }
|
|
5988
|
+
pos.set(top[0], ch.reduce((a, c) => a + pos.get(c), 0) / ch.length);
|
|
5989
|
+
stack.pop();
|
|
5990
|
+
}
|
|
5809
5991
|
const maxDepth = Math.max(0, ...depth.values());
|
|
5810
5992
|
const rest = nodes.filter((n) => !depth.has(n.id)).sort(byGroup);
|
|
5811
5993
|
return { depth, pos, leaves: leaf, maxDepth, rest, treeEdges, children };
|
|
@@ -6016,7 +6198,8 @@ function network(spec, theme) {
|
|
|
6016
6198
|
for (const n of nodes) { n.r = Math.max(rMin, rTop * Math.sqrt(Math.max(0, n.w) / maxW)); n.r0 = n.r; n.ring = false; }
|
|
6017
6199
|
|
|
6018
6200
|
const focusNode = spec.focus !== undefined && spec.focus !== null ? byId.get(String([].concat(spec.focus)[0])) ?? null : null;
|
|
6019
|
-
const
|
|
6201
|
+
const hlIds = spec.highlight === undefined || spec.highlight === null ? [] : [].concat(spec.highlight).map(String).filter((id) => byId.has(id));
|
|
6202
|
+
const hl = focusNode ? new Set([focusNode.id]) : hlIds.length ? new Set(hlIds) : null;
|
|
6020
6203
|
const near = new Set();
|
|
6021
6204
|
if (hl) { for (const id of hl) near.add(id); for (const e of edges) { if (hl.has(e.s)) near.add(e.t); if (hl.has(e.t)) near.add(e.s); } }
|
|
6022
6205
|
const on = (n) => !hl || near.has(n.id);
|
|
@@ -6331,7 +6514,8 @@ function chord(spec, theme) {
|
|
|
6331
6514
|
const P = (ang, r) => [cx + Math.cos(ang) * r, cy + Math.sin(ang) * r];
|
|
6332
6515
|
const f = (v) => v.toFixed(2);
|
|
6333
6516
|
|
|
6334
|
-
const
|
|
6517
|
+
const hlNames = spec.highlight === undefined || spec.highlight === null ? [] : [].concat(spec.highlight).map(String).filter((k) => names.includes(k));
|
|
6518
|
+
const hl = hlNames.length ? new Set(hlNames) : null;
|
|
6335
6519
|
const isOn = (rb) => !hl || hl.has(names[rb.i]) || hl.has(names[rb.j]);
|
|
6336
6520
|
|
|
6337
6521
|
const ribbonG = [];
|
|
@@ -7377,15 +7561,24 @@ const STOP = new Set((
|
|
|
7377
7561
|
+ ' 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'
|
|
7378
7562
|
).split(/\s+/));
|
|
7379
7563
|
|
|
7564
|
+
const STOP_BY = {
|
|
7565
|
+
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',
|
|
7566
|
+
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',
|
|
7567
|
+
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',
|
|
7568
|
+
};
|
|
7569
|
+
const plain = (w) => w.normalize('NFD').replace(/\p{M}/gu, '');
|
|
7570
|
+
const STOP_PLAIN = new Set([...STOP].map(plain));
|
|
7571
|
+
|
|
7380
7572
|
/** Texto crudo → [{ text, value }] por frecuencia. */
|
|
7381
7573
|
function countWords(input, { locale, minLength = 3, stopwords = [] } = {}) {
|
|
7382
7574
|
const extra = new Set([].concat(stopwords).map((w) => String(w).toLowerCase()));
|
|
7575
|
+
for (const w of (STOP_BY[String(locale || '').slice(0, 2)] || '').split(' ')) if (w) extra.add(w);
|
|
7383
7576
|
const counts = new Map(), shown = new Map();
|
|
7384
7577
|
const docs = [].concat(input).filter((d) => typeof d === 'string');
|
|
7385
7578
|
for (const d of docs) for (const m of d.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)) {
|
|
7386
7579
|
const raw = m[0].replace(/['’-]+$/, '');
|
|
7387
7580
|
const k = raw.toLowerCase();
|
|
7388
|
-
if (k.length < minLength || STOP.has(k) || extra.has(k) || /^\d+$/.test(k)) continue;
|
|
7581
|
+
if (k.length < minLength || STOP.has(k) || STOP_PLAIN.has(plain(k)) || extra.has(k) || /^\d+$/.test(k)) continue;
|
|
7389
7582
|
counts.set(k, (counts.get(k) || 0) + 1);
|
|
7390
7583
|
const f = shown.get(k) || new Map();
|
|
7391
7584
|
f.set(raw, (f.get(raw) || 0) + 1);
|
|
@@ -7419,9 +7612,11 @@ function wordcloud(spec, theme) {
|
|
|
7419
7612
|
if (!t) continue;
|
|
7420
7613
|
const v = valueF ? num(r[valueF]) : 1;
|
|
7421
7614
|
if (!Number.isFinite(v) || v <= 0) continue;
|
|
7422
|
-
const
|
|
7615
|
+
const gk = groupF ? String(r[groupF]) : null;
|
|
7616
|
+
const key = gk === null ? t : gk + '\u0000' + t;
|
|
7617
|
+
const e = acc.get(key) || { text: t, value: 0, group: gk };
|
|
7423
7618
|
e.value += v;
|
|
7424
|
-
acc.set(
|
|
7619
|
+
acc.set(key, e);
|
|
7425
7620
|
}
|
|
7426
7621
|
words = [...acc.values()];
|
|
7427
7622
|
}
|
|
@@ -7521,7 +7716,8 @@ function normKey(s) {
|
|
|
7521
7716
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
7522
7717
|
.toLowerCase()
|
|
7523
7718
|
.replace(/^\s*(region|provincia|comuna)\s+(metropolitana\s+de\s+)?(de\s+la\s+|del\s+|de\s+los\s+|de\s+las\s+|de\s+)?/, (m, a, metro) => (metro ? 'metropolitana' : ''))
|
|
7524
|
-
.replace(/[^a-z0-9]/g, '')
|
|
7719
|
+
.replace(/[^a-z0-9]/g, '')
|
|
7720
|
+
.replace(/^0+(?=\d)/, ''); // códigos con cero a la izquierda: "08" = 8, "004" = 4
|
|
7525
7721
|
}
|
|
7526
7722
|
|
|
7527
7723
|
function featureIndex(features, featureKey) {
|
|
@@ -7705,6 +7901,7 @@ function prepare(spec, locale) {
|
|
|
7705
7901
|
const categorical = spec.scale === 'categorical' || (data.length > 0 && fieldKind(data, valueF) === 'category');
|
|
7706
7902
|
let features = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
|
|
7707
7903
|
if (!spec.antarctica) features = features.filter((f) => (f.properties || {}).iso3 !== 'ATA');
|
|
7904
|
+
if (!features.length) throw new PlotError(T(locale, 'El `geo` no tiene geometrías para dibujar.', 'The `geo` has no geometries to draw.'), T(locale, 'Pasa un GeoJSON con al menos un Feature con geometría.', 'Pass a GeoJSON with at least one Feature that has a geometry.'));
|
|
7708
7905
|
const hasName = (f) => { const p = f.properties || {}; return primitive(p.name) || primitive(p.nameEn) || primitive(p.NAME) || primitive(p.nombre); };
|
|
7709
7906
|
const firstText = () => { for (const f of features.slice(0, 20)) for (const [k, v] of Object.entries(f.properties || {})) if (typeof v === 'string' && v.trim()) return k; return null; };
|
|
7710
7907
|
const nameKey = spec.nameKey ?? (features.length && !features.some(hasName) ? NAME_PROPS.find((k) => features.some((f) => primitive(f.properties?.[k]))) ?? spec.featureKey ?? firstText() : null);
|
|
@@ -8468,8 +8665,9 @@ const projections = {
|
|
|
8468
8665
|
*/
|
|
8469
8666
|
function makeProjection(p) {
|
|
8470
8667
|
if (!p) return null;
|
|
8471
|
-
|
|
8472
|
-
if (typeof p === '
|
|
8668
|
+
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; };
|
|
8669
|
+
if (typeof p === 'string') { const f = projections[p] || find(p); return f ? f() : null; }
|
|
8670
|
+
if (typeof p === 'object') { const f = projections[p.type || p.name] || find(p.type || p.name); return f ? f(p) : null; }
|
|
8473
8671
|
return null;
|
|
8474
8672
|
}
|
|
8475
8673
|
|
|
@@ -8606,6 +8804,8 @@ const MAX_LAT = 85.0511287798;
|
|
|
8606
8804
|
const TILE = 256;
|
|
8607
8805
|
const MAX_TILES = 48;
|
|
8608
8806
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
8807
|
+
const lo = (a) => a.reduce((m, v) => (v < m ? v : m), Infinity);
|
|
8808
|
+
const hi = (a) => a.reduce((m, v) => (v > m ? v : m), -Infinity);
|
|
8609
8809
|
|
|
8610
8810
|
/** Longitud → x de Web Mercator en [0, 1] (0 = 180° O). */
|
|
8611
8811
|
const mercX = (lon) => (lon + 180) / 360;
|
|
@@ -8803,10 +9003,10 @@ function pointmap(spec, theme) {
|
|
|
8803
9003
|
let unwrapped = false;
|
|
8804
9004
|
{
|
|
8805
9005
|
const lons = pts.map((p) => p.lon);
|
|
8806
|
-
const span =
|
|
9006
|
+
const span = hi(lons) - lo(lons);
|
|
8807
9007
|
if (span > 180) {
|
|
8808
9008
|
const alt = lons.map((l) => (l < 0 ? l + 360 : l));
|
|
8809
|
-
if (
|
|
9009
|
+
if (hi(alt) - lo(alt) < span) { for (const p of pts) if (p.lon < 0) p.lon += 360; unwrapped = true; }
|
|
8810
9010
|
}
|
|
8811
9011
|
}
|
|
8812
9012
|
for (const p of pts) { p.mx = mercX(p.lon); p.my = mercY(p.lat); }
|
|
@@ -8833,7 +9033,7 @@ function pointmap(spec, theme) {
|
|
|
8833
9033
|
colorOf = (p) => cmap.get(p.k) || theme.other;
|
|
8834
9034
|
if (keys.length > 1) legend = keys.map((k) => ({ label: k, color: cmap.get(k) }));
|
|
8835
9035
|
} else if (valueF && values.length) {
|
|
8836
|
-
const vmin =
|
|
9036
|
+
const vmin = lo(values), vmax = hi(values);
|
|
8837
9037
|
const mid = spec.midpoint ?? (spec.diverging ? 0 : undefined);
|
|
8838
9038
|
let stops, at;
|
|
8839
9039
|
if (mid !== undefined) {
|
|
@@ -8858,7 +9058,7 @@ function pointmap(spec, theme) {
|
|
|
8858
9058
|
const frame = createFrame(spec, theme, { height: Math.round(W * 0.8) });
|
|
8859
9059
|
let area = frame.layout(legend);
|
|
8860
9060
|
|
|
8861
|
-
const maxS = Math.max(
|
|
9061
|
+
const maxS = sizes.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
|
8862
9062
|
const wk = Math.max(0.6, Math.min(1.2, area.w / 672));
|
|
8863
9063
|
const rMax = sizeF && maxS ? spec.maxRadius ?? Math.round((n <= 40 ? 22 : n <= 150 ? 15 : n <= 600 ? 10 : 6) * wk) : 0;
|
|
8864
9064
|
const rBase = spec.radius ?? (dense ? 2.2 : n > 500 ? 3 : n > 120 ? 4 : 5.5);
|
|
@@ -9060,8 +9260,8 @@ function describePointmap({ pts, n, provider, world, hueF, keys, sizeF, valueF,
|
|
|
9060
9260
|
const en = String(locale).startsWith('en');
|
|
9061
9261
|
const W = en ? 'W' : 'O';
|
|
9062
9262
|
const lats = pts.map((p) => p.lat), lons = pts.map((p) => (p.lon > 180 ? p.lon - 360 : p.lon));
|
|
9063
|
-
const la0 =
|
|
9064
|
-
const lo0 =
|
|
9263
|
+
const la0 = lo(lats), la1 = hi(lats);
|
|
9264
|
+
const lo0 = lo(lons), lo1 = hi(lons);
|
|
9065
9265
|
const base = provider ? provider.name || provider.attribution : world ? (en ? 'a world outline' : 'un contorno del mundo') : '';
|
|
9066
9266
|
const where = n === 1 || (la1 - la0 < 0.05 && lo1 - lo0 < 0.05)
|
|
9067
9267
|
? (en ? ` at ${deg(la0, 'N', 'S', locale)}, ${deg(lo0, 'E', W, locale)}` : ` en ${deg(la0, 'N', 'S', locale)}, ${deg(lo0, 'E', W, locale)}`)
|
|
@@ -9224,6 +9424,503 @@ function heatmap(spec, theme) {
|
|
|
9224
9424
|
|
|
9225
9425
|
return { "heatmap": heatmap };
|
|
9226
9426
|
});
|
|
9427
|
+
__def("src/charts/corr.js", function () {
|
|
9428
|
+
const { el, g, text, tip, barPath, dotLayer } = __req("src/svg.js");
|
|
9429
|
+
const { measure, truncate } = __req("src/text.js");
|
|
9430
|
+
const { makeFormat, formatNumber } = __req("src/format.js");
|
|
9431
|
+
const { ramp, textOn, colorMap, readable } = __req("src/theme.js");
|
|
9432
|
+
const { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, PlotError } = __req("src/data.js");
|
|
9433
|
+
const { createFrame } = __req("src/frame.js");
|
|
9434
|
+
const { ctx } = __req("src/context.js");
|
|
9435
|
+
const { pearson, polyfit, histogram, kde: kdeEst } = __req("src/stats.js");
|
|
9436
|
+
const { hueField, axisDomain, tickFmt } = __req("src/charts/dist.js");
|
|
9437
|
+
|
|
9438
|
+
const isEN = (spec) => String(spec.locale || 'es').startsWith('en');
|
|
9439
|
+
const halo = (theme) => ({ stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' });
|
|
9440
|
+
|
|
9441
|
+
|
|
9442
|
+
/** Rangos con empates promediados (para Spearman). */
|
|
9443
|
+
function ranks(vs) {
|
|
9444
|
+
const idx = vs.map((_, i) => i).sort((a, b) => vs[a] - vs[b]);
|
|
9445
|
+
const out = new Array(vs.length);
|
|
9446
|
+
for (let i = 0; i < idx.length;) {
|
|
9447
|
+
let j = i;
|
|
9448
|
+
while (j + 1 < idx.length && vs[idx[j + 1]] === vs[idx[i]]) j++;
|
|
9449
|
+
const avg = (i + j) / 2 + 1;
|
|
9450
|
+
for (let t = i; t <= j; t++) out[idx[t]] = avg;
|
|
9451
|
+
i = j + 1;
|
|
9452
|
+
}
|
|
9453
|
+
return out;
|
|
9454
|
+
}
|
|
9455
|
+
|
|
9456
|
+
/** Φ(z) (Abramowitz–Stegun 7.1.26, error < 1,5e-7). */
|
|
9457
|
+
function normCdf(z) {
|
|
9458
|
+
const x = Math.abs(z) / Math.SQRT2;
|
|
9459
|
+
const t = 1 / (1 + 0.3275911 * x);
|
|
9460
|
+
const erf = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
|
|
9461
|
+
return z >= 0 ? (1 + erf) / 2 : (1 - erf) / 2;
|
|
9462
|
+
}
|
|
9463
|
+
|
|
9464
|
+
/** ln Γ(x) (Lanczos, g = 7). */
|
|
9465
|
+
function lnGamma(x) {
|
|
9466
|
+
const c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
|
|
9467
|
+
x -= 1;
|
|
9468
|
+
let a = c[0];
|
|
9469
|
+
const t = x + 7.5;
|
|
9470
|
+
for (let i = 1; i < 9; i++) a += c[i] / (x + i);
|
|
9471
|
+
return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(a);
|
|
9472
|
+
}
|
|
9473
|
+
|
|
9474
|
+
/** Beta incompleta regularizada I_x(a, b) (fracción continua de Lentz). */
|
|
9475
|
+
function betaInc(x, a, b) {
|
|
9476
|
+
if (x <= 0) return 0;
|
|
9477
|
+
if (x >= 1) return 1;
|
|
9478
|
+
const front = Math.exp(lnGamma(a + b) - lnGamma(a) - lnGamma(b) + a * Math.log(x) + b * Math.log(1 - x));
|
|
9479
|
+
const cf = (xx, aa, bb) => {
|
|
9480
|
+
const tiny = 1e-300;
|
|
9481
|
+
let c = 1, d = 1 - ((aa + bb) * xx) / (aa + 1);
|
|
9482
|
+
d = 1 / (Math.abs(d) < tiny ? tiny : d);
|
|
9483
|
+
let h = d;
|
|
9484
|
+
for (let m = 1; m <= 200; m++) {
|
|
9485
|
+
const m2 = 2 * m;
|
|
9486
|
+
let k = (m * (bb - m) * xx) / ((aa + m2 - 1) * (aa + m2));
|
|
9487
|
+
d = 1 + k * d; d = 1 / (Math.abs(d) < tiny ? tiny : d);
|
|
9488
|
+
c = 1 + k / c; if (Math.abs(c) < tiny) c = tiny;
|
|
9489
|
+
h *= d * c;
|
|
9490
|
+
k = (-(aa + m) * (aa + bb + m) * xx) / ((aa + m2) * (aa + m2 + 1));
|
|
9491
|
+
d = 1 + k * d; d = 1 / (Math.abs(d) < tiny ? tiny : d);
|
|
9492
|
+
c = 1 + k / c; if (Math.abs(c) < tiny) c = tiny;
|
|
9493
|
+
const del = d * c;
|
|
9494
|
+
h *= del;
|
|
9495
|
+
if (Math.abs(del - 1) < 1e-14) break;
|
|
9496
|
+
}
|
|
9497
|
+
return h;
|
|
9498
|
+
};
|
|
9499
|
+
return x < (a + 1) / (a + b + 2) ? (front * cf(x, a, b)) / a : 1 - (front * cf(1 - x, b, a)) / b;
|
|
9500
|
+
}
|
|
9501
|
+
|
|
9502
|
+
/**
|
|
9503
|
+
* p bilateral de H0: ρ = 0, con la t de Student de n − 2 grados de libertad
|
|
9504
|
+
* (la misma de scipy.stats.pearsonr / spearmanr y de pandas). La aproximación
|
|
9505
|
+
* normal de Fisher se queda corta con pocas filas y cambia quién pasa `alpha`.
|
|
9506
|
+
*/
|
|
9507
|
+
function pValue(r, n) {
|
|
9508
|
+
if (!Number.isFinite(r) || n < 3) return NaN;
|
|
9509
|
+
if (Math.abs(r) >= 1) return 0;
|
|
9510
|
+
return betaInc(1 - r * r, (n - 2) / 2, 0.5);
|
|
9511
|
+
}
|
|
9512
|
+
|
|
9513
|
+
/** Campos numéricos a correlacionar: `vars`, `y: [...]` o todas las medidas. */
|
|
9514
|
+
function corrVars(spec, data) {
|
|
9515
|
+
const list = spec.vars ?? (Array.isArray(spec.y) ? spec.y : null);
|
|
9516
|
+
if (list) {
|
|
9517
|
+
for (const f of list) requireField(data, f, 'vars');
|
|
9518
|
+
return uniq(list.map(String));
|
|
9519
|
+
}
|
|
9520
|
+
const hue = hueField(spec);
|
|
9521
|
+
return fieldsOf(data).filter((f) => f !== hue && f !== spec.label && fieldKind(data, f) === 'number');
|
|
9522
|
+
}
|
|
9523
|
+
|
|
9524
|
+
/**
|
|
9525
|
+
* Correlaciones por pares (casos completos de cada par).
|
|
9526
|
+
* Devuelve { r, n, p } como matrices k × k. Un par sin variación da NaN.
|
|
9527
|
+
*/
|
|
9528
|
+
function correlations(data, vars, method = 'pearson') {
|
|
9529
|
+
const cols = vars.map((f) => data.map((row) => num(row[f])));
|
|
9530
|
+
const k = vars.length;
|
|
9531
|
+
const M = () => Array.from({ length: k }, () => new Array(k).fill(NaN));
|
|
9532
|
+
const r = M(), n = M(), p = M();
|
|
9533
|
+
for (let i = 0; i < k; i++) {
|
|
9534
|
+
const c = cols[i].filter(Number.isFinite);
|
|
9535
|
+
const varies = c.some((v) => v !== c[0]);
|
|
9536
|
+
r[i][i] = varies ? 1 : NaN; n[i][i] = c.length; p[i][i] = varies ? 0 : NaN;
|
|
9537
|
+
for (let j = i + 1; j < k; j++) {
|
|
9538
|
+
const xs = [], ys = [];
|
|
9539
|
+
for (let t = 0; t < data.length; t++) {
|
|
9540
|
+
const a = cols[i][t], b = cols[j][t];
|
|
9541
|
+
if (Number.isFinite(a) && Number.isFinite(b)) { xs.push(a); ys.push(b); }
|
|
9542
|
+
}
|
|
9543
|
+
const m = xs.length;
|
|
9544
|
+
let v = NaN;
|
|
9545
|
+
if (m >= 3 && xs.some((x) => x !== xs[0]) && ys.some((y) => y !== ys[0])) {
|
|
9546
|
+
v = method === 'spearman' ? pearson(ranks(xs), ranks(ys)) : pearson(xs, ys);
|
|
9547
|
+
v = Math.max(-1, Math.min(1, v));
|
|
9548
|
+
}
|
|
9549
|
+
r[i][j] = r[j][i] = v;
|
|
9550
|
+
n[i][j] = n[j][i] = m;
|
|
9551
|
+
p[i][j] = p[j][i] = pValue(v, m);
|
|
9552
|
+
}
|
|
9553
|
+
}
|
|
9554
|
+
return { r, n, p };
|
|
9555
|
+
}
|
|
9556
|
+
|
|
9557
|
+
/**
|
|
9558
|
+
* Agrupamiento jerárquico aglomerativo (enlace promedio, distancia 1 − r),
|
|
9559
|
+
* como el de sns.clustermap. Devuelve el árbol: hojas { leaf }, nodos { left, right, h }.
|
|
9560
|
+
*/
|
|
9561
|
+
function clusterTree(r, idx = r.map((_, i) => i)) {
|
|
9562
|
+
let nodes = idx.map((i) => ({ leaf: i, members: [i], h: 0 }));
|
|
9563
|
+
const dist = (A, B) => {
|
|
9564
|
+
let s = 0, c = 0;
|
|
9565
|
+
for (const a of A.members) for (const b of B.members) { const v = r[a][b]; if (Number.isFinite(v)) { s += 1 - v; c++; } }
|
|
9566
|
+
return c ? s / c : 2;
|
|
9567
|
+
};
|
|
9568
|
+
while (nodes.length > 1) {
|
|
9569
|
+
let best = [0, 1, Infinity];
|
|
9570
|
+
for (let a = 0; a < nodes.length; a++) for (let b = a + 1; b < nodes.length; b++) {
|
|
9571
|
+
const d = dist(nodes[a], nodes[b]);
|
|
9572
|
+
if (d < best[2] - 1e-12) best = [a, b, d];
|
|
9573
|
+
}
|
|
9574
|
+
const [a, b, d] = best;
|
|
9575
|
+
const node = { left: nodes[a], right: nodes[b], h: Math.max(d, nodes[a].h, nodes[b].h), members: [...nodes[a].members, ...nodes[b].members] };
|
|
9576
|
+
nodes = nodes.filter((_, i) => i !== a && i !== b);
|
|
9577
|
+
nodes.splice(a, 0, node);
|
|
9578
|
+
}
|
|
9579
|
+
return nodes[0];
|
|
9580
|
+
}
|
|
9581
|
+
|
|
9582
|
+
const leaves = (node) => (node.left ? [...leaves(node.left), ...leaves(node.right)] : [node.leaf]);
|
|
9583
|
+
|
|
9584
|
+
|
|
9585
|
+
/**
|
|
9586
|
+
* Matriz de correlación. Desde filas crudas (`vars` o todas las medidas) o
|
|
9587
|
+
* desde una matriz ya calculada en formato largo (`x`, `y`, `value`).
|
|
9588
|
+
* Opciones: method ("pearson" | "spearman"), triangle ("lower" | "full"),
|
|
9589
|
+
* cluster (reordena y dibuja el dendrograma), alpha (apaga las no significativas),
|
|
9590
|
+
* highlight (una variable), labels, cellSize.
|
|
9591
|
+
*/
|
|
9592
|
+
function corr(spec, theme) {
|
|
9593
|
+
const data = ensureData(spec);
|
|
9594
|
+
const en = isEN(spec);
|
|
9595
|
+
const locale = spec.locale || 'es';
|
|
9596
|
+
const method = String(spec.method || 'pearson').toLowerCase() === 'spearman' ? 'spearman' : 'pearson';
|
|
9597
|
+
const long = typeof spec.x === 'string' && typeof spec.y === 'string' && typeof spec.value === 'string';
|
|
9598
|
+
|
|
9599
|
+
let vars, R, N, P;
|
|
9600
|
+
if (long) {
|
|
9601
|
+
requireField(data, spec.x, 'x'); requireField(data, spec.y, 'y'); requireField(data, spec.value, 'value');
|
|
9602
|
+
vars = uniq([...data.map((row) => String(row[spec.x])), ...data.map((row) => String(row[spec.y]))]);
|
|
9603
|
+
const at = new Map(vars.map((v, i) => [v, i]));
|
|
9604
|
+
const k = vars.length;
|
|
9605
|
+
R = Array.from({ length: k }, (_, i) => Array.from({ length: k }, (_, j) => (i === j ? 1 : NaN)));
|
|
9606
|
+
N = R.map((row) => row.map(() => NaN));
|
|
9607
|
+
P = N.map((row) => [...row]);
|
|
9608
|
+
for (const row of data) {
|
|
9609
|
+
const i = at.get(String(row[spec.x])), j = at.get(String(row[spec.y])), v = num(row[spec.value]);
|
|
9610
|
+
if (!Number.isFinite(v)) continue;
|
|
9611
|
+
R[i][j] = v;
|
|
9612
|
+
if (!Number.isFinite(R[j][i]) || i === j) R[j][i] = v;
|
|
9613
|
+
}
|
|
9614
|
+
} else {
|
|
9615
|
+
vars = corrVars(spec, data);
|
|
9616
|
+
({ r: R, n: N, p: P } = correlations(data, vars, method));
|
|
9617
|
+
}
|
|
9618
|
+
if (vars.length < 2) {
|
|
9619
|
+
throw en
|
|
9620
|
+
? new PlotError('A correlation matrix needs at least two numeric fields.', 'Pass rows with two or more measures, or list them in `vars: ["price", "area", "age"]`.')
|
|
9621
|
+
: new PlotError('La matriz de correlación necesita al menos dos campos numéricos.', 'Entrega filas con dos o más medidas, o indícalas en `vars: ["precio", "superficie", "antiguedad"]`.');
|
|
9622
|
+
}
|
|
9623
|
+
const name = (i) => (spec.labelsFor && spec.labelsFor[vars[i]]) || (long ? vars[i] : humanize(vars[i]));
|
|
9624
|
+
|
|
9625
|
+
const k = vars.length;
|
|
9626
|
+
let tree = null;
|
|
9627
|
+
let order = vars.map((_, i) => i);
|
|
9628
|
+
if (spec.cluster && k > 2) { tree = clusterTree(R); order = leaves(tree); }
|
|
9629
|
+
else if (Array.isArray(spec.order)) {
|
|
9630
|
+
const pos = (i) => { const a = spec.order.indexOf(vars[i]); return a < 0 ? spec.order.indexOf(name(i)) : a; };
|
|
9631
|
+
order = [...order].sort((a, b) => ((pos(a) + 1 || 1e9) - (pos(b) + 1 || 1e9)) || a - b);
|
|
9632
|
+
}
|
|
9633
|
+
const triangle = spec.triangle === false ? 'full' : spec.triangle === true ? 'lower' : (spec.triangle ?? (spec.cluster ? 'full' : 'lower'));
|
|
9634
|
+
const full = triangle === 'full' || k === 2 && triangle !== 'lower';
|
|
9635
|
+
const rows = full ? order : order.slice(1);
|
|
9636
|
+
const cols = full ? order : order.slice(0, -1);
|
|
9637
|
+
const showDendro = !!tree && full && spec.dendrogram !== false;
|
|
9638
|
+
|
|
9639
|
+
const vals = R.flat().filter(Number.isFinite);
|
|
9640
|
+
const span = long ? Math.max(1, ...vals.map(Math.abs)) : 1;
|
|
9641
|
+
const stops = theme.diverging;
|
|
9642
|
+
const colorOf = (v) => (Number.isFinite(v) ? ramp(stops, 0.5 + v / span / 2) : theme.tint);
|
|
9643
|
+
const dec = (v) => formatNumber(v, span > 1 ? 1 : 2, locale);
|
|
9644
|
+
const fmt = long && span > 1 ? makeFormat(spec.format, locale, vals) : dec;
|
|
9645
|
+
const endFmt = (v) => (span === 1 ? formatNumber(v, 0, locale) : fmt(v));
|
|
9646
|
+
const alpha = Number.isFinite(+spec.alpha) && +spec.alpha > 0 && +spec.alpha < 1 ? +spec.alpha : null;
|
|
9647
|
+
const insignificant = (i, j) => alpha !== null && i !== j && Number.isFinite(P[i][j]) && P[i][j] >= alpha;
|
|
9648
|
+
const sym = method === 'spearman' ? 'ρ' : 'r';
|
|
9649
|
+
const pText = (p) => (Number.isFinite(p) ? (p < 0.001 ? `p < ${formatNumber(0.001, 3, locale)}` : `p = ${formatNumber(p, p < 0.01 ? 3 : 2, locale)}`) : '');
|
|
9650
|
+
|
|
9651
|
+
const hl = new Set([].concat(spec.highlight ?? []).map(String));
|
|
9652
|
+
const isHL = (i) => hl.has(vars[i]) || hl.has(name(i));
|
|
9653
|
+
const anyHL = hl.size > 0 && vars.some((_, i) => isHL(i));
|
|
9654
|
+
|
|
9655
|
+
const rampTitle = spec.legendTitle ?? (long ? humanize(spec.value) : method === 'spearman' ? (en ? 'Spearman ρ' : 'ρ de Spearman') : (en ? 'Pearson r' : 'r de Pearson'));
|
|
9656
|
+
const legend = spec.legend === false ? [] : [
|
|
9657
|
+
{ type: 'ramp', stops, min: endFmt(-span), max: endFmt(span), title: rampTitle },
|
|
9658
|
+
...(alpha !== null ? [{ label: en ? `p ≥ ${formatNumber(alpha, 2, locale)}: not significant` : `p ≥ ${formatNumber(alpha, 2, locale)}: no significativa`, color: theme.tint }] : []),
|
|
9659
|
+
];
|
|
9660
|
+
const frame = createFrame(spec, theme, { height: 520 });
|
|
9661
|
+
const area = frame.layout(legend);
|
|
9662
|
+
|
|
9663
|
+
const X = ctx.textScale;
|
|
9664
|
+
const fs = 12;
|
|
9665
|
+
const rowW = Math.min(area.w * 0.34, Math.max(...rows.map((i) => measure(name(i), fs)))) + 12;
|
|
9666
|
+
const dendroH = showDendro ? 48 : 0;
|
|
9667
|
+
const colW = Math.max(...cols.map((i) => measure(name(i), fs)));
|
|
9668
|
+
const colLabMax = Math.min(colW, 150, spec.height ? Math.max(40, area.h * 0.28) : 150);
|
|
9669
|
+
const labH = (ang) => (ang === 90 ? colLabMax + 14 * X : ang ? colLabMax * 0.71 + 14 * X : 20 * X);
|
|
9670
|
+
const fitCell = (reserve) => Math.max(14, Math.min(+spec.cellSize || 96, (area.w - rowW) / cols.length, (spec.height ? Math.max(80, area.h - dendroH - reserve) : 600) / rows.length));
|
|
9671
|
+
const angleFor = (c) => (colW + 8 <= c ? 0 : c * 0.71 < fs * X + 3 ? 90 : 45);
|
|
9672
|
+
let cell = fitCell(labH(0));
|
|
9673
|
+
let angle = angleFor(cell);
|
|
9674
|
+
if (spec.height && angle) {
|
|
9675
|
+
cell = fitCell(labH(angle));
|
|
9676
|
+
angle = angleFor(cell);
|
|
9677
|
+
if (angle === 90) cell = fitCell(labH(90));
|
|
9678
|
+
}
|
|
9679
|
+
const colLabH = labH(angle);
|
|
9680
|
+
const gx = area.x + rowW, gy = area.y + dendroH;
|
|
9681
|
+
const gw = cell * cols.length, gh = cell * rows.length;
|
|
9682
|
+
if (spec.height === undefined) frame.setHeight(gy + gh + colLabH + 8 + frame.chrome);
|
|
9683
|
+
|
|
9684
|
+
const lab = cell >= 70 ? 13 : 11;
|
|
9685
|
+
const showLabels = spec.labels ?? cell >= measure(dec(-0.88), lab) + 8;
|
|
9686
|
+
const body = [];
|
|
9687
|
+
|
|
9688
|
+
if (showDendro) {
|
|
9689
|
+
const cx = new Map(cols.map((i, c) => [i, gx + c * cell + cell / 2]));
|
|
9690
|
+
const maxH = tree.h || 1;
|
|
9691
|
+
const yOf = (h) => gy - 6 - (h / maxH) * (dendroH - 12);
|
|
9692
|
+
const segs = [];
|
|
9693
|
+
const walk = (nd) => {
|
|
9694
|
+
if (!nd.left) return [cx.get(nd.leaf), gy - 6];
|
|
9695
|
+
const [xl, yl] = walk(nd.left), [xr, yr] = walk(nd.right);
|
|
9696
|
+
const y = yOf(nd.h);
|
|
9697
|
+
segs.push(`M${xl.toFixed(1)},${yl.toFixed(1)}V${y.toFixed(1)}H${xr.toFixed(1)}V${yr.toFixed(1)}`);
|
|
9698
|
+
return [(xl + xr) / 2, y];
|
|
9699
|
+
};
|
|
9700
|
+
walk(tree);
|
|
9701
|
+
body.push(el('path', { d: segs.join(''), fill: 'none', stroke: theme.muted, strokeWidth: 1.1, strokeLinejoin: 'round' }));
|
|
9702
|
+
}
|
|
9703
|
+
|
|
9704
|
+
rows.forEach((i, ri) => {
|
|
9705
|
+
const on = !anyHL || isHL(i);
|
|
9706
|
+
body.push(text(gx - 10, gy + ri * cell + cell / 2 + 4, truncate(name(i), rowW - 12, fs), { fill: anyHL && on ? theme.ink : theme.lede, fontSize: fs, textAnchor: 'end', fontWeight: anyHL && on ? 600 : null }));
|
|
9707
|
+
cols.forEach((j, ci) => {
|
|
9708
|
+
if (!full && order.indexOf(i) <= order.indexOf(j)) return;
|
|
9709
|
+
const v = R[i][j];
|
|
9710
|
+
const ns = insignificant(i, j);
|
|
9711
|
+
const fill = ns ? theme.tint : colorOf(v);
|
|
9712
|
+
const x = gx + ci * cell + 1, y = gy + ri * cell + 1, w = Math.max(0, cell - 2);
|
|
9713
|
+
const dim = anyHL && !isHL(i) && !isHL(j);
|
|
9714
|
+
const parts = [el('rect', { x, y, width: w, height: w, rx: Math.min(4, cell / 8), fill, opacity: dim ? 0.22 : null })];
|
|
9715
|
+
if (showLabels && Number.isFinite(v) && i !== j) {
|
|
9716
|
+
parts.push(text(x + w / 2, y + w / 2 + lab * 0.36, fmt(v), { fill: ns || dim ? theme.muted : textOn(fill, theme), fontSize: lab, textAnchor: 'middle', fontWeight: 500, opacity: dim ? 0.7 : null }));
|
|
9717
|
+
}
|
|
9718
|
+
const lines = Number.isFinite(v)
|
|
9719
|
+
? [`${long ? humanize(spec.value) : sym} = ${fmt(v)}`, i !== j && Number.isFinite(N[i][j]) ? `n = ${formatNumber(N[i][j], 0, locale)}${Number.isFinite(P[i][j]) ? ' · ' + pText(P[i][j]) : ''}` : '']
|
|
9720
|
+
: [en ? 'No variation or too few rows' : 'Sin variación o muy pocas filas'];
|
|
9721
|
+
body.push(g({ class: 'pa-m', 'data-tip': tip(`${name(i)} · ${name(j)}`, ...lines) }, parts));
|
|
9722
|
+
});
|
|
9723
|
+
});
|
|
9724
|
+
|
|
9725
|
+
cols.forEach((j, ci) => {
|
|
9726
|
+
const on = !anyHL || isHL(j);
|
|
9727
|
+
const cx = gx + ci * cell + cell / 2;
|
|
9728
|
+
const attrs = { fill: anyHL && on ? theme.ink : theme.lede, fontSize: fs, fontWeight: anyHL && on ? 600 : null };
|
|
9729
|
+
if (angle) {
|
|
9730
|
+
const y = gy + gh + (angle === 90 ? 8 : 12);
|
|
9731
|
+
const ax = angle === 90 ? cx + fs * 0.35 : cx + 4;
|
|
9732
|
+
body.push(text(ax, y, truncate(name(j), colLabMax, fs), { ...attrs, textAnchor: 'end', transform: `rotate(-${angle} ${ax.toFixed(1)} ${y.toFixed(1)})` }));
|
|
9733
|
+
} else body.push(text(cx, gy + gh + 16 * X, name(j), { ...attrs, textAnchor: 'middle' }));
|
|
9734
|
+
});
|
|
9735
|
+
|
|
9736
|
+
const pairs = [];
|
|
9737
|
+
for (let a = 0; a < k; a++) for (let b = a + 1; b < k; b++) if (Number.isFinite(R[a][b])) pairs.push([a, b, R[a][b]]);
|
|
9738
|
+
pairs.sort((p, q) => q[2] - p[2]);
|
|
9739
|
+
const pos = pairs.find((p) => p[2] > 0), neg = [...pairs].reverse().find((p) => p[2] < 0);
|
|
9740
|
+
const kind = long ? '' : method === 'spearman' ? ' (Spearman)' : ' (Pearson)';
|
|
9741
|
+
const say = (p) => `${name(p[0])} ${en ? 'and' : 'y'} ${name(p[1])} (${fmt(p[2])})`;
|
|
9742
|
+
const desc = en
|
|
9743
|
+
? `Correlation matrix${kind} of ${k} variables${tree ? ', ordered by hierarchical clustering' : ''}.` + (pos ? ` Strongest positive: ${say(pos)}.` : '') + (neg ? ` Strongest negative: ${say(neg)}.` : '') + (!pairs.length ? ' No pair has enough data.' : '')
|
|
9744
|
+
: `Matriz de correlación${kind} de ${k} variables${tree ? ', ordenadas por agrupamiento jerárquico' : ''}.` + (pos ? ` Más fuerte positiva: ${say(pos)}.` : '') + (neg ? ` Más fuerte negativa: ${say(neg)}.` : '') + (!pairs.length ? ' Ningún par tiene datos suficientes.' : '');
|
|
9745
|
+
return frame.finish(body, { desc, type: 'corr' });
|
|
9746
|
+
}
|
|
9747
|
+
|
|
9748
|
+
|
|
9749
|
+
/**
|
|
9750
|
+
* Matriz de dispersión (sns.pairplot). Abajo de la diagonal, dispersión de
|
|
9751
|
+
* cada par; en la diagonal, la distribución de cada variable; arriba, el r
|
|
9752
|
+
* de Pearson (o dispersión con `upper: "scatter"`, nada con `corner: true`).
|
|
9753
|
+
* Opciones: vars, hue, diag ("hist" | "kde"), kind ("scatter" | "reg"), bins, radius.
|
|
9754
|
+
*/
|
|
9755
|
+
function pairplot(spec, theme) {
|
|
9756
|
+
const data = ensureData(spec);
|
|
9757
|
+
const en = isEN(spec);
|
|
9758
|
+
const locale = spec.locale || 'es';
|
|
9759
|
+
let vars = corrVars(spec, data);
|
|
9760
|
+
const explicit = spec.vars || Array.isArray(spec.y);
|
|
9761
|
+
if (!explicit && vars.length > 6) vars = vars.slice(0, 6);
|
|
9762
|
+
if (vars.length < 2) {
|
|
9763
|
+
throw en
|
|
9764
|
+
? new PlotError('A pair plot needs at least two numeric fields.', 'Pass rows with two or more measures, or list them in `vars: ["price", "area"]`.')
|
|
9765
|
+
: new PlotError('La matriz de dispersión necesita al menos dos campos numéricos.', 'Entrega filas con dos o más medidas, o indícalas en `vars: ["precio", "superficie"]`.');
|
|
9766
|
+
}
|
|
9767
|
+
const hue = hueField(spec);
|
|
9768
|
+
if (hue) requireField(data, hue, 'hue');
|
|
9769
|
+
const k = vars.length;
|
|
9770
|
+
const name = (i) => (spec.labelsFor && spec.labelsFor[vars[i]]) || humanize(vars[i]);
|
|
9771
|
+
const cols = vars.map((f) => data.map((row) => num(row[f])));
|
|
9772
|
+
const hk = data.map((row) => (hue ? String(row[hue] ?? '') : ''));
|
|
9773
|
+
const keys = hue ? uniq(hk) : [''];
|
|
9774
|
+
const K = keys.length;
|
|
9775
|
+
const cmap = colorMap(keys, theme, spec.colors);
|
|
9776
|
+
const colorOf = (key) => (K === 1 ? (spec.colors && spec.colors['']) || theme.categorical[0] : cmap.get(key));
|
|
9777
|
+
const legend = K > 1 && spec.legend !== false ? keys.map((key) => ({ label: key, color: colorOf(key) })) : [];
|
|
9778
|
+
|
|
9779
|
+
const frame = createFrame(spec, theme, { height: 640 });
|
|
9780
|
+
const area = frame.layout(legend);
|
|
9781
|
+
const X = ctx.textScale;
|
|
9782
|
+
const fmts = vars.map((_, i) => makeFormat(spec.format, locale, cols[i].filter(Number.isFinite)));
|
|
9783
|
+
const axes = vars.map((_, i) => axisDomain(cols[i], { nice: 'tight', count: 3 }));
|
|
9784
|
+
const tfs = vars.map((_, i) => tickFmt(fmts[i], axes[i]));
|
|
9785
|
+
const ticksIn = (ax) => ax.ticks.filter((t) => t >= ax.min && t <= ax.max);
|
|
9786
|
+
const fs = 11;
|
|
9787
|
+
const leftM = Math.max(...vars.slice(1).map((_, i) => Math.max(0, ...ticksIn(axes[i + 1]).map((t) => measure(tfs[i + 1](t), fs))))) + 8;
|
|
9788
|
+
const gap = 8;
|
|
9789
|
+
const bottom = 20 * X;
|
|
9790
|
+
const fitW = (area.w - leftM - gap * (k - 1)) / k;
|
|
9791
|
+
const fitH = spec.height ? (area.h - bottom - gap * (k - 1)) / k : Infinity;
|
|
9792
|
+
const s = Math.max(40, Math.min(fitW, fitH, +spec.panelSize || 220));
|
|
9793
|
+
const px = (j) => area.x + leftM + j * (s + gap);
|
|
9794
|
+
const py = (i) => area.y + i * (s + gap);
|
|
9795
|
+
if (spec.height === undefined) frame.setHeight(py(k - 1) + s + bottom + 6 + frame.chrome);
|
|
9796
|
+
|
|
9797
|
+
const { r: R, n: N } = correlations(data, vars, 'pearson');
|
|
9798
|
+
const upper = spec.corner ? 'none' : spec.upper === false ? 'none' : (spec.upper ?? 'corr');
|
|
9799
|
+
const diag = spec.diag === 'kde' || spec.diag === 'hist' ? spec.diag : hue ? 'kde' : 'hist';
|
|
9800
|
+
const n = data.length;
|
|
9801
|
+
const rad = spec.radius ?? (n > 600 ? 1.5 : n > 200 ? 2 : n > 60 ? 2.6 : 3.2);
|
|
9802
|
+
const M = theme.marks || {};
|
|
9803
|
+
const body = [];
|
|
9804
|
+
const frameRect = (x, y) => el('rect', { x, y, width: s, height: s, fill: 'none', stroke: theme.grid, strokeWidth: 1 });
|
|
9805
|
+
const gridLines = (i, j) => {
|
|
9806
|
+
const xs = axes[j].scale(px(j) + 4, px(j) + s - 4), ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
|
|
9807
|
+
const out = [];
|
|
9808
|
+
for (const t of ticksIn(axes[j])) out.push(el('line', { x1: xs(t), x2: xs(t), y1: py(i), y2: py(i) + s, stroke: theme.grid, strokeWidth: 0.75 }));
|
|
9809
|
+
for (const t of ticksIn(axes[i])) out.push(el('line', { x1: px(j), x2: px(j) + s, y1: ys(t), y2: ys(t), stroke: theme.grid, strokeWidth: 0.75 }));
|
|
9810
|
+
return out;
|
|
9811
|
+
};
|
|
9812
|
+
|
|
9813
|
+
const scatterPanel = (i, j) => {
|
|
9814
|
+
const xs = axes[j].scale(px(j) + 4, px(j) + s - 4), ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
|
|
9815
|
+
const pts = [];
|
|
9816
|
+
for (let t = 0; t < n; t++) {
|
|
9817
|
+
const a = cols[j][t], b = cols[i][t];
|
|
9818
|
+
if (Number.isFinite(a) && Number.isFinite(b)) pts.push({ x: a, y: b, key: hk[t], label: spec.label ? String(data[t][spec.label] ?? '') : '' });
|
|
9819
|
+
}
|
|
9820
|
+
const out = [...gridLines(i, j)];
|
|
9821
|
+
out.push(g({}, dotLayer(pts.map((p) => ({
|
|
9822
|
+
class: 'pa-m', cx: xs(p.x), cy: ys(p.y), r: rad, fill: colorOf(p.key), fillOpacity: M.dotOpacity ?? 0.72,
|
|
9823
|
+
stroke: pts.length > 300 ? 'none' : theme.surface, strokeWidth: 0.75,
|
|
9824
|
+
'data-tip': tip(p.label || p.key || `${name(i)} · ${name(j)}`, `${name(j)}: ${fmts[j](p.x)}`, `${name(i)}: ${fmts[i](p.y)}`, p.label && p.key ? p.key : ''),
|
|
9825
|
+
})))));
|
|
9826
|
+
if (spec.kind === 'reg') {
|
|
9827
|
+
for (const key of keys) {
|
|
9828
|
+
const gp = pts.filter((p) => p.key === key);
|
|
9829
|
+
if (gp.length < 3) continue;
|
|
9830
|
+
const f = polyfit(gp.map((p) => p.x), gp.map((p) => p.y));
|
|
9831
|
+
const lo = Math.min(...gp.map((p) => p.x)), hi = Math.max(...gp.map((p) => p.x));
|
|
9832
|
+
const clampY = (v) => Math.max(py(i), Math.min(py(i) + s, ys(v)));
|
|
9833
|
+
out.push(el('line', { x1: xs(lo), y1: clampY(f.predict(lo)), x2: xs(hi), y2: clampY(f.predict(hi)), stroke: K === 1 ? theme.ink : colorOf(key), strokeWidth: 1.75, opacity: 0.9 }));
|
|
9834
|
+
}
|
|
9835
|
+
}
|
|
9836
|
+
return out;
|
|
9837
|
+
};
|
|
9838
|
+
|
|
9839
|
+
const diagPanel = (i) => {
|
|
9840
|
+
const ax = axes[i];
|
|
9841
|
+
const xs = ax.scale(px(i) + 4, px(i) + s - 4);
|
|
9842
|
+
const base = py(i) + s, top = py(i) + 22 * X;
|
|
9843
|
+
const out = [];
|
|
9844
|
+
const groups = keys.map((key) => cols[i].filter((v, t) => Number.isFinite(v) && hk[t] === key));
|
|
9845
|
+
const allV = cols[i].filter(Number.isFinite);
|
|
9846
|
+
if (diag === 'kde') {
|
|
9847
|
+
const curves = groups.map((vs) => (vs.length > 1 && vs.some((v) => v !== vs[0]) ? kdeEst(vs, { extent: [ax.min, ax.max], gridSize: 90 }).map(([x, d]) => [x, (d * vs.length) / allV.length]) : []));
|
|
9848
|
+
const mx = Math.max(1e-12, ...curves.flat().map((p) => p[1]));
|
|
9849
|
+
curves.forEach((c, gi) => {
|
|
9850
|
+
if (!c.length) return;
|
|
9851
|
+
const yOf = (d) => base - (d / mx) * (base - top);
|
|
9852
|
+
const line = c.map(([x, d], t) => `${t ? 'L' : 'M'}${xs(x).toFixed(2)},${yOf(d).toFixed(2)}`).join('');
|
|
9853
|
+
out.push(el('path', { d: `${line}L${xs(c[c.length - 1][0]).toFixed(2)},${base}L${xs(c[0][0]).toFixed(2)},${base}Z`, fill: colorOf(keys[gi]), fillOpacity: (M.areaOpacity ?? 0.12) * 1.6 }));
|
|
9854
|
+
out.push(el('path', { d: line, fill: 'none', stroke: colorOf(keys[gi]), strokeWidth: 1.6 }));
|
|
9855
|
+
});
|
|
9856
|
+
} else {
|
|
9857
|
+
const nb = spec.bins ?? Math.max(6, Math.min(s < 120 ? 14 : 22, histogram(allV).length));
|
|
9858
|
+
const bins = histogram(allV, { bins: nb, domain: [ax.min, ax.max] });
|
|
9859
|
+
const counts = groups.map((vs) => bins.map((b, bi) => vs.filter((v) => v >= b.x0 && (v < b.x1 || (bi === bins.length - 1 && v <= b.x1))).length));
|
|
9860
|
+
const mx = Math.max(1, ...counts.flat());
|
|
9861
|
+
const op = K > 1 ? 0.55 : 0.85;
|
|
9862
|
+
counts.forEach((cs, gi) => bins.forEach((b, bi) => {
|
|
9863
|
+
const h = (cs[bi] / mx) * (base - top);
|
|
9864
|
+
const a0 = xs(b.x0) + 0.5, a1 = xs(b.x1) - 0.5;
|
|
9865
|
+
if (h <= 0 || a1 <= a0) return;
|
|
9866
|
+
out.push(el('path', { class: 'pa-m', d: barPath(a0, base - h, a1 - a0, h, 'up', 1.5), fill: colorOf(keys[gi]), fillOpacity: op, 'data-tip': tip(name(i), `${fmts[i](b.x0)} – ${fmts[i](b.x1)}`, `${keys[gi] ? keys[gi] + ': ' : ''}${cs[bi]}`) }));
|
|
9867
|
+
}));
|
|
9868
|
+
}
|
|
9869
|
+
out.push(el('line', { x1: px(i), x2: px(i) + s, y1: base - 0.5, y2: base - 0.5, stroke: theme.axis }));
|
|
9870
|
+
out.push(text(px(i) + 6, py(i) + 14 * X, truncate(name(i), s - 12, 12, 600), { fill: theme.ink, fontSize: 12, fontWeight: 600, ...halo(theme) }));
|
|
9871
|
+
return out;
|
|
9872
|
+
};
|
|
9873
|
+
|
|
9874
|
+
const corrPanel = (i, j) => {
|
|
9875
|
+
const v = R[i][j];
|
|
9876
|
+
const cx = px(j) + s / 2, cy = py(i) + s / 2;
|
|
9877
|
+
const out = [el('rect', { x: px(j), y: py(i), width: s, height: s, fill: Number.isFinite(v) ? ramp(theme.diverging, 0.5 + v / 2) : theme.tint, fillOpacity: Number.isFinite(v) ? 0.08 + 0.32 * Math.abs(v) : 0.5 })];
|
|
9878
|
+
if (Number.isFinite(v)) {
|
|
9879
|
+
const size = Math.min(s * 0.24, 13 + 15 * Math.abs(v));
|
|
9880
|
+
const showGroups = K > 1 && K <= 4 && s >= 110;
|
|
9881
|
+
const y0 = showGroups ? cy - (K * 14) / 2 + 2 : cy + size * 0.36;
|
|
9882
|
+
out.push(text(cx, y0, formatNumber(v, 2, locale), { fill: readable(ramp(theme.diverging, 0.5 + v / 2), theme, 4.5), fontSize: size, fontWeight: 600, textAnchor: 'middle' }));
|
|
9883
|
+
if (showGroups) {
|
|
9884
|
+
keys.forEach((key, gi) => {
|
|
9885
|
+
const sub = correlations(data.filter((_, t) => hk[t] === key), [vars[i], vars[j]]).r[0][1];
|
|
9886
|
+
out.push(text(cx, y0 + 18 + gi * 14, `${truncate(key, s * 0.55, 10.5)}: ${Number.isFinite(sub) ? formatNumber(sub, 2, locale) : '—'}`, { fill: readable(colorOf(key), theme, 3), fontSize: 10.5, textAnchor: 'middle' }));
|
|
9887
|
+
});
|
|
9888
|
+
}
|
|
9889
|
+
}
|
|
9890
|
+
return [g({ class: 'pa-m', 'data-tip': tip(`${name(i)} · ${name(j)}`, `r = ${Number.isFinite(v) ? formatNumber(v, 2, locale) : '—'}`, `n = ${formatNumber(N[i][j], 0, locale)}`) }, out)];
|
|
9891
|
+
};
|
|
9892
|
+
|
|
9893
|
+
for (let i = 0; i < k; i++) {
|
|
9894
|
+
for (let j = 0; j < k; j++) {
|
|
9895
|
+
if (i < j && upper === 'none') continue;
|
|
9896
|
+
const part = i === j ? diagPanel(i) : i > j || upper === 'scatter' ? scatterPanel(i, j) : corrPanel(i, j);
|
|
9897
|
+
body.push(g({}, [...part, frameRect(px(j), py(i))]));
|
|
9898
|
+
}
|
|
9899
|
+
}
|
|
9900
|
+
|
|
9901
|
+
for (let j = 0; j < k; j++) {
|
|
9902
|
+
const xs = axes[j].scale(px(j) + 4, px(j) + s - 4);
|
|
9903
|
+
let ts = ticksIn(axes[j]);
|
|
9904
|
+
if (ts.length > 2 && ts.some((t, ti) => ti && xs(t) - xs(ts[ti - 1]) < Math.max(...ts.map((u) => measure(tfs[j](u), fs))) + 6)) ts = ts.filter((_, ti) => ti % 2 === 0);
|
|
9905
|
+
for (const t of ts) body.push(text(xs(t), py(k - 1) + s + 14 * X, tfs[j](t), { fill: theme.muted, fontSize: fs, textAnchor: 'middle' }));
|
|
9906
|
+
}
|
|
9907
|
+
for (let i = 1; i < k; i++) {
|
|
9908
|
+
const ys = axes[i].scale(py(i) + s - 4, py(i) + 4);
|
|
9909
|
+
for (const t of ticksIn(axes[i])) body.push(text(px(0) - 6, ys(t) + 4, tfs[i](t), { fill: theme.muted, fontSize: fs, textAnchor: 'end' }));
|
|
9910
|
+
}
|
|
9911
|
+
|
|
9912
|
+
const pairs = [];
|
|
9913
|
+
for (let a = 0; a < k; a++) for (let b = a + 1; b < k; b++) if (Number.isFinite(R[a][b])) pairs.push([a, b, R[a][b]]);
|
|
9914
|
+
pairs.sort((p, q) => Math.abs(q[2]) - Math.abs(p[2]));
|
|
9915
|
+
const top = pairs[0];
|
|
9916
|
+
const desc = en
|
|
9917
|
+
? `Pair plot of ${k} variables (${formatNumber(n, 0, locale)} rows)${hue ? ` colored by ${humanize(hue)}` : ''}: scatter plots below the diagonal, distributions on it.` + (top ? ` Strongest correlation: ${name(top[0])} and ${name(top[1])} (r = ${formatNumber(top[2], 2, locale)}).` : '')
|
|
9918
|
+
: `Matriz de dispersión de ${k} variables (${formatNumber(n, 0, locale)} filas)${hue ? ` por ${humanize(hue).toLowerCase()}` : ''}: dispersión bajo la diagonal y distribuciones en ella.` + (top ? ` Correlación más fuerte: ${name(top[0])} y ${name(top[1])} (r = ${formatNumber(top[2], 2, locale)}).` : '');
|
|
9919
|
+
return frame.finish(body, { desc, type: 'pairplot' });
|
|
9920
|
+
}
|
|
9921
|
+
|
|
9922
|
+
return { "corrVars": corrVars, "correlations": correlations, "clusterTree": clusterTree, "corr": corr, "pairplot": pairplot };
|
|
9923
|
+
});
|
|
9227
9924
|
__def("src/charts/sankey.js", function () {
|
|
9228
9925
|
const { el, g, text, tip } = __req("src/svg.js");
|
|
9229
9926
|
const { measure, truncate } = __req("src/text.js");
|
|
@@ -9338,7 +10035,7 @@ function sankey(spec, theme) {
|
|
|
9338
10035
|
const x0 = l.sn.x + NW, x1 = l.tn.x, xm = (x0 + x1) / 2, w = l.v * ky;
|
|
9339
10036
|
const d = `M${x0},${l.y0}C${xm},${l.y0} ${xm},${l.y1} ${x1},${l.y1}L${x1},${l.y1 + w}C${xm},${l.y1 + w} ${xm},${l.y0 + w} ${x0},${l.y0 + w}Z`;
|
|
9340
10037
|
const share = l.sn.value ? Math.round((l.v / l.sn.value) * 100) : 0;
|
|
9341
|
-
linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% de ${l.s}` }));
|
|
10038
|
+
linkG.push(el('path', { class: 'pa-m', d, fill: linkColor(l), fillOpacity: theme.link, 'data-tip': `${l.s} → ${l.t}\n${fmt(l.v)} · ${share}% ${en ? 'of' : 'de'} ${l.s}` }));
|
|
9342
10039
|
}
|
|
9343
10040
|
body.push(g({ class: 'pa-links' }, linkG));
|
|
9344
10041
|
for (const [ci, c] of cols.entries()) for (const n of c) {
|
|
@@ -9456,14 +10153,14 @@ function kpi(spec, theme) {
|
|
|
9456
10153
|
if (it.target !== undefined) {
|
|
9457
10154
|
y += 24 * X;
|
|
9458
10155
|
const t = num(it.target);
|
|
9459
|
-
const f = Math.max(0, Math.min(1, value / (t || 1)));
|
|
10156
|
+
const f = Number.isFinite(value) ? Math.max(0, Math.min(1, value / (t || 1))) : 0; // valor no numérico: riel vacío, nunca NaN
|
|
9460
10157
|
const w = tw - P * 2;
|
|
9461
10158
|
const upGood = (it.good ?? 'up') === 'up';
|
|
9462
10159
|
const ratio = upGood ? value / (t || 1) : (t || 1) / (value || 1);
|
|
9463
10160
|
const fill = ratio >= 1 ? theme.status.good : ratio >= 0.8 ? theme.categorical[0] : theme.status.critical;
|
|
9464
10161
|
parts.push(el('rect', { x: cx + P, y, width: w, height: 6, rx: 3, fill: theme.tint2 }));
|
|
9465
10162
|
parts.push(el('rect', { x: cx + P, y, width: Math.max(6, w * f), height: 6, rx: 3, fill }));
|
|
9466
|
-
const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + formatNumber((value / (t || 1)) * 100, 0, locale) + '%';
|
|
10163
|
+
const lab = (en ? 'Target ' : 'Meta ') + fmt(t) + ' · ' + (Number.isFinite(value) ? formatNumber((value / (t || 1)) * 100, 0, locale) + '%' : '—');
|
|
9467
10164
|
parts.push(text(cx + P, y - 7, lab, { fill: theme.lede, fontSize: 11.5 }));
|
|
9468
10165
|
}
|
|
9469
10166
|
|
|
@@ -9484,6 +10181,850 @@ function kpi(spec, theme) {
|
|
|
9484
10181
|
|
|
9485
10182
|
return { "kpi": kpi };
|
|
9486
10183
|
});
|
|
10184
|
+
__def("src/charts/table.js", function () {
|
|
10185
|
+
const { el, g, text, tip, esc } = __req("src/svg.js");
|
|
10186
|
+
const { measure, truncate, wrap } = __req("src/text.js");
|
|
10187
|
+
const { makeFormat, formatDate, parseDate, currencyPrefix } = __req("src/format.js");
|
|
10188
|
+
const { ensureData, fieldsOf, fieldKind, num, uniq, humanize, PlotError, aggregate } = __req("src/data.js");
|
|
10189
|
+
const { createFrame } = __req("src/frame.js");
|
|
10190
|
+
const { ctx } = __req("src/context.js");
|
|
10191
|
+
const { ramp, textOn, readable, hexToRgb, rgbToHex, contrast } = __req("src/theme.js");
|
|
10192
|
+
const { dateUnit } = __req("src/scale.js");
|
|
10193
|
+
const { resolveColorScale, sample } = __req("src/colorscales.js");
|
|
10194
|
+
const { words, langOf } = __req("src/i18n.js");
|
|
10195
|
+
|
|
10196
|
+
const TYPE_ALIAS = {
|
|
10197
|
+
text: 'text', string: 'text', label: 'text', category: 'text',
|
|
10198
|
+
number: 'number', numeric: 'number', value: 'number',
|
|
10199
|
+
bar: 'bar', bars: 'bar', databar: 'bar',
|
|
10200
|
+
heat: 'heat', heatmap: 'heat', color: 'heat', colour: 'heat', scale: 'heat',
|
|
10201
|
+
delta: 'delta', change: 'delta', variation: 'delta', variacion: 'delta', 'variación': 'delta', arrow: 'delta', diff: 'delta',
|
|
10202
|
+
sparkline: 'sparkline', spark: 'sparkline', trend: 'sparkline', line: 'sparkline', tendencia: 'sparkline',
|
|
10203
|
+
progress: 'progress', meter: 'progress', target: 'progress', bullet: 'progress', avance: 'progress', goal: 'progress',
|
|
10204
|
+
badge: 'badge', pill: 'badge', tag: 'badge', status: 'badge', estado: 'badge', chip: 'badge',
|
|
10205
|
+
check: 'check', boolean: 'check', bool: 'check', icon: 'check',
|
|
10206
|
+
date: 'date', fecha: 'date', time: 'date',
|
|
10207
|
+
link: 'link', url: 'link',
|
|
10208
|
+
rank: 'rank',
|
|
10209
|
+
};
|
|
10210
|
+
const COLUMN_TYPES = [...new Set(Object.values(TYPE_ALIAS))];
|
|
10211
|
+
const columnType = (t) => TYPE_ALIAS[String(t || '').toLowerCase()] || null;
|
|
10212
|
+
|
|
10213
|
+
const DELTA_RE = /(^|[\s_.-])((var|vars|chg|yoy|mom|qoq|diff|dif)(?![a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1])|delta|change|cambio|crec|growth|difference|diferencia|variaci[oó]n|varia[cç][aã]o|ver[äa]nderung|[ée]volution|evoluci[oó]n|\+\/-)/i;
|
|
10214
|
+
const PCT_NAME_RE = /(%|pct|percent|porc|porcentaje|tasa|rate|share|participaci|anteil|taux|part\b)/i;
|
|
10215
|
+
const PROGRESS_RE = /(progress|avance|progreso|complet|cumplim|ejecuci[oó]n|achiev|logro|fortschritt|avancement|andamento)/i;
|
|
10216
|
+
const GOAL_RE = /^(meta|metas|target|goal|objetivo|objective|ziel|objectif|cumplimiento|attainment|ratio)$|(^|[\s_.-])(meta|target|goal|objetivo)([\s_.-]|$)/i;
|
|
10217
|
+
const ID_RE = /^(id|_id|code|c[oó]d(igo)?|cod_.*|n[°º.]?|no\.?|nro|num(ero|ber)?|#|rut|dni|zip|cp|phone|tel[eé]fono|tel)$/i;
|
|
10218
|
+
|
|
10219
|
+
const STATUS = [
|
|
10220
|
+
['good', /^(ok|okay|done|complete|completed|finished|approved|active|paid|delivered|resolved|success|successful|on track|healthy|passed|closed|won|live|listo|lista|hecho|hecha|completad[oa]|terminad[oa]|finalizad[oa]|aprobad[oa]|activ[oa]|pagad[oa]|entregad[oa]|resuelt[oa]|al d[ií]a|cumplid[oa]|exitos[oa]|vigente|logrado|ganad[oa]|conclu[ií]d[oa]|pago|ativo|aprovad[oa]|fertig|erledigt|abgeschlossen|aktiv|bezahlt|genehmigt|termin[ée]e?|valid[ée]e?|pay[ée]e?|actif|active|r[ée]ussi)$/i],
|
|
10221
|
+
['warning', /^(at risk|pending|on hold|in review|under review|review|delayed|warning|paused|draft|waiting|medium|en riesgo|pendiente|en espera|en revisi[oó]n|pausad[oa]|borrador|demorad[oa]|retrasad[oa]|observad[oa]|em risco|pendente|em an[aá]lise|em espera|en attente|[àa] risque|en retard|brouillon|ausstehend|verz[öo]gert|gef[äa]hrdet|in pr[üu]fung|entwurf)$/i],
|
|
10222
|
+
['critical', /^(late|overdue|failed|fail|failing|error|blocked|cancell?ed|rejected|lost|critical|down|off track|expired|atrasad[oa]|vencid[oa]|bloquead[oa]|cancelad[oa]|rechazad[oa]|fallid[oa]|perdid[oa]|cr[ií]tic[oa]|ca[ií]d[oa]|expirad[oa]|reprovad[oa]|falh[ao]u?|[ée]chou[ée]e?|bloqu[ée]e?|annul[ée]e?|rejet[ée]e?|perdu|fehlgeschlagen|blockiert|abgelehnt|storniert|[üu]berf[äa]llig|verloren)$/i],
|
|
10223
|
+
['info', /^(in progress|ongoing|open|new|started|running|planned|scheduled|en curso|en progreso|en proceso|en ejecuci[oó]n|abiert[oa]|nuev[oa]|iniciad[oa]|planificad[oa]|programad[oa]|em andamento|em curso|aberto|novo|planejado|en cours|ouvert|nouveau|planifi[ée]e?|in bearbeitung|offen|neu|geplant|l[äa]uft)$/i],
|
|
10224
|
+
];
|
|
10225
|
+
const statusOf = (v) => { const s = String(v ?? '').trim(); for (const [k, re] of STATUS) if (re.test(s)) return k; return null; };
|
|
10226
|
+
|
|
10227
|
+
const YES = /^(true|yes|y|s[ií]|sim|oui|ja|x|✓|✔|☑)$/i;
|
|
10228
|
+
const NO = /^(false|no|n|não|nao|non|nein|✗|✘|☐|-|–|—)$/i;
|
|
10229
|
+
const SAFE_URL = /^(https?:\/\/|mailto:)[^\s"'<>]+$/i;
|
|
10230
|
+
|
|
10231
|
+
const norm = (s) => String(s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
10232
|
+
const isNil = (v) => v === null || v === undefined || v === '' || (typeof v === 'number' && Number.isNaN(v));
|
|
10233
|
+
const decimalsOf = (v) => { if (!Number.isFinite(v) || Number.isInteger(v)) return 0; const s = String(+v.toPrecision(12)); const m = /\.(\d+)$/.exec(s) || /e-(\d+)/.exec(s); return m ? Math.min(6, m[1].length) : 0; };
|
|
10234
|
+
|
|
10235
|
+
function mix(a, b, t) {
|
|
10236
|
+
const A = hexToRgb(a), B = hexToRgb(b);
|
|
10237
|
+
return rgbToHex(A.map((v, i) => v + (B[i] - v) * t));
|
|
10238
|
+
}
|
|
10239
|
+
/** Acerca `fg` a `toward` hasta lograr contraste ≥ min sobre `bg`. */
|
|
10240
|
+
function legible(fg, bg, toward, min = 4.5) {
|
|
10241
|
+
let c = fg;
|
|
10242
|
+
for (let t = 0.05; contrast(c, bg) < min && t <= 1.0001; t += 0.05) c = mix(fg, toward, t);
|
|
10243
|
+
return c;
|
|
10244
|
+
}
|
|
10245
|
+
const hexOr = (c, d) => (typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim()) ? c.trim() : d);
|
|
10246
|
+
|
|
10247
|
+
/** Lista de columnas: la que da el agente o una inferida de los datos. */
|
|
10248
|
+
function columnList(spec, data) {
|
|
10249
|
+
const fields = fieldsOf(data);
|
|
10250
|
+
const groupF = typeof spec.group === 'string' && fields.includes(spec.group) ? spec.group : null;
|
|
10251
|
+
if (Array.isArray(spec.columns) && spec.columns.length) {
|
|
10252
|
+
return spec.columns.map((c) => (typeof c === 'string' ? { field: c } : c && typeof c === 'object' ? { ...c, field: c.field ?? c.key ?? c.name ?? c.id } : null)).filter((c) => c && (c.field !== undefined || columnType(c.type) === 'rank'));
|
|
10253
|
+
}
|
|
10254
|
+
const labelF = spec.label ?? spec.x;
|
|
10255
|
+
let list;
|
|
10256
|
+
if (spec.y !== undefined || spec.value !== undefined) {
|
|
10257
|
+
list = [labelF, ...[].concat(spec.y ?? []), ...[].concat(spec.value ?? [])].filter((f) => f !== undefined && f !== null);
|
|
10258
|
+
if (!labelF) { const first = fields.find((f) => fieldKind(data, f) === 'category' && f !== groupF); if (first) list.unshift(first); }
|
|
10259
|
+
} else {
|
|
10260
|
+
list = fields.filter((f) => f !== groupF && !String(f).startsWith('_'));
|
|
10261
|
+
if (labelF && list.includes(labelF)) list = [labelF, ...list.filter((f) => f !== labelF)];
|
|
10262
|
+
}
|
|
10263
|
+
if (typeof spec.dashed === 'string') list = list.filter((f) => f !== spec.dashed);
|
|
10264
|
+
return uniq(list).map((f) => ({ field: f }));
|
|
10265
|
+
}
|
|
10266
|
+
|
|
10267
|
+
/** Pista de formato a partir del texto crudo: "12%" → porcentaje, "$ 3.000" → moneda. */
|
|
10268
|
+
function rawHint(values) {
|
|
10269
|
+
const strs = values.filter((v) => typeof v === 'string' && v.trim());
|
|
10270
|
+
if (!strs.length || strs.length < values.length * 0.6) return null;
|
|
10271
|
+
if (strs.filter((s) => /%\s*$/.test(s)).length >= strs.length * 0.6) return { type: 'percent', scale: 1 };
|
|
10272
|
+
const cur = strs.map((s) => (/^\s*[-−]?\s*(US\$|\$|€|£|R\$|S\/|UF)/.exec(s) || [])[1]).filter(Boolean);
|
|
10273
|
+
if (cur.length >= strs.length * 0.6) return { type: 'number', prefix: cur[0] === 'UF' ? 'UF ' : cur[0] };
|
|
10274
|
+
return null;
|
|
10275
|
+
}
|
|
10276
|
+
|
|
10277
|
+
/**
|
|
10278
|
+
* Formateador de una columna numérica: mismos decimales en toda la columna
|
|
10279
|
+
* (los números se leen alineados) y cifras completas; nada de "1,2 M" en una
|
|
10280
|
+
* tabla salvo que se pida `format: "compact"`.
|
|
10281
|
+
*/
|
|
10282
|
+
function columnFormat(fmt, values, locale, hint) {
|
|
10283
|
+
let o = typeof fmt === 'string' ? { type: fmt } : fmt && typeof fmt === 'object' ? { ...fmt } : hint ? { ...hint } : { type: 'number' };
|
|
10284
|
+
if (!o.type) o.type = 'number';
|
|
10285
|
+
const nums = values.filter(Number.isFinite);
|
|
10286
|
+
const maxAbs = nums.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
|
10287
|
+
if (o.type === 'currency') { o = { ...o, type: 'number', prefix: o.prefix ?? currencyPrefix(o.currency, langOf(locale)) }; if (o.decimals === undefined) o.decimals = maxAbs >= 100 || nums.every(Number.isInteger) ? 0 : 2; }
|
|
10288
|
+
if (o.type === 'number' && o.decimals === undefined) {
|
|
10289
|
+
const cap = maxAbs >= 1000 ? 0 : maxAbs >= 100 ? 1 : maxAbs >= 1 ? 2 : 3;
|
|
10290
|
+
o.decimals = Math.min(cap, Math.max(0, ...nums.map(decimalsOf)));
|
|
10291
|
+
}
|
|
10292
|
+
if (o.type === 'percent' && o.decimals === undefined) {
|
|
10293
|
+
const scale = o.scale ?? (maxAbs > 1.5 ? 1 : 100);
|
|
10294
|
+
o.scale = scale;
|
|
10295
|
+
const sc = nums.map((v) => v * scale);
|
|
10296
|
+
const m = sc.reduce((a, v) => Math.max(a, Math.abs(v)), 0);
|
|
10297
|
+
o.decimals = Math.min(m >= 10 ? 1 : 2, Math.max(0, ...sc.map((v) => decimalsOf(+v.toPrecision(10)))));
|
|
10298
|
+
}
|
|
10299
|
+
return makeFormat(o, locale, nums);
|
|
10300
|
+
}
|
|
10301
|
+
|
|
10302
|
+
/** Prepara cada columna: tipo, alineación, formato y estadísticas sobre TODAS las filas. */
|
|
10303
|
+
function prepareColumns(spec, data, theme, locale) {
|
|
10304
|
+
const W = words(locale);
|
|
10305
|
+
const list = columnList(spec, data);
|
|
10306
|
+
const fields = fieldsOf(data);
|
|
10307
|
+
for (const c of list) {
|
|
10308
|
+
if (c.field !== undefined && columnType(c.type) !== 'rank' && !fields.includes(c.field)) {
|
|
10309
|
+
throw new PlotError(`La columna "${c.field}" no existe en los datos.`, `Campos disponibles: ${fields.map((f) => `"${f}"`).join(', ') || '(ninguno)'}`);
|
|
10310
|
+
}
|
|
10311
|
+
}
|
|
10312
|
+
const labelF = spec.label ?? spec.x ?? (list.find((c) => c.field !== undefined && fieldKind(data, c.field) === 'category') || list[0] || {}).field;
|
|
10313
|
+
const mainF = spec.bars === false ? null : spec.value ?? (typeof spec.y === 'string' ? spec.y : undefined);
|
|
10314
|
+
let barGiven = false;
|
|
10315
|
+
const cols = list.map((c0, i) => {
|
|
10316
|
+
const c = { ...c0, index: i };
|
|
10317
|
+
const t = columnType(c.type);
|
|
10318
|
+
if (c.type !== undefined && !t) c.type = undefined;
|
|
10319
|
+
c.label = c.label ?? c.title ?? c.header ?? (t === 'rank' && c.field === undefined ? '#' : humanize(c.field));
|
|
10320
|
+
if (t === 'rank') { c.type = 'rank'; c.align = c.align ?? 'right'; return c; }
|
|
10321
|
+
const raw = data.map((r) => r[c.field]);
|
|
10322
|
+
const present = raw.filter((v) => !isNil(v));
|
|
10323
|
+
const kind = present.length ? fieldKind(data, c.field) : 'empty';
|
|
10324
|
+
const isArr = present.length && present.filter((v) => Array.isArray(v)).length >= present.length * 0.5;
|
|
10325
|
+
const isBool = present.length && (present.every((v) => typeof v === 'boolean') || (present.every((v) => typeof v === 'string' && (YES.test(v.trim()) || NO.test(v.trim()))) && present.some((v) => YES.test(String(v).trim())) && present.some((v) => NO.test(String(v).trim())) && uniq(present.map((v) => String(v).trim().toLowerCase())).length <= 3));
|
|
10326
|
+
const nums = kind === 'number' || kind === 'year' ? raw.map(num) : [];
|
|
10327
|
+
const finite = nums.filter(Number.isFinite);
|
|
10328
|
+
c.isLabel = c.field === labelF;
|
|
10329
|
+
let type = t;
|
|
10330
|
+
if (!type) {
|
|
10331
|
+
if (isArr) type = 'sparkline';
|
|
10332
|
+
else if (isBool) type = 'check';
|
|
10333
|
+
else if (kind === 'date') type = 'date';
|
|
10334
|
+
else if (kind === 'number' || (kind === 'year' && !c.isLabel)) {
|
|
10335
|
+
const lo = Math.min(...finite), hi = Math.max(...finite);
|
|
10336
|
+
if (ID_RE.test(String(c.field))) type = 'text';
|
|
10337
|
+
else if (kind === 'year') type = 'number';
|
|
10338
|
+
else if (DELTA_RE.test(String(c.field)) || DELTA_RE.test(String(c.label))) type = 'delta';
|
|
10339
|
+
else if ((PROGRESS_RE.test(String(c.field)) || PROGRESS_RE.test(String(c.label))) && lo >= 0 && hi <= 150) type = 'progress';
|
|
10340
|
+
else if ((GOAL_RE.test(String(c.field)) || GOAL_RE.test(String(c.label))) && lo >= 0 && hi <= 1.5 && !finite.every(Number.isInteger)) type = 'progress';
|
|
10341
|
+
else type = 'number';
|
|
10342
|
+
} else if (present.length && present.filter((v) => typeof v === 'string' && SAFE_URL.test(v.trim())).length >= present.length * 0.8) type = 'link';
|
|
10343
|
+
else if (present.length >= 2 && present.filter((v) => statusOf(v)).length >= present.length * 0.8) type = 'badge';
|
|
10344
|
+
else type = 'text';
|
|
10345
|
+
}
|
|
10346
|
+
if (type === 'bar' || type === 'heat' || type === 'progress') barGiven = barGiven || type === 'bar';
|
|
10347
|
+
c.type = type;
|
|
10348
|
+
c.kind = kind;
|
|
10349
|
+
c.values = nums;
|
|
10350
|
+
c.finite = finite;
|
|
10351
|
+
const numeric = ['number', 'bar', 'heat', 'delta', 'progress'].includes(type);
|
|
10352
|
+
if (numeric && !finite.length && !(type === 'number' && kind === 'year')) c.type = type = 'text';
|
|
10353
|
+
const hint = rawHint(raw);
|
|
10354
|
+
if (type === 'delta') {
|
|
10355
|
+
const pctLike = hint?.type === 'percent' || PCT_NAME_RE.test(String(c.field)) || PCT_NAME_RE.test(String(c.label)) || (finite.length && finite.every((v) => Math.abs(v) <= 1.5) && !finite.every(Number.isInteger));
|
|
10356
|
+
c.fmt = columnFormat(c.format ?? (pctLike ? (hint?.type === 'percent' ? hint : { type: 'percent' }) : hint), finite.map(Math.abs), locale, null);
|
|
10357
|
+
} else if (type === 'progress') {
|
|
10358
|
+
const target = c.target ?? c.max;
|
|
10359
|
+
c.targetOf = (r) => { const t = typeof target === 'string' ? num(r[target]) : Number.isFinite(+target) && target !== null && target !== undefined ? +target : NaN; return t; };
|
|
10360
|
+
c.hasTarget = target !== undefined && target !== null;
|
|
10361
|
+
const hi = Math.max(0, ...finite);
|
|
10362
|
+
c.scale100 = !c.hasTarget && hi > 1.5;
|
|
10363
|
+
c.fmt = c.hasTarget ? columnFormat(c.format ?? spec.format ?? hint, finite, locale, null) : makeFormat({ type: 'percent', scale: c.scale100 ? 1 : 100, decimals: 0 }, locale, finite);
|
|
10364
|
+
c.pctFmt = makeFormat({ type: 'percent', scale: 100, decimals: 0 }, locale, []);
|
|
10365
|
+
} else if (numeric || (type === 'number' && kind === 'year')) {
|
|
10366
|
+
c.fmt = kind === 'year' && !c.format ? (v) => String(Math.round(v)) : columnFormat(c.format ?? spec.format ?? hint, finite, locale, null);
|
|
10367
|
+
} else if (type === 'sparkline') {
|
|
10368
|
+
const all = present.flatMap((v) => (Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []));
|
|
10369
|
+
c.fmt = columnFormat(c.format ?? spec.format, all, locale, null);
|
|
10370
|
+
c.sparkAll = all;
|
|
10371
|
+
} else if (type === 'date') {
|
|
10372
|
+
const ts = present.map(parseDate).filter(Number.isFinite);
|
|
10373
|
+
c.unit = ts.length ? dateUnit(ts) : 'day';
|
|
10374
|
+
c.withYear = c.yearless !== true;
|
|
10375
|
+
}
|
|
10376
|
+
c.align = c.align ?? (['number', 'bar', 'heat', 'delta', 'progress'].includes(type) ? 'right' : type === 'check' ? 'center' : type === 'sparkline' ? 'left' : 'left');
|
|
10377
|
+
if (type === 'bar' || type === 'heat') {
|
|
10378
|
+
c.lo = Math.min(0, ...finite);
|
|
10379
|
+
c.hi = Math.max(0, ...finite);
|
|
10380
|
+
if (type === 'heat') {
|
|
10381
|
+
c.hlo = Math.min(...finite); c.hhi = Math.max(...finite);
|
|
10382
|
+
const cs = resolveColorScale(c.colorScale ?? c.cmap);
|
|
10383
|
+
c.mid = c.midpoint ?? spec.midpoint ?? (cs && typeof c.colorScale === 'object' && c.colorScale.mid !== undefined ? +c.colorScale.mid : undefined) ?? ((!cs || cs.kind === 'diverging') && c.hlo < 0 && c.hhi > 0 ? 0 : undefined);
|
|
10384
|
+
c.stops = cs && cs.kind !== 'qualitative' ? sample(cs.stops, 17) : null;
|
|
10385
|
+
if (c.stops && cs.kind === 'diverging' && c.mid === undefined) c.mid = (c.hlo + c.hhi) / 2;
|
|
10386
|
+
}
|
|
10387
|
+
}
|
|
10388
|
+
if (type === 'badge') {
|
|
10389
|
+
const vals = uniq(present.map(String));
|
|
10390
|
+
const fixed = { ...(spec.colors || {}), ...(c.colors || {}) };
|
|
10391
|
+
c.badge = new Map(vals.map((v) => {
|
|
10392
|
+
const st = statusOf(v);
|
|
10393
|
+
const base = hexOr(fixed[v], st === 'good' ? theme.status.good : st === 'warning' ? theme.status.warning : st === 'critical' ? theme.status.critical : st === 'info' ? theme.categorical[0] : null);
|
|
10394
|
+
return [v, base];
|
|
10395
|
+
}));
|
|
10396
|
+
}
|
|
10397
|
+
c.good = c.good ?? spec.good ?? 'up';
|
|
10398
|
+
return c;
|
|
10399
|
+
});
|
|
10400
|
+
if (!barGiven && spec.bars !== false && data.length >= 3 && !(Array.isArray(spec.columns) && spec.columns.some((c) => c && typeof c === 'object' && c.type))) {
|
|
10401
|
+
const main = cols.find((c) => c.field === mainF && c.type === 'number' && c.kind === 'number') || (mainF === undefined || mainF === null ? cols.find((c) => c.type === 'number' && c.kind === 'number' && !c.isLabel) : null);
|
|
10402
|
+
if (main && main.finite.length >= 2) { main.type = 'bar'; main.lo = Math.min(0, ...main.finite); main.hi = Math.max(0, ...main.finite); main.auto = true; }
|
|
10403
|
+
}
|
|
10404
|
+
if (spec.rank && !cols.some((c) => c.type === 'rank')) cols.unshift({ type: 'rank', label: '#', align: 'right', index: -1 });
|
|
10405
|
+
return { cols, labelF, W };
|
|
10406
|
+
}
|
|
10407
|
+
|
|
10408
|
+
/** Texto visible de una celda (y su valor para ordenar). */
|
|
10409
|
+
function cellText(c, r, locale, W) {
|
|
10410
|
+
const v = r[c.field];
|
|
10411
|
+
if (c.type === 'rank') return String(r.__rank ?? '');
|
|
10412
|
+
if (isNil(v)) return '';
|
|
10413
|
+
switch (c.type) {
|
|
10414
|
+
case 'number': case 'bar': case 'heat': { const n = num(v); return Number.isFinite(n) ? c.fmt(n) : String(v); }
|
|
10415
|
+
case 'delta': { const n = num(v); if (!Number.isFinite(n)) return String(v); const s = c.fmt(Math.abs(n)); return n > 0 ? '+' + s : n < 0 ? '−' + s : s; }
|
|
10416
|
+
case 'progress': { const n = num(v); return Number.isFinite(n) ? c.fmt(n) : String(v); }
|
|
10417
|
+
case 'date': { const t = parseDate(v); return Number.isFinite(t) ? formatDate(t, c.unit, locale, c.withYear) : String(v); }
|
|
10418
|
+
case 'check': return v === true || YES.test(String(v).trim()) ? W.yes : W.no;
|
|
10419
|
+
case 'sparkline': { const a = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []; return a.length ? c.fmt(a[a.length - 1]) : ''; }
|
|
10420
|
+
case 'link': { const s = String(v).trim(); if (c.text && r[c.text] !== undefined) return String(r[c.text]); return s.replace(/^mailto:/i, '').replace(/^https?:\/\/(www\.)?/i, '').replace(/\/$/, ''); }
|
|
10421
|
+
default:
|
|
10422
|
+
if (Array.isArray(v)) return v.map((x) => (x && typeof x === 'object' ? JSON.stringify(x) : String(x))).join(', ');
|
|
10423
|
+
if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return '…'; } }
|
|
10424
|
+
return String(v);
|
|
10425
|
+
}
|
|
10426
|
+
}
|
|
10427
|
+
|
|
10428
|
+
function sortKey(c, r) {
|
|
10429
|
+
const v = r[c.field];
|
|
10430
|
+
if (c.type === 'rank') return r.__rank;
|
|
10431
|
+
if (isNil(v)) return null;
|
|
10432
|
+
switch (c.type) {
|
|
10433
|
+
case 'number': case 'bar': case 'heat': case 'delta': case 'progress': { const n = num(v); return Number.isFinite(n) ? n : null; }
|
|
10434
|
+
case 'date': { const t = parseDate(v); return Number.isFinite(t) ? t : String(v); }
|
|
10435
|
+
case 'check': return v === true || YES.test(String(v).trim()) ? 1 : 0;
|
|
10436
|
+
case 'sparkline': { const a = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : []; return a.length ? a[a.length - 1] : null; }
|
|
10437
|
+
default: return String(v);
|
|
10438
|
+
}
|
|
10439
|
+
}
|
|
10440
|
+
|
|
10441
|
+
const collatorCache = new Map();
|
|
10442
|
+
function compareText(locale) {
|
|
10443
|
+
const k = langOf(locale);
|
|
10444
|
+
if (!collatorCache.has(k)) {
|
|
10445
|
+
let cmp;
|
|
10446
|
+
try { const col = new Intl.Collator(k, { numeric: true, sensitivity: 'base' }); cmp = (a, b) => col.compare(a, b); } catch { cmp = (a, b) => (norm(a) < norm(b) ? -1 : norm(a) > norm(b) ? 1 : 0); }
|
|
10447
|
+
collatorCache.set(k, cmp);
|
|
10448
|
+
}
|
|
10449
|
+
return collatorCache.get(k);
|
|
10450
|
+
}
|
|
10451
|
+
|
|
10452
|
+
/** Orden actual: `sortBy` (campo o etiqueta) y `sort` ("desc"/"asc"). También `sort: "-ventas"`. */
|
|
10453
|
+
function sortState(spec, cols) {
|
|
10454
|
+
let by = spec.sortBy ?? spec.orderBy;
|
|
10455
|
+
let dir = spec.sortOrder ?? (['asc', 'desc'].includes(spec.sort) ? spec.sort : undefined);
|
|
10456
|
+
if (by === undefined && typeof spec.sort === 'string' && !['asc', 'desc', 'none', 'alpha', 'start'].includes(spec.sort)) {
|
|
10457
|
+
by = spec.sort.replace(/^[-+]/, '');
|
|
10458
|
+
if (dir === undefined) dir = spec.sort.startsWith('-') ? 'desc' : spec.sort.startsWith('+') ? 'asc' : undefined;
|
|
10459
|
+
}
|
|
10460
|
+
if (spec.sort && typeof spec.sort === 'object') { by = spec.sort.by ?? spec.sort.field; dir = spec.sort.order ?? spec.sort.dir; }
|
|
10461
|
+
if (by === undefined && dir) by = (cols.find((c) => c.type === 'bar') || cols.find((c) => ['number', 'heat', 'progress'].includes(c.type)) || {}).field;
|
|
10462
|
+
if (by === undefined || spec.sort === 'none') return null;
|
|
10463
|
+
const col = cols.find((c) => c.field === by) || cols.find((c) => String(c.label) === String(by)) || (by === '#' || by === 'rank' ? cols.find((c) => c.type === 'rank') : null);
|
|
10464
|
+
if (!col) return null;
|
|
10465
|
+
const numericish = ['number', 'bar', 'heat', 'delta', 'progress', 'sparkline', 'check'].includes(col.type);
|
|
10466
|
+
return { col, dir: dir === 'asc' || dir === 'desc' ? dir : numericish ? 'desc' : 'asc' };
|
|
10467
|
+
}
|
|
10468
|
+
|
|
10469
|
+
const BASE = { cell: 13, head: 12, pad: 10 };
|
|
10470
|
+
|
|
10471
|
+
function table(spec, theme) {
|
|
10472
|
+
const locale = spec.locale || 'es';
|
|
10473
|
+
const lang = langOf(locale);
|
|
10474
|
+
const all = ensureData(spec).map((r, i) => ({ ...r, __i: i }));
|
|
10475
|
+
const { cols, labelF, W } = prepareColumns(spec, all, theme, locale);
|
|
10476
|
+
if (!cols.length) throw new PlotError('La tabla no tiene columnas.', 'Pasa filas con campos, o `columns: ["region", "ventas"]`.');
|
|
10477
|
+
const X = ctx.textScale;
|
|
10478
|
+
const frame = createFrame(spec, theme, { height: 400 });
|
|
10479
|
+
const area = frame.layout([]);
|
|
10480
|
+
const density = spec.density === 'compact' ? 0.65 : spec.density === 'comfortable' ? 1.3 : 1;
|
|
10481
|
+
const padY = Math.round(9 * density);
|
|
10482
|
+
const FS = BASE.cell, HS = BASE.head;
|
|
10483
|
+
const LH = Math.round(FS * X * 1.38);
|
|
10484
|
+
const groupF = typeof spec.group === 'string' && all.length && spec.group in all[0] ? spec.group : null;
|
|
10485
|
+
const hl = spec.highlight === undefined || spec.highlight === null || spec.highlight === false ? null : new Set([].concat(spec.highlight).map(String));
|
|
10486
|
+
const isHL = (r) => hl && labelF !== undefined && hl.has(String(r[labelF]));
|
|
10487
|
+
|
|
10488
|
+
let rows = all;
|
|
10489
|
+
const query = typeof spec.query === 'string' ? spec.query.trim() : '';
|
|
10490
|
+
if (query) {
|
|
10491
|
+
const terms = norm(query).split(/\s+/).filter(Boolean);
|
|
10492
|
+
rows = rows.filter((r) => {
|
|
10493
|
+
const hay = norm(cols.map((c) => (c.type === 'rank' ? '' : cellText(c, r, locale, W) + ' ' + (r[c.field] ?? ''))).join(' ') + ' ' + (groupF ? r[groupF] : ''));
|
|
10494
|
+
return terms.every((t) => hay.includes(t));
|
|
10495
|
+
});
|
|
10496
|
+
}
|
|
10497
|
+
const st = sortState(spec, cols);
|
|
10498
|
+
if (st && st.col.type !== 'rank') {
|
|
10499
|
+
const cmpT = compareText(locale);
|
|
10500
|
+
const sgn = st.dir === 'asc' ? 1 : -1;
|
|
10501
|
+
rows = [...rows].sort((a, b) => {
|
|
10502
|
+
const ka = sortKey(st.col, a), kb = sortKey(st.col, b);
|
|
10503
|
+
if (ka === null && kb === null) return a.__i - b.__i;
|
|
10504
|
+
if (ka === null) return 1;
|
|
10505
|
+
if (kb === null) return -1;
|
|
10506
|
+
const d = typeof ka === 'number' && typeof kb === 'number' ? ka - kb : cmpT(String(ka), String(kb));
|
|
10507
|
+
return d * sgn || a.__i - b.__i;
|
|
10508
|
+
});
|
|
10509
|
+
} else if (st && st.col.type === 'rank' && st.dir === 'desc') rows = [...rows].reverse();
|
|
10510
|
+
if (groupF) {
|
|
10511
|
+
const order = new Map(uniq(all.map((r) => String(r[groupF] ?? ''))).map((k, i) => [k, i]));
|
|
10512
|
+
rows = [...rows].sort((a, b) => order.get(String(a[groupF] ?? '')) - order.get(String(b[groupF] ?? '')));
|
|
10513
|
+
}
|
|
10514
|
+
if (Number.isFinite(+spec.top) && +spec.top > 0) rows = rows.slice(0, +spec.top);
|
|
10515
|
+
rows = rows.map((r, i) => ({ ...r, __rank: i + 1 }));
|
|
10516
|
+
|
|
10517
|
+
const nRows = rows.length;
|
|
10518
|
+
const psRaw = spec.pageSize ?? spec.perPage ?? (spec.paginate === false ? Infinity : undefined);
|
|
10519
|
+
const pageSize = psRaw === false || psRaw === 0 || psRaw === Infinity ? Infinity : Number.isFinite(+psRaw) && +psRaw > 0 ? Math.round(+psRaw) : nRows > 15 ? 10 : Infinity;
|
|
10520
|
+
const pages = pageSize === Infinity ? 1 : Math.max(1, Math.ceil(nRows / pageSize));
|
|
10521
|
+
const page = Math.max(1, Math.min(pages, Math.round(+spec.page || 1)));
|
|
10522
|
+
const pageRows = pageSize === Infinity ? rows : rows.slice((page - 1) * pageSize, page * pageSize);
|
|
10523
|
+
|
|
10524
|
+
const totalOf = (c) => {
|
|
10525
|
+
const pick = c.total ?? (spec.total === true || typeof spec.total === 'string' ? (typeof spec.total === 'string' && spec.total !== 'true' ? spec.total : 'auto') : spec.total && typeof spec.total === 'object' ? spec.total[c.field] ?? 'auto' : undefined);
|
|
10526
|
+
if (pick === undefined || pick === false || c.isLabel || c.type === 'rank') return null;
|
|
10527
|
+
if (!['number', 'bar', 'heat', 'progress', 'delta'].includes(c.type) || c.kind === 'year') return null;
|
|
10528
|
+
const how = pick === true || pick === 'auto' ? (c.type === 'delta' ? null : (c.type === 'progress' && !c.hasTarget) || c.fmt?.type === 'percent' ? 'mean' : 'sum') : pick; // con meta (gasto vs presupuesto) se suma
|
|
10529
|
+
if (!how) return null;
|
|
10530
|
+
const v = aggregate(rows.map((r) => num(r[c.field])), how);
|
|
10531
|
+
return Number.isFinite(v) ? v : null;
|
|
10532
|
+
};
|
|
10533
|
+
const hasTotal = spec.total !== false && cols.some((c) => totalOf(c) !== null);
|
|
10534
|
+
const totalLabel = spec.totalLabel ?? (spec.total && typeof spec.total === 'object' ? spec.total.label : undefined) ?? W.total;
|
|
10535
|
+
|
|
10536
|
+
const edge = Math.min(BASE.pad, Math.max(4, frame.pad - 6));
|
|
10537
|
+
const tableX = area.x - edge, tableW = area.w + edge * 2;
|
|
10538
|
+
const sample = all.length > 1500 ? all.filter((_, i) => i % Math.ceil(all.length / 1500) === 0) : all;
|
|
10539
|
+
const P = BASE.pad;
|
|
10540
|
+
const pillH = Math.round(Math.min(22, LH + 3));
|
|
10541
|
+
for (const c of cols) {
|
|
10542
|
+
const texts = c.type === 'rank' ? [String(Math.max(1, nRows))] : sample.map((r) => cellText(c, r, locale, W));
|
|
10543
|
+
const weight = c.isLabel ? 500 : 400;
|
|
10544
|
+
const widths = texts.map((t) => measure(t, FS, weight));
|
|
10545
|
+
let tw = Math.max(0, ...widths);
|
|
10546
|
+
if (hasTotal && c.isLabel) tw = Math.max(tw, measure(totalLabel, FS, 600));
|
|
10547
|
+
const tot = hasTotal ? totalOf(c) : null;
|
|
10548
|
+
if (tot !== null) tw = Math.max(tw, measure(c.type === 'progress' && !c.hasTarget ? c.pctFmt(tot / (c.scale100 ? 100 : 1)) : c.fmt(tot), FS, 600));
|
|
10549
|
+
const longest = Math.max(0, ...texts.map((t) => Math.max(0, ...String(t).split(/\s+/).map((w) => measure(w, FS, weight)))));
|
|
10550
|
+
const avgLen = texts.reduce((a, t) => a + String(t).length, 0) / Math.max(1, texts.length);
|
|
10551
|
+
c.lines = c.lines ?? (c.type === 'text' || c.type === 'link' ? (avgLen > 36 ? 3 : 2) : 1);
|
|
10552
|
+
c.valueW = tw;
|
|
10553
|
+
let nat, min;
|
|
10554
|
+
switch (c.type) {
|
|
10555
|
+
case 'bar': nat = tw + 8 + 120; min = tw + 8 + 36; break;
|
|
10556
|
+
case 'heat': nat = tw + 16; min = nat; break;
|
|
10557
|
+
case 'delta': nat = tw + 13; min = nat; break;
|
|
10558
|
+
case 'sparkline': nat = 104; min = 56; break;
|
|
10559
|
+
case 'progress': nat = tw + 8 + 96; min = tw + 8 + 40; break;
|
|
10560
|
+
case 'badge': { const pw = Math.max(0, ...texts.map((t) => measure(t, 12, 500) + 18)); nat = pw; min = Math.min(pw, 84); break; }
|
|
10561
|
+
case 'check': nat = 18; min = 18; break;
|
|
10562
|
+
case 'text': case 'link': nat = tw; min = Math.min(tw, Math.max(longest, Math.min(tw, 96))); break;
|
|
10563
|
+
default: nat = tw; min = tw;
|
|
10564
|
+
}
|
|
10565
|
+
const hw = measure(c.label, HS, 600) + (c.type === 'rank' ? 0 : 12); // + flecha de orden
|
|
10566
|
+
const hmin = Math.max(...String(c.label).split(/\s+/).map((w) => measure(w, HS, 600))) + 12;
|
|
10567
|
+
c.nat = Math.max(nat, Math.min(hw, Math.max(nat, hw))) + 2 * P;
|
|
10568
|
+
c.min = Math.max(min, Math.min(hmin, hw)) + 2 * P;
|
|
10569
|
+
if (Number.isFinite(+c.width) && +c.width > 0) { c.nat = c.min = +c.width; c.fixed = true; }
|
|
10570
|
+
c.minW = Math.min(c.min, c.nat);
|
|
10571
|
+
}
|
|
10572
|
+
const sumMin = cols.reduce((a, c) => a + c.minW, 0);
|
|
10573
|
+
const sumNat = cols.reduce((a, c) => a + c.nat, 0);
|
|
10574
|
+
const cardsOpt = spec.cards ?? spec.stacked ?? 'auto';
|
|
10575
|
+
const cards = cardsOpt === true || cardsOpt === 'always' || (cardsOpt !== false && cardsOpt !== 'never' && (sumMin > tableW + 0.5 || (tableW < 360 && cols.length > 3)));
|
|
10576
|
+
|
|
10577
|
+
if (!cards) {
|
|
10578
|
+
for (const c of cols) c.w = c.nat;
|
|
10579
|
+
if (sumNat <= tableW) {
|
|
10580
|
+
let extra = tableW - sumNat;
|
|
10581
|
+
const tracks = cols.filter((c) => !c.fixed && (c.type === 'bar' || c.type === 'progress'));
|
|
10582
|
+
for (const c of tracks) { const add = Math.min(extra / tracks.length, 110); c.w += add; }
|
|
10583
|
+
extra = tableW - cols.reduce((a, c) => a + c.w, 0);
|
|
10584
|
+
const flex = cols.filter((c) => !c.fixed && (c.type === 'text' || c.type === 'link' || c.isLabel));
|
|
10585
|
+
const share = flex.length ? flex : cols.filter((c) => !c.fixed);
|
|
10586
|
+
const base = share.reduce((a, c) => a + c.w, 0) || 1;
|
|
10587
|
+
for (const c of share) c.w += (extra * c.w) / base;
|
|
10588
|
+
} else {
|
|
10589
|
+
let need = sumNat - tableW;
|
|
10590
|
+
const ORDER = [['sparkline', 'bar', 'progress'], ['text', 'link'], null];
|
|
10591
|
+
for (const group of ORDER) {
|
|
10592
|
+
if (need <= 0) break;
|
|
10593
|
+
const take = cols.filter((c) => !c.fixed && (group ? group.includes(c.type) : true));
|
|
10594
|
+
const slack = take.reduce((a, c) => a + (c.w - c.minW), 0);
|
|
10595
|
+
if (slack <= 0) continue;
|
|
10596
|
+
const k = Math.min(1, need / slack);
|
|
10597
|
+
for (const c of take) { const d = (c.w - c.minW) * k; c.w -= d; need -= d; }
|
|
10598
|
+
}
|
|
10599
|
+
if (need > 0.5) { const k = tableW / cols.reduce((a, c) => a + c.w, 0); for (const c of cols) c.w *= k; }
|
|
10600
|
+
}
|
|
10601
|
+
let x = tableX;
|
|
10602
|
+
for (const c of cols) { c.x = x; x += c.w; }
|
|
10603
|
+
}
|
|
10604
|
+
|
|
10605
|
+
const body = [];
|
|
10606
|
+
const accent = theme.categorical[0];
|
|
10607
|
+
const negColor = theme.categorical[1] || theme.status.critical;
|
|
10608
|
+
const isDark = theme.mode === 'dark';
|
|
10609
|
+
const goodC = readable(theme.status.good, theme), badC = readable(theme.status.critical, theme);
|
|
10610
|
+
const ink = theme.ink;
|
|
10611
|
+
const dimC = mix(theme.muted, theme.surface, isDark ? 0.4 : 0.45);
|
|
10612
|
+
const baseline = (top) => top + padY + Math.round(FS * X * 0.96);
|
|
10613
|
+
const midLine = (top) => top + padY + LH / 2;
|
|
10614
|
+
|
|
10615
|
+
const pill = (x, cy, label, color, maxW, align = 'left') => {
|
|
10616
|
+
const bg = color ? mix(color, theme.surface, isDark ? 0.72 : 0.86) : theme.tint;
|
|
10617
|
+
const fg = color ? legible(isDark ? mix(color, '#FFFFFF', 0.25) : color, bg, isDark ? '#FFFFFF' : ink) : theme.lede;
|
|
10618
|
+
const t = truncate(label, maxW - 18, 12, 500);
|
|
10619
|
+
const w = Math.min(maxW, measure(t, 12, 500) + 18);
|
|
10620
|
+
const x0 = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x;
|
|
10621
|
+
return [
|
|
10622
|
+
el('rect', { x: x0, y: cy - pillH / 2, width: w, height: pillH, rx: pillH / 2, fill: bg }),
|
|
10623
|
+
color ? el('circle', { cx: x0 + 9, cy, r: 3, fill: fg }) : '',
|
|
10624
|
+
text(x0 + (color ? 16 : 9), cy + 4.2 * X, t, { fill: fg, fontSize: 12, fontWeight: 500 }),
|
|
10625
|
+
];
|
|
10626
|
+
};
|
|
10627
|
+
const heatColor = (c, v) => {
|
|
10628
|
+
if (c.mid !== undefined) { const span = Math.max(c.hhi - c.mid, c.mid - c.hlo) || 1; return ramp(c.stops || theme.diverging, 0.5 + 0.85 * ((v - c.mid) / span / 2)); }
|
|
10629
|
+
const t = (v - c.hlo) / (c.hhi - c.hlo || 1);
|
|
10630
|
+
return c.stops ? ramp(c.stops, 0.05 + 0.75 * t) : ramp(theme.sequential.slice(0, -1), 0.04 + 0.72 * t);
|
|
10631
|
+
};
|
|
10632
|
+
const arrow = (x, cy, up, color) => el('path', { d: up ? `M${x},${cy + 3}L${x + 3.5},${cy - 3}L${x + 7},${cy + 3}Z` : `M${x},${cy - 3}L${x + 3.5},${cy + 3}L${x + 7},${cy - 3}Z`, fill: color, 'aria-hidden': 'true' });
|
|
10633
|
+
const deltaColor = (c, n) => (n === 0 ? theme.muted : (n > 0) === (c.good !== 'down') ? goodC : badC);
|
|
10634
|
+
const check = (cx, cy, yes) => yes
|
|
10635
|
+
? el('path', { d: `M${cx - 4.5},${cy + 0.2}L${cx - 1.4},${cy + 3.3}L${cx + 4.8},${cy - 3.6}`, fill: 'none', stroke: goodC, strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' })
|
|
10636
|
+
: el('path', { d: `M${cx - 3.5},${cy}H${cx + 3.5}`, stroke: theme.muted, strokeWidth: 1.6, strokeLinecap: 'round' });
|
|
10637
|
+
const spark = (x, cy, w, h, vals, muted) => {
|
|
10638
|
+
if (vals.length < 2) return '';
|
|
10639
|
+
const mn = Math.min(...vals), mx = Math.max(...vals);
|
|
10640
|
+
const pts = vals.map((v, k) => [x + (k / (vals.length - 1)) * (w - 4) + 1, cy + h / 2 - ((v - mn) / (mx - mn || 1)) * h]);
|
|
10641
|
+
const d = pts.map(([a, b], k) => (k ? 'L' : 'M') + a.toFixed(1) + ',' + b.toFixed(1)).join('');
|
|
10642
|
+
const [lx, ly] = pts[pts.length - 1];
|
|
10643
|
+
return [
|
|
10644
|
+
el('path', { d: d + `L${lx.toFixed(1)},${(cy + h / 2).toFixed(1)}L${pts[0][0].toFixed(1)},${(cy + h / 2).toFixed(1)}Z`, fill: muted ? dimC : accent, fillOpacity: 0.08, stroke: 'none' }),
|
|
10645
|
+
el('path', { d, fill: 'none', stroke: muted ? dimC : theme.muted, strokeWidth: 1.5, strokeLinejoin: 'round', strokeLinecap: 'round' }),
|
|
10646
|
+
el('circle', { cx: lx, cy: ly, r: 2.6, fill: muted ? dimC : accent }),
|
|
10647
|
+
];
|
|
10648
|
+
};
|
|
10649
|
+
const sparkTip = (c, vals) => (vals.length ? `${c.fmt(vals[0])} → ${c.fmt(vals[vals.length - 1])}` + (vals.length > 2 ? ` (min ${c.fmt(Math.min(...vals))}, max ${c.fmt(Math.max(...vals))})` : '') : '');
|
|
10650
|
+
const anyHL = hl && rows.some(isHL);
|
|
10651
|
+
|
|
10652
|
+
/** Contenido de una celda dentro de una caja (x, w) y una fila (top, alto). */
|
|
10653
|
+
function cell(c, r, x, w, top, h, opts = {}) {
|
|
10654
|
+
const out = [];
|
|
10655
|
+
const ix = x + P, iw = Math.max(4, w - 2 * P);
|
|
10656
|
+
const by = baseline(top), my = midLine(top);
|
|
10657
|
+
const v = c.type === 'rank' ? r.__rank : r[c.field];
|
|
10658
|
+
const weight = opts.total ? 600 : c.isLabel ? (opts.hl ? 600 : 500) : 400;
|
|
10659
|
+
const muted = anyHL && !opts.hl && !opts.total;
|
|
10660
|
+
const tx = c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 : ix;
|
|
10661
|
+
const anchor = c.align === 'right' ? 'end' : c.align === 'center' ? 'middle' : null;
|
|
10662
|
+
const put = (s, { x: px, ...attrs } = {}) => out.push(text(px ?? tx, by, s, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: anchor, ...attrs }));
|
|
10663
|
+
if (opts.total && c.isLabel) { put(truncate(totalLabel, iw, FS, 600)); return { out }; }
|
|
10664
|
+
if (opts.total) {
|
|
10665
|
+
const tv = totalOf(c);
|
|
10666
|
+
if (tv === null) return { out };
|
|
10667
|
+
const ts = c.type === 'delta' ? (tv > 0 ? '+' : tv < 0 ? '−' : '') + c.fmt(Math.abs(tv)) : c.fmt(tv);
|
|
10668
|
+
if ((c.type === 'bar' || c.type === 'progress') && c.align === 'right') put(ts, { x: ix + c.valueW, textAnchor: 'end' });
|
|
10669
|
+
else put(ts);
|
|
10670
|
+
return { out };
|
|
10671
|
+
}
|
|
10672
|
+
if (c.type !== 'rank' && isNil(v)) { put('—', { fill: theme.muted, fontWeight: 400 }); return { out }; }
|
|
10673
|
+
const s = cellText(c, r, locale, W);
|
|
10674
|
+
switch (c.type) {
|
|
10675
|
+
case 'rank': put(s, { fill: theme.muted, fontWeight: 400 }); break;
|
|
10676
|
+
case 'bar': {
|
|
10677
|
+
const n = num(v);
|
|
10678
|
+
if (!Number.isFinite(n)) { put(s); break; }
|
|
10679
|
+
const vw = c.valueW;
|
|
10680
|
+
const trackX = ix + vw + 8, trackW = Math.max(12, ix + iw - trackX);
|
|
10681
|
+
const span = c.hi - c.lo || 1;
|
|
10682
|
+
const z = trackX + (-c.lo / span) * trackW;
|
|
10683
|
+
const len = (Math.abs(n) / span) * trackW;
|
|
10684
|
+
const bh = Math.max(6, Math.min(12, Math.round(LH * 0.58)));
|
|
10685
|
+
const fill = muted ? dimC : n < 0 ? negColor : accent;
|
|
10686
|
+
out.push(text(ix + vw, by, s, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: 'end' }));
|
|
10687
|
+
if (c.lo < 0) out.push(el('line', { x1: z, x2: z, y1: my - bh / 2 - 3, y2: my + bh / 2 + 3, stroke: theme.axis, strokeWidth: 1 }));
|
|
10688
|
+
if (len > 0.3) out.push(el('rect', { x: n < 0 ? z - len : z, y: my - bh / 2, width: Math.max(1.5, len), height: bh, rx: Math.min(2, len / 2), fill }));
|
|
10689
|
+
break;
|
|
10690
|
+
}
|
|
10691
|
+
case 'heat': {
|
|
10692
|
+
const n = num(v);
|
|
10693
|
+
if (!Number.isFinite(n)) { put(s); break; }
|
|
10694
|
+
const fill = heatColor(c, n);
|
|
10695
|
+
if (opts.chip) {
|
|
10696
|
+
const bw = Math.min(w - P, measure(s, FS, weight) + 16), right = x + w - P;
|
|
10697
|
+
out.push(el('rect', { x: right - bw, y: top + 5, width: bw, height: Math.max(0, h - 10), rx: 5, fill }));
|
|
10698
|
+
put(s, { fill: textOn(fill, theme), x: right - 8 });
|
|
10699
|
+
break;
|
|
10700
|
+
}
|
|
10701
|
+
out.push(el('rect', { x: x + 3, y: top + 4, width: Math.max(0, w - 6), height: Math.max(0, h - 8), rx: 5, fill }));
|
|
10702
|
+
put(s, { fill: textOn(fill, theme) });
|
|
10703
|
+
break;
|
|
10704
|
+
}
|
|
10705
|
+
case 'delta': {
|
|
10706
|
+
const n = num(v);
|
|
10707
|
+
if (!Number.isFinite(n)) { put(s); break; }
|
|
10708
|
+
const color = deltaColor(c, n);
|
|
10709
|
+
const tw = measure(s, FS, 500);
|
|
10710
|
+
const right = c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 + (tw + 11) / 2 : ix + tw + 11;
|
|
10711
|
+
out.push(text(right, by, s, { fill: color, fontSize: FS, fontWeight: 500, textAnchor: 'end' }));
|
|
10712
|
+
if (n !== 0) out.push(arrow(right - tw - 11, my, n > 0, color));
|
|
10713
|
+
break;
|
|
10714
|
+
}
|
|
10715
|
+
case 'progress': {
|
|
10716
|
+
const n = num(v);
|
|
10717
|
+
if (!Number.isFinite(n)) { put(s); break; }
|
|
10718
|
+
const t = c.hasTarget ? c.targetOf(r) : c.scale100 ? 100 : 1;
|
|
10719
|
+
const ratio = Number.isFinite(t) && t ? n / t : NaN;
|
|
10720
|
+
const vw = c.valueW;
|
|
10721
|
+
const shown = c.hasTarget && Number.isFinite(ratio) ? c.pctFmt(ratio) : s;
|
|
10722
|
+
out.push(text(ix + vw, by, shown, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: 'end' }));
|
|
10723
|
+
const trackX = ix + vw + 8, trackW = Math.max(12, ix + iw - trackX);
|
|
10724
|
+
out.push(el('rect', { x: trackX, y: my - 3, width: trackW, height: 6, rx: 3, fill: theme.tint2 }));
|
|
10725
|
+
if (Number.isFinite(ratio) && ratio > 0) {
|
|
10726
|
+
const goodUp = c.good !== 'down';
|
|
10727
|
+
const fill = muted ? dimC : goodUp ? (ratio >= 1 ? theme.status.good : accent) : ratio > 1 ? theme.status.critical : theme.status.good;
|
|
10728
|
+
out.push(el('rect', { x: trackX, y: my - 3, width: Math.max(6, trackW * Math.min(1, ratio)), height: 6, rx: 3, fill }));
|
|
10729
|
+
}
|
|
10730
|
+
return { out, tip: c.hasTarget && Number.isFinite(t) ? `${c.label}: ${c.fmt(n)} / ${c.fmt(t)} (${c.pctFmt(ratio)})` : null };
|
|
10731
|
+
}
|
|
10732
|
+
case 'sparkline': {
|
|
10733
|
+
const vals = Array.isArray(v) ? v.map(num).filter(Number.isFinite) : [];
|
|
10734
|
+
if (vals.length < 2) { put(vals.length ? c.fmt(vals[0]) : '—', { fill: theme.muted }); break; }
|
|
10735
|
+
const sw = Math.min(iw, 120), sh = Math.max(10, Math.min(18, h - 2 * padY - 2));
|
|
10736
|
+
const sx = c.align === 'right' ? x + w - P - sw : c.align === 'center' ? x + (w - sw) / 2 : ix;
|
|
10737
|
+
out.push(spark(sx, my, sw, sh, vals, muted));
|
|
10738
|
+
return { out, tip: `${c.label}: ${sparkTip(c, vals)}` };
|
|
10739
|
+
}
|
|
10740
|
+
case 'badge': {
|
|
10741
|
+
const color = c.badge.get(String(v));
|
|
10742
|
+
out.push(...pill(c.align === 'right' ? x + w - P : c.align === 'center' ? x + w / 2 : ix, my, s, color, iw, c.align));
|
|
10743
|
+
return { out, tip: measure(s, 12, 500) + 18 > iw ? s : null };
|
|
10744
|
+
}
|
|
10745
|
+
case 'check': {
|
|
10746
|
+
const yes = v === true || YES.test(String(v).trim());
|
|
10747
|
+
const cx = c.align === 'right' ? x + w - P - 5 : c.align === 'center' ? x + w / 2 : ix + 5;
|
|
10748
|
+
out.push(g({ role: 'img', 'aria-label': s }, [check(cx, my, yes), el('title', {}, esc(s))]));
|
|
10749
|
+
break;
|
|
10750
|
+
}
|
|
10751
|
+
case 'link': {
|
|
10752
|
+
const href = String(v).trim();
|
|
10753
|
+
const lines = wrap(s, iw, FS, 400, c.lines);
|
|
10754
|
+
const color = readable(accent, theme);
|
|
10755
|
+
const t = lines.map((l, k) => text(tx, by + k * LH, l, { fill: color, fontSize: FS, textAnchor: anchor, textDecoration: 'underline' })).join('');
|
|
10756
|
+
out.push(SAFE_URL.test(href) ? el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, t) : t);
|
|
10757
|
+
return { out, lines: lines.length, tip: lines.join(' ') !== s ? s : null };
|
|
10758
|
+
}
|
|
10759
|
+
case 'text': case 'date': default: {
|
|
10760
|
+
const lines = c.type === 'text' ? wrap(s, iw, FS, weight, c.lines) : [truncate(s, iw, FS, weight)];
|
|
10761
|
+
lines.forEach((l, k) => out.push(text(tx, by + k * LH, l, { fill: ink, fontSize: FS, fontWeight: weight, textAnchor: anchor })));
|
|
10762
|
+
return { out, lines: lines.length, tip: lines.join(' ').replace(/…$/, '') !== s ? s : null };
|
|
10763
|
+
}
|
|
10764
|
+
}
|
|
10765
|
+
return { out };
|
|
10766
|
+
}
|
|
10767
|
+
|
|
10768
|
+
const groupCounts = new Map();
|
|
10769
|
+
if (groupF) for (const r of rows) groupCounts.set(String(r[groupF] ?? ''), (groupCounts.get(String(r[groupF] ?? '')) || 0) + 1);
|
|
10770
|
+
const groupH = Math.round(LH + 12 * density + 4);
|
|
10771
|
+
const groupRow = (name, top) => {
|
|
10772
|
+
const n = groupCounts.get(name) || 0;
|
|
10773
|
+
const lab = truncate(name || '—', area.w - 60, 12.5, 600);
|
|
10774
|
+
return g({ class: 'pa-tgroup', role: 'row' }, [
|
|
10775
|
+
el('rect', { x: tableX, y: top + 4, width: tableW, height: groupH - 6, rx: 6, fill: theme.tint }),
|
|
10776
|
+
text(area.x, top + 4 + (groupH - 6) / 2 + 4.3 * X, lab, { fill: ink, fontSize: 12.5, fontWeight: 600, role: 'rowheader' }),
|
|
10777
|
+
text(area.x + measure(lab, 12.5, 600) + 8, top + 4 + (groupH - 6) / 2 + 4.3 * X, String(n), { fill: theme.muted, fontSize: 12 }),
|
|
10778
|
+
]);
|
|
10779
|
+
};
|
|
10780
|
+
|
|
10781
|
+
const tableRows = [];
|
|
10782
|
+
let y = area.y;
|
|
10783
|
+
if (spec._search) {
|
|
10784
|
+
body.push(el('rect', { class: 'pa-search-slot', x: area.x, y, width: Math.min(area.w, 300), height: Math.round(32 * Math.max(1, 0.6 + 0.4 * X)), fill: 'transparent' }));
|
|
10785
|
+
y += Math.round(32 * Math.max(1, 0.6 + 0.4 * X)) + 12;
|
|
10786
|
+
}
|
|
10787
|
+
const label = labelF !== undefined ? cols.find((c) => c.field === labelF) : null;
|
|
10788
|
+
const rowAttrs = (r, i) => ({ class: 'pa-tr' + (isHL(r) ? ' pa-tr-hl' : ''), role: 'row', 'aria-rowindex': (page - 1) * (pageSize === Infinity ? 0 : pageSize) + i + 2, 'data-row': r.__i });
|
|
10789
|
+
|
|
10790
|
+
if (!cards) {
|
|
10791
|
+
const HLH = Math.round(HS * X * 1.3);
|
|
10792
|
+
const headLines = cols.map((c) => wrap(c.label, Math.max(10, c.w - 2 * P - (c.type === 'rank' ? 0 : 10)), HS, 600, 2));
|
|
10793
|
+
const nHead = Math.max(1, ...headLines.map((l) => l.length));
|
|
10794
|
+
const headH = nHead * HLH + Math.round(10 * Math.max(0.8, density)) + 4;
|
|
10795
|
+
const heads = cols.map((c, i) => {
|
|
10796
|
+
const lines = headLines[i];
|
|
10797
|
+
const sorted = st && st.col === c;
|
|
10798
|
+
const overValue = (c.type === 'bar' || c.type === 'progress') && c.align === 'right';
|
|
10799
|
+
const headW = Math.max(...lines.map((l) => measure(l, HS, 600)));
|
|
10800
|
+
const ax = overValue ? Math.min(c.x + c.w - P, c.x + P + Math.max(c.valueW, headW + (sorted ? 10 : 0))) : c.align === 'right' ? c.x + c.w - P : c.align === 'center' ? c.x + c.w / 2 : c.x + P;
|
|
10801
|
+
const anchor = c.align === 'right' ? 'end' : c.align === 'center' ? 'middle' : null;
|
|
10802
|
+
const y0 = y + headH - 9 - (lines.length - 1) * HLH;
|
|
10803
|
+
const parts = [el('rect', { class: 'pa-thbg', x: c.x, y, width: c.w, height: headH, fill: 'transparent' })];
|
|
10804
|
+
lines.forEach((l, k) => parts.push(text(ax, y0 + k * HLH, l, { fill: sorted ? ink : theme.lede, fontSize: HS, fontWeight: 600, textAnchor: anchor })));
|
|
10805
|
+
const lw = measure(lines[lines.length - 1], HS, 600);
|
|
10806
|
+
const ly = y0 + (lines.length - 1) * HLH - HS * X * 0.36;
|
|
10807
|
+
const axx = c.align === 'right' ? ax - lw - 9 : c.align === 'center' ? ax + lw / 2 + 3 : ax + lw + 3;
|
|
10808
|
+
if (c.type !== 'rank' && c.sortable !== false) {
|
|
10809
|
+
const up = sorted ? st.dir === 'asc' : false;
|
|
10810
|
+
const d = up ? `M${axx},${ly + 2}L${axx + 3},${ly - 2}L${axx + 6},${ly + 2}Z` : `M${axx},${ly - 2}L${axx + 3},${ly + 2}L${axx + 6},${ly - 2}Z`;
|
|
10811
|
+
parts.push(el('path', { class: sorted ? 'pa-sort-on' : 'pa-sorthint', d, fill: sorted ? ink : theme.muted }));
|
|
10812
|
+
}
|
|
10813
|
+
const attrs = { class: 'pa-th', role: 'columnheader' };
|
|
10814
|
+
if (c.type !== 'rank' && c.sortable !== false && spec.sortable !== false) {
|
|
10815
|
+
attrs['data-sort'] = c.field;
|
|
10816
|
+
attrs['aria-sort'] = sorted ? (st.dir === 'asc' ? 'ascending' : 'descending') : 'none';
|
|
10817
|
+
attrs['data-num'] = ['number', 'bar', 'heat', 'delta', 'progress', 'sparkline', 'check'].includes(c.type) ? '1' : null;
|
|
10818
|
+
}
|
|
10819
|
+
return g(attrs, parts);
|
|
10820
|
+
});
|
|
10821
|
+
tableRows.push(g({ class: 'pa-thead', role: 'row', 'aria-rowindex': 1 }, heads));
|
|
10822
|
+
y += headH;
|
|
10823
|
+
tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y - 0.5, y2: y - 0.5, stroke: theme.axis, strokeWidth: 1 }));
|
|
10824
|
+
|
|
10825
|
+
let lastGroup = null;
|
|
10826
|
+
if (!pageRows.length) {
|
|
10827
|
+
tableRows.push(text(area.x + area.w / 2, y + 30, query ? W.none : '—', { fill: theme.muted, fontSize: FS, textAnchor: 'middle' }));
|
|
10828
|
+
y += 48;
|
|
10829
|
+
}
|
|
10830
|
+
pageRows.forEach((r, i) => {
|
|
10831
|
+
if (groupF) {
|
|
10832
|
+
const gname = String(r[groupF] ?? '');
|
|
10833
|
+
if (gname !== lastGroup) { tableRows.push(groupRow(gname, y)); y += groupH; lastGroup = gname; }
|
|
10834
|
+
}
|
|
10835
|
+
const h0 = LH + 2 * padY;
|
|
10836
|
+
const made = cols.map((c) => cell(c, r, c.x, c.w, y, h0, { hl: isHL(r) }));
|
|
10837
|
+
const nLines = Math.max(1, ...made.map((m) => m.lines || 1));
|
|
10838
|
+
const h = h0 + (nLines - 1) * LH;
|
|
10839
|
+
const made2 = nLines > 1 ? cols.map((c, k) => (c.type === 'heat' ? cell(c, r, c.x, c.w, y, h, { hl: isHL(r) }) : made[k])) : made;
|
|
10840
|
+
const zebra = spec.striped && i % 2 === 1;
|
|
10841
|
+
const parts = [el('rect', { class: 'pa-trbg', x: tableX, y: y + 1, width: tableW, height: h - 2, rx: 6, fill: isHL(r) ? theme.tint : zebra ? theme.tint : 'transparent', fillOpacity: zebra && !isHL(r) ? 0.55 : null })];
|
|
10842
|
+
if (isHL(r)) parts.push(el('rect', { x: tableX, y: y + 5, width: 3, height: h - 10, rx: 1.5, fill: accent }));
|
|
10843
|
+
made2.forEach((m, k) => {
|
|
10844
|
+
const c = cols[k];
|
|
10845
|
+
parts.push(g({ role: c.isLabel ? 'rowheader' : 'cell', 'data-tip': m.tip ? tip(label ? String(r[labelF] ?? '') : '', m.tip) : null }, m.out));
|
|
10846
|
+
});
|
|
10847
|
+
tableRows.push(g(rowAttrs(r, i), parts));
|
|
10848
|
+
y += h;
|
|
10849
|
+
if (i < pageRows.length - 1 || hasTotal) tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y - 0.5, y2: y - 0.5, stroke: theme.grid, strokeWidth: 1 }));
|
|
10850
|
+
});
|
|
10851
|
+
if (hasTotal) {
|
|
10852
|
+
const h = LH + 2 * padY + 2;
|
|
10853
|
+
tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y + 0.5, y2: y + 0.5, stroke: theme.axis, strokeWidth: 1.25 }));
|
|
10854
|
+
const firstText = cols.find((c) => c.isLabel) || cols.find((c) => c.type === 'text') || cols[0];
|
|
10855
|
+
const made = cols.map((c) => (c === firstText ? cell({ ...c, isLabel: true }, {}, c.x, c.w, y + 2, h, { total: true }) : cell(c, {}, c.x, c.w, y + 2, h, { total: true })));
|
|
10856
|
+
tableRows.push(g({ class: 'pa-ttotal', role: 'row' }, made.map((m, k) => g({ role: cols[k] === firstText ? 'rowheader' : 'cell' }, m.out))));
|
|
10857
|
+
y += h + 2;
|
|
10858
|
+
}
|
|
10859
|
+
} else {
|
|
10860
|
+
const labelCol = label || cols.find((c) => c.type === 'text') || null;
|
|
10861
|
+
const rankCol = cols.find((c) => c.type === 'rank');
|
|
10862
|
+
const mainCol = cols.find((c) => c.type === 'bar');
|
|
10863
|
+
const rest = cols.filter((c) => c !== labelCol && c !== rankCol && c !== mainCol);
|
|
10864
|
+
const KL = 12.5; // tamaño de las etiquetas
|
|
10865
|
+
const lineH = Math.round(Math.max(LH, pillH) + 6);
|
|
10866
|
+
const CP = 12;
|
|
10867
|
+
const keyW = Math.min(area.w * 0.46, Math.max(60, ...rest.map((c) => measure(c.label, KL) + 12)));
|
|
10868
|
+
if (!pageRows.length) { body.push(text(area.x + area.w / 2, y + 30, query ? W.none : '—', { fill: theme.muted, fontSize: FS, textAnchor: 'middle' })); y += 48; }
|
|
10869
|
+
let lastGroup = null;
|
|
10870
|
+
const cardOf = (r, i, total = false) => {
|
|
10871
|
+
const top = y;
|
|
10872
|
+
const parts = [];
|
|
10873
|
+
const hlr = !total && isHL(r);
|
|
10874
|
+
const muted = anyHL && !hlr && !total;
|
|
10875
|
+
let cy = top + CP;
|
|
10876
|
+
const rankW = rankCol && !total ? measure(String(r.__rank), 12) + 8 : 0;
|
|
10877
|
+
const mainS = mainCol ? (total ? (totalOf(mainCol) !== null ? mainCol.fmt(totalOf(mainCol)) : '') : cellText(mainCol, r, locale, W)) : '';
|
|
10878
|
+
const mainW = mainS ? measure(mainS, 14, 600) + 12 : 0;
|
|
10879
|
+
const titleS = total ? totalLabel : labelCol ? cellText(labelCol, r, locale, W) || '—' : `#${r.__rank}`;
|
|
10880
|
+
const tl = wrap(titleS, area.w - rankW - mainW, 14, 600, 2);
|
|
10881
|
+
const TLH = Math.round(14 * X * 1.3);
|
|
10882
|
+
if (rankW) parts.push(text(area.x, cy + 14 * X * 0.92, String(r.__rank), { fill: theme.muted, fontSize: 12 }));
|
|
10883
|
+
tl.forEach((l, k) => parts.push(text(area.x + rankW, cy + 14 * X * 0.92 + k * TLH, l, { fill: ink, fontSize: 14, fontWeight: 600, role: k ? null : 'rowheader' })));
|
|
10884
|
+
if (mainS) parts.push(text(area.x + area.w, cy + 14 * X * 0.92, mainS, { fill: ink, fontSize: 14, fontWeight: 600, textAnchor: 'end' }));
|
|
10885
|
+
cy += tl.length * TLH;
|
|
10886
|
+
if (mainCol && !total) {
|
|
10887
|
+
const n = num(r[mainCol.field]);
|
|
10888
|
+
if (Number.isFinite(n)) {
|
|
10889
|
+
const span = mainCol.hi - mainCol.lo || 1;
|
|
10890
|
+
const z = area.x + (-mainCol.lo / span) * area.w;
|
|
10891
|
+
const len = (Math.abs(n) / span) * area.w;
|
|
10892
|
+
cy += 5;
|
|
10893
|
+
parts.push(el('rect', { x: area.x, y: cy, width: area.w, height: 5, rx: 2.5, fill: theme.tint }));
|
|
10894
|
+
if (len > 0.3) parts.push(el('rect', { x: n < 0 ? z - len : z, y: cy, width: Math.max(2, len), height: 5, rx: 2.5, fill: muted ? dimC : n < 0 ? negColor : accent }));
|
|
10895
|
+
cy += 5;
|
|
10896
|
+
}
|
|
10897
|
+
}
|
|
10898
|
+
cy += 6;
|
|
10899
|
+
for (const c of rest) {
|
|
10900
|
+
if (total && totalOf(c) === null) continue;
|
|
10901
|
+
const long = c.type === 'text' && !total && measure(cellText(c, r, locale, W), FS) > area.w - keyW;
|
|
10902
|
+
const rowTop = cy + (lineH - (LH + 2 * padY)) / 2;
|
|
10903
|
+
parts.push(text(area.x, baseline(rowTop), truncate(c.label, long ? area.w : keyW - 8, KL), { fill: theme.lede, fontSize: KL }));
|
|
10904
|
+
if (long) {
|
|
10905
|
+
const lines = wrap(cellText(c, r, locale, W), area.w, FS, 400, 4);
|
|
10906
|
+
lines.forEach((l, k) => parts.push(text(area.x, cy + lineH + LH * (k + 0.75), l, { fill: ink, fontSize: FS })));
|
|
10907
|
+
cy += lineH + lines.length * LH + 2;
|
|
10908
|
+
continue;
|
|
10909
|
+
}
|
|
10910
|
+
const cc = { ...c, align: 'right' };
|
|
10911
|
+
const boxW = cc.type === 'progress' || cc.type === 'bar' ? Math.min(area.w - keyW, cc.valueW + 8 + 110) : area.w - keyW;
|
|
10912
|
+
const vx = area.x + area.w - boxW - P, vw = boxW + 2 * P;
|
|
10913
|
+
const m = cell(cc, r, vx, vw, rowTop, LH + 2 * padY, { total, hl: hlr, chip: true });
|
|
10914
|
+
parts.push(g({ role: 'cell', 'data-tip': m.tip ? tip(titleS, m.tip) : null }, m.out));
|
|
10915
|
+
cy += lineH;
|
|
10916
|
+
}
|
|
10917
|
+
cy += CP - 4;
|
|
10918
|
+
const h = cy - top;
|
|
10919
|
+
const bg = el('rect', { class: 'pa-trbg', x: tableX, y: top + 2, width: tableW, height: h - 4, rx: 8, fill: hlr ? theme.tint : 'transparent' });
|
|
10920
|
+
const mark = hlr ? el('rect', { x: tableX, y: top + 8, width: 3, height: h - 16, rx: 1.5, fill: accent }) : '';
|
|
10921
|
+
y = cy;
|
|
10922
|
+
return g(total ? { class: 'pa-ttotal', role: 'row' } : rowAttrs(r, i), [bg, mark, ...parts]);
|
|
10923
|
+
};
|
|
10924
|
+
pageRows.forEach((r, i) => {
|
|
10925
|
+
if (groupF) {
|
|
10926
|
+
const gname = String(r[groupF] ?? '');
|
|
10927
|
+
if (gname !== lastGroup) { tableRows.push(groupRow(gname, y)); y += groupH; lastGroup = gname; }
|
|
10928
|
+
}
|
|
10929
|
+
tableRows.push(cardOf(r, i));
|
|
10930
|
+
if (i < pageRows.length - 1 || hasTotal) tableRows.push(el('line', { x1: area.x, x2: area.x + area.w, y1: y - 0.5, y2: y - 0.5, stroke: hasTotal && i === pageRows.length - 1 ? theme.axis : theme.grid, strokeWidth: hasTotal && i === pageRows.length - 1 ? 1.25 : 1 }));
|
|
10931
|
+
});
|
|
10932
|
+
if (hasTotal) tableRows.push(cardOf({}, -1, true));
|
|
10933
|
+
}
|
|
10934
|
+
|
|
10935
|
+
const colCount = cols.length;
|
|
10936
|
+
body.push(g({ class: 'pa-table' + (cards ? ' pa-cards' : ''), role: 'table', 'aria-label': spec.title || W.table, 'aria-rowcount': nRows + 1, 'aria-colcount': colCount, style: 'font-variant-numeric:tabular-nums' }, tableRows));
|
|
10937
|
+
|
|
10938
|
+
if (pages > 1) {
|
|
10939
|
+
y += 14;
|
|
10940
|
+
const B = Math.round(28 * Math.max(1, 0.55 + 0.45 * X));
|
|
10941
|
+
const cy = y + B / 2;
|
|
10942
|
+
const from = (page - 1) * pageSize + 1, to = Math.min(nRows, page * pageSize);
|
|
10943
|
+
const fmtI = makeFormat({ type: 'number', decimals: 0 }, locale, []);
|
|
10944
|
+
const rangeS = `${fmtI(from)}–${fmtI(to)} / ${fmtI(nRows)}`;
|
|
10945
|
+
const items = pagerItems(page, pages);
|
|
10946
|
+
const rangeW = measure(rangeS, 12);
|
|
10947
|
+
const btnW = (s) => Math.max(B, measure(s, 12.5, 600) + 14);
|
|
10948
|
+
const full = items.reduce((a, it) => a + (it === '…' ? 14 : btnW(fmtI(it)) + 2), 0) + 2 * (B + 2);
|
|
10949
|
+
const compact = full + rangeW + 16 > area.w;
|
|
10950
|
+
const parts = [];
|
|
10951
|
+
if (!compact || rangeW + 16 + 2 * B + measure(`${page} / ${pages}`, 12.5, 600) + 16 < area.w) {
|
|
10952
|
+
parts.push(text(area.x, cy + 4.2 * X, rangeS, { fill: theme.muted, fontSize: 12 }));
|
|
10953
|
+
}
|
|
10954
|
+
let x = area.x + area.w;
|
|
10955
|
+
const btn = (n, content, label, opts = {}) => {
|
|
10956
|
+
const w = opts.w ?? B;
|
|
10957
|
+
x -= w;
|
|
10958
|
+
const disabled = n === null;
|
|
10959
|
+
const attrs = disabled ? { class: 'pa-pg pa-pg-off', opacity: 0.32, 'aria-hidden': 'true' } : { class: 'pa-pg' + (opts.current ? ' pa-pg-on' : ''), 'data-page': n, 'data-arrow': opts.arrow, 'aria-label': label, 'aria-current': opts.current ? 'page' : null };
|
|
10960
|
+
const out = g(attrs, [el('rect', { class: 'pa-pgbg', x, y, width: w, height: B, rx: 7, fill: opts.current ? theme.tint2 : 'transparent' }), content(x, w)]);
|
|
10961
|
+
x -= 2;
|
|
10962
|
+
return out;
|
|
10963
|
+
};
|
|
10964
|
+
const chev = (dir) => (x0, w) => el('path', { d: dir < 0 ? `M${x0 + w / 2 + 2.5},${cy - 5}L${x0 + w / 2 - 2.5},${cy}L${x0 + w / 2 + 2.5},${cy + 5}` : `M${x0 + w / 2 - 2.5},${cy - 5}L${x0 + w / 2 + 2.5},${cy}L${x0 + w / 2 - 2.5},${cy + 5}`, fill: 'none', stroke: ink, strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' });
|
|
10965
|
+
const num0 = (s, cur) => (x0, w) => text(x0 + w / 2, cy + 4.3 * X, s, { fill: cur ? ink : theme.lede, fontSize: 12.5, fontWeight: cur ? 600 : 400, textAnchor: 'middle' });
|
|
10966
|
+
const out = [];
|
|
10967
|
+
out.push(btn(page < pages ? page + 1 : null, chev(1), `${W.next} (${W.page} ${page + 1})`, { arrow: 'next' }));
|
|
10968
|
+
if (compact) {
|
|
10969
|
+
const s = `${fmtI(page)} / ${fmtI(pages)}`;
|
|
10970
|
+
const w = measure(s, 12.5, 600) + 16;
|
|
10971
|
+
x -= w;
|
|
10972
|
+
out.push(text(x + w / 2, cy + 4.3 * X, s, { fill: ink, fontSize: 12.5, fontWeight: 600, textAnchor: 'middle' }));
|
|
10973
|
+
x -= 2;
|
|
10974
|
+
} else {
|
|
10975
|
+
for (const it of [...items].reverse()) {
|
|
10976
|
+
if (it === '…') { x -= 14; out.push(text(x + 7, cy + 4.3 * X, '…', { fill: theme.muted, fontSize: 12.5, textAnchor: 'middle' })); x -= 2; continue; }
|
|
10977
|
+
const s = fmtI(it);
|
|
10978
|
+
out.push(btn(it, num0(s, it === page), `${W.page} ${it}`, { current: it === page, w: btnW(s) }));
|
|
10979
|
+
}
|
|
10980
|
+
}
|
|
10981
|
+
out.push(btn(page > 1 ? page - 1 : null, chev(-1), `${W.prev} (${W.page} ${page - 1})`, { arrow: 'prev' }));
|
|
10982
|
+
parts.push(...out);
|
|
10983
|
+
body.push(g({ class: 'pa-pager', role: 'navigation', 'aria-label': `${W.page} ${page} ${W.of} ${pages}` }, parts));
|
|
10984
|
+
y += B;
|
|
10985
|
+
}
|
|
10986
|
+
if (spec.height === undefined) frame.setHeight(y + 12 + frame.chrome);
|
|
10987
|
+
|
|
10988
|
+
const desc = describeTable({ lang, cols, rows, nRows, page, pages, st, label, query });
|
|
10989
|
+
const css = `.pa-live .pa-th[data-sort]{cursor:pointer}.pa-sorthint{opacity:0;transition:opacity .15s}.pa-live .pa-th[data-sort]:hover .pa-sorthint,.pa-live .pa-th[data-sort]:focus-visible .pa-sorthint{opacity:.7}`
|
|
10990
|
+
+ `.pa-live .pa-th[data-sort]:hover .pa-thbg{fill:${theme.tint};fill-opacity:.6}.pa-tr .pa-trbg{transition:fill .12s}.pa-live .pa-tr:hover .pa-trbg{fill:${theme.tint}}`
|
|
10991
|
+
+ `.pa-live .pa-pg{cursor:pointer}.pa-live .pa-pg:hover .pa-pgbg{fill:${theme.tint}}.pa-live .pa-pg-on:hover .pa-pgbg{fill:${theme.tint2}}`
|
|
10992
|
+
+ `.pa-th:focus-visible,.pa-pg:focus-visible{outline:2px solid ${accent};outline-offset:-2px}@media (prefers-reduced-motion:reduce){.pa-sorthint,.pa-tr .pa-trbg{transition:none}}`;
|
|
10993
|
+
return frame.finish(body, { desc, type: 'table', role: 'figure', css });
|
|
10994
|
+
}
|
|
10995
|
+
|
|
10996
|
+
/** Botones de página: 1 … 4 5 6 … 20 (como mucho 7 entradas). */
|
|
10997
|
+
function pagerItems(page, pages) {
|
|
10998
|
+
if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
|
|
10999
|
+
if (page <= 4) return [1, 2, 3, 4, 5, '…', pages];
|
|
11000
|
+
if (page >= pages - 3) return [1, '…', pages - 4, pages - 3, pages - 2, pages - 1, pages];
|
|
11001
|
+
return [1, '…', page - 1, page, page + 1, '…', pages];
|
|
11002
|
+
}
|
|
11003
|
+
|
|
11004
|
+
function describeTable({ lang, cols, rows, nRows, page, pages, st, label, query }) {
|
|
11005
|
+
const T = {
|
|
11006
|
+
es: { t: 'Tabla de', and: 'y', r: 'filas', c: 'columnas', p: 'Página', of: 'de', s: 'Ordenada por', desc: 'de mayor a menor', asc: 'de menor a mayor', hi: 'Mayor', lo: 'menor', q: 'Filtrada por' },
|
|
11007
|
+
en: { t: 'Table with', and: 'and', r: 'rows', c: 'columns', p: 'Page', of: 'of', s: 'Sorted by', desc: 'high to low', asc: 'low to high', hi: 'Highest', lo: 'lowest', q: 'Filtered by' },
|
|
11008
|
+
pt: { t: 'Tabela com', and: 'e', r: 'linhas', c: 'colunas', p: 'Página', of: 'de', s: 'Ordenada por', desc: 'do maior para o menor', asc: 'do menor para o maior', hi: 'Maior', lo: 'menor', q: 'Filtrada por' },
|
|
11009
|
+
fr: { t: 'Tableau de', and: 'et', r: 'lignes', c: 'colonnes', p: 'Page', of: 'sur', s: 'Trié par', desc: 'décroissant', asc: 'croissant', hi: 'Maximum', lo: 'minimum', q: 'Filtré par' },
|
|
11010
|
+
de: { t: 'Tabelle mit', and: 'und', r: 'Zeilen', c: 'Spalten', p: 'Seite', of: 'von', s: 'Sortiert nach', desc: 'absteigend', asc: 'aufsteigend', hi: 'Höchster Wert', lo: 'niedrigster', q: 'Gefiltert nach' },
|
|
11011
|
+
}[lang];
|
|
11012
|
+
const names = cols.filter((c) => c.type !== 'rank').map((c) => c.label);
|
|
11013
|
+
let s = `${T.t} ${nRows} ${T.r} ${T.and} ${names.length} ${T.c}: ${names.join(', ')}.`;
|
|
11014
|
+
if (query) s += ` ${T.q} "${query}".`;
|
|
11015
|
+
if (pages > 1) s += ` ${T.p} ${page} ${T.of} ${pages}.`;
|
|
11016
|
+
if (st) s += ` ${T.s} ${st.col.label}, ${st.dir === 'desc' ? T.desc : T.asc}.`;
|
|
11017
|
+
const m = cols.find((c) => c.type === 'bar') || cols.find((c) => ['number', 'heat'].includes(c.type) && !c.isLabel && c.kind === 'number');
|
|
11018
|
+
if (m && label && rows.length > 1) {
|
|
11019
|
+
let best = null, worst = null;
|
|
11020
|
+
for (const r of rows) { const v = num(r[m.field]); if (!Number.isFinite(v)) continue; if (!best || v > best[1]) best = [r, v]; if (!worst || v < worst[1]) worst = [r, v]; }
|
|
11021
|
+
if (best && worst && best[0] !== worst[0]) s += ` ${T.hi} ${m.label}: ${best[0][label.field]} (${m.fmt(best[1])}); ${T.lo}: ${worst[0][label.field]} (${m.fmt(worst[1])}).`;
|
|
11022
|
+
}
|
|
11023
|
+
return s;
|
|
11024
|
+
}
|
|
11025
|
+
|
|
11026
|
+
return { "sortState": sortState, "table": table, "pagerItems": pagerItems, "COLUMN_TYPES": COLUMN_TYPES, "columnType": columnType, "statusOf": statusOf };
|
|
11027
|
+
});
|
|
9487
11028
|
__def("src/facet.js", function () {
|
|
9488
11029
|
const { el, g } = __req("src/svg.js");
|
|
9489
11030
|
const { createFrame } = __req("src/frame.js");
|
|
@@ -9515,8 +11056,8 @@ function facet(spec, theme, renderOne) {
|
|
|
9515
11056
|
const frame = createFrame({ ...spec, height: 200 }, theme, { width: 720 });
|
|
9516
11057
|
const area = frame.layout(legend);
|
|
9517
11058
|
const gap = spec.panelGap ?? 14;
|
|
9518
|
-
const fit = Math.max(1, Math.floor((area.w + gap) /
|
|
9519
|
-
const want = spec.columns ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
|
|
11059
|
+
const fit = Math.max(1, Math.floor((area.w + gap) / 240));
|
|
11060
|
+
const want = (typeof spec.columns === 'number' ? spec.columns : undefined) ?? spec.colWrap ?? spec.col_wrap ?? Math.max(1, Math.floor((area.w + gap) / 230));
|
|
9520
11061
|
const cols = Math.max(1, Math.min(keys.length, want, fit));
|
|
9521
11062
|
const pw = (area.w - gap * (cols - 1)) / cols;
|
|
9522
11063
|
const ph = spec.panelHeight ?? Math.round(Math.min(300, Math.max(160, pw * 0.66)));
|
|
@@ -9568,7 +11109,7 @@ return { "facet": facet };
|
|
|
9568
11109
|
__def("src/infer.js", function () {
|
|
9569
11110
|
const { fieldsOf, fieldKind } = __req("src/data.js");
|
|
9570
11111
|
|
|
9571
|
-
const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap'];
|
|
11112
|
+
const TYPES = ['bar', 'column', 'count', 'line', 'area', 'scatter', 'bubble', 'treemap', 'sunburst', 'donut', 'pie', 'heatmap', 'sankey', 'kpi', 'hist', 'kde', 'ecdf', 'regplot', 'residplot', 'jointplot', 'box', 'violin', 'strip', 'swarm', 'point', 'radar', 'network', 'chord', 'pyramid', 'flowchart', 'gantt', 'wordcloud', 'choropleth', 'symbolmap', 'pointmap', 'table', 'corr', 'pairplot'];
|
|
9572
11113
|
|
|
9573
11114
|
const ALIASES = {
|
|
9574
11115
|
barplot: ['bar', { ci: true }], countplot: 'count', columns: 'column', lineplot: 'line', areaplot: 'area',
|
|
@@ -9582,6 +11123,11 @@ const ALIASES = {
|
|
|
9582
11123
|
map: 'choropleth', mapa: 'choropleth', choroplethmap: 'choropleth', symbol: 'symbolmap', bubblemap: 'symbolmap', 'mapa-simbolos': 'symbolmap',
|
|
9583
11124
|
words: 'wordcloud', 'word-cloud': 'wordcloud', wordclouds: 'wordcloud', tagcloud: 'wordcloud', 'tag-cloud': 'wordcloud', nube: 'wordcloud', 'nube-de-palabras': 'wordcloud', cloud: 'wordcloud',
|
|
9584
11125
|
timeline: 'gantt', roadmap: 'gantt', schedule: 'gantt', cronograma: 'gantt', 'carta-gantt': 'gantt', ganttchart: 'gantt', 'gantt-chart': 'gantt', planning: 'gantt',
|
|
11126
|
+
tabla: 'table', tables: 'table', datatable: 'table', 'data-table': 'table', grid: 'table', tableau: 'table', tabelle: 'table', tabela: 'table', spreadsheet: 'table', planilla: 'table',
|
|
11127
|
+
correlation: 'corr', correlations: 'corr', corrplot: 'corr', corrmatrix: 'corr', 'corr-matrix': 'corr', 'correlation-matrix': 'corr', correlogram: 'corr', correlograma: 'corr',
|
|
11128
|
+
correlacion: 'corr', 'correlación': 'corr', 'matriz-correlacion': 'corr', 'matriz-de-correlacion': 'corr', 'matriz-de-correlación': 'corr',
|
|
11129
|
+
clustermap: ['corr', { cluster: true }], 'cluster-map': ['corr', { cluster: true }],
|
|
11130
|
+
pairs: 'pairplot', pairgrid: 'pairplot', splom: 'pairplot', scattermatrix: 'pairplot', 'scatter-matrix': 'pairplot', 'scatterplot-matrix': 'pairplot', 'matriz-de-dispersion': 'pairplot', 'matriz-de-dispersión': 'pairplot',
|
|
9585
11131
|
tree: 'treemap', stat: 'kpi', stats: 'kpi', number: 'kpi', heat: 'heatmap', flow: 'sankey',
|
|
9586
11132
|
};
|
|
9587
11133
|
|
|
@@ -9595,6 +11141,7 @@ function normalizeType(t) {
|
|
|
9595
11141
|
|
|
9596
11142
|
function inferType(spec, data = []) {
|
|
9597
11143
|
if (spec.items) return 'kpi';
|
|
11144
|
+
if (Array.isArray(spec.columns) || spec.pageSize !== undefined) return 'table';
|
|
9598
11145
|
if (spec.geo) return 'choropleth';
|
|
9599
11146
|
if (spec.lat || spec.lon || spec.tiles !== undefined) return 'pointmap';
|
|
9600
11147
|
{ const f = fieldsOf(data); if (f.some((k) => /^(lat|latitude|latitud)$/i.test(k)) && f.some((k) => /^(lon|lng|long|longitude|longitud)$/i.test(k))) return 'pointmap'; }
|
|
@@ -9605,6 +11152,8 @@ function inferType(spec, data = []) {
|
|
|
9605
11152
|
if (spec.matrix) return 'chord';
|
|
9606
11153
|
if (spec.from || spec.to) return 'sankey';
|
|
9607
11154
|
if (spec.path || spec.group) return 'treemap';
|
|
11155
|
+
if (spec.triangle !== undefined || spec.cluster !== undefined || spec.method !== undefined) return 'corr';
|
|
11156
|
+
if (spec.diag !== undefined || spec.corner !== undefined || spec.upper !== undefined) return 'pairplot';
|
|
9608
11157
|
if (spec.size) return 'bubble';
|
|
9609
11158
|
const fields = fieldsOf(data);
|
|
9610
11159
|
if ((fields.includes('source') && fields.includes('target')) || (fields.includes('from') && fields.includes('to'))) return 'sankey';
|
|
@@ -9632,14 +11181,26 @@ const { themeFor, themes, palettes, ramps } = __req("src/theme.js");
|
|
|
9632
11181
|
const { resolveColorScale, colorScaleNames, RAINBOW } = __req("src/colorscales.js");
|
|
9633
11182
|
const { schema } = __req("src/schema.js");
|
|
9634
11183
|
const { parseDate } = __req("src/format.js");
|
|
11184
|
+
const { autoLocale } = __req("src/i18n.js");
|
|
11185
|
+
const { COLUMN_TYPES, columnType } = __req("src/charts/table.js");
|
|
9635
11186
|
|
|
9636
11187
|
/** El índice registra aquí un dibujo de prueba (para revisar coincidencias de mapas). */
|
|
9637
11188
|
let RENDER_PROBE = null;
|
|
9638
11189
|
const _setProbe = (fn) => { RENDER_PROBE = fn; };
|
|
9639
11190
|
|
|
11191
|
+
/** Nunca lanza: si la revisión misma falla con un spec raro, lo dice como aviso y sigue. */
|
|
9640
11192
|
function lint(spec = {}) {
|
|
11193
|
+
try { return lintSpec(spec); }
|
|
11194
|
+
catch (e) {
|
|
11195
|
+
const type = spec && typeof spec === 'object' && typeof spec.type === 'string' ? spec.type : 'auto';
|
|
11196
|
+
return { ok: true, type, errors: [], warnings: [{ code: 'lint-failed', message: `No se pudo revisar el spec por completo: ${String(e && e.message).split('\n')[0]}`, fix: 'Revisa que cada opción tenga el tipo del schema (`toolDefinition()`): textos donde van textos, listas donde van listas.' }], suggestions: [] };
|
|
11197
|
+
}
|
|
11198
|
+
}
|
|
11199
|
+
|
|
11200
|
+
function lintSpec(spec = {}) {
|
|
9641
11201
|
if (!spec || typeof spec !== 'object') spec = {};
|
|
9642
|
-
const
|
|
11202
|
+
const autoLang = spec.locale === 'auto' || (spec.locale === undefined && /^(table|tabla|tables|datatable|data-table|grid)$/i.test(String(spec.type || '')));
|
|
11203
|
+
const en = (autoLang ? autoLocale(spec, Array.isArray(spec.data) ? spec.data : []) : String(spec.locale || '')).toLowerCase().startsWith('en');
|
|
9643
11204
|
/** Mensajes nuevos: se escriben directo en ambos idiomas. */
|
|
9644
11205
|
const L = (es, english) => (en ? english : es);
|
|
9645
11206
|
const errors = [];
|
|
@@ -9652,7 +11213,7 @@ function lint(spec = {}) {
|
|
|
9652
11213
|
let data = [];
|
|
9653
11214
|
const rawWords = !spec.data && (typeof spec.text === 'string' || Array.isArray(spec.text));
|
|
9654
11215
|
try { data = spec.type === 'kpi' || spec.items || rawWords ? [] : ensureData(spec); } catch (e) { err('data', e.message.split('\n')[0], 'Pasa `data` como arreglo de filas: [{ categoria: "A", valor: 10 }]'); }
|
|
9655
|
-
const type = spec.type && spec.type !== 'auto' ? normalizeType(spec.type)[0] : inferType(spec, data);
|
|
11216
|
+
const type = spec.type && spec.type !== 'auto' ? normalizeType(String(spec.type).trim())[0] : inferType(spec, data);
|
|
9656
11217
|
if (!KNOWN_TYPES.has(type)) err('type', `Tipo "${spec.type}" no soportado.`, `Usa uno de: ${[...KNOWN_TYPES].join(', ')}`);
|
|
9657
11218
|
const fields = fieldsOf(data);
|
|
9658
11219
|
const check = (f, role) => {
|
|
@@ -9665,7 +11226,7 @@ function lint(spec = {}) {
|
|
|
9665
11226
|
} else if (type !== 'kpi' && !rawWords && !errors.length) err('empty', 'No hay filas en `data`.', 'Entrega al menos una fila.');
|
|
9666
11227
|
|
|
9667
11228
|
if (!spec.title) sug('title', 'Falta el título.', 'Escribe el hallazgo como una frase completa: "Biobío concentra el 32% de las ventas", no "Ventas por región".');
|
|
9668
|
-
else if (!/\d/.test(spec.title) && /^(evoluci[oó]n|distribuci[oó]n|comparaci[oó]n|ranking|gr[aá]fico|ventas|total|resumen)\b|\b(por|seg[uú]n|vs\.?)\s+\w+$/i.test(spec.title.trim())) sug('title-fact', 'El título describe el gráfico pero no dice qué muestra.', 'Un buen título es un hecho, idealmente con la cifra clave.');
|
|
11229
|
+
else if (typeof spec.title === 'string' && !/\d/.test(spec.title) && /^(evoluci[oó]n|distribuci[oó]n|comparaci[oó]n|ranking|gr[aá]fico|ventas|total|resumen)\b|\b(por|seg[uú]n|vs\.?)\s+\w+$/i.test(spec.title.trim())) sug('title-fact', 'El título describe el gráfico pero no dice qué muestra.', 'Un buen título es un hecho, idealmente con la cifra clave.');
|
|
9669
11230
|
if (!spec.source) sug('source', 'Falta la fuente de los datos.', 'Agrega `source: "INE, 2025"` (o "datos ilustrativos").');
|
|
9670
11231
|
|
|
9671
11232
|
const series = spec.series ?? spec.color;
|
|
@@ -9734,6 +11295,26 @@ function lint(spec = {}) {
|
|
|
9734
11295
|
for (const f of [spec.x, spec.y]) if (f && fields.includes(f) && !numericIn(f)) err('numeric', `"${f}" no es numérico: la regresión necesita dos medidas.`, 'Usa `type: "box"` para comparar una medida entre categorías.');
|
|
9735
11296
|
if (data.length < 8) warn('few-obs', `Solo ${data.length} puntos: la recta y su intervalo son poco confiables.`, 'Muestra los puntos con `type: "scatter"`.');
|
|
9736
11297
|
}
|
|
11298
|
+
if ((type === 'corr' || type === 'pairplot') && data.length) {
|
|
11299
|
+
const longForm = type === 'corr' && typeof spec.x === 'string' && typeof spec.y === 'string' && typeof spec.value === 'string';
|
|
11300
|
+
if (!longForm) {
|
|
11301
|
+
const list = spec.vars ?? (Array.isArray(spec.y) ? spec.y : null);
|
|
11302
|
+
if (spec.vars !== undefined && !Array.isArray(spec.vars)) err('corr-vars', L('`vars` debe ser una lista de campos.', '`vars` must be a list of fields.'), 'vars: ["precio", "superficie", "antiguedad"]');
|
|
11303
|
+
if (Array.isArray(spec.vars)) check(spec.vars, 'vars');
|
|
11304
|
+
const hueF = spec.hue ?? spec.series;
|
|
11305
|
+
const nums = (Array.isArray(list) ? list : fields.filter((f) => f !== hueF && f !== spec.label && fieldKind(data, f) === 'number')).filter((f) => fields.includes(f));
|
|
11306
|
+
if (Array.isArray(list)) for (const f of list) if (fields.includes(f) && fieldKind(data, f) !== 'number') err('corr-numeric', L(`"${f}" no es numérico: una correlación necesita medidas.`, `"${f}" is not numeric: a correlation needs measures.`), L('Quítalo de `vars`; para comparar una medida entre categorías usa `type: "box"`.', 'Drop it from `vars`; to compare a measure across categories use `type: "box"`.'));
|
|
11307
|
+
if (nums.length < 2) err('corr-vars', L(`Hay ${nums.length} campo(s) numérico(s): se necesitan al menos dos.`, `There are ${nums.length} numeric field(s): at least two are needed.`), L('Entrega filas con dos o más medidas, o indícalas en `vars: [...]`.', 'Pass rows with two or more measures, or list them in `vars: [...]`.'));
|
|
11308
|
+
const constant = nums.filter((f) => { const vs = data.map((r) => num(r[f])).filter(Number.isFinite); return vs.length > 1 && vs.every((v) => v === vs[0]); });
|
|
11309
|
+
if (constant.length) warn('corr-constant', L(`Sin variación: ${constant.join(', ')}. Su correlación no está definida (celda vacía).`, `No variation: ${constant.join(', ')}. Its correlation is undefined (empty cell).`), L('Quítalos de `vars`.', 'Drop them from `vars`.'));
|
|
11310
|
+
if (data.length < 10) warn('corr-few-rows', L(`Solo ${data.length} filas: una correlación con tan pocos casos es inestable.`, `Only ${data.length} rows: a correlation from so few cases is unstable.`), L('Muestra los puntos con `type: "scatter"`, o agrega `alpha: 0.05` para apagar lo no significativo.', 'Show the points with `type: "scatter"`, or add `alpha: 0.05` to grey out what is not significant.'));
|
|
11311
|
+
if (type === 'corr' && nums.length > 12 && !spec.cluster) sug('corr-many', L(`${nums.length} variables: el orden de los datos esconde los grupos que se mueven juntos.`, `${nums.length} variables: data order hides the groups that move together.`), L('Agrega `cluster: true` (o usa `type: "clustermap"`).', 'Add `cluster: true` (or use `type: "clustermap"`).'));
|
|
11312
|
+
if (type === 'corr' && nums.length > 30) warn('corr-too-many', L(`${nums.length} variables: las celdas quedan demasiado chicas para leerse.`, `${nums.length} variables: cells get too small to read.`), L('Elige las que importan con `vars: [...]`.', 'Pick the ones that matter with `vars: [...]`.'));
|
|
11313
|
+
if (type === 'pairplot' && !Array.isArray(list) && nums.length > 6) warn('pairplot-many', L(`${nums.length} campos numéricos: se dibujan solo los 6 primeros (${nums.slice(0, 6).join(', ')}).`, `${nums.length} numeric fields: only the first 6 are drawn (${nums.slice(0, 6).join(', ')}).`), L('Elige con `vars: [...]`, o usa `type: "corr"` para verlos todos.', 'Choose with `vars: [...]`, or use `type: "corr"` to see them all.'));
|
|
11314
|
+
if (type === 'pairplot' && Array.isArray(list) && list.length > 7) warn('pairplot-many', L(`${list.length} variables son ${list.length * list.length} paneles diminutos.`, `${list.length} variables make ${list.length * list.length} tiny panels.`), L('Deja 3–6 en `vars`, o usa `type: "corr"`.', 'Keep 3–6 in `vars`, or use `type: "corr"`.'));
|
|
11315
|
+
if (type === 'pairplot' && data.length > 3000) sug('pairplot-dense', L(`${data.length} filas: los puntos se tapan en cada panel.`, `${data.length} rows: points hide each other in every panel.`), L('Usa `type: "corr"`, o `radius: 1` con una muestra.', 'Use `type: "corr"`, or `radius: 1` with a sample.'));
|
|
11316
|
+
}
|
|
11317
|
+
}
|
|
9737
11318
|
if (['box', 'violin'].includes(type) && data.length && spec.x && fields.includes(spec.x)) {
|
|
9738
11319
|
const counts = new Map();
|
|
9739
11320
|
for (const r of data) counts.set(String(r[spec.x]), (counts.get(String(r[spec.x])) || 0) + 1);
|
|
@@ -9786,6 +11367,29 @@ function lint(spec = {}) {
|
|
|
9786
11367
|
if (data.length > 25 && spec.collapsed === undefined) sug('gantt-collapse', L(`${data.length} tareas: plegar las fases terminadas acorta la carta.`, `${data.length} tasks: collapsing finished phases shortens the chart.`), L(`Agrega \`collapsed: ["${phases[0]}"]\` (o \`true\` para ver solo el resumen).`, `Add \`collapsed: ["${phases[0]}"]\` (or \`true\` for the summary only).`));
|
|
9787
11368
|
}
|
|
9788
11369
|
}
|
|
11370
|
+
if (type === 'table' && data.length) {
|
|
11371
|
+
const cols = Array.isArray(spec.columns) ? spec.columns : [];
|
|
11372
|
+
for (const c of cols) {
|
|
11373
|
+
const f = typeof c === 'string' ? c : c && typeof c === 'object' ? c.field ?? c.key ?? c.name : undefined;
|
|
11374
|
+
if (c && typeof c === 'object' && c.type !== undefined && !columnType(c.type)) {
|
|
11375
|
+
const near = didYouMean(String(c.type), COLUMN_TYPES);
|
|
11376
|
+
err('table-column-type', L(`Tipo de columna "${c.type}" desconocido${f ? ` (columna "${f}")` : ''}.`, `Unknown column type "${c.type}"${f ? ` (column "${f}")` : ''}.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L(`Tipos: ${COLUMN_TYPES.join(', ')}.`, `Types: ${COLUMN_TYPES.join(', ')}.`));
|
|
11377
|
+
}
|
|
11378
|
+
if (f === undefined && !(c && typeof c === 'object' && columnType(c.type) === 'rank')) err('table-column', L('Una columna no dice qué campo muestra.', 'A column does not say which field it shows.'), L('Usa `columns: ["region", { field: "ventas", type: "bar" }]`.', 'Use `columns: ["region", { field: "sales", type: "bar" }]`.'));
|
|
11379
|
+
else if (f !== undefined) check(f, 'columns');
|
|
11380
|
+
if (c && typeof c === 'object' && typeof c.target === 'string') check(c.target, 'columns.target');
|
|
11381
|
+
}
|
|
11382
|
+
const by = spec.sortBy ?? spec.orderBy;
|
|
11383
|
+
if (by !== undefined && !fields.includes(by) && !cols.some((c) => c && typeof c === 'object' && String(c.label) === String(by)) && !['#', 'rank'].includes(String(by))) {
|
|
11384
|
+
const near = didYouMean(String(by), fields);
|
|
11385
|
+
err('table-sort', L(`\`sortBy: "${by}"\` no es una columna.`, `\`sortBy: "${by}"\` is not a column.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L(`Campos: ${fields.join(', ')}.`, `Fields: ${fields.join(', ')}.`));
|
|
11386
|
+
}
|
|
11387
|
+
if (spec.pageSize !== undefined && spec.pageSize !== false && !(Number.isFinite(+spec.pageSize) && +spec.pageSize >= 1)) err('table-page-size', L('`pageSize` debe ser un entero positivo (o false).', '`pageSize` must be a positive integer (or false).'), 'pageSize: 10');
|
|
11388
|
+
const nCols = cols.length || fields.length;
|
|
11389
|
+
if (nCols > 8) warn('table-wide', L(`${nCols} columnas: en pantallas angostas la tabla pasa a tarjetas largas y en escritorio se aprieta.`, `${nCols} columns: on narrow screens the table turns into long cards, and it gets cramped on desktop.`), L('Deja las 4–7 columnas que responden la pregunta del título con `columns: [...]`.', 'Keep the 4–7 columns that answer the title\'s question with `columns: [...]`.'));
|
|
11390
|
+
if (data.length > 15 && spec.pageSize === false) sug('table-long', L(`${data.length} filas sin paginar hacen una imagen muy alta.`, `${data.length} unpaginated rows make a very tall image.`), L('Quita `pageSize: false` (páginas de 10), o usa `top: 15` con un orden.', 'Drop `pageSize: false` (pages of 10), or use `top: 15` with a sort.'));
|
|
11391
|
+
if (data.length > 0 && data.length <= 3 && nCols <= 2) sug('table-tiny', L('Una tabla de pocas cifras dice más como indicadores.', 'A table with so few numbers works better as indicators.'), L('Usa `type: "kpi"` con `items`.', 'Use `type: "kpi"` with `items`.'));
|
|
11392
|
+
}
|
|
9789
11393
|
if (type === 'kpi' && ![].concat(spec.items || spec.data || []).length) err('items', 'El KPI necesita `items`.', 'items: [{ label: "Ventas", value: 1250, delta: 0.12, period: "vs 2025" }]');
|
|
9790
11394
|
|
|
9791
11395
|
checkOptions(spec, { type, data, fields, err, warn, sug, L });
|
|
@@ -9870,6 +11474,7 @@ const EXTRA_OPTIONS = `
|
|
|
9870
11474
|
tileOpacity tileStyle tiles tilesKey todayColor truncate unit valueDomain valueFormat valueScale weekends word
|
|
9871
11475
|
xFormat yFormat yTicks zoom curveness exclude colorScale filters kpis charts updated primary layout
|
|
9872
11476
|
after area good minLength sizeKey x2
|
|
11477
|
+
orderBy perPage paginate stacked totalLabel
|
|
9873
11478
|
id name description notes meta alt $schema comment
|
|
9874
11479
|
`.trim().split(/\s+/);
|
|
9875
11480
|
|
|
@@ -9954,6 +11559,11 @@ function checkOptions(spec, { type, data, fields, err, warn, L }) {
|
|
|
9954
11559
|
if (pos !== undefined) enumCheck('legend', String(pos).toLowerCase(), LEGEND_POS, 'true, false, "top", "bottom", "left", "right" o { position }');
|
|
9955
11560
|
}
|
|
9956
11561
|
if (type === 'network' && spec.layout !== undefined) enumCheck('layout', String(spec.layout).toLowerCase(), NETWORK_LAYOUTS, '"force", "circular", "radial", "concentric", "clusters", "tree", "grid"');
|
|
11562
|
+
if (type === 'corr' && spec.method !== undefined) enumCheck('method', String(spec.method).toLowerCase(), ['pearson', 'spearman']);
|
|
11563
|
+
if (type === 'corr' && typeof spec.triangle === 'string') enumCheck('triangle', spec.triangle, ['lower', 'full']);
|
|
11564
|
+
if (type === 'corr' && spec.alpha !== undefined && !(+spec.alpha > 0 && +spec.alpha < 1)) err('corr-alpha', L('`alpha` es un nivel de significancia entre 0 y 1.', '`alpha` is a significance level between 0 and 1.'), 'alpha: 0.05');
|
|
11565
|
+
if (type === 'pairplot' && spec.diag !== undefined) enumCheck('diag', spec.diag, ['hist', 'kde']);
|
|
11566
|
+
if (type === 'pairplot' && typeof spec.upper === 'string') enumCheck('upper', spec.upper, ['corr', 'scatter']);
|
|
9957
11567
|
if (type === 'pointmap') {
|
|
9958
11568
|
if (typeof spec.tiles === 'string') enumCheck('tiles', spec.tiles.toLowerCase(), TILES, '"osm", "esri-gray", "esri-dark", "carto-light", "carto-dark", "mapbox-light", "mapbox-dark", { url, attribution } o false');
|
|
9959
11569
|
if (spec.tileStyle !== undefined) enumCheck('tileStyle', spec.tileStyle, ['muted', 'original']);
|
|
@@ -9972,7 +11582,7 @@ function checkOptions(spec, { type, data, fields, err, warn, L }) {
|
|
|
9972
11582
|
const base = String(nameOf).replace(/_r$/i, '');
|
|
9973
11583
|
const near = didYouMean(base, all);
|
|
9974
11584
|
err('colorscale-unknown', L(`La escala de color "${nameOf}" no existe.`, `The color scale "${nameOf}" does not exist.`), (near ? L(`¿Quisiste decir "${near}"? `, `Did you mean "${near}"? `) : '') + L('Usa un nombre de matplotlib ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` invierte) o una lista de colores hex.', 'Use a matplotlib name ("viridis", "Blues", "RdBu", "coolwarm"…; `_r` reverses) or a list of hex colors.'));
|
|
9975
|
-
} else err('colorscale-unknown', L(`\`${key}\` no se entiende.`, `\`${key}\` is not understood.`), L('Usa un nombre ("viridis"), al menos dos colores
|
|
11585
|
+
} else err('colorscale-unknown', L(`\`${key}\` no se entiende.`, `\`${key}\` is not understood.`), L('Usa un nombre ("viridis"), al menos dos colores (["#FFF", "steelblue"]) o { name, reverse, mid }.', 'Use a name ("viridis"), at least two colors (["#FFF", "steelblue"]) or { name, reverse, mid }.'));
|
|
9976
11586
|
} else if (cs.kind === 'qualitative') {
|
|
9977
11587
|
err('colorscale-qualitative', L(`"${cs.name}" es una paleta cualitativa: no sirve para una escala continua.`, `"${cs.name}" is a qualitative palette: it cannot be a continuous scale.`), L(`Usa \`palette: "${cs.name}"\` para categorías, o una escala como "viridis" o "Blues".`, `Use \`palette: "${cs.name}"\` for categories, or a scale such as "viridis" or "Blues".`));
|
|
9978
11588
|
} else if (RAINBOW.has(cs.name.replace(/_r$/, ''))) {
|
|
@@ -10064,7 +11674,7 @@ const schema = {
|
|
|
10064
11674
|
type: 'string',
|
|
10065
11675
|
enum: [...new Set(['auto', ...TYPES, 'pointmap', ...Object.keys(ALIASES)])],
|
|
10066
11676
|
description:
|
|
10067
|
-
'Compare: bar, count. Time: line, area. Schedules: gantt. Relationships: scatter, bubble, regplot, residplot, jointplot, radar. Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi, network, chord, pyramid, flowchart, wordcloud. Maps: choropleth, symbolmap, pointmap (lat/lon dots over a tile basemap). seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
|
|
11677
|
+
'Compare: bar, count. Time: line, area. Schedules: gantt. Relationships: scatter, bubble, regplot, residplot, jointplot, radar, corr (correlation matrix; clustermap), pairplot (scatter-plot matrix). Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi, network, chord, pyramid, flowchart, wordcloud. Maps: choropleth, symbolmap, pointmap (lat/lon dots over a tile basemap). seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
|
|
10068
11678
|
},
|
|
10069
11679
|
data: {
|
|
10070
11680
|
description: 'Array of rows, e.g. [{ "region": "Biobío", "sales": 1240 }]. For kpi it can be omitted in favor of `items`.',
|
|
@@ -10073,7 +11683,7 @@ const schema = {
|
|
|
10073
11683
|
title: { type: 'string', description: 'The finding as a full sentence, ideally with the key number, e.g. "Biobío accounts for 32% of sales".' },
|
|
10074
11684
|
subtitle: { type: 'string', description: 'How to read the chart: measure, unit and period, e.g. "Sales by region, CLP millions, 2025".' },
|
|
10075
11685
|
source: { type: 'string', description: 'Data source, shown at the bottom right.' },
|
|
10076
|
-
locale: { type: 'string', enum: ['es', 'en'], description: 'Language and number format. Default "es" (1.234,5);
|
|
11686
|
+
locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'], description: 'Language and number format. Default "es" (1.234,5); "en" for 1,234.5; also "pt", "fr", "de". "auto" recognizes the language from the title, subtitle, column names and categories (tables do this when `locale` is omitted).' },
|
|
10077
11687
|
x: field('x-axis or category field (bar, line, area, scatter, heatmap, box, violin…).'),
|
|
10078
11688
|
y: fieldOrList('Value field. For line/bar a list of fields (wide table) turns each one into a series.'),
|
|
10079
11689
|
series: field('Field that splits the data into colored series. Alias: hue.'),
|
|
@@ -10081,7 +11691,7 @@ const schema = {
|
|
|
10081
11691
|
hue: field('Alias of series (seaborn naming).'),
|
|
10082
11692
|
value: field('Value field for treemap, sunburst, donut, heatmap, sankey and radar.'),
|
|
10083
11693
|
label: field('Name field (treemap, donut, point labels in scatter/regplot).'),
|
|
10084
|
-
group: field('Level-1 group field for a two-level treemap (shortcut for path). gantt: phase/workstream field (one colored section per phase).'),
|
|
11694
|
+
group: field('Level-1 group field for a two-level treemap (shortcut for path). gantt: phase/workstream field (one colored section per phase). table: rows grouped under a header per value.'),
|
|
10085
11695
|
path: { type: 'array', items: { type: 'string' }, description: 'Hierarchy for treemap/sunburst, any depth: [level1, level2, …].' },
|
|
10086
11696
|
size: field('Bubble size field (area-proportional).'),
|
|
10087
11697
|
from: field('Sankey source field.'),
|
|
@@ -10180,7 +11790,33 @@ const schema = {
|
|
|
10180
11790
|
facet: field('One panel per value of this field (small multiples, seaborn col). Shared scales and colors.'),
|
|
10181
11791
|
col: field('Alias of facet (seaborn naming).'),
|
|
10182
11792
|
facetOrder: { type: 'array', items: { type: 'string' }, description: 'facet: panel order (values of the facet field; the rest follow).' },
|
|
10183
|
-
columns: {
|
|
11793
|
+
columns: {
|
|
11794
|
+
description: 'table: the columns, in order: field names or objects { field, label, type, format, align, width, good, target, colorScale, total, lines }. Column types: text, number, bar (data bar), heat (cell color), delta (▲/▼ change, colored by `good`), sparkline (field holds a list of numbers), progress (meter vs `target` field/number, or 0–1 / 0–100), badge (status pill; status words in es/en/pt/fr/de get good/warning/critical colors), check (booleans as ✓), date, link, rank. Omit to infer everything. facet: panels per row (an integer, seaborn col_wrap). kpi: tiles per row.',
|
|
11795
|
+
anyOf: [
|
|
11796
|
+
{ type: 'integer' },
|
|
11797
|
+
{ type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'object', properties: {
|
|
11798
|
+
field: { type: 'string' }, label: { type: 'string' },
|
|
11799
|
+
type: { type: 'string', enum: ['text', 'number', 'bar', 'heat', 'delta', 'sparkline', 'progress', 'badge', 'check', 'date', 'link', 'rank'] },
|
|
11800
|
+
format: { anyOf: [{ type: 'string' }, { type: 'object' }] }, align: { type: 'string', enum: ['left', 'right', 'center'] }, width: { type: 'number' },
|
|
11801
|
+
good: { type: 'string', enum: ['up', 'down'] }, target: { anyOf: [{ type: 'string' }, { type: 'number' }] }, colorScale: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }, { type: 'object' }] },
|
|
11802
|
+
total: { anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max', 'count'] }] }, lines: { type: 'integer', minimum: 1, maximum: 6 }, text: { type: 'string', description: 'link: field with the text to show instead of the URL.' }, sortable: { type: 'boolean' },
|
|
11803
|
+
} }] } },
|
|
11804
|
+
],
|
|
11805
|
+
},
|
|
11806
|
+
sortBy: field('table: column to sort by (field or label). Numbers sort high→low by default, text A→Z. In the browser a click on a header sorts.'),
|
|
11807
|
+
sortOrder: { type: 'string', enum: ['asc', 'desc'], description: 'table: sort direction (same as `sort: "asc" | "desc"`).' },
|
|
11808
|
+
pageSize: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'boolean', enum: [false] }], description: 'table: rows per page. Default: all rows up to 15, then pages of 10. false shows every row.' },
|
|
11809
|
+
page: { type: 'integer', minimum: 1, description: 'table: page to show (1-based). In the browser the pager changes it.' },
|
|
11810
|
+
query: { type: 'string', description: 'table: show only rows that contain these words (accent- and case-insensitive).' },
|
|
11811
|
+
search: { type: 'boolean', description: 'table: search box in the browser (default: on above 20 rows).' },
|
|
11812
|
+
total: { description: 'table: totals row. true sums numbers (and averages percents and progress); "mean"/"sum"; or { field: "sum" | "mean" | false, label }.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max'] }, { type: 'object' }] },
|
|
11813
|
+
totalLabel: { type: 'string', description: 'table: label of the totals row (default "Total"/"Gesamt").' },
|
|
11814
|
+
rank: { type: 'boolean', description: 'table: add a "#" column with the position under the current sort.' },
|
|
11815
|
+
cards: { description: 'table: one card per row. "auto" (default) switches when the columns do not fit (phones); true always; false never (columns shrink and truncate).', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['auto'] }] },
|
|
11816
|
+
density: { type: 'string', enum: ['compact', 'normal', 'comfortable'], description: 'table: row height.' },
|
|
11817
|
+
striped: { type: 'boolean', description: 'table: tint every other row (helps on very wide tables).' },
|
|
11818
|
+
bars: { type: 'boolean', description: 'table: false turns off the automatic data bar on the main numeric column.' },
|
|
11819
|
+
sortable: { type: 'boolean', description: 'table: false disables click-to-sort on headers in the browser.' },
|
|
10184
11820
|
sharex: bool('facet: same x scale in every panel (default true).'),
|
|
10185
11821
|
panelHeight: number('facet: height of each panel in px.'),
|
|
10186
11822
|
sharey: { type: 'boolean', description: 'facet: same y scale in every panel (default true).' },
|
|
@@ -10214,7 +11850,7 @@ const schema = {
|
|
|
10214
11850
|
xFormat: formatSpec('Format of x values when x is a measure (scatter, regplot, hist).'),
|
|
10215
11851
|
yFormat: formatSpec('Format of y values (overrides `format` on the y axis).'),
|
|
10216
11852
|
|
|
10217
|
-
highlight: { description: 'Emphasize one or more categories/series and mute the rest.', ...strOrList },
|
|
11853
|
+
highlight: { description: 'Emphasize one or more categories/series and mute the rest. table: the rows whose label matches.', ...strOrList },
|
|
10218
11854
|
dashed: { description: 'Draw some shapes dashed (hatched fill + dashed outline; dashed stroke for lines) to mark estimates, projections or provisional figures. A category/series/region name or a list of them, a data field whose truthy rows are dashed, { field, equals }, or true for all.', anyOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'array', items: { type: 'string' } }, { type: 'object', properties: { field: { type: 'string' }, equals: {} } }] },
|
|
10219
11855
|
dashedFrom: { description: 'line/area/bar on a time axis: x value from which marks are dashed (a projection). Lines turn dashed from the last firm point, over a soft labeled band.', anyOf: [{ type: 'string' }, { type: 'number' }] },
|
|
10220
11856
|
dashedLabel: { description: 'What the dashed shapes mean ("Projection", "Preliminary"): legend entry, band label and tooltip line. false hides the band label.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
|
|
@@ -10224,7 +11860,7 @@ const schema = {
|
|
|
10224
11860
|
|
|
10225
11861
|
stack: { description: 'bar/area: true stacks; "percent" stacks to 100%.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['percent'] }] },
|
|
10226
11862
|
horizontal: { type: 'boolean', description: 'Force horizontal orientation (otherwise chosen from label length and count).' },
|
|
10227
|
-
top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other".' },
|
|
11863
|
+
top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other". table: keep the first N rows after sorting.' },
|
|
10228
11864
|
endLabels: bool('line/regplot: false removes the series names at the end of each line (a legend is drawn instead).'),
|
|
10229
11865
|
labelsFor: { type: 'object', additionalProperties: { type: 'string' }, description: 'Wide table (y as a list): display names per column, e.g. { "rev": "Revenue" }.' },
|
|
10230
11866
|
index: { type: 'boolean', description: 'line: rebase each series to 100 at its first value (compare growth without a dual axis).' },
|
|
@@ -10240,12 +11876,12 @@ const schema = {
|
|
|
10240
11876
|
kde: { type: 'boolean', description: 'hist: overlay a density curve.' },
|
|
10241
11877
|
multiple: { type: 'string', enum: ['layer', 'stack', 'dodge', 'fill'], description: 'hist/kde with hue: how groups are combined.' },
|
|
10242
11878
|
fill: { type: 'boolean', description: 'kde: fill the area. line: false removes the gradient under a single line.' },
|
|
10243
|
-
order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
|
|
11879
|
+
order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list). corr: variable order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
|
|
10244
11880
|
lowess: { type: 'boolean', description: 'regplot: local regression instead of a straight line.' },
|
|
10245
11881
|
logx: { type: 'boolean', description: 'regplot: fit y ~ log(x) with a log x axis.' },
|
|
10246
11882
|
trend: { type: 'boolean', description: 'scatter: add a least-squares line and R².' },
|
|
10247
11883
|
annotate: { type: 'boolean', description: 'regplot: show R² and n.' },
|
|
10248
|
-
kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot.' },
|
|
11884
|
+
kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot. pairplot: "reg" adds a least-squares line to every scatter panel.' },
|
|
10249
11885
|
marginal: { type: 'string', enum: ['hist', 'kde'], description: 'jointplot: marginal plots.' },
|
|
10250
11886
|
inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'stick', 'none'], description: 'violin: what goes inside.' },
|
|
10251
11887
|
bandwidth: number('violin: KDE bandwidth (default Scott\'s rule).'),
|
|
@@ -10269,6 +11905,17 @@ const schema = {
|
|
|
10269
11905
|
centerValue: { type: 'string', description: 'donut/sunburst: text in the center (default: the total).' },
|
|
10270
11906
|
centerLabel: { type: 'string', description: 'donut/sunburst: caption under the center value.' },
|
|
10271
11907
|
cellHeight: number('heatmap: maximum cell height in px.'),
|
|
11908
|
+
vars: { type: 'array', items: { type: 'string' }, description: 'corr/pairplot: the numeric fields to relate, in order (seaborn `vars`). Default: every numeric field (pairplot: the first 6). `y: [...]` works too.' },
|
|
11909
|
+
method: { type: 'string', enum: ['pearson', 'spearman'], description: 'corr: correlation coefficient. "pearson" (default, linear) or "spearman" (ranks: monotonic relations, robust to outliers).' },
|
|
11910
|
+
triangle: { anyOf: [{ type: 'string', enum: ['lower', 'full'] }, { type: 'boolean' }], description: 'corr: "lower" (default) shows each pair once, below the diagonal; "full" shows the whole symmetric matrix (default with `cluster`).' },
|
|
11911
|
+
cluster: bool('corr: reorder variables by hierarchical clustering (average linkage on 1 − r) and draw the dendrogram on top, like sns.clustermap. `type: "clustermap"` sets it.'),
|
|
11912
|
+
dendrogram: bool('corr with `cluster`: false hides the dendrogram but keeps the clustered order.'),
|
|
11913
|
+
alpha: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 1, description: 'corr: significance level (e.g. 0.05). Cells with p ≥ alpha are greyed out and a legend key explains it. p-values use Fisher\'s z.' },
|
|
11914
|
+
cellSize: number('corr: maximum cell size in px (cells are square).'),
|
|
11915
|
+
diag: { type: 'string', enum: ['hist', 'kde'], description: 'pairplot: what goes on the diagonal. Default "hist", or "kde" when `hue` is set.' },
|
|
11916
|
+
upper: { anyOf: [{ type: 'string', enum: ['corr', 'scatter'] }, { type: 'boolean', enum: [false] }], description: 'pairplot: above the diagonal, "corr" (default: Pearson r, tinted by strength, per `hue` group when there is room), "scatter" (mirror), or false (empty).' },
|
|
11917
|
+
corner: bool('pairplot: draw only the lower triangle and the diagonal (seaborn corner=True).'),
|
|
11918
|
+
panelSize: number('pairplot: maximum panel size in px.'),
|
|
10272
11919
|
diverging: bool('heatmap/choropleth: diverging color scale around 0 (or `midpoint`).'),
|
|
10273
11920
|
|
|
10274
11921
|
colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities: { "Biobío": "#3A47B8" }.' },
|
|
@@ -10309,13 +11956,12 @@ const dashboardSchema = {
|
|
|
10309
11956
|
title: 'plotagents dashboard',
|
|
10310
11957
|
description: 'A dashboard with best-practice defaults: KPIs on top, the main chart wide, the rest in balanced full rows of a 12-column grid, one color per entity across charts, shared filters, one source line.',
|
|
10311
11958
|
type: 'object',
|
|
10312
|
-
required: ['charts'],
|
|
10313
11959
|
properties: {
|
|
10314
11960
|
title: { type: 'string', description: 'The question the dashboard answers, e.g. "How are southern sales doing?"' },
|
|
10315
11961
|
subtitle: { type: 'string', description: 'Scope: measure, unit, period.' },
|
|
10316
11962
|
source: { type: 'string', description: 'Data source, shown once at the bottom.' },
|
|
10317
11963
|
updated: { type: 'string', description: 'Last update, e.g. "2026-01-05".' },
|
|
10318
|
-
locale: { type: 'string', enum: ['es', 'en'] },
|
|
11964
|
+
locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'] },
|
|
10319
11965
|
theme: { description: 'One theme for every chart: "papel" (light) or "noche" (dark), or a theme object.', anyOf: [{ type: 'string' }, { type: 'object' }] },
|
|
10320
11966
|
data: { type: 'array', items: { type: 'object' }, description: 'Shared rows: charts without their own `data` use these, and computed KPIs and filters work on them.' },
|
|
10321
11967
|
kpis: {
|
|
@@ -10326,6 +11972,7 @@ const dashboardSchema = {
|
|
|
10326
11972
|
scale: { type: 'number', description: 'Multiply the computed value (e.g. 1000 to show thousands).' },
|
|
10327
11973
|
compare: { type: 'object', description: 'Current vs previous period: { field: "year", current: 2025, previous: 2024 }.', properties: { field: { type: 'string' }, current: {}, previous: {} } },
|
|
10328
11974
|
trendBy: { type: 'string', description: 'Field to draw a sparkline by (e.g. "month").' },
|
|
11975
|
+
previous: { type: 'number', description: 'Previous value: the change is computed from it.' }, display: { type: 'string', description: 'Text shown instead of the formatted value.' },
|
|
10329
11976
|
format: {}, good: { type: 'string', enum: ['up', 'down'] }, trend: { type: 'array', items: { type: 'number' } },
|
|
10330
11977
|
} },
|
|
10331
11978
|
},
|
|
@@ -10333,6 +11980,12 @@ const dashboardSchema = {
|
|
|
10333
11980
|
filters: { type: 'array', description: 'Fields to filter every chart and computed KPI by: "region" or { field, label, control: "buttons" | "select", value }.', items: { anyOf: [{ type: 'string' }, { type: 'object' }] } },
|
|
10334
11981
|
layout: { type: 'string', enum: ['auto', 'executive', 'grid'], description: 'auto (default): balanced rows by chart type. executive: main chart full width, the rest in pairs. grid: equal panels.' },
|
|
10335
11982
|
colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities across every chart.' },
|
|
11983
|
+
order: { type: 'string', enum: ['given'], description: '"given" keeps the charts in the order you wrote them (otherwise the main time series goes first).' },
|
|
11984
|
+
note: { type: 'string', description: 'Short note next to the source, at the bottom.' },
|
|
11985
|
+
font: { description: 'Font for every chart: a Google Fonts name, a CSS stack or { family, import }.', anyOf: [{ type: 'string' }, { type: 'object' }] },
|
|
11986
|
+
palette: { description: 'Categorical palette for every chart: a name ("tab10", "okabe-ito") or a list of colors.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
|
|
11987
|
+
ramp: { description: 'Sequential ramp for every chart.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
|
|
11988
|
+
textScale: { type: 'number', description: 'Multiplies every text (1.5 for presentations).' },
|
|
10336
11989
|
},
|
|
10337
11990
|
};
|
|
10338
11991
|
|
|
@@ -10346,7 +11999,7 @@ function toolDefinition({ name, flavor = 'anthropic', kind = 'chart' } = {}) {
|
|
|
10346
11999
|
}
|
|
10347
12000
|
name = name ?? 'plot';
|
|
10348
12001
|
const description =
|
|
10349
|
-
'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, wordcloud (words sized by frequency, or raw text to count), gantt (tasks with start/end, phases, progress, dependencies, milestones), scatter, bubble, regplot, residplot, jointplot, hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, choropleth (Chile regions, US states and world countries built in, or any GeoJSON/TopoJSON in `geo`, with markers, layers and fitTo), symbolmap, pointmap (lat/lon dots over a tile basemap), network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` shows one period (add `slider: true` for controls). `data` is required except for kpi (`items`), flowchart (`nodes`/`edges`), chord (`matrix`) and wordcloud raw `text`. Set `locale: "en"` for English number formats.';
|
|
12002
|
+
'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, wordcloud (words sized by frequency, or raw text to count), gantt (tasks with start/end, phases, progress, dependencies, milestones), scatter, bubble, regplot, residplot, jointplot, corr (correlation matrix of numeric fields, optional clustering: clustermap), pairplot (scatter-plot matrix with distributions and r), hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, table (exact values with per-column indicators: data bars, ▲/▼ changes, cell colors, sparklines, progress meters, status pills; paginated, cards on phones), choropleth (Chile regions, US states and world countries built in, or any GeoJSON/TopoJSON in `geo`, with markers, layers and fitTo), symbolmap, pointmap (lat/lon dots over a tile basemap), network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` shows one period (add `slider: true` for controls). `data` is required except for kpi (`items`), flowchart (`nodes`/`edges`), chord (`matrix`) and wordcloud raw `text`. Set `locale: "en"` for English number formats.';
|
|
10350
12003
|
const { $schema, $id, title, ...params } = schema;
|
|
10351
12004
|
if (flavor === 'openai') return { type: 'function', function: { name, description, parameters: params } };
|
|
10352
12005
|
return { name, description, input_schema: params };
|
|
@@ -10812,6 +12465,7 @@ const { ensureData, fieldKind, uniq, num, PlotError } = __req("src/data.js");
|
|
|
10812
12465
|
const { parseDate, formatDate } = __req("src/format.js");
|
|
10813
12466
|
const { dateUnit } = __req("src/scale.js");
|
|
10814
12467
|
const { isGeoUrl } = __req("src/geo/registry.js");
|
|
12468
|
+
const { words } = __req("src/i18n.js");
|
|
10815
12469
|
|
|
10816
12470
|
let plotFn = null;
|
|
10817
12471
|
let resolveFn = null;
|
|
@@ -10861,7 +12515,12 @@ function ensureAssets() {
|
|
|
10861
12515
|
.pa-live .pa-chev{opacity:1!important}
|
|
10862
12516
|
.pa-chart .pa-gh:hover .pa-hit{fill:var(--pa-tint,#ECEEF8);fill-opacity:.5}
|
|
10863
12517
|
.pa-chart [data-node]:focus-visible{outline:none;stroke:var(--pa-accent);stroke-width:3px}
|
|
10864
|
-
|
|
12518
|
+
.pa-search{position:absolute;z-index:2;box-sizing:border-box;margin:0;font:inherit;font-size:13px;color:var(--pa-ink);background-color:var(--pa-surface);background-repeat:no-repeat;background-position:10px center;border:1px solid var(--pa-line);border-radius:8px;padding:0 10px 0 32px;outline:none;transition:border-color .15s,box-shadow .15s;-webkit-appearance:none;appearance:none}
|
|
12519
|
+
.pa-search::placeholder{color:var(--pa-muted);opacity:1}
|
|
12520
|
+
.pa-search:hover{border-color:var(--pa-muted)}
|
|
12521
|
+
.pa-search:focus-visible,.pa-search:focus{border-color:var(--pa-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--pa-accent) 22%,transparent)}
|
|
12522
|
+
.pa-chart .pa-th[data-sort]:focus-visible,.pa-chart .pa-pg:focus-visible{outline:2px solid var(--pa-accent);outline-offset:-2px}
|
|
12523
|
+
@media (prefers-reduced-motion: reduce){.pa-m{transition:none!important}.pa-search{transition:none}}`;
|
|
10865
12524
|
document.head.appendChild(st);
|
|
10866
12525
|
}
|
|
10867
12526
|
|
|
@@ -10921,6 +12580,38 @@ function render(target, spec, opts = {}) {
|
|
|
10921
12580
|
let timer = null, ro = null, raf = 0;
|
|
10922
12581
|
|
|
10923
12582
|
const width = () => state.width ?? Math.max(280, Math.round(chart.clientWidth || host.clientWidth || 720));
|
|
12583
|
+
let search = null, searchTimer = 0;
|
|
12584
|
+
if (state.type === 'table' && state.search !== false) {
|
|
12585
|
+
let n = 0;
|
|
12586
|
+
try { n = ensureData(state).length; } catch { n = 0; }
|
|
12587
|
+
if (state.search === true || n > 20) {
|
|
12588
|
+
const th = themeFor(state);
|
|
12589
|
+
const W = words(state.locale);
|
|
12590
|
+
search = document.createElement('input');
|
|
12591
|
+
search.type = 'search'; search.className = 'pa-search';
|
|
12592
|
+
search.placeholder = W.search + '…'; search.setAttribute('aria-label', W.search);
|
|
12593
|
+
search.autocomplete = 'off'; search.spellcheck = false;
|
|
12594
|
+
if (state.query) search.value = state.query;
|
|
12595
|
+
const icon = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><circle cx="6" cy="6" r="4.25" fill="none" stroke="${th.muted}" stroke-width="1.5"/><path d="M9.2 9.2l3.3 3.3" stroke="${th.muted}" stroke-width="1.5" stroke-linecap="round"/></svg>`;
|
|
12596
|
+
search.style.cssText = `--pa-accent:${th.categorical[0]};--pa-surface:${th.surface};--pa-ink:${th.ink};--pa-muted:${th.muted};--pa-line:${th.axis};font-family:${(th.font?.family || 'system-ui').replace(/"/g, "'")};background-image:url("data:image/svg+xml,${encodeURIComponent(icon)}")`;
|
|
12597
|
+
search.addEventListener('input', () => {
|
|
12598
|
+
clearTimeout(searchTimer);
|
|
12599
|
+
searchTimer = setTimeout(() => { state = { ...state, query: search.value.trim() || undefined, page: 1 }; draw(); }, 120);
|
|
12600
|
+
});
|
|
12601
|
+
search.addEventListener('keydown', (e) => { if (e.key === 'Escape' && search.value) { e.stopPropagation(); search.value = ''; state = { ...state, query: undefined, page: 1 }; draw(); } });
|
|
12602
|
+
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
|
12603
|
+
host.appendChild(search);
|
|
12604
|
+
}
|
|
12605
|
+
}
|
|
12606
|
+
const placeSearch = () => {
|
|
12607
|
+
if (!search) return;
|
|
12608
|
+
const slot = chart.querySelector('.pa-search-slot');
|
|
12609
|
+
if (!slot) { search.hidden = true; return; }
|
|
12610
|
+
search.hidden = false;
|
|
12611
|
+
const r = slot.getBoundingClientRect(), h = host.getBoundingClientRect();
|
|
12612
|
+
search.style.left = r.left - h.left + 'px'; search.style.top = r.top - h.top + 'px';
|
|
12613
|
+
search.style.width = r.width + 'px'; search.style.height = r.height + 'px';
|
|
12614
|
+
};
|
|
10924
12615
|
const nodePos = () => {
|
|
10925
12616
|
const m = new Map();
|
|
10926
12617
|
for (const c of chart.querySelectorAll('circle[data-node]')) m.set(c.getAttribute('data-node'), [+c.getAttribute('cx'), +c.getAttribute('cy')]);
|
|
@@ -10980,7 +12671,7 @@ function render(target, spec, opts = {}) {
|
|
|
10980
12671
|
const draw = () => {
|
|
10981
12672
|
const before = nodePos();
|
|
10982
12673
|
const fills = regionFills();
|
|
10983
|
-
chart.innerHTML = plotFn({ ...state, width: width() });
|
|
12674
|
+
chart.innerHTML = plotFn({ ...state, width: width(), _search: !!search });
|
|
10984
12675
|
animateFrom(before);
|
|
10985
12676
|
tweenFills(fills);
|
|
10986
12677
|
chart.__pa = false;
|
|
@@ -11025,6 +12716,49 @@ function render(target, spec, opts = {}) {
|
|
|
11025
12716
|
n.addEventListener('click', go);
|
|
11026
12717
|
n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
|
|
11027
12718
|
}
|
|
12719
|
+
placeSearch();
|
|
12720
|
+
const refocus = (sel) => { const m = chart.querySelector(sel); if (m) m.focus(); return !!m; };
|
|
12721
|
+
const refocusSame = (arrow, p) => (arrow ? refocus(`[data-arrow="${arrow}"][data-page]`) : refocus(`[data-page="${p}"]:not([data-arrow])`));
|
|
12722
|
+
for (const n of chart.querySelectorAll('[data-page]')) {
|
|
12723
|
+
const p = +n.getAttribute('data-page');
|
|
12724
|
+
const arrow = n.getAttribute('data-arrow');
|
|
12725
|
+
n.setAttribute('tabindex', '0');
|
|
12726
|
+
n.setAttribute('role', 'button');
|
|
12727
|
+
const go = (e) => {
|
|
12728
|
+
state = { ...state, page: p };
|
|
12729
|
+
draw();
|
|
12730
|
+
if (e && e.type === 'keydown' && !refocusSame(arrow, p)) refocus('.pa-pg-on');
|
|
12731
|
+
};
|
|
12732
|
+
n.addEventListener('click', go);
|
|
12733
|
+
n.addEventListener('keydown', (e) => {
|
|
12734
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); return; }
|
|
12735
|
+
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
|
12736
|
+
if (!d) return;
|
|
12737
|
+
const pages = [...chart.querySelectorAll('[data-page]')];
|
|
12738
|
+
const k = pages.indexOf(n) + d;
|
|
12739
|
+
if (pages[k]) { e.preventDefault(); pages[k].focus(); }
|
|
12740
|
+
});
|
|
12741
|
+
}
|
|
12742
|
+
for (const n of chart.querySelectorAll('.pa-th[data-sort]')) {
|
|
12743
|
+
const f = n.getAttribute('data-sort');
|
|
12744
|
+
n.setAttribute('tabindex', '0');
|
|
12745
|
+
n.setAttribute('aria-label', `${words(state.locale).sort} ${n.textContent}`);
|
|
12746
|
+
const go = (e) => {
|
|
12747
|
+
const cur = n.getAttribute('aria-sort');
|
|
12748
|
+
const numeric = n.getAttribute('data-num') === '1';
|
|
12749
|
+
const first = numeric ? 'desc' : 'asc';
|
|
12750
|
+
const firstAria = first === 'desc' ? 'descending' : 'ascending';
|
|
12751
|
+
let next;
|
|
12752
|
+
if (cur === 'none' || !cur) next = { sortBy: f, sort: first };
|
|
12753
|
+
else if (cur === firstAria) next = { sortBy: f, sort: first === 'desc' ? 'asc' : 'desc' };
|
|
12754
|
+
else next = { sortBy: undefined, sort: undefined };
|
|
12755
|
+
state = { ...state, ...next, sortOrder: undefined, page: 1 };
|
|
12756
|
+
draw();
|
|
12757
|
+
if (e && e.type === 'keydown') refocus(`.pa-th[data-sort="${CSS.escape ? CSS.escape(f) : f}"]`);
|
|
12758
|
+
};
|
|
12759
|
+
n.addEventListener('click', go);
|
|
12760
|
+
n.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(e); } });
|
|
12761
|
+
}
|
|
11028
12762
|
};
|
|
11029
12763
|
const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
|
|
11030
12764
|
|
|
@@ -11135,12 +12869,13 @@ function render(target, spec, opts = {}) {
|
|
|
11135
12869
|
set();
|
|
11136
12870
|
}
|
|
11137
12871
|
|
|
11138
|
-
|
|
12872
|
+
const onEsc = (e) => {
|
|
11139
12873
|
if (e.key !== 'Escape') return;
|
|
11140
12874
|
if (state.focus !== undefined && state.type === 'network') { state = { ...state, focus: undefined }; draw(); return; }
|
|
11141
12875
|
if (Array.isArray(state.focus) && state.focus.length) { state = { ...state, focus: state.focus.slice(0, -1) }; draw(); return; }
|
|
11142
12876
|
if (state.exclude && [].concat(state.exclude).length) { state = { ...state, exclude: undefined }; draw(); }
|
|
11143
|
-
}
|
|
12877
|
+
};
|
|
12878
|
+
host.addEventListener('keydown', onEsc);
|
|
11144
12879
|
|
|
11145
12880
|
draw();
|
|
11146
12881
|
if (state.width === undefined && typeof ResizeObserver !== 'undefined') {
|
|
@@ -11151,8 +12886,8 @@ function render(target, spec, opts = {}) {
|
|
|
11151
12886
|
return {
|
|
11152
12887
|
get svg() { return chart.querySelector('svg'); },
|
|
11153
12888
|
get spec() { return state; },
|
|
11154
|
-
update(next) { clearInterval(timer); ro && ro.disconnect(); return render(host, next, opts); },
|
|
11155
|
-
destroy() { clearInterval(timer); ro && ro.disconnect(); host.innerHTML = ''; },
|
|
12889
|
+
update(next) { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); return render(host, next, opts); },
|
|
12890
|
+
destroy() { clearInterval(timer); clearTimeout(searchTimer); ro && ro.disconnect(); host.removeEventListener('keydown', onEsc); host.innerHTML = ''; },
|
|
11156
12891
|
};
|
|
11157
12892
|
}
|
|
11158
12893
|
|
|
@@ -11216,11 +12951,18 @@ let core = null;
|
|
|
11216
12951
|
/** index.js registra plot/resolve/lint aquí (evita una importación circular). */
|
|
11217
12952
|
function _bindDashboard(fns) { core = fns; }
|
|
11218
12953
|
|
|
12954
|
+
/** Spec tolerante: charts/kpis/filters siempre listas de objetos (los nulos y valores sueltos se descartan). */
|
|
12955
|
+
function tidy(spec) {
|
|
12956
|
+
const list = (v) => (v == null || v === false ? [] : [].concat(v)).filter((x) => x != null);
|
|
12957
|
+
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')) };
|
|
12958
|
+
}
|
|
12959
|
+
const hasField = (r, f) => r !== null && typeof r === 'object' && f in r;
|
|
12960
|
+
|
|
11219
12961
|
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
11220
12962
|
|
|
11221
12963
|
const WIDE = new Set(['line', 'area', 'gantt', 'heatmap', 'sankey', 'flowchart', 'network', 'jointplot']);
|
|
11222
12964
|
const NARROW = new Set(['donut', 'pie', 'radar', 'kpi', 'hist', 'kde', 'ecdf', 'count']);
|
|
11223
|
-
const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart']);
|
|
12965
|
+
const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart', 'table']);
|
|
11224
12966
|
|
|
11225
12967
|
function spanFor(s) {
|
|
11226
12968
|
if (s.size === 'full' || s.span === 12) return 12;
|
|
@@ -11316,6 +13058,7 @@ const colorField = (s) => s.series ?? s.hue ?? (Array.isArray(s.path) ? s.path[0
|
|
|
11316
13058
|
function planDashboard(spec = {}) {
|
|
11317
13059
|
if (!core) throw new PlotError('plotagents no está inicializado.');
|
|
11318
13060
|
if (!spec || typeof spec !== 'object') throw new PlotError('El dashboard debe ser un objeto.', 'dashboard({ title, kpis, charts: [...] })');
|
|
13061
|
+
spec = tidy(spec);
|
|
11319
13062
|
const locale = spec.locale || 'es';
|
|
11320
13063
|
const shared = spec.data ? ensureData(spec) : null;
|
|
11321
13064
|
const base = {};
|
|
@@ -11368,7 +13111,7 @@ function planDashboard(spec = {}) {
|
|
|
11368
13111
|
const filters = [].concat(spec.filters || []).map((f) => {
|
|
11369
13112
|
const o = typeof f === 'string' ? { field: f } : { ...f };
|
|
11370
13113
|
const pool = [...(shared || []), ...panels.flatMap((p) => (Array.isArray(p.spec.data) && p.spec.data !== shared ? p.spec.data : []))];
|
|
11371
|
-
const vals = uniq(pool.map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
|
|
13114
|
+
const vals = uniq(pool.filter((r) => hasField(r, o.field)).map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
|
|
11372
13115
|
const kind = pool.length ? fieldKind(pool, o.field) : 'category';
|
|
11373
13116
|
vals.sort((a, b) => (kind === 'date' ? parseDate(a) - parseDate(b) : kind === 'year' || kind === 'number' ? num(a) - num(b) : 0));
|
|
11374
13117
|
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') };
|
|
@@ -11381,6 +13124,7 @@ function planDashboard(spec = {}) {
|
|
|
11381
13124
|
}
|
|
11382
13125
|
|
|
11383
13126
|
function lintDashboard(spec = {}) {
|
|
13127
|
+
spec = tidy(spec && typeof spec === 'object' ? spec : {});
|
|
11384
13128
|
const locale = spec.locale || 'es';
|
|
11385
13129
|
const L = (es, en) => T(locale, es, en);
|
|
11386
13130
|
const errors = [], warnings = [], suggestions = [];
|
|
@@ -11402,7 +13146,7 @@ function lintDashboard(spec = {}) {
|
|
|
11402
13146
|
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.') });
|
|
11403
13147
|
for (const f of [].concat(spec.filters || [])) {
|
|
11404
13148
|
const field = typeof f === 'string' ? f : f.field;
|
|
11405
|
-
const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => r
|
|
13149
|
+
const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => hasField(r, field)));
|
|
11406
13150
|
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`.') });
|
|
11407
13151
|
}
|
|
11408
13152
|
charts.forEach((c, i) => {
|
|
@@ -11463,7 +13207,8 @@ function kpiMarkup(plan, rows) {
|
|
|
11463
13207
|
if (k.trendBy && !k.trend && rows && rows.length) {
|
|
11464
13208
|
const by = new Map();
|
|
11465
13209
|
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); }
|
|
11466
|
-
const
|
|
13210
|
+
const allNum = [...by.keys()].every((x) => Number.isFinite(num(x)));
|
|
13211
|
+
const ks = [...by.keys()].sort((x, y) => (allNum ? num(x) - num(y) : String(x) < String(y) ? -1 : String(x) > String(y) ? 1 : 0));
|
|
11467
13212
|
k = { ...k, trend: ks.map((x) => computeKpi(k, by.get(x))) };
|
|
11468
13213
|
}
|
|
11469
13214
|
const fmt = makeFormat(k.format, locale, [v]);
|
|
@@ -11515,7 +13260,7 @@ function filterSpec(spec, plan, active, locale) {
|
|
|
11515
13260
|
const v = active[i];
|
|
11516
13261
|
if (v === null || v === undefined || v === '') return;
|
|
11517
13262
|
if (s.time === f.field) { s = { ...s, timeValue: v }; return; }
|
|
11518
|
-
if (Array.isArray(s.data) && s.data.some((r) => r
|
|
13263
|
+
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}`); }
|
|
11519
13264
|
});
|
|
11520
13265
|
if (tags.length) s = { ...s, subtitle: [s.subtitle, tags.join(' · ')].filter(Boolean).join(' · ') };
|
|
11521
13266
|
return s;
|
|
@@ -11537,7 +13282,7 @@ function mountDashboard(root, plan) {
|
|
|
11537
13282
|
} catch (e) { fig.innerHTML = `<p class="pa-err">${esc(e.message)}</p>`; }
|
|
11538
13283
|
}
|
|
11539
13284
|
if (plan.data && plan.kpis.some((k) => k.value === undefined)) {
|
|
11540
|
-
const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !(f.field
|
|
13285
|
+
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])));
|
|
11541
13286
|
const slot = root.querySelector('.pa-kslot');
|
|
11542
13287
|
if (slot) slot.innerHTML = kpiMarkup(plan, rows);
|
|
11543
13288
|
}
|
|
@@ -11598,10 +13343,12 @@ ${bundle ? `<script>${bundle.replace(/<\/(script)/gi, '<\\/$1')}</script>
|
|
|
11598
13343
|
|
|
11599
13344
|
/** Nunca lanza: { ok, html, plan, errors, warnings, suggestions } (para agentes). */
|
|
11600
13345
|
function safeDashboard(spec) {
|
|
11601
|
-
|
|
13346
|
+
let rep;
|
|
13347
|
+
try { rep = lintDashboard(spec || {}); }
|
|
13348
|
+
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: [] }; }
|
|
11602
13349
|
if (!rep.ok) return { ...rep, html: null, plan: null };
|
|
11603
13350
|
try { const html = dashboard(spec); return { ...rep, ok: true, html, plan: planDashboard(spec) }; }
|
|
11604
|
-
catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint }] }; }
|
|
13351
|
+
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.' }] }; }
|
|
11605
13352
|
}
|
|
11606
13353
|
|
|
11607
13354
|
|