plotagents 0.5.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 +29 -5
- package/README.md +44 -15
- package/bin/plotagents.js +40 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +6161 -757
- package/docs/api.md +23 -5
- package/docs/charts.md +229 -10
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- 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/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +275 -20
- package/package.json +11 -4
- package/schema.json +1382 -53
- package/skills/plotagents/SKILL.md +105 -0
- package/src/axes.js +11 -3
- package/src/charts/bar.js +40 -21
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +6 -4
- package/src/charts/choropleth.js +287 -37
- package/src/charts/corr.js +512 -0
- package/src/charts/dist.js +39 -21
- package/src/charts/donut.js +4 -2
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +11 -8
- package/src/charts/line.js +187 -43
- package/src/charts/network.js +557 -189
- package/src/charts/pointmap.js +602 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +16 -4
- package/src/charts/sankey.js +42 -6
- package/src/charts/scatter.js +103 -23
- package/src/charts/sunburst.js +7 -6
- package/src/charts/table.js +882 -0
- package/src/charts/treemap.js +12 -9
- package/src/charts/wordcloud.js +177 -0
- package/src/colorscales.js +193 -0
- package/src/dashboard.js +451 -0
- package/src/dashed.js +1 -1
- package/src/data.js +4 -2
- package/src/facet.js +4 -3
- package/src/format.js +80 -25
- package/src/frame.js +101 -29
- package/src/geo/projection.js +76 -9
- package/src/geo/registry.js +87 -8
- package/src/hierarchy.js +6 -0
- package/src/i18n.js +107 -0
- package/src/index.js +92 -14
- package/src/infer.js +18 -1
- package/src/interact.js +157 -36
- package/src/lint.js +353 -11
- package/src/runtime.js +301 -101
- package/src/scale.js +82 -11
- package/src/schema.js +235 -36
- package/src/stats.js +76 -17
- package/src/svg.js +4 -1
- package/src/text.js +5 -0
- package/src/theme.js +101 -14
package/src/format.js
CHANGED
|
@@ -4,19 +4,29 @@
|
|
|
4
4
|
const SEP = {
|
|
5
5
|
es: { group: '.', dec: ',' },
|
|
6
6
|
en: { group: ',', dec: '.' },
|
|
7
|
+
pt: { group: '.', dec: ',' },
|
|
8
|
+
de: { group: '.', dec: ',' },
|
|
9
|
+
fr: { group: '\u202F', dec: ',' }, // espacio fino: 1 234,5
|
|
7
10
|
};
|
|
8
11
|
|
|
9
12
|
const COMPACT = {
|
|
10
13
|
es: [[1e12, ' B'], [1e6, ' M'], [1e3, ' mil']],
|
|
11
14
|
en: [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
|
|
15
|
+
pt: [[1e12, ' tri'], [1e9, ' bi'], [1e6, ' mi'], [1e3, ' mil']],
|
|
16
|
+
fr: [[1e12, ' Bn'], [1e9, ' Md'], [1e6, ' M'], [1e3, ' k']],
|
|
17
|
+
de: [[1e12, ' Bio.'], [1e9, ' Mrd.'], [1e6, ' Mio.'], [1e3, ' Tsd.']],
|
|
12
18
|
};
|
|
13
19
|
|
|
14
20
|
export const MONTHS = {
|
|
15
21
|
es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
|
|
16
22
|
en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
23
|
+
pt: ['jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez'],
|
|
24
|
+
fr: ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'],
|
|
25
|
+
de: ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'],
|
|
17
26
|
};
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
/** Idioma de formato: es, en, pt, fr o de ("pt-BR" → pt); cualquier otro, es. */
|
|
29
|
+
const loc = (l) => { const k = String(l || 'es').toLowerCase().slice(0, 2); return k in SEP ? k : 'es'; };
|
|
20
30
|
|
|
21
31
|
/** Número con separadores. `decimals` fijo; `trim` quita ceros finales. */
|
|
22
32
|
export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
@@ -24,7 +34,8 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
|
24
34
|
if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
|
|
25
35
|
const { group, dec } = SEP[loc(locale)];
|
|
26
36
|
const neg = v < 0;
|
|
27
|
-
|
|
37
|
+
const fixed = Math.abs(v) >= 1e21 ? BigInt(Math.abs(v)).toString() : Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))); // toFixed pasa a "1e+21"
|
|
38
|
+
let [int, frac = ''] = fixed.split('.');
|
|
28
39
|
int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
|
|
29
40
|
if (trim) frac = frac.replace(/0+$/, '');
|
|
30
41
|
const out = frac ? int + dec + frac : int;
|
|
@@ -34,19 +45,26 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
|
34
45
|
/** Decimales "inteligentes" según la magnitud. */
|
|
35
46
|
export function smartDecimals(v) {
|
|
36
47
|
const a = Math.abs(v);
|
|
37
|
-
if (a === 0 || a >= 100) return 0;
|
|
48
|
+
if (a === 0 || !Number.isFinite(a) || a >= 100) return 0;
|
|
38
49
|
if (a >= 10) return 1;
|
|
39
50
|
if (a >= 1) return 2;
|
|
40
|
-
|
|
51
|
+
// Bajo 1: tres cifras significativas (0,123 · 0,00012), nunca "0".
|
|
52
|
+
return Math.min(20, 2 - Math.floor(Math.log10(a)));
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
export function formatCompact(v, locale = 'es', decimals) {
|
|
44
56
|
const l = loc(locale);
|
|
45
57
|
const a = Math.abs(v);
|
|
46
|
-
|
|
58
|
+
const tbl = COMPACT[l];
|
|
59
|
+
for (let i = 0; i < tbl.length; i++) {
|
|
60
|
+
let [n, suf] = tbl[i];
|
|
47
61
|
if (a >= n) {
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
let s = v / n;
|
|
63
|
+
let d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
|
|
64
|
+
// 999.999 → "1.000 mil" no: al redondear llega a 1000, sube a la unidad siguiente (1 M).
|
|
65
|
+
if (i > 0 && tbl[i - 1][0] / n === 1000 && Math.abs(+Math.abs(s).toFixed(d)) >= 1000) {
|
|
66
|
+
[n, suf] = tbl[i - 1]; s = v / n; d = decimals ?? (Math.abs(s) < 10 ? 1 : 0);
|
|
67
|
+
}
|
|
50
68
|
return formatNumber(s, d, l, decimals === undefined) + suf;
|
|
51
69
|
}
|
|
52
70
|
}
|
|
@@ -102,24 +120,26 @@ export function makeFormat(fmt, locale = 'es', values = []) {
|
|
|
102
120
|
return pre + s + suf;
|
|
103
121
|
};
|
|
104
122
|
f.type = type;
|
|
105
|
-
|
|
123
|
+
// `ticks` (opcional): las marcas del eje, o la mayor |marca|. Fija una sola
|
|
124
|
+
// unidad compacta para todo el eje (sin "500 M" junto a "1.000,0 M").
|
|
125
|
+
f.tick = (step, ticks) => tickFormat(step, o, type, l, pre, suf, max, d, ticks);
|
|
106
126
|
return f;
|
|
107
127
|
}
|
|
108
128
|
|
|
109
|
-
function currencyPrefix(cur, l) {
|
|
129
|
+
export function currencyPrefix(cur, l) {
|
|
110
130
|
if (!cur) return '$';
|
|
111
131
|
const c = String(cur).toUpperCase();
|
|
112
132
|
return { USD: 'US$', CLP: '$', EUR: '€', UF: 'UF ', GBP: '£', PEN: 'S/ ', MXN: 'MX$', BRL: 'R$' }[c] || c + ' ';
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
/** Formateador de marcas de eje: decimales según el paso, nunca ruido. */
|
|
116
|
-
function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
|
|
136
|
+
function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
|
|
117
137
|
// Decimales mínimos para escribir el paso exacto (2,5 → 1; 0,25 → 2).
|
|
118
138
|
const decFor = (s) => {
|
|
119
139
|
const a = Math.abs(s) || 1;
|
|
120
140
|
let d = Math.max(0, -Math.floor(Math.log10(a) + 1e-9));
|
|
121
|
-
while (d <
|
|
122
|
-
return Math.min(
|
|
141
|
+
while (d < 12 && Math.abs(Math.round(a * 10 ** d) - a * 10 ** d) > 1e-6 * 10 ** d) d++;
|
|
142
|
+
return Math.min(12, d);
|
|
123
143
|
};
|
|
124
144
|
let core;
|
|
125
145
|
if (type === 'year') core = (v) => String(Math.round(v));
|
|
@@ -127,15 +147,23 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
|
|
|
127
147
|
const scale = o.scale ?? (max > 1.5 ? 1 : 100);
|
|
128
148
|
core = (v) => formatNumber(v * scale, decFor(step * scale), l) + '%';
|
|
129
149
|
} else if (type === 'compact' || max >= 1e4 && type !== 'integer') {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
150
|
+
// Una sola unidad por eje, la de la mayor marca, entre las que tiene el idioma
|
|
151
|
+
// (en español no hay "mil millones": 1.000 M). Sin ceros finales.
|
|
152
|
+
let top;
|
|
153
|
+
if (Array.isArray(ticks)) top = ticks.reduce((m, t) => (Number.isFinite(t) ? Math.max(m, Math.abs(t)) : m), 0);
|
|
154
|
+
else if (Number.isFinite(ticks)) top = Math.abs(ticks);
|
|
155
|
+
else top = step > 0 ? Math.ceil(max / step - 1e-9) * step : max;
|
|
156
|
+
const [unit, sufx] = COMPACT[l].find(([n]) => top >= n) || [1, ''];
|
|
157
|
+
core = unit === 1
|
|
158
|
+
? (v) => formatNumber(v, decFor(step), l)
|
|
159
|
+
: (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
|
|
136
160
|
} else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
|
|
161
|
+
// Una marca entera se escribe sin ",0" (600, no 600,0); las fraccionarias
|
|
162
|
+
// conservan sus decimales (0 · 0,5 · 1 · 1,5).
|
|
163
|
+
const zeroFrac = new RegExp('\\' + SEP[l].dec + '0+(?!\\d)');
|
|
137
164
|
return (v) => {
|
|
138
|
-
|
|
165
|
+
if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
|
|
166
|
+
const s = core(v).replace(zeroFrac, '');
|
|
139
167
|
if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
|
|
140
168
|
return pre + s + suf;
|
|
141
169
|
};
|
|
@@ -144,28 +172,55 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
|
|
|
144
172
|
// ---------------------------------------------------------------------------
|
|
145
173
|
// Fechas
|
|
146
174
|
|
|
147
|
-
const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)
|
|
175
|
+
const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?)?/i;
|
|
148
176
|
|
|
149
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC).
|
|
179
|
+
* Sin zona horaria, la hora se toma tal cual (como UTC); con `Z` o `±hh:mm`
|
|
180
|
+
* se respeta el desfase, así datos con zonas distintas se ordenan bien.
|
|
181
|
+
*/
|
|
150
182
|
export function parseDate(v) {
|
|
151
183
|
if (v instanceof Date) return v.getTime();
|
|
152
184
|
if (typeof v === 'string') {
|
|
153
185
|
const m = ISO.exec(v.trim());
|
|
154
|
-
|
|
186
|
+
// Rangos válidos: "2020-21" (temporada) o "2024-13" no son fechas.
|
|
187
|
+
if (m && +m[2] >= 1 && +m[2] <= 12 && +(m[3] || 1) >= 1 && +(m[3] || 1) <= new Date(Date.UTC(+m[1], +m[2], 0)).getUTCDate() && +(m[4] || 0) < 24 && +(m[5] || 0) < 60 && +(m[6] || 0) < 60) {
|
|
188
|
+
const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
|
|
189
|
+
let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
|
|
190
|
+
const z = m[8];
|
|
191
|
+
if (z && z.toUpperCase() !== 'Z') {
|
|
192
|
+
const sign = z[0] === '-' ? -1 : 1;
|
|
193
|
+
const hh = +z.slice(1, 3), mm = +(z.slice(1).replace(':', '').slice(2, 4) || 0);
|
|
194
|
+
t -= sign * (hh * 60 + mm) * 6e4;
|
|
195
|
+
}
|
|
196
|
+
return t;
|
|
197
|
+
}
|
|
155
198
|
}
|
|
156
199
|
return NaN;
|
|
157
200
|
}
|
|
158
201
|
|
|
159
|
-
export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' &&
|
|
202
|
+
export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && Number.isFinite(parseDate(v)));
|
|
160
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
|
|
206
|
+
* En 'hour'/'minute', `withYear`: true → fecha completa + "HH:mm";
|
|
207
|
+
* 'day' → fecha sin año + hora; false → solo "HH:mm".
|
|
208
|
+
*/
|
|
161
209
|
export function formatDate(ms, unit, locale = 'es', withYear = true) {
|
|
162
210
|
const d = new Date(ms);
|
|
163
211
|
const l = loc(locale);
|
|
164
212
|
const y = d.getUTCFullYear();
|
|
165
213
|
const m = MONTHS[l][d.getUTCMonth()];
|
|
214
|
+
if (unit === 'hour' || unit === 'minute') {
|
|
215
|
+
const hm = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
|
|
216
|
+
if (withYear === false) return hm;
|
|
217
|
+
return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
|
|
218
|
+
}
|
|
166
219
|
if (unit === 'year') return String(y);
|
|
167
|
-
if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
|
|
220
|
+
if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' || l === 'de' ? 'Q' : 'T');
|
|
168
221
|
if (unit === 'month') return withYear ? `${m} ${y}` : m;
|
|
169
222
|
const day = d.getUTCDate();
|
|
170
|
-
|
|
223
|
+
if (l === 'en') return `${m} ${day}${withYear ? ', ' + y : ''}`;
|
|
224
|
+
if (l === 'de') return `${day}. ${m}${withYear ? ' ' + y : ''}`;
|
|
225
|
+
return `${day} ${m}${withYear ? ' ' + y : ''}`;
|
|
171
226
|
}
|
package/src/frame.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Marco común de cada gráfico: título (un hecho), bajada, leyenda, área de
|
|
2
2
|
// trazado y línea de fuente chica a la derecha. Nada de cajas ni filetes.
|
|
3
3
|
import { el, esc, g, text, r2 } from './svg.js';
|
|
4
|
-
import { measure, wrap } from './text.js';
|
|
4
|
+
import { measure, wrap, truncate } from './text.js';
|
|
5
5
|
import { FONT, FONT_CSS } from './theme.js';
|
|
6
6
|
import { ctx } from './context.js';
|
|
7
|
+
import { words } from './i18n.js';
|
|
7
8
|
|
|
8
9
|
export const SIZES = {
|
|
9
10
|
title: 22,
|
|
@@ -18,7 +19,11 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
18
19
|
const width = Math.max(240, +spec.width || defaults.width || 720);
|
|
19
20
|
// Con textos más grandes (textScale), el alto por defecto crece para no aplastar los datos.
|
|
20
21
|
const XS = ctx.textScale;
|
|
21
|
-
|
|
22
|
+
// En gráficos anchos el alto por defecto crece con el ancho (hasta ~2,8:1),
|
|
23
|
+
// para que una lámina de 1500 px no quede como una franja vacía.
|
|
24
|
+
const h0 = defaults.height || 420;
|
|
25
|
+
const hW = defaults.fixedHeight ? h0 : Math.max(h0, Math.min(Math.round(h0 * 1.35), Math.round(width * 0.36)));
|
|
26
|
+
let height = Math.max(160, +spec.height || Math.round(hW * (1 + 0.55 * (XS - 1))));
|
|
22
27
|
// Responsivo: en pantallas angostas, menos margen y títulos algo menores
|
|
23
28
|
// (nunca bajo 11 px en ejes). El ancho útil es lo que más importa en móvil.
|
|
24
29
|
const k = width < 420 ? 0.86 : width < 560 ? 0.93 : 1;
|
|
@@ -45,39 +50,67 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
45
50
|
if (subLines.length) y += subLines.length * slh + 2;
|
|
46
51
|
if (titleLines.length || subLines.length) y += 10;
|
|
47
52
|
|
|
48
|
-
const
|
|
53
|
+
const srcText = spec.source ? (/^(fuente|source|fonte|quelle)/i.test(spec.source) ? spec.source : words(spec.locale).source + spec.source) : '';
|
|
54
|
+
// Una fuente larga salta a una segunda línea en vez de salirse por la izquierda.
|
|
55
|
+
const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
|
|
56
|
+
const srcLH = Math.round(T.source * X * 1.35);
|
|
57
|
+
const sourceH = srcText ? Math.round(18 * Math.max(1, X * 0.95)) + (srcLines.length - 1) * srcLH : 0;
|
|
58
|
+
const legendPos = legendPosition(spec, width);
|
|
59
|
+
let legendBottom = null; // leyenda inferior: se dibuja en finish(), cuando el alto es definitivo
|
|
49
60
|
|
|
50
61
|
const frame = {
|
|
51
62
|
width,
|
|
52
63
|
pad,
|
|
53
64
|
theme,
|
|
65
|
+
legendPosition: legendPos,
|
|
54
66
|
get height() { return height; },
|
|
55
67
|
top: y,
|
|
56
|
-
get bottom() { return height - pad - sourceH; },
|
|
57
|
-
/** Alto extra fuera del área de trazado (pie + margen). */
|
|
58
|
-
chrome
|
|
59
|
-
/**
|
|
68
|
+
get bottom() { return height - pad - sourceH - (legendBottom ? legendBottom.h + 12 : 0); },
|
|
69
|
+
/** Alto extra fuera del área de trazado (pie + margen + leyenda inferior). */
|
|
70
|
+
get chrome() { return pad + sourceH + (legendBottom ? legendBottom.h + 12 : 0); },
|
|
71
|
+
/**
|
|
72
|
+
* Dibuja la leyenda y devuelve el área de trazado restante. La leyenda va
|
|
73
|
+
* arriba por defecto; `legend: "bottom" | "left" | "right"` la mueve.
|
|
74
|
+
*/
|
|
60
75
|
layout(legend = [], opts = {}) {
|
|
61
76
|
let ly = y;
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
const items = withExcluded(legend, spec, theme);
|
|
78
|
+
const extraBottom = Math.max(0, (XS - 1) * 22);
|
|
79
|
+
if (items.length && (legendPos === 'left' || legendPos === 'right')) {
|
|
80
|
+
const colW = Math.min(innerW * 0.32, Math.max(90, legendWidth(items, T.legend)));
|
|
81
|
+
const { svg } = drawLegend(items, legendPos === 'left' ? pad : width - pad - colW, ly + 2, colW, theme, { ...opts, size: T.legend, vertical: true });
|
|
82
|
+
header.push(svg);
|
|
83
|
+
const gap = 16;
|
|
84
|
+
return { x: legendPos === 'left' ? pad + colW + gap : pad, y: ly, w: innerW - colW - gap, h: Math.max(40, frame.bottom - ly - extraBottom) };
|
|
85
|
+
}
|
|
86
|
+
if (items.length && legendPos === 'bottom') {
|
|
87
|
+
const probe = drawLegend(items, pad, 0, innerW, theme, { ...opts, size: T.legend });
|
|
88
|
+
legendBottom = { items, opts, h: probe.h };
|
|
89
|
+
return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
|
|
90
|
+
}
|
|
91
|
+
if (items.length) {
|
|
92
|
+
const { svg, h } = drawLegend(items, pad, ly, innerW, theme, { ...opts, size: T.legend });
|
|
64
93
|
header.push(svg);
|
|
65
94
|
ly += h + 10; // la leyenda pegada al gráfico que explica
|
|
66
95
|
}
|
|
67
96
|
// Banda extra bajo el área para etiquetas y títulos de eje agrandados.
|
|
68
|
-
return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly -
|
|
97
|
+
return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
|
|
69
98
|
},
|
|
70
99
|
/** Permite a gráficos de alto automático (kpi) fijar el alto final. */
|
|
71
100
|
setHeight(h) { height = h; },
|
|
72
|
-
finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
|
|
73
|
-
if (
|
|
74
|
-
const
|
|
75
|
-
|
|
101
|
+
finish(body, { desc = '', type = 'chart', defs = '', role = 'img', css = '' } = {}) {
|
|
102
|
+
if (legendBottom) {
|
|
103
|
+
const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
|
|
104
|
+
header.push(svg);
|
|
76
105
|
}
|
|
77
|
-
|
|
106
|
+
srcLines.forEach((l, i) => parts.push(text(width - pad, height - pad - 2 - (srcLines.length - 1 - i) * srcLH, l, { fill: theme.muted, fontSize: T.source, textAnchor: 'end' })));
|
|
107
|
+
// Id estable pero distinto entre gráficos sin título del mismo tipo (aria-labelledby).
|
|
108
|
+
const id = 'pa' + Math.abs(hash((spec.title || '') + type + width + desc)).toString(36);
|
|
78
109
|
const fontImport = spec.fontImport ? `@import url('${theme.font?.import || FONT_CSS}');` : '';
|
|
79
|
-
const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}`
|
|
80
|
-
+ (
|
|
110
|
+
const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}@media (prefers-reduced-motion:reduce){.pa-m{transition:none}}.pa-chart:focus-visible{outline:2px solid ${theme.categorical[0]};outline-offset:2px}`
|
|
111
|
+
+ (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
|
|
112
|
+
+ (/^(choropleth|symbolmap|pointmap)$/.test(type) ? '.pa-zoom path,.pa-zoom circle,.pa-zoom rect{vector-effect:non-scaling-stroke}.pa-zoom-hide{transition:opacity .2s ease}.pa-zoomed .pa-zoom-hide{opacity:0}' : '')
|
|
113
|
+
+ css;
|
|
81
114
|
return el(
|
|
82
115
|
'svg',
|
|
83
116
|
{
|
|
@@ -85,13 +118,16 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
85
118
|
viewBox: `0 0 ${width} ${r2(height)}`,
|
|
86
119
|
width,
|
|
87
120
|
height: r2(height),
|
|
88
|
-
role
|
|
89
|
-
'aria-labelledby': `${id}-t ${id}-d`,
|
|
121
|
+
role,
|
|
122
|
+
'aria-labelledby': role === 'img' ? `${id}-t ${id}-d` : `${id}-t`,
|
|
123
|
+
'aria-describedby': role === 'img' ? null : `${id}-d`,
|
|
90
124
|
fontFamily: spec.fontFamily || theme.font?.family || FONT,
|
|
91
125
|
class: 'pa-chart',
|
|
92
126
|
'data-pa-type': type,
|
|
93
127
|
'data-pa-mode': theme.mode,
|
|
94
128
|
'data-pa-lang': String(spec.locale || 'es').slice(0, 2),
|
|
129
|
+
'data-pa-top': r2(frame.top),
|
|
130
|
+
'data-pa-tip': theme.tooltip && (theme.tooltip.bg || theme.tooltip.ink) ? `${theme.tooltip.bg || ''},${theme.tooltip.ink || ''}` : null,
|
|
95
131
|
style: 'max-width:100%;height:auto;font-variant-numeric:lining-nums',
|
|
96
132
|
},
|
|
97
133
|
[
|
|
@@ -110,40 +146,76 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
110
146
|
return frame;
|
|
111
147
|
}
|
|
112
148
|
|
|
149
|
+
/** Posición de la leyenda: `legend: "top" | "bottom" | "left" | "right"` (o { position }). */
|
|
150
|
+
export function legendPosition(spec, width = 720) {
|
|
151
|
+
const v = typeof spec.legend === 'string' ? spec.legend : spec.legend && typeof spec.legend === 'object' ? spec.legend.position : spec.legendPosition;
|
|
152
|
+
const p = String(v || 'top').toLowerCase();
|
|
153
|
+
const pos = { arriba: 'top', abajo: 'bottom', izquierda: 'left', derecha: 'right' }[p] || p;
|
|
154
|
+
if (!['top', 'bottom', 'left', 'right'].includes(pos)) return 'top';
|
|
155
|
+
// En pantallas angostas una columna lateral roba demasiado ancho: va abajo.
|
|
156
|
+
if ((pos === 'left' || pos === 'right') && width < 520) return 'bottom';
|
|
157
|
+
return pos;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Entradas excluidas con shift + clic (spec.exclude): siguen en la leyenda, apagadas, para volver a sumarlas. */
|
|
161
|
+
function withExcluded(items, spec, theme) {
|
|
162
|
+
const ex = spec._excluded;
|
|
163
|
+
if (!ex || !ex.length || !items.length) return items;
|
|
164
|
+
const have = new Set(items.map((it) => String(it.value ?? it.label)));
|
|
165
|
+
const extra = ex.filter((v) => !have.has(String(v))).map((v) => ({ label: String(v), value: v, color: (spec.colors && spec.colors[v]) || theme.dim, shape: items[0]?.shape === 'line' ? 'line' : items[0]?.shape || 'square', off: true }));
|
|
166
|
+
return [...items, ...extra];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function legendWidth(items, size) {
|
|
170
|
+
const X = ctx.textScale;
|
|
171
|
+
return Math.max(...items.map((it) => (it.type === 'ramp' ? 220 : 18 * X + measure(String(it.label), size) + 12)));
|
|
172
|
+
}
|
|
173
|
+
|
|
113
174
|
function drawLegend(items, x0, y0, maxW, theme, opts) {
|
|
114
175
|
const out = [];
|
|
115
176
|
const size = opts.size ?? SIZES.legend;
|
|
116
177
|
const X = ctx.textScale;
|
|
117
|
-
const rowH = Math.round(20 * X);
|
|
178
|
+
const rowH = Math.round((opts.vertical ? 22 : 20) * X);
|
|
179
|
+
const vertical = !!opts.vertical;
|
|
118
180
|
let x = x0, y = y0, rows = 1;
|
|
119
181
|
for (const it of items) {
|
|
120
182
|
if (it.type === 'ramp') {
|
|
183
|
+
if (vertical && x > x0) { x = x0; y += rowH; rows++; }
|
|
121
184
|
const w = Math.min(220, maxW);
|
|
122
185
|
const gid = 'pa-ramp-' + Math.abs(hash(it.stops.join(''))).toString(36);
|
|
123
186
|
const stops = it.stops.map((c, i) => el('stop', { offset: `${(i / (it.stops.length - 1)) * 100}%`, stopColor: c }));
|
|
124
187
|
out.push(el('defs', {}, el('linearGradient', { id: gid }, stops)));
|
|
125
|
-
if (it.title) {
|
|
188
|
+
if (it.title) {
|
|
189
|
+
out.push(text(x, y + 10, it.title, { fill: theme.lede, fontSize: size }));
|
|
190
|
+
if (vertical) { y += rowH; rows++; } else x += measure(it.title, size) + 10;
|
|
191
|
+
}
|
|
126
192
|
out.push(text(x, y + 10, it.min, { fill: theme.muted, fontSize: SIZES.axis }));
|
|
127
193
|
x += measure(it.min, SIZES.axis) + 6;
|
|
128
|
-
|
|
129
|
-
|
|
194
|
+
const rw = vertical ? Math.max(40, Math.min(w * 0.6, maxW - (x - x0) - measure(it.max, SIZES.axis) - 6)) : w * 0.6;
|
|
195
|
+
out.push(el('rect', { x, y: y + 2, width: rw, height: 10, rx: 5, fill: `url(#${gid})` }));
|
|
196
|
+
x += rw + 6;
|
|
130
197
|
out.push(text(x, y + 10, it.max, { fill: theme.muted, fontSize: SIZES.axis }));
|
|
131
198
|
x += measure(it.max, SIZES.axis) + 18;
|
|
132
199
|
continue;
|
|
133
200
|
}
|
|
134
|
-
const
|
|
201
|
+
const sw = 10 * Math.min(1.6, X);
|
|
202
|
+
let label = String(it.label);
|
|
203
|
+
if (vertical) label = truncate(label, maxW - sw - 10, size);
|
|
135
204
|
const w = 18 * X + measure(label, size) + 18;
|
|
136
|
-
if (x + w > x0 + maxW && x > x0) { x = x0; y += rowH; rows++; }
|
|
205
|
+
if ((vertical || x + w > x0 + maxW) && x > x0) { x = x0; y += rowH; rows++; }
|
|
137
206
|
const cy = y + 6.5 * X;
|
|
138
|
-
|
|
207
|
+
const v = it.value !== undefined ? it.value : it.key ? undefined : it.label;
|
|
208
|
+
const val = v !== undefined ? ` data-value="${esc(String(v))}"` : '';
|
|
209
|
+
const faded = it.off ? 0.45 : it.active === false ? 0.5 : null;
|
|
210
|
+
out.push(`<g class="pa-key${it.off ? ' pa-key-off' : ''}" data-color="${esc(it.key ?? it.color)}"${val}${it.active ? ' aria-pressed="true"' : ''}${faded ? ` opacity="${faded}"` : ''} style="cursor:pointer">`);
|
|
139
211
|
out.push(el('rect', { x: x - 3, y: y - 3, width: w - 10, height: rowH - 1, rx: 4, fill: 'transparent' }));
|
|
140
|
-
const sw = 10 * Math.min(1.6, X);
|
|
141
212
|
// Entradas punteadas: línea discontinua o cuadro rayado con borde discontinuo.
|
|
142
|
-
if (it.
|
|
213
|
+
if (it.off) out.push(el('rect', { x: x + 0.75, y: cy - sw / 2 + 0.75, width: sw - 1.5, height: sw - 1.5, rx: 2, fill: 'none', stroke: it.color, strokeWidth: 1.5 }));
|
|
214
|
+
else if (it.shape === 'line') out.push(el('line', { x1: x, x2: x + sw * 1.2, y1: cy, y2: cy, stroke: it.color, strokeWidth: 2.5, strokeLinecap: it.dashed ? 'butt' : 'round', strokeDasharray: it.dashed ? '4 2.5' : null }));
|
|
143
215
|
else if (it.shape === 'square' && it.dashed) out.push(el('rect', { x: x + 0.6, y: cy - sw / 2 + 0.6, width: sw - 1.2, height: sw - 1.2, rx: 2, fill: it.fill || it.color, stroke: it.stroke || it.color, strokeWidth: 1.2, strokeDasharray: '3 2' }));
|
|
144
216
|
else if (it.shape === 'square') out.push(el('rect', { x, y: cy - sw / 2, width: sw, height: sw, rx: 2.5, fill: it.color }));
|
|
145
217
|
else out.push(el('circle', { cx: x + sw / 2, cy, r: sw / 2, fill: it.color }));
|
|
146
|
-
out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size }));
|
|
218
|
+
out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size, textDecoration: it.off ? 'line-through' : null }));
|
|
147
219
|
out.push('</g>');
|
|
148
220
|
x += w;
|
|
149
221
|
}
|
package/src/geo/projection.js
CHANGED
|
@@ -61,19 +61,71 @@ export function albersUsa() {
|
|
|
61
61
|
return f;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/** Equirectangular (placa carré): lon/lat tal cual. Útil para mapas chicos o datos ya en grados. */
|
|
65
|
+
export function equirectangular() {
|
|
66
|
+
return ([lon, lat]) => [lon * RAD, -lat * RAD];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Coordenadas planas, sin proyectar: para GeoJSON en metros (UTM), en
|
|
71
|
+
* píxeles o dibujado a mano. `identity` deja el eje y hacia abajo (como
|
|
72
|
+
* SVG); `planar` lo invierte (y hacia arriba, como un plano cartesiano o UTM).
|
|
73
|
+
*/
|
|
74
|
+
export function identity() { const f = ([x, y]) => [x, y]; f.continuous = true; return f; }
|
|
75
|
+
export function planar() { const f = ([x, y]) => [x, -y]; f.continuous = true; return f; }
|
|
76
|
+
|
|
77
|
+
/** Cónica de áreas iguales con paralelos y meridiano central propios. */
|
|
78
|
+
export function conicEqualArea({ parallels = [29.5, 45.5], rotate, center = [0, 0] } = {}) {
|
|
79
|
+
const lon0 = rotate !== undefined ? -[].concat(rotate)[0] : center[0];
|
|
80
|
+
const f = conic(-lon0, [lon0, center[1] ?? 0], parallels);
|
|
81
|
+
return ([lon, lat]) => f(lon, lat);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const projections = {
|
|
85
|
+
mercator, equalEarth, 'equal-earth': equalEarth, albersUsa, 'albers-usa': albersUsa, albers: albersUsa,
|
|
86
|
+
equirectangular, 'plate-carree': equirectangular, latlon: equirectangular,
|
|
87
|
+
identity, none: identity, pixels: identity, planar, cartesian: planar, utm: planar,
|
|
88
|
+
conic: conicEqualArea, conicEqualArea, 'conic-equal-area': conicEqualArea,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Proyección desde el spec: un nombre ("mercator", "identity"…) o un objeto
|
|
93
|
+
* { type: "conic", parallels: [-30, -45], rotate: 72 }. Devuelve null si no
|
|
94
|
+
* se reconoce (se elige una automáticamente).
|
|
95
|
+
*/
|
|
96
|
+
export function makeProjection(p) {
|
|
97
|
+
if (!p) return null;
|
|
98
|
+
// Sin distinguir mayúsculas ni guiones: "equalearth", "Equal-Earth" y "equalEarth" son lo mismo.
|
|
99
|
+
const find = (n) => { const k = String(n ?? '').toLowerCase().replace(/[-_\s]/g, ''); const hit = Object.keys(projections).find((x) => x.toLowerCase().replace(/[-_\s]/g, '') === k); return hit ? projections[hit] : null; };
|
|
100
|
+
if (typeof p === 'string') { const f = projections[p] || find(p); return f ? f() : null; }
|
|
101
|
+
if (typeof p === 'object') { const f = projections[p.type || p.name] || find(p.type || p.name); return f ? f(p) : null; }
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
65
104
|
|
|
66
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Todas las secuencias de puntos de una geometría (anillos de polígonos,
|
|
107
|
+
* líneas y puntos): sirve para encuadrar y para saber si la forma existe.
|
|
108
|
+
*/
|
|
67
109
|
export function rings(geom) {
|
|
68
|
-
if (!geom) return [];
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
110
|
+
if (!geom || !geom.type) return [];
|
|
111
|
+
const c = geom.coordinates;
|
|
112
|
+
switch (geom.type) {
|
|
113
|
+
case 'Polygon': case 'MultiLineString': return c || [];
|
|
114
|
+
case 'MultiPolygon': return (c || []).flat();
|
|
115
|
+
case 'LineString': case 'MultiPoint': return c && c.length ? [c] : [];
|
|
116
|
+
case 'Point': return c && c.length >= 2 ? [[c]] : [];
|
|
117
|
+
case 'GeometryCollection': return (geom.geometries || []).flatMap(rings);
|
|
118
|
+
default: return [];
|
|
119
|
+
}
|
|
72
120
|
}
|
|
121
|
+
/** ¿La geometría es de puntos o de líneas (sin área)? */
|
|
122
|
+
export const isPointGeom = (geom) => !!geom && (geom.type === 'Point' || geom.type === 'MultiPoint');
|
|
123
|
+
export const isLineGeom = (geom) => !!geom && (geom.type === 'LineString' || geom.type === 'MultiLineString');
|
|
73
124
|
export function polygonsOf(geom) {
|
|
74
125
|
if (!geom) return [];
|
|
75
126
|
if (geom.type === 'Polygon') return [geom.coordinates];
|
|
76
127
|
if (geom.type === 'MultiPolygon') return geom.coordinates;
|
|
128
|
+
if (geom.type === 'GeometryCollection') return (geom.geometries || []).flatMap(polygonsOf);
|
|
77
129
|
return [];
|
|
78
130
|
}
|
|
79
131
|
|
|
@@ -114,9 +166,17 @@ const r1 = (v) => Math.round(v * 10) / 10;
|
|
|
114
166
|
|
|
115
167
|
/**
|
|
116
168
|
* Geometría → atributo d de SVG. Los anillos que saltan más de 180° de
|
|
117
|
-
* longitud (antimeridiano) se cortan para no cruzar el mapa entero.
|
|
169
|
+
* longitud (antimeridiano) se cortan para no cruzar el mapa entero. Las
|
|
170
|
+
* líneas quedan abiertas y los puntos se dibujan como círculos de radio `r`.
|
|
118
171
|
*/
|
|
119
|
-
export function pathD(geom, proj) {
|
|
172
|
+
export function pathD(geom, proj, { r = 4.5 } = {}) {
|
|
173
|
+
if (!geom) return '';
|
|
174
|
+
if (geom.type === 'GeometryCollection') return (geom.geometries || []).map((x) => pathD(x, proj, { r })).join('');
|
|
175
|
+
if (isPointGeom(geom)) {
|
|
176
|
+
const pts = geom.type === 'Point' ? [geom.coordinates] : geom.coordinates || [];
|
|
177
|
+
return pts.map((p) => { const [x, y] = proj(p); return `M${r1(x - r)},${r1(y)}a${r},${r} 0 1,0 ${2 * r},0a${r},${r} 0 1,0 ${-2 * r},0`; }).join('');
|
|
178
|
+
}
|
|
179
|
+
const open = isLineGeom(geom);
|
|
120
180
|
let d = '';
|
|
121
181
|
const cut = !proj.continuous;
|
|
122
182
|
for (const ring of rings(geom)) {
|
|
@@ -130,13 +190,20 @@ export function pathD(geom, proj) {
|
|
|
130
190
|
d += (started ? 'L' : 'M') + X + ',' + Y;
|
|
131
191
|
started = true; lx = X; ly = Y; n++;
|
|
132
192
|
}
|
|
133
|
-
if (n) d += 'Z';
|
|
193
|
+
if (n && !open) d += 'Z';
|
|
134
194
|
}
|
|
135
195
|
return d;
|
|
136
196
|
}
|
|
137
197
|
|
|
138
198
|
/** Área (en px²) y centroide del polígono proyectado más grande. */
|
|
139
199
|
export function centroid(geom, proj) {
|
|
200
|
+
if (!polygonsOf(geom).length) {
|
|
201
|
+
// Puntos y líneas: el promedio de sus vértices (área chica fija para las etiquetas).
|
|
202
|
+
const pts = rings(geom).flat().map(proj);
|
|
203
|
+
if (!pts.length) return { x: 0, y: 0, area: 0 };
|
|
204
|
+
const mid = isLineGeom(geom) ? pts[Math.floor(pts.length / 2)] : [pts.reduce((a, p) => a + p[0], 0) / pts.length, pts.reduce((a, p) => a + p[1], 0) / pts.length];
|
|
205
|
+
return { x: mid[0], y: mid[1], area: 0, point: true };
|
|
206
|
+
}
|
|
140
207
|
let best = null;
|
|
141
208
|
for (const poly of polygonsOf(geom)) {
|
|
142
209
|
const pts = poly[0].map(proj);
|