apexcharts
Version:
A JavaScript Chart Library
983 lines (946 loc) • 28.1 kB
JavaScript
// @ts-check
import Graphics from '../../modules/Graphics'
/**
* Strata (#2) P2: CanvasGraphics: an svg.js-compatible display-list shim.
*
* The per-type `draw()` methods emit series marks by calling primitive methods
* (`renderPaths`, `drawMarker`, ...) and then reading back from the returned
* element (`.attr()`, `.node.setAttribute()`, `.add()`, filter/animation
* helpers). Rather than hoist a scene description out of those tangled sites,
* the canvas renderer implements the SAME element API, but instead of creating
* a DOM node it records a paint command. The returned handle quacks like an
* svg.js element so the emit sites need no structural change.
*
* PERFORMANCE: the whole point of canvas mode is dense data, so the per-point
* primitive (`drawMarker`) MUST NOT allocate a retained object per point.
* Allocating tens of thousands of retained objects into a growing array while
* the render pipeline churns transient garbage triggers a V8 scavenge blow-up
* (measured: 50k object-cmds = ~5s, columnar number arrays = a few ms). So
* markers are stored COLUMNAR: parallel unboxed-number arrays (x/y/size/shape/
* styleId) plus a tiny deduped style palette. The returned handle is a
* transient index ref (dies after the emit site, never retained). Lower-count
* primitives (series paths, rects, lines, text) keep object commands.
*
* P2 records the FINAL geometry only (animation is P4). The recorded data is the
* substrate later phases re-query without recomputing geometry: hit-testing
* (P3), state restyle (P3), animation (P4).
*
* @module renderers/canvas/CanvasGraphics
*/
/**
* Object-command style attribute keys → flat field names (path/rect/etc.).
* @type {Record<string, string>}
*/
const STYLE_KEYS = {
fill: 'fill',
stroke: 'stroke',
'stroke-width': 'strokeWidth',
'stroke-dasharray': 'strokeDash',
'stroke-linecap': 'lineCap',
'fill-opacity': 'fillOpacity',
'stroke-opacity': 'strokeOpacity',
'fill-rule': 'fillRule',
}
/** Sentinel that never `===` a real style value, so the cache misses first use. */
const NEVER = Symbol('never')
/**
* Marker shape → small int id (keeps the columnar shape array unboxed).
* @type {Record<string, number>}
*/
const SHAPE_ID = {
circle: 0,
square: 1,
rect: 1,
triangle: 2,
diamond: 3,
star: 4,
sparkle: 5,
cross: 6,
plus: 7,
line: 8,
}
const SHAPE_NAME = [
'circle',
'square',
'triangle',
'diamond',
'star',
'sparkle',
'cross',
'plus',
'line',
]
/**
* A chainable no-op animation runner so `el.animate().attr().after()` chains on
* a recorded mark don't throw. P2 paints the final state, so animation is inert.
*/
const NOOP_RUNNER = {
/** @returns {any} */
attr() {
return NOOP_RUNNER
},
plot() {
return NOOP_RUNNER
},
during() {
return NOOP_RUNNER
},
after(/** @type {any} */ fn) {
if (typeof fn === 'function') fn()
return NOOP_RUNNER
},
animate() {
return NOOP_RUNNER
},
delay() {
return NOOP_RUNNER
},
loop() {
return NOOP_RUNNER
},
finish() {
return NOOP_RUNNER
},
stop() {
return NOOP_RUNNER
},
}
/**
* A single shared no-op node for columnar marker refs. Markers are write-only in
* the emit path (fill flows through `attr`; identity/style class/selection are
* P3, animation is P4), so a shared stub avoids a per-point node allocation.
*/
const SHARED_MARKER_NODE = {
nodeName: 'path',
style: {},
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
setAttribute() {},
getAttribute: () => null,
removeAttribute() {},
hasAttribute: () => false,
addEventListener() {},
removeEventListener() {},
appendChild() {},
getBBox: () => ({ x: 0, y: 0, width: 0, height: 0 }),
}
/**
* A single shared no-op group. Series draw() creates wrap groups per point that
* carry no paint state in canvas mode, so they all share this: zero per-point
* group allocation (the difference between a GC blow-up and a fast render).
*/
const SHARED_GROUP = {
__isCanvasMark: true,
node: {
nodeName: 'g',
instance: null,
style: {},
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
setAttribute() {},
getAttribute: () => null,
removeAttribute() {},
addEventListener() {},
removeEventListener() {},
appendChild() {},
getBBox: () => ({ x: 0, y: 0, width: 0, height: 0 }),
},
/** @returns {any} */
attr() {
return SHARED_GROUP
},
add() {
return SHARED_GROUP
},
addTo() {
return SHARED_GROUP
},
remove() {
return SHARED_GROUP
},
clear() {
return SHARED_GROUP
},
css() {
return SHARED_GROUP
},
hide() {
return SHARED_GROUP
},
show() {
return SHARED_GROUP
},
removeClass() {
return SHARED_GROUP
},
animate() {
return NOOP_RUNNER
},
}
/**
* Transient handle for a columnar marker. Holds only (graphics, index); the only
* paint-relevant mutation the emit sites make is `attr('fill', ...)` (scatter
* per-point fill). Everything else no-ops. Not retained past the emit site.
*/
class CanvasMarkerRef {
/**
* @param {CanvasGraphics} g
* @param {number} i
*/
constructor(g, i) {
this.__isCanvasMark = true
this._g = g
this._i = i
}
get node() {
return SHARED_MARKER_NODE
}
/**
* @param {any} a
* @param {any} [v]
* @returns {any}
*/
attr(a, v) {
if (typeof a === 'string') {
if (a === 'fill' && v !== undefined) this._g._setMarkerFill(this._i, v)
return v === undefined ? null : this
}
if (a && a.fill !== undefined) this._g._setMarkerFill(this._i, a.fill)
return this
}
/** @param {any} _c */
add(_c) {
return this
}
/** @param {any} _p */
addTo(_p) {
return this
}
remove() {
return this
}
/** @param {any} _s */
css(_s) {
return this
}
/** @param {any} _v */
fill(_v) {
if (_v !== undefined) this._g._setMarkerFill(this._i, _v)
return this
}
/** @param {any} _v */
stroke(_v) {
return this
}
hide() {
return this
}
show() {
return this
}
/** @param {string} _c */
removeClass(_c) {
return this
}
animate() {
return NOOP_RUNNER
}
}
/**
* Shared no-op handle for a columnar rect cell. Cells are write-only in the
* emit path (all geometry + style is passed to drawRectCell up front; identity
* lives in the columns), so every cell returns this one stub: zero per-cell
* allocation. Chainable so any stray `.attr()/.add()` on a cell can't throw.
*/
const SHARED_RECT_REF = {
__isCanvasMark: true,
node: SHARED_MARKER_NODE,
/** @returns {any} */
attr() {
return SHARED_RECT_REF
},
add() {
return SHARED_RECT_REF
},
addTo() {
return SHARED_RECT_REF
},
remove() {
return SHARED_RECT_REF
},
/** @returns {any} */
css() {
return SHARED_RECT_REF
},
animate() {
return NOOP_RUNNER
},
}
/**
* Object-command handle for lower-count primitives (series paths, rects, lines,
* text). Style-relevant `attr()` / `node.setAttribute()` writes flow into the
* bound command so late edits (forecast dashArray, fill-rule) paint. It
* deliberately omits `filterWith`/`unfilter`: `Filters` guards on their
* presence, so filter/dropShadow effects safely no-op in canvas mode.
*/
class CanvasMark {
/** @param {any} cmd */
constructor(cmd) {
this.__isCanvasMark = true
this._cmd = cmd
const self = this
this.node = {
nodeName: cmd ? cmd.tag : 'g',
instance: this,
style: {},
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
/** @param {string} k @param {any} v */
setAttribute(k, v) {
self._applyAttr(k, v)
},
getAttribute: () => null,
removeAttribute() {},
hasAttribute: () => false,
addEventListener() {},
removeEventListener() {},
appendChild() {},
getBBox: () => ({ x: 0, y: 0, width: 0, height: 0 }),
}
}
/**
* @param {string} k
* @param {any} v
*/
_applyAttr(k, v) {
const cmd = this._cmd
if (!cmd) return
const sk = STYLE_KEYS[k]
if (sk !== undefined) cmd[sk] = v
}
/**
* @param {any} a
* @param {any} [v]
* @returns {any}
*/
attr(a, v) {
if (typeof a === 'string') {
if (v === undefined) return null
this._applyAttr(a, v)
return this
}
for (const k in a) {
if (a[k] !== undefined) this._applyAttr(k, a[k])
}
return this
}
/** @param {any} _c */
add(_c) {
return this
}
/** @param {any} _p */
addTo(_p) {
return this
}
remove() {
return this
}
clear() {
return this
}
/** @param {any} _s */
css(_s) {
return this
}
/** @param {any} v */
fill(v) {
if (typeof v === 'object') return this.attr(v)
return this.attr('fill', v)
}
/** @param {any} v */
stroke(v) {
if (typeof v === 'object') {
if (v.color !== undefined) this.attr('stroke', v.color)
if (v.width !== undefined) this.attr('stroke-width', v.width)
return this
}
return this.attr('stroke', v)
}
/** @param {string} d */
plot(d) {
if (typeof d === 'string' && this._cmd && this._cmd.tag === 'path') {
this._cmd.d = d
}
return this
}
hide() {
return this
}
show() {
return this
}
/** @param {string} _c */
removeClass(_c) {
return this
}
bbox() {
return { x: 0, y: 0, width: 0, height: 0 }
}
animate() {
return NOOP_RUNNER
}
}
export default class CanvasGraphics {
/** @param {any} w */
constructor(w) {
this.w = w
this._g = new Graphics(w) // getMarkerPath is a pure geometry helper
// Initialized here (not only in reset()) so their types are non-nullable.
/** @type {any[]} object commands: paths / rects / lines / text */
this._list = []
// Columnar marker store: PRE-SIZED typed arrays, index-assigned (never
// .push()). Growing a retained array per point while the render churns
// transient garbage triggers a V8 scavenge blow-up (measured: 50k pushes ~
// 4.8s); a fixed typed array whose contents aren't GC-traced avoids it
// entirely (~ms). Capacity is estimated from the series lengths per render.
/** @type {Float64Array} */
this._mx = new Float64Array(16)
/** @type {Float64Array} */
this._my = new Float64Array(16)
/** @type {Float64Array} */
this._msize = new Float64Array(16)
/** @type {Int16Array} */
this._mshape = new Int16Array(16)
/** @type {Int32Array} */
this._mstyle = new Int32Array(16)
/** @type {Int32Array} series (realIndex) per marker, for per-series restyle */
this._msi = new Int32Array(16)
this._mn = 0
this._mcap = 16
// Columnar rect-cell store (heatmap cells; dense same-shape rects). Same
// rationale as markers: a retained object per cell trips a V8 scavenge
// blow-up at 50k+, so cells are unboxed parallel arrays + the shared style
// palette. Corner radius is uniform per chart, so it is a scalar not a column.
/** @type {Float64Array} */
this._crx = new Float64Array(16)
/** @type {Float64Array} */
this._cry = new Float64Array(16)
/** @type {Float64Array} */
this._crw = new Float64Array(16)
/** @type {Float64Array} */
this._crh = new Float64Array(16)
/** @type {Int32Array} */
this._crstyle = new Int32Array(16)
/** @type {Int32Array} series realIndex per cell (restyle / hit-test) */
this._crsi = new Int32Array(16)
/** @type {Int32Array} dataPointIndex per cell (hit-test) */
this._crdi = new Int32Array(16)
this._crn = 0
this._crcap = 16
this._cellRadius = 0
/** @type {any[]} */
this._styles = []
/** @type {Map<string, number>} */
this._styleMap = new Map()
// Last-style + last-fill-override caches (keep interning out of the marker
// hot loop). Declared here with types so TS treats them as non-nullable;
// _resetStyleCache() reassigns them per render.
/** @type {any} */
this._lf = NEVER
/** @type {any} */
this._ls = NEVER
/** @type {any} */
this._lsw = NEVER
/** @type {any} */
this._ld = NEVER
/** @type {any} */
this._lfo = NEVER
/** @type {any} */
this._lso = NEVER
/** @type {number} */
this._lid = -1
/** @type {any} */
this._lofFill = NEVER
/** @type {number} */
this._lofBase = -1
/** @type {number} */
this._lofId = -1
// rect-cell last-style cache (keeps the intern Map/string work off the
// per-cell hot loop when a run of cells shares one style)
/** @type {any} */
this._rlf = NEVER
/** @type {any} */
this._rls = NEVER
/** @type {any} */
this._rlsw = NEVER
/** @type {any} */
this._rlfo = NEVER
/** @type {any} */
this._rlso = NEVER
/** @type {number} */
this._rlid = -1
}
_resetStyleCache() {
this._lf = NEVER // fill
this._ls = NEVER // stroke
this._lsw = NEVER
this._ld = NEVER
this._lfo = NEVER
this._lso = NEVER
this._lid = -1
this._lofFill = NEVER // last fill-override (scatter per-point fill)
this._lofBase = -1
this._lofId = -1
this._rlf = NEVER // rect-cell last style
this._rls = NEVER
this._rlsw = NEVER
this._rlfo = NEVER
this._rlso = NEVER
this._rlid = -1
}
/** Start a fresh scene (columnar marker store + object-command list). */
reset() {
this._list = []
this._mn = 0
this._styles = []
this._styleMap = new Map()
this._resetStyleCache()
// Size the marker columns to the upper bound of points this render can emit.
const series = this.w.config.series || []
let cap = 16
for (let i = 0; i < series.length; i++) {
const d = series[i] && series[i].data
if (Array.isArray(d)) cap += d.length
}
cap = Math.ceil(cap * 1.15) + 16
if (cap > this._mcap) this._allocMarkers(cap)
// Rect cells share the same upper bound (one cell per data point).
this._crn = 0
this._cellRadius = 0
if (cap > this._crcap) this._allocRects(cap)
}
/** @param {number} cap */
_allocRects(cap) {
this._crcap = cap
this._crx = new Float64Array(cap)
this._cry = new Float64Array(cap)
this._crw = new Float64Array(cap)
this._crh = new Float64Array(cap)
this._crstyle = new Int32Array(cap)
this._crsi = new Int32Array(cap)
this._crdi = new Int32Array(cap)
}
/** Grow the rect columns (rare: capacity estimate was low). */
_growRects() {
const cap = this._crcap * 2
const nx = new Float64Array(cap)
nx.set(this._crx)
this._crx = nx
const ny = new Float64Array(cap)
ny.set(this._cry)
this._cry = ny
const nw = new Float64Array(cap)
nw.set(this._crw)
this._crw = nw
const nh = new Float64Array(cap)
nh.set(this._crh)
this._crh = nh
const nst = new Int32Array(cap)
nst.set(this._crstyle)
this._crstyle = nst
const nsi = new Int32Array(cap)
nsi.set(this._crsi)
this._crsi = nsi
const ndi = new Int32Array(cap)
ndi.set(this._crdi)
this._crdi = ndi
this._crcap = cap
}
/** @param {number} cap */
_allocMarkers(cap) {
this._mcap = cap
this._mx = new Float64Array(cap)
this._my = new Float64Array(cap)
this._msize = new Float64Array(cap)
this._mshape = new Int16Array(cap)
this._mstyle = new Int32Array(cap)
this._msi = new Int32Array(cap)
}
/** Grow the marker columns (rare: capacity estimate was low). */
_growMarkers() {
const cap = this._mcap * 2
const nx = new Float64Array(cap)
nx.set(this._mx)
this._mx = nx
const ny = new Float64Array(cap)
ny.set(this._my)
this._my = ny
const ns = new Float64Array(cap)
ns.set(this._msize)
this._msize = ns
const nsh = new Int16Array(cap)
nsh.set(this._mshape)
this._mshape = nsh
const nst = new Int32Array(cap)
nst.set(this._mstyle)
this._mstyle = nst
const nsi = new Int32Array(cap)
nsi.set(this._msi)
this._msi = nsi
this._mcap = cap
}
displayList() {
return this._list
}
markerCount() {
return this._mn
}
/**
* Intern a marker style; returns its palette id. Keeps the per-point columns
* numeric (no retained per-point object).
* @param {any} fill @param {any} stroke @param {any} sw @param {any} dash
* @param {any} fo @param {any} so
* @returns {number}
*/
_internStyle(fill, stroke, sw, dash, fo, so) {
const key = `${fill}|${stroke}|${sw}|${dash}|${fo}|${so}`
const cached = this._styleMap.get(key)
if (cached !== undefined) return cached
const id = this._styles.length
this._styles.push({
fill,
stroke,
strokeWidth: sw,
strokeDash: dash,
fillOpacity: fo,
strokeOpacity: so,
})
this._styleMap.set(key, id)
return id
}
/**
* Override a recorded marker's fill (scatter sets a per-point fill via `attr`
* right after drawMarker). Like the draw path, the string-key intern is cached
* on (base style, fill) so the Map/string work stays OFF the per-point path
* (it otherwise mixes with the `_mstyle[i]` typed-array write → the ~80× slow
* path). For a scatter series the base + fill are uniform, so it interns once.
* @param {number} i @param {any} fill
*/
_setMarkerFill(i, fill) {
const base = this._mstyle[i]
if (fill === this._lofFill && base === this._lofBase) {
this._mstyle[i] = this._lofId
return
}
const s = this._styles[base]
if (!s || s.fill === fill) {
this._lofFill = fill
this._lofBase = base
this._lofId = base
return
}
const id = this._internStyle(
fill,
s.stroke,
s.strokeWidth,
s.strokeDash,
s.fillOpacity,
s.strokeOpacity,
)
this._mstyle[i] = id
this._lofFill = fill
this._lofBase = base
this._lofId = id
}
/** @param {number} i @returns {any} the style object for a marker index */
markerStyle(i) {
return this._styles[this._mstyle[i]]
}
/** @param {number} i @returns {number} series (realIndex) of a marker, -1 if none */
markerSeries(i) {
return this._msi[i]
}
/** @param {number} id @returns {string} */
shapeName(id) {
return SHAPE_NAME[id] || 'circle'
}
// ── columnar rect cell (heatmap): parallel unboxed arrays, no per-cell object ──
/**
* Record a heatmap-style cell (a filled, optionally stroked rect). Geometry
* and style are captured up front into the columns; the returned handle is a
* shared no-op (the emit site sets nothing back on it in canvas mode).
* @param {number} x @param {number} y @param {number} w @param {number} h
* @param {any} opts {fill, fillOpacity, stroke, strokeWidth, radius, seriesIndex, dataPointIndex}
* @returns {any}
*/
drawRectCell(x, y, w, h, opts = {}) {
const styleId = this._rectStyleId(opts)
if (this._crn >= this._crcap) this._growRects()
const i = this._crn++
this._crx[i] = x || 0
this._cry[i] = y || 0
this._crw[i] = w > 0 ? w : 0
this._crh[i] = h > 0 ? h : 0
this._crstyle[i] = styleId
this._crsi[i] = opts.seriesIndex == null ? -1 : opts.seriesIndex
this._crdi[i] = opts.dataPointIndex == null ? -1 : opts.dataPointIndex
// Corner radius is uniform across a heatmap; record the latest non-zero one.
if (opts.radius) this._cellRadius = opts.radius
return SHARED_RECT_REF
}
/**
* Resolve (and dedupe) a rect-cell style → shared-palette id. A last-style
* cache keeps the Map/string work off the path for runs of same-style cells.
* @param {any} opts
* @returns {number}
*/
_rectStyleId(opts) {
const fill = opts.fill
const stroke = opts.stroke
const sw = opts.strokeWidth
const fo = opts.fillOpacity
const so = opts.strokeOpacity
if (
fill === this._rlf &&
stroke === this._rls &&
sw === this._rlsw &&
fo === this._rlfo &&
so === this._rlso
) {
return this._rlid
}
const id = this._internStyle(fill, stroke, sw, 0, fo, so)
this._rlf = fill
this._rls = stroke
this._rlsw = sw
this._rlfo = fo
this._rlso = so
this._rlid = id
return id
}
/** @returns {number} number of recorded rect cells */
rectCount() {
return this._crn
}
/** @param {number} i @returns {any} the style object for a rect cell */
rectStyle(i) {
return this._styles[this._crstyle[i]]
}
/** @param {number} i @returns {number} series (realIndex) of a cell, -1 if none */
rectSeries(i) {
return this._crsi[i]
}
/**
* @param {string} tag
* @param {number} z
* @returns {any}
*/
_cmd(tag, z) {
const cmd = {
tag,
z: z || 0,
fill: undefined,
stroke: undefined,
strokeWidth: undefined,
strokeDash: undefined,
lineCap: undefined,
fillOpacity: undefined,
strokeOpacity: undefined,
fillRule: undefined,
}
this._list.push(cmd)
return cmd
}
// ── organizational (groups don't paint or record) ──
// Series draw() creates a wrap group PER POINT (scatter.draw / plotChartMarkers
// run per point), so allocating a handle per group is ~50k heavy allocations
// at scale: enough transient churn to tip V8 into a GC blow-up. Groups carry
// no paint state in canvas mode (attr/add are no-ops), so every group shares
// one singleton: zero per-point allocation.
/** @param {any} _attrs */
group(_attrs) {
return SHARED_GROUP
}
// ── per-point marker (line/area markers, scatter, bubble): COLUMNAR ──
/**
* @param {number} x
* @param {number} y
* @param {any} opts
*/
drawMarker(x, y, opts = {}) {
// Style resolution (string key + Map intern) lives in a SEPARATE method from
// the typed-array stores below. Co-locating string/Map work with typed-array
// element stores in one hot function tips V8 into a pathological slow path
// (measured ~80× at 50k points); split, each stays optimized. `styleId`
// indexes the deduped style palette.
const styleId = this._markerStyleId(opts)
if (this._mn >= this._mcap) this._growMarkers()
const i = this._mn++
this._mx[i] = x || 0
this._my[i] = typeof y === 'number' ? y : NaN
this._msize[i] = opts.pSize || 0
this._mshape[i] = SHAPE_ID[opts.shape || 'circle'] ?? 0
this._mstyle[i] = styleId
// Series identity (realIndex) so per-series restyle (hover/legend dim) can
// repaint markers without recomputing geometry. Both marker emitters pass
// realIndex as opts.seriesIndex (Markers.getMarkerConfig, Scatter.drawPoint).
this._msi[i] = opts.seriesIndex == null ? -1 : opts.seriesIndex
return new CanvasMarkerRef(this, i)
}
/**
* Resolve (and dedupe) a marker style → palette id. A last-style cache keeps
* the Map/string work off the path when consecutive markers share a style
* (the common case), so intern runs once per style run.
* @param {any} opts
* @returns {number}
*/
_markerStyleId(opts) {
const shape = opts.shape || 'circle'
const strokeTinted = shape === 'line' || shape === 'plus' || shape === 'cross'
const fill = strokeTinted ? 'none' : opts.pointFillColor
const stroke = strokeTinted ? opts.pointFillColor : opts.pointStrokeColor
const sw = opts.pointStrokeWidth
const dash = opts.pointStrokeDashArray
const fo = opts.pointFillOpacity
const so = strokeTinted ? opts.pointFillOpacity : opts.pointStrokeOpacity
if (
fill === this._lf &&
stroke === this._ls &&
sw === this._lsw &&
dash === this._ld &&
fo === this._lfo &&
so === this._lso
) {
return this._lid
}
const id = this._internStyle(fill, stroke, sw, dash, fo, so)
this._lf = fill
this._ls = stroke
this._lsw = sw
this._ld = dash
this._lfo = fo
this._lso = so
this._lid = id
return id
}
// ── series body path (line/area/bar): object command ──
/** @param {any} opts */
renderPaths(opts) {
const cmd = this._cmd('path', opts.realIndex)
cmd.d = opts.pathTo
// Numeric fast-path coords (plain straight line/area): painted with a
// direct moveTo/lineTo loop, no d-string Path2D parse. closeY (area)
// drops the fill down to the baseline and closes the polygon.
if (opts.pathToNumeric) {
cmd.nxs = opts.pathToNumeric.xs
cmd.nys = opts.pathToNumeric.ys
cmd.ncloseY = opts.pathToNumeric.closeY
}
cmd.stroke = opts.stroke
cmd.strokeWidth = opts.strokeWidth
cmd.fill = opts.fill
cmd.lineCap = opts.strokeLinecap
cmd.si = opts.realIndex
// Mirror the SVG renderPaths side effect so downstream "wait for animation"
// logic doesn't stall (canvas P2 paints the final frame directly).
this.w.globals.animationEnded = true
return new CanvasMark(cmd)
}
/** @param {any} opts */
drawPath(opts) {
const cmd = this._cmd('path', 0)
cmd.d = opts.d
cmd.stroke = opts.stroke
cmd.strokeWidth = opts.strokeWidth
cmd.fill = opts.fill
cmd.fillOpacity = opts.fillOpacity
cmd.strokeOpacity = opts.strokeOpacity
cmd.lineCap = opts.strokeLinecap
cmd.strokeDash = opts.strokeDashArray
return new CanvasMark(cmd)
}
// ── remaining primitives (contract completeness / Marks #11 forward-compat) ──
/**
* @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2
* @param {string} lineColor @param {any} dashArray @param {number} strokeWidth
*/
drawLine(x1, y1, x2, y2, lineColor = '#a8a8a8', dashArray = 0, strokeWidth = 1) {
const cmd = this._cmd('line', 0)
cmd.lx1 = x1
cmd.ly1 = y1
cmd.lx2 = x2
cmd.ly2 = y2
cmd.stroke = lineColor
cmd.strokeDash = dashArray
cmd.strokeWidth = strokeWidth
return new CanvasMark(cmd)
}
/**
* Mirrors Graphics.drawRect's full signature (including stroke), so callers
* that stroke rects (Marks api.rect, chart code) paint the same on canvas.
* @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2
* @param {number} radius @param {string} color @param {number} opacity
* @param {number|null} [strokeWidth] @param {string|null} [strokeColor]
* @param {any} [strokeDashArray]
*/
drawRect(
x1 = 0,
y1 = 0,
x2 = 0,
y2 = 0,
radius = 0,
color = '#fefefe',
opacity = 1,
strokeWidth = null,
strokeColor = null,
strokeDashArray = 0,
) {
const cmd = this._cmd('rect', 0)
cmd.x1 = x1
cmd.y1 = y1
cmd.rw = x2 > 0 ? x2 : 0
cmd.rh = y2 > 0 ? y2 : 0
cmd.radius = radius
cmd.fill = color
cmd.fillOpacity = opacity
if (strokeColor != null) {
cmd.stroke = strokeColor
cmd.strokeWidth = strokeWidth == null ? 1 : strokeWidth
cmd.strokeDash = strokeDashArray
}
return new CanvasMark(cmd)
}
/**
* @param {number} radius
* @param {any} attrs
*/
drawCircle(radius, attrs = null) {
const cmd = this._cmd('circle', 0)
cmd.r = radius < 0 ? 0 : radius
if (attrs) {
cmd.cx = attrs.cx
cmd.cy = attrs.cy
if (attrs.fill !== undefined) cmd.fill = attrs.fill
if (attrs.stroke !== undefined) cmd.stroke = attrs.stroke
}
return new CanvasMark(cmd)
}
/** @param {any} opts */
drawText(opts) {
const cmd = this._cmd('text', 0)
cmd.text = Array.isArray(opts.text) ? opts.text.join(' ') : opts.text
cmd.tx = opts.x
cmd.ty = opts.y
cmd.textAnchor = opts.textAnchor || 'start'
cmd.fontSize = opts.fontSize
cmd.fontFamily = opts.fontFamily
cmd.fill = opts.foreColor
return new CanvasMark(cmd)
}
/**
* Resolve a marker's SVG path `d` (non-circle shapes) lazily at paint time.
* @param {number} x @param {number} y @param {number} shapeId @param {number} size
* @returns {string}
*/
markerPath(x, y, shapeId, size) {
return this._g.getMarkerPath(x, y, SHAPE_NAME[shapeId] || 'circle', size)
}
}
export { CanvasMark, CanvasMarkerRef }