UNPKG

poly-simplify

Version:
1,880 lines (1,478 loc) 60.1 kB
/** * split compound paths into * sub path data array */ function splitSubpaths(pathData) { let subPathArr = []; try { let subPathIndices = pathData.map((com, i) => (com.type.toLowerCase() === 'm' ? i : -1)).filter(i => i !== -1); } catch { console.log('catch', pathData); } let subPathIndices = pathData.map((com, i) => (com.type.toLowerCase() === 'm' ? i : -1)).filter(i => i !== -1); // no compound path if (subPathIndices.length === 1) { return [pathData] } subPathIndices.forEach((index, i) => { subPathArr.push(pathData.slice(index, subPathIndices[i + 1])); }); return subPathArr; } function parsePathNorm(d) { return pathDataToLonghands(pathDataToAbsolute(parse(d))); } function pathDataToPoly(pathData) { let pts = [{ x: pathData[0].values[0], y: pathData[0].values[1] }]; for (let i = 1, l = pathData.length; i < l; i++) { let { values } = pathData[i]; if (values.length) { let valsLast = values.slice(-2); pts.push({ x: valsLast[0], y: valsLast[1] }); } } return pts; } /* export function pathDataToPoly(d) { let pathData = pathDataToLonghands(pathDataToAbsolute(parse(d))); let pts = [{ x: pathData[0].values[0], y: pathData[0].values[1] }]; for (let i = 1, l = pathData.length; i < l; i++) { let { values } = pathData[i]; if (values.length) { let valsLast = values.slice(-2); pts.push({ x: valsLast[0], y: valsLast[1] }) } } return pts; } */ function parse(path, debug = true) { const paramCounts = { // Move (absolute & relative) 0x4D: 2, 0x6D: 2, // Arc 0x41: 7, 0x61: 7, // Cubic Bézier 0x43: 6, 0x63: 6, // Horizontal Line 0x48: 1, 0x68: 1, // Line To 0x4C: 2, 0x6C: 2, // Quadratic Bézier 0x51: 4, 0x71: 4, // Smooth Cubic Bézier 0x53: 4, 0x73: 4, // Smooth Quadratic Bézier 0x54: 2, 0x74: 2, // Vertical Line 0x56: 1, 0x76: 1, // Close Path 0x5A: 0, 0x7A: 0 }; let commandSet = new Set([...Object.keys(paramCounts).map(Number)]); const SPECIAL_SPACES = new Set([ 0x1680, 0x180E, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x202F, 0x205F, 0x3000, 0xFEFF ]); function isSpace(ch) { return (ch === 0x0A) || (ch === 0x0D) || (ch === 0x2028) || (ch === 0x2029) || // Line terminators // White spaces or comma (ch === 0x002C) || (ch === 0x20) || (ch === 0x09) || (ch === 0x0B) || (ch === 0x0C) || (ch === 0xA0) || (ch >= 0x1680 && SPECIAL_SPACES.has(ch) >= 0); } function isCommandType(code) { return commandSet.has(code); } let i = 0, len = path.length; let lastCommand = ""; let pathData = []; let itemCount = -1; let val = ''; let wasE = false; let wasSpace = false; let floatCount = 0; let valueIndex = 0; let maxParams = 0; let needsNewSegment = false; let log = []; let feedback; const addSeg = () => { // Create new segment if needed before adding the minus sign if (needsNewSegment) { // sanitize implicit linetos if (lastCommand === 'M') lastCommand = 'L'; else if (lastCommand === 'm') lastCommand = 'l'; pathData.push({ type: lastCommand, values: [] }); itemCount++; valueIndex = 0; needsNewSegment = false; } }; const pushVal = (checkFloats = false) => { // regular value or float if (!checkFloats ? val !== '' : floatCount > 0) { // error: no first command if (debug && itemCount === -1) { feedback = 'Pathdata must start with M command'; log.push(feedback); // add M command to collect subsequent errors lastCommand = 'M'; pathData.push({ type: lastCommand, values: [] }); maxParams = 2; valueIndex = 0; itemCount++; } if (lastCommand === 'A' || lastCommand === 'a') { val = sanitizeArc(); pathData[itemCount].values.push(...val); } else { // error: leading zeroes if (debug && val[1] && val[1] !== '.' && val[0] === '0') { feedback = 'Leading zeros not valid: ' + val; log.push(feedback); } pathData[itemCount].values.push(+val); } valueIndex++; val = ''; floatCount = 0; // Mark that a new segment is needed if maxParams is reached needsNewSegment = valueIndex >= maxParams; } }; const sanitizeArc = () => { let valLen = val.length; let arcSucks = false; // large arc and sweep if (valueIndex === 3 && valLen === 2) { val = [+val[0], +val[1]]; arcSucks = true; valueIndex++; } // sweep and final else if (valueIndex === 4 && valLen > 1) { val = [+val[0], +val[1]]; arcSucks = true; valueIndex++; } // large arc, sweep and final pt combined else if (valueIndex === 3 && valLen >= 3) { val = [+val[0], +val[1], +val.substring(2)]; arcSucks = true; valueIndex += 2; } return !arcSucks ? [+val] : val; }; const validateCommand = () => { if (debug) { let lastCom = itemCount > 0 ? pathData[itemCount] : 0; let valLen = lastCom ? lastCom.values.length : 0; if ((valLen && valLen < maxParams) || (valLen && valLen > maxParams) || ((lastCommand === 'z' || lastCommand === 'Z') && valLen > 0)) { let diff = maxParams - valLen; feedback = `Pathdata commands in "${lastCommand}" (segment index: ${itemCount}) don't match allowed number of values: ${diff}/${maxParams}`; log.push(feedback); } } }; while (i < len) { let char = path[i]; let charCode = path.charCodeAt(i); // New command if (isCommandType(charCode)) { // command is concatenated without whitespace if (val !== '') { pathData[itemCount].values.push(+val); valueIndex++; val = ''; } // check if previous command was correctly closed validateCommand(); // new command type lastCommand = char; maxParams = paramCounts[charCode]; let isM = lastCommand === 'M' || lastCommand === 'm'; let wasClosePath = itemCount > 0 && (pathData[itemCount].type === 'z' || pathData[itemCount].type === 'Z'); // add omitted M command after Z if (wasClosePath && !isM) { pathData.push({ type: 'm', values: [0, 0] }); itemCount++; } pathData.push({ type: lastCommand, values: [] }); itemCount++; // reset counters wasSpace = false; floatCount = 0; valueIndex = 0; needsNewSegment = false; i++; continue; } // Separated by White space if (isSpace(charCode)) { // push value pushVal(); wasSpace = true; wasE = false; i++; continue; } // if last else if (i === len - 1) { val += char; // push value pushVal(); wasSpace = false; wasE = false; validateCommand(); break; } // minus or float separated if ((!wasE && !wasSpace && charCode === 0x2D) || (!wasE && charCode === 0x2E) ) { // checkFloats changes condition for value adding let checkFloats = charCode === 0x2E; // new val pushVal(checkFloats); // new segment addSeg(); // concatenated floats if (checkFloats) { floatCount++; } } // regular splitting else { addSeg(); } val += char; // e/scientific notation in value wasE = (charCode === 0x45 || charCode === 0x65); wasSpace = false; i++; } validateCommand(); pathData[0].type = 'M'; // return error log if (debug && log.length) { feedback = 'Invalid path data:\n' + log.join('\n'); if (debug === 'log') { console.warn(feedback); } else { throw new Error(feedback) } } return pathData } /** * convert pathData to * This is just a port of Dmitry Baranovskiy's * pathToRelative/Absolute methods used in snap.svg * https://github.com/adobe-webplatform/Snap.svg/ */ function pathDataToAbsoluteOrRelative(pathData, toRelative = false) { let M = pathData[0].values; let x = M[0], y = M[1], mx = x, my = y; for (let i = 1, len = pathData.length; i < len; i++) { let com = pathData[i]; let { type, values } = com; let newType = toRelative ? type.toLowerCase() : type.toUpperCase(); if (type !== newType) { type = newType; com.type = type; switch (type) { case "a": case "A": values[5] = toRelative ? values[5] - x : values[5] + x; values[6] = toRelative ? values[6] - y : values[6] + y; break; case "v": case "V": values[0] = toRelative ? values[0] - y : values[0] + y; break; case "h": case "H": values[0] = toRelative ? values[0] - x : values[0] + x; break; case "m": case "M": if (toRelative) { values[0] -= x; values[1] -= y; } else { values[0] += x; values[1] += y; } mx = toRelative ? values[0] + x : values[0]; my = toRelative ? values[1] + y : values[1]; break; default: if (values.length) { for (let v = 0; v < values.length; v++) { values[v] = toRelative ? values[v] - (v % 2 ? y : x) : values[v] + (v % 2 ? y : x); } } } } let vLen = values.length; switch (type) { case "z": case "Z": x = mx; y = my; break; case "h": case "H": x = toRelative ? x + values[0] : values[0]; break; case "v": case "V": y = toRelative ? y + values[0] : values[0]; break; case "m": case "M": mx = values[vLen - 2] + (toRelative ? x : 0); my = values[vLen - 1] + (toRelative ? y : 0); default: x = values[vLen - 2] + (toRelative ? x : 0); y = values[vLen - 1] + (toRelative ? y : 0); } } pathData[0].type = 'M'; pathData = pathData.map(com=>{return {type:com.type, values:com.values.map(val=>+val.toFixed(9))} }); return pathData; } function pathDataToRelative(pathData) { return pathDataToAbsoluteOrRelative(pathData, true) } function pathDataToAbsolute(pathData) { return pathDataToAbsoluteOrRelative(pathData, false) } /** * decompose/convert shorthands to "longhand" commands: * H, V, S, T => L, L, C, Q * reversed method: pathDataToShorthands() */ function pathDataToLonghands(pathData) { let pathDataLonghand = [{ type: "M", values: pathData[0].values }]; let comPrev = pathDataLonghand[0]; for (let i = 1, len = pathData.length; i < len; i++) { let com = pathData[i]; let { type, values } = com; let valuesL = values.length; let valuesPrev = comPrev.values; let valuesPrevL = valuesPrev.length; let [x, y] = [values[valuesL - 2], values[valuesL - 1]]; let [prevX, prevY] = [ valuesPrev[valuesPrevL - 2], valuesPrev[valuesPrevL - 1] ]; switch (type) { case "H": comPrev = { type: "L", values: [values[0], prevY] }; break; case "V": comPrev = { type: "L", values: [prevX, values[0]] }; break; case "z": case "Z": comPrev = { type: "Z", values: [] }; break; case "M": comPrev = { type: "M", values: [values[0], values[1]] }; break; default: comPrev = { type: 'L', values: [x, y] }; } pathDataLonghand.push(comPrev); } return pathDataLonghand; } // round pathData function minifyPathData(pathData, decimals = -1, toRelative = false, toShorthands = false) { if (toShorthands) pathData = pathDataToShorthands(pathData); if (toRelative) pathData = pathDataToRelative(pathData); if (decimals > -1) { pathData = pathData.map(com => { return { type: com.type, values: com.values.map(val => +val.toFixed(decimals)) } }); } return pathData; } /** * Serialize pathData array to a minified "d" attribute string. */ function pathDataToD(pathData, optimize = 1) { pathData = JSON.parse(JSON.stringify(pathData)); let beautify = optimize > 1; let minify = beautify || optimize===0 ? false : true; // Convert first "M" to "m" if followed by "l" (when minified) if (pathData[1].type === "l" && minify) { pathData[0].type = "m"; } let d = ''; if (beautify) { d = `${pathData[0].type} ${pathData[0].values.join(" ")}\n`; } else { d = `${pathData[0].type}${pathData[0].values.join(" ")}`; } for (let i = 1, len = pathData.length; i < len; i++) { let com0 = pathData[i - 1]; let com = pathData[i]; let { type, values } = com; // Minify Arc commands (A/a) – actually sucks! if (minify && (type === 'A' || type === 'a')) { values = [ values[0], values[1], values[2], `${values[3]}${values[4]}${values[5]}`, values[6] ]; } // Omit type for repeated commands type = (com0.type === com.type && com.type.toLowerCase() !== 'm' && minify) ? " " : ( (com0.type === "m" && com.type === "l") || (com0.type === "M" && com.type === "l") || (com0.type === "M" && com.type === "L") ) && minify ? " " : com.type; // concatenate subsequent floating point values if (minify) { let valsString = ''; let prevWasFloat = false; for (let v = 0, l = values.length; v < l; v++) { let val = values[v]; let valStr = val.toString(); let isFloat = valStr.includes('.'); let isSmallFloat = isFloat && Math.abs(val) < 1; // Remove leading zero from small floats *only* if the previous was also a float if (isSmallFloat && prevWasFloat) { valStr = valStr.replace(/^0\./, '.'); } // Add space unless this is the first value OR previous was a small float if (v > 0 && !(prevWasFloat && isSmallFloat)) { valsString += ' '; } valsString += valStr; prevWasFloat = isSmallFloat; } d += `${type}${valsString}`; } // regular non-minified output else { if (beautify) { d += `${type} ${values.join(' ')}\n`; } else { d += `${type}${values.join(' ')}`; } } } if (minify) { d = d // Space before small decimals .replace(/ 0\./g, " .") // Remove space before negatives .replace(/ -/g, "-") // Remove leading zero from negative decimals .replace(/-0\./g, "-.") // Convert uppercase 'Z' to lowercase .replace(/Z/g, "z"); } return d; } /** * apply shorthand commands if possible * L, L, C, Q => H, V, S, T * reversed method: pathDataToLonghands() */ function pathDataToShorthands(pathData) { let pathDataShorts = [{ type: "M", values: pathData[0].values }]; let comShort = pathDataShorts[0]; let p0 = { x: pathData[0].values[0], y: pathData[0].values[1] }; let p; let tolerance = 0.01; for (let i = 1, len = pathData.length; i < len; i++) { let com = pathData[i]; let { type, values } = com; let valuesLast = values.length ? values.slice(-2) : []; p = { x: valuesLast[0], y: valuesLast[1] }; let w = Math.abs(p.x - p0.x); let h = Math.abs(p.y - p0.y); let thresh = (w + h) / 2 * tolerance; switch (type) { case "L": // H if (h === 0 || (h < thresh && w > thresh)) { comShort = {type: "H",values: [values[0]]}; } // V else if (w === 0 || (h > thresh && w < thresh)) { comShort = {type: "V",values: [values[1]]}; } else { comShort = com; } break; case 'M': case 'Z': case 'z': comShort = {type ,values: valuesLast}; break; default: comShort = { type: 'L', values: valuesLast }; } p0 = { x: valuesLast[0], y: valuesLast[1] }; pathDataShorts.push(comShort); } return pathDataShorts; } function normalizePointInput(pts) { if (!pts || !pts.length) return []; // convert to point object array helper const toPointArray = (pts) => { let ptArr = []; for (let i = 1, l = pts.length; i < l; i += 2) { ptArr.push({ x: pts[i - 1], y: pts[i] }); } return ptArr; }; /** * 1. check if input is already * a point object array */ let isPointArray = pts[0].x || false; // 1.1 check if point object array but tied to an API constructor e.g SVGPoint let hasConstructor = isPointArray && pts.length > 0 && typeof pts[0] === 'object' && pts[0].constructor !== Object; const decoupleFromConstructor = (pts) => { let len = pts.length; let ptArr = new Array(len); for (let i = 0; i < len; i++) { ptArr[i] = { x: pts[i].x, y: pts[i].y }; } return ptArr; }; // decouple from constructor object type - e.g SVGPoints if (hasConstructor) decoupleFromConstructor(pts); // normalized return array if (isPointArray) { return pts; } /** * 2. input is string - * e.g from polygon points attribute */ let isString = typeof pts === "string"; // is SVG path data let isPathData = isString ? (pts.startsWith('M') || pts.startsWith('m')) : false; let isCompound = false; if (isPathData) { // check if plugin is installed if (typeof pathDataToPoly !== 'function') { console.warn('path to point parser is not installed'); return [{ x: 0, y: 0 }]; } // check compoundPath let pathData = parsePathNorm(pts); let suPaths = splitSubpaths(pathData); isCompound = suPaths.length > 1; let ptArr = []; if (isCompound) { suPaths.forEach(pathData => { let ptsSub = pathDataToPoly(pathData); ptArr.push(ptsSub); }); } else { ptArr = pathDataToPoly(pathData); } return ptArr } // 2.1 check if it's JSON let isJSON = isString ? pts.startsWith('{') || pts.startsWith('[') : false; function fixJsObjectString(str) { return str.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); } // 2.1.1: if JSON – parse data if (isJSON) { try { pts = JSON.parse(pts); } catch { // convert to point array pts = JSON.parse(fixJsObjectString(pts)); } isString = false; } // 2.2: stringified poly notation – split to array if (isString) { pts = pts.trim().split(/,| /).filter(Boolean).map(Number); // 2.3: nonsense string input let hasNaN = pts.filter(pt => isNaN(pt)).length; if (hasNaN) { console.warn("input doesn't contain point data – please, check your input structure for syntax errors"); return []; } } /** * 3. is array * either a flat or a nested one */ let isArray = Array.isArray(pts); // 3.1: is nested array – x/y grouped in sub arrays let isNested = isArray && pts[0].length === 2; // has su polys let isCompoundPoly = !isNested && isArray && Array.isArray(pts[0]); // grouped in x/y pairs let isCompoundPolyNested = isCompoundPoly && pts[0][0].length === 2; // flat point value array let isCompoundPolyFlat = isCompoundPoly && !isCompoundPolyNested && pts[0].length > 2 && !pts[0][0].hasOwnProperty('x'); /* let isCompoundPolyObj = isCompoundPoly && !isCompoundPolyNested && pts[0].length>2 && pts[0][0].hasOwnProperty('x'); console.log('isCompoundPolyObj', isCompoundPolyObj, 'isCompoundPolyFlat', isCompoundPolyFlat, 'isCompoundPolyNested', isCompoundPolyNested, isCompoundPoly, isNested); */ if (isCompoundPolyFlat || isCompoundPolyNested) { let ptsN = []; pts.forEach(sub => { let pts = isCompoundPolyFlat ? toPointArray(sub) : sub.map((pt) => { return { x: pt[0], y: pt[1] }; }); ptsN.push(pts); }); pts = ptsN; } // convert to point array else if (isNested) { pts = pts.map((pt) => { return { x: pt[0], y: pt[1] }; }); } // 3.2: flat array – group x/y let isFlat = !Array.isArray(pts[0]) && !pts[0].hasOwnProperty('x'); if (isFlat) pts = toPointArray(pts); return pts; } function getSquareDistance(p1, p2) { return (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2 } function detectRegularPolygon(pts) { let lastDist = getSquareDistance(pts[0], pts[1]); let isRegular = true; for (let i = 3, l = pts.length; i < l; i++) { let pt1 = pts[i - 1]; let pt2 = pts[i]; let dist = getSquareDistance(pt1, pt2); let distDiff = 100 / lastDist * Math.abs(lastDist - dist); if (distDiff > 0.1) { return false; } lastDist = dist; } return isRegular; } /** * reorder vertices to * avoid mid points on colinear segments */ function sortPolygonLeftTopFirst(pts, isPolygon=null) { if (pts.length === 0) return pts; isPolygon = isPolygon===null ? isClosedPolygon(pts) : isPolygon; if(!isPolygon) return pts; let firstIndex = 0; for (let i = 1,l=pts.length; i < l; i++) { let current = pts[i]; let first = pts[firstIndex]; if (current.x < first.x || (current.x === first.x && current.y < first.y)) { firstIndex = i; } } let ptsN = pts.slice(firstIndex).concat(pts.slice(0, firstIndex)); return ptsN; } /** * check whether a polygon is likely * to be closed * or an open polyline */ function isClosedPolygon(pts, reduce=24){ let ptsR = reducePoints$1(pts, reduce); let { width, height } = getPolyBBox$1(ptsR); let dimAvg = Math.max(width, height); let closingThresh = (dimAvg / pts.length) ** 2; let closingDist = getSquareDistance(pts[0], pts[pts.length - 1]); return closingDist < closingThresh; } /** * reduce polypoints * for sloppy dimension approximations */ function reducePoints$1(points, maxPoints = 48) { if (!Array.isArray(points) || points.length <= maxPoints) return points; // Calculate how many points to skip between kept points let len = points.length; let step = len / maxPoints; let reduced = []; for (let i = 0; i < maxPoints; i++) { reduced.push(points[Math.floor(i * step)]); } let lenR = reduced.length; // Always include the last point to maintain path integrity if (reduced[lenR - 1] !== points[len - 1]) { reduced[lenR - 1] = points[len - 1]; } return reduced; } function getPolygonArea(points, absolute = true) { let area = 0; for (let i = 0, len = points.length; len && i < len; i++) { let addX = points[i].x; let addY = points[i === points.length - 1 ? 0 : i + 1].y; let subX = points[i === points.length - 1 ? 0 : i + 1].x; let subY = points[i].y; area += addX * addY * 0.5 - subX * subY * 0.5; } return absolute ? Math.abs(area) : area; } function getPolyBBox$1(vertices) { let xArr = vertices.map(pt => pt.x); let yArr = vertices.map(pt => pt.y); let left = Math.min(...xArr); let right = Math.max(...xArr); let top = Math.min(...yArr); let bottom = Math.max(...yArr); let bb = { x: left, left: left, right: right, y: top, top: top, bottom: bottom, width: right - left, height: bottom - top }; return bb; } function scalePolygon(pts, scale = 1, translateX = 0, translateY = 0, alignToZero = false, scaleToWidth = 0, scaleToHeight = 0) { if (scale === 1 && scaleToWidth === 0 && scaleToHeight === 0 && translateX === 0 && translateY === 0 && alignToZero === false) return pts; let x, y, width, height; let isCompound = Array.isArray(pts[0]); let ptsArr = isCompound ? pts : [pts]; let ptsFlat = isCompound ? pts.flat() : pts; ({ x, y, width, height } = getPolyBBox$1(ptsFlat)); ptsArr.forEach((pts,p) => { scale = scaleToWidth ? scaleToWidth / width : (scaleToHeight ? scaleToHeight / height : scale); // if both are defined - adjust to fit in box max dimension if (scaleToHeight) { if (height * scale > scaleToHeight) { scale = scaleToHeight / height; } } if (alignToZero) { translateX = -x; translateY = -y; } for (let i = 0, l = pts.length; i < l; i++) { let pt = pts[i]; ptsArr[p][i] = { x: (pt.x + translateX) * scale, y: (pt.y + translateY) * scale }; } }); return ptsArr ; } /** * unite self intersecting polygons * based on J. Holmes's answer * https://stackoverflow.com/a/10673515/15015675 */ function unitePolygon(poly) { const getSelfIntersections = (pts, pt0, pt1) => { const getLineIntersection = (pt0, pt1, pt2, pt3) => { let [x1, x2, x3, x4] = [pt0.x, pt1.x, pt2.x, pt3.x]; let [y1, y2, y3, y4] = [pt0.y, pt1.y, pt2.y, pt3.y]; // get x/y deltas let [dx1, dx2] = [x1 - x2, x3 - x4]; let [dy1, dy2] = [y1 - y2, y3 - y4]; // Calculate the denominator of the intersection point formula (cross product) let denominator = dx1 * dy2 - dy1 * dx2; // denominator === 0: lines are parallel - no intersection if (denominator === 0) return null; // Cross products of the endpoints let cross1 = x1 * y2 - y1 * x2; let cross2 = x3 * y4 - y3 * x4; let x = (cross1 * dx2 - dx1 * cross2) / denominator; let y = (cross1 * dy2 - dy1 * cross2) / denominator; // Check if the x and y coordinates are within both lines boundaries if ( x < Math.min(x1, x2) || x > Math.max(x1, x2) || x < Math.min(x3, x4) || x > Math.max(x3, x4) || y < Math.min(y1, y2) || y > Math.max(y1, y2) || y < Math.min(y3, y4) || y > Math.max(y3, y4) ) { return null; } return { x, y }; }; const squaredDist = (p1, p2) => { return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2; }; const len = pts.length; // collect intersections let intersections = []; let segLenSq = squaredDist(pt0, pt1); let thresh = segLenSq / 1000; for (let i = 0; i < len; i++) { let pt2 = pts[i]; let pt3 = pts[(i + 1) % len]; // Skip if this is the segment itself if (pt3 === pt1) continue; let intersectionPoint = getLineIntersection(pt0, pt1, pt2, pt3); if (intersectionPoint) { const lengthSq = squaredDist(pt0, intersectionPoint); if (lengthSq > thresh && lengthSq < segLenSq) { intersections.push({ pt0, pt1, startPoint: pt2, intersectionPoint, endPoint: pt3, lengthSq }); } } } intersections.sort((a, b) => a.lengthSq - b.lengthSq); return intersections; }; const len = poly.length; if (len < 3) return poly; // Set up next indices once for (let i = 0; i < len; i++) { poly[i].next = (i + 1) % len; } const newPoly = []; let currentPoint = poly[0]; let nextPoint = poly[currentPoint.next]; newPoly.push(currentPoint); for (let i = 0; i < len * 2; i++) { const intersections = getSelfIntersections(poly, currentPoint, nextPoint); if (intersections.length === 0) { newPoly.push(nextPoint); currentPoint = nextPoint; nextPoint = poly[nextPoint.next]; } else { const closest = intersections[0]; currentPoint = closest.intersectionPoint; nextPoint = closest.endPoint; newPoly.push(currentPoint); } // Closed loop detection — same position, not necessarily same object if ( newPoly.length > 2 && currentPoint.x === newPoly[0].x && currentPoint.y === newPoly[0].y ) { break; } } // Remove closing duplicate point if present const first = newPoly[0]; const last = newPoly[newPoly.length - 1]; if (first.x === last.x && first.y === last.y) { newPoly.pop(); } return newPoly; } function pointsToMercator(pts) { if(!isGeoData(pts)) return pts; const degToRad = (deg) => deg * (Math.PI / 180); const mercatorProject = (pt) => { let lat = Math.max(-85.05112878, Math.min(85.05112878, pt.y)); let lon = pt.x; return { x: (degToRad(lon) + Math.PI) / (2 * Math.PI), y: (Math.PI - Math.log(Math.tan(Math.PI / 4 + degToRad(lat) / 2))) / (2 * Math.PI), }; }; let len = pts.length; /** * get all projected points */ let ptsP = []; for(let i=0; i<len; i++){ ptsP.push(mercatorProject(pts[i])); } // scale and translate let bbO = getPolyBBox$1(pts); let bb = getPolyBBox$1(ptsP); let scale = bbO.width/bb.width; let offsetX = (bb.x*scale-bbO.x); let offsetY = (bb.y*scale-bbO.y); for(let i=0; i<len; i++){ ptsP[i].x = ptsP[i].x * scale -offsetX; ptsP[i].y = ptsP[i].y * scale -offsetY; } return ptsP; } /** * check if data input * is geodata based */ function isGeoData(pts) { const isValidCoord = (lon, lat) =>{ return (lon >= -180 && lon <= 180 && lat >= -90 && lat <= 90); }; let samplePoints = [ pts[0], pts[Math.floor(pts.length / 2)], pts[pts.length - 1], ]; return samplePoints.every(p => { return isValidCoord(p.x, p.y); }); } // output helper function getOutputData(polyArr, polyArrSimpl, outputFormat = 'points', meta = false, decimals = -1, toRelative = false, toShorthands = false, minifyString = false, scale = 1, translateX = 0, translateY = 0, alignToZero = false, scaleToWidth = 0, scaleToHeight = 0, isCompound=false) { let outputObj = { data: [], ptsArr: [], countOriginal: 0, count: 0, areaOriginal: 0, areaptsSmp: 0, areaDiff: 0, isPolygon: [] }; /** * scale points * useful for tiny polygons */ polyArrSimpl = scalePolygon(polyArrSimpl, scale, translateX, translateY, alignToZero, scaleToWidth, scaleToHeight); for (let i = 0, l = polyArrSimpl.length; i < l; i++) { // original points let pts = polyArr[i]; let ptsSmp = polyArrSimpl[i]; // original vertices count let total = pts.length; outputObj.countOriginal += total; // simplified vertices count let totalSmpl = ptsSmp.length; outputObj.count += totalSmpl; outputObj.ptsArr.push(ptsSmp); let isPolygon = false; // check if closed if (meta) { let ptsR = reducePoints$1(pts, 32); let { width, height } = getPolyBBox$1(ptsR); let dimAvg = Math.max(width, height); let closingThresh = (dimAvg / pts.length) ** 2; let closingDist = getSquareDistance(pts[0], pts[pts.length - 1]); isPolygon = closingDist < closingThresh; outputObj.isPolygon.push(isPolygon); } } /** * approximate minimum * floating point precision * to prevent distortions */ if(decimals>-1 && decimals<=3){ let polySimplFlat = polyArrSimpl.flat(); let polyAppr = reducePoints$1(polySimplFlat, 24); let { width, height } = getPolyBBox$1(polyAppr); let dimAvg = (width + height) / 2; if(dimAvg>500) { decimals=0; }else { let complexity = polySimplFlat.length/dimAvg; let ratLength = dimAvg / 1000; let decimalsMinLen = Math.ceil(1 / ratLength).toString().length; let decimalsMinCompl = Math.ceil(complexity).toString().length; let decimalsMin = Math.ceil((decimalsMinLen+decimalsMinCompl)/2); decimals = decimals > -1 && decimals < decimalsMin ? decimalsMin : decimals; } } /** * compile output */ outputFormat = outputFormat ? outputFormat.toLowerCase() : 'points'; switch (outputFormat) { case 'points': case 'pointstring': case 'pointsnested': case 'json': // round coordinates if(decimals>-1){ outputObj.ptsArr = outputObj.ptsArr.map(pts => pts.map(pt => { return { x: +pt.x.toFixed(decimals), y: +pt.y.toFixed(decimals) } } )); } if (outputFormat === 'pointstring') { outputObj.data = outputObj.ptsArr.map(pts => pts.map(pt => `${pt.x} ${pt.y}`).join(' ')); } else if (outputFormat === 'points') { if(!isCompound) { outputObj.ptsArr = outputObj.ptsArr[0]; } outputObj.data = outputObj.ptsArr; } else if (outputFormat === 'pointsnested') { outputObj.data = outputObj.ptsArr.map(pts => pts.map(pt => [pt.x, pt.y])); if(!isCompound) { outputObj.data = outputObj.data[0]; outputObj.ptsArr = outputObj.ptsArr[0]; } } else if (outputFormat === 'json') { if(!isCompound) outputObj.ptsArr = outputObj.ptsArr[0]; outputObj.data = JSON.stringify(outputObj.ptsArr); } break; case 'pathdata': case 'path': let pathDataCompound = []; outputObj.ptsArr.forEach((pts, i) => { let pathData = [ { type: 'M', values: [pts[0].x, pts[0].y] }, ...pts.slice(1).map(pt => { return { type: 'L', values: [pt.x, pt.y] } }) ]; // add close path if (outputObj.isPolygon[i]) { pathData.push({ type: 'Z', values: [] }); } pathDataCompound.push(...pathData); }); // minify/optimize pathDataCompound = minifyPathData(pathDataCompound, decimals, toRelative, toShorthands); if (outputFormat === 'path') { outputObj.data = [pathDataToD(pathDataCompound, (minifyString ? 1 : 0))]; if(!isCompound) outputObj.data = outputObj.data[0]; } else { outputObj.data = pathDataCompound; } } return outputObj } /** * "lossless" simplification: * remove zero length or * horizontal or vertical segments * geometry should be perfectly retained */ function simplifyRC(pts) { if (pts.length < 3) return pts; let ptsSmp = [pts[0]]; let pt0 = pts[0]; let pt1, pt2; let ptL = pts[pts.length-1]; let tolerance = 1; // First pass: Remove colinear points and collect triangle areas for (let i = 2, l = pts.length; i < l; i++) { pt1 = pts[i - 1]; pt2 = pts[i]; // Skip zero-length segments if (pt1.x === pt2.x && pt1.y === pt2.y) continue; // Check for vertical/horizontal segments let isVertical = (pt0.x === pt1.x); let isHorizontal = (pt0.y === pt1.y); if (isVertical || isHorizontal) { let nextVertical = (pt1.x === pt2.x); let nextHorizontal = (pt1.y === pt2.y); if (!(nextVertical && isVertical) && !(nextHorizontal && isHorizontal)) { ptsSmp.push(pt1); } // add last point if(i === l - 1){ ptsSmp.push(pt2); } pt0 = pt1; continue; } // Cross product check for colinearity let dx0 = pt0.x - pt2.x; let dy0 = pt0.y - pt2.y; let dx1 = pt0.x - pt1.x; let dy1 = pt0.y - pt1.y; let cross = Math.abs(dx0 * dy1 - dy0 * dx1); // Dynamic tolerance based on segment length let squareDistance = getSquareDistance(pt1, pt2); tolerance = squareDistance / 50; if (cross > tolerance) { if (i === l - 1) { // last point ptsSmp.push(pt1, pt2); } else { ptsSmp.push(pt1); } } pt0 = pt1; } // first and last points coincide pt0 = ptsSmp[0]; if(pt0.x===ptL.x && pt0.y===ptL.y){ ptsSmp.pop(); } return ptsSmp; } /** * radialDistance simplification * sloppy but fast */ function simplifyRD(pts, quality = 0.9, width = 0, height = 0) { /** * switch between absolute or * quality based relative thresholds */ let isAbsolute = false; if (typeof quality === 'string') { let value = parseFloat(quality); isAbsolute = true; quality = value; } // nothing to do - exit if (pts.length < 4 || (!isAbsolute && quality) >= 1) return pts; let p0 = pts[0]; let pt; let ptsSmp = [p0]; // convert quality to squaredistance tolerance let tolerance = quality; if (!isAbsolute) { // quality to tolerance tolerance = 1 - quality; /** * approximate dimensions * adjust tolerance for * very small polygons e.g geodata */ if (!width && !height) { let polyS = reducePoints$1(pts, 12); ({ width, height } = getPolyBBox$1(polyS)); } // average side lengths let dimAvg = (width + height) / 2; let scale = dimAvg / 25; tolerance = (tolerance * (scale)) ** 2; if (quality > 0.5) tolerance /= 10; } for (let i = 1, l = pts.length; i < l; i++) { pt = pts[i]; let dist = getSquareDistance(p0, pt); if (dist > tolerance) { ptsSmp.push(pt); p0 = pt; } } // add last point - if not coinciding with first point if (p0.x !== pt.x && p0.y !== pt.y) { ptsSmp.push(pt); } return ptsSmp; } /** * Ramer-Douglas-Peucker-Algorithm * for polyline simplification * See also: * https://en.wikipedia.org/wiki/Ramer–Douglas–Peucker_algorithm * and https://karthaus.nl/rdp/ */ function simplifyRDP(pts, quality = 0.9, width = 0, height = 0) { /** * switch between absolute or * quality based relative thresholds */ let isAbsolute = false; if (typeof quality === 'string') { isAbsolute = true; quality = parseFloat(quality); } if (pts.length < 4 || (!isAbsolute && quality) >= 1) return pts; // convert quality to squaredistance tolerance let tolerance = quality; if (!isAbsolute) { tolerance = 1 - quality; // adjust for higher qualities if (quality > 0.5) tolerance /= 2; /** * approximate dimensions * adjust tolerance for * very small polygons e.g geodata */ if (!width && !height) { let polyS = reducePoints$1(pts, 12); ({ width, height } = getPolyBBox$1(polyS)); } // average side lengths let dimAvg = (width + height) / 2; let scale = dimAvg / 100; tolerance = (tolerance * (scale)) ** 2; } // Square distance from point to segment const segmentSquareDistance = (p, p1, p2) => { let x = p1.x, y = p1.y; let dx = p2.x - x, dy = p2.y - y; if (dx !== 0 || dy !== 0) { let t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy); if (t > 1) { x = p2.x; y = p2.y; } else if (t > 0) { x += dx * t; y += dy * t; } } return (p.x - x) ** 2 + (p.y - y) ** 2; }; // start collecting ptsSmp polyline let ptsSmp = [pts[0]]; // create processing stack let stack = []; stack.push([0, pts.length - 1]); while (stack.length > 0) { let [first, last] = stack.pop(); let maxDist = tolerance; let index = -1; // Find point with maximum distance for (let i = first + 1; i < last; i++) { let currentDist = segmentSquareDistance(pts[i], pts[first], pts[last]); if (currentDist > maxDist) { index = i; maxDist = currentDist; } } // If max distance > tolerance, split and process if (maxDist > tolerance) { stack.push([index, last]); stack.push([first, index]); } else { ptsSmp.push(pts[last]); } } return ptsSmp; } /** * Visvalingam-Whyatt * simplification */ function simplifyVW(pts, quality = 1, width = 0, height = 0) { /** * switch between absolute or * quality based relative thresholds */ let isAbsolute = false; if (typeof quality === 'string') { isAbsolute = true; quality = parseFloat(quality); } if (pts.length < 4 || (!isAbsolute && quality) >= 1) return pts; // no heap data - calculate let heap = initHeap(pts); if (!width && !height) { let polyS = reducePoints(pts, 12); ({ width, height } = getPolyBBox(polyS)); } let tolerance = quality; if (!isAbsolute) { // average side lengths let dimAvg = (width + height) / 2; let scale = dimAvg / 100; tolerance = ((1 - quality) * (scale)) ** 2; } const updateArea = (pts, index, heap) => { let pt = pts[index]; if (pt.prev === null || pt.next === null) return; let tri = [pts[pt.prev], pt, pts[pt.next]]; let area = getPolygonArea(tri); pt.area = area; if (pt.heapIndex !== undefined) { heap.update(pt.heapIndex, area); } else { pt.heapIndex = heap.push(area, index); } }; let maxArea = 0; let len = pts.length; while (heap.size() > 0) { const { area, index } = heap.pop(); const pt = pts[index]; if (area && area < maxArea) { pt.area = maxArea; } else { maxArea = area; } if (index !== 0) { pts[pt.prev].next = pt.next; updateArea(pts, pt.prev, heap); } if (index !== len - 1) { pts[pt.next].prev = pt.prev; updateArea(pts, pt.next, heap); } } let ptsS = []; for (let i = 0, l = pts.length; i < l; i++) { let pt = pts[i]; if (!pt.area || i === 0 || i === l - 1 || pt.area >= tolerance) { ptsS.push(pt); } } return ptsS } /** * get area data * for heap */ function initHeap(pts) { const heap = new MinHeap(); for (let i = 0, len = pts.length; i < len; i++) { // prev, current, next let i0 = i === 0 ? len - 1 : i - 1; let i1 = i; let i2 = i === len - 1 ? 0 : i + 1; let pt0 = pts[i0]; let pt1 = pts[i1]; let pt2 = pts[i2]; let area = i > 0 || i === len - 1 ? (pt1.area ? pt1.area : getPolygonArea([pt0, pt1, pt2])) : Infinity; pt1.prev = i0; pt1.index = i1; pt1.next = i2; pt1.area = area; pt1.heapIndex = i > 0 ? heap.push(area, i1) : 0; } return heap; } /** * minheap */ class MinHeap { constructor() { this.heap = []; this.indexMap = new Map(); } push(area, index) { const node = { area, index }; this.heap.push(node); const heapIndex = this.heap.length - 1; this.indexMap.set(index, heapIndex); this.bubbleUp(heapIndex); return heapIndex; } pop() { if (this.heap.length === 0) return null; const min = this.heap[0]; const last = this.heap.pop(); if (this.heap.length > 0) { this.heap[0] = last; this.indexMap.set(last.index, 0); this.bubbleDown(0); } this.indexMap.delete(min.index); return min; } update(heapIndex, newArea) { if ( typeof heapIndex !== 'number' || heapIndex < 0 || heapIndex >= this.heap.length ) return; const oldArea = this.heap[heapIndex].area; this.heap[heapIndex].area = newArea; if (newArea < oldArea) { this.bubbleUp(heapIndex); } else { this.bubbleDown(heapIndex); } } size() { return this.heap.length; } bubbleUp(index) { while (index > 0) { const parent = Math.floor((index - 1) / 2); if (this.heap[parent].area <= this.heap[index].area) break; this.swap(index, parent); index = parent; } } bubbleDown(index) { while (true) { const left = 2 * index + 1; const right = 2 * index + 2; let smallest = index; if ( left < this.heap.length && this.heap[left].area < this.heap[smallest].area ) { smallest = left; } if ( right < this.heap.length && this.heap[right].area < this.heap[smallest].area ) { smallest = right; } if (smallest === index) break; this.swap(index, smallest); index = smallest; } } swap(i, j) { [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]]; this.indexMap.set(this.heap[i].index, i); this.indexMap.set(this.heap[j].index, j); } } /* export function renderPoint( svg, coords, fill = "red", r = "1%", opacity = "1", title = '', render = true, id = "", className = "" ) { if (Array.isArray(coords)) { coords = { x: coords[0], y: coords[1] }; } let marker = `<circle class="${className}" opacity="${opacity}" id="${id}" cx="${coords.x}" cy="${coords.y}" r="${r}" fill="${fill}"> <title>${title}</title></circle>`; if (render) { svg.insertAdjacentHTML("beforeend", marker); } else { return marker; } } */ function simplifyToMax(pts, maxVertices = 0) { if (pts.length <= 3 || !maxVertices || pts.length<=maxVertices) return pts;