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.
- package/AGENTS.md +24 -4
- package/README.md +26 -14
- package/bin/plotagents.js +38 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +4294 -637
- package/docs/api.md +23 -5
- package/docs/charts.md +143 -9
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +188 -17
- package/package.json +9 -3
- package/schema.json +1025 -46
- package/src/axes.js +11 -3
- package/src/charts/bar.js +28 -17
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +3 -3
- package/src/charts/choropleth.js +284 -36
- package/src/charts/dist.js +37 -20
- package/src/charts/donut.js +1 -1
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +9 -6
- package/src/charts/line.js +173 -41
- package/src/charts/network.js +552 -189
- package/src/charts/pointmap.js +599 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +7 -1
- package/src/charts/sankey.js +41 -5
- package/src/charts/scatter.js +100 -22
- package/src/charts/sunburst.js +5 -4
- package/src/charts/treemap.js +10 -7
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +1 -1
- package/src/data.js +2 -2
- package/src/format.js +46 -14
- package/src/frame.js +95 -26
- package/src/geo/projection.js +74 -9
- package/src/geo/registry.js +87 -8
- package/src/index.js +76 -11
- package/src/infer.js +10 -1
- package/src/interact.js +157 -36
- package/src/lint.js +289 -9
- package/src/runtime.js +209 -97
- package/src/scale.js +80 -10
- package/src/schema.js +187 -31
- package/src/stats.js +71 -14
- 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'],
|
|
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
|
|
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
|
-
|
|
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
|
|
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: {
|
|
92
|
-
play: { type: 'boolean', description: 'Browser: false keeps the time
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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: '
|
|
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`.
|
|
165
|
-
anyOf: [
|
|
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 = '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
273
|
+
const fit = new Array(n).fill(0);
|
|
227
274
|
for (let it = 0; it <= iters; it++) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
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 =
|
|
234
|
-
const u = Math.abs(
|
|
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 *
|
|
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; });
|