apexcharts
Version:
A JavaScript Chart Library
604 lines (545 loc) • 15 kB
JavaScript
// @ts-check
import { Point, Matrix } from './math'
/*!
* Path morphing for SVG path animations
* Based on svg.pathmorphing.js by Ulrich-Matthias Schäfer (MIT License)
* Refactored to be standalone (no SVG.js dependency)
*/
// Parse an SVG path 'd' string into an array of command arrays
// e.g. "M 0 0 L 10 20" → [['M', 0, 0], ['L', 10, 20]]
/**
* @param {string} d
*/
function parsePath(d) {
if (!d || typeof d !== 'string') return [['M', 0, 0]]
const commands = []
// Match command letter followed by numbers (including negative, decimal)
const re = /([MmLlHhVvCcSsQqTtAaZz])\s*/g
const numRe = /[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi
let match
const letters = []
const positions = []
// Find all command letters and their positions
while ((match = re.exec(d)) !== null) {
letters.push(match[1])
positions.push(match.index)
}
for (let i = 0; i < letters.length; i++) {
const start = positions[i] + letters[i].length
const end = i + 1 < positions.length ? positions[i + 1] : d.length
const paramStr = d.substring(start, end)
const nums = []
let numMatch
numRe.lastIndex = 0
while ((numMatch = numRe.exec(paramStr)) !== null) {
nums.push(parseFloat(numMatch[0]))
}
const cmd = letters[i].toUpperCase()
if (cmd === 'Z') {
commands.push(['Z'])
} else if (cmd === 'M' || cmd === 'L' || cmd === 'T') {
// pairs of (x, y)
for (let j = 0; j < nums.length; j += 2) {
commands.push([cmd, nums[j], nums[j + 1]])
}
} else if (cmd === 'H') {
for (let j = 0; j < nums.length; j++) {
commands.push([cmd, nums[j]])
}
} else if (cmd === 'V') {
for (let j = 0; j < nums.length; j++) {
commands.push([cmd, nums[j]])
}
} else if (cmd === 'C') {
for (let j = 0; j < nums.length; j += 6) {
commands.push([
cmd,
nums[j],
nums[j + 1],
nums[j + 2],
nums[j + 3],
nums[j + 4],
nums[j + 5],
])
}
} else if (cmd === 'S' || cmd === 'Q') {
for (let j = 0; j < nums.length; j += 4) {
commands.push([cmd, nums[j], nums[j + 1], nums[j + 2], nums[j + 3]])
}
} else if (cmd === 'A') {
for (let j = 0; j < nums.length; j += 7) {
commands.push([
cmd,
nums[j],
nums[j + 1],
nums[j + 2],
nums[j + 3],
nums[j + 4],
nums[j + 5],
nums[j + 6],
])
}
}
}
if (commands.length === 0) {
commands.push(['M', 0, 0])
}
return commands
}
// Calculate bounding box of a parsed path array
/**
* @param {any[]} arr
*/
function pathBbox(arr) {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity
/**
* @param {any[]} cmd
*/
arr.forEach((cmd) => {
for (let i = 1; i < cmd.length; i += 2) {
if (i + 1 <= cmd.length) {
const x = cmd[i]
const y = cmd[i + 1]
if (typeof x === 'number' && typeof y === 'number') {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
}
})
if (minX === Infinity) {
return { x: 0, y: 0, width: 0, height: 0 }
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
// Serialize command array back to path string
/**
* @param {any[]} arr
*/
function arrayToPath(arr) {
/**
* @param {any[]} cmd
*/
return arr.map((cmd) => cmd.join(' ')).join(' ')
}
// Convert shorthand types to long form
/**
* @this {any}
* @param {any[]} val
*/
function simplify(val) {
switch (val[0]) {
case 'z':
case 'Z':
val[0] = 'L'
val[1] = this.start[0]
val[2] = this.start[1]
break
case 'H':
val[0] = 'L'
val[2] = this.pos[1]
break
case 'V':
val[0] = 'L'
val[2] = val[1]
val[1] = this.pos[0]
break
case 'T':
val[0] = 'Q'
val[3] = val[1]
val[4] = val[2]
val[1] = this.reflection[1]
val[2] = this.reflection[0]
break
case 'S':
val[0] = 'C'
val[6] = val[4]
val[5] = val[3]
val[4] = val[2]
val[3] = val[1]
val[2] = this.reflection[1]
val[1] = this.reflection[0]
break
}
return val
}
// Update reflection point and current position
/**
* @this {any}
* @param {any[]} val
*/
function setPosAndReflection(val) {
var len = val.length
this.pos = [val[len - 2], val[len - 1]]
if ('SCQT'.indexOf(val[0]) != -1) {
this.reflection = [
2 * this.pos[0] - val[len - 4],
2 * this.pos[1] - val[len - 3],
]
}
return val
}
// Convert all types to cubic bezier
/**
* @this {any}
* @param {any[]} val
*/
function toBezier(val) {
var retVal = [val]
switch (val[0]) {
case 'M':
this.pos = this.start = [val[1], val[2]]
return retVal
case 'L':
val[5] = val[3] = val[1]
val[6] = val[4] = val[2]
// @ts-ignore — this.pos is always set before L/Q cases are reached
val[1] = this.pos[0]
// @ts-ignore
val[2] = this.pos[1]
break
case 'Q':
val[6] = val[4]
val[5] = val[3]
val[4] = (val[4] * 1) / 3 + (val[2] * 2) / 3
val[3] = (val[3] * 1) / 3 + (val[1] * 2) / 3
// @ts-ignore — this.pos is always set before L/Q cases are reached
val[2] = (this.pos[1] * 1) / 3 + (val[2] * 2) / 3
// @ts-ignore
val[1] = (this.pos[0] * 1) / 3 + (val[1] * 2) / 3
break
case 'A':
retVal = arcToBezier(this.pos ?? [], val)
val = retVal[0]
break
}
val[0] = 'C'
this.pos = [val[5], val[6]]
this.reflection = [2 * val[5] - val[3], 2 * val[6] - val[4]]
return retVal
}
// Find next M command position
/** @param {any[]} arr @param {number | false} offset @returns {number | false} */
function findNextM(arr, offset) {
if (offset === false) return false
for (var i = offset, len = arr.length; i < len; ++i) {
if (arr[i][0] == 'M') return i
}
return false
}
// Convert arc segment to cubic bezier curves
/**
* @param {number[]} pos
* @param {any[]} val
*/
function arcToBezier(pos, val) {
var rx = Math.abs(val[1]),
ry = Math.abs(val[2]),
xAxisRotation = val[3] % 360,
largeArcFlag = val[4],
sweepFlag = val[5],
x = val[6],
y = val[7],
A = new Point(pos[0], pos[1]),
B = new Point(x, y),
primedCoord,
lambda,
mat,
k,
c,
cSquare,
t,
O,
OA,
OB,
tetaStart,
tetaEnd,
deltaTeta,
nbSectors,
f,
arcSegPoints,
angle,
sinAngle,
cosAngle,
pt,
i,
il,
retVal = [],
x1,
y1,
x2,
y2
if (rx === 0 || ry === 0 || (A.x === B.x && A.y === B.y)) {
return [['C', A.x, A.y, B.x, B.y, B.x, B.y]]
}
primedCoord = new Point((A.x - B.x) / 2, (A.y - B.y) / 2).transform(
/** @type {any} */ (new Matrix(0, 0, 0, 0, 0, 0)).rotate(xAxisRotation),
)
lambda =
(primedCoord.x * primedCoord.x) / (rx * rx) +
(primedCoord.y * primedCoord.y) / (ry * ry)
if (lambda > 1) {
lambda = Math.sqrt(lambda)
rx = lambda * rx
ry = lambda * ry
}
mat = /** @type {any} */ (new Matrix(0, 0, 0, 0, 0, 0))
.rotate(xAxisRotation)
.scale(1 / rx, 1 / ry)
.rotate(-xAxisRotation)
A = A.transform(mat)
B = B.transform(mat)
k = [B.x - A.x, B.y - A.y]
cSquare = k[0] * k[0] + k[1] * k[1]
c = Math.sqrt(cSquare)
k[0] /= c
k[1] /= c
t = cSquare < 4 ? Math.sqrt(1 - cSquare / 4) : 0
if (largeArcFlag === sweepFlag) {
t *= -1
}
O = new Point((B.x + A.x) / 2 + t * -k[1], (B.y + A.y) / 2 + t * k[0])
OA = new Point(A.x - O.x, A.y - O.y)
OB = new Point(B.x - O.x, B.y - O.y)
tetaStart = Math.acos(OA.x / Math.sqrt(OA.x * OA.x + OA.y * OA.y))
if (OA.y < 0) tetaStart *= -1
tetaEnd = Math.acos(OB.x / Math.sqrt(OB.x * OB.x + OB.y * OB.y))
if (OB.y < 0) tetaEnd *= -1
if (sweepFlag && tetaStart > tetaEnd) {
tetaEnd += 2 * Math.PI
}
if (!sweepFlag && tetaStart < tetaEnd) {
tetaEnd -= 2 * Math.PI
}
nbSectors = Math.ceil((Math.abs(tetaStart - tetaEnd) * 2) / Math.PI)
arcSegPoints = []
angle = tetaStart
deltaTeta = (tetaEnd - tetaStart) / nbSectors
f = (4 * Math.tan(deltaTeta / 4)) / 3
for (i = 0; i <= nbSectors; i++) {
cosAngle = Math.cos(angle)
sinAngle = Math.sin(angle)
pt = new Point(O.x + cosAngle, O.y + sinAngle)
arcSegPoints[i] = [
new Point(pt.x + f * sinAngle, pt.y - f * cosAngle),
pt,
new Point(pt.x - f * sinAngle, pt.y + f * cosAngle),
]
angle += deltaTeta
}
arcSegPoints[0][0] = arcSegPoints[0][1].clone()
arcSegPoints[arcSegPoints.length - 1][2] =
arcSegPoints[arcSegPoints.length - 1][1].clone()
mat = /** @type {any} */ (new Matrix(0, 0, 0, 0, 0, 0))
.rotate(xAxisRotation)
.scale(rx, ry)
.rotate(-xAxisRotation)
for (i = 0, il = arcSegPoints.length; i < il; i++) {
arcSegPoints[i][0] = arcSegPoints[i][0].transform(mat)
arcSegPoints[i][1] = arcSegPoints[i][1].transform(mat)
arcSegPoints[i][2] = arcSegPoints[i][2].transform(mat)
}
for (i = 1, il = arcSegPoints.length; i < il; i++) {
pt = arcSegPoints[i - 1][2]
x1 = pt.x
y1 = pt.y
pt = arcSegPoints[i][0]
x2 = pt.x
y2 = pt.y
pt = arcSegPoints[i][1]
x = pt.x
y = pt.y
retVal.push(['C', x1, y1, x2, y2, x, y])
}
return retVal
}
// Synchronize one block (from M to next M) so types and lengths match
/**
* @param {any[]} startArr
* @param {number} startOffsetM
* @param {any} startOffsetNextM
* @param {any[]} destArr
* @param {number} destOffsetM
* @param {any} destOffsetNextM
*/
function handleBlock(
startArr,
startOffsetM,
startOffsetNextM,
destArr,
destOffsetM,
destOffsetNextM,
) {
var startArrTemp = startArr.slice(startOffsetM, startOffsetNextM || undefined)
var destArrTemp = destArr.slice(destOffsetM, destOffsetNextM || undefined)
var i = 0,
posStart = { pos: [0, 0], start: [0, 0] },
posDest = { pos: [0, 0], start: [0, 0] }
while (true) {
startArrTemp[i] = simplify.call(posStart, startArrTemp[i])
destArrTemp[i] = simplify.call(posDest, destArrTemp[i])
if (
startArrTemp[i][0] != destArrTemp[i][0] ||
startArrTemp[i][0] == 'M' ||
(startArrTemp[i][0] == 'A' &&
(startArrTemp[i][4] != destArrTemp[i][4] ||
startArrTemp[i][5] != destArrTemp[i][5]))
) {
Array.prototype.splice.apply(
startArrTemp,
/** @type {[number, number, ...any[]]} */ (
[i, 1].concat(
/** @type {any} */ (toBezier).call(posStart, startArrTemp[i]),
)
),
)
Array.prototype.splice.apply(
destArrTemp,
/** @type {[number, number, ...any[]]} */ (
[i, 1].concat(
/** @type {any} */ (toBezier).call(posDest, destArrTemp[i]),
)
),
)
} else {
startArrTemp[i] = /** @type {any} */ (setPosAndReflection).call(
posStart,
startArrTemp[i],
)
destArrTemp[i] = /** @type {any} */ (setPosAndReflection).call(
posDest,
destArrTemp[i],
)
}
if (++i == startArrTemp.length && i == destArrTemp.length) break
if (i == startArrTemp.length) {
startArrTemp.push([
'C',
posStart.pos[0],
posStart.pos[1],
posStart.pos[0],
posStart.pos[1],
posStart.pos[0],
posStart.pos[1],
])
}
if (i == destArrTemp.length) {
destArrTemp.push([
'C',
posDest.pos[0],
posDest.pos[1],
posDest.pos[0],
posDest.pos[1],
posDest.pos[0],
posDest.pos[1],
])
}
}
return { start: startArrTemp, dest: destArrTemp }
}
// Synchronize two path arrays so they can be interpolated
/**
* @param {string} fromD
* @param {string} toD
*/
function synchronizePaths(fromD, toD) {
var startArr = parsePath(fromD)
var destArr = parsePath(toD)
/** @type {number | false} */ var startOffsetM = 0
/** @type {number | false} */ var destOffsetM = 0
/** @type {number | false} */ var startOffsetNextM = false
/** @type {number | false} */ var destOffsetNextM = false
var result
while (true) {
if (startOffsetM === false && destOffsetM === false) break
startOffsetNextM = findNextM(
startArr,
startOffsetM === false ? false : startOffsetM + 1,
)
destOffsetNextM = findNextM(
destArr,
destOffsetM === false ? false : destOffsetM + 1,
)
if (startOffsetM === false) {
const bbox = pathBbox(/** @type {any} */ (result).start)
if (bbox.height == 0 || bbox.width == 0) {
startOffsetM = startArr.push(startArr[0]) - 1
} else {
startOffsetM =
startArr.push([
'M',
bbox.x + bbox.width / 2,
bbox.y + bbox.height / 2,
]) - 1
}
}
if (destOffsetM === false) {
const bbox = pathBbox(/** @type {any} */ (result).dest)
if (bbox.height == 0 || bbox.width == 0) {
destOffsetM = destArr.push(destArr[0]) - 1
} else {
destOffsetM =
destArr.push([
'M',
bbox.x + bbox.width / 2,
bbox.y + bbox.height / 2,
]) - 1
}
}
result = handleBlock(
startArr,
startOffsetM,
startOffsetNextM,
destArr,
destOffsetM,
destOffsetNextM,
)
startArr = startArr
.slice(0, startOffsetM)
.concat(
result.start,
startOffsetNextM === false ? [] : startArr.slice(startOffsetNextM),
)
destArr = destArr
.slice(0, destOffsetM)
.concat(
result.dest,
destOffsetNextM === false ? [] : destArr.slice(destOffsetNextM),
)
startOffsetM =
startOffsetNextM === false ? false : startOffsetM + result.start.length
destOffsetM =
destOffsetNextM === false ? false : destOffsetM + result.dest.length
}
return { start: startArr, dest: destArr }
}
/**
* Create a path interpolation function.
* @param {string} fromD - Source SVG path 'd' string
* @param {string} toD - Target SVG path 'd' string
* @returns {function} Interpolator: (pos: 0..1) => pathString
*/
function morphPaths(fromD, toD) {
var synced = synchronizePaths(fromD, toD)
var startArr = synced.start
var destArr = synced.dest
/**
* @param {number} pos
*/
return function (pos) {
var result = startArr.map(function (from, idx) {
return destArr[idx].map(function (to, toIdx) {
if (toIdx === 0) return to // command letter
// @ts-ignore — toIdx > 0 entries are always numbers; index 0 (command letter) is returned above
return from[toIdx] + (destArr[idx][toIdx] - from[toIdx]) * pos
})
})
return arrayToPath(result)
}
}
export { parsePath, morphPaths, pathBbox, arrayToPath }