From 509b237d02aa6fc904e9dc90e70837da571af346 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Tue, 14 Feb 2023 00:08:21 +0100 Subject: [PATCH] Add clipping to generateCache --- Logic/GeoOperations.ts | 194 ++++++++++++++++++------------- scripts/generateCache.ts | 32 +++-- test/Logic/GeoOperations.spec.ts | 41 +++++++ 3 files changed, 177 insertions(+), 90 deletions(-) diff --git a/Logic/GeoOperations.ts b/Logic/GeoOperations.ts index b5a34956a..457656538 100644 --- a/Logic/GeoOperations.ts +++ b/Logic/GeoOperations.ts @@ -15,6 +15,11 @@ import togpx from "togpx" import Constants from "../Models/Constants" export class GeoOperations { + /** + * Create a union between two features + */ + static union = turf.union + static intersect = turf.intersect private static readonly _earthRadius = 6378137 private static readonly _originShift = (2 * Math.PI * GeoOperations._earthRadius) / 2 @@ -158,35 +163,6 @@ export class GeoOperations { return result } - /** - * Helper function which does the heavy lifting for 'inside' - */ - private static pointInPolygonCoordinates( - x: number, - y: number, - coordinates: [number, number][][] - ) { - const inside = GeoOperations.pointWithinRing( - x, - y, - /*This is the outer ring of the polygon */ coordinates[0] - ) - if (!inside) { - return false - } - for (let i = 1; i < coordinates.length; i++) { - const inHole = GeoOperations.pointWithinRing( - x, - y, - coordinates[i] /* These are inner rings, aka holes*/ - ) - if (inHole) { - return false - } - } - return true - } - /** * Detect wether or not the given point is located in the feature * @@ -620,6 +596,113 @@ export class GeoOperations { return copy } + /** + * Takes two points and finds the geographic bearing between them, i.e. the angle measured in degrees from the north line (0 degrees) + */ + public static bearing(a: Coord, b: Coord): number { + return turf.bearing(a, b) + } + + /** + * Returns 'true' if one feature contains the other feature + * + * const pond: Feature = { + * "type": "Feature", + * "properties": {"natural":"water","water":"pond"}, + * "geometry": { + * "type": "Polygon", + * "coordinates": [[ + * [4.362924098968506,50.8435422298544 ], + * [4.363272786140442,50.8435219059949 ], + * [4.363213777542114,50.8437420806679 ], + * [4.362924098968506,50.8435422298544 ] + * ]]}} + * const park: Feature = { + * "type": "Feature", + * "properties": {"leisure":"park"}, + * "geometry": { + * "type": "Polygon", + * "coordinates": [[ + * [ 4.36073541641235,50.84323737103244 ], + * [ 4.36469435691833, 50.8423905305197 ], + * [ 4.36659336090087, 50.8458997374786 ], + * [ 4.36254858970642, 50.8468007074916 ], + * [ 4.36073541641235, 50.8432373710324 ] + * ]]}} + * GeoOperations.completelyWithin(pond, park) // => true + * GeoOperations.completelyWithin(park, pond) // => false + */ + static completelyWithin( + feature: Feature, + possiblyEncloingFeature: Feature + ): boolean { + return booleanWithin(feature, possiblyEncloingFeature) + } + + /** + * Create an intersection between two features. + * A new feature is returned based on 'toSplit', which'll have a geometry that is completely withing boundary + */ + public static clipWith(toSplit: Feature, boundary: Feature): Feature[] { + if (toSplit.geometry.type === "Point") { + const p = >toSplit + if (GeoOperations.inside(p.geometry.coordinates, boundary)) { + return [p] + } else { + return [] + } + } + + if (toSplit.geometry.type === "LineString") { + const splitup = turf.lineSplit(>toSplit, boundary) + const kept = [] + for (const f of splitup.features) { + const ls = >f + if (!GeoOperations.inside(GeoOperations.centerpointCoordinates(f), boundary)) { + continue + } + f.properties = { ...toSplit.properties } + kept.push(f) + } + return kept + } + if (toSplit.geometry.type === "Polygon" || toSplit.geometry.type == "MultiPolygon") { + const splitup = turf.intersect(>toSplit, boundary) + splitup.properties = { ...toSplit.properties } + return [splitup] + } + throw "Invalid geometry type with GeoOperations.clipWith: " + toSplit.geometry.type + } + + /** + * Helper function which does the heavy lifting for 'inside' + */ + private static pointInPolygonCoordinates( + x: number, + y: number, + coordinates: [number, number][][] + ) { + const inside = GeoOperations.pointWithinRing( + x, + y, + /*This is the outer ring of the polygon */ coordinates[0] + ) + if (!inside) { + return false + } + for (let i = 1; i < coordinates.length; i++) { + const inHole = GeoOperations.pointWithinRing( + x, + y, + coordinates[i] /* These are inner rings, aka holes*/ + ) + if (inHole) { + return false + } + } + return true + } + private static pointWithinRing(x: number, y: number, ring: [number, number][]) { let inside = false for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { @@ -740,57 +823,4 @@ export class GeoOperations { } throw "CalculateIntersection fallthrough: can not calculate an intersection between features" } - - /** - * Takes two points and finds the geographic bearing between them, i.e. the angle measured in degrees from the north line (0 degrees) - */ - public static bearing(a: Coord, b: Coord): number { - return turf.bearing(a, b) - } - - /** - * Returns 'true' if one feature contains the other feature - * - * const pond: Feature = { - * "type": "Feature", - * "properties": {"natural":"water","water":"pond"}, - * "geometry": { - * "type": "Polygon", - * "coordinates": [[ - * [4.362924098968506,50.8435422298544 ], - * [4.363272786140442,50.8435219059949 ], - * [4.363213777542114,50.8437420806679 ], - * [4.362924098968506,50.8435422298544 ] - * ]]}} - * const park: Feature = { - * "type": "Feature", - * "properties": {"leisure":"park"}, - * "geometry": { - * "type": "Polygon", - * "coordinates": [[ - * [ 4.36073541641235,50.84323737103244 ], - * [ 4.36469435691833, 50.8423905305197 ], - * [ 4.36659336090087, 50.8458997374786 ], - * [ 4.36254858970642, 50.8468007074916 ], - * [ 4.36073541641235, 50.8432373710324 ] - * ]]}} - * GeoOperations.completelyWithin(pond, park) // => true - * GeoOperations.completelyWithin(park, pond) // => false - */ - static completelyWithin( - feature: Feature, - possiblyEncloingFeature: Feature - ): boolean { - return booleanWithin(feature, possiblyEncloingFeature) - } - - /** - * Create a union between two features - */ - static union = turf.union - - /** - * Create an intersection between two features - */ - static intersect = turf.intersect } diff --git a/scripts/generateCache.ts b/scripts/generateCache.ts index f145378e2..104498895 100644 --- a/scripts/generateCache.ts +++ b/scripts/generateCache.ts @@ -24,6 +24,9 @@ import { GeoOperations } from "../Logic/GeoOperations" import SimpleMetaTaggers from "../Logic/SimpleMetaTagger" import FilteringFeatureSource from "../Logic/FeatureSource/Sources/FilteringFeatureSource" import Loc from "../Models/Loc" +import { Feature } from "geojson" +import { BBox } from "../Logic/BBox" +import { bboxClip } from "@turf/turf" ScriptUtils.fixUtils() @@ -232,7 +235,8 @@ function sliceToTiles( theme: LayoutConfig, relationsTracker: RelationsTracker, targetdir: string, - pointsOnlyLayers: string[] + pointsOnlyLayers: string[], + clip: boolean ) { const skippedLayers = new Set() @@ -310,6 +314,7 @@ function sliceToTiles( maxFeatureCount: undefined, registerTile: (tile) => { const tileIndex = tile.tileIndex + const bbox = BBox.fromTileIndex(tileIndex).asGeoJson({}) console.log("Got tile:", tileIndex, tile.layer.layerDef.id) if (tile.features.data.length === 0) { return @@ -343,9 +348,9 @@ function sliceToTiles( } let strictlyCalculated = 0 let featureCount = 0 - for (const feature of filteredTile.features.data) { + let features: Feature[] = filteredTile.features.data.map((f) => f.feature) + for (const feature of features) { // Some cleanup - delete feature.feature["bbox"] if (tile.layer.layerDef.calculatedTags !== undefined) { // Evaluate all the calculated tags strictly @@ -353,7 +358,7 @@ function sliceToTiles( (ct) => ct[0] ) featureCount++ - const props = feature.feature.properties + const props = feature.properties for (const calculatedTagKey of calculatedTagKeys) { const strict = props[calculatedTagKey] @@ -379,7 +384,16 @@ function sliceToTiles( } } } + delete feature["bbox"] } + + if (clip) { + console.log("Clipping features") + features = [].concat( + ...features.map((f: Feature) => GeoOperations.clipWith(f, bbox)) + ) + } + // Lets save this tile! const [z, x, y] = Tiles.tile_from_index(tileIndex) // console.log("Writing tile ", z, x, y, layerId) @@ -391,7 +405,7 @@ function sliceToTiles( JSON.stringify( { type: "FeatureCollection", - features: filteredTile.features.data.map((f) => f.feature), + features, }, null, " " @@ -476,8 +490,9 @@ export async function main(args: string[]) { console.log("Cache builder started with args ", args.join(", ")) if (args.length < 6) { console.error( - "Expected arguments are: theme zoomlevel targetdirectory lat0 lon0 lat1 lon1 [--generate-point-overview layer-name,layer-name,...] [--force-zoom-level z] \n" + - "Note: a new directory named will be created in targetdirectory" + "Expected arguments are: theme zoomlevel targetdirectory lat0 lon0 lat1 lon1 [--generate-point-overview layer-name,layer-name,...] [--force-zoom-level z] [--clip]" + + "--force-zoom-level causes non-cached-layers to be donwnloaded\n" + + "--clip will erase parts of the feature falling outside of the bounding box" ) return } @@ -494,6 +509,7 @@ export async function main(args: string[]) { const lon0 = Number(args[4]) const lat1 = Number(args[5]) const lon1 = Number(args[6]) + const clip = args.indexOf("--clip") >= 0 if (isNaN(lat0)) { throw "The first number (a latitude) is not a valid number" @@ -570,7 +586,7 @@ export async function main(args: string[]) { const extraFeatures = await downloadExtraData(theme) const allFeaturesSource = loadAllTiles(targetdir, tileRange, theme, extraFeatures) - sliceToTiles(allFeaturesSource, theme, relationTracker, targetdir, generatePointLayersFor) + sliceToTiles(allFeaturesSource, theme, relationTracker, targetdir, generatePointLayersFor, clip) } let args = [...process.argv] diff --git a/test/Logic/GeoOperations.spec.ts b/test/Logic/GeoOperations.spec.ts index 749dce613..bc52f6fff 100644 --- a/test/Logic/GeoOperations.spec.ts +++ b/test/Logic/GeoOperations.spec.ts @@ -2,6 +2,7 @@ import { describe } from "mocha" import { expect } from "chai" import * as turf from "@turf/turf" import { GeoOperations } from "../../Logic/GeoOperations" +import { Feature, LineString, Polygon } from "geojson" describe("GeoOperations", () => { describe("calculateOverlap", () => { @@ -133,4 +134,44 @@ describe("GeoOperations", () => { expect(overlapsRev).empty }) }) + describe("clipWith", () => { + it("clipWith should clip linestrings", () => { + const bbox: Feature = { + type: "Feature", + properties: {}, + geometry: { + coordinates: [ + [ + [3.218560377159008, 51.21600586532159], + [3.218560377159008, 51.21499687768525], + [3.2207456783268356, 51.21499687768525], + [3.2207456783268356, 51.21600586532159], + [3.218560377159008, 51.21600586532159], + ], + ], + type: "Polygon", + }, + } + const line: Feature = { + type: "Feature", + properties: {}, + geometry: { + coordinates: [ + [3.218405371672816, 51.21499091846559], + [3.2208408127450525, 51.21560173433727], + ], + type: "LineString", + }, + } + const result = GeoOperations.clipWith(line, bbox) + expect(result.length).to.equal(1) + expect(result[0].geometry.type).to.eq("LineString") + const clippedLine = (>result[0]).geometry.coordinates + const expCoordinates = [ + [3.2185480732975975, 51.21502965337126], + [3.2207456783252724, 51.2155808773463], + ] + expect(clippedLine).to.deep.equal(expCoordinates) + }) + }) })