apexcharts
Version:
A JavaScript Chart Library
287 lines (267 loc) • 9.83 kB
JavaScript
// @ts-check
/**
* Union-anchor path reconciliation for variable-length line/area updates.
*
* The command-level morph engine (PathMorphing.morphPaths) pairs commands by
* array index. When the data length changes that pairing breaks down: the
* shorter path gets padded with phantom commands frozen at its endpoint, so
* appended points "unfurl" out of the old terminus, and, far worse, an area
* path's closing baseline commands get paired against data vertices, which
* tweens fill corners diagonally across the plot (self-intersecting fill).
*
* This module fixes the pairing by construction. Given the keyed join between
* old and new datums (see LengthTransition.joinKeys) it rebuilds BOTH paths
* over the union of anchors:
*
* - the FROM path gains one anchor per ENTERING datum, placed exactly ON the
* old curve (de Casteljau split of the bracketing segment, or pinned to an
* endpoint for prepends/appends), so frame 0 renders pixel-identical to
* the old path;
* - the interpolation TARGET gains one anchor per EXITING datum, placed
* exactly ON the new curve, so exiting points melt into the line instead
* of collapsing to the endpoint; the runner then snaps to the clean,
* un-padded final path at t=1 (see SVGAnimationRunner.plot's snapTo).
*
* Both outputs have identical command counts and identical command types per
* index (M, then all-L or all-C, then for areas a closing block that pairs
* with the other side's closing block), so the per-command lerp degenerates
* into a clean coordinate tween: fills can no longer tear.
*
* Scope guard: only single-segment paths from the standard builders are
* handled (one M, uniform L or C segments, optional 3-command area close).
* Anything else (null-segmented series, steplines' H/V, rangeArea joins)
* fails analysis and the caller falls back to the legacy morph.
*
* @module svg/PathReconcile
*/
import { parsePath, arrayToPath } from './PathMorphing'
/**
* @typedef {Object} UnionEntry
* @property {number} oldJ - old datum index, or -1 for an entering datum
* @property {number} newJ - new datum index, or -1 for an exiting datum
*/
/**
* Merge a keyed join into the union sequence of datums, ordered so that both
* the old indices and the new indices appear in ascending order.
*
* @param {{toOld: number[], exits: number[]}} join - from joinKeys()
* @param {number} oldN
* @returns {UnionEntry[]}
*/
export function buildUnionEntries(join, oldN) {
const exitSet = new Set(join.exits)
/** @type {UnionEntry[]} */
const entries = []
let oi = 0
for (let nj = 0; nj < join.toOld.length; nj++) {
const oj = join.toOld[nj]
if (oj !== -1) {
// Flush exits that sit before this survivor in the old order.
while (oi < oj) {
if (exitSet.has(oi)) entries.push({ oldJ: oi, newJ: -1 })
oi++
}
entries.push({ oldJ: oj, newJ: nj })
oi = oj + 1
} else {
entries.push({ oldJ: -1, newJ: nj })
}
}
while (oi < oldN) {
if (exitSet.has(oi)) entries.push({ oldJ: oi, newJ: -1 })
oi++
}
return entries
}
/**
* Parse and validate a series path against the structure the line/area
* builders emit for a single un-segmented series.
*
* @param {string} d
* @param {number} expectedAnchors - the datum count this path must carry
* @param {boolean} isArea - expect the 3-command closing block
* @returns {{body: any[], closing: any[] | null, segType: string, anchors: number[][]} | null}
*/
function analyzeSeriesPath(d, expectedAnchors, isArea) {
if (!d || typeof d !== 'string') return null
const cmds = parsePath(d)
if (!cmds.length || cmds[0][0] !== 'M') return null
for (let i = 1; i < cmds.length; i++) {
if (cmds[i][0] === 'M') return null // multi-segment: not handled
}
let body = cmds
/** @type {any[] | null} */
let closing = null
if (isArea) {
if (cmds.length < 5) return null
closing = cmds.slice(-3)
body = cmds.slice(0, -3)
if (closing[2][0] !== 'Z') return null
if (closing[1][0] !== 'L') return null
if (closing[0][0] !== 'L' && closing[0][0] !== 'C') return null
} else if (cmds[cmds.length - 1][0] === 'Z') {
return null
}
if (body.length !== expectedAnchors || body.length < 2) return null
const segType = body[1][0]
if (segType !== 'L' && segType !== 'C') return null
for (let i = 2; i < body.length; i++) {
if (body[i][0] !== segType) return null
}
/** @type {number[][]} */
const anchors = body.map((c) =>
c[0] === 'C' ? [Number(c[5]), Number(c[6])] : [Number(c[1]), Number(c[2])],
)
for (const a of anchors) {
if (!isFinite(a[0]) || !isFinite(a[1])) return null
}
return { body, closing, segType, anchors }
}
/**
* @param {number[]} a
* @param {number[]} b
* @param {number} t
*/
function lerpPt(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
/**
* De Casteljau split of a cubic segment at parameter t.
*
* @param {number[]} s - segment start point
* @param {any[]} cmd - ['C', x1, y1, x2, y2, x, y]
* @param {number} t
* @returns {{first: any[], second: any[], mid: number[]}}
*/
function splitCubic(s, cmd, t) {
const p0 = s
const p1 = [cmd[1], cmd[2]]
const p2 = [cmd[3], cmd[4]]
const p3 = [cmd[5], cmd[6]]
const p01 = lerpPt(p0, p1, t)
const p12 = lerpPt(p1, p2, t)
const p23 = lerpPt(p2, p3, t)
const p012 = lerpPt(p01, p12, t)
const p123 = lerpPt(p12, p23, t)
const mid = lerpPt(p012, p123, t)
return {
first: ['C', p01[0], p01[1], p012[0], p012[1], mid[0], mid[1]],
second: ['C', p123[0], p123[1], p23[0], p23[1], p3[0], p3[1]],
mid,
}
}
/**
* Split of a straight segment at parameter t.
*
* @param {number[]} s - segment start point
* @param {any[]} cmd - ['L', x, y]
* @param {number} t
* @returns {{first: any[], second: any[], mid: number[]}}
*/
function splitLine(s, cmd, t) {
const e = [cmd[1], cmd[2]]
const mid = lerpPt(s, e, t)
return {
first: ['L', mid[0], mid[1]],
second: ['L', e[0], e[1]],
mid,
}
}
/**
* Rebuild one path over the union sequence. `ownIdx[k]` is the entry's anchor
* index in THIS path (old index for the from path, new index for the target
* path) or -1 for a foreign entry (an enter when rebuilding the from path, an
* exit when rebuilding the target). Foreign entries between two own anchors
* split the original segment at equal parameters (staying exactly on the
* curve); leading/trailing foreigners pin to the first/last anchor.
*
* Own anchor indices are guaranteed to appear in ascending 0..n-1 order by
* buildUnionEntries.
*
* @param {{body: any[], closing: any[] | null, segType: string, anchors: number[][]}} analysis
* @param {number[]} ownIdx
* @returns {any[]}
*/
function expandPath(analysis, ownIdx) {
const { body, closing, segType, anchors } = analysis
const n = anchors.length
const m = ownIdx.length
/** @param {number[]} p */
const degen = (p) =>
segType === 'C'
? ['C', p[0], p[1], p[0], p[1], p[0], p[1]]
: ['L', p[0], p[1]]
const out = [['M', anchors[0][0], anchors[0][1]]]
// Leading foreign entries sit at the first anchor: the M covers entry 0,
// every further entry up to (and including) own anchor 0 is a degenerate
// segment pinned there.
let firstOwn = 0
while (firstOwn < m && ownIdx[firstOwn] !== 0) firstOwn++
for (let q = 1; q <= firstOwn; q++) out.push(degen(anchors[0]))
let entry = firstOwn + 1
for (let s = 0; s < n - 1; s++) {
let interior = 0
while (entry + interior < m && ownIdx[entry + interior] === -1) interior++
const cmd = body[s + 1]
if (!interior) {
out.push(cmd.slice())
} else {
const totalParts = interior + 1
let start = anchors[s]
let rest = cmd
for (let q = 0; q < interior; q++) {
const t = 1 / (totalParts - q)
const sp =
segType === 'C' ? splitCubic(start, rest, t) : splitLine(start, rest, t)
out.push(sp.first)
start = sp.mid
rest = sp.second
}
out.push(rest)
}
entry += interior + 1 // interiors + own anchor s+1
}
// Trailing foreign entries pin to the last anchor.
while (entry < m) {
out.push(degen(anchors[n - 1]))
entry++
}
if (closing) closing.forEach((c) => out.push(c.slice()))
return out
}
/**
* Reconcile one from/to path pair over the union entries.
*
* @param {string} fromD - the previous render's path for this series
* @param {string} toD - the freshly built (clean) path
* @param {UnionEntry[]} entries - from buildUnionEntries()
* @param {number} oldN - old datum count (fromD must carry this many anchors)
* @param {number} newN - new datum count (toD must carry this many anchors)
* @param {boolean} isArea
* @returns {{from: string, toInterp: string} | null} null when either path
* does not match the expected single-segment structure (caller falls back
* to the legacy morph)
*/
export function reconcilePathPair(fromD, toD, entries, oldN, newN, isArea) {
const fromAnalysis = analyzeSeriesPath(fromD, oldN, isArea)
if (!fromAnalysis) return null
const toAnalysis = analyzeSeriesPath(toD, newN, isArea)
if (!toAnalysis) return null
// The morph lerps index-to-index: segment types must agree or the engine
// would still bezier-promote mid-flight.
if (fromAnalysis.segType !== toAnalysis.segType) return null
if (
(fromAnalysis.closing || toAnalysis.closing) &&
(!fromAnalysis.closing ||
!toAnalysis.closing ||
fromAnalysis.closing[0][0] !== toAnalysis.closing[0][0])
) {
return null
}
const fromOwn = entries.map((e) => e.oldJ)
const toOwn = entries.map((e) => e.newJ)
return {
from: arrayToPath(expandPath(fromAnalysis, fromOwn)),
toInterp: arrayToPath(expandPath(toAnalysis, toOwn)),
}
}