From 1a9a08b61fe4776522f709d8c0823d0370ffe719 Mon Sep 17 00:00:00 2001 From: Harel M Date: Sun, 5 May 2024 00:40:03 +0300 Subject: [PATCH] Add `distance` expression support (#642) * Initial commit to distance calculations * Add changelog item * Added polygon tests * Fix lint * Fix algorithm, imporve tests readablility * Add classify rings tests from the maplibre repo * Add wrap type * Make the threshold symetrical. * Allow using classify rings with a mapbox point type, remove circular dependencies. --- .gitignore | 3 +- CHANGELOG.md | 2 + build/generate-style-spec.ts | 1 + package-lock.json | 14 +- package.json | 4 +- src/expression/compound_expression.ts | 7 +- src/expression/definitions/distance.ts | 582 ++++++++++++++++++ src/expression/definitions/index.ts | 4 +- src/expression/definitions/within.ts | 164 +---- src/expression/expression.test.ts | 304 ++++++++- src/feature_filter/feature_filter.test.ts | 36 +- src/feature_filter/index.ts | 5 +- src/index.ts | 2 + src/reference/v8.json | 4 +- src/util/cheap_ruler.ts | 105 ++++ src/util/classify_rings.test.ts | 108 ++++ src/util/classify_rings.ts | 71 +++ src/util/geometry_util.ts | 153 +++++ src/validate/validate_filter.ts | 8 - src/validate/validate_sky.ts | 3 +- .../expression/tests/distance/basic/test.json | 33 + test/lib/geometry.ts | 70 ++- test/lib/util.ts | 29 - 23 files changed, 1463 insertions(+), 249 deletions(-) create mode 100644 src/expression/definitions/distance.ts create mode 100644 src/util/cheap_ruler.ts create mode 100644 src/util/classify_rings.test.ts create mode 100644 src/util/classify_rings.ts create mode 100644 src/util/geometry_util.ts create mode 100644 test/integration/expression/tests/distance/basic/test.json diff --git a/.gitignore b/.gitignore index 1b9f1f6f..1cf408f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,9 @@ dist .DS_Store */**/*.g.ts .cache -docs/* +coverage/ site/ +docs/* !docs/assets/extra.css !docs/assets/logo.svg !docs/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d76ef99..d863cff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## main ### ✨ Features and improvements + +- Support `distance` expression in web style spec - [#642](https://github.com/maplibre/maplibre-style-spec/pull/642) - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/build/generate-style-spec.ts b/build/generate-style-spec.ts index 1e8ed3dc..b9a5258a 100644 --- a/build/generate-style-spec.ts +++ b/build/generate-style-spec.ts @@ -199,6 +199,7 @@ export type ExpressionSpecification = ...(ExpressionInputType | ExpressionInputType[] | ExpressionSpecification)[], // repeated as above ExpressionInputType | ExpressionSpecification] | ['within', unknown | ExpressionSpecification] + | ['distance', unknown | ExpressionSpecification] // Ramps, scales, curves | ['interpolate', InterpolationSpecification, number | ExpressionSpecification, ...(number | number[] | ColorSpecification | ExpressionSpecification)[]] // alternating number and number | number[] | ColorSpecification diff --git a/package-lock.json b/package-lock.json index d73fa785..23600f22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,10 @@ "@mapbox/unitbezier": "^0.0.1", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", + "quickselect": "^2.0.0", "rw": "^1.3.3", - "sort-object": "^3.0.3" + "sort-object": "^3.0.3", + "tinyqueue": "^2.0.3" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", @@ -7502,6 +7504,11 @@ } ] }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8393,6 +8400,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index e9a0ef2d..ad7cc709 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,10 @@ "@mapbox/unitbezier": "^0.0.1", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", + "quickselect": "^2.0.0", "rw": "^1.3.3", - "sort-object": "^3.0.3" + "sort-object": "^3.0.3", + "tinyqueue": "^2.0.3" }, "sideEffects": false, "devDependencies": { diff --git a/src/expression/compound_expression.ts b/src/expression/compound_expression.ts index d9cfb076..67cd4468 100644 --- a/src/expression/compound_expression.ts +++ b/src/expression/compound_expression.ts @@ -21,10 +21,10 @@ import Literal from './definitions/literal'; import Assertion from './definitions/assertion'; import Coercion from './definitions/coercion'; import Var from './definitions/var'; +import Distance from './definitions/distance'; import type {Expression, ExpressionRegistry} from './expression'; import type {Value} from './values'; - import type {Type} from './types'; import {typeOf, Color, validateRGBA, toString as valueToString} from './values'; @@ -664,6 +664,8 @@ function isExpressionConstant(expression: Expression) { return false; } else if (expression instanceof Within) { return false; + } else if (expression instanceof Distance) { + return false; } const isTypeAnnotation = expression instanceof Coercion || @@ -715,6 +717,9 @@ function isFeatureConstant(e: Expression) { if (e instanceof Within) { return false; } + if (e instanceof Distance) { + return false; + } let result = true; e.eachChild(arg => { diff --git a/src/expression/definitions/distance.ts b/src/expression/definitions/distance.ts new file mode 100644 index 00000000..b16d2b5b --- /dev/null +++ b/src/expression/definitions/distance.ts @@ -0,0 +1,582 @@ +import TinyQueue from 'tinyqueue'; +import {Expression} from '../expression'; +import ParsingContext from '../parsing_context'; +import {NumberType, Type} from '../types'; +import {isValue} from '../values'; +import EvaluationContext from '../evaluation_context'; +import {BBox, boxWithinBox, getLngLatFromTileCoord, pointWithinPolygon, segmentIntersectSegment, updateBBox} from '../../util/geometry_util'; +import {classifyRings} from '../../util/classify_rings'; +import CheapRuler from '../../util/cheap_ruler'; + +type SimpleGeometry = GeoJSON.Polygon | GeoJSON.LineString | GeoJSON.Point; + +const MinPointsSize = 100; +const MinLinePointsSize = 50; + +type IndexRange = [number, number]; +type DistPair = [number, IndexRange, IndexRange]; + +function compareDistPair(a: DistPair, b: DistPair): number { + return b[0] - a[0]; +} + +function getRangeSize(range: IndexRange) { + return range[1] - range[0] + 1; +} + +function isRangeSafe(range: IndexRange, threshold: number): boolean { + return range[1] >= range[0] && range[1] < threshold; +} + +function splitRange(range: IndexRange, isLine: boolean): [IndexRange, IndexRange] { + if (range[0] > range[1]) { + return [null, null]; + } + const size = getRangeSize(range); + if (isLine) { + if (size === 2) { + return [range, null]; + } + const size1 = Math.floor(size / 2); + return [[range[0], range[0] + size1], + [range[0] + size1, range[1]]]; + } + if (size === 1) { + return [range, null]; + } + const size1 = Math.floor(size / 2) - 1; + return [[range[0], range[0] + size1], + [range[0] + size1 + 1, range[1]]]; +} + +function getBBox(coords: [number, number][], range: IndexRange): BBox { + if (!isRangeSafe(range, coords.length)) { + return [Infinity, Infinity, -Infinity, -Infinity]; + } + + const bbox: BBox = [Infinity, Infinity, -Infinity, -Infinity]; + for (let i = range[0]; i <= range[1]; ++i) { + updateBBox(bbox, coords[i]); + } + return bbox; +} + +function getPolygonBBox(polygon: [number, number][][]): BBox { + const bbox: BBox = [Infinity, Infinity, -Infinity, -Infinity]; + for (const ring of polygon) { + for (const coord of ring) { + updateBBox(bbox, coord); + } + } + return bbox; +} + +function isValidBBox(bbox: BBox): boolean { + return bbox[0] !== -Infinity && bbox[1] !== -Infinity && bbox[2] !== Infinity && bbox[3] !== Infinity; +} + +// Calculate the distance between two bounding boxes. +// Calculate the delta in x and y direction, and use two fake points {0.0, 0.0} +// and {dx, dy} to calculate the distance. Distance will be 0.0 if bounding box are overlapping. +function bboxToBBoxDistance(bbox1: BBox, bbox2: BBox, ruler: CheapRuler): number { + if (!isValidBBox(bbox1) || !isValidBBox(bbox2)) { + return NaN; + } + let dx = 0.0; + let dy = 0.0; + // bbox1 in left side + if (bbox1[2] < bbox2[0]) { + dx = bbox2[0] - bbox1[2]; + } + // bbox1 in right side + if (bbox1[0] > bbox2[2]) { + dx = bbox1[0] - bbox2[2]; + } + // bbox1 in above side + if (bbox1[1] > bbox2[3]) { + dy = bbox1[1] - bbox2[3]; + } + // bbox1 in down side + if (bbox1[3] < bbox2[1]) { + dy = bbox2[1] - bbox1[3]; + } + return ruler.distance([0.0, 0.0], [dx, dy]); +} + +function pointToLineDistance(point: [number, number], line: [number, number][], ruler: CheapRuler): number { + const nearestPoint = ruler.pointOnLine(line, point); + return ruler.distance(point, nearestPoint.point); +} + +function segmentToSegmentDistance(p1: [number, number], p2: [number, number], + q1: [number, number], q2: [number, number], ruler: CheapRuler): number { + const dist1 = Math.min(pointToLineDistance(p1, [q1, q2], ruler), pointToLineDistance(p2, [q1, q2], ruler)); + const dist2 = Math.min(pointToLineDistance(q1, [p1, p2], ruler), pointToLineDistance(q2, [p1, p2], ruler)); + return Math.min(dist1, dist2); +} + +function lineToLineDistance(line1: [number, number][], + range1: IndexRange, + line2: [number, number][], + range2: IndexRange, + ruler: CheapRuler): number { + const rangeSafe = isRangeSafe(range1, line1.length) && isRangeSafe(range2, line2.length); + if (!rangeSafe) { + return Infinity; + } + + let dist = Infinity; + for (let i = range1[0]; i < range1[1]; ++i) { + const p1 = line1[i]; + const p2 = line1[i + 1]; + for (let j = range2[0]; j < range2[1]; ++j) { + const q1 = line2[j]; + const q2 = line2[j + 1]; + if (segmentIntersectSegment(p1, p2, q1, q2)) { + return 0.0; + } + dist = Math.min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler)); + } + } + return dist; +} + +function pointsToPointsDistance(points1: [number, number][], + range1: IndexRange, + points2: [number, number][], + range2: IndexRange, + ruler: CheapRuler): number { + const rangeSafe = isRangeSafe(range1, points1.length) && isRangeSafe(range2, points2.length); + if (!rangeSafe) { + return NaN; + } + + let dist = Infinity; + for (let i = range1[0]; i <= range1[1]; ++i) { + for (let j = range2[0]; j <= range2[1]; ++j) { + dist = Math.min(dist, ruler.distance(points1[i], points2[j])); + if (dist === 0.0) { + return dist; + } + } + } + return dist; +} + +function pointToPolygonDistance(point: [number, number], + polygon: [number, number][][], + ruler: CheapRuler): number { + if (pointWithinPolygon(point, polygon, true)) { + return 0.0; + } + let dist = Infinity; + for (const ring of polygon) { + const front = ring[0]; + const back = ring[ring.length - 1]; + if (front !== back) { + dist = Math.min(dist, pointToLineDistance(point, [back, front], ruler)); + if (dist === 0.0) { + return dist; + } + } + const nearestPoint = ruler.pointOnLine(ring, point); + dist = Math.min(dist, ruler.distance(point, nearestPoint.point)); + if (dist === 0.0) { + return dist; + } + } + return dist; +} + +function lineToPolygonDistance(line: [number, number][], + range: IndexRange, + polygon: [number, number][][], + ruler: CheapRuler): number { + if (!isRangeSafe(range, line.length)) { + return NaN; + } + + for (let i = range[0]; i <= range[1]; ++i) { + if (pointWithinPolygon(line[i], polygon, true)) { + return 0.0; + } + } + + let dist = Infinity; + for (let i = range[0]; i < range[1]; ++i) { + const p1 = line[i]; + const p2 = line[i + 1]; + for (const ring of polygon) { + for (let j = 0, len = ring.length, k = len - 1; j < len; k = j++) { + const q1 = ring[k]; + const q2 = ring[j]; + if (segmentIntersectSegment(p1, p2, q1, q2)) { + return 0.0; + } + dist = Math.min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler)); + } + } + } + return dist; +} + +function polygonIntersect(poly1: [number, number][][], poly2: [number, number][][]): boolean { + for (const ring of poly1) { + for (const point of ring) { + if (pointWithinPolygon(point, poly2, true)) { + return true; + } + } + } + return false; +} + +function polygonToPolygonDistance(polygon1: [number, number][][], + polygon2: [number, number][][], + ruler, + currentMiniDist = Infinity): number { + const bbox1 = getPolygonBBox(polygon1); + const bbox2 = getPolygonBBox(polygon2); + if (currentMiniDist !== Infinity && bboxToBBoxDistance(bbox1, bbox2, ruler) >= currentMiniDist) { + return currentMiniDist; + } + + if (boxWithinBox(bbox1, bbox2)) { + if (polygonIntersect(polygon1, polygon2)) { + return 0.0; + } + } else if (polygonIntersect(polygon2, polygon1)) { + return 0.0; + } + + let dist = Infinity; + for (const ring1 of polygon1) { + for (let i = 0, len1 = ring1.length, l = len1 - 1; i < len1; l = i++) { + const p1 = ring1[l]; + const p2 = ring1[i]; + for (const ring2 of polygon2) { + for (let j = 0, len2 = ring2.length, k = len2 - 1; j < len2; k = j++) { + const q1 = ring2[k]; + const q2 = ring2[j]; + if (segmentIntersectSegment(p1, p2, q1, q2)) { + return 0.0; + } + dist = Math.min(dist, segmentToSegmentDistance(p1, p2, q1, q2, ruler)); + } + } + } + } + return dist; +} + +function updateQueue(distQueue: TinyQueue, miniDist: number, ruler: CheapRuler, points: [number, number][], polyBBox: BBox, rangeA?: IndexRange) { + if (!rangeA) { + return; + } + const tempDist = bboxToBBoxDistance(getBBox(points, rangeA), polyBBox, ruler); + // Insert new pair to the queue if the bbox distance is less than + // miniDist, The pair with biggest distance will be at the top + if (tempDist < miniDist) { + distQueue.push([tempDist, rangeA, [0, 0]]); + } +} + +function updateQueueTwoSets(distQueue: TinyQueue, miniDist: number, ruler: CheapRuler, + pointSet1: [number, number][], pointSet2: [number, number][], range1?: IndexRange, range2?: IndexRange) { + if (!range1 || !range2) { + return; + } + const tempDist = bboxToBBoxDistance( + getBBox(pointSet1, range1), getBBox(pointSet2, range2), ruler); + // Insert new pair to the queue if the bbox distance is less than + // miniDist, The pair with biggest distance will be at the top + if (tempDist < miniDist) { + distQueue.push([tempDist, range1, range2]); + } +} + +// Divide and conquer, the time complexity is O(n*lgn), faster than Brute force +// O(n*n) Most of the time, use index for in-place processing. +function pointsToPolygonDistance(points: [number, number][], + isLine: boolean, + polygon: [number, number][][], + ruler: CheapRuler, + currentMiniDist = Infinity) { + let miniDist = Math.min(ruler.distance(points[0], polygon[0][0]), currentMiniDist); + if (miniDist === 0.0) { + return miniDist; + } + + const distQueue = new TinyQueue([[0, [0, points.length - 1], [0, 0]]], compareDistPair); + + const polyBBox = getPolygonBBox(polygon); + while (distQueue.length > 0) { + const distPair = distQueue.pop(); + if (distPair[0] >= miniDist) { + continue; + } + + const range = distPair[1]; + + // In case the set size are relatively small, we could use brute-force directly + const threshold = isLine ? MinLinePointsSize : MinPointsSize; + if (getRangeSize(range) <= threshold) { + if (!isRangeSafe(range, points.length)) { + return NaN; + } + if (isLine) { + const tempDist = lineToPolygonDistance(points, range, polygon, ruler); + if (isNaN(tempDist) || tempDist === 0.0) { + return tempDist; + } + miniDist = Math.min(miniDist, tempDist); + } else { + for (let i = range[0]; i <= range[1]; ++i) { + const tempDist = pointToPolygonDistance(points[i], polygon, ruler); + miniDist = Math.min(miniDist, tempDist); + if (miniDist === 0.0) { + return 0.0; + } + } + } + } else { + const newRangesA = splitRange(range, isLine); + + updateQueue(distQueue, miniDist, ruler, points, polyBBox, newRangesA[0]); + updateQueue(distQueue, miniDist, ruler, points, polyBBox, newRangesA[1]); + } + } + return miniDist; +} + +function pointSetToPointSetDistance(pointSet1: [number, number][], + isLine1: boolean, + pointSet2: [number, number][], + isLine2: boolean, + ruler: CheapRuler, + currentMiniDist = Infinity): number { + let miniDist = Math.min(currentMiniDist, ruler.distance(pointSet1[0], pointSet2[0])); + if (miniDist === 0.0) { + return miniDist; + } + + const distQueue = new TinyQueue([[0, [0, pointSet1.length - 1], [0, pointSet2.length - 1]]], compareDistPair); + + while (distQueue.length > 0) { + const distPair = distQueue.pop(); + if (distPair[0] >= miniDist) { + continue; + } + + const rangeA = distPair[1]; + const rangeB = distPair[2]; + const threshold1 = isLine1 ? MinLinePointsSize : MinPointsSize; + const threshold2 = isLine2 ? MinLinePointsSize : MinPointsSize; + + // In case the set size are relatively small, we could use brute-force directly + if (getRangeSize(rangeA) <= threshold1 && getRangeSize(rangeB) <= threshold2) { + if (!isRangeSafe(rangeA, pointSet1.length) && isRangeSafe(rangeB, pointSet2.length)) { + return NaN; + } + let tempDist: number; + if (isLine1 && isLine2) { + tempDist = lineToLineDistance(pointSet1, rangeA, pointSet2, rangeB, ruler); + miniDist = Math.min(miniDist, tempDist); + } else if (isLine1 && !isLine2) { + const sublibe = pointSet1.slice(rangeA[0], rangeA[1] + 1); + for (let i = rangeB[0]; i <= rangeB[1]; ++i) { + tempDist = pointToLineDistance(pointSet2[i], sublibe, ruler); + miniDist = Math.min(miniDist, tempDist); + if (miniDist === 0.0) { + return miniDist; + } + } + } else if (!isLine1 && isLine2) { + const sublibe = pointSet2.slice(rangeB[0], rangeB[1] + 1); + for (let i = rangeA[0]; i <= rangeA[1]; ++i) { + tempDist = pointToLineDistance(pointSet1[i], sublibe, ruler); + miniDist = Math.min(miniDist, tempDist); + if (miniDist === 0.0) { + return miniDist; + } + } + } else { + tempDist = pointsToPointsDistance(pointSet1, rangeA, pointSet2, rangeB, ruler); + miniDist = Math.min(miniDist, tempDist); + } + } else { + const newRangesA = splitRange(rangeA, isLine1); + const newRangesB = splitRange(rangeB, isLine2); + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, newRangesA[0], newRangesB[0]); + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, newRangesA[0], newRangesB[1]); + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, newRangesA[1], newRangesB[0]); + updateQueueTwoSets(distQueue, miniDist, ruler, pointSet1, pointSet2, newRangesA[1], newRangesB[1]); + } + } + return miniDist; +} + +function pointToGeometryDistance(ctx: EvaluationContext, geometries: SimpleGeometry[]) { + const tilePoints = ctx.geometry(); + const pointPosition = tilePoints.flat().map(p => getLngLatFromTileCoord([p.x, p.y], ctx.canonical) as [number, number]); + if (tilePoints.length === 0) { + return NaN; + } + const ruler = new CheapRuler(pointPosition[0][1]); + let dist = Infinity; + for (const geometry of geometries) { + switch (geometry.type) { + case 'Point': + dist = Math.min(dist, pointSetToPointSetDistance(pointPosition, false, [geometry.coordinates as [number, number]], false, ruler, dist)); + break; + case 'LineString': + dist = Math.min(dist, pointSetToPointSetDistance(pointPosition, false, geometry.coordinates as [number, number][], true, ruler, dist)); + break; + case 'Polygon': + dist = Math.min(dist, pointsToPolygonDistance(pointPosition, false, geometry.coordinates as [number, number][][], ruler, dist)); + break; + } + if (dist === 0.0) { + return dist; + } + } + return dist; +} + +function lineStringToGeometryDistance(ctx: EvaluationContext, geometries: SimpleGeometry[]) { + const tileLine = ctx.geometry(); + const linePositions = tileLine.flat().map(p => getLngLatFromTileCoord([p.x, p.y], ctx.canonical) as [number, number]); + if (tileLine.length === 0) { + return NaN; + } + const ruler = new CheapRuler(linePositions[0][1]); + let dist = Infinity; + for (const geometry of geometries) { + switch (geometry.type) { + case 'Point': + dist = Math.min(dist, pointSetToPointSetDistance(linePositions, true, [geometry.coordinates as [number, number]], false, ruler, dist)); + break; + case 'LineString': + dist = Math.min(dist, pointSetToPointSetDistance(linePositions, true, geometry.coordinates as [number, number][], true, ruler, dist)); + break; + case 'Polygon': + dist = Math.min(dist, pointsToPolygonDistance(linePositions, true, geometry.coordinates as [number, number][][], ruler, dist)); + break; + } + if (dist === 0.0) { + return dist; + } + } + return dist; +} + +function polygonToGeometryDistance(ctx: EvaluationContext, geometries: SimpleGeometry[]) { + const tilePolygon = ctx.geometry(); + if (tilePolygon.length === 0 || tilePolygon[0].length === 0) { + return NaN; + } + const polygons = classifyRings(tilePolygon, 0).map(polygon => { + return polygon.map(ring => { + return ring.map(p => getLngLatFromTileCoord([p.x, p.y], ctx.canonical) as [number, number]); + }); + }); + const ruler = new CheapRuler(polygons[0][0][0][1]); + let dist = Infinity; + for (const geometry of geometries) { + for (const polygon of polygons) { + switch (geometry.type) { + case 'Point': + dist = Math.min(dist, pointsToPolygonDistance([geometry.coordinates as [number, number]], false, polygon, ruler, dist)); + break; + case 'LineString': + dist = Math.min(dist, pointsToPolygonDistance(geometry.coordinates as [number, number][], true, polygon, ruler, dist)); + break; + case 'Polygon': + dist = Math.min(dist, polygonToPolygonDistance(polygon, geometry.coordinates as [number, number][][], ruler, dist)); + break; + } + if (dist === 0.0) { + return dist; + } + } + } + return dist; + +} + +function toSimpleGeometry(geometry: Exclude): SimpleGeometry[] { + if (geometry.type === 'MultiPolygon') { + return geometry.coordinates.map(polygon => { + return { + type: 'Polygon', + coordinates: polygon + }; + }); + } + if (geometry.type === 'MultiLineString') { + return geometry.coordinates.map(lineString => { + return { + type: 'LineString', + coordinates: lineString + }; + }); + } + if (geometry.type === 'MultiPoint') { + return geometry.coordinates.map(point => { + return { + type: 'Point', + coordinates: point + }; + }); + } + return [geometry]; +} + +class Distance implements Expression { + type: Type; + geojson: GeoJSON.GeoJSON; + geometries: SimpleGeometry[]; + + constructor(geojson: GeoJSON.GeoJSON, geometries: SimpleGeometry[]) { + this.type = NumberType; + this.geojson = geojson; + this.geometries = geometries; + } + + static parse(args: ReadonlyArray, context: ParsingContext): Expression { + if (args.length !== 2) + return context.error(`'distance' expression requires exactly one argument, but found ${args.length - 1} instead.`) as null; + if (isValue(args[1])) { + const geojson = (args[1] as any); + if (geojson.type === 'FeatureCollection') { + return new Distance(geojson, geojson.features.map(feature => toSimpleGeometry(feature.geometry)).flat()); + } else if (geojson.type === 'Feature') { + return new Distance(geojson, toSimpleGeometry(geojson.geometry)); + } else if ('type' in geojson && 'coordinates' in geojson) { + return new Distance(geojson, toSimpleGeometry(geojson)); + } + } + return context.error('\'distance\' expression requires valid geojson object that contains polygon geometry type.') as null; + } + + evaluate(ctx: EvaluationContext) { + if (ctx.geometry() != null && ctx.canonicalID() != null) { + if (ctx.geometryType() === 'Point') { + return pointToGeometryDistance(ctx, this.geometries); + } else if (ctx.geometryType() === 'LineString') { + return lineStringToGeometryDistance(ctx, this.geometries); + } else if (ctx.geometryType() === 'Polygon') { + return polygonToGeometryDistance(ctx, this.geometries); + } + } + return NaN; + } + + eachChild() {} + + outputDefined(): boolean { + return true; + } +} + +export default Distance; diff --git a/src/expression/definitions/index.ts b/src/expression/definitions/index.ts index 80eba4be..8832a0a9 100644 --- a/src/expression/definitions/index.ts +++ b/src/expression/definitions/index.ts @@ -27,6 +27,7 @@ import FormatExpression from './format'; import ImageExpression from './image'; import Length from './length'; import Within from './within'; +import Distance from './distance'; import type {ExpressionRegistry} from '../expression'; @@ -66,7 +67,8 @@ export const expressions: ExpressionRegistry = { 'to-number': Coercion, 'to-string': Coercion, 'var': Var, - 'within': Within + 'within': Within, + 'distance': Distance }; export default expressions; diff --git a/src/expression/definitions/within.ts b/src/expression/definitions/within.ts index c917b501..5beb640b 100644 --- a/src/expression/definitions/within.ts +++ b/src/expression/definitions/within.ts @@ -5,148 +5,12 @@ import type {Expression} from '../expression'; import type ParsingContext from '../parsing_context'; import type EvaluationContext from '../evaluation_context'; import {ICanonicalTileID} from '../../tiles_and_coordinates'; -import {Position} from 'geojson'; +import {BBox, EXTENT, boxWithinBox, getTileCoordinates, lineStringWithinPolygon, lineStringWithinPolygons, pointWithinPolygon, pointWithinPolygons, updateBBox} from '../../util/geometry_util'; +import {Point2D} from '../../point2d'; type GeoJSONPolygons = GeoJSON.Polygon | GeoJSON.MultiPolygon; -// minX, minY, maxX, maxY -type BBox = [number, number, number, number]; - -const EXTENT = 8192; - -function updateBBox(bbox: BBox, coord: [number, number]) { - bbox[0] = Math.min(bbox[0], coord[0]); - bbox[1] = Math.min(bbox[1], coord[1]); - bbox[2] = Math.max(bbox[2], coord[0]); - bbox[3] = Math.max(bbox[3], coord[1]); -} - -function mercatorXfromLng(lng: number) { - return (180 + lng) / 360; -} - -function mercatorYfromLat(lat: number) { - return (180 - (180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)))) / 360; -} - -function boxWithinBox(bbox1: BBox, bbox2: BBox) { - if (bbox1[0] <= bbox2[0]) return false; - if (bbox1[2] >= bbox2[2]) return false; - if (bbox1[1] <= bbox2[1]) return false; - if (bbox1[3] >= bbox2[3]) return false; - return true; -} - -function getTileCoordinates(p, canonical: ICanonicalTileID): [number, number] { - const x = mercatorXfromLng(p[0]); - const y = mercatorYfromLat(p[1]); - const tilesAtZoom = Math.pow(2, canonical.z); - return [Math.round(x * tilesAtZoom * EXTENT), Math.round(y * tilesAtZoom * EXTENT)]; -} - -function onBoundary(p, p1, p2) { - const x1 = p[0] - p1[0]; - const y1 = p[1] - p1[1]; - const x2 = p[0] - p2[0]; - const y2 = p[1] - p2[1]; - return (x1 * y2 - x2 * y1 === 0) && (x1 * x2 <= 0) && (y1 * y2 <= 0); -} - -function rayIntersect(p, p1, p2) { - return ((p1[1] > p[1]) !== (p2[1] > p[1])) && (p[0] < (p2[0] - p1[0]) * (p[1] - p1[1]) / (p2[1] - p1[1]) + p1[0]); -} - -// ray casting algorithm for detecting if point is in polygon -function pointWithinPolygon(point, rings) { - let inside = false; - for (let i = 0, len = rings.length; i < len; i++) { - const ring = rings[i]; - for (let j = 0, len2 = ring.length; j < len2 - 1; j++) { - if (onBoundary(point, ring[j], ring[j + 1])) return false; - if (rayIntersect(point, ring[j], ring[j + 1])) inside = !inside; - } - } - return inside; -} - -function pointWithinPolygons(point, polygons) { - for (let i = 0; i < polygons.length; i++) { - if (pointWithinPolygon(point, polygons[i])) return true; - } - return false; -} - -function perp(v1, v2) { - return (v1[0] * v2[1] - v1[1] * v2[0]); -} - -// check if p1 and p2 are in different sides of line segment q1->q2 -function twoSided(p1, p2, q1, q2) { - // q1->p1 (x1, y1), q1->p2 (x2, y2), q1->q2 (x3, y3) - const x1 = p1[0] - q1[0]; - const y1 = p1[1] - q1[1]; - const x2 = p2[0] - q1[0]; - const y2 = p2[1] - q1[1]; - const x3 = q2[0] - q1[0]; - const y3 = q2[1] - q1[1]; - const det1 = (x1 * y3 - x3 * y1); - const det2 = (x2 * y3 - x3 * y2); - if ((det1 > 0 && det2 < 0) || (det1 < 0 && det2 > 0)) return true; - return false; -} -// a, b are end points for line segment1, c and d are end points for line segment2 -function lineIntersectLine(a, b, c, d) { - // check if two segments are parallel or not - // precondition is end point a, b is inside polygon, if line a->b is - // parallel to polygon edge c->d, then a->b won't intersect with c->d - const vectorP = [b[0] - a[0], b[1] - a[1]]; - const vectorQ = [d[0] - c[0], d[1] - c[1]]; - if (perp(vectorQ, vectorP) === 0) return false; - - // If lines are intersecting with each other, the relative location should be: - // a and b lie in different sides of segment c->d - // c and d lie in different sides of segment a->b - if (twoSided(a, b, c, d) && twoSided(c, d, a, b)) return true; - return false; -} - -function lineIntersectPolygon(p1, p2, polygon) { - for (const ring of polygon) { - // loop through every edge of the ring - for (let j = 0; j < ring.length - 1; ++j) { - if (lineIntersectLine(p1, p2, ring[j], ring[j + 1])) { - return true; - } - } - } - return false; -} - -function lineStringWithinPolygon(line, polygon) { - // First, check if geometry points of line segments are all inside polygon - for (let i = 0; i < line.length; ++i) { - if (!pointWithinPolygon(line[i], polygon)) { - return false; - } - } - - // Second, check if there is line segment intersecting polygon edge - for (let i = 0; i < line.length - 1; ++i) { - if (lineIntersectPolygon(line[i], line[i + 1], polygon)) { - return false; - } - } - return true; -} - -function lineStringWithinPolygons(line, polygons) { - for (let i = 0; i < polygons.length; i++) { - if (lineStringWithinPolygon(line, polygons[i])) return true; - } - return false; -} - -function getTilePolygon(coordinates, bbox, canonical) { +function getTilePolygon(coordinates: GeoJSON.Position[][], bbox: BBox, canonical: ICanonicalTileID) { const polygon = []; for (let i = 0; i < coordinates.length; i++) { const ring = []; @@ -160,7 +24,7 @@ function getTilePolygon(coordinates, bbox, canonical) { return polygon; } -function getTilePolygons(coordinates, bbox, canonical) { +function getTilePolygons(coordinates: GeoJSON.Position[][][], bbox: BBox, canonical: ICanonicalTileID) { const polygons = []; for (let i = 0; i < coordinates.length; i++) { const polygon = getTilePolygon(coordinates[i], bbox, canonical); @@ -169,7 +33,7 @@ function getTilePolygons(coordinates, bbox, canonical) { return polygons; } -function updatePoint(p, bbox, polyBBox, worldSize) { +function updatePoint(p: GeoJSON.Position, bbox: BBox, polyBBox: BBox, worldSize: number) { if (p[0] < polyBBox[0] || p[0] > polyBBox[2]) { const halfWorldSize = worldSize * 0.5; let shift = (p[0] - polyBBox[0] > halfWorldSize) ? -worldSize : (polyBBox[0] - p[0] > halfWorldSize) ? worldSize : 0; @@ -181,18 +45,18 @@ function updatePoint(p, bbox, polyBBox, worldSize) { updateBBox(bbox, p); } -function resetBBox(bbox) { +function resetBBox(bbox: BBox) { bbox[0] = bbox[1] = Infinity; bbox[2] = bbox[3] = -Infinity; } -function getTilePoints(geometry, pointBBox, polyBBox, canonical) { +function getTilePoints(geometry: Point2D[][], pointBBox: BBox, polyBBox: BBox, canonical: ICanonicalTileID): [number, number][] { const worldSize = Math.pow(2, canonical.z) * EXTENT; const shifts = [canonical.x * EXTENT, canonical.y * EXTENT]; - const tilePoints = []; + const tilePoints: [number, number][] = []; for (const points of geometry) { for (const point of points) { - const p = [point.x + shifts[0], point.y + shifts[1]]; + const p: [number, number] = [point.x + shifts[0], point.y + shifts[1]]; updatePoint(p, pointBBox, polyBBox, worldSize); tilePoints.push(p); } @@ -200,14 +64,14 @@ function getTilePoints(geometry, pointBBox, polyBBox, canonical) { return tilePoints; } -function getTileLines(geometry, lineBBox, polyBBox, canonical) { +function getTileLines(geometry: Point2D[][], lineBBox: BBox, polyBBox: BBox, canonical: ICanonicalTileID): [number, number][][] { const worldSize = Math.pow(2, canonical.z) * EXTENT; const shifts = [canonical.x * EXTENT, canonical.y * EXTENT]; - const tileLines = []; + const tileLines: [number, number][][] = []; for (const line of geometry) { - const tileLine = []; + const tileLine:[number, number][] = []; for (const point of line) { - const p = [point.x + shifts[0], point.y + shifts[1]] as [number, number]; + const p: [number, number] = [point.x + shifts[0], point.y + shifts[1]]; updateBBox(lineBBox, p); tileLine.push(p); } @@ -296,7 +160,7 @@ class Within implements Expression { if (isValue(args[1])) { const geojson = (args[1] as any); if (geojson.type === 'FeatureCollection') { - const polygonsCoords: Position[][][] = []; + const polygonsCoords: GeoJSON.Position[][][] = []; for (const polygon of geojson.features) { const {type, coordinates} = polygon.geometry; if (type === 'Polygon') { diff --git a/src/expression/expression.test.ts b/src/expression/expression.test.ts index ef56d24c..b8b9f77b 100644 --- a/src/expression/expression.test.ts +++ b/src/expression/expression.test.ts @@ -1,9 +1,10 @@ import {createPropertyExpression, Feature, GlobalProperties, StylePropertyExpression} from '../expression'; import definitions from './definitions'; import v8 from '../reference/v8.json' assert {type: 'json'}; -import {StylePropertySpecification} from '..'; +import {createExpression, ICanonicalTileID, StyleExpression, StylePropertySpecification} from '..'; import ParsingError from './parsing_error'; import {VariableAnchorOffsetCollection} from './values'; +import {getGeometry} from '../../test/lib/geometry'; // filter out interal "error" and "filter-*" expressions from definition list const filterExpressionRegex = /filter-/; @@ -84,5 +85,306 @@ describe('evaluate expression', () => { expect(value.evaluate({} as GlobalProperties, {properties: {x: ['top', [2, 2]]}} as any as Feature)).toEqual(new VariableAnchorOffsetCollection(['top', [2, 2]])); expect(console.warn).not.toHaveBeenCalled(); }); +}); + +describe('Distance expression', () => { + describe('Invalid expression', () => { + test('missing geometry', () => { + const response = createExpression(['distance']); + expect(response.result).toBe('error'); + }); + test('invalid geometry', () => { + const response = createExpression(['distance', {type: 'Nope!'}]); + expect(response.result).toBe('error'); + }); + }); + + describe('valid expression', () => { + test('multi point geometry', () => { + const response = createExpression(['distance', {type: 'MultiPoint', coordinates: [[3, 3], [3, 4]]}]); + expect(response.result).toBe('success'); + }); + test('multi line geometry', () => { + const response = createExpression(['distance', {type: 'MultiLineString', coordinates: [[[3, 3], [3, 4]]]}]); + expect(response.result).toBe('success'); + }); + test('multi polygon geometry', () => { + const response = createExpression(['distance', {type: 'MultiPolygon', coordinates: [[[[3, 3], [3, 4], [4, 4], [4, 3], [3, 3]]]]}]); + expect(response.result).toBe('success'); + }); + }); + + describe('Distance from point', () => { + const featureInTile = {} as Feature; + const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID; + let value: StyleExpression; + + beforeEach(() => { + const response = createExpression( + ['distance', {type: 'Point', coordinates: [3, 3]}], + { + type: 'number', + 'property-type': 'data-constant', + expression: { + interpolated: false, + parameters: ['zoom'] + } + } as StylePropertySpecification); + value = response.value as StyleExpression; + }); + test('point to point in the same location', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2); + }); + test('point to point in different location', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3.001]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + test('point to line', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3.001, 3]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0); + }); + test('point to line that passes through the point', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[2, 3], [4, 3]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2); + }); + test('point to line that pass through the point vertically', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[3, 2], [3, 4]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2); + }); + test('point to containing polygon', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('point to near by polygon', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 2.999], [3, 2.999], [3, 0], [0, 0]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + test('point to polygon with hole around the point', () => { + // polygon inner ring direction is important! + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [3.001, 2.999], [3.001, 3.001], [2.999, 3.001], [2.999, 2.999]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + }); + describe('Distance from line', () => { + const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID; + const featureInTile = {} as Feature; + let value: StyleExpression; + + beforeEach(() => { + const response = createExpression( + ['distance', {type: 'LineString', coordinates: [[3, 3], [3, 4]]}], + { + type: 'number', + 'property-type': 'data-constant', + expression: { + interpolated: false, + parameters: ['zoom'] + } + } as StylePropertySpecification); + + value = response.value as StyleExpression; + }); + test('line to point that is on the line', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2); + }); + test('line to point that is on the middle of the line', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3.5]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2); + }); + test('line to point that is close by horizontally', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3, 2.999]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + test('line to point that is close by vertically', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3.001, 3]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0); + }); + test('line to line that are parallel', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[3.001, 3], [3.001, 4]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0); + }); + test('line to line that are perpendicular', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.5, 3.5], [3.5, 3.5]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('line to polygon that contains the line', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('line to polygon that is close by', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 2.999], [3, 2.999], [3, 0], [0, 0]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + test('line to polygon with hole around the line', () => { + // polygon inner ring direction is important! + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [3.001, 2.999], [3.001, 4.001], [2.999, 4.001], [2.999, 2.999]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + }); + describe('Distance from long line', () => { + const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID; + const featureInTile = {} as Feature; + let value: StyleExpression; + + beforeEach(() => { + const coordinates = []; + for (let i = 0; i < 200; i++) { + coordinates.push([3, 3 + i / 200.0]); + } + const response = createExpression( + ['distance', { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'LineString', coordinates + } + }] + }], + { + type: 'number', + 'property-type': 'data-constant', + expression: { + interpolated: false, + parameters: ['zoom'] + } + } as StylePropertySpecification); + value = response.value as StyleExpression; + }); + test('long line to multiple points close by', () => { + const coordinates = []; + for (let i = 0; i < 200; i++) { + coordinates.push([2.999, 3 + i / 200.0]); + } + getGeometry(featureInTile, {type: 'MultiPoint', coordinates}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0); + }); + }); + + describe('Distance from simple polygon', () => { + const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID; + const featureInTile = {} as Feature; + let value: StyleExpression; + let response: any; + + beforeEach(() => { + response = createExpression( + ['distance', { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[[3, 3], [3, 4], [4, 4], [4, 3], [3, 3]]] + }, + }], + { + type: 'number', + 'property-type': 'data-constant', + expression: { + interpolated: false, + parameters: ['zoom'] + } + } as StylePropertySpecification); + value = response.value as StyleExpression; + }); + + test('polygon to point that is on the edge of the the polygon', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0, 2); + }); + test('polygon to point that is inside the polygon', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3.001, 3.001]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('polygon to point that is outside the polygon', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [2.999, 3]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0); + }); + test('polygon to multiple points outside the polygon that require a better algorithm', () => { + const coordinates = []; + for (let i = 0; i < 200; i++) { + coordinates.push([2.999, 3 - i / 1000.0]); + } + getGeometry(featureInTile, {type: 'MultiPoint', coordinates}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0); + }); + + test('polygon to multiple points outside and inside the polygon that require a better algorithm', () => { + const coordinates = []; + for (let i = 0; i < 200; i++) { + coordinates.push([2.999, 3 - i / 1000.0]); + } + coordinates.push([3.001, 3.001]); + getGeometry(featureInTile, {type: 'MultiPoint', coordinates}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + + test('polygon to line that is outside the polygon', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.999, 3], [2.999, 4]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(111.1, 0); + }); + test('polygon to line that is passes through the polygon', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.5, 3.5], [3.5, 3.5]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('polygon to polygon that is inside the polygon', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('polygon to polygon that is outside the polygon', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 2.999], [3, 2.999], [3, 0], [0, 0]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + test('polygon to polygon that contains the polygon', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[3.5, 3.5], [3.5, 3.6], [3.6, 3.6], [3.6, 3.5], [3.5, 3.5]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('polygon to polygon with hole around the polygon', () => { + // polygon with hole - direction is important! + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [4.001, 2.999,], [4.001, 4.001], [2.999, 4.001], [2.999, 2.999]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + }); + describe('Distance from polygon with hole', () => { + const canonical = {z: 20, x: 3, y: 3} as ICanonicalTileID; + const featureInTile = {} as Feature; + let value: StyleExpression; + + beforeEach(() => { + const response = createExpression( + ['distance', {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]], [[2.999, 2.999], [2.999, 4.001], [4.001, 4.001], [4.001, 2.999], [2.999, 2.999]]]}], + { + type: 'number', + 'property-type': 'data-constant', + expression: { + interpolated: false, + parameters: ['zoom'] + } + } as StylePropertySpecification); + value = response.value as StyleExpression; + }); + + test('polygon to point inside the hole', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [3, 3]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + test('polygon to point inside the polygon', () => { + getGeometry(featureInTile, {type: 'Point', coordinates: [2.999, 2.999]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(0); + }); + test('polygon to line inside the hole', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[3, 3], [3, 4]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBeCloseTo(110.5, 0); + }); + test('polygon to line inside the polygon', () => { + getGeometry(featureInTile, {type: 'LineString', coordinates: [[2.5, 3.5], [3.5, 3.5]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + test('polygon to polygon containing the polygon with the hole', () => { + getGeometry(featureInTile, {type: 'Polygon', coordinates: [[[0, 0], [0, 5], [5, 5], [5, 0], [0, 0]]]}, canonical); + expect(value.evaluate({zoom: 20}, featureInTile, {}, canonical)).toBe(0); + }); + }); }); diff --git a/src/feature_filter/feature_filter.test.ts b/src/feature_filter/feature_filter.test.ts index 76d2608e..959a7b5e 100644 --- a/src/feature_filter/feature_filter.test.ts +++ b/src/feature_filter/feature_filter.test.ts @@ -4,7 +4,7 @@ import convertFilter from './convert'; import {ICanonicalTileID} from '../tiles_and_coordinates'; import {ExpressionFilterSpecification, ExpressionInputType, ExpressionSpecification, FilterSpecification} from '../types.g'; import {Feature} from '../expression'; -import {getPointFromLngLat} from '../../test/lib/util'; +import {getGeometry} from '../../test/lib/geometry'; describe('filter', () => { test('expressions transpilation test', () => { @@ -153,28 +153,22 @@ describe('filter', () => { }); test('expression, within', () => { - - const withinFilter = createFilter(['within', {'type': 'Polygon', 'coordinates': [[[0, 0], [5, 0], [5, 5], [0, 5], [0, 0]]]}]); + const withinFilter = createFilter(['within', {'type': 'Polygon', 'coordinates': [[[0, 0], [5, 0], [5, 5], [0, 5], [0, 0]]]}]); expect(withinFilter.needGeometry).toBe(true); const canonical = {z: 3, x: 3, y: 3} as ICanonicalTileID; - expect( - withinFilter.filter({zoom: 3}, {type: 1, geometry: [[getPointFromLngLat(2, 2, canonical)]]} as Feature, canonical) - ).toBe(true); - expect( - withinFilter.filter({zoom: 3}, {type: 1, geometry: [[getPointFromLngLat(6, 6, canonical)]]} as Feature, canonical) - ).toBe(false); - expect( - withinFilter.filter({zoom: 3}, {type: 1, geometry: [[getPointFromLngLat(5, 5, canonical)]]} as Feature, canonical) - ).toBe(false); - expect( - withinFilter.filter({zoom: 3}, {type: 2, geometry: [[getPointFromLngLat(2, 2, canonical), getPointFromLngLat(3, 3, canonical)]]} as Feature, canonical) - ).toBe(true); - expect( - withinFilter.filter({zoom: 3}, {type: 2, geometry: [[getPointFromLngLat(6, 6, canonical), getPointFromLngLat(2, 2, canonical)]]} as Feature, canonical) - ).toBe(false); - expect( - withinFilter.filter({zoom: 3}, {type: 2, geometry: [[getPointFromLngLat(5, 5, canonical), getPointFromLngLat(2, 2, canonical)]]} as Feature, canonical) - ).toBe(false); + const featureInTile = {} as Feature; + getGeometry(featureInTile, {type: 'Point', coordinates: [2, 2]}, canonical); + expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(true); + getGeometry(featureInTile, {type: 'Point', coordinates: [6, 6]}, canonical); + expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false); + getGeometry(featureInTile, {type: 'Point', coordinates: [5, 5]}, canonical); + expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false); + getGeometry(featureInTile, {type: 'LineString', coordinates: [[2, 2], [3, 3]]}, canonical); + expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(true); + getGeometry(featureInTile, {type: 'LineString', coordinates: [[6, 6], [2, 2]]}, canonical); + expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false); + getGeometry(featureInTile, {type: 'LineString', coordinates: [[5, 5], [2, 2]]}, canonical); + expect(withinFilter.filter({zoom: 3}, featureInTile, canonical)).toBe(false); }); legacyFilterTests(createFilter); diff --git a/src/feature_filter/index.ts b/src/feature_filter/index.ts index 39848f45..88cbe214 100644 --- a/src/feature_filter/index.ts +++ b/src/feature_filter/index.ts @@ -106,7 +106,7 @@ function compare(a, b) { function geometryNeeded(filter) { if (!Array.isArray(filter)) return false; - if (filter[0] === 'within') return true; + if (filter[0] === 'within' || filter[0] === 'distance') return true; for (let index = 1; index < filter.length; index++) { if (geometryNeeded(filter[index])) return true; } @@ -131,8 +131,7 @@ function convertFilter(filter?: Array | null): unknown { op === '!in' ? convertNegation(convertInOp(filter[1], filter.slice(2))) : op === 'has' ? convertHasOp(filter[1]) : op === '!has' ? convertNegation(convertHasOp(filter[1])) : - op === 'within' ? filter : - true; + true; return converted; } diff --git a/src/index.ts b/src/index.ts index 00f88307..bbd11d7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,6 +110,7 @@ import {VariableAnchorOffsetCollectionSpecification} from './types.g'; import format from './format'; import validate from './validate/validate'; import migrate from './migrate'; +import {classifyRings} from './util/classify_rings'; const expression = { StyleExpression, @@ -187,6 +188,7 @@ export { format, validate, migrate, + classifyRings, ColorType, interpolates, diff --git a/src/reference/v8.json b/src/reference/v8.json index 25440f81..5a0644c2 100644 --- a/src/reference/v8.json +++ b/src/reference/v8.json @@ -2684,9 +2684,6 @@ }, "!has": { "doc": "`[\"!has\", key]` `feature[key]` does not exist" - }, - "within": { - "doc": "`[\"within\", object]` feature geometry is within object geometry" } }, "doc": "The filter operator." @@ -4079,6 +4076,7 @@ "group": "Math", "sdk-support": { "basic functionality": { + "js": "4.2.0", "android": "9.2.0", "ios": "5.9.0", "macos": "0.16.0" diff --git a/src/util/cheap_ruler.ts b/src/util/cheap_ruler.ts new file mode 100644 index 00000000..f0753c9b --- /dev/null +++ b/src/util/cheap_ruler.ts @@ -0,0 +1,105 @@ +// This is taken from https://github.com/mapbox/cheap-ruler/ in order to take only the relevant parts + +// Values that define WGS84 ellipsoid model of the Earth +const RE = 6378.137; // equatorial radius +const FE = 1 / 298.257223563; // flattening + +const E2 = FE * (2 - FE); +const RAD = Math.PI / 180; + +export default class CheapRuler { + private kx: number; + private ky: number; + + constructor(lat: number) { + + // Curvature formulas from https://en.wikipedia.org/wiki/Earth_radius#Meridional + const m = RAD * RE * 1000; + const coslat = Math.cos(lat * RAD); + const w2 = 1 / (1 - E2 * (1 - coslat * coslat)); + const w = Math.sqrt(w2); + + // multipliers for converting longitude and latitude degrees into distance + this.kx = m * w * coslat; // based on normal radius of curvature + this.ky = m * w * w2 * (1 - E2); // based on meridonal radius of curvature + } + + /** + * Given two points of the form [longitude, latitude], returns the distance. + * + * @param a - point [longitude, latitude] + * @param b - point [longitude, latitude] + * @returns distance + * @example + * const distance = ruler.distance([30.5, 50.5], [30.51, 50.49]); + * //=distance + */ + public distance(a: [number, number], b: [number, number]) { + const dx = this.wrap(a[0] - b[0]) * this.kx; + const dy = (a[1] - b[1]) * this.ky; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * Returns an object of the form {point, index, t}, where point is closest point on the line + * from the given point, index is the start index of the segment with the closest point, + * and t is a parameter from 0 to 1 that indicates where the closest point is on that segment. + * + * @param line - an array of points that form the line + * @param p - point [longitude, latitude] + * @returns the nearest point, its index in the array and the proportion along the line + * @example + * const point = ruler.pointOnLine(line, [-67.04, 50.5]).point; + * //=point + */ + public pointOnLine(line: [number, number][], p: [number, number]) { + let minDist = Infinity; + let minX: number, minY: number, minI: number, minT: number; + + for (let i = 0; i < line.length - 1; i++) { + + let x = line[i][0]; + let y = line[i][1]; + let dx = this.wrap(line[i + 1][0] - x) * this.kx; + let dy = (line[i + 1][1] - y) * this.ky; + let t = 0; + + if (dx !== 0 || dy !== 0) { + t = (this.wrap(p[0] - x) * this.kx * dx + (p[1] - y) * this.ky * dy) / (dx * dx + dy * dy); + + if (t > 1) { + x = line[i + 1][0]; + y = line[i + 1][1]; + + } else if (t > 0) { + x += (dx / this.kx) * t; + y += (dy / this.ky) * t; + } + } + + dx = this.wrap(p[0] - x) * this.kx; + dy = (p[1] - y) * this.ky; + + const sqDist = dx * dx + dy * dy; + if (sqDist < minDist) { + minDist = sqDist; + minX = x; + minY = y; + minI = i; + minT = t; + } + } + + return { + point: [minX, minY] as [number, number], + index: minI, + t: Math.max(0, Math.min(1, minT)) + }; + } + + private wrap(deg: number) { + while (deg < -180) deg += 360; + while (deg > 180) deg -= 360; + return deg; + } +} diff --git a/src/util/classify_rings.test.ts b/src/util/classify_rings.test.ts new file mode 100644 index 00000000..e5656d40 --- /dev/null +++ b/src/util/classify_rings.test.ts @@ -0,0 +1,108 @@ +import {Point2D} from '../point2d'; +import {RingWithArea, classifyRings} from './classify_rings'; + +describe('classifyRings', () => { + test('classified.length', () => { + let geometry: Point2D[][]; + let classified: RingWithArea[][]; + + geometry = [ + [ + {x: 0, y: 0}, + {x: 0, y: 40}, + {x: 40, y: 40}, + {x: 40, y: 0}, + {x: 0, y: 0} + ] + ]; + classified = classifyRings(geometry, undefined); + expect(classified).toHaveLength(1); + expect(classified[0]).toHaveLength(1); + + geometry = [ + [ + {x: 0, y: 0}, + {x: 0, y: 40}, + {x: 40, y: 40}, + {x: 40, y: 0}, + {x: 0, y: 0} + ], + [ + {x: 60, y: 0}, + {x: 60, y: 40}, + {x: 100, y: 40}, + {x: 100, y: 0}, + {x: 60, y: 0} + ] + ]; + classified = classifyRings(geometry, undefined); + expect(classified).toHaveLength(2); + expect(classified[0]).toHaveLength(1); + expect(classified[1]).toHaveLength(1); + + geometry = [ + [ + {x: 0, y: 0}, + {x: 0, y: 40}, + {x: 40, y: 40}, + {x: 40, y: 0}, + {x: 0, y: 0} + ], + [ + {x: 10, y: 10}, + {x: 20, y: 10}, + {x: 20, y: 20}, + {x: 10, y: 10} + ] + ]; + classified = classifyRings(geometry, undefined); + expect(classified).toHaveLength(1); + expect(classified[0]).toHaveLength(2); + }); +}); + +describe('classifyRings + maxRings', () => { + + function createGeometry(options?) { + const geometry = [ + // Outer ring, area = 3200 + [{x: 0, y: 0}, {x: 0, y: 40}, {x: 40, y: 40}, {x: 40, y: 0}, {x: 0, y: 0}], + // Inner ring, area = 100 + [{x: 30, y: 30}, {x: 32, y: 30}, {x: 32, y: 32}, {x: 30, y: 30}], + // Inner ring, area = 4 + [{x: 10, y: 10}, {x: 20, y: 10}, {x: 20, y: 20}, {x: 10, y: 10}] + ] as Point2D[][]; + if (options && options.reverse) { + geometry[0].reverse(); + geometry[1].reverse(); + geometry[2].reverse(); + } + return geometry; + } + + test('maxRings=undefined', () => { + const geometry = classifyRings(createGeometry()); + expect(geometry).toHaveLength(1); + expect(geometry[0]).toHaveLength(3); + expect(geometry[0][0].area).toBe(3200); + expect(geometry[0][1].area).toBe(4); + expect(geometry[0][2].area).toBe(100); + }); + + test('maxRings=2', () => { + const geometry = classifyRings(createGeometry(), 2); + expect(geometry).toHaveLength(1); + expect(geometry[0]).toHaveLength(2); + expect(geometry[0][0].area).toBe(3200); + expect(geometry[0][1].area).toBe(100); + + }); + + test('maxRings=2, reversed geometry', () => { + const geometry = classifyRings(createGeometry({reverse: true}), 2); + expect(geometry).toHaveLength(1); + expect(geometry[0]).toHaveLength(2); + expect(geometry[0][0].area).toBe(3200); + expect(geometry[0][1].area).toBe(100); + }); +}); diff --git a/src/util/classify_rings.ts b/src/util/classify_rings.ts new file mode 100644 index 00000000..ae9ab269 --- /dev/null +++ b/src/util/classify_rings.ts @@ -0,0 +1,71 @@ +import quickselect from 'quickselect'; +import {Point2D} from '../point2d'; + +export type RingWithArea = T[] & { area?: number }; + +/** + * Classifies an array of rings into polygons with outer rings and holes + * @param rings - the rings to classify + * @param maxRings - the maximum number of rings to include in a polygon, use 0 to include all rings + * @returns an array of polygons with internal rings as holes + */ +export function classifyRings(rings: RingWithArea[], maxRings?: number): RingWithArea[][] { + const len = rings.length; + + if (len <= 1) return [rings]; + + const polygons: T[][][] = []; + let polygon: T[][]; + let ccw: boolean | undefined; + + for (const ring of rings) { + const area = calculateSignedArea(ring); + if (area === 0) continue; + + ring.area = Math.abs(area); + + if (ccw === undefined) ccw = area < 0; + + if (ccw === area < 0) { + if (polygon) polygons.push(polygon); + polygon = [ring]; + } else { + polygon.push(ring); + } + } + if (polygon) polygons.push(polygon); + + // Earcut performance degrades with the # of rings in a polygon. For this + // reason, we limit strip out all but the `maxRings` largest rings. + if (maxRings > 1) { + for (let j = 0; j < polygons.length; j++) { + if (polygons[j].length <= maxRings) continue; + quickselect(polygons[j], maxRings, 1, polygons[j].length - 1, compareAreas); + polygons[j] = polygons[j].slice(0, maxRings); + } + } + + return polygons; +} + +function compareAreas(a: {area: number}, b: {area: number}) { + return b.area - a.area; +} + +/** + * Returns the signed area for the polygon ring. Positive areas are exterior rings and + * have a clockwise winding. Negative areas are interior rings and have a counter clockwise + * ordering. + * + * @param ring - Exterior or interior ring + * @returns Signed area + */ +function calculateSignedArea(ring: Point2D[]): number { + let sum = 0; + for (let i = 0, len = ring.length, j = len - 1, p1, p2; i < len; j = i++) { + p1 = ring[i]; + p2 = ring[j]; + sum += (p2.x - p1.x) * (p1.y + p2.y); + } + return sum; +} diff --git a/src/util/geometry_util.ts b/src/util/geometry_util.ts new file mode 100644 index 00000000..5fce15d3 --- /dev/null +++ b/src/util/geometry_util.ts @@ -0,0 +1,153 @@ +import {ICanonicalTileID} from '../tiles_and_coordinates'; + +// minX, minY, maxX, maxY +export type BBox = [number, number, number, number]; + +export const EXTENT = 8192; + +export function getTileCoordinates(p: GeoJSON.Position, canonical: ICanonicalTileID): [number, number] { + const x = mercatorXfromLng(p[0]); + const y = mercatorYfromLat(p[1]); + const tilesAtZoom = Math.pow(2, canonical.z); + return [Math.round(x * tilesAtZoom * EXTENT), Math.round(y * tilesAtZoom * EXTENT)]; +} + +export function getLngLatFromTileCoord(coord: [number, number], canonical: ICanonicalTileID): GeoJSON.Position { + const tilesAtZoom = Math.pow(2, canonical.z); + const x = (coord[0] / EXTENT + canonical.x) / tilesAtZoom; + const y = (coord[1] / EXTENT + canonical.y) / tilesAtZoom; + return [lngFromMercatorXfromLng(x), latFromMercatorY(y)]; +} + +function mercatorXfromLng(lng: number) { + return (180 + lng) / 360; +} + +function lngFromMercatorXfromLng(mercatorX: number) { + return mercatorX * 360 - 180; +} + +function mercatorYfromLat(lat: number) { + return (180 - (180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)))) / 360; +} + +function latFromMercatorY(mercatorY: number) { + return 360 / Math.PI * Math.atan(Math.exp((180 - mercatorY * 360) * Math.PI / 180)) - 90; +} + +export function updateBBox(bbox: BBox, coord: GeoJSON.Position) { + bbox[0] = Math.min(bbox[0], coord[0]); + bbox[1] = Math.min(bbox[1], coord[1]); + bbox[2] = Math.max(bbox[2], coord[0]); + bbox[3] = Math.max(bbox[3], coord[1]); +} + +export function boxWithinBox(bbox1: BBox, bbox2: BBox) { + if (bbox1[0] <= bbox2[0]) return false; + if (bbox1[2] >= bbox2[2]) return false; + if (bbox1[1] <= bbox2[1]) return false; + if (bbox1[3] >= bbox2[3]) return false; + return true; +} + +export function rayIntersect(p: [number, number], p1: [number, number], p2: [number, number]): boolean { + return ((p1[1] > p[1]) !== (p2[1] > p[1])) && (p[0] < (p2[0] - p1[0]) * (p[1] - p1[1]) / (p2[1] - p1[1]) + p1[0]); +} + +function pointOnBoundary(p: [number, number], p1: [number, number], p2: [number, number]): boolean { + const x1 = p[0] - p1[0]; + const y1 = p[1] - p1[1]; + const x2 = p[0] - p2[0]; + const y2 = p[1] - p2[1]; + return (x1 * y2 - x2 * y1 === 0) && (x1 * x2 <= 0) && (y1 * y2 <= 0); +} + +// a, b are end points for line segment1, c and d are end points for line segment2 +export function segmentIntersectSegment(a: [number, number], b: [number, number], c: [number, number], d: [number, number]) { + // check if two segments are parallel or not + // precondition is end point a, b is inside polygon, if line a->b is + // parallel to polygon edge c->d, then a->b won't intersect with c->d + const vectorP: [number, number] = [b[0] - a[0], b[1] - a[1]]; + const vectorQ: [number, number] = [d[0] - c[0], d[1] - c[1]]; + if (perp(vectorQ, vectorP) === 0) return false; + + // If lines are intersecting with each other, the relative location should be: + // a and b lie in different sides of segment c->d + // c and d lie in different sides of segment a->b + if (twoSided(a, b, c, d) && twoSided(c, d, a, b)) return true; + return false; +} + +export function lineIntersectPolygon(p1, p2, polygon) { + for (const ring of polygon) { + // loop through every edge of the ring + for (let j = 0; j < ring.length - 1; ++j) { + if (segmentIntersectSegment(p1, p2, ring[j], ring[j + 1])) { + return true; + } + } + } + return false; +} + +// ray casting algorithm for detecting if point is in polygon +export function pointWithinPolygon(point: [number, number], rings: [number, number][][], trueIfOnBoundary = false) { + let inside = false; + for (const ring of rings) { + for (let j = 0; j < ring.length - 1; j++) { + if (pointOnBoundary(point, ring[j], ring[j + 1])) return trueIfOnBoundary; + if (rayIntersect(point, ring[j], ring[j + 1])) inside = !inside; + } + } + return inside; +} + +export function pointWithinPolygons(point: [number, number], polygons: [number, number][][][]) { + for (const polygon of polygons) { + if (pointWithinPolygon(point, polygon)) return true; + } + return false; +} + +export function lineStringWithinPolygon(line: [number, number][], polygon: [number, number][][]) { + // First, check if geometry points of line segments are all inside polygon + for (const point of line) { + if (!pointWithinPolygon(point, polygon)) { + return false; + } + } + + // Second, check if there is line segment intersecting polygon edge + for (let i = 0; i < line.length - 1; ++i) { + if (lineIntersectPolygon(line[i], line[i + 1], polygon)) { + return false; + } + } + return true; +} + +export function lineStringWithinPolygons(line: [number, number][], polygons: [number, number][][][]) { + for (const polygon of polygons) { + if (lineStringWithinPolygon(line, polygon)) return true; + } + return false; +} + +function perp(v1: [number, number], v2: [number, number]) { + return (v1[0] * v2[1] - v1[1] * v2[0]); +} + +// check if p1 and p2 are in different sides of line segment q1->q2 +function twoSided(p1: [number, number], p2: [number, number], q1: [number, number], q2: [number, number]) { + // q1->p1 (x1, y1), q1->p2 (x2, y2), q1->q2 (x3, y3) + const x1 = p1[0] - q1[0]; + const y1 = p1[1] - q1[1]; + const x2 = p2[0] - q1[0]; + const y2 = p2[1] - q1[1]; + const x3 = q2[0] - q1[0]; + const y3 = q2[1] - q1[1]; + const det1 = (x1 * y3 - x3 * y1); + const det2 = (x2 * y3 - x3 * y2); + if ((det1 > 0 && det2 < 0) || (det1 < 0 && det2 > 0)) return true; + return false; +} diff --git a/src/validate/validate_filter.ts b/src/validate/validate_filter.ts index 9fe1fad7..659173c0 100644 --- a/src/validate/validate_filter.ts +++ b/src/validate/validate_filter.ts @@ -104,14 +104,6 @@ function validateNonExpressionFilter(options) { errors.push(new ValidationError(`${key}[1]`, value[1], `string expected, ${type} found`)); } break; - case 'within': - type = getType(value[1]); - if (value.length !== 2) { - errors.push(new ValidationError(key, value, `filter array for "${value[0]}" operator must have 2 elements`)); - } else if (type !== 'object') { - errors.push(new ValidationError(`${key}[1]`, value[1], `object expected, ${type} found`)); - } - break; } return errors; } diff --git a/src/validate/validate_sky.ts b/src/validate/validate_sky.ts index 66e73591..77421887 100644 --- a/src/validate/validate_sky.ts +++ b/src/validate/validate_sky.ts @@ -1,6 +1,5 @@ import ValidationError from '../error/validation_error'; import getType from '../util/get_type'; -import validate from './validate'; import v8 from '../reference/v8.json' assert {type: 'json'}; import {SkySpecification, StyleSpecification} from '../types.g'; @@ -28,7 +27,7 @@ export default function validateSky(options: ValidateSkyOptions) { let errors = []; for (const key in sky) { if (skySpec[key]) { - errors = errors.concat(validate({ + errors = errors.concat(options.validateSpec({ key, value: sky[key], valueSpec: skySpec[key], diff --git a/test/integration/expression/tests/distance/basic/test.json b/test/integration/expression/tests/distance/basic/test.json new file mode 100644 index 00000000..7a8d5804 --- /dev/null +++ b/test/integration/expression/tests/distance/basic/test.json @@ -0,0 +1,33 @@ +{ + "expression": [ + "distance", + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [3, 3] + } + } + ] + } + ], + "inputs": [ + [ + {"zoom": 20, "canonicalID": {"z": 20, "x": 3, "y": 3}}, + {"geometry": {"type": "Point", "coordinates": [3, 3]}} + ] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "number" + }, + "outputs": [0.0017018857364025284] + } + } + \ No newline at end of file diff --git a/test/lib/geometry.ts b/test/lib/geometry.ts index 0d808453..d66d7b06 100644 --- a/test/lib/geometry.ts +++ b/test/lib/geometry.ts @@ -1,13 +1,22 @@ -import {ICanonicalTileID, ILngLatLike} from '../../src'; +import {ICanonicalTileID} from '../../src'; import {Point2D} from '../../src/point2d'; -import {getPoint} from './util'; +import {EXTENT, getTileCoordinates} from '../../src/util/geometry_util'; -function convertPoint(coord: ILngLatLike, canonical: ICanonicalTileID): Point2D[] { +function getPoint(coord: GeoJSON.Position, canonical: ICanonicalTileID): Point2D { + const tileCoords = getTileCoordinates(coord, canonical); + // this shift is to place the point relative to the tile instead of the world + const shifts = [canonical.x * EXTENT, canonical.y * EXTENT]; + const x = tileCoords[0] - shifts[0]; + const y = tileCoords[1] - shifts[1]; + return {x, y}; +} + +function convertPoint(coord: GeoJSON.Position, canonical: ICanonicalTileID): Point2D[] { return [getPoint(coord, canonical)]; } -function convertPoints(coords: ILngLatLike[], canonical: ICanonicalTileID): Point2D[][] { +function convertPoints(coords: GeoJSON.Position[], canonical: ICanonicalTileID): Point2D[][] { const o: Point2D[][] = []; for (let i = 0; i < coords.length; i++) { o.push(convertPoint(coords[i], canonical)); @@ -16,7 +25,7 @@ function convertPoints(coords: ILngLatLike[], canonical: ICanonicalTileID): Poin return o; } -function convertLine(line: ILngLatLike[], canonical: ICanonicalTileID): Point2D[] { +function convertLine(line: GeoJSON.Position[], canonical: ICanonicalTileID): Point2D[] { const l: Point2D[] = []; for (let i = 0; i < line.length; i++) { l.push(getPoint(line[i], canonical)); @@ -24,7 +33,7 @@ function convertLine(line: ILngLatLike[], canonical: ICanonicalTileID): Point2D[ return l; } -function convertLines(lines: ILngLatLike[][], canonical: ICanonicalTileID): Point2D[][] { +function convertLines(lines: GeoJSON.Position[][], canonical: ICanonicalTileID): Point2D[][] { const l: Point2D[][] = []; for (let i = 0; i < lines.length; i++) { l.push(convertLine(lines[i], canonical)); @@ -32,31 +41,38 @@ function convertLines(lines: ILngLatLike[][], canonical: ICanonicalTileID): Poin return l; } -export function getGeometry(feature, geometry, canonical: ICanonicalTileID) { - if (geometry.coordinates) { - const coords = geometry.coordinates; - const type = geometry.type; - feature.type = type; - feature.geometry = []; - if (type === 'Point') { - feature.geometry.push(convertPoint(coords, canonical)); - } else if (type === 'MultiPoint') { +export function getGeometry(feature: {type?: any; id?: any; geometry?: Point2D[][]}, + geometry: GeoJSON.MultiLineString | GeoJSON.LineString | GeoJSON.Point | GeoJSON.MultiPoint | GeoJSON.Polygon | GeoJSON.MultiPolygon, + canonical: ICanonicalTileID) { + if (!geometry.coordinates) { + return; + } + feature.type = geometry.type; + feature.geometry = []; + switch (geometry.type) { + case 'Point': + feature.geometry.push(convertPoint(geometry.coordinates, canonical)); + break; + case 'MultiPoint': feature.type = 'Point'; - feature.geometry.push(...convertPoints(coords, canonical)); - } else if (type === 'LineString') { - feature.geometry.push(convertLine(coords, canonical)); - } else if (type === 'MultiLineString') { + feature.geometry.push(...convertPoints(geometry.coordinates, canonical)); + break; + case 'LineString': + feature.geometry.push(convertLine(geometry.coordinates, canonical)); + break; + case 'MultiLineString': feature.type = 'LineString'; - feature.geometry.push(...convertLines(coords, canonical)); - } else if (type === 'Polygon') { - feature.geometry.push(...convertLines(coords, canonical)); - } else if (type === 'MultiPolygon') { + feature.geometry.push(...convertLines(geometry.coordinates, canonical)); + break; + case 'Polygon': + feature.geometry.push(...convertLines(geometry.coordinates, canonical)); + break; + case 'MultiPolygon': feature.type = 'Polygon'; - for (let i = 0; i < coords.length; i++) { + for (let i = 0; i < geometry.coordinates.length; i++) { const polygon: Point2D[][] = []; - polygon.push(...convertLines(coords[i], canonical)); - feature.geometry.push(polygon); + polygon.push(...convertLines(geometry.coordinates[i], canonical)); + feature.geometry.push(...polygon); } - } } } diff --git a/test/lib/util.ts b/test/lib/util.ts index 8795b0fe..202ba372 100644 --- a/test/lib/util.ts +++ b/test/lib/util.ts @@ -1,6 +1,4 @@ import compactStringify from 'json-stringify-pretty-compact'; -import {ICanonicalTileID, ILngLatLike} from '../../src'; -import {Point2D} from '../../src/point2d'; import Color from '../../src/util/color'; /** @@ -30,33 +28,6 @@ export function expectCloseToArray(toTest: number[], expected: number[], numDigi expect(toTest).toEqual(expected.map(n => isNaN(n) ? n : expect.closeTo(n, numDigits))); } -export function getPointFromLngLat(lng: number, lat: number, canonical: ICanonicalTileID) { - return getTilePoint(canonical, {x: mercatorXfromLng(lng), y: mercatorYfromLat(lat), z: 0}); -} - -function getTilePoint(canonical: ICanonicalTileID, coord: {x: number; y: number; z: number}): Point2D { - const tilesAtZoom = Math.pow(2, canonical.z); - return { - x: (coord.x * tilesAtZoom - canonical.x) * 8192, - y: (coord.y * tilesAtZoom - canonical.y) * 8192 - }; -} - -function mercatorXfromLng(lng: number) { - return (180 + lng) / 360; -} - -function mercatorYfromLat(lat: number) { - return (180 - (180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)))) / 360; -} - -export function getPoint(coord: ILngLatLike, canonical: ICanonicalTileID): Point2D { - const p: Point2D = getTilePoint(canonical, {x: mercatorXfromLng(coord[0]), y: mercatorYfromLat(coord[1]), z: 0}); - p.x = Math.round(p.x); - p.y = Math.round(p.y); - return p; -} - // we have to handle this edge case here because we have test fixtures for this // edge case, and we don't want UPDATE=1 to mess with them export function stringify(v) {