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
package/src/stats.js CHANGED
@@ -6,6 +6,20 @@ export const finite = (vs) => vs.filter((v) => Number.isFinite(v));
6
6
  export const sum = (vs) => vs.reduce((a, b) => a + b, 0);
7
7
  export const mean = (vs) => (vs.length ? sum(vs) / vs.length : NaN);
8
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
+
9
23
  export function variance(vs) {
10
24
  const n = vs.length;
11
25
  if (n < 2) return 0;
@@ -97,11 +111,13 @@ export function histogram(vs, { bins, binWidth, domain } = {}) {
97
111
  }
98
112
  step = bins ? (hi - lo) / bins : niceBinStep(hi - lo, n);
99
113
  }
100
- const start = bins ? lo : Math.floor(lo / step + 1e-9) * step;
114
+ const start = bins ? lo : +(Math.floor(lo / step + 1e-9) * step).toPrecision(15);
101
115
  const count = Math.max(1, Math.ceil((hi - start) / step - 1e-9));
102
- const out = Array.from({ length: count }, (_, i) => ({ x0: start + i * step, x1: start + (i + 1) * step, count: 0 }));
116
+ const out = Array.from({ length: count }, (_, i) => ({ x0: +(start + i * step).toPrecision(15), x1: +(start + (i + 1) * step).toPrecision(15), count: 0 }));
103
117
  for (const v of s) {
104
- let i = Math.floor((v - start) / step);
118
+ // Con `domain`, lo que cae fuera no se cuenta (antes se apilaba en la última barra).
119
+ if (domain && (v < domain[0] || v > domain[1])) continue;
120
+ let i = Math.floor((v - start) / step + 1e-9);
105
121
  if (i >= count) i = count - 1;
106
122
  if (i >= 0) out[i].count++;
107
123
  }
@@ -112,11 +128,16 @@ export function histogram(vs, { bins, binWidth, domain } = {}) {
112
128
  export function scottBandwidth(vs) {
113
129
  const v = finite(vs);
114
130
  const s = sd(v);
115
- const iqr = quantile([...v].sort((a, b) => a - b), 0.75) - quantile([...v].sort((a, b) => a - b), 0.25);
131
+ const sorted = [...v].sort((a, b) => a - b);
132
+ const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25);
116
133
  const sigma = Math.min(s, iqr / 1.349) || s || 1;
117
134
  return 1.06 * sigma * v.length ** -0.2;
118
135
  }
119
136
 
137
+ const extentOf = extent;
138
+ /** Sobre este número de valores, la KDE usa binning lineal. */
139
+ export const KDE_BIN_MIN = 2048;
140
+
120
141
  /**
121
142
  * Densidad por kernel gaussiano. Devuelve [[x, densidad], ...] en una grilla.
122
143
  * `cut` extiende la grilla `cut` anchos de banda más allá de los extremos.
@@ -125,14 +146,29 @@ export function kde(vs, { bandwidth, adjust = 1, gridSize = 160, extent, cut = 3
125
146
  const v = finite(vs);
126
147
  if (!v.length) return [];
127
148
  const bw = (bandwidth ?? scottBandwidth(v)) * adjust || 1;
128
- let [lo, hi] = extent || [Math.min(...v) - cut * bw, Math.max(...v) + cut * bw];
149
+ const [vmin, vmax] = extentOf(v);
150
+ let [lo, hi] = extent || [vmin - cut * bw, vmax + cut * bw];
129
151
  if (clip) { lo = Math.max(lo, clip[0]); hi = Math.min(hi, clip[1]); }
130
152
  const k = 1 / (v.length * bw * Math.sqrt(2 * Math.PI));
153
+ // Muchos valores: binning lineal en ~1024 nodos (cada valor reparte su peso
154
+ // entre los dos nodos vecinos). Error despreciable frente al ancho de banda.
155
+ let xsK = v, ws = null;
156
+ if (v.length > KDE_BIN_MIN && vmax > vmin) {
157
+ const B = 1024, dx = (vmax - vmin) / (B - 1);
158
+ ws = new Float64Array(B);
159
+ for (const xi of v) {
160
+ const t = (xi - vmin) / dx, j = Math.min(B - 2, Math.floor(t)), f = t - j;
161
+ ws[j] += 1 - f; ws[j + 1] += f;
162
+ }
163
+ xsK = Array.from({ length: B }, (_, j) => vmin + j * dx);
164
+ }
131
165
  const out = [];
132
166
  for (let i = 0; i < gridSize; i++) {
133
167
  const x = lo + ((hi - lo) * i) / (gridSize - 1);
134
168
  let d = 0;
135
- for (const xi of v) { const u = (x - xi) / bw; if (u > -6 && u < 6) d += Math.exp(-0.5 * u * u); }
169
+ if (ws) {
170
+ 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); }
171
+ } else for (const xi of v) { const u = (x - xi) / bw; if (u > -6 && u < 6) d += Math.exp(-0.5 * u * u); }
136
172
  out.push([x, d * k]);
137
173
  }
138
174
  return out;
@@ -216,28 +252,51 @@ export function pearson(xs, ys) {
216
252
  return sxx && syy ? sxy / Math.sqrt(sxx * syy) : 0;
217
253
  }
218
254
 
219
- /** LOWESS (Cleveland): suavizado local robusto. Devuelve [[x, y], ...] ordenado. */
220
- export function lowess(xs, ys, { frac = 2 / 3, iters = 2 } = {}) {
255
+ /**
256
+ * LOWESS (Cleveland): suavizado local robusto. Devuelve [[x, y], ...] ordenado.
257
+ * Como x está ordenado, los r vecinos más cercanos forman una ventana contigua
258
+ * (dos punteros, sin ordenar distancias). Con muchos puntos (> 400) se ajusta
259
+ * solo en ~200 anclas y se interpola entre ellas (el `delta` de statsmodels).
260
+ */
261
+ export function lowess(xs, ys, { frac = 2 / 3, iters = 2, delta } = {}) {
221
262
  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]);
222
263
  const n = pts.length;
223
264
  if (n < 3) return pts;
224
- const r = Math.max(2, Math.ceil(frac * n));
265
+ const X = pts.map((p) => p[0]), Y = pts.map((p) => p[1]);
266
+ const r = Math.min(n, Math.max(2, Math.ceil(frac * n)));
267
+ const dl = delta ?? (n > 400 ? (X[n - 1] - X[0]) / 200 : 0);
268
+ // Anclas: el primero, luego el primer punto a más de `dl` del ancla anterior, y el último.
269
+ const anchors = [0];
270
+ if (dl > 0) {
271
+ for (let i = 1; i < n; i++) if (X[i] > X[anchors[anchors.length - 1]] + dl) anchors.push(i);
272
+ if (anchors[anchors.length - 1] !== n - 1) anchors.push(n - 1);
273
+ } else for (let i = 1; i < n; i++) anchors.push(i);
225
274
  let robust = new Array(n).fill(1);
226
- let fit = new Array(n).fill(0);
275
+ const fit = new Array(n).fill(0);
227
276
  for (let it = 0; it <= iters; it++) {
228
- for (let i = 0; i < n; i++) {
229
- const x0 = pts[i][0];
230
- const d = pts.map(([x]) => Math.abs(x - x0)).sort((a, b) => a - b);
231
- const h = d[Math.min(n - 1, r - 1)] || 1e-12;
277
+ let lo = 0;
278
+ for (const i of anchors) {
279
+ const x0 = X[i];
280
+ while (lo + r < n && X[lo + r] - x0 < x0 - X[lo]) lo++;
281
+ const hiI = lo + r - 1;
282
+ const h = Math.max(x0 - X[lo], X[hiI] - x0) || 1e-12;
232
283
  let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0;
233
- for (let j = 0; j < n; j++) {
234
- const u = Math.abs(pts[j][0] - x0) / h;
284
+ for (let j = lo; j <= hiI; j++) {
285
+ const u = Math.abs(X[j] - x0) / h;
235
286
  const w = u < 1 ? (1 - u ** 3) ** 3 * robust[j] : 0;
236
- sw += w; swx += w * pts[j][0]; swy += w * pts[j][1]; swxx += w * pts[j][0] ** 2; swxy += w * pts[j][0] * pts[j][1];
287
+ sw += w; swx += w * X[j]; swy += w * Y[j]; swxx += w * X[j] ** 2; swxy += w * X[j] * Y[j];
237
288
  }
238
289
  const den = sw * swxx - swx * swx;
239
290
  fit[i] = Math.abs(den) < 1e-12 ? swy / (sw || 1) : ((swxx * swy - swx * swxy) + (sw * swxy - swx * swy) * x0) / den;
240
291
  }
292
+ // Entre anclas: interpolación lineal (mismo x → mismo valor).
293
+ for (let a = 0; a < anchors.length - 1; a++) {
294
+ const i0 = anchors[a], i1 = anchors[a + 1];
295
+ for (let i = i0 + 1; i < i1; i++) {
296
+ const span = X[i1] - X[i0];
297
+ fit[i] = span ? fit[i0] + ((fit[i1] - fit[i0]) * (X[i] - X[i0])) / span : fit[i0];
298
+ }
299
+ }
241
300
  const res = pts.map(([, y], i) => Math.abs(y - fit[i]));
242
301
  const m = median(res) || 1e-12;
243
302
  robust = res.map((e) => { const u = e / (6 * m); return u < 1 ? (1 - u * u) ** 2 : 0; });
package/src/svg.js CHANGED
@@ -3,7 +3,10 @@
3
3
  import { ctx } from './context.js';
4
4
 
5
5
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
6
- export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
6
+ // Además de escapar, quita los caracteres que XML 1.0 prohíbe (controles y
7
+ // sustitutos sueltos): un solo \x00 en un dato invalida todo el SVG.
8
+ const BAD_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
9
+ export const esc = (s) => String(s ?? '').replace(BAD_XML, '').replace(/[&<>"']/g, (c) => ESC[c]);
7
10
 
8
11
  /** Redondea a 2 decimales para SVG compactos. */
9
12
  export const r2 = (n) => Math.round(n * 100) / 100;
package/src/text.js CHANGED
@@ -24,6 +24,11 @@ set('—', 0.9);
24
24
  set('▲▼■●', 0.9);
25
25
  set('·', 0.3);
26
26
  set('→', 0.9);
27
+ set('\u202F', 0.17); // espacio fino (miles en francés)
28
+ set('\u00A0', 0.27);
29
+ set('✓✗', 0.7);
30
+ set('‹›', 0.32);
31
+ set('#', 0.6);
27
32
 
28
33
  export function measure(str, size = 12, weight = 400) {
29
34
  let w = 0;
package/src/theme.js CHANGED
@@ -14,6 +14,8 @@ export const MONO = "'Fragment Mono', ui-monospace, 'SFMono-Regular', Menlo, mon
14
14
  export const FONT_CSS =
15
15
  'https://fonts.googleapis.com/css2?family=Mozilla+Text:wght@400;500;600;700&display=swap';
16
16
 
17
+ import { resolveColorScale, interpolate, sample } from './colorscales.js';
18
+
17
19
  // Tokens no cromáticos, compartidos por ambos temas y configurables por JSON.
18
20
  const STRUCT = {
19
21
  font: { family: FONT, mono: MONO, import: FONT_CSS },
@@ -105,28 +107,111 @@ export function deepMerge(base, over) {
105
107
  return out;
106
108
  }
107
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
+
108
165
  /**
109
- * Tema = JSON. Acepta un nombre ("papel", "noche") o un objeto parcial que se
110
- * fusiona en profundidad sobre `extends` (por defecto "papel"):
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"):
111
181
  * { extends: "noche", categorical: ["#..."], type: { title: 24 }, marks: { lineWidth: 3 } }
112
- * `categorical`, `sequential` y `diverging` aceptan también un nombre de paleta.
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.
113
184
  */
114
185
  export function getTheme(t) {
115
186
  if (!t) return papel;
116
187
  if (typeof t === 'string') return themes[t] || papel;
117
- const base = themes[t.extends] || (t.mode === 'dark' ? noche : papel);
118
- const merged = deepMerge(base, t);
119
- if (typeof merged.categorical === 'string') merged.categorical = palettes[merged.categorical] || base.categorical;
120
- if (typeof merged.sequential === 'string') merged.sequential = ramps[merged.sequential] || base.sequential;
121
- if (typeof merged.diverging === 'string') merged.diverging = ramps[merged.diverging] || base.diverging;
122
- return merged;
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);
123
196
  }
124
197
 
125
- /** Tema efectivo de un spec: `theme` + atajos `palette` y `ramp`. */
198
+ /** Tema efectivo de un spec: `theme` + atajos `palette`, `ramp` y `colorScale`. */
126
199
  export function themeFor(spec = {}) {
127
200
  let t = getTheme(spec.theme);
128
- if (spec.palette) t = { ...t, categorical: Array.isArray(spec.palette) ? spec.palette : palettes[spec.palette] || t.categorical };
129
- if (spec.ramp) t = { ...t, sequential: Array.isArray(spec.ramp) ? spec.ramp : ramps[spec.ramp] || t.sequential };
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
+ }
130
215
  return t;
131
216
  }
132
217
 
@@ -185,11 +270,13 @@ export function ramp(stops, t) {
185
270
  */
186
271
  export function colorMap(keys, theme, overrides = {}) {
187
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()));
188
276
  let i = 0;
189
277
  for (const k of keys) {
190
278
  if (overrides && overrides[k]) map.set(k, overrides[k]);
191
- else map.set(k, i < theme.categorical.length ? theme.categorical[i] : theme.other);
192
- if (!(overrides && overrides[k])) i++;
279
+ else map.set(k, i < free.length ? free[i++] : theme.other);
193
280
  }
194
281
  return map;
195
282
  }