export const ARROW_BODY_STYLE_CONSTANT = 1; export const ARROW_BODY_STYLE_LINEAR = 2; export const ARROW_BODY_STYLE_EXPONENTIAL = 3; export const METERS = 'meters'; import * as turf from "@turf/turf"; //ARROW // Cubic interpolation source from https://www.paulinternet.nl/?page=bicubic /** * @param {number[]} points - An array of 4 values [p0, p1, p2, p3] representing control points. * @param {number} t - The relative position between p1 and p2 (range typically from 0 to 1). * @returns {number} The interpolated value. */ export function cubicInterpolate(p, t) { return p[1] + 0.5 * t * (p[2] - p[0] + t * (2.0 * p[0] - 5.0 * p[1] + 4.0 * p[2] - p[3] + t * (3.0 * (p[1] - p[2]) + p[3] - p[0]))); } function exponentialWidthCurve(normalizedPosition, range = 5, minValue = 0.1) { return minValue + (1 - minValue) * Math.exp(-range * normalizedPosition); } function linearWidthCurve(normalizedPosition, range = 1, minValue = 0.1) { return 1 + (minValue - 1) * normalizedPosition / range ; } //GEOJSON /** * @param {Object} arrowData - Object with data for arrow * @param {Array<[number, number]>} arrowData.points - List of points defining the arrow's path. * @param {number} arrowData.splineStep - The step size for the spline interpolation. * @param {number} arrowData.offsetDistance - The offset distance for the arrow's path (width). * * @param {Object} style - Object with data for the calculation style. * @param {number} style.calculation - The style for the calculation * @param {number} style.range - The range for the calculation style. * @param {number} style.minValue - The minimum value used in the calculation. * * @param {Object} arrowHeadData - Optional data for the arrowhead. * @param {number} arrowHeadData.widthArrow - The width of the arrowhead. * @param {number} arrowHeadData.lengthArrow - The length of the arrowhead. * * @returns {GeoJSON.Feature} - An array of points representing the arrow polygon. */ export function getArrowPolygon(arrowData, style, arrowHeadData) { if (!arrowData || !(arrowData.points) || arrowData.points.length === 0) { console.warn("getArrowPolygon: Invalid arrowData or empty points array."); return []; } if (!style) { style = { calculation: ARROW_BODY_STYLE_CONSTANT, range: 0, minValue: 0 }; } const splinePoints = computeSplinePoints(arrowData.points, arrowData.splineStep); const { leftSidePoints, rightSidePoints } = computeSideOffsets(splinePoints, arrowData.offsetDistance, style); const end = splinePoints[splinePoints.length -1]; const bearing = averageBearing(splinePoints, 3); const arrowHead= arrowHeadData ? createIsoscelesTriangleCoords( turf.point(end), arrowData.offsetDistance * arrowHeadData.widthArrow, arrowData.offsetDistance * arrowHeadData.lengthArrow, bearing) : []; const polygonCoords = [ ...leftSidePoints, ...arrowHead, ...rightSidePoints.reverse(), leftSidePoints[0] ]; return turf.polygon([[...polygonCoords]]); } function averageBearing(points, count = 3) { const bearings = []; for (let i = points.length - count; i < points.length -1; i++) { if (i >= 0) { bearings.push(turf.bearing(turf.point(points[i]), turf.point(points[i + 1]))); } } const sinSum = bearings.reduce((sum, b) => sum + Math.sin(b * Math.PI / 180), 0); const cosSum = bearings.reduce((sum, b) => sum + Math.cos(b * Math.PI / 180), 0); return Math.atan2(sinSum, cosSum) * 180 / Math.PI; } function computeSplinePoints(points, splineStep = 10) { if (points.length < 2) return points; const result = []; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i === 0 ? i : i - 1]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[i + 2] || p2; for (let j = 0; j < splineStep; j++) { const t = j / splineStep; const lon = cubicInterpolate([p0[0], p1[0], p2[0], p3[0]], t); const lat = cubicInterpolate([p0[1], p1[1], p2[1], p3[1]], t); result.push([lon, lat]); } } result.push(points[points.length - 1]); return result; } function computeSideOffsets(points, offsetMeters, style) { let leftSidePoints = []; let rightSidePoints = []; const total = points.length - 1; for (let i = 1; i < points.length; i++) { const previousPoint = points[i - 1]; const currentPoint = points[i]; const bearing = turf.bearing(turf.point(previousPoint), turf.point(currentPoint)); const normalizedPosition = i / total; let localOffsetDistance; switch (style.calculation) { case ARROW_BODY_STYLE_LINEAR: localOffsetDistance = offsetMeters * linearWidthCurve(normalizedPosition, style.range, style.minValue); break; case ARROW_BODY_STYLE_EXPONENTIAL: localOffsetDistance = offsetMeters * exponentialWidthCurve(normalizedPosition, style.range, style.minValue); break; case ARROW_BODY_STYLE_CONSTANT: default: localOffsetDistance = offsetMeters; } leftSidePoints.push(turf.destination(turf.point(currentPoint), localOffsetDistance, bearing - 90, { units: METERS }).geometry.coordinates); rightSidePoints.push(turf.destination(turf.point(currentPoint), localOffsetDistance, bearing + 90, { units: METERS }).geometry.coordinates); } return { leftSidePoints, rightSidePoints }; } function createIsoscelesTriangleCoords(center, baseLengthMeters, heightMeters, bearing = 0) { const halfBase = baseLengthMeters / 2; const left = turf.destination(center, halfBase, bearing - 90, { units: METERS }).geometry.coordinates; const right = turf.destination(center, halfBase, bearing + 90, { units: METERS }).geometry.coordinates; const tip = turf.destination(center, heightMeters, bearing, { units: METERS }).geometry.coordinates; return [left, tip, right]; } //GEOJSON //CANVAS /** * @param {Object} arrowData - Object with data for arrow * @param {{x: number, y: number}[]} arrowData.points - List of points defining the arrow's path. * @param {number} arrowData.splineStep - The step size for the spline interpolation. * @param {number} arrowData.spacing - The spacing between the points along the arrow. * @param {number} arrowData.offsetDistance - The offset distance for the arrow's path (width). * * @param {Object} style - Object with data for the calculation style. * @param {number} style.calculation - The style for the calculation * @param {number} style.range - The range for the calculation style. * @param {number} style.minValue - The minimum value used in the calculation. * * @param {Object|undefined} arrowHeadData - Optional data for the arrowhead. * @param {number} arrowHeadData.widthArrow - The width of the arrowhead. * @param {number} arrowHeadData.lengthArrow - The length of the arrowhead. * * @returns {{x: number, y: number}[]} - An array of points representing the arrow polygon. */ export function getArrowPolygonEuclidean( arrowData, style= undefined, arrowHeadData = undefined) { if (!style) style = { calculation: ARROW_BODY_STYLE_CONSTANT, range: 0, minValue: 0 }; const splinePoints = computeSplinePointsEuclidean(arrowData.points, arrowData.splineStep); const { leftSidePoints, rightSidePoints } = computeSidesEuclidean(splinePoints, arrowData.spacing, arrowData.offsetDistance, style); const arrowHead= arrowHeadData ? computeArrowHeadEuclidean(splinePoints, arrowHeadData.widthArrow, arrowHeadData.lengthArrow) : []; return [...leftSidePoints, ...arrowHead.reverse(), ...rightSidePoints.reverse()]; } function computeSplinePointsEuclidean(points, splineStep) { let splinePoints = []; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i === 0 ? i : i - 1]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[i + 2] || p2; for (let t = 0; t <= 1; t += splineStep) { splinePoints.push({ x: cubicInterpolate([p0.x, p1.x, p2.x, p3.x], t), y: cubicInterpolate([p0.y, p1.y, p2.y, p3.y], t) }); } } return splinePoints; } function computeSidesEuclidean(splinePoints, spacing, offsetDistance, style) { let leftSidePoints = []; let rightSidePoints = []; let accumulatedDistance = 0; for (let i = 1; i < splinePoints.length; i++) { const previousPoint = splinePoints[i - 1]; const currentPoint = splinePoints[i]; const segmentLength = Math.hypot(currentPoint.x - previousPoint.x, currentPoint.y - previousPoint.y); accumulatedDistance += segmentLength; if (accumulatedDistance >= spacing || i === 1 || i === splinePoints.length - 1) { const distanceX = currentPoint.y - previousPoint.y; const distanceY = previousPoint.x - currentPoint.x; const length = Math.hypot(distanceX, distanceY); const normalizedPosition = i / (splinePoints.length - 1); let localOffsetDistance; switch (style.calculation) { case ARROW_BODY_STYLE_LINEAR: localOffsetDistance = offsetDistance * linearWidthCurve(normalizedPosition, style.range, style.minValue); break; case ARROW_BODY_STYLE_EXPONENTIAL: localOffsetDistance = offsetDistance * exponentialWidthCurve(normalizedPosition, style.range, style.minValue); break; case ARROW_BODY_STYLE_CONSTANT: default: localOffsetDistance = offsetDistance; } const offsetX = (distanceX / length) * localOffsetDistance; const offsetY = (distanceY / length) * localOffsetDistance; accumulatedDistance = 0; leftSidePoints.push({ x: currentPoint.x + offsetX, y: currentPoint.y + offsetY }); rightSidePoints.push({ x: currentPoint.x - offsetX, y: currentPoint.y - offsetY }); } } return { leftSidePoints, rightSidePoints }; } function computeArrowHeadEuclidean(splinePoints, width, length) { const len = splinePoints.length; const lastPoint = splinePoints[len - 1]; const secondLastPoint = splinePoints[len - 2]; const x = lastPoint.x - secondLastPoint.x; const y = lastPoint.y - secondLastPoint.y; const magnitude = Math.hypot(x, y); const normalizedX = x / magnitude; const normalizedY = y / magnitude; return [ { x: lastPoint.x - normalizedY * width, y: lastPoint.y + normalizedX * width }, { x: lastPoint.x + normalizedX * length, y: lastPoint.y + normalizedY * length }, { x: lastPoint.x + normalizedY * width, y: lastPoint.y - normalizedX * width }, ]; } //CANVAS //ARROW