This commit is contained in:
Valentyne Stigloher 2024-05-28 17:20:45 +02:00
parent bc61615066
commit 0ffc1852d3
5 changed files with 272 additions and 146 deletions

View File

@ -1,7 +1,9 @@
<template>
<div>
<div id="map" :class="isDark ? 'dark' : ''"></div>
{{ JSON.stringify(polygons) }}
<div v-for="polygon in polygons">
{{ JSON.stringify(polygon) }}
</div>
</div>
</template>
@ -9,8 +11,7 @@
<script lang="ts">
import dark from '../plugins/dark.ts';
import walsLanguages from '~/assets/languages.csv';
import polygons from '~/assets/shapes.json';
import newMexicoPolygon from '~/assets/de.json';
import polygonsByLocale from '~/assets/map.json';
import locales from '../locale/locales.ts';
import { clearUrl } from '~/src/helpers.ts';
import type { LocaleDescription } from '../locale/locales.ts';
@ -38,49 +39,9 @@ declare module 'leaflet' {
export default dark.extend({
data() {
return {
inputCurve: null as any,
curve: null as any,
controls: [],
polygons: newMexicoPolygon,
polygons: {} as Record<string, L.Polygon[]>,
};
},
watch: {
polygons() {
this.update();
},
},
methods: {
update() {
const inputPath = [];
const polygonPath = [];
for (const polygon of this.polygons) {
if (polygon.length >= 1) {
inputPath.push('M', polygon[0]);
}
for (let i = 1; i < polygon.length; i++) {
inputPath.push('L', polygon[i]);
}
inputPath.push('Z');
/*const middleOf = (a: [number, number], b: [number, number]) => {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
};
if (polygon.length >= 3) {
polygonPath.push('M', middleOf(polygon[0], polygon[1]));
polygonPath.push('Q', polygon[1], middleOf(polygon[1], polygon[2]));
for (let i = 2; i < polygon.length - 1; i++) {
polygonPath.push('T', middleOf(polygon[i], polygon[i + 1]));
}
polygonPath.push('T', middleOf(polygon[polygon.length - 1], polygon[0]));
polygonPath.push('T', middleOf(polygon[0], polygon[1]));
}*/
}
// this.inputCurve.setPath(inputPath);
this.curve.setPath(inputPath);
},
},
async mounted() {
const { default: L } = await import('leaflet');
await import ('@elfalem/leaflet-curve');
@ -95,29 +56,13 @@ export default dark.extend({
const map = L.map('map', {
attributionControl: false,
worldCopyJump: true,
center: [50, 12.8],
zoom: 6,
center: [47, 10],
zoom: 4,
minZoom: 2,
maxZoom: 6,
maxZoom: 7,
maxBounds: [[-75, Number.NEGATIVE_INFINITY], [85, Number.POSITIVE_INFINITY]],
});
map.addEventListener('click', (event) => {
this.polygons[this.polygons.length - 1].push(toPoint(event.latlng));
});
window.addEventListener('keydown', (event) => {
switch (event.key) {
case 'Enter':
this.polygons.push([]);
return false;
case 'Backspace':
this.polygons[this.polygons.length - 1].pop();
event.preventDefault();
return false;
}
});
L.control.attribution({ position: 'bottomright' })
.addAttribution('&copy; <a href="https://doi.org/10.5281/zenodo.7385533" target="_blank" title="Dryer, Matthew S. & Haspelmath, Martin (eds.) 2013. The World Atlas of Language Structures Online. Leipzig: Max Planck Institute for Evolutionary Anthropology.">WALS</a>')
.addAttribution('<a href="https://wals.info" target="_blank">wals.info</a>')
@ -141,18 +86,20 @@ export default dark.extend({
},
}).addTo(map);
this.inputCurve = L.curve([], {
color: '#ffffff',
opacity: 0.5,
}).addTo(map);
this.curve = L.curve([], {
color: '#971064',
fill: true,
fillColor: '#c71585',
fillOpacity: 0.5,
fillRule: 'nonzero',
}).addTo(map);
for (const [locale, polygons] of Object.entries(polygonsByLocale)) {
const polygonActors = polygons.map((polygon) => {
return L.polygon(polygon, {
color: '#971064',
weight: 1,
fill: true,
// fillColor: `hsl(${index * 360 / Object.values(polygonsByLocale).length}deg, 100%, 50%)`,
fillColor: '#c71585',
fillOpacity: 0.2,
fillRule: 'evenodd',
}).addTo(map);
});
this.polygons[locale] = polygonActors;
}
/* for (let i = 0; i < this.polygons.length; i++) {
for (let j = 0; j < this.polygons[i].length; j++) {
@ -167,7 +114,7 @@ export default dark.extend({
}
}*/
/*for (const walsLanguage of walsLanguages) {
for (const walsLanguage of walsLanguages) {
if (!localesByWalsCode.hasOwnProperty(walsLanguage.id)) {
continue;
}
@ -181,12 +128,18 @@ export default dark.extend({
radius: 300000 * Math.cos(walsLanguage.latitude * Math.PI / 180), // compensate for Mercator projection
}).addTo(map);
circle.bindTooltip(`<strong>${locale.name}</strong><br/>${clearUrl(locale.url)}`, { opacity: 1 });
circle.on('mouseover', () => {
this.polygons[locale.code].map((polygon) => polygon.setStyle({ fillOpacity: 1 }));
});
circle.on('mouseout', () => {
this.polygons[locale.code].map((polygon) => polygon.setStyle({ fillOpacity: 0.2 }));
});
circle.on('click', () => {
window.open(locale.url);
});
}
}*/
this.update();
}
// this.update();
},
});
</script>

View File

@ -65,11 +65,13 @@
"pageres": "^6.3.1",
"papaparse": "^5.4.1",
"plausible-api": "https://github.com/avo7/plausible-api.git#main",
"polygon-clipping": "^0.15.7",
"qr-code-styling": "^1.6.0-rc.1",
"query-string": "^7.1.1",
"querystringify": "^2.2.0",
"rtlcss": "^3.1.2",
"sha1": "^1.1.1",
"simplify-js": "^1.2.4",
"speakeasy": "^2.0.0",
"sql-template-strings": "^2.2.2",
"sqlite": "^4.0.12",

25
pnpm-lock.yaml generated
View File

@ -158,6 +158,9 @@ dependencies:
plausible-api:
specifier: https://github.com/avo7/plausible-api.git#main
version: github.com/avo7/plausible-api/0bb79ad1d26754a71b3ec1351255dbf5a32e6e2a
polygon-clipping:
specifier: ^0.15.7
version: 0.15.7
qr-code-styling:
specifier: ^1.6.0-rc.1
version: 1.6.0-rc.1
@ -173,6 +176,9 @@ dependencies:
sha1:
specifier: ^1.1.1
version: 1.1.1
simplify-js:
specifier: ^1.2.4
version: 1.2.4
speakeasy:
specifier: ^2.0.0
version: 2.0.0
@ -13146,6 +13152,13 @@ packages:
- typescript
dev: false
/polygon-clipping@0.15.7:
resolution: {integrity: sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==}
dependencies:
robust-predicates: 3.0.2
splaytree: 3.1.2
dev: false
/posix-character-classes@0.1.1:
resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==}
engines: {node: '>=0.10.0'}
@ -14802,6 +14815,10 @@ packages:
resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==}
dev: false
/robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
dev: false
/rrweb-cssom@0.6.0:
resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==}
dev: false
@ -15178,6 +15195,10 @@ packages:
is-arrayish: 0.3.2
dev: true
/simplify-js@1.2.4:
resolution: {integrity: sha512-vITfSlwt7h/oyrU42R83mtzFpwYk3+mkH9bOHqq/Qw6n8rtR7aE3NZQ5fbcyCUVVmuMJR6ynsAhOfK2qoah8Jg==}
dev: false
/sirv@2.0.3:
resolution: {integrity: sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==}
engines: {node: '>= 10'}
@ -15352,6 +15373,10 @@ packages:
base32.js: 0.0.1
dev: false
/splaytree@3.1.2:
resolution: {integrity: sha512-4OM2BJgC5UzrhVnnJA4BkHKGtjXNzzUfpQjCO8I05xYPsfS/VuQDwjCGGMi8rYQilHEV4j8NBqTFbls/PZEE7A==}
dev: false
/split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}

View File

@ -1,19 +1,5 @@
<template>
<Page>
<div class="list-group d-flex flex-wrap flex-row my-4">
<a
v-for="(options, locale) in $locales"
:key="locale"
:href="options.url"
class="list-group-item list-group-item-action list-group-item-hoverable w-md-50"
>
<div class="h3">
<LocaleIcon :locale="options" class="mx-2" />
{{ options.name }}
<small v-if="options.extra" class="text-muted">({{ options.extra }})</small>
</div>
</a>
</div>
<template #below>
<LanguageMap />
<p class="small text-muted my-3">

View File

@ -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<string, string> = {};
adm1: Record<string, string> = {};
adm2: Record<string, string> = {};
coordinatesByCode: Record<string, [number, number][][]> = {};
coordinatesByCode: Record<string, Record<string, MultiPolygon>> = {};
async prime(codes: Record<string, Record<string, string[]>>) {
const neededLevelsByCountryCode: Record<string, Set<0 | 1 | 2>> = {};
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<string> {
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<void> => {
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 = <T>(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<string, Record<string, string[]>>): Promise<void> => {
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<void> => {
@ -160,8 +288,40 @@ const main = async (): Promise<void> => {
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();