plotagents 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +98 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/plotagents.js +73 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +11611 -0
- package/docs/api.md +172 -0
- package/docs/charts.md +352 -0
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +142 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +116 -0
- package/index.d.ts +384 -0
- package/package.json +80 -4
- package/schema.json +1874 -0
- package/src/axes.js +152 -0
- package/src/charts/bar.js +350 -0
- package/src/charts/categorical.js +495 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +807 -0
- package/src/charts/dist.js +530 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +100 -0
- package/src/charts/line.js +551 -0
- package/src/charts/network.js +707 -0
- package/src/charts/pointmap.js +599 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +158 -0
- package/src/charts/regplot.js +370 -0
- package/src/charts/sankey.js +169 -0
- package/src/charts/scatter.js +214 -0
- package/src/charts/sunburst.js +136 -0
- package/src/charts/treemap.js +197 -0
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/context.js +10 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +203 -0
- package/src/frame.js +226 -0
- package/src/geo/chile.js +7 -0
- package/src/geo/data/chile-regiones.js +12 -0
- package/src/geo/data/usa-estados.js +12 -0
- package/src/geo/data/world.js +12 -0
- package/src/geo/projection.js +217 -0
- package/src/geo/registry.js +110 -0
- package/src/geo/usa.js +8 -0
- package/src/geo/world.js +7 -0
- package/src/hierarchy.js +139 -0
- package/src/index.js +336 -0
- package/src/infer.js +58 -0
- package/src/interact.js +483 -0
- package/src/lint.js +417 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +416 -0
- package/src/scale.js +241 -0
- package/src/schema.js +337 -0
- package/src/stats.js +303 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +311 -0
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// Dashboards con buenas prácticas por defecto.
|
|
2
|
+
//
|
|
3
|
+
// import { dashboard } from 'plotagents';
|
|
4
|
+
// const html = dashboard({
|
|
5
|
+
// title: 'Ventas del sur, 2025', source: 'ERP comercial', locale: 'es',
|
|
6
|
+
// data: ventas, // filas compartidas (opcional)
|
|
7
|
+
// kpis: [{ label: 'Ventas', field: 'monto', agg: 'sum', format: 'compact' }, …],
|
|
8
|
+
// charts: [{ type: 'line', x: 'mes', y: 'monto', series: 'region', title: '…' }, …],
|
|
9
|
+
// filters: ['region', 'canal'],
|
|
10
|
+
// });
|
|
11
|
+
//
|
|
12
|
+
// Lo que se decide aquí para que cualquier dashboard empiece bien:
|
|
13
|
+
// · Lectura en Z: KPIs arriba (a lo más 5), luego el gráfico principal ancho
|
|
14
|
+
// (la serie de tiempo, si hay una), luego el detalle.
|
|
15
|
+
// · Grilla de 12 columnas con filas completas (sin huecos): el ancho de cada
|
|
16
|
+
// panel sale de su tipo (series y flujos anchos; donas y rankings angostos).
|
|
17
|
+
// · Mismo color para la misma entidad en todos los gráficos.
|
|
18
|
+
// · Un solo tema, idioma y fuente tipográfica; la fuente de datos va una vez,
|
|
19
|
+
// al pie (salvo la de un gráfico que diga otra cosa).
|
|
20
|
+
// · Filtros como botones (o lista si hay muchos valores) que afectan a todos
|
|
21
|
+
// los gráficos que tienen ese campo y a los KPIs calculados.
|
|
22
|
+
// · En teléfonos, una columna en el orden de lectura.
|
|
23
|
+
import { themeFor, colorMap } from './theme.js';
|
|
24
|
+
import { ensureData, num, uniq, fieldKind, aggregate, PlotError } from './data.js';
|
|
25
|
+
import { makeFormat, formatNumber, parseDate } from './format.js';
|
|
26
|
+
import { esc } from './svg.js';
|
|
27
|
+
import { render } from './runtime.js';
|
|
28
|
+
|
|
29
|
+
let core = null;
|
|
30
|
+
/** index.js registra plot/resolve/lint aquí (evita una importación circular). */
|
|
31
|
+
export function _bindDashboard(fns) { core = fns; }
|
|
32
|
+
|
|
33
|
+
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
34
|
+
|
|
35
|
+
// Ancho (de 12) según el tipo: lo que se lee de izquierda a derecha, ancho.
|
|
36
|
+
const WIDE = new Set(['line', 'area', 'gantt', 'heatmap', 'sankey', 'flowchart', 'network', 'jointplot']);
|
|
37
|
+
const NARROW = new Set(['donut', 'pie', 'radar', 'kpi', 'hist', 'kde', 'ecdf', 'count']);
|
|
38
|
+
const AUTO_HEIGHT = new Set(['kpi', 'gantt', 'flowchart']);
|
|
39
|
+
|
|
40
|
+
function spanFor(s) {
|
|
41
|
+
if (s.size === 'full' || s.span === 12) return 12;
|
|
42
|
+
if (Number.isFinite(+s.span)) return Math.max(3, Math.min(12, Math.round(+s.span)));
|
|
43
|
+
if (s.size === 'large') return 8;
|
|
44
|
+
if (s.size === 'medium') return 6;
|
|
45
|
+
if (s.size === 'small') return 4;
|
|
46
|
+
if (s.facet) return 12;
|
|
47
|
+
if (s.type === 'flowchart' || s.type === 'sankey') return 12;
|
|
48
|
+
if (WIDE.has(s.type)) return 8;
|
|
49
|
+
if (s.type === 'choropleth' || s.type === 'symbolmap') return s.geo === 'chile' || s.geo === undefined ? 4 : 6;
|
|
50
|
+
if (NARROW.has(s.type)) return 4;
|
|
51
|
+
if (s.type === 'bar') {
|
|
52
|
+
try {
|
|
53
|
+
const d = ensureData(s);
|
|
54
|
+
const n = s.x && d.length ? uniq(d.map((r) => String(r[s.x]))).length : d.length;
|
|
55
|
+
return n <= 6 && !s.series ? 4 : 6;
|
|
56
|
+
} catch { return 6; }
|
|
57
|
+
}
|
|
58
|
+
return 6;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Filas completas de 12: si el siguiente no cabe, los de la fila se estiran. */
|
|
62
|
+
export function packRows(spans) {
|
|
63
|
+
const rows = [];
|
|
64
|
+
let row = [];
|
|
65
|
+
const close = () => {
|
|
66
|
+
if (!row.length) return;
|
|
67
|
+
let left = 12 - row.reduce((a, r) => a + r.span, 0);
|
|
68
|
+
for (let k = 0; left > 0; k = (k + 1) % row.length) { row[k].span++; left--; }
|
|
69
|
+
rows.push(row);
|
|
70
|
+
row = [];
|
|
71
|
+
};
|
|
72
|
+
spans.forEach((span, i) => {
|
|
73
|
+
const used = row.reduce((a, r) => a + r.span, 0);
|
|
74
|
+
if (used + span > 12) close();
|
|
75
|
+
row.push({ i, span });
|
|
76
|
+
});
|
|
77
|
+
close();
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Filas balanceadas: el principal solo (o con un compañero angosto) y el
|
|
83
|
+
* resto en filas de 2 o 3, siempre completas: 2 → 6+6 (8+4 si uno es ancho),
|
|
84
|
+
* 3 → 4+4+4, 4 → 2+2, 5 → 3+2, 7 → 3+2+2…
|
|
85
|
+
*/
|
|
86
|
+
export function balancedRows(spans, hasPrimary) {
|
|
87
|
+
const rows = [];
|
|
88
|
+
let idx = spans.map((_, i) => i);
|
|
89
|
+
if (hasPrimary && idx.length) {
|
|
90
|
+
const p = idx.shift();
|
|
91
|
+
if (spans[p] >= 8 || !idx.length) rows.push([{ i: p, span: 12 }]);
|
|
92
|
+
else { const q = idx.shift(); rows.push([{ i: p, span: 8 }, { i: q, span: 4 }]); }
|
|
93
|
+
}
|
|
94
|
+
const n = idx.length;
|
|
95
|
+
const sizes = n === 1 ? [1] : n === 2 ? [2] : n === 4 ? [2, 2] : [];
|
|
96
|
+
if (!sizes.length) { let m = n; while (m > 0) { if (m === 4) { sizes.push(2, 2); m = 0; } else if (m === 2) { sizes.push(2); m = 0; } else if (m === 1) { sizes.push(1); m = 0; } else { sizes.push(3); m -= 3; } } }
|
|
97
|
+
for (const k of sizes) {
|
|
98
|
+
const row = idx.splice(0, k);
|
|
99
|
+
if (k === 1) rows.push([{ i: row[0], span: 12 }]);
|
|
100
|
+
else if (k === 2) {
|
|
101
|
+
const [a, b] = row;
|
|
102
|
+
const wideA = spans[a] >= 8, wideB = spans[b] >= 8;
|
|
103
|
+
rows.push(wideA && !wideB ? [{ i: a, span: 8 }, { i: b, span: 4 }] : wideB && !wideA ? [{ i: a, span: 4 }, { i: b, span: 8 }] : [{ i: a, span: 6 }, { i: b, span: 6 }]);
|
|
104
|
+
} else rows.push(row.map((i) => ({ i, span: 4 })));
|
|
105
|
+
}
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Valores de un KPI calculado: { field, agg } sobre las filas. */
|
|
110
|
+
export function computeKpi(def, rows) {
|
|
111
|
+
if (def.value !== undefined) return num(def.value);
|
|
112
|
+
const v = rawKpi(def, rows);
|
|
113
|
+
return Number.isFinite(v) && def.scale ? v * def.scale : v;
|
|
114
|
+
}
|
|
115
|
+
function rawKpi(def, rows) {
|
|
116
|
+
const agg = def.agg ?? def.aggregate ?? (def.field ? 'sum' : 'count');
|
|
117
|
+
if (agg === 'count') return rows.length;
|
|
118
|
+
if (agg === 'distinct') return uniq(rows.map((r) => r[def.field])).length;
|
|
119
|
+
const vals = rows.map((r) => num(r[def.field])).filter(Number.isFinite);
|
|
120
|
+
if (agg === 'ratio' && def.over) { const a = vals.reduce((x, y) => x + y, 0); const b = rows.map((r) => num(r[def.over])).filter(Number.isFinite).reduce((x, y) => x + y, 0); return b ? a / b : NaN; }
|
|
121
|
+
return vals.length ? aggregate(vals, agg) : NaN;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Campo que identifica las entidades coloreadas de un gráfico. */
|
|
125
|
+
const colorField = (s) => s.series ?? s.hue ?? (Array.isArray(s.path) ? s.path[0] : null) ?? s.group ?? (['donut', 'pie'].includes(s.type) ? s.label ?? s.x : null) ?? (s.type === 'treemap' ? s.label : null);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Plan del dashboard: decide orden, anchos, colores comunes, filtros y KPIs.
|
|
129
|
+
* Es puro (sin DOM): dashboard() y renderDashboard() lo dibujan.
|
|
130
|
+
*/
|
|
131
|
+
export function planDashboard(spec = {}) {
|
|
132
|
+
if (!core) throw new PlotError('plotagents no está inicializado.');
|
|
133
|
+
if (!spec || typeof spec !== 'object') throw new PlotError('El dashboard debe ser un objeto.', 'dashboard({ title, kpis, charts: [...] })');
|
|
134
|
+
const locale = spec.locale || 'es';
|
|
135
|
+
const shared = spec.data ? ensureData(spec) : null;
|
|
136
|
+
const base = {};
|
|
137
|
+
for (const k of ['locale', 'theme', 'font', 'palette', 'ramp', 'textScale']) if (spec[k] !== undefined) base[k] = spec[k];
|
|
138
|
+
// Gráficos: heredan datos, idioma y tema; el tipo se resuelve para decidir su ancho.
|
|
139
|
+
let charts = (spec.charts || []).map((c, i) => {
|
|
140
|
+
const s0 = { ...base, ...c, ...(c.data === undefined && shared ? { data: shared } : {}) };
|
|
141
|
+
if (spec.theme !== undefined && c.theme === undefined) s0.theme = spec.theme;
|
|
142
|
+
let s;
|
|
143
|
+
try { s = core.resolve(s0); } catch (e) { s = { ...s0, _error: e.message }; }
|
|
144
|
+
return { i, spec: s, primary: !!c.primary };
|
|
145
|
+
});
|
|
146
|
+
// El principal: el marcado, o la primera serie de tiempo; va primero.
|
|
147
|
+
let primary = charts.find((c) => c.primary) || charts.find((c) => ['line', 'area'].includes(c.spec.type));
|
|
148
|
+
if (spec.layout === 'grid') primary = null;
|
|
149
|
+
if (primary && spec.order !== 'given') charts = [primary, ...charts.filter((c) => c !== primary)];
|
|
150
|
+
// Colores comunes: misma entidad, mismo color en todos los gráficos.
|
|
151
|
+
const theme = themeFor(core.resolveDefaults ? core.resolveDefaults({ ...base }) : { ...base });
|
|
152
|
+
const tot = new Map();
|
|
153
|
+
for (const c of charts) {
|
|
154
|
+
const f = colorField(c.spec);
|
|
155
|
+
if (!f || !Array.isArray(c.spec.data)) continue;
|
|
156
|
+
const vf = c.spec.value ?? (typeof c.spec.y === 'string' ? c.spec.y : null);
|
|
157
|
+
for (const r of c.spec.data) { const k = r[f]; if (k === undefined || k === null) continue; tot.set(String(k), (tot.get(String(k)) || 0) + (vf && Number.isFinite(num(r[vf])) ? Math.abs(num(r[vf])) : 1)); }
|
|
158
|
+
}
|
|
159
|
+
const keys = [...tot.keys()].sort((a, b) => tot.get(b) - tot.get(a));
|
|
160
|
+
const colors = { ...Object.fromEntries(colorMap(keys, theme, spec.colors || {})), ...(spec.colors || {}) };
|
|
161
|
+
// Fuente común: una vez al pie; se quita de los gráficos que la repiten.
|
|
162
|
+
const source = spec.source;
|
|
163
|
+
// Anchos y filas.
|
|
164
|
+
const layout = spec.layout || 'auto';
|
|
165
|
+
const explicit = (spec.charts || []).some((c) => c.span !== undefined || c.size !== undefined);
|
|
166
|
+
let rows;
|
|
167
|
+
if (explicit || layout === 'grid' || layout === 'executive') {
|
|
168
|
+
const spans = charts.map((c, k) => {
|
|
169
|
+
if (layout === 'grid') return charts.length % 3 === 0 && charts.length > 4 ? 4 : 6;
|
|
170
|
+
let sp = spanFor(c.spec);
|
|
171
|
+
if (k === 0 && c === primary) sp = Math.max(sp, layout === 'executive' || WIDE.has(c.spec.type) ? 12 : 8);
|
|
172
|
+
if (layout === 'executive' && k > 0) sp = 6;
|
|
173
|
+
return sp;
|
|
174
|
+
});
|
|
175
|
+
rows = packRows(spans);
|
|
176
|
+
} else rows = balancedRows(charts.map((c) => spanFor(c.spec)), !!primary);
|
|
177
|
+
const panels = [];
|
|
178
|
+
rows.forEach((row, r) => row.forEach(({ i, span }) => {
|
|
179
|
+
const c = charts[i];
|
|
180
|
+
const s = { ...c.spec, colors: { ...colors, ...(c.spec.colors || {}) } };
|
|
181
|
+
if (source && s.source === source) delete s.source;
|
|
182
|
+
// Alto común por fila (los que se dimensionan solos, se respetan).
|
|
183
|
+
if (s.height === undefined && !AUTO_HEIGHT.has(s.type)) s.height = span >= 12 ? 380 : row.length >= 3 ? 340 : 360;
|
|
184
|
+
if (s.padding === undefined) s.padding = 16;
|
|
185
|
+
delete s.primary; delete s.size; delete s.span;
|
|
186
|
+
panels.push({ id: `p${c.i}`, row: r, span, spec: s, error: c.spec._error || null });
|
|
187
|
+
}));
|
|
188
|
+
// KPIs: fijos ({ value }) o calculados ({ field, agg }) sobre los datos compartidos.
|
|
189
|
+
const kpis = (spec.kpis || []).map((k) => (typeof k === 'object' ? { ...k } : { label: String(k) }));
|
|
190
|
+
// Filtros: campos presentes en los datos; botones con ≤ 6 valores.
|
|
191
|
+
const filters = [].concat(spec.filters || []).map((f) => {
|
|
192
|
+
const o = typeof f === 'string' ? { field: f } : { ...f };
|
|
193
|
+
const pool = [...(shared || []), ...panels.flatMap((p) => (Array.isArray(p.spec.data) && p.spec.data !== shared ? p.spec.data : []))];
|
|
194
|
+
const vals = uniq(pool.map((r) => r[o.field]).filter((v) => v !== undefined && v !== null && v !== ''));
|
|
195
|
+
const kind = pool.length ? fieldKind(pool, o.field) : 'category';
|
|
196
|
+
vals.sort((a, b) => (kind === 'date' ? parseDate(a) - parseDate(b) : kind === 'year' || kind === 'number' ? num(a) - num(b) : 0));
|
|
197
|
+
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') };
|
|
198
|
+
}).filter((f) => f.values.length > 1);
|
|
199
|
+
return {
|
|
200
|
+
title: spec.title ?? '', subtitle: spec.subtitle ?? '', source, updated: spec.updated ?? null, note: spec.note ?? null,
|
|
201
|
+
locale, base, theme: { mode: theme.mode, surface: theme.surface, ink: theme.ink, lede: theme.lede, muted: theme.muted, grid: theme.grid, axis: theme.axis, tint: theme.tint, accent: theme.categorical[0], good: theme.status.good, bad: theme.status.critical, font: theme.font?.family, fontImport: theme.font?.import },
|
|
202
|
+
kpis, data: shared, panels, filters, colors,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Revisión (lint) ────────────────────────────────────────────────────────
|
|
207
|
+
export function lintDashboard(spec = {}) {
|
|
208
|
+
const locale = spec.locale || 'es';
|
|
209
|
+
const L = (es, en) => T(locale, es, en);
|
|
210
|
+
const errors = [], warnings = [], suggestions = [];
|
|
211
|
+
const charts = spec.charts || [];
|
|
212
|
+
if (!charts.length && !(spec.kpis || []).length) errors.push({ code: 'dash-empty', message: L('El dashboard no tiene gráficos ni KPIs.', 'The dashboard has no charts or KPIs.'), fix: L('Agrega `charts: [spec, …]` y/o `kpis: [{ label, value }]`.', 'Add `charts: [spec, …]` and/or `kpis: [{ label, value }]`.') });
|
|
213
|
+
if (!spec.title) suggestions.push({ code: 'dash-title', message: L('Falta el título del dashboard.', 'The dashboard has no title.'), fix: L('Di qué pregunta responde: "¿Cómo van las ventas del sur en 2025?"', 'Say what question it answers: "How are southern sales doing in 2025?"') });
|
|
214
|
+
if (!spec.source && !charts.every((c) => c.source)) suggestions.push({ code: 'dash-source', message: L('Falta la fuente de los datos.', 'The data source is missing.'), fix: L('Agrega `source` al dashboard: se muestra una vez, al pie.', 'Add `source` to the dashboard: it is shown once, at the bottom.') });
|
|
215
|
+
if (charts.length > 9) warnings.push({ code: 'dash-many-charts', message: L(`${charts.length} gráficos: un dashboard que lo muestra todo no destaca nada.`, `${charts.length} charts: a dashboard that shows everything highlights nothing.`), fix: L('Quédate con 4–6 que respondan una pregunta; el resto, en otra página.', 'Keep the 4–6 that answer one question; move the rest to another page.') });
|
|
216
|
+
else if (charts.length > 6) suggestions.push({ code: 'dash-charts', message: L(`${charts.length} gráficos: sobre 6 cuesta leer el dashboard de un vistazo.`, `${charts.length} charts: beyond 6 the dashboard is hard to read at a glance.`), fix: L('Prioriza o agrupa en pestañas/páginas.', 'Prioritize, or split into pages.') });
|
|
217
|
+
const nk = (spec.kpis || []).length;
|
|
218
|
+
if (nk > 5) warnings.push({ code: 'dash-many-kpis', message: L(`${nk} KPIs: sobre 5 ninguno destaca.`, `${nk} KPIs: beyond 5 none stands out.`), fix: L('Deja los 3–5 que mueven decisiones.', 'Keep the 3–5 that drive decisions.') });
|
|
219
|
+
let types = [];
|
|
220
|
+
try { types = charts.map((c) => (core ? core.resolve({ ...c, ...(c.data === undefined && spec.data ? { data: spec.data } : {}) }).type : c.type)); } catch { types = charts.map((c) => c.type); }
|
|
221
|
+
const pies = types.filter((t) => t === 'donut' || t === 'pie').length;
|
|
222
|
+
if (pies > 1) suggestions.push({ code: 'dash-pies', message: L(`${pies} donas o tortas: compararlas entre sí es difícil.`, `${pies} donuts or pies: comparing them with each other is hard.`), fix: L('Usa una sola, o barras apiladas al 100% (`stack: "percent"`).', 'Keep one, or use 100% stacked bars (`stack: "percent"`).') });
|
|
223
|
+
const themes = new Set(charts.map((c) => (typeof c.theme === 'string' ? c.theme : c.theme ? 'custom' : null)).filter(Boolean));
|
|
224
|
+
if (themes.size > 1) warnings.push({ code: 'dash-themes', message: L('Los gráficos usan temas distintos.', 'Charts use different themes.'), fix: L('Fija `theme` en el dashboard y quítalo de los gráficos.', 'Set `theme` on the dashboard and remove it from the charts.') });
|
|
225
|
+
const untitled = charts.filter((c) => !c.title).length;
|
|
226
|
+
if (untitled) suggestions.push({ code: 'dash-chart-titles', message: L(`${untitled} gráfico(s) sin título.`, `${untitled} chart(s) without a title.`), fix: L('Cada panel necesita su hallazgo como título.', 'Every panel needs its finding as the title.') });
|
|
227
|
+
for (const f of [].concat(spec.filters || [])) {
|
|
228
|
+
const field = typeof f === 'string' ? f : f.field;
|
|
229
|
+
const has = [spec.data, ...charts.map((c) => c.data)].some((d) => Array.isArray(d) && d.some((r) => r && field in r));
|
|
230
|
+
if (!has) warnings.push({ code: 'dash-filter-field', message: L(`El filtro "${field}" no existe en los datos de ningún gráfico.`, `The filter "${field}" is not in any chart's data.`), fix: L('Usa un campo de `data`.', 'Use a field of `data`.') });
|
|
231
|
+
}
|
|
232
|
+
// Revisión de cada gráfico, con su número y título.
|
|
233
|
+
charts.forEach((c, i) => {
|
|
234
|
+
const r = core ? core.lint({ locale, ...c, ...(c.data === undefined && spec.data ? { data: spec.data } : {}), source: c.source ?? spec.source ?? 'dashboard' }) : null;
|
|
235
|
+
if (!r) return;
|
|
236
|
+
const tag = `${L('Gráfico', 'Chart')} ${i + 1}${c.title ? ` («${String(c.title).slice(0, 40)}»)` : ''}: `;
|
|
237
|
+
for (const [list, out] of [[r.errors, errors], [r.warnings, warnings], [r.suggestions, suggestions]]) for (const it of list) if (it.code !== 'title') out.push({ ...it, chart: i, message: tag + it.message });
|
|
238
|
+
});
|
|
239
|
+
return { ok: errors.length === 0, errors, warnings, suggestions };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Marcado (HTML) ─────────────────────────────────────────────────────────
|
|
243
|
+
export const DASH_CSS = `
|
|
244
|
+
.pa-dash{--g:24px;max-width:1440px;margin:0 auto;padding:clamp(16px,3vw,40px);color:var(--d-ink);background:var(--d-surface);font-family:var(--d-font);font-variant-numeric:lining-nums tabular-nums}
|
|
245
|
+
.pa-dash *{box-sizing:border-box}
|
|
246
|
+
.pa-dash header.pa-dh{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:12px 32px;padding-bottom:16px;border-bottom:1px solid var(--d-ink)}
|
|
247
|
+
.pa-dash h1{font-size:clamp(26px,3.2vw,40px);line-height:1.08;letter-spacing:-.03em;font-weight:600;margin:0}
|
|
248
|
+
.pa-dash .pa-dsub{color:var(--d-lede);font-size:15px;margin:6px 0 0;max-width:70ch}
|
|
249
|
+
.pa-dash .pa-dmeta{font-size:12.5px;color:var(--d-muted);text-align:right}
|
|
250
|
+
.pa-dfilters{display:flex;flex-wrap:wrap;gap:8px 28px;align-items:center;padding:10px 0;border-bottom:1px solid var(--d-grid);position:sticky;top:0;z-index:5;background:var(--d-surface)}
|
|
251
|
+
.pa-dfilter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:13px}
|
|
252
|
+
.pa-dfilter>span{color:var(--d-muted);margin-right:4px}
|
|
253
|
+
.pa-dfilter button{font:inherit;font-size:13px;color:var(--d-muted);background:none;border:0;padding:5px 6px;cursor:pointer;border-radius:0;box-shadow:inset 0 -1px 0 transparent}
|
|
254
|
+
.pa-dfilter button:hover{color:var(--d-ink)}
|
|
255
|
+
.pa-dfilter button[aria-pressed=true]{color:var(--d-ink);font-weight:600;box-shadow:inset 0 -2px 0 var(--d-accent)}
|
|
256
|
+
.pa-dfilter select{font:inherit;font-size:13px;color:var(--d-ink);background:transparent;border:0;border-bottom:1px solid var(--d-axis);padding:4px 2px}
|
|
257
|
+
.pa-dfilter button:focus-visible,.pa-dfilter select:focus-visible{outline:2px solid var(--d-accent);outline-offset:2px}
|
|
258
|
+
.pa-kpis{display:grid;grid-template-columns:repeat(var(--n,4),minmax(0,1fr));border-bottom:1px solid var(--d-grid)}
|
|
259
|
+
.pa-kpi{padding:18px 20px 18px 0;min-width:0}
|
|
260
|
+
.pa-kpi+.pa-kpi{padding-left:20px;border-left:1px solid var(--d-grid)}
|
|
261
|
+
.pa-kpi .l{font-size:13px;color:var(--d-lede);margin:0}
|
|
262
|
+
.pa-kpi .v{font-size:clamp(26px,2.6vw,36px);font-weight:600;letter-spacing:-.02em;line-height:1.15;margin:4px 0 2px;white-space:nowrap}
|
|
263
|
+
.pa-kpi .d{font-size:12.5px;font-weight:600}
|
|
264
|
+
.pa-kpi .d small{font-weight:400;color:var(--d-muted);margin-left:6px}
|
|
265
|
+
.pa-kpi svg{display:block;width:100%;height:28px;margin-top:6px;overflow:visible}
|
|
266
|
+
.pa-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:0 var(--g)}
|
|
267
|
+
.pa-panel{grid-column:span var(--s);min-width:0;padding-top:20px;margin-top:4px;border-top:1px solid var(--d-grid)}
|
|
268
|
+
.pa-panel figure{margin:0}
|
|
269
|
+
.pa-panel figure svg{width:100%;height:auto;display:block}
|
|
270
|
+
.pa-panel .pa-err{font-size:13px;color:var(--d-bad);padding:12px 0}
|
|
271
|
+
.pa-dfoot{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;font-size:12.5px;color:var(--d-muted);margin-top:24px;padding-top:12px;border-top:1px solid var(--d-ink)}
|
|
272
|
+
@media (max-width:1000px){.pa-panel{grid-column:span max(6,var(--s))}}
|
|
273
|
+
@media (max-width:1000px){.pa-kpis[data-n='4'],.pa-kpis[data-n='5']{grid-template-columns:repeat(2,minmax(0,1fr))}.pa-kpis[data-n='4'] .pa-kpi:nth-child(n+3),.pa-kpis[data-n='5'] .pa-kpi:nth-child(n+3){border-top:1px solid var(--d-grid)}.pa-kpis[data-n='4'] .pa-kpi:nth-child(2n+1),.pa-kpis[data-n='5'] .pa-kpi:nth-child(2n+1){padding-left:0;border-left:0}}
|
|
274
|
+
@media (max-width:700px){.pa-grid{grid-template-columns:minmax(0,1fr)}.pa-panel{grid-column:auto}.pa-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.pa-kpi,.pa-kpi+.pa-kpi{padding:14px 12px 14px 0;border-left:0}.pa-kpi:nth-child(2n){padding-left:12px;border-left:1px solid var(--d-grid)}.pa-kpi:nth-child(n+3){border-top:1px solid var(--d-grid)}.pa-kpi .v{font-size:26px}}
|
|
275
|
+
@media print{.pa-dfilters{position:static}.pa-panel{break-inside:avoid}}
|
|
276
|
+
`;
|
|
277
|
+
|
|
278
|
+
const cssVars = (t) => `--d-surface:${t.surface};--d-ink:${t.ink};--d-lede:${t.lede};--d-muted:${t.muted};--d-grid:${t.grid};--d-axis:${t.axis};--d-tint:${t.tint};--d-accent:${t.accent};--d-good:${t.good};--d-bad:${t.bad};--d-font:${(t.font || 'system-ui').replace(/"/g, "'")}`;
|
|
279
|
+
|
|
280
|
+
/** KPIs en HTML: etiqueta, cifra, variación con flecha y texto, tendencia. */
|
|
281
|
+
export function kpiMarkup(plan, rows) {
|
|
282
|
+
if (!plan.kpis.length) return '';
|
|
283
|
+
const { locale } = plan;
|
|
284
|
+
const cells = plan.kpis.map((k) => {
|
|
285
|
+
// `compare: { field, current, previous }`: el valor del periodo actual y su variación frente al anterior.
|
|
286
|
+
const cmp = k.compare && k.value === undefined ? k.compare : null;
|
|
287
|
+
const inPeriod = (p) => (rows || []).filter((r) => String(r[cmp.field]) === String(p));
|
|
288
|
+
const v = cmp ? computeKpi(k, inPeriod(cmp.current)) : computeKpi(k, rows || []);
|
|
289
|
+
if (cmp && k.delta === undefined) { const pv = computeKpi(k, inPeriod(cmp.previous)); k = { ...k, delta: Number.isFinite(pv) && pv ? (v - pv) / Math.abs(pv) : undefined, period: k.period ?? `vs ${cmp.previous}` }; }
|
|
290
|
+
// `trendBy: "mes"`: minigráfico calculado (mismo agregado por periodo).
|
|
291
|
+
if (k.trendBy && !k.trend && rows && rows.length) {
|
|
292
|
+
const by = new Map();
|
|
293
|
+
for (const r of cmp ? inPeriod(cmp.current) : rows) { const key = r[k.trendBy]; if (!by.has(key)) by.set(key, []); by.get(key).push(r); }
|
|
294
|
+
const ks = [...by.keys()].sort((x, y) => (String(x) < String(y) ? -1 : 1));
|
|
295
|
+
k = { ...k, trend: ks.map((x) => computeKpi(k, by.get(x))) };
|
|
296
|
+
}
|
|
297
|
+
const fmt = makeFormat(k.format, locale, [v]);
|
|
298
|
+
const shown = k.display ?? (Number.isFinite(v) ? fmt(v) : '—');
|
|
299
|
+
let d = '';
|
|
300
|
+
let delta = k.delta !== undefined ? num(k.delta) : k.previous !== undefined && num(k.previous) ? (v - num(k.previous)) / Math.abs(num(k.previous)) : NaN;
|
|
301
|
+
if (Number.isFinite(delta)) {
|
|
302
|
+
if (Math.abs(delta) > 1.5 && k.delta !== undefined && !cmp) delta /= 100;
|
|
303
|
+
// Una variación que se redondea a 0% se muestra como estable (sin flecha verde engañosa).
|
|
304
|
+
const flat = Math.abs(delta) < (Math.abs(delta) < 0.1 ? 0.0005 : 0.005), up = delta > 0 && !flat, good = flat ? null : up === ((k.good ?? 'up') === 'up');
|
|
305
|
+
const c = good === null ? 'var(--d-lede)' : good ? 'var(--d-good)' : 'var(--d-bad)';
|
|
306
|
+
d = `<p class="d" style="color:${c};margin:0">${flat ? '■' : up ? '▲' : '▼'} ${up ? '+' : flat ? '' : '−'}${formatNumber(Math.abs(delta) * 100, Math.abs(delta) < 0.1 ? 1 : 0, locale, true)}%${k.period ? `<small>${esc(k.period)}</small>` : ''}</p>`;
|
|
307
|
+
} else if (k.period) d = `<p class="d" style="margin:0"><small style="margin:0">${esc(k.period)}</small></p>`;
|
|
308
|
+
let spark = '';
|
|
309
|
+
if (Array.isArray(k.trend) && k.trend.length > 1) {
|
|
310
|
+
const ts = k.trend.map(num), mn = Math.min(...ts), mx = Math.max(...ts);
|
|
311
|
+
const pts = ts.map((y, i) => `${((i / (ts.length - 1)) * 100).toFixed(2)},${(26 - ((y - mn) / (mx - mn || 1)) * 24).toFixed(2)}`).join(' ');
|
|
312
|
+
spark = `<svg viewBox="0 0 100 28" preserveAspectRatio="none" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="var(--d-accent)" stroke-width="1.6" vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>`;
|
|
313
|
+
}
|
|
314
|
+
return `<div class="pa-kpi"><p class="l">${esc(k.label ?? '')}</p><p class="v">${esc(shown)}</p>${d}${spark}</div>`;
|
|
315
|
+
});
|
|
316
|
+
return `<section class="pa-kpis" data-n="${cells.length}" style="--n:${cells.length}" aria-label="${T(locale, 'Indicadores', 'Key figures')}">${cells.join('')}</section>`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** HTML del dashboard. `svgs` (opcional) trae los gráficos ya dibujados (servidor). */
|
|
320
|
+
export function dashboardMarkup(plan, svgs = {}) {
|
|
321
|
+
const { locale } = plan;
|
|
322
|
+
const filters = plan.filters.map((f, i) => {
|
|
323
|
+
const opts = f.control === 'buttons'
|
|
324
|
+
? [`<button type="button" data-f="${i}" data-v="" aria-pressed="${f.value === null}">${esc(f.all)}</button>`, ...f.values.map((v) => `<button type="button" data-f="${i}" data-v="${esc(v)}" aria-pressed="${String(f.value) === v}">${esc(v)}</button>`)].join('')
|
|
325
|
+
: `<select data-f="${i}" aria-label="${esc(f.label)}"><option value="">${esc(f.all)}</option>${f.values.map((v) => `<option${String(f.value) === v ? ' selected' : ''}>${esc(v)}</option>`).join('')}</select>`;
|
|
326
|
+
return `<div class="pa-dfilter" role="group" aria-label="${esc(f.label)}"><span>${esc(f.label)}</span>${opts}</div>`;
|
|
327
|
+
}).join('');
|
|
328
|
+
const panels = plan.panels.map((p) => `<section class="pa-panel" style="--s:${p.span}" aria-label="${esc(p.spec.title || p.spec.type)}">${p.error ? `<p class="pa-err">${esc(p.error)}</p>` : `<figure class="pa" data-panel="${p.id}">${svgs[p.id] || ''}</figure>`}</section>`).join('');
|
|
329
|
+
const meta = [plan.updated ? `${T(locale, 'Actualizado', 'Updated')}: ${esc(plan.updated)}` : '', `${plan.panels.length} ${T(locale, plan.panels.length === 1 ? 'gráfico' : 'gráficos', plan.panels.length === 1 ? 'chart' : 'charts')}`].filter(Boolean).join(' · ');
|
|
330
|
+
return `<div class="pa-dash" style="${cssVars(plan.theme)}">
|
|
331
|
+
<header class="pa-dh"><div>${plan.title ? `<h1>${esc(plan.title)}</h1>` : ''}${plan.subtitle ? `<p class="pa-dsub">${esc(plan.subtitle)}</p>` : ''}</div><div class="pa-dmeta">${meta}</div></header>
|
|
332
|
+
${filters ? `<nav class="pa-dfilters" aria-label="${T(locale, 'Filtros', 'Filters')}">${filters}</nav>` : ''}
|
|
333
|
+
<div class="pa-kslot">${kpiMarkup(plan, plan.data)}</div>
|
|
334
|
+
<div class="pa-grid">${panels}</div>
|
|
335
|
+
<footer class="pa-dfoot"><span>${plan.source ? `${T(locale, 'Fuente', 'Source')}: ${esc(plan.source)}` : ''}${plan.note ? ` · ${esc(plan.note)}` : ''}</span><span>plotagents</span></footer>
|
|
336
|
+
</div>`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Aplica los filtros activos a un panel (datos, o el periodo si es su campo `time`). */
|
|
340
|
+
export function filterSpec(spec, plan, active, locale) {
|
|
341
|
+
let s = spec;
|
|
342
|
+
const tags = [];
|
|
343
|
+
plan.filters.forEach((f, i) => {
|
|
344
|
+
const v = active[i];
|
|
345
|
+
if (v === null || v === undefined || v === '') return;
|
|
346
|
+
if (s.time === f.field) { s = { ...s, timeValue: v }; return; }
|
|
347
|
+
if (Array.isArray(s.data) && s.data.some((r) => r && f.field in r)) { s = { ...s, data: s.data.filter((r) => String(r[f.field]) === String(v)) }; tags.push(`${f.label}: ${v}`); }
|
|
348
|
+
});
|
|
349
|
+
if (tags.length) s = { ...s, subtitle: [s.subtitle, tags.join(' · ')].filter(Boolean).join(' · ') };
|
|
350
|
+
return s;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Navegador: interacción (filtros) ───────────────────────────────────────
|
|
354
|
+
/** Conecta filtros y gráficos de un dashboard ya en el DOM (lo hace dashboard() en su página). */
|
|
355
|
+
export function mountDashboard(root, plan) {
|
|
356
|
+
const handles = {};
|
|
357
|
+
const active = plan.filters.map((f) => f.value);
|
|
358
|
+
const draw = () => {
|
|
359
|
+
for (const p of plan.panels) {
|
|
360
|
+
if (p.error) continue;
|
|
361
|
+
const fig = root.querySelector(`[data-panel="${p.id}"]`);
|
|
362
|
+
if (!fig) continue;
|
|
363
|
+
const s = filterSpec(p.spec, plan, active, plan.locale);
|
|
364
|
+
try {
|
|
365
|
+
if (handles[p.id]) handles[p.id] = handles[p.id].update(s);
|
|
366
|
+
else handles[p.id] = render(fig, s);
|
|
367
|
+
} catch (e) { fig.innerHTML = `<p class="pa-err">${esc(e.message)}</p>`; }
|
|
368
|
+
}
|
|
369
|
+
// KPIs calculados: se recalculan con los filtros.
|
|
370
|
+
if (plan.data && plan.kpis.some((k) => k.value === undefined)) {
|
|
371
|
+
const rows = plan.data.filter((r) => plan.filters.every((f, i) => active[i] === null || active[i] === '' || !(f.field in r) || String(r[f.field]) === String(active[i])));
|
|
372
|
+
const slot = root.querySelector('.pa-kslot');
|
|
373
|
+
if (slot) slot.innerHTML = kpiMarkup(plan, rows);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
root.querySelectorAll('.pa-dfilter button').forEach((b) => b.addEventListener('click', () => {
|
|
377
|
+
const i = +b.dataset.f;
|
|
378
|
+
active[i] = b.dataset.v || null;
|
|
379
|
+
root.querySelectorAll(`.pa-dfilter button[data-f="${i}"]`).forEach((o) => o.setAttribute('aria-pressed', String(o === b)));
|
|
380
|
+
draw();
|
|
381
|
+
}));
|
|
382
|
+
root.querySelectorAll('.pa-dfilter select').forEach((sel) => sel.addEventListener('change', () => { active[+sel.dataset.f] = sel.value || null; draw(); }));
|
|
383
|
+
draw();
|
|
384
|
+
return { plan, update: draw, destroy() { Object.values(handles).forEach((h) => h && h.destroy && h.destroy()); } };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Navegador: dibuja un dashboard dentro de un elemento. */
|
|
388
|
+
export function renderDashboard(target, spec) {
|
|
389
|
+
const host = typeof target === 'string' ? document.querySelector(target) : target;
|
|
390
|
+
if (!host) throw new PlotError('No encontré el elemento destino.', 'renderDashboard("#tablero", spec)');
|
|
391
|
+
if (typeof document !== 'undefined' && !document.getElementById('pa-dash-css')) {
|
|
392
|
+
const st = document.createElement('style'); st.id = 'pa-dash-css'; st.textContent = DASH_CSS; document.head.appendChild(st);
|
|
393
|
+
}
|
|
394
|
+
const plan = planDashboard(spec);
|
|
395
|
+
host.innerHTML = dashboardMarkup(plan);
|
|
396
|
+
return mountDashboard(host.firstElementChild, plan);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Servidor: página HTML autónoma ─────────────────────────────────────────
|
|
400
|
+
/**
|
|
401
|
+
* Página HTML de un archivo con el dashboard: gráficos ya dibujados (se ven
|
|
402
|
+
* sin JavaScript, sirven para PDF) y, si está la biblioteca para el
|
|
403
|
+
* navegador, tooltips y filtros.
|
|
404
|
+
*/
|
|
405
|
+
export function dashboard(spec) {
|
|
406
|
+
const plan = planDashboard(spec);
|
|
407
|
+
const svgs = {};
|
|
408
|
+
for (const p of plan.panels) if (!p.error) {
|
|
409
|
+
try { svgs[p.id] = core.plot({ ...p.spec, width: p.span >= 12 ? 1200 : p.span >= 8 ? 800 : p.span >= 6 ? 600 : 400 }); }
|
|
410
|
+
catch (e) { p.error = e.message.split('\n')[0]; }
|
|
411
|
+
}
|
|
412
|
+
const needsGeo = plan.panels.some((p) => ['choropleth', 'symbolmap'].includes(p.spec.type) && typeof p.spec.geo !== 'object');
|
|
413
|
+
const lib = core.bundleSource && core.bundleSource();
|
|
414
|
+
const bundle = lib && (needsGeo ? lib + '\n' + (core.bundleSource('plotagents-geo.js') || '') : lib);
|
|
415
|
+
const json = JSON.stringify(plan).replace(/</g, '\\u003c');
|
|
416
|
+
const lang = String(plan.locale).slice(0, 2);
|
|
417
|
+
const fonts = [...new Set([plan.theme.fontImport, 'https://fonts.googleapis.com/css2?family=Mozilla+Text:wght@400;500;600;700&display=swap'].filter(Boolean))];
|
|
418
|
+
return `<!doctype html>
|
|
419
|
+
<html lang="${esc(lang)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
420
|
+
<title>${esc(plan.title || 'Dashboard')}</title>
|
|
421
|
+
${fonts.map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join('\n')}
|
|
422
|
+
<style>:root{color-scheme:${plan.theme.mode === 'dark' ? 'dark' : 'light'}}body{margin:0;background:${plan.theme.surface}}${DASH_CSS}</style>
|
|
423
|
+
</head><body>
|
|
424
|
+
${dashboardMarkup(plan, svgs)}
|
|
425
|
+
${bundle ? `<script>${bundle.replace(/<\/(script)/gi, '<\\/$1')}</script>
|
|
426
|
+
<script type="application/json" id="pa-dash-plan">${json}</script>
|
|
427
|
+
<script>plotagents.mountDashboard(document.querySelector('.pa-dash'),JSON.parse(document.getElementById('pa-dash-plan').textContent));</script>` : ''}
|
|
428
|
+
</body></html>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Nunca lanza: { ok, html, plan, errors, warnings, suggestions } (para agentes). */
|
|
432
|
+
export function safeDashboard(spec) {
|
|
433
|
+
const rep = lintDashboard(spec || {});
|
|
434
|
+
if (!rep.ok) return { ...rep, html: null, plan: null };
|
|
435
|
+
try { const html = dashboard(spec); return { ...rep, ok: true, html, plan: planDashboard(spec) }; }
|
|
436
|
+
catch (e) { return { ...rep, ok: false, html: null, plan: null, errors: [...rep.errors, { code: 'render', message: e.message.split('\n')[0], fix: e.hint }] }; }
|
|
437
|
+
}
|
|
438
|
+
|
package/src/dashed.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Formas "punteadas" (`dashed`): para marcar lo que no es un dato firme —
|
|
2
|
+
// estimaciones, proyecciones, cifras provisorias, territorios en disputa—
|
|
3
|
+
// sin inventar otro color. La forma conserva su tono, pero con un relleno
|
|
4
|
+
// rayado (franjas del color sobre una versión clara del mismo) y un contorno
|
|
5
|
+
// discontinuo. En líneas, el trazo se vuelve discontinuo (`dashedFrom` corta
|
|
6
|
+
// una serie en el punto donde empieza la proyección).
|
|
7
|
+
//
|
|
8
|
+
// dashed: "Texas" → una categoría, serie o región
|
|
9
|
+
// dashed: ["Texas", "Ohio"] → varias
|
|
10
|
+
// dashed: "provisional" → un campo de los datos (filas con valor verdadero)
|
|
11
|
+
// dashed: { field: "estado", equals: "estimado" }
|
|
12
|
+
// dashedFrom: "2025" → desde ese x en adelante (líneas, áreas y columnas)
|
|
13
|
+
// dashedLabel: "Estimación" → entrada de leyenda y línea en el tooltip
|
|
14
|
+
import { el } from './svg.js';
|
|
15
|
+
import { ramp, readable } from './theme.js';
|
|
16
|
+
|
|
17
|
+
/** Trazos discontinuos: contornos cortos y finos; líneas algo más largas. */
|
|
18
|
+
export const DASH = { outline: '4 2.5', line: '5 6', legend: '3.5 2.5' };
|
|
19
|
+
|
|
20
|
+
const TRUE = /^(true|1|s[ií]|yes|y|x|✓)$/i;
|
|
21
|
+
const truthy = (v) => v === true || v === 1 || (typeof v === 'string' && TRUE.test(v.trim()));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Devuelve una función (clave, serie?) → boolean, o null si no hay `dashed`.
|
|
25
|
+
* `keyField` y `seriesField` dicen qué campos identifican cada forma.
|
|
26
|
+
*/
|
|
27
|
+
export function dashedMatcher(spec, data, keyField, seriesField, norm = String) {
|
|
28
|
+
const d = spec.dashed;
|
|
29
|
+
if (d === undefined || d === null || d === false) return null;
|
|
30
|
+
if (d === true) return () => true;
|
|
31
|
+
const row0 = data && data[0];
|
|
32
|
+
const field = typeof d === 'object' && !Array.isArray(d) ? d.field : typeof d === 'string' && row0 && (data || []).some((r) => r && typeof r === 'object' && d in r) ? d : null;
|
|
33
|
+
if (field) {
|
|
34
|
+
const eq = typeof d === 'object' ? d.equals ?? d.value : undefined;
|
|
35
|
+
const pairs = new Set();
|
|
36
|
+
for (const r of data) {
|
|
37
|
+
const hit = eq !== undefined ? [].concat(eq).map(String).includes(String(r[field])) : truthy(r[field]);
|
|
38
|
+
if (hit) pairs.add(norm(r[keyField]) + '\u0000' + (seriesField ? norm(r[seriesField]) : ''));
|
|
39
|
+
}
|
|
40
|
+
return (k, s) => pairs.has(norm(k) + '\u0000' + (seriesField ? norm(s ?? '') : ''));
|
|
41
|
+
}
|
|
42
|
+
const keys = new Set([].concat(d).map(norm));
|
|
43
|
+
return (k, s) => keys.has(norm(k)) || (s !== undefined && s !== null && keys.has(norm(s)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** ¿El valor x está en el tramo proyectado (x ≥ dashedFrom)? */
|
|
47
|
+
export function fromTest(spec, toNum) {
|
|
48
|
+
if (spec.dashedFrom === undefined || spec.dashedFrom === null) return null;
|
|
49
|
+
const t = toNum(spec.dashedFrom);
|
|
50
|
+
return Number.isFinite(t) ? (x) => Number.isFinite(x) && x >= t : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isHex = (c) => /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(String(c || ''));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fábrica de rellenos rayados, uno por color. `fill(c)` da el url(#…) y
|
|
57
|
+
* `defs()` los <pattern> que hay que poner en el SVG.
|
|
58
|
+
*/
|
|
59
|
+
export function hatcher(theme, id) {
|
|
60
|
+
const pats = new Map();
|
|
61
|
+
return {
|
|
62
|
+
fill(color) {
|
|
63
|
+
if (!pats.has(color)) pats.set(color, `${id}-h${pats.size}`);
|
|
64
|
+
return `url(#${pats.get(color)})`;
|
|
65
|
+
},
|
|
66
|
+
/** Contorno: el mismo tono, con al menos 3:1 contra el fondo (WCAG, gráficos). */
|
|
67
|
+
stroke(color) { return isHex(color) ? readable(color, theme, 3) : color; },
|
|
68
|
+
defs() {
|
|
69
|
+
return [...pats].map(([c, pid]) => pattern(pid, c, theme)).join('');
|
|
70
|
+
},
|
|
71
|
+
get size() { return pats.size; },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pattern(pid, color, theme) {
|
|
76
|
+
const bg = isHex(color) && isHex(theme.surface) ? ramp([theme.surface, color], theme.mode === 'dark' ? 0.3 : 0.22) : null;
|
|
77
|
+
return el('pattern', { id: pid, width: 6, height: 6, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, [
|
|
78
|
+
bg ? el('rect', { width: 6, height: 6, fill: bg }) : el('rect', { width: 6, height: 6, fill: color, fillOpacity: 0.22 }),
|
|
79
|
+
el('rect', { width: 2.6, height: 6, fill: color }),
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Atributos de una forma punteada: relleno rayado + contorno discontinuo. */
|
|
84
|
+
export function dashedAttrs(H, color, { width = 1.25 } = {}) {
|
|
85
|
+
return { fill: H.fill(color), stroke: H.stroke(color), strokeWidth: width, strokeDasharray: DASH.outline, 'data-dashed': '1', 'data-color': color };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
89
|
+
/** Texto por defecto para la leyenda cuando el agente no da `dashedLabel`. */
|
|
90
|
+
export const dashedDefaultLabel = (locale) => T(locale, 'Estimado', 'Estimate');
|