diff --git a/server/createMapPolygons.ts b/server/createMapPolygons.ts
index 2eddd194a..0bbc18039 100644
--- a/server/createMapPolygons.ts
+++ b/server/createMapPolygons.ts
@@ -1,5 +1,9 @@
import fs from 'fs';
+import polygonClipping from 'polygon-clipping';
+import type { MultiPolygon, Polygon, Ring } from 'polygon-clipping';
+import simplify from 'simplify-js';
+
const __dirname = new URL('.', import.meta.url).pathname;
const getFilePath = (code: string, level: number): string => {
@@ -37,29 +41,37 @@ interface GeoJSONSubset {
}[]
}
-const getCountryCode = (code: string): [string, 0 | 1 | 2] => {
- if (code.length === 3) {
- return [code, 0];
- } else {
- const level = code.length === 5 ? 1 : 2;
- let countryCode;
- switch (code.slice(0, 2)) {
- case 'BE': countryCode = 'BEL';
- break;
- case 'CH': countryCode = 'CHE';
- break;
- default:
- throw new Error(`Unknown ${countryCode}`);
- }
- return [countryCode, level];
+const getLevel = (region: string): 0 | 1 | 2 => {
+ if (region === 'ALL') {
+ return 0;
+ } else if (region.length === 3 || region.length === 5) {
+ return 1;
}
+ return 2;
};
class CoordinatesFetcher {
adm0: Record = {};
adm1: Record = {};
adm2: Record = {};
- coordinatesByCode: Record = {};
+ coordinatesByCode: Record> = {};
+
+ async prime(codes: Record>) {
+ const neededLevelsByCountryCode: Record> = {};
+ for (const a of Object.values(codes)) {
+ for (const [countryCode, regionCodes] of Object.entries(a)) {
+ if (!Object.hasOwn(neededLevelsByCountryCode, countryCode)) {
+ neededLevelsByCountryCode[countryCode] = new Set();
+ }
+ for (const regionCode of regionCodes) {
+ neededLevelsByCountryCode[countryCode].add(getLevel(regionCode));
+ }
+ }
+ }
+ await Promise.all(Object.entries(neededLevelsByCountryCode).flatMap(([countryCode, levels]) => {
+ return [...levels].map((level) => this.fetchCoordinates(countryCode, level));
+ }));
+ }
async getGeoJSONLink(code: string, level: number): Promise {
if (level === 0) {
@@ -84,36 +96,40 @@ class CoordinatesFetcher {
const path = getFilePath(countryCode, level);
if (!fs.existsSync(path)) {
const response = await fetch(await this.getGeoJSONLink(countryCode, level));
- fs.writeFileSync(path, await response.text());
+ await fs.promises.writeFile(path, await response.text());
}
- const data = JSON.parse(fs.readFileSync(path, 'utf-8')) as GeoJSONSubset;
+ const data = JSON.parse(await fs.promises.readFile(path, 'utf-8')) as GeoJSONSubset;
const entries = data.features.map((feature) => {
+ let coordinates;
switch (feature.geometry.type) {
case 'Polygon':
- if (feature.geometry.coordinates.length !== 1) {
- process.stderr.write(`Assertion failed on ${feature.properties.shapeISO} (Polygon)\n`);
- }
- return [feature.properties.shapeISO, feature.geometry.coordinates] as const;
+ coordinates = [feature.geometry.coordinates];
+ break;
case 'MultiPolygon':
- if (feature.geometry.coordinates[0].length !== 1) {
- process.stderr.write(`Assertion failed on ${feature.properties.shapeISO} (MultiPolygon)\n`);
- }
- return [feature.properties.shapeISO, feature.geometry.coordinates.flatMap((a) => a)] as const;
+ coordinates = feature.geometry.coordinates;
+ break;
default:
throw new Error(`Unknown type ${countryCode}`);
}
+ return [feature.properties.shapeISO, coordinates] as const;
});
- for (const [code, coordinates] of entries) {
- this.coordinatesByCode[code] = coordinates;
+ if (!Object.hasOwn(this.coordinatesByCode, countryCode)) {
+ this.coordinatesByCode[countryCode] = {};
+ }
+ for (const [regionCode, coordinates] of entries) {
+ this.coordinatesByCode[countryCode][regionCode] = coordinates;
}
}
- async getCoordinates(code: string): Promise<[number, number][][]> {
- if (!Object.hasOwn(this.coordinatesByCode, code)) {
- const [countryCode, level] = getCountryCode(code);
- await this.fetchCoordinates(countryCode, level);
+ getCoordinates(countryCode: string, regionCode: string): MultiPolygon {
+ if (regionCode === 'ALL') {
+ regionCode = countryCode;
}
- return this.coordinatesByCode[code];
+ const coordinates = this.coordinatesByCode[countryCode][regionCode];
+ if (coordinates === undefined) {
+ throw new Error(`Cannot find ${countryCode} ${regionCode}`);
+ }
+ return coordinates;
}
}
@@ -126,33 +142,145 @@ const transformCoordinate = (coordinate: [number, number]): [number, number] =>
return [round(coordinate[1]), round(coordinate[0])];
};
-const countCoordinates = (polygons: [number, number][][]): number => {
- return polygons.reduce((count, polygon) => count + polygon.length, 0);
+const countCoordinates = (polygons: MultiPolygon): number => {
+ return polygons.flatMap((polygon) => polygon).reduce((count, ring) => count + ring.length, 0);
};
-const makePolygons = async (fetcher: CoordinatesFetcher, code: string): Promise<[number, number][][]> => {
- const original = await fetcher.getCoordinates(code);
+const makePolygons = (fetcher: CoordinatesFetcher, countryCode: string, regionCode: string): Polygon => {
+ const original = fetcher.getCoordinates(countryCode, regionCode);
const transformed = original.map((polygon) => {
- return polygon.map(transformCoordinate).reduce((acc, cur) => {
- // process.stdout.write(`${JSON.stringify(acc[acc.length - 1])} ${JSON.stringify(cur)}\n`);
- if (acc.length >= 2 && (acc[acc.length - 2][0] === cur[0] && acc[acc.length - 1][0] === cur[0] || acc[acc.length - 2][1] === cur[1] && acc[acc.length - 1][1] === cur[1])) {
- acc.pop();
+ return polygon.map((ring) => ring.map(transformCoordinate));
+ /* return polygon.map((ring) => {
+ const transformed = ring.map(transformCoordinate).reduce((acc, cur) => {
+ // process.stdout.write(`${JSON.stringify(acc[acc.length - 1])} ${JSON.stringify(cur)}\n`);
+ if (acc.length >= 2 && (acc[acc.length - 2][0] === cur[0] && acc[acc.length - 1][0] === cur[0] || acc[acc.length - 2][1] === cur[1] && acc[acc.length - 1][1] === cur[1])) {
+ acc.pop();
+ }
+ if (acc.length === 0 || !pointEquals(acc[acc.length - 1], cur)) {
+ // process.stdout.write('push\n');
+ acc.push(cur);
+ }
+ return acc;
+ }, [] as [number, number][]);
+ if (transformed.length >= 2 && pointEquals(transformed[0], transformed[transformed.length - 1])) {
+ transformed.pop();
}
- if (acc.length === 0 || acc[acc.length - 1][0] !== cur[0] || acc[acc.length - 1][1] !== cur[1]) {
- // process.stdout.write('push\n');
- acc.push(cur);
- }
- return acc;
- }, [] as [number, number][]);
+ return transformed;
+ });*/
});
- process.stdout.write(`${code} has ${countCoordinates(original)} original and ${countCoordinates(transformed)} transformed coordinates\n`);
+ // process.stdout.write(`${countryCode} ${regionCode} has ${countCoordinates(original)} original and ${countCoordinates(transformed)} transformed coordinates\n`);
return transformed;
};
-const writePolygons = async (fetcher: CoordinatesFetcher, locale: string, codes: string[]): Promise => {
- const result = await Promise.all(codes.map((code) => makePolygons(fetcher, code)));
- const polygons = result.flatMap((polygons) => polygons);
- fs.writeFileSync(`${__dirname}/../assets/${locale}.json`, JSON.stringify(polygons));
+const pointEquals = (pointA: [number, number] | undefined, pointB: [number, number] | undefined): boolean => {
+ return pointA !== undefined && pointB !== undefined && pointA[0] === pointB[0] && pointA[1] === pointB[1];
+};
+
+const pointNear = (pointA: [number, number] | undefined, pointB: [number, number] | undefined): boolean => {
+ const distance = 0.1;
+ return pointA !== undefined && pointB !== undefined && Math.sqrt((pointA[0] - pointB[0]) ** 2 + (pointA[1] - pointB[1]) ** 2) <= distance;
+};
+
+const wrappingIndex = (array: T[], index: number): T => {
+ return array[(index % array.length + array.length) % array.length];
+};
+
+const tryMergePolygons = (polygonA: [number, number][], polygonB: [number, number][]): [number, number][] | undefined => {
+ for (let i = 0; i < polygonA.length; i++) {
+ for (let j = 0; j < polygonB.length; j++) {
+ if (pointNear(polygonA[i], polygonB[j])) {
+ if (pointNear(polygonA[i + 1], polygonB[j + 1])) {
+ console.log(`found a match forwards ${i} ${j}`);
+ }
+ if (pointNear(polygonA[i + 1], polygonB[j - 1])) {
+ console.log(`found a match backwards ${i} (of ${polygonA.length}) ${j} (of ${polygonB.length})`);
+ let k = 2;
+ while (pointNear(polygonA[i + k], wrappingIndex(polygonB, j - k)) && k < polygonA.length) {
+ k++;
+ }
+ console.log(`length ${k}`);
+ /* console.log(`neighbors A: ${JSON.stringify(polygonA.slice(i - 2, i + 1))} ${JSON.stringify(polygonB.slice(j, j + 3))}`);
+ console.log(`neighbors B: ${JSON.stringify(polygonB.slice(j - k + 2, j - k + 1))} ${JSON.stringify(polygonA.slice(i + k - 1, i + k + 2))}`);
+
+ console.log(polygonA.slice(0, i));
+ console.log(polygonB.slice(j, polygonB.length));
+ console.log(polygonB.slice(j - k + 5, j - k));
+ console.log(polygonA.slice(i + k - 1, i + k + 4));*/
+ return [
+ ...polygonA.slice(0, i),
+ ...polygonB.slice(j, polygonB.length),
+ ...polygonB.slice(0, j - k),
+ ...polygonA.slice(i + k - 1, polygonA.length),
+ ];
+ }
+ }
+ }
+ }
+};
+
+/* const mergePolygons = (polygons: MultiPolygon): MultiPolygon=> {
+ return union(polygons);
+ for (let i = 0; i < polygons.length; i++) {
+ for (let j = i + 1; j < polygons.length; j++) {
+ // console.log(`trying to merge ${i} ${j}`);
+ const merged = tryMergePolygons(polygons[i], polygons[j]);
+ if (merged) {
+ console.log(`merged ${i} and ${j}`);
+ polygons[i] = merged;
+ polygons.splice(j, 1);
+ }
+ }
+ }
+ return polygons;
+};
+
+const tidyPolygon = (polygon: [number, number][]) => {
+ for (let i = 0; i < polygon.length; i++) {
+ for (let j = i + 1; j < polygon.length; j++) {
+ if (pointEquals(polygon[i], polygon[j])) {
+ if (pointEquals(polygon[i + 1], polygon[j + 1])) {
+ /* console.log(`found a tidy match forwards ${i} ${j}`);
+ let k = 2;
+ while (pointEquals(polygon[i + k], polygon[(j + k) % polygon.length]) && k < polygon.length) {
+ k++;
+ }
+ polygon.splice(j, k);
+ continue;*
+ }
+ if (pointEquals(polygon[i + 1], polygon[j - 1])) {
+ /* console.log(`found a tidy match backwards ${i} ${j}`);
+ let k = 2;
+ while (pointEquals(polygon[i + k], wrappingIndex(polygon, j + k)) && k < polygon.length) {
+ k++;
+ }
+ console.log(i, j, k, j - i);
+ console.log(polygon.slice(i - 3, i + 4));
+ console.log(polygon.slice(j - 3, j + 4));
+ // polygon.splice(j, k);
+ continue;*
+ }
+ }
+ }
+ }
+ return polygon;
+};
+ */
+
+const simplifyPolygon = (polygon: Polygon): Ring => {
+ const ring = polygon[0].map(([x, y]) => ({ x, y }));
+ const simplified = simplify(ring, 0.05);
+ return simplified.map(({ x, y }) => [x, y]);
+};
+
+const writePolygons = async (fetcher: CoordinatesFetcher, locales: Record>): Promise => {
+ const entries = Object.entries(locales).map(([locale, codes]) => {
+ let polygons = Object.entries(codes)
+ .flatMap(([code, regions]) => regions.flatMap((region) => makePolygons(fetcher, code, region)));
+ polygons = polygonClipping.union(polygons).map(simplifyPolygon);
+ // polygons = polygonClipping.union(polygons).map(simplifyPolygon);
+ return [locale, polygons];
+ });
+ fs.writeFileSync(`${__dirname}/../assets/map.json`, JSON.stringify(Object.fromEntries(entries)));
};
const main = async (): Promise => {
@@ -160,8 +288,40 @@ const main = async (): Promise => {
fs.mkdirSync(`${__dirname}/../assets/geo/`);
}
const fetcher = new CoordinatesFetcher();
- await fetcher.fetchCoordinates('BEL', 3);
- await writePolygons(fetcher, 'de', ['POL', 'AUT', 'BE-WLG', 'DEU', 'LIE', 'LUX', 'CH-AG', 'CH-AR', 'CH-AI', 'CH-BL', 'CH-BS', 'CH-BE', 'CH-FR', 'CH-GL', 'CH-GR', 'CH-LU', 'CH-NW', 'CH-OW', 'CH-SG', 'CH-SH', 'CH-SO', 'CH-SZ', 'CH-TG', 'CH-UR', 'CH-VS', 'CH-ZG', 'CH-ZH']);
+ const codes = {
+ de: {
+ AUT: ['ALL'],
+ BEL: ['BE-WLG'],
+ DEU: ['ALL'],
+ LIE: ['ALL'],
+ LUX: ['ALL'],
+ CHE: ['CH-AG', 'CH-AR', 'CH-AI', 'CH-BL', 'CH-BS', 'CH-BE', 'CH-FR', 'CH-GL', 'CH-GR', 'CH-LU', 'CH-NW', 'CH-OW', 'CH-SG', 'CH-SH', 'CH-SO', 'CH-SZ', 'CH-TG', 'CH-UR', 'CH-VS', 'CH-ZG', 'CH-ZH'],
+ },
+ en: {
+ AUS: ['ALL'],
+ GBR: ['ALL'],
+ IRL: ['ALL'],
+ USA: ['ALL'],
+ },
+ es: {
+ ESP: ['ALL'],
+ },
+ fr: {
+ BEL: ['BRU', 'WAL'],
+ CHE: ['CH-BE', 'CH-FR', 'CH-GE', 'CH-JU', 'CH-NE', 'CH-VS', 'CH-VD'],
+ FRA: ['ALL'],
+ LUX: ['ALL'],
+ },
+ nl: {
+ BEL: ['BRU', 'VLG'],
+ NLD: ['ALL'],
+ },
+ pl: {
+ POL: ['ALL'],
+ },
+ };
+ await fetcher.prime(codes);
+ await writePolygons(fetcher, codes);
};
await main();