plotagents 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +98 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/plotagents.js +73 -0
- package/dist/plotagents-geo.js +79 -0
- package/dist/plotagents.js +11611 -0
- package/docs/api.md +172 -0
- package/docs/charts.md +352 -0
- package/docs/colorscales.md +46 -0
- package/docs/dashboards.md +79 -0
- package/docs/getting-started.md +142 -0
- package/docs/rendering.md +47 -0
- package/docs/themes.md +116 -0
- package/index.d.ts +384 -0
- package/package.json +80 -4
- package/schema.json +1874 -0
- package/src/axes.js +152 -0
- package/src/charts/bar.js +350 -0
- package/src/charts/categorical.js +495 -0
- package/src/charts/chord.js +218 -0
- package/src/charts/choropleth.js +807 -0
- package/src/charts/dist.js +530 -0
- package/src/charts/donut.js +232 -0
- package/src/charts/flowchart.js +407 -0
- package/src/charts/gantt.js +460 -0
- package/src/charts/heatmap.js +76 -0
- package/src/charts/kpi.js +100 -0
- package/src/charts/line.js +551 -0
- package/src/charts/network.js +707 -0
- package/src/charts/pointmap.js +599 -0
- package/src/charts/pyramid.js +177 -0
- package/src/charts/radar.js +158 -0
- package/src/charts/regplot.js +370 -0
- package/src/charts/sankey.js +169 -0
- package/src/charts/scatter.js +214 -0
- package/src/charts/sunburst.js +136 -0
- package/src/charts/treemap.js +197 -0
- package/src/charts/wordcloud.js +163 -0
- package/src/colorscales.js +182 -0
- package/src/context.js +10 -0
- package/src/dashboard.js +438 -0
- package/src/dashed.js +90 -0
- package/src/data.js +137 -0
- package/src/facet.js +84 -0
- package/src/format.js +203 -0
- package/src/frame.js +226 -0
- package/src/geo/chile.js +7 -0
- package/src/geo/data/chile-regiones.js +12 -0
- package/src/geo/data/usa-estados.js +12 -0
- package/src/geo/data/world.js +12 -0
- package/src/geo/projection.js +217 -0
- package/src/geo/registry.js +110 -0
- package/src/geo/usa.js +8 -0
- package/src/geo/world.js +7 -0
- package/src/hierarchy.js +139 -0
- package/src/index.js +336 -0
- package/src/infer.js +58 -0
- package/src/interact.js +483 -0
- package/src/lint.js +417 -0
- package/src/palette.js +59 -0
- package/src/runtime.js +416 -0
- package/src/scale.js +241 -0
- package/src/schema.js +337 -0
- package/src/stats.js +303 -0
- package/src/svg.js +85 -0
- package/src/text.js +61 -0
- package/src/theme.js +311 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// Mapa de puntos sobre un mapa base real: teselas de OpenStreetMap (por
|
|
2
|
+
// defecto), Esri, CARTO o Mapbox, en Web Mercator, con puntos lon/lat encima.
|
|
3
|
+
// Las teselas se apagan con un filtro SVG (casi sin saturación y teñidas con
|
|
4
|
+
// los colores del tema) para que el mapa acompañe y los datos manden. Debajo
|
|
5
|
+
// va un respaldo vectorial (el mundo registrado, si lo hay) para que el mapa
|
|
6
|
+
// se lea aunque las teselas no carguen. En el navegador: zoom con clic, Ctrl/⌘
|
|
7
|
+
// + rueda o pellizco (la misma estructura .pa-zoom de los coropléticos).
|
|
8
|
+
//
|
|
9
|
+
// Ojo: un SVG usado como <img> no carga recursos externos (las teselas). Usa el
|
|
10
|
+
// SVG en línea, toHTML(), o embedTiles() para incrustarlas como data: URI.
|
|
11
|
+
import { el, g, text, tip, dotLayer, esc } from '../svg.js';
|
|
12
|
+
import { measure } from '../text.js';
|
|
13
|
+
import { makeFormat, formatNumber } from '../format.js';
|
|
14
|
+
import { colorMap, ramp, hexToRgb, rgbToHex } from '../theme.js';
|
|
15
|
+
import { ensureData, requireField, num, fieldsOf, fieldKind, humanize, PlotError } from '../data.js';
|
|
16
|
+
import { createFrame, hash } from '../frame.js';
|
|
17
|
+
import { getGeo, registeredGeo } from '../geo/registry.js';
|
|
18
|
+
import { pathD, rings } from '../geo/projection.js';
|
|
19
|
+
|
|
20
|
+
const T = (locale, es, en) => (String(locale || 'es').startsWith('en') ? en : es);
|
|
21
|
+
const RAD = Math.PI / 180;
|
|
22
|
+
const MAX_LAT = 85.0511287798;
|
|
23
|
+
const TILE = 256;
|
|
24
|
+
const MAX_TILES = 48;
|
|
25
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
26
|
+
|
|
27
|
+
// ── Web Mercator (EPSG:3857) en coordenadas normalizadas [0, 1] ─────────────
|
|
28
|
+
/** Longitud → x de Web Mercator en [0, 1] (0 = 180° O). */
|
|
29
|
+
export const mercX = (lon) => (lon + 180) / 360;
|
|
30
|
+
/** Latitud → y de Web Mercator en [0, 1] (0 = 85,05° N, y hacia abajo). */
|
|
31
|
+
export function mercY(lat) {
|
|
32
|
+
const s = Math.sin(clamp(lat, -MAX_LAT, MAX_LAT) * RAD);
|
|
33
|
+
return 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI);
|
|
34
|
+
}
|
|
35
|
+
const lonOf = (x) => x * 360 - 180;
|
|
36
|
+
const latOf = (y) => Math.atan(Math.sinh(Math.PI * (1 - 2 * y))) / RAD;
|
|
37
|
+
|
|
38
|
+
// ── Proveedores de teselas ──────────────────────────────────────────────────
|
|
39
|
+
const OSM_ATTR = '© OpenStreetMap contributors';
|
|
40
|
+
/**
|
|
41
|
+
* Mapas base con nombre. `dark` dice si las teselas ya vienen oscuras (el
|
|
42
|
+
* filtro "muted" las invierte solo cuando no calzan con el tema). `{r}` es el
|
|
43
|
+
* sufijo @2x de las teselas retina; `{key}`, la clave o token.
|
|
44
|
+
*/
|
|
45
|
+
export const TILE_PROVIDERS = {
|
|
46
|
+
osm: { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: OSM_ATTR, link: 'https://www.openstreetmap.org/copyright', maxZoom: 19 },
|
|
47
|
+
'esri-gray': { name: 'Esri Light Gray Canvas', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', attribution: 'Esri, HERE, Garmin, © OpenStreetMap contributors', link: 'https://www.esri.com/en-us/legal/terms/full-master-agreement', maxZoom: 16, keyParam: 'token' },
|
|
48
|
+
'esri-dark': { name: 'Esri Dark Gray Canvas', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', attribution: 'Esri, HERE, Garmin, © OpenStreetMap contributors', link: 'https://www.esri.com/en-us/legal/terms/full-master-agreement', maxZoom: 16, dark: true, keyParam: 'token' },
|
|
49
|
+
// CARTO pide una clave gratuita (carto.com/basemaps/apikey): sin ella, las teselas traen una marca de agua.
|
|
50
|
+
'carto-light': { name: 'CARTO Positron', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', subdomains: 'abcd', retina: true, attribution: '© OpenStreetMap contributors © CARTO', link: 'https://carto.com/attributions', maxZoom: 20, needsKey: true, keyParam: 'key', keyHelp: 'carto.com/basemaps/apikey' },
|
|
51
|
+
'carto-dark': { name: 'CARTO Dark Matter', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', subdomains: 'abcd', retina: true, attribution: '© OpenStreetMap contributors © CARTO', link: 'https://carto.com/attributions', maxZoom: 20, dark: true, needsKey: true, keyParam: 'key', keyHelp: 'carto.com/basemaps/apikey' },
|
|
52
|
+
'mapbox-light': { name: 'Mapbox Light', url: 'https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token={key}', attribution: '© Mapbox © OpenStreetMap', link: 'https://www.mapbox.com/about/maps/', maxZoom: 22, needsKey: true, keyHelp: 'account.mapbox.com' },
|
|
53
|
+
'mapbox-dark': { name: 'Mapbox Dark', url: 'https://api.mapbox.com/styles/v1/mapbox/dark-v11/tiles/256/{z}/{x}/{y}@2x?access_token={key}', attribution: '© Mapbox © OpenStreetMap', link: 'https://www.mapbox.com/about/maps/', maxZoom: 22, needsKey: true, dark: true, keyHelp: 'account.mapbox.com' },
|
|
54
|
+
};
|
|
55
|
+
// Atajos: el claro o el oscuro según el tema.
|
|
56
|
+
const FAMILY = { esri: 'esri', carto: 'carto', mapbox: 'mapbox' };
|
|
57
|
+
const ALIASES = { openstreetmap: 'osm', 'esri-light': 'esri-gray', 'esri-grey': 'esri-gray', positron: 'carto-light', 'dark-matter': 'carto-dark', darkmatter: 'carto-dark' };
|
|
58
|
+
|
|
59
|
+
const safeUrl = (u) => typeof u === 'string' && (/^(https?:)?\/\//i.test(u.trim()) || /^\.{0,2}\/(?!\/)/.test(u.trim()));
|
|
60
|
+
|
|
61
|
+
/** `tiles` del spec → proveedor resuelto ({ url, attribution, link, … }) o null. */
|
|
62
|
+
export function tileProvider(spec, theme = {}) {
|
|
63
|
+
const locale = spec.locale || 'es';
|
|
64
|
+
const t = spec.tiles === undefined || spec.tiles === null || spec.tiles === true ? 'osm' : spec.tiles;
|
|
65
|
+
if (t === false || t === 'none') return null;
|
|
66
|
+
const key = spec.tilesKey ?? (typeof t === 'object' ? t.key : undefined);
|
|
67
|
+
if (key !== undefined && key !== null && String(key).trim().startsWith('sk.')) {
|
|
68
|
+
throw new PlotError(
|
|
69
|
+
T(locale, '`tilesKey` es un token secreto de Mapbox (empieza con "sk."): quedaría a la vista de cualquiera en el SVG.', '`tilesKey` is a secret Mapbox token (it starts with "sk."): anyone could read it in the SVG.'),
|
|
70
|
+
T(locale, 'Crea un token público (empieza con "pk.") en account.mapbox.com, restringido a tu dominio.', 'Create a public token (it starts with "pk.") at account.mapbox.com, restricted to your domain.'),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
let p;
|
|
74
|
+
if (typeof t === 'string') {
|
|
75
|
+
const name = t.trim().toLowerCase();
|
|
76
|
+
const id = FAMILY[name] ? `${name}-${theme.mode === 'dark' ? 'dark' : name === 'esri' ? 'gray' : 'light'}` : ALIASES[name] || name;
|
|
77
|
+
p = TILE_PROVIDERS[id];
|
|
78
|
+
if (!p) {
|
|
79
|
+
throw new PlotError(
|
|
80
|
+
T(locale, `No conozco el mapa base "${t}".`, `Unknown basemap "${t}".`),
|
|
81
|
+
T(locale, `Usa uno de ${Object.keys(TILE_PROVIDERS).map((k) => `"${k}"`).join(', ')}, un objeto { url, attribution } o false.`, `Use one of ${Object.keys(TILE_PROVIDERS).map((k) => `"${k}"`).join(', ')}, an object { url, attribution } or false.`),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
p = { id, ...p };
|
|
85
|
+
} else if (typeof t === 'object') {
|
|
86
|
+
if (!safeUrl(t.url) || !/\{z\}/.test(t.url) || !/\{x\}/.test(t.url) || !/\{-?y\}/.test(t.url)) {
|
|
87
|
+
throw new PlotError(
|
|
88
|
+
T(locale, '`tiles.url` debe ser una URL http(s) con {z}, {x} e {y}.', '`tiles.url` must be an http(s) URL with {z}, {x} and {y}.'),
|
|
89
|
+
'tiles: { url: "https://tile.example.com/{z}/{x}/{y}.png", attribution: "© OpenStreetMap contributors" }',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
p = { id: 'custom', name: t.name, url: t.url.trim(), attribution: t.attribution, link: t.link ?? t.attributionUrl, subdomains: t.subdomains ?? 'abc', maxZoom: t.maxZoom ?? 19, minZoom: t.minZoom ?? 0, retina: !!t.retina, dark: !!t.dark };
|
|
93
|
+
if (!p.attribution) { try { p.attribution = new URL(p.url.replace(/\{[^}]*\}/g, '0'), 'https://x.invalid/').host.replace(/^x\.invalid$/, ''); } catch { p.attribution = ''; } }
|
|
94
|
+
} else {
|
|
95
|
+
throw new PlotError(T(locale, '`tiles` debe ser un nombre, un objeto { url, attribution } o false.', '`tiles` must be a name, an object { url, attribution } or false.'));
|
|
96
|
+
}
|
|
97
|
+
if (p.needsKey && !key) {
|
|
98
|
+
const mb = /^mapbox/.test(p.id);
|
|
99
|
+
throw new PlotError(
|
|
100
|
+
T(locale, `"${p.id}" necesita ${mb ? 'un token público de Mapbox' : 'una clave'} en \`tilesKey\`.`, `"${p.id}" needs ${mb ? 'a public Mapbox token' : 'a key'} in \`tilesKey\`.`),
|
|
101
|
+
T(locale, `tilesKey: "${mb ? 'pk.…' : '…'}" (gratis en ${p.keyHelp}), o usa tiles: "osm" o "esri-gray", que no piden clave.`, `tilesKey: "${mb ? 'pk.…' : '…'}" (free at ${p.keyHelp}), or use tiles: "osm" or "esri-gray", which need no key.`),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (p.link && !/^https?:\/\//i.test(p.link)) p.link = null;
|
|
105
|
+
return { ...p, key: key ?? null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** URL de una tesela: {z} {x} {y} {-y} {s} {r} {key}. */
|
|
109
|
+
export function tileUrl(p, z, x, y) {
|
|
110
|
+
const n = 2 ** z;
|
|
111
|
+
const subs = Array.isArray(p.subdomains) ? p.subdomains : String(p.subdomains || 'abc').split('');
|
|
112
|
+
let u = p.url
|
|
113
|
+
.replace(/\{z\}/g, z).replace(/\{x\}/g, x).replace(/\{y\}/g, y).replace(/\{-y\}/g, n - 1 - y)
|
|
114
|
+
.replace(/\{s\}/g, subs[Math.abs(x + y) % subs.length] || '')
|
|
115
|
+
.replace(/\{r\}/g, p.retina ? '@2x' : '')
|
|
116
|
+
.replace(/\{key\}/g, encodeURIComponent(p.key ?? ''));
|
|
117
|
+
if (p.key && p.keyParam && !/\{key\}/.test(p.url)) u += (u.includes('?') ? '&' : '?') + p.keyParam + '=' + encodeURIComponent(p.key);
|
|
118
|
+
return u;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Color ───────────────────────────────────────────────────────────────────
|
|
122
|
+
const mix = (a, b, t) => { const A = hexToRgb(a), B = hexToRgb(b); return rgbToHex(A.map((v, i) => v + (B[i] - v) * t)); };
|
|
123
|
+
const isHex = (c) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(c || ''));
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Filtro "muted": casi sin saturación y llevado a dos tonos del tema. Los
|
|
127
|
+
* mapas claros (OSM, Positron, Light Gray) se invierten en el tema oscuro y
|
|
128
|
+
* los oscuros en el claro; el fondo del mapa (tierra) cae cerca de la
|
|
129
|
+
* superficie y el texto de las teselas, en un gris tinta suave.
|
|
130
|
+
*/
|
|
131
|
+
function tileFilter(id, theme, provider, box) {
|
|
132
|
+
const dark = theme.mode === 'dark';
|
|
133
|
+
const surface = isHex(theme.surface) ? theme.surface : dark ? '#0B0F2B' : '#FCFBF9';
|
|
134
|
+
const ink = isHex(theme.ink) ? theme.ink : dark ? '#F2F3FA' : '#141A3F';
|
|
135
|
+
const tint = isHex(theme.tint) ? theme.tint : surface;
|
|
136
|
+
// base: lo que toma el fondo de la tesela; mark: lo que toma su trazo más intenso.
|
|
137
|
+
const base = dark ? mix(surface, tint, 0.75) : mix(surface, tint, 0.18);
|
|
138
|
+
const mark = dark ? mix(surface, ink, 0.5) : mix(surface, ink, 0.62);
|
|
139
|
+
const lightTiles = !provider.dark;
|
|
140
|
+
let f;
|
|
141
|
+
if (dark && lightTiles) {
|
|
142
|
+
// Teselas claras en tema oscuro: se invierten con una tabla (no lineal) para
|
|
143
|
+
// que el agua quede más oscura que la tierra, como en un mapa nocturno.
|
|
144
|
+
// entrada: 0 (texto) … 0,8 (agua, bosques) … 0,94 (tierra) … 1 (calles)
|
|
145
|
+
const water = mix(surface, '#000000', 0.12);
|
|
146
|
+
const T9 = [0.56, 0.53, 0.49, 0.45, 0.39, 0.27, 0.02, 0.05, 0.12];
|
|
147
|
+
const stops = T9.map((t) => hexToRgb(t <= 0.02 ? water : mix(mix(surface, tint, 0.5), ink, t - 0.02)).map((v) => v / 255));
|
|
148
|
+
f = (i) => `type="table" tableValues="${stops.map((c) => +c[i].toFixed(4)).join(' ')}"`;
|
|
149
|
+
} else {
|
|
150
|
+
// Lineal: teselas claras, blanco → base y negro → mark; oscuras, al revés.
|
|
151
|
+
const [c0, c1] = lightTiles ? [mark, base] : [base, mark];
|
|
152
|
+
const A = hexToRgb(c0).map((v) => v / 255), B = hexToRgb(c1).map((v) => v / 255);
|
|
153
|
+
f = (i) => `type="linear" slope="${+(B[i] - A[i]).toFixed(4)}" intercept="${+A[i].toFixed(4)}"`;
|
|
154
|
+
}
|
|
155
|
+
// filterUnits y tableValues van escritos a mano: el() pasaría el camelCase a guiones.
|
|
156
|
+
const open = el('filter', { id, x: box.x, y: box.y, width: box.w, height: box.h, colorInterpolationFilters: 'sRGB' }, 'X').replace(/>X<\/filter>$/, '');
|
|
157
|
+
return `${open} filterUnits="userSpaceOnUse">`
|
|
158
|
+
+ el('feColorMatrix', { type: 'saturate', values: dark ? 0.1 : 0.14 })
|
|
159
|
+
+ `<feComponentTransfer><feFuncR ${f(0)}/><feFuncG ${f(1)}/><feFuncB ${f(2)}/></feComponentTransfer>`
|
|
160
|
+
+ '</filter>';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Respaldo vectorial (mundo registrado) ───────────────────────────────────
|
|
164
|
+
const bboxCache = new WeakMap();
|
|
165
|
+
function lonLatBox(f) {
|
|
166
|
+
let b = bboxCache.get(f);
|
|
167
|
+
if (b) return b;
|
|
168
|
+
b = [Infinity, Infinity, -Infinity, -Infinity];
|
|
169
|
+
for (const r of rings(f.geometry)) for (const p of r) { if (p[0] < b[0]) b[0] = p[0]; if (p[0] > b[2]) b[2] = p[0]; if (p[1] < b[1]) b[1] = p[1]; if (p[1] > b[3]) b[3] = p[1]; }
|
|
170
|
+
bboxCache.set(f, b);
|
|
171
|
+
return b;
|
|
172
|
+
}
|
|
173
|
+
function worldGeo() {
|
|
174
|
+
try { return registeredGeo().includes('world') ? getGeo('world') : null; } catch { return null; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Etiquetas ───────────────────────────────────────────────────────────────
|
|
178
|
+
const hits = (box, boxes) => boxes.some((b) => !(box[2] < b[0] || box[0] > b[2] || box[3] < b[1] || box[1] > b[3]));
|
|
179
|
+
|
|
180
|
+
/** Valor "redondo" para la leyenda de tamaños (1, 2, 2,5 o 5 × 10ⁿ, hacia abajo). */
|
|
181
|
+
function niceDown(v) {
|
|
182
|
+
if (!(v > 0)) return v;
|
|
183
|
+
const m = 10 ** Math.floor(Math.log10(v));
|
|
184
|
+
return [10, 5, 2.5, 2, 1].map((k) => k * m).find((x) => x <= v * 1.0001) || m;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const deg = (v, pos, neg, locale) => `${formatNumber(Math.abs(v), 1, locale, true)}° ${v < 0 ? neg : pos}`;
|
|
188
|
+
|
|
189
|
+
/** Mapa de puntos (lon/lat) sobre teselas reales. */
|
|
190
|
+
export function pointmap(spec, theme) {
|
|
191
|
+
const locale = spec.locale || 'es';
|
|
192
|
+
const en = String(locale).startsWith('en');
|
|
193
|
+
const dark = theme.mode === 'dark';
|
|
194
|
+
const data = ensureData(spec);
|
|
195
|
+
const fields = fieldsOf(data);
|
|
196
|
+
const find = (re) => fields.find((f) => re.test(f));
|
|
197
|
+
const named = (v) => (typeof v === 'string' && fields.includes(v) ? v : null);
|
|
198
|
+
const lonF = spec.lon ?? spec.lng ?? spec.longitude ?? find(/^(lon|lng|long|longitude|longitud)$/i) ?? named(spec.x);
|
|
199
|
+
const latF = spec.lat ?? spec.latitude ?? find(/^(lat|latitude|latitud)$/i) ?? named(spec.y);
|
|
200
|
+
if (!lonF || !latF) {
|
|
201
|
+
throw new PlotError(
|
|
202
|
+
T(locale, 'Un mapa de puntos necesita longitud y latitud.', 'A point map needs longitude and latitude.'),
|
|
203
|
+
T(locale, `Nombra los campos "lon" y "lat" (o "lng", "longitude", "latitud") o indícalos con lon: "…", lat: "…". Campos: ${fields.map((f) => `"${f}"`).join(', ') || '(ninguno)'}.`, `Name the fields "lon" and "lat" (or "lng", "longitude", "latitude") or pass lon: "…", lat: "…". Fields: ${fields.map((f) => `"${f}"`).join(', ') || '(none)'}.`),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
requireField(data, lonF, 'lon');
|
|
207
|
+
requireField(data, latF, 'lat');
|
|
208
|
+
const sizeF = named(spec.size);
|
|
209
|
+
if (spec.size !== undefined && typeof spec.size === 'string') requireField(data, spec.size, 'size');
|
|
210
|
+
let hueF = spec.hue ?? spec.series ?? named(spec.color);
|
|
211
|
+
let valueF = spec.value ?? null;
|
|
212
|
+
if (hueF) requireField(data, hueF, 'hue');
|
|
213
|
+
if (valueF) requireField(data, valueF, 'value');
|
|
214
|
+
// Un `hue` numérico se lee como rampa; un `value` de texto, como categorías.
|
|
215
|
+
const numeric = (f) => ['number', 'year'].includes(fieldKind(data, f));
|
|
216
|
+
if (hueF && !valueF && numeric(hueF) && spec.scale !== 'categorical') { valueF = hueF; hueF = null; }
|
|
217
|
+
if (valueF && !hueF && !numeric(valueF)) { hueF = valueF; valueF = null; }
|
|
218
|
+
const used = new Set([lonF, latF, sizeF, hueF, valueF]);
|
|
219
|
+
const labelF = spec.label ?? find(/^(label|name|nombre|city|ciudad|title|titulo|título|place|lugar|comuna|station|estacion|estación)$/i) ?? fields.find((f) => !used.has(f) && fieldKind(data, f) === 'category');
|
|
220
|
+
if (spec.label) requireField(data, spec.label, 'label');
|
|
221
|
+
|
|
222
|
+
// Puntos válidos; longitudes llevadas a [-180, 180) y "desenvueltas" si cruzan el antimeridiano.
|
|
223
|
+
const pts = [];
|
|
224
|
+
for (const r of data) {
|
|
225
|
+
const lon = num(r[lonF]), lat = num(r[latF]);
|
|
226
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lat) > 90) continue;
|
|
227
|
+
pts.push({ r, lon: ((((lon + 180) % 360) + 360) % 360) - 180, lat, s: sizeF ? num(r[sizeF]) : NaN, v: valueF ? num(r[valueF]) : NaN, k: hueF ? String(r[hueF] ?? '') : '', name: labelF && r[labelF] !== undefined && r[labelF] !== null ? String(r[labelF]) : '' });
|
|
228
|
+
}
|
|
229
|
+
if (!pts.length) {
|
|
230
|
+
throw new PlotError(
|
|
231
|
+
T(locale, `No hay filas con "${lonF}" y "${latF}" numéricos (lat entre −90 y 90).`, `No rows with numeric "${lonF}" and "${latF}" (lat between −90 and 90).`),
|
|
232
|
+
T(locale, '¿Están invertidas? La latitud de Santiago es −33,4 y su longitud, −70,6.', 'Swapped? Santiago\'s latitude is −33.4 and its longitude −70.6.'),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
let unwrapped = false;
|
|
236
|
+
{
|
|
237
|
+
const lons = pts.map((p) => p.lon);
|
|
238
|
+
const span = Math.max(...lons) - Math.min(...lons);
|
|
239
|
+
if (span > 180) {
|
|
240
|
+
const alt = lons.map((l) => (l < 0 ? l + 360 : l));
|
|
241
|
+
if (Math.max(...alt) - Math.min(...alt) < span) { for (const p of pts) if (p.lon < 0) p.lon += 360; unwrapped = true; }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const p of pts) { p.mx = mercX(p.lon); p.my = mercY(p.lat); }
|
|
245
|
+
const n = pts.length;
|
|
246
|
+
const dense = n > 1500;
|
|
247
|
+
const provider = tileProvider(spec, theme);
|
|
248
|
+
const muted = (spec.tileStyle ?? 'muted') !== 'original';
|
|
249
|
+
|
|
250
|
+
// Formatos: `format` va al tamaño si no hay `value`; `sizeFormat` / `valueFormat` los separan.
|
|
251
|
+
const sizes = pts.map((p) => p.s).filter(Number.isFinite);
|
|
252
|
+
const values = pts.map((p) => p.v).filter(Number.isFinite);
|
|
253
|
+
const fs = makeFormat(spec.sizeFormat ?? (valueF ? undefined : spec.format), locale, sizes);
|
|
254
|
+
const fv = makeFormat(spec.valueFormat ?? spec.format, locale, values);
|
|
255
|
+
|
|
256
|
+
// Color: por categoría (hue), por valor (rampa) o uno solo.
|
|
257
|
+
const hl = spec.highlight === undefined || spec.highlight === null ? null : new Set([].concat(spec.highlight).map(String));
|
|
258
|
+
const focus = (p) => !hl || hl.has(p.name) || hl.has(p.k);
|
|
259
|
+
let legend = [];
|
|
260
|
+
let colorOf;
|
|
261
|
+
let keys = [];
|
|
262
|
+
if (hueF) {
|
|
263
|
+
const count = new Map();
|
|
264
|
+
for (const p of pts) count.set(p.k, (count.get(p.k) || 0) + 1);
|
|
265
|
+
keys = Array.isArray(spec.order) ? spec.order.map(String).filter((k) => count.has(k)).concat([...count.keys()].filter((k) => !spec.order.map(String).includes(k))) : [...count.keys()].sort((a, b) => count.get(b) - count.get(a));
|
|
266
|
+
const cmap = colorMap(keys, theme, spec.colors);
|
|
267
|
+
colorOf = (p) => cmap.get(p.k) || theme.other;
|
|
268
|
+
if (keys.length > 1) legend = keys.map((k) => ({ label: k, color: cmap.get(k) }));
|
|
269
|
+
} else if (valueF && values.length) {
|
|
270
|
+
const vmin = Math.min(...values), vmax = Math.max(...values);
|
|
271
|
+
const mid = spec.midpoint ?? (spec.diverging ? 0 : undefined);
|
|
272
|
+
let stops, at;
|
|
273
|
+
if (mid !== undefined) {
|
|
274
|
+
const dev = Math.max(Math.abs(vmax - mid), Math.abs(vmin - mid)) || 1;
|
|
275
|
+
stops = theme.diverging;
|
|
276
|
+
at = (v) => 0.5 + (v - mid) / dev / 2;
|
|
277
|
+
legend = [{ type: 'ramp', stops, min: fv(mid - dev), max: fv(mid + dev), title: spec.legendTitle ?? humanize(valueF) }];
|
|
278
|
+
} else {
|
|
279
|
+
// La rampa de fábrica pierde sus tonos más claros (u oscuros, en el tema oscuro), que se
|
|
280
|
+
// perderían sobre el mapa; una escala pedida (colorScale/cmap/ramp) se usa completa.
|
|
281
|
+
const own = theme.cmap || spec.ramp || spec.colorScale || spec.cmap || spec.colormap;
|
|
282
|
+
stops = own ? theme.sequential.slice(1) : theme.sequential.slice(dark ? 3 : 2);
|
|
283
|
+
at = (v) => (v - vmin) / (vmax - vmin || 1);
|
|
284
|
+
legend = [{ type: 'ramp', stops, min: fv(vmin), max: fv(vmax), title: spec.legendTitle ?? humanize(valueF) }];
|
|
285
|
+
}
|
|
286
|
+
colorOf = (p) => (Number.isFinite(p.v) ? ramp(stops, at(p.v)) : theme.other);
|
|
287
|
+
} else {
|
|
288
|
+
const one = (typeof spec.color === 'string' && !named(spec.color) && spec.color) || (spec.colors && typeof spec.colors === 'string' && spec.colors) || theme.categorical[0];
|
|
289
|
+
colorOf = () => one;
|
|
290
|
+
}
|
|
291
|
+
if (spec.legend === false) legend = [];
|
|
292
|
+
|
|
293
|
+
const W = spec.width || 720;
|
|
294
|
+
const frame = createFrame(spec, theme, { height: Math.round(W * 0.8) });
|
|
295
|
+
let area = frame.layout(legend);
|
|
296
|
+
|
|
297
|
+
// Radios: área proporcional a `size` (raíz cuadrada), o uno fijo según la densidad.
|
|
298
|
+
const maxS = Math.max(0, ...sizes.map(Math.abs));
|
|
299
|
+
const wk = Math.max(0.6, Math.min(1.2, area.w / 672));
|
|
300
|
+
const rMax = sizeF && maxS ? spec.maxRadius ?? Math.round((n <= 40 ? 22 : n <= 150 ? 15 : n <= 600 ? 10 : 6) * wk) : 0;
|
|
301
|
+
const rBase = spec.radius ?? (dense ? 2.2 : n > 500 ? 3 : n > 120 ? 4 : 5.5);
|
|
302
|
+
const rOf = (p) => (rMax ? (Number.isFinite(p.s) ? Math.max(dense ? 1.5 : 2.5, Math.sqrt(Math.abs(p.s) / maxS) * rMax) : 2) : rBase);
|
|
303
|
+
const rTop = rMax || rBase;
|
|
304
|
+
|
|
305
|
+
// Extensión de los datos en Mercator.
|
|
306
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
307
|
+
for (const p of pts) { if (p.mx < x0) x0 = p.mx; if (p.mx > x1) x1 = p.mx; if (p.my < y0) y0 = p.my; if (p.my > y1) y1 = p.my; }
|
|
308
|
+
const spanX = x1 - x0, spanY = y1 - y0;
|
|
309
|
+
const tiny = 1e-9;
|
|
310
|
+
// Alto del mapa según la forma de los datos (si el agente no fijó `height`).
|
|
311
|
+
if (spec.height === undefined) {
|
|
312
|
+
const aspect = spanX > tiny || spanY > tiny ? spanY / Math.max(spanX, tiny) : 0.62;
|
|
313
|
+
const ratio = clamp(aspect, W < 560 ? 0.8 : 0.56, W < 560 ? 1.15 : 0.92);
|
|
314
|
+
frame.setHeight(area.y + area.w * ratio + frame.chrome);
|
|
315
|
+
area = { ...area, h: frame.bottom - area.y };
|
|
316
|
+
}
|
|
317
|
+
const box = { x: area.x, y: area.y, w: area.w, h: area.h };
|
|
318
|
+
|
|
319
|
+
// Zoom (fraccionario) que encuadra los datos con margen para círculos y etiquetas.
|
|
320
|
+
const padX = Math.max(26, rTop + 12), padT = Math.max(30, rTop + 20), padB = Math.max(26, rTop + 12) + (rMax ? 0 : 6);
|
|
321
|
+
const availW = Math.max(40, box.w - 2 * padX), availH = Math.max(40, box.h - padT - padB);
|
|
322
|
+
const maxZ = provider ? provider.maxZoom ?? 19 : 19;
|
|
323
|
+
let z;
|
|
324
|
+
if (Number.isFinite(+spec.zoom) && spec.zoom !== null && spec.zoom !== '') z = clamp(+spec.zoom, 0, 22);
|
|
325
|
+
else if (spanX < tiny && spanY < tiny) z = 12;
|
|
326
|
+
else z = Math.min(17, maxZ, Math.log2(Math.min(spanX > tiny ? availW / (spanX * TILE) : Infinity, spanY > tiny ? availH / (spanY * TILE) : Infinity)));
|
|
327
|
+
z = Math.max(z, Math.log2(box.h / TILE), 0); // el mundo llena el alto: nunca hay franjas vacías arriba o abajo
|
|
328
|
+
const k = TILE * 2 ** z;
|
|
329
|
+
// Centro: el de los datos (o `center`), en el centro del área útil; sin salirse del mundo en vertical.
|
|
330
|
+
let mcx, mcy;
|
|
331
|
+
if (Array.isArray(spec.center) && spec.center.length >= 2 && spec.center.every((v) => Number.isFinite(+v))) {
|
|
332
|
+
let lon = +spec.center[0];
|
|
333
|
+
if (unwrapped && lon < 0) lon += 360;
|
|
334
|
+
mcx = mercX(lon); mcy = mercY(+spec.center[1]);
|
|
335
|
+
} else { mcx = (x0 + x1) / 2; mcy = (y0 + y1) / 2; }
|
|
336
|
+
const cxp = box.x + box.w / 2;
|
|
337
|
+
let cyp = box.y + padT + availH / 2;
|
|
338
|
+
{
|
|
339
|
+
const top = mcy - (cyp - box.y) / k, bottom = mcy + (box.y + box.h - cyp) / k;
|
|
340
|
+
if (top < 0) mcy -= top; else if (bottom > 1) mcy -= bottom - 1;
|
|
341
|
+
}
|
|
342
|
+
const P = (mx, my) => [cxp + (mx - mcx) * k, cyp + (my - mcy) * k];
|
|
343
|
+
const proj = ([lon, lat]) => P(mercX(lon), mercY(lat));
|
|
344
|
+
for (const p of pts) [p.x, p.y] = P(p.mx, p.my);
|
|
345
|
+
const view = { mx0: mcx - (cxp - box.x) / k, mx1: mcx + (box.x + box.w - cxp) / k, my0: mcy - (cyp - box.y) / k, my1: mcy + (box.y + box.h - cyp) / k };
|
|
346
|
+
|
|
347
|
+
const gid = 'pa-pm-' + Math.abs(hash(String(spec.title) + 'pointmap' + W + n)).toString(36);
|
|
348
|
+
const defs = [];
|
|
349
|
+
const layer = [];
|
|
350
|
+
// 1) Agua (tinte del tema) y respaldo vectorial: se ve si las teselas no cargan.
|
|
351
|
+
layer.push(el('rect', { x: box.x, y: box.y, width: box.w, height: box.h, fill: theme.tint }));
|
|
352
|
+
const world = worldGeo();
|
|
353
|
+
if (world) {
|
|
354
|
+
const land = dark ? mix(theme.tint, theme.tint2 || theme.tint, 0.6) : theme.surface;
|
|
355
|
+
const offsets = [0, ...(view.mx0 < 0 ? [-1] : []), ...(view.mx1 > 1 ? [1] : [])];
|
|
356
|
+
const lon0 = lonOf(view.mx0), lon1 = lonOf(view.mx1), latT = latOf(clamp(view.my0, 0, 1)), latB = latOf(clamp(view.my1, 0, 1));
|
|
357
|
+
const copies = offsets.map((o) => {
|
|
358
|
+
const paths = [];
|
|
359
|
+
for (const f of world.features) {
|
|
360
|
+
if (!f || !f.geometry) continue;
|
|
361
|
+
const b = lonLatBox(f);
|
|
362
|
+
if (b[2] + 360 * o < lon0 || b[0] + 360 * o > lon1 || b[3] < latB || b[1] > latT) continue;
|
|
363
|
+
const d = pathD(f.geometry, proj);
|
|
364
|
+
if (d) paths.push(el('path', { d, fill: land, stroke: theme.axis, strokeWidth: 0.6, strokeLinejoin: 'round' }));
|
|
365
|
+
}
|
|
366
|
+
return paths.length ? g(o ? { transform: `translate(${Math.round(o * k * 100) / 100},0)` } : {}, paths) : '';
|
|
367
|
+
});
|
|
368
|
+
layer.push(g({ class: 'pa-basemap', 'pointer-events': 'none' }, copies));
|
|
369
|
+
}
|
|
370
|
+
// 2) Teselas.
|
|
371
|
+
let tileCount = 0;
|
|
372
|
+
if (provider) {
|
|
373
|
+
let zt = clamp(Math.round(z), provider.minZoom ?? 0, maxZ);
|
|
374
|
+
let grid;
|
|
375
|
+
for (;;) {
|
|
376
|
+
const N = 2 ** zt;
|
|
377
|
+
const tx0 = Math.floor(view.mx0 * N), tx1 = Math.floor(view.mx1 * N - tiny);
|
|
378
|
+
const ty0 = Math.max(0, Math.floor(view.my0 * N)), ty1 = Math.min(N - 1, Math.floor(view.my1 * N - tiny));
|
|
379
|
+
grid = { N, tx0, tx1, ty0, ty1 };
|
|
380
|
+
if ((tx1 - tx0 + 1) * (ty1 - ty0 + 1) <= MAX_TILES || zt <= 0) break;
|
|
381
|
+
zt--;
|
|
382
|
+
}
|
|
383
|
+
const { N, tx0, tx1, ty0, ty1 } = grid;
|
|
384
|
+
const ts = k / N;
|
|
385
|
+
const imgs = [];
|
|
386
|
+
for (let ty = ty0; ty <= ty1; ty++) for (let tx = tx0; tx <= tx1; tx++) {
|
|
387
|
+
const [x, y] = P(tx / N, ty / N);
|
|
388
|
+
const wx = ((tx % N) + N) % N;
|
|
389
|
+
// +0,5 px: sin costuras entre teselas al escalar.
|
|
390
|
+
imgs.push(el('image', { class: 'pa-tile', href: tileUrl(provider, zt, wx, ty), x, y, width: ts + 0.5, height: ts + 0.5, preserveAspectRatio: 'none' }));
|
|
391
|
+
}
|
|
392
|
+
tileCount = imgs.length;
|
|
393
|
+
if (muted) defs.push(tileFilter(`${gid}-tf`, theme, provider, box));
|
|
394
|
+
const op = spec.tileOpacity !== undefined && Number.isFinite(+spec.tileOpacity) ? clamp(+spec.tileOpacity, 0, 1) : null;
|
|
395
|
+
layer.push(g({ class: 'pa-tiles', 'pointer-events': 'none', filter: muted ? `url(#${gid}-tf)` : null, opacity: op }, imgs));
|
|
396
|
+
// Velo del color de fondo: en el tema oscuro funde las teselas con la superficie.
|
|
397
|
+
if (muted && dark) layer.push(el('rect', { x: box.x, y: box.y, width: box.w, height: box.h, fill: theme.surface, opacity: 0.16, 'pointer-events': 'none' }));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 3) Puntos: grandes abajo, chicos encima; los fuera de foco, al fondo.
|
|
401
|
+
const ring = theme.surface;
|
|
402
|
+
const sorted = [...pts].sort((a, b) => focus(a) - focus(b) || rOf(b) - rOf(a));
|
|
403
|
+
const circles = sorted.map((p) => {
|
|
404
|
+
const c = focus(p) ? colorOf(p) : theme.dim;
|
|
405
|
+
const lines = [p.name || `${deg(p.lat, 'N', 'S', locale)}, ${deg(p.lon > 180 ? p.lon - 360 : p.lon, 'E', en ? 'W' : 'O', locale)}`];
|
|
406
|
+
if (hueF) lines.push(`${humanize(hueF)}: ${p.k}`);
|
|
407
|
+
if (sizeF && Number.isFinite(p.s)) lines.push(`${humanize(sizeF)}: ${fs(p.s)}`);
|
|
408
|
+
if (valueF && Number.isFinite(p.v)) lines.push(`${humanize(valueF)}: ${fv(p.v)}`);
|
|
409
|
+
return { class: 'pa-m', cx: p.x, cy: p.y, r: rOf(p), fill: c, fillOpacity: !focus(p) ? 0.75 : dense ? 0.6 : rMax ? 0.82 : 0.92, stroke: dense ? 'none' : ring, strokeWidth: dense ? null : rMax ? 1.25 : 1.5, 'data-tip': tip(...lines), 'data-color': c };
|
|
410
|
+
});
|
|
411
|
+
layer.push(g({ class: 'pa-points' }, dotLayer(circles)));
|
|
412
|
+
|
|
413
|
+
// 4) Leyenda de tamaños (círculos anidados) en una tarjeta abajo a la izquierda.
|
|
414
|
+
const obstacles = [];
|
|
415
|
+
const overlay = [];
|
|
416
|
+
const fsz = 10.5;
|
|
417
|
+
if (rMax && spec.legend !== false) {
|
|
418
|
+
const vs = [...new Set([niceDown(maxS), niceDown(maxS / 4), niceDown(maxS / 16)])].filter((v) => v > 0);
|
|
419
|
+
const items = vs.map((v) => ({ v, r: Math.max(2.5, Math.sqrt(v / maxS) * rMax), s: fs(v) }));
|
|
420
|
+
const title = humanize(sizeF);
|
|
421
|
+
const labW = Math.max(...items.map((it) => measure(it.s, fsz))) * 1.06;
|
|
422
|
+
// Rótulos separados al menos 11 px (de abajo hacia arriba), con línea guía.
|
|
423
|
+
const gap = 11.5;
|
|
424
|
+
const rel = [...items].sort((a, b) => a.r - b.r).map((it) => ({ ...it, top: 2 * it.r, ly: 2 * it.r }));
|
|
425
|
+
for (let i = 1; i < rel.length; i++) rel[i].ly = Math.max(rel[i].ly, rel[i - 1].ly + gap);
|
|
426
|
+
const above = Math.max(2 * rMax, ...rel.map((it) => it.ly + 4));
|
|
427
|
+
const cw = Math.max(16 + 2 * rMax + 16 + labW, measure(title, fsz, 600) * 1.06 + 16);
|
|
428
|
+
const ch = above + 32;
|
|
429
|
+
const lx = box.x + 8, ly = box.y + box.h - ch - 8;
|
|
430
|
+
const cx = lx + 8 + rMax, base = ly + ch - 8;
|
|
431
|
+
const parts = [el('rect', { x: lx, y: ly, width: cw, height: ch, rx: 6, fill: theme.surface, fillOpacity: 0.88 }), text(lx + 8, ly + 15, title, { fill: theme.lede, fontSize: fsz, fontWeight: 600 })];
|
|
432
|
+
for (const it of rel) parts.push(el('circle', { cx, cy: base - it.r, r: it.r, fill: 'none', stroke: theme.muted, strokeWidth: 0.8 }));
|
|
433
|
+
for (const it of rel) {
|
|
434
|
+
const yT = base - it.top, yL = base - it.ly;
|
|
435
|
+
parts.push(el('path', { d: `M${cx},${yT}H${cx + rMax + 3}L${cx + rMax + 10},${yL}`, fill: 'none', stroke: theme.muted, strokeWidth: 0.5 }));
|
|
436
|
+
parts.push(text(cx + rMax + 13, yL + 3.6, it.s, { fill: theme.lede, fontSize: fsz }));
|
|
437
|
+
}
|
|
438
|
+
overlay.push(g({ class: 'pa-legend pa-size-legend', 'pointer-events': 'none' }, parts));
|
|
439
|
+
obstacles.push([lx, ly, lx + cw, ly + ch]);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 5) Atribución: siempre, abajo a la derecha, con enlace a la licencia.
|
|
443
|
+
const attrText = provider ? provider.attribution : world ? 'Natural Earth' : '';
|
|
444
|
+
if (attrText) {
|
|
445
|
+
const af = 9.5;
|
|
446
|
+
// Ancho con holgura (la fuente real puede ser más ancha que la medida); el texto se ancla a la derecha.
|
|
447
|
+
const aw = measure(attrText, af) * 1.08 + 12;
|
|
448
|
+
const ax = box.x + box.w - aw - 4, ay = box.y + box.h - 19;
|
|
449
|
+
const inner = [el('rect', { x: ax, y: ay, width: aw, height: 15, rx: 3, fill: theme.surface, fillOpacity: 0.82 }), text(ax + aw - 6, ay + 11, attrText, { fill: dark ? theme.muted : theme.lede, fontSize: af, textAnchor: 'end' })];
|
|
450
|
+
const link = provider ? provider.link : 'https://www.naturalearthdata.com/';
|
|
451
|
+
overlay.push(g({ class: 'pa-attribution' }, link ? el('a', { href: link, target: '_blank', rel: 'noopener' }, inner) : inner));
|
|
452
|
+
obstacles.push([ax, ay, ax + aw, ay + 15]);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 6) Etiquetas: las mayores (o las pedidas), con halo y sin choques.
|
|
456
|
+
const labs = [];
|
|
457
|
+
if (labelF && spec.labels !== false) {
|
|
458
|
+
let chosen;
|
|
459
|
+
if (Array.isArray(spec.labels)) { const want = new Set(spec.labels.map(String)); chosen = pts.filter((p) => want.has(p.name)); }
|
|
460
|
+
else {
|
|
461
|
+
const def = dense ? 0 : rMax ? Math.min(8, n) : valueF ? Math.min(5, n) : n <= 15 ? n : 0;
|
|
462
|
+
const nLab = typeof spec.labels === 'number' ? spec.labels : hl ? 0 : def;
|
|
463
|
+
const rank = rMax ? (p) => Math.abs(p.s) || 0 : valueF ? (p) => (Number.isFinite(p.v) ? p.v : -Infinity) : () => 0;
|
|
464
|
+
chosen = [...pts].sort((a, b) => rank(b) - rank(a)).slice(0, nLab);
|
|
465
|
+
if (hl) chosen = [...new Set([...pts.filter((p) => hl.has(p.name) || hl.has(p.k)), ...chosen])];
|
|
466
|
+
}
|
|
467
|
+
const showVal = spec.labelValues !== false && (rMax || valueF);
|
|
468
|
+
const placed = [...obstacles];
|
|
469
|
+
if (n <= 400) for (const p of pts) { const r = rOf(p); if (r >= 3.5 || chosen.includes(p)) placed.push([p.x - r * 0.7, p.y - r * 0.7, p.x + r * 0.7, p.y + r * 0.7]); }
|
|
470
|
+
const halo = { stroke: theme.surface, strokeWidth: 3, strokeOpacity: 0.92, paintOrder: 'stroke', strokeLinejoin: 'round' };
|
|
471
|
+
const fl = 11.5;
|
|
472
|
+
for (const p of chosen) {
|
|
473
|
+
if (!p.name) continue;
|
|
474
|
+
const r = rOf(p);
|
|
475
|
+
const val = showVal ? (rMax ? (Number.isFinite(p.s) ? fs(p.s) : '') : Number.isFinite(p.v) ? fv(p.v) : '') : '';
|
|
476
|
+
const w = measure(p.name, fl, 600) + (val ? measure(' ' + val, fl - 1) : 0);
|
|
477
|
+
// Derecha, izquierda, arriba o abajo del punto; dentro del mapa y sin pisar otra etiqueta.
|
|
478
|
+
const cands = [
|
|
479
|
+
{ box: [p.x + r + 3, p.y - 7, p.x + r + 5 + w, p.y + 7], x: p.x + r + 4, y: p.y + 4, anchor: 'start' },
|
|
480
|
+
{ box: [p.x - r - 5 - w, p.y - 7, p.x - r - 3, p.y + 7], x: p.x - r - 4, y: p.y + 4, anchor: 'end' },
|
|
481
|
+
{ box: [p.x - w / 2 - 2, p.y - r - 18, p.x + w / 2 + 2, p.y - r - 3], x: p.x, y: p.y - r - 6, anchor: 'middle' },
|
|
482
|
+
{ box: [p.x - w / 2 - 2, p.y + r + 3, p.x + w / 2 + 2, p.y + r + 18], x: p.x, y: p.y + r + 14, anchor: 'middle' },
|
|
483
|
+
];
|
|
484
|
+
const own = (b) => b[0] >= p.x - r * 0.7 - 0.01 && b[2] <= p.x + r * 0.7 + 0.01 && b[1] >= p.y - r * 0.7 - 0.01 && b[3] <= p.y + r * 0.7 + 0.01;
|
|
485
|
+
const others = placed.filter((b) => !own(b));
|
|
486
|
+
const inside = (b) => b[0] >= box.x + 2 && b[2] <= box.x + box.w - 2 && b[1] >= box.y + 2 && b[3] <= box.y + box.h - 2;
|
|
487
|
+
const c = cands.find((cd) => inside(cd.box) && !hits(cd.box, others));
|
|
488
|
+
if (!c) continue;
|
|
489
|
+
placed.push(c.box);
|
|
490
|
+
const kids = [esc(p.name)];
|
|
491
|
+
if (val) kids.push(el('tspan', { fill: theme.lede, fontWeight: 400, fontSize: fl - 1 }, esc(' ' + val)));
|
|
492
|
+
labs.push(el('text', { x: c.x, y: c.y, fill: theme.ink, fontSize: fl, fontWeight: 600, textAnchor: c.anchor, ...halo }, kids));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (labs.length) layer.push(g({ class: 'pa-map-label pa-zoom-hide', 'pointer-events': 'none' }, labs));
|
|
496
|
+
|
|
497
|
+
// Capa con recorte y zoom (la misma estructura que choropleth: interact.js la reconoce).
|
|
498
|
+
const zb = [box.x, box.y, box.w, box.h].map((v) => Math.round(v * 10) / 10);
|
|
499
|
+
defs.push(el('clipPath', { id: `${gid}-clip` }, el('rect', { x: zb[0], y: zb[1], width: zb[2], height: zb[3], rx: 6 })));
|
|
500
|
+
const body = [
|
|
501
|
+
g({ class: 'pa-zoom-clip pa-pointmap', 'clip-path': `url(#${gid}-clip)` }, [
|
|
502
|
+
el('rect', { x: zb[0], y: zb[1], width: zb[2], height: zb[3], fill: 'transparent' }),
|
|
503
|
+
g({ class: 'pa-zoom', 'data-zoom': zb.join(',') }, layer),
|
|
504
|
+
]),
|
|
505
|
+
...overlay,
|
|
506
|
+
];
|
|
507
|
+
|
|
508
|
+
const desc = describePointmap({ pts, n, provider, world, hueF, keys, sizeF, valueF, fs, fv, locale, tileCount });
|
|
509
|
+
return frame.finish(body, { desc, type: 'pointmap', defs: defs.join('') });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function describePointmap({ pts, n, provider, world, hueF, keys, sizeF, valueF, fs, fv, locale, tileCount }) {
|
|
513
|
+
const en = String(locale).startsWith('en');
|
|
514
|
+
const W = en ? 'W' : 'O';
|
|
515
|
+
const lats = pts.map((p) => p.lat), lons = pts.map((p) => (p.lon > 180 ? p.lon - 360 : p.lon));
|
|
516
|
+
const la0 = Math.min(...lats), la1 = Math.max(...lats);
|
|
517
|
+
const lo0 = Math.min(...lons), lo1 = Math.max(...lons);
|
|
518
|
+
const base = provider ? provider.name || provider.attribution : world ? (en ? 'a world outline' : 'un contorno del mundo') : '';
|
|
519
|
+
const where = n === 1 || (la1 - la0 < 0.05 && lo1 - lo0 < 0.05)
|
|
520
|
+
? (en ? ` at ${deg(la0, 'N', 'S', locale)}, ${deg(lo0, 'E', W, locale)}` : ` en ${deg(la0, 'N', 'S', locale)}, ${deg(lo0, 'E', W, locale)}`)
|
|
521
|
+
: en
|
|
522
|
+
? ` between latitudes ${deg(la0, 'N', 'S', locale)} and ${deg(la1, 'N', 'S', locale)} and longitudes ${deg(lo0, 'E', W, locale)} and ${deg(lo1, 'E', W, locale)}`
|
|
523
|
+
: ` entre las latitudes ${deg(la0, 'N', 'S', locale)} y ${deg(la1, 'N', 'S', locale)} y las longitudes ${deg(lo0, 'E', W, locale)} y ${deg(lo1, 'E', W, locale)}`;
|
|
524
|
+
let s = en ? `Map of ${n} point${n === 1 ? '' : 's'}${where}` : `Mapa de ${n} punto${n === 1 ? '' : 's'}${where}`;
|
|
525
|
+
s += base ? (en ? `, over ${base}${tileCount ? ' tiles' : ''}.` : `, sobre ${tileCount ? 'teselas de ' : ''}${base}.`) : '.';
|
|
526
|
+
if (sizeF) {
|
|
527
|
+
const top = [...pts].filter((p) => Number.isFinite(p.s)).sort((a, b) => b.s - a.s).slice(0, 3);
|
|
528
|
+
if (top.length) s += en ? ` Largest ${humanize(sizeF)}: ${top.map((p) => `${p.name || '—'} (${fs(p.s)})`).join(', ')}.` : ` Mayor ${humanize(sizeF).toLowerCase()}: ${top.map((p) => `${p.name || '—'} (${fs(p.s)})`).join(', ')}.`;
|
|
529
|
+
}
|
|
530
|
+
if (hueF && keys.length) {
|
|
531
|
+
const c = new Map();
|
|
532
|
+
for (const p of pts) c.set(p.k, (c.get(p.k) || 0) + 1);
|
|
533
|
+
s += ` ${humanize(hueF)}: ${keys.slice(0, 8).map((k) => `${k} (${c.get(k)})`).join(', ')}.`;
|
|
534
|
+
}
|
|
535
|
+
if (valueF) {
|
|
536
|
+
const vs = pts.filter((p) => Number.isFinite(p.v)).sort((a, b) => b.v - a.v);
|
|
537
|
+
if (vs.length) s += en ? ` ${humanize(valueF)} from ${fv(vs.at(-1).v)} (${vs.at(-1).name || '—'}) to ${fv(vs[0].v)} (${vs[0].name || '—'}).` : ` ${humanize(valueF)} de ${fv(vs.at(-1).v)} (${vs.at(-1).name || '—'}) a ${fv(vs[0].v)} (${vs[0].name || '—'}).`;
|
|
538
|
+
}
|
|
539
|
+
return s;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── Incrustar teselas (para PDF, <img>, correo) ─────────────────────────────
|
|
543
|
+
const unesc = (s) => s.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
544
|
+
const hostOf = (u) => { try { return new URL(u, 'https://x.invalid/').host.toLowerCase(); } catch { return ''; } };
|
|
545
|
+
/** Las teselas de OpenStreetMap no se descargan en lote (política de uso de tile.openstreetmap.org). */
|
|
546
|
+
export const refusesPrefetch = (u) => /(^|\.)openstreetmap\.(org|fr|de)$/.test(hostOf(u));
|
|
547
|
+
|
|
548
|
+
function toBase64(buf) {
|
|
549
|
+
const bytes = new Uint8Array(buf);
|
|
550
|
+
if (typeof globalThis.Buffer === 'function') return globalThis.Buffer.from(bytes).toString('base64');
|
|
551
|
+
let bin = '';
|
|
552
|
+
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
553
|
+
return globalThis.btoa(bin);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Reemplaza el href de cada tesela por un data: URI, para que el SVG se vea
|
|
558
|
+
* igual como <img>, en un PDF o sin conexión. Las teselas de OpenStreetMap se
|
|
559
|
+
* dejan tal cual: su política prohíbe la descarga en lote (usa "esri-gray",
|
|
560
|
+
* "carto-light" o un servidor propio si necesitas incrustar).
|
|
561
|
+
* Devuelve el SVG (texto). Las teselas que fallan quedan con su URL.
|
|
562
|
+
*/
|
|
563
|
+
export async function embedTiles(svg, { fetch: fetchFn = globalThis.fetch, userAgent, concurrency = 6 } = {}) {
|
|
564
|
+
const src = String(svg ?? '');
|
|
565
|
+
if (typeof fetchFn !== 'function') throw new PlotError('embedTiles necesita fetch (Node 18+ o un navegador), o pásalo en { fetch }.', 'await embedTiles(svg, { fetch })');
|
|
566
|
+
const tagRe = /<image\b[^>]*>/g;
|
|
567
|
+
const hrefRe = /\shref="([^"]*)"/;
|
|
568
|
+
const urls = new Set();
|
|
569
|
+
for (const tag of src.match(tagRe) || []) {
|
|
570
|
+
if (!/class="[^"]*\bpa-tile\b/.test(tag)) continue;
|
|
571
|
+
const m = hrefRe.exec(tag);
|
|
572
|
+
if (!m) continue;
|
|
573
|
+
const u = unesc(m[1]);
|
|
574
|
+
if (/^data:/i.test(u) || refusesPrefetch(u)) continue;
|
|
575
|
+
urls.add(u);
|
|
576
|
+
}
|
|
577
|
+
const out = new Map();
|
|
578
|
+
const list = [...urls];
|
|
579
|
+
let i = 0;
|
|
580
|
+
const headers = userAgent ? { 'User-Agent': userAgent } : undefined;
|
|
581
|
+
const worker = async () => {
|
|
582
|
+
while (i < list.length) {
|
|
583
|
+
const u = list[i++];
|
|
584
|
+
try {
|
|
585
|
+
const res = await fetchFn(u, headers ? { headers } : undefined);
|
|
586
|
+
if (!res || !res.ok) continue;
|
|
587
|
+
const type = (res.headers && typeof res.headers.get === 'function' && res.headers.get('content-type')) || (/\.jpe?g(\?|$)/i.test(u) ? 'image/jpeg' : /\.webp(\?|$)/i.test(u) ? 'image/webp' : 'image/png');
|
|
588
|
+
const buf = await res.arrayBuffer();
|
|
589
|
+
out.set(u, `data:${String(type).split(';')[0].trim()};base64,${toBase64(buf)}`);
|
|
590
|
+
} catch { /* la tesela queda con su URL */ }
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, list.length)) }, worker));
|
|
594
|
+
if (!out.size) return src;
|
|
595
|
+
return src.replace(tagRe, (tag) => {
|
|
596
|
+
if (!/class="[^"]*\bpa-tile\b/.test(tag)) return tag;
|
|
597
|
+
return tag.replace(hrefRe, (all, h) => { const d = out.get(unesc(h)); return d ? ` href="${d}"` : all; });
|
|
598
|
+
});
|
|
599
|
+
}
|