apexcharts
Version:
A JavaScript Chart Library
404 lines (380 loc) • 14.1 kB
JavaScript
// @ts-check
/**
* Geometry and animation helpers for the morph engine's piece layer.
*
* A cross-type morph between one mark and N objects has no path to
* interpolate, and fading the outgoing chart over the incoming one reads as a
* double exposure: two pictures, both semi-visible, neither becoming the
* other. The piece layer conserves the ink instead. The mark is cut into
* exactly N cells, and each cell flies to one object while its corners round
* off and its fill blends, so at every frame the total shape content is a
* valid intermediate. Nothing fades; the old ink travels to where the new ink
* is needed.
*
* Everything here is pure geometry or a self-contained rAF driver, imported
* only by MorphTypeChange, so a bundle without the morph feature never pays
* for it.
*
* @module modules/MorphPieces
*/
import { BrowserAPIs } from '../ssr/BrowserAPIs'
/**
* Divide a bounding box into exactly `count` cells, as an aspect-ratio-aware
* grid: rows run along the LONGER axis, `rows ~ sqrt(ratio * count)`, and any
* remainder is distributed one extra cell per row from the first row on. The
* cells tile the box exactly (last cell of a row closes on the box edge), so
* mid-flight the pieces still read as the mark they came from.
*
* @param {{x:number, y:number, width:number, height:number}} bbox
* @param {number} count
* @returns {Array<{x:number, y:number, width:number, height:number}>}
*/
export function gridDivideRect(bbox, count) {
if (!(count > 0)) return []
if (count === 1) return [{ ...bbox }]
const horizontal = bbox.width >= bbox.height
const rowExtent = horizontal ? bbox.width : bbox.height
const colExtent = horizontal ? bbox.height : bbox.width
// A degenerate box (an empty histogram bin captures one with zero area)
// still yields `count` stacked slivers, so the caller never has to special
// case the pairing.
const ratio = colExtent > 0 ? rowExtent / colExtent : count
let rows = Math.max(1, Math.ceil(Math.sqrt(ratio * count)))
if (rows > count) rows = count
const baseCols = Math.floor(count / rows)
let remainder = count - baseCols * rows
/** @type {Array<{x:number, y:number, width:number, height:number}>} */
const cells = []
const rowSize = rowExtent / rows
let rowStart = 0
for (let r = 0; r < rows; r++) {
const cols = baseCols + (remainder > 0 ? 1 : 0)
if (remainder > 0) remainder--
const colSize = cols > 0 ? colExtent / cols : 0
for (let c = 0; c < cols; c++) {
cells.push(
horizontal
? {
x: bbox.x + rowStart,
y: bbox.y + c * colSize,
width: rowSize,
height: colSize,
}
: {
x: bbox.x + c * colSize,
y: bbox.y + rowStart,
width: colSize,
height: rowSize,
},
)
}
rowStart += rowSize
}
return cells
}
/**
* Divide a mark into exactly `count` cells that follow the mark's OWN
* silhouette instead of its bounding box.
*
* Same row layout as gridDivideRect (rows along the longer axis, remainder
* one extra cell per row from the first row on), but each row's cells are laid
* inside the ink the mark actually has across that row's band. A band may
* cross the ink MORE THAN ONCE, which is why this takes intervals rather than
* one extent: a violin or a box crosses once, a pie wedge can cross twice, and
* a donut ring crosses twice over most of its height, two arms with a hole
* between them. A row's cells are shared between its intervals in proportion
* to their length, so the ink is covered where it is and nowhere else, and no
* cell is ever stamped over the hole.
*
* `intervalsAt(bandLo, bandHi, horizontal)` returns every [lo, hi] interval of
* ink across the minor axis within that major-axis band, or null when the band
* has none measurable; a null band falls back to the full box, which is the
* plain-grid behaviour.
*
* @param {{x:number, y:number, width:number, height:number}} bbox
* @param {number} count
* @param {(bandLo: number, bandHi: number, horizontal: boolean) => Array<[number, number]> | null} intervalsAt
* @returns {Array<{x:number, y:number, width:number, height:number}>}
*/
export function gridDivideShape(bbox, count, intervalsAt) {
if (!(count > 0)) return []
const horizontal = bbox.width >= bbox.height
const rowExtent = horizontal ? bbox.width : bbox.height
const colExtent = horizontal ? bbox.height : bbox.width
const minorLo = horizontal ? bbox.y : bbox.x
const minorHi = minorLo + colExtent
// The silhouette is only as smooth as its bands are thin. The square-ish
// grid that serves a bar makes a low count chunky here (14 cells over a
// violin = ten thick slabs, which reads as a blocky column), so take more,
// thinner rows whenever the count allows: full-extent slabs trace a curve,
// near-square chunks do not.
const ratio = colExtent > 0 ? rowExtent / colExtent : count
let rows = Math.max(
Math.ceil(Math.sqrt(ratio * count)),
Math.ceil(rowExtent / 16),
)
if (!(rows >= 1)) rows = 1
if (rows > count) rows = count
const baseCols = Math.floor(count / rows)
let remainder = count - baseCols * rows
/** @type {Array<{x:number, y:number, width:number, height:number}>} */
const cells = []
const rowSize = rowExtent / rows
let rowStart = horizontal ? bbox.x : bbox.y
for (let r = 0; r < rows; r++) {
const cols = baseCols + (remainder > 0 ? 1 : 0)
if (remainder > 0) remainder--
// Clamp what the prober reports into the box, and drop anything empty.
/** @type {Array<[number, number]>} */
const spans = []
const raw = intervalsAt(rowStart, rowStart + rowSize, horizontal)
if (Array.isArray(raw)) {
for (let s = 0; s < raw.length; s++) {
const iv = raw[s]
if (!iv) continue
const lo = Math.max(minorLo, Math.min(iv[0], iv[1]))
const hi = Math.min(minorHi, Math.max(iv[0], iv[1]))
if (hi > lo) spans.push([lo, hi])
}
}
if (!spans.length) spans.push([minorLo, minorHi])
// Share the row's cells between its intervals by length, largest fraction
// first, so the row still emits exactly `cols` of them.
const totalLen = spans.reduce((a, s) => a + (s[1] - s[0]), 0)
const exact = spans.map((s) =>
totalLen > 0 ? ((s[1] - s[0]) / totalLen) * cols : cols / spans.length,
)
const share = exact.map((v) => Math.floor(v))
let used = share.reduce((a, b) => a + b, 0)
const byFrac = exact
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
.sort((a, b) => b.frac - a.frac)
for (let k = 0; used < cols; k++, used++) {
share[byFrac[k % byFrac.length].i]++
}
for (let s = 0; s < spans.length; s++) {
const n = share[s]
if (n <= 0) continue
const lo = spans[s][0]
const colSize = (spans[s][1] - lo) / n
for (let c = 0; c < n; c++) {
cells.push(
horizontal
? { x: rowStart, y: lo + c * colSize, width: rowSize, height: colSize }
: { x: lo + c * colSize, y: rowStart, width: colSize, height: rowSize },
)
}
}
rowStart += rowSize
}
return cells
}
/**
* Distance along a Hilbert curve for a point in the given extent.
*
* Sorting both the cells and their targets on this before zipping them keeps
* the assignment spatially coherent: the left-most piece flies to the
* left-most object instead of across the whole plot, which is the difference
* between "coming apart" and a crossing storm. A plain row-major or z-order
* sort still allows long diagonal jumps at row boundaries; the Hilbert curve
* does not.
*
* @param {number} x
* @param {number} y
* @param {number} minX
* @param {number} minY
* @param {number} maxX
* @param {number} maxY
* @returns {number}
*/
export function hilbertIndex(x, y, minX, minY, maxX, maxY) {
let ix = maxX === minX ? 0 : Math.round(32767 * ((x - minX) / (maxX - minX)))
let iy = maxY === minY ? 0 : Math.round(32767 * ((y - minY) / (maxY - minY)))
let d = 0
// 16-bit curve; integer halving, so the loop ends at s = 1 rather than
// decaying through subnormal floats.
for (let s = 32768; s >= 1; s /= 2) {
const rx = (ix & s) > 0 ? 1 : 0
const ry = (iy & s) > 0 ? 1 : 0
d += s * s * ((3 * rx) ^ ry)
// Rotate the quadrant so the curve stays continuous.
if (ry === 0) {
if (rx === 1) {
ix = s - 1 - ix
iy = s - 1 - iy
}
const t = ix
ix = iy
iy = t
}
}
return d
}
/**
* Sort a copy of `items` by their Hilbert index over the extent implied by
* the items themselves.
*
* @template T
* @param {T[]} items
* @param {(item: T) => [number, number]} getXY
* @returns {T[]}
*/
export function sortByHilbert(items, getXY) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const pts = items.map((it) => {
const [x, y] = getXY(it)
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
return [x, y]
})
return items
.map((item, k) => ({
item,
d: hilbertIndex(pts[k][0], pts[k][1], minX, minY, maxX, maxY),
}))
.sort((a, b) => a.d - b.d)
.map((e) => e.item)
}
/**
* Parse a solid CSS colour into [r, g, b, a], or null for anything that
* cannot be interpolated (gradients arrive as `url(#...)`, and a piece is
* better off keeping one endpoint's colour than guessing at a gradient).
*
* @param {string|null|undefined} str
* @returns {[number, number, number, number]|null}
*/
export function parseColor(str) {
if (!str || typeof str !== 'string') return null
const s = str.trim()
if (s[0] === '#') {
const hex = s.slice(1)
if (hex.length === 3) {
return [
parseInt(hex[0] + hex[0], 16),
parseInt(hex[1] + hex[1], 16),
parseInt(hex[2] + hex[2], 16),
1,
]
}
if (hex.length === 6 || hex.length === 8) {
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
]
}
return null
}
const m = s.match(/^rgba?\(([^)]+)\)$/i)
if (m) {
const parts = m[1].split(',').map((p) => parseFloat(p))
if (parts.length < 3 || parts.some((v) => !isFinite(v))) return null
return [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1]
}
return null
}
/**
* A colour interpolator between two solid fills, or null when either end is
* not a solid colour. The caller falls back to a hard switch at the far end.
*
* @param {string|null|undefined} from
* @param {string|null|undefined} to
* @returns {((t: number) => string) | null}
*/
export function makeColorLerp(from, to) {
const a = parseColor(from)
const b = parseColor(to)
if (!a || !b) return null
return (t) => {
const r = Math.round(a[0] + (b[0] - a[0]) * t)
const g = Math.round(a[1] + (b[1] - a[1]) * t)
const bl = Math.round(a[2] + (b[2] - a[2]) * t)
const al = a[3] + (b[3] - a[3]) * t
return al >= 1 ? `rgb(${r},${g},${bl})` : `rgba(${r},${g},${bl},${al})`
}
}
/** @param {number} t */
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
/**
* @typedef {Object} PieceRect
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
* @property {number} rx
*/
/**
* @typedef {Object} Piece
* @property {any} el - the overlay <rect> being driven
* @property {PieceRect} from
* @property {PieceRect} to
* @property {((t: number) => string) | null} fill - colour interpolator
* @property {string|null} [fillEnd] - fill to snap to at t=1 when no interpolator
* @property {number} delay - ms offset into the run
* @property {any} [meta] - caller data, handed back on completion
*/
/**
* Drive all pieces on ONE rAF loop.
*
* Every piece is a <rect>, because a rect whose corner radius reaches half
* its (equal) sides IS a circle: cell-to-dot needs no path interpolation at
* all, just x/y/width/height/rx tweens, and mid-flight the piece is a rounded
* rectangle, which is exactly the "corner rounding off as it flies" look.
*
* @param {Object} opts
* @param {Piece[]} opts.pieces
* @param {number} opts.duration - per-piece flight time, ms
* @param {(piece: Piece) => void} [opts.onPieceDone] - fired once per piece at t=1
* @param {() => void} [opts.onAllDone]
* @returns {() => void} cancel
*/
export function runPieceTween({ pieces, duration, onPieceDone, onAllDone }) {
let cancelled = false
const start = Date.now()
const dur = Math.max(1, duration)
/** @param {Piece} p @param {number} e */
const write = (p, e) => {
const f = p.from
const t = p.to
const el = p.el
el.setAttribute('x', String(f.x + (t.x - f.x) * e))
el.setAttribute('y', String(f.y + (t.y - f.y) * e))
el.setAttribute('width', String(Math.max(0, f.width + (t.width - f.width) * e)))
el.setAttribute('height', String(Math.max(0, f.height + (t.height - f.height) * e)))
el.setAttribute('rx', String(Math.max(0, f.rx + (t.rx - f.rx) * e)))
if (p.fill) el.setAttribute('fill', p.fill(e))
else if (e >= 1 && p.fillEnd) el.setAttribute('fill', p.fillEnd)
}
const frame = () => {
if (cancelled) return
const elapsed = Date.now() - start
let live = false
for (let k = 0; k < pieces.length; k++) {
const p = pieces[k]
if (/** @type {any} */ (p)._done) continue
const raw = (elapsed - p.delay) / dur
if (raw < 1) live = true
if (raw <= 0) continue
const t = Math.min(1, raw)
write(p, easeInOutCubic(t))
if (t >= 1) {
;/** @type {any} */ (p)._done = true
if (onPieceDone) onPieceDone(p)
}
}
if (live) BrowserAPIs.requestAnimationFrame(frame)
else if (onAllDone) onAllDone()
}
BrowserAPIs.requestAnimationFrame(frame)
return () => {
cancelled = true
}
}