plotagents 0.0.0-stage → 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 +98 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/plotagents.js +73 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +11611 -0
- package/docs/api.md +172 -0
- package/docs/charts.md +352 -0
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +142 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +116 -0
- package/index.d.ts +384 -0
- package/package.json +80 -4
- package/schema.json +1874 -0
- package/src/axes.js +152 -0
- package/src/charts/bar.js +350 -0
- package/src/charts/categorical.js +495 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +807 -0
- package/src/charts/dist.js +530 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +100 -0
- package/src/charts/line.js +551 -0
- package/src/charts/network.js +707 -0
- package/src/charts/pointmap.js +599 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +158 -0
- package/src/charts/regplot.js +370 -0
- package/src/charts/sankey.js +169 -0
- package/src/charts/scatter.js +214 -0
- package/src/charts/sunburst.js +136 -0
- package/src/charts/treemap.js +197 -0
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/context.js +10 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +203 -0
- package/src/frame.js +226 -0
- package/src/geo/chile.js +7 -0
- package/src/geo/data/chile-regiones.js +12 -0
- package/src/geo/data/usa-estados.js +12 -0
- package/src/geo/data/world.js +12 -0
- package/src/geo/projection.js +217 -0
- package/src/geo/registry.js +110 -0
- package/src/geo/usa.js +8 -0
- package/src/geo/world.js +7 -0
- package/src/hierarchy.js +139 -0
- package/src/index.js +336 -0
- package/src/infer.js +58 -0
- package/src/interact.js +483 -0
- package/src/lint.js +417 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +416 -0
- package/src/scale.js +241 -0
- package/src/schema.js +337 -0
- package/src/stats.js +303 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +311 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Pirámide de población: barras horizontales espalda con espalda desde un eje
|
|
2
|
+
// central, con los tramos de edad en la columna del medio (sin ejes repetidos).
|
|
3
|
+
// Opcional: comparación con otro periodo como contorno escalonado.
|
|
4
|
+
import { el, g, text, tip, barPath } from '../svg.js';
|
|
5
|
+
import { measure } from '../text.js';
|
|
6
|
+
import { makeFormat, formatNumber } from '../format.js';
|
|
7
|
+
import { niceStep, linear } from '../scale.js';
|
|
8
|
+
import { colorMap, readable } from '../theme.js';
|
|
9
|
+
import { ensureData, requireField, num, uniq, fieldsOf, fieldKind, PlotError } from '../data.js';
|
|
10
|
+
import { createFrame } from '../frame.js';
|
|
11
|
+
|
|
12
|
+
const T = (en, es, locale) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
13
|
+
const lead = (s) => { const m = /^\s*(\d+)/.exec(String(s)); return m ? +m[1] : NaN; };
|
|
14
|
+
|
|
15
|
+
export function pyramid(spec, theme) {
|
|
16
|
+
const locale = spec.locale || 'es';
|
|
17
|
+
const data = ensureData(spec);
|
|
18
|
+
const fields = fieldsOf(data);
|
|
19
|
+
const cats = fields.filter((f) => fieldKind(data, f) !== 'number' || /edad|age|grupo|tramo/i.test(f));
|
|
20
|
+
const nums = fields.filter((f) => fieldKind(data, f) === 'number');
|
|
21
|
+
const ageF = spec.age ?? spec.y ?? cats.find((f) => /edad|age|tramo|grupo/i.test(f)) ?? cats[0];
|
|
22
|
+
const sideF = spec.sex ?? spec.series ?? spec.hue ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : null) ?? cats.find((f) => f !== ageF && /sexo|sex|gender|género|genero/i.test(f)) ?? cats.find((f) => f !== ageF);
|
|
23
|
+
const valF = spec.value ?? spec.x ?? nums.find((f) => f !== ageF && f !== sideF);
|
|
24
|
+
requireField(data, ageF, 'age');
|
|
25
|
+
requireField(data, sideF, 'series');
|
|
26
|
+
requireField(data, valF, 'value');
|
|
27
|
+
|
|
28
|
+
// Comparación: otra tabla (compareData) o un valor de un campo (compare + compareField).
|
|
29
|
+
// Con `time` el núcleo ya filtró los datos y entrega el periodo de comparación en _prev.
|
|
30
|
+
let rows = data, cmpRows = spec.compareData ? ensureData({ data: spec.compareData }) : spec._prev && spec.compare !== undefined && spec.compare !== false ? spec._prev.data : null;
|
|
31
|
+
const cmpField = spec.compareField ?? spec.time;
|
|
32
|
+
if (!cmpRows && spec.compare !== undefined && cmpField && fields.includes(cmpField)) {
|
|
33
|
+
cmpRows = data.filter((r) => String(r[cmpField]) === String(spec.compare));
|
|
34
|
+
rows = data.filter((r) => String(r[cmpField]) !== String(spec.compare));
|
|
35
|
+
const periods = uniq(rows.map((r) => String(r[cmpField])));
|
|
36
|
+
if (periods.length > 1) { const last = spec.current ?? periods.sort().at(-1); rows = rows.filter((r) => String(r[cmpField]) === String(last)); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sides = spec.sides ?? uniq(rows.map((r) => String(r[sideF])));
|
|
40
|
+
if (sides.length !== 2) throw new PlotError(T(`A population pyramid needs exactly 2 groups in "${sideF}" (found ${sides.length}).`, `La pirámide necesita exactamente 2 grupos en "${sideF}" (hay ${sides.length}).`, locale), T('Filter the data to two groups, e.g. men and women.', 'Filtra los datos a dos grupos, por ejemplo hombres y mujeres.', locale));
|
|
41
|
+
let ages = uniq([...rows, ...(cmpRows || [])].map((r) => String(r[ageF])));
|
|
42
|
+
if (ages.every((a) => Number.isFinite(lead(a)))) ages.sort((a, b) => lead(a) - lead(b));
|
|
43
|
+
if (spec.ageOrder) ages = spec.ageOrder.map(String);
|
|
44
|
+
const top = [...ages].reverse(); // los mayores arriba
|
|
45
|
+
|
|
46
|
+
const sumBy = (rs) => {
|
|
47
|
+
const m = new Map();
|
|
48
|
+
for (const r of rs) {
|
|
49
|
+
const k = String(r[ageF]) + '\u0000' + String(r[sideF]);
|
|
50
|
+
const v = num(r[valF]);
|
|
51
|
+
if (Number.isFinite(v)) m.set(k, (m.get(k) || 0) + Math.abs(v));
|
|
52
|
+
}
|
|
53
|
+
return m;
|
|
54
|
+
};
|
|
55
|
+
const cur = sumBy(rows), cmp = cmpRows ? sumBy(cmpRows) : null;
|
|
56
|
+
const total = [...cur.values()].reduce((a, b) => a + b, 0) || 1;
|
|
57
|
+
const cmpTotal = cmp ? [...cmp.values()].reduce((a, b) => a + b, 0) || 1 : 1;
|
|
58
|
+
const pct = spec.percent === true || spec.format === 'percent' || spec.format?.type === 'percent';
|
|
59
|
+
const val = (m, a, s, tot) => { const v = m.get(a + '\u0000' + s) || 0; return pct ? v / tot : v; };
|
|
60
|
+
const V = (a, s) => val(cur, a, s, total);
|
|
61
|
+
const C = (a, s) => (cmp ? val(cmp, a, s, cmpTotal) : NaN);
|
|
62
|
+
|
|
63
|
+
const allV = ages.flatMap((a) => sides.flatMap((s) => [V(a, s), C(a, s)])).filter(Number.isFinite);
|
|
64
|
+
const vmax = Math.max(...allV, 1e-9);
|
|
65
|
+
const fmt = pct ? makeFormat({ type: 'percent', scale: 100, decimals: spec.format?.decimals }, locale) : makeFormat(spec.format, locale, allV);
|
|
66
|
+
const share = (v) => formatNumber((v / total) * 100, (v / total) * 100 < 10 ? 1 : 0, locale, true) + '%';
|
|
67
|
+
|
|
68
|
+
const cmap = colorMap(sides, theme, spec.colors);
|
|
69
|
+
const colorOf = (s) => cmap.get(s);
|
|
70
|
+
const cmpLabel = spec.compareLabel ?? spec._prev?.label ?? (spec.compare !== undefined ? String(spec.compare) : T('Comparison', 'Comparación', locale));
|
|
71
|
+
const legend = cmp && spec.legend !== false ? [{ label: cmpLabel, color: theme.ink, shape: 'line' }] : [];
|
|
72
|
+
|
|
73
|
+
const frame = createFrame(spec, theme, { height: 420 });
|
|
74
|
+
let area = frame.layout(legend);
|
|
75
|
+
const n = ages.length;
|
|
76
|
+
const headerH = 24, axisH = 24;
|
|
77
|
+
const step = spec.barHeight ? spec.barHeight + 2 : Math.max(14, Math.min(26, frame.width < 480 ? 17 : 21));
|
|
78
|
+
if (spec.height === undefined) {
|
|
79
|
+
frame.setHeight(area.y + headerH + n * step + axisH + frame.chrome);
|
|
80
|
+
area = { ...area, h: frame.bottom - area.y };
|
|
81
|
+
}
|
|
82
|
+
const ageSize = theme.type?.axis ?? 11.5;
|
|
83
|
+
const centerW = Math.max(...ages.map((a) => measure(a, ageSize, 500))) + 18;
|
|
84
|
+
const labelsOn = spec.labels === true;
|
|
85
|
+
const sample = fmt(vmax);
|
|
86
|
+
const endPad = labelsOn ? measure(sample, 11, 600) + 8 : 4;
|
|
87
|
+
const halfW = (area.w - centerW) / 2;
|
|
88
|
+
const cx0 = area.x + halfW, cx1 = cx0 + centerW; // bordes de la columna central
|
|
89
|
+
const plotTop = area.y + headerH;
|
|
90
|
+
const plotH = area.h - headerH - axisH;
|
|
91
|
+
const st = plotH / n;
|
|
92
|
+
const bh = Math.max(4, Math.min(spec.barHeight ?? 24, st - 2));
|
|
93
|
+
// Escala ajustada al dato (sin aire vacío), marcas "bonitas" dentro de ella.
|
|
94
|
+
const nd = { step: niceStep(vmax, Math.max(2, Math.round((halfW - endPad) / 85))) };
|
|
95
|
+
const dmax = spec.xDomain ? spec.xDomain[1] : vmax * 1.02;
|
|
96
|
+
const len = linear(0, dmax, 0, halfW - endPad);
|
|
97
|
+
const yOf = (i) => plotTop + i * st + (st - bh) / 2;
|
|
98
|
+
|
|
99
|
+
const body = [];
|
|
100
|
+
// Grilla vertical simétrica y marcas absolutas abajo.
|
|
101
|
+
const tf = fmt.tick(nd.step);
|
|
102
|
+
const tsize = theme.type?.axis ?? 11.5;
|
|
103
|
+
const ticks = len.ticks(nd.step).filter((t) => t <= dmax + 1e-9 && len(t) + measure(tf(t), tsize) / 2 <= halfW + 2);
|
|
104
|
+
const gridParts = [];
|
|
105
|
+
for (const t of ticks) {
|
|
106
|
+
for (const [x, anchor] of [[cx0 - len(t), 'middle'], [cx1 + len(t), 'middle']]) {
|
|
107
|
+
const px = Math.round(x) + 0.5;
|
|
108
|
+
gridParts.push(el('line', { x1: px, x2: px, y1: plotTop - 4, y2: plotTop + plotH, stroke: t === 0 ? theme.axis : theme.grid, strokeWidth: 1, shapeRendering: 'crispEdges' }));
|
|
109
|
+
if (t === 0 && anchor) continue;
|
|
110
|
+
gridParts.push(text(px, plotTop + plotH + 16, tf(t), { fill: theme.muted, fontSize: theme.type?.axis ?? 11.5, textAnchor: anchor }));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
body.push(g({ class: 'pa-grid' }, gridParts));
|
|
114
|
+
|
|
115
|
+
// Encabezados: nombre de cada lado en su color (legible) y su participación.
|
|
116
|
+
const sideTot = (s) => ages.reduce((a, ag) => a + (cur.get(ag + '\u0000' + s) || 0), 0);
|
|
117
|
+
sides.forEach((s, k) => {
|
|
118
|
+
const lab = s;
|
|
119
|
+
const sh = share(sideTot(s)) ;
|
|
120
|
+
const hx = k === 0 ? cx0 - 2 : cx1 + 2;
|
|
121
|
+
const anchor = k === 0 ? 'end' : 'start';
|
|
122
|
+
const tw = measure(lab, 13, 600);
|
|
123
|
+
body.push(text(hx, area.y + 14, lab, { fill: readable(colorOf(s), theme), fontSize: 13, fontWeight: 600, textAnchor: anchor }));
|
|
124
|
+
body.push(text(k === 0 ? hx - tw - 6 : hx + tw + 6, area.y + 14, sh, { fill: theme.muted, fontSize: 12, textAnchor: anchor }));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Barras
|
|
128
|
+
top.forEach((a, i) => {
|
|
129
|
+
const y = yOf(i);
|
|
130
|
+
// Etiqueta de edad en la columna central.
|
|
131
|
+
body.push(text((cx0 + cx1) / 2, y + bh / 2 + ageSize * 0.36, a, { fill: theme.lede, fontSize: ageSize, fontWeight: 500, textAnchor: 'middle' }));
|
|
132
|
+
sides.forEach((s, k) => {
|
|
133
|
+
const v = V(a, s);
|
|
134
|
+
const w = len(Math.min(v, dmax));
|
|
135
|
+
const c = colorOf(s);
|
|
136
|
+
const x = k === 0 ? cx0 - w : cx1;
|
|
137
|
+
const d = barPath(x, y, w, bh, k === 0 ? 'left' : 'right', Math.min(3.5, bh / 2));
|
|
138
|
+
const cv = C(a, s);
|
|
139
|
+
const lines = [`${a} · ${s}`, fmt(v) + (pct ? '' : ` · ${share(v)}`)];
|
|
140
|
+
if (Number.isFinite(cv)) lines.push(`${cmpLabel}: ${fmt(cv)}`);
|
|
141
|
+
const parts = [el('rect', { x: k === 0 ? area.x : cx1, y: plotTop + i * st, width: halfW, height: st, fill: 'transparent' })];
|
|
142
|
+
if (d) parts.push(el('path', { d, fill: c }));
|
|
143
|
+
if (labelsOn && v > 0) {
|
|
144
|
+
const lab = fmt(v);
|
|
145
|
+
parts.push(text(k === 0 ? x - 5 : x + w + 5, y + bh / 2 + 4, lab, { fill: theme.lede, fontSize: 11, fontWeight: 500, textAnchor: k === 0 ? 'end' : 'start' }));
|
|
146
|
+
}
|
|
147
|
+
body.push(g({ class: 'pa-m', 'data-tip': tip(...lines) }, parts));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Comparación: contorno escalonado por lado.
|
|
152
|
+
if (cmp) {
|
|
153
|
+
sides.forEach((s, k) => {
|
|
154
|
+
let d = '';
|
|
155
|
+
top.forEach((a, i) => {
|
|
156
|
+
const cv = C(a, s);
|
|
157
|
+
if (!Number.isFinite(cv)) return;
|
|
158
|
+
const x = k === 0 ? cx0 - len(Math.min(cv, dmax)) : cx1 + len(Math.min(cv, dmax));
|
|
159
|
+
const y0 = plotTop + i * st, y1 = y0 + st;
|
|
160
|
+
d += (d ? `L${x.toFixed(1)},${y0.toFixed(1)}` : `M${(k === 0 ? cx0 : cx1).toFixed(1)},${y0.toFixed(1)}L${x.toFixed(1)},${y0.toFixed(1)}`) + `L${x.toFixed(1)},${y1.toFixed(1)}`;
|
|
161
|
+
});
|
|
162
|
+
if (d) {
|
|
163
|
+
d += `L${(k === 0 ? cx0 : cx1).toFixed(1)},${(plotTop + n * st).toFixed(1)}`;
|
|
164
|
+
body.push(el('path', { d, fill: 'none', stroke: theme.ink, strokeWidth: 1.5, strokeLinejoin: 'round', opacity: 0.85, class: 'pa-compare' }));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Descripción
|
|
170
|
+
const en = String(locale).startsWith('en');
|
|
171
|
+
let big = null;
|
|
172
|
+
for (const a of ages) for (const s of sides) { const v = cur.get(a + '\u0000' + s) || 0; if (!big || v > big[2]) big = [a, s, v]; }
|
|
173
|
+
const desc = (en ? 'Population pyramid. ' : 'Pirámide de población. ')
|
|
174
|
+
+ sides.map((s) => `${s} ${share(sideTot(s))}`).join(', ') + '. '
|
|
175
|
+
+ (big ? (en ? `Largest group: ${big[0]} (${big[1]}), ${fmt(pct ? big[2] / total : big[2])}.` : `Grupo más numeroso: ${big[0]} (${big[1]}), ${fmt(pct ? big[2] / total : big[2])}.`) : '');
|
|
176
|
+
return frame.finish(body, { desc, type: 'pyramid' });
|
|
177
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Radar (araña): perfil de una o más series en varios ejes comparables.
|
|
2
|
+
// Formato largo { eje, valor, serie } o ancho (una fila por serie + `axes`).
|
|
3
|
+
import { el, g, text, tip } from '../svg.js';
|
|
4
|
+
import { measure, wrap } from '../text.js';
|
|
5
|
+
import { makeFormat } from '../format.js';
|
|
6
|
+
import { niceDomain, niceStep, linear } from '../scale.js';
|
|
7
|
+
import { colorMap } from '../theme.js';
|
|
8
|
+
import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize, aggregate, PlotError } from '../data.js';
|
|
9
|
+
import { createFrame } from '../frame.js';
|
|
10
|
+
|
|
11
|
+
export function radar(spec, theme) {
|
|
12
|
+
const data = ensureData(spec);
|
|
13
|
+
const fields = fieldsOf(data);
|
|
14
|
+
const kinds = Object.fromEntries(fields.map((f) => [f, fieldKind(data, f)]));
|
|
15
|
+
const locale = spec.locale || 'es';
|
|
16
|
+
const en = String(locale).startsWith('en');
|
|
17
|
+
let series = spec.hue ?? spec.series ?? (typeof spec.color === 'string' && !spec.color.startsWith('#') ? spec.color : undefined);
|
|
18
|
+
let axes, seriesKeys;
|
|
19
|
+
const values = new Map(); // serie → Map(eje → valor)
|
|
20
|
+
|
|
21
|
+
if (Array.isArray(spec.axes) && spec.axes.length) {
|
|
22
|
+
// Formato ancho: cada fila es una serie; las columnas `axes` son los ejes.
|
|
23
|
+
axes = spec.axes.map(String);
|
|
24
|
+
axes.forEach((a) => requireField(data, a, 'axes'));
|
|
25
|
+
const labelF = spec.label ?? series ?? fields.find((f) => kinds[f] === 'category' && !axes.includes(f));
|
|
26
|
+
if (labelF) requireField(data, labelF, 'label');
|
|
27
|
+
seriesKeys = data.map((r, i) => (labelF ? String(r[labelF]) : `${en ? 'Series' : 'Serie'} ${i + 1}`));
|
|
28
|
+
data.forEach((r, i) => values.set(seriesKeys[i], new Map(axes.map((a) => [a, num(r[a])]))));
|
|
29
|
+
series = labelF;
|
|
30
|
+
} else {
|
|
31
|
+
const axisF = spec.axis ?? spec.x ?? fields.find((f) => kinds[f] === 'category' && f !== series);
|
|
32
|
+
const valueF = spec.value ?? spec.y ?? fields.find((f) => kinds[f] === 'number' && f !== series);
|
|
33
|
+
requireField(data, axisF, 'axis (x)');
|
|
34
|
+
requireField(data, valueF, 'value (y)');
|
|
35
|
+
if (series) requireField(data, series, 'series');
|
|
36
|
+
axes = uniq(data.map((r) => String(r[axisF])));
|
|
37
|
+
seriesKeys = series ? uniq(data.map((r) => String(r[series]))) : [humanize(valueF)];
|
|
38
|
+
const acc = new Map();
|
|
39
|
+
for (const r of data) {
|
|
40
|
+
const s = series ? String(r[series]) : seriesKeys[0];
|
|
41
|
+
const k = s + '\u0000' + String(r[axisF]);
|
|
42
|
+
if (!acc.has(k)) acc.set(k, []);
|
|
43
|
+
acc.get(k).push(num(r[valueF]));
|
|
44
|
+
}
|
|
45
|
+
for (const s of seriesKeys) values.set(s, new Map(axes.map((a) => [a, aggregate(acc.get(s + '\u0000' + a) || [], spec.aggregate || 'mean')])));
|
|
46
|
+
}
|
|
47
|
+
const n = axes.length;
|
|
48
|
+
const raw = [...values.values()].flatMap((m) => [...m.values()]).filter(Number.isFinite);
|
|
49
|
+
if (!raw.length) {
|
|
50
|
+
const vf = Array.isArray(spec.axes) && spec.axes.length ? axes.join('`, `') : (spec.value ?? spec.y ?? 'value');
|
|
51
|
+
throw en
|
|
52
|
+
? new PlotError(`No numeric values in \`${vf}\`.`, 'A radar needs a number per axis and series (empty, null and non-finite values are skipped).')
|
|
53
|
+
: new PlotError(`No hay valores numéricos en \`${vf}\`.`, 'El radar necesita un número por eje y serie (se omiten vacíos, nulos y valores no finitos).');
|
|
54
|
+
}
|
|
55
|
+
const fmt = makeFormat(spec.format, locale, raw);
|
|
56
|
+
|
|
57
|
+
// Normalización por eje (cada eje a 0–1 según su máximo).
|
|
58
|
+
const axisMax = new Map(axes.map((a) => [a, Math.max(...seriesKeys.map((s) => values.get(s).get(a)).filter(Number.isFinite), 0)]));
|
|
59
|
+
const norm = !!spec.normalize;
|
|
60
|
+
const val = (s, a) => { const v = values.get(s).get(a); return norm ? v / (axisMax.get(a) || 1) : v; };
|
|
61
|
+
const vals = seriesKeys.flatMap((s) => axes.map((a) => val(s, a))).filter(Number.isFinite);
|
|
62
|
+
let dom;
|
|
63
|
+
if (spec.domain || spec.valueDomain || spec.yDomain) { const d = spec.domain || spec.valueDomain || spec.yDomain; dom = { min: d[0], max: d[1], step: niceStep(d[1] - d[0], 5) }; }
|
|
64
|
+
else if (norm) dom = { min: 0, max: 1, step: 0.25 };
|
|
65
|
+
else dom = niceDomain(Math.min(0, ...vals), Math.max(...vals), 4);
|
|
66
|
+
const rings = linear(dom.min, dom.max, 0, 1).ticks(dom.step).filter((t) => t > dom.min + 1e-9 && t <= dom.max + 1e-9);
|
|
67
|
+
if (!rings.length || Math.abs(rings[rings.length - 1] - dom.max) > 1e-9) rings.push(dom.max);
|
|
68
|
+
const ringFmt = norm ? (v) => Math.round(v * 100) + '%' : fmt.tick(dom.step);
|
|
69
|
+
|
|
70
|
+
// Colores y foco
|
|
71
|
+
const cmap = colorMap(seriesKeys, theme, spec.colors);
|
|
72
|
+
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
73
|
+
const focus = (s) => !hl || hl.has(s);
|
|
74
|
+
const colorOf = (s) => (focus(s) ? (seriesKeys.length === 1 ? cmap.get(s) || theme.categorical[0] : cmap.get(s)) : theme.dim);
|
|
75
|
+
const legend = seriesKeys.length > 1 ? seriesKeys.map((s) => ({ label: s, color: colorOf(s), shape: 'square' })) : [];
|
|
76
|
+
|
|
77
|
+
const frame = createFrame(spec, theme, { height: (spec.width || 720) < 560 ? Math.round((spec.width || 720) * 0.95 + 170) : 460 });
|
|
78
|
+
let area = frame.layout(spec.legend === false ? [] : legend);
|
|
79
|
+
|
|
80
|
+
// Etiquetas de eje: ancho máximo y alto (hasta 2 líneas).
|
|
81
|
+
const ls = (theme.type?.label ?? 12) + 0.5;
|
|
82
|
+
const maxLabelW = Math.min(130, Math.max(60, area.w * 0.2));
|
|
83
|
+
const labelLines = axes.map((a) => wrap(a, maxLabelW, ls, 500, 2));
|
|
84
|
+
const labW = Math.max(...labelLines.map((ls2) => Math.max(...ls2.map((l) => measure(l, ls, 500)))));
|
|
85
|
+
const labH = ls * 1.25 * Math.max(...labelLines.map((l) => l.length));
|
|
86
|
+
const gapL = 10;
|
|
87
|
+
let R = Math.min((area.w - 2 * (labW + gapL)) / 2, (area.h - 2 * (labH + gapL)) / 2);
|
|
88
|
+
if (spec.height === undefined) {
|
|
89
|
+
R = Math.max(60, Math.min((area.w - 2 * (labW + gapL)) / 2, spec.maxRadius ?? 210));
|
|
90
|
+
frame.setHeight(area.y + 2 * (R + labH + gapL) + 4 + frame.chrome);
|
|
91
|
+
area = { ...area, h: frame.bottom - area.y };
|
|
92
|
+
}
|
|
93
|
+
R = Math.max(40, R);
|
|
94
|
+
const cx = area.x + area.w / 2;
|
|
95
|
+
const cy = area.y + area.h / 2;
|
|
96
|
+
const ang = (i) => -Math.PI / 2 + (2 * Math.PI * i) / n;
|
|
97
|
+
const rOf = (v) => Math.max(0, ((v - dom.min) / (dom.max - dom.min || 1)) * R);
|
|
98
|
+
const pt = (i, r) => [cx + r * Math.cos(ang(i)), cy + r * Math.sin(ang(i))];
|
|
99
|
+
const shape = spec.grid === 'circle' ? 'circle' : 'polygon';
|
|
100
|
+
const ringPath = (r) => (shape === 'circle' ? null : 'M' + axes.map((_, i) => pt(i, r).map((v) => v.toFixed(2)).join(',')).join('L') + 'Z');
|
|
101
|
+
|
|
102
|
+
const body = [];
|
|
103
|
+
// Rejilla: anillos y radios, recesivos.
|
|
104
|
+
rings.forEach((t, k) => {
|
|
105
|
+
const r = rOf(t);
|
|
106
|
+
const outer = k === rings.length - 1;
|
|
107
|
+
const attrs = { fill: 'none', stroke: outer ? theme.axis : theme.grid, strokeWidth: 1 };
|
|
108
|
+
body.push(shape === 'circle' ? el('circle', { cx, cy, r, ...attrs }) : el('path', { d: ringPath(r), ...attrs, strokeLinejoin: 'round' }));
|
|
109
|
+
});
|
|
110
|
+
axes.forEach((_, i) => { const [x, y] = pt(i, R); body.push(el('line', { x1: cx, y1: cy, x2: x, y2: y, stroke: theme.grid, strokeWidth: 1 })); });
|
|
111
|
+
|
|
112
|
+
// Series: el foco se dibuja al final (encima).
|
|
113
|
+
const order = [...seriesKeys].sort((a, b) => (focus(a) ? 1 : 0) - (focus(b) ? 1 : 0));
|
|
114
|
+
const op = theme.marks?.bandOpacity ?? 0.16;
|
|
115
|
+
const lw = theme.marks?.lineWidth ?? 2;
|
|
116
|
+
for (const s of order) {
|
|
117
|
+
const c = colorOf(s);
|
|
118
|
+
const verts = axes.map((a, i) => { const v = val(s, a); return Number.isFinite(v) ? pt(i, rOf(v)) : [cx, cy]; });
|
|
119
|
+
const d = 'M' + verts.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join('L') + 'Z';
|
|
120
|
+
const top = axes.map((a) => [a, values.get(s).get(a)]).filter(([, v]) => Number.isFinite(v)).sort((p, q) => (norm ? q[1] / (axisMax.get(q[0]) || 1) - p[1] / (axisMax.get(p[0]) || 1) : q[1] - p[1]));
|
|
121
|
+
const parts = [el('path', { d, fill: c, fillOpacity: focus(s) ? op : op * 0.5, stroke: c, strokeWidth: focus(s) ? lw : 1.25, strokeLinejoin: 'round' })];
|
|
122
|
+
if (focus(s)) verts.forEach(([x, y], i) => {
|
|
123
|
+
const v = values.get(s).get(axes[i]);
|
|
124
|
+
parts.push(el('circle', { cx: x, cy: y, r: (theme.marks?.pointRadius ?? 4) - 0.5, fill: c, stroke: theme.surface, strokeWidth: theme.marks?.ring ?? 2, 'data-tip': tip(s, `${axes[i]}: ${Number.isFinite(v) ? fmt(v) : '—'}`) }));
|
|
125
|
+
});
|
|
126
|
+
body.push(g({ class: 'pa-m', 'data-series': s, 'data-tip': tip(s, ...top.slice(0, 6).map(([a, v]) => `${a}: ${fmt(v)}`)) }, parts));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Valores de los anillos sobre el primer radio (arriba), con halo.
|
|
130
|
+
rings.forEach((t) => {
|
|
131
|
+
const r = rOf(t);
|
|
132
|
+
body.push(text(cx + 4, cy - r - 3, ringFmt(t), { fill: theme.muted, fontSize: (theme.type?.axis ?? 11.5) - 1, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Etiquetas de eje fuera del círculo, ancladas según el ángulo.
|
|
136
|
+
axes.forEach((a, i) => {
|
|
137
|
+
const t = ang(i);
|
|
138
|
+
const cos = Math.cos(t), sin = Math.sin(t);
|
|
139
|
+
const [x, y] = pt(i, R + gapL);
|
|
140
|
+
const anchor = cos > 0.25 ? 'start' : cos < -0.25 ? 'end' : 'middle';
|
|
141
|
+
const lines = labelLines[i];
|
|
142
|
+
const lh = ls * 1.25;
|
|
143
|
+
const total = lines.length * lh;
|
|
144
|
+
const y0 = sin < -0.25 ? y - total + ls * 0.85 : sin > 0.25 ? y + ls * 0.85 : y - total / 2 + ls * 0.85;
|
|
145
|
+
lines.forEach((l, k) => body.push(text(x, y0 + k * lh, l, { fill: theme.ink, fontSize: ls, fontWeight: 500, textAnchor: anchor })));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Descripción: fortaleza de cada serie.
|
|
149
|
+
const parts = seriesKeys.slice(0, 5).map((s) => {
|
|
150
|
+
const best = axes.map((a) => [a, val(s, a)]).filter(([, v]) => Number.isFinite(v)).sort((p, q) => q[1] - p[1]);
|
|
151
|
+
if (!best.length) return '';
|
|
152
|
+
const b = best[0], w = best[best.length - 1];
|
|
153
|
+
return en ? `${s}: strongest on ${b[0]}, weakest on ${w[0]}.` : `${s}: más alto en ${b[0]}, más bajo en ${w[0]}.`;
|
|
154
|
+
});
|
|
155
|
+
const warn = seriesKeys.length > 4 ? (en ? ' More than 4 series overlap; consider highlight.' : ' Más de 4 series se superponen; considera highlight.') : '';
|
|
156
|
+
const desc = (en ? `Radar chart with ${n} axes and ${seriesKeys.length} series${norm ? ' (each axis normalized to its maximum)' : ''}. ` : `Gráfico de radar con ${n} ejes y ${seriesKeys.length} series${norm ? ' (cada eje normalizado a su máximo)' : ''}. `) + parts.join(' ') + warn;
|
|
157
|
+
return frame.finish(body, { desc, type: 'radar' });
|
|
158
|
+
}
|