plotagents 0.5.0 → 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 +24 -4
- package/README.md +26 -14
- package/bin/plotagents.js +38 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +4294 -637
- package/docs/api.md +23 -5
- package/docs/charts.md +143 -9
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +188 -17
- package/package.json +9 -3
- package/schema.json +1025 -46
- package/src/axes.js +11 -3
- package/src/charts/bar.js +28 -17
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +3 -3
- package/src/charts/choropleth.js +284 -36
- package/src/charts/dist.js +37 -20
- package/src/charts/donut.js +1 -1
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +9 -6
- package/src/charts/line.js +173 -41
- package/src/charts/network.js +552 -189
- package/src/charts/pointmap.js +599 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +7 -1
- package/src/charts/sankey.js +41 -5
- package/src/charts/scatter.js +100 -22
- package/src/charts/sunburst.js +5 -4
- package/src/charts/treemap.js +10 -7
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +1 -1
- package/src/data.js +2 -2
- package/src/format.js +46 -14
- package/src/frame.js +95 -26
- package/src/geo/projection.js +74 -9
- package/src/geo/registry.js +87 -8
- package/src/index.js +76 -11
- package/src/infer.js +10 -1
- package/src/interact.js +157 -36
- package/src/lint.js +289 -9
- package/src/runtime.js +209 -97
- package/src/scale.js +80 -10
- package/src/schema.js +187 -31
- package/src/stats.js +71 -14
- package/src/theme.js +101 -14
package/src/dashed.js
CHANGED
|
@@ -29,7 +29,7 @@ export function dashedMatcher(spec, data, keyField, seriesField, norm = String)
|
|
|
29
29
|
if (d === undefined || d === null || d === false) return null;
|
|
30
30
|
if (d === true) return () => true;
|
|
31
31
|
const row0 = data && data[0];
|
|
32
|
-
const field = typeof d === 'object' && !Array.isArray(d) ? d.field : typeof d === 'string' && row0 && d in
|
|
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
33
|
if (field) {
|
|
34
34
|
const eq = typeof d === 'object' ? d.equals ?? d.value : undefined;
|
|
35
35
|
const pairs = new Set();
|
package/src/data.js
CHANGED
|
@@ -113,8 +113,8 @@ export function aggregate(values, how = 'sum') {
|
|
|
113
113
|
if (!v.length) return NaN;
|
|
114
114
|
switch (how) {
|
|
115
115
|
case 'mean': case 'avg': return v.reduce((a, b) => a + b, 0) / v.length;
|
|
116
|
-
case 'max': return
|
|
117
|
-
case 'min': return
|
|
116
|
+
case 'max': return v.reduce((a, b) => (b > a ? b : a), -Infinity); // sin spread: no desborda con 150 mil filas
|
|
117
|
+
case 'min': return v.reduce((a, b) => (b < a ? b : a), Infinity);
|
|
118
118
|
case 'count': return v.length;
|
|
119
119
|
case 'last': return v[v.length - 1];
|
|
120
120
|
case 'first': return v[0];
|
package/src/format.js
CHANGED
|
@@ -24,7 +24,7 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
|
24
24
|
if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞';
|
|
25
25
|
const { group, dec } = SEP[loc(locale)];
|
|
26
26
|
const neg = v < 0;
|
|
27
|
-
let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, decimals)).split('.');
|
|
27
|
+
let [int, frac = ''] = Math.abs(v).toFixed(Math.max(0, Math.min(20, Math.round(+decimals) || 0))).split('.');
|
|
28
28
|
int = int.replace(/\B(?=(\d{3})+(?!\d))/g, group);
|
|
29
29
|
if (trim) frac = frac.replace(/0+$/, '');
|
|
30
30
|
const out = frac ? int + dec + frac : int;
|
|
@@ -34,10 +34,11 @@ export function formatNumber(v, decimals = 0, locale = 'es', trim = false) {
|
|
|
34
34
|
/** Decimales "inteligentes" según la magnitud. */
|
|
35
35
|
export function smartDecimals(v) {
|
|
36
36
|
const a = Math.abs(v);
|
|
37
|
-
if (a === 0 || a >= 100) return 0;
|
|
37
|
+
if (a === 0 || !Number.isFinite(a) || a >= 100) return 0;
|
|
38
38
|
if (a >= 10) return 1;
|
|
39
39
|
if (a >= 1) return 2;
|
|
40
|
-
|
|
40
|
+
// Bajo 1: tres cifras significativas (0,123 · 0,00012), nunca "0".
|
|
41
|
+
return Math.min(20, 2 - Math.floor(Math.log10(a)));
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export function formatCompact(v, locale = 'es', decimals) {
|
|
@@ -102,7 +103,9 @@ export function makeFormat(fmt, locale = 'es', values = []) {
|
|
|
102
103
|
return pre + s + suf;
|
|
103
104
|
};
|
|
104
105
|
f.type = type;
|
|
105
|
-
|
|
106
|
+
// `ticks` (opcional): las marcas del eje, o la mayor |marca|. Fija una sola
|
|
107
|
+
// unidad compacta para todo el eje (sin "500 M" junto a "1.000,0 M").
|
|
108
|
+
f.tick = (step, ticks) => tickFormat(step, o, type, l, pre, suf, max, d, ticks);
|
|
106
109
|
return f;
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -113,7 +116,7 @@ function currencyPrefix(cur, l) {
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
/** Formateador de marcas de eje: decimales según el paso, nunca ruido. */
|
|
116
|
-
function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
|
|
119
|
+
function tickFormat(step, o, type, l, pre, suf, max, dFixed, ticks) {
|
|
117
120
|
// Decimales mínimos para escribir el paso exacto (2,5 → 1; 0,25 → 2).
|
|
118
121
|
const decFor = (s) => {
|
|
119
122
|
const a = Math.abs(s) || 1;
|
|
@@ -127,14 +130,19 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
|
|
|
127
130
|
const scale = o.scale ?? (max > 1.5 ? 1 : 100);
|
|
128
131
|
core = (v) => formatNumber(v * scale, decFor(step * scale), l) + '%';
|
|
129
132
|
} else if (type === 'compact' || max >= 1e4 && type !== 'integer') {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
// Una sola unidad por eje, la de la mayor marca, entre las que tiene el idioma
|
|
134
|
+
// (en español no hay "mil millones": 1.000 M). Sin ceros finales.
|
|
135
|
+
let top;
|
|
136
|
+
if (Array.isArray(ticks)) top = ticks.reduce((m, t) => (Number.isFinite(t) ? Math.max(m, Math.abs(t)) : m), 0);
|
|
137
|
+
else if (Number.isFinite(ticks)) top = Math.abs(ticks);
|
|
138
|
+
else top = step > 0 ? Math.ceil(max / step - 1e-9) * step : max;
|
|
139
|
+
const [unit, sufx] = COMPACT[l].find(([n]) => top >= n) || [1, ''];
|
|
140
|
+
core = unit === 1
|
|
141
|
+
? (v) => formatNumber(v, decFor(step), l)
|
|
142
|
+
: (v) => formatNumber(v / unit, decFor(step / unit), l, true) + sufx;
|
|
136
143
|
} else core = (v) => formatNumber(v, dFixed ?? decFor(step), l);
|
|
137
144
|
return (v) => {
|
|
145
|
+
if (v === 0 && dFixed === undefined && type !== 'year') return pre + '0' + (type === 'percent' ? '%' : '') + suf;
|
|
138
146
|
const s = core(v);
|
|
139
147
|
if (pre && s.startsWith('−')) return '−' + pre + s.slice(1) + suf;
|
|
140
148
|
return pre + s + suf;
|
|
@@ -144,25 +152,49 @@ function tickFormat(step, o, type, l, pre, suf, max, dFixed) {
|
|
|
144
152
|
// ---------------------------------------------------------------------------
|
|
145
153
|
// Fechas
|
|
146
154
|
|
|
147
|
-
const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)
|
|
155
|
+
const ISO = /^(\d{4})-(\d{2})(?:-(\d{2}))?(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?)?/i;
|
|
148
156
|
|
|
149
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Convierte Date | 'YYYY-MM' | 'YYYY-MM-DD' | ISO a milisegundos (UTC).
|
|
159
|
+
* Sin zona horaria, la hora se toma tal cual (como UTC); con `Z` o `±hh:mm`
|
|
160
|
+
* se respeta el desfase, así datos con zonas distintas se ordenan bien.
|
|
161
|
+
*/
|
|
150
162
|
export function parseDate(v) {
|
|
151
163
|
if (v instanceof Date) return v.getTime();
|
|
152
164
|
if (typeof v === 'string') {
|
|
153
165
|
const m = ISO.exec(v.trim());
|
|
154
|
-
if (m)
|
|
166
|
+
if (m) {
|
|
167
|
+
const ms = m[7] ? Math.round(+('0.' + m[7]) * 1000) : 0;
|
|
168
|
+
let t = Date.UTC(+m[1], +m[2] - 1, +(m[3] || 1), +(m[4] || 0), +(m[5] || 0), +(m[6] || 0), ms);
|
|
169
|
+
const z = m[8];
|
|
170
|
+
if (z && z.toUpperCase() !== 'Z') {
|
|
171
|
+
const sign = z[0] === '-' ? -1 : 1;
|
|
172
|
+
const hh = +z.slice(1, 3), mm = +(z.slice(1).replace(':', '').slice(2, 4) || 0);
|
|
173
|
+
t -= sign * (hh * 60 + mm) * 6e4;
|
|
174
|
+
}
|
|
175
|
+
return t;
|
|
176
|
+
}
|
|
155
177
|
}
|
|
156
178
|
return NaN;
|
|
157
179
|
}
|
|
158
180
|
|
|
159
181
|
export const isDateLike = (v) => v instanceof Date || (typeof v === 'string' && ISO.test(v.trim()));
|
|
160
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Fecha legible. `unit`: 'year' | 'quarter' | 'month' | 'day' | 'hour' | 'minute'.
|
|
185
|
+
* En 'hour'/'minute', `withYear`: true → fecha completa + "HH:mm";
|
|
186
|
+
* 'day' → fecha sin año + hora; false → solo "HH:mm".
|
|
187
|
+
*/
|
|
161
188
|
export function formatDate(ms, unit, locale = 'es', withYear = true) {
|
|
162
189
|
const d = new Date(ms);
|
|
163
190
|
const l = loc(locale);
|
|
164
191
|
const y = d.getUTCFullYear();
|
|
165
192
|
const m = MONTHS[l][d.getUTCMonth()];
|
|
193
|
+
if (unit === 'hour' || unit === 'minute') {
|
|
194
|
+
const hm = String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
|
|
195
|
+
if (withYear === false) return hm;
|
|
196
|
+
return formatDate(ms, 'day', l, withYear !== 'day') + ' ' + hm;
|
|
197
|
+
}
|
|
166
198
|
if (unit === 'year') return String(y);
|
|
167
199
|
if (unit === 'quarter') return `T${Math.floor(d.getUTCMonth() / 3) + 1}${withYear ? ' ' + y : ''}`.replace('T', l === 'en' ? 'Q' : 'T');
|
|
168
200
|
if (unit === 'month') return withYear ? `${m} ${y}` : m;
|
package/src/frame.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
|
|
@@ -18,7 +18,11 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
18
18
|
const width = Math.max(240, +spec.width || defaults.width || 720);
|
|
19
19
|
// Con textos más grandes (textScale), el alto por defecto crece para no aplastar los datos.
|
|
20
20
|
const XS = ctx.textScale;
|
|
21
|
-
|
|
21
|
+
// En gráficos anchos el alto por defecto crece con el ancho (hasta ~2,8:1),
|
|
22
|
+
// para que una lámina de 1500 px no quede como una franja vacía.
|
|
23
|
+
const h0 = defaults.height || 420;
|
|
24
|
+
const hW = defaults.fixedHeight ? h0 : Math.max(h0, Math.min(Math.round(h0 * 1.35), Math.round(width * 0.36)));
|
|
25
|
+
let height = Math.max(160, +spec.height || Math.round(hW * (1 + 0.55 * (XS - 1))));
|
|
22
26
|
// Responsivo: en pantallas angostas, menos margen y títulos algo menores
|
|
23
27
|
// (nunca bajo 11 px en ejes). El ancho útil es lo que más importa en móvil.
|
|
24
28
|
const k = width < 420 ? 0.86 : width < 560 ? 0.93 : 1;
|
|
@@ -45,39 +49,66 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
45
49
|
if (subLines.length) y += subLines.length * slh + 2;
|
|
46
50
|
if (titleLines.length || subLines.length) y += 10;
|
|
47
51
|
|
|
48
|
-
const
|
|
52
|
+
const srcText = spec.source ? (/^fuente|^source/i.test(spec.source) ? spec.source : (String(spec.locale || 'es').startsWith('en') ? 'Source: ' : 'Fuente: ') + spec.source) : '';
|
|
53
|
+
// Una fuente larga salta a una segunda línea en vez de salirse por la izquierda.
|
|
54
|
+
const srcLines = srcText ? wrap(srcText, innerW, T.source, 400, 2) : [];
|
|
55
|
+
const srcLH = Math.round(T.source * X * 1.35);
|
|
56
|
+
const sourceH = srcText ? Math.round(18 * Math.max(1, X * 0.95)) + (srcLines.length - 1) * srcLH : 0;
|
|
57
|
+
const legendPos = legendPosition(spec, width);
|
|
58
|
+
let legendBottom = null; // leyenda inferior: se dibuja en finish(), cuando el alto es definitivo
|
|
49
59
|
|
|
50
60
|
const frame = {
|
|
51
61
|
width,
|
|
52
62
|
pad,
|
|
53
63
|
theme,
|
|
64
|
+
legendPosition: legendPos,
|
|
54
65
|
get height() { return height; },
|
|
55
66
|
top: y,
|
|
56
|
-
get bottom() { return height - pad - sourceH; },
|
|
57
|
-
/** Alto extra fuera del área de trazado (pie + margen). */
|
|
58
|
-
chrome
|
|
59
|
-
/**
|
|
67
|
+
get bottom() { return height - pad - sourceH - (legendBottom ? legendBottom.h + 12 : 0); },
|
|
68
|
+
/** Alto extra fuera del área de trazado (pie + margen + leyenda inferior). */
|
|
69
|
+
get chrome() { return pad + sourceH + (legendBottom ? legendBottom.h + 12 : 0); },
|
|
70
|
+
/**
|
|
71
|
+
* Dibuja la leyenda y devuelve el área de trazado restante. La leyenda va
|
|
72
|
+
* arriba por defecto; `legend: "bottom" | "left" | "right"` la mueve.
|
|
73
|
+
*/
|
|
60
74
|
layout(legend = [], opts = {}) {
|
|
61
75
|
let ly = y;
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
const items = withExcluded(legend, spec, theme);
|
|
77
|
+
const extraBottom = Math.max(0, (XS - 1) * 22);
|
|
78
|
+
if (items.length && (legendPos === 'left' || legendPos === 'right')) {
|
|
79
|
+
const colW = Math.min(innerW * 0.32, Math.max(90, legendWidth(items, T.legend)));
|
|
80
|
+
const { svg } = drawLegend(items, legendPos === 'left' ? pad : width - pad - colW, ly + 2, colW, theme, { ...opts, size: T.legend, vertical: true });
|
|
81
|
+
header.push(svg);
|
|
82
|
+
const gap = 16;
|
|
83
|
+
return { x: legendPos === 'left' ? pad + colW + gap : pad, y: ly, w: innerW - colW - gap, h: Math.max(40, frame.bottom - ly - extraBottom) };
|
|
84
|
+
}
|
|
85
|
+
if (items.length && legendPos === 'bottom') {
|
|
86
|
+
const probe = drawLegend(items, pad, 0, innerW, theme, { ...opts, size: T.legend });
|
|
87
|
+
legendBottom = { items, opts, h: probe.h };
|
|
88
|
+
return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
|
|
89
|
+
}
|
|
90
|
+
if (items.length) {
|
|
91
|
+
const { svg, h } = drawLegend(items, pad, ly, innerW, theme, { ...opts, size: T.legend });
|
|
64
92
|
header.push(svg);
|
|
65
93
|
ly += h + 10; // la leyenda pegada al gráfico que explica
|
|
66
94
|
}
|
|
67
95
|
// 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 -
|
|
96
|
+
return { x: pad, y: ly, w: innerW, h: Math.max(40, frame.bottom - ly - extraBottom) };
|
|
69
97
|
},
|
|
70
98
|
/** Permite a gráficos de alto automático (kpi) fijar el alto final. */
|
|
71
99
|
setHeight(h) { height = h; },
|
|
72
100
|
finish(body, { desc = '', type = 'chart', defs = '' } = {}) {
|
|
73
|
-
if (
|
|
74
|
-
const
|
|
75
|
-
|
|
101
|
+
if (legendBottom) {
|
|
102
|
+
const { svg } = drawLegend(legendBottom.items, pad, height - pad - sourceH - legendBottom.h - 2, innerW, theme, { ...legendBottom.opts, size: T.legend });
|
|
103
|
+
header.push(svg);
|
|
76
104
|
}
|
|
77
|
-
|
|
105
|
+
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' })));
|
|
106
|
+
// Id estable pero distinto entre gráficos sin título del mismo tipo (aria-labelledby).
|
|
107
|
+
const id = 'pa' + Math.abs(hash((spec.title || '') + type + width + desc)).toString(36);
|
|
78
108
|
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
|
-
+ (
|
|
109
|
+
const style = `${fontImport}.pa-m{transition:opacity .15s ease}.pa-focus .pa-m{opacity:.28}.pa-focus .pa-m.pa-on{opacity:1}@media (prefers-reduced-motion:reduce){.pa-m{transition:none}}.pa-chart:focus-visible{outline:2px solid ${theme.categorical[0]};outline-offset:2px}`
|
|
110
|
+
+ (type === 'network' ? '.pa-focus .pa-net .pa-m{opacity:.12}.pa-focus .pa-net .pa-m.pa-on{opacity:1}' : '')
|
|
111
|
+
+ (/^(choropleth|symbolmap|pointmap)$/.test(type) ? '.pa-zoom path,.pa-zoom circle,.pa-zoom rect{vector-effect:non-scaling-stroke}.pa-zoom-hide{transition:opacity .2s ease}.pa-zoomed .pa-zoom-hide{opacity:0}' : '');
|
|
81
112
|
return el(
|
|
82
113
|
'svg',
|
|
83
114
|
{
|
|
@@ -92,6 +123,8 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
92
123
|
'data-pa-type': type,
|
|
93
124
|
'data-pa-mode': theme.mode,
|
|
94
125
|
'data-pa-lang': String(spec.locale || 'es').slice(0, 2),
|
|
126
|
+
'data-pa-top': r2(frame.top),
|
|
127
|
+
'data-pa-tip': theme.tooltip && (theme.tooltip.bg || theme.tooltip.ink) ? `${theme.tooltip.bg || ''},${theme.tooltip.ink || ''}` : null,
|
|
95
128
|
style: 'max-width:100%;height:auto;font-variant-numeric:lining-nums',
|
|
96
129
|
},
|
|
97
130
|
[
|
|
@@ -110,40 +143,76 @@ export function createFrame(spec, theme, defaults = {}) {
|
|
|
110
143
|
return frame;
|
|
111
144
|
}
|
|
112
145
|
|
|
146
|
+
/** Posición de la leyenda: `legend: "top" | "bottom" | "left" | "right"` (o { position }). */
|
|
147
|
+
export function legendPosition(spec, width = 720) {
|
|
148
|
+
const v = typeof spec.legend === 'string' ? spec.legend : spec.legend && typeof spec.legend === 'object' ? spec.legend.position : spec.legendPosition;
|
|
149
|
+
const p = String(v || 'top').toLowerCase();
|
|
150
|
+
const pos = { arriba: 'top', abajo: 'bottom', izquierda: 'left', derecha: 'right' }[p] || p;
|
|
151
|
+
if (!['top', 'bottom', 'left', 'right'].includes(pos)) return 'top';
|
|
152
|
+
// En pantallas angostas una columna lateral roba demasiado ancho: va abajo.
|
|
153
|
+
if ((pos === 'left' || pos === 'right') && width < 520) return 'bottom';
|
|
154
|
+
return pos;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Entradas excluidas con shift + clic (spec.exclude): siguen en la leyenda, apagadas, para volver a sumarlas. */
|
|
158
|
+
function withExcluded(items, spec, theme) {
|
|
159
|
+
const ex = spec._excluded;
|
|
160
|
+
if (!ex || !ex.length || !items.length) return items;
|
|
161
|
+
const have = new Set(items.map((it) => String(it.value ?? it.label)));
|
|
162
|
+
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 }));
|
|
163
|
+
return [...items, ...extra];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function legendWidth(items, size) {
|
|
167
|
+
const X = ctx.textScale;
|
|
168
|
+
return Math.max(...items.map((it) => (it.type === 'ramp' ? 220 : 18 * X + measure(String(it.label), size) + 12)));
|
|
169
|
+
}
|
|
170
|
+
|
|
113
171
|
function drawLegend(items, x0, y0, maxW, theme, opts) {
|
|
114
172
|
const out = [];
|
|
115
173
|
const size = opts.size ?? SIZES.legend;
|
|
116
174
|
const X = ctx.textScale;
|
|
117
|
-
const rowH = Math.round(20 * X);
|
|
175
|
+
const rowH = Math.round((opts.vertical ? 22 : 20) * X);
|
|
176
|
+
const vertical = !!opts.vertical;
|
|
118
177
|
let x = x0, y = y0, rows = 1;
|
|
119
178
|
for (const it of items) {
|
|
120
179
|
if (it.type === 'ramp') {
|
|
180
|
+
if (vertical && x > x0) { x = x0; y += rowH; rows++; }
|
|
121
181
|
const w = Math.min(220, maxW);
|
|
122
182
|
const gid = 'pa-ramp-' + Math.abs(hash(it.stops.join(''))).toString(36);
|
|
123
183
|
const stops = it.stops.map((c, i) => el('stop', { offset: `${(i / (it.stops.length - 1)) * 100}%`, stopColor: c }));
|
|
124
184
|
out.push(el('defs', {}, el('linearGradient', { id: gid }, stops)));
|
|
125
|
-
if (it.title) {
|
|
185
|
+
if (it.title) {
|
|
186
|
+
out.push(text(x, y + 10, it.title, { fill: theme.lede, fontSize: size }));
|
|
187
|
+
if (vertical) { y += rowH; rows++; } else x += measure(it.title, size) + 10;
|
|
188
|
+
}
|
|
126
189
|
out.push(text(x, y + 10, it.min, { fill: theme.muted, fontSize: SIZES.axis }));
|
|
127
190
|
x += measure(it.min, SIZES.axis) + 6;
|
|
128
|
-
|
|
129
|
-
|
|
191
|
+
const rw = vertical ? Math.max(40, Math.min(w * 0.6, maxW - (x - x0) - measure(it.max, SIZES.axis) - 6)) : w * 0.6;
|
|
192
|
+
out.push(el('rect', { x, y: y + 2, width: rw, height: 10, rx: 5, fill: `url(#${gid})` }));
|
|
193
|
+
x += rw + 6;
|
|
130
194
|
out.push(text(x, y + 10, it.max, { fill: theme.muted, fontSize: SIZES.axis }));
|
|
131
195
|
x += measure(it.max, SIZES.axis) + 18;
|
|
132
196
|
continue;
|
|
133
197
|
}
|
|
134
|
-
const
|
|
198
|
+
const sw = 10 * Math.min(1.6, X);
|
|
199
|
+
let label = String(it.label);
|
|
200
|
+
if (vertical) label = truncate(label, maxW - sw - 10, size);
|
|
135
201
|
const w = 18 * X + measure(label, size) + 18;
|
|
136
|
-
if (x + w > x0 + maxW && x > x0) { x = x0; y += rowH; rows++; }
|
|
202
|
+
if ((vertical || x + w > x0 + maxW) && x > x0) { x = x0; y += rowH; rows++; }
|
|
137
203
|
const cy = y + 6.5 * X;
|
|
138
|
-
|
|
204
|
+
const v = it.value !== undefined ? it.value : it.key ? undefined : it.label;
|
|
205
|
+
const val = v !== undefined ? ` data-value="${esc(String(v))}"` : '';
|
|
206
|
+
const faded = it.off ? 0.45 : it.active === false ? 0.5 : null;
|
|
207
|
+
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
208
|
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
209
|
// Entradas punteadas: línea discontinua o cuadro rayado con borde discontinuo.
|
|
142
|
-
if (it.
|
|
210
|
+
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 }));
|
|
211
|
+
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
212
|
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
213
|
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
214
|
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 }));
|
|
215
|
+
out.push(text(x + sw + 6, y + 11 * X, label, { fill: theme.lede, fontSize: size, textDecoration: it.off ? 'line-through' : null }));
|
|
147
216
|
out.push('</g>');
|
|
148
217
|
x += w;
|
|
149
218
|
}
|
package/src/geo/projection.js
CHANGED
|
@@ -61,19 +61,69 @@ 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
|
+
if (typeof p === 'string') { const f = projections[p] || projections[p.toLowerCase()]; return f ? f() : null; }
|
|
99
|
+
if (typeof p === 'object') { const f = projections[p.type || p.name]; return f ? f(p) : null; }
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
65
102
|
|
|
66
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Todas las secuencias de puntos de una geometría (anillos de polígonos,
|
|
105
|
+
* líneas y puntos): sirve para encuadrar y para saber si la forma existe.
|
|
106
|
+
*/
|
|
67
107
|
export function rings(geom) {
|
|
68
|
-
if (!geom) return [];
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
if (!geom || !geom.type) return [];
|
|
109
|
+
const c = geom.coordinates;
|
|
110
|
+
switch (geom.type) {
|
|
111
|
+
case 'Polygon': case 'MultiLineString': return c || [];
|
|
112
|
+
case 'MultiPolygon': return (c || []).flat();
|
|
113
|
+
case 'LineString': case 'MultiPoint': return c && c.length ? [c] : [];
|
|
114
|
+
case 'Point': return c && c.length >= 2 ? [[c]] : [];
|
|
115
|
+
case 'GeometryCollection': return (geom.geometries || []).flatMap(rings);
|
|
116
|
+
default: return [];
|
|
117
|
+
}
|
|
72
118
|
}
|
|
119
|
+
/** ¿La geometría es de puntos o de líneas (sin área)? */
|
|
120
|
+
export const isPointGeom = (geom) => !!geom && (geom.type === 'Point' || geom.type === 'MultiPoint');
|
|
121
|
+
export const isLineGeom = (geom) => !!geom && (geom.type === 'LineString' || geom.type === 'MultiLineString');
|
|
73
122
|
export function polygonsOf(geom) {
|
|
74
123
|
if (!geom) return [];
|
|
75
124
|
if (geom.type === 'Polygon') return [geom.coordinates];
|
|
76
125
|
if (geom.type === 'MultiPolygon') return geom.coordinates;
|
|
126
|
+
if (geom.type === 'GeometryCollection') return (geom.geometries || []).flatMap(polygonsOf);
|
|
77
127
|
return [];
|
|
78
128
|
}
|
|
79
129
|
|
|
@@ -114,9 +164,17 @@ const r1 = (v) => Math.round(v * 10) / 10;
|
|
|
114
164
|
|
|
115
165
|
/**
|
|
116
166
|
* 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.
|
|
167
|
+
* longitud (antimeridiano) se cortan para no cruzar el mapa entero. Las
|
|
168
|
+
* líneas quedan abiertas y los puntos se dibujan como círculos de radio `r`.
|
|
118
169
|
*/
|
|
119
|
-
export function pathD(geom, proj) {
|
|
170
|
+
export function pathD(geom, proj, { r = 4.5 } = {}) {
|
|
171
|
+
if (!geom) return '';
|
|
172
|
+
if (geom.type === 'GeometryCollection') return (geom.geometries || []).map((x) => pathD(x, proj, { r })).join('');
|
|
173
|
+
if (isPointGeom(geom)) {
|
|
174
|
+
const pts = geom.type === 'Point' ? [geom.coordinates] : geom.coordinates || [];
|
|
175
|
+
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('');
|
|
176
|
+
}
|
|
177
|
+
const open = isLineGeom(geom);
|
|
120
178
|
let d = '';
|
|
121
179
|
const cut = !proj.continuous;
|
|
122
180
|
for (const ring of rings(geom)) {
|
|
@@ -130,13 +188,20 @@ export function pathD(geom, proj) {
|
|
|
130
188
|
d += (started ? 'L' : 'M') + X + ',' + Y;
|
|
131
189
|
started = true; lx = X; ly = Y; n++;
|
|
132
190
|
}
|
|
133
|
-
if (n) d += 'Z';
|
|
191
|
+
if (n && !open) d += 'Z';
|
|
134
192
|
}
|
|
135
193
|
return d;
|
|
136
194
|
}
|
|
137
195
|
|
|
138
196
|
/** Área (en px²) y centroide del polígono proyectado más grande. */
|
|
139
197
|
export function centroid(geom, proj) {
|
|
198
|
+
if (!polygonsOf(geom).length) {
|
|
199
|
+
// Puntos y líneas: el promedio de sus vértices (área chica fija para las etiquetas).
|
|
200
|
+
const pts = rings(geom).flat().map(proj);
|
|
201
|
+
if (!pts.length) return { x: 0, y: 0, area: 0 };
|
|
202
|
+
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];
|
|
203
|
+
return { x: mid[0], y: mid[1], area: 0, point: true };
|
|
204
|
+
}
|
|
140
205
|
let best = null;
|
|
141
206
|
for (const poly of polygonsOf(geom)) {
|
|
142
207
|
const pts = poly[0].map(proj);
|
package/src/geo/registry.js
CHANGED
|
@@ -1,31 +1,110 @@
|
|
|
1
1
|
// Registro de geometrías. El núcleo no trae mapas pesados: se registran al
|
|
2
2
|
// importar 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o se pasa un
|
|
3
3
|
// GeoJSON propio en spec.geo.
|
|
4
|
+
//
|
|
5
|
+
// `geo` acepta, además de un nombre registrado:
|
|
6
|
+
// · un GeoJSON FeatureCollection, un Feature, un arreglo de Features o una geometría suelta
|
|
7
|
+
// · un TopoJSON (Topology); `geoObject` elige el objeto (por defecto, el primero)
|
|
8
|
+
// · en el navegador, una URL (render() la descarga; ver runtime.js)
|
|
4
9
|
import { PlotError } from '../data.js';
|
|
5
10
|
|
|
6
11
|
const REG = new Map();
|
|
7
12
|
|
|
8
|
-
export function registerGeo(name,
|
|
9
|
-
|
|
13
|
+
export function registerGeo(name, geo, aliases = []) {
|
|
14
|
+
const fc = toFeatureCollection(geo);
|
|
10
15
|
for (const n of [name, ...aliases]) REG.set(String(n).toLowerCase(), fc);
|
|
11
16
|
return fc;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
/** ¿Parece una URL o una ruta a un archivo .json / .geojson / .topojson? */
|
|
20
|
+
export const isGeoUrl = (s) => typeof s === 'string' && (/^(https?:)?\/\//i.test(s) || /^(\.{0,2}\/|data:)/.test(s) || /\.(geo|topo)?json(\?.*)?$/i.test(s));
|
|
21
|
+
|
|
22
|
+
export function getGeo(geo, objectName) {
|
|
23
|
+
if (geo && typeof geo === 'object') return toFeatureCollection(geo, objectName);
|
|
24
|
+
if (isGeoUrl(geo)) {
|
|
25
|
+
throw new PlotError(
|
|
26
|
+
`"${geo}" es una URL o un archivo: plot() es sincrónico y no descarga nada.`,
|
|
27
|
+
"En Node, lee el archivo y pásalo como objeto: geo: JSON.parse(fs.readFileSync('mapa.geojson', 'utf8')). En el navegador, render(el, spec) sí acepta la URL."
|
|
28
|
+
);
|
|
19
29
|
}
|
|
20
30
|
const fc = REG.get(String(geo || '').toLowerCase());
|
|
21
31
|
if (!fc) {
|
|
22
32
|
const known = [...new Set(REG.keys())];
|
|
23
33
|
throw new PlotError(
|
|
24
34
|
`No hay una geometría registrada con el nombre "${geo}".`,
|
|
25
|
-
known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
|
|
35
|
+
known.length ? `Disponibles: ${known.join(', ')}. O pasa un GeoJSON o TopoJSON en \`geo\`.` : "Importa 'plotagents/geo/chile', 'plotagents/geo/usa' o 'plotagents/geo/world', o pasa un GeoJSON en `geo`."
|
|
26
36
|
);
|
|
27
37
|
}
|
|
28
38
|
return fc;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export const registeredGeo = () => [...new Set(REG.keys())];
|
|
42
|
+
|
|
43
|
+
const GEOMS = new Set(['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection']);
|
|
44
|
+
const cache = new WeakMap();
|
|
45
|
+
|
|
46
|
+
/** Cualquier forma de GeoJSON / TopoJSON → FeatureCollection. */
|
|
47
|
+
export function toFeatureCollection(geo, objectName) {
|
|
48
|
+
if (!geo || typeof geo !== 'object') throw new PlotError('`geo` debe ser un nombre registrado, un GeoJSON o un TopoJSON.', 'geo: "chile" | "usa" | "world" | { type: "FeatureCollection", features: [...] }');
|
|
49
|
+
if (Array.isArray(geo)) return { type: 'FeatureCollection', features: geo.map(asFeature) };
|
|
50
|
+
if (geo.type === 'FeatureCollection') {
|
|
51
|
+
if (!Array.isArray(geo.features)) throw new PlotError('El FeatureCollection no tiene `features`.');
|
|
52
|
+
return geo.features.every((f) => f && f.type === 'Feature') ? geo : { ...geo, features: geo.features.map(asFeature) };
|
|
53
|
+
}
|
|
54
|
+
if (geo.type === 'Feature') return { type: 'FeatureCollection', features: [geo] };
|
|
55
|
+
if (GEOMS.has(geo.type)) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: geo }] };
|
|
56
|
+
if (geo.type === 'Topology') {
|
|
57
|
+
const key = objectName ?? '';
|
|
58
|
+
const memo = cache.get(geo) || {};
|
|
59
|
+
if (!memo[key]) { memo[key] = topoFeatures(geo, objectName); cache.set(geo, memo); }
|
|
60
|
+
return memo[key];
|
|
61
|
+
}
|
|
62
|
+
throw new PlotError(`No reconozco este \`geo\` (type: "${geo.type}").`, 'Usa un GeoJSON (FeatureCollection, Feature o geometría) o un TopoJSON (type: "Topology").');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asFeature(f) {
|
|
66
|
+
if (f && f.type === 'Feature') return f;
|
|
67
|
+
if (f && GEOMS.has(f.type)) return { type: 'Feature', properties: {}, geometry: f };
|
|
68
|
+
throw new PlotError('Cada elemento de `geo` debe ser un Feature de GeoJSON.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── TopoJSON (decodificador mínimo, sin dependencias) ──────────────────────
|
|
72
|
+
function topoFeatures(topo, objectName) {
|
|
73
|
+
const names = Object.keys(topo.objects || {});
|
|
74
|
+
if (!names.length) throw new PlotError('El TopoJSON no tiene objetos.');
|
|
75
|
+
const name = objectName ?? names[0];
|
|
76
|
+
const obj = topo.objects[name];
|
|
77
|
+
if (!obj) throw new PlotError(`El TopoJSON no tiene el objeto "${name}".`, `Objetos disponibles: ${names.join(', ')}. Indícalo con \`geoObject\`.`);
|
|
78
|
+
const tf = topo.transform;
|
|
79
|
+
// Arcos absolutos (deshace la cuantización y la codificación delta).
|
|
80
|
+
const arcs = (topo.arcs || []).map((arc) => {
|
|
81
|
+
if (!tf) return arc.map((p) => p.slice(0, 2));
|
|
82
|
+
let x = 0, y = 0;
|
|
83
|
+
return arc.map((p) => { x += p[0]; y += p[1]; return [x * tf.scale[0] + tf.translate[0], y * tf.scale[1] + tf.translate[1]]; });
|
|
84
|
+
});
|
|
85
|
+
const point = (p) => (tf ? [p[0] * tf.scale[0] + tf.translate[0], p[1] * tf.scale[1] + tf.translate[1]] : p.slice(0, 2));
|
|
86
|
+
const line = (ids) => {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const i of ids) {
|
|
89
|
+
const a = i < 0 ? arcs[~i].slice().reverse() : arcs[i];
|
|
90
|
+
if (!a) continue;
|
|
91
|
+
for (let k = out.length ? 1 : 0; k < a.length; k++) out.push(a[k]);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
const ring = (ids) => { const r = line(ids); if (r.length && (r[0][0] !== r[r.length - 1][0] || r[0][1] !== r[r.length - 1][1])) r.push(r[0]); return r; };
|
|
96
|
+
const geom = (o) => {
|
|
97
|
+
switch (o.type) {
|
|
98
|
+
case 'Point': return { type: 'Point', coordinates: point(o.coordinates) };
|
|
99
|
+
case 'MultiPoint': return { type: 'MultiPoint', coordinates: o.coordinates.map(point) };
|
|
100
|
+
case 'LineString': return { type: 'LineString', coordinates: line(o.arcs) };
|
|
101
|
+
case 'MultiLineString': return { type: 'MultiLineString', coordinates: o.arcs.map(line) };
|
|
102
|
+
case 'Polygon': return { type: 'Polygon', coordinates: o.arcs.map(ring) };
|
|
103
|
+
case 'MultiPolygon': return { type: 'MultiPolygon', coordinates: o.arcs.map((p) => p.map(ring)) };
|
|
104
|
+
default: return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const feat = (o) => ({ type: 'Feature', id: o.id, properties: { ...(o.properties || {}), ...(o.id !== undefined && (o.properties || {}).id === undefined ? { id: o.id } : {}) }, geometry: geom(o) });
|
|
108
|
+
const list = obj.type === 'GeometryCollection' ? obj.geometries.map(feat) : [feat(obj)];
|
|
109
|
+
return { type: 'FeatureCollection', features: list.filter((f) => f.geometry) };
|
|
110
|
+
}
|