plotagents 0.5.0 → 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 (51) hide show
  1. package/AGENTS.md +24 -4
  2. package/README.md +26 -14
  3. package/bin/plotagents.js +38 -14
  4. package/dist/plotagents-geo.js +1 -1
  5. package/dist/plotagents.js +4294 -637
  6. package/docs/api.md +23 -5
  7. package/docs/charts.md +143 -9
  8. package/docs/colorscales.md +46 -0
  9. package/docs/dashboards.md +79 -0
  10. package/docs/getting-started.md +6 -2
  11. package/docs/themes.md +39 -9
  12. package/index.d.ts +188 -17
  13. package/package.json +9 -3
  14. package/schema.json +1025 -46
  15. package/src/axes.js +11 -3
  16. package/src/charts/bar.js +28 -17
  17. package/src/charts/categorical.js +31 -7
  18. package/src/charts/chord.js +3 -3
  19. package/src/charts/choropleth.js +284 -36
  20. package/src/charts/dist.js +37 -20
  21. package/src/charts/donut.js +1 -1
  22. package/src/charts/gantt.js +460 -0
  23. package/src/charts/heatmap.js +2 -2
  24. package/src/charts/kpi.js +9 -6
  25. package/src/charts/line.js +173 -41
  26. package/src/charts/network.js +552 -189
  27. package/src/charts/pointmap.js +599 -0
  28. package/src/charts/radar.js +7 -1
  29. package/src/charts/regplot.js +7 -1
  30. package/src/charts/sankey.js +41 -5
  31. package/src/charts/scatter.js +100 -22
  32. package/src/charts/sunburst.js +5 -4
  33. package/src/charts/treemap.js +10 -7
  34. package/src/charts/wordcloud.js +163 -0
  35. package/src/colorscales.js +182 -0
  36. package/src/dashboard.js +438 -0
  37. package/src/dashed.js +1 -1
  38. package/src/data.js +2 -2
  39. package/src/format.js +46 -14
  40. package/src/frame.js +95 -26
  41. package/src/geo/projection.js +74 -9
  42. package/src/geo/registry.js +87 -8
  43. package/src/index.js +76 -11
  44. package/src/infer.js +10 -1
  45. package/src/interact.js +157 -36
  46. package/src/lint.js +289 -9
  47. package/src/runtime.js +209 -97
  48. package/src/scale.js +80 -10
  49. package/src/schema.js +187 -31
  50. package/src/stats.js +71 -14
  51. package/src/theme.js +101 -14
package/src/axes.js CHANGED
@@ -10,7 +10,7 @@ export function gridY(ticks, y, area, theme, fmt, { labels = true, baseline = 0
10
10
  for (const t of ticks) {
11
11
  const py = Math.round(y(t)) + 0.5;
12
12
  const isBase = t === baseline;
13
- out.push(el('line', { x1: area.x, x2: area.x + area.w, y1: py, y2: py, stroke: isBase ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' }));
13
+ out.push(el('line', { x1: area.x, x2: area.x + area.w, y1: py, y2: py, stroke: isBase ? theme.axis : theme.grid, ...gridAttrs(theme, isBase) }));
14
14
  if (labels) out.push(text(area.x - 8, py + 4, fmt(t), { fill: theme.muted, fontSize: SIZES.axis, textAnchor: 'end' }));
15
15
  }
16
16
  return g({ class: 'pa-axis pa-axis-y' }, out);
@@ -21,7 +21,7 @@ export function gridX(ticks, x, area, theme, fmt, { labels = true, baseline = 0,
21
21
  const out = [];
22
22
  const items = ticks.map((t) => ({ pos: Math.round(x(t)) + 0.5, label: fmt(t), t }));
23
23
  for (const it of items) {
24
- if (lines) out.push(el('line', { x1: it.pos, x2: it.pos, y1: area.y, y2: area.y + area.h, stroke: it.t === baseline ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' }));
24
+ if (lines) out.push(el('line', { x1: it.pos, x2: it.pos, y1: area.y, y2: area.y + area.h, stroke: it.t === baseline ? theme.axis : theme.grid, ...gridAttrs(theme, it.t === baseline) }));
25
25
  }
26
26
  if (labels) out.push(xLabels(items, area, theme));
27
27
  return g({ class: 'pa-axis pa-axis-x' }, out);
@@ -76,7 +76,7 @@ export const yAxisTop = (theme, inside) => (isInside(theme, inside) ? 14 * ctx.t
76
76
  export function yGrid(ticks, y, area, theme, { baseline = 0 } = {}) {
77
77
  return g({ class: 'pa-grid' }, ticks.map((t) => {
78
78
  const py = Math.round(y(t)) + 0.5;
79
- return el('line', { x1: area.x, x2: area.x + area.w, y1: py, y2: py, stroke: t === baseline ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' });
79
+ return el('line', { x1: area.x, x2: area.x + area.w, y1: py, y2: py, stroke: t === baseline ? theme.axis : theme.grid, ...gridAttrs(theme, t === baseline) });
80
80
  }));
81
81
  }
82
82
 
@@ -142,3 +142,11 @@ export function quietCorner(points, inner, w, h = 14, pad = 8) {
142
142
  }
143
143
  return { x: best.tx, y: best.ty, anchor: best.anchor };
144
144
  }
145
+
146
+ /** Trazo de grilla y base según el tema (theme.grid { dash, width }, theme.axis { width }). */
147
+ export function gridAttrs(theme, base = false) {
148
+ const st = base ? theme.axisStyle : theme.gridStyle;
149
+ const w = st && Number.isFinite(+st.width) ? +st.width : 1;
150
+ const dash = !base && st && st.dash && /^[\d.\s,]+$/.test(String(st.dash)) ? String(st.dash) : null;
151
+ return { strokeWidth: w, strokeDasharray: dash, shapeRendering: dash ? null : 'crispEdges' };
152
+ }
package/src/charts/bar.js CHANGED
@@ -4,13 +4,13 @@
4
4
  import { el, g, text, barPath, tip } from '../svg.js';
5
5
  import { measure, truncate } from '../text.js';
6
6
  import { makeFormat } from '../format.js';
7
- import { niceDomain, niceStep, linear, band } from '../scale.js';
7
+ import { niceDomain, niceStep, linear, band, dateUnit } from '../scale.js';
8
8
  import { colorMap } from '../theme.js';
9
9
  import { ensureData, inferXY, requireField, melt, uniq, aggregate, num, fieldKind, humanize } from '../data.js';
10
10
  import { gridY, gridX, xLabels, ticksWidth, refLinesY, refLinesX } from '../axes.js';
11
11
  import { mean, sd, meanCI } from '../stats.js';
12
12
  import { createFrame, SIZES, hash } from '../frame.js';
13
- import { parseDate } from '../format.js';
13
+ import { parseDate, formatDate } from '../format.js';
14
14
  import { dashedMatcher, fromTest, hatcher, dashedAttrs, dashedDefaultLabel } from '../dashed.js';
15
15
  import { ctx } from '../context.js';
16
16
 
@@ -63,6 +63,7 @@ export function prepareBar(spec) {
63
63
  }));
64
64
  const total = (c) => values.get(c).reduce((a, b) => a + b, 0);
65
65
  if (kind === 'year' || kind === 'number') cats.sort((a, b) => num(a) - num(b));
66
+ else if (kind === 'date') cats.sort((a, b) => parseDate(a) - parseDate(b));
66
67
  const nominal = kind === 'category';
67
68
  const sort = spec.sort ?? (nominal ? 'desc' : 'none');
68
69
  if (sort === 'desc') cats.sort((a, b) => total(b) - total(a));
@@ -70,7 +71,8 @@ export function prepareBar(spec) {
70
71
  else if (sort === 'alpha') cats.sort((a, b) => a.localeCompare(b, 'es'));
71
72
 
72
73
  // Cola larga → "Otros" (nunca 40 barras ilegibles).
73
- const top = spec.top ?? (cats.length > 40 ? 25 : null);
74
+ // Solo categorías nominales: los periodos (años, meses) nunca se pliegan en "Otros".
75
+ const top = spec.top ?? (nominal && cats.length > 40 ? 25 : null);
74
76
  let folded = 0;
75
77
  if (top && cats.length > top) {
76
78
  const keep = cats.slice(0, top);
@@ -110,6 +112,8 @@ export function bar(spec, theme) {
110
112
  if (m.errs.size) for (const e of m.errs.values()) for (const r of e) if (r) all.push(...r);
111
113
  let vmin = Math.min(0, ...all), vmax = Math.max(0, ...all);
112
114
  if (pct) { vmin = Math.min(0, vmin); vmax = Math.max(vmax, 1); }
115
+ // Conteos y enteros: marcas enteras (nunca 0,5 personas).
116
+ const allInt = !pct && all.length > 0 && all.every((v) => Number.isInteger(v));
113
117
  const fmt = pct ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale) : makeFormat(spec.format, locale, all);
114
118
 
115
119
  // Orientación
@@ -154,6 +158,7 @@ export function bar(spec, theme) {
154
158
  const maxBar = spec.barWidth || (horizontal ? theme.marks?.barMaxH ?? 22 : theme.marks?.barMax ?? 36);
155
159
 
156
160
  let area = frame.layout(legend);
161
+ const RAD = theme.marks?.barRadius ?? 4;
157
162
  const body = [];
158
163
  const refs = (spec.refLines || []).map((r) => ({ ...r, y: r.value ?? r.y }));
159
164
 
@@ -161,16 +166,21 @@ export function bar(spec, theme) {
161
166
  // ── Columnas
162
167
  if (spec.height === undefined && frame.height < 360) frame.setHeight(360);
163
168
  const ticksCount = Math.max(3, Math.round(area.h / 64));
164
- const nd = spec.yDomain ? { min: spec.yDomain[0], max: spec.yDomain[1], step: niceStep(spec.yDomain[1] - spec.yDomain[0], ticksCount) } : niceDomain(vmin, vmax, ticksCount);
169
+ const nd = spec.yDomain ? { min: spec.yDomain[0], max: spec.yDomain[1], step: niceStep(spec.yDomain[1] - spec.yDomain[0], ticksCount) } : niceDomain(vmin, vmax, ticksCount, { integer: allInt });
165
170
  const dom = hideAxis && !spec.yDomain ? { min: vmin, max: vmax || 1 } : nd;
166
171
  if (spec._domains) spec._domains.y = [dom.min, dom.max];
167
- const leftM = hideAxis ? 0 : ticksWidth(linear(nd.min, nd.max, 0, 1).ticks(nd.step), fmt.tick(nd.step)) + 10;
168
- const top = showLabels ? 20 : 6;
172
+ const leftM = hideAxis ? 0 : ticksWidth(linear(nd.min, nd.max, 0, 1).ticks(nd.step), fmt.tick(nd.step, linear(nd.min, nd.max, 0, 1).ticks(nd.step))) + 10;
173
+ const top = showLabels ? 16 : 6;
169
174
  const inner = { x: area.x + leftM, y: area.y + top * ctx.textScale, w: area.w - leftM, h: area.h - top * ctx.textScale - 26 * ctx.textScale };
170
175
  const yS = linear(dom.min, dom.max, inner.y + inner.h, inner.y);
171
176
  const xb = band(cats, inner.x, inner.x + inner.w, { inner: multi && !stack ? 0.2 : 0.3, outer: 0.1 });
172
- if (!hideAxis) body.push(gridY(yS.ticks(nd.step), yS, inner, theme, fmt.tick(nd.step)));
177
+ if (!hideAxis) body.push(gridY(yS.ticks(nd.step), yS, inner, theme, fmt.tick(nd.step, yS.ticks(nd.step))));
173
178
  const y0 = yS(0);
179
+ // Ancho de barra relativo a la banda (≈ 60 % del paso, como Chart.js y
180
+ // ECharts): con pocas categorías las barras no quedan como palitos entre
181
+ // grandes huecos. `barWidth` lo fija; theme.marks.barMax es el piso.
182
+ const capW = spec.barWidth || (stack ? Math.min(120, Math.max(maxBar * 1.4, xb.step * 0.6))
183
+ : k === 1 ? Math.min(120, Math.max(maxBar, xb.step * 0.6)) : Math.max(maxBar, Math.min(56, (xb.step * 0.7) / k)));
174
184
  cats.forEach((c) => {
175
185
  const bx = xb(c);
176
186
  const bw = xb.bandwidth;
@@ -178,7 +188,7 @@ export function bar(spec, theme) {
178
188
  const parts = [];
179
189
  const hit = el('rect', { x: bx - xb.step * 0.12, y: inner.y, width: bw + xb.step * 0.24, height: inner.h, fill: 'transparent' });
180
190
  if (stack) {
181
- const w = Math.min(maxBar * 1.4, bw);
191
+ const w = Math.min(capW, bw);
182
192
  const x0 = bx + (bw - w) / 2;
183
193
  const sg = segs.get(c);
184
194
  const lastPos = sg.map((s, i) => (s.vv > 0 ? i : -1)).filter((i) => i >= 0).pop();
@@ -190,7 +200,7 @@ export function bar(spec, theme) {
190
200
  const gap = s.from !== 0 ? 2 : 0;
191
201
  if (s.vv > 0) h -= gap; else { top += gap; h -= gap; }
192
202
  const outer = s.vv > 0 ? i === lastPos : i === lastNeg;
193
- const d = barPath(x0, top, w, Math.max(0, h), s.vv > 0 ? 'up' : 'down', outer ? 4 : 0);
203
+ const d = barPath(x0, top, w, Math.max(0, h), s.vv > 0 ? 'up' : 'down', outer ? RAD : 0);
194
204
  parts.push(g({ class: 'pa-m', 'data-tip': tip(c, `${seriesKeys[i]}: ${fmt(pct ? s.vv : s.v)}`, dl(c, i)) }, [el('path', { d, ...paint(c, i) })]));
195
205
  });
196
206
  if (showLabels && !pct) {
@@ -200,14 +210,14 @@ export function bar(spec, theme) {
200
210
  }
201
211
  body.push(g({ 'data-tip': tip(c, ...sg.map((s, i) => `${seriesKeys[i]}: ${fmt(pct ? s.vv : s.v)}` + (isDashed(c, i) && dm ? ` (${dashedLabel})` : '')), pct ? '' : `Total: ${fmt(m.total(c))}`, from && from(xNum(c)) ? dashedLabel : '') }, [hit, ...parts]));
202
212
  } else {
203
- const w = Math.min(maxBar, (bw - 2 * (k - 1)) / k);
213
+ const w = Math.min(capW, (bw - 2 * (k - 1)) / k);
204
214
  const groupW = w * k + 2 * (k - 1);
205
215
  const x0 = bx + (bw - groupW) / 2;
206
216
  vs.forEach((v, i) => {
207
217
  const bxI = x0 + i * (w + 2);
208
218
  const yv = yS(v);
209
219
  const top = Math.min(yv, y0), h = Math.abs(yv - y0);
210
- const d = barPath(bxI, top, w, h, v >= 0 ? 'up' : 'down');
220
+ const d = barPath(bxI, top, w, h, v >= 0 ? 'up' : 'down', RAD);
211
221
  const er0 = m.errs.get(c)?.[i];
212
222
  const label = (multi ? `${seriesKeys[i]}: ${fmt(v)}` : fmt(v)) + (er0 ? ` (IC ${fmt(er0[0])}–${fmt(er0[1])})` : '');
213
223
  parts.push(g({ class: 'pa-m', 'data-tip': tip(c, label, dl(c, i)) }, [
@@ -226,7 +236,8 @@ export function bar(spec, theme) {
226
236
  // Línea base
227
237
  body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: Math.round(y0) + 0.5, y2: Math.round(y0) + 0.5, stroke: theme.axis, strokeWidth: 1 }));
228
238
  // Categorías
229
- const items = cats.map((c) => ({ pos: xb.center(c), label: m.nominal ? truncate(c, Math.max(xb.step - 6, 40), 12) : c }));
239
+ const dUnit = m.kind === 'date' ? dateUnit(cats.map((c) => parseDate(c))) : null;
240
+ const items = cats.map((c) => ({ pos: xb.center(c), label: m.nominal ? truncate(c, Math.max(xb.step - 6, 40), 12) : dUnit ? formatDate(parseDate(c), dUnit, locale) : c }));
230
241
  body.push(xLabels(items, inner, theme, { y: inner.y + inner.h + 18, size: 12, color: theme.lede }));
231
242
  body.push(refLinesY(refs, yS, inner, theme, fmt));
232
243
  } else {
@@ -242,12 +253,12 @@ export function bar(spec, theme) {
242
253
  const hasNeg = vmin < 0;
243
254
  const inner = { x: area.x + catW, y: area.y + 4, w: area.w - catW - rightM - (hasNeg && showLabels ? valueLabelW + 10 : 0), h: area.h - (hideAxis ? 8 : 28) };
244
255
  if (hasNeg && showLabels) inner.x += valueLabelW + 10;
245
- const nd = spec.yDomain ? { min: spec.yDomain[0], max: spec.yDomain[1], step: niceStep(spec.yDomain[1] - spec.yDomain[0], Math.max(3, Math.round(inner.w / 110))) } : niceDomain(vmin, vmax, Math.max(3, Math.round(inner.w / 110)));
256
+ const nd = spec.yDomain ? { min: spec.yDomain[0], max: spec.yDomain[1], step: niceStep(spec.yDomain[1] - spec.yDomain[0], Math.max(3, Math.round(inner.w / 110))) } : niceDomain(vmin, vmax, Math.max(3, Math.round(inner.w / 110)), { integer: allInt });
246
257
  const dom = hideAxis && !spec.yDomain ? { min: vmin, max: vmax || 1 } : nd;
247
258
  if (spec._domains) spec._domains.y = [dom.min, dom.max];
248
259
  const xS = linear(dom.min, dom.max, inner.x, inner.x + inner.w);
249
260
  const yb = band(cats, inner.y, inner.y + inner.h, { inner: 0.32, outer: 0.1 });
250
- if (!hideAxis) body.push(gridX(xS.ticks(nd.step), xS, inner, theme, fmt.tick(nd.step)));
261
+ if (!hideAxis) body.push(gridX(xS.ticks(nd.step), xS, inner, theme, fmt.tick(nd.step, xS.ticks(nd.step))));
251
262
  const x0 = xS(0);
252
263
  cats.forEach((c) => {
253
264
  const by = yb(c);
@@ -256,7 +267,7 @@ export function bar(spec, theme) {
256
267
  const parts = [];
257
268
  const strong = !hl || hl.has(c);
258
269
  parts.push(el('rect', { x: area.x, y: by - yb.step * 0.16, width: area.w, height: yb.step, fill: 'transparent' }));
259
- parts.push(text(inner.x - 10 - (hasNeg && showLabels ? valueLabelW + 10 : 0), by + bh / 2 + 4.5, truncate(c, catW - 12, labelSize), { fill: strong ? theme.ink : theme.muted, fontSize: labelSize, fontWeight: hl && strong ? 600 : 400, textAnchor: 'end' }));
270
+ parts.push(text(inner.x - 10 - (hasNeg && showLabels ? valueLabelW + 10 : 0), by + bh / 2 + 4.5, truncate(c, Math.min(maxCatW, area.w * 0.38) + 0.5, labelSize), { fill: strong ? theme.ink : theme.muted, fontSize: labelSize, fontWeight: hl && strong ? 600 : 400, textAnchor: 'end' }));
260
271
  if (stack) {
261
272
  const h = Math.min(maxBar, bh);
262
273
  const y0b = by + (bh - h) / 2;
@@ -267,7 +278,7 @@ export function bar(spec, theme) {
267
278
  const xa = xS(s.from), xb2 = xS(s.to);
268
279
  let left = Math.min(xa, xb2), w = Math.abs(xb2 - xa);
269
280
  if (s.from !== 0) { if (s.vv > 0) left += 2; w -= 2; }
270
- const d = barPath(left, y0b, Math.max(0, w), h, s.vv > 0 ? 'right' : 'left', i === lastPos || s.vv < 0 ? 4 : 0);
281
+ const d = barPath(left, y0b, Math.max(0, w), h, s.vv > 0 ? 'right' : 'left', i === lastPos || s.vv < 0 ? RAD : 0);
271
282
  const fill = colorFor(c, i);
272
283
  const lab = pct ? fmt(s.vv) : '';
273
284
  const fits = lab && measure(lab, 11, 600) + 10 < w;
@@ -286,7 +297,7 @@ export function bar(spec, theme) {
286
297
  vs.forEach((v, i) => {
287
298
  const yy = y0b + i * (h + 2);
288
299
  const xv = xS(v);
289
- const d = barPath(Math.min(xv, x0), yy, Math.abs(xv - x0), h, v >= 0 ? 'right' : 'left');
300
+ const d = barPath(Math.min(xv, x0), yy, Math.abs(xv - x0), h, v >= 0 ? 'right' : 'left', RAD);
290
301
  const er = m.errs.get(c)?.[i];
291
302
  const erTxt = er ? ` (IC ${fmt(er[0])}–${fmt(er[1])})` : '';
292
303
  parts.push(g({ class: 'pa-m', 'data-tip': tip(c, (multi ? `${seriesKeys[i]}: ` : '') + fmt(v) + erTxt, dl(c, i)) }, [el('path', { d, ...paint(c, i) }), errBar(er, (e) => [xS(e), yy + h / 2], false, theme)]));
@@ -7,7 +7,7 @@ import { measure, truncate } from '../text.js';
7
7
  import { makeFormat } from '../format.js';
8
8
  import { niceDomain, niceStep, linear, log, logDomain, band } from '../scale.js';
9
9
  import { colorMap, readable } from '../theme.js';
10
- import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize } from '../data.js';
10
+ import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
11
11
  import { gridX, xLabels, yAxisMargin, yAxisTop, yGrid, yTickLabels, refLinesY, refLinesX } from '../axes.js';
12
12
  import { createFrame } from '../frame.js';
13
13
  import { ctx } from '../context.js';
@@ -45,8 +45,8 @@ function prepare(spec) {
45
45
  if (cat) requireField(data, cat, 'x (categoría)');
46
46
  if (hue) requireField(data, hue, 'hue');
47
47
  const catOf = (r) => (cat ? String(r[cat] ?? '') : '');
48
- const cats = spec.order ? spec.order.map(String) : uniq(data.map(catOf));
49
- const hues = hue ? (spec.hueOrder ? spec.hueOrder.map(String) : uniq(data.map((r) => String(r[hue])))) : [''];
48
+ const cats = Array.isArray(spec.order) ? spec.order.map(String) : uniq(data.map(catOf));
49
+ const hues = hue ? (Array.isArray(spec.hueOrder) ? spec.hueOrder.map(String) : uniq(data.map((r) => String(r[hue])))) : [''];
50
50
  const groups = new Map();
51
51
  for (const r of data) {
52
52
  const v = num(r[val]);
@@ -57,6 +57,13 @@ function prepare(spec) {
57
57
  }
58
58
  const get = (c, h) => groups.get(c + '\u0000' + h) || [];
59
59
  const all = [...groups.values()].flat();
60
+ // Sin un solo número no hay eje de valores que dibujar.
61
+ if (!all.length) {
62
+ const en = String(spec.locale || 'es').startsWith('en');
63
+ throw en
64
+ ? new PlotError(`No numeric values in \`${val}\`.`, `\`${val}\` must hold numbers (empty, null and non-finite values are skipped). To count rows per category, use \`type: "countplot"\`.`)
65
+ : new PlotError(`No hay valores numéricos en \`${val}\`.`, `\`${val}\` debe tener números (se omiten vacíos, nulos y valores no finitos). Para contar filas por categoría usa \`type: "countplot"\`.`);
66
+ }
60
67
  return { data, cat, val, hue, cats, hues, get, all, swapped };
61
68
  }
62
69
 
@@ -134,7 +141,7 @@ function setup(spec, theme, m, extent, { type, zero = false, dodge = true, rowSt
134
141
 
135
142
  if (spec._domains && typeof spec._domains === 'object') spec._domains.y = [nd.min, nd.max];
136
143
  // Líneas de referencia en el eje de valores (metas, umbrales).
137
- const refs = (spec.refLines || []).map((r) => ({ ...r, y: r.value ?? r.y ?? r.x }));
144
+ const refs = [].concat(spec.refLines ?? []).filter((r) => r && typeof r === 'object').map((r) => ({ ...r, y: r.value ?? r.y ?? r.x }));
138
145
  if (refs.length) body.post.push(vertical ? refLinesY(refs, valS, inner, theme, fmt) : refLinesX(refs.map((r) => ({ ...r, x: r.y, y: undefined })), valS, inner, theme, fmt));
139
146
 
140
147
  // Posición en el eje de categorías: centro de cada grupo (con "dodge" por hue).
@@ -229,7 +236,13 @@ export function violin(spec, theme) {
229
236
  const tf = logV ? Math.log10 : (v) => v, inv = logV ? (v) => 10 ** v : (v) => v;
230
237
  for (const c of m.cats) for (const h of m.hues) {
231
238
  const vs = m.get(c, h);
232
- if (vs.length < 2) continue;
239
+ if (!vs.length) continue;
240
+ // Un solo valor (o todos iguales) no tiene densidad: se dibuja como una marca.
241
+ if (vs.length < 2 || Math.min(...vs) === Math.max(...vs)) {
242
+ dens.set(c + '\u0000' + h, { single: true, f: fiveNumber(vs), n: vs.length, max: 0 });
243
+ ext.push(vs[0]);
244
+ continue;
245
+ }
233
246
  const tv = vs.map(tf);
234
247
  const bw = (spec.bandwidth ?? scottBandwidth(tv)) * (spec.bwAdjust ?? 1);
235
248
  const mn = Math.min(...tv), mx = Math.max(...tv);
@@ -239,8 +252,8 @@ export function violin(spec, theme) {
239
252
  }
240
253
  const s = setup(spec, theme, m, extentOf(ext), { type: 'violin', dodge: !split });
241
254
  const scaleMode = ['width', 'count', 'area'].includes(spec.scale) ? spec.scale : spec.densityNorm ?? 'width';
242
- const gMax = Math.max(...[...dens.values()].map((x) => x.max));
243
- const nMax = Math.max(...[...dens.values()].map((x) => x.n));
255
+ const gMax = Math.max(...[...dens.values()].filter((x) => !x.single).map((x) => x.max), 0);
256
+ const nMax = Math.max(...[...dens.values()].filter((x) => !x.single).map((x) => x.n), 1);
244
257
  const inner = spec.inner ?? 'box';
245
258
  const op = Math.min(0.5, (theme.marks?.bandOpacity ?? 0.16) * 2.2);
246
259
  m.cats.forEach((c) => m.hues.forEach((h, hi2) => {
@@ -252,6 +265,17 @@ export function violin(spec, theme) {
252
265
  const norm = scaleMode === 'width' ? D.max : scaleMode === 'count' ? gMax * (nMax / D.n) : gMax;
253
266
  const wOf = (d) => (d / (norm || 1)) * half;
254
267
  const side = split ? (hi2 === 0 ? -1 : 1) : 0;
268
+ if (D.single) {
269
+ // Sin forma que estimar: una raya a lo ancho del violín y un punto en el valor.
270
+ const v = D.f.median, w = Math.min(half, 18);
271
+ const [x, y] = s.P(cpos, v);
272
+ const parts = [
273
+ seg(s, cpos - (side === 1 ? 0 : w), v, cpos + (side === -1 ? 0 : w), v, { stroke: color, strokeWidth: 2, strokeLinecap: 'round' }),
274
+ el('circle', { cx: x, cy: y, r: 3.5, fill: color, stroke: theme.surface, strokeWidth: 1.5 }),
275
+ ];
276
+ s.body.marks.push(g({ class: 'pa-m', 'data-tip': tip(fmtTipLabel(c, h), `n = ${D.n}`, `${T('Value', 'Valor', s.locale)}: ${s.fmt(v)}`) }, parts));
277
+ return;
278
+ }
255
279
  const right = D.d.map(([v, d]) => s.P(cpos + (side === -1 ? 0 : wOf(d)), v));
256
280
  const left = D.d.map(([v, d]) => s.P(cpos - (side === 1 ? 0 : wOf(d)), v)).reverse();
257
281
  const pts = side === -1 ? [...D.d.map(([v]) => s.P(cpos, v)), ...left] : side === 1 ? [...right, ...D.d.map(([v]) => s.P(cpos, v)).reverse()] : [...right, ...left];
@@ -16,7 +16,7 @@ export function prepareChord(spec) {
16
16
  if (Array.isArray(spec.matrix)) {
17
17
  const names = (spec.names || spec.matrix.map((_, i) => String(i + 1))).map(String);
18
18
  if (spec.matrix.length !== names.length) throw new PlotError('`matrix` y `names` deben tener el mismo largo.', 'matrix: [[0, 5], [3, 0]], names: ["A", "B"]');
19
- return { names, m: spec.matrix.map((row) => names.map((_, j) => Math.max(0, num(row[j]) || 0))) };
19
+ return { names, m: spec.matrix.map((row) => names.map((_, j) => { const v = num(row[j]); return Number.isFinite(v) && v > 0 ? v : 0; })) };
20
20
  }
21
21
  const data = ensureData(spec);
22
22
  const fields = fieldsOf(data);
@@ -33,7 +33,7 @@ export function prepareChord(spec) {
33
33
  for (const r of data) {
34
34
  if (r[from] === null || r[from] === undefined || r[to] === null || r[to] === undefined) continue;
35
35
  const v = vf ? num(r[vf]) : 1;
36
- if (!(v > 0)) continue;
36
+ if (!(v > 0) || !Number.isFinite(v)) continue;
37
37
  flows.push([at(String(r[from])), at(String(r[to])), v]);
38
38
  }
39
39
  const m = names.map(() => names.map(() => 0));
@@ -78,7 +78,7 @@ export function chord(spec, theme) {
78
78
  const fmt = makeFormat(spec.format, locale, ribbons.map((r) => r.v).concat(totals));
79
79
  const pct = (a, b) => (b > 0 ? formatNumber((a / b) * 100, a / b < 0.1 ? 1 : 0, locale, true) + '%' : '');
80
80
 
81
- const legend = spec.legend === true ? sizeOrder.map((i) => ({ label: names[i], color: colorOf(i) })) : [];
81
+ const legend = spec.legend === true || (spec.legend && typeof spec.legend !== 'boolean') ? sizeOrder.map((i) => ({ label: names[i], color: colorOf(i) })) : [];
82
82
  const W = spec.width || 720;
83
83
  const frame = createFrame(spec, theme, { height: W < 560 ? Math.round(W * 1.02 + 150) : 600 });
84
84
  const area = frame.layout(legend);