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/theme.js ADDED
@@ -0,0 +1,311 @@
1
+ // Temas de plotagents.
2
+ //
3
+ // Las paletas categóricas están validadas (no a ojo) con el validador de
4
+ // paletas OKLab: banda de luminosidad, piso de croma, separación bajo
5
+ // daltonismo (protan/deutan) entre vecinos y piso de visión normal.
6
+ // papel · adyacentes ΔE CVD 13,7 · visión normal 22,6 · primeros 3 todos-los-pares 16,5
7
+ // noche · adyacentes ΔE CVD 9,1 · visión normal 19,6 · primeros 3 todos-los-pares 9,1
8
+ // El ORDEN de los colores es parte de la seguridad: no lo cambies sin volver
9
+ // a validar.
10
+
11
+ export const FONT =
12
+ "'Mozilla Text', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
13
+ export const MONO = "'Fragment Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace";
14
+ export const FONT_CSS =
15
+ 'https://fonts.googleapis.com/css2?family=Mozilla+Text:wght@400;500;600;700&display=swap';
16
+
17
+ import { resolveColorScale, interpolate, sample } from './colorscales.js';
18
+
19
+ // Tokens no cromáticos, compartidos por ambos temas y configurables por JSON.
20
+ const STRUCT = {
21
+ font: { family: FONT, mono: MONO, import: FONT_CSS },
22
+ type: { title: 22, subtitle: 14.5, label: 12, axis: 11.5, legend: 12.5, source: 11, titleWeight: 600 },
23
+ marks: { barRadius: 4, barMax: 36, barMaxH: 22, lineWidth: 2.25, pointRadius: 4, gap: 2, ring: 2, areaOpacity: 0.12, bandOpacity: 0.16, dotOpacity: 0.85 },
24
+ axes: { inside: true, grid: true },
25
+ layout: { padding: 24, paddingSmall: 12 },
26
+ };
27
+
28
+ const papel = {
29
+ name: 'papel',
30
+ mode: 'light',
31
+ surface: '#FCFBF9',
32
+ ink: '#141A3F',
33
+ lede: '#4A5070',
34
+ muted: '#7C819C',
35
+ grid: '#E9EBF3',
36
+ axis: '#C9CDE0',
37
+ tint: '#ECEEF8',
38
+ tint2: '#D9DDF3',
39
+ dim: '#CDD1E3', // marcas fuera de foco (highlight)
40
+ other: '#A5AAC0', // "Otros"
41
+ link: 0.32,
42
+ categorical: [
43
+ '#3A47B8', // índigo
44
+ '#D42A5E', // magenta
45
+ '#E8A33D', // ámbar
46
+ '#1E9C89', // turquesa
47
+ '#7B5CD6', // violeta
48
+ '#B5562E', // óxido
49
+ '#5BA3E0', // cielo
50
+ '#8E9A2F', // oliva
51
+ ],
52
+ // Secuencial: un solo tono, claro → oscuro.
53
+ sequential: ['#EEF0FA', '#D9DDF3', '#B3BAE8', '#8590D8', '#5A66C7', '#3A47B8', '#26307F', '#141A3F'],
54
+ // Divergente: índigo ↔ gris neutro ↔ magenta.
55
+ diverging: ['#26307F', '#5A66C7', '#A9B0E2', '#ECEBE6', '#EFA9BE', '#D9557E', '#9E1D45'],
56
+ status: { good: '#178A68', warning: '#B87A00', critical: '#C9531A' },
57
+ ...STRUCT,
58
+ };
59
+
60
+ const noche = {
61
+ name: 'noche',
62
+ mode: 'dark',
63
+ surface: '#0B0F2B',
64
+ ink: '#F2F3FA',
65
+ lede: '#B4BAE0',
66
+ muted: '#8A90B4',
67
+ grid: '#1B2147',
68
+ axis: '#2E3666',
69
+ tint: '#151A3D',
70
+ tint2: '#1F2654',
71
+ dim: '#2A3160',
72
+ other: '#4A5280',
73
+ link: 0.4,
74
+ categorical: ['#6170E0', '#E63369', '#C4851C', '#22A892', '#9780E8', '#D06A3E', '#499BDA', '#909D2D'],
75
+ sequential: ['#141A3F', '#1F2862', '#2C3888', '#3A47B8', '#6170E0', '#8E99EA', '#BEC5F3', '#E6E9FB'],
76
+ diverging: ['#BEC5F3', '#7F8BE6', '#3F4CA8', '#2A2E48', '#A3294F', '#E8577F', '#F7B3C7'],
77
+ status: { good: '#2BB38A', warning: '#E0A030', critical: '#E8743F' },
78
+ ...STRUCT,
79
+ };
80
+
81
+ export const themes = { papel, noche, light: papel, dark: noche };
82
+
83
+ // Paletas con nombre (categóricas). La de fábrica está validada; las demás
84
+ // son conocidas y se pueden revisar con validatePalette().
85
+ export const palettes = {
86
+ cdia: papel.categorical,
87
+ 'cdia-noche': noche.categorical,
88
+ 'okabe-ito': ['#0072B2', '#E69F00', '#009E73', '#CC79A7', '#56B4E9', '#D55E00', '#F0E442', '#000000'],
89
+ tableau10: ['#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F', '#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC'],
90
+ udec: ['#223C6A', '#E69B0A', '#D21428', '#5C7DB8'],
91
+ };
92
+ // Rampas secuenciales (un tono, claro → oscuro) y divergentes.
93
+ export const ramps = {
94
+ indigo: papel.sequential,
95
+ magenta: ['#FBEAF0', '#F6CADA', '#EE9DBA', '#E46F98', '#D42A5E', '#A81F4A', '#7A1636', '#4D0E22'],
96
+ teal: ['#E6F5F2', '#C2E8E1', '#8CD3C6', '#52BBA8', '#1E9C89', '#167A6B', '#0F5A4F', '#093B34'],
97
+ amber: ['#FDF3E1', '#F9E0B5', '#F4C77E', '#EEAD4E', '#E8A33D', '#BF7F1F', '#8C5C14', '#5C3C0C'],
98
+ 'indigo-magenta': papel.diverging,
99
+ 'teal-rust': ['#0F5A4F', '#1E9C89', '#8CD3C6', '#ECEBE6', '#E8B49A', '#C46A3F', '#7F3A1B'],
100
+ };
101
+
102
+ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
103
+ export function deepMerge(base, over) {
104
+ if (!isObj(over)) return base;
105
+ const out = { ...base };
106
+ for (const [k, v] of Object.entries(over)) out[k] = isObj(v) && isObj(base[k]) ? deepMerge(base[k], v) : v;
107
+ return out;
108
+ }
109
+
110
+ // Colores válidos: hex, rgb()/hsl() y nombres CSS. Cualquier otra cosa se
111
+ // descarta: los colores viajan a atributos, CSS y tooltips (sin inyección).
112
+ const COLOR_RE = /^(#[0-9a-f]{3,8}|(rgba?|hsla?)\([\d\s.,%/+-]+\)|[a-z]{3,20}|transparent|none)$/i;
113
+ export const isColor = (c) => typeof c === 'string' && COLOR_RE.test(c.trim());
114
+ const COLOR_KEYS = ['surface', 'ink', 'lede', 'muted', 'grid', 'axis', 'tint', 'tint2', 'dim', 'other'];
115
+
116
+ /** Alias legibles de los tokens (background → surface, text → ink, …). */
117
+ function normalizeTokens(t) {
118
+ if (!isObj(t)) return t;
119
+ const o = { ...t };
120
+ if (o.background !== undefined && o.surface === undefined) o.surface = o.background;
121
+ if (o.text !== undefined && o.ink === undefined) o.ink = o.text;
122
+ if (o.textMuted !== undefined && o.muted === undefined) o.muted = o.textMuted;
123
+ // grid: color o { color, dash, width }.
124
+ if (isObj(o.grid)) { o.gridStyle = { ...(o.gridStyle || {}), ...o.grid }; if (o.grid.color) o.grid = o.grid.color; else delete o.grid; }
125
+ if (isObj(o.axis)) { o.axisStyle = { ...(o.axisStyle || {}), ...o.axis }; if (o.axis.color) o.axis = o.axis.color; else delete o.axis; }
126
+ if (o.barRadius !== undefined) o.marks = { ...(o.marks || {}), barRadius: o.barRadius };
127
+ if (o.radius !== undefined) o.marks = { ...(o.marks || {}), barRadius: o.marks?.barRadius ?? o.radius, radius: o.radius };
128
+ if (o.lineWidth !== undefined) o.marks = { ...(o.marks || {}), lineWidth: o.lineWidth };
129
+ if (o.padding !== undefined) o.layout = { ...(o.layout || {}), padding: o.padding };
130
+ if (typeof o.font === 'string') o.font = fontSpec(o.font);
131
+ return o;
132
+ }
133
+
134
+ /** Descarta colores inválidos (vuelve al del tema base). */
135
+ function sanitize(theme, base) {
136
+ for (const k of COLOR_KEYS) if (theme[k] !== undefined && !isColor(theme[k])) theme[k] = base[k];
137
+ for (const k of ['categorical', 'sequential', 'diverging']) {
138
+ if (Array.isArray(theme[k])) { const ok = theme[k].filter(isColor); theme[k] = ok.length ? ok : base[k]; }
139
+ }
140
+ if (theme.accent !== undefined && !isColor(theme.accent)) delete theme.accent;
141
+ if (isObj(theme.tooltip)) for (const k of ['bg', 'ink']) if (theme.tooltip[k] !== undefined && !isColor(theme.tooltip[k])) delete theme.tooltip[k];
142
+ if (isObj(theme.status)) for (const k of Object.keys(theme.status)) if (!isColor(theme.status[k])) theme.status[k] = base.status?.[k];
143
+ if (theme.font && typeof theme.font.import === 'string' && !/^https:\/\/[^\s'"()<>\\]+$/.test(theme.font.import)) theme.font = { ...theme.font, import: undefined };
144
+ if (theme.font && typeof theme.font.family === 'string') theme.font = { ...theme.font, family: theme.font.family.replace(/[<>{};]/g, '') };
145
+ return theme;
146
+ }
147
+
148
+ /** Paleta categórica por nombre: propias, matplotlib cualitativas o muestras de una colormap. */
149
+ function namedPalette(name, n = 8) {
150
+ if (palettes[name]) return palettes[name];
151
+ const cs = resolveColorScale(name);
152
+ if (!cs) return null;
153
+ if (cs.kind === 'qualitative') return cs.stops;
154
+ // Escala continua como paleta: n muestras sin los extremos (estilo seaborn).
155
+ return Array.from({ length: n }, (_, i) => interpolate(cs.stops, (i + 1) / (n + 1)));
156
+ }
157
+
158
+ /** Rampa secuencial/divergente por nombre: propias o cualquier colormap. */
159
+ function namedRamp(name) {
160
+ if (ramps[name]) return ramps[name];
161
+ const cs = resolveColorScale(name);
162
+ return cs && cs.kind !== 'qualitative' ? sample(cs.stops, 17) : null;
163
+ }
164
+
165
+ /**
166
+ * Registra un tema con nombre (se fusiona sobre `extends`, por defecto papel):
167
+ * registerTheme('acme', { background: '#FFF', ink: '#111', categorical: 'tab10', grid: { dash: '2 3' } })
168
+ * configure({ theme: 'acme' }) // todos los gráficos
169
+ */
170
+ export function registerTheme(name, theme) {
171
+ if (!name || typeof name !== 'string') throw new TypeError('registerTheme(name, theme): falta el nombre.');
172
+ if (name === 'papel' || name === 'noche' || name === 'light' || name === 'dark') throw new TypeError(`"${name}" es un tema de fábrica: usa otro nombre o { extends: "${name}" }.`);
173
+ themes[name] = { ...getTheme(theme || {}), name };
174
+ return themes[name];
175
+ }
176
+
177
+ /**
178
+ * Tema = JSON. Acepta un nombre ("papel", "noche" o uno registrado) o un
179
+ * objeto parcial que se fusiona en profundidad sobre `extends` (por defecto
180
+ * "papel"):
181
+ * { extends: "noche", categorical: ["#..."], type: { title: 24 }, marks: { lineWidth: 3 } }
182
+ * Alias: background, text, accent, grid { color, dash, width }, tooltip { bg, ink }, radius, barRadius, font.
183
+ * `categorical`, `sequential` y `diverging` aceptan también un nombre de paleta o colormap.
184
+ */
185
+ export function getTheme(t) {
186
+ if (!t) return papel;
187
+ if (typeof t === 'string') return themes[t] || papel;
188
+ const over = normalizeTokens(t);
189
+ const base = (typeof over.extends === 'string' ? themes[over.extends] : isObj(over.extends) ? getTheme(over.extends) : null) || (over.mode === 'dark' ? noche : papel);
190
+ const merged = deepMerge(base, over);
191
+ delete merged.extends;
192
+ if (typeof merged.categorical === 'string') merged.categorical = namedPalette(merged.categorical) || base.categorical;
193
+ if (typeof merged.sequential === 'string') merged.sequential = namedRamp(merged.sequential) || base.sequential;
194
+ if (typeof merged.diverging === 'string') merged.diverging = namedRamp(merged.diverging) || base.diverging;
195
+ return sanitize(merged, base);
196
+ }
197
+
198
+ /** Tema efectivo de un spec: `theme` + atajos `palette`, `ramp` y `colorScale`. */
199
+ export function themeFor(spec = {}) {
200
+ let t = getTheme(spec.theme);
201
+ if (spec.palette) {
202
+ const p = Array.isArray(spec.palette) ? spec.palette.filter(isColor) : namedPalette(spec.palette);
203
+ if (p && p.length) t = { ...t, categorical: p };
204
+ }
205
+ if (spec.ramp) {
206
+ const r = Array.isArray(spec.ramp) ? spec.ramp.filter(isColor) : namedRamp(spec.ramp);
207
+ if (r && r.length >= 2) t = { ...t, sequential: r };
208
+ }
209
+ const cs = resolveColorScale(spec.colorScale ?? spec.cmap ?? spec.colormap);
210
+ if (cs && cs.kind !== 'qualitative') {
211
+ const stops = sample(cs.stops, 17);
212
+ // `cmap`: la escala se usa completa (sin recortar el extremo claro como la rampa de fábrica).
213
+ t = cs.kind === 'diverging' ? { ...t, diverging: stops, cmap: cs.name } : { ...t, sequential: [stops[0], ...stops], cmap: cs.name };
214
+ }
215
+ return t;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Utilidades de color
220
+
221
+ export function hexToRgb(hex) {
222
+ let h = String(hex).replace('#', '');
223
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
224
+ const n = parseInt(h.slice(0, 6), 16);
225
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
226
+ }
227
+
228
+ export function rgbToHex([r, g, b]) {
229
+ const f = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
230
+ return ('#' + f(r) + f(g) + f(b)).toUpperCase();
231
+ }
232
+
233
+ const lin = (c) => {
234
+ c /= 255;
235
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
236
+ };
237
+ const delin = (c) => 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
238
+
239
+ export function luminance(hex) {
240
+ const [r, g, b] = hexToRgb(hex).map(lin);
241
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
242
+ }
243
+
244
+ export function contrast(a, b) {
245
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
246
+ return (hi + 0.05) / (lo + 0.05);
247
+ }
248
+
249
+ /** Texto blanco o tinta según qué contraste mejor sobre el relleno. */
250
+ export function textOn(fill, theme) {
251
+ const dark = theme.mode === 'dark' ? '#0B0F2B' : theme.ink;
252
+ return contrast('#FFFFFF', fill) >= contrast(dark, fill) ? '#FFFFFF' : dark;
253
+ }
254
+
255
+ /** Interpola en RGB lineal a lo largo de una rampa de colores. t ∈ [0,1]. */
256
+ export function ramp(stops, t) {
257
+ t = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
258
+ const p = t * (stops.length - 1);
259
+ const i = Math.min(stops.length - 2, Math.floor(p));
260
+ const f = p - i;
261
+ const a = hexToRgb(stops[i]).map(lin);
262
+ const b = hexToRgb(stops[i + 1]).map(lin);
263
+ return rgbToHex(a.map((v, k) => delin(v + (b[k] - v) * f)));
264
+ }
265
+
266
+ /**
267
+ * Asigna colores a entidades en orden fijo. El color sigue a la entidad,
268
+ * nunca a su posición: pasar `colors: {"Biobío": "#..."}` fija el color.
269
+ * Más allá de 8 entidades no se inventan tonos: el resto va en gris "Otros".
270
+ */
271
+ export function colorMap(keys, theme, overrides = {}) {
272
+ const map = new Map();
273
+ // Los colores fijados por el usuario no se repiten en las demás entidades.
274
+ const taken = new Set(Object.values(overrides || {}).map((c) => String(c).toLowerCase()));
275
+ const free = theme.categorical.filter((c) => !taken.has(String(c).toLowerCase()));
276
+ let i = 0;
277
+ for (const k of keys) {
278
+ if (overrides && overrides[k]) map.set(k, overrides[k]);
279
+ else map.set(k, i < free.length ? free[i++] : theme.other);
280
+ }
281
+ return map;
282
+ }
283
+
284
+ /**
285
+ * Versión legible de un color de serie para usarlo en TEXTO: lo oscurece
286
+ * (o aclara, en tema oscuro) hasta ≥ 4,5:1 contra el fondo. Así una etiqueta
287
+ * puede llevar el color de su línea sin perder legibilidad.
288
+ */
289
+ export function readable(color, theme, min = 4.5) {
290
+ const target = theme.mode === 'dark' ? '#FFFFFF' : theme.ink;
291
+ let c = color;
292
+ for (let t = 0; t <= 1.0001 && contrast(c, theme.surface) < min; t += 0.05) {
293
+ const A = hexToRgb(color), B = hexToRgb(target);
294
+ c = rgbToHex(A.map((v, i) => v + (B[i] - v) * t));
295
+ }
296
+ return c;
297
+ }
298
+
299
+ /**
300
+ * Tipografía a partir de un nombre: "Poppins" → pila con respaldo y la URL de
301
+ * Google Fonts para cargarla. Una pila completa ("'Inter', sans-serif") se usa
302
+ * tal cual. También acepta { family, import }.
303
+ */
304
+ export function fontSpec(font) {
305
+ if (!font) return null;
306
+ if (typeof font === 'object') return { ...font };
307
+ const f = String(font).trim();
308
+ if (/[,'"]/.test(f)) return { family: f };
309
+ const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@400;500;600;700&display=swap`;
310
+ return { family: `'${f}', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif`, import: url };
311
+ }