// deck.gl-community // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors /* eslint-disable no-shadow */ import destination from '@turf/destination'; import bearing from '@turf/bearing'; import pointToLineDistance from '@turf/point-to-line-distance'; import {flattenEach} from '@turf/meta'; import {point} from '@turf/helpers'; import {getCoords} from '@turf/invariant'; import {WebMercatorViewport} from '@math.gl/web-mercator'; import {Viewport, Pick, EditHandleFeature, EditHandleType, StartDraggingEvent} from './types'; import { SimpleGeometry, Position, Point, LineString, Polygon, Feature, SimpleGeometryCoordinates } from '../utils/geojson-types'; export type NearestPointType = Feature; export function toDeckColor( color?: [number, number, number, number] | number, defaultColor: [number, number, number, number] = [255, 0, 0, 255] ): [number, number, number, number] { if (!Array.isArray(color)) { return defaultColor; } return [color[0] * 255, color[1] * 255, color[2] * 255, color[3] * 255]; } // // a GeoJSON helper function that calls the provided function with // an argument that is the most deeply-nested array having elements // that are arrays of primitives as an argument, e.g. // // { // "type": "MultiPolygon", // "coordinates": [ // [ // [[30, 20], [45, 40], [10, 40], [30, 20]] // ], // [ // [[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]] // ] // ] // } // // the function would be called on: // // [[30, 20], [45, 40], [10, 40], [30, 20]] // // and // // [[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]] // export function recursivelyTraverseNestedArrays( array: Array, prefix: Array, fn: (array: Array, prefix: number[]) => void ) { if (!Array.isArray(array[0])) { return true; } for (let i = 0; i < array.length; i++) { if (recursivelyTraverseNestedArrays(array[i], [...prefix, i], fn)) { fn(array, prefix); break; } } return false; } export function generatePointsParallelToLinePoints( p1: Position, p2: Position, coords: Position ): Position[] { const lineString: LineString = { type: 'LineString', coordinates: [p1, p2] }; const pt = point(coords); const ddistance = pointToLineDistance(pt, lineString); const lineBearing = bearing(p1, p2); // Check if current point is to the left or right of line // Line from A=(x1,y1) to B=(x2,y2) a point P=(x,y) // then (x−x1)(y2−y1)−(y−y1)(x2−x1) const isPointToLeftOfLine = (coords[0] - p1[0]) * (p2[1] - p1[1]) - (coords[1] - p1[1]) * (p2[0] - p1[0]); // Bearing to draw perpendicular to the line string const orthogonalBearing = isPointToLeftOfLine < 0 ? lineBearing - 90 : lineBearing - 270; // Get coordinates for the point p3 and p4 which are perpendicular to the lineString // Add the distance as the current position moves away from the lineString const p3 = destination(p2, ddistance, orthogonalBearing); const p4 = destination(p1, ddistance, orthogonalBearing); return [p3.geometry.coordinates, p4.geometry.coordinates] as Position[]; } export function distance2d(x1: number, y1: number, x2: number, y2: number): number { const dx = x1 - x2; const dy = y1 - y2; return Math.sqrt(dx * dx + dy * dy); } export function mix(a: number, b: number, ratio: number): number { return b * ratio + a * (1 - ratio); } export function nearestPointOnProjectedLine( line: Feature, inPoint: Feature, viewport: Viewport ): NearestPointType { const wmViewport = new WebMercatorViewport(viewport); // Project the line to viewport, then find the nearest point const coordinates: Array> = line.geometry.coordinates as any; const projectedCoords = coordinates.map(([x, y, z = 0]) => wmViewport.project([x, y, z])); const [x, y] = wmViewport.project(inPoint.geometry.coordinates); // console.log('projectedCoords', JSON.stringify(projectedCoords)); let minDistance = Infinity; let minPointInfo = {}; projectedCoords.forEach(([x2, y2], index) => { if (index === 0) { return; } const [x1, y1] = projectedCoords[index - 1]; // line from projectedCoords[index - 1] to projectedCoords[index] // convert to Ax + By + C = 0 const A = y1 - y2; const B = x2 - x1; const C = x1 * y2 - x2 * y1; // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line const div = A * A + B * B; const distance = Math.abs(A * x + B * y + C) / Math.sqrt(div); // TODO: Check if inside bounds if (distance < minDistance) { minDistance = distance; minPointInfo = { index, x0: (B * (B * x - A * y) - A * C) / div, y0: (A * (-B * x + A * y) - B * C) / div }; } }); // @ts-expect-error TODO const {index, x0, y0} = minPointInfo; const [x1, y1, z1 = 0] = projectedCoords[index - 1]; const [x2, y2, z2 = 0] = projectedCoords[index]; // calculate what ratio of the line we are on to find the proper z const lineLength = distance2d(x1, y1, x2, y2); const startToPointLength = distance2d(x1, y1, x0, y0); const ratio = startToPointLength / lineLength; const z0 = mix(z1, z2, ratio); return { type: 'Feature', geometry: { type: 'Point', coordinates: wmViewport.unproject([x0, y0, z0]) }, properties: { // TODO: calculate the distance in proper units dist: minDistance, index: index - 1 } }; } export function nearestPointOnLine( // ( lines: Feature, inPoint: Feature, viewport?: Viewport ): NearestPointType { let mercator; if (viewport) { mercator = new WebMercatorViewport(viewport); } let closestPoint: any = point([Infinity, Infinity], { dist: Infinity }); if (!lines.geometry?.coordinates.length || lines.geometry?.coordinates.length < 2) { return closestPoint; } // eslint-disable-next-line max-statements, complexity flattenEach(lines, (line: any) => { const coords: any = getCoords(line); const pointCoords: any = getCoords(inPoint); let minDist; let to; let from; let x; let y; let segmentIdx; let dist; if (coords.length > 1 && pointCoords.length) { let lineCoordinates; let pointCoordinate; // If viewport is given, then translate these coordinates to pixels to increase precision if (mercator) { lineCoordinates = coords.map((lineCoordinate) => mercator.project(lineCoordinate)); pointCoordinate = mercator.project(pointCoords); } else { lineCoordinates = coords; pointCoordinate = pointCoords; } for (let n = 1; n < lineCoordinates.length; n++) { if (lineCoordinates[n][0] !== lineCoordinates[n - 1][0]) { const slope = (lineCoordinates[n][1] - lineCoordinates[n - 1][1]) / (lineCoordinates[n][0] - lineCoordinates[n - 1][0]); const inverseSlope = lineCoordinates[n][1] - slope * lineCoordinates[n][0]; dist = Math.abs(slope * pointCoordinate[0] + inverseSlope - pointCoordinate[1]) / Math.sqrt(slope * slope + 1); } else dist = Math.abs(pointCoordinate[0] - lineCoordinates[n][0]); // length^2 of line segment const rl2 = Math.pow(lineCoordinates[n][1] - lineCoordinates[n - 1][1], 2) + Math.pow(lineCoordinates[n][0] - lineCoordinates[n - 1][0], 2); // distance^2 of pt to end line segment const ln2 = Math.pow(lineCoordinates[n][1] - pointCoordinate[1], 2) + Math.pow(lineCoordinates[n][0] - pointCoordinate[0], 2); // distance^2 of pt to begin line segment const lnm12 = Math.pow(lineCoordinates[n - 1][1] - pointCoordinate[1], 2) + Math.pow(lineCoordinates[n - 1][0] - pointCoordinate[0], 2); // minimum distance^2 of pt to infinite line const dist2 = Math.pow(dist, 2); // calculated length^2 of line segment const calcrl2 = ln2 - dist2 + lnm12 - dist2; // redefine minimum distance to line segment (not infinite line) if necessary if (calcrl2 > rl2) { dist = Math.sqrt(Math.min(ln2, lnm12)); } if (minDist === null || minDist === undefined || minDist > dist) { // eslint-disable-next-line max-depth if (calcrl2 > rl2) { // eslint-disable-next-line max-depth if (lnm12 < ln2) { to = 0; // nearer to previous point from = 1; } else { from = 0; // nearer to current point to = 1; } } else { // perpendicular from point intersects line segment to = Math.sqrt(lnm12 - dist2) / Math.sqrt(rl2); from = Math.sqrt(ln2 - dist2) / Math.sqrt(rl2); } minDist = dist; segmentIdx = n; } } const dx = lineCoordinates[segmentIdx - 1][0] - lineCoordinates[segmentIdx][0]; const dy = lineCoordinates[segmentIdx - 1][1] - lineCoordinates[segmentIdx][1]; x = lineCoordinates[segmentIdx - 1][0] - dx * to; y = lineCoordinates[segmentIdx - 1][1] - dy * to; } // index needs to be -1 because we have to account for the shift from initial backscan let snapPoint = {x, y, idx: segmentIdx - 1, to, from}; if (mercator) { const pixelToLatLong = mercator.unproject([snapPoint.x, snapPoint.y]); snapPoint = { x: pixelToLatLong[0], y: pixelToLatLong[1], idx: segmentIdx - 1, to, from }; } closestPoint = point([snapPoint.x, snapPoint.y], { dist: Math.abs(snapPoint.from - snapPoint.to), index: snapPoint.idx }); }); return closestPoint; } export function getPickedEditHandle( picks: Pick[] | null | undefined ): EditHandleFeature | null | undefined { const handles = getPickedEditHandles(picks); return handles.length ? handles[0] : null; } export function getPickedSnapSourceEditHandle( picks: Pick[] | null | undefined ): EditHandleFeature | null | undefined { const handles = getPickedEditHandles(picks); return handles.find((handle) => handle.properties.editHandleType === 'snap-source'); } export function getNonGuidePicks(picks: Pick[]): Pick[] { return picks && picks.filter((pick) => !pick.isGuide); } export function getPickedExistingEditHandle( picks: Pick[] | null | undefined ): EditHandleFeature | null | undefined { const handles = getPickedEditHandles(picks); return handles.find( ({properties}) => properties.featureIndex >= 0 && properties.editHandleType === 'existing' ); } export function getPickedIntermediateEditHandle( picks: Pick[] | null | undefined ): EditHandleFeature | null | undefined { const handles = getPickedEditHandles(picks); return handles.find( ({properties}) => properties.featureIndex >= 0 && properties.editHandleType === 'intermediate' ); } export function getPickedEditHandles(picks: Pick[] | null | undefined): EditHandleFeature[] { const handles = (picks && picks .filter((pick) => pick.isGuide && pick.object.properties.guideType === 'editHandle') .map((pick) => pick.object)) || []; return handles; } export function getEditHandlesForGeometry( geometry: SimpleGeometry, featureIndex: number, editHandleType: EditHandleType = 'existing' ): EditHandleFeature[] { let handles: EditHandleFeature[] = []; switch (geometry.type) { case 'Point': // positions are not nested handles = [ { type: 'Feature', properties: { guideType: 'editHandle', editHandleType, positionIndexes: [], featureIndex }, geometry: { type: 'Point', coordinates: geometry.coordinates } } ]; break; case 'MultiPoint': case 'LineString': // positions are nested 1 level handles = handles.concat( getEditHandlesForCoordinates(geometry.coordinates, [], featureIndex, editHandleType) ); break; case 'Polygon': case 'MultiLineString': // positions are nested 2 levels for (let a = 0; a < geometry.coordinates.length; a++) { handles = handles.concat( getEditHandlesForCoordinates(geometry.coordinates[a], [a], featureIndex, editHandleType) ); if (geometry.type === 'Polygon') { // Don't repeat the first/last handle for Polygons handles = handles.slice(0, -1); } } break; case 'MultiPolygon': // positions are nested 3 levels for (let a = 0; a < geometry.coordinates.length; a++) { for (let b = 0; b < geometry.coordinates[a].length; b++) { handles = handles.concat( getEditHandlesForCoordinates( geometry.coordinates[a][b], [a, b], featureIndex, editHandleType ) ); // Don't repeat the first/last handle for Polygons handles = handles.slice(0, -1); } } break; default: throw Error(`Unhandled geometry type: ${(geometry as {type: string}).type}`); } return handles; } function getEditHandlesForCoordinates( coordinates: any[], positionIndexPrefix: number[], featureIndex: number, editHandleType: EditHandleType = 'existing' ): EditHandleFeature[] { const editHandles: EditHandleFeature[] = []; for (let i = 0; i < coordinates.length; i++) { const position = coordinates[i] as Position; editHandles.push({ type: 'Feature', properties: { guideType: 'editHandle', positionIndexes: [...positionIndexPrefix, i], featureIndex, editHandleType }, geometry: { type: 'Point', coordinates: position } }); } return editHandles; } /** * Calculates coordinates for a feature preserving rectangular shape. * @param feature Feature before modification. * @param editHandleIndex Index of the point to modify. * @param coords New position for the point. * @returns Updated coordinates. */ export function updateRectanglePosition( feature: Feature, editHandleIndex: number, coords: Position ): Position[][] | null { const coordinates = feature.geometry.coordinates; if (!coordinates) { return null; } const points = coordinates[0].slice(0, 4); points[editHandleIndex % 4] = coords; const p0 = points[(editHandleIndex + 2) % 4]; const p2 = points[editHandleIndex % 4]; points[(editHandleIndex + 1) % 4] = [p2[0], p0[1]]; points[(editHandleIndex + 3) % 4] = [p0[0], p2[1]]; return [[...points, points[0]]]; } /** Creates a copy of feature's coordinates. * Each position in coordinates is transformed by calling the provided function. * @param coords Coordinates of a feature. * @param callback A function to transform each coordinate. * @retuns Transformed coordinates. */ export function mapCoords( coords: SimpleGeometryCoordinates, callback: (coords: Position) => Position ): SimpleGeometryCoordinates { if (typeof coords[0] === 'number') { if (!isNaN(coords[0]) && isFinite(coords[0])) { return callback(coords as Position); } return coords; } return (coords as Position[]) .map((coord) => { return mapCoords(coord, callback) as Position; }) .filter(Boolean); } export function shouldCancelPan(event: StartDraggingEvent) { return event.picks.length && event.picks.find((p) => p.featureType === 'points'); }