UNPKG

apexcharts

Version:

A JavaScript Chart Library

308 lines (289 loc) 12 kB
// @ts-check import Graphics from '../modules/Graphics' import Utils from '../utils/Utils' import { seriesEmitter } from '../renderers/Renderer' /** * Marks (#11): build a chart-type class from a `registerSeriesType` definition. * * The returned class implements the standard type-class contract * (`new Ctor(w, ctx, xyRatios).draw(series, ctype, iArray)` returning an SVG * group), so it slots into `Core.plotChartType` exactly like Bar/Line and * inherits the Strata canvas paint wrap. Series marks are emitted through * `seriesEmitter`, so a custom series paints to canvas above `rendererThreshold` * with no per-type change. * * `def.renderItem({datum, x, y, scales, api, seriesIndex, dataPointIndex, color})` * is called once per datum and draws primitives via the `api` (path/line/rect/ * circle/text). The `api` emits immediately, tags each node with the datum's * `index`/`j` (so event delegation + tooltip + keyboard light up), and adds it * to the series group. `x`/`y` are the datum's resolved series-space pixels; * `scales` converts any other data value. * * @param {string} name * @param {{ renderItem: Function, dataType?: string, yExtent?: Function, tooltip?: Function }} def * @returns {new (w:any, ctx:any, xyRatios:any) => any} * @module charts/CustomSeries */ export function makeCustomSeriesClass(name, def) { const cls = class CustomSeries { /** * @param {any} w @param {any} ctx @param {any} xyRatios */ constructor(w, ctx, xyRatios) { this.w = w this.ctx = ctx this.xyRatios = xyRatios this._warned = false } /** * @param {any[]} series parsed y-arrays (one per drawn series) * @param {string} [_ctype] * @param {number[]} [seriesIndices] realIndex per entry (combo dispatch) * @returns {any} the wrap group */ draw(series, _ctype, seriesIndices) { const w = this.w // ctx-bearing so the delegated pointer handlers fire chart events with a // proper ctx (Graphics defaults ctx to null). const graphics = new Graphics(w, this.ctx) const emit = seriesEmitter(this.ctx, graphics) const ret = graphics.group({ class: 'apexcharts-marks-series' }) series.forEach((_s, idx) => { const realIndex = Array.isArray(seriesIndices) ? seriesIndices[idx] : idx const elSeries = graphics.group({ class: 'apexcharts-series', rel: realIndex + 1, seriesName: Utils.escapeString(w.seriesData.seriesNames[realIndex]), 'data:realIndex': realIndex, }) const scales = this._scales( realIndex, (w.seriesData.series[realIndex] || []).length, ) const color = w.globals.colors[realIndex] const rawData = /** @type {any} */ (w.config.series[realIndex])?.data || [] const xvals = w.seriesData.seriesX[realIndex] || [] const yvals = w.seriesData.series[realIndex] || [] // Coordinate caches so the shared tooltip / crosshair / keyboard nav // target custom marks like any built-in series (populated per datum). w.globals.seriesXvalues[realIndex] = [] w.globals.seriesYvalues[realIndex] = [] if (typeof w.globals.pointsArray[realIndex] === 'undefined') { w.globals.pointsArray[realIndex] = [] } for (let j = 0; j < yvals.length; j++) { const yVal = yvals[j] if (yVal === null || typeof yVal === 'undefined') continue const xVal = xvals[j] // xAt resolves numeric x by value and categorical x (band axis, e.g. // xaxis.tickPlacement:'between', where seriesX holds label strings) by // index, so ctx.x + the coordinate caches are always valid pixels. const xPx = scales.xAt(j, xVal) const yPx = scales.y(yVal) const api = this._api(emit, elSeries, realIndex, j) try { def.renderItem({ datum: rawData[j], x: xPx, y: yPx, scales, api, seriesIndex: realIndex, dataPointIndex: j, color, }) } catch (e) { if (!this._warned) { console.warn( `[apexcharts] renderItem for series type "${name}" threw; skipping datum:`, e, ) this._warned = true } } // seriesXvalues/seriesYvalues hold PIXEL coordinates (matching the // built-ins, e.g. Line.js paths.xArrj/yArrj), NOT data values: the // shared-tooltip closest-point detector compares the hover pixel // against these. Storing data values here made every hover resolve to // the max-x point. pointsArray likewise holds pixels. w.globals.seriesXvalues[realIndex][j] = xPx w.globals.seriesYvalues[realIndex][j] = yPx w.globals.pointsArray[realIndex][j] = [xPx, yPx] } // Event delegation so hovering/clicking a mark fires dataPointMouseEnter // / dataPointSelection with the datum's (index, j). Coordinate-based // tooltip/crosshair also work off the caches above. (Inert on canvas, // where marks have no DOM node and events are coordinate-based.) graphics.setupEventDelegation(elSeries, '.apexcharts-marks-mark') ret.add(elSeries) }) // Custom series render their final geometry directly (no animation bridge // yet), so complete the animation state the way a non-animated built-in // render does: without this, animationEnded stays false forever, which // blocks redrawOnParentResize, keeps overlay layers hidden, and never // fires the user's animationEnd event. (In a combo with an animated // built-in series this completes early; animationCompleted itself is // idempotent and does not cancel running tweens.) this.ctx.animations?.animationCompleted?.(ret) return ret } /** * Series-space (elGraphical-local, translate-free) scales, matching how the * built-ins compute pixels, so custom marks align with axes and gridlines * and paint correctly on the elGraphical-local canvas. * @param {number} realIndex * @param {number} [nPts] number of data points (for categorical band sizing) */ _scales(realIndex, nPts) { const gl = this.w.globals const cnf = this.w.config const xRatio = this.xyRatios.xRatio const yRatioArr = this.xyRatios.yRatio const axis = gl.seriesYAxisReverseMap?.[realIndex] ?? 0 const yr = Array.isArray(yRatioArr) ? yRatioArr[axis] ?? yRatioArr[0] : yRatioArr const maxYArr = /** @type {any} */ (gl).maxYArr const maxY = Array.isArray(maxYArr) && maxYArr.length ? maxYArr[axis] ?? gl.maxY : gl.maxY const gridWidth = gl.gridWidth const gridHeight = gl.gridHeight // Categorical band axis: when x is NOT numeric (e.g. a category axis with // xaxis.tickPlacement:'between', where the parser keeps seriesX as label // strings), x() cannot map a value, so points are placed by INDEX in one // of N equal bands. The default category axis is converted to numeric // (isXNumeric true), so this only affects the band-axis case. const catMode = !gl.isXNumeric const n = nPts || gl.dataPoints || 1 const bandW = n > 0 ? gridWidth / n : gridWidth const tickOn = cnf.xaxis.tickPlacement === 'on' /** @param {number} v */ const x = (v) => (v - gl.minX) / xRatio /** @param {number} v */ const y = (v) => (maxY - v) / yr // Resolve a datum's x pixel: numeric axes map by value; categorical band // axes map by index (band center, or the tick for tickPlacement:'on'). /** @param {number} index @param {any} v */ const xAt = (index, v) => { if (!catMode) return x(v) if (tickOn && n > 1) return (index / (n - 1)) * gridWidth return (index + 0.5) * bandW } // Pixel span of one x step, for bar-like custom shapes. On a numeric axis // x() maps data as v/xRatio (xRatio = data-units-per-pixel), so one step // (minXDiff data units) is minXDiff/xRatio pixels; on a band axis it is // one band. Guard a zero/absent xRatio (single-point/degenerate domains). const step = gl.minXDiff || 1 const band = catMode ? bandW : xRatio ? step / xRatio : gridWidth return { x, xAt, y, gridWidth, gridHeight, band, } } /** * Per-datum primitive API. Each call emits immediately (canvas-aware via * `emit`), tags the node with the datum's identity, and adds it to the * series group; on canvas the tag/add are inert (marks live on the canvas, * events are coordinate-based). * @param {any} emit @param {any} elSeries @param {number} realIndex @param {number} j */ _api(emit, elSeries, realIndex, j) { /** @param {any} el */ const tag = (el) => { if (el) { try { el.node.setAttribute('index', String(realIndex)) el.node.setAttribute('j', String(j)) el.node.classList.add('apexcharts-marks-mark') } catch (e) { /* canvas marks have a no-op node */ } elSeries.add(el) } return el } return { /** @param {any} o */ path: (o = {}) => tag( emit.drawPath({ d: o.d || '', stroke: o.stroke ?? '#000', strokeWidth: o.width ?? o.strokeWidth ?? 1, fill: o.fill ?? 'none', fillOpacity: o.fillOpacity ?? (o.fill && o.fill !== 'none' ? o.opacity ?? 1 : 0), strokeOpacity: o.strokeOpacity ?? o.opacity ?? 1, strokeDashArray: o.dash ?? 0, strokeLinecap: o.lineCap, }), ), /** @param {any} o */ line: (o = {}) => tag( emit.drawLine( o.x1, o.y1, o.x2, o.y2, o.stroke ?? '#000', o.dash ?? 0, o.width ?? o.strokeWidth ?? 1, ), ), /** @param {any} o */ rect: (o = {}) => tag( emit.drawRect( o.x ?? 0, o.y ?? 0, o.w ?? 0, o.h ?? 0, o.r ?? 0, o.fill ?? '#000', o.opacity ?? 1, o.stroke != null ? o.strokeWidth ?? 1 : null, o.stroke, ), ), /** @param {any} o */ circle: (o = {}) => tag( emit.drawCircle(o.r ?? 0, { cx: o.cx ?? 0, cy: o.cy ?? 0, fill: o.fill ?? '#000', stroke: o.stroke || 'none', 'stroke-width': o.strokeWidth ?? (o.stroke ? 1 : 0), }), ), /** @param {any} o */ text: (o = {}) => tag( emit.drawText({ x: o.x ?? 0, y: o.y ?? 0, text: o.text ?? '', textAnchor: o.anchor ?? 'start', fontSize: o.size, foreColor: o.color, fontWeight: o.weight, }), ), } } } // Marks (#11) P3: static descriptors read by Data.parseData so a custom type // with range/extent semantics folds BOTH y-bounds into the axis scale. // `dataType:'rangeXY'` handles the y:[lo,hi] datum (dumbbell); `yExtent` // is a per-datum override for shapes whose drawn span isn't just `y` // (bullet targets/qualitative bands). Both route through the range-data path. cls.dataType = def.dataType || 'xy' cls.yExtent = typeof def.yExtent === 'function' ? def.yExtent : null return cls }