plotagents 0.0.0-stage → 0.5.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 +78 -0
- package/LICENSE +21 -0
- package/README.md +205 -2
- package/bin/plotagents.js +49 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +7954 -0
- package/docs/api.md +154 -0
- package/docs/charts.md +218 -0
- package/docs/getting-started.md +138 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +86 -0
- package/index.d.ts +213 -0
- package/package.json +74 -4
- package/schema.json +895 -0
- package/src/axes.js +144 -0
- package/src/charts/bar.js +339 -0
- package/src/charts/categorical.js +471 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +559 -0
- package/src/charts/dist.js +513 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +97 -0
- package/src/charts/line.js +419 -0
- package/src/charts/network.js +344 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +152 -0
- package/src/charts/regplot.js +364 -0
- package/src/charts/sankey.js +133 -0
- package/src/charts/scatter.js +136 -0
- package/src/charts/sunburst.js +135 -0
- package/src/charts/treemap.js +194 -0
- package/src/context.js +10 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +171 -0
- package/src/frame.js +157 -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 +152 -0
- package/src/geo/registry.js +31 -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 +271 -0
- package/src/infer.js +49 -0
- package/src/interact.js +362 -0
- package/src/lint.js +137 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +304 -0
- package/src/scale.js +171 -0
- package/src/schema.js +181 -0
- package/src/stats.js +246 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +224 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Dispersión y burbujas: relación entre dos variables numéricas, con tamaño
|
|
2
|
+
// opcional (área proporcional), escalas log y línea de tendencia.
|
|
3
|
+
import { el, g, text, tip, dotLayer } from '../svg.js';
|
|
4
|
+
import { measure } from '../text.js';
|
|
5
|
+
import { makeFormat } from '../format.js';
|
|
6
|
+
import { niceDomain, niceStep, linear, log, logDomain } from '../scale.js';
|
|
7
|
+
import { colorMap } from '../theme.js';
|
|
8
|
+
import { ensureData, requireField, uniq, num, fieldsOf, fieldKind, humanize } from '../data.js';
|
|
9
|
+
import { gridY, gridX, ticksWidth, refLinesY, refLinesX, yAxisMargin, yAxisTop, yGrid, yTickLabels, quietCorner } from '../axes.js';
|
|
10
|
+
import { createFrame, SIZES } from '../frame.js';
|
|
11
|
+
|
|
12
|
+
export function scatter(spec, theme) {
|
|
13
|
+
const data = ensureData(spec);
|
|
14
|
+
const fields = fieldsOf(data);
|
|
15
|
+
const numeric = fields.filter((f) => fieldKind(data, f) === 'number' || fieldKind(data, f) === 'year');
|
|
16
|
+
const x = spec.x || numeric[0];
|
|
17
|
+
const y = spec.y || numeric.find((f) => f !== x);
|
|
18
|
+
const size = spec.size;
|
|
19
|
+
const series = spec.series ?? spec.hue ?? spec.color;
|
|
20
|
+
const labelF = spec.label ?? fields.find((f) => fieldKind(data, f) === 'category' && f !== series);
|
|
21
|
+
requireField(data, x, 'x');
|
|
22
|
+
requireField(data, y, 'y');
|
|
23
|
+
if (size) requireField(data, size, 'size');
|
|
24
|
+
if (series) requireField(data, series, 'series');
|
|
25
|
+
const locale = spec.locale || 'es';
|
|
26
|
+
|
|
27
|
+
const pts = data
|
|
28
|
+
.map((r) => ({ r, x: num(r[x]), y: num(r[y]), s: size ? num(r[size]) : 1, k: series ? String(r[series]) : '', name: labelF ? String(r[labelF] ?? '') : '' }))
|
|
29
|
+
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
30
|
+
const keys = series ? uniq(pts.map((p) => p.k)) : [''];
|
|
31
|
+
const cmap = colorMap(keys, theme, spec.colors);
|
|
32
|
+
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
33
|
+
const focus = (p) => !hl || hl.has(p.name) || hl.has(p.k);
|
|
34
|
+
const colorOf = (p) => (!focus(p) ? theme.dim : series ? cmap.get(p.k) : theme.categorical[0]);
|
|
35
|
+
|
|
36
|
+
const fx = makeFormat(spec.xFormat, locale, pts.map((p) => p.x));
|
|
37
|
+
const fy = makeFormat(spec.yFormat ?? spec.format, locale, pts.map((p) => p.y));
|
|
38
|
+
const fs = size ? makeFormat(spec.sizeFormat, locale, pts.map((p) => p.s)) : null;
|
|
39
|
+
|
|
40
|
+
const legend = series && keys.length > 1 && spec.legend !== false ? keys.map((k) => ({ label: k, color: cmap.get(k) })) : [];
|
|
41
|
+
const frame = createFrame(spec, theme, { height: 460 });
|
|
42
|
+
const area = frame.layout(legend);
|
|
43
|
+
|
|
44
|
+
const xLog = spec.xScale === 'log', yLog = spec.yScale === 'log';
|
|
45
|
+
const ex = [Math.min(...pts.map((p) => p.x)), Math.max(...pts.map((p) => p.x))];
|
|
46
|
+
const ey = [Math.min(...pts.map((p) => p.y)), Math.max(...pts.map((p) => p.y))];
|
|
47
|
+
if (spec.xDomain) ex.splice(0, 2, ...spec.xDomain);
|
|
48
|
+
if (spec.yDomain) ey.splice(0, 2, ...spec.yDomain);
|
|
49
|
+
const dx = xLog ? logDomain(...ex) : spec.xDomain ? { min: ex[0], max: ex[1], step: niceStep(ex[1] - ex[0], Math.max(3, Math.round(area.w / 110))) } : niceDomain(ex[0], ex[1], Math.max(3, Math.round(area.w / 110)));
|
|
50
|
+
const dy = yLog ? logDomain(...ey) : spec.yDomain ? { min: ey[0], max: ey[1], step: niceStep(ey[1] - ey[0], Math.max(3, Math.round(area.h / 70))) } : niceDomain(ey[0], ey[1], Math.max(3, Math.round(area.h / 70)));
|
|
51
|
+
if (spec._domains) { spec._domains.x = [dx.min, dx.max]; spec._domains.y = [dy.min, dy.max]; }
|
|
52
|
+
const ytf = yLog ? fy : fy.tick(dy.step);
|
|
53
|
+
const xtf = xLog ? fx : fx.tick(dx.step);
|
|
54
|
+
const yTicks = (yLog ? log(dy.min, dy.max, 0, 1) : linear(dy.min, dy.max, 0, 1)).ticks(dy.step);
|
|
55
|
+
const leftM = yAxisMargin(yTicks, ytf, theme);
|
|
56
|
+
const top = area.y + 18 + yAxisTop(theme);
|
|
57
|
+
const inner = { x: area.x + leftM, y: top, w: area.w - leftM - 8, h: area.y + area.h - top - 44 };
|
|
58
|
+
const maxS = Math.max(...pts.map((p) => p.s));
|
|
59
|
+
const rMax = spec.maxRadius ?? Math.min(30, Math.max(12, Math.sqrt((inner.w * inner.h) / Math.max(1, pts.length)) / 3));
|
|
60
|
+
// Las burbujas no se salen del área: margen interno del radio máximo.
|
|
61
|
+
const bp = size ? rMax * 0.75 : 6;
|
|
62
|
+
const xS = (xLog ? log : linear)(dx.min, dx.max, inner.x + bp, inner.x + inner.w - bp);
|
|
63
|
+
const yS = (yLog ? log : linear)(dy.min, dy.max, inner.y + inner.h - bp, inner.y + bp);
|
|
64
|
+
const rOf = (p) => (size ? Math.max(2.5, Math.sqrt(Math.max(0, p.s) / (maxS || 1)) * rMax) : spec.radius ?? (pts.length > 300 ? 3 : 5));
|
|
65
|
+
|
|
66
|
+
const body = [];
|
|
67
|
+
body.push(yGrid(yS.ticks(dy.step), yS, inner, theme, { baseline: NaN }));
|
|
68
|
+
body.push(gridX(xS.ticks(dx.step), xS, inner, theme, xtf, { baseline: NaN }));
|
|
69
|
+
body.push(el('line', { x1: inner.x, x2: inner.x + inner.w, y1: inner.y + inner.h + 0.5, y2: inner.y + inner.h + 0.5, stroke: theme.axis }));
|
|
70
|
+
// Títulos de ejes: horizontales, nunca rotados.
|
|
71
|
+
body.push(text(area.x, area.y + 10, spec.yLabel ?? humanize(y), { fill: theme.lede, fontSize: SIZES.axis, fontWeight: 500 }));
|
|
72
|
+
body.push(text(inner.x + inner.w, inner.y + inner.h + 40, spec.xLabel ?? humanize(x), { fill: theme.lede, fontSize: SIZES.axis, fontWeight: 500, textAnchor: 'end' }));
|
|
73
|
+
|
|
74
|
+
// Tendencia lineal (mínimos cuadrados)
|
|
75
|
+
if (spec.trend) {
|
|
76
|
+
const tx = pts.map((p) => (xLog ? Math.log10(p.x) : p.x)), ty = pts.map((p) => (yLog ? Math.log10(p.y) : p.y));
|
|
77
|
+
const n = tx.length, mx = tx.reduce((a, b) => a + b, 0) / n, my = ty.reduce((a, b) => a + b, 0) / n;
|
|
78
|
+
let sxy = 0, sxx = 0, syy = 0;
|
|
79
|
+
for (let i = 0; i < n; i++) { sxy += (tx[i] - mx) * (ty[i] - my); sxx += (tx[i] - mx) ** 2; syy += (ty[i] - my) ** 2; }
|
|
80
|
+
const b = sxy / (sxx || 1), a = my - b * mx;
|
|
81
|
+
const r2v = sxx && syy ? (sxy * sxy) / (sxx * syy) : 0;
|
|
82
|
+
const lx0 = xLog ? Math.log10(dx.min) : dx.min, lx1 = xLog ? Math.log10(dx.max) : dx.max;
|
|
83
|
+
const P = (lx) => [xS(xLog ? 10 ** lx : lx), yS(yLog ? 10 ** (a + b * lx) : a + b * lx)];
|
|
84
|
+
const [ax, ay] = P(lx0), [bx, by] = P(lx1);
|
|
85
|
+
body.push(el('clipPath', { id: 'pa-clip-trend' }, el('rect', { x: inner.x, y: inner.y, width: inner.w, height: inner.h })));
|
|
86
|
+
body.push(el('line', { x1: ax, y1: ay, x2: bx, y2: by, stroke: theme.ink, strokeWidth: 1.5, opacity: 0.55, clipPath: 'url(#pa-clip-trend)' }));
|
|
87
|
+
const r2s = `R² = ${r2v.toFixed(2).replace('.', String(locale).startsWith('en') ? '.' : ',')}`;
|
|
88
|
+
const occ = pts.map((p) => [xS(p.x), yS(p.y)]);
|
|
89
|
+
for (let i = 0; i <= 24; i++) occ.push([ax + ((bx - ax) * i) / 24, ay + ((by - ay) * i) / 24]);
|
|
90
|
+
const qc = quietCorner(occ, inner, measure(r2s, 11.5) + 4, 16);
|
|
91
|
+
body.push(text(qc.x, qc.y, r2s, { fill: theme.lede, fontSize: 11.5, textAnchor: qc.anchor, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke' }));
|
|
92
|
+
}
|
|
93
|
+
body.push(refLinesY(spec.refLines || [], yS, inner, theme, fy));
|
|
94
|
+
body.push(refLinesX(spec.refLines || [], xS, inner, theme, fx));
|
|
95
|
+
|
|
96
|
+
// Burbujas grandes debajo, chicas arriba; anillo del color de fondo.
|
|
97
|
+
const sorted = [...pts].sort((a, b) => (focus(a) - focus(b)) || b.s - a.s);
|
|
98
|
+
const circles = sorted.map((p) => {
|
|
99
|
+
const lines = [p.name || '', `${humanize(x)}: ${fx(p.x)}`, `${humanize(y)}: ${fy(p.y)}`];
|
|
100
|
+
if (size) lines.push(`${humanize(size)}: ${fs(p.s)}`);
|
|
101
|
+
if (series) lines.splice(1, 0, p.k);
|
|
102
|
+
return { class: 'pa-m', cx: xS(p.x), cy: yS(p.y), r: rOf(p), fill: colorOf(p), fillOpacity: size ? 0.78 : 0.9, stroke: theme.surface, strokeWidth: size ? 1.5 : pts.length > 400 ? 1 : 2, 'data-tip': tip(...lines) };
|
|
103
|
+
});
|
|
104
|
+
body.push(dotLayer(circles));
|
|
105
|
+
|
|
106
|
+
body.push(yTickLabels(yS.ticks(dy.step), yS, inner, theme, ytf));
|
|
107
|
+
|
|
108
|
+
// Etiquetas selectivas: las que pide el agente o las N mayores.
|
|
109
|
+
if (labelF && spec.labels !== false) {
|
|
110
|
+
let chosen;
|
|
111
|
+
if (Array.isArray(spec.labels)) chosen = pts.filter((p) => spec.labels.map(String).includes(p.name));
|
|
112
|
+
else {
|
|
113
|
+
const n = typeof spec.labels === 'number' ? spec.labels : hl ? 0 : Math.min(6, pts.length);
|
|
114
|
+
chosen = [...pts].sort((a, b) => (size ? b.s - a.s : b.y - a.y)).slice(0, n);
|
|
115
|
+
if (hl) chosen = chosen.concat(pts.filter((p) => hl.has(p.name)));
|
|
116
|
+
}
|
|
117
|
+
const placed = [];
|
|
118
|
+
for (const p of chosen) {
|
|
119
|
+
if (!p.name) continue;
|
|
120
|
+
const w = measure(p.name, 12, 500);
|
|
121
|
+
const r = rOf(p);
|
|
122
|
+
let lx = xS(p.x) + r + 4, ly = yS(p.y) + 4, anchor = 'start';
|
|
123
|
+
if (lx + w > inner.x + inner.w) { lx = xS(p.x) - r - 4; anchor = 'end'; }
|
|
124
|
+
const box = anchor === 'start' ? [lx, ly - 11, lx + w, ly + 3] : [lx - w, ly - 11, lx, ly + 3];
|
|
125
|
+
if (placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]))) continue;
|
|
126
|
+
placed.push(box);
|
|
127
|
+
body.push(el('text', { x: lx, y: ly, fill: theme.ink, fontSize: 12, fontWeight: 500, textAnchor: anchor, stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' }, p.name.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c])));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const en = String(locale).startsWith('en');
|
|
132
|
+
const desc = en
|
|
133
|
+
? `Scatter plot of ${humanize(y)} vs ${humanize(x)} with ${pts.length} points.`
|
|
134
|
+
: `Gráfico de dispersión de ${humanize(y)} versus ${humanize(x)} con ${pts.length} puntos.`;
|
|
135
|
+
return frame.finish(g({}, body), { desc, type: size ? 'bubble' : 'scatter' });
|
|
136
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Sunburst: la misma jerarquía que el treemap, en anillos concéntricos. El
|
|
2
|
+
// anillo interior es el primer nivel (color por categoría); los exteriores
|
|
3
|
+
// heredan el color con pasos sutiles de luminosidad. Total al centro.
|
|
4
|
+
import { el, g, text, tip } from '../svg.js';
|
|
5
|
+
import { measure } from '../text.js';
|
|
6
|
+
import { makeFormat } from '../format.js';
|
|
7
|
+
import { textOn } from '../theme.js';
|
|
8
|
+
import { humanize } from '../data.js';
|
|
9
|
+
import { createFrame } from '../frame.js';
|
|
10
|
+
import { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
|
|
11
|
+
|
|
12
|
+
const TAU = Math.PI * 2;
|
|
13
|
+
const f = (n) => n.toFixed(2);
|
|
14
|
+
|
|
15
|
+
function arcPath(cx, cy, r0, r1, a0, a1) {
|
|
16
|
+
if (a1 - a0 >= TAU - 1e-6) a1 = a0 + TAU - 1e-4;
|
|
17
|
+
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
18
|
+
const p = (r, a) => `${f(cx + r * Math.cos(a))},${f(cy + r * Math.sin(a))}`;
|
|
19
|
+
if (r0 <= 0.01) return `M${f(cx)},${f(cy)}L${p(r1, a0)}A${f(r1)},${f(r1)} 0 ${large} 1 ${p(r1, a1)}Z`;
|
|
20
|
+
return `M${p(r1, a0)}A${f(r1)},${f(r1)} 0 ${large} 1 ${p(r1, a1)}L${p(r0, a1)}A${f(r0)},${f(r0)} 0 ${large} 0 ${p(r0, a0)}Z`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sunburst(spec, theme) {
|
|
24
|
+
const locale = spec.locale || 'es';
|
|
25
|
+
const en = String(locale).startsWith('en');
|
|
26
|
+
const { root, fields, valueField } = buildHierarchy(spec);
|
|
27
|
+
const focus = findNode(root, spec.focus);
|
|
28
|
+
const levels = Math.max(1, height(focus));
|
|
29
|
+
assignColors(root, theme, spec); // colores estables al navegar
|
|
30
|
+
const vals = [];
|
|
31
|
+
(function walk(n) { vals.push(n.value); n.children.forEach(walk); })(root);
|
|
32
|
+
const fmt = makeFormat(spec.format, locale, vals);
|
|
33
|
+
const share = shareFmt(locale);
|
|
34
|
+
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
35
|
+
const dim = (n) => hl && !n.path.some((p) => hl.has(p));
|
|
36
|
+
|
|
37
|
+
const top = focus.children;
|
|
38
|
+
const legend = top.length > 1 && spec.legend !== false && !focus.path.length
|
|
39
|
+
? [...top.filter((c) => !c.folded).map((c) => ({ label: c.name, color: c.color, shape: 'square' })), ...(top.some((c) => c.folded) ? [{ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' }] : [])]
|
|
40
|
+
: [];
|
|
41
|
+
|
|
42
|
+
const frame = createFrame(spec, theme, { height: (spec.width || 720) < 560 ? Math.round((spec.width || 720) + 190) : Math.min(560, Math.max(380, (spec.width || 720) * 0.7)) });
|
|
43
|
+
let area = frame.layout(legend);
|
|
44
|
+
const body = [];
|
|
45
|
+
const bc = breadcrumb(focus, area.x, area.y, area.w, theme, locale);
|
|
46
|
+
if (bc.h) { body.push(bc.svg); area = { ...area, y: area.y + bc.h, h: area.h - bc.h }; }
|
|
47
|
+
|
|
48
|
+
const R = Math.max(40, Math.min(area.w, area.h) / 2 - 2);
|
|
49
|
+
const cx = area.x + area.w / 2, cy = area.y + area.h / 2;
|
|
50
|
+
const r0 = R * (spec.hole ?? 0.3);
|
|
51
|
+
// Anillos: el exterior más delgado cuando hay muchos niveles.
|
|
52
|
+
const weights = Array.from({ length: levels }, (_, i) => (levels >= 3 && i === levels - 1 ? 0.62 : 1));
|
|
53
|
+
const wsum = weights.reduce((a, b) => a + b, 0);
|
|
54
|
+
const radii = [r0];
|
|
55
|
+
weights.forEach((w) => radii.push(radii[radii.length - 1] + ((R - r0) * w) / wsum));
|
|
56
|
+
|
|
57
|
+
const total = focus.value, grand = root.value;
|
|
58
|
+
const sep = theme.marks?.gap ?? 2;
|
|
59
|
+
const arcs = [];
|
|
60
|
+
const labels = [];
|
|
61
|
+
(function layout(n, a0, a1, d) {
|
|
62
|
+
let a = a0;
|
|
63
|
+
for (const c of n.children) {
|
|
64
|
+
const span = ((a1 - a0) * c.value) / (n.value || 1);
|
|
65
|
+
if (d < levels) {
|
|
66
|
+
arcs.push({ n: c, a0: a, a1: a + span, r0: radii[d], r1: radii[d + 1], d: d + 1 });
|
|
67
|
+
layout(c, a, a + span, d + 1);
|
|
68
|
+
}
|
|
69
|
+
a += span;
|
|
70
|
+
}
|
|
71
|
+
})(focus, -Math.PI / 2, (3 * Math.PI) / 2, 0);
|
|
72
|
+
|
|
73
|
+
for (const a of arcs) {
|
|
74
|
+
const n = a.n;
|
|
75
|
+
const fill = dim(n) ? theme.dim : n.color;
|
|
76
|
+
const parent = n.parent && n.parent.depth > 0 ? n.parent : null;
|
|
77
|
+
const t = tip(n.path.join(SEP), `${fmt(n.value)} · ${share(n.value, grand)} ${en ? 'of total' : 'del total'}`, parent ? `${share(n.value, parent.value)} ${en ? 'of' : 'de'} ${parent.name}` : '');
|
|
78
|
+
const drill = n.children.length ? JSON.stringify(n.path) : undefined;
|
|
79
|
+
body.push(el('path', {
|
|
80
|
+
class: 'pa-m', d: arcPath(cx, cy, a.r0, a.r1, a.a0, a.a1), fill,
|
|
81
|
+
stroke: theme.surface, strokeWidth: Math.min(sep, 1.75), strokeLinejoin: 'round',
|
|
82
|
+
'data-tip': t, 'data-drill': drill, style: drill ? 'cursor:pointer' : undefined,
|
|
83
|
+
}));
|
|
84
|
+
// Etiqueta: tangencial si cabe a lo largo del arco; si no, radial; si no, nada.
|
|
85
|
+
const mid = (a.a0 + a.a1) / 2;
|
|
86
|
+
const rm = (a.r0 + a.r1) / 2;
|
|
87
|
+
const thick = a.r1 - a.r0;
|
|
88
|
+
const ink = textOn(fill, theme);
|
|
89
|
+
for (const size of [12, 11, 10]) {
|
|
90
|
+
const w = measure(n.name, size, 600);
|
|
91
|
+
const deg = (mid * 180) / Math.PI;
|
|
92
|
+
const half = (a.a1 - a.a0) / 2;
|
|
93
|
+
// Tangencial: texto recto sobre un arco. Se busca un radio donde el
|
|
94
|
+
// borde interior no cruce r0 y las puntas no salgan por r1.
|
|
95
|
+
const rt = a.r0 + 2 + size / 2;
|
|
96
|
+
const reach = Math.sqrt((rt + size / 2) ** 2 + (w / 2 + 3) ** 2);
|
|
97
|
+
const angOk = half < Math.PI / 2 && w / 2 + 4 <= (rt - size / 2) * Math.tan(half);
|
|
98
|
+
if (reach <= a.r1 - 2 && angOk) {
|
|
99
|
+
const lo = rt, hi = Math.sqrt(Math.max(0, (a.r1 - 2) ** 2 - (w / 2 + 3) ** 2)) - size / 2;
|
|
100
|
+
const rr = Math.max(lo, Math.min(hi, (lo + hi) / 2));
|
|
101
|
+
let rot = deg + 90;
|
|
102
|
+
if (Math.sin(mid) > 0) rot -= 180;
|
|
103
|
+
labels.push(text(0, 0, n.name, { transform: `translate(${f(cx + rr * Math.cos(mid))},${f(cy + rr * Math.sin(mid))}) rotate(${f(rot)})`, textAnchor: 'middle', dy: size * 0.35, fill: ink, fontSize: size, fontWeight: 600, pointerEvents: 'none' }));
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
// Radial: a lo largo del radio, si el anillo es largo y el arco ancho.
|
|
107
|
+
if (w + 10 <= thick && 2 * a.r0 * Math.sin(Math.min(half, Math.PI / 2)) >= size + 3) {
|
|
108
|
+
let rot = deg;
|
|
109
|
+
if (Math.cos(mid) < 0) rot += 180;
|
|
110
|
+
labels.push(text(0, 0, n.name, { transform: `translate(${f(cx + rm * Math.cos(mid))},${f(cy + rm * Math.sin(mid))}) rotate(${f(rot)})`, textAnchor: 'middle', dy: size * 0.35, fill: ink, fontSize: size, fontWeight: 600, pointerEvents: 'none' }));
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
body.push(g({ class: 'pa-labels' }, labels));
|
|
116
|
+
|
|
117
|
+
// Centro: total (o el nodo en foco). Clic en el centro sube un nivel.
|
|
118
|
+
const big = spec.centerValue ?? fmt(total);
|
|
119
|
+
let bigSize = Math.min(30, r0 * 0.46);
|
|
120
|
+
while (bigSize > 12 && measure(big, bigSize, 600) > r0 * 1.7) bigSize -= 1;
|
|
121
|
+
const small = spec.centerLabel ?? (focus.path.length ? focus.name : en ? 'Total' : 'Total');
|
|
122
|
+
const up = focus.path.length ? JSON.stringify(focus.path.slice(0, -1)) : undefined;
|
|
123
|
+
body.push(g({ class: 'pa-center', 'data-drill': up, style: up ? 'cursor:pointer' : undefined }, [
|
|
124
|
+
el('circle', { cx, cy, r: Math.max(0, r0 - 2), fill: theme.surface }),
|
|
125
|
+
text(cx, cy + bigSize * 0.3, big, { fill: theme.ink, fontSize: bigSize, fontWeight: 600, textAnchor: 'middle' }),
|
|
126
|
+
measure(small, 11.5) <= r0 * 1.7 ? text(cx, cy + bigSize * 0.3 + 16, small, { fill: theme.lede, fontSize: 11.5, textAnchor: 'middle' }) : '',
|
|
127
|
+
]));
|
|
128
|
+
|
|
129
|
+
const top3 = top.slice(0, 3).map((c) => `${c.name} ${share(c.value, total)}`).join(', ');
|
|
130
|
+
const what = humanize(valueField);
|
|
131
|
+
const desc = en
|
|
132
|
+
? `Sunburst of ${what} in ${fields.length} levels, total ${fmt(total)}. Largest groups: ${top3}.`
|
|
133
|
+
: `Sunburst de ${what.toLowerCase()} en ${fields.length} niveles, total ${fmt(total)}. Mayores grupos: ${top3}.`;
|
|
134
|
+
return frame.finish(body, { desc, type: 'sunburst' });
|
|
135
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Treemap de varios niveles (estilo Atlas de Complejidad Económica / d3plus).
|
|
2
|
+
// Color por grupo de primer nivel, variación sutil de luminosidad en niveles
|
|
3
|
+
// más profundos, encabezados de grupo cuando hay espacio y etiquetas que solo
|
|
4
|
+
// aparecen si caben. Drill-down: data-drill + spec.focus.
|
|
5
|
+
import { el, g, text, tip, esc } from '../svg.js';
|
|
6
|
+
import { measure, wrap } from '../text.js';
|
|
7
|
+
import { makeFormat } from '../format.js';
|
|
8
|
+
import { textOn, ramp } from '../theme.js';
|
|
9
|
+
import { formatNumber } from '../format.js';
|
|
10
|
+
import { humanize } from '../data.js';
|
|
11
|
+
import { createFrame } from '../frame.js';
|
|
12
|
+
import { ctx } from '../context.js';
|
|
13
|
+
import { buildHierarchy, findNode, assignColors, shareFmt, breadcrumb, height, SEP } from '../hierarchy.js';
|
|
14
|
+
|
|
15
|
+
/** Algoritmo squarified (Bruls, Huizing & van Wijk, 2000). */
|
|
16
|
+
export function squarify(items, x, y, w, h) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const total = items.reduce((a, b) => a + b.value, 0);
|
|
19
|
+
if (total <= 0 || w <= 0 || h <= 0) return out;
|
|
20
|
+
const scale = (w * h) / total;
|
|
21
|
+
const nodes = items.map((it) => ({ it, area: it.value * scale })).filter((n) => n.area > 0);
|
|
22
|
+
let rect = { x, y, w, h };
|
|
23
|
+
let row = [];
|
|
24
|
+
const worst = (r, side) => {
|
|
25
|
+
const s = r.reduce((a, b) => a + b.area, 0);
|
|
26
|
+
const mx = Math.max(...r.map((n) => n.area)), mn = Math.min(...r.map((n) => n.area));
|
|
27
|
+
return Math.max((side * side * mx) / (s * s), (s * s) / (side * side * mn));
|
|
28
|
+
};
|
|
29
|
+
const layoutRow = (r, last) => {
|
|
30
|
+
const s = r.reduce((a, b) => a + b.area, 0);
|
|
31
|
+
if (rect.w >= rect.h) {
|
|
32
|
+
const cw = last ? rect.w : s / rect.h;
|
|
33
|
+
let cy = rect.y;
|
|
34
|
+
r.forEach((n, i) => { const ch = i === r.length - 1 ? rect.y + rect.h - cy : n.area / cw; out.push({ ...n.it, x: rect.x, y: cy, w: cw, h: ch }); cy += ch; });
|
|
35
|
+
rect = { x: rect.x + cw, y: rect.y, w: rect.w - cw, h: rect.h };
|
|
36
|
+
} else {
|
|
37
|
+
const ch = last ? rect.h : s / rect.w;
|
|
38
|
+
let cx = rect.x;
|
|
39
|
+
r.forEach((n, i) => { const cw = i === r.length - 1 ? rect.x + rect.w - cx : n.area / ch; out.push({ ...n.it, x: cx, y: rect.y, w: cw, h: ch }); cx += cw; });
|
|
40
|
+
rect = { x: rect.x, y: rect.y + ch, w: rect.w, h: rect.h - ch };
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
nodes.forEach((n, idx) => {
|
|
44
|
+
const side = Math.min(rect.w, rect.h);
|
|
45
|
+
if (!row.length || worst([...row, n], side) <= worst(row, side)) row.push(n);
|
|
46
|
+
else { layoutRow(row, false); row = [n]; }
|
|
47
|
+
if (idx === nodes.length - 1) layoutRow(row, true);
|
|
48
|
+
});
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Separación entre hermanos según la profundidad visible (1 = primer nivel).
|
|
53
|
+
// Separación por nivel: más aire entre categorías, menos dentro de ellas.
|
|
54
|
+
const GAP = (d) => (d <= 1 ? 4 : d === 2 ? 2 : 1);
|
|
55
|
+
|
|
56
|
+
export function treemap(spec, theme) {
|
|
57
|
+
const locale = spec.locale || 'es';
|
|
58
|
+
const en = String(locale).startsWith('en');
|
|
59
|
+
const { root, fields, valueField } = buildHierarchy(spec);
|
|
60
|
+
const focus = findNode(root, spec.focus);
|
|
61
|
+
const levels = height(focus);
|
|
62
|
+
// El color sigue a la entidad: se asigna sobre el árbol completo, así al
|
|
63
|
+
// entrar a un grupo sus elementos conservan la familia de color del padre.
|
|
64
|
+
const single = height(root) <= 1 && spec.colorBy !== 'label';
|
|
65
|
+
assignColors(root, theme, spec, { single });
|
|
66
|
+
const allValues = [];
|
|
67
|
+
(function walk(n) { allValues.push(n.value); n.children.forEach(walk); })(root);
|
|
68
|
+
const fmt = makeFormat(spec.format, locale, allValues);
|
|
69
|
+
const share = shareFmt(locale);
|
|
70
|
+
const hl = spec.highlight === undefined ? null : new Set([].concat(spec.highlight).map(String));
|
|
71
|
+
|
|
72
|
+
// Variación vs. el periodo anterior (con `time`) o vs. `compareData`.
|
|
73
|
+
const prevRows = spec.compareData ?? spec._prev?.data;
|
|
74
|
+
const prevLabel = spec.compareLabel ?? spec._prev?.label;
|
|
75
|
+
const prevMap = new Map();
|
|
76
|
+
if (prevRows && prevRows.length) {
|
|
77
|
+
const pr = buildHierarchy({ ...spec, data: prevRows, time: undefined }).root;
|
|
78
|
+
(function walk(n) { prevMap.set(n.path.join('\u0000'), n.value); n.children.forEach(walk); })(pr);
|
|
79
|
+
}
|
|
80
|
+
const changeOf = (n) => { const pv = prevMap.get(n.path.join('\u0000')); return pv > 0 ? n.value / pv - 1 : null; };
|
|
81
|
+
const showChange = prevMap.size > 0 && spec.change !== false;
|
|
82
|
+
const byChange = showChange && spec.colorBy === 'change';
|
|
83
|
+
const pctTxt = (c) => (c === null ? '' : `${c > 0 ? '▲' : c < 0 ? '▼' : '■'} ${formatNumber(Math.abs(c) * 100, Math.abs(c) < 0.1 ? 1 : 0, locale, true)}%`);
|
|
84
|
+
let cMax = 0;
|
|
85
|
+
if (byChange) (function walk(n) { if (!n.children.length) { const c = changeOf(n); if (c !== null) cMax = Math.max(cMax, Math.abs(c)); } n.children.forEach(walk); })(focus);
|
|
86
|
+
cMax = spec.changeDomain ?? (Math.ceil(Math.min(1, cMax || 0.1) * 20) / 20);
|
|
87
|
+
// Crecimiento en el tono frío, caída en el cálido; gris neutro sin cambio.
|
|
88
|
+
const divRamp = [...theme.diverging].reverse();
|
|
89
|
+
const changeColor = (c) => (c === null ? theme.tint : ramp(divRamp, 0.5 + Math.max(-1, Math.min(1, c / cMax)) / 2));
|
|
90
|
+
|
|
91
|
+
// Leyenda del primer nivel visible, con su participación: la leyenda también informa.
|
|
92
|
+
const top = focus.children;
|
|
93
|
+
let legend = [];
|
|
94
|
+
if (byChange && spec.legend !== false) {
|
|
95
|
+
const lab = (v) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${formatNumber(Math.abs(v) * 100, 0, locale)}%`;
|
|
96
|
+
legend = [{ type: 'ramp', stops: [...theme.diverging].reverse(), min: lab(-cMax), max: lab(cMax), title: (en ? 'Change' : 'Variación') + (prevLabel ? (en ? ' vs ' : ' vs ') + prevLabel : '') }];
|
|
97
|
+
} else if (levels > 1 && top.length > 1 && spec.legend !== false && !focus.path.length) {
|
|
98
|
+
const shown = top.filter((c) => !c.folded);
|
|
99
|
+
const sh0 = shareFmt(locale);
|
|
100
|
+
legend = shown.map((c) => ({ label: `${c.name} ${sh0(c.value, focus.value)}`, color: c.color, shape: 'square' }));
|
|
101
|
+
if (top.some((c) => c.folded)) legend.push({ label: en ? 'Other' : 'Otros', color: theme.other, shape: 'square' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// En pantallas angostas el treemap crece hacia abajo: rectángulos legibles.
|
|
105
|
+
const W = spec.width || 720;
|
|
106
|
+
const frame = createFrame(spec, theme, { height: W < 560 ? Math.round(Math.min(780, W * 1.15 + 170)) : Math.round(Math.max(440, Math.min(620, W * 0.62))) });
|
|
107
|
+
let area = frame.layout(legend);
|
|
108
|
+
const body = [];
|
|
109
|
+
const bc = breadcrumb(focus, area.x, area.y, area.w, theme, locale);
|
|
110
|
+
if (bc.h) { body.push(bc.svg); area = { ...area, y: area.y + bc.h, h: area.h - bc.h }; }
|
|
111
|
+
|
|
112
|
+
const total = focus.value;
|
|
113
|
+
const grand = root.value;
|
|
114
|
+
const R = theme.marks?.barRadius ?? 4;
|
|
115
|
+
|
|
116
|
+
const tipFor = (n) => {
|
|
117
|
+
const parentShare = n.parent && n.parent.value ? share(n.value, n.parent.value) : '';
|
|
118
|
+
const ch = showChange ? changeOf(n) : null;
|
|
119
|
+
return tip(
|
|
120
|
+
n.path.join(SEP),
|
|
121
|
+
`${fmt(n.value)} · ${share(n.value, grand)} ${en ? 'of total' : 'del total'}`,
|
|
122
|
+
n.parent && n.parent.depth > 0 ? `${parentShare} ${en ? 'of' : 'de'} ${n.parent.name}` : '',
|
|
123
|
+
ch !== null ? `${pctTxt(ch)} ${prevLabel ? 'vs ' + prevLabel : ''}`.trim() : ''
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
const dim = (n) => hl && !n.path.some((p) => hl.has(p));
|
|
127
|
+
|
|
128
|
+
function drawLeaf(n, x, y, w, h, relDepth) {
|
|
129
|
+
if (w < 0.6 || h < 0.6) return '';
|
|
130
|
+
const ch = showChange ? changeOf(n) : null;
|
|
131
|
+
const fill = dim(n) ? theme.dim : byChange ? changeColor(ch) : n.color;
|
|
132
|
+
const rx = Math.max(0, Math.min(R - 1, w / 6, h / 6));
|
|
133
|
+
const parts = [el('rect', { x, y, width: w, height: h, rx, fill })];
|
|
134
|
+
const extra = [spec.showValues ? fmt(n.value) : null, ch !== null ? pctTxt(ch) : null].filter(Boolean).join(' · ');
|
|
135
|
+
parts.push(...tileLabel(n.name, share(n.value, total), x, y, w, h, textOn(fill, theme), extra || null, relDepth));
|
|
136
|
+
const drill = relDepth === 1 && n.children.length ? JSON.stringify(n.path) : undefined;
|
|
137
|
+
return g({ class: 'pa-m', 'data-tip': tipFor(n), 'data-drill': drill, style: drill ? 'cursor:pointer' : undefined }, parts);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function drawNode(n, x, y, w, h, relDepth) {
|
|
141
|
+
// Hoja visible (sin hijos o al fondo de la jerarquía mostrada).
|
|
142
|
+
if (!n.children.length) return drawLeaf(n, x, y, w, h, relDepth);
|
|
143
|
+
// Sin bandas de encabezado: el área de cada grupo es exactamente su valor.
|
|
144
|
+
// La categoría se reconoce por su color sólido, el espacio entre grupos y la leyenda.
|
|
145
|
+
const parts = [];
|
|
146
|
+
const iy = y, ih = h;
|
|
147
|
+
const gap = GAP(relDepth + 1);
|
|
148
|
+
const rects = squarify(n.children, x, iy, w, ih);
|
|
149
|
+
for (const c of rects) parts.push(drawNode(c, c.x + gap / 2, c.y + gap / 2, c.w - gap, c.h - gap, relDepth + 1));
|
|
150
|
+
const drill = relDepth === 1 ? JSON.stringify(n.path) : undefined;
|
|
151
|
+
return g({ class: relDepth === 1 ? 'pa-group' : undefined, 'data-tip': relDepth === 1 ? tipFor(n) : undefined, 'data-drill': drill, style: drill ? 'cursor:pointer' : undefined }, parts);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const g1 = GAP(1);
|
|
155
|
+
const rects = squarify(focus.children, area.x - g1 / 2, area.y - g1 / 2, area.w + g1, area.h + g1);
|
|
156
|
+
for (const r of rects) body.push(drawNode(r, r.x + g1 / 2, r.y + g1 / 2, r.w - g1, r.h - g1, 1));
|
|
157
|
+
|
|
158
|
+
const leaves = [];
|
|
159
|
+
(function walk(n) { if (!n.children.length) leaves.push(n); n.children.forEach(walk); })(focus);
|
|
160
|
+
const top3 = leaves.sort((a, b) => b.value - a.value).slice(0, 3).map((l) => `${l.name} ${share(l.value, total)}`).join(', ');
|
|
161
|
+
const what = humanize(valueField);
|
|
162
|
+
const where = focus.path.length ? ` (${focus.path.join(SEP)})` : '';
|
|
163
|
+
const desc = en
|
|
164
|
+
? `Treemap of ${what}${where} across ${fields.length} level${fields.length > 1 ? 's' : ''}, total ${fmt(total)}. Largest: ${top3}.`
|
|
165
|
+
: `Treemap de ${what.toLowerCase()}${where} en ${fields.length} nivel${fields.length > 1 ? 'es' : ''}, total ${fmt(total)}. Mayores: ${top3}.`;
|
|
166
|
+
return frame.finish(body, { desc, type: 'treemap' });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Etiqueta dentro del rectángulo solo si cabe; tamaño según el área. */
|
|
170
|
+
function tileLabel(name, pct, x, y, w, h, fill, value, depth) {
|
|
171
|
+
const out = [];
|
|
172
|
+
const pad = w < 60 || h < 40 ? 5 : 8;
|
|
173
|
+
const maxSize = depth <= 1 ? 24 : depth === 2 ? 18 : 15;
|
|
174
|
+
for (let size = Math.min(maxSize, Math.max(10, Math.sqrt(w * h) / 8.5)); size >= 9.5; size -= 1) {
|
|
175
|
+
const lines = wrap(name, w - pad * 2, size, 600, 3);
|
|
176
|
+
const X = ctx.textScale;
|
|
177
|
+
const lh = size * X * 1.14;
|
|
178
|
+
const pctSize = Math.max(9.5, size * 0.78);
|
|
179
|
+
const sub = value ? `${pct} · ${value}` : pct;
|
|
180
|
+
const need = pad + lines.length * lh + 3 + pctSize * X + pad * 0.5;
|
|
181
|
+
const widest = Math.max(...lines.map((l) => measure(l, size, 600)), measure(sub, pctSize));
|
|
182
|
+
const fitsW = lines.every((l) => measure(l, size, 600) <= w - pad * 2) && !/…$/.test(lines[lines.length - 1]);
|
|
183
|
+
if (fitsW && widest <= w - pad * 2 && need <= h) {
|
|
184
|
+
lines.forEach((l, i) => out.push(text(x + pad, y + pad + size * X * 0.88 + i * lh, l, { fill, fontSize: size, fontWeight: 600 })));
|
|
185
|
+
out.push(text(x + pad, y + pad + size * X * 0.88 + (lines.length - 1) * lh + pctSize * X + 4, sub, { fill, fontSize: pctSize, opacity: 0.86 }));
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
if (lines.length === 1 && fitsW && pad + lh <= h) {
|
|
189
|
+
out.push(text(x + pad, y + pad + size * X * 0.88, lines[0], { fill, fontSize: size, fontWeight: 600 }));
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Contexto de dibujo: valores globales que plot() fija mientras dibuja un
|
|
2
|
+
// gráfico (todo es sincrónico, así que no hay mezcla entre gráficos).
|
|
3
|
+
export const ctx = { textScale: 1 };
|
|
4
|
+
|
|
5
|
+
/** Ejecuta fn con un contexto temporal y lo restaura después. */
|
|
6
|
+
export function withContext(values, fn) {
|
|
7
|
+
const prev = { ...ctx };
|
|
8
|
+
Object.assign(ctx, values);
|
|
9
|
+
try { return fn(); } finally { Object.assign(ctx, prev); }
|
|
10
|
+
}
|
package/src/dashed.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Formas "punteadas" (`dashed`): para marcar lo que no es un dato firme —
|
|
2
|
+
// estimaciones, proyecciones, cifras provisorias, territorios en disputa—
|
|
3
|
+
// sin inventar otro color. La forma conserva su tono, pero con un relleno
|
|
4
|
+
// rayado (franjas del color sobre una versión clara del mismo) y un contorno
|
|
5
|
+
// discontinuo. En líneas, el trazo se vuelve discontinuo (`dashedFrom` corta
|
|
6
|
+
// una serie en el punto donde empieza la proyección).
|
|
7
|
+
//
|
|
8
|
+
// dashed: "Texas" → una categoría, serie o región
|
|
9
|
+
// dashed: ["Texas", "Ohio"] → varias
|
|
10
|
+
// dashed: "provisional" → un campo de los datos (filas con valor verdadero)
|
|
11
|
+
// dashed: { field: "estado", equals: "estimado" }
|
|
12
|
+
// dashedFrom: "2025" → desde ese x en adelante (líneas, áreas y columnas)
|
|
13
|
+
// dashedLabel: "Estimación" → entrada de leyenda y línea en el tooltip
|
|
14
|
+
import { el } from './svg.js';
|
|
15
|
+
import { ramp, readable } from './theme.js';
|
|
16
|
+
|
|
17
|
+
/** Trazos discontinuos: contornos cortos y finos; líneas algo más largas. */
|
|
18
|
+
export const DASH = { outline: '4 2.5', line: '5 6', legend: '3.5 2.5' };
|
|
19
|
+
|
|
20
|
+
const TRUE = /^(true|1|s[ií]|yes|y|x|✓)$/i;
|
|
21
|
+
const truthy = (v) => v === true || v === 1 || (typeof v === 'string' && TRUE.test(v.trim()));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Devuelve una función (clave, serie?) → boolean, o null si no hay `dashed`.
|
|
25
|
+
* `keyField` y `seriesField` dicen qué campos identifican cada forma.
|
|
26
|
+
*/
|
|
27
|
+
export function dashedMatcher(spec, data, keyField, seriesField, norm = String) {
|
|
28
|
+
const d = spec.dashed;
|
|
29
|
+
if (d === undefined || d === null || d === false) return null;
|
|
30
|
+
if (d === true) return () => true;
|
|
31
|
+
const row0 = data && data[0];
|
|
32
|
+
const field = typeof d === 'object' && !Array.isArray(d) ? d.field : typeof d === 'string' && row0 && d in row0 ? d : null;
|
|
33
|
+
if (field) {
|
|
34
|
+
const eq = typeof d === 'object' ? d.equals ?? d.value : undefined;
|
|
35
|
+
const pairs = new Set();
|
|
36
|
+
for (const r of data) {
|
|
37
|
+
const hit = eq !== undefined ? [].concat(eq).map(String).includes(String(r[field])) : truthy(r[field]);
|
|
38
|
+
if (hit) pairs.add(norm(r[keyField]) + '\u0000' + (seriesField ? norm(r[seriesField]) : ''));
|
|
39
|
+
}
|
|
40
|
+
return (k, s) => pairs.has(norm(k) + '\u0000' + (seriesField ? norm(s ?? '') : ''));
|
|
41
|
+
}
|
|
42
|
+
const keys = new Set([].concat(d).map(norm));
|
|
43
|
+
return (k, s) => keys.has(norm(k)) || (s !== undefined && s !== null && keys.has(norm(s)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** ¿El valor x está en el tramo proyectado (x ≥ dashedFrom)? */
|
|
47
|
+
export function fromTest(spec, toNum) {
|
|
48
|
+
if (spec.dashedFrom === undefined || spec.dashedFrom === null) return null;
|
|
49
|
+
const t = toNum(spec.dashedFrom);
|
|
50
|
+
return Number.isFinite(t) ? (x) => Number.isFinite(x) && x >= t : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isHex = (c) => /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(String(c || ''));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fábrica de rellenos rayados, uno por color. `fill(c)` da el url(#…) y
|
|
57
|
+
* `defs()` los <pattern> que hay que poner en el SVG.
|
|
58
|
+
*/
|
|
59
|
+
export function hatcher(theme, id) {
|
|
60
|
+
const pats = new Map();
|
|
61
|
+
return {
|
|
62
|
+
fill(color) {
|
|
63
|
+
if (!pats.has(color)) pats.set(color, `${id}-h${pats.size}`);
|
|
64
|
+
return `url(#${pats.get(color)})`;
|
|
65
|
+
},
|
|
66
|
+
/** Contorno: el mismo tono, con al menos 3:1 contra el fondo (WCAG, gráficos). */
|
|
67
|
+
stroke(color) { return isHex(color) ? readable(color, theme, 3) : color; },
|
|
68
|
+
defs() {
|
|
69
|
+
return [...pats].map(([c, pid]) => pattern(pid, c, theme)).join('');
|
|
70
|
+
},
|
|
71
|
+
get size() { return pats.size; },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pattern(pid, color, theme) {
|
|
76
|
+
const bg = isHex(color) && isHex(theme.surface) ? ramp([theme.surface, color], theme.mode === 'dark' ? 0.3 : 0.22) : null;
|
|
77
|
+
return el('pattern', { id: pid, width: 6, height: 6, patternUnits: 'userSpaceOnUse', patternTransform: 'rotate(45)' }, [
|
|
78
|
+
bg ? el('rect', { width: 6, height: 6, fill: bg }) : el('rect', { width: 6, height: 6, fill: color, fillOpacity: 0.22 }),
|
|
79
|
+
el('rect', { width: 2.6, height: 6, fill: color }),
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Atributos de una forma punteada: relleno rayado + contorno discontinuo. */
|
|
84
|
+
export function dashedAttrs(H, color, { width = 1.25 } = {}) {
|
|
85
|
+
return { fill: H.fill(color), stroke: H.stroke(color), strokeWidth: width, strokeDasharray: DASH.outline, 'data-dashed': '1', 'data-color': color };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
89
|
+
/** Texto por defecto para la leyenda cuando el agente no da `dashedLabel`. */
|
|
90
|
+
export const dashedDefaultLabel = (locale) => T(locale, 'Estimado', 'Estimate');
|