StorymapperArrow/Arrow.js
2025-06-06 11:02:47 +02:00

276 lines
11 KiB
JavaScript

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<GeoJSON.Polygon>} - 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