d3plus-shape
Version:
Fancy SVG shapes for visualizations
278 lines (265 loc) • 14.3 kB
JavaScript
function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); }
function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
function _iterableToArrayLimit(arr, i) { var _i = null == arr ? null : "undefined" != typeof Symbol && arr[Symbol.iterator] || arr["@@iterator"]; if (null != _i) { var _s, _e, _x, _r, _arr = [], _n = !0, _d = !1; try { if (_x = (_i = _i.call(arr)).next, 0 === i) { if (Object(_i) !== _i) return; _n = !1; } else for (; !(_n = (_s = _x.call(_i)).done) && (_arr.push(_s.value), _arr.length !== i); _n = !0); } catch (err) { _d = !0, _e = err; } finally { try { if (!_n && null != _i["return"] && (_r = _i["return"](), Object(_r) !== _r)) return; } finally { if (_d) throw _e; } } return _arr; } }
function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
import { extent, merge, range } from "d3-array";
import { polygonArea, polygonCentroid, polygonContains } from "d3-polygon";
import polygonInside from "./polygonInside.js";
import polygonRayCast from "./polygonRayCast.js";
import polygonRotate from "./polygonRotate.js";
import simplify from "./simplify.js";
import pointDistanceSquared from "./pointDistanceSquared.js";
// Algorithm constants
var aspectRatioStep = 0.5; // step size for the aspect ratio
var angleStep = 5; // step size for angles (in degrees); has linear impact on running time
var polyCache = {};
/**
@typedef {Object} LargestRect
@desc The returned Object of the largestRect function.
@property {Number} width The width of the rectangle
@property {Number} height The height of the rectangle
@property {Number} cx The x coordinate of the rectangle's center
@property {Number} cy The y coordinate of the rectangle's center
@property {Number} angle The rotation angle of the rectangle in degrees. The anchor of rotation is the center point.
@property {Number} area The area of the largest rectangle.
@property {Array} points An array of x/y coordinates for each point in the rectangle, useful for rendering paths.
*/
/**
@function largestRect
@author Daniel Smilkov [dsmilkov@gmail.com]
@desc An angle of zero means that the longer side of the polygon (the width) will be aligned with the x axis. An angle of 90 and/or -90 means that the longer side of the polygon (the width) will be aligned with the y axis. The value can be a number between -90 and 90 specifying the angle of rotation of the polygon, a string which is parsed to a number, or an array of numbers specifying the possible rotations of the polygon.
@param {Array} poly An Array of points that represent a polygon.
@param {Object} [options] An Object that allows for overriding various parameters of the algorithm.
@param {Number|String|Array} [options.angle = d3.range(-90, 95, 5)] The allowed rotations of the final rectangle.
@param {Number|String|Array} [options.aspectRatio] The ratio between the width and height of the rectangle. The value can be a number, a string which is parsed to a number, or an array of numbers specifying the possible aspect ratios of the final rectangle.
@param {Number} [options.maxAspectRatio = 15] The maximum aspect ratio (width/height) allowed for the rectangle. This property should only be used if the aspectRatio is not provided.
@param {Number} [options.minAspectRatio = 1] The minimum aspect ratio (width/height) allowed for the rectangle. This property should only be used if the aspectRatio is not provided.
@param {Number} [options.nTries = 20] The number of randomly drawn points inside the polygon which the algorithm explores as possible center points of the maximal rectangle.
@param {Number} [options.minHeight = 0] The minimum height of the rectangle.
@param {Number} [options.minWidth = 0] The minimum width of the rectangle.
@param {Number} [options.tolerance = 0.02] The simplification tolerance factor, between 0 and 1. A larger tolerance corresponds to more extensive simplification.
@param {Array} [options.origin] The center point of the rectangle. If specified, the rectangle will be fixed at that point, otherwise the algorithm optimizes across all possible points. The given value can be either a two dimensional array specifying the x and y coordinate of the origin or an array of two dimensional points specifying multiple possible center points of the rectangle.
@param {Boolean} [options.cache] Whether or not to cache the result, which would be used in subsequent calculations to preserve consistency and speed up calculation time.
@return {LargestRect}
*/
export default function (poly) {
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
if (poly.length < 3) {
if (options.verbose) console.error("polygon has to have at least 3 points", poly);
return null;
}
// For visualization debugging purposes
var events = [];
// User's input normalization
options = Object.assign({
angle: range(-90, 90 + angleStep, angleStep),
cache: true,
maxAspectRatio: 15,
minAspectRatio: 1,
minHeight: 0,
minWidth: 0,
nTries: 20,
tolerance: 0.02,
verbose: false
}, options);
var angles = options.angle instanceof Array ? options.angle : typeof options.angle === "number" ? [options.angle] : typeof options.angle === "string" && !isNaN(options.angle) ? [Number(options.angle)] : [];
var aspectRatios = options.aspectRatio instanceof Array ? options.aspectRatio : typeof options.aspectRatio === "number" ? [options.aspectRatio] : typeof options.aspectRatio === "string" && !isNaN(options.aspectRatio) ? [Number(options.aspectRatio)] : [];
var origins = options.origin && options.origin instanceof Array ? options.origin[0] instanceof Array ? options.origin : [options.origin] : [];
var cacheString;
if (options.cache) {
cacheString = merge(poly).join(",");
cacheString += "-".concat(options.minAspectRatio);
cacheString += "-".concat(options.maxAspectRatio);
cacheString += "-".concat(options.minHeight);
cacheString += "-".concat(options.minWidth);
cacheString += "-".concat(angles.join(","));
cacheString += "-".concat(origins.join(","));
if (polyCache[cacheString]) return polyCache[cacheString];
}
var area = Math.abs(polygonArea(poly)); // take absolute value of the signed area
if (area === 0) {
if (options.verbose) console.error("polygon has 0 area", poly);
return null;
}
// get the width of the bounding box of the original polygon to determine tolerance
var _extent = extent(poly, function (d) {
return d[0];
}),
_extent2 = _slicedToArray(_extent, 2),
minx = _extent2[0],
maxx = _extent2[1];
var _extent3 = extent(poly, function (d) {
return d[1];
}),
_extent4 = _slicedToArray(_extent3, 2),
miny = _extent4[0],
maxy = _extent4[1];
// simplify polygon
var tolerance = Math.min(maxx - minx, maxy - miny) * options.tolerance;
if (tolerance > 0) poly = simplify(poly, tolerance);
if (options.events) events.push({
type: "simplify",
poly: poly
});
// get the width of the bounding box of the simplified polygon
var _extent5 = extent(poly, function (d) {
return d[0];
});
var _extent6 = _slicedToArray(_extent5, 2);
minx = _extent6[0];
maxx = _extent6[1];
var _extent7 = extent(poly, function (d) {
return d[1];
});
var _extent8 = _slicedToArray(_extent7, 2);
miny = _extent8[0];
maxy = _extent8[1];
var boxWidth = maxx - minx,
boxHeight = maxy - miny;
// discretize the binary search for optimal width to a resolution of this times the polygon width
var widthStep = Math.min(boxWidth, boxHeight) / 50;
// populate possible center points with random points inside the polygon
if (!origins.length) {
// get the centroid of the polygon
var centroid = polygonCentroid(poly);
if (!isFinite(centroid[0])) {
if (options.verbose) console.error("cannot find centroid", poly);
return null;
}
if (polygonContains(poly, centroid)) origins.push(centroid);
var nTries = options.nTries;
// get few more points inside the polygon
while (nTries) {
var rndX = Math.random() * boxWidth + minx;
var rndY = Math.random() * boxHeight + miny;
var rndPoint = [rndX, rndY];
if (polygonContains(poly, rndPoint)) {
origins.push(rndPoint);
}
nTries--;
}
}
if (options.events) events.push({
type: "origins",
points: origins
});
var maxArea = 0;
var maxRect = null;
for (var ai = 0; ai < angles.length; ai++) {
var angle = angles[ai];
var angleRad = -angle * Math.PI / 180;
if (options.events) events.push({
type: "angle",
angle: angle
});
for (var i = 0; i < origins.length; i++) {
var origOrigin = origins[i];
// generate improved origins
var _polygonRayCast = polygonRayCast(poly, origOrigin, angleRad),
_polygonRayCast2 = _slicedToArray(_polygonRayCast, 2),
p1W = _polygonRayCast2[0],
p2W = _polygonRayCast2[1];
var _polygonRayCast3 = polygonRayCast(poly, origOrigin, angleRad + Math.PI / 2),
_polygonRayCast4 = _slicedToArray(_polygonRayCast3, 2),
p1H = _polygonRayCast4[0],
p2H = _polygonRayCast4[1];
var modifOrigins = [];
if (p1W && p2W) modifOrigins.push([(p1W[0] + p2W[0]) / 2, (p1W[1] + p2W[1]) / 2]); // average along with width axis
if (p1H && p2H) modifOrigins.push([(p1H[0] + p2H[0]) / 2, (p1H[1] + p2H[1]) / 2]); // average along with height axis
if (options.events) events.push({
type: "modifOrigin",
idx: i,
p1W: p1W,
p2W: p2W,
p1H: p1H,
p2H: p2H,
modifOrigins: modifOrigins
});
for (var _i2 = 0; _i2 < modifOrigins.length; _i2++) {
var origin = modifOrigins[_i2];
if (options.events) events.push({
type: "origin",
cx: origin[0],
cy: origin[1]
});
var _polygonRayCast5 = polygonRayCast(poly, origin, angleRad),
_polygonRayCast6 = _slicedToArray(_polygonRayCast5, 2),
_p1W = _polygonRayCast6[0],
_p2W = _polygonRayCast6[1];
if (_p1W === null || _p2W === null) continue;
var minSqDistW = Math.min(pointDistanceSquared(origin, _p1W), pointDistanceSquared(origin, _p2W));
var maxWidth = 2 * Math.sqrt(minSqDistW);
var _polygonRayCast7 = polygonRayCast(poly, origin, angleRad + Math.PI / 2),
_polygonRayCast8 = _slicedToArray(_polygonRayCast7, 2),
_p1H = _polygonRayCast8[0],
_p2H = _polygonRayCast8[1];
if (_p1H === null || _p2H === null) continue;
var minSqDistH = Math.min(pointDistanceSquared(origin, _p1H), pointDistanceSquared(origin, _p2H));
var maxHeight = 2 * Math.sqrt(minSqDistH);
if (maxWidth * maxHeight < maxArea) continue;
var aRatios = aspectRatios;
if (!aRatios.length) {
var minAspectRatio = Math.max(options.minAspectRatio, options.minWidth / maxHeight, maxArea / (maxHeight * maxHeight));
var maxAspectRatio = Math.min(options.maxAspectRatio, maxWidth / options.minHeight, maxWidth * maxWidth / maxArea);
aRatios = range(minAspectRatio, maxAspectRatio + aspectRatioStep, aspectRatioStep);
}
for (var a = 0; a < aRatios.length; a++) {
var aRatio = aRatios[a];
// do a binary search to find the max width that works
var left = Math.max(options.minWidth, Math.sqrt(maxArea * aRatio));
var right = Math.min(maxWidth, maxHeight * aRatio);
if (right * maxHeight < maxArea) continue;
if (options.events && right - left >= widthStep) events.push({
type: "aRatio",
aRatio: aRatio
});
while (right - left >= widthStep) {
var width = (left + right) / 2;
var height = width / aRatio;
var _origin = _slicedToArray(origin, 2),
cx = _origin[0],
cy = _origin[1];
var rectPoly = [[cx - width / 2, cy - height / 2], [cx + width / 2, cy - height / 2], [cx + width / 2, cy + height / 2], [cx - width / 2, cy + height / 2]];
rectPoly = polygonRotate(rectPoly, angleRad, origin);
var insidePoly = polygonInside(rectPoly, poly);
if (insidePoly) {
// we know that the area is already greater than the maxArea found so far
maxArea = width * height;
rectPoly.push(rectPoly[0]);
maxRect = {
area: maxArea,
cx: cx,
cy: cy,
width: width,
height: height,
angle: -angle,
points: rectPoly
};
left = width; // increase the width in the binary search
} else {
right = width; // decrease the width in the binary search
}
if (options.events) events.push({
type: "rectangle",
areaFraction: width * height / area,
cx: cx,
cy: cy,
width: width,
height: height,
angle: angle,
insidePoly: insidePoly
});
}
}
}
}
}
if (options.cache) {
polyCache[cacheString] = maxRect;
}
return options.events ? Object.assign(maxRect || {}, {
events: events
}) : maxRect;
}