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/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. 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`.',
@@ -33,10 +51,11 @@ export const schema = {
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).'),
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,27 @@ 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: { type: 'integer', description: 'facet: panels per row (seaborn col_wrap). Reduced automatically on narrow screens. kpi: tiles per row.' },
162
+ sharex: bool('facet: same x scale in every panel (default true).'),
163
+ panelHeight: number('facet: height of each panel in px.'),
88
164
  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.'),
165
+ 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
166
  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.' },
167
+ 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'] }] },
168
+ play: { type: 'boolean', description: 'Browser: false keeps the time controls but hides the play button.' },
169
+ playInterval: number('Browser: milliseconds per period while playing.'),
170
+ 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'] } } }] },
171
+ legendTitle: { type: 'string', description: 'Title of a color-ramp or size legend (heatmap, maps).' },
172
+ 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
173
  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
174
  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
175
  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' } }] },
176
+ xRange: { type: 'array', description: 'line/area: visible x range [from, to] (what the range slider sets with `slider: true`).' },
177
+ showValues: bool('treemap: show each tile value next to its label.'),
178
+ hole: { type: 'number', minimum: 0, maximum: 0.9, description: 'sunburst: size of the center hole as a fraction of the radius (default 0.3).' },
179
+ 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
180
  depth: { type: 'integer', description: 'treemap/sunburst: how many levels to show.' },
99
181
 
100
182
  // Scales and axes
@@ -102,31 +184,30 @@ export const schema = {
102
184
  yScale: { type: 'string', enum: ['linear', 'log'] },
103
185
  xDomain: { type: 'array', items: { type: 'number' }, description: 'Fixed x domain [min, max].' },
104
186
  yDomain: { type: 'array', items: { type: 'number' }, description: 'Fixed value-axis domain [min, max].' },
187
+ valueDomain: numPair('box/violin/strip/swarm/point/radar: fixed value-axis domain [min, max] whatever the orientation.'),
188
+ orient: { type: 'string', enum: ['v', 'h'], description: 'box/violin/strip/swarm/point: "v" vertical or "h" horizontal (same as `horizontal`).' },
105
189
  zero: { type: 'boolean', description: 'line: force (or prevent) a y axis starting at zero.' },
106
190
  xLabel: { type: 'string', description: 'x-axis title.' },
107
191
  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
- },
192
+ format: formatSpec('Value format: "number" | "compact" | "percent" | "currency" | "integer", or { type, prefix, suffix, decimals (0–20), currency }.'),
193
+ xFormat: formatSpec('Format of x values when x is a measure (scatter, regplot, hist).'),
194
+ yFormat: formatSpec('Format of y values (overrides `format` on the y axis).'),
115
195
 
116
196
  // Emphasis and annotation
117
197
  highlight: { description: 'Emphasize one or more categories/series and mute the rest.', ...strOrList },
118
198
  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
199
  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
200
  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' } }] },
201
+ 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
202
  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
203
  annotations: { type: 'array', description: 'line/area: events on the x axis.', items: { type: 'object', properties: { x: {}, label: { type: 'string' } } } },
124
204
 
125
205
  // Bars and lines
126
206
  stack: { description: 'bar/area: true stacks; "percent" stacks to 100%.', anyOf: [{ type: 'boolean' }, { type: 'string', enum: ['percent'] }] },
127
207
  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
208
  top: { type: 'integer', description: 'bar: keep the N largest and fold the rest into "Other".' },
209
+ endLabels: bool('line/regplot: false removes the series names at the end of each line (a legend is drawn instead).'),
210
+ labelsFor: { type: 'object', additionalProperties: { type: 'string' }, description: 'Wide table (y as a list): display names per column, e.g. { "rev": "Revenue" }.' },
130
211
  index: { type: 'boolean', description: 'line: rebase each series to 100 at its first value (compare growth without a dual axis).' },
131
212
  curve: { type: 'string', enum: ['linear', 'smooth', 'step'] },
132
213
  aggregate: { type: 'string', enum: ['sum', 'mean', 'max', 'min', 'count', 'last', 'first'], description: 'How to combine repeated rows. Default sum.' },
@@ -148,33 +229,108 @@ export const schema = {
148
229
  annotate: { type: 'boolean', description: 'regplot: show R² and n.' },
149
230
  kind: { type: 'string', enum: ['scatter', 'reg'], description: 'jointplot: center plot.' },
150
231
  marginal: { type: 'string', enum: ['hist', 'kde'], description: 'jointplot: marginal plots.' },
151
- inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'none'], description: 'violin: what goes inside.' },
232
+ inner: { type: 'string', enum: ['box', 'quartiles', 'points', 'stick', 'none'], description: 'violin: what goes inside.' },
233
+ bandwidth: number('violin: KDE bandwidth (default Scott\'s rule).'),
234
+ bwAdjust: number('kde/violin/hist kde: multiply the bandwidth (seaborn bw_adjust).'),
235
+ cut: number('kde/violin: how far the density extends past the data, in bandwidths.'),
236
+ 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' }] },
237
+ dodge: bool('strip/swarm/point with hue: place groups side by side.'),
238
+ hueOrder: { type: 'array', items: { type: 'string' }, description: 'Order of the hue groups.' },
239
+ showMeans: bool('box/strip: mark the mean.'),
240
+ cumulative: bool('hist/kde: cumulative distribution.'),
241
+ rug: bool('kde: tick marks for each observation.'),
242
+ complementary: bool('ecdf: show 1 − F(x) (share above each value).'),
243
+ radius: number('Point radius in px (scatter, regplot, strip, swarm).'),
152
244
  split: { type: 'boolean', description: 'violin: with two hue levels, one half each.' },
153
245
  points: { type: 'boolean', description: 'box: overlay the observations. line: markers on every point.' },
154
246
  normalize: { type: 'boolean', description: 'radar: scale each axis 0–1 by its own maximum.' },
155
247
 
156
248
  // Part-to-whole
157
249
  half: { type: 'boolean', description: 'donut: semicircle.' },
250
+ thickness: { type: 'number', minimum: 0, maximum: 1, description: 'donut: ring thickness as a fraction of the radius (1 = pie).' },
251
+ maxSlices: { type: 'integer', minimum: 2, description: 'donut/pie: slices before the rest fold into "Other" (default 6).' },
252
+ centerValue: { type: 'string', description: 'donut/sunburst: text in the center (default: the total).' },
253
+ centerLabel: { type: 'string', description: 'donut/sunburst: caption under the center value.' },
254
+ cellHeight: number('heatmap: maximum cell height in px.'),
255
+ diverging: bool('heatmap/choropleth: diverging color scale around 0 (or `midpoint`).'),
158
256
 
159
257
  // Appearance
160
258
  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 },
259
+ 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 },
260
+ ramp: { description: 'Sequential ramp: a name ("indigo", "magenta", "teal", "amber"), any colormap name, or a list of colors from light to dark.', ...strOrList },
261
+ 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).'),
262
+ cmap: colorScaleSpec('Alias of colorScale (matplotlib naming).'),
263
+ colormap: colorScaleSpec('Alias of colorScale.'),
163
264
  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' }],
265
+ 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.',
266
+ anyOf: [
267
+ { type: 'string', description: '"papel", "noche", "light", "dark" or a registered theme name.' },
268
+ { type: 'object', properties: {
269
+ extends: { type: 'string' }, mode: { type: 'string', enum: ['light', 'dark'] },
270
+ background: { type: 'string' }, surface: { type: 'string' }, text: { type: 'string' }, ink: { type: 'string' }, lede: { type: 'string' }, muted: { type: 'string' }, accent: { type: 'string' },
271
+ grid: { anyOf: [{ type: 'string' }, { type: 'object', properties: { color: { type: 'string' }, dash: { type: 'string' }, width: { type: 'number' } } }] },
272
+ axis: { type: 'string' }, tooltip: { type: 'object', properties: { bg: { type: 'string' }, ink: { type: 'string' } } },
273
+ radius: { type: 'number' }, barRadius: { type: 'number' },
274
+ categorical: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
275
+ sequential: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
276
+ diverging: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
277
+ font: { anyOf: [{ type: 'string' }, { type: 'object' }] },
278
+ status: { type: 'object' }, type: { type: 'object' }, marks: { type: 'object' }, axes: { type: 'object' }, layout: { type: 'object' },
279
+ } },
280
+ ],
166
281
  },
167
282
  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
283
  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
284
  width: { type: 'number', description: 'Width in px (default 720; render() uses the container width).' },
170
285
  height: { type: 'number', description: 'Height in px. Automatic for horizontal bars, heatmaps, KPI tiles, and narrow treemaps, sunbursts and radars.' },
286
+ padding: number('Outer padding in px (default from the width).'),
287
+ },
288
+ };
289
+
290
+ /** JSON Schema of a dashboard: dashboard({ title, kpis, charts, filters, … }). */
291
+ export const dashboardSchema = {
292
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
293
+ title: 'plotagents dashboard',
294
+ 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.',
295
+ type: 'object',
296
+ required: ['charts'],
297
+ properties: {
298
+ title: { type: 'string', description: 'The question the dashboard answers, e.g. "How are southern sales doing?"' },
299
+ subtitle: { type: 'string', description: 'Scope: measure, unit, period.' },
300
+ source: { type: 'string', description: 'Data source, shown once at the bottom.' },
301
+ updated: { type: 'string', description: 'Last update, e.g. "2026-01-05".' },
302
+ locale: { type: 'string', enum: ['es', 'en'] },
303
+ theme: { description: 'One theme for every chart: "papel" (light) or "noche" (dark), or a theme object.', anyOf: [{ type: 'string' }, { type: 'object' }] },
304
+ data: { type: 'array', items: { type: 'object' }, description: 'Shared rows: charts without their own `data` use these, and computed KPIs and filters work on them.' },
305
+ kpis: {
306
+ type: 'array', description: 'Up to 5 key figures. Fixed ({ label, value, delta, period }) or computed from `data` ({ label, field, agg, compare, trendBy }).',
307
+ items: { type: 'object', properties: {
308
+ label: { type: 'string' }, value: { type: 'number' }, delta: { type: 'number', description: 'Change as a fraction (0.12 = +12%).' }, period: { type: 'string' },
309
+ field: { type: 'string' }, agg: { type: 'string', enum: ['sum', 'mean', 'median', 'min', 'max', 'count', 'distinct', 'ratio'] }, over: { type: 'string', description: 'agg "ratio": denominator field.' },
310
+ scale: { type: 'number', description: 'Multiply the computed value (e.g. 1000 to show thousands).' },
311
+ compare: { type: 'object', description: 'Current vs previous period: { field: "year", current: 2025, previous: 2024 }.', properties: { field: { type: 'string' }, current: {}, previous: {} } },
312
+ trendBy: { type: 'string', description: 'Field to draw a sparkline by (e.g. "month").' },
313
+ format: {}, good: { type: 'string', enum: ['up', 'down'] }, trend: { type: 'array', items: { type: 'number' } },
314
+ } },
315
+ },
316
+ 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).' },
317
+ 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' }] } },
318
+ 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.' },
319
+ colors: { type: 'object', additionalProperties: { type: 'string' }, description: 'Pin colors to entities across every chart.' },
171
320
  },
172
321
  };
173
322
 
174
323
  /** Tool definition ready for Claude (Anthropic) or OpenAI. */
175
- export function toolDefinition({ name = 'plot', flavor = 'anthropic' } = {}) {
324
+ export function toolDefinition({ name, flavor = 'anthropic', kind = 'chart' } = {}) {
325
+ if (kind === 'dashboard') {
326
+ 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.';
327
+ const { $schema: _s, title: _t, ...params } = dashboardSchema;
328
+ const n = name ?? 'dashboard';
329
+ return flavor === 'openai' ? { type: 'function', function: { name: n, description, parameters: params } } : { name: n, description, input_schema: params };
330
+ }
331
+ name = name ?? 'plot';
176
332
  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.';
333
+ '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, hist, kde, ecdf, box, violin, strip, swarm, point, radar, treemap, sunburst, donut, pie, heatmap, sankey, kpi, 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
334
  const { $schema, $id, title, ...params } = schema;
179
335
  if (flavor === 'openai') return { type: 'function', function: { name, description, parameters: params } };
180
336
  return { name, description, input_schema: params };
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;
@@ -112,11 +126,16 @@ export function histogram(vs, { bins, binWidth, domain } = {}) {
112
126
  export function scottBandwidth(vs) {
113
127
  const v = finite(vs);
114
128
  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);
129
+ const sorted = [...v].sort((a, b) => a - b);
130
+ const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25);
116
131
  const sigma = Math.min(s, iqr / 1.349) || s || 1;
117
132
  return 1.06 * sigma * v.length ** -0.2;
118
133
  }
119
134
 
135
+ const extentOf = extent;
136
+ /** Sobre este número de valores, la KDE usa binning lineal. */
137
+ export const KDE_BIN_MIN = 2048;
138
+
120
139
  /**
121
140
  * Densidad por kernel gaussiano. Devuelve [[x, densidad], ...] en una grilla.
122
141
  * `cut` extiende la grilla `cut` anchos de banda más allá de los extremos.
@@ -125,14 +144,29 @@ export function kde(vs, { bandwidth, adjust = 1, gridSize = 160, extent, cut = 3
125
144
  const v = finite(vs);
126
145
  if (!v.length) return [];
127
146
  const bw = (bandwidth ?? scottBandwidth(v)) * adjust || 1;
128
- let [lo, hi] = extent || [Math.min(...v) - cut * bw, Math.max(...v) + cut * bw];
147
+ const [vmin, vmax] = extentOf(v);
148
+ let [lo, hi] = extent || [vmin - cut * bw, vmax + cut * bw];
129
149
  if (clip) { lo = Math.max(lo, clip[0]); hi = Math.min(hi, clip[1]); }
130
150
  const k = 1 / (v.length * bw * Math.sqrt(2 * Math.PI));
151
+ // Muchos valores: binning lineal en ~1024 nodos (cada valor reparte su peso
152
+ // entre los dos nodos vecinos). Error despreciable frente al ancho de banda.
153
+ let xsK = v, ws = null;
154
+ if (v.length > KDE_BIN_MIN && vmax > vmin) {
155
+ const B = 1024, dx = (vmax - vmin) / (B - 1);
156
+ ws = new Float64Array(B);
157
+ for (const xi of v) {
158
+ const t = (xi - vmin) / dx, j = Math.min(B - 2, Math.floor(t)), f = t - j;
159
+ ws[j] += 1 - f; ws[j + 1] += f;
160
+ }
161
+ xsK = Array.from({ length: B }, (_, j) => vmin + j * dx);
162
+ }
131
163
  const out = [];
132
164
  for (let i = 0; i < gridSize; i++) {
133
165
  const x = lo + ((hi - lo) * i) / (gridSize - 1);
134
166
  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); }
167
+ if (ws) {
168
+ 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); }
169
+ } else for (const xi of v) { const u = (x - xi) / bw; if (u > -6 && u < 6) d += Math.exp(-0.5 * u * u); }
136
170
  out.push([x, d * k]);
137
171
  }
138
172
  return out;
@@ -216,28 +250,51 @@ export function pearson(xs, ys) {
216
250
  return sxx && syy ? sxy / Math.sqrt(sxx * syy) : 0;
217
251
  }
218
252
 
219
- /** LOWESS (Cleveland): suavizado local robusto. Devuelve [[x, y], ...] ordenado. */
220
- export function lowess(xs, ys, { frac = 2 / 3, iters = 2 } = {}) {
253
+ /**
254
+ * LOWESS (Cleveland): suavizado local robusto. Devuelve [[x, y], ...] ordenado.
255
+ * Como x está ordenado, los r vecinos más cercanos forman una ventana contigua
256
+ * (dos punteros, sin ordenar distancias). Con muchos puntos (> 400) se ajusta
257
+ * solo en ~200 anclas y se interpola entre ellas (el `delta` de statsmodels).
258
+ */
259
+ export function lowess(xs, ys, { frac = 2 / 3, iters = 2, delta } = {}) {
221
260
  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
261
  const n = pts.length;
223
262
  if (n < 3) return pts;
224
- const r = Math.max(2, Math.ceil(frac * n));
263
+ const X = pts.map((p) => p[0]), Y = pts.map((p) => p[1]);
264
+ const r = Math.min(n, Math.max(2, Math.ceil(frac * n)));
265
+ const dl = delta ?? (n > 400 ? (X[n - 1] - X[0]) / 200 : 0);
266
+ // Anclas: el primero, luego el primer punto a más de `dl` del ancla anterior, y el último.
267
+ const anchors = [0];
268
+ if (dl > 0) {
269
+ for (let i = 1; i < n; i++) if (X[i] > X[anchors[anchors.length - 1]] + dl) anchors.push(i);
270
+ if (anchors[anchors.length - 1] !== n - 1) anchors.push(n - 1);
271
+ } else for (let i = 1; i < n; i++) anchors.push(i);
225
272
  let robust = new Array(n).fill(1);
226
- let fit = new Array(n).fill(0);
273
+ const fit = new Array(n).fill(0);
227
274
  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;
275
+ let lo = 0;
276
+ for (const i of anchors) {
277
+ const x0 = X[i];
278
+ while (lo + r < n && X[lo + r] - x0 < x0 - X[lo]) lo++;
279
+ const hiI = lo + r - 1;
280
+ const h = Math.max(x0 - X[lo], X[hiI] - x0) || 1e-12;
232
281
  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;
282
+ for (let j = lo; j <= hiI; j++) {
283
+ const u = Math.abs(X[j] - x0) / h;
235
284
  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];
285
+ sw += w; swx += w * X[j]; swy += w * Y[j]; swxx += w * X[j] ** 2; swxy += w * X[j] * Y[j];
237
286
  }
238
287
  const den = sw * swxx - swx * swx;
239
288
  fit[i] = Math.abs(den) < 1e-12 ? swy / (sw || 1) : ((swxx * swy - swx * swxy) + (sw * swxy - swx * swy) * x0) / den;
240
289
  }
290
+ // Entre anclas: interpolación lineal (mismo x → mismo valor).
291
+ for (let a = 0; a < anchors.length - 1; a++) {
292
+ const i0 = anchors[a], i1 = anchors[a + 1];
293
+ for (let i = i0 + 1; i < i1; i++) {
294
+ const span = X[i1] - X[i0];
295
+ fit[i] = span ? fit[i0] + ((fit[i1] - fit[i0]) * (X[i] - X[i0])) / span : fit[i0];
296
+ }
297
+ }
241
298
  const res = pts.map(([, y], i) => Math.abs(y - fit[i]));
242
299
  const m = median(res) || 1e-12;
243
300
  robust = res.map((e) => { const u = e / (6 * m); return u < 1 ? (1 - u * u) ** 2 : 0; });