plotagents 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +29 -5
- package/README.md +44 -15
- package/bin/plotagents.js +40 -14
- package/dist/plotagents-geo.js +1 -1
- package/dist/plotagents.js +6161 -757
- package/docs/api.md +23 -5
- package/docs/charts.md +229 -10
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/es/agents.md +102 -0
- package/docs/es/api.md +172 -0
- package/docs/es/charts.md +436 -0
- package/docs/es/colorscales.md +46 -0
- package/docs/es/dashboards.md +79 -0
- package/docs/es/getting-started.md +142 -0
- package/docs/es/rendering.md +47 -0
- package/docs/es/themes.md +116 -0
- package/docs/getting-started.md +6 -2
- package/docs/themes.md +39 -9
- package/index.d.ts +275 -20
- package/package.json +11 -4
- package/schema.json +1382 -53
- package/skills/plotagents/SKILL.md +105 -0
- package/src/axes.js +11 -3
- package/src/charts/bar.js +40 -21
- package/src/charts/categorical.js +31 -7
- package/src/charts/chord.js +6 -4
- package/src/charts/choropleth.js +287 -37
- package/src/charts/corr.js +512 -0
- package/src/charts/dist.js +39 -21
- package/src/charts/donut.js +4 -2
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +2 -2
- package/src/charts/kpi.js +11 -8
- package/src/charts/line.js +187 -43
- package/src/charts/network.js +557 -189
- package/src/charts/pointmap.js +602 -0
- package/src/charts/radar.js +7 -1
- package/src/charts/regplot.js +16 -4
- package/src/charts/sankey.js +42 -6
- package/src/charts/scatter.js +103 -23
- package/src/charts/sunburst.js +7 -6
- package/src/charts/table.js +882 -0
- package/src/charts/treemap.js +12 -9
- package/src/charts/wordcloud.js +177 -0
- package/src/colorscales.js +193 -0
- package/src/dashboard.js +451 -0
- package/src/dashed.js +1 -1
- package/src/data.js +4 -2
- package/src/facet.js +4 -3
- package/src/format.js +80 -25
- package/src/frame.js +101 -29
- package/src/geo/projection.js +76 -9
- package/src/geo/registry.js +87 -8
- package/src/hierarchy.js +6 -0
- package/src/i18n.js +107 -0
- package/src/index.js +92 -14
- package/src/infer.js +18 -1
- package/src/interact.js +157 -36
- package/src/lint.js +353 -11
- package/src/runtime.js +301 -101
- package/src/scale.js +82 -11
- package/src/schema.js +235 -36
- package/src/stats.js +76 -17
- package/src/svg.js +4 -1
- package/src/text.js +5 -0
- package/src/theme.js +101 -14
package/src/charts/choropleth.js
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
import { el, g, text } from '../svg.js';
|
|
9
9
|
import { measure, truncate } from '../text.js';
|
|
10
10
|
import { makeFormat } from '../format.js';
|
|
11
|
-
import { ramp, textOn } from '../theme.js';
|
|
12
|
-
import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, aggregate } from '../data.js';
|
|
11
|
+
import { ramp, textOn, colorMap } from '../theme.js';
|
|
12
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, aggregate, PlotError } from '../data.js';
|
|
13
13
|
import { createFrame, hash } from '../frame.js';
|
|
14
14
|
import { niceStep } from '../scale.js';
|
|
15
15
|
import { getGeo } from '../geo/registry.js';
|
|
16
|
-
import { mercator, equalEarth, projections, fit, aspectOf, pathD, centroid, rings } from '../geo/projection.js';
|
|
16
|
+
import { mercator, equalEarth, planar, projections, makeProjection, fit, aspectOf, pathD, centroid, rings, isPointGeom, isLineGeom } from '../geo/projection.js';
|
|
17
17
|
import { dashedMatcher, hatcher, dashedAttrs, dashedDefaultLabel, DASH } from '../dashed.js';
|
|
18
18
|
|
|
19
19
|
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
@@ -24,7 +24,8 @@ export function normKey(s) {
|
|
|
24
24
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
25
25
|
.toLowerCase()
|
|
26
26
|
.replace(/^\s*(region|provincia|comuna)\s+(metropolitana\s+de\s+)?(de\s+la\s+|del\s+|de\s+los\s+|de\s+las\s+|de\s+)?/, (m, a, metro) => (metro ? 'metropolitana' : ''))
|
|
27
|
-
.replace(/[^a-z0-9]/g, '')
|
|
27
|
+
.replace(/[^a-z0-9]/g, '')
|
|
28
|
+
.replace(/^0+(?=\d)/, ''); // códigos con cero a la izquierda: "08" = 8, "004" = 4
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function featureIndex(features, featureKey) {
|
|
@@ -32,7 +33,8 @@ function featureIndex(features, featureKey) {
|
|
|
32
33
|
const add = (k, f) => { const n = normKey(k); if (n && !idx.has(n)) idx.set(n, f); };
|
|
33
34
|
for (const f of features) {
|
|
34
35
|
const p = f.properties || {};
|
|
35
|
-
if (featureKey) { add(p[featureKey], f); continue; }
|
|
36
|
+
if (featureKey) { add(featureKey === 'id' && p.id === undefined ? f.id : p[featureKey], f); continue; }
|
|
37
|
+
if (f.id !== undefined && f.id !== null) add(f.id, f);
|
|
36
38
|
for (const k of ['code', 'num', 'id', 'iso3', 'iso_a3', 'name', 'nameEn', 'NAME', 'nombre']) if (p[k] !== undefined && p[k] !== null) add(p[k], f);
|
|
37
39
|
for (const a of p.aliases || []) add(a, f);
|
|
38
40
|
}
|
|
@@ -41,9 +43,42 @@ function featureIndex(features, featureKey) {
|
|
|
41
43
|
|
|
42
44
|
const nameOf = (f, locale) => {
|
|
43
45
|
const p = f.properties || {};
|
|
44
|
-
return (String(locale).startsWith('en') ? p.nameEn || p.name : p.name || p.nameEn) || p.NAME || p.nombre || String(p.code ?? p.id ?? '');
|
|
46
|
+
return (String(locale).startsWith('en') ? p.nameEn || p.name : p.name || p.nameEn) || p.NAME || p.nombre || String(p.code ?? p.id ?? f.id ?? '');
|
|
45
47
|
};
|
|
46
48
|
|
|
49
|
+
// Propiedades que suelen traer el nombre en GeoJSON de otras fuentes (INE, IGM, Natural Earth, GADM, censos).
|
|
50
|
+
const NAME_PROPS = ['name', 'nameEn', 'NAME', 'nombre', 'Nombre', 'NOMBRE', 'name_en', 'NAME_EN', 'NAME_1', 'NAME_2', 'NAME_3', 'ADMIN', 'admin', 'NOM_REG', 'NOM_REGION', 'Region', 'REGION', 'region', 'NOM_PROV', 'NOM_COM', 'NOM_COMUNA', 'COMUNA', 'Comuna', 'comuna', 'provincia', 'PROVINCIA', 'label', 'title', 'NAMELSAD', 'GEN', 'NUTS_NAME', 'shapeName'];
|
|
51
|
+
const primitive = (v) => typeof v === 'string' || typeof v === 'number';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* GeoJSON ajeno: busca la propiedad cuyos valores calzan con más filas de
|
|
55
|
+
* los datos (p. ej. "CUT_REG", "NOM_COM" o "GEOID"). Devuelve null si
|
|
56
|
+
* ninguna mejora lo que ya calzaba.
|
|
57
|
+
*/
|
|
58
|
+
function bestProperty(features, keys, already) {
|
|
59
|
+
const want = new Set(keys.map(normKey).filter(Boolean));
|
|
60
|
+
if (!want.size) return null;
|
|
61
|
+
const props = new Set();
|
|
62
|
+
for (const f of features.slice(0, 80)) for (const [k, v] of Object.entries(f.properties || {})) if (primitive(v)) props.add(k);
|
|
63
|
+
let best = null, bestN = already;
|
|
64
|
+
for (const k of props) {
|
|
65
|
+
let n = 0;
|
|
66
|
+
const seen = new Set(features.map((f) => normKey(f.properties?.[k])));
|
|
67
|
+
for (const w of want) if (seen.has(w)) n++;
|
|
68
|
+
if (n > bestN) { best = k; bestN = n; }
|
|
69
|
+
}
|
|
70
|
+
return best;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Copia las features con `name` resuelto (nameKey explícito o adivinado). */
|
|
74
|
+
function withNames(features, nameKey) {
|
|
75
|
+
return features.map((f) => {
|
|
76
|
+
const p = f.properties || {};
|
|
77
|
+
const v = p[nameKey];
|
|
78
|
+
return primitive(v) && p.name !== v ? { ...f, properties: { ...p, name: String(v) } } : f;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
47
82
|
/** Redondeo "bonito" de un corte de clase (2 cifras relativas al rango). */
|
|
48
83
|
function niceRound(v, range) {
|
|
49
84
|
if (!range) return v;
|
|
@@ -89,7 +124,7 @@ export function colorScale(values, spec, theme) {
|
|
|
89
124
|
breaks = [...new Set(breaks)].filter((b) => b > min && b <= max);
|
|
90
125
|
}
|
|
91
126
|
const n = breaks.length + 1;
|
|
92
|
-
const t0 = theme.mode === 'dark' ? 0.26 : 0.16;
|
|
127
|
+
const t0 = theme.cmap ? 0 : theme.mode === 'dark' ? 0.26 : 0.16;
|
|
93
128
|
const colors = Array.from({ length: n }, (_, i) => ramp(stops, n === 1 ? 0.6 : t0 + ((1 - t0) * i) / (n - 1)));
|
|
94
129
|
return classed(breaks, colors, min, max);
|
|
95
130
|
}
|
|
@@ -173,21 +208,41 @@ const noDataPattern = (id, theme) =>
|
|
|
173
208
|
|
|
174
209
|
/** Datos + geometría emparejados. */
|
|
175
210
|
function prepare(spec, locale) {
|
|
176
|
-
const fc = getGeo(spec.geo ?? 'chile');
|
|
211
|
+
const fc = getGeo(spec.geo ?? 'chile', spec.geoObject);
|
|
177
212
|
const data = ensureData(spec);
|
|
178
213
|
const fields = fieldsOf(data);
|
|
179
214
|
const key = spec.key ?? spec.region ?? spec.x ?? spec.label ?? fields.find((f) => fieldKind(data, f) === 'category');
|
|
180
215
|
const valueF = spec.value ?? spec.y ?? (typeof spec.size === 'string' ? spec.size : null) ?? fields.find((f) => f !== key && fieldKind(data, f) === 'number');
|
|
181
216
|
requireField(data, key, 'key');
|
|
182
217
|
requireField(data, valueF, 'value');
|
|
183
|
-
|
|
218
|
+
// Valores de texto ("Izquierda", "Derecha"…): un color por categoría.
|
|
219
|
+
const categorical = spec.scale === 'categorical' || (data.length > 0 && fieldKind(data, valueF) === 'category');
|
|
220
|
+
let features = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
|
|
184
221
|
if (!spec.antarctica) features = features.filter((f) => (f.properties || {}).iso3 !== 'ATA');
|
|
222
|
+
if (!features.length) throw new PlotError(T(locale, 'El `geo` no tiene geometrías para dibujar.', 'The `geo` has no geometries to draw.'), T(locale, 'Pasa un GeoJSON con al menos un Feature con geometría.', 'Pass a GeoJSON with at least one Feature that has a geometry.'));
|
|
223
|
+
// Nombres: `nameKey` o la primera propiedad conocida (NOMBRE, NAME_1, ADMIN…).
|
|
224
|
+
const hasName = (f) => { const p = f.properties || {}; return primitive(p.name) || primitive(p.nameEn) || primitive(p.NAME) || primitive(p.nombre); };
|
|
225
|
+
const firstText = () => { for (const f of features.slice(0, 20)) for (const [k, v] of Object.entries(f.properties || {})) if (typeof v === 'string' && v.trim()) return k; return null; };
|
|
226
|
+
const nameKey = spec.nameKey ?? (features.length && !features.some(hasName) ? NAME_PROPS.find((k) => features.some((f) => primitive(f.properties?.[k]))) ?? spec.featureKey ?? firstText() : null);
|
|
227
|
+
if (nameKey) features = withNames(features, nameKey);
|
|
185
228
|
let idx = featureIndex(features, spec.featureKey);
|
|
229
|
+
// Sin `featureKey`: si las filas no calzan con las claves de siempre, se busca la propiedad que sí calza.
|
|
230
|
+
let autoKey = null;
|
|
231
|
+
if (!spec.featureKey && data.length) {
|
|
232
|
+
const keys = data.map((r) => r[key]).filter(primitive);
|
|
233
|
+
const matched = keys.filter((k) => idx.has(normKey(k))).length;
|
|
234
|
+
if (matched < new Set(keys.map(normKey)).size) {
|
|
235
|
+
autoKey = bestProperty(features, keys, new Set(keys.filter((k) => idx.has(normKey(k))).map(normKey)).size);
|
|
236
|
+
if (autoKey) for (const [k, f] of featureIndex(features, autoKey)) if (!idx.has(k)) idx.set(k, f);
|
|
237
|
+
if (autoKey && !nameKey && !features.some(hasName)) features = withNames(features, autoKey), idx = new Map([...featureIndex(features, null), ...featureIndex(features, autoKey)]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
186
240
|
// Territorios opcionales (Puerto Rico en 'usa'): solo se dibujan si hay datos para ellos.
|
|
187
241
|
if (features.some((f) => f.properties?.optional)) {
|
|
188
242
|
const used = new Set(data.map((r) => idx.get(normKey(r[key]))).filter(Boolean));
|
|
189
243
|
features = features.filter((f) => !f.properties?.optional || used.has(f) || spec.territories === true);
|
|
190
|
-
idx = featureIndex(features, spec.featureKey);
|
|
244
|
+
idx = featureIndex(features, spec.featureKey ?? autoKey);
|
|
245
|
+
if (!spec.featureKey && autoKey) for (const [k, f] of featureIndex(features, null)) if (!idx.has(k)) idx.set(k, f);
|
|
191
246
|
}
|
|
192
247
|
const buckets = new Map();
|
|
193
248
|
const unmatched = [];
|
|
@@ -195,20 +250,129 @@ function prepare(spec, locale) {
|
|
|
195
250
|
const f = idx.get(normKey(r[key]));
|
|
196
251
|
if (!f) { if (r[key] !== undefined && r[key] !== null && r[key] !== '') unmatched.push(String(r[key])); continue; }
|
|
197
252
|
if (!buckets.has(f)) buckets.set(f, []);
|
|
198
|
-
buckets.get(f).push(num(r[valueF]));
|
|
253
|
+
buckets.get(f).push(categorical ? r[valueF] : num(r[valueF]));
|
|
199
254
|
}
|
|
200
255
|
const values = new Map();
|
|
201
|
-
|
|
256
|
+
const mode = (vs) => { const c = new Map(); for (const v of vs) if (primitive(v) && v !== '') c.set(String(v), (c.get(String(v)) || 0) + 1); return [...c].sort((a, b) => b[1] - a[1])[0]?.[0]; };
|
|
257
|
+
for (const [f, vs] of buckets) {
|
|
258
|
+
const v = categorical ? mode(vs) : aggregate(vs, spec.aggregate || 'mean');
|
|
259
|
+
if (categorical ? v !== undefined : Number.isFinite(v)) values.set(f, v);
|
|
260
|
+
}
|
|
202
261
|
if (Array.isArray(spec._unmatched)) spec._unmatched.push(...new Set(unmatched));
|
|
203
|
-
|
|
262
|
+
// Categorías ordenadas por cuántas áreas cubren (o el orden dado en `order`).
|
|
263
|
+
const count = new Map();
|
|
264
|
+
for (const v of values.values()) count.set(v, (count.get(v) || 0) + 1);
|
|
265
|
+
const categories = categorical ? (Array.isArray(spec.order) ? spec.order.map(String).filter((c) => count.has(c)).concat([...count.keys()].filter((c) => !spec.order.map(String).includes(c))) : [...count.keys()].sort((a, b) => count.get(b) - count.get(a))) : null;
|
|
266
|
+
const ranked = categorical ? [] : [...values.entries()].sort((a, b) => b[1] - a[1]);
|
|
204
267
|
const rank = new Map(ranked.map(([f], i) => [f, i + 1]));
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
268
|
+
// Proyección: la pedida; si no, una según la geometría (coordenadas fuera de
|
|
269
|
+
// lon/lat → plano cartesiano, p. ej. UTM; mundo → Equal Earth; resto → Mercator).
|
|
270
|
+
const projName = typeof spec.projection === 'string' ? spec.projection : fc.projection ?? (/^(world|mundo|countries|paises|países)$/i.test(String(spec.geo)) ? 'equalEarth' : null);
|
|
271
|
+
let lon0 = Infinity, lon1 = -Infinity, latMax = 0;
|
|
272
|
+
for (const f of features) for (const r of rings(f.geometry)) for (const p of r) { if (p[0] < lon0) lon0 = p[0]; if (p[0] > lon1) lon1 = p[0]; if (Math.abs(p[1]) > latMax) latMax = Math.abs(p[1]); }
|
|
273
|
+
const projected = Math.max(Math.abs(lon0), Math.abs(lon1)) > 180.5 || latMax > 90.5;
|
|
274
|
+
const raw = makeProjection(spec.projection) ?? (projName && projections[projName] ? projections[projName]() : projected ? planar() : lon1 - lon0 > 100 ? equalEarth() : mercator());
|
|
208
275
|
// `dashed`: por nombre de región (con los mismos alias) o por un campo de los datos.
|
|
209
276
|
const dm = dashedMatcher(spec, data, key, null, normKey);
|
|
210
277
|
const dashed = new Set(dm ? features.filter((f) => featureKeys(f).some((k) => dm(k))) : []);
|
|
211
|
-
return { fc, features, values, rank, ranked, unmatched: [...new Set(unmatched)], key, valueF, raw, data, dashed };
|
|
278
|
+
return { fc, features, values, rank, ranked, unmatched: [...new Set(unmatched)], key, valueF, raw, data, dashed, categorical, categories };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Capas extra: marcadores (lon/lat) y GeoJSON superpuesto ─────────────────
|
|
282
|
+
const pickF = (fields, names) => names.find((n) => fields.includes(n));
|
|
283
|
+
|
|
284
|
+
/** `markers`: [{ lon, lat, label, value }] o { data, lon, lat, label, size, color, legend }. */
|
|
285
|
+
function markerList(spec, theme) {
|
|
286
|
+
const mk = spec.markers;
|
|
287
|
+
if (!mk) return null;
|
|
288
|
+
const o = Array.isArray(mk) ? {} : mk;
|
|
289
|
+
const rows = ensureData({ data: Array.isArray(mk) ? mk : mk.data || [] });
|
|
290
|
+
const fields = fieldsOf(rows);
|
|
291
|
+
const lonF = o.lon ?? pickF(fields, ['lon', 'lng', 'long', 'longitude', 'longitud', 'x']);
|
|
292
|
+
const latF = o.lat ?? pickF(fields, ['lat', 'latitude', 'latitud', 'y']);
|
|
293
|
+
const labelF = o.label ?? pickF(fields, ['label', 'name', 'nombre', 'city', 'ciudad', 'title']);
|
|
294
|
+
const sizeF = o.size ?? pickF(fields, ['size', 'value', 'valor']);
|
|
295
|
+
const colorF = typeof o.color === 'string' && fields.includes(o.color) ? o.color : pickF(fields, ['color']);
|
|
296
|
+
if (!lonF || !latF) throw new PlotError('Los marcadores necesitan longitud y latitud.', 'markers: [{ lon: -73.05, lat: -36.82, label: "Concepción" }]');
|
|
297
|
+
const items = rows.map((r) => ({ lon: num(r[lonF]), lat: num(r[latF]), label: labelF ? r[labelF] : undefined, size: sizeF ? num(r[sizeF]) : NaN, color: (colorF && r[colorF]) || (typeof o.color === 'string' && !fields.includes(o.color) ? o.color : null), row: r }))
|
|
298
|
+
.filter((m) => Number.isFinite(m.lon) && Number.isFinite(m.lat));
|
|
299
|
+
const maxS = Math.max(0, ...items.map((m) => Math.abs(m.size)).filter(Number.isFinite));
|
|
300
|
+
for (const m of items) m.r = Number.isFinite(m.size) && maxS ? Math.max(3, Math.sqrt(Math.abs(m.size) / maxS) * (o.maxRadius ?? 14)) : o.radius ?? 4.5;
|
|
301
|
+
return { items, color: (typeof o.color === 'string' && !fields.includes(o.color) && o.color) || theme.ink, legend: o.legend ?? o.title ?? null, labels: o.labels !== false, sizeF };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** `layers`: [{ geo, stroke, fill, width, dash, opacity, label, below }]. */
|
|
305
|
+
function layerList(spec, theme) {
|
|
306
|
+
return [].concat(spec.layers || []).filter(Boolean).map((L, i) => {
|
|
307
|
+
const fc = getGeo(L.geo ?? L.data, L.object ?? L.geoObject);
|
|
308
|
+
const fs = fc.features.filter((f) => f && f.geometry && rings(f.geometry).length);
|
|
309
|
+
const lines = fs.every((f) => isLineGeom(f.geometry));
|
|
310
|
+
return { ...L, features: fs, i, stroke: L.stroke ?? L.color ?? (lines ? theme.ink : theme.lede), width: L.width ?? L.strokeWidth ?? (lines ? 1.5 : 1), fill: L.fill ?? 'none' };
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function drawLayer(L, proj, theme, locale) {
|
|
315
|
+
return g({ class: 'pa-layer', 'pointer-events': L.label || L.tooltip !== false ? null : 'none' }, L.features.map((f) => {
|
|
316
|
+
const pt = isPointGeom(f.geometry);
|
|
317
|
+
const nm = nameOf(f, locale);
|
|
318
|
+
const tipT = [L.label, nm && nm !== String(f.id ?? '') ? nm : null].filter(Boolean).join('\n');
|
|
319
|
+
return el('path', {
|
|
320
|
+
class: tipT ? 'pa-m' : null, 'data-tip': tipT || null, d: pathD(f.geometry, proj, { r: L.radius ?? 3 }),
|
|
321
|
+
fill: pt ? (L.fill !== 'none' ? L.fill : L.stroke) : L.fill, fillOpacity: L.fillOpacity ?? null,
|
|
322
|
+
stroke: pt ? theme.surface : L.stroke, strokeWidth: pt ? 1 : L.width, strokeDasharray: L.dash === true ? '5 4' : L.dash || null,
|
|
323
|
+
strokeLinejoin: 'round', strokeLinecap: 'round', opacity: L.opacity ?? null, 'data-color': L.stroke,
|
|
324
|
+
});
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function drawMarkers(mk, proj, theme, fmt, placed) {
|
|
329
|
+
const dots = [], labs = [];
|
|
330
|
+
const halo = { stroke: theme.surface, strokeWidth: 3, paintOrder: 'stroke', strokeLinejoin: 'round' };
|
|
331
|
+
for (const m of [...mk.items].sort((a, b) => b.r - a.r)) {
|
|
332
|
+
const [x, y] = proj([m.lon, m.lat]);
|
|
333
|
+
m.x = x; m.y = y;
|
|
334
|
+
const c = m.color || mk.color;
|
|
335
|
+
const tipT = [m.label, Number.isFinite(m.size) && mk.sizeF ? fmt(m.size) : null].filter((v) => v !== undefined && v !== null && v !== '').join('\n');
|
|
336
|
+
dots.push(el('circle', { class: 'pa-m', cx: x, cy: y, r: m.r, fill: c, fillOpacity: m.r > 6 ? 0.8 : 1, stroke: theme.surface, strokeWidth: 1.5, 'data-tip': tipT || null, 'data-color': c }));
|
|
337
|
+
}
|
|
338
|
+
for (const m of mk.items) placed.push([m.x - m.r - 2, m.y - m.r - 2, m.x + m.r + 2, m.y + m.r + 2]);
|
|
339
|
+
if (mk.labels) for (const m of mk.items) {
|
|
340
|
+
if (m.label === undefined || m.label === null || m.label === '') continue;
|
|
341
|
+
const w = measure(String(m.label), 11, 600);
|
|
342
|
+
const hit = (box) => placed.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]));
|
|
343
|
+
// A la derecha del punto; si choca, a la izquierda; si no, encima.
|
|
344
|
+
const cands = [
|
|
345
|
+
{ box: [m.x + m.r + 3, m.y - 8, m.x + m.r + 6 + w, m.y + 8], x: m.x + m.r + 4, y: m.y + 4, anchor: 'start' },
|
|
346
|
+
{ box: [m.x - m.r - 6 - w, m.y - 8, m.x - m.r - 3, m.y + 8], x: m.x - m.r - 4, y: m.y + 4, anchor: 'end' },
|
|
347
|
+
{ box: [m.x - w / 2 - 2, m.y - m.r - 20, m.x + w / 2 + 2, m.y - m.r - 3], x: m.x, y: m.y - m.r - 7, anchor: 'middle' },
|
|
348
|
+
];
|
|
349
|
+
const c = cands.find((k) => !hit(k.box));
|
|
350
|
+
if (!c) continue;
|
|
351
|
+
placed.push(c.box);
|
|
352
|
+
labs.push(text(c.x, c.y, String(m.label), { fill: theme.ink, fontSize: 11, fontWeight: 600, textAnchor: c.anchor, ...halo }));
|
|
353
|
+
}
|
|
354
|
+
return [g({ class: 'pa-markers' }, dots), g({ class: 'pa-map-label pa-marker-labels', 'pointer-events': 'none' }, labs)];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Leyenda de categorías (mapas con valores de texto): cuadros enlazados con las regiones. */
|
|
358
|
+
function catLegend(sc, x, y, maxW, theme, { title, missing, locale, gid, dashed }) {
|
|
359
|
+
const out = [];
|
|
360
|
+
const size = theme.type?.axis ?? 11.5;
|
|
361
|
+
let lx = x, ly = y, rows = 1;
|
|
362
|
+
if (title) { out.push(text(lx, ly + 9.5, title, { fill: theme.lede, fontSize: 12, fontWeight: 500 })); lx += measure(title, 12, 500) + 14; }
|
|
363
|
+
const item = (label, attrs, key) => {
|
|
364
|
+
const w = 14 + measure(label, size) + 16;
|
|
365
|
+
if (lx + w > x + maxW && lx > x) { lx = x; ly += 20; rows++; }
|
|
366
|
+
out.push(g({ class: 'pa-key', 'data-color': key, style: 'cursor:pointer' }, [
|
|
367
|
+
el('rect', { x: lx, y: ly, width: 10, height: 10, rx: 2, ...attrs }),
|
|
368
|
+
text(lx + 14, ly + 9, label, { fill: theme.lede, fontSize: size }),
|
|
369
|
+
]));
|
|
370
|
+
lx += w;
|
|
371
|
+
};
|
|
372
|
+
for (const c of sc.cats) item(c, { fill: sc.color(c) }, sc.color(c));
|
|
373
|
+
if (missing) item(T(locale, 'Sin dato', 'No data'), { fill: `url(#${gid}-nd)`, stroke: theme.axis, strokeWidth: 0.5 }, `url(#${gid}-nd)`);
|
|
374
|
+
if (dashed) item(dashed.label, { fill: dashed.fill, stroke: dashed.stroke, strokeWidth: 1.1, strokeDasharray: DASH.legend }, 'dashed');
|
|
375
|
+
return { svg: g({ class: 'pa-legend pa-map-legend' }, out), h: rows * 20 + 2 };
|
|
212
376
|
}
|
|
213
377
|
|
|
214
378
|
/**
|
|
@@ -360,11 +524,37 @@ function insetFrames(features, proj, theme) {
|
|
|
360
524
|
function baseChart(spec, theme, kind) {
|
|
361
525
|
const locale = spec.locale || 'es';
|
|
362
526
|
const m = prepare(spec, locale);
|
|
363
|
-
|
|
364
|
-
const
|
|
527
|
+
if (m.categorical && kind === 'symbol') throw new PlotError(T(locale, `"${m.valueF}" tiene texto, no números: un mapa de símbolos necesita cantidades.`, `"${m.valueF}" holds text, not numbers: a symbol map needs quantities.`), T(locale, 'Usa type: "choropleth" para colorear por categoría.', 'Use type: "choropleth" to color by category.'));
|
|
528
|
+
const vals = m.categorical ? [] : [...m.values.values()];
|
|
529
|
+
const numFmt = makeFormat(spec.format, locale, vals);
|
|
530
|
+
const fmt = m.categorical ? (v) => (typeof v === 'number' ? numFmt(v) : String(v ?? '')) : numFmt;
|
|
365
531
|
const W = spec.width || 720;
|
|
366
|
-
const
|
|
367
|
-
const
|
|
532
|
+
const layers = layerList(spec, theme);
|
|
533
|
+
const markers = markerList(spec, theme);
|
|
534
|
+
// `fitTo`: encuadra unas regiones (nombres o códigos), una caja [lon0, lat0, lon1, lat1] o "markers".
|
|
535
|
+
let fitFeats = m.features;
|
|
536
|
+
if (spec.fitTo !== undefined && spec.fitTo !== null) {
|
|
537
|
+
const ft = spec.fitTo;
|
|
538
|
+
if (Array.isArray(ft) && ft.length === 4 && ft.every((v) => typeof v === 'number')) {
|
|
539
|
+
const [a, b, c, d] = ft;
|
|
540
|
+
fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [[[a, b], [c, b], [c, d], [a, d], [a, b]]] } }];
|
|
541
|
+
} else if (ft === 'markers' && markers?.items.length) {
|
|
542
|
+
fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'MultiPoint', coordinates: markers.items.map((x) => [x.lon, x.lat]) } }];
|
|
543
|
+
} else {
|
|
544
|
+
const want = [].concat(ft).map(normKey);
|
|
545
|
+
const hit = m.features.filter((f) => featureIndexHas(f, want));
|
|
546
|
+
if (hit.length) fitFeats = hit;
|
|
547
|
+
}
|
|
548
|
+
// Un margen del 6 % para que lo encuadrado no toque el borde.
|
|
549
|
+
if (fitFeats !== m.features) {
|
|
550
|
+
let a = Infinity, b = Infinity, c = -Infinity, d = -Infinity;
|
|
551
|
+
for (const f of fitFeats) for (const r of rings(f.geometry)) for (const p of r) { a = Math.min(a, p[0]); b = Math.min(b, p[1]); c = Math.max(c, p[0]); d = Math.max(d, p[1]); }
|
|
552
|
+
const px = (c - a) * 0.06 || 0.5, py = (d - b) * 0.06 || 0.5;
|
|
553
|
+
fitFeats = [{ type: 'Feature', properties: {}, geometry: { type: 'MultiPoint', coordinates: [[a - px, b - py], [c + px, d + py], [a - px, d + py], [c + px, b - py]] } }];
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const aspect = aspectOf(fitFeats, m.raw);
|
|
557
|
+
const tall = aspect > 1.6 && m.features.length <= 40 && fitFeats === m.features && spec.tall !== false;
|
|
368
558
|
// Alto por defecto según la forma del territorio.
|
|
369
559
|
const defH = tall ? (W < 560 ? 660 : 740) : Math.round(W * Math.min(1.1, aspect) + 150);
|
|
370
560
|
const frame = createFrame(spec, theme, { height: defH });
|
|
@@ -376,7 +566,13 @@ function baseChart(spec, theme, kind) {
|
|
|
376
566
|
const H = hatcher(theme, gid);
|
|
377
567
|
const dashedLabel = spec.dashedLabel ?? dashedDefaultLabel(locale);
|
|
378
568
|
const dashedKey = m.dashed.size && spec.legend !== false ? { label: dashedLabel, fill: H.fill(theme.muted), stroke: H.stroke(theme.muted) } : null;
|
|
379
|
-
if (kind === 'choropleth' &&
|
|
569
|
+
if (kind === 'choropleth' && m.categorical && m.values.size) {
|
|
570
|
+
const cmap = colorMap(m.categories, theme, spec.colors);
|
|
571
|
+
sc = { mode: 'categories', cats: m.categories, color: (v) => cmap.get(String(v)) || theme.other };
|
|
572
|
+
const lg = spec.legend === false ? { svg: '', h: 0 } : catLegend(sc, area.x, area.y, area.w, theme, { title: spec.legendTitle ?? null, missing: missingAny, locale, gid, dashed: dashedKey });
|
|
573
|
+
body.push(lg.svg);
|
|
574
|
+
area = { ...area, y: area.y + lg.h + 8, h: area.h - lg.h - 8 };
|
|
575
|
+
} else if (kind === 'choropleth' && vals.length) {
|
|
380
576
|
sc = colorScale(vals, spec, theme);
|
|
381
577
|
const lg = legendBar(sc, area.x, area.y, area.w, theme, fmt.tick ? (v) => fmt(v) : fmt, { title: spec.legendTitle ?? humanize(m.valueF), missing: missingAny, locale, gid, dashed: dashedKey });
|
|
382
578
|
body.push(lg.svg);
|
|
@@ -403,7 +599,30 @@ function baseChart(spec, theme, kind) {
|
|
|
403
599
|
}
|
|
404
600
|
lx += 10;
|
|
405
601
|
}
|
|
406
|
-
|
|
602
|
+
// Claves de las capas y marcadores con nombre (`label` / `legend`).
|
|
603
|
+
{
|
|
604
|
+
const size = theme.type?.axis ?? 11.5;
|
|
605
|
+
const ky = rowItems.length ? area.y + 2 * (rMax || 0) - 9 : area.y + 4.6;
|
|
606
|
+
for (const L of layers) {
|
|
607
|
+
if (!L.label) continue;
|
|
608
|
+
const pts = L.features.every((f) => isPointGeom(f.geometry)), poly = !pts && !L.features.every((f) => isLineGeom(f.geometry));
|
|
609
|
+
rowItems.push(g({ class: 'pa-key', 'data-color': L.stroke, style: 'cursor:pointer' }, [
|
|
610
|
+
pts ? el('circle', { cx: lx + 5, cy: ky + 4.9, r: 3.5, fill: L.fill !== 'none' ? L.fill : L.stroke })
|
|
611
|
+
: poly ? el('rect', { x: lx + 0.5, y: ky + 0.5, width: 9.5, height: 9.5, rx: 2, fill: L.fill === 'none' ? 'none' : L.fill, stroke: L.stroke, strokeWidth: 1.2, strokeDasharray: L.dash === true ? '3 2' : L.dash || null })
|
|
612
|
+
: el('line', { x1: lx, x2: lx + 14, y1: ky + 4.9, y2: ky + 4.9, stroke: L.stroke, strokeWidth: Math.min(3, L.width + 0.5), strokeDasharray: L.dash === true ? '4 3' : L.dash || null, strokeLinecap: 'round' }),
|
|
613
|
+
text(lx + (pts || poly ? 15 : 19), ky + 8.4, L.label, { fill: theme.lede, fontSize: size }),
|
|
614
|
+
]));
|
|
615
|
+
lx += (pts || poly ? 15 : 19) + measure(L.label, size) + 16;
|
|
616
|
+
}
|
|
617
|
+
if (markers?.legend) {
|
|
618
|
+
rowItems.push(g({ class: 'pa-key', 'data-color': markers.color, style: 'cursor:pointer' }, [
|
|
619
|
+
el('circle', { cx: lx + 5, cy: ky + 4.9, r: 4.5, fill: markers.color, stroke: theme.surface, strokeWidth: 1 }),
|
|
620
|
+
text(lx + 15, ky + 8.4, markers.legend, { fill: theme.lede, fontSize: size }),
|
|
621
|
+
]));
|
|
622
|
+
lx += 15 + measure(markers.legend, size) + 16;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (dashedKey && !(kind === 'choropleth' && (vals.length || m.categorical))) {
|
|
407
626
|
const size = theme.type?.axis ?? 11.5;
|
|
408
627
|
const ky = rowItems.length ? area.y + 2 * rMax - 9 : area.y + 4.6;
|
|
409
628
|
rowItems.push(g({ class: 'pa-key', 'data-color': 'dashed', style: 'cursor:pointer' }, [
|
|
@@ -434,9 +653,13 @@ function baseChart(spec, theme, kind) {
|
|
|
434
653
|
let mapW = Math.min(area.w * (1 - colMin), area.h / aspect);
|
|
435
654
|
box = [area.x, area.y, area.x + mapW, area.y + area.h];
|
|
436
655
|
} else box = [area.x, area.y, area.x + area.w - calloutW, area.y + area.h];
|
|
437
|
-
|
|
656
|
+
// Puntos y marcadores en el borde: margen para el círculo y su etiqueta.
|
|
657
|
+
const inset = m.features.some((f) => isPointGeom(f.geometry)) ? (spec.pointRadius ?? 5) + 18 : markers?.items.length ? 12 : 0;
|
|
658
|
+
const pts = m.features.some((f) => isPointGeom(f.geometry));
|
|
659
|
+
if (inset) box = [box[0] + inset * (tall ? 0 : 1), box[1] + inset + (pts ? 12 : 0), box[2] - inset, box[3] - Math.min(inset, 10)];
|
|
660
|
+
const proj = fit(fitFeats, m.raw, box, { align: tall ? 'left' : 'center' });
|
|
438
661
|
const calloutX = calloutW ? proj.bounds.x + proj.bounds.w + 14 : 0;
|
|
439
|
-
return { locale, m, vals, fmt, frame, area, gid, missingAny, body, sc, proj, tall, H, dashedLabel, calloutX, rMax };
|
|
662
|
+
return { locale, m, vals, fmt, frame, area, gid, missingAny, body, sc, proj, tall, H, dashedLabel, calloutX, rMax, layers, markers };
|
|
440
663
|
}
|
|
441
664
|
|
|
442
665
|
const rankText = (r, n, locale) => (String(locale).startsWith('en') ? `#${r} of ${n}` : `${r}.º de ${n}`);
|
|
@@ -454,7 +677,9 @@ function zoomLayer(B, children) {
|
|
|
454
677
|
|
|
455
678
|
export function choropleth(spec, theme) {
|
|
456
679
|
const B = baseChart(spec, theme, 'choropleth');
|
|
457
|
-
const { locale, m, fmt, frame, area, gid, body, sc, proj, tall, H, dashedLabel, calloutX } = B;
|
|
680
|
+
const { locale, m, fmt, frame, area, gid, body, sc, proj, tall, H, dashedLabel, calloutX, layers, markers } = B;
|
|
681
|
+
const borderC = spec.borderColor ?? theme.surface, borderW = spec.borderWidth ?? 0.75;
|
|
682
|
+
const ptR = spec.pointRadius ?? 5;
|
|
458
683
|
const hl = spec.highlight === undefined ? null : [].concat(spec.highlight).map(normKey);
|
|
459
684
|
const isHl = (f) => hl && featureIndexHas(f, hl);
|
|
460
685
|
const N = m.values.size;
|
|
@@ -466,8 +691,9 @@ export function choropleth(spec, theme) {
|
|
|
466
691
|
const base = has ? sc.color(v) : null;
|
|
467
692
|
const isDashed = m.dashed.has(f);
|
|
468
693
|
const name = nameOf(f, locale);
|
|
469
|
-
const tipTxt = (has ? `${name}\n${fmt(v)} ·
|
|
470
|
-
const d = pathD(f.geometry, proj);
|
|
694
|
+
const tipTxt = (has ? `${name}\n${fmt(v)}${m.categorical ? '' : ' · ' + rankText(m.rank.get(f), N, locale)}` : `${name}\n${T(locale, 'Sin dato', 'No data')}`) + (isDashed ? `\n${dashedLabel}` : '');
|
|
695
|
+
const d = pathD(f.geometry, proj, { r: ptR });
|
|
696
|
+
const isLine = isLineGeom(f.geometry);
|
|
471
697
|
const common = { class: 'pa-m', d, strokeLinejoin: 'round', opacity: hl && !isHl(f) ? 0.5 : null, 'data-tip': tipTxt, 'data-k': normKey(f.properties?.code ?? f.properties?.iso3 ?? name) };
|
|
472
698
|
let fill = has ? base : spec.missing === 'plain' ? theme.tint : `url(#${gid}-nd)`;
|
|
473
699
|
let stroke = null;
|
|
@@ -479,39 +705,45 @@ export function choropleth(spec, theme) {
|
|
|
479
705
|
} else if (isDashed) {
|
|
480
706
|
stroke = theme.muted;
|
|
481
707
|
dashedRegions.push(el('path', { ...common, fill, stroke, strokeWidth: 1.1, strokeDasharray: DASH.outline, 'data-dashed': '1', 'data-color': fill }));
|
|
482
|
-
} else regions.push(el('path', { ...common, fill, stroke: theme.
|
|
708
|
+
} else if (isLine) regions.push(el('path', { ...common, fill: 'none', stroke: has ? base : theme.muted, strokeWidth: spec.lineWidth ?? 2.5, strokeLinecap: 'round', 'data-color': has ? base : fill }));
|
|
709
|
+
else regions.push(el('path', { ...common, fill, stroke: isPointGeom(f.geometry) ? theme.surface : borderC, strokeWidth: isPointGeom(f.geometry) ? 1.2 : borderW, 'data-color': fill }));
|
|
483
710
|
if (isHl(f)) outlines.push(el('path', { d, fill: 'none', stroke: theme.ink, strokeWidth: 1.5, strokeLinejoin: 'round', 'pointer-events': 'none' }));
|
|
484
711
|
const c = centroid(f.geometry, proj);
|
|
485
712
|
items.push({ f, name, v, cx: c.x, cy: c.y, area: c.area, label: has ? fmt(v) : '—', color: has ? base : theme.tint, fill, stroke, dashedShape: isDashed, missing: !has, tip: tipTxt });
|
|
486
713
|
}
|
|
487
|
-
const layer = [g({ class: 'pa-regions' }, regions), dashedRegions.length ? g({ class: 'pa-regions pa-dashed' }, dashedRegions) : '', g({ class: 'pa-outlines' }, outlines), insetFrames(m.features, proj, theme)
|
|
714
|
+
const layer = [...layers.filter((L) => L.below).map((L) => drawLayer(L, proj, theme, locale)), g({ class: 'pa-regions' }, regions), dashedRegions.length ? g({ class: 'pa-regions pa-dashed' }, dashedRegions) : '', g({ class: 'pa-outlines' }, outlines), insetFrames(m.features, proj, theme),
|
|
715
|
+
...layers.filter((L) => !L.below).map((L) => drawLayer(L, proj, theme, locale))];
|
|
716
|
+
const placed = [];
|
|
717
|
+
if (markers) layer.push(...drawMarkers(markers, proj, theme, fmt, placed));
|
|
488
718
|
const after = [];
|
|
489
719
|
if (tall && spec.labels !== false) {
|
|
490
720
|
const x = proj.bounds.x + proj.bounds.w + 26;
|
|
491
721
|
const maxV = Math.max(...[...m.values.values()].map(Math.abs));
|
|
492
|
-
after.push(...labelColumn(items, x, area.y, area.y + area.h, area.x + area.w - x, theme, fmt, { bars: spec.bars !== false, maxV }));
|
|
722
|
+
after.push(...labelColumn(items, x, area.y, area.y + area.h, area.x + area.w - x, theme, fmt, { bars: spec.bars !== false && !m.categorical, maxV }));
|
|
493
723
|
} else if (spec.labels !== false && m.features.some((f) => f.properties?.abbr) && !Array.isArray(spec.labels) && typeof spec.labels !== 'number') {
|
|
494
724
|
const [lab, ...rest] = abbrLabels(items, proj, theme, { calloutX, showValues: spec.values !== false, top: area.y, bottom: area.y + area.h });
|
|
495
725
|
layer.push(lab);
|
|
496
726
|
after.push(...rest);
|
|
497
727
|
} else {
|
|
498
728
|
const chosen = new Set(chooseLabeled(m, spec, locale, m.features.length <= 30 ? m.features.length : 0));
|
|
499
|
-
const ordered = items.filter((it) => chosen.has(it.f) && !it.missing).sort((a, b) => b.area - a.area)
|
|
500
|
-
|
|
729
|
+
const ordered = items.filter((it) => chosen.has(it.f) && !it.missing).sort((a, b) => b.area - a.area)
|
|
730
|
+
.map((it) => (isPointGeom(it.f.geometry) ? { ...it, cy: it.cy - ptR - 12 } : it));
|
|
731
|
+
layer.push(g({ class: 'pa-zoom-hide' }, mapLabels(ordered, theme, placed)));
|
|
501
732
|
}
|
|
502
733
|
body.push(...(tall ? layer : zoomLayer(B, layer)), ...after);
|
|
503
|
-
const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map'));
|
|
734
|
+
const desc = describeMap(m, fmt, locale, T(locale, 'Mapa coroplético', 'Choropleth map')) + describeExtras(markers, layers, locale);
|
|
504
735
|
return frame.finish(body, { desc, type: 'choropleth', defs: noDataPattern(`${gid}-nd`, theme) + H.defs() });
|
|
505
736
|
}
|
|
506
737
|
|
|
507
738
|
/** Mapa de símbolos: círculos de área proporcional en el centroide. */
|
|
508
739
|
export function symbolMap(spec, theme) {
|
|
509
740
|
const B = baseChart(spec, theme, 'symbol');
|
|
510
|
-
const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax } = B;
|
|
741
|
+
const { locale, m, fmt, frame, area, body, proj, tall, gid, H, dashedLabel, rMax, layers, markers } = B;
|
|
511
742
|
const N = m.values.size;
|
|
512
743
|
const maxV = Math.max(...[...m.values.values()].map(Math.abs), 0) || 1;
|
|
513
744
|
const color = (spec.colors && Object.values(spec.colors)[0]) || theme.categorical[0];
|
|
514
|
-
const layer = [g({ class: 'pa-regions pa-base' }, m.features.map((f) => el('path', { d: pathD(f.geometry, proj), fill: theme.tint, stroke: theme.surface, strokeWidth: 0.75, strokeLinejoin: 'round', 'data-k': normKey(f.properties?.code ?? f.properties?.iso3 ?? nameOf(f, locale)) }))), insetFrames(m.features, proj, theme)
|
|
745
|
+
const layer = [...layers.filter((L) => L.below).map((L) => drawLayer(L, proj, theme, locale)), g({ class: 'pa-regions pa-base' }, m.features.map((f) => el('path', { d: pathD(f.geometry, proj, { r: 2.5 }), fill: isLineGeom(f.geometry) ? 'none' : theme.tint, stroke: isLineGeom(f.geometry) ? theme.axis : spec.borderColor ?? theme.surface, strokeWidth: isLineGeom(f.geometry) ? 1.5 : spec.borderWidth ?? 0.75, strokeLinejoin: 'round', 'data-k': normKey(f.properties?.code ?? f.properties?.iso3 ?? nameOf(f, locale)) }))), insetFrames(m.features, proj, theme),
|
|
746
|
+
...layers.filter((L) => !L.below).map((L) => drawLayer(L, proj, theme, locale))];
|
|
515
747
|
const items = m.features.map((f) => {
|
|
516
748
|
const c = centroid(f.geometry, proj);
|
|
517
749
|
const v = m.values.get(f);
|
|
@@ -525,6 +757,7 @@ export function symbolMap(spec, theme) {
|
|
|
525
757
|
.map((it) => el('circle', { class: 'pa-m', cx: it.cx, cy: it.cy, r: Math.max(1.5, it.r), 'data-tip': it.tip, 'data-color': color,
|
|
526
758
|
...(it.dashedShape ? { ...dashedAttrs(H, color, { width: 1.3 }) } : { fill: color, fillOpacity: 0.75, stroke: theme.surface, strokeWidth: 1.5 }) }));
|
|
527
759
|
layer.push(g({ class: 'pa-symbols' }, circles));
|
|
760
|
+
if (markers) layer.push(...drawMarkers(markers, proj, theme, fmt, []));
|
|
528
761
|
// Leyenda de tamaños (tres círculos anidados) en la esquina libre.
|
|
529
762
|
const sizes = [maxV, maxV / 4, maxV / 16].map((v) => ({ v, r: Math.sqrt(v / maxV) * rMax }));
|
|
530
763
|
const lx = tall ? proj.bounds.x + proj.bounds.w + 26 : area.x + 4, ly = tall ? area.y + area.h - 4 : area.y + area.h - 4;
|
|
@@ -543,12 +776,29 @@ export function symbolMap(spec, theme) {
|
|
|
543
776
|
body.push(...zoomLayer(B, layer));
|
|
544
777
|
}
|
|
545
778
|
if (tall) body.push(g({ class: 'pa-legend pa-size-legend' }, leg));
|
|
546
|
-
const desc = describeMap(m, fmt, locale, T(locale, 'Mapa de símbolos', 'Proportional symbol map'));
|
|
779
|
+
const desc = describeMap(m, fmt, locale, T(locale, 'Mapa de símbolos', 'Proportional symbol map')) + describeExtras(markers, layers, locale);
|
|
547
780
|
return frame.finish(body, { desc, type: 'symbolmap', defs: H.defs() });
|
|
548
781
|
}
|
|
549
782
|
|
|
783
|
+
function describeExtras(markers, layers, locale) {
|
|
784
|
+
const en = String(locale).startsWith('en');
|
|
785
|
+
let s = '';
|
|
786
|
+
if (markers?.items.length) s += en ? ` ${markers.items.length} markers${markers.legend ? ` (${markers.legend})` : ''}.` : ` ${markers.items.length} marcadores${markers.legend ? ` (${markers.legend})` : ''}.`;
|
|
787
|
+
const named = layers.filter((L) => L.label).map((L) => L.label);
|
|
788
|
+
if (named.length) s += en ? ` Layers: ${named.join(', ')}.` : ` Capas: ${named.join(', ')}.`;
|
|
789
|
+
return s;
|
|
790
|
+
}
|
|
791
|
+
|
|
550
792
|
function describeMap(m, fmt, locale, what) {
|
|
551
793
|
const en = String(locale).startsWith('en');
|
|
794
|
+
if (m.categorical) {
|
|
795
|
+
const cnt = new Map();
|
|
796
|
+
for (const v of m.values.values()) cnt.set(v, (cnt.get(v) || 0) + 1);
|
|
797
|
+
const parts = m.categories.map((c) => `${c} (${cnt.get(c)})`).join(', ');
|
|
798
|
+
let s = en ? `${what} of ${humanize(m.valueF)} across ${m.values.size} areas: ${parts}.` : `${what} de ${humanize(m.valueF).toLowerCase()} en ${m.values.size} territorios: ${parts}.`;
|
|
799
|
+
if (m.unmatched.length) s += en ? ` Unmatched: ${m.unmatched.slice(0, 8).join(', ')}.` : ` Sin coincidencia: ${m.unmatched.slice(0, 8).join(', ')}.`;
|
|
800
|
+
return s;
|
|
801
|
+
}
|
|
552
802
|
if (!m.ranked.length) return `${what}.`;
|
|
553
803
|
const [hiF, hiV] = m.ranked[0], [loF, loV] = m.ranked[m.ranked.length - 1];
|
|
554
804
|
let s = en
|