modules/edit-modes/src/utils.ts (381 lines of code) (raw):

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, MultiLineString } from '@turf/helpers'; import { getCoords } from '@turf/invariant'; import WebMercatorViewport from 'viewport-mercator-project'; import { Viewport, Pick, EditHandleFeature, EditHandleType } from './types'; import { Geometry, Position, Point, LineString, Polygon, FeatureOf, FeatureWithProps, AnyCoordinates, } from './geojson-types'; export type NearestPointType = FeatureWithProps<Point, { dist: number; index: number }>; 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<any>, prefix: Array<number>, fn: (array: Array<any>, 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, mapCoords: Position ): Position[] { const lineString: LineString = { type: 'LineString', coordinates: [p1, p2], }; const pt = point(mapCoords); 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 = (mapCoords[0] - p1[0]) * (p2[1] - p1[1]) - (mapCoords[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: FeatureOf<LineString>, inPoint: FeatureOf<Point>, viewport: Viewport ): NearestPointType { const wmViewport = new WebMercatorViewport(viewport); // Project the line to viewport, then find the nearest point const coordinates: Array<Array<number>> = 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-ignore 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', // @ts-expect-error coordinates: wmViewport.unproject([x0, y0, z0]), }, properties: { // TODO: calculate the distance in proper units dist: minDistance, index: index - 1, }, }; } export function nearestPointOnLine<G extends LineString | MultiLineString>( lines: FeatureOf<LineString>, inPoint: FeatureOf<Point>, 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; } // @ts-ignore flattenEach(lines, (line: any) => { const coords: any = getCoords(line); // @ts-ignore 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: Geometry, 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: // @ts-expect-error throw Error(`Unhandled geometry type: ${geometry.type}`); } return handles; } function getEditHandlesForCoordinates( coordinates: any[], positionIndexPrefix: number[], featureIndex: number, editHandleType: EditHandleType = 'existing' ): EditHandleFeature[] { const editHandles = []; for (let i = 0; i < coordinates.length; i++) { const position = coordinates[i]; 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 mapCoords New position for the point. * @returns Updated coordinates. */ export function updateRectanglePosition( feature: FeatureOf<Polygon>, editHandleIndex: number, mapCoords: Position ): Position[][] { const coordinates = feature.geometry.coordinates; if (!coordinates) { return null; } const points = coordinates[0].slice(0, 4); points[editHandleIndex % 4] = mapCoords; 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: AnyCoordinates, callback: (coords: Position) => Position ): AnyCoordinates { 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); }