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.
Files changed (67) hide show
  1. package/AGENTS.md +29 -5
  2. package/README.md +44 -15
  3. package/bin/plotagents.js +40 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +6161 -757
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +229 -10
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/es/agents.md +102 -0
  11. package/docs/es/api.md +172 -0
  12. package/docs/es/charts.md +436 -0
  13. package/docs/es/colorscales.md +46 -0
  14. package/docs/es/dashboards.md +79 -0
  15. package/docs/es/getting-started.md +142 -0
  16. package/docs/es/rendering.md +47 -0
  17. package/docs/es/themes.md +116 -0
  18. package/docs/getting-started.md +6 -2
  19. package/docs/themes.md +39 -9
  20. package/index.d.ts +275 -20
  21. package/package.json +11 -4
  22. package/schema.json +1382 -53
  23. package/skills/plotagents/SKILL.md +105 -0
  24. package/src/axes.js +11 -3
  25. package/src/charts/bar.js +40 -21
  26. package/src/charts/categorical.js +31 -7
  27. package/src/charts/chord.js +6 -4
  28. package/src/charts/choropleth.js +287 -37
  29. package/src/charts/corr.js +512 -0
  30. package/src/charts/dist.js +39 -21
  31. package/src/charts/donut.js +4 -2
  32. package/src/charts/gantt.js +460 -0
  33. package/src/charts/heatmap.js +2 -2
  34. package/src/charts/kpi.js +11 -8
  35. package/src/charts/line.js +187 -43
  36. package/src/charts/network.js +557 -189
  37. package/src/charts/pointmap.js +602 -0
  38. package/src/charts/radar.js +7 -1
  39. package/src/charts/regplot.js +16 -4
  40. package/src/charts/sankey.js +42 -6
  41. package/src/charts/scatter.js +103 -23
  42. package/src/charts/sunburst.js +7 -6
  43. package/src/charts/table.js +882 -0
  44. package/src/charts/treemap.js +12 -9
  45. package/src/charts/wordcloud.js +177 -0
  46. package/src/colorscales.js +193 -0
  47. package/src/dashboard.js +451 -0
  48. package/src/dashed.js +1 -1
  49. package/src/data.js +4 -2
  50. package/src/facet.js +4 -3
  51. package/src/format.js +80 -25
  52. package/src/frame.js +101 -29
  53. package/src/geo/projection.js +76 -9
  54. package/src/geo/registry.js +87 -8
  55. package/src/hierarchy.js +6 -0
  56. package/src/i18n.js +107 -0
  57. package/src/index.js +92 -14
  58. package/src/infer.js +18 -1
  59. package/src/interact.js +157 -36
  60. package/src/lint.js +353 -11
  61. package/src/runtime.js +301 -101
  62. package/src/scale.js +82 -11
  63. package/src/schema.js +235 -36
  64. package/src/stats.js +76 -17
  65. package/src/svg.js +4 -1
  66. package/src/text.js +5 -0
  67. package/src/theme.js +101 -14
@@ -10,7 +10,7 @@ import { formatNumber } from '../format.js';
10
10
  import { humanize } from '../data.js';
11
11
  import { createFrame } from '../frame.js';
12
12
  import { ctx } from '../context.js';
13
- import { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
13
+ import { buildHierarchy, focusNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
14
14
 
15
15
  /** Algoritmo squarified (Bruls, Huizing & van Wijk, 2000). */
16
16
  export function squarify(items, x, y, w, h) {
@@ -57,7 +57,7 @@ export function treemap(spec, theme) {
57
57
  const locale = spec.locale || 'es';
58
58
  const en = String(locale).startsWith('en');
59
59
  const { root, fields, valueField } = buildHierarchy(spec);
60
- const focus = findNode(root, spec.focus);
60
+ const focus = focusNode(root, spec.focus);
61
61
  const levels = height(focus);
62
62
  // El color sigue a la entidad: se asigna sobre el árbol completo, así al
63
63
  // entrar a un grupo sus elementos conservan la familia de color del padre.
@@ -85,7 +85,8 @@ export function treemap(spec, theme) {
85
85
  if (byChange) (function walk(n) { if (!n.children.length) { const c = changeOf(n); if (c !== null) cMax = Math.max(cMax, Math.abs(c)); } n.children.forEach(walk); })(focus);
86
86
  cMax = spec.changeDomain ?? (Math.ceil(Math.min(1, cMax || 0.1) * 20) / 20);
87
87
  // Crecimiento en el tono frío, caída en el cálido; gris neutro sin cambio.
88
- const divRamp = [...theme.diverging].reverse();
88
+ // Con una colormap propia (cmap) se respeta su orientación: bajo → alto.
89
+ const divRamp = theme.cmap ? [...theme.diverging] : [...theme.diverging].reverse();
89
90
  const changeColor = (c) => (c === null ? theme.tint : ramp(divRamp, 0.5 + Math.max(-1, Math.min(1, c / cMax)) / 2));
90
91
 
91
92
  // Leyenda del primer nivel visible, con su participación: la leyenda también informa.
@@ -93,12 +94,14 @@ export function treemap(spec, theme) {
93
94
  let legend = [];
94
95
  if (byChange && spec.legend !== false) {
95
96
  const lab = (v) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${formatNumber(Math.abs(v) * 100, 0, locale)}%`;
96
- legend = [{ type: 'ramp', stops: [...theme.diverging].reverse(), min: lab(-cMax), max: lab(cMax), title: (en ? 'Change' : 'Variación') + (prevLabel ? (en ? ' vs ' : ' vs ') + prevLabel : '') }];
97
- } else if (levels > 1 && top.length > 1 && spec.legend !== false && !focus.path.length) {
98
- const shown = top.filter((c) => !c.folded);
99
- const sh0 = shareFmt(locale);
100
- legend = shown.map((c) => ({ label: `${c.name} ${sh0(c.value, focus.value)}`, color: c.color, shape: 'square' }));
101
- if (top.some((c) => c.folded)) legend.push({ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' });
97
+ legend = [{ type: 'ramp', stops: divRamp, min: lab(-cMax), max: lab(cMax), title: (en ? 'Change' : 'Variación') + (prevLabel ? (en ? ' vs ' : ' vs ') + prevLabel : '') }];
98
+ } else if (height(root) > 1 && root.children.length > 1 && spec.legend !== false) {
99
+ // Leyenda del primer nivel, también al entrar a un grupo: clic = ver solo
100
+ // esa categoría en todo el plano; shift + clic = quitarla (en el navegador).
101
+ const tops = root.children;
102
+ const active = focus.path[0];
103
+ legend = tops.filter((c) => !c.folded).map((c) => ({ label: `${c.name} ${share(c.value, root.value)}`, value: c.name, color: c.color, shape: 'square', active: active === undefined ? undefined : active === c.name }));
104
+ if (tops.some((c) => c.folded)) legend.push({ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' });
102
105
  }
103
106
 
104
107
  // En pantallas angostas el treemap crece hacia abajo: rectángulos legibles.
@@ -0,0 +1,177 @@
1
+ // Nube de palabras, hecha con cuidado.
2
+ //
3
+ // plot({ type: 'wordcloud', data, text: 'palabra', value: 'menciones' })
4
+ // plot({ type: 'wordcloud', text: 'respuestas abiertas de una encuesta…' })
5
+ //
6
+ // Decisiones de diseño (lo que suele salir mal en una nube):
7
+ // · Todas las palabras horizontales: girarlas obliga a torcer el cuello.
8
+ // · El tamaño de letra crece con la raíz de la frecuencia, para que el ÁREA
9
+ // de la palabra (no su altura) sea proporcional al conteo.
10
+ // · Colocación determinista en espiral desde el centro, la más frecuente
11
+ // primero; sin choques, con aire entre palabras. Mismo spec, misma nube.
12
+ // · Color con significado: por grupo (`series`) con leyenda, o un solo tono
13
+ // que se oscurece con la frecuencia. Nunca colores al azar.
14
+ // · Si una palabra no cabe, se achica toda la nube; las que igual no caben
15
+ // se informan en la descripción, nunca se cortan.
16
+ // · Con texto crudo, cuenta palabras quitando las vacías (es/en).
17
+ import { g, text, tip } from '../svg.js';
18
+ import { measure } from '../text.js';
19
+ import { makeFormat, formatNumber } from '../format.js';
20
+ import { colorMap, ramp } from '../theme.js';
21
+ import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
22
+ import { createFrame } from '../frame.js';
23
+ import { ctx } from '../context.js';
24
+
25
+ const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
26
+
27
+ const STOP = new Set((
28
+ // Español
29
+ 'a al algo algún alguna algunas alguno algunos ante antes aquel aquella aquellas aquellos aquí así aun aún bajo bien cada casi como cómo con contra cual cuál cuales cuando cuándo de del desde donde dónde dos el él ella ellas ellos en entre era eran es esa esas ese eso esos esta está están estar este esto estos fue fueron ha había han hasta hay la las le les lo los más me mi mis mismo mucho muy nada ni no nos nosotros nuestra nuestro o os otra otras otro otros para pero poco por porque qué que quien quién se sea ser si sí sin sobre son su sus también tan tanto te tiene tienen todo todos tu tus un una uno unos usted ustedes ya yo estoy estamos fue sido ser hacer hace cosa cosas solo sólo vez veces etc pues entonces luego'
30
+ // English
31
+ + ' a about above after again all am an and any are as at be because been before being below between both but by can could did do does doing down during each few for from further had has have having he her here hers him his how i if in into is it its itself just me more most my no nor not now of off on once only or other our ours out over own same she should so some such than that the their them then there these they this those through to too under until up very was we were what when where which while who whom why will with would you your yours also get got really much many one two'
32
+ ).split(/\s+/));
33
+
34
+ // Palabras vacías extra por idioma (se suman a es/en solo si el locale las pide).
35
+ const STOP_BY = {
36
+ pt: 'não uma uns umas dos das nas nos pelo pela pelos pelas com para por que mas como mais muito já também são foi era tem têm ele ela eles elas isso isto esse essa este esta aos àquele seu sua seus suas meu minha nós você vocês ser estar ter há então quando onde',
37
+ fr: 'les des une dans pour que qui est sont mais comme plus par sur pas avec ces cette cet aux ils elles nous vous ont été être avoir fait très aussi donc ainsi sans sous entre leur leurs son ses mes tes notre votre',
38
+ de: 'der die das den dem des ein eine einer einem einen und oder aber nicht mit von zu für auf ist sind war waren wird werden hat haben auch als wie bei nach aus über sich es sie wir ihr ich du er an im am zum zur dass noch nur sehr',
39
+ };
40
+ // Compara sin tildes: "tambien" y "también" son la misma palabra vacía.
41
+ const plain = (w) => w.normalize('NFD').replace(/\p{M}/gu, '');
42
+ const STOP_PLAIN = new Set([...STOP].map(plain));
43
+
44
+ /** Texto crudo → [{ text, value }] por frecuencia. */
45
+ export function countWords(input, { locale, minLength = 3, stopwords = [] } = {}) {
46
+ const extra = new Set([].concat(stopwords).map((w) => String(w).toLowerCase()));
47
+ for (const w of (STOP_BY[String(locale || '').slice(0, 2)] || '').split(' ')) if (w) extra.add(w);
48
+ const counts = new Map(), shown = new Map();
49
+ const docs = [].concat(input).filter((d) => typeof d === 'string');
50
+ for (const d of docs) for (const m of d.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)) {
51
+ const raw = m[0].replace(/['’-]+$/, '');
52
+ const k = raw.toLowerCase();
53
+ if (k.length < minLength || STOP.has(k) || STOP_PLAIN.has(plain(k)) || extra.has(k) || /^\d+$/.test(k)) continue;
54
+ counts.set(k, (counts.get(k) || 0) + 1);
55
+ // Se muestra la forma más común (respeta "Biobío", "IA", "ChatGPT").
56
+ const f = shown.get(k) || new Map();
57
+ f.set(raw, (f.get(raw) || 0) + 1);
58
+ shown.set(k, f);
59
+ }
60
+ return [...counts].map(([k, v]) => ({ text: [...shown.get(k)].sort((a, b) => b[1] - a[1])[0][0], value: v }));
61
+ }
62
+
63
+ export function wordcloud(spec, theme) {
64
+ const locale = spec.locale || 'es';
65
+ const en = String(locale).startsWith('en');
66
+ // ── Palabras ────────────────────────────────────────────────────────────
67
+ let words, groupF = null, valueF = 'value';
68
+ // `text` es un campo de los datos, o el texto mismo a contar (string o lista de strings).
69
+ const isField = typeof spec.text === 'string' && Array.isArray(spec.data) && spec.data.length > 0 && fieldsOf(spec.data).includes(spec.text);
70
+ const rawText = !isField && (typeof spec.text === 'string' || (Array.isArray(spec.text) && spec.text.length > 0 && spec.text.every((t) => typeof t === 'string')));
71
+ if (rawText || (spec.textField && spec.data)) {
72
+ const docs = rawText ? spec.text : ensureData(spec).map((r) => r[spec.textField]);
73
+ words = countWords(docs, { locale, minLength: spec.minLength ?? 3, stopwords: spec.stopwords });
74
+ valueF = T(locale, 'menciones', 'mentions');
75
+ } else {
76
+ const data = ensureData(spec);
77
+ const fields = fieldsOf(data);
78
+ const textF = spec.text ?? spec.word ?? spec.label ?? spec.x ?? fields.find((f) => /^(word|palabra|term|término|termino|text|texto|tag|keyword)$/i.test(f)) ?? fields.find((f) => fieldKind(data, f) === 'category');
79
+ valueF = spec.value ?? spec.size ?? spec.y ?? fields.find((f) => f !== textF && fieldKind(data, f) === 'number');
80
+ groupF = spec.series ?? spec.hue ?? spec.group ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : null);
81
+ requireField(data, textF, 'text');
82
+ if (valueF) requireField(data, valueF, 'value');
83
+ if (groupF) requireField(data, groupF, 'series');
84
+ const acc = new Map();
85
+ for (const r of data) {
86
+ const t = String(r[textF] ?? '').trim();
87
+ if (!t) continue;
88
+ const v = valueF ? num(r[valueF]) : 1;
89
+ if (!Number.isFinite(v) || v <= 0) continue;
90
+ // Con `series`, la misma palabra en dos grupos son dos entradas (no se funde con el primero).
91
+ const gk = groupF ? String(r[groupF]) : null;
92
+ const key = gk === null ? t : gk + '\u0000' + t;
93
+ const e = acc.get(key) || { text: t, value: 0, group: gk };
94
+ e.value += v;
95
+ acc.set(key, e);
96
+ }
97
+ words = [...acc.values()];
98
+ }
99
+ if (!words.length) throw new PlotError(T(locale, 'La nube no tiene palabras.', 'The word cloud has no words.'), T(locale, 'Pasa filas { palabra, valor } o `text: "…"` con el texto a contar.', 'Pass rows { word, value } or `text: "…"` with the text to count.'));
100
+ words.sort((a, b) => b.value - a.value || a.text.localeCompare(b.text));
101
+ const total = words.reduce((a, w) => a + w.value, 0);
102
+ const top = spec.top ?? 80;
103
+ const dropped = Math.max(0, words.length - top);
104
+ words = words.slice(0, top);
105
+
106
+ // ── Marco y color ───────────────────────────────────────────────────────
107
+ const groups = groupF ? [...new Set(words.map((w) => w.group))] : [];
108
+ const cmap = groups.length ? colorMap(groups, theme, spec.colors) : null;
109
+ const legend = groups.map((gname) => ({ label: gname, color: cmap.get(gname), shape: 'square' }));
110
+ const frame = createFrame(spec, theme, { height: 460 });
111
+ const area = frame.layout(legend);
112
+ const vmax = words[0].value, vmin = words[words.length - 1].value;
113
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map((s) => String(s).toLowerCase()));
114
+ const colorOf = (w, t) => {
115
+ if (cmap) return cmap.get(w.group);
116
+ if (spec.colors && spec.colors[w.text]) return spec.colors[w.text];
117
+ // Un solo tono: más frecuente, más oscuro (en tema oscuro, más claro).
118
+ return ramp(theme.sequential, 0.5 + 0.5 * t);
119
+ };
120
+
121
+ // ── Colocación: espiral, de la más frecuente a la menos ─────────────────
122
+ const XS = ctx.textScale;
123
+ const minF = spec.minFont ?? 11;
124
+ const maxF0 = spec.maxFont ?? Math.max(26, Math.min(80, Math.sqrt(area.w * area.h) / 6.2));
125
+ const cx = area.x + area.w / 2, cy = area.y + area.h / 2;
126
+ const pad = 3;
127
+ const tryLayout = (k) => {
128
+ const maxF = Math.max(minF + 4, maxF0 * k);
129
+ const placed = [], miss = [];
130
+ const cell = 40, grid = new Map();
131
+ const keyOf = (i, j) => i * 100003 + j;
132
+ const cellsOf = (b) => { const out = []; for (let i = Math.floor(b[0] / cell); i <= Math.floor(b[2] / cell); i++) for (let j = Math.floor(b[1] / cell); j <= Math.floor(b[3] / cell); j++) out.push(keyOf(i, j)); return out; };
133
+ const hit = (b) => { for (const c of cellsOf(b)) for (const o of grid.get(c) || []) if (!(b[2] <= o[0] || b[0] >= o[2] || b[3] <= o[1] || b[1] >= o[3])) return true; return false; };
134
+ const add = (b) => { for (const c of cellsOf(b)) { if (!grid.has(c)) grid.set(c, []); grid.get(c).push(b); } };
135
+ const ratio = area.w / Math.max(1, area.h);
136
+ for (const [idx, w] of words.entries()) {
137
+ const t = vmax === vmin ? 0.6 : Math.sqrt((w.value - vmin) / (vmax - vmin));
138
+ const size = Math.max(minF, minF + (maxF - minF) * t);
139
+ const weight = t > 0.55 ? 600 : t > 0.2 ? 500 : 400;
140
+ const bw = measure(w.text, size, weight), bh = size * XS * 1.02;
141
+ if (bw > area.w || bh > area.h) { miss.push(w); continue; }
142
+ let ok = null;
143
+ // Espiral de Arquímedes, achatada según la forma del panel.
144
+ for (let s = 0, r = 0; r < Math.hypot(area.w, area.h); s += 0.12) {
145
+ r = 1.6 * s;
146
+ const x = cx + r * Math.cos(s + idx * 0.7) * Math.sqrt(ratio), y = cy + r * Math.sin(s + idx * 0.7) / Math.sqrt(ratio);
147
+ const b = [x - bw / 2 - pad, y - bh / 2 - pad, x + bw / 2 + pad, y + bh / 2 + pad];
148
+ if (b[0] < area.x || b[2] > area.x + area.w || b[1] < area.y || b[3] > area.y + area.h) continue;
149
+ if (!hit(b)) { ok = { x, y, b }; break; }
150
+ }
151
+ if (!ok) { miss.push(w); continue; }
152
+ add(ok.b);
153
+ placed.push({ ...w, x: ok.x, y: ok.y, size, weight, t, bh });
154
+ }
155
+ return { placed, miss };
156
+ };
157
+ let L = tryLayout(1);
158
+ // Si no caben las importantes (el primer cuarto), se achica la nube entera.
159
+ for (let k = 0.88, n = 0; n < 7 && L.miss.some((w) => words.indexOf(w) < Math.max(5, words.length / 4)); k *= 0.88, n++) L = tryLayout(k);
160
+ const fmt = makeFormat(spec.format, locale, words.map((w) => w.value));
161
+ const body = L.placed.map((w) => {
162
+ const color = colorOf(w, w.t);
163
+ const dim = hl && !hl.has(w.text.toLowerCase());
164
+ const share = total ? ` · ${formatNumber((100 * w.value) / total, (100 * w.value) / total < 10 ? 1 : 0, locale)}%` : '';
165
+ return text(w.x, w.y + w.bh * 0.3, w.text, {
166
+ class: 'pa-m', fill: dim ? theme.dim : hl ? theme.categorical[1] : color, fontSize: w.size, fontWeight: w.weight, textAnchor: 'middle',
167
+ letterSpacing: w.size > 30 ? '-0.02em' : null, 'data-color': color, 'data-tip': tip(w.text, `${fmt(w.value)} ${valueF ? humanize(valueF).toLowerCase() : ''}${share}`.trim(), w.group),
168
+ });
169
+ });
170
+ // ── Descripción ─────────────────────────────────────────────────────────
171
+ const head = words.slice(0, 5).map((w) => `${w.text} (${fmt(w.value)})`).join(', ');
172
+ let desc = en ? `Word cloud of ${L.placed.length} words, sized by ${valueF ? humanize(valueF).toLowerCase() : 'frequency'}. Most frequent: ${head}.` : `Nube de ${L.placed.length} palabras, con tamaño según ${valueF ? humanize(valueF).toLowerCase() : 'frecuencia'}. Más frecuentes: ${head}.`;
173
+ const lost = L.miss.length + dropped;
174
+ if (lost) desc += en ? ` ${lost} less frequent words are not shown.` : ` No se muestran ${lost} palabras menos frecuentes.`;
175
+ return frame.finish([g({ class: 'pa-words' }, body)], { desc, type: 'wordcloud' });
176
+ }
177
+
@@ -0,0 +1,193 @@
1
+ // Escalas de color (colormaps) con nombre: todas las de matplotlib, más las
2
+ // propias de plotagents y escalas a medida.
3
+ //
4
+ // plot({ type: 'heatmap', …, colorScale: 'viridis' }) // secuencial
5
+ // plot({ type: 'choropleth', …, colorScale: 'RdBu_r' }) // divergente, invertida
6
+ // plot({ …, colorScale: ['#FFF7EC', '#7F0000'] }) // propia (2+ colores)
7
+ // plot({ …, colorScale: { colors: [...], reverse: true } })
8
+ // plot({ type: 'bar', …, palette: 'tab10' }) // categórica
9
+ //
10
+ // Los mapas se muestrearon de matplotlib 3.9 en puntos equiespaciados y se
11
+ // interpolan en OKLab (error p99 ≤ 2 ΔE×100 frente al original).
12
+ export const COLORMAPS = {
13
+ // Perceptually uniform sequential
14
+ viridis: '440154472D7B3B528B2C728E21918C28AE805EC962ADDC30FDE725',
15
+ plasma: '0D08874C02A17E03A8AA2395CC4778E66C5CF89540FDC527F0F921',
16
+ inferno: '0000040B0724210C4A3D096557106E71196E8A226AA32C61BC3754D24644E45A31F1731DF98E09FCAC11F9CB35F2EA69FCFFA4',
17
+ magma: '0000040A08221D114736106B51127C6A1C818326819C2E7FB73779D0416FE75263F56B5CFC8961FEA772FEC488FDE2A3FCFDBF',
18
+ cividis: '00224E002E6A1A386F32436D434E6C535A6D61656F6F70737D7C788C88789B9476ABA072BCAE6CCDBB63DEC958F0D846FEE838',
19
+ // Sequential (ColorBrewer)
20
+ Greys: 'FFFFFFFBFBFBF7F7F7F4F4F4F0F0F0EAEAEAE4E4E4DFDFDFD9D9D9D2D2D2CBCBCBC4C4C4BDBDBDB3B3B3A9A9A99F9F9F9595958D8D8D8484847B7B7B7272726A6A6A6262625A5A5A5151514646463A3A3A2F2F2F2424241B1B1B111111080808000000',
21
+ Purples: 'FCFBFDEFEDF5DADAEBBCBDDC9E9AC8807CBA6950A353268F3F007D',
22
+ Blues: 'F7FBFFDEEBF7C6DBEF9DCAE16AAED64191C62070B408509B08306B',
23
+ Greens: 'F7FCF5E5F5E0C7E9C0A0D99B73C47640AA5D228A44006C2C00441B',
24
+ Oranges: 'FFF5EBFEE6CEFDD0A2FDAE6AFD8C3BF16813D84801A536037F2704',
25
+ Reds: 'FFF5F0FEE0D2FCBBA1FC9272FB694AEE3A2CCA181DA30F1567000D',
26
+ YlOrBr: 'FFFFE5FFF7BCFEE390FEC34FFE9829EB6F14CB4B02983404662506',
27
+ YlOrRd: 'FFFFCCFFEDA0FED976FEB24CFD8C3CFC4D2AE2191CBB0026800026',
28
+ OrRd: 'FFF7ECFEE8C8FDD49EFDBA83FC8C59EF6447D62F1EB200007F0000',
29
+ PuRd: 'F7F4F9E7E1EFD4B9DAC993C7DF64AFE72989CD125697004267001F',
30
+ RdPu: 'FFF7F3FDE0DDFCC5C0FA9EB5F767A1DC3397AD017E79017749006A',
31
+ BuPu: 'F7FCFDE0ECF4BFD3E69EBCDA8C95C68C6AB188409C800F7B4D004B',
32
+ GnBu: 'F7FCF0E0F3DBCCEBC5A7DDB57ACCC44DB2D32A8BBE0867AB084081',
33
+ PuBu: 'FFF7FBECE7F2D0D1E6A5BDDB73A9CF358FC0056FAF04598C023858',
34
+ YlGnBu: 'FFFFD9EDF8B1C6E9B47ECDBB40B5C41D90C0225DA8243392081D58',
35
+ PuBuGn: 'FFF7FBECE2F0D0D1E6A5BDDB66A9CF3590BF028189016B58014636',
36
+ BuGn: 'F7FCFDE5F5F9CCECE698D8C965C2A340AD75228A44006C2C00441B',
37
+ YlGn: 'FFFFE5F7FCB9D9F0A3ACDD8E77C67940AA5C228343006737004529',
38
+ // Sequential (2)
39
+ binary: 'FFFFFFF7F7F7EFEFEFE7E7E7DFDFDFD7D7D7CFCFCFC7C7C7BFBFBFB7B7B7AFAFAFA7A7A79F9F9F9797978F8F8F8787877F7F7F7777776F6F6F6767675F5F5F5757574F4F4F4747473F3F3F3737372F2F2F2727271F1F1F1717170F0F0F070707000000',
40
+ gist_yarg: 'FFFFFFF7F7F7EFEFEFE7E7E7DFDFDFD7D7D7CFCFCFC7C7C7BFBFBFB7B7B7AFAFAFA7A7A79F9F9F9797978F8F8F8787877F7F7F7777776F6F6F6767675F5F5F5757574F4F4F4747473F3F3F3737372F2F2F2727271F1F1F1717170F0F0F070707000000',
41
+ gist_gray: '000000080808101010181818202020282828303030383838404040484848505050585858606060686868707070787878808080888888909090989898A0A0A0A8A8A8B0B0B0B8B8B8C0C0C0C8C8C8D0D0D0D8D8D8E0E0E0E8E8E8F0F0F0F8F8F8FFFFFF',
42
+ gray: '000000080808101010181818202020282828303030383838404040484848505050585858606060686868707070787878808080888888909090989898A0A0A0A8A8A8B0B0B0B8B8B8C0C0C0C8C8C8D0D0D0D8D8D8E0E0E0E8E8E8F0F0F0F8F8F8FFFFFF',
43
+ bone: '00000007070A0E0E1315151D1C1C272323312A2A3A31314438384E3F3F584646614D4D6B5455745B5F7B626882697289707B907785977E8F9E8598A58CA2AC93ACB39AB5BAA1BFC1A9C8C8B4CFCFBFD6D6CADDDDD5E4E4E0EBEBEAF2F2F5F9F9FFFFFF',
44
+ pink: '1E0000402525553434664040744A4A8152528D5A5A976262A16868AB6F6FB47575BC7A7AC38280C68E85CA988ACDA28FD0AC94D4B598D7BD9CDAC5A1DDCDA5E0D5A9E3DCADE6E3B1E9E9B6ECECC1EFEFCBF2F2D5F4F4DEF7F7E7FAFAF0FDFDF8FFFFFF',
45
+ spring: 'FF00FFFF20DFFF40BFFF609FFF807FFFA05FFFC03FFFE01FFFFF00',
46
+ summer: '00806620906640A06660B06680C066A0D066C0E066E0F066FFFF66',
47
+ autumn: 'FF0000FF2000FF4000FF6000FF8000FFA000FFC000FFE000FFFF00',
48
+ winter: '0000FF0020EF0040DF0060CF0080BF00A0AF00C09F00E08F00FF80',
49
+ cool: '00FFFF20DFFF40BFFF609FFF807FFFA05FFFC03FFFE01FFFFF00FF',
50
+ Wistia: 'E4FF7AF2F34AFFE81AFFD20DFFBD00FFAE00FFA000FD8F00FC7F00',
51
+ hot: '0B00005F0000B30000FF0800FF5C00FFB000FFFF07FFFF85FFFFFF',
52
+ afmhot: '000000100000200000300000400000500000600000700000800000901000A02000B03000C04000D05000E06000F07000FF8001FF9011FFA021FFB031FFC041FFD051FFE061FFF071FFFF81FFFF91FFFFA1FFFFB1FFFFC1FFFFD1FFFFE1FFFFF1FFFFFF',
53
+ gist_heat: '0000000C00001800002400003000003C00004800005400006000006C00007800008400009000009C0000A80000B40000C00100CC1100D82100E43100F04100FC5100FF6100FF7100FF8103FF9123FFA143FFB163FFC183FFD1A3FFE1C3FFF1E3FFFFFF',
54
+ copper: '0000000A0604140C081E130C281910311F143B2518452C1C4F3220593824633E286D452C774B308051348A5738945E3C9E6440A86A44B27048BC774CC67D50D08354D98958E3905CED9660F79C64FFA267FFA96BFFAF6FFFB573FFBB77FFC27BFFC77F',
55
+ // Diverging
56
+ PiYG: '8E0152CB3289E897C4FAD6EAF7F7F6D9F0BC9ACD61589B28276419',
57
+ PRGn: '40004B621A6E7F3C8D9568A6AE8BBDC7ABD2DEC9E2EDE2EEF6F7F6E4F2E0CBEAC5ABDDA57EC37F50A65A29844010632B00441B',
58
+ BrBG: '543005774508995D13B97B29CFA256E2C787F1DFB3F6EDD7F4F5F5D7EEEBB4E2DB87D0C558B0A72D8F870C716901554B003C30',
59
+ PuOr: '7F3B08BE630AEF9E3CFED7A2F6F6F7CECDE4988DBE5D37902D004B',
60
+ RdGy: '67001FBB2A34E58368FBCEB7FEFEFED6D6D69F9F9F5A5A5A1A1A1A',
61
+ RdBu: '67001FBB2A34E58368FBCEB7F6F7F7C0DCEB68ABD02870B1053061',
62
+ RdYlBu: 'A50026DE402EF98E52FED485FEFFC0D1ECF48EC2DC4F81BA313695',
63
+ RdYlGn: 'A50026DE402EF98E52FED481FEFFBECBE98284CA662AA054006837',
64
+ Spectral: '9E0142C1274ADD4A4CF06744F98E52FDB567FED481FEEC9FFFFFBEEFF9A6D6EE9BB1DFA386CFA55EB9A93D95B84471B25E4FA2',
65
+ coolwarm: '3B4CC06282EA8DB0FEB9D0F9DDDCDCF5C4ACF4987ADD5F4BB40426',
66
+ bwr: '0000FF4040FF8080FFC0C0FFFFFEFEFFBEBEFF7E7EFF3E3EFF0000',
67
+ seismic: '00004C0000790000A60000D30101FF4141FF8181FFC1C1FFFFFDFDFFBDBDFF7D7DFF3D3DFE0000DE0000BE00009E0000800000',
68
+ // Cyclic
69
+ twilight: 'E2D9E2C4CED495B5C77297C16276BA5E51AD592A8F45135C2F14364A1342741E4F983550B25652C27C63CCA389D8C7BEE2D9E2',
70
+ twilight_shifted: '30143745135C592A8F5E51AD6276BA7297C195B5C7C4CED4E2D9E2D8C7BECCA389C27C63B25652983550741E4F4A13422F1436',
71
+ hsv: 'FF0000FF2F00FF5F00FF8E00FFBD00FFEC00E2FF00B3FF0084FF0055FF0025FF0002FF0C00FF3900FF6800FF9700FFC700FFF600D9FF00AAFF007AFF004BFF001CFF1300FF4300FF7200FFA100FFD000FFFA00F9FF00CFFF00A0FF0071FF0041FF0018',
72
+ // Miscellaneous
73
+ ocean: '0080000068100050200038300020400008500010600028700040800058900070A01288B042A0C072B9D0A2D0E0D2E8F0FFFFFF',
74
+ gist_earth: '0000000502740B15751027771539781B497A20597B25677D2B737E30808034857638896C3B8E623F925843974F4C9C475EA04B6FA4507FA8538CAB5699AF58A7B25BB4B65DB9B260BDAB62C0A465C8A779D1AE8CDAB7A0E3C3B6ECD3CEF5E7E6FDFBFB',
75
+ terrain: '3333992848AE1E5EC41373D90888EE009CF400ACC400BC9401CC6621D36D41D97361DF7981E680A1EC86C1F38DE1F993FEFE98EEE990DED587CEC07FBEAC76AE976D9E83658E6E5C815E5691726CA18781B19B96C1B0ACD1C4C1E1D9D7F1EDECFFFFFF',
76
+ gist_stern: '000000920810F51020CD1830A520407D28505530602D38704040804848905050A05858B06060C06868D07070E07878F08080FD8888DB9090B9989897A0A075A8A853B0B031B8B80FC0C011C8C82FD0D04ED8D86CE0E08AE8E8A8F0F0C6F8F8E5FFFFFF',
77
+ gnuplot: '0000002D00324000624E008E5A01B56501D56F02EC7703FA8004FF8706FA8F08EB960AD39C0EB3A3118CA9165FAF1B2FB52000BA2700C02E00C53600CA3F00CF4900D45400D96000DD6D00E27B00E68A00EB9B00EFAD00F3C000F7D500FBEB00FFFF00',
78
+ gnuplot2: '0000000000200000400000600000800000A00000C00000E00100FF1A00FF3300FF4C00FF6500FF7E00FF970AF5B01AE5C92AD5E23AC5FB4AB5FF5AA5FF6A95FF7A85FF8A75FF9A65FFAA55FFBA45FFCA35FFDA25FFEA15FFFA05FFFF43FFFFA7FFFFFF',
79
+ CMRmap: '0000000A0A201313401D1D602626803026903A26A04326B04D26BF602AAF732D9F87308F9A337EB43668CD3A52E73D3BFF4126F8511CF26112EB7109E68100E69107E6A10DE6B114E6C01CE6CA36E6D34FE6DD69E6E683EDEDA3F3F3C3F9F9E3FFFFFF',
80
+ cubehelix: '0000000C050E150B1D19132D1B1E3B192A4617374D15464E16534C1A6046236A3E3172364477315A7A2F727B328A7A3BA1794AB5795EC57A76CF7E8FD484A9D48DC1D198D4CCA6E4C6B4EEC2C3F2C1D1F3C4DDF2CBE8F0D6F0EFE3F6F0F2FBF6FFFFFF',
81
+ brg: '0000FF4000BF80007FC0003FFE0100BE41007E81003EC10000FF00',
82
+ gist_rainbow: 'FF0029FF0200FF2D00FF5800FF8400FFAF00FFDA00F9FF00CDFF00A2FF0077FF004CFF0020FF0000FF0B00FF3600FF6100FF8C00FFB700FFE200F1FF00C6FF009AFF006FFF0043FF0018FF1400FF3F00FF6B00FF9600FFC200FFED00FFFF00E5FF00BF',
83
+ rainbow: '8000FF6032FE4062FA208EF400B5EB20D5E140ECD460FAC580FFB4A0FAA1C0EB8DE0D377FFB360FF8C49FF5F30FF2F18FF0000',
84
+ jet: '0000800000A40000C80000ED0000FF0020FF0040FF0060FF0080FF00A0FF00C0FF00E0FB16FFE130FFC749FFAD63FF947DFF7A97FF60B1FF46CAFF2CE4FF13FEED00FFD000FFB200FF9400FF7700FF5900FF3B00FF1E00E80000C400009F0000800000',
85
+ turbo: '30123B4040A2466BE34294FF28BCEB18DDC232F2986DFE62A4FC3CCDEC34EECF3AFDAC34FB7E21EB500ED02F05A916017A0403',
86
+ nipy_spectral: '0000004B00557B008C8600974300A20000B10000D1002FDD0078DD008DDD009ECF00A8AF00AA9800A773009D1D00A70000BC0000D20000E70000FC0067FF00C4FC00E4F100F5DF00FFC900FFA900FF6900FF0900EC0000DA0000CF0000CC5C5CCCCCCC',
87
+ gist_ncar: '000080003A35004B370014BC0047FF00B8FF00E0FF00FFE500FBB000FB6806FF1539E50068D50078EF0092FF18B6FF38DBFF20FFF800FFE400FFD004FFBC0DFF8107FF4300FF1D00FF0047FF00D5D615FF9F33FECE62F4EE8DF0F3B3F5F9D8FAFEF8FE',
88
+ };
89
+ export const QUALITATIVE = {
90
+ Pastel1: 'FBB4AEB3CDE3CCEBC5DECBE4FED9A6FFFFCCE5D8BDFDDAECF2F2F2',
91
+ Pastel2: 'B3E2CDFDCDACCBD5E8F4CAE4E6F5C9FFF2AEF1E2CCCCCCCC',
92
+ Paired: 'A6CEE31F78B4B2DF8A33A02CFB9A99E31A1CFDBF6FFF7F00CAB2D66A3D9AFFFF99B15928',
93
+ Accent: '7FC97FBEAED4FDC086FFFF99386CB0F0027FBF5B17666666',
94
+ Dark2: '1B9E77D95F027570B3E7298A66A61EE6AB02A6761D666666',
95
+ Set1: 'E41A1C377EB84DAF4A984EA3FF7F00FFFF33A65628F781BF999999',
96
+ Set2: '66C2A5FC8D628DA0CBE78AC3A6D854FFD92FE5C494B3B3B3',
97
+ Set3: '8DD3C7FFFFB3BEBADAFB807280B1D3FDB462B3DE69FCCDE5D9D9D9BC80BDCCEBC5FFED6F',
98
+ tab10: '1F77B4FF7F0E2CA02CD627289467BD8C564BE377C27F7F7FBCBD2217BECF',
99
+ tab20: '1F77B4AEC7E8FF7F0EFFBB782CA02C98DF8AD62728FF98969467BDC5B0D58C564BC49C94E377C2F7B6D27F7F7FC7C7C7BCBD22DBDB8D17BECF9EDAE5',
100
+ tab20b: '393B795254A36B6ECF9C9EDE6379398CA252B5CF6BCEDB9C8C6D31BD9E39E7BA52E7CB94843C39AD494AD6616BE7969C7B4173A55194CE6DBDDE9ED6',
101
+ tab20c: '3182BD6BAED69ECAE1C6DBEFE6550DFD8D3CFDAE6BFDD0A231A35474C476A1D99BC7E9C0756BB19E9AC8BCBDDCDADAEB636363969696BDBDBDD9D9D9',
102
+ };
103
+ // Kind drives defaults: diverging maps center on `mid`; qualitative maps are
104
+ // rejected as continuous scales; misc rainbow maps get a lint warning.
105
+ export const DIVERGING = new Set('PiYG PRGn BrBG PuOr RdGy RdBu RdYlBu RdYlGn Spectral coolwarm bwr seismic'.split(' '));
106
+ export const CYCLIC = new Set('twilight twilight_shifted hsv'.split(' '));
107
+ export const RAINBOW = new Set('jet rainbow gist_rainbow hsv nipy_spectral gist_ncar gist_stern'.split(' '));
108
+
109
+ // ── Interpolación en OKLab ─────────────────────────────────────────────────
110
+ const lin = (c) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
111
+ const gam = (c) => 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.max(0, c) ** (1 / 2.4) - 0.055);
112
+ const hex2 = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
113
+ function toLab(hex) {
114
+ let h = String(hex).replace('#', '');
115
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
116
+ const n = parseInt(h.slice(0, 6), 16), [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(lin);
117
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b), m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b), s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
118
+ return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
119
+ }
120
+ function fromLab([L, a, b]) {
121
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3, m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3, s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
122
+ return '#' + [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s].map(gam).map(hex2).join('').toUpperCase();
123
+ }
124
+
125
+ /** Color en t ∈ [0, 1] a lo largo de `stops` (equiespaciados), en OKLab. */
126
+ export function interpolate(stops, t) {
127
+ t = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
128
+ if (stops.length === 1) return stops[0];
129
+ const p = t * (stops.length - 1), i = Math.min(stops.length - 2, Math.floor(p)), f = p - i;
130
+ const A = toLab(stops[i]), B = toLab(stops[i + 1]);
131
+ return fromLab(A.map((v, k) => v + (B[k] - v) * f));
132
+ }
133
+
134
+ /** `n` colores equiespaciados de una escala (incluye ambos extremos). */
135
+ export function sample(stops, n) {
136
+ if (n <= 1) return [interpolate(stops, 0.5)];
137
+ return Array.from({ length: n }, (_, i) => interpolate(stops, i / (n - 1)));
138
+ }
139
+
140
+ const LOWER = new Map();
141
+ for (const k of [...Object.keys(COLORMAPS), ...Object.keys(QUALITATIVE)]) LOWER.set(k.toLowerCase(), k);
142
+ const unpack = (s) => s.match(/.{6}/g).map((h) => '#' + h);
143
+ const isHex = (c) => typeof c === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(c.trim());
144
+ // Nombres CSS comunes en listas de colores (["white", "steelblue"]): antes se
145
+ // descartaban en silencio y la escala caía a la por defecto.
146
+ const NAMED = {
147
+ black: '#000000', white: '#ffffff', gray: '#808080', grey: '#808080', lightgray: '#d3d3d3', lightgrey: '#d3d3d3', darkgray: '#a9a9a9', darkgrey: '#a9a9a9', silver: '#c0c0c0',
148
+ red: '#ff0000', darkred: '#8b0000', crimson: '#dc143c', tomato: '#ff6347', salmon: '#fa8072', pink: '#ffc0cb', maroon: '#800000',
149
+ orange: '#ffa500', darkorange: '#ff8c00', gold: '#ffd700', yellow: '#ffff00', khaki: '#f0e68c', beige: '#f5f5dc', ivory: '#fffff0', wheat: '#f5deb3', brown: '#a52a2a',
150
+ green: '#008000', darkgreen: '#006400', lime: '#00ff00', limegreen: '#32cd32', olive: '#808000', seagreen: '#2e8b57', forestgreen: '#228b22', teal: '#008080',
151
+ cyan: '#00ffff', aqua: '#00ffff', turquoise: '#40e0d0', lightblue: '#add8e6', skyblue: '#87ceeb', steelblue: '#4682b4', blue: '#0000ff', darkblue: '#00008b', navy: '#000080', royalblue: '#4169e1', cornflowerblue: '#6495ed', dodgerblue: '#1e90ff',
152
+ purple: '#800080', indigo: '#4b0082', violet: '#ee82ee', magenta: '#ff00ff', fuchsia: '#ff00ff', orchid: '#da70d6', lavender: '#e6e6fa', plum: '#dda0dd',
153
+ };
154
+ const toHex = (c) => (isHex(c) ? c.trim() : typeof c === 'string' ? NAMED[c.trim().toLowerCase()] : undefined);
155
+
156
+ /** Nombres disponibles, por tipo. */
157
+ export function colorScaleNames() {
158
+ const all = Object.keys(COLORMAPS);
159
+ return {
160
+ sequential: all.filter((k) => !DIVERGING.has(k) && !CYCLIC.has(k)),
161
+ diverging: [...DIVERGING],
162
+ cyclic: [...CYCLIC],
163
+ qualitative: Object.keys(QUALITATIVE),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Resuelve una escala: nombre de matplotlib (insensible a mayúsculas, `_r`
169
+ * invierte), lista de colores u objeto { colors | name, reverse }.
170
+ * Devuelve { name, stops, kind: 'sequential' | 'diverging' | 'cyclic' | 'qualitative' } o null.
171
+ */
172
+ export function resolveColorScale(v) {
173
+ if (!v) return null;
174
+ if (Array.isArray(v)) {
175
+ const stops = v.map(toHex).filter(Boolean);
176
+ return stops.length >= 2 ? { name: 'custom', stops, kind: 'sequential' } : null;
177
+ }
178
+ if (typeof v === 'object') {
179
+ const base = resolveColorScale(v.colors ?? v.name ?? v.scale);
180
+ if (!base) return null;
181
+ const kind = v.diverging === true || v.mid !== undefined ? 'diverging' : base.kind;
182
+ return { ...base, kind, stops: v.reverse ? [...base.stops].reverse() : base.stops };
183
+ }
184
+ let name = String(v).trim();
185
+ let rev = false;
186
+ if (/_r$/i.test(name) && !LOWER.has(name.toLowerCase())) { name = name.slice(0, -2); rev = true; }
187
+ const key = LOWER.get(name.toLowerCase());
188
+ if (!key) return null;
189
+ const qual = key in QUALITATIVE;
190
+ const stops = unpack(qual ? QUALITATIVE[key] : COLORMAPS[key]);
191
+ if (rev) stops.reverse();
192
+ return { name: key + (rev ? '_r' : ''), stops, kind: qual ? 'qualitative' : DIVERGING.has(key) ? 'diverging' : CYCLIC.has(key) ? 'cyclic' : 'sequential' };
193
+ }