UNPKG

plotboilerplate

Version:

A simple javascript plotting boilerplate for 2d stuff.

362 lines 19.9 kB
/** * A function to detect contour lines from 3D terrain data. * * For usage see demo `./demos/48-contour-plot`. * * @requires detectPaths * @requires GenericPath * @author Ikaros Kappler * @date 2023-11-05 * @modified 2023-11-20 Addig path detection on a triangle based grid. * @version 1.0.0 */ import { Line } from "../../Line"; import { Vertex } from "../../Vertex"; import { detectPaths } from "./detectPaths"; import { clearDuplicateVertices } from "./clearDuplicateVertices"; export class ContourLineDetection { /** * Creates a new instance for calculating contour lines from the given data grid. * @param {IDataGrid2d<number>} dataGrid - The data grid to use. Must not contain any NaN or null values. * @param {boolean=?} debugMode - (optional) Pass `true` to log warnings on (rare) critical edge cases where the algorithm might fail. */ constructor(dataGrid, debugMode) { this.rawLinearPathSegments = []; // Activates/deactivates warning messages on rare edge cases where local path detection fails. this.debugMode = false; this.dataGrid = dataGrid; this.debugMode = Boolean(debugMode); } /** * Detect contour paths from the underlying data source. * * @param {number} criticalHeightValue - The height value. If above data's maximum or below data's minimum then the result will be empty (no intersections). * @param {number} options.closeGapType - `CLOSE_GAP_TYPE_NONE` or `CLOSE_GAP_TYPE_ABOVE` or `CLOSE_GAP_TYPE_BELOW`. * @param {boolean=false} options.useTriangles - If set to true the detection will split each face3 quad into two triangle faces. * @param {pathDetectEpsilon=0.000001} options.pathDetectEpsilon - (optional) The epsilon to tell if two points are located 'in the same place'. Used for connected path detection. If not specified the value `0.0000001` is used. * @param {pointEliminationEpsilon=0.0000001} options.pointEliminationEpsilon - (optional) The epsilon for duplicate point elimination (default is 0.000001). * @param {function?} onRawSegmentsDetected - (optional) Get the interim result of all detected single lines before path detection starts; DO NOT MODIFY the array. * @returns {Array<GenericPath>} - A list of connected paths that resemble the contour lines of the data/terrain at the given height value. */ detectContourPaths(criticalHeightValue, options) { var _a, _b; options = options || { closeGapType: ContourLineDetection.CLOSE_GAP_TYPE_NONE }; // First: clear the buffer this.rawLinearPathSegments = []; // Imagine a face4 element like this // (x,y) (x+1,y) // A-----B // | / | // | / | // D-----C // (x,y+1) (x+1,y+1) // then result in the buffer will be // [ [A,B], // [D,C] ] // Note that the diagonal line (used for triangles) is optional; depends on `options.useTriangles`. const heightFace = [ [0, 0], [0, 0] ]; for (var y = 0; y + 1 < this.dataGrid.ySegmentCount; y++) { for (var x = 0; x + 1 < this.dataGrid.xSegmentCount; x++) { this.dataGrid.getDataFace4At(x, y, heightFace); this.findHeightFaceIntersectionLines(x, y, heightFace, criticalHeightValue, (_a = options.pointEliminationEpsilon) !== null && _a !== void 0 ? _a : 0.0000001, options.useTriangles); } } // Collect value above/below on the y axis if (options.closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_ABOVE || options.closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_BELOW) { const xExtremes = [0, this.dataGrid.xSegmentCount - 1]; for (var i = 0; i < xExtremes.length; i++) { const x = xExtremes[i]; for (var y = 0; y + 1 < this.dataGrid.ySegmentCount; y++) { const nextX = x; const nextY = y + 1; this.detectAboveBelowLerpSegment(x, y, nextX, nextY, criticalHeightValue, options.closeGapType); } } const yExtremes = [0, this.dataGrid.ySegmentCount - 1]; for (var j = 0; j < yExtremes.length; j++) { var y = yExtremes[j]; for (var x = 0; x + 1 < this.dataGrid.xSegmentCount; x++) { const nextX = x + 1; const nextY = y; this.detectAboveBelowLerpSegment(x, y, nextX, nextY, criticalHeightValue, options.closeGapType); } } } if (options.onRawSegmentsDetected) { options.onRawSegmentsDetected(this.rawLinearPathSegments); } // Detect connected paths let pathSegments = detectPaths(this.rawLinearPathSegments, (_b = options.pathDetectEpsilon) !== null && _b !== void 0 ? _b : 0.0000001); // Epsilon // Filter out segments with only a single line of length~=0 pathSegments = pathSegments.filter(function (pathSegment) { return (pathSegment.segments.length != 1 || (pathSegment.segments.length === 1 && pathSegment.segments[0].length() > 0.1)); }); return pathSegments; } /** * This function will calculate a single intersecion line of the given face4 data * segment. If the given face does not intersect with the plane at the given `heightValue` * then no segments will be stored. * * @param {number} xIndex - The x position (index) of the data face. * @param {number} yIndex - The y position (index) of the data face. * @param {[[number,number],[number,number]]} heightFace - The data sample that composes the face4 as a two-dimensional number array. * @param {number} heightValue - The height value of the intersection plane to check for. * @returns {Line|null} */ findHeight4FaceIntersectionLine(xIndex, yIndex, heightFace, heightValue, pointEliminationEpsilon) { const heightValueA = heightFace[0][0]; // value at (x,y) const heightValueB = heightFace[1][0]; const heightValueC = heightFace[1][1]; const heightValueD = heightFace[0][1]; if (heightValueA === null || heightValueB === null || heightValueC === null || heightValueD === null) { throw `[findHeightFace4IntersectionLine] Cannot extract data face at (${xIndex},${yIndex}). Some values are null.`; } let points = []; // Case A: use full quad face if (this.isBetween(heightValueA, heightValueB, heightValue)) { const lerpValueByHeight = this.getLerpRatio(heightValueA, heightValueB, heightValue); points.push(new Vertex(this.lerp(xIndex, xIndex + 1, lerpValueByHeight), yIndex)); } if (this.isBetween(heightValueB, heightValueC, heightValue)) { const lerpValueByHeight = this.getLerpRatio(heightValueB, heightValueC, heightValue); points.push(new Vertex(xIndex + 1, this.lerp(yIndex, yIndex + 1, lerpValueByHeight))); } if (this.isBetween(heightValueC, heightValueD, heightValue)) { const lerpValueByHeight = this.getLerpRatio(heightValueC, heightValueD, heightValue); points.push(new Vertex(this.lerp(xIndex + 1, xIndex, lerpValueByHeight), yIndex + 1)); } if (this.isBetween(heightValueD, heightValueA, heightValue)) { const lerpValueByHeight = this.getLerpRatio(heightValueD, heightValueA, heightValue); points.push(new Vertex(xIndex, this.lerp(yIndex + 1, yIndex, lerpValueByHeight))); } // Warning: if a plane intersection point is located EXACTLY on the corner // edge of a face, then the two adjacent edges will result in 2x the same // intersecion point. This must be handled as one, so filter the point list // by an epsilon. points = clearDuplicateVertices(points, pointEliminationEpsilon); // 0.000001); if (points.length >= 2) { const startPoint = points[0]; const endPoint = points[1]; if (this.debugMode && points.length > 2) { console.warn("[findHeightFace4IntersectionLine] Detected more than 2 points on one face whre only 0 or 2 should appear. At ", xIndex, yIndex, points); } return new Line(startPoint, endPoint); } else { if (this.debugMode && points.length === 1) { console.warn("[findHeightFace4IntersectionLine] Point list has only one point (should not happen)."); } return null; } } /** * This function will calculate a single intersecion line of the given face4 data * segment. If the given face does not intersect with the plane at the given `heightValue` * then no segments will be stored. * * @param {number} xIndex - The x position (index) of the data face. * @param {number} yIndex - The y position (index) of the data face. * @param {[[number,number],[number,number]]} heightFace - The data sample that composes the face4 as a two-dimensional number array. * @param {number} heightValue - The height value of the intersection plane to check for. * @returns {Line|null} */ findHeightFaceIntersectionLines(xIndex, yIndex, heightFace, criticalHeightValue, pointEliminationEpsilon, useTriangles) { // Imagine a face4 element like this // (x,y) (x+1,y) // A-----B // | / | // | / | // D-----C // (x,y+1) (x+1,y+1) // then result in the buffer will be // [ [A,B], // [D,C] ] if (useTriangles) { const lineA = this.findHeighteFace3IntersectionLine(xIndex, yIndex, xIndex, yIndex + 1, xIndex + 1, yIndex, [heightFace[0][0], heightFace[0][1], heightFace[1][0]], criticalHeightValue, pointEliminationEpsilon); if (lineA) { this.rawLinearPathSegments.push(lineA); } const lineB = this.findHeighteFace3IntersectionLine(xIndex, yIndex + 1, xIndex + 1, yIndex + 1, xIndex + 1, yIndex, [heightFace[0][1], heightFace[1][1], heightFace[1][0]], criticalHeightValue, pointEliminationEpsilon); if (lineB) { this.rawLinearPathSegments.push(lineB); } } else { const line = this.findHeight4FaceIntersectionLine(xIndex, yIndex, heightFace, criticalHeightValue, pointEliminationEpsilon); if (line) { this.rawLinearPathSegments.push(line); } } } /** * This function will calculate a single intersecion line of the given face3 data * segment. If the given face does not intersect with the plane at the given `heightValue` * then no segments will be stored. * * @param {number} xIndexA - The x position (index) of the first triangle data point. * @param {number} yIndexA - The y position (index) of the first triangle data point. * @param {[[number,number],[number,number]]} heightFace - The data sample that composes the face4 as a two-dimensional number array. * @param {number} heightValue - The height value of the intersection plane to check for. * @returns {Line|null} */ findHeighteFace3IntersectionLine(xIndexA, yIndexA, xIndexB, yIndexB, xIndexC, yIndexC, heightFace, heightValue, pointEliminationEpsilon) { const heightValueA = heightFace[0]; // value at (x,y) const heightValueB = heightFace[1]; const heightValueC = heightFace[2]; if (heightValueA === null || heightValueB === null || heightValueC === null) { throw `[findHeightFace3IntersectionLine] Cannot extract data face at (${xIndexA},${yIndexA}). Some values are null.`; } let points = []; // Case A: use full quad face if (this.isBetween(heightValueA, heightValueB, heightValue)) { const lerpValueByHeight = this.getLerpRatio(heightValueA, heightValueB, heightValue); points.push(new Vertex(this.lerp(xIndexA, xIndexB, lerpValueByHeight), this.lerp(yIndexA, yIndexB, lerpValueByHeight))); } if (this.isBetween(heightValueB, heightValueC, heightValue)) { const lerpValueByHeight = this.getLerpRatio(heightValueB, heightValueC, heightValue); // points.push(new Vertex(xIndex + 1, this.lerp(yIndex, yIndex + 1, lerpValueByHeight))); points.push(new Vertex(this.lerp(xIndexB, xIndexC, lerpValueByHeight), this.lerp(yIndexB, yIndexC, lerpValueByHeight))); } if (this.isBetween(heightValueC, heightValueA, heightValue)) { const lerpValueByHeight = this.getLerpRatio(heightValueC, heightValueA, heightValue); // points.push(new Vertex(this.lerp(xIndex + 1, xIndex, lerpValueByHeight), yIndex + 1)); points.push(new Vertex(this.lerp(xIndexC, xIndexA, lerpValueByHeight), this.lerp(yIndexC, yIndexA, lerpValueByHeight))); } // Warning: if a plane intersection point is located EXACTLY on the corner // edge of a face, then the two adjacent edges will result in 2x the same // intersecion point. This must be handled as one, so filter the point list // by an epsilon. points = clearDuplicateVertices(points, pointEliminationEpsilon); // 0.0000001); if (points.length >= 2) { const startPoint = points[0]; const endPoint = points[1]; if (this.debugMode && points.length > 2) { console.warn("[findHeightFace3IntersectionLine] Detected more than 2 points on one face whre only 0 or 2 should appear. At ", xIndexA, yIndexA, points); } return new Line(startPoint, endPoint); } else { if (this.debugMode && points.length === 1) { console.warn("[findHeightFace3IntersectionLine] Point list has only one point (should not happen)."); } return null; } } /** * This procedure will look at the face4 at the ((x,y),(nextX,nextY)) position – which are four values – * and determines the local contour lines for these cases. * * This is used to detect and calculate edge cases on the borders of the underliying height data: * * left and right border (x=0, x=data.xSegmentCount) * * top and bottom border (x=y, x=data.ySegmentCount) * * Resulting path segments will be stored in the global `rawLinearPathSegments` array for further processing. * * @param {number} x * @param {number} y * @param {number} nextX * @param {number} nextY * @param {number} criticalHeightValue * @param {number} closeGapType - CLOSE_GAP_TYPE_ABOVE or CLOSE_GAP_TYPE_BELOW. * @return {void} */ detectAboveBelowLerpSegment(x, y, nextX, nextY, criticalHeightValue, closeGapType) { const heightValueA = this.dataGrid.getDataValueAt(x, y); const heightValueB = this.dataGrid.getDataValueAt(nextX, nextY); // if (heightValueA >= criticalHeightValue && heightValueB >= criticalHeightValue) { if (this.areBothValuesOnRequiredPlaneSide(heightValueA, heightValueB, criticalHeightValue, closeGapType)) { // Both above const line = new Line(new Vertex(x, y), new Vertex(nextX, nextY)); this.rawLinearPathSegments.push(line); } else if ((closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_ABOVE && heightValueA >= criticalHeightValue && heightValueB <= criticalHeightValue) || (closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_BELOW && heightValueA <= criticalHeightValue && heightValueB >= criticalHeightValue)) { // Only one of both (first) is above -> interpolate to find exact intersection point const lerpValueByHeight = this.getLerpRatio(heightValueA, heightValueB, criticalHeightValue); const interpLine = new Line(new Vertex(x, y), new Vertex(this.lerp(x, nextX, lerpValueByHeight), this.lerp(y, nextY, lerpValueByHeight))); this.rawLinearPathSegments.push(interpLine); } else if ((closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_ABOVE && heightValueA <= criticalHeightValue && heightValueB >= criticalHeightValue) || (closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_BELOW && heightValueA >= criticalHeightValue && heightValueB <= criticalHeightValue)) { // Only one of both (second) is above -> interpolate to find exact intersection point const lerpValueByHeight = this.getLerpRatio(heightValueA, heightValueB, criticalHeightValue); const interpLine = new Line(new Vertex(this.lerp(x, nextX, lerpValueByHeight), this.lerp(y, nextY, lerpValueByHeight)), new Vertex(nextX, nextY)); this.rawLinearPathSegments.push(interpLine); } } /** * Checks if both value are on the same side of the critical value (above or below). The `closeGapType` * indictes if `CLOSE_GAP_TYPE_BELOW` or `CLOSE_GAP_TYPE_ABOVE` should be used as a rule. * * @param {number} valueA * @param {number} valueB * @param {number} criticalValue * @param {number} closeGapType * @returns {boolean} */ areBothValuesOnRequiredPlaneSide(valueA, valueB, criticalValue, closeGapType) { return ((closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_BELOW && valueA <= criticalValue && valueB <= criticalValue) || (closeGapType == ContourLineDetection.CLOSE_GAP_TYPE_ABOVE && valueA >= criticalValue && valueB >= criticalValue)); } /** * Test if a given numeric value (`curValue`) is between the given values `valA` and `valB`. * Value A and B don't need to be in ascending order, so `valA <= curValue <= valB` and `valB <= curvalue <= valA` * will do the job. * * @param {number} valA - The first of the two bounds. * @param {number} valB - The second of the two bounds. * @param {number} curValue - The value to check if it is between (or equal) to the given bounds. * @returns {boolean} */ isBetween(valA, valB, curValue) { return (valA <= curValue && curValue <= valB) || (valB <= curValue && curValue <= valA); } /** * Get a 'lerp' value - which is some sort of percentage/ratio for the `curValue` inside the * range of the given interval `[valA ... valB]`. * * Examples: * * getLerpRatio(0,100,50) === 0.5 * * getLerpRatio(50,100,75) === 0.5 * * getLerpRatio(0,100,0) === 0.0 * * getLerpRatio(0,100,100) === 1.0 * * getLerpRatio(0,100,-50) === -0.5 * * @param {number} valA * @param {number} valB * @param {number} curValue * @returns */ getLerpRatio(valA, valB, curValue) { return (curValue - valA) / (valB - valA); } /** * Helper function to lerp a numeric value. * * @param {number} min - The min (start) value. Doesn't necesarily need to be the smaller one. * @param {number} max - The max (end) value. Doesn't necesarily need to be the larger one. * @param {number} ratio - The lerp ratio; usually a value between 0.0 and 1.0, but other values a valid for linear interpolation, too. * @returns {number} */ lerp(min, max, ratio) { return min + (max - min) * ratio; } } ContourLineDetection.CLOSE_GAP_TYPE_NONE = 0; ContourLineDetection.CLOSE_GAP_TYPE_ABOVE = 1; ContourLineDetection.CLOSE_GAP_TYPE_BELOW = 2; //# sourceMappingURL=ContourLineDetection.js.map