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