Add polygon merging

This commit is contained in:
Pieter Vander Vennet 2024-01-26 18:18:07 +01:00
parent ee38cdb9d7
commit ee3e000cd1
11 changed files with 460 additions and 305 deletions

View file

@ -16,6 +16,12 @@ export interface FeatureSourceForLayer<T extends Feature = Feature> extends Feat
readonly layer: FilteredLayer
}
export interface FeatureSourceForTile <T extends Feature = Feature> extends FeatureSource<T> {
readonly x: number
readonly y: number
readonly z: number
}
/**
* A feature source which is aware of the indexes it contains
*/

View file

@ -2,43 +2,49 @@ import { Store, UIEventSource } from "../../UIEventSource"
import { FeatureSource, IndexedFeatureSource } from "../FeatureSource"
import { Feature } from "geojson"
import { Utils } from "../../../Utils"
import DynamicTileSource from "../TiledFeatureSource/DynamicTileSource"
/**
*
* The featureSourceMerger receives complete geometries from various sources.
* If multiple sources contain the same object (as determined by 'id'), only one copy of them is retained
*/
export default class FeatureSourceMerger implements IndexedFeatureSource {
export default class FeatureSourceMerger<Src extends FeatureSource = FeatureSource> implements IndexedFeatureSource {
public features: UIEventSource<Feature[]> = new UIEventSource([])
public readonly featuresById: Store<Map<string, Feature>>
private readonly _featuresById: UIEventSource<Map<string, Feature>>
private readonly _sources: FeatureSource[] = []
protected readonly _featuresById: UIEventSource<Map<string, Feature>>
private readonly _sources: Src[] = []
/**
* Merges features from different featureSources.
* In case that multiple features have the same id, the latest `_version_number` will be used. Otherwise, we will take the last one
*/
constructor(...sources: FeatureSource[]) {
constructor(...sources: Src[]) {
this._featuresById = new UIEventSource<Map<string, Feature>>(new Map<string, Feature>())
this.featuresById = this._featuresById
const self = this
sources = Utils.NoNull(sources)
for (let source of sources) {
source.features.addCallback(() => {
self.addData(sources.map((s) => s.features.data))
self.addDataFromSources(sources)
})
}
this.addData(sources.map((s) => s.features.data))
this.addDataFromSources(sources)
this._sources = sources
}
public addSource(source: FeatureSource) {
public addSource(source: Src) {
if (!source) {
return
}
this._sources.push(source)
source.features.addCallbackAndRun(() => {
this.addData(this._sources.map((s) => s.features.data))
this.addDataFromSources(this._sources)
})
}
protected addDataFromSources(sources: Src[]){
this.addData(sources.map(s => s.features.data))
}
protected addData(sources: Feature[][]) {
sources = Utils.NoNull(sources)
let somethingChanged = false
@ -56,7 +62,7 @@ export default class FeatureSourceMerger implements IndexedFeatureSource {
const id = f.properties.id
unseen.delete(id)
if (!all.has(id)) {
// This is a new feature
// This is a new, previously unseen feature
somethingChanged = true
all.set(id, f)
continue
@ -81,10 +87,8 @@ export default class FeatureSourceMerger implements IndexedFeatureSource {
return
}
const newList = []
all.forEach((value) => {
newList.push(value)
})
const newList = Array.from(all.values())
this.features.setData(newList)
this._featuresById.setData(all)
}

View file

@ -1,6 +1,6 @@
import { Feature, Geometry } from "geojson"
import { Store, UIEventSource } from "../../UIEventSource"
import { FeatureSource } from "../FeatureSource"
import { FeatureSourceForTile } from "../FeatureSource"
import Pbf from "pbf"
import * as pbfCompile from "pbf/compile"
import * as PbfSchema from "protocol-buffers-schema"
@ -19,8 +19,67 @@ class MvtFeatureBuilder {
this._y0 = extent * y
}
public toGeoJson(geometry, typeIndex, properties): Feature {
let coords: [number, number] | Coords | Coords[] = this.encodeGeometry(geometry)
private static signedArea(ring: Coords): number {
let sum = 0
const len = ring.length
// J is basically (i - 1) % len
let j = len - 1
let p1
let p2
for (let i = 0; i < len; i++) {
p1 = ring[i]
p2 = ring[j]
sum += (p2.x - p1.x) * (p1.y + p2.y)
j = i
}
return sum
}
/**
*
* const rings = [ [ [ 3.208361864089966, 51.186908820014736 ], [ 3.2084155082702637, 51.18689537073311 ], [ 3.208436965942383, 51.186888646090836 ], [ 3.2084155082702637, 51.18686174751187 ], [ 3.2084155082702637, 51.18685502286465 ], [ 3.2083725929260254, 51.18686847215807 ], [ 3.2083404064178467, 51.18687519680333 ], [ 3.208361864089966, 51.186908820014736 ] ] ]
* MvtFeatureBuilder.classifyRings(rings) // => [rings]
*/
private static classifyRings(rings: Coords[]): Coords[][] {
if (rings.length <= 0) {
throw "Now rings in polygon found"
}
if (rings.length == 1) {
return [rings]
}
const polygons: Coords[][] = []
let currentPolygon: Coords[]
for (let i = 0; i < rings.length; i++) {
let ring = rings[i]
const area = this.signedArea(ring)
if (area === 0) {
// Weird, degenerate ring
continue
}
const ccw = area < 0
if (ccw === (area < 0)) {
if (currentPolygon) {
polygons.push(currentPolygon)
}
currentPolygon = [ring]
} else {
currentPolygon.push(ring)
}
}
if (currentPolygon) {
polygons.push(currentPolygon)
}
return polygons
}
public toGeoJson(geometry: number[], typeIndex: 1 | 2 | 3, properties: any): Feature {
let coords: Coords[] = this.encodeGeometry(geometry)
let classified = undefined
switch (typeIndex) {
case 1:
const points = []
@ -38,9 +97,9 @@ class MvtFeatureBuilder {
break
case 3:
let classified = this.classifyRings(coords)
for (let i = 0; i < coords.length; i++) {
for (let j = 0; j < coords[i].length; j++) {
classified = MvtFeatureBuilder.classifyRings(coords)
for (let i = 0; i < classified.length; i++) {
for (let j = 0; j < classified[i].length; j++) {
this.project(classified[i][j])
}
}
@ -48,9 +107,11 @@ class MvtFeatureBuilder {
}
let type: string = MvtFeatureBuilder.geom_types[typeIndex]
let polygonCoords: Coords | Coords[] | Coords[][]
if (coords.length === 1) {
coords = coords[0]
polygonCoords = (classified ?? coords)[0]
} else {
polygonCoords = classified ?? coords
type = "Multi" + type
}
@ -58,13 +119,22 @@ class MvtFeatureBuilder {
type: "Feature",
geometry: {
type: <any>type,
coordinates: <any>coords,
coordinates: <any>polygonCoords,
},
properties,
}
}
private encodeGeometry(geometry: number[]) {
/**
*
* const geometry = [9,233,8704,130,438,1455,270,653,248,423,368,493,362,381,330,267,408,301,406,221,402,157,1078,429,1002,449,1036,577,800,545,1586,1165,164,79,40]
* const builder = new MvtFeatureBuilder(4096, 66705, 43755, 17)
* const expected = [[3.2106759399175644,51.213658395282124],[3.2108227908611298,51.21396418776169],[3.2109133154153824,51.21410154168976],[3.210996463894844,51.214190590500664],[3.211119845509529,51.214294340548975],[3.211241215467453,51.2143745681588],[3.2113518565893173,51.21443085341426],[3.211488649249077,51.21449427925393],[3.2116247713565826,51.214540903490956],[3.211759552359581,51.21457408647774],[3.2121209800243378,51.214664394485254],[3.212456926703453,51.21475890267553],[3.2128042727708817,51.214880292910834],[3.213072493672371,51.214994962285544],[3.2136042416095734,51.21523984134939],[3.2136592268943787,51.21525664260963],[3.213672637939453,51.21525664260963]]
* builder.project(builder.encodeGeometry(geometry)[0]) // => expected
* @param geometry
* @private
*/
private encodeGeometry(geometry: number[]): Coords[] {
let cX = 0
let cY = 0
let coordss: Coords[] = []
@ -86,7 +156,7 @@ class MvtFeatureBuilder {
currentRing = []
}
}
if (commandId === 1 || commandId === 2){
if (commandId === 1 || commandId === 2) {
for (let j = 0; j < commandCount; j++) {
const dx = geometry[i + j * 2 + 1]
cX += ((dx >> 1) ^ (-(dx & 1)))
@ -94,10 +164,11 @@ class MvtFeatureBuilder {
cY += ((dy >> 1) ^ (-(dy & 1)))
currentRing.push([cX, cY])
}
i = commandCount * 2
i += commandCount * 2
}
if(commandId === 7){
if (commandId === 7) {
currentRing.push([...currentRing[0]])
i++
}
}
@ -107,62 +178,12 @@ class MvtFeatureBuilder {
return coordss
}
private signedArea(ring: Coords): number {
let sum = 0
const len = ring.length
// J is basically (i - 1) % len
let j = len - 1
let p1
let p2
for (let i = 0; i < len; i++) {
p1 = ring[i]
p2 = ring[j]
sum += (p2.x - p1.x) * (p1.y + p2.y)
j = i
}
return sum
}
private classifyRings(rings: Coords[]): Coords[][] {
const len = rings.length
if (len <= 1) return [rings]
const polygons = []
let polygon
// CounterClockWise
let ccw: boolean
for (let i = 0; i < len; i++) {
const area = this.signedArea(rings[i])
if (area === 0) continue
if (ccw === undefined) {
ccw = area < 0
}
if (ccw === (area < 0)) {
if (polygon) {
polygons.push(polygon)
}
polygon = [rings[i]]
} else {
polygon.push(rings[i])
}
}
if (polygon) {
polygons.push(polygon)
}
return polygons
}
/**
* Inline replacement of the location by projecting
* @param line
* @private
* @param line the line which will be rewritten inline
* @return line
*/
private project(line: [number, number][]) {
private project(line: Coords) {
const y0 = this._y0
const x0 = this._x0
const size = this._size
@ -174,12 +195,13 @@ class MvtFeatureBuilder {
360 / Math.PI * Math.atan(Math.exp(y2 * Math.PI / 180)) - 90,
]
}
return line
}
}
export default class MvtSource implements FeatureSource {
export default class MvtSource implements FeatureSourceForTile {
private static readonly schemaSpec = `
private static readonly schemaSpec21 = `
package vector_tile;
option optimize_for = LITE_RUNTIME;
@ -259,26 +281,30 @@ message Tile {
extensions 16 to 8191;
}
`
private static readonly tile_schema = pbfCompile(PbfSchema.parse(MvtSource.schemaSpec)).Tile
private static readonly tile_schema = (pbfCompile.default ?? pbfCompile)(PbfSchema.parse(MvtSource.schemaSpec21)).Tile
public readonly features: Store<Feature<Geometry, { [name: string]: any }>[]>
private readonly _url: string
private readonly _layerName: string
private readonly _features: UIEventSource<Feature<Geometry, {
[name: string]: any
}>[]> = new UIEventSource<Feature<Geometry, { [p: string]: any }>[]>([])
public readonly features: Store<Feature<Geometry, { [name: string]: any }>[]> = this._features
private readonly x: number
private readonly y: number
private readonly z: number
public readonly x: number
public readonly y: number
public readonly z: number
constructor(url: string, x: number, y: number, z: number, layerName?: string) {
constructor(url: string, x: number, y: number, z: number, layerName?: string, isActive?: Store<boolean>) {
this._url = url
this._layerName = layerName
this.x = x
this.y = y
this.z = z
this.downloadSync()
this.features = this._features.map(fs => {
if (fs === undefined || isActive?.data === false) {
return []
}
return fs
}, [isActive])
}
private getValue(v: {
@ -316,16 +342,23 @@ message Tile {
}
private downloadSync(){
private downloadSync() {
this.download().then(d => {
if(d.length === 0){
if (d.length === 0) {
return
}
return this._features.setData(d)
}).catch(e => {console.error(e)})
}).catch(e => {
console.error(e)
})
}
private async download(): Promise<Feature[]> {
const result = await fetch(this._url)
if (result.status !== 200) {
console.error("Could not download tile " + this._url)
return []
}
const buffer = await result.arrayBuffer()
const data = MvtSource.tile_schema.read(new Pbf(buffer))
const layers = data.layers
@ -336,7 +369,7 @@ message Tile {
}
layer = layers.find(l => l.name === this._layerName)
}
if(!layer){
if (!layer) {
return []
}
const builder = new MvtFeatureBuilder(layer.extent, this.x, this.y, this.z)

View file

@ -1,4 +1,4 @@
import { Store } from "../../UIEventSource"
import { ImmutableStore, Store } from "../../UIEventSource"
import DynamicTileSource from "./DynamicTileSource"
import { Utils } from "../../../Utils"
import GeoJsonSource from "../Sources/GeoJsonSource"
@ -65,7 +65,7 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
const blackList = new Set<string>()
super(
source.geojsonZoomLevel,
new ImmutableStore(source.geojsonZoomLevel),
layer.minzoom,
(zxy) => {
if (whitelist !== undefined) {

View file

@ -1,13 +1,45 @@
import { Store } from "../../UIEventSource"
import DynamicTileSource from "./DynamicTileSource"
import DynamicTileSource, { PolygonSourceMerger } from "./DynamicTileSource"
import { Utils } from "../../../Utils"
import { BBox } from "../../BBox"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import MvtSource from "../Sources/MvtSource"
import { Tiles } from "../../../Models/TileRange"
import Constants from "../../../Models/Constants"
import FeatureSourceMerger from "../Sources/FeatureSourceMerger"
export default class DynamicMvtileSource extends DynamicTileSource {
class PolygonMvtSource extends PolygonSourceMerger{
constructor( layer: LayerConfig,
mapProperties: {
zoom: Store<number>
bounds: Store<BBox>
},
options?: {
isActive?: Store<boolean>
}) {
const roundedZoom = mapProperties.zoom.mapD(z => Math.min(Math.floor(z/2)*2, 14))
super(
roundedZoom,
layer.minzoom,
(zxy) => {
const [z, x, y] = Tiles.tile_from_index(zxy)
const url = Utils.SubstituteKeys(Constants.VectorTileServer,
{
z, x, y, layer: layer.id,
type: "polygons",
})
return new MvtSource(url, x, y, z)
},
mapProperties,
{
isActive: options?.isActive,
})
}
}
class PointMvtSource extends DynamicTileSource {
constructor(
layer: LayerConfig,
@ -19,14 +51,16 @@ export default class DynamicMvtileSource extends DynamicTileSource {
isActive?: Store<boolean>
},
) {
const roundedZoom = mapProperties.zoom.mapD(z => Math.min(Math.floor(z/2)*2, 14))
super(
mapProperties.zoom,
roundedZoom,
layer.minzoom,
(zxy) => {
const [z, x, y] = Tiles.tile_from_index(zxy)
const url = Utils.SubstituteKeys(Constants.VectorTileServer,
{
z, x, y, layer: layer.id,
type: "pois",
})
return new MvtSource(url, x, y, z)
},
@ -37,3 +71,24 @@ export default class DynamicMvtileSource extends DynamicTileSource {
)
}
}
export default class DynamicMvtileSource extends FeatureSourceMerger {
constructor(
layer: LayerConfig,
mapProperties: {
zoom: Store<number>
bounds: Store<BBox>
},
options?: {
isActive?: Store<boolean>
},
) {
const roundedZoom = mapProperties.zoom.mapD(z => Math.floor(z))
super(
new PointMvtSource(layer, mapProperties, options),
new PolygonMvtSource(layer, mapProperties, options)
)
}
}

View file

@ -1,25 +1,37 @@
import { Store, Stores } from "../../UIEventSource"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
import { FeatureSource } from "../FeatureSource"
import { FeatureSource, FeatureSourceForTile } from "../FeatureSource"
import FeatureSourceMerger from "../Sources/FeatureSourceMerger"
import { Feature } from "geojson"
import { Utils } from "../../../Utils"
import { GeoOperations } from "../../GeoOperations"
/***
* A tiled source which dynamically loads the required tiles at a fixed zoom level.
* A single featureSource will be initialized for every tile in view; which will later be merged into this featureSource
*/
export default class DynamicTileSource extends FeatureSourceMerger {
export default class DynamicTileSource<Src extends FeatureSource = FeatureSource> extends FeatureSourceMerger<Src> {
/**
*
* @param zoomlevel If {z} is specified in the source, the 'zoomlevel' will be used as zoomlevel to download from
* @param minzoom Only activate this feature source if zoomed in further then this
* @param constructSource
* @param mapProperties
* @param options
*/
constructor(
zoomlevel: Store<number>,
minzoom: number,
constructSource: (tileIndex: number) => FeatureSource,
constructSource: (tileIndex: number) => Src,
mapProperties: {
bounds: Store<BBox>
zoom: Store<number>
},
options?: {
isActive?: Store<boolean>
}
},
) {
super()
const loadedTiles = new Set<number>()
@ -34,32 +46,32 @@ export default class DynamicTileSource extends FeatureSourceMerger {
if (mapProperties.zoom.data < minzoom) {
return undefined
}
const z = Math.round(zoomlevel.data)
const z = Math.floor(zoomlevel.data)
const tileRange = Tiles.TileRangeBetween(
z,
bounds.getNorth(),
bounds.getEast(),
bounds.getSouth(),
bounds.getWest()
bounds.getWest(),
)
if (tileRange.total > 500) {
console.warn(
"Got a really big tilerange, bounds and location might be out of sync"
"Got a really big tilerange, bounds and location might be out of sync",
)
return undefined
}
const needed = Tiles.MapRange(tileRange, (x, y) =>
Tiles.tile_index(z, x, y)
Tiles.tile_index(z, x, y),
).filter((i) => !loadedTiles.has(i))
if (needed.length === 0) {
return undefined
}
return needed
},
[options?.isActive, mapProperties.zoom]
[options?.isActive, mapProperties.zoom],
)
.stabilized(250)
.stabilized(250),
)
neededTiles.addCallbackAndRunD((neededIndexes) => {
@ -70,3 +82,70 @@ export default class DynamicTileSource extends FeatureSourceMerger {
})
}
}
/**
* The PolygonSourceMerger receives various small pieces of bigger polygons and stitches them together.
* This is used to reconstruct polygons of vector tiles
*/
export class PolygonSourceMerger extends DynamicTileSource<FeatureSourceForTile> {
constructor(
zoomlevel: Store<number>,
minzoom: number,
constructSource: (tileIndex: number) => FeatureSourceForTile,
mapProperties: {
bounds: Store<BBox>
zoom: Store<number>
},
options?: {
isActive?: Store<boolean>
},
) {
super(zoomlevel, minzoom, constructSource, mapProperties, options)
}
protected addDataFromSources(sources: FeatureSourceForTile[]) {
sources = Utils.NoNull(sources)
const all: Map<string, Feature> = new Map()
const zooms: Map<string, number> = new Map()
for (const source of sources) {
let z = source.z
for (const f of source.features.data) {
const id = f.properties.id
if(id.endsWith("146616907")){
console.log("Horeca totaal")
}
if (!all.has(id)) {
// No other parts of this polygon have been seen before, simply add it
all.set(id, f)
zooms.set(id, z)
continue
}
// A part of this object has been seen before, eventually from a different zoom level
const oldV = all.get(id)
const oldZ = zooms.get(id)
if (oldZ > z) {
// The store contains more detailed information, so we ignore this part which has a lower accuraccy
continue
}
if (oldZ < z) {
// The old value has worse accuracy then what we receive now, we throw it away
all.set(id, f)
zooms.set(id, z)
continue
}
const merged = GeoOperations.union(f, oldV)
merged.properties = oldV.properties
all.set(id, merged)
zooms.set(id, z)
}
}
const newList = Array.from(all.values())
this.features.setData(newList)
this._featuresById.setData(all)
}
}