@cerca.red/geo 0.2.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.
@@ -0,0 +1,90 @@
1
+ import { type Coordenada } from "./coordenadas.ts";
2
+ import { type GeometriaGeoJSON } from "./geojson.ts";
3
+ /**
4
+ * Cajas delimitadoras.
5
+ *
6
+ * ## El orden, y por qué se escribe con nombres
7
+ *
8
+ * `[oeste, sur, este, norte]` — el mismo de MapLibre (`LngLatBoundsLike`), el
9
+ * de `--bbox` de `go-pmtiles` y el del campo `bounds` de GeoJSON. Va con
10
+ * nombres en el tipo (`readonly [oeste: number, ...]`) porque una tupla de
11
+ * cuatro números es el sitio clásico donde alguien mete `[sur, oeste, norte,
12
+ * este]`, que es el orden de Leaflet, y **eso no lanza ningún error**: encuadra
13
+ * el sitio equivocado, o encuadra el mundo entero. Con los nombres puestos, el
14
+ * editor lo dice al escribir.
15
+ *
16
+ * ## Lo que este archivo no hace: cruzar el antimeridiano
17
+ *
18
+ * Una caja cuyo `oeste` sea mayor que su `este` —la que cubre Fiyi, o el
19
+ * estrecho de Bering— no se representa aquí. Colombia no lo cruza y ninguna de
20
+ * las regiones de ADR-024 tampoco, así que la alternativa era escribir un caso
21
+ * que nadie ejecuta y por tanto nadie prueba. Lo que sí hay es que
22
+ * `cajaDeCoordenadas` **lo dice**: si le llegan puntos a los dos lados del
23
+ * antimeridiano devuelve una caja que da la vuelta al mundo por el lado largo,
24
+ * que es visiblemente absurdo, en vez de una caja pequeña y equivocada.
25
+ */
26
+ /** Una caja delimitadora en grados: `[oeste, sur, este, norte]`. */
27
+ export type Caja = readonly [
28
+ oeste: number,
29
+ sur: number,
30
+ este: number,
31
+ norte: number
32
+ ];
33
+ /** `true` si el valor es una caja usable: cuatro números finitos, en rango y ordenados. */
34
+ export declare function esCaja(valor: unknown): valor is Caja;
35
+ /** La misma caja, o un error que dice dónde venía la mala. */
36
+ export declare function exigirCaja(valor: unknown, donde: string): Caja;
37
+ /**
38
+ * La caja más pequeña que contiene todas las coordenadas, o `undefined` si no
39
+ * había ninguna válida.
40
+ *
41
+ * Devuelve `undefined` y no una caja vacía a propósito: «no hay nada que
42
+ * encuadrar» y «hay algo, en un punto» son dos situaciones que el mapa resuelve
43
+ * distinto —la primera no mueve la cámara, la segunda vuela a un punto con
44
+ * tope de zoom—, y colapsarlas en un mismo valor es como se acaba aterrizando
45
+ * en el zoom máximo sobre la isla Null.
46
+ */
47
+ export declare function cajaDeCoordenadas(coordenadas: Iterable<Coordenada>): Caja | undefined;
48
+ /** La caja de una geometría GeoJSON, recorriendo todos sus niveles de anidamiento. */
49
+ export declare function cajaDeGeometria(geometria: GeometriaGeoJSON): Caja | undefined;
50
+ /** El centro geométrico de la caja. No es el centroide de lo que hay dentro. */
51
+ export declare function centroDeCaja(caja: Caja): Coordenada;
52
+ /** `true` si la coordenada cae dentro de la caja, bordes incluidos. */
53
+ export declare function cajaContiene(caja: Caja, coordenada: Coordenada): boolean;
54
+ /** La caja más pequeña que contiene a las dos. */
55
+ export declare function unirCajas(a: Caja, b: Caja): Caja;
56
+ /** `true` si las dos cajas comparten al menos un punto. */
57
+ export declare function cajasSeCruzan(a: Caja, b: Caja): boolean;
58
+ /**
59
+ * La caja crecida `metros` en las cuatro direcciones.
60
+ *
61
+ * El crecimiento en longitud se calcula **en el borde más alejado del
62
+ * ecuador**, que es donde un grado mide menos: usar el centro dejaría la
63
+ * esquina de arriba corta justo en el caso que motiva expandir, que es dar
64
+ * margen para que un símbolo no quede pegado al borde.
65
+ */
66
+ export declare function expandirCajaEnMetros(caja: Caja, metros: number): Caja;
67
+ /** La caja que cubre un radio en metros alrededor de un punto. */
68
+ export declare function cajaDeRadio(centro: Coordenada, metros: number): Caja;
69
+ /** La diagonal de la caja, de la esquina suroeste a la noreste, en metros. */
70
+ export declare function diagonalDeCajaEnMetros(caja: Caja): number;
71
+ /**
72
+ * El umbral por debajo del cual una caja se considera un punto.
73
+ *
74
+ * 50 metros no es un número redondo elegido por gusto: es aproximadamente el
75
+ * ancho de una manzana en una ciudad colombiana, o sea la escala por debajo de
76
+ * la cual encuadrar «la caja» y encuadrar «el punto» son lo mismo para quien
77
+ * mira.
78
+ */
79
+ export declare const DIAGONAL_MINIMA_EN_METROS = 50;
80
+ /**
81
+ * `true` si la caja es tan pequeña que encuadrarla no tiene sentido.
82
+ *
83
+ * Este es el predicado que evita el fallo más visible de un mapa: `fitBounds`
84
+ * sobre una caja de área cero —un solo negocio, o varios en la misma
85
+ * dirección— resuelve el zoom que hace caber esa caja en la pantalla, y para
86
+ * un área cero ese zoom es el máximo. El usuario aterriza encima de un tejado
87
+ * sin ninguna referencia alrededor, y el mapa parece roto aunque hizo
88
+ * exactamente lo que se le pidió.
89
+ */
90
+ export declare function esCajaDegenerada(caja: Caja, diagonalMinimaEnMetros?: number): boolean;
package/dist/cajas.js ADDED
@@ -0,0 +1,136 @@
1
+ import { esCoordenada } from "./coordenadas.js";
2
+ import { distanciaEnMetros, gradosDeLatitudPorMetros, gradosDeLongitudPorMetros, } from "./distancias.js";
3
+ import { coordenadasDe } from "./geojson.js";
4
+ /** `true` si el valor es una caja usable: cuatro números finitos, en rango y ordenados. */
5
+ export function esCaja(valor) {
6
+ if (!Array.isArray(valor) || valor.length !== 4)
7
+ return false;
8
+ const [oeste, sur, este, norte] = valor;
9
+ for (const numero of [oeste, sur, este, norte]) {
10
+ if (typeof numero !== "number" || !Number.isFinite(numero))
11
+ return false;
12
+ }
13
+ const o = oeste;
14
+ const s = sur;
15
+ const e = este;
16
+ const n = norte;
17
+ return o >= -180 && e <= 180 && s >= -90 && n <= 90 && o <= e && s <= n;
18
+ }
19
+ /** La misma caja, o un error que dice dónde venía la mala. */
20
+ export function exigirCaja(valor, donde) {
21
+ if (!esCaja(valor)) {
22
+ throw new TypeError(`${donde}: se esperaba una caja [oeste, sur, este, norte] en grados, ` +
23
+ `y llegó ${JSON.stringify(valor)}. El orden de Leaflet ` +
24
+ `([sur, oeste, norte, este]) no falla al leerse: encuadra otro sitio.`);
25
+ }
26
+ return valor;
27
+ }
28
+ /**
29
+ * La caja más pequeña que contiene todas las coordenadas, o `undefined` si no
30
+ * había ninguna válida.
31
+ *
32
+ * Devuelve `undefined` y no una caja vacía a propósito: «no hay nada que
33
+ * encuadrar» y «hay algo, en un punto» son dos situaciones que el mapa resuelve
34
+ * distinto —la primera no mueve la cámara, la segunda vuela a un punto con
35
+ * tope de zoom—, y colapsarlas en un mismo valor es como se acaba aterrizando
36
+ * en el zoom máximo sobre la isla Null.
37
+ */
38
+ export function cajaDeCoordenadas(coordenadas) {
39
+ let oeste = Number.POSITIVE_INFINITY;
40
+ let sur = Number.POSITIVE_INFINITY;
41
+ let este = Number.NEGATIVE_INFINITY;
42
+ let norte = Number.NEGATIVE_INFINITY;
43
+ let hubo = false;
44
+ for (const coordenada of coordenadas) {
45
+ if (!esCoordenada(coordenada))
46
+ continue;
47
+ hubo = true;
48
+ if (coordenada.lon < oeste)
49
+ oeste = coordenada.lon;
50
+ if (coordenada.lon > este)
51
+ este = coordenada.lon;
52
+ if (coordenada.lat < sur)
53
+ sur = coordenada.lat;
54
+ if (coordenada.lat > norte)
55
+ norte = coordenada.lat;
56
+ }
57
+ return hubo ? [oeste, sur, este, norte] : undefined;
58
+ }
59
+ /** La caja de una geometría GeoJSON, recorriendo todos sus niveles de anidamiento. */
60
+ export function cajaDeGeometria(geometria) {
61
+ return cajaDeCoordenadas(coordenadasDe(geometria));
62
+ }
63
+ /** El centro geométrico de la caja. No es el centroide de lo que hay dentro. */
64
+ export function centroDeCaja(caja) {
65
+ return { lon: (caja[0] + caja[2]) / 2, lat: (caja[1] + caja[3]) / 2 };
66
+ }
67
+ /** `true` si la coordenada cae dentro de la caja, bordes incluidos. */
68
+ export function cajaContiene(caja, coordenada) {
69
+ return (coordenada.lon >= caja[0] &&
70
+ coordenada.lon <= caja[2] &&
71
+ coordenada.lat >= caja[1] &&
72
+ coordenada.lat <= caja[3]);
73
+ }
74
+ /** La caja más pequeña que contiene a las dos. */
75
+ export function unirCajas(a, b) {
76
+ return [
77
+ Math.min(a[0], b[0]),
78
+ Math.min(a[1], b[1]),
79
+ Math.max(a[2], b[2]),
80
+ Math.max(a[3], b[3]),
81
+ ];
82
+ }
83
+ /** `true` si las dos cajas comparten al menos un punto. */
84
+ export function cajasSeCruzan(a, b) {
85
+ return a[0] <= b[2] && b[0] <= a[2] && a[1] <= b[3] && b[1] <= a[3];
86
+ }
87
+ /**
88
+ * La caja crecida `metros` en las cuatro direcciones.
89
+ *
90
+ * El crecimiento en longitud se calcula **en el borde más alejado del
91
+ * ecuador**, que es donde un grado mide menos: usar el centro dejaría la
92
+ * esquina de arriba corta justo en el caso que motiva expandir, que es dar
93
+ * margen para que un símbolo no quede pegado al borde.
94
+ */
95
+ export function expandirCajaEnMetros(caja, metros) {
96
+ const latitudMasExtrema = Math.max(Math.abs(caja[1]), Math.abs(caja[3]));
97
+ const enLongitud = gradosDeLongitudPorMetros(metros, latitudMasExtrema);
98
+ const enLatitud = gradosDeLatitudPorMetros(metros);
99
+ return [
100
+ Math.max(-180, caja[0] - enLongitud),
101
+ Math.max(-90, caja[1] - enLatitud),
102
+ Math.min(180, caja[2] + enLongitud),
103
+ Math.min(90, caja[3] + enLatitud),
104
+ ];
105
+ }
106
+ /** La caja que cubre un radio en metros alrededor de un punto. */
107
+ export function cajaDeRadio(centro, metros) {
108
+ return expandirCajaEnMetros([centro.lon, centro.lat, centro.lon, centro.lat], metros);
109
+ }
110
+ /** La diagonal de la caja, de la esquina suroeste a la noreste, en metros. */
111
+ export function diagonalDeCajaEnMetros(caja) {
112
+ return distanciaEnMetros({ lon: caja[0], lat: caja[1] }, { lon: caja[2], lat: caja[3] });
113
+ }
114
+ /**
115
+ * El umbral por debajo del cual una caja se considera un punto.
116
+ *
117
+ * 50 metros no es un número redondo elegido por gusto: es aproximadamente el
118
+ * ancho de una manzana en una ciudad colombiana, o sea la escala por debajo de
119
+ * la cual encuadrar «la caja» y encuadrar «el punto» son lo mismo para quien
120
+ * mira.
121
+ */
122
+ export const DIAGONAL_MINIMA_EN_METROS = 50;
123
+ /**
124
+ * `true` si la caja es tan pequeña que encuadrarla no tiene sentido.
125
+ *
126
+ * Este es el predicado que evita el fallo más visible de un mapa: `fitBounds`
127
+ * sobre una caja de área cero —un solo negocio, o varios en la misma
128
+ * dirección— resuelve el zoom que hace caber esa caja en la pantalla, y para
129
+ * un área cero ese zoom es el máximo. El usuario aterriza encima de un tejado
130
+ * sin ninguna referencia alrededor, y el mapa parece roto aunque hizo
131
+ * exactamente lo que se le pidió.
132
+ */
133
+ export function esCajaDegenerada(caja, diagonalMinimaEnMetros = DIAGONAL_MINIMA_EN_METROS) {
134
+ return diagonalDeCajaEnMetros(caja) < diagonalMinimaEnMetros;
135
+ }
136
+ //# sourceMappingURL=cajas.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cajas.js","sourceRoot":"","sources":["../src/cajas.ts"],"names":[],"mappings":"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","sourcesContent":["import { type Coordenada, esCoordenada } from \"./coordenadas.ts\";\nimport {\n distanciaEnMetros,\n gradosDeLatitudPorMetros,\n gradosDeLongitudPorMetros,\n} from \"./distancias.ts\";\nimport { coordenadasDe, type GeometriaGeoJSON } from \"./geojson.ts\";\n\n/**\n * Cajas delimitadoras.\n *\n * ## El orden, y por qué se escribe con nombres\n *\n * `[oeste, sur, este, norte]` — el mismo de MapLibre (`LngLatBoundsLike`), el\n * de `--bbox` de `go-pmtiles` y el del campo `bounds` de GeoJSON. Va con\n * nombres en el tipo (`readonly [oeste: number, ...]`) porque una tupla de\n * cuatro números es el sitio clásico donde alguien mete `[sur, oeste, norte,\n * este]`, que es el orden de Leaflet, y **eso no lanza ningún error**: encuadra\n * el sitio equivocado, o encuadra el mundo entero. Con los nombres puestos, el\n * editor lo dice al escribir.\n *\n * ## Lo que este archivo no hace: cruzar el antimeridiano\n *\n * Una caja cuyo `oeste` sea mayor que su `este` —la que cubre Fiyi, o el\n * estrecho de Bering— no se representa aquí. Colombia no lo cruza y ninguna de\n * las regiones de ADR-024 tampoco, así que la alternativa era escribir un caso\n * que nadie ejecuta y por tanto nadie prueba. Lo que sí hay es que\n * `cajaDeCoordenadas` **lo dice**: si le llegan puntos a los dos lados del\n * antimeridiano devuelve una caja que da la vuelta al mundo por el lado largo,\n * que es visiblemente absurdo, en vez de una caja pequeña y equivocada.\n */\n\n/** Una caja delimitadora en grados: `[oeste, sur, este, norte]`. */\nexport type Caja = readonly [\n oeste: number,\n sur: number,\n este: number,\n norte: number,\n];\n\n/** `true` si el valor es una caja usable: cuatro números finitos, en rango y ordenados. */\nexport function esCaja(valor: unknown): valor is Caja {\n if (!Array.isArray(valor) || valor.length !== 4) return false;\n const [oeste, sur, este, norte] = valor as [\n unknown,\n unknown,\n unknown,\n unknown,\n ];\n for (const numero of [oeste, sur, este, norte]) {\n if (typeof numero !== \"number\" || !Number.isFinite(numero)) return false;\n }\n const o = oeste as number;\n const s = sur as number;\n const e = este as number;\n const n = norte as number;\n return o >= -180 && e <= 180 && s >= -90 && n <= 90 && o <= e && s <= n;\n}\n\n/** La misma caja, o un error que dice dónde venía la mala. */\nexport function exigirCaja(valor: unknown, donde: string): Caja {\n if (!esCaja(valor)) {\n throw new TypeError(\n `${donde}: se esperaba una caja [oeste, sur, este, norte] en grados, ` +\n `y llegó ${JSON.stringify(valor)}. El orden de Leaflet ` +\n `([sur, oeste, norte, este]) no falla al leerse: encuadra otro sitio.`,\n );\n }\n return valor;\n}\n\n/**\n * La caja más pequeña que contiene todas las coordenadas, o `undefined` si no\n * había ninguna válida.\n *\n * Devuelve `undefined` y no una caja vacía a propósito: «no hay nada que\n * encuadrar» y «hay algo, en un punto» son dos situaciones que el mapa resuelve\n * distinto —la primera no mueve la cámara, la segunda vuela a un punto con\n * tope de zoom—, y colapsarlas en un mismo valor es como se acaba aterrizando\n * en el zoom máximo sobre la isla Null.\n */\nexport function cajaDeCoordenadas(\n coordenadas: Iterable<Coordenada>,\n): Caja | undefined {\n let oeste = Number.POSITIVE_INFINITY;\n let sur = Number.POSITIVE_INFINITY;\n let este = Number.NEGATIVE_INFINITY;\n let norte = Number.NEGATIVE_INFINITY;\n let hubo = false;\n\n for (const coordenada of coordenadas) {\n if (!esCoordenada(coordenada)) continue;\n hubo = true;\n if (coordenada.lon < oeste) oeste = coordenada.lon;\n if (coordenada.lon > este) este = coordenada.lon;\n if (coordenada.lat < sur) sur = coordenada.lat;\n if (coordenada.lat > norte) norte = coordenada.lat;\n }\n\n return hubo ? [oeste, sur, este, norte] : undefined;\n}\n\n/** La caja de una geometría GeoJSON, recorriendo todos sus niveles de anidamiento. */\nexport function cajaDeGeometria(geometria: GeometriaGeoJSON): Caja | undefined {\n return cajaDeCoordenadas(coordenadasDe(geometria));\n}\n\n/** El centro geométrico de la caja. No es el centroide de lo que hay dentro. */\nexport function centroDeCaja(caja: Caja): Coordenada {\n return { lon: (caja[0] + caja[2]) / 2, lat: (caja[1] + caja[3]) / 2 };\n}\n\n/** `true` si la coordenada cae dentro de la caja, bordes incluidos. */\nexport function cajaContiene(caja: Caja, coordenada: Coordenada): boolean {\n return (\n coordenada.lon >= caja[0] &&\n coordenada.lon <= caja[2] &&\n coordenada.lat >= caja[1] &&\n coordenada.lat <= caja[3]\n );\n}\n\n/** La caja más pequeña que contiene a las dos. */\nexport function unirCajas(a: Caja, b: Caja): Caja {\n return [\n Math.min(a[0], b[0]),\n Math.min(a[1], b[1]),\n Math.max(a[2], b[2]),\n Math.max(a[3], b[3]),\n ];\n}\n\n/** `true` si las dos cajas comparten al menos un punto. */\nexport function cajasSeCruzan(a: Caja, b: Caja): boolean {\n return a[0] <= b[2] && b[0] <= a[2] && a[1] <= b[3] && b[1] <= a[3];\n}\n\n/**\n * La caja crecida `metros` en las cuatro direcciones.\n *\n * El crecimiento en longitud se calcula **en el borde más alejado del\n * ecuador**, que es donde un grado mide menos: usar el centro dejaría la\n * esquina de arriba corta justo en el caso que motiva expandir, que es dar\n * margen para que un símbolo no quede pegado al borde.\n */\nexport function expandirCajaEnMetros(caja: Caja, metros: number): Caja {\n const latitudMasExtrema = Math.max(Math.abs(caja[1]), Math.abs(caja[3]));\n const enLongitud = gradosDeLongitudPorMetros(metros, latitudMasExtrema);\n const enLatitud = gradosDeLatitudPorMetros(metros);\n return [\n Math.max(-180, caja[0] - enLongitud),\n Math.max(-90, caja[1] - enLatitud),\n Math.min(180, caja[2] + enLongitud),\n Math.min(90, caja[3] + enLatitud),\n ];\n}\n\n/** La caja que cubre un radio en metros alrededor de un punto. */\nexport function cajaDeRadio(centro: Coordenada, metros: number): Caja {\n return expandirCajaEnMetros(\n [centro.lon, centro.lat, centro.lon, centro.lat],\n metros,\n );\n}\n\n/** La diagonal de la caja, de la esquina suroeste a la noreste, en metros. */\nexport function diagonalDeCajaEnMetros(caja: Caja): number {\n return distanciaEnMetros(\n { lon: caja[0], lat: caja[1] },\n { lon: caja[2], lat: caja[3] },\n );\n}\n\n/**\n * El umbral por debajo del cual una caja se considera un punto.\n *\n * 50 metros no es un número redondo elegido por gusto: es aproximadamente el\n * ancho de una manzana en una ciudad colombiana, o sea la escala por debajo de\n * la cual encuadrar «la caja» y encuadrar «el punto» son lo mismo para quien\n * mira.\n */\nexport const DIAGONAL_MINIMA_EN_METROS = 50;\n\n/**\n * `true` si la caja es tan pequeña que encuadrarla no tiene sentido.\n *\n * Este es el predicado que evita el fallo más visible de un mapa: `fitBounds`\n * sobre una caja de área cero —un solo negocio, o varios en la misma\n * dirección— resuelve el zoom que hace caber esa caja en la pantalla, y para\n * un área cero ese zoom es el máximo. El usuario aterriza encima de un tejado\n * sin ninguna referencia alrededor, y el mapa parece roto aunque hizo\n * exactamente lo que se le pidió.\n */\nexport function esCajaDegenerada(\n caja: Caja,\n diagonalMinimaEnMetros: number = DIAGONAL_MINIMA_EN_METROS,\n): boolean {\n return diagonalDeCajaEnMetros(caja) < diagonalMinimaEnMetros;\n}\n"]}
@@ -0,0 +1,28 @@
1
+ import type { Coordenada } from "./coordenadas.ts";
2
+ import { type PoligonoGeoJSON } from "./geojson.ts";
3
+ /**
4
+ * Un círculo geográfico convertido en polígono.
5
+ *
6
+ * ## Por qué hace falta
7
+ *
8
+ * MapLibre sabe dibujar círculos, pero su radio va **en píxeles**: un
9
+ * `circle-radius` de 40 mide lo mismo en pantalla al zoom 10 que al 18, o sea
10
+ * kilómetros primero y metros después. Para todo lo que representa una
11
+ * distancia real —el margen de error de una ubicación aproximada, el radio de
12
+ * incertidumbre del GPS, un radio de reparto— eso es justo lo contrario de lo
13
+ * que hace falta: el área tiene que quedarse donde está mientras el usuario
14
+ * se acerca.
15
+ *
16
+ * Un polígono en coordenadas sí escala con el mapa, porque está en el mundo y
17
+ * no en la pantalla.
18
+ *
19
+ * ## Los 64 lados
20
+ *
21
+ * Con 64 vértices, un círculo de 500 m tiene un error de flecha menor que
22
+ * medio metro —la sagita de un arco de 5,6° sobre ese radio—, o sea invisible
23
+ * a cualquier zoom en el que quepa el círculo entero. Con 32 ya se ve el
24
+ * polígono al acercarse; con 128 se duplican los vértices sin que nadie note
25
+ * nada.
26
+ */
27
+ export declare const LADOS_POR_DEFECTO = 64;
28
+ export declare function circuloComoPoligono(centro: Coordenada, radioEnMetros: number, lados?: number): PoligonoGeoJSON;
@@ -0,0 +1,50 @@
1
+ import { desplazar } from "./distancias.js";
2
+ import { aPosicion } from "./geojson.js";
3
+ /**
4
+ * Un círculo geográfico convertido en polígono.
5
+ *
6
+ * ## Por qué hace falta
7
+ *
8
+ * MapLibre sabe dibujar círculos, pero su radio va **en píxeles**: un
9
+ * `circle-radius` de 40 mide lo mismo en pantalla al zoom 10 que al 18, o sea
10
+ * kilómetros primero y metros después. Para todo lo que representa una
11
+ * distancia real —el margen de error de una ubicación aproximada, el radio de
12
+ * incertidumbre del GPS, un radio de reparto— eso es justo lo contrario de lo
13
+ * que hace falta: el área tiene que quedarse donde está mientras el usuario
14
+ * se acerca.
15
+ *
16
+ * Un polígono en coordenadas sí escala con el mapa, porque está en el mundo y
17
+ * no en la pantalla.
18
+ *
19
+ * ## Los 64 lados
20
+ *
21
+ * Con 64 vértices, un círculo de 500 m tiene un error de flecha menor que
22
+ * medio metro —la sagita de un arco de 5,6° sobre ese radio—, o sea invisible
23
+ * a cualquier zoom en el que quepa el círculo entero. Con 32 ya se ve el
24
+ * polígono al acercarse; con 128 se duplican los vértices sin que nadie note
25
+ * nada.
26
+ */
27
+ export const LADOS_POR_DEFECTO = 64;
28
+ export function circuloComoPoligono(centro, radioEnMetros, lados = LADOS_POR_DEFECTO) {
29
+ if (!(radioEnMetros > 0)) {
30
+ throw new RangeError(`circuloComoPoligono: el radio tiene que ser mayor que cero, y llegó ` +
31
+ `${radioEnMetros}. Un círculo de radio cero es un punto, y un punto ` +
32
+ `se dibuja como punto.`);
33
+ }
34
+ if (!Number.isInteger(lados) || lados < 3) {
35
+ throw new RangeError(`circuloComoPoligono: hacen falta al menos 3 lados, y llegaron ${lados}.`);
36
+ }
37
+ const anillo = [];
38
+ for (let i = 0; i < lados; i += 1) {
39
+ anillo.push(aPosicion(desplazar(centro, (i * 360) / lados, radioEnMetros)));
40
+ }
41
+ // GeoJSON exige que el anillo cierre: el último vértice es el primero otra
42
+ // vez. Sin esto MapLibre dibuja igual —rellena lo que puede— pero el
43
+ // documento no es válido, y cualquier herramienta que lo valide de verdad
44
+ // lo rechaza.
45
+ const primero = anillo[0];
46
+ if (primero !== undefined)
47
+ anillo.push(primero);
48
+ return { type: "Polygon", coordinates: [anillo] };
49
+ }
50
+ //# sourceMappingURL=circulos.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"circulos.js","sourceRoot":"","sources":["../src/circulos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,SAAS,EAAuC,MAAM,cAAc,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAEpC,MAAM,UAAU,mBAAmB,CACjC,MAAkB,EAClB,aAAqB,EACrB,QAAgB,iBAAiB;IAEjC,IAAI,CAAC,CAAC,aAAa,GAAG,CAAC,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,UAAU,CAClB,sEAAsE;YACpE,GAAG,aAAa,qDAAqD;YACrE,uBAAuB,CAC1B,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QAC1C,MAAM,IAAI,UAAU,CAClB,iEAAiE,KAAK,GAAG,CAC1E,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAe,EAAE,CAAC;IAC9B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAClC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,KAAK,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC;IAC9E,CAAC;IACD,2EAA2E;IAC3E,qEAAqE;IACrE,0EAA0E;IAC1E,cAAc;IACd,MAAM,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IAC1B,IAAI,OAAO,KAAK,SAAS;QAAE,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAEhD,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;AACpD,CAAC","sourcesContent":["import type { Coordenada } from \"./coordenadas.ts\";\nimport { desplazar } from \"./distancias.ts\";\nimport { aPosicion, type PoligonoGeoJSON, type Posicion } from \"./geojson.ts\";\n\n/**\n * Un círculo geográfico convertido en polígono.\n *\n * ## Por qué hace falta\n *\n * MapLibre sabe dibujar círculos, pero su radio va **en píxeles**: un\n * `circle-radius` de 40 mide lo mismo en pantalla al zoom 10 que al 18, o sea\n * kilómetros primero y metros después. Para todo lo que representa una\n * distancia real —el margen de error de una ubicación aproximada, el radio de\n * incertidumbre del GPS, un radio de reparto— eso es justo lo contrario de lo\n * que hace falta: el área tiene que quedarse donde está mientras el usuario\n * se acerca.\n *\n * Un polígono en coordenadas sí escala con el mapa, porque está en el mundo y\n * no en la pantalla.\n *\n * ## Los 64 lados\n *\n * Con 64 vértices, un círculo de 500 m tiene un error de flecha menor que\n * medio metro —la sagita de un arco de 5,6° sobre ese radio—, o sea invisible\n * a cualquier zoom en el que quepa el círculo entero. Con 32 ya se ve el\n * polígono al acercarse; con 128 se duplican los vértices sin que nadie note\n * nada.\n */\nexport const LADOS_POR_DEFECTO = 64;\n\nexport function circuloComoPoligono(\n centro: Coordenada,\n radioEnMetros: number,\n lados: number = LADOS_POR_DEFECTO,\n): PoligonoGeoJSON {\n if (!(radioEnMetros > 0)) {\n throw new RangeError(\n `circuloComoPoligono: el radio tiene que ser mayor que cero, y llegó ` +\n `${radioEnMetros}. Un círculo de radio cero es un punto, y un punto ` +\n `se dibuja como punto.`,\n );\n }\n if (!Number.isInteger(lados) || lados < 3) {\n throw new RangeError(\n `circuloComoPoligono: hacen falta al menos 3 lados, y llegaron ${lados}.`,\n );\n }\n\n const anillo: Posicion[] = [];\n for (let i = 0; i < lados; i += 1) {\n anillo.push(aPosicion(desplazar(centro, (i * 360) / lados, radioEnMetros)));\n }\n // GeoJSON exige que el anillo cierre: el último vértice es el primero otra\n // vez. Sin esto MapLibre dibuja igual —rellena lo que puede— pero el\n // documento no es válido, y cualquier herramienta que lo valide de verdad\n // lo rechaza.\n const primero = anillo[0];\n if (primero !== undefined) anillo.push(primero);\n\n return { type: \"Polygon\", coordinates: [anillo] };\n}\n"]}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Una coordenada geográfica, y las tres operaciones que hay que hacerle antes
3
+ * de confiar en ella.
4
+ *
5
+ * ## Por qué `lon` y `lat`, y no `longitud` y `latitud`
6
+ *
7
+ * El código propio de Cerca está en español y los conceptos se traducen. Aquí
8
+ * no, y la razón no es comodidad: en español **`longitud` significa también
9
+ * *length***, y este paquete calcula largos de líneas. Un campo `longitud`
10
+ * junto a una función `largoDeLineaEnMetros` obliga a leer el tipo para saber
11
+ * cuál de las dos cosas es cada vez. `lon` y `lat` son además la forma que
12
+ * usan GeoJSON, MapLibre y PMTiles, o sea la palabra literal de la
13
+ * herramienta, que es la excepción escrita de la convención.
14
+ *
15
+ * ## Por qué no `lng`
16
+ *
17
+ * `lat` y `lng` es la forma de Google Maps, y es la que usa hoy la tabla de
18
+ * negocios de la PWA (`packages/db/migrations/0001_core.sql`, columnas `lat` y
19
+ * `lng`). No se sigue aquí porque `lon` es la de GeoJSON y la de MapLibre, que
20
+ * son las dos con las que este código habla de verdad. Quien traduzca de la
21
+ * fila de D1 a una coordenada de mapa hace el cambio de nombre una vez, en la
22
+ * superficie — que es justo el sitio donde ADR-027 pone la traducción de
23
+ * dominio a proyección cartográfica.
24
+ */
25
+ /** Una coordenada geográfica en grados decimales, WGS 84. */
26
+ export interface Coordenada {
27
+ /** Grados este-oeste, en `[-180, 180]`. */
28
+ readonly lon: number;
29
+ /** Grados norte-sur, en `[-90, 90]`. */
30
+ readonly lat: number;
31
+ }
32
+ /**
33
+ * El paralelo donde se corta la proyección Web Mercator.
34
+ *
35
+ * Mercator manda los polos al infinito, así que toda teselación web recorta.
36
+ * El valor sale de resolver la latitud cuya `y` proyectada vale exactamente 1
37
+ * —`atan(sinh(π)) en grados`— y es el que usan MapLibre, PMTiles y el esquema
38
+ * de teselas de Google. Por encima de este paralelo no hay teselas que pedir.
39
+ */
40
+ export declare const LATITUD_MAXIMA_WEB_MERCATOR = 85.0511287798066;
41
+ /** `true` si el valor es una coordenada usable: los dos campos finitos y en rango. */
42
+ export declare function esCoordenada(valor: unknown): valor is Coordenada;
43
+ /**
44
+ * La misma coordenada, o un error que dice **dónde** venía la mala.
45
+ *
46
+ * El `donde` no es decoración. Una coordenada inválida no rompe nada donde se
47
+ * crea: viaja hasta el mapa y aparece como un alfiler en mitad del Atlántico,
48
+ * o como nada en absoluto. Para cuando alguien lo nota, la traza ya no dice de
49
+ * qué lista salió.
50
+ */
51
+ export declare function exigirCoordenada(valor: unknown, donde: string): Coordenada;
52
+ /**
53
+ * La longitud llevada al rango `[-180, 180)`.
54
+ *
55
+ * Hace falta porque una cámara que gira acumula: arrastrar el mapa hacia el
56
+ * este tres vueltas deja el centro en 1.080 grados, que es la misma posición y
57
+ * un número que ninguna comparación de cajas reconoce.
58
+ */
59
+ export declare function normalizarLongitud(lon: number): number;
60
+ /**
61
+ * La latitud recortada a lo que Web Mercator sabe proyectar.
62
+ *
63
+ * No es lo mismo que rechazarla: 89° es una latitud legítima, lo que no existe
64
+ * es su tesela. Recortar deja la cámara en el borde del mapa; rechazar dejaría
65
+ * la aplicación sin cámara.
66
+ */
67
+ export declare function recortarLatitud(lat: number): number;
68
+ /** La coordenada con la longitud normalizada y la latitud recortada al mapa. */
69
+ export declare function normalizarCoordenada(coordenada: Coordenada): Coordenada;
70
+ /** `true` si las dos coordenadas coinciden dentro de `tolerancia` grados. */
71
+ export declare function coordenadasIguales(a: Coordenada, b: Coordenada, tolerancia?: number): boolean;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Una coordenada geográfica, y las tres operaciones que hay que hacerle antes
3
+ * de confiar en ella.
4
+ *
5
+ * ## Por qué `lon` y `lat`, y no `longitud` y `latitud`
6
+ *
7
+ * El código propio de Cerca está en español y los conceptos se traducen. Aquí
8
+ * no, y la razón no es comodidad: en español **`longitud` significa también
9
+ * *length***, y este paquete calcula largos de líneas. Un campo `longitud`
10
+ * junto a una función `largoDeLineaEnMetros` obliga a leer el tipo para saber
11
+ * cuál de las dos cosas es cada vez. `lon` y `lat` son además la forma que
12
+ * usan GeoJSON, MapLibre y PMTiles, o sea la palabra literal de la
13
+ * herramienta, que es la excepción escrita de la convención.
14
+ *
15
+ * ## Por qué no `lng`
16
+ *
17
+ * `lat` y `lng` es la forma de Google Maps, y es la que usa hoy la tabla de
18
+ * negocios de la PWA (`packages/db/migrations/0001_core.sql`, columnas `lat` y
19
+ * `lng`). No se sigue aquí porque `lon` es la de GeoJSON y la de MapLibre, que
20
+ * son las dos con las que este código habla de verdad. Quien traduzca de la
21
+ * fila de D1 a una coordenada de mapa hace el cambio de nombre una vez, en la
22
+ * superficie — que es justo el sitio donde ADR-027 pone la traducción de
23
+ * dominio a proyección cartográfica.
24
+ */
25
+ /**
26
+ * El paralelo donde se corta la proyección Web Mercator.
27
+ *
28
+ * Mercator manda los polos al infinito, así que toda teselación web recorta.
29
+ * El valor sale de resolver la latitud cuya `y` proyectada vale exactamente 1
30
+ * —`atan(sinh(π)) en grados`— y es el que usan MapLibre, PMTiles y el esquema
31
+ * de teselas de Google. Por encima de este paralelo no hay teselas que pedir.
32
+ */
33
+ export const LATITUD_MAXIMA_WEB_MERCATOR = 85.0511287798066;
34
+ /** `true` si el valor es una coordenada usable: los dos campos finitos y en rango. */
35
+ export function esCoordenada(valor) {
36
+ if (typeof valor !== "object" || valor === null)
37
+ return false;
38
+ const posible = valor;
39
+ return (typeof posible.lon === "number" &&
40
+ Number.isFinite(posible.lon) &&
41
+ posible.lon >= -180 &&
42
+ posible.lon <= 180 &&
43
+ typeof posible.lat === "number" &&
44
+ Number.isFinite(posible.lat) &&
45
+ posible.lat >= -90 &&
46
+ posible.lat <= 90);
47
+ }
48
+ /**
49
+ * La misma coordenada, o un error que dice **dónde** venía la mala.
50
+ *
51
+ * El `donde` no es decoración. Una coordenada inválida no rompe nada donde se
52
+ * crea: viaja hasta el mapa y aparece como un alfiler en mitad del Atlántico,
53
+ * o como nada en absoluto. Para cuando alguien lo nota, la traza ya no dice de
54
+ * qué lista salió.
55
+ */
56
+ export function exigirCoordenada(valor, donde) {
57
+ if (!esCoordenada(valor)) {
58
+ throw new TypeError(`${donde}: se esperaba una coordenada { lon, lat } en grados, ` +
59
+ `y llegó ${JSON.stringify(valor)}. Una longitud va en [-180, 180] y ` +
60
+ `una latitud en [-90, 90]; si están al revés, el mapa no falla, ` +
61
+ `dibuja en el sitio equivocado.`);
62
+ }
63
+ return { lon: valor.lon, lat: valor.lat };
64
+ }
65
+ /**
66
+ * La longitud llevada al rango `[-180, 180)`.
67
+ *
68
+ * Hace falta porque una cámara que gira acumula: arrastrar el mapa hacia el
69
+ * este tres vueltas deja el centro en 1.080 grados, que es la misma posición y
70
+ * un número que ninguna comparación de cajas reconoce.
71
+ */
72
+ export function normalizarLongitud(lon) {
73
+ if (!Number.isFinite(lon)) {
74
+ throw new TypeError(`normalizarLongitud: ${lon} no es un número finito.`);
75
+ }
76
+ const resto = (((lon + 180) % 360) + 360) % 360;
77
+ return resto - 180;
78
+ }
79
+ /**
80
+ * La latitud recortada a lo que Web Mercator sabe proyectar.
81
+ *
82
+ * No es lo mismo que rechazarla: 89° es una latitud legítima, lo que no existe
83
+ * es su tesela. Recortar deja la cámara en el borde del mapa; rechazar dejaría
84
+ * la aplicación sin cámara.
85
+ */
86
+ export function recortarLatitud(lat) {
87
+ if (!Number.isFinite(lat)) {
88
+ throw new TypeError(`recortarLatitud: ${lat} no es un número finito.`);
89
+ }
90
+ return Math.min(LATITUD_MAXIMA_WEB_MERCATOR, Math.max(-LATITUD_MAXIMA_WEB_MERCATOR, lat));
91
+ }
92
+ /** La coordenada con la longitud normalizada y la latitud recortada al mapa. */
93
+ export function normalizarCoordenada(coordenada) {
94
+ return {
95
+ lon: normalizarLongitud(coordenada.lon),
96
+ lat: recortarLatitud(coordenada.lat),
97
+ };
98
+ }
99
+ /** `true` si las dos coordenadas coinciden dentro de `tolerancia` grados. */
100
+ export function coordenadasIguales(a, b, tolerancia = 1e-9) {
101
+ return (Math.abs(a.lon - b.lon) <= tolerancia &&
102
+ Math.abs(a.lat - b.lat) <= tolerancia);
103
+ }
104
+ //# sourceMappingURL=coordenadas.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coordenadas.js","sourceRoot":"","sources":["../src/coordenadas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAUH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,gBAAgB,CAAC;AAE5D,sFAAsF;AACtF,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,OAAO,GAAG,KAAyC,CAAC;IAC1D,OAAO,CACL,OAAO,OAAO,CAAC,GAAG,KAAK,QAAQ;QAC/B,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC;QAC5B,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG;QACnB,OAAO,CAAC,GAAG,IAAI,GAAG;QAClB,OAAO,OAAO,CAAC,GAAG,KAAK,QAAQ;QAC/B,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC;QAC5B,OAAO,CAAC,GAAG,IAAI,CAAC,EAAE;QAClB,OAAO,CAAC,GAAG,IAAI,EAAE,CAClB,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc,EAAE,KAAa;IAC5D,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,SAAS,CACjB,GAAG,KAAK,uDAAuD;YAC7D,WAAW,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,qCAAqC;YACrE,iEAAiE;YACjE,gCAAgC,CACnC,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC;AAC5C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,SAAS,CAAC,uBAAuB,GAAG,0BAA0B,CAAC,CAAC;IAC5E,CAAC;IACD,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC;IAChD,OAAO,KAAK,GAAG,GAAG,CAAC;AACrB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,SAAS,CAAC,oBAAoB,GAAG,0BAA0B,CAAC,CAAC;IACzE,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CACb,2BAA2B,EAC3B,IAAI,CAAC,GAAG,CAAC,CAAC,2BAA2B,EAAE,GAAG,CAAC,CAC5C,CAAC;AACJ,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,oBAAoB,CAAC,UAAsB;IACzD,OAAO;QACL,GAAG,EAAE,kBAAkB,CAAC,UAAU,CAAC,GAAG,CAAC;QACvC,GAAG,EAAE,eAAe,CAAC,UAAU,CAAC,GAAG,CAAC;KACrC,CAAC;AACJ,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,kBAAkB,CAChC,CAAa,EACb,CAAa,EACb,UAAU,GAAG,IAAI;IAEjB,OAAO,CACL,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,UAAU;QACrC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,UAAU,CACtC,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Una coordenada geográfica, y las tres operaciones que hay que hacerle antes\n * de confiar en ella.\n *\n * ## Por qué `lon` y `lat`, y no `longitud` y `latitud`\n *\n * El código propio de Cerca está en español y los conceptos se traducen. Aquí\n * no, y la razón no es comodidad: en español **`longitud` significa también\n * *length***, y este paquete calcula largos de líneas. Un campo `longitud`\n * junto a una función `largoDeLineaEnMetros` obliga a leer el tipo para saber\n * cuál de las dos cosas es cada vez. `lon` y `lat` son además la forma que\n * usan GeoJSON, MapLibre y PMTiles, o sea la palabra literal de la\n * herramienta, que es la excepción escrita de la convención.\n *\n * ## Por qué no `lng`\n *\n * `lat` y `lng` es la forma de Google Maps, y es la que usa hoy la tabla de\n * negocios de la PWA (`packages/db/migrations/0001_core.sql`, columnas `lat` y\n * `lng`). No se sigue aquí porque `lon` es la de GeoJSON y la de MapLibre, que\n * son las dos con las que este código habla de verdad. Quien traduzca de la\n * fila de D1 a una coordenada de mapa hace el cambio de nombre una vez, en la\n * superficie — que es justo el sitio donde ADR-027 pone la traducción de\n * dominio a proyección cartográfica.\n */\n\n/** Una coordenada geográfica en grados decimales, WGS 84. */\nexport interface Coordenada {\n /** Grados este-oeste, en `[-180, 180]`. */\n readonly lon: number;\n /** Grados norte-sur, en `[-90, 90]`. */\n readonly lat: number;\n}\n\n/**\n * El paralelo donde se corta la proyección Web Mercator.\n *\n * Mercator manda los polos al infinito, así que toda teselación web recorta.\n * El valor sale de resolver la latitud cuya `y` proyectada vale exactamente 1\n * —`atan(sinh(π)) en grados`— y es el que usan MapLibre, PMTiles y el esquema\n * de teselas de Google. Por encima de este paralelo no hay teselas que pedir.\n */\nexport const LATITUD_MAXIMA_WEB_MERCATOR = 85.0511287798066;\n\n/** `true` si el valor es una coordenada usable: los dos campos finitos y en rango. */\nexport function esCoordenada(valor: unknown): valor is Coordenada {\n if (typeof valor !== \"object\" || valor === null) return false;\n const posible = valor as { lon?: unknown; lat?: unknown };\n return (\n typeof posible.lon === \"number\" &&\n Number.isFinite(posible.lon) &&\n posible.lon >= -180 &&\n posible.lon <= 180 &&\n typeof posible.lat === \"number\" &&\n Number.isFinite(posible.lat) &&\n posible.lat >= -90 &&\n posible.lat <= 90\n );\n}\n\n/**\n * La misma coordenada, o un error que dice **dónde** venía la mala.\n *\n * El `donde` no es decoración. Una coordenada inválida no rompe nada donde se\n * crea: viaja hasta el mapa y aparece como un alfiler en mitad del Atlántico,\n * o como nada en absoluto. Para cuando alguien lo nota, la traza ya no dice de\n * qué lista salió.\n */\nexport function exigirCoordenada(valor: unknown, donde: string): Coordenada {\n if (!esCoordenada(valor)) {\n throw new TypeError(\n `${donde}: se esperaba una coordenada { lon, lat } en grados, ` +\n `y llegó ${JSON.stringify(valor)}. Una longitud va en [-180, 180] y ` +\n `una latitud en [-90, 90]; si están al revés, el mapa no falla, ` +\n `dibuja en el sitio equivocado.`,\n );\n }\n return { lon: valor.lon, lat: valor.lat };\n}\n\n/**\n * La longitud llevada al rango `[-180, 180)`.\n *\n * Hace falta porque una cámara que gira acumula: arrastrar el mapa hacia el\n * este tres vueltas deja el centro en 1.080 grados, que es la misma posición y\n * un número que ninguna comparación de cajas reconoce.\n */\nexport function normalizarLongitud(lon: number): number {\n if (!Number.isFinite(lon)) {\n throw new TypeError(`normalizarLongitud: ${lon} no es un número finito.`);\n }\n const resto = (((lon + 180) % 360) + 360) % 360;\n return resto - 180;\n}\n\n/**\n * La latitud recortada a lo que Web Mercator sabe proyectar.\n *\n * No es lo mismo que rechazarla: 89° es una latitud legítima, lo que no existe\n * es su tesela. Recortar deja la cámara en el borde del mapa; rechazar dejaría\n * la aplicación sin cámara.\n */\nexport function recortarLatitud(lat: number): number {\n if (!Number.isFinite(lat)) {\n throw new TypeError(`recortarLatitud: ${lat} no es un número finito.`);\n }\n return Math.min(\n LATITUD_MAXIMA_WEB_MERCATOR,\n Math.max(-LATITUD_MAXIMA_WEB_MERCATOR, lat),\n );\n}\n\n/** La coordenada con la longitud normalizada y la latitud recortada al mapa. */\nexport function normalizarCoordenada(coordenada: Coordenada): Coordenada {\n return {\n lon: normalizarLongitud(coordenada.lon),\n lat: recortarLatitud(coordenada.lat),\n };\n}\n\n/** `true` si las dos coordenadas coinciden dentro de `tolerancia` grados. */\nexport function coordenadasIguales(\n a: Coordenada,\n b: Coordenada,\n tolerancia = 1e-9,\n): boolean {\n return (\n Math.abs(a.lon - b.lon) <= tolerancia &&\n Math.abs(a.lat - b.lat) <= tolerancia\n );\n}\n"]}
@@ -0,0 +1,69 @@
1
+ import type { Coordenada } from "./coordenadas.ts";
2
+ /**
3
+ * Distancias y rumbos sobre una esfera.
4
+ *
5
+ * ## Por qué esfera y no elipsoide
6
+ *
7
+ * La Tierra es un elipsoide y la fórmula exacta —Vincenty, Karney— existe. No
8
+ * se usa aquí porque el error de la aproximación esférica es de **0,3 %** en
9
+ * el peor caso y de mucho menos en distancias urbanas, y ninguna de las tres
10
+ * cosas para las que Cerca mide distancias —ordenar negocios por cercanía,
11
+ * decidir si un domicilio cae dentro de una zona, dibujar un radio— cambia de
12
+ * respuesta por tres metros en mil. Vincenty, además, no converge para puntos
13
+ * casi antipodales, que es un fallo que aparece con datos malos y no con datos
14
+ * lejanos.
15
+ *
16
+ * Lo que **no** se puede hacer con esto es medir un terreno o una linde. Si
17
+ * algún día hace falta esa precisión, el cambio es este archivo y nada más.
18
+ */
19
+ /**
20
+ * El radio medio de la Tierra en metros: la media aritmética de los tres
21
+ * semiejes del elipsoide GRS 80 / WGS 84 (R₁ de la IUGG).
22
+ *
23
+ * Está escrito con su procedencia porque circulan tres valores parecidos
24
+ * —6.371.000, 6.372.797, 6.378.137— y el último es el radio **ecuatorial**,
25
+ * no el medio: usarlo mete un 0,3 % de sesgo sistemático que nunca se nota
26
+ * porque no falla, solo miente un poco siempre.
27
+ */
28
+ export declare const RADIO_TERRESTRE_EN_METROS = 6371008.8;
29
+ /**
30
+ * La distancia en metros entre dos coordenadas, por el camino más corto sobre
31
+ * la esfera.
32
+ *
33
+ * Fórmula del haversine, elegida sobre la ley esférica de cosenos porque esta
34
+ * última pierde precisión justo en las distancias cortas —dos esquinas de una
35
+ * manzana— por cancelación al restar cosenos casi iguales, que es todo lo que
36
+ * mide Cerca.
37
+ */
38
+ export declare function distanciaEnMetros(a: Coordenada, b: Coordenada): number;
39
+ /**
40
+ * El rumbo inicial de `a` hacia `b`, en grados desde el norte y en el sentido
41
+ * de las agujas del reloj, dentro de `[0, 360)`.
42
+ *
43
+ * «Inicial» no es un matiz: sobre una esfera el rumbo cambia a lo largo del
44
+ * trayecto, y el de vuelta no es este más 180 salvo sobre un meridiano. Sirve
45
+ * para orientar un símbolo —el vehículo que se mueve— y no para navegar.
46
+ */
47
+ export declare function rumboEnGrados(a: Coordenada, b: Coordenada): number;
48
+ /**
49
+ * La coordenada que queda a `metros` del origen siguiendo `rumboEnGrados`.
50
+ *
51
+ * Es lo que convierte «300 metros» en una caja o en un círculo dibujable, y
52
+ * por eso vive aquí y no en el paquete del mapa: el Worker de rutas necesita
53
+ * la misma operación y tiene que dar el mismo resultado.
54
+ */
55
+ export declare function desplazar(origen: Coordenada, rumbo: number, metros: number): Coordenada;
56
+ /** El largo total de una polilínea, sumando tramo a tramo. */
57
+ export declare function largoDeLineaEnMetros(linea: readonly Coordenada[]): number;
58
+ /**
59
+ * Cuántos grados de longitud son `metros` a esa latitud.
60
+ *
61
+ * Un grado de longitud mide 111 km en el ecuador y cero en el polo, así que la
62
+ * conversión **depende de dónde estés**. Es la razón por la que un radio en
63
+ * metros no se puede convertir a una caja sin saber la latitud, y por la que
64
+ * expandir una caja con un número fijo de grados deforma la zona conforme se
65
+ * sube o se baja en el mapa.
66
+ */
67
+ export declare function gradosDeLongitudPorMetros(metros: number, lat: number): number;
68
+ /** Cuántos grados de latitud son `metros`. No depende de dónde: los meridianos son iguales. */
69
+ export declare function gradosDeLatitudPorMetros(metros: number): number;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Distancias y rumbos sobre una esfera.
3
+ *
4
+ * ## Por qué esfera y no elipsoide
5
+ *
6
+ * La Tierra es un elipsoide y la fórmula exacta —Vincenty, Karney— existe. No
7
+ * se usa aquí porque el error de la aproximación esférica es de **0,3 %** en
8
+ * el peor caso y de mucho menos en distancias urbanas, y ninguna de las tres
9
+ * cosas para las que Cerca mide distancias —ordenar negocios por cercanía,
10
+ * decidir si un domicilio cae dentro de una zona, dibujar un radio— cambia de
11
+ * respuesta por tres metros en mil. Vincenty, además, no converge para puntos
12
+ * casi antipodales, que es un fallo que aparece con datos malos y no con datos
13
+ * lejanos.
14
+ *
15
+ * Lo que **no** se puede hacer con esto es medir un terreno o una linde. Si
16
+ * algún día hace falta esa precisión, el cambio es este archivo y nada más.
17
+ */
18
+ /**
19
+ * El radio medio de la Tierra en metros: la media aritmética de los tres
20
+ * semiejes del elipsoide GRS 80 / WGS 84 (R₁ de la IUGG).
21
+ *
22
+ * Está escrito con su procedencia porque circulan tres valores parecidos
23
+ * —6.371.000, 6.372.797, 6.378.137— y el último es el radio **ecuatorial**,
24
+ * no el medio: usarlo mete un 0,3 % de sesgo sistemático que nunca se nota
25
+ * porque no falla, solo miente un poco siempre.
26
+ */
27
+ export const RADIO_TERRESTRE_EN_METROS = 6371008.8;
28
+ const GRADOS_A_RADIANES = Math.PI / 180;
29
+ const RADIANES_A_GRADOS = 180 / Math.PI;
30
+ /**
31
+ * La distancia en metros entre dos coordenadas, por el camino más corto sobre
32
+ * la esfera.
33
+ *
34
+ * Fórmula del haversine, elegida sobre la ley esférica de cosenos porque esta
35
+ * última pierde precisión justo en las distancias cortas —dos esquinas de una
36
+ * manzana— por cancelación al restar cosenos casi iguales, que es todo lo que
37
+ * mide Cerca.
38
+ */
39
+ export function distanciaEnMetros(a, b) {
40
+ const latitudA = a.lat * GRADOS_A_RADIANES;
41
+ const latitudB = b.lat * GRADOS_A_RADIANES;
42
+ const deltaLatitud = (b.lat - a.lat) * GRADOS_A_RADIANES;
43
+ const deltaLongitud = (b.lon - a.lon) * GRADOS_A_RADIANES;
44
+ const senoLatitud = Math.sin(deltaLatitud / 2);
45
+ const senoLongitud = Math.sin(deltaLongitud / 2);
46
+ const h = senoLatitud * senoLatitud +
47
+ Math.cos(latitudA) * Math.cos(latitudB) * senoLongitud * senoLongitud;
48
+ return 2 * RADIO_TERRESTRE_EN_METROS * Math.asin(Math.min(1, Math.sqrt(h)));
49
+ }
50
+ /**
51
+ * El rumbo inicial de `a` hacia `b`, en grados desde el norte y en el sentido
52
+ * de las agujas del reloj, dentro de `[0, 360)`.
53
+ *
54
+ * «Inicial» no es un matiz: sobre una esfera el rumbo cambia a lo largo del
55
+ * trayecto, y el de vuelta no es este más 180 salvo sobre un meridiano. Sirve
56
+ * para orientar un símbolo —el vehículo que se mueve— y no para navegar.
57
+ */
58
+ export function rumboEnGrados(a, b) {
59
+ const latitudA = a.lat * GRADOS_A_RADIANES;
60
+ const latitudB = b.lat * GRADOS_A_RADIANES;
61
+ const deltaLongitud = (b.lon - a.lon) * GRADOS_A_RADIANES;
62
+ const y = Math.sin(deltaLongitud) * Math.cos(latitudB);
63
+ const x = Math.cos(latitudA) * Math.sin(latitudB) -
64
+ Math.sin(latitudA) * Math.cos(latitudB) * Math.cos(deltaLongitud);
65
+ const grados = Math.atan2(y, x) * RADIANES_A_GRADOS;
66
+ return (grados + 360) % 360;
67
+ }
68
+ /**
69
+ * La coordenada que queda a `metros` del origen siguiendo `rumboEnGrados`.
70
+ *
71
+ * Es lo que convierte «300 metros» en una caja o en un círculo dibujable, y
72
+ * por eso vive aquí y no en el paquete del mapa: el Worker de rutas necesita
73
+ * la misma operación y tiene que dar el mismo resultado.
74
+ */
75
+ export function desplazar(origen, rumbo, metros) {
76
+ const angular = metros / RADIO_TERRESTRE_EN_METROS;
77
+ const rumboEnRadianes = rumbo * GRADOS_A_RADIANES;
78
+ const latitudOrigen = origen.lat * GRADOS_A_RADIANES;
79
+ const longitudOrigen = origen.lon * GRADOS_A_RADIANES;
80
+ const latitudDestino = Math.asin(Math.sin(latitudOrigen) * Math.cos(angular) +
81
+ Math.cos(latitudOrigen) * Math.sin(angular) * Math.cos(rumboEnRadianes));
82
+ const longitudDestino = longitudOrigen +
83
+ Math.atan2(Math.sin(rumboEnRadianes) * Math.sin(angular) * Math.cos(latitudOrigen), Math.cos(angular) - Math.sin(latitudOrigen) * Math.sin(latitudDestino));
84
+ return {
85
+ lon: ((longitudDestino * RADIANES_A_GRADOS + 540) % 360) - 180,
86
+ lat: latitudDestino * RADIANES_A_GRADOS,
87
+ };
88
+ }
89
+ /** El largo total de una polilínea, sumando tramo a tramo. */
90
+ export function largoDeLineaEnMetros(linea) {
91
+ let total = 0;
92
+ for (let i = 1; i < linea.length; i += 1) {
93
+ const anterior = linea[i - 1];
94
+ const actual = linea[i];
95
+ if (anterior === undefined || actual === undefined)
96
+ continue;
97
+ total += distanciaEnMetros(anterior, actual);
98
+ }
99
+ return total;
100
+ }
101
+ /**
102
+ * Cuántos grados de longitud son `metros` a esa latitud.
103
+ *
104
+ * Un grado de longitud mide 111 km en el ecuador y cero en el polo, así que la
105
+ * conversión **depende de dónde estés**. Es la razón por la que un radio en
106
+ * metros no se puede convertir a una caja sin saber la latitud, y por la que
107
+ * expandir una caja con un número fijo de grados deforma la zona conforme se
108
+ * sube o se baja en el mapa.
109
+ */
110
+ export function gradosDeLongitudPorMetros(metros, lat) {
111
+ const coseno = Math.cos(lat * GRADOS_A_RADIANES);
112
+ // En el polo el coseno es cero y la división se va a infinito. Ahí una
113
+ // distancia horizontal no tiene equivalente en grados, y devolver 180 —media
114
+ // vuelta, o sea "todo"— es lo que hace que expandir una caja polar la deje
115
+ // cubriendo el mundo entero en vez de producir un `Infinity` que envenena
116
+ // todo lo que toque después.
117
+ if (Math.abs(coseno) < 1e-12)
118
+ return 180;
119
+ return (metros / (RADIO_TERRESTRE_EN_METROS * coseno)) * RADIANES_A_GRADOS;
120
+ }
121
+ /** Cuántos grados de latitud son `metros`. No depende de dónde: los meridianos son iguales. */
122
+ export function gradosDeLatitudPorMetros(metros) {
123
+ return (metros / RADIO_TERRESTRE_EN_METROS) * RADIANES_A_GRADOS;
124
+ }
125
+ //# sourceMappingURL=distancias.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"distancias.js","sourceRoot":"","sources":["../src/distancias.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Coordenada } from \"./coordenadas.ts\";\n\n/**\n * Distancias y rumbos sobre una esfera.\n *\n * ## Por qué esfera y no elipsoide\n *\n * La Tierra es un elipsoide y la fórmula exacta —Vincenty, Karney— existe. No\n * se usa aquí porque el error de la aproximación esférica es de **0,3 %** en\n * el peor caso y de mucho menos en distancias urbanas, y ninguna de las tres\n * cosas para las que Cerca mide distancias —ordenar negocios por cercanía,\n * decidir si un domicilio cae dentro de una zona, dibujar un radio— cambia de\n * respuesta por tres metros en mil. Vincenty, además, no converge para puntos\n * casi antipodales, que es un fallo que aparece con datos malos y no con datos\n * lejanos.\n *\n * Lo que **no** se puede hacer con esto es medir un terreno o una linde. Si\n * algún día hace falta esa precisión, el cambio es este archivo y nada más.\n */\n\n/**\n * El radio medio de la Tierra en metros: la media aritmética de los tres\n * semiejes del elipsoide GRS 80 / WGS 84 (R₁ de la IUGG).\n *\n * Está escrito con su procedencia porque circulan tres valores parecidos\n * —6.371.000, 6.372.797, 6.378.137— y el último es el radio **ecuatorial**,\n * no el medio: usarlo mete un 0,3 % de sesgo sistemático que nunca se nota\n * porque no falla, solo miente un poco siempre.\n */\nexport const RADIO_TERRESTRE_EN_METROS = 6371008.8;\n\nconst GRADOS_A_RADIANES = Math.PI / 180;\nconst RADIANES_A_GRADOS = 180 / Math.PI;\n\n/**\n * La distancia en metros entre dos coordenadas, por el camino más corto sobre\n * la esfera.\n *\n * Fórmula del haversine, elegida sobre la ley esférica de cosenos porque esta\n * última pierde precisión justo en las distancias cortas —dos esquinas de una\n * manzana— por cancelación al restar cosenos casi iguales, que es todo lo que\n * mide Cerca.\n */\nexport function distanciaEnMetros(a: Coordenada, b: Coordenada): number {\n const latitudA = a.lat * GRADOS_A_RADIANES;\n const latitudB = b.lat * GRADOS_A_RADIANES;\n const deltaLatitud = (b.lat - a.lat) * GRADOS_A_RADIANES;\n const deltaLongitud = (b.lon - a.lon) * GRADOS_A_RADIANES;\n\n const senoLatitud = Math.sin(deltaLatitud / 2);\n const senoLongitud = Math.sin(deltaLongitud / 2);\n\n const h =\n senoLatitud * senoLatitud +\n Math.cos(latitudA) * Math.cos(latitudB) * senoLongitud * senoLongitud;\n\n return 2 * RADIO_TERRESTRE_EN_METROS * Math.asin(Math.min(1, Math.sqrt(h)));\n}\n\n/**\n * El rumbo inicial de `a` hacia `b`, en grados desde el norte y en el sentido\n * de las agujas del reloj, dentro de `[0, 360)`.\n *\n * «Inicial» no es un matiz: sobre una esfera el rumbo cambia a lo largo del\n * trayecto, y el de vuelta no es este más 180 salvo sobre un meridiano. Sirve\n * para orientar un símbolo —el vehículo que se mueve— y no para navegar.\n */\nexport function rumboEnGrados(a: Coordenada, b: Coordenada): number {\n const latitudA = a.lat * GRADOS_A_RADIANES;\n const latitudB = b.lat * GRADOS_A_RADIANES;\n const deltaLongitud = (b.lon - a.lon) * GRADOS_A_RADIANES;\n\n const y = Math.sin(deltaLongitud) * Math.cos(latitudB);\n const x =\n Math.cos(latitudA) * Math.sin(latitudB) -\n Math.sin(latitudA) * Math.cos(latitudB) * Math.cos(deltaLongitud);\n\n const grados = Math.atan2(y, x) * RADIANES_A_GRADOS;\n return (grados + 360) % 360;\n}\n\n/**\n * La coordenada que queda a `metros` del origen siguiendo `rumboEnGrados`.\n *\n * Es lo que convierte «300 metros» en una caja o en un círculo dibujable, y\n * por eso vive aquí y no en el paquete del mapa: el Worker de rutas necesita\n * la misma operación y tiene que dar el mismo resultado.\n */\nexport function desplazar(\n origen: Coordenada,\n rumbo: number,\n metros: number,\n): Coordenada {\n const angular = metros / RADIO_TERRESTRE_EN_METROS;\n const rumboEnRadianes = rumbo * GRADOS_A_RADIANES;\n const latitudOrigen = origen.lat * GRADOS_A_RADIANES;\n const longitudOrigen = origen.lon * GRADOS_A_RADIANES;\n\n const latitudDestino = Math.asin(\n Math.sin(latitudOrigen) * Math.cos(angular) +\n Math.cos(latitudOrigen) * Math.sin(angular) * Math.cos(rumboEnRadianes),\n );\n const longitudDestino =\n longitudOrigen +\n Math.atan2(\n Math.sin(rumboEnRadianes) * Math.sin(angular) * Math.cos(latitudOrigen),\n Math.cos(angular) - Math.sin(latitudOrigen) * Math.sin(latitudDestino),\n );\n\n return {\n lon: ((longitudDestino * RADIANES_A_GRADOS + 540) % 360) - 180,\n lat: latitudDestino * RADIANES_A_GRADOS,\n };\n}\n\n/** El largo total de una polilínea, sumando tramo a tramo. */\nexport function largoDeLineaEnMetros(linea: readonly Coordenada[]): number {\n let total = 0;\n for (let i = 1; i < linea.length; i += 1) {\n const anterior = linea[i - 1];\n const actual = linea[i];\n if (anterior === undefined || actual === undefined) continue;\n total += distanciaEnMetros(anterior, actual);\n }\n return total;\n}\n\n/**\n * Cuántos grados de longitud son `metros` a esa latitud.\n *\n * Un grado de longitud mide 111 km en el ecuador y cero en el polo, así que la\n * conversión **depende de dónde estés**. Es la razón por la que un radio en\n * metros no se puede convertir a una caja sin saber la latitud, y por la que\n * expandir una caja con un número fijo de grados deforma la zona conforme se\n * sube o se baja en el mapa.\n */\nexport function gradosDeLongitudPorMetros(metros: number, lat: number): number {\n const coseno = Math.cos(lat * GRADOS_A_RADIANES);\n // En el polo el coseno es cero y la división se va a infinito. Ahí una\n // distancia horizontal no tiene equivalente en grados, y devolver 180 —media\n // vuelta, o sea \"todo\"— es lo que hace que expandir una caja polar la deje\n // cubriendo el mundo entero en vez de producir un `Infinity` que envenena\n // todo lo que toque después.\n if (Math.abs(coseno) < 1e-12) return 180;\n return (metros / (RADIO_TERRESTRE_EN_METROS * coseno)) * RADIANES_A_GRADOS;\n}\n\n/** Cuántos grados de latitud son `metros`. No depende de dónde: los meridianos son iguales. */\nexport function gradosDeLatitudPorMetros(metros: number): number {\n return (metros / RADIO_TERRESTRE_EN_METROS) * RADIANES_A_GRADOS;\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import type { Coordenada } from "./coordenadas.ts";
2
+ import type { Caja } from "./cajas.ts";
3
+ /** Cuántos caracteres se usan cuando nadie dice otra cosa. */
4
+ export declare const PRECISION_POR_DEFECTO = 9;
5
+ /**
6
+ * El geohash de una coordenada, con `precision` caracteres.
7
+ *
8
+ * Cada carácter añade cinco bits, que se reparten alternando longitud y
9
+ * latitud. La escala aproximada, para elegir precisión con criterio en vez de
10
+ * a ojo: 5 caracteres ≈ 5 km, 6 ≈ 1,2 km, 7 ≈ 150 m, 8 ≈ 38 m, 9 ≈ 5 m.
11
+ */
12
+ export declare function codificarGeohash(coordenada: Coordenada, precision?: number): string;
13
+ /**
14
+ * La celda que representa un geohash: su caja y su centro.
15
+ *
16
+ * Devuelve la **caja** y no solo el centro porque un geohash no es un punto:
17
+ * es un rectángulo, y tratarlo como punto es lo que hace que dos negocios de
18
+ * la misma manzana aparezcan exactamente encima del mismo píxel.
19
+ */
20
+ export declare function decodificarGeohash(geohash: string): {
21
+ centro: Coordenada;
22
+ caja: Caja;
23
+ };
24
+ /**
25
+ * Los ocho geohashes que rodean a uno, más él mismo: nueve en total.
26
+ *
27
+ * Es lo que hace utilizable el geohash como índice de proximidad. Un geohash
28
+ * por sí solo tiene un defecto conocido: **dos puntos a un metro pueden tener
29
+ * prefijos distintos** si caen a los dos lados de una división de la rejilla,
30
+ * y una consulta por prefijo se salta justo al vecino de enfrente. Buscar en
31
+ * la celda y en sus ocho vecinas es la forma estándar de taparlo.
32
+ *
33
+ * Se calcula por geometría —tomando el centro de la celda y desplazándolo el
34
+ * ancho de una celda en cada dirección— en vez de con las tablas de bordes
35
+ * clásicas. Es más lento y son treinta líneas menos: para nueve celdas, la
36
+ * diferencia no se mide, y las tablas son literalmente cuatro cadenas de 32
37
+ * caracteres que nadie puede revisar leyéndolas.
38
+ */
39
+ export declare function vecinosDeGeohash(geohash: string): string[];
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Geohash: una coordenada convertida en una cadena que se puede indexar.
3
+ *
4
+ * ## Por qué está en este paquete y no en la base de datos
5
+ *
6
+ * ADR-003 eligió geohash y caja delimitadora para resolver proximidad en D1,
7
+ * porque no hay extensión geográfica. Eso quiere decir que **hay dos sitios**
8
+ * que calculan el geohash de un negocio: el que lo escribe en la fila y el que
9
+ * consulta por cercanía. Si esos dos usaran implementaciones distintas —una en
10
+ * SQL, otra en el cliente— bastaría un desacuerdo en el último carácter para
11
+ * que un negocio deje de aparecer en su propia manzana, y no fallaría nada:
12
+ * simplemente no saldría.
13
+ *
14
+ * Vive aquí, en un paquete que corre igual dentro de un Worker y dentro del
15
+ * navegador, precisamente para que haya una sola implementación.
16
+ *
17
+ * ## El alfabeto
18
+ *
19
+ * Base 32 de Gustavo Niemeyer: los dígitos y las letras menos `a`, `i`, `l` y
20
+ * `o`, que se confunden al leerlas. No es un alfabeto elegible — es el que
21
+ * hace que un geohash de Cerca sea el mismo que el de cualquier otra
22
+ * herramienta.
23
+ */
24
+ const ALFABETO = "0123456789bcdefghjkmnpqrstuvwxyz";
25
+ /** Cuántos caracteres se usan cuando nadie dice otra cosa. */
26
+ export const PRECISION_POR_DEFECTO = 9;
27
+ /**
28
+ * El geohash de una coordenada, con `precision` caracteres.
29
+ *
30
+ * Cada carácter añade cinco bits, que se reparten alternando longitud y
31
+ * latitud. La escala aproximada, para elegir precisión con criterio en vez de
32
+ * a ojo: 5 caracteres ≈ 5 km, 6 ≈ 1,2 km, 7 ≈ 150 m, 8 ≈ 38 m, 9 ≈ 5 m.
33
+ */
34
+ export function codificarGeohash(coordenada, precision = PRECISION_POR_DEFECTO) {
35
+ if (!Number.isInteger(precision) || precision < 1 || precision > 12) {
36
+ throw new RangeError(`codificarGeohash: la precisión va de 1 a 12 caracteres, y llegó ${precision}. ` +
37
+ `Por encima de 12 los bits que se añaden ya no caben en un número de ` +
38
+ `punto flotante y los caracteres extra son ruido, no detalle.`);
39
+ }
40
+ let oeste = -180;
41
+ let este = 180;
42
+ let sur = -90;
43
+ let norte = 90;
44
+ let resultado = "";
45
+ let bits = 0;
46
+ let valor = 0;
47
+ let tocaLongitud = true;
48
+ while (resultado.length < precision) {
49
+ if (tocaLongitud) {
50
+ const medio = (oeste + este) / 2;
51
+ if (coordenada.lon >= medio) {
52
+ valor = valor * 2 + 1;
53
+ oeste = medio;
54
+ }
55
+ else {
56
+ valor *= 2;
57
+ este = medio;
58
+ }
59
+ }
60
+ else {
61
+ const medio = (sur + norte) / 2;
62
+ if (coordenada.lat >= medio) {
63
+ valor = valor * 2 + 1;
64
+ sur = medio;
65
+ }
66
+ else {
67
+ valor *= 2;
68
+ norte = medio;
69
+ }
70
+ }
71
+ tocaLongitud = !tocaLongitud;
72
+ bits += 1;
73
+ if (bits === 5) {
74
+ resultado += ALFABETO[valor] ?? "";
75
+ bits = 0;
76
+ valor = 0;
77
+ }
78
+ }
79
+ return resultado;
80
+ }
81
+ /**
82
+ * La celda que representa un geohash: su caja y su centro.
83
+ *
84
+ * Devuelve la **caja** y no solo el centro porque un geohash no es un punto:
85
+ * es un rectángulo, y tratarlo como punto es lo que hace que dos negocios de
86
+ * la misma manzana aparezcan exactamente encima del mismo píxel.
87
+ */
88
+ export function decodificarGeohash(geohash) {
89
+ let oeste = -180;
90
+ let este = 180;
91
+ let sur = -90;
92
+ let norte = 90;
93
+ let tocaLongitud = true;
94
+ for (const caracter of geohash.toLowerCase()) {
95
+ const indice = ALFABETO.indexOf(caracter);
96
+ if (indice === -1) {
97
+ throw new TypeError(`decodificarGeohash: "${caracter}" no está en el alfabeto base 32 ` +
98
+ `de geohash. Las letras a, i, l y o quedan fuera a propósito ` +
99
+ `porque se confunden al leerlas.`);
100
+ }
101
+ for (let bit = 4; bit >= 0; bit -= 1) {
102
+ const encendido = ((indice >> bit) & 1) === 1;
103
+ if (tocaLongitud) {
104
+ const medio = (oeste + este) / 2;
105
+ if (encendido)
106
+ oeste = medio;
107
+ else
108
+ este = medio;
109
+ }
110
+ else {
111
+ const medio = (sur + norte) / 2;
112
+ if (encendido)
113
+ sur = medio;
114
+ else
115
+ norte = medio;
116
+ }
117
+ tocaLongitud = !tocaLongitud;
118
+ }
119
+ }
120
+ return {
121
+ centro: { lon: (oeste + este) / 2, lat: (sur + norte) / 2 },
122
+ caja: [oeste, sur, este, norte],
123
+ };
124
+ }
125
+ /**
126
+ * Los ocho geohashes que rodean a uno, más él mismo: nueve en total.
127
+ *
128
+ * Es lo que hace utilizable el geohash como índice de proximidad. Un geohash
129
+ * por sí solo tiene un defecto conocido: **dos puntos a un metro pueden tener
130
+ * prefijos distintos** si caen a los dos lados de una división de la rejilla,
131
+ * y una consulta por prefijo se salta justo al vecino de enfrente. Buscar en
132
+ * la celda y en sus ocho vecinas es la forma estándar de taparlo.
133
+ *
134
+ * Se calcula por geometría —tomando el centro de la celda y desplazándolo el
135
+ * ancho de una celda en cada dirección— en vez de con las tablas de bordes
136
+ * clásicas. Es más lento y son treinta líneas menos: para nueve celdas, la
137
+ * diferencia no se mide, y las tablas son literalmente cuatro cadenas de 32
138
+ * caracteres que nadie puede revisar leyéndolas.
139
+ */
140
+ export function vecinosDeGeohash(geohash) {
141
+ const { caja } = decodificarGeohash(geohash);
142
+ const anchoEnGrados = caja[2] - caja[0];
143
+ const altoEnGrados = caja[3] - caja[1];
144
+ const centro = {
145
+ lon: (caja[0] + caja[2]) / 2,
146
+ lat: (caja[1] + caja[3]) / 2,
147
+ };
148
+ const vecinos = [];
149
+ for (const desplazamientoY of [1, 0, -1]) {
150
+ for (const desplazamientoX of [-1, 0, 1]) {
151
+ const lat = centro.lat + desplazamientoY * altoEnGrados;
152
+ // Fuera del planeta no hay celda vecina: en el borde superior de la
153
+ // rejilla, «el de arriba» no existe, y devolver el que da la vuelta por
154
+ // el polo sería devolver una celda que no toca a esta.
155
+ if (lat > 90 || lat < -90)
156
+ continue;
157
+ let lon = centro.lon + desplazamientoX * anchoEnGrados;
158
+ // La longitud sí da la vuelta: la celda al este de la última es la
159
+ // primera, y son vecinas de verdad.
160
+ if (lon > 180)
161
+ lon -= 360;
162
+ if (lon < -180)
163
+ lon += 360;
164
+ vecinos.push(codificarGeohash({ lon, lat }, geohash.length));
165
+ }
166
+ }
167
+ return vecinos;
168
+ }
169
+ //# sourceMappingURL=geohash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"geohash.js","sourceRoot":"","sources":["../src/geohash.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Coordenada } from \"./coordenadas.ts\";\nimport type { Caja } from \"./cajas.ts\";\n\n/**\n * Geohash: una coordenada convertida en una cadena que se puede indexar.\n *\n * ## Por qué está en este paquete y no en la base de datos\n *\n * ADR-003 eligió geohash y caja delimitadora para resolver proximidad en D1,\n * porque no hay extensión geográfica. Eso quiere decir que **hay dos sitios**\n * que calculan el geohash de un negocio: el que lo escribe en la fila y el que\n * consulta por cercanía. Si esos dos usaran implementaciones distintas —una en\n * SQL, otra en el cliente— bastaría un desacuerdo en el último carácter para\n * que un negocio deje de aparecer en su propia manzana, y no fallaría nada:\n * simplemente no saldría.\n *\n * Vive aquí, en un paquete que corre igual dentro de un Worker y dentro del\n * navegador, precisamente para que haya una sola implementación.\n *\n * ## El alfabeto\n *\n * Base 32 de Gustavo Niemeyer: los dígitos y las letras menos `a`, `i`, `l` y\n * `o`, que se confunden al leerlas. No es un alfabeto elegible — es el que\n * hace que un geohash de Cerca sea el mismo que el de cualquier otra\n * herramienta.\n */\n\nconst ALFABETO = \"0123456789bcdefghjkmnpqrstuvwxyz\";\n\n/** Cuántos caracteres se usan cuando nadie dice otra cosa. */\nexport const PRECISION_POR_DEFECTO = 9;\n\n/**\n * El geohash de una coordenada, con `precision` caracteres.\n *\n * Cada carácter añade cinco bits, que se reparten alternando longitud y\n * latitud. La escala aproximada, para elegir precisión con criterio en vez de\n * a ojo: 5 caracteres ≈ 5 km, 6 ≈ 1,2 km, 7 ≈ 150 m, 8 ≈ 38 m, 9 ≈ 5 m.\n */\nexport function codificarGeohash(\n coordenada: Coordenada,\n precision: number = PRECISION_POR_DEFECTO,\n): string {\n if (!Number.isInteger(precision) || precision < 1 || precision > 12) {\n throw new RangeError(\n `codificarGeohash: la precisión va de 1 a 12 caracteres, y llegó ${precision}. ` +\n `Por encima de 12 los bits que se añaden ya no caben en un número de ` +\n `punto flotante y los caracteres extra son ruido, no detalle.`,\n );\n }\n\n let oeste = -180;\n let este = 180;\n let sur = -90;\n let norte = 90;\n\n let resultado = \"\";\n let bits = 0;\n let valor = 0;\n let tocaLongitud = true;\n\n while (resultado.length < precision) {\n if (tocaLongitud) {\n const medio = (oeste + este) / 2;\n if (coordenada.lon >= medio) {\n valor = valor * 2 + 1;\n oeste = medio;\n } else {\n valor *= 2;\n este = medio;\n }\n } else {\n const medio = (sur + norte) / 2;\n if (coordenada.lat >= medio) {\n valor = valor * 2 + 1;\n sur = medio;\n } else {\n valor *= 2;\n norte = medio;\n }\n }\n tocaLongitud = !tocaLongitud;\n\n bits += 1;\n if (bits === 5) {\n resultado += ALFABETO[valor] ?? \"\";\n bits = 0;\n valor = 0;\n }\n }\n\n return resultado;\n}\n\n/**\n * La celda que representa un geohash: su caja y su centro.\n *\n * Devuelve la **caja** y no solo el centro porque un geohash no es un punto:\n * es un rectángulo, y tratarlo como punto es lo que hace que dos negocios de\n * la misma manzana aparezcan exactamente encima del mismo píxel.\n */\nexport function decodificarGeohash(geohash: string): {\n centro: Coordenada;\n caja: Caja;\n} {\n let oeste = -180;\n let este = 180;\n let sur = -90;\n let norte = 90;\n let tocaLongitud = true;\n\n for (const caracter of geohash.toLowerCase()) {\n const indice = ALFABETO.indexOf(caracter);\n if (indice === -1) {\n throw new TypeError(\n `decodificarGeohash: \"${caracter}\" no está en el alfabeto base 32 ` +\n `de geohash. Las letras a, i, l y o quedan fuera a propósito ` +\n `porque se confunden al leerlas.`,\n );\n }\n for (let bit = 4; bit >= 0; bit -= 1) {\n const encendido = ((indice >> bit) & 1) === 1;\n if (tocaLongitud) {\n const medio = (oeste + este) / 2;\n if (encendido) oeste = medio;\n else este = medio;\n } else {\n const medio = (sur + norte) / 2;\n if (encendido) sur = medio;\n else norte = medio;\n }\n tocaLongitud = !tocaLongitud;\n }\n }\n\n return {\n centro: { lon: (oeste + este) / 2, lat: (sur + norte) / 2 },\n caja: [oeste, sur, este, norte],\n };\n}\n\n/**\n * Los ocho geohashes que rodean a uno, más él mismo: nueve en total.\n *\n * Es lo que hace utilizable el geohash como índice de proximidad. Un geohash\n * por sí solo tiene un defecto conocido: **dos puntos a un metro pueden tener\n * prefijos distintos** si caen a los dos lados de una división de la rejilla,\n * y una consulta por prefijo se salta justo al vecino de enfrente. Buscar en\n * la celda y en sus ocho vecinas es la forma estándar de taparlo.\n *\n * Se calcula por geometría —tomando el centro de la celda y desplazándolo el\n * ancho de una celda en cada dirección— en vez de con las tablas de bordes\n * clásicas. Es más lento y son treinta líneas menos: para nueve celdas, la\n * diferencia no se mide, y las tablas son literalmente cuatro cadenas de 32\n * caracteres que nadie puede revisar leyéndolas.\n */\nexport function vecinosDeGeohash(geohash: string): string[] {\n const { caja } = decodificarGeohash(geohash);\n const anchoEnGrados = caja[2] - caja[0];\n const altoEnGrados = caja[3] - caja[1];\n const centro = {\n lon: (caja[0] + caja[2]) / 2,\n lat: (caja[1] + caja[3]) / 2,\n };\n\n const vecinos: string[] = [];\n for (const desplazamientoY of [1, 0, -1]) {\n for (const desplazamientoX of [-1, 0, 1]) {\n const lat = centro.lat + desplazamientoY * altoEnGrados;\n // Fuera del planeta no hay celda vecina: en el borde superior de la\n // rejilla, «el de arriba» no existe, y devolver el que da la vuelta por\n // el polo sería devolver una celda que no toca a esta.\n if (lat > 90 || lat < -90) continue;\n let lon = centro.lon + desplazamientoX * anchoEnGrados;\n // La longitud sí da la vuelta: la celda al este de la última es la\n // primera, y son vecinas de verdad.\n if (lon > 180) lon -= 360;\n if (lon < -180) lon += 360;\n vecinos.push(codificarGeohash({ lon, lat }, geohash.length));\n }\n }\n return vecinos;\n}\n"]}
@@ -0,0 +1,85 @@
1
+ import { type Coordenada } from "./coordenadas.ts";
2
+ /**
3
+ * Los tipos de GeoJSON que este ecosistema usa, declarados aquí.
4
+ *
5
+ * ## Por qué en inglés
6
+ *
7
+ * `type`, `coordinates`, `properties`, `Feature`, `FeatureCollection` **son el
8
+ * formato**, no nombres que se elijan: van dentro del JSON que viaja a
9
+ * MapLibre y a Valhalla, y traducirlos produciría un documento que ninguno de
10
+ * los dos entiende. Es la excepción escrita de la convención —la palabra
11
+ * literal de una herramienta se queda como está—, y aquí es literal de verdad:
12
+ * cambiar `coordinates` por `coordenadas` no es una preferencia de estilo, es
13
+ * romper el archivo.
14
+ *
15
+ * ## Por qué declarados y no importados de `@types/geojson`
16
+ *
17
+ * Porque son doce líneas y una dependencia menos en tres paquetes que tienen
18
+ * que instalarse en un navegador, en workerd y en Node. Son estructuralmente
19
+ * compatibles con las de `@types/geojson`, así que lo que produce este paquete
20
+ * entra en MapLibre sin conversión.
21
+ *
22
+ * ## El orden, que es la trampa
23
+ *
24
+ * En GeoJSON una posición es **`[lon, lat]`**, en ese orden. Casi todo lo
25
+ * demás en el mundo dice «latitud y longitud», en el orden contrario. Cambiar
26
+ * el orden **no lanza ningún error**: produce un mapa que dibuja Pereira en
27
+ * mitad del océano Índico, y solo se descubre mirando. Por eso las dos
28
+ * conversiones de este archivo existen y por eso nadie debería escribir el par
29
+ * a mano.
30
+ */
31
+ /** Una posición GeoJSON: `[lon, lat]`, opcionalmente con altura. */
32
+ export type Posicion = readonly [lon: number, lat: number] | readonly [lon: number, lat: number, altura: number];
33
+ export interface PuntoGeoJSON {
34
+ readonly type: "Point";
35
+ readonly coordinates: Posicion;
36
+ }
37
+ export interface LineaGeoJSON {
38
+ readonly type: "LineString";
39
+ readonly coordinates: readonly Posicion[];
40
+ }
41
+ export interface PoligonoGeoJSON {
42
+ readonly type: "Polygon";
43
+ /** Anillo exterior primero; los siguientes son huecos. */
44
+ readonly coordinates: readonly (readonly Posicion[])[];
45
+ }
46
+ export interface MultiPoligonoGeoJSON {
47
+ readonly type: "MultiPolygon";
48
+ readonly coordinates: readonly (readonly (readonly Posicion[])[])[];
49
+ }
50
+ export interface MultiLineaGeoJSON {
51
+ readonly type: "MultiLineString";
52
+ readonly coordinates: readonly (readonly Posicion[])[];
53
+ }
54
+ export type GeometriaGeoJSON = PuntoGeoJSON | LineaGeoJSON | MultiLineaGeoJSON | PoligonoGeoJSON | MultiPoligonoGeoJSON;
55
+ export interface EntidadGeoJSON<P extends Record<string, unknown> = Record<string, unknown>> {
56
+ readonly type: "Feature";
57
+ readonly id?: string | number;
58
+ readonly geometry: GeometriaGeoJSON;
59
+ readonly properties: P;
60
+ }
61
+ export interface ColeccionDeEntidades<P extends Record<string, unknown> = Record<string, unknown>> {
62
+ readonly type: "FeatureCollection";
63
+ readonly features: readonly EntidadGeoJSON<P>[];
64
+ }
65
+ /** De coordenada a posición GeoJSON. El único sitio donde se decide el orden. */
66
+ export declare function aPosicion(coordenada: Coordenada): Posicion;
67
+ /** De posición GeoJSON a coordenada. El otro único sitio. */
68
+ export declare function dePosicion(posicion: Posicion): Coordenada;
69
+ /**
70
+ * Recorre las posiciones de cualquier geometría, sin importar cuántos niveles
71
+ * de anidamiento tenga.
72
+ *
73
+ * Existe para no escribir cuatro veces el mismo bucle con distinta
74
+ * profundidad: eso es lo que produce la caja de una ruta que ignora el segundo
75
+ * tramo, un fallo que no se ve porque el encuadre queda «casi bien».
76
+ */
77
+ export declare function posicionesDe(geometria: GeometriaGeoJSON): Generator<Posicion, void, undefined>;
78
+ /** Las coordenadas de una geometría, ya en la forma que usa el resto del paquete. */
79
+ export declare function coordenadasDe(geometria: GeometriaGeoJSON): Generator<Coordenada, void, undefined>;
80
+ /** Una entidad GeoJSON con la geometría y las propiedades dadas. */
81
+ export declare function entidad<P extends Record<string, unknown>>(geometria: GeometriaGeoJSON, properties: P, id?: string | number): EntidadGeoJSON<P>;
82
+ /** Una colección de entidades. La forma que espera una fuente GeoJSON de MapLibre. */
83
+ export declare function coleccion<P extends Record<string, unknown>>(features: readonly EntidadGeoJSON<P>[]): ColeccionDeEntidades<P>;
84
+ /** La colección vacía. Es lo que se le da a una fuente para borrarla sin quitarla del estilo. */
85
+ export declare function coleccionVacia<P extends Record<string, unknown> = Record<string, unknown>>(): ColeccionDeEntidades<P>;
@@ -0,0 +1,61 @@
1
+ import { esCoordenada } from "./coordenadas.js";
2
+ /** De coordenada a posición GeoJSON. El único sitio donde se decide el orden. */
3
+ export function aPosicion(coordenada) {
4
+ return [coordenada.lon, coordenada.lat];
5
+ }
6
+ /** De posición GeoJSON a coordenada. El otro único sitio. */
7
+ export function dePosicion(posicion) {
8
+ return { lon: posicion[0], lat: posicion[1] };
9
+ }
10
+ /**
11
+ * Recorre las posiciones de cualquier geometría, sin importar cuántos niveles
12
+ * de anidamiento tenga.
13
+ *
14
+ * Existe para no escribir cuatro veces el mismo bucle con distinta
15
+ * profundidad: eso es lo que produce la caja de una ruta que ignora el segundo
16
+ * tramo, un fallo que no se ve porque el encuadre queda «casi bien».
17
+ */
18
+ export function* posicionesDe(geometria) {
19
+ switch (geometria.type) {
20
+ case "Point":
21
+ yield geometria.coordinates;
22
+ return;
23
+ case "LineString":
24
+ yield* geometria.coordinates;
25
+ return;
26
+ case "MultiLineString":
27
+ case "Polygon":
28
+ for (const anillo of geometria.coordinates)
29
+ yield* anillo;
30
+ return;
31
+ case "MultiPolygon":
32
+ for (const poligono of geometria.coordinates) {
33
+ for (const anillo of poligono)
34
+ yield* anillo;
35
+ }
36
+ return;
37
+ }
38
+ }
39
+ /** Las coordenadas de una geometría, ya en la forma que usa el resto del paquete. */
40
+ export function* coordenadasDe(geometria) {
41
+ for (const posicion of posicionesDe(geometria)) {
42
+ const coordenada = dePosicion(posicion);
43
+ if (esCoordenada(coordenada))
44
+ yield coordenada;
45
+ }
46
+ }
47
+ /** Una entidad GeoJSON con la geometría y las propiedades dadas. */
48
+ export function entidad(geometria, properties, id) {
49
+ return id === undefined
50
+ ? { type: "Feature", geometry: geometria, properties }
51
+ : { type: "Feature", id, geometry: geometria, properties };
52
+ }
53
+ /** Una colección de entidades. La forma que espera una fuente GeoJSON de MapLibre. */
54
+ export function coleccion(features) {
55
+ return { type: "FeatureCollection", features };
56
+ }
57
+ /** La colección vacía. Es lo que se le da a una fuente para borrarla sin quitarla del estilo. */
58
+ export function coleccionVacia() {
59
+ return { type: "FeatureCollection", features: [] };
60
+ }
61
+ //# sourceMappingURL=geojson.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"geojson.js","sourceRoot":"","sources":["../src/geojson.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAsFjE,iFAAiF;AACjF,MAAM,UAAU,SAAS,CAAC,UAAsB;IAC9C,OAAO,CAAC,UAAU,CAAC,GAAG,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC;AAC1C,CAAC;AAED,6DAA6D;AAC7D,MAAM,UAAU,UAAU,CAAC,QAAkB;IAC3C,OAAO,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;AAChD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,SAAS,CAAC,CAAC,YAAY,CAC3B,SAA2B;IAE3B,QAAQ,SAAS,CAAC,IAAI,EAAE,CAAC;QACvB,KAAK,OAAO;YACV,MAAM,SAAS,CAAC,WAAW,CAAC;YAC5B,OAAO;QACT,KAAK,YAAY;YACf,KAAK,CAAC,CAAC,SAAS,CAAC,WAAW,CAAC;YAC7B,OAAO;QACT,KAAK,iBAAiB,CAAC;QACvB,KAAK,SAAS;YACZ,KAAK,MAAM,MAAM,IAAI,SAAS,CAAC,WAAW;gBAAE,KAAK,CAAC,CAAC,MAAM,CAAC;YAC1D,OAAO;QACT,KAAK,cAAc;YACjB,KAAK,MAAM,QAAQ,IAAI,SAAS,CAAC,WAAW,EAAE,CAAC;gBAC7C,KAAK,MAAM,MAAM,IAAI,QAAQ;oBAAE,KAAK,CAAC,CAAC,MAAM,CAAC;YAC/C,CAAC;YACD,OAAO;IACX,CAAC;AACH,CAAC;AAED,qFAAqF;AACrF,MAAM,SAAS,CAAC,CAAC,aAAa,CAC5B,SAA2B;IAE3B,KAAK,MAAM,QAAQ,IAAI,YAAY,CAAC,SAAS,CAAC,EAAE,CAAC;QAC/C,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,YAAY,CAAC,UAAU,CAAC;YAAE,MAAM,UAAU,CAAC;IACjD,CAAC;AACH,CAAC;AAED,oEAAoE;AACpE,MAAM,UAAU,OAAO,CACrB,SAA2B,EAC3B,UAAa,EACb,EAAoB;IAEpB,OAAO,EAAE,KAAK,SAAS;QACrB,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE;QACtD,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC;AAC/D,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,SAAS,CACvB,QAAsC;IAEtC,OAAO,EAAE,IAAI,EAAE,mBAAmB,EAAE,QAAQ,EAAE,CAAC;AACjD,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,cAAc;IAG5B,OAAO,EAAE,IAAI,EAAE,mBAAmB,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;AACrD,CAAC","sourcesContent":["import { type Coordenada, esCoordenada } from \"./coordenadas.ts\";\n\n/**\n * Los tipos de GeoJSON que este ecosistema usa, declarados aquí.\n *\n * ## Por qué en inglés\n *\n * `type`, `coordinates`, `properties`, `Feature`, `FeatureCollection` **son el\n * formato**, no nombres que se elijan: van dentro del JSON que viaja a\n * MapLibre y a Valhalla, y traducirlos produciría un documento que ninguno de\n * los dos entiende. Es la excepción escrita de la convención —la palabra\n * literal de una herramienta se queda como está—, y aquí es literal de verdad:\n * cambiar `coordinates` por `coordenadas` no es una preferencia de estilo, es\n * romper el archivo.\n *\n * ## Por qué declarados y no importados de `@types/geojson`\n *\n * Porque son doce líneas y una dependencia menos en tres paquetes que tienen\n * que instalarse en un navegador, en workerd y en Node. Son estructuralmente\n * compatibles con las de `@types/geojson`, así que lo que produce este paquete\n * entra en MapLibre sin conversión.\n *\n * ## El orden, que es la trampa\n *\n * En GeoJSON una posición es **`[lon, lat]`**, en ese orden. Casi todo lo\n * demás en el mundo dice «latitud y longitud», en el orden contrario. Cambiar\n * el orden **no lanza ningún error**: produce un mapa que dibuja Pereira en\n * mitad del océano Índico, y solo se descubre mirando. Por eso las dos\n * conversiones de este archivo existen y por eso nadie debería escribir el par\n * a mano.\n */\n\n/** Una posición GeoJSON: `[lon, lat]`, opcionalmente con altura. */\nexport type Posicion =\n | readonly [lon: number, lat: number]\n | readonly [lon: number, lat: number, altura: number];\n\nexport interface PuntoGeoJSON {\n readonly type: \"Point\";\n readonly coordinates: Posicion;\n}\n\nexport interface LineaGeoJSON {\n readonly type: \"LineString\";\n readonly coordinates: readonly Posicion[];\n}\n\nexport interface PoligonoGeoJSON {\n readonly type: \"Polygon\";\n /** Anillo exterior primero; los siguientes son huecos. */\n readonly coordinates: readonly (readonly Posicion[])[];\n}\n\nexport interface MultiPoligonoGeoJSON {\n readonly type: \"MultiPolygon\";\n readonly coordinates: readonly (readonly (readonly Posicion[])[])[];\n}\n\nexport interface MultiLineaGeoJSON {\n readonly type: \"MultiLineString\";\n readonly coordinates: readonly (readonly Posicion[])[];\n}\n\nexport type GeometriaGeoJSON =\n | PuntoGeoJSON\n | LineaGeoJSON\n | MultiLineaGeoJSON\n | PoligonoGeoJSON\n | MultiPoligonoGeoJSON;\n\nexport interface EntidadGeoJSON<\n P extends Record<string, unknown> = Record<string, unknown>,\n> {\n readonly type: \"Feature\";\n readonly id?: string | number;\n readonly geometry: GeometriaGeoJSON;\n readonly properties: P;\n}\n\nexport interface ColeccionDeEntidades<\n P extends Record<string, unknown> = Record<string, unknown>,\n> {\n readonly type: \"FeatureCollection\";\n readonly features: readonly EntidadGeoJSON<P>[];\n}\n\n/** De coordenada a posición GeoJSON. El único sitio donde se decide el orden. */\nexport function aPosicion(coordenada: Coordenada): Posicion {\n return [coordenada.lon, coordenada.lat];\n}\n\n/** De posición GeoJSON a coordenada. El otro único sitio. */\nexport function dePosicion(posicion: Posicion): Coordenada {\n return { lon: posicion[0], lat: posicion[1] };\n}\n\n/**\n * Recorre las posiciones de cualquier geometría, sin importar cuántos niveles\n * de anidamiento tenga.\n *\n * Existe para no escribir cuatro veces el mismo bucle con distinta\n * profundidad: eso es lo que produce la caja de una ruta que ignora el segundo\n * tramo, un fallo que no se ve porque el encuadre queda «casi bien».\n */\nexport function* posicionesDe(\n geometria: GeometriaGeoJSON,\n): Generator<Posicion, void, undefined> {\n switch (geometria.type) {\n case \"Point\":\n yield geometria.coordinates;\n return;\n case \"LineString\":\n yield* geometria.coordinates;\n return;\n case \"MultiLineString\":\n case \"Polygon\":\n for (const anillo of geometria.coordinates) yield* anillo;\n return;\n case \"MultiPolygon\":\n for (const poligono of geometria.coordinates) {\n for (const anillo of poligono) yield* anillo;\n }\n return;\n }\n}\n\n/** Las coordenadas de una geometría, ya en la forma que usa el resto del paquete. */\nexport function* coordenadasDe(\n geometria: GeometriaGeoJSON,\n): Generator<Coordenada, void, undefined> {\n for (const posicion of posicionesDe(geometria)) {\n const coordenada = dePosicion(posicion);\n if (esCoordenada(coordenada)) yield coordenada;\n }\n}\n\n/** Una entidad GeoJSON con la geometría y las propiedades dadas. */\nexport function entidad<P extends Record<string, unknown>>(\n geometria: GeometriaGeoJSON,\n properties: P,\n id?: string | number,\n): EntidadGeoJSON<P> {\n return id === undefined\n ? { type: \"Feature\", geometry: geometria, properties }\n : { type: \"Feature\", id, geometry: geometria, properties };\n}\n\n/** Una colección de entidades. La forma que espera una fuente GeoJSON de MapLibre. */\nexport function coleccion<P extends Record<string, unknown>>(\n features: readonly EntidadGeoJSON<P>[],\n): ColeccionDeEntidades<P> {\n return { type: \"FeatureCollection\", features };\n}\n\n/** La colección vacía. Es lo que se le da a una fuente para borrarla sin quitarla del estilo. */\nexport function coleccionVacia<\n P extends Record<string, unknown> = Record<string, unknown>,\n>(): ColeccionDeEntidades<P> {\n return { type: \"FeatureCollection\", features: [] };\n}\n"]}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `@cerca.red/geo` — geometría pura para el ecosistema Cerca.
3
+ *
4
+ * Coordenadas, cajas, distancias, geohash y los tipos de GeoJSON. Nada de esto
5
+ * dibuja ni lee un archivo: corre igual en un navegador, dentro de workerd y
6
+ * en Node, que es la propiedad de la que depende que la distancia que ve el
7
+ * usuario y la que decide un reparto no puedan discrepar (ADR-027).
8
+ *
9
+ * `eslint.config.mjs` lo mantiene así: este paquete no puede importar `node:*`,
10
+ * ni `maplibre-gl`, ni tocar un global del navegador.
11
+ *
12
+ * ## Qué se publica de aquí
13
+ *
14
+ * ADR-027 decidió publicar este paquete en el registro público bajo el scope
15
+ * `@cerca.red`. Lo que sale al registro es `dist/` —JavaScript y
16
+ * declaraciones que emite `tsc -p tsconfig.build.json`—, y lo que consume el
17
+ * workspace sigue siendo el TypeScript de `src/`: el `exports` de este
18
+ * paquete apunta a la fuente y `publishConfig.exports` lo sustituye por
19
+ * `dist/` al empaquetar. Así el taller y las pruebas no necesitan un paso de
20
+ * compilación, y el consumidor de npm no necesita un empacador.
21
+ *
22
+ * La **primera** publicación bajo `@cerca.red` la ejecuta una persona
23
+ * (ADR-027, «qué exige validación humana»): es hacia afuera y no se deshace.
24
+ */
25
+ export { type Coordenada, LATITUD_MAXIMA_WEB_MERCATOR, coordenadasIguales, esCoordenada, exigirCoordenada, normalizarCoordenada, normalizarLongitud, recortarLatitud, } from "./coordenadas.ts";
26
+ export { type Caja, DIAGONAL_MINIMA_EN_METROS, cajaContiene, cajaDeCoordenadas, cajaDeGeometria, cajaDeRadio, cajasSeCruzan, centroDeCaja, diagonalDeCajaEnMetros, esCaja, esCajaDegenerada, exigirCaja, expandirCajaEnMetros, unirCajas, } from "./cajas.ts";
27
+ export { RADIO_TERRESTRE_EN_METROS, desplazar, distanciaEnMetros, gradosDeLatitudPorMetros, gradosDeLongitudPorMetros, largoDeLineaEnMetros, rumboEnGrados, } from "./distancias.ts";
28
+ export { type ColeccionDeEntidades, type EntidadGeoJSON, type GeometriaGeoJSON, type LineaGeoJSON, type MultiLineaGeoJSON, type MultiPoligonoGeoJSON, type PoligonoGeoJSON, type Posicion, type PuntoGeoJSON, aPosicion, coleccion, coleccionVacia, coordenadasDe, dePosicion, entidad, posicionesDe, } from "./geojson.ts";
29
+ export { LADOS_POR_DEFECTO, circuloComoPoligono } from "./circulos.ts";
30
+ export { PRECISION_POR_DEFECTO, codificarGeohash, decodificarGeohash, vecinosDeGeohash, } from "./geohash.ts";
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@cerca.red/geo` — geometría pura para el ecosistema Cerca.
3
+ *
4
+ * Coordenadas, cajas, distancias, geohash y los tipos de GeoJSON. Nada de esto
5
+ * dibuja ni lee un archivo: corre igual en un navegador, dentro de workerd y
6
+ * en Node, que es la propiedad de la que depende que la distancia que ve el
7
+ * usuario y la que decide un reparto no puedan discrepar (ADR-027).
8
+ *
9
+ * `eslint.config.mjs` lo mantiene así: este paquete no puede importar `node:*`,
10
+ * ni `maplibre-gl`, ni tocar un global del navegador.
11
+ *
12
+ * ## Qué se publica de aquí
13
+ *
14
+ * ADR-027 decidió publicar este paquete en el registro público bajo el scope
15
+ * `@cerca.red`. Lo que sale al registro es `dist/` —JavaScript y
16
+ * declaraciones que emite `tsc -p tsconfig.build.json`—, y lo que consume el
17
+ * workspace sigue siendo el TypeScript de `src/`: el `exports` de este
18
+ * paquete apunta a la fuente y `publishConfig.exports` lo sustituye por
19
+ * `dist/` al empaquetar. Así el taller y las pruebas no necesitan un paso de
20
+ * compilación, y el consumidor de npm no necesita un empacador.
21
+ *
22
+ * La **primera** publicación bajo `@cerca.red` la ejecuta una persona
23
+ * (ADR-027, «qué exige validación humana»): es hacia afuera y no se deshace.
24
+ */
25
+ export { LATITUD_MAXIMA_WEB_MERCATOR, coordenadasIguales, esCoordenada, exigirCoordenada, normalizarCoordenada, normalizarLongitud, recortarLatitud, } from "./coordenadas.js";
26
+ export { DIAGONAL_MINIMA_EN_METROS, cajaContiene, cajaDeCoordenadas, cajaDeGeometria, cajaDeRadio, cajasSeCruzan, centroDeCaja, diagonalDeCajaEnMetros, esCaja, esCajaDegenerada, exigirCaja, expandirCajaEnMetros, unirCajas, } from "./cajas.js";
27
+ export { RADIO_TERRESTRE_EN_METROS, desplazar, distanciaEnMetros, gradosDeLatitudPorMetros, gradosDeLongitudPorMetros, largoDeLineaEnMetros, rumboEnGrados, } from "./distancias.js";
28
+ export { aPosicion, coleccion, coleccionVacia, coordenadasDe, dePosicion, entidad, posicionesDe, } from "./geojson.js";
29
+ export { LADOS_POR_DEFECTO, circuloComoPoligono } from "./circulos.js";
30
+ export { PRECISION_POR_DEFECTO, codificarGeohash, decodificarGeohash, vecinosDeGeohash, } from "./geohash.js";
31
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAEL,2BAA2B,EAC3B,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAEL,yBAAyB,EACzB,YAAY,EACZ,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,aAAa,EACb,YAAY,EACZ,sBAAsB,EACtB,MAAM,EACN,gBAAgB,EAChB,UAAU,EACV,oBAAoB,EACpB,SAAS,GACV,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,yBAAyB,EACzB,SAAS,EACT,iBAAiB,EACjB,wBAAwB,EACxB,yBAAyB,EACzB,oBAAoB,EACpB,aAAa,GACd,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAUL,SAAS,EACT,SAAS,EACT,cAAc,EACd,aAAa,EACb,UAAU,EACV,OAAO,EACP,YAAY,GACb,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEvE,OAAO,EACL,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,cAAc,CAAC","sourcesContent":["/**\n * `@cerca.red/geo` — geometría pura para el ecosistema Cerca.\n *\n * Coordenadas, cajas, distancias, geohash y los tipos de GeoJSON. Nada de esto\n * dibuja ni lee un archivo: corre igual en un navegador, dentro de workerd y\n * en Node, que es la propiedad de la que depende que la distancia que ve el\n * usuario y la que decide un reparto no puedan discrepar (ADR-027).\n *\n * `eslint.config.mjs` lo mantiene así: este paquete no puede importar `node:*`,\n * ni `maplibre-gl`, ni tocar un global del navegador.\n *\n * ## Qué se publica de aquí\n *\n * ADR-027 decidió publicar este paquete en el registro público bajo el scope\n * `@cerca.red`. Lo que sale al registro es `dist/` —JavaScript y\n * declaraciones que emite `tsc -p tsconfig.build.json`—, y lo que consume el\n * workspace sigue siendo el TypeScript de `src/`: el `exports` de este\n * paquete apunta a la fuente y `publishConfig.exports` lo sustituye por\n * `dist/` al empaquetar. Así el taller y las pruebas no necesitan un paso de\n * compilación, y el consumidor de npm no necesita un empacador.\n *\n * La **primera** publicación bajo `@cerca.red` la ejecuta una persona\n * (ADR-027, «qué exige validación humana»): es hacia afuera y no se deshace.\n */\n\nexport {\n type Coordenada,\n LATITUD_MAXIMA_WEB_MERCATOR,\n coordenadasIguales,\n esCoordenada,\n exigirCoordenada,\n normalizarCoordenada,\n normalizarLongitud,\n recortarLatitud,\n} from \"./coordenadas.ts\";\n\nexport {\n type Caja,\n DIAGONAL_MINIMA_EN_METROS,\n cajaContiene,\n cajaDeCoordenadas,\n cajaDeGeometria,\n cajaDeRadio,\n cajasSeCruzan,\n centroDeCaja,\n diagonalDeCajaEnMetros,\n esCaja,\n esCajaDegenerada,\n exigirCaja,\n expandirCajaEnMetros,\n unirCajas,\n} from \"./cajas.ts\";\n\nexport {\n RADIO_TERRESTRE_EN_METROS,\n desplazar,\n distanciaEnMetros,\n gradosDeLatitudPorMetros,\n gradosDeLongitudPorMetros,\n largoDeLineaEnMetros,\n rumboEnGrados,\n} from \"./distancias.ts\";\n\nexport {\n type ColeccionDeEntidades,\n type EntidadGeoJSON,\n type GeometriaGeoJSON,\n type LineaGeoJSON,\n type MultiLineaGeoJSON,\n type MultiPoligonoGeoJSON,\n type PoligonoGeoJSON,\n type Posicion,\n type PuntoGeoJSON,\n aPosicion,\n coleccion,\n coleccionVacia,\n coordenadasDe,\n dePosicion,\n entidad,\n posicionesDe,\n} from \"./geojson.ts\";\n\nexport { LADOS_POR_DEFECTO, circuloComoPoligono } from \"./circulos.ts\";\n\nexport {\n PRECISION_POR_DEFECTO,\n codificarGeohash,\n decodificarGeohash,\n vecinosDeGeohash,\n} from \"./geohash.ts\";\n"]}
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@cerca.red/geo",
3
+ "version": "0.2.0",
4
+ "description": "Geometría pura para el ecosistema Cerca: coordenadas, cajas, distancias y geohash. Corre igual en un navegador, en workerd y en Node.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "sideEffects": false,
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Exus-Agencia-Web/cerca-maps.git",
23
+ "directory": "packages/geo"
24
+ },
25
+ "scripts": {
26
+ "construir": "tsc -p tsconfig.build.json"
27
+ }
28
+ }