You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
362 lines
11 KiB
362 lines
11 KiB
/**
|
|
* Genera `src/lib/mapa/iberoamerica.ts` a partir del mapa de Wikimedia.
|
|
*
|
|
* node scripts/mapa/generar.mjs [ruta al svg]
|
|
*
|
|
* El original es `BlankMap-Americas.svg`, de Lokal_Profil, en dominio público:
|
|
* https://commons.wikimedia.org/wiki/File:BlankMap-Americas.svg
|
|
*
|
|
* De sus cincuenta y cinco países se conservan los veinte de Iberoamérica en
|
|
* América, y de cada uno todos sus trazados unidos en uno. Dos recortes bajan
|
|
* el archivo de 640 KB a algo que puede viajar en una página:
|
|
*
|
|
* - **Las coordenadas, a un decimal.** El mapa mide dos mil setecientas
|
|
* unidades de ancho y se dibuja a setecientos píxeles: la décima de unidad no
|
|
* llega ni a la décima de píxel.
|
|
* - **Fuera los islotes.** Chile trae ciento cincuenta y cinco trazados, casi
|
|
* todos rocas del sur que a este tamaño ocupan menos de un píxel. Se
|
|
* descartan los que no llegan al umbral; las islas que sí se ven —Galápagos,
|
|
* Chiloé, las Malvinas— lo pasan de sobra.
|
|
*
|
|
* Se deja como script y no como un archivo pegado a mano para que se pueda
|
|
* rehacer: el día que haga falta añadir un país o cambiar el umbral, esto es
|
|
* una línea, y a mano sería volver a empezar.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
|
|
const ORIGEN = process.argv[2] ?? 'scripts/mapa/BlankMap-World.svg';
|
|
const DESTINO = 'src/lib/mapa/iberoamerica.ts';
|
|
|
|
/**
|
|
* Los veintidós de Iberoamérica: los veinte de América más España y Portugal.
|
|
*
|
|
* Estaban fuera en la primera versión, hecha sobre un mapa de las Américas.
|
|
* Iberoamérica es también la Península —y España está en los datos: el bolero
|
|
* se adoptó ahí—, así que el origen pasa a ser el mapa del mundo.
|
|
*/
|
|
const PAISES = [
|
|
'es',
|
|
'pt',
|
|
'mx',
|
|
'gt',
|
|
'sv',
|
|
'hn',
|
|
'ni',
|
|
'cr',
|
|
'pa',
|
|
'cu',
|
|
'do',
|
|
'pr',
|
|
've',
|
|
'co',
|
|
'ec',
|
|
'pe',
|
|
'bo',
|
|
'py',
|
|
'cl',
|
|
'ar',
|
|
'uy',
|
|
'br'
|
|
];
|
|
|
|
/** Un trazado más pequeño que esto no se ve, y sí se nota en el peso. */
|
|
const MINIMO = 2;
|
|
|
|
/**
|
|
* El nombre en español de cada uno.
|
|
*
|
|
* Va aquí y no se lee de la tabla `pais`: el mapa dibuja los veintidós desde el
|
|
* primer día y la tabla solo tiene los países que ha traído alguna semilla. Un
|
|
* país sin nombre en la lista de al lado no se puede ni pulsar.
|
|
*/
|
|
const NOMBRES = {
|
|
es: 'España',
|
|
pt: 'Portugal',
|
|
mx: 'México',
|
|
gt: 'Guatemala',
|
|
sv: 'El Salvador',
|
|
hn: 'Honduras',
|
|
ni: 'Nicaragua',
|
|
cr: 'Costa Rica',
|
|
pa: 'Panamá',
|
|
cu: 'Cuba',
|
|
do: 'República Dominicana',
|
|
pr: 'Puerto Rico',
|
|
ve: 'Venezuela',
|
|
co: 'Colombia',
|
|
ec: 'Ecuador',
|
|
pe: 'Perú',
|
|
bo: 'Bolivia',
|
|
py: 'Paraguay',
|
|
cl: 'Chile',
|
|
ar: 'Argentina',
|
|
uy: 'Uruguay',
|
|
br: 'Brasil'
|
|
};
|
|
|
|
const svg = fs.readFileSync(ORIGEN, 'utf8');
|
|
|
|
/*
|
|
* Se recorren las etiquetas con expresión regular y no con un analizador de
|
|
* XML: hace falta saber en qué grupo va cada trazado, y para eso basta con
|
|
* llevar la cuenta de los `<g>` abiertos.
|
|
*/
|
|
const trozos = Object.fromEntries(PAISES.map((c) => [c, []]));
|
|
const pila = [];
|
|
|
|
for (const [, cierre, etiqueta, atributos, suelta] of svg.matchAll(
|
|
/<(\/?)(g|path)\b([^>]*?)(\/?)>/g
|
|
)) {
|
|
if (etiqueta === 'g') {
|
|
if (cierre) pila.pop();
|
|
else pila.push(atributos.match(/(?:^|\s)id="([^"]+)"/)?.[1] ?? null);
|
|
continue;
|
|
}
|
|
if (cierre) continue;
|
|
|
|
const propio = atributos.match(/(?:^|\s)id="([^"]+)"/)?.[1] ?? null;
|
|
const codigo = [propio, ...[...pila].reverse()].find((c) => c && PAISES.includes(c));
|
|
/*
|
|
* El límite del atributo importa: sin él, `d="…"` encaja dentro de
|
|
* `id="Mexico_mainland"` y el trazado que se extrae es el nombre del
|
|
* país. Salía un mapa vacío sin que nada fallara.
|
|
*/
|
|
const d = atributos.match(/(?:^|\s)d="([^"]+)"/)?.[1];
|
|
if (codigo && d) trozos[codigo].push(d);
|
|
void suelta;
|
|
}
|
|
|
|
/**
|
|
* Recorre un trazado y devuelve sus puntos, ya en absolutas.
|
|
*
|
|
* Hace falta porque el mapa del mundo está escrito en relativas —`m 1244,328 c
|
|
* 0.1,-0.12 …`— y ahí los números no son coordenadas sino desplazamientos:
|
|
* sumarlos como si lo fueran daba un rectángulo sin sentido y el encuadre salía
|
|
* infinito.
|
|
*
|
|
* No convierte el trazado, solo lo mide: el `d` se guarda tal cual, que dibuja
|
|
* igual y ocupa menos.
|
|
*/
|
|
function puntosDe(d) {
|
|
const piezas = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
|
|
const puntos = [];
|
|
|
|
let x = 0;
|
|
let y = 0;
|
|
let inicioX = 0;
|
|
let inicioY = 0;
|
|
let comando = 'M';
|
|
let i = 0;
|
|
|
|
/** Cuántos números lleva cada comando. */
|
|
const cuantos = { M: 2, L: 2, T: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, A: 7, Z: 0 };
|
|
|
|
while (i < piezas.length) {
|
|
if (/[a-zA-Z]/.test(piezas[i])) {
|
|
comando = piezas[i];
|
|
i++;
|
|
if (comando.toUpperCase() === 'Z') {
|
|
x = inicioX;
|
|
y = inicioY;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const letra = comando.toUpperCase();
|
|
const relativo = comando !== letra;
|
|
const n = cuantos[letra];
|
|
if (n === undefined) break;
|
|
|
|
const args = piezas.slice(i, i + n).map(Number);
|
|
i += n;
|
|
if (args.length < n) break;
|
|
|
|
if (letra === 'H') x = relativo ? x + args[0] : args[0];
|
|
else if (letra === 'V') y = relativo ? y + args[0] : args[0];
|
|
else if (letra === 'A') {
|
|
// De la elipse solo interesa dónde acaba.
|
|
x = relativo ? x + args[5] : args[5];
|
|
y = relativo ? y + args[6] : args[6];
|
|
} else {
|
|
// El punto final es la última pareja; los de control quedan fuera del
|
|
// cálculo, y para medir un país la diferencia no se ve.
|
|
x = relativo ? x + args[n - 2] : args[n - 2];
|
|
y = relativo ? y + args[n - 1] : args[n - 1];
|
|
}
|
|
|
|
if (letra === 'M') {
|
|
inicioX = x;
|
|
inicioY = y;
|
|
// Una `m` con más parejas sigue como `l`; una `M`, como `L`.
|
|
comando = relativo ? 'l' : 'L';
|
|
}
|
|
|
|
puntos.push([x, y]);
|
|
}
|
|
|
|
return puntos;
|
|
}
|
|
|
|
/** El rectángulo que ocupa un trazado, para saber si merece la pena. */
|
|
function tamano(d) {
|
|
const puntos = puntosDe(d);
|
|
const xs = puntos.map((p) => p[0]);
|
|
const ys = puntos.map((p) => p[1]);
|
|
if (xs.length === 0) return { ancho: 0, alto: 0, xs, ys };
|
|
return {
|
|
ancho: Math.max(...xs) - Math.min(...xs),
|
|
alto: Math.max(...ys) - Math.min(...ys),
|
|
xs,
|
|
ys
|
|
};
|
|
}
|
|
|
|
const unaDecimal = (d) => d.replace(/-?\d+\.\d+/g, (n) => String(Math.round(Number(n) * 10) / 10));
|
|
|
|
/**
|
|
* Une varios trazados en uno.
|
|
*
|
|
* El primer salto de cada uno pasa a ser absoluto. Dentro de un `<path>` la
|
|
* posición de partida es el origen, así que `m 1244,328` y `M 1244,328` son lo
|
|
* mismo; pero al pegar el segundo detrás del primero, su `m` se sumaba a donde
|
|
* había acabado el anterior y México acababa a cuarenta mil unidades de su
|
|
* sitio. El encuadre salía de cuarenta y dos mil por cincuenta y ocho mil.
|
|
*/
|
|
const unir = (trazados) => trazados.map((d) => d.replace(/^\s*m/, 'M')).join(' ');
|
|
|
|
const contorno = {};
|
|
const centro = {};
|
|
/** El trazado mayor de cada país: es con el que se encuadra el inserto. */
|
|
const principal = {};
|
|
let descartados = 0;
|
|
|
|
/**
|
|
* Países de los que solo se dibuja el trozo mayor.
|
|
*
|
|
* España es la Península más Canarias y Baleares. Canarias cae a mil
|
|
* kilómetros al suroeste, fuera del recuadro; las Baleares son cuatro píxeles
|
|
* sueltos al este. En un mapa que trata de géneros, ninguna de las dos dice
|
|
* nada que no diga ya la Península, y sueltas al borde del encuadre solo
|
|
* ensucian.
|
|
*/
|
|
const SIN_ISLAS = ['es'];
|
|
|
|
for (const codigo of PAISES) {
|
|
const buenos = trozos[codigo].filter((d) => {
|
|
const { ancho, alto } = tamano(d);
|
|
const vale = ancho >= MINIMO || alto >= MINIMO;
|
|
if (!vale) descartados++;
|
|
return vale;
|
|
});
|
|
|
|
/*
|
|
* Un punto donde agarrar el país.
|
|
*
|
|
* Es el promedio de los vértices del trazado más grande, no el centro de su
|
|
* rectángulo: el de Cuba cae en el mar, porque la isla es alargada y curva.
|
|
* Con el promedio de la costa el punto queda dentro, y además da algo que
|
|
* pulsar en Puerto Rico o El Salvador, que a esta escala miden cuatro
|
|
* píxeles.
|
|
*/
|
|
const mayor = buenos.reduce(
|
|
(a, b) => (tamano(b).ancho * tamano(b).alto > tamano(a).ancho * tamano(a).alto ? b : a),
|
|
buenos[0] ?? ''
|
|
);
|
|
const { xs, ys } = tamano(mayor);
|
|
const medio = (ns) => ns.reduce((t, n) => t + n, 0) / ns.length;
|
|
centro[codigo] = [medio(xs), medio(ys)].map((n) => Math.round(n * 10) / 10);
|
|
principal[codigo] = mayor;
|
|
|
|
contorno[codigo] = unaDecimal(unir(SIN_ISLAS.includes(codigo) ? [mayor] : buenos));
|
|
}
|
|
|
|
/**
|
|
* Dos encuadres, no uno.
|
|
*
|
|
* Iberoamérica son dos orillas y en medio hay un océano: con un solo encuadre,
|
|
* la mitad del dibujo es Atlántico vacío y todo lo demás queda diminuto. Se
|
|
* calcula uno para América y otro para la Península, y el mapa pinta el segundo
|
|
* en un recuadro —la misma solución que usa cualquier mapa de España con
|
|
* Canarias—.
|
|
*/
|
|
const IBERIA = ['es', 'pt'];
|
|
const MARGEN = 10;
|
|
|
|
function encuadrar(codigos, soloPrincipal = false) {
|
|
const xs = [];
|
|
const ys = [];
|
|
for (const c of codigos) {
|
|
const t = tamano(soloPrincipal ? principal[c] : contorno[c]);
|
|
xs.push(...t.xs);
|
|
ys.push(...t.ys);
|
|
}
|
|
const x0 = Math.min(...xs) - MARGEN;
|
|
const y0 = Math.min(...ys) - MARGEN;
|
|
return [x0, y0, Math.max(...xs) - x0 + MARGEN, Math.max(...ys) - y0 + MARGEN]
|
|
.map((n) => n.toFixed(1))
|
|
.join(' ');
|
|
}
|
|
|
|
const encuadre = encuadrar(PAISES.filter((c) => !IBERIA.includes(c)));
|
|
/*
|
|
* El inserto encuadra solo la parte continental. Con Canarias dentro, el
|
|
* rectángulo se estiraba mil kilómetros al suroeste y la Península quedaba del
|
|
* tamaño de una mota. De España ya no se dibujan las islas; de Portugal sí, y
|
|
* Azores y Madeira caen fuera de este rectángulo, que es donde deben caer.
|
|
*/
|
|
const encuadreIberia = encuadrar(IBERIA, true);
|
|
|
|
const cuerpo = PAISES.map(
|
|
(c) =>
|
|
`\t{ codigo: '${c.toUpperCase()}', nombre: '${NOMBRES[c]}', centro: [${centro[c].join(', ')}], d: '${contorno[c]}' }`
|
|
).join(',\n');
|
|
|
|
fs.writeFileSync(
|
|
DESTINO,
|
|
`/**
|
|
* El mapa de Iberoamérica, país a país.
|
|
*
|
|
* **Generado. No se edita a mano:** \`node scripts/mapa/generar.mjs\`, que
|
|
* explica de dónde sale y qué se recorta.
|
|
*
|
|
* Los contornos son de \`BlankMap-Americas.svg\` de Wikimedia Commons, obra de
|
|
* Lokal_Profil, en dominio público.
|
|
*
|
|
* España y Portugal no están: el original es de las Américas. El día que hagan
|
|
* falta habrá que traerlos de otro mapa o enseñarlos aparte.
|
|
*/
|
|
|
|
export interface PaisDelMapa {
|
|
/** ISO 3166-1 alfa-2, como en la tabla \`pais\`. */
|
|
codigo: string;
|
|
/** En español. Lo lleva el mapa porque dibuja países que aún no están en la base. */
|
|
nombre: string;
|
|
/** Un punto dentro del país, para colgar de él lo que haya que pulsar. */
|
|
centro: [number, number];
|
|
/** El contorno, con todas sus islas en un solo trazado. */
|
|
d: string;
|
|
}
|
|
|
|
/** El encuadre de la orilla americana. */
|
|
export const ENCUADRE = '${encuadre}';
|
|
|
|
/** El de la Península, que va en su propio recuadro. */
|
|
export const ENCUADRE_IBERIA = '${encuadreIberia}';
|
|
|
|
/** Quiénes van en ese recuadro. */
|
|
export const IBERIA = ['ES', 'PT'];
|
|
|
|
export const PAISES: PaisDelMapa[] = [
|
|
${cuerpo}
|
|
];
|
|
`
|
|
);
|
|
|
|
const kb = (fs.statSync(DESTINO).size / 1024).toFixed(0);
|
|
console.log(` países ${PAISES.length}`);
|
|
console.log(` descartados ${descartados} trazados por debajo de ${MINIMO} unidades`);
|
|
console.log(` encuadre ${encuadre}`);
|
|
console.log(` — península ${encuadreIberia}`);
|
|
console.log(` tamaño ${kb} KB`);
|