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/scale.js CHANGED
@@ -10,21 +10,37 @@ export function niceStep(span, count = 5) {
10
10
  return f * mag;
11
11
  }
12
12
 
13
- /** Dominio "bonito": extremos redondeados al paso de las marcas. */
14
- export function niceDomain(min, max, count = 5) {
13
+ /** Paso entero (1, 2, 5, 10, 20, 25…): nunca < 1 ni 2,5. */
14
+ export const intStep = (step) => (!(step >= 1) ? 1 : Math.abs(step - 2.5) < 1e-9 ? 2 : step);
15
+
16
+ /**
17
+ * Dominio "bonito": extremos redondeados al paso de las marcas.
18
+ * `integer: true` (valores enteros, como conteos) garantiza paso ≥ 1 y sin 2,5
19
+ * (como `minInterval: 1` de ECharts): nunca marcas en 0,5.
20
+ */
21
+ export function niceDomain(min, max, count = 5, { integer = false } = {}) {
22
+ const st = (span) => (integer ? intStep(niceStep(span, count)) : niceStep(span, count));
23
+ // Sin valores finitos (todo nulo o texto): dominio [0, 1] en vez de un bucle infinito.
24
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
25
+ if (Number.isFinite(min)) max = min;
26
+ else if (Number.isFinite(max)) min = max;
27
+ else return integer ? { min: 0, max: 1, step: 1 } : { min: 0, max: 1, step: 0.2 };
28
+ }
29
+ if (min > max) [min, max] = [max, min];
15
30
  if (min === max) {
16
- if (min === 0) return { min: 0, max: 1, step: 0.2 };
17
- const pad = Math.abs(min) * 0.1;
31
+ if (min === 0) return integer ? { min: 0, max: 1, step: 1 } : { min: 0, max: 1, step: 0.2 };
32
+ const pad = integer ? 0.5 : Math.abs(min) * 0.1;
18
33
  min -= pad;
19
34
  max += pad;
20
35
  }
21
- let step = niceStep(max - min, count);
36
+ let step = st(max - min);
22
37
  let lo = Math.floor(min / step + 1e-9) * step;
23
38
  let hi = Math.ceil(max / step - 1e-9) * step;
24
- step = niceStep(hi - lo, count);
39
+ step = st(hi - lo);
25
40
  lo = Math.floor(min / step + 1e-9) * step;
26
41
  hi = Math.ceil(max / step - 1e-9) * step;
27
- return { min: lo, max: hi, step };
42
+ // Sin ruido de punto flotante (0,30000000000000004 → 0,3).
43
+ return { min: +lo.toPrecision(15), max: +hi.toPrecision(15), step: +step.toPrecision(15) };
28
44
  }
29
45
 
30
46
  export function linear(d0, d1, r0, r1) {
@@ -35,9 +51,14 @@ export function linear(d0, d1, r0, r1) {
35
51
  s.range = [r0, r1];
36
52
  s.ticks = (step) => {
37
53
  const out = [];
38
- const st = step || niceStep(d1 - d0, 5);
39
- const start = Math.ceil(Math.min(d0, d1) / st - 1e-9) * st;
40
- for (let v = start; v <= Math.max(d0, d1) + st * 1e-9; v += st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(12));
54
+ if (!Number.isFinite(d0) || !Number.isFinite(d1)) return out;
55
+ let st = step > 0 && Number.isFinite(step) ? step : niceStep(Math.abs(d1 - d0), 5);
56
+ const lo = Math.min(d0, d1), hi = Math.max(d0, d1);
57
+ // Nunca más de ~1000 marcas (paso diminuto frente al dominio, o paso que no avanza).
58
+ if ((hi - lo) / st > 1000) st = niceStep(hi - lo, 5);
59
+ const start = Math.ceil(lo / st - 1e-9) * st;
60
+ if (start + st === start) return [lo];
61
+ for (let i = 0, v = start; v <= hi + st * 1e-9 && i <= 1000; i++, v = start + i * st) out.push(Math.abs(v) < st * 1e-9 ? 0 : +v.toPrecision(15));
41
62
  return out;
42
63
  };
43
64
  return s;
@@ -53,6 +74,7 @@ export function log(d0, d1, r0, r1) {
53
74
  s.range = [r0, r1];
54
75
  // Marcas 1-2-5 cuando hay pocas décadas; solo potencias de 10 si hay muchas.
55
76
  s.ticks = (max = 7) => {
77
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [];
56
78
  const cap = Number.isInteger(max) && max >= 2 ? max : 7;
57
79
  const gen = (mult) => {
58
80
  const out = [];
@@ -71,6 +93,15 @@ export function log(d0, d1, r0, r1) {
71
93
  const every = Math.ceil(t.length / cap);
72
94
  return t.filter((_, i) => i % every === 0);
73
95
  };
96
+ // Rango estrecho (120–180 → solo "100, 200"): marcas lineales dentro del dominio.
97
+ const logTicks = s.ticks;
98
+ s.ticks = (max = 7) => {
99
+ const t = logTicks(max);
100
+ if (t.length >= 3 || !(d1 > d0)) return t;
101
+ const st = niceStep(d1 - d0, 5);
102
+ const lin = linear(d0, d1, 0, 1).ticks(st).filter((v) => v > 0);
103
+ return lin.length > t.length ? lin : t;
104
+ };
74
105
  return s;
75
106
  }
76
107
 
@@ -79,6 +110,8 @@ export function logDomain(min, max) {
79
110
  const steps = [1, 2, 5, 10];
80
111
  const down = (v) => { const e = Math.floor(Math.log10(v)); const m = v / 10 ** e; const k = [...steps].reverse().find((s) => s <= m + 1e-9) || 1; return k * 10 ** e; };
81
112
  const up = (v) => { const e = Math.floor(Math.log10(v)); const m = v / 10 ** e; const k = steps.find((s) => s >= m - 1e-9) || 10; return k * 10 ** e; };
113
+ if (!Number.isFinite(min) || !(min > 0)) min = Number.isFinite(max) && max > 0 ? max / 10 : 1;
114
+ if (!Number.isFinite(max) || !(max > 0)) max = min * 10;
82
115
  const lo = down(Math.max(min, 1e-12)), hi = up(Math.max(max, 1e-12));
83
116
  return { min: lo, max: hi === lo ? lo * 10 : hi };
84
117
  }
@@ -101,9 +134,19 @@ export function band(domain, r0, r1, { inner = 0.3, outer = 0.15 } = {}) {
101
134
  // ---------------------------------------------------------------------------
102
135
  // Tiempo
103
136
 
104
- const DAY = 864e5;
137
+ const MIN = 6e4, HOUR = 36e5, DAY = 864e5;
105
138
  const UNITS = [
139
+ ['minute', 1, MIN],
140
+ ['minute', 5, 5 * MIN],
141
+ ['minute', 15, 15 * MIN],
142
+ ['minute', 30, 30 * MIN],
143
+ ['hour', 1, HOUR],
144
+ ['hour', 3, 3 * HOUR],
145
+ ['hour', 6, 6 * HOUR],
146
+ ['hour', 12, 12 * HOUR],
106
147
  ['day', 1, DAY],
148
+ ['day', 2, 2 * DAY],
149
+ ['day', 3, 3 * DAY],
107
150
  ['day', 7, 7 * DAY],
108
151
  ['month', 1, 30.44 * DAY],
109
152
  ['month', 3, 91.3 * DAY],
@@ -114,10 +157,14 @@ const UNITS = [
114
157
  ['year', 10, 3652 * DAY],
115
158
  ['year', 25, 9131 * DAY],
116
159
  ['year', 50, 18262 * DAY],
160
+ ['year', 100, 36525 * DAY],
161
+ ['year', 250, 91312 * DAY],
162
+ ['year', 500, 182625 * DAY],
117
163
  ];
118
164
 
119
165
  /** Marcas de tiempo legibles: elige la unidad que da ≤ `count` marcas. */
120
166
  export function timeTicks(t0, t1, count = 6, locale = 'es') {
167
+ if (!Number.isFinite(t0) || !Number.isFinite(t1)) return { ticks: [], unit: 'day', every: 1 };
121
168
  const span = Math.max(1, t1 - t0);
122
169
  let [unit, every] = UNITS.find(([, , ms]) => span / ms <= count) || UNITS[UNITS.length - 1];
123
170
  const out = [];
@@ -140,6 +187,14 @@ export function timeTicks(t0, t1, count = 6, locale = 'es') {
140
187
  const c = new Date(cur);
141
188
  cur = Date.UTC(c.getUTCFullYear(), c.getUTCMonth() + every, 1);
142
189
  }
190
+ } else if (unit === 'hour' || unit === 'minute') {
191
+ // Múltiplos exactos del paso en UTC (las horas 1/3/6/12 dividen el día).
192
+ const ms = every * (unit === 'hour' ? HOUR : MIN);
193
+ cur = Math.ceil(t0 / ms - 1e-9) * ms;
194
+ while (cur <= t1) {
195
+ out.push(cur);
196
+ cur += ms;
197
+ }
143
198
  } else {
144
199
  cur = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
145
200
  if (cur < t0) cur += DAY;
@@ -148,9 +203,18 @@ export function timeTicks(t0, t1, count = 6, locale = 'es') {
148
203
  cur += every * DAY;
149
204
  }
150
205
  }
206
+ const dayOf = (t) => Math.floor(t / DAY);
151
207
  const label = (t, i) => {
152
208
  const dt = new Date(t);
153
209
  if (unit === 'year') return formatDate(t, 'year', locale);
210
+ // Hora "HH:mm"; la fecha solo cuando cambia el día (a medianoche, solo la fecha).
211
+ if (unit === 'hour' || unit === 'minute') {
212
+ const newDay = i === 0 || dayOf(out[i - 1]) !== dayOf(t);
213
+ if (!newDay) return formatDate(t, unit, locale, false);
214
+ const prevY = i > 0 ? new Date(out[i - 1]).getUTCFullYear() : null;
215
+ const date = formatDate(t, 'day', locale, i > 0 && prevY !== dt.getUTCFullYear());
216
+ return t % DAY === 0 && i > 0 ? date : `${date} ${formatDate(t, unit, locale, false)}`;
217
+ }
154
218
  if (unit === 'month') {
155
219
  const first = i === 0 || dt.getUTCMonth() < every;
156
220
  if (every === 3) return formatDate(t, 'quarter', locale, first);
@@ -165,7 +229,14 @@ export function timeTicks(t0, t1, count = 6, locale = 'es') {
165
229
  /** Unidad natural de una serie de fechas (para tooltips). */
166
230
  export function dateUnit(times) {
167
231
  const ds = times.map((t) => new Date(t));
232
+ if (ds.some((d) => d.getUTCSeconds() || d.getUTCMinutes())) return 'minute';
233
+ if (ds.some((d) => d.getUTCHours())) return 'hour';
168
234
  if (ds.every((d) => d.getUTCMonth() === 0 && d.getUTCDate() === 1)) return 'year';
169
235
  if (ds.every((d) => d.getUTCDate() === 1)) return 'month';
170
236
  return 'day';
171
237
  }
238
+
239
+ /** Mitad de la unidad natural (para centrar una sola fecha en el eje). */
240
+ export function halfUnit(unit) {
241
+ return { minute: MIN / 2, hour: HOUR / 2, day: DAY / 2, month: 15 * DAY, year: 182.6 * DAY }[unit] ?? DAY / 2;
242
+ }
package/src/schema.js CHANGED
@@ -6,21 +6,39 @@ import { TYPES, ALIASES } from './infer.js';
6
6
  const field = (d) => ({ type: 'string', description: d });
7
7
  const fieldOrList = (d) => ({ anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: d });
8
8
  const strOrList = { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] };
9
+ const bool = (d) => ({ type: 'boolean', description: d });
10
+ const number = (d) => ({ type: 'number', description: d });
11
+ const numPair = (d) => ({ type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2, description: d });
12
+ const formatSpec = (d) => ({
13
+ description: d,
14
+ anyOf: [
15
+ { type: 'string', enum: ['number', 'compact', 'percent', 'currency', 'integer'] },
16
+ { type: 'object', properties: { type: { type: 'string', enum: ['number', 'compact', 'percent', 'currency', 'integer'] }, prefix: { type: 'string' }, suffix: { type: 'string' }, decimals: { type: 'integer', minimum: 0, maximum: 20 }, currency: { type: 'string' }, scale: { type: 'number' } } },
17
+ ],
18
+ });
19
+ /** Escala de color continua: nombre de matplotlib, lista de colores u objeto. */
20
+ const colorScaleSpec = (d) => ({
21
+ description: d,
22
+ anyOf: [
23
+ { type: 'string' },
24
+ { type: 'array', items: { type: 'string' }, minItems: 2 },
25
+ { type: 'object', properties: { name: { type: 'string' }, colors: { type: 'array', items: { type: 'string' } }, reverse: { type: 'boolean' }, mid: { type: 'number', description: 'Center of a diverging scale.' } } },
26
+ ],
27
+ });
9
28
 
10
29
  export const schema = {
11
30
  $schema: 'https://json-schema.org/draft/2020-12/schema',
12
31
  $id: 'https://unpkg.com/plotagents/schema.json',
13
32
  title: 'plotagents spec',
14
33
  description:
15
- 'Declarative chart spec. Data as rows (tidy) plus the field name for each role. Everything else has well-designed defaults.',
34
+ 'Declarative chart spec. Data as rows (tidy) plus the field name for each role. Everything else has well-designed defaults. `data` is required except for kpi (`items`), flowchart (`nodes` + `edges`), chord (`matrix`) and wordcloud with raw `text`.',
16
35
  type: 'object',
17
- required: ['data'],
18
36
  properties: {
19
37
  type: {
20
38
  type: 'string',
21
- enum: ['auto', ...TYPES, ...Object.keys(ALIASES)],
39
+ enum: [...new Set(['auto', ...TYPES, 'pointmap', ...Object.keys(ALIASES)])],
22
40
  description:
23
- 'Compare: bar, count. Time: line, area. Relationships: scatter, bubble, regplot, residplot, jointplot, radar. Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi. seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
41
+ 'Compare: bar, count. Time: line, area. Schedules: gantt. Relationships: scatter, bubble, regplot, residplot, jointplot, radar, corr (correlation matrix; clustermap), pairplot (scatter-plot matrix). Distributions (seaborn): hist, kde, ecdf, box, violin, strip, swarm, point. Part-to-whole: treemap, sunburst, donut, pie. Other: heatmap, sankey, kpi, network, chord, pyramid, flowchart, wordcloud. Maps: choropleth, symbolmap, pointmap (lat/lon dots over a tile basemap). seaborn names are accepted (barplot, countplot, histplot, kdeplot, boxplot, violinplot, lmplot…). "auto" (default) picks from the data.',
24
42
  },
25
43
  data: {
26
44
  description: 'Array of rows, e.g. [{ "region": "Biobío", "sales": 1240 }]. For kpi it can be omitted in favor of `items`.',
@@ -29,14 +47,15 @@ export const schema = {
29
47
  title: { type: 'string', description: 'The finding as a full sentence, ideally with the key number, e.g. "Biobío accounts for 32% of sales".' },
30
48
  subtitle: { type: 'string', description: 'How to read the chart: measure, unit and period, e.g. "Sales by region, CLP millions, 2025".' },
31
49
  source: { type: 'string', description: 'Data source, shown at the bottom right.' },
32
- locale: { type: 'string', enum: ['es', 'en'], description: 'Language and number format. Default "es" (1.234,5); use "en" for 1,234.5.' },
50
+ locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'], description: 'Language and number format. Default "es" (1.234,5); "en" for 1,234.5; also "pt", "fr", "de". "auto" recognizes the language from the title, subtitle, column names and categories (tables do this when `locale` is omitted).' },
33
51
  x: field('x-axis or category field (bar, line, area, scatter, heatmap, box, violin…).'),
34
52
  y: fieldOrList('Value field. For line/bar a list of fields (wide table) turns each one into a series.'),
35
53
  series: field('Field that splits the data into colored series. Alias: hue.'),
54
+ color: field('Alias of series when it names a field (a "#hex" value is a fixed color instead).'),
36
55
  hue: field('Alias of series (seaborn naming).'),
37
56
  value: field('Value field for treemap, sunburst, donut, heatmap, sankey and radar.'),
38
57
  label: field('Name field (treemap, donut, point labels in scatter/regplot).'),
39
- group: field('Level-1 group field for a two-level treemap (shortcut for path).'),
58
+ group: field('Level-1 group field for a two-level treemap (shortcut for path). gantt: phase/workstream field (one colored section per phase). table: rows grouped under a header per value.'),
40
59
  path: { type: 'array', items: { type: 'string' }, description: 'Hierarchy for treemap/sunburst, any depth: [level1, level2, …].' },
41
60
  size: field('Bubble size field (area-proportional).'),
42
61
  from: field('Sankey source field.'),
@@ -63,17 +82,70 @@ export const schema = {
63
82
  },
64
83
  },
65
84
 
85
+ // Word clouds
86
+ text: { description: 'wordcloud: field with the words, OR the raw text to count (a string or a list of strings, e.g. open survey answers). Raw text drops Spanish/English stopwords.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
87
+ textField: field('wordcloud: field holding free text in each row; words are counted across rows.'),
88
+ stopwords: { type: 'array', items: { type: 'string' }, description: 'wordcloud: extra words to ignore when counting raw text.' },
89
+ minFont: { type: 'number', description: 'wordcloud: smallest font size (default 11).' },
90
+ maxFont: { type: 'number', description: 'wordcloud: largest font size (default from the panel size).' },
91
+
92
+ // Schedules
93
+ task: field('gantt: task name field (one row per task).'),
94
+ start: field('gantt: start date field ("2026-03-02", "2026-03") or a number (e.g. week).'),
95
+ end: field('gantt: end date field. Day dates are inclusive. Missing end, or end equal to start, makes a milestone.'),
96
+ duration: field('gantt: duration field (days, or units of a numeric axis) instead of `end`.'),
97
+ progress: field('gantt: completion field, 0–1 or 0–100. The filled part of each bar is the progress.'),
98
+ dependsOn: field('gantt: field with the name(s) of the predecessor task(s) ("A" or "A, B"): draws arrows.'),
99
+ milestone: field('gantt: boolean field that marks milestones (drawn as diamonds).'),
100
+ today: { description: 'gantt: date of the "today" line ("2026-03-12"), or true for the current date.', anyOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'number' }] },
101
+ groupHeaders: { description: 'gantt: false draws phases as a color legend instead of section headers.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['compact'] }] },
102
+ rowHeight: { type: 'number', description: 'gantt: row height in px.' },
103
+ collapsed: { description: 'gantt: phases (values of `group`) drawn collapsed into one summary row; true collapses all. In the browser a click on a phase header toggles it.', anyOf: [{ type: 'boolean' }, { type: 'string' }, { type: 'array', items: { type: 'string' } }] },
104
+ collapsible: bool('gantt: false disables click-to-collapse of phases in the browser.'),
105
+ critical: bool('gantt: highlight the critical path, the zero-slack chain along `dependsOn`.'),
106
+ criticalColor: { type: 'string', description: 'gantt: color of the critical path.' },
107
+ weekends: bool('gantt: shade weekends on day plans of up to 120 days (default true).'),
108
+ todayColor: { type: 'string', description: 'gantt: color of the "today" line.' },
109
+ sort: { type: 'string', enum: ['desc', 'asc', 'none', 'alpha', 'start'], description: 'bar: category order (desc by default unless the categories are time). gantt: "start" orders tasks by start date. donut/chord: "none" keeps the data order.' },
110
+
66
111
  // Maps, networks, flows
67
- geo: { description: 'choropleth/symbolmap: "chile" (16 regions, default), "usa" (50 states + DC, Albers with Alaska/Hawaii insets; Puerto Rico when it has data), "world" (countries), a name registered with registerGeo(), or a GeoJSON FeatureCollection.', anyOf: [{ type: 'string' }, { type: 'object' }] },
112
+ geo: { description: 'choropleth/symbolmap: "chile" (16 regions, default), "usa" (50 states + DC, Albers with Alaska/Hawaii insets; Puerto Rico when it has data), "world" (countries), a name registered with registerGeo(), or YOUR OWN geometry: a GeoJSON FeatureCollection, Feature, list of Features or bare geometry, or a TopoJSON Topology. Polygons are filled, LineStrings stroked and Points drawn as dots. In the browser, render() also accepts a URL to a .geojson/.topojson file.', anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array', items: { type: 'object' } }] },
113
+ geoObject: field('TopoJSON: name of the object inside `objects` to draw (default: the first one).'),
68
114
  key: field('choropleth/symbolmap: data field with the region, state or country name/code to match (US: "TX", "Texas", "06").'),
69
- projection: { type: 'string', enum: ['mercator', 'equalEarth', 'albersUsa'], description: 'choropleth/symbolmap: map projection (picked automatically).' },
115
+ projection: { description: 'Map projection (picked automatically): "mercator", "equalEarth", "albersUsa", "equirectangular", "conic" (or { type: "conic", parallels: [-30, -45], rotate: 72 }), "identity" (coordinates already in pixels, y down) or "planar" (projected coordinates such as UTM metres, y up; chosen automatically when coordinates are outside lon/lat range).', anyOf: [{ type: 'string', enum: ['mercator', 'equalEarth', 'albersUsa', 'equirectangular', 'conic', 'identity', 'planar'] }, { type: 'object', properties: { type: { type: 'string' }, parallels: { type: 'array', items: { type: 'number' } }, rotate: { type: 'number' }, center: { type: 'array', items: { type: 'number' } } } }] },
70
116
  values: { type: 'boolean', description: 'choropleth with state codes (usa): false shows only the codes, without values.' },
71
- featureKey: field('choropleth: feature property to match against (default tries code, id, ISO-3, name and aliases).'),
117
+ featureKey: field('choropleth: feature property to match `key` against. Default tries code, id, ISO-3, name and aliases, then any property whose values match the data (e.g. "CUT_REG", "GEOID").'),
118
+ nameKey: field('choropleth: feature property with the display name (default: name, NOMBRE, NAME_1, ADMIN…).'),
119
+ fitTo: { description: 'Map framing: zoom to some regions (names or codes), a bounding box [lonMin, latMin, lonMax, latMax], or "markers". The rest of the map stays as context, clipped.', anyOf: [{ type: 'string' }, { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }] },
120
+ markers: { description: 'Map: points on top, from [{ lon, lat, label, value?, color? }] or { data, lon, lat, label, size, color, legend, maxRadius, labels }. With a size field, circles are area-proportional.', anyOf: [{ type: 'array', items: { type: 'object' } }, { type: 'object' }] },
121
+ layers: { type: 'array', description: 'Map: extra GeoJSON drawn over (or `below: true` under) the regions: rivers, roads, routes, borders, points. Each { geo, label?, stroke?, width?, fill?, dash?, opacity?, below? }; a `label` adds a legend key.', items: { type: 'object' } },
122
+ borderColor: { type: 'string', description: 'Map: color of the borders between regions.' },
123
+ borderWidth: { type: 'number', description: 'Map: width of the borders between regions.' },
124
+ pointRadius: { type: 'number', description: 'Map: radius of Point features in your own GeoJSON (default 5).' },
125
+ missing: { type: 'string', enum: ['hatch', 'plain'], description: 'choropleth: how regions without data look: hatched (default) or "plain" (flat tint).' },
126
+ thresholds: { type: 'array', items: { type: 'number' }, description: 'choropleth: class breaks (same as a list in `scale`).' },
127
+ lat: field('pointmap: latitude field (auto-detected: lat, latitude, latitud).'),
128
+ lon: field('pointmap: longitude field (auto-detected: lon, lng, longitude, longitud).'),
129
+ tiles: { description: 'pointmap: basemap. "osm" (default), "esri-gray", "esri-dark", "carto-light", "carto-dark", "mapbox-light", "mapbox-dark" (needs `tilesKey`), your own { url, attribution, subdomains, maxZoom, retina } with {z}/{x}/{y}, or false for none (vector world outline).', anyOf: [{ type: 'string', enum: ['osm', 'esri-gray', 'esri-dark', 'carto-light', 'carto-dark', 'mapbox-light', 'mapbox-dark'] }, { type: 'boolean', enum: [false] }, { type: 'object', properties: { url: { type: 'string' }, attribution: { type: 'string' }, subdomains: { type: 'string' }, maxZoom: { type: 'number' }, retina: { type: 'boolean' } }, required: ['url'] }] },
130
+ tilesKey: { type: 'string', description: 'pointmap: API key or PUBLIC token for the tile provider (Mapbox "pk.…"; a secret "sk." token is rejected because it would be visible in the SVG).' },
131
+ tileStyle: { type: 'string', enum: ['muted', 'original'], description: 'pointmap: "muted" (default) desaturates the basemap so the data stands out; "original" keeps its colors.' },
132
+ tileOpacity: { type: 'number', minimum: 0, maximum: 1, description: 'pointmap: basemap opacity.' },
133
+ zoom: { type: 'number', description: 'pointmap: tile zoom level (automatic from the points by default).' },
134
+ center: { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2, description: 'pointmap: map center [lon, lat] (default: fitted to the points).' },
135
+ tall: { type: 'boolean', description: 'Map: false prevents the tall layout (map on the left, ranked label column on the right) for elongated territories.' },
72
136
  midpoint: { type: 'number', description: 'choropleth/heatmap: diverging color scale around this value (e.g. 0 for changes).' },
73
137
  classes: { type: 'integer', description: 'choropleth: number of color classes (default 5).' },
138
+ scale: { description: 'choropleth: "quantile" (default), "equal", "continuous", "categorical" (text values get one color each; automatic when `value` holds text) or a list of thresholds.', anyOf: [{ type: 'string', enum: ['quantile', 'equal', 'continuous', 'categorical'] }, { type: 'array', items: { type: 'number' } }] },
74
139
  nodes: { type: 'array', items: { type: 'object' }, description: 'network: node rows (id, group, size). flowchart: steps (id, label, type: start|end|process|decision|data|note, lane).' },
75
140
  edges: { type: 'array', items: { type: 'object' }, description: 'flowchart: connections (from, to, label). Same as `data`.' },
76
- layout: { type: 'string', enum: ['force', 'circle'], description: 'network layout.' },
141
+ layout: { type: 'string', enum: ['force', 'circular', 'radial', 'concentric', 'clusters', 'tree', 'grid', 'circle'], description: 'network layout: "force" (default) deterministic force-directed; "circular" around a circle by group with names rotated outward ("circle" = circular); "radial" tree rings from `focus` or the most connected node; "concentric" rings by weight; "clusters" one cluster per group; "tree" the radial tree laid flat; "grid" ordered by group.' },
142
+ curveness: { type: 'number', minimum: 0, description: 'network: edge bend as a share of its length (default 0.3; 0 = straight).' },
143
+ maxRadius: { type: 'number', description: 'network/symbolmap/pointmap/bubble: largest node or circle radius in px.' },
144
+ iterations: { type: 'integer', description: 'network: force-layout iterations (automatic by size).' },
145
+ nodeId: field('network: id field in `nodes` (default id/name).'),
146
+ nodeLabel: field('network: label field in `nodes` (default label, else the id).'),
147
+ labelOrientation: { type: 'string', enum: ['horizontal', 'radial'], description: 'chord: label orientation (radial by default above 10 groups).' },
148
+ ribbonColor: { type: 'string', enum: ['source', 'smaller', 'larger'], description: 'chord: ribbon color: "source" (default when directed), "smaller" group (default) or "larger" flow.' },
77
149
  matrix: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'chord: square flow matrix (with `names`).' },
78
150
  names: { type: 'array', items: { type: 'string' }, description: 'chord: group names for `matrix`.' },
79
151
  directed: { type: 'boolean', description: 'chord: treat flows as directed (default false).' },
@@ -84,17 +156,53 @@ export const schema = {
84
156
 
85
157
  // Layout and interaction
86
158
  facet: field('One panel per value of this field (small multiples, seaborn col). Shared scales and colors.'),
87
- columns: { type: 'integer', description: 'facet: panels per row (seaborn col_wrap). Reduced automatically on narrow screens.' },
159
+ col: field('Alias of facet (seaborn naming).'),
160
+ facetOrder: { type: 'array', items: { type: 'string' }, description: 'facet: panel order (values of the facet field; the rest follow).' },
161
+ columns: {
162
+ description: 'table: the columns, in order: field names or objects { field, label, type, format, align, width, good, target, colorScale, total, lines }. Column types: text, number, bar (data bar), heat (cell color), delta (▲/▼ change, colored by `good`), sparkline (field holds a list of numbers), progress (meter vs `target` field/number, or 0–1 / 0–100), badge (status pill; status words in es/en/pt/fr/de get good/warning/critical colors), check (booleans as ✓), date, link, rank. Omit to infer everything. facet: panels per row (an integer, seaborn col_wrap). kpi: tiles per row.',
163
+ anyOf: [
164
+ { type: 'integer' },
165
+ { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'object', properties: {
166
+ field: { type: 'string' }, label: { type: 'string' },
167
+ type: { type: 'string', enum: ['text', 'number', 'bar', 'heat', 'delta', 'sparkline', 'progress', 'badge', 'check', 'date', 'link', 'rank'] },
168
+ format: { anyOf: [{ type: 'string' }, { type: 'object' }] }, align: { type: 'string', enum: ['left', 'right', 'center'] }, width: { type: 'number' },
169
+ good: { type: 'string', enum: ['up', 'down'] }, target: { anyOf: [{ type: 'string' }, { type: 'number' }] }, colorScale: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }, { type: 'object' }] },
170
+ total: { anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max', 'count'] }] }, lines: { type: 'integer', minimum: 1, maximum: 6 }, text: { type: 'string', description: 'link: field with the text to show instead of the URL.' }, sortable: { type: 'boolean' },
171
+ } }] } },
172
+ ],
173
+ },
174
+ sortBy: field('table: column to sort by (field or label). Numbers sort high→low by default, text A→Z. In the browser a click on a header sorts.'),
175
+ sortOrder: { type: 'string', enum: ['asc', 'desc'], description: 'table: sort direction (same as `sort: "asc" | "desc"`).' },
176
+ pageSize: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'boolean', enum: [false] }], description: 'table: rows per page. Default: all rows up to 15, then pages of 10. false shows every row.' },
177
+ page: { type: 'integer', minimum: 1, description: 'table: page to show (1-based). In the browser the pager changes it.' },
178
+ query: { type: 'string', description: 'table: show only rows that contain these words (accent- and case-insensitive).' },
179
+ search: { type: 'boolean', description: 'table: search box in the browser (default: on above 20 rows).' },
180
+ total: { description: 'table: totals row. true sums numbers (and averages percents and progress); "mean"/"sum"; or { field: "sum" | "mean" | false, label }.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['sum', 'mean', 'min', 'max'] }, { type: 'object' }] },
181
+ totalLabel: { type: 'string', description: 'table: label of the totals row (default "Total"/"Gesamt").' },
182
+ rank: { type: 'boolean', description: 'table: add a "#" column with the position under the current sort.' },
183
+ cards: { description: 'table: one card per row. "auto" (default) switches when the columns do not fit (phones); true always; false never (columns shrink and truncate).', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['auto'] }] },
184
+ density: { type: 'string', enum: ['compact', 'normal', 'comfortable'], description: 'table: row height.' },
185
+ striped: { type: 'boolean', description: 'table: tint every other row (helps on very wide tables).' },
186
+ bars: { type: 'boolean', description: 'table: false turns off the automatic data bar on the main numeric column.' },
187
+ sortable: { type: 'boolean', description: 'table: false disables click-to-sort on headers in the browser.' },
188
+ sharex: bool('facet: same x scale in every panel (default true).'),
189
+ panelHeight: number('facet: height of each panel in px.'),
88
190
  sharey: { type: 'boolean', description: 'facet: same y scale in every panel (default true).' },
89
- time: field('Time field (d3plus-style): shows one period at a time (the latest by default) and, in the browser, a slider with play. Not used by line/area, which get a range slider.'),
191
+ time: field('Time field (d3plus-style): shows one period at a time (the latest by default, or `timeValue`). Add `slider: true` only when the person asks to move through periods.'),
90
192
  timeValue: { description: 'Period to show when `time` is set.' },
91
- slider: { type: 'boolean', description: 'Browser: false removes the time slider (the chart shows `timeValue` or the latest period).' },
92
- play: { type: 'boolean', description: 'Browser: false keeps the time slider but hides the play button.' },
193
+ slider: { description: 'Browser time controls, OFF by default; set them only when the person asks. true: period buttons when there are few periods (≤ 8), a minimal slider otherwise; line/area get a range slider. "buttons" or "slider" force one.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['buttons', 'slider'] }] },
194
+ play: { type: 'boolean', description: 'Browser: false keeps the time controls but hides the play button.' },
195
+ playInterval: number('Browser: milliseconds per period while playing.'),
196
+ legend: { description: 'Legend: true/false, or its position "top" (default), "bottom", "left", "right" (left/right fall back to bottom under 520px wide), or { position }.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['top', 'bottom', 'left', 'right'] }, { type: 'object', properties: { position: { type: 'string', enum: ['top', 'bottom', 'left', 'right'] } } }] },
197
+ legendTitle: { type: 'string', description: 'Title of a color-ramp or size legend (heatmap, maps).' },
198
+ exclude: { description: 'Drop legend entries (series, first hierarchy level, group or donut slice) and recompute axes and shares with the rest. Colors stay fixed; excluded entries remain in the legend, faded, so a click re-adds them. In the browser shift+click on a legend key toggles it.', ...strOrList },
93
199
  compare: { description: 'With `time`: period to compute changes against: "previous" (default), "first", a specific period, or false.', anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] },
94
200
  colorBy: { type: 'string', enum: ['group', 'change', 'label'], description: 'treemap: "change" colors tiles by % change vs the comparison period (diverging, growth cool / decline warm); "label" colors a single-level treemap by item.' },
95
201
  shade: { type: 'boolean', description: 'treemap/sunburst: subtle lightness steps inside each category (default false: one solid color per category).' },
96
- xRange: { type: 'array', description: 'line/area: visible x range [from, to] (what the range slider sets).' },
97
- focus: { description: 'treemap/sunburst: path to drill into, e.g. ["Mining"]. network: a node id to center and highlight with its neighbors. In the browser, clicking does it.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
202
+ xRange: { type: 'array', description: 'line/area: visible x range [from, to] (what the range slider sets with `slider: true`).' },
203
+ showValues: bool('treemap: show each tile value next to its label.'),
204
+ hole: { type: 'number', minimum: 0, maximum: 0.9, description: 'sunburst: size of the center hole as a fraction of the radius (default 0.3).' },
205
+ focus: { description: 'treemap/sunburst: path to drill into, e.g. ["Mining"] (in the browser a click on a tile or legend key zooms to it). network: a node id to center and highlight with its neighbors.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
98
206
  depth: { type: 'integer', description: 'treemap/sunburst: how many levels to show.' },
99
207
 
100
208
  // Scales and axes
@@ -102,31 +210,30 @@ export const schema = {
102
210
  yScale: { type: 'string', enum: ['linear', 'log'] },
103
211
  xDomain: { type: 'array', items: { type: 'number' }, description: 'Fixed x domain [min, max].' },
104
212
  yDomain: { type: 'array', items: { type: 'number' }, description: 'Fixed value-axis domain [min, max].' },
213
+ valueDomain: numPair('box/violin/strip/swarm/point/radar: fixed value-axis domain [min, max] whatever the orientation.'),
214
+ orient: { type: 'string', enum: ['v', 'h'], description: 'box/violin/strip/swarm/point: "v" vertical or "h" horizontal (same as `horizontal`).' },
105
215
  zero: { type: 'boolean', description: 'line: force (or prevent) a y axis starting at zero.' },
106
216
  xLabel: { type: 'string', description: 'x-axis title.' },
107
217
  yLabel: { type: 'string', description: 'y-axis title (drawn horizontally above the axis).' },
108
- format: {
109
- description: 'Value format: "number" | "compact" | "percent" | "currency" | "integer", or { type, prefix, suffix, decimals, currency }.',
110
- anyOf: [
111
- { type: 'string', enum: ['number', 'compact', 'percent', 'currency', 'integer'] },
112
- { type: 'object', properties: { type: { type: 'string' }, prefix: { type: 'string' }, suffix: { type: 'string' }, decimals: { type: 'integer' }, currency: { type: 'string' } } },
113
- ],
114
- },
218
+ format: formatSpec('Value format: "number" | "compact" | "percent" | "currency" | "integer", or { type, prefix, suffix, decimals (0–20), currency }.'),
219
+ xFormat: formatSpec('Format of x values when x is a measure (scatter, regplot, hist).'),
220
+ yFormat: formatSpec('Format of y values (overrides `format` on the y axis).'),
115
221
 
116
222
  // Emphasis and annotation
117
- highlight: { description: 'Emphasize one or more categories/series and mute the rest.', ...strOrList },
223
+ highlight: { description: 'Emphasize one or more categories/series and mute the rest. table: the rows whose label matches.', ...strOrList },
118
224
  dashed: { description: 'Draw some shapes dashed (hatched fill + dashed outline; dashed stroke for lines) to mark estimates, projections or provisional figures. A category/series/region name or a list of them, a data field whose truthy rows are dashed, { field, equals }, or true for all.', anyOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'array', items: { type: 'string' } }, { type: 'object', properties: { field: { type: 'string' }, equals: {} } }] },
119
225
  dashedFrom: { description: 'line/area/bar on a time axis: x value from which marks are dashed (a projection). Lines turn dashed from the last firm point, over a soft labeled band.', anyOf: [{ type: 'string' }, { type: 'number' }] },
120
226
  dashedLabel: { description: 'What the dashed shapes mean ("Projection", "Preliminary"): legend entry, band label and tooltip line. false hides the band label.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
121
- labels: { description: 'Value labels: true, false or "auto". In scatter/regplot: a number of points or a list of names to label.', anyOf: [{ type: 'boolean' }, { type: 'string' }, { type: 'number' }, { type: 'array', items: { type: 'string' } }] },
227
+ labels: { description: 'Value labels: true, false or "auto". In scatter/regplot: a number of points or a list of names to label. network: true labels every node.', anyOf: [{ type: 'boolean' }, { type: 'string' }, { type: 'number' }, { type: 'array', items: { type: 'string' } }] },
122
228
  refLines: { type: 'array', description: 'Reference lines (targets, averages).', items: { type: 'object', properties: { y: { type: 'number' }, x: { type: 'number' }, value: { type: 'number' }, label: { type: 'string' } } } },
123
229
  annotations: { type: 'array', description: 'line/area: events on the x axis.', items: { type: 'object', properties: { x: {}, label: { type: 'string' } } } },
124
230
 
125
231
  // Bars and lines
126
232
  stack: { description: 'bar/area: true stacks; "percent" stacks to 100%.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['percent'] }] },
127
233
  horizontal: { type: 'boolean', description: 'Force horizontal orientation (otherwise chosen from label length and count).' },
128
- sort: { type: 'string', enum: ['desc', 'asc', 'none', 'alpha'], description: 'bar: category order (desc by default unless the categories are time).' },
129
- top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other".' },
234
+ top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other". table: keep the first N rows after sorting.' },
235
+ endLabels: bool('line/regplot: false removes the series names at the end of each line (a legend is drawn instead).'),
236
+ labelsFor: { type: 'object', additionalProperties: { type: 'string' }, description: 'Wide table (y as a list): display names per column, e.g. { "rev": "Revenue" }.' },
130
237
  index: { type: 'boolean', description: 'line: rebase each series to 100 at its first value (compare growth without a dual axis).' },
131
238
  curve: { type: 'string', enum: ['linear', 'smooth', 'step'] },
132
239
  aggregate: { type: 'string', enum: ['sum', 'mean', 'max', 'min', 'count', 'last', 'first'], description: 'How to combine repeated rows. Default sum.' },
@@ -141,40 +248,132 @@ export const schema = {
141
248
  kde: { type: 'boolean', description: 'hist: overlay a density curve.' },
142
249
  multiple: { type: 'string', enum: ['layer', 'stack', 'dodge', 'fill'], description: 'hist/kde with hue: how groups are combined.' },
143
250
  fill: { type: 'boolean', description: 'kde: fill the area. line: false removes the gradient under a single line.' },
144
- order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
251
+ order: { description: 'regplot: polynomial degree (number). box/violin/strip/swarm/point: category order (list). corr: variable order (list).', anyOf: [{ type: 'integer' }, { type: 'array', items: { type: 'string' } }] },
145
252
  lowess: { type: 'boolean', description: 'regplot: local regression instead of a straight line.' },
146
253
  logx: { type: 'boolean', description: 'regplot: fit y ~ log(x) with a log x axis.' },
147
254
  trend: { type: 'boolean', description: 'scatter: add a least-squares line and R².' },
148
255
  annotate: { type: 'boolean', description: 'regplot: show R² and n.' },
149
- kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot.' },
256
+ kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot. pairplot: "reg" adds a least-squares line to every scatter panel.' },
150
257
  marginal: { type: 'string', enum: ['hist', 'kde'], description: 'jointplot: marginal plots.' },
151
- inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'none'], description: 'violin: what goes inside.' },
258
+ inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'stick', 'none'], description: 'violin: what goes inside.' },
259
+ bandwidth: number('violin: KDE bandwidth (default Scott\'s rule).'),
260
+ bwAdjust: number('kde/violin/hist kde: multiply the bandwidth (seaborn bw_adjust).'),
261
+ cut: number('kde/violin: how far the density extends past the data, in bandwidths.'),
262
+ jitter: { description: 'strip: spread of the points across the category (fraction of the band, default 0.6; 0 or false = none).', anyOf: [{ type: 'number' }, { type: 'boolean' }] },
263
+ dodge: bool('strip/swarm/point with hue: place groups side by side.'),
264
+ hueOrder: { type: 'array', items: { type: 'string' }, description: 'Order of the hue groups.' },
265
+ showMeans: bool('box/strip: mark the mean.'),
266
+ cumulative: bool('hist/kde: cumulative distribution.'),
267
+ rug: bool('kde: tick marks for each observation.'),
268
+ complementary: bool('ecdf: show 1 − F(x) (share above each value).'),
269
+ radius: number('Point radius in px (scatter, regplot, strip, swarm).'),
152
270
  split: { type: 'boolean', description: 'violin: with two hue levels, one half each.' },
153
271
  points: { type: 'boolean', description: 'box: overlay the observations. line: markers on every point.' },
154
272
  normalize: { type: 'boolean', description: 'radar: scale each axis 0–1 by its own maximum.' },
155
273
 
156
274
  // Part-to-whole
157
275
  half: { type: 'boolean', description: 'donut: semicircle.' },
276
+ thickness: { type: 'number', minimum: 0, maximum: 1, description: 'donut: ring thickness as a fraction of the radius (1 = pie).' },
277
+ maxSlices: { type: 'integer', minimum: 2, description: 'donut/pie: slices before the rest fold into "Other" (default 6).' },
278
+ centerValue: { type: 'string', description: 'donut/sunburst: text in the center (default: the total).' },
279
+ centerLabel: { type: 'string', description: 'donut/sunburst: caption under the center value.' },
280
+ cellHeight: number('heatmap: maximum cell height in px.'),
281
+ vars: { type: 'array', items: { type: 'string' }, description: 'corr/pairplot: the numeric fields to relate, in order (seaborn `vars`). Default: every numeric field (pairplot: the first 6). `y: [...]` works too.' },
282
+ method: { type: 'string', enum: ['pearson', 'spearman'], description: 'corr: correlation coefficient. "pearson" (default, linear) or "spearman" (ranks: monotonic relations, robust to outliers).' },
283
+ triangle: { anyOf: [{ type: 'string', enum: ['lower', 'full'] }, { type: 'boolean' }], description: 'corr: "lower" (default) shows each pair once, below the diagonal; "full" shows the whole symmetric matrix (default with `cluster`).' },
284
+ cluster: bool('corr: reorder variables by hierarchical clustering (average linkage on 1 − r) and draw the dendrogram on top, like sns.clustermap. `type: "clustermap"` sets it.'),
285
+ dendrogram: bool('corr with `cluster`: false hides the dendrogram but keeps the clustered order.'),
286
+ alpha: { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 1, description: 'corr: significance level (e.g. 0.05). Cells with p ≥ alpha are greyed out and a legend key explains it. p-values use Fisher\'s z.' },
287
+ cellSize: number('corr: maximum cell size in px (cells are square).'),
288
+ diag: { type: 'string', enum: ['hist', 'kde'], description: 'pairplot: what goes on the diagonal. Default "hist", or "kde" when `hue` is set.' },
289
+ upper: { anyOf: [{ type: 'string', enum: ['corr', 'scatter'] }, { type: 'boolean', enum: [false] }], description: 'pairplot: above the diagonal, "corr" (default: Pearson r, tinted by strength, per `hue` group when there is room), "scatter" (mirror), or false (empty).' },
290
+ corner: bool('pairplot: draw only the lower triangle and the diagonal (seaborn corner=True).'),
291
+ panelSize: number('pairplot: maximum panel size in px.'),
292
+ diverging: bool('heatmap/choropleth: diverging color scale around 0 (or `midpoint`).'),
158
293
 
159
294
  // Appearance
160
295
  colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities: { "Biobío": "#3A47B8" }.' },
161
- palette: { description: 'Palette shortcut: a name ("cdia", "okabe-ito", "tableau10", "udec") or a list of colors in fixed order.', ...strOrList },
162
- ramp: { description: 'Sequential ramp: a name ("indigo", "magenta", "teal", "amber") or a list of colors from light to dark.', ...strOrList },
296
+ palette: { description: 'Categorical colors: a name ("cdia", "okabe-ito", "tableau10", "udec"), a matplotlib qualitative palette ("tab10", "tab20", "Set1", "Set2", "Set3", "Pastel1", "Pastel2", "Paired", "Accent", "Dark2"), any colormap name (sampled, e.g. "viridis"), or a list of colors in fixed order.', ...strOrList },
297
+ ramp: { description: 'Sequential ramp: a name ("indigo", "magenta", "teal", "amber"), any colormap name, or a list of colors from light to dark.', ...strOrList },
298
+ colorScale: colorScaleSpec('Continuous color scale (heatmap, choropleth, treemap change, pointmap values): any matplotlib colormap name, case-insensitive, `_r` reverses ("viridis", "plasma", "inferno", "magma", "cividis", "Blues", "Greens", "YlOrRd", "RdBu", "coolwarm", "Spectral", "turbo", "twilight"…); diverging names imply a diverging scale. Or a list of hex colors, or { name | colors, reverse, mid }. Aliases: cmap, colormap. Qualitative palettes (tab10…) go in `palette`; avoid rainbow maps (jet, hsv).'),
299
+ cmap: colorScaleSpec('Alias of colorScale (matplotlib naming).'),
300
+ colormap: colorScaleSpec('Alias of colorScale.'),
163
301
  theme: {
164
- description: 'Theme: "papel" (light, default), "noche" (dark), or a JSON object deep-merged over `extends`. Keys: surface, ink, lede, muted, grid, axis, tint, dim, other, categorical (list or palette name), sequential, diverging, status {good, warning, critical}, font {family, mono, import}, type {title, subtitle, label, axis, legend, source, titleWeight}, marks {barRadius, barMax, lineWidth, pointRadius, gap, ring, areaOpacity, bandOpacity, dotOpacity}, axes {inside, grid}, layout {padding}.',
165
- anyOf: [{ type: 'string', enum: ['papel', 'noche', 'light', 'dark'] }, { type: 'object' }],
302
+ description: 'Theme: "papel" (light, default), "noche" (dark), a name registered with registerTheme(), or a JSON object deep-merged over `extends`. Simple tokens: background (alias of surface), text (alias of ink), accent (first series color), grid (a color or { color, dash, width }), axis, tooltip { bg, ink }, radius / barRadius (corner radius), font. Full keys: surface, ink, lede, muted, tint, dim, other, categorical (list, palette or colormap name), sequential, diverging, status {good, warning, critical}, font {family, mono, import}, type {title, subtitle, label, axis, legend, source, titleWeight}, marks {barRadius, barMax, lineWidth, pointRadius, gap, ring, areaOpacity, bandOpacity, dotOpacity}, axes {inside, grid}, layout {padding}. configure({ theme }) applies it to every chart.',
303
+ anyOf: [
304
+ { type: 'string', description: '"papel", "noche", "light", "dark" or a registered theme name.' },
305
+ { type: 'object', properties: {
306
+ extends: { type: 'string' }, mode: { type: 'string', enum: ['light', 'dark'] },
307
+ background: { type: 'string' }, surface: { type: 'string' }, text: { type: 'string' }, ink: { type: 'string' }, lede: { type: 'string' }, muted: { type: 'string' }, accent: { type: 'string' },
308
+ grid: { anyOf: [{ type: 'string' }, { type: 'object', properties: { color: { type: 'string' }, dash: { type: 'string' }, width: { type: 'number' } } }] },
309
+ axis: { type: 'string' }, tooltip: { type: 'object', properties: { bg: { type: 'string' }, ink: { type: 'string' } } },
310
+ radius: { type: 'number' }, barRadius: { type: 'number' },
311
+ categorical: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
312
+ sequential: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
313
+ diverging: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
314
+ font: { anyOf: [{ type: 'string' }, { type: 'object' }] },
315
+ status: { type: 'object' }, type: { type: 'object' }, marks: { type: 'object' }, axes: { type: 'object' }, layout: { type: 'object' },
316
+ } },
317
+ ],
166
318
  },
167
319
  font: { description: 'Typeface for the whole chart: a Google Fonts name ("Poppins"), a CSS stack ("\'Inter\', sans-serif") or { family, import }. configure({ font }) sets it for every chart.', anyOf: [{ type: 'string' }, { type: 'object' }] },
168
320
  textScale: { type: 'number', description: 'Multiplies every text size (1.5, 2…); the layout is recomputed so nothing overlaps. Aliases: fontScale, labelScale. configure({ textScale }) sets it for every chart.' },
169
321
  width: { type: 'number', description: 'Width in px (default 720; render() uses the container width).' },
170
322
  height: { type: 'number', description: 'Height in px. Automatic for horizontal bars, heatmaps, KPI tiles, and narrow treemaps, sunbursts and radars.' },
323
+ padding: number('Outer padding in px (default from the width).'),
324
+ },
325
+ };
326
+
327
+ /** JSON Schema of a dashboard: dashboard({ title, kpis, charts, filters, … }). */
328
+ export const dashboardSchema = {
329
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
330
+ title: 'plotagents dashboard',
331
+ description: 'A dashboard with best-practice defaults: KPIs on top, the main chart wide, the rest in balanced full rows of a 12-column grid, one color per entity across charts, shared filters, one source line.',
332
+ type: 'object',
333
+ properties: {
334
+ title: { type: 'string', description: 'The question the dashboard answers, e.g. "How are southern sales doing?"' },
335
+ subtitle: { type: 'string', description: 'Scope: measure, unit, period.' },
336
+ source: { type: 'string', description: 'Data source, shown once at the bottom.' },
337
+ updated: { type: 'string', description: 'Last update, e.g. "2026-01-05".' },
338
+ locale: { type: 'string', enum: ['es', 'en', 'pt', 'fr', 'de', 'auto'] },
339
+ theme: { description: 'One theme for every chart: "papel" (light) or "noche" (dark), or a theme object.', anyOf: [{ type: 'string' }, { type: 'object' }] },
340
+ data: { type: 'array', items: { type: 'object' }, description: 'Shared rows: charts without their own `data` use these, and computed KPIs and filters work on them.' },
341
+ kpis: {
342
+ type: 'array', description: 'Up to 5 key figures. Fixed ({ label, value, delta, period }) or computed from `data` ({ label, field, agg, compare, trendBy }).',
343
+ items: { type: 'object', properties: {
344
+ label: { type: 'string' }, value: { type: 'number' }, delta: { type: 'number', description: 'Change as a fraction (0.12 = +12%).' }, period: { type: 'string' },
345
+ field: { type: 'string' }, agg: { type: 'string', enum: ['sum', 'mean', 'median', 'min', 'max', 'count', 'distinct', 'ratio'] }, over: { type: 'string', description: 'agg "ratio": denominator field.' },
346
+ scale: { type: 'number', description: 'Multiply the computed value (e.g. 1000 to show thousands).' },
347
+ compare: { type: 'object', description: 'Current vs previous period: { field: "year", current: 2025, previous: 2024 }.', properties: { field: { type: 'string' }, current: {}, previous: {} } },
348
+ trendBy: { type: 'string', description: 'Field to draw a sparkline by (e.g. "month").' },
349
+ previous: { type: 'number', description: 'Previous value: the change is computed from it.' }, display: { type: 'string', description: 'Text shown instead of the formatted value.' },
350
+ format: {}, good: { type: 'string', enum: ['up', 'down'] }, trend: { type: 'array', items: { type: 'number' } },
351
+ } },
352
+ },
353
+ charts: { type: 'array', items: { type: 'object' }, description: 'Chart specs (same as plot()). Optional per chart: `primary: true` (the main, wide chart), `size: "full" | "large" | "medium" | "small"` or `span` (of 12).' },
354
+ filters: { type: 'array', description: 'Fields to filter every chart and computed KPI by: "region" or { field, label, control: "buttons" | "select", value }.', items: { anyOf: [{ type: 'string' }, { type: 'object' }] } },
355
+ layout: { type: 'string', enum: ['auto', 'executive', 'grid'], description: 'auto (default): balanced rows by chart type. executive: main chart full width, the rest in pairs. grid: equal panels.' },
356
+ colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities across every chart.' },
357
+ order: { type: 'string', enum: ['given'], description: '"given" keeps the charts in the order you wrote them (otherwise the main time series goes first).' },
358
+ note: { type: 'string', description: 'Short note next to the source, at the bottom.' },
359
+ font: { description: 'Font for every chart: a Google Fonts name, a CSS stack or { family, import }.', anyOf: [{ type: 'string' }, { type: 'object' }] },
360
+ palette: { description: 'Categorical palette for every chart: a name ("tab10", "okabe-ito") or a list of colors.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
361
+ ramp: { description: 'Sequential ramp for every chart.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
362
+ textScale: { type: 'number', description: 'Multiplies every text (1.5 for presentations).' },
171
363
  },
172
364
  };
173
365
 
174
366
  /** Tool definition ready for Claude (Anthropic) or OpenAI. */
175
- export function toolDefinition({ name = 'plot', flavor = 'anthropic' } = {}) {
367
+ export function toolDefinition({ name, flavor = 'anthropic', kind = 'chart' } = {}) {
368
+ if (kind === 'dashboard') {
369
+ const description = 'Builds a self-contained, interactive HTML dashboard with best-practice defaults: up to 5 KPIs on top (fixed or computed from data, with year-over-year change), the main chart wide, the rest in balanced rows, the same color for the same entity in every chart, and filters as buttons. Each chart is a plotagents spec with a `title` that states its finding.';
370
+ const { $schema: _s, title: _t, ...params } = dashboardSchema;
371
+ const n = name ?? 'dashboard';
372
+ return flavor === 'openai' ? { type: 'function', function: { name: n, description, parameters: params } } : { name: n, description, input_schema: params };
373
+ }
374
+ name = name ?? 'plot';
176
375
  const description =
177
- 'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, scatter, bubble, regplot, residplot, jointplot, hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, choropleth (Chile regions / world countries built in), symbolmap, network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` adds a per-period slider. Set `locale: "en"` for English number formats.';
376
+ 'Draws an elegant, accessible chart from tabular data. Pass rows in `data`, name the fields (x, y, series/hue…) and write a `title` that states the finding. Returns an SVG. Types: bar, count, line, area, wordcloud (words sized by frequency, or raw text to count), gantt (tasks with start/end, phases, progress, dependencies, milestones), scatter, bubble, regplot, residplot, jointplot, corr (correlation matrix of numeric fields, optional clustering: clustermap), pairplot (scatter-plot matrix with distributions and r), hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, table (exact values with per-column indicators: data bars, ▲/▼ changes, cell colors, sparklines, progress meters, status pills; paginated, cards on phones), choropleth (Chile regions, US states and world countries built in, or any GeoJSON/TopoJSON in `geo`, with markers, layers and fitTo), symbolmap, pointmap (lat/lon dots over a tile basemap), network, chord, pyramid, flowchart (seaborn names accepted). `facet` makes panels; `time` shows one period (add `slider: true` for controls). `data` is required except for kpi (`items`), flowchart (`nodes`/`edges`), chord (`matrix`) and wordcloud raw `text`. Set `locale: "en"` for English number formats.';
178
377
  const { $schema, $id, title, ...params } = schema;
179
378
  if (flavor === 'openai') return { type: 'function', function: { name, description, parameters: params } };
180
379
  return { name, description, input_schema: params };