UNPKG

apexcharts

Version:

A JavaScript Chart Library

414 lines (391 loc) 11.4 kB
// @ts-check /** * SVG path data to polygons, with no DOM. * * The sample this module grew out of borrowed the browser's own * `getTotalLength()`/`getPointAtLength()` by parking a `<path>` in the document. * That cannot ship: it breaks under `apexcharts/ssr` (where touching `document` * is forbidden), it makes flattening precision browser-dependent, so snapshots * drift between engines, and it costs a DOM round trip per shape. * * So paths are parsed and flattened here instead. One code path in every * environment, deterministic to the last bit, and it lets the shape catalog * stay a set of ~300-byte strings rather than pre-baked polygons (which are * roughly five times the bytes for the same outline). * * @module unit-shapes/engine/path */ /** @typedef {{ x: number, y: number }} Point */ const CMD = /[MmLlHhVvCcSsQqTtAaZz]/ /** * Flatten a path's subpaths into closed polygons, in path units. * * Curves are subdivided until their control-polygon length implies steps no * longer than `tolerance`, which keeps vertex density even across a shape * whatever its curve mix. Subpaths are returned in source order; the caller * decides what winding means (see `polygonRegion`). * * @param {string} d * @param {number} [tolerance] max step length in path units (0.6) * @param {boolean} [keepLines] keep two-point subpaths. A two-point subpath * encloses no area, so a FILL must drop it (that is the default): left in, it * would contribute a pair of coincident scanline crossings and open a * zero-width span. A STROKE is the opposite case, where a straight line from A * to B is a perfectly ordinary glyph, so the stroke region asks for them. * @returns {Point[][]} */ export function flattenPath(d, tolerance = 0.6, keepLines = false) { const tol = tolerance > 0 ? tolerance : 0.6 const n = d.length let i = 0 /** @type {Point[][]} */ const polys = [] /** @type {Point[]} */ let poly = [] // Current point, subpath start, and the previous curve's trailing control // point (S/T reflect through it). let cx = 0 let cy = 0 let sx = 0 let sy = 0 let px = 0 let py = 0 let prev = '' const isWs = (/** @type {string} */ c) => c === ' ' || c === ',' || c === '\t' || c === '\n' || c === '\r' function skip() { while (i < n && isWs(d[i])) i++ } /** * Read one number, or null at the end of this command's arguments. * @returns {number|null} */ function num() { skip() const start = i if (d[i] === '+' || d[i] === '-') i++ while (i < n && d[i] >= '0' && d[i] <= '9') i++ if (d[i] === '.') { i++ while (i < n && d[i] >= '0' && d[i] <= '9') i++ } if (d[i] === 'e' || d[i] === 'E') { i++ if (d[i] === '+' || d[i] === '-') i++ while (i < n && d[i] >= '0' && d[i] <= '9') i++ } if (i === start) return null const v = parseFloat(d.slice(start, i)) return isFinite(v) ? v : null } /** * Arc flags are single characters, so a minifier is free to write `0158` for * `0 1 58`. Reading them one char at a time is what makes real-world * (SVGO-compressed) path data parse. * @returns {boolean|null} */ function flag() { skip() const c = d[i] if (c === '0' || c === '1') { i++ return c === '1' } return null } function closePoly() { // A subpath that closed back onto its first point leaves a zero-length // edge, which the scanline ignores, so no de-duplication is needed. if (poly.length > (keepLines ? 1 : 2)) polys.push(poly) poly = [] } /** @param {number} x @param {number} y */ function move(x, y) { closePoly() cx = sx = x cy = sy = y poly = [{ x, y }] } /** @param {number} x @param {number} y */ function line(x, y) { poly.push({ x, y }) cx = x cy = y } /** * @param {number} x1 @param {number} y1 * @param {number} x2 @param {number} y2 * @param {number} x @param {number} y */ function cubic(x1, y1, x2, y2, x, y) { const hull = Math.hypot(x1 - cx, y1 - cy) + Math.hypot(x2 - x1, y2 - y1) + Math.hypot(x - x2, y - y2) const steps = Math.max(2, Math.min(160, Math.ceil(hull / tol))) const x0 = cx const y0 = cy for (let k = 1; k <= steps; k++) { const t = k / steps const u = 1 - t const a = u * u * u const b = 3 * u * u * t const c = 3 * u * t * t const e = t * t * t poly.push({ x: a * x0 + b * x1 + c * x2 + e * x, y: a * y0 + b * y1 + c * y2 + e * y, }) } px = x2 py = y2 cx = x cy = y } /** * @param {number} x1 @param {number} y1 @param {number} x @param {number} y */ function quad(x1, y1, x, y) { const qx = x1 const qy = y1 // Raise to a cubic, so there is one flattener to keep honest. cubic( cx + (2 / 3) * (x1 - cx), cy + (2 / 3) * (y1 - cy), x + (2 / 3) * (x1 - x), y + (2 / 3) * (y1 - y), x, y, ) px = qx py = qy } /** * Endpoint-parameterised arc, per the SVG spec's implementation notes * (F.6.5), then sampled by angle. * * @param {number} rx @param {number} ry @param {number} rot degrees * @param {boolean} large @param {boolean} sweep * @param {number} x @param {number} y */ function arc(rx, ry, rot, large, sweep, x, y) { if (!rx || !ry) { line(x, y) return } const x1 = cx const y1 = cy rx = Math.abs(rx) ry = Math.abs(ry) const phi = (rot * Math.PI) / 180 const cosP = Math.cos(phi) const sinP = Math.sin(phi) const dx2 = (x1 - x) / 2 const dy2 = (y1 - y) / 2 const x1p = cosP * dx2 + sinP * dy2 const y1p = -sinP * dx2 + cosP * dy2 const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry) if (lambda > 1) { const s = Math.sqrt(lambda) rx *= s ry *= s } const num1 = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p const den1 = rx * rx * y1p * y1p + ry * ry * x1p * x1p const co = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num1 / (den1 || 1))) const cxp = (co * rx * y1p) / ry const cyp = (-co * ry * x1p) / rx const ccx = cosP * cxp - sinP * cyp + (x1 + x) / 2 const ccy = sinP * cxp + cosP * cyp + (y1 + y) / 2 const ux = (x1p - cxp) / rx const uy = (y1p - cyp) / ry const vx = (-x1p - cxp) / rx const vy = (-y1p - cyp) / ry const theta = Math.atan2(uy, ux) let delta = Math.atan2(vy, vx) - theta if (!sweep && delta > 0) delta -= 2 * Math.PI if (sweep && delta < 0) delta += 2 * Math.PI const steps = Math.max( 2, Math.min(320, Math.ceil((Math.abs(delta) * Math.max(rx, ry)) / tol)), ) for (let k = 1; k <= steps; k++) { const t = theta + (delta * k) / steps const ct = Math.cos(t) const st = Math.sin(t) poly.push({ x: ccx + rx * ct * cosP - ry * st * sinP, y: ccy + rx * ct * sinP + ry * st * cosP, }) } px = x py = y cx = x cy = y } while (i < n) { skip() if (i >= n) break let cmd = d[i] if (CMD.test(cmd)) { i++ } else if (prev) { // Repeated arguments continue the previous command, except that a // repeated M is an implicit L. cmd = prev === 'M' ? 'L' : prev === 'm' ? 'l' : prev } else { break } const rel = cmd >= 'a' && cmd <= 'z' const up = cmd.toUpperCase() if (up === 'Z') { // Back to the subpath start, so the polygon is closed geometrically as // well as logically. if (poly.length) poly.push({ x: sx, y: sy }) closePoly() cx = sx cy = sy prev = cmd continue } /** @type {(v: number|null) => number} */ const need = (v) => (v == null ? NaN : v) const a = need(num()) if (isNaN(a)) break switch (up) { case 'M': { const b = need(num()) if (isNaN(b)) return polys move(rel ? cx + a : a, rel ? cy + b : b) break } case 'L': { const b = need(num()) if (isNaN(b)) return polys line(rel ? cx + a : a, rel ? cy + b : b) break } case 'H': line(rel ? cx + a : a, cy) break case 'V': line(cx, rel ? cy + a : a) break case 'C': { const args = [a, num(), num(), num(), num(), num()] if (args.some((v) => v == null)) return polys const v = /** @type {number[]} */ (args) cubic( rel ? cx + v[0] : v[0], rel ? cy + v[1] : v[1], rel ? cx + v[2] : v[2], rel ? cy + v[3] : v[3], rel ? cx + v[4] : v[4], rel ? cy + v[5] : v[5], ) break } case 'S': { const args = [a, num(), num(), num()] if (args.some((v) => v == null)) return polys const v = /** @type {number[]} */ (args) const smooth = prev && 'CcSs'.indexOf(prev) >= 0 cubic( smooth ? 2 * cx - px : cx, smooth ? 2 * cy - py : cy, rel ? cx + v[0] : v[0], rel ? cy + v[1] : v[1], rel ? cx + v[2] : v[2], rel ? cy + v[3] : v[3], ) break } case 'Q': { const args = [a, num(), num(), num()] if (args.some((v) => v == null)) return polys const v = /** @type {number[]} */ (args) quad( rel ? cx + v[0] : v[0], rel ? cy + v[1] : v[1], rel ? cx + v[2] : v[2], rel ? cy + v[3] : v[3], ) break } case 'T': { const b = need(num()) if (isNaN(b)) return polys const smooth = prev && 'QqTt'.indexOf(prev) >= 0 quad( smooth ? 2 * cx - px : cx, smooth ? 2 * cy - py : cy, rel ? cx + a : a, rel ? cy + b : b, ) break } case 'A': { const rx = a const ry = num() const rot = num() const large = flag() const sweep = flag() const ex = num() const ey = num() if ( ry == null || rot == null || large == null || sweep == null || ex == null || ey == null ) { return polys } arc(rx, ry, rot, large, sweep, rel ? cx + ex : ex, rel ? cy + ey : ey) break } default: return polys } if (up !== 'C' && up !== 'S' && up !== 'Q' && up !== 'T') { px = cx py = cy } prev = cmd } closePoly() return polys } /** * @param {Point[][]} polys * @returns {{x0:number, y0:number, x1:number, y1:number}} */ export function boundsOf(polys) { const b = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity } polys.forEach((pts) => { pts.forEach((p) => { if (p.x < b.x0) b.x0 = p.x if (p.y < b.y0) b.y0 = p.y if (p.x > b.x1) b.x1 = p.x if (p.y > b.y1) b.y1 = p.y }) }) return b } /** * Signed area, twice over. Its sign is the winding direction, which is how a * hole is authored: a subpath wound against the outline it sits in. * @param {Point[]} pts * @returns {number} */ export function signedArea(pts) { let sum = 0 for (let i = 0; i < pts.length; i++) { const a = pts[i] const b = pts[(i + 1) % pts.length] sum += a.x * b.y - b.x * a.y } return sum }