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.
Files changed (67) hide show
  1. package/AGENTS.md +98 -0
  2. package/LICENSE +21 -0
  3. package/README.md +217 -2
  4. package/bin/plotagents.js +73 -0
  5. package/dist/plotagents-geo.js +79 -0
  6. package/dist/plotagents.js +11611 -0
  7. package/docs/api.md +172 -0
  8. package/docs/charts.md +352 -0
  9. package/docs/colorscales.md +46 -0
  10. package/docs/dashboards.md +79 -0
  11. package/docs/getting-started.md +142 -0
  12. package/docs/rendering.md +47 -0
  13. package/docs/themes.md +116 -0
  14. package/index.d.ts +384 -0
  15. package/package.json +80 -4
  16. package/schema.json +1874 -0
  17. package/src/axes.js +152 -0
  18. package/src/charts/bar.js +350 -0
  19. package/src/charts/categorical.js +495 -0
  20. package/src/charts/chord.js +218 -0
  21. package/src/charts/choropleth.js +807 -0
  22. package/src/charts/dist.js +530 -0
  23. package/src/charts/donut.js +232 -0
  24. package/src/charts/flowchart.js +407 -0
  25. package/src/charts/gantt.js +460 -0
  26. package/src/charts/heatmap.js +76 -0
  27. package/src/charts/kpi.js +100 -0
  28. package/src/charts/line.js +551 -0
  29. package/src/charts/network.js +707 -0
  30. package/src/charts/pointmap.js +599 -0
  31. package/src/charts/pyramid.js +177 -0
  32. package/src/charts/radar.js +158 -0
  33. package/src/charts/regplot.js +370 -0
  34. package/src/charts/sankey.js +169 -0
  35. package/src/charts/scatter.js +214 -0
  36. package/src/charts/sunburst.js +136 -0
  37. package/src/charts/treemap.js +197 -0
  38. package/src/charts/wordcloud.js +163 -0
  39. package/src/colorscales.js +182 -0
  40. package/src/context.js +10 -0
  41. package/src/dashboard.js +438 -0
  42. package/src/dashed.js +90 -0
  43. package/src/data.js +137 -0
  44. package/src/facet.js +84 -0
  45. package/src/format.js +203 -0
  46. package/src/frame.js +226 -0
  47. package/src/geo/chile.js +7 -0
  48. package/src/geo/data/chile-regiones.js +12 -0
  49. package/src/geo/data/usa-estados.js +12 -0
  50. package/src/geo/data/world.js +12 -0
  51. package/src/geo/projection.js +217 -0
  52. package/src/geo/registry.js +110 -0
  53. package/src/geo/usa.js +8 -0
  54. package/src/geo/world.js +7 -0
  55. package/src/hierarchy.js +139 -0
  56. package/src/index.js +336 -0
  57. package/src/infer.js +58 -0
  58. package/src/interact.js +483 -0
  59. package/src/lint.js +417 -0
  60. package/src/palette.js +59 -0
  61. package/src/runtime.js +416 -0
  62. package/src/scale.js +241 -0
  63. package/src/schema.js +337 -0
  64. package/src/stats.js +303 -0
  65. package/src/svg.js +85 -0
  66. package/src/text.js +61 -0
  67. package/src/theme.js +311 -0
package/src/stats.js ADDED
@@ -0,0 +1,303 @@
1
+ // Estadística liviana para los gráficos tipo seaborn: cuantiles, intervalos
2
+ // de confianza, histogramas, densidad (KDE), regresiones y ECDF.
3
+ // Determinista (sin bootstrap aleatorio) para que el SVG sea reproducible.
4
+
5
+ export const finite = (vs) => vs.filter((v) => Number.isFinite(v));
6
+ export const sum = (vs) => vs.reduce((a, b) => a + b, 0);
7
+ export const mean = (vs) => (vs.length ? sum(vs) / vs.length : NaN);
8
+
9
+ /**
10
+ * [mín, máx] de los valores finitos con un bucle: `Math.min(...arr)` desborda
11
+ * la pila con ~150 mil elementos. Vacío → [Infinity, -Infinity] (como Math.min()).
12
+ */
13
+ export function extent(vs) {
14
+ let lo = Infinity, hi = -Infinity;
15
+ for (const v of vs) {
16
+ if (!Number.isFinite(v)) continue;
17
+ if (v < lo) lo = v;
18
+ if (v > hi) hi = v;
19
+ }
20
+ return [lo, hi];
21
+ }
22
+
23
+ export function variance(vs) {
24
+ const n = vs.length;
25
+ if (n < 2) return 0;
26
+ const m = mean(vs);
27
+ return vs.reduce((a, v) => a + (v - m) ** 2, 0) / (n - 1);
28
+ }
29
+ export const sd = (vs) => Math.sqrt(variance(vs));
30
+
31
+ /** Cuantil con interpolación lineal (tipo 7, como numpy/R). `sorted` ordenado. */
32
+ export function quantile(sorted, p) {
33
+ if (!sorted.length) return NaN;
34
+ const h = (sorted.length - 1) * p;
35
+ const lo = Math.floor(h), hi = Math.ceil(h);
36
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (h - lo);
37
+ }
38
+ export const median = (vs) => quantile([...vs].sort((a, b) => a - b), 0.5);
39
+
40
+ /** Cuantil de la normal estándar (Acklam). */
41
+ export function zQuantile(p) {
42
+ const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239];
43
+ const b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572];
44
+ const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
45
+ const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
46
+ const pl = 0.02425;
47
+ let q, r;
48
+ if (p < pl) { q = Math.sqrt(-2 * Math.log(p)); return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1); }
49
+ if (p > 1 - pl) { q = Math.sqrt(-2 * Math.log(1 - p)); return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1); }
50
+ q = p - 0.5; r = q * q;
51
+ return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
52
+ }
53
+
54
+ /** Cuantil de la t de Student (expansión de Cornish-Fisher; exacta a ~1e-3 para df ≥ 3). */
55
+ export function tQuantile(df, p = 0.975) {
56
+ if (df <= 0) return NaN;
57
+ if (df === 1) return Math.tan(Math.PI * (p - 0.5));
58
+ if (df === 2) { const a = 4 * p * (1 - p); return (2 * p - 1) * Math.sqrt(2 / a); }
59
+ const z = zQuantile(p);
60
+ const z3 = z ** 3, z5 = z ** 5, z7 = z ** 7, z9 = z ** 9;
61
+ return z + (z3 + z) / (4 * df) + (5 * z5 + 16 * z3 + 3 * z) / (96 * df ** 2) + (3 * z7 + 19 * z5 + 17 * z3 - 15 * z) / (384 * df ** 3) + (79 * z9 + 776 * z7 + 1482 * z5 - 1920 * z3 - 945 * z) / (92160 * df ** 4);
62
+ }
63
+
64
+ /** Intervalo de confianza de la media (t de Student). */
65
+ export function meanCI(vs, level = 0.95) {
66
+ const v = finite(vs);
67
+ const m = mean(v);
68
+ if (v.length < 2) return [m, m];
69
+ const h = tQuantile(v.length - 1, 0.5 + level / 2) * sd(v) / Math.sqrt(v.length);
70
+ return [m - h, m + h];
71
+ }
72
+
73
+ /** Resumen para cajas: cuartiles, bigotes a 1,5·RIC y atípicos. */
74
+ export function fiveNumber(vs) {
75
+ const s = finite(vs).sort((a, b) => a - b);
76
+ const q1 = quantile(s, 0.25), med = quantile(s, 0.5), q3 = quantile(s, 0.75);
77
+ const iqr = q3 - q1;
78
+ const loF = q1 - 1.5 * iqr, hiF = q3 + 1.5 * iqr;
79
+ const inside = s.filter((v) => v >= loF && v <= hiF);
80
+ return {
81
+ n: s.length, min: s[0], max: s[s.length - 1], q1, median: med, q3, iqr, mean: mean(s),
82
+ whiskerLo: inside.length ? inside[0] : q1, whiskerHi: inside.length ? inside[inside.length - 1] : q3,
83
+ outliers: s.filter((v) => v < loF || v > hiF),
84
+ };
85
+ }
86
+
87
+ const niceBinStep = (span, n) => {
88
+ const raw = span / Math.max(1, n);
89
+ const mag = 10 ** Math.floor(Math.log10(raw));
90
+ const f = raw / mag;
91
+ return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * mag;
92
+ };
93
+
94
+ /**
95
+ * Histograma con anchos "bonitos". Por defecto, regla de Freedman–Diaconis
96
+ * acotada a 5–50 barras. Opciones: bins (número), binWidth, domain [a,b].
97
+ */
98
+ export function histogram(vs, { bins, binWidth, domain } = {}) {
99
+ const s = finite(vs).sort((a, b) => a - b);
100
+ if (!s.length) return [];
101
+ let [lo, hi] = domain || [s[0], s[s.length - 1]];
102
+ if (lo === hi) { lo -= 0.5; hi += 0.5; }
103
+ let step = binWidth;
104
+ if (!step) {
105
+ let n = bins;
106
+ if (!n) {
107
+ const iqr = quantile(s, 0.75) - quantile(s, 0.25);
108
+ const fd = iqr > 0 ? 2 * iqr / Math.cbrt(s.length) : 0;
109
+ n = fd > 0 ? Math.ceil((hi - lo) / fd) : Math.ceil(Math.log2(s.length) + 1);
110
+ n = Math.max(5, Math.min(50, n));
111
+ }
112
+ step = bins ? (hi - lo) / bins : niceBinStep(hi - lo, n);
113
+ }
114
+ const start = bins ? lo : Math.floor(lo / step + 1e-9) * step;
115
+ const count = Math.max(1, Math.ceil((hi - start) / step - 1e-9));
116
+ const out = Array.from({ length: count }, (_, i) => ({ x0: start + i * step, x1: start + (i + 1) * step, count: 0 }));
117
+ for (const v of s) {
118
+ let i = Math.floor((v - start) / step);
119
+ if (i >= count) i = count - 1;
120
+ if (i >= 0) out[i].count++;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Ancho de banda de Scott (el de seaborn por defecto). */
126
+ export function scottBandwidth(vs) {
127
+ const v = finite(vs);
128
+ const s = sd(v);
129
+ const sorted = [...v].sort((a, b) => a - b);
130
+ const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25);
131
+ const sigma = Math.min(s, iqr / 1.349) || s || 1;
132
+ return 1.06 * sigma * v.length ** -0.2;
133
+ }
134
+
135
+ const extentOf = extent;
136
+ /** Sobre este número de valores, la KDE usa binning lineal. */
137
+ export const KDE_BIN_MIN = 2048;
138
+
139
+ /**
140
+ * Densidad por kernel gaussiano. Devuelve [[x, densidad], ...] en una grilla.
141
+ * `cut` extiende la grilla `cut` anchos de banda más allá de los extremos.
142
+ */
143
+ export function kde(vs, { bandwidth, adjust = 1, gridSize = 160, extent, cut = 3, clip } = {}) {
144
+ const v = finite(vs);
145
+ if (!v.length) return [];
146
+ const bw = (bandwidth ?? scottBandwidth(v)) * adjust || 1;
147
+ const [vmin, vmax] = extentOf(v);
148
+ let [lo, hi] = extent || [vmin - cut * bw, vmax + cut * bw];
149
+ if (clip) { lo = Math.max(lo, clip[0]); hi = Math.min(hi, clip[1]); }
150
+ const k = 1 / (v.length * bw * Math.sqrt(2 * Math.PI));
151
+ // Muchos valores: binning lineal en ~1024 nodos (cada valor reparte su peso
152
+ // entre los dos nodos vecinos). Error despreciable frente al ancho de banda.
153
+ let xsK = v, ws = null;
154
+ if (v.length > KDE_BIN_MIN && vmax > vmin) {
155
+ const B = 1024, dx = (vmax - vmin) / (B - 1);
156
+ ws = new Float64Array(B);
157
+ for (const xi of v) {
158
+ const t = (xi - vmin) / dx, j = Math.min(B - 2, Math.floor(t)), f = t - j;
159
+ ws[j] += 1 - f; ws[j + 1] += f;
160
+ }
161
+ xsK = Array.from({ length: B }, (_, j) => vmin + j * dx);
162
+ }
163
+ const out = [];
164
+ for (let i = 0; i < gridSize; i++) {
165
+ const x = lo + ((hi - lo) * i) / (gridSize - 1);
166
+ let d = 0;
167
+ if (ws) {
168
+ for (let j = 0; j < xsK.length; j++) { if (!ws[j]) continue; const u = (x - xsK[j]) / bw; if (u > -6 && u < 6) d += ws[j] * Math.exp(-0.5 * u * u); }
169
+ } else for (const xi of v) { const u = (x - xi) / bw; if (u > -6 && u < 6) d += Math.exp(-0.5 * u * u); }
170
+ out.push([x, d * k]);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /** Función de distribución empírica: [[x, proporción acumulada], ...]. */
176
+ export function ecdf(vs) {
177
+ const s = finite(vs).sort((a, b) => a - b);
178
+ return s.map((x, i) => [x, (i + 1) / s.length]);
179
+ }
180
+
181
+ // ── Regresión ──────────────────────────────────────────────────────────────
182
+
183
+ function solve(A, b) {
184
+ // Gauss-Jordan con pivoteo parcial. Devuelve x tal que A·x = b.
185
+ const n = A.length;
186
+ const M = A.map((row, i) => [...row, b[i]]);
187
+ for (let c = 0; c < n; c++) {
188
+ let p = c;
189
+ for (let r = c + 1; r < n; r++) if (Math.abs(M[r][c]) > Math.abs(M[p][c])) p = r;
190
+ [M[c], M[p]] = [M[p], M[c]];
191
+ const d = M[c][c] || 1e-12;
192
+ for (let k = c; k <= n; k++) M[c][k] /= d;
193
+ for (let r = 0; r < n; r++) if (r !== c) { const f = M[r][c]; for (let k = c; k <= n; k++) M[r][k] -= f * M[c][k]; }
194
+ }
195
+ return M.map((row) => row[n]);
196
+ }
197
+
198
+ function inverse(A) {
199
+ const n = A.length;
200
+ return A.map((_, j) => solve(A, A.map((__, i) => (i === j ? 1 : 0)))).reduce((acc, col, j) => {
201
+ col.forEach((v, i) => { acc[i][j] = v; });
202
+ return acc;
203
+ }, A.map(() => new Array(n).fill(0)));
204
+ }
205
+
206
+ /**
207
+ * Regresión polinomial por mínimos cuadrados (order 1 = lineal), con banda de
208
+ * confianza para la media de y. Devuelve { coef, predict(x), band(x) → [lo,hi], r2, n }.
209
+ */
210
+ export function polyfit(xs, ys, { order = 1, level = 0.95 } = {}) {
211
+ const pts = xs.map((x, i) => [x, ys[i]]).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y));
212
+ const n = pts.length;
213
+ const p = order + 1;
214
+ if (n <= p) {
215
+ const my = mean(pts.map((q) => q[1]));
216
+ return { coef: [my], predict: () => my, band: () => [my, my], r2: 0, n };
217
+ }
218
+ // Centrar x mejora la estabilidad numérica.
219
+ const mx = mean(pts.map((q) => q[0]));
220
+ const row = (x) => Array.from({ length: p }, (_, k) => (x - mx) ** k);
221
+ const X = pts.map(([x]) => row(x));
222
+ const XtX = Array.from({ length: p }, (_, i) => Array.from({ length: p }, (_, j) => X.reduce((a, r) => a + r[i] * r[j], 0)));
223
+ const Xty = Array.from({ length: p }, (_, i) => X.reduce((a, r, k) => a + r[i] * pts[k][1], 0));
224
+ const beta = solve(XtX, Xty);
225
+ const predict = (x) => row(x).reduce((a, v, k) => a + v * beta[k], 0);
226
+ const resid = pts.map(([x, y]) => y - predict(x));
227
+ const sse = sum(resid.map((r) => r * r));
228
+ const my = mean(pts.map((q) => q[1]));
229
+ const sst = sum(pts.map(([, y]) => (y - my) ** 2));
230
+ const s2 = sse / (n - p);
231
+ const inv = inverse(XtX);
232
+ const t = tQuantile(n - p, 0.5 + level / 2);
233
+ const band = (x) => {
234
+ const r = row(x);
235
+ let v = 0;
236
+ for (let i = 0; i < p; i++) for (let j = 0; j < p; j++) v += r[i] * inv[i][j] * r[j];
237
+ const h = t * Math.sqrt(Math.max(0, s2 * v));
238
+ const yh = predict(x);
239
+ return [yh - h, yh + h];
240
+ };
241
+ return { coef: beta, center: mx, predict, band, r2: sst ? 1 - sse / sst : 0, n };
242
+ }
243
+
244
+ /** Pearson r. */
245
+ export function pearson(xs, ys) {
246
+ const pts = xs.map((x, i) => [x, ys[i]]).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y));
247
+ const mx = mean(pts.map((p) => p[0])), my = mean(pts.map((p) => p[1]));
248
+ let sxy = 0, sxx = 0, syy = 0;
249
+ for (const [x, y] of pts) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2; }
250
+ return sxx && syy ? sxy / Math.sqrt(sxx * syy) : 0;
251
+ }
252
+
253
+ /**
254
+ * LOWESS (Cleveland): suavizado local robusto. Devuelve [[x, y], ...] ordenado.
255
+ * Como x está ordenado, los r vecinos más cercanos forman una ventana contigua
256
+ * (dos punteros, sin ordenar distancias). Con muchos puntos (> 400) se ajusta
257
+ * solo en ~200 anclas y se interpola entre ellas (el `delta` de statsmodels).
258
+ */
259
+ export function lowess(xs, ys, { frac = 2 / 3, iters = 2, delta } = {}) {
260
+ const pts = xs.map((x, i) => [x, ys[i]]).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)).sort((a, b) => a[0] - b[0]);
261
+ const n = pts.length;
262
+ if (n < 3) return pts;
263
+ const X = pts.map((p) => p[0]), Y = pts.map((p) => p[1]);
264
+ const r = Math.min(n, Math.max(2, Math.ceil(frac * n)));
265
+ const dl = delta ?? (n > 400 ? (X[n - 1] - X[0]) / 200 : 0);
266
+ // Anclas: el primero, luego el primer punto a más de `dl` del ancla anterior, y el último.
267
+ const anchors = [0];
268
+ if (dl > 0) {
269
+ for (let i = 1; i < n; i++) if (X[i] > X[anchors[anchors.length - 1]] + dl) anchors.push(i);
270
+ if (anchors[anchors.length - 1] !== n - 1) anchors.push(n - 1);
271
+ } else for (let i = 1; i < n; i++) anchors.push(i);
272
+ let robust = new Array(n).fill(1);
273
+ const fit = new Array(n).fill(0);
274
+ for (let it = 0; it <= iters; it++) {
275
+ let lo = 0;
276
+ for (const i of anchors) {
277
+ const x0 = X[i];
278
+ while (lo + r < n && X[lo + r] - x0 < x0 - X[lo]) lo++;
279
+ const hiI = lo + r - 1;
280
+ const h = Math.max(x0 - X[lo], X[hiI] - x0) || 1e-12;
281
+ let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;
282
+ for (let j = lo; j <= hiI; j++) {
283
+ const u = Math.abs(X[j] - x0) / h;
284
+ const w = u < 1 ? (1 - u ** 3) ** 3 * robust[j] : 0;
285
+ sw += w; swx += w * X[j]; swy += w * Y[j]; swxx += w * X[j] ** 2; swxy += w * X[j] * Y[j];
286
+ }
287
+ const den = sw * swxx - swx * swx;
288
+ fit[i] = Math.abs(den) < 1e-12 ? swy / (sw || 1) : ((swxx * swy - swx * swxy) + (sw * swxy - swx * swy) * x0) / den;
289
+ }
290
+ // Entre anclas: interpolación lineal (mismo x → mismo valor).
291
+ for (let a = 0; a < anchors.length - 1; a++) {
292
+ const i0 = anchors[a], i1 = anchors[a + 1];
293
+ for (let i = i0 + 1; i < i1; i++) {
294
+ const span = X[i1] - X[i0];
295
+ fit[i] = span ? fit[i0] + ((fit[i1] - fit[i0]) * (X[i] - X[i0])) / span : fit[i0];
296
+ }
297
+ }
298
+ const res = pts.map(([, y], i) => Math.abs(y - fit[i]));
299
+ const m = median(res) || 1e-12;
300
+ robust = res.map((e) => { const u = e / (6 * m); return u < 1 ? (1 - u * u) ** 2 : 0; });
301
+ }
302
+ return pts.map(([x], i) => [x, fit[i]]);
303
+ }
package/src/svg.js ADDED
@@ -0,0 +1,85 @@
1
+ // Constructor mínimo de SVG como texto (funciona en Node y en el navegador).
2
+
3
+ import { ctx } from './context.js';
4
+
5
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
6
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
7
+
8
+ /** Redondea a 2 decimales para SVG compactos. */
9
+ export const r2 = (n) => Math.round(n * 100) / 100;
10
+
11
+ const ATTR_ALIAS = { className: 'class' };
12
+ const KEEP_CASE = new Set(['viewBox', 'preserveAspectRatio', 'gradientUnits', 'gradientTransform', 'patternUnits', 'patternTransform', 'patternContentUnits', 'clipPathUnits', 'refX', 'refY', 'markerWidth', 'markerHeight', 'markerUnits', 'textLength', 'lengthAdjust', 'startOffset']);
13
+
14
+ export function el(tag, attrs = {}, children) {
15
+ let s = '<' + tag;
16
+ for (const [k, v] of Object.entries(attrs)) {
17
+ if (v === undefined || v === null || v === false) continue;
18
+ const name = ATTR_ALIAS[k] || (KEEP_CASE.has(k) ? k : k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()));
19
+ // Escala de texto global: todo font-size numérico se multiplica aquí.
20
+ const val = typeof v === 'number' ? r2(k === 'fontSize' ? v * ctx.textScale : v) : v;
21
+ s += ` ${name}="${esc(val)}"`;
22
+ }
23
+ if (children === undefined || children === null || children === '') return s + '/>';
24
+ const inner = Array.isArray(children) ? children.flat(Infinity).filter(Boolean).join('') : children;
25
+ return s + '>' + inner + '</' + tag + '>';
26
+ }
27
+
28
+ export const g = (attrs, children) => el('g', attrs, children);
29
+
30
+ /** Texto SVG. `children` se escapa. */
31
+ export function text(x, y, str, attrs = {}) {
32
+ return el('text', { x, y, ...attrs }, esc(str));
33
+ }
34
+
35
+ /**
36
+ * Barra con extremo de dato redondeado (4px) y base recta.
37
+ * dir: hacia dónde crece la barra: 'up' | 'down' | 'right' | 'left'.
38
+ */
39
+ export function barPath(x, y, w, h, dir = 'up', radius = 4) {
40
+ if (w <= 0 || h <= 0) return '';
41
+ const r = Math.max(0, Math.min(radius, (dir === 'up' || dir === 'down' ? w : h) / 2, dir === 'up' || dir === 'down' ? h : w));
42
+ const R = r2;
43
+ const x1 = x + w, y1 = y + h;
44
+ if (r === 0) return `M${R(x)},${R(y)}H${R(x1)}V${R(y1)}H${R(x)}Z`;
45
+ switch (dir) {
46
+ case 'up':
47
+ return `M${R(x)},${R(y1)}V${R(y + r)}Q${R(x)},${R(y)} ${R(x + r)},${R(y)}H${R(x1 - r)}Q${R(x1)},${R(y)} ${R(x1)},${R(y + r)}V${R(y1)}Z`;
48
+ case 'down':
49
+ return `M${R(x)},${R(y)}V${R(y1 - r)}Q${R(x)},${R(y1)} ${R(x + r)},${R(y1)}H${R(x1 - r)}Q${R(x1)},${R(y1)} ${R(x1)},${R(y1 - r)}V${R(y)}Z`;
50
+ case 'right':
51
+ return `M${R(x)},${R(y)}H${R(x1 - r)}Q${R(x1)},${R(y)} ${R(x1)},${R(y + r)}V${R(y1 - r)}Q${R(x1)},${R(y1)} ${R(x1 - r)},${R(y1)}H${R(x)}Z`;
52
+ case 'left':
53
+ return `M${R(x1)},${R(y)}H${R(x + r)}Q${R(x)},${R(y)} ${R(x)},${R(y + r)}V${R(y1 - r)}Q${R(x)},${R(y1)} ${R(x + r)},${R(y1)}H${R(x1)}Z`;
54
+ default:
55
+ return `M${R(x)},${R(y)}H${R(x1)}V${R(y1)}H${R(x)}Z`;
56
+ }
57
+ }
58
+
59
+ /** Texto de tooltip: líneas unidas por \n en un atributo data-tip. */
60
+ export const tip = (...lines) => lines.filter((l) => l !== undefined && l !== null && l !== '').join('\n');
61
+
62
+ /**
63
+ * Capa de puntos. Con pocos puntos, un <circle> por punto (tooltip propio).
64
+ * Con muchos (> threshold), un solo <path> por estilo —los círculos como
65
+ * arcos— y una capa invisible que busca el punto más cercano al pasar el
66
+ * mouse. Es tan rápido como Canvas y conserva lo vectorial (ver docs).
67
+ */
68
+ export function dotLayer(circles, { threshold = 1500, maxTips = 20000 } = {}) {
69
+ if (circles.length <= threshold) return circles.map((c) => el('circle', c)).join('');
70
+ const groups = new Map();
71
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
72
+ const near = [];
73
+ for (const c of circles) {
74
+ const k = [c.fill, c.fillOpacity, c.r, c.stroke, c.strokeWidth].join('|');
75
+ if (!groups.has(k)) groups.set(k, { attrs: c, d: [] });
76
+ const r = +c.r, cx = +c.cx, cy = +c.cy;
77
+ groups.get(k).d.push(`M${r2(cx - r)},${r2(cy)}a${r},${r} 0 1,0 ${r2(2 * r)},0a${r},${r} 0 1,0 ${r2(-2 * r)},0`);
78
+ x0 = Math.min(x0, cx - r); y0 = Math.min(y0, cy - r); x1 = Math.max(x1, cx + r); y1 = Math.max(y1, cy + r);
79
+ if (near.length < maxTips) near.push([r2(cx), r2(cy), c['data-tip'] || '', c.fill]);
80
+ }
81
+ const paths = [...groups.values()].map(({ attrs, d }) =>
82
+ el('path', { d: d.join(''), fill: attrs.fill, fillOpacity: attrs.fillOpacity, stroke: attrs.stroke && attrs.stroke !== 'none' ? attrs.stroke : null, strokeWidth: attrs.stroke && attrs.stroke !== 'none' ? Math.min(+attrs.strokeWidth || 1, 0.75) : null }));
83
+ const hover = el('rect', { class: 'pa-near', x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), fill: 'transparent', 'data-near': JSON.stringify(near) });
84
+ return g({ class: 'pa-dense' }, [...paths, hover]);
85
+ }
package/src/text.js ADDED
@@ -0,0 +1,61 @@
1
+ // Medición aproximada de texto sin DOM (Node, workers, SSR).
2
+ // Calibrada para sans humanistas (Mozilla Text, Inter, system-ui).
3
+ // Respeta la escala de texto global (textScale): el layout se calcula con el
4
+ // tamaño real con que se dibuja el texto.
5
+ import { ctx } from './context.js';
6
+
7
+ const W = {};
8
+ const set = (chars, w) => { for (const c of chars) W[c] = w; };
9
+ set('abcdeghknopqsuvxyzáéóúñü', 0.54);
10
+ set('ijlíï', 0.25);
11
+ set('frt', 0.36);
12
+ set('mw', 0.82);
13
+ set('ABCDEFGHKNOPQRSTUVXYZÁÉÍÓÚÑ', 0.64);
14
+ set('IJ', 0.3);
15
+ set('MW', 0.86);
16
+ set('0123456789', 0.56);
17
+ set(' ', 0.27);
18
+ set('.,:;\'!|', 0.26);
19
+ set('-–()[]/', 0.36);
20
+ set('%', 0.82);
21
+ set('$€', 0.58);
22
+ set('+=<>±×', 0.58);
23
+ set('—', 0.9);
24
+ set('▲▼■●', 0.9);
25
+ set('·', 0.3);
26
+ set('→', 0.9);
27
+
28
+ export function measure(str, size = 12, weight = 400) {
29
+ let w = 0;
30
+ for (const c of String(str ?? '')) w += W[c] ?? 0.56;
31
+ const bold = weight >= 600 ? 1.06 : weight >= 500 ? 1.03 : 1;
32
+ return w * size * ctx.textScale * bold * 1.05; // margen: Mozilla Text es un poco más ancha
33
+ }
34
+
35
+ /** Recorta con elipsis para caber en `max` px. */
36
+ export function truncate(str, max, size = 12, weight = 400) {
37
+ str = String(str ?? '');
38
+ if (measure(str, size, weight) <= max) return str;
39
+ let s = str;
40
+ while (s.length > 1 && measure(s + '…', size, weight) > max) s = s.slice(0, -1);
41
+ return s.trimEnd() + '…';
42
+ }
43
+
44
+ /** Ajusta texto en líneas de ancho `max`. Devuelve un arreglo de líneas. */
45
+ export function wrap(str, max, size = 12, weight = 400, maxLines = Infinity) {
46
+ const words = String(str ?? '').split(/\s+/).filter(Boolean);
47
+ const lines = [];
48
+ let cur = '';
49
+ for (const w of words) {
50
+ const next = cur ? cur + ' ' + w : w;
51
+ if (measure(next, size, weight) <= max || !cur) cur = next;
52
+ else { lines.push(cur); cur = w; }
53
+ }
54
+ if (cur) lines.push(cur);
55
+ if (lines.length > maxLines) {
56
+ const kept = lines.slice(0, maxLines);
57
+ kept[maxLines - 1] = truncate(lines.slice(maxLines - 1).join(' '), max, size, weight);
58
+ return kept;
59
+ }
60
+ return lines;
61
+ }