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
@@ -0,0 +1,163 @@
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
+ /** Texto crudo → [{ text, value }] por frecuencia. */
35
+ export function countWords(input, { locale, minLength = 3, stopwords = [] } = {}) {
36
+ const extra = new Set([].concat(stopwords).map((w) => String(w).toLowerCase()));
37
+ const counts = new Map(), shown = new Map();
38
+ const docs = [].concat(input).filter((d) => typeof d === 'string');
39
+ for (const d of docs) for (const m of d.matchAll(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)) {
40
+ const raw = m[0].replace(/['’-]+$/, '');
41
+ const k = raw.toLowerCase();
42
+ if (k.length < minLength || STOP.has(k) || extra.has(k) || /^\d+$/.test(k)) continue;
43
+ counts.set(k, (counts.get(k) || 0) + 1);
44
+ // Se muestra la forma más común (respeta "Biobío", "IA", "ChatGPT").
45
+ const f = shown.get(k) || new Map();
46
+ f.set(raw, (f.get(raw) || 0) + 1);
47
+ shown.set(k, f);
48
+ }
49
+ return [...counts].map(([k, v]) => ({ text: [...shown.get(k)].sort((a, b) => b[1] - a[1])[0][0], value: v }));
50
+ }
51
+
52
+ export function wordcloud(spec, theme) {
53
+ const locale = spec.locale || 'es';
54
+ const en = String(locale).startsWith('en');
55
+ // ── Palabras ────────────────────────────────────────────────────────────
56
+ let words, groupF = null, valueF = 'value';
57
+ // `text` es un campo de los datos, o el texto mismo a contar (string o lista de strings).
58
+ const isField = typeof spec.text === 'string' && Array.isArray(spec.data) && spec.data.length > 0 && fieldsOf(spec.data).includes(spec.text);
59
+ const rawText = !isField && (typeof spec.text === 'string' || (Array.isArray(spec.text) && spec.text.length > 0 && spec.text.every((t) => typeof t === 'string')));
60
+ if (rawText || (spec.textField && spec.data)) {
61
+ const docs = rawText ? spec.text : ensureData(spec).map((r) => r[spec.textField]);
62
+ words = countWords(docs, { locale, minLength: spec.minLength ?? 3, stopwords: spec.stopwords });
63
+ valueF = T(locale, 'menciones', 'mentions');
64
+ } else {
65
+ const data = ensureData(spec);
66
+ const fields = fieldsOf(data);
67
+ 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');
68
+ valueF = spec.value ?? spec.size ?? spec.y ?? fields.find((f) => f !== textF && fieldKind(data, f) === 'number');
69
+ groupF = spec.series ?? spec.hue ?? spec.group ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : null);
70
+ requireField(data, textF, 'text');
71
+ if (valueF) requireField(data, valueF, 'value');
72
+ if (groupF) requireField(data, groupF, 'series');
73
+ const acc = new Map();
74
+ for (const r of data) {
75
+ const t = String(r[textF] ?? '').trim();
76
+ if (!t) continue;
77
+ const v = valueF ? num(r[valueF]) : 1;
78
+ if (!Number.isFinite(v) || v <= 0) continue;
79
+ const e = acc.get(t) || { text: t, value: 0, group: groupF ? String(r[groupF]) : null };
80
+ e.value += v;
81
+ acc.set(t, e);
82
+ }
83
+ words = [...acc.values()];
84
+ }
85
+ 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.'));
86
+ words.sort((a, b) => b.value - a.value || a.text.localeCompare(b.text));
87
+ const total = words.reduce((a, w) => a + w.value, 0);
88
+ const top = spec.top ?? 80;
89
+ const dropped = Math.max(0, words.length - top);
90
+ words = words.slice(0, top);
91
+
92
+ // ── Marco y color ───────────────────────────────────────────────────────
93
+ const groups = groupF ? [...new Set(words.map((w) => w.group))] : [];
94
+ const cmap = groups.length ? colorMap(groups, theme, spec.colors) : null;
95
+ const legend = groups.map((gname) => ({ label: gname, color: cmap.get(gname), shape: 'square' }));
96
+ const frame = createFrame(spec, theme, { height: 460 });
97
+ const area = frame.layout(legend);
98
+ const vmax = words[0].value, vmin = words[words.length - 1].value;
99
+ const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map((s) => String(s).toLowerCase()));
100
+ const colorOf = (w, t) => {
101
+ if (cmap) return cmap.get(w.group);
102
+ if (spec.colors && spec.colors[w.text]) return spec.colors[w.text];
103
+ // Un solo tono: más frecuente, más oscuro (en tema oscuro, más claro).
104
+ return ramp(theme.sequential, 0.5 + 0.5 * t);
105
+ };
106
+
107
+ // ── Colocación: espiral, de la más frecuente a la menos ─────────────────
108
+ const XS = ctx.textScale;
109
+ const minF = spec.minFont ?? 11;
110
+ const maxF0 = spec.maxFont ?? Math.max(26, Math.min(80, Math.sqrt(area.w * area.h) / 6.2));
111
+ const cx = area.x + area.w / 2, cy = area.y + area.h / 2;
112
+ const pad = 3;
113
+ const tryLayout = (k) => {
114
+ const maxF = Math.max(minF + 4, maxF0 * k);
115
+ const placed = [], miss = [];
116
+ const cell = 40, grid = new Map();
117
+ const keyOf = (i, j) => i * 100003 + j;
118
+ 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; };
119
+ 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; };
120
+ const add = (b) => { for (const c of cellsOf(b)) { if (!grid.has(c)) grid.set(c, []); grid.get(c).push(b); } };
121
+ const ratio = area.w / Math.max(1, area.h);
122
+ for (const [idx, w] of words.entries()) {
123
+ const t = vmax === vmin ? 0.6 : Math.sqrt((w.value - vmin) / (vmax - vmin));
124
+ const size = Math.max(minF, minF + (maxF - minF) * t);
125
+ const weight = t > 0.55 ? 600 : t > 0.2 ? 500 : 400;
126
+ const bw = measure(w.text, size, weight), bh = size * XS * 1.02;
127
+ if (bw > area.w || bh > area.h) { miss.push(w); continue; }
128
+ let ok = null;
129
+ // Espiral de Arquímedes, achatada según la forma del panel.
130
+ for (let s = 0, r = 0; r < Math.hypot(area.w, area.h); s += 0.12) {
131
+ r = 1.6 * s;
132
+ 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);
133
+ const b = [x - bw / 2 - pad, y - bh / 2 - pad, x + bw / 2 + pad, y + bh / 2 + pad];
134
+ if (b[0] < area.x || b[2] > area.x + area.w || b[1] < area.y || b[3] > area.y + area.h) continue;
135
+ if (!hit(b)) { ok = { x, y, b }; break; }
136
+ }
137
+ if (!ok) { miss.push(w); continue; }
138
+ add(ok.b);
139
+ placed.push({ ...w, x: ok.x, y: ok.y, size, weight, t, bh });
140
+ }
141
+ return { placed, miss };
142
+ };
143
+ let L = tryLayout(1);
144
+ // Si no caben las importantes (el primer cuarto), se achica la nube entera.
145
+ 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);
146
+ const fmt = makeFormat(spec.format, locale, words.map((w) => w.value));
147
+ const body = L.placed.map((w) => {
148
+ const color = colorOf(w, w.t);
149
+ const dim = hl && !hl.has(w.text.toLowerCase());
150
+ const share = total ? ` · ${formatNumber((100 * w.value) / total, (100 * w.value) / total < 10 ? 1 : 0, locale)}%` : '';
151
+ return text(w.x, w.y + w.bh * 0.3, w.text, {
152
+ class: 'pa-m', fill: dim ? theme.dim : hl ? theme.categorical[1] : color, fontSize: w.size, fontWeight: w.weight, textAnchor: 'middle',
153
+ 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),
154
+ });
155
+ });
156
+ // ── Descripción ─────────────────────────────────────────────────────────
157
+ const head = words.slice(0, 5).map((w) => `${w.text} (${fmt(w.value)})`).join(', ');
158
+ 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}.`;
159
+ const lost = L.miss.length + dropped;
160
+ if (lost) desc += en ? ` ${lost} less frequent words are not shown.` : ` No se muestran ${lost} palabras menos frecuentes.`;
161
+ return frame.finish([g({ class: 'pa-words' }, body)], { desc, type: 'wordcloud' });
162
+ }
163
+
@@ -0,0 +1,182 @@
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
+
145
+ /** Nombres disponibles, por tipo. */
146
+ export function colorScaleNames() {
147
+ const all = Object.keys(COLORMAPS);
148
+ return {
149
+ sequential: all.filter((k) => !DIVERGING.has(k) && !CYCLIC.has(k)),
150
+ diverging: [...DIVERGING],
151
+ cyclic: [...CYCLIC],
152
+ qualitative: Object.keys(QUALITATIVE),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Resuelve una escala: nombre de matplotlib (insensible a mayúsculas, `_r`
158
+ * invierte), lista de colores u objeto { colors | name, reverse }.
159
+ * Devuelve { name, stops, kind: 'sequential' | 'diverging' | 'cyclic' | 'qualitative' } o null.
160
+ */
161
+ export function resolveColorScale(v) {
162
+ if (!v) return null;
163
+ if (Array.isArray(v)) {
164
+ const stops = v.filter(isHex);
165
+ return stops.length >= 2 ? { name: 'custom', stops, kind: 'sequential' } : null;
166
+ }
167
+ if (typeof v === 'object') {
168
+ const base = resolveColorScale(v.colors ?? v.name ?? v.scale);
169
+ if (!base) return null;
170
+ const kind = v.diverging === true || v.mid !== undefined ? 'diverging' : base.kind;
171
+ return { ...base, kind, stops: v.reverse ? [...base.stops].reverse() : base.stops };
172
+ }
173
+ let name = String(v).trim();
174
+ let rev = false;
175
+ if (/_r$/i.test(name) && !LOWER.has(name.toLowerCase())) { name = name.slice(0, -2); rev = true; }
176
+ const key = LOWER.get(name.toLowerCase());
177
+ if (!key) return null;
178
+ const qual = key in QUALITATIVE;
179
+ const stops = unpack(qual ? QUALITATIVE[key] : COLORMAPS[key]);
180
+ if (rev) stops.reverse();
181
+ return { name: key + (rev ? '_r' : ''), stops, kind: qual ? 'qualitative' : DIVERGING.has(key) ? 'diverging' : CYCLIC.has(key) ? 'cyclic' : 'sequential' };
182
+ }
package/src/context.js ADDED
@@ -0,0 +1,10 @@
1
+ // Contexto de dibujo: valores globales que plot() fija mientras dibuja un
2
+ // gráfico (todo es sincrónico, así que no hay mezcla entre gráficos).
3
+ export const ctx = { textScale: 1 };
4
+
5
+ /** Ejecuta fn con un contexto temporal y lo restaura después. */
6
+ export function withContext(values, fn) {
7
+ const prev = { ...ctx };
8
+ Object.assign(ctx, values);
9
+ try { return fn(); } finally { Object.assign(ctx, prev); }
10
+ }