Scripts(community_index): create script to update community index files, create weekly data maintenance script

This commit is contained in:
Pieter Vander Vennet 2025-01-27 02:32:19 +01:00
parent 36a9b49c66
commit 7bddaa7d4c
11 changed files with 412 additions and 141 deletions

View file

@ -0,0 +1,31 @@
name: Weekly data updates
on:
schedule:
-cron: "* * * * 1"
jobs:
deploy_on_hetzner_single:
runs-on: [ ubuntu-latest, hetzner-access ]
runs-on: [ ubuntu-latest, hetzner-access ]
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
cache-dependency-path: package-lock.json
- name: install deps
run: npm ci
shell: bash
- name: update community index files
shell: bash
run: |
mkdir community-index
npm run download:community-index -- community-index/
zip community-index.zip community-index/*
scp community-index.zip hetzner:data/

View file

@ -119,6 +119,7 @@
"download:editor-layer-index": "vite-node scripts/downloadEli.ts", "download:editor-layer-index": "vite-node scripts/downloadEli.ts",
"download:stats": "vite-node scripts/GenerateSeries.ts", "download:stats": "vite-node scripts/GenerateSeries.ts",
"download:images": "vite-node scripts/generateImageAnalysis.ts -- ~/data/imgur-image-backup/", "download:images": "vite-node scripts/generateImageAnalysis.ts -- ~/data/imgur-image-backup/",
"download:community-index": "vite-node scripts/downloadCommunityIndex.ts -- /tmp/test",
"weblate:add-upstream": "git remote add weblate https://translate.mapcomplete.org/git/mapcomplete/core/ ; git remote update weblate", "weblate:add-upstream": "git remote add weblate https://translate.mapcomplete.org/git/mapcomplete/core/ ; git remote update weblate",
"weblate:fix": "npm run weblate:add-upstream && git merge weblate/master && git rebase origin/master && git push origin master", "weblate:fix": "npm run weblate:add-upstream && git merge weblate/master && git rebase origin/master && git push origin master",
"lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:themes", "lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:themes",

View file

@ -0,0 +1,116 @@
import Script from "./Script"
import { CommunityResource } from "../src/Logic/Web/CommunityIndex"
import { Utils } from "../src/Utils"
import { FeatureCollection, MultiPolygon, Polygon } from "geojson"
import { writeFileSync } from "fs"
import { GeoOperations } from "../src/Logic/GeoOperations"
import { Tiles } from "../src/Models/TileRange"
import ScriptUtils from "./ScriptUtils"
class DownloadCommunityIndex extends Script {
constructor() {
super("Updates the community index")
}
printHelp() {
console.log("Arguments are:\noutputdirectory")
}
private static targetZoomlevel: number = 6
private static upstreamUrl: string = "https://raw.githubusercontent.com/osmlab/osm-community-index/main/dist/"
/**
* Prunes away unnecessary fields from a CommunityResource
* @private
*/
private static stripResource(r: Readonly<CommunityResource>): CommunityResource {
return {
id: r.id,
languageCodes: r.languageCodes,
account: r.account,
type: r.type,
resolved: {
name: r.resolved.name,
description: r.resolved.description,
url: r.resolved.url
}
}
}
private static stripResourcesObj(resources: Readonly<Record<string, Readonly<CommunityResource>>>) {
const stripped: Record<string, CommunityResource> = {}
for (const k in resources) {
stripped[k] = DownloadCommunityIndex.stripResource(resources[k])
}
return stripped
}
public static async update(targetDirectory: string) {
const data = await Utils.downloadJson<FeatureCollection<Polygon | MultiPolygon, {
resources: Record<string, CommunityResource>,
nameEn: string,
id: string
}>>(DownloadCommunityIndex.upstreamUrl + "completeFeatureCollection.json"
)
const features = data.features
const global = features.find(
f => f.id === "Q2"
)
const globalProperties = DownloadCommunityIndex.stripResourcesObj(global.properties.resources)
writeFileSync(targetDirectory + "/global.json", JSON.stringify(globalProperties), "utf8")
console.log("Written global properties")
const types = new Set<string>()
for (const f of features) {
const res = f.properties.resources
for (const k in res) {
types.add(res[k].type)
}
}
for (const type of types) {
const url = `${DownloadCommunityIndex.upstreamUrl}img/${type}.svg`
await ScriptUtils.DownloadFileTo(url, `${targetDirectory}/${type}.svg`)
}
const local = features.filter(f => f.id !== "Q2")
const spread = GeoOperations.spreadIntoBboxes(local, DownloadCommunityIndex.targetZoomlevel)
let written = 0
let skipped = 0
writeFileSync(targetDirectory + "local.geojson", JSON.stringify({ type: "FeatureCollection", features: local }))
for (const tileIndex of spread.keys()) {
const features = spread.get(tileIndex)
const clipped = GeoOperations.clipAllInBox(features, tileIndex)
if (clipped.length === 0) {
skipped++
features.push(Tiles.asGeojson(tileIndex))
writeFileSync(`${targetDirectory + tileIndex}_skipped.geojson`, JSON.stringify({
type: "FeatureCollection", features
}))
continue
}
const [z, x, y] = Tiles.tile_from_index(tileIndex)
const path = `${targetDirectory}/tile_${z}_${x}_${y}.geojson`
clipped.forEach((f) => {
delete f.bbox
})
writeFileSync(path, JSON.stringify({ type: "FeatureCollection", features: clipped }), "utf8")
written++
console.log(`Written tile ${path}`)
}
console.log(`Created ${written} tiles, skipped ${skipped}`)
}
async main(args: string[]): Promise<void> {
const path = args[0]
if (!path) {
this.printHelp()
return
}
await DownloadCommunityIndex.update(path)
}
}
new DownloadCommunityIndex().run()

View file

@ -1,13 +1,12 @@
import * as fs from "fs" import * as fs from "fs"
import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource"
import * as readline from "readline" import * as readline from "readline"
import ScriptUtils from "./ScriptUtils" import ScriptUtils from "./ScriptUtils"
import { Utils } from "../Utils" import { Utils } from "../Utils"
import Script from "./Script" import Script from "./Script"
import { BBox } from "../Logic/BBox"
import { GeoOperations } from "../Logic/GeoOperations" import { GeoOperations } from "../Logic/GeoOperations"
import { Tiles } from "../Models/TileRange" import { Tiles } from "../Models/TileRange"
import { Feature } from "geojson" import { Feature } from "geojson"
import { features } from "monaco-editor/esm/metadata"
/** /**
* This script slices a big newline-delimeted geojson file into tiled geojson * This script slices a big newline-delimeted geojson file into tiled geojson
@ -96,34 +95,15 @@ class Slice extends Script {
features: Feature[], features: Feature[],
tileIndex: number, tileIndex: number,
outputDirectory: string, outputDirectory: string,
doSlice: boolean, doClip: boolean,
handled: number, handled: number,
maxNumberOfTiles: number maxNumberOfTiles: number
) { ): boolean {
if (doClip) {
features = GeoOperations.clipAllInBox(features, tileIndex)
}
const [z, x, y] = Tiles.tile_from_index(tileIndex) const [z, x, y] = Tiles.tile_from_index(tileIndex)
const path = `${outputDirectory}/tile_${z}_${x}_${y}.geojson` const path = `${outputDirectory}/tile_${z}_${x}_${y}.geojson`
const box = BBox.fromTileIndex(tileIndex)
if (doSlice) {
features = Utils.NoNull(
features.map((f) => {
const bbox = box.asGeoJson({})
const properties = {
...f.properties,
id: (f.properties?.id ?? "") + "_" + z + "_" + x + "_" + y,
}
if (GeoOperations.completelyWithin(bbox, <any>f)) {
bbox.properties = properties
return bbox
}
const intersection = GeoOperations.intersect(f, box.asGeoJson({}))
if (intersection) {
intersection.properties = properties
}
return intersection
})
)
}
features.forEach((f) => { features.forEach((f) => {
delete f.bbox delete f.bbox
}) })
@ -177,7 +157,7 @@ class Slice extends Script {
} }
console.log("Using directory ", outputDirectory) console.log("Using directory ", outputDirectory)
let allFeatures: any[] let allFeatures: Feature[]
if (inputFile.endsWith(".geojson")) { if (inputFile.endsWith(".geojson")) {
console.log("Detected geojson") console.log("Detected geojson")
allFeatures = await this.readFeaturesFromGeoJson(inputFile) allFeatures = await this.readFeaturesFromGeoJson(inputFile)
@ -202,18 +182,16 @@ class Slice extends Script {
} }
const maxNumberOfTiles = Math.pow(2, zoomlevel) * Math.pow(2, zoomlevel) const maxNumberOfTiles = Math.pow(2, zoomlevel) * Math.pow(2, zoomlevel)
let handled = 0 let handled = 0
StaticFeatureSource.fromGeojson(allFeatures).features.addCallbackAndRun((feats) => { GeoOperations.slice(zoomlevel, features).forEach((tileData, tileIndex) => {
GeoOperations.slice(zoomlevel, feats).forEach((tileData, tileIndex) => { handled = handled + 1
handled = handled + 1 this.handleTileData(
this.handleTileData( tileData,
tileData, tileIndex,
tileIndex, outputDirectory,
outputDirectory, doSlice,
doSlice, handled,
handled, maxNumberOfTiles
maxNumberOfTiles )
)
})
}) })
} }
} }

View file

@ -75,7 +75,7 @@ export default class SaveFeatureSourceToLocalStorage {
this.storage = storage this.storage = storage
const singleTileSavers: Map<number, SingleTileSaver> = new Map<number, SingleTileSaver>() const singleTileSavers: Map<number, SingleTileSaver> = new Map<number, SingleTileSaver>()
features.features.addCallbackAndRunD((features) => { features.features.addCallbackAndRunD((features) => {
const sliced = GeoOperations.slice(zoomlevel, features) const sliced = GeoOperations.spreadIntoBboxes(features, zoomlevel)
sliced.forEach((features, tileIndex) => { sliced.forEach((features, tileIndex) => {
let tileSaver = singleTileSavers.get(tileIndex) let tileSaver = singleTileSavers.get(tileIndex)

View file

@ -1,6 +1,6 @@
import { BBox } from "./BBox" import { BBox } from "./BBox"
import * as turf from "@turf/turf" import * as turf from "@turf/turf"
import { AllGeoJSON, booleanWithin, Coord } from "@turf/turf" import { AllGeoJSON, booleanWithin, Coord, Polygon } from "@turf/turf"
import { import {
Feature, Feature,
FeatureCollection, FeatureCollection,
@ -9,13 +9,13 @@ import {
MultiLineString, MultiLineString,
MultiPolygon, MultiPolygon,
Point, Point,
Polygon, Position
Position,
} from "geojson" } from "geojson"
import { Tiles } from "../Models/TileRange" import { Tiles } from "../Models/TileRange"
import { Utils } from "../Utils" import { Utils } from "../Utils"
import { NearestPointOnLine } from "@turf/nearest-point-on-line" import { NearestPointOnLine } from "@turf/nearest-point-on-line"
;("use strict")
("use strict")
export class GeoOperations { export class GeoOperations {
private static readonly _earthRadius = 6378137 private static readonly _earthRadius = 6378137
@ -29,7 +29,7 @@ export class GeoOperations {
"behind", "behind",
"sharp_left", "sharp_left",
"left", "left",
"slight_left", "slight_left"
] as const ] as const
private static reverseBearing = { private static reverseBearing = {
N: 0, N: 0,
@ -47,7 +47,7 @@ export class GeoOperations {
W: 270, W: 270,
WNW: 292.5, WNW: 292.5,
NW: 315, NW: 315,
NNW: 337.5, NNW: 337.5
} }
/** /**
@ -61,8 +61,8 @@ export class GeoOperations {
} }
public static intersect( public static intersect(
f0: Feature<Polygon | MultiPolygon>, f0: Readonly<Feature<Polygon | MultiPolygon>>,
f1: Feature<Polygon | MultiPolygon> f1: Readonly<Feature<Polygon | MultiPolygon>>
): Feature<Polygon | MultiPolygon> | null { ): Feature<Polygon | MultiPolygon> | null {
return turf.intersect(f0, f1) return turf.intersect(f0, f1)
} }
@ -309,7 +309,7 @@ export class GeoOperations {
bufferSizeInMeter: number bufferSizeInMeter: number
): Feature<Polygon | MultiPolygon> | FeatureCollection<Polygon | MultiPolygon> { ): Feature<Polygon | MultiPolygon> | FeatureCollection<Polygon | MultiPolygon> {
return turf.buffer(feature, bufferSizeInMeter / 1000, { return turf.buffer(feature, bufferSizeInMeter / 1000, {
units: "kilometers", units: "kilometers"
}) })
} }
@ -325,9 +325,9 @@ export class GeoOperations {
[lon0, lat], [lon0, lat],
[lon0, lat0], [lon0, lat0],
[lon, lat0], [lon, lat0],
[lon, lat], [lon, lat]
], ]
}, }
} }
} }
@ -368,9 +368,9 @@ export class GeoOperations {
type: "Feature", type: "Feature",
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: way.geometry.coordinates[0], coordinates: way.geometry.coordinates[0]
}, },
properties: way.properties, properties: way.properties
} }
} }
if (way.geometry.type === "MultiPolygon") { if (way.geometry.type === "MultiPolygon") {
@ -378,9 +378,9 @@ export class GeoOperations {
type: "Feature", type: "Feature",
geometry: { geometry: {
type: "MultiLineString", type: "MultiLineString",
coordinates: way.geometry.coordinates[0], coordinates: way.geometry.coordinates[0]
}, },
properties: way.properties, properties: way.properties
} }
} }
if (way.geometry.type === "LineString") { if (way.geometry.type === "LineString") {
@ -512,6 +512,8 @@ export class GeoOperations {
/** /**
* Given a list of features, will construct a map of slippy map tile-indices. * Given a list of features, will construct a map of slippy map tile-indices.
* Features of which the BBOX overlaps with the corresponding slippy map tile are added to the corresponding array * Features of which the BBOX overlaps with the corresponding slippy map tile are added to the corresponding array
*
* Also @see clipAllInBox
* @param features * @param features
* @param zoomlevel * @param zoomlevel
*/ */
@ -535,6 +537,33 @@ export class GeoOperations {
return perBbox return perBbox
} }
/**
* Given a list of features, returns a new list of features so that the features are clipped into the given tile-index.
* Note: IDs are rewritten
* Also @see spreadIntoBBoxes
*/
public static clipAllInBox(features: ReadonlyArray<Readonly<Feature>>, tileIndex: number): Feature[] {
const bbox = Tiles.asGeojson(tileIndex)
const newFeatures: Feature[] = []
for (const f of features) {
const intersectionParts = GeoOperations.clipWith(f, bbox)
for (let i = 0; i < intersectionParts.length; i++) {
const intersectionPart = intersectionParts[i]
let id = (f.properties?.id ?? "") + "_" + tileIndex
if (i > 0) {
id += "_part_" + i
}
const properties = {
...f.properties,
id
}
intersectionPart.properties = properties
newFeatures.push(intersectionPart)
}
}
return Utils.NoNull(newFeatures)
}
public static toGpx( public static toGpx(
locations: locations:
| Feature<LineString> | Feature<LineString>
@ -558,8 +587,8 @@ export class GeoOperations {
properties: {}, properties: {},
geometry: { geometry: {
type: "Point", type: "Point",
coordinates: p, coordinates: p
}, }
} }
) )
} }
@ -575,7 +604,7 @@ export class GeoOperations {
trackPoints.push(trkpt) trackPoints.push(trkpt)
} }
const header = const header =
'<gpx version="1.1" creator="mapcomplete.org" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">' "<gpx version=\"1.1\" creator=\"mapcomplete.org\" xmlns=\"http://www.topografix.com/GPX/1/1\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd\">"
return ( return (
header + header +
"\n<name>" + "\n<name>" +
@ -614,7 +643,7 @@ export class GeoOperations {
trackPoints.push(trkpt) trackPoints.push(trkpt)
} }
const header = const header =
'<gpx version="1.1" creator="mapcomplete.org" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">' "<gpx version=\"1.1\" creator=\"mapcomplete.org\" xmlns=\"http://www.topografix.com/GPX/1/1\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd\">"
return ( return (
header + header +
"\n<name>" + "\n<name>" +
@ -640,7 +669,7 @@ export class GeoOperations {
const copy = { const copy = {
...feature, ...feature,
geometry: { ...feature.geometry }, geometry: { ...feature.geometry }
} }
let coordinates: [number, number][] let coordinates: [number, number][]
if (feature.geometry.type === "LineString") { if (feature.geometry.type === "LineString") {
@ -698,8 +727,8 @@ export class GeoOperations {
type: "Feature", type: "Feature",
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [a, b], coordinates: [a, b]
}, }
}, },
distanceMeter, distanceMeter,
{ units: "meters" } { units: "meters" }
@ -736,17 +765,26 @@ export class GeoOperations {
* GeoOperations.completelyWithin(park, pond) // => false * GeoOperations.completelyWithin(park, pond) // => false
*/ */
static completelyWithin( static completelyWithin(
feature: Feature, feature: Readonly<Feature>,
possiblyEnclosingFeature: Feature<Polygon | MultiPolygon> possiblyEnclosingFeature: Readonly<Feature<Polygon | MultiPolygon>>
): boolean { ): boolean {
if (feature.geometry.type === "MultiPolygon") {
const polygons = feature.geometry.coordinates.map(coordinates =>
<Feature<Polygon>>{
type: "Feature", geometry: {
type: "Polygon", coordinates
}
})
return !polygons.some(polygon => !booleanWithin(polygon, possiblyEnclosingFeature))
}
return booleanWithin(feature, possiblyEnclosingFeature) return booleanWithin(feature, possiblyEnclosingFeature)
} }
/** /**
* Create an intersection between two features. * Create an intersection between two features.
* One or multiple new feature is returned based on 'toSplit', which'll have a geometry that is completely withing boundary * One or multiple new feature are returned based on 'toSplit', which'll have a geometry that is completely withing boundary
*/ */
public static clipWith(toSplit: Feature, boundary: Feature<Polygon>): Feature[] { public static clipWith(toSplit: Readonly<Feature>, boundary: Readonly<Feature<Polygon>>): Feature[] {
if (toSplit.geometry.type === "Point") { if (toSplit.geometry.type === "Point") {
const p = <Feature<Point>>toSplit const p = <Feature<Point>>toSplit
if (GeoOperations.inside(<[number, number]>p.geometry.coordinates, boundary)) { if (GeoOperations.inside(<[number, number]>p.geometry.coordinates, boundary)) {
@ -757,9 +795,9 @@ export class GeoOperations {
} }
if (toSplit.geometry.type === "LineString") { if (toSplit.geometry.type === "LineString") {
const splitup = turf.lineSplit(<Feature<LineString>>toSplit, boundary) const splitup: Feature<LineString>[] = turf.lineSplit(<Feature<LineString>>toSplit, boundary).features
const kept = [] const kept: Feature[] = []
for (const f of splitup.features) { for (const f of splitup) {
if (!GeoOperations.inside(GeoOperations.centerpointCoordinates(f), boundary)) { if (!GeoOperations.inside(GeoOperations.centerpointCoordinates(f), boundary)) {
continue continue
} }
@ -787,7 +825,24 @@ export class GeoOperations {
return kept return kept
} }
if (toSplit.geometry.type === "Polygon" || toSplit.geometry.type == "MultiPolygon") { if (toSplit.geometry.type === "Polygon" || toSplit.geometry.type == "MultiPolygon") {
const splitup = turf.intersect(<Feature<Polygon>>toSplit, boundary) const splitup = turf.intersect(<Feature<Polygon>>toSplit, boundary)
if (splitup === null) {
// No intersection found.
// Either: the boundary is contained fully in 'toSplit', 'toSplit' is contained fully in 'boundary' or they are unrelated at all
if (GeoOperations.completelyWithin(toSplit, boundary)) {
return [toSplit]
}
if (GeoOperations.completelyWithin(boundary, <Feature<Polygon | MultiPolygon>>toSplit)) {
return [{
type: "Feature",
properties: { ...toSplit.properties },
geometry: boundary.geometry,
bbox: boundary.bbox
}]
}
return []
}
splitup.properties = { ...toSplit.properties } splitup.properties = { ...toSplit.properties }
return [splitup] return [splitup]
} }
@ -864,32 +919,6 @@ export class GeoOperations {
} }
} }
/**
* Constructs all tiles where features overlap with and puts those features in them.
* Long features (e.g. lines or polygons) which overlap with multiple tiles are referenced in each tile they overlap with
* @param zoomlevel
* @param features
*/
public static slice(zoomlevel: number, features: Feature[]): Map<number, Feature[]> {
const tiles = new Map<number, Feature[]>()
for (const feature of features) {
const bbox = BBox.get(feature)
Tiles.MapRange(Tiles.tileRangeFrom(bbox, zoomlevel), (x, y) => {
const i = Tiles.tile_index(zoomlevel, x, y)
let tiledata = tiles.get(i)
if (tiledata === undefined) {
tiledata = []
tiles.set(i, tiledata)
}
tiledata.push(feature)
})
}
return tiles
}
/** /**
* Creates a linestring object based on the outer ring of the given polygon * Creates a linestring object based on the outer ring of the given polygon
* *
@ -905,8 +934,8 @@ export class GeoOperations {
properties: p.properties, properties: p.properties,
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: p.geometry.coordinates[0], coordinates: p.geometry.coordinates[0]
}, }
} }
} }
@ -934,7 +963,7 @@ export class GeoOperations {
console.debug("SPlitting way", feature.properties.id) console.debug("SPlitting way", feature.properties.id)
result.push({ result.push({
...feature, ...feature,
geometry: { ...feature.geometry, coordinates: coors.slice(i + 1) }, geometry: { ...feature.geometry, coordinates: coors.slice(i + 1) }
}) })
coors = coors.slice(0, i + 1) coors = coors.slice(0, i + 1)
break break
@ -943,7 +972,7 @@ export class GeoOperations {
} }
result.push({ result.push({
...feature, ...feature,
geometry: { ...feature.geometry, coordinates: coors }, geometry: { ...feature.geometry, coordinates: coors }
}) })
} }
} }
@ -1117,8 +1146,8 @@ export class GeoOperations {
properties: multiLineStringFeature.properties, properties: multiLineStringFeature.properties,
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: coors[0], coordinates: coors[0]
}, }
} }
} }
return { return {
@ -1126,8 +1155,8 @@ export class GeoOperations {
properties: multiLineStringFeature.properties, properties: multiLineStringFeature.properties,
geometry: { geometry: {
type: "MultiLineString", type: "MultiLineString",
coordinates: coors, coordinates: coors
}, }
} }
} }

View file

@ -0,0 +1,33 @@
/**
* Various tools and types to work with the community index (https://openstreetmap.community/; https://github.com/osmlab/osm-community-index)
*/
export interface CommunityResource {
/**
* A unique identifier for the resource
* "pattern": "^[-_.A-Za-z0-9]+$"
*/
id: string,
/**
* Type of community resource (thus: platform)
*/
type: string,
/**
* included and excluded locations for this item
* See location-conflation documentation for compatible values: https://github.com/rapideditor/location-conflation#readme
*/
locationSet?,
/** Array of ISO-639-1 (2 letter) or ISO-639-3 (3 letter) codes in lowercase
* */
languageCodes?: string[]
/**
* Resource account string, required for some resource types
*/
account?: string
resolved?: { url: string, name: string, description: string } & Record<string, string>
}

View file

@ -1,4 +1,5 @@
import { BBox } from "../Logic/BBox" import { BBox } from "../Logic/BBox"
import { Feature, Polygon } from "geojson"
export interface TileRange { export interface TileRange {
xstart: number xstart: number
@ -80,6 +81,17 @@ export class Tiles {
return [z, x, index % factor] return [z, x, index % factor]
} }
static asGeojson(index: number): Feature<Polygon>;
static asGeojson(x: number, y: number, z: number): Feature<Polygon>;
static asGeojson(zIndex: number, x?: number, y?: number): Feature<Polygon> {
let z = zIndex
if (x === undefined) {
[z, x, y] = Tiles.tile_from_index(zIndex)
}
const bounds = Tiles.tile_bounds_lon_lat(z, x, y)
return new BBox(bounds).asGeoJson()
}
/** /**
* Return x, y of the tile containing (lat, lon) on the given zoom level * Return x, y of the tile containing (lat, lon) on the given zoom level
*/ */

View file

@ -6,8 +6,10 @@
import ContactLink from "./ContactLink.svelte" import ContactLink from "./ContactLink.svelte"
import { GeoOperations } from "../../Logic/GeoOperations" import { GeoOperations } from "../../Logic/GeoOperations"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
import type { Feature, Geometry, GeometryCollection } from "@turf/turf" import type { Feature, Geometry, GeometryCollection } from "@turf/turf"
import type { FeatureCollection, Polygon } from "geojson"
import type { CommunityResource } from "../../Logic/Web/CommunityIndex"
import Tr from "../Base/Tr.svelte"
export let location: Store<{ lat: number; lon: number }> export let location: Store<{ lat: number; lon: number }>
const tileToFetch: Store<string> = location.mapD((l) => { const tileToFetch: Store<string> = location.mapD((l) => {
@ -20,7 +22,10 @@
>([]) >([])
tileToFetch.addCallbackAndRun(async (url) => { tileToFetch.addCallbackAndRun(async (url) => {
const data = await Utils.downloadJsonCached(url, 24 * 60 * 60) const data = await Utils.downloadJsonCached<FeatureCollection<Polygon, {
nameEn: string,
resources: Record<string, CommunityResource>
}>>(url, 24 * 60 * 60)
if (data === undefined) { if (data === undefined) {
return return
} }
@ -29,15 +34,13 @@
const filteredResources = resources.map( const filteredResources = resources.map(
(features) => (features) =>
features.filter((f) => { features.filter((f) => GeoOperations.inside([location.data.lon, location.data.lat], f)),
return GeoOperations.inside([location.data.lon, location.data.lat], f)
}),
[location] [location]
) )
</script> </script>
<div> <div>
<ToSvelte construct={t.intro} /> <Tr t={t.intro} />
{#each $filteredResources as feature} {#each $filteredResources as feature}
<ContactLink country={feature.properties} /> <ContactLink country={feature.properties} />
{/each} {/each}

View file

@ -3,23 +3,18 @@
// The _properties_ of a community feature // The _properties_ of a community feature
import Locale from "../i18n/Locale.js" import Locale from "../i18n/Locale.js"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
import * as native from "../../assets/language_native.json" import * as native from "../../assets/language_native.json"
import { TypedTranslation } from "../i18n/Translation" import { TypedTranslation } from "../i18n/Translation"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import type { CommunityResource } from "../../Logic/Web/CommunityIndex"
const availableTranslationTyped: TypedTranslation<{ native: string }> = const availableTranslationTyped: TypedTranslation<{ native: string }> =
Translations.t.communityIndex.available Translations.t.communityIndex.available
const availableTranslation = availableTranslationTyped.OnEveryLanguage((s, ln) => const availableTranslation = availableTranslationTyped.OnEveryLanguage((s, ln) =>
s.replace("{native}", native[ln] ?? ln) s.replace("{native}", native[ln] ?? ln)
) )
export let country: { resources; nameEn: string } export let country: { resources: Record<string, CommunityResource>; nameEn: string }
let resources: { let resources: CommunityResource[] = []
id: string
resolved: Record<string, string>
languageCodes: string[]
type: string
}[] = []
$: resources = Array.from(Object.values(country?.resources ?? {})) $: resources = Array.from(Object.values(country?.resources ?? {}))
const language = Locale.language const language = Locale.language

View file

@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"
describe("GeoOperations", () => { describe("GeoOperations", () => {
describe("calculateOverlap", () => { describe("calculateOverlap", () => {
it("should not give too much overlap (regression test)", () => { it("should not give too much overlap (regression test)", () => {
const polyGrb = { const polyGrb: Feature<Polygon> = <any>{
type: "Feature", type: "Feature",
properties: { properties: {
osm_id: "25189153", osm_id: "25189153",
@ -37,7 +37,7 @@ describe("GeoOperations", () => {
"_now:date": "2021-12-05", "_now:date": "2021-12-05",
"_now:datetime": "2021-12-05 21:51:40", "_now:datetime": "2021-12-05 21:51:40",
"_loaded:date": "2021-12-05", "_loaded:date": "2021-12-05",
"_loaded:datetime": "2021-12-05 21:51:40", "_loaded:datetime": "2021-12-05 21:51:40"
}, },
geometry: { geometry: {
type: "Polygon", type: "Polygon",
@ -50,21 +50,21 @@ describe("GeoOperations", () => {
[3.24329779999996, 50.837435399999855], [3.24329779999996, 50.837435399999855],
[3.2431881000000504, 50.83740090000025], [3.2431881000000504, 50.83740090000025],
[3.243152699999997, 50.83738980000017], [3.243152699999997, 50.83738980000017],
[3.2431059999999974, 50.83730270000021], [3.2431059999999974, 50.83730270000021]
], ]
], ]
}, },
id: "https://betadata.grbosm.site/grb?bbox=360935.6475626023,6592540.815539878,361088.52161917265,6592693.689596449/37", id: "https://betadata.grbosm.site/grb?bbox=360935.6475626023,6592540.815539878,361088.52161917265,6592693.689596449/37",
_lon: 3.2432137000000116, _lon: 3.2432137000000116,
_lat: 50.83736194999996, _lat: 50.83736194999996,
bbox: { bbox: {
minLat: 50.83728850000007,
maxLat: 50.837435399999855, maxLat: 50.837435399999855,
maxLon: 3.2433214000000254, maxLon: 3.2433214000000254,
minLat: 50.83728850000007, minLon: 3.2431059999999974
minLon: 3.2431059999999974, }
},
} }
const polyHouse = { const polyHouse: Feature<Polygon> = <any>{
type: "Feature", type: "Feature",
id: "way/594963177", id: "way/594963177",
properties: { properties: {
@ -95,7 +95,7 @@ describe("GeoOperations", () => {
"_loaded:date": "2021-12-05", "_loaded:date": "2021-12-05",
"_loaded:datetime": "2021-12-05 21:51:39", "_loaded:datetime": "2021-12-05 21:51:39",
_surface: "93.32785810484549", _surface: "93.32785810484549",
"_surface:ha": "0", "_surface:ha": "0"
}, },
geometry: { geometry: {
type: "Polygon", type: "Polygon",
@ -108,9 +108,9 @@ describe("GeoOperations", () => {
[3.2431691, 50.8374252], [3.2431691, 50.8374252],
[3.2430936, 50.837401], [3.2430936, 50.837401],
[3.243046, 50.8374112], [3.243046, 50.8374112],
[3.2429993, 50.8373243], [3.2429993, 50.8373243]
], ]
], ]
}, },
_lon: 3.2430937, _lon: 3.2430937,
_lat: 50.83736395, _lat: 50.83736395,
@ -118,8 +118,8 @@ describe("GeoOperations", () => {
maxLat: 50.8374252, maxLat: 50.8374252,
maxLon: 3.2431881, maxLon: 3.2431881,
minLat: 50.8373027, minLat: 50.8373027,
minLon: 3.2429993, minLon: 3.2429993
}, }
} }
const p0 = turf.polygon(polyGrb.geometry.coordinates) const p0 = turf.polygon(polyGrb.geometry.coordinates)
@ -145,11 +145,11 @@ describe("GeoOperations", () => {
[3.218560377159008, 51.21499687768525], [3.218560377159008, 51.21499687768525],
[3.2207456783268356, 51.21499687768525], [3.2207456783268356, 51.21499687768525],
[3.2207456783268356, 51.21600586532159], [3.2207456783268356, 51.21600586532159],
[3.218560377159008, 51.21600586532159], [3.218560377159008, 51.21600586532159]
], ]
], ],
type: "Polygon", type: "Polygon"
}, }
} }
const line: Feature<LineString> = { const line: Feature<LineString> = {
type: "Feature", type: "Feature",
@ -157,10 +157,10 @@ describe("GeoOperations", () => {
geometry: { geometry: {
coordinates: [ coordinates: [
[3.218405371672816, 51.21499091846559], [3.218405371672816, 51.21499091846559],
[3.2208408127450525, 51.21560173433727], [3.2208408127450525, 51.21560173433727]
], ],
type: "LineString", type: "LineString"
}, }
} }
const result = GeoOperations.clipWith(line, bbox) const result = GeoOperations.clipWith(line, bbox)
expect(result.length).to.equal(1) expect(result.length).to.equal(1)
@ -168,10 +168,83 @@ describe("GeoOperations", () => {
const clippedLine = (<Feature<LineString>>result[0]).geometry.coordinates const clippedLine = (<Feature<LineString>>result[0]).geometry.coordinates
const expCoordinates = [ const expCoordinates = [
[3.2185604, 51.215029800031594], [3.2185604, 51.215029800031594],
[3.2207457, 51.21557787977764], [3.2207457, 51.21557787977764]
] ]
expect(clippedLine).to.deep.equal(expCoordinates) expect(clippedLine).to.deep.equal(expCoordinates)
}) })
it("clipWith should contain the full feature if it is fully contained", () => {
const bbox: Feature<Polygon> = {
type: "Feature",
properties: {},
geometry: {
coordinates: [
[
[
2.1541744759711037,
51.73994420687188
],
[
2.1541744759711037,
50.31129074222787
],
[
4.53247037641421,
50.31129074222787
],
[
4.53247037641421,
51.73994420687188
],
[
2.1541744759711037,
51.73994420687188
]
]
],
type: "Polygon"
}
}
const content: Feature<Polygon> = {
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
[
[
2.8900597545854225,
50.9035099487991
],
[
3.4872999807053873,
50.74856284865993
],
[
3.9512276563531543,
50.947206170675486
],
[
3.897902636163167,
51.25526892606362
],
[
3.188679867646016,
51.24525576870511
], [
2.8900597545854225,
50.9035099487991
]
]
],
"type": "Polygon"
}
}
const clipped = GeoOperations.clipWith(content, bbox)
expect(clipped.length).to.equal(1)
const clippedReverse = GeoOperations.clipWith(bbox, content)
expect(clippedReverse.length).to.equal(1)
}
)
}) })
}) })