forked from marcelreppi/shape2geohash
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
316 lines (264 loc) · 10.4 KB
/
index.js
File metadata and controls
316 lines (264 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
const { polygon: turfPolygon, lineString: turfLine, point: turfPoint } = require("@turf/helpers")
const { default: turfBboxPolygon } = require("@turf/bbox-polygon")
const { default: turfBbox } = require("@turf/bbox")
const { default: turfEnvelope } = require("@turf/envelope")
const { default: turfIntersect } = require("@turf/intersect")
const { default: turfBooleanOverlap } = require("@turf/boolean-overlap")
const { default: turfBooleanWithin } = require("@turf/boolean-within")
const { default: turfBooleanPointInPolygon } = require("@turf/boolean-point-in-polygon")
const { default: turfLineSplit } = require("@turf/line-split")
const { default: turfArea } = require("@turf/area")
const ngeohash = require("ngeohash")
const Stream = require("stream")
const {
isPoint,
isLine,
isMulti,
switchBbox,
allRectangleEdgesWithin,
extractCoordinatesFromGeoJSON,
} = require("./helpers")
const defaultOptions = {
precision: 6,
hashMode: "intersect",
minIntersect: 0,
customWriter: null,
allowDuplicates: true,
}
async function shape2geohash(shapes, options = {}) {
options = { ...defaultOptions, ...options } // overwrite default options
let allShapes = null
if (Array.isArray(shapes)) {
// The input is either an array of polygon coordinates or a list of polygons
allShapes = isMulti(shapes) ? shapes : [shapes] // Make sure allShapes is always an array of shapes
} else {
allShapes = extractCoordinatesFromGeoJSON(shapes)
}
let allGeohashes = []
if (!options.allowDuplicates) {
allGeohashes = new Set()
}
const addGeohashes = geohashes => {
if (!options.allowDuplicates) {
geohashes.forEach(gh => allGeohashes.add(gh))
return
}
allGeohashes.push(...geohashes)
}
const allShapePromises = allShapes.map(shape => {
return new Promise((resolve, reject) => {
if (isPoint(shape) && options.customWriter === null) {
// Optimization for points. No need for streams.
addGeohashes([ngeohash.encode(...shape.reverse(), options.precision)])
resolve()
return
}
const geohashStream = new GeohashStream(shape, options)
const writer = new Stream.Writable({
objectMode: true,
write: (rowGeohashes, enc, callback) => {
addGeohashes(rowGeohashes)
callback()
},
})
if (options.customWriter) {
geohashStream.pipe(options.customWriter)
} else {
geohashStream.pipe(writer) // Kick off the stream
}
geohashStream.on("end", () => {
resolve()
})
})
})
return Promise.all(allShapePromises).then(() => Array.from(allGeohashes))
}
class GeohashStream extends Stream.Readable {
constructor(shapeCoordinates, options) {
super({ objectMode: true })
this.options = options
this.shapeIsPoint = isPoint(shapeCoordinates)
if (this.shapeIsPoint) {
this.pointCoordinates = shapeCoordinates
return
}
this.originalShape = isLine(shapeCoordinates)
? turfLine(shapeCoordinates)
: turfPolygon(shapeCoordinates)
// [minX, minY, maxX, maxY]
const originalEnvelopeBbox = turfBbox(turfEnvelope(this.originalShape))
// [minX, minY, maxX, maxY]
const topLeftGeohashBbox = switchBbox(
ngeohash.decode_bbox(
ngeohash.encode(originalEnvelopeBbox[3], originalEnvelopeBbox[0], this.options.precision)
)
)
// [minX, minY, maxX, maxY]
const bottomRightGeohashBbox = switchBbox(
ngeohash.decode_bbox(
ngeohash.encode(originalEnvelopeBbox[1], originalEnvelopeBbox[2], this.options.precision)
)
)
// The extended geohash envelope covers the area from top left geohash until bottom right geohash
// I use it instead of the original envelope because I want every row match the real geohash row
const geohashEnvelopeBbox = [
topLeftGeohashBbox[0],
bottomRightGeohashBbox[1],
bottomRightGeohashBbox[2],
topLeftGeohashBbox[3],
]
this.rowWidth = Math.abs(geohashEnvelopeBbox[2] - geohashEnvelopeBbox[0])
this.geohashHeight = Math.abs(topLeftGeohashBbox[3] - topLeftGeohashBbox[1])
// Current point is the top left corner of the extended geohash envelope
// Traversing the polygon from top to bottom
this.currentPoint = [geohashEnvelopeBbox[0], geohashEnvelopeBbox[3]]
// Bottom border of the extended geohash envelope
this.bottomLimit = geohashEnvelopeBbox[1]
// The minimum shared area between the polygon and the geohash
this.minIntersectArea =
this.options.minIntersect * turfArea(turfBboxPolygon(topLeftGeohashBbox))
// Used in processRowSegment to keep track of how much area of the row
// has been covered by the matching geohashes
// Prevent duplicate geohashes
this.rowProgress = -Infinity
}
_read(size) {
if (this.shapeIsPoint) {
this.push([ngeohash.encode(...this.pointCoordinates.reverse(), this.options.precision)])
this.push(null)
return
}
const rowGeohashes = this.processNextRow()
if (rowGeohashes !== null) {
this.push(rowGeohashes) // Push data out of the stream
} else {
this.push(null) // End the stream
}
}
processNextRow() {
if (this.currentPoint[1] <= this.bottomLimit) {
// We have reached the bottom of the polygon
return null
}
// Calculate the row polygon
const rowPolygon = turfBboxPolygon([
this.currentPoint[0],
this.currentPoint[1] - this.geohashHeight,
this.currentPoint[0] + this.rowWidth,
this.currentPoint[1],
])
const geohashes = [] // Geohashes for this row
if (this.options.hashMode === "envelope") {
geohashes.push(...this.processRowSegment(rowPolygon.geometry.coordinates))
} else {
if (this.originalShape.geometry.type === "LineString") {
const lineSegments = turfLineSplit(this.originalShape, rowPolygon).features
if (lineSegments.length){
let evenPairs
const firstPointOfFirstSegment = lineSegments[0].geometry.coordinates[0]
if (turfBooleanPointInPolygon(firstPointOfFirstSegment, rowPolygon)) {
evenPairs = true
} else {
evenPairs = false
}
// Filter for line segments that are inside the row polygon
// Put an envelope around the segment and get geohashes
lineSegments
.filter((p, i) => (evenPairs ? i % 2 == 0 : i % 2 == 1))
.forEach(lineSegment => {
const lineSegmentEnvelope = turfEnvelope(lineSegment).geometry.coordinates
geohashes.push(...this.processRowSegment(lineSegmentEnvelope))
})
}
} else {
// Its a Polygon
// Calculate the intersection between the row and the original polygon
const intersectionGeoJSON = turfIntersect(this.originalShape, rowPolygon)
if (intersectionGeoJSON !== null) {
let coordinates = intersectionGeoJSON.geometry.coordinates
coordinates = isMulti(coordinates) ? coordinates : [coordinates]
// Check every intersection part for geohashes
coordinates.forEach(polygon => {
geohashes.push(...this.processRowSegment(polygon))
})
}
}
}
// Move one row lower
this.currentPoint[1] -= this.geohashHeight
// Reset rowProgress
this.rowProgress = -Infinity
return geohashes
}
// Returns all the geohashes that are within the current row
processRowSegment(coordinates) {
// Convert coordinates into polygon object
const segmentPolygon = turfPolygon(coordinates)
const envelopeBbox = turfBbox(turfEnvelope(segmentPolygon))
// Most left geohash in box OR the next geohash after current rowProgress
const startingGeohash = ngeohash.encode(
envelopeBbox[3],
Math.max(this.rowProgress, envelopeBbox[0] + 0.00001), // Add some small long value to avoid edge cases
this.options.precision
)
const geohashList = []
// Checking every geohash in the row from left to right
let currentGeohash = startingGeohash
while (true) {
const geohashPolygon = turfBboxPolygon(switchBbox(ngeohash.decode_bbox(currentGeohash)))
let addGeohash = false
if (this.originalShape.geometry.type === "LineString") {
// We add every geohash because all segments that come in are envelopes of the relevant line segments
addGeohash = true
} else {
// Its a polygon
switch (this.options.hashMode) {
case "intersect":
// Only add geohash if they intersect/overlap with the original polygon
addGeohash = turfBooleanOverlap(segmentPolygon, geohashPolygon)
if (addGeohash && this.minIntersectArea > 0) {
const intersect = turfIntersect(this.originalShape, geohashPolygon)
addGeohash = turfArea(intersect) >= this.minIntersectArea
}
break
case "envelope":
addGeohash = true // add every geohash
break
case "insideOnly":
// Only add geohash if it is completely within the original polygon
addGeohash =
turfBooleanWithin(geohashPolygon, this.originalShape) &&
allRectangleEdgesWithin(geohashPolygon, this.originalShape)
// Extra check to avoid turf.js bug
// REMOVE allRectangleEdgesWithin CHECK IF POSSIBLE -> NEGATIVE PERFORMANCE IMPACT
break
case "border":
// Only add geohash if they overlap
addGeohash =
turfBooleanOverlap(segmentPolygon, geohashPolygon) &&
!turfBooleanWithin(geohashPolygon, this.originalShape)
break
}
}
// Check if geohash polygon overlaps/intersects with original polygon
// I need to check both because of some weird bug with turf
// If yes -> add it to the list of geohashes
if (addGeohash) {
geohashList.push(currentGeohash)
}
// Save rowProgress
// maxX plus some small amount to avoid overlapping edges due to lat/long inaccuracies
this.rowProgress = turfBbox(geohashPolygon)[2] + 0.00001
const maxX = geohashPolygon.bbox[2]
if (maxX >= envelopeBbox[2]) {
// If right edge of current geohash is out of bounds we are done
currentGeohash = null
break
}
// Get eastern neighbor and set him as next geohash to be checked
currentGeohash = ngeohash.neighbor(currentGeohash, [0, 1])
}
return geohashList
}
}
module.exports = shape2geohash