UNPKG

apexcharts

Version:

A JavaScript Chart Library

1,227 lines (1,135 loc) 43 kB
// @ts-check import Utils from '../../utils/Utils' import Options from '../settings/Options' /** * Ink Layer (#7): direct-manipulation annotation authoring. * * Turns declarative annotations into point-and-drag editing. Opt in per * annotation with `<annotation>.draggable: true`, or globally with * `chart.ink.enabled: true` (every annotation becomes draggable unless it sets * `draggable: false`). Tree-shakeable `ink` feature; eager module (`ctx.ink`) * that rebinds handlers after each (re)render. * * Capabilities: * - P1 drag point annotations (marker + label move together). * - P2 double-click a label to edit its text inline. * - P3 click-to-create: `chart.ink.palette` / `ctx.ink.startCreate()` arms * create mode; the next plot click drops an editable, draggable note. * - P4 drag xaxis/yaxis annotations (a line moves along its axis; a range moves * both edges) and resize an xaxis range by dragging its left/right edge. * - P6 floating note editor: click (or double-click) an ink-managed annotation * to open a card anchored to it: rename inline, recolor (swatches), toggle * bold, step the font size, size/reshape the marker, or delete the note. * * Mechanics: annotation elements are paths/lines/rects appended un-grouped to * their `.apexcharts-<type>-annotations` group and share the annotation's `id` * CSS class, so Ink uses its own pointer handler (found by `.<id>`) rather than * SVGElement.draggable (which moves via x/y attributes). During a drag the * elements get a live `transform` (move) or the rect is stretched (resize); on * release the pixel delta is inverted to data, written to the annotation's * config slot, and the annotation is re-drawn at its new anchor via a targeted * redraw (no full re-render, and repeat-safe unlike updateOptions({})). * * P4 covers numeric/datetime x + linear (non-log) y on a non-inverted axis; a * category x keeps its x. Editing works for every annotation type. * * @module modules/ink/InkLayer */ const DRAG_CLASS = 'apexcharts-ink-draggable' const TYPES = ['point', 'xaxis', 'yaxis'] const EDGE_PX = 8 // A press that travels no further than this is a click (select), not a drag. const CLICK_SLOP_PX = 2 const FONT_STEPS = [10, 11, 12, 14, 17, 20] const MARKER_SHAPES = ['circle', 'square', 'diamond', 'triangle'] /** @type {Record<string, string>} */ const SHAPE_GLYPHS = { circle: '●', square: '■', diamond: '◆', triangle: '▲', } // Default note accent swatches; override with chart.ink.noteColors. const NOTE_COLORS = [ '#ffffff', '#334155', '#2563eb', '#16a34a', '#d97706', '#dc2626', ] const TRASH_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>' export default class InkLayer { /** * @param {import('../../types/internal').ChartStateW} w * @param {import('../../types/internal').ChartContext} ctx */ constructor(w, ctx) { this.w = w this.ctx = ctx this._wired = false /** @type {any} */ this._drag = null /** @type {any} */ this._editor = null this._creating = false this._createSeq = 0 this._attach = this._attach.bind(this) this._onRerender = this._onRerender.bind(this) this._onMove = this._onMove.bind(this) this._onUp = this._onUp.bind(this) this._onCreateClick = this._onCreateClick.bind(this) this._onDocDownEditor = this._onDocDownEditor.bind(this) if ( this._enabledGlobally() || this._hasDraggable() || this._paletteEnabled() ) { this._wire() } } _enabledGlobally() { const ink = this.w.config.chart.ink return !!(ink && ink.enabled) } _paletteEnabled() { const ink = this.w.config.chart.ink return !!(ink && ink.palette) } _snapEnabled() { const ink = this.w.config.chart.ink return !!(ink && ink.snap) } // ─── P5: snap to gridlines ──────────────────────────────────────────────── /** @param {number} v @param {number[]} ticks @returns {number} nearest tick */ _nearest(v, ticks) { let best = v let bd = Infinity for (let i = 0; i < ticks.length; i++) { const d = Math.abs(ticks[i] - v) if (d < bd) { bd = d best = ticks[i] } } return best } /** * Snap an x value to the nearest x gridline (numeric axes only). * @param {number} x */ _snapX(x) { if (!this._snapEnabled() || typeof x !== 'number') return x const s = this.w.globals.xAxisScale return s && Array.isArray(s.result) && s.result.length ? this._nearest(x, s.result) : x } /** * Snap a y value to the nearest y gridline. * @param {number} y @param {number} si */ _snapY(y, si) { if (!this._snapEnabled() || typeof y !== 'number') return y const scales = this.w.globals.yAxisScale const s = scales && scales[si] return s && Array.isArray(s.result) && s.result.length ? this._nearest(y, s.result) : y } /** @param {string} type @returns {any[]} the config annotations of a type */ _annoList(type) { const a = this.w.config.annotations if (!a) return [] const key = type === 'point' ? 'points' : type return Array.isArray(a[key]) ? a[key] : [] } /** @param {any} anno */ _isDraggable(anno) { if (!anno) return false if (anno.draggable === true) return true if (anno.draggable === false) return false return this._enabledGlobally() } _hasDraggable() { return TYPES.some((t) => this._annoList(t).some((p) => this._isDraggable(p))) } _wire() { if (this._wired) return this._wired = true // Listeners survive update(); the DOM is rebound after each (re)render. this.ctx.addEventListener('mounted', this._onRerender) this.ctx.addEventListener('updated', this._onRerender) } /** * A full (re)render rebuilds the SVG and may swap the annotations config * (updateOptions, undo restore), so an open editor card points at stale * state: drop it (without committing) before rebinding handlers. Targeted * redraws call _attach() directly and keep the card open. */ _onRerender() { this._closeEditor(false) this._attach() } /** * After each (re)render, bind drag + edit handlers to every draggable * annotation's elements. Idempotent via a per-node flag so a targeted redraw * re-runs this without double-binding the untouched annotations. */ _attach() { const w = this.w const baseEl = w.dom.baseEl if (!baseEl) return TYPES.forEach((type) => { this._annoList(type).forEach((anno, index) => { if (!this._isDraggable(anno)) return if (!anno.id) { anno.id = 'apexcharts-ink-' + type + '-' + index + '-' + w.globals.chartID } baseEl.querySelectorAll('.' + anno.id).forEach((/** @type {any} */ el) => { if (el.__inkBound) return el.__inkBound = true el.style.cursor = 'move' el.classList.add(DRAG_CLASS) el.addEventListener('mousedown', (/** @type {any} */ e) => this._onDown(e, type, index), ) el.addEventListener('touchstart', (/** @type {any} */ e) => this._onDown(e, type, index), ) el.addEventListener('dblclick', (/** @type {any} */ e) => { e.preventDefault() e.stopPropagation() this._startEdit(type, index, { select: true }) }) }) }) }) if (this._paletteEnabled()) this._renderPalette() } // ─── drag / resize ──────────────────────────────────────────────────────── /** * @param {any} e @param {string} type @param {number} index */ _onDown(e, type, index) { if (e.button && e.button !== 0) return const w = this.w const doc = w.dom.baseEl && w.dom.baseEl.ownerDocument if (!doc) return // Stop ZoomPanSelection from treating this as a new rubber-band selection. e.stopPropagation() if (e.cancelable) e.preventDefault() const isTouch = e.type === 'touchstart' const ev = isTouch ? e.touches[0] : e const svgRoot = w.dom.Paper && w.dom.Paper.node const ctm = svgRoot && svgRoot.getScreenCTM ? svgRoot.getScreenCTM() : null const anno = this._annoList(type)[index] // Mode: an xaxis range grabbed near an edge resizes; everything else moves. let mode = 'move' /** @type {any} */ let rect = null let origX = 0 let origW = 0 if (type === 'xaxis' && anno.x2 != null) { rect = w.dom.baseEl.querySelector('.apexcharts-annotation-rect.' + anno.id) if (rect) { const r = rect.getBoundingClientRect() if (Math.abs(ev.clientX - r.left) <= EDGE_PX) mode = 'resize-x1' else if (Math.abs(ev.clientX - r.right) <= EDGE_PX) mode = 'resize-x2' origX = parseFloat(rect.getAttribute('x')) || 0 origW = parseFloat(rect.getAttribute('width')) || 0 } } this._drag = { type, index, anno, els: Array.from(w.dom.baseEl.querySelectorAll('.' + anno.id)), mode, rect, origX, origW, startX: ev.clientX, startY: ev.clientY, scaleX: ctm && ctm.a ? ctm.a : 1, scaleY: ctm && ctm.d ? ctm.d : 1, dxPixel: 0, dyPixel: 0, moved: false, } doc.addEventListener('mousemove', this._onMove) doc.addEventListener('touchmove', this._onMove, { passive: false }) doc.addEventListener('mouseup', this._onUp) doc.addEventListener('touchend', this._onUp) } /** @param {any} me */ _onMove(me) { const d = this._drag if (!d) return if (me.cancelable) me.preventDefault() const mev = me.type === 'touchmove' ? me.touches[0] : me d.dxPixel = (mev.clientX - d.startX) / d.scaleX d.dyPixel = (mev.clientY - d.startY) / d.scaleY if (Math.abs(d.dxPixel) > CLICK_SLOP_PX || Math.abs(d.dyPixel) > CLICK_SLOP_PX) { d.moved = true } if (d.mode === 'move') { const t = `translate(${d.dxPixel} ${d.dyPixel})` d.els.forEach((/** @type {any} */ el) => el.setAttribute('transform', t)) } else if (d.rect) { if (d.mode === 'resize-x1') { d.rect.setAttribute('x', d.origX + d.dxPixel) d.rect.setAttribute('width', Math.max(1, d.origW - d.dxPixel)) } else if (d.mode === 'resize-x2') { d.rect.setAttribute('width', Math.max(1, d.origW + d.dxPixel)) } } } _onUp() { const d = this._drag this._drag = null this._teardownDocListeners() if (!d || !d.moved) { if (d) { d.els.forEach((/** @type {any} */ el) => el.removeAttribute('transform')) // A press without a drag is a select: open the note editor card. this._startEdit(d.type, d.index) } return } const anno = this._annoList(d.type)[d.index] if (!anno) return this._applyDelta(d, anno) d.els.forEach((/** @type {any} */ el) => el.removeAttribute('transform')) this._redrawAnno(d.type, anno, d.index) this._checkpoint('ink:drag') this._fireDragged(d.type, anno, d.index) } /** * Record a Rewind (undo) checkpoint for an ink edit. Targeted redraws fire no * 'updated' event, so History would otherwise miss them. No-op when the * history feature is absent or disabled. * @param {string} label */ _checkpoint(label) { this.ctx.history?.snapshot?.(label) } /** * Mutate the annotation's config from the pixel drag delta (type + mode aware). * @param {any} d @param {any} anno */ _applyDelta(d, anno) { const w = this.w const dxData = w.layout.gridWidth ? d.dxPixel * (w.globals.xRange / w.layout.gridWidth) : 0 if (d.type === 'point') { const { newX, newY } = this._invertPoint(anno, d.dxPixel, d.dyPixel) anno.x = this._snapX(newX) if (newY != null) { const yi = anno.yAxisIndex || 0 const map = w.globals.seriesYAxisMap anno.y = this._snapY(newY, map && map[yi] ? map[yi][0] : 0) } return } if (d.type === 'xaxis') { if (typeof anno.x !== 'number') return // category/string x: leave as-is if (d.mode === 'move') { if (typeof anno.x2 === 'number') { // range move: shift both edges, keep the span (no snap) anno.x += dxData anno.x2 += dxData } else { // line move: snap the single value to a gridline anno.x = this._snapX(anno.x + dxData) } } else if (d.mode === 'resize-x1' || d.mode === 'resize-x2') { // resize: the left edge is the smaller value, the right edge the larger const xIsLeft = anno.x2 == null || anno.x <= anno.x2 const grow = d.mode === 'resize-x2' ? !xIsLeft : xIsLeft // grow === true -> adjust anno.x, else adjust anno.x2 (snap the edge) if (grow) anno.x = this._snapX(anno.x + dxData) else if (typeof anno.x2 === 'number') anno.x2 = this._snapX(anno.x2 + dxData) } return } if (d.type === 'yaxis') { const yi = anno.yAxisIndex || 0 const map = w.globals.seriesYAxisMap const si = map && map[yi] ? map[yi][0] : 0 const yRange = w.globals.yRange ? w.globals.yRange[si] : null if (yRange == null || !w.layout.gridHeight) return const dyData = -d.dyPixel * (yRange / w.layout.gridHeight) if (typeof anno.y2 === 'number') { // range move: shift both edges, keep the span (no snap) if (typeof anno.y === 'number') anno.y += dyData anno.y2 += dyData } else if (typeof anno.y === 'number') { // line move: snap the single value to a gridline anno.y = this._snapY(anno.y + dyData, si) } } } /** * Invert a pixel drag delta to a point annotation's data x/y. * @param {any} anno @param {number} dxPixel @param {number} dyPixel * @returns {{newX:any, newY:any}} */ _invertPoint(anno, dxPixel, dyPixel) { const w = this.w const categoryX = (w.config.xaxis.type === 'category' || w.config.xaxis.convertedCatToNumeric) && !w.axisFlags.dataFormatXNumeric let newX = anno.x if (!categoryX && typeof anno.x === 'number' && w.layout.gridWidth) { newX = anno.x + dxPixel * (w.globals.xRange / w.layout.gridWidth) } let newY = anno.y const yi = anno.yAxisIndex || 0 const map = w.globals.seriesYAxisMap const si = map && map[yi] ? map[yi][0] : 0 const yRange = w.globals.yRange ? w.globals.yRange[si] : null const logY = w.config.yaxis[yi] && w.config.yaxis[yi].logarithmic if (typeof anno.y === 'number' && yRange != null && !logY && w.layout.gridHeight) { newY = anno.y - dyPixel * (yRange / w.layout.gridHeight) } return { newX, newY } } /** * Targeted redraw of one annotation: drop its elements and re-add the shape + * label + label background at the current config coordinates (no full chart * re-render, and repeat-safe unlike updateOptions({})). * @param {string} type @param {any} anno @param {number} index */ _redrawAnno(type, anno, index) { const w = this.w const baseEl = w.dom.baseEl const annotations = this.ctx.annotations if (!baseEl || !annotations) return baseEl.querySelectorAll('.' + anno.id).forEach((/** @type {any} */ el) => el.remove()) const group = baseEl.querySelector('.apexcharts-' + type + '-annotations') if (!group) return if (type === 'point' && annotations.pointsAnnotations) { annotations.pointsAnnotations.addPointAnnotation(anno, group, index) } else if (type === 'xaxis' && annotations.xAxisAnnotations) { annotations.xAxisAnnotations.addXaxisAnnotation(anno, group, index) } else if (type === 'yaxis' && annotations.yAxisAnnotations) { annotations.yAxisAnnotations.addYaxisAnnotation(anno, group, index) } // The label background is drawn by annotationsBackground() (after the main // draw), not by the per-annotation add, so re-add just this label's bg. const labelEl = baseEl.querySelector( '.apexcharts-' + type + '-annotation-label.' + anno.id, ) if (labelEl && annotations.helpers && anno.label && anno.label.text) { const elRect = annotations.helpers.addBackgroundToAnno(labelEl, anno) if (elRect && labelEl.parentNode) { labelEl.parentNode.insertBefore(elRect.node, labelEl) } } this._attach() // bind the freshly drawn elements (idempotent for the rest) } /** @param {string} type @param {any} anno @param {number} index */ _fireDragged(type, anno, index) { /** @type {any} */ const args = { type, id: anno.id, index, x: anno.x, y: anno.y } if (anno.x2 != null) args.x2 = anno.x2 if (anno.y2 != null) args.y2 = anno.y2 const events = this.w.config.chart.events if (typeof events.annotationDragged === 'function') { events.annotationDragged(this.ctx, args) } this.ctx.events?.fireEvent('annotationDragged', [this.ctx, args]) } // ─── P3: click-to-create ───────────────────────────────────────────────── /** * Enter create mode: the next click on the plot area drops a new draggable * point annotation there and opens its label editor. */ startCreate() { if (this._creating) return const svg = this.w.dom.Paper && this.w.dom.Paper.node if (!svg) return this._creating = true svg.style.cursor = 'crosshair' svg.addEventListener('click', this._onCreateClick, true) this._syncPalette() } /** Leave create mode. */ stopCreate() { if (!this._creating) return this._creating = false const svg = this.w.dom.Paper && this.w.dom.Paper.node if (svg) { svg.style.cursor = '' svg.removeEventListener('click', this._onCreateClick, true) } this._syncPalette() } /** @param {any} e */ _onCreateClick(e) { if (!this._creating) return e.preventDefault() e.stopPropagation() const pos = this._pixelToData(e.clientX, e.clientY) this.stopCreate() if (!pos) return this.createAt(pos.x, pos.y) } /** * Create a draggable note at data coordinates and open its editor card. * Public: the context menu's "Add note here" routes here so its notes are * config-backed too, and thus draggable, editable, persistable and undoable. * @param {any} x @param {any} y @param {{text?: string}} [opts] * @returns {any} the created annotation config */ createAt(x, y, opts = {}) { const w = this.w this._wire() // the note is ink-managed even when chart.ink is otherwise off this._createSeq += 1 const id = 'apexcharts-ink-new-' + this._createSeq + '-' + w.globals.chartID const anno = Utils.extend(new Options().pointAnnotation, { x, y, id, draggable: true, label: { text: opts.text || 'Note' }, }) if (!w.config.annotations) w.config.annotations = {} if (!Array.isArray(w.config.annotations.points)) w.config.annotations.points = [] w.config.annotations.points.push(anno) const index = w.config.annotations.points.length - 1 this._redrawAnno('point', anno, index) this._checkpoint('ink:create') this._fireCreated('point', anno, index) this._startEdit('point', index, { select: true }) return anno } /** * Create a draggable dashed LINE annotation at a data value and open its * editor card: axis 'x' drops a vertical line at a data x, axis 'y' a * horizontal line at a data y. Public: the context menu's "Annotate here" * routes here so its lines are config-backed too, and thus draggable, * editable, persistable and undoable. Lines only: x2/y2 are never set, so * this can never produce a range rectangle. * @param {'x'|'y'} axis @param {any} val * @param {{text?: string, strokeDashArray?: number, color?: string, select?: boolean}} [opts] * @returns {any} the created annotation config */ createLineAt(axis, val, opts = {}) { const w = this.w this._wire() // the line is ink-managed even when chart.ink is otherwise off this._createSeq += 1 const id = 'apexcharts-ink-new-' + this._createSeq + '-' + w.globals.chartID const type = axis === 'y' ? 'yaxis' : 'xaxis' const defaults = type === 'yaxis' ? new Options().yAxisAnnotation : new Options().xAxisAnnotation /** @type {any} */ const over = { id, draggable: true, strokeDashArray: opts.strokeDashArray != null ? opts.strokeDashArray : 4, label: { text: opts.text || '' }, } if (opts.color) { over.borderColor = opts.color over.label.borderColor = opts.color } if (type === 'yaxis') over.y = val else over.x = val const anno = Utils.extend(defaults, over) if (!w.config.annotations) w.config.annotations = {} if (!Array.isArray(w.config.annotations[type])) w.config.annotations[type] = [] w.config.annotations[type].push(anno) const index = w.config.annotations[type].length - 1 this._redrawAnno(type, anno, index) this._checkpoint('ink:create') this._fireCreated(type, anno, index) if (opts.select !== false) this._startEdit(type, index, { select: true }) return anno } /** * Convert a client-space point to data coordinates (absolute, for create). * @param {number} clientX @param {number} clientY * @returns {{x:any, y:any}|null} */ _pixelToData(clientX, clientY) { const w = this.w const gridEl = w.dom.baseEl && w.dom.baseEl.querySelector('.apexcharts-grid') if (!gridEl) return null const g = gridEl.getBoundingClientRect() if (!g.width || !g.height) return null const fx = (clientX - g.left) / g.width const fy = (clientY - g.top) / g.height const minX = w.globals.minX const xRange = w.globals.xRange const minY = w.globals.minYArr && w.globals.minYArr[0] != null ? w.globals.minYArr[0] : w.globals.minY const yRange = w.globals.yRange && w.globals.yRange[0] != null ? w.globals.yRange[0] : w.globals.maxY - w.globals.minY let x = minX + fx * xRange const y = minY + (1 - fy) * yRange const categoryX = (w.config.xaxis.type === 'category' || w.config.xaxis.convertedCatToNumeric) && !w.axisFlags.dataFormatXNumeric if (categoryX) x = Math.round(x) return { x, y } } /** @param {string} type @param {any} anno @param {number} index */ _fireCreated(type, anno, index) { /** @type {any} */ const args = { type, id: anno.id, index } if (typeof anno.x !== 'undefined') args.x = anno.x if (typeof anno.y !== 'undefined') args.y = anno.y const events = this.w.config.chart.events if (typeof events.annotationCreated === 'function') { events.annotationCreated(this.ctx, args) } this.ctx.events?.fireEvent('annotationCreated', [this.ctx, args]) } // ─── P3: tool palette ──────────────────────────────────────────────────── /** Render a minimal "add note" toggle into the chart wrap (once per render). */ _renderPalette() { const w = this.w const elWrap = w.dom.elWrap if (!elWrap || elWrap.querySelector('.apexcharts-ink-palette')) return const doc = elWrap.ownerDocument const bar = doc.createElement('div') bar.className = 'apexcharts-ink-palette' const s = bar.style s.position = 'absolute' s.top = '6px' s.left = '6px' s.zIndex = '15' const btn = doc.createElement('button') btn.type = 'button' btn.className = 'apexcharts-ink-add' btn.textContent = '+ Note' const bs = btn.style bs.cursor = 'pointer' bs.font = '12px sans-serif' bs.padding = '4px 9px' bs.borderRadius = '5px' bs.border = '1px solid #6366f1' bs.color = '#4338ca' bs.background = '#fff' btn.addEventListener('click', (/** @type {any} */ e) => { e.stopPropagation() if (this._creating) this.stopCreate() else this.startCreate() }) bar.appendChild(btn) elWrap.appendChild(bar) this._syncPalette() } /** Reflect create-mode state on the palette button. */ _syncPalette() { const elWrap = this.w.dom.elWrap const btn = /** @type {any} */ ( elWrap && elWrap.querySelector('.apexcharts-ink-add') ) if (!btn) return if (this._creating) { btn.style.background = '#6366f1' btn.style.color = '#fff' btn.textContent = 'Click chart...' } else { btn.style.background = '#fff' btn.style.color = '#4338ca' btn.textContent = '+ Note' } } // ─── P2 + P6: the floating note editor card ────────────────────────────── // Click (or double-click) an ink-managed annotation to open a small card // anchored to it: rename inline, recolor via accent swatches, toggle bold, // step the font size, size/reshape the marker (points), or delete the note. /** @returns {string[]} the accent swatches offered by the editor */ _noteColors() { const ink = this.w.config.chart.ink return ink && Array.isArray(ink.noteColors) && ink.noteColors.length ? ink.noteColors : NOTE_COLORS } /** * Perceived-luminance check so text/border contrast follows the accent. * @param {string} hex */ static _isLight(hex) { const h = String(hex || '').replace('#', '') const full = h.length === 3 ? h .split('') .map((c) => c + c) .join('') : h const n = parseInt(full, 16) if (isNaN(n) || full.length !== 6) return false const r = (n >> 16) & 255 const g = (n >> 8) & 255 const b = n & 255 return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.72 } /** @param {any} style */ static _isBold(style) { const fw = style && style.fontWeight return fw === 'bold' || parseInt(String(fw), 10) >= 600 } /** * Small icon/text button for the editor card. mousedown is prevented so the * text input keeps focus while formatting. * @param {any} doc @param {string} content @param {string} title * @param {Function} onClick @param {string} [extraClass] @param {boolean} [isSvg] */ _cardBtn(doc, content, title, onClick, extraClass, isSvg) { const b = doc.createElement('button') b.type = 'button' b.className = 'apexcharts-ink-btn' + (extraClass ? ' ' + extraClass : '') b.title = title b.setAttribute('aria-label', title) if (isSvg) b.innerHTML = content else b.textContent = content b.addEventListener('mousedown', (/** @type {any} */ e) => e.preventDefault()) b.addEventListener('click', (/** @type {any} */ e) => { e.stopPropagation() onClick() }) return b } /** * Uppercase row caption ("Label" / "Line" / "Marker") for the editor card. * @param {any} doc @param {string} text */ _cardLabel(doc, text) { const lab = doc.createElement('span') lab.className = 'apexcharts-ink-cardlabel' lab.textContent = text return lab } /** * Color swatch button for the editor card. mousedown is prevented so the * text input keeps focus while restyling. * @param {any} doc @param {string} c * @param {(c: string) => void} pick @param {string} [extraClass] */ _mkSwatch(doc, c, pick, extraClass) { const sw = doc.createElement('button') sw.type = 'button' sw.className = 'apexcharts-ink-swatch' + (extraClass ? ' ' + extraClass : '') sw.title = c sw.setAttribute('aria-label', 'Color ' + c) sw.dataset.color = c sw.style.background = c sw.addEventListener('mousedown', (/** @type {any} */ e) => e.preventDefault()) sw.addEventListener('click', (/** @type {any} */ e) => { e.stopPropagation() pick(c) }) return sw } /** * Open the floating editor card for an annotation. * @param {string} type @param {number} index * @param {{select?: boolean}} [opts] select: preselect the text (create / dblclick) */ _startEdit(type, index, opts = {}) { const w = this.w const anno = this._annoList(type)[index] const baseEl = w.dom.baseEl const elWrap = w.dom.elWrap if (!anno || !anno.id || !baseEl || !elWrap) return this._closeEditor(false) const doc = baseEl.ownerDocument const card = doc.createElement('div') card.className = 'apexcharts-ink-card' card.setAttribute('role', 'dialog') card.setAttribute('aria-label', 'Edit note') card.style.visibility = 'hidden' // row 1: the label text + delete const rowText = doc.createElement('div') rowText.className = 'apexcharts-ink-card-row' const input = doc.createElement('input') input.type = 'text' input.className = 'apexcharts-ink-editor' input.placeholder = 'Note text' input.value = (anno.label && anno.label.text) || '' rowText.appendChild(input) rowText.appendChild( this._cardBtn( doc, TRASH_ICON, 'Delete note', () => this._deleteAnno(), 'apexcharts-ink-btn--delete', true, ), ) card.appendChild(rowText) // row 2: accent swatches + text formatting. For axis annotations these // swatches style the LABEL chip only; the line stroke gets its own row. const rowStyle = doc.createElement('div') rowStyle.className = 'apexcharts-ink-card-row' if (type !== 'point') { rowStyle.appendChild(this._cardLabel(doc, 'Label')) } this._noteColors().forEach((c) => { rowStyle.appendChild(this._mkSwatch(doc, c, (col) => this._applyColor(col))) }) const sep = doc.createElement('span') sep.className = 'apexcharts-ink-sep' rowStyle.appendChild(sep) rowStyle.appendChild( this._cardBtn(doc, 'B', 'Bold', () => this._toggleBold(), 'apexcharts-ink-btn--bold'), ) rowStyle.appendChild(this._cardBtn(doc, 'A-', 'Smaller text', () => this._stepFont(-1))) rowStyle.appendChild(this._cardBtn(doc, 'A+', 'Larger text', () => this._stepFont(1))) card.appendChild(rowStyle) // row 3 (axis annotations): the line's own stroke color, separate from the // label chip, so restyling the chip cannot blank the line. if (type !== 'point') { const rowLine = doc.createElement('div') rowLine.className = 'apexcharts-ink-card-row' rowLine.appendChild(this._cardLabel(doc, 'Line')) this._noteColors().forEach((c) => { rowLine.appendChild( this._mkSwatch( doc, c, (col) => this._applyLineColor(col), 'apexcharts-ink-swatch--line', ), ) }) card.appendChild(rowLine) } // row 3 (point annotations): marker options if (type === 'point') { const rowMarker = doc.createElement('div') rowMarker.className = 'apexcharts-ink-card-row' rowMarker.appendChild(this._cardLabel(doc, 'Marker')) rowMarker.appendChild( this._cardBtn(doc, '-', 'Smaller marker', () => this._stepMarker(-1)), ) const sizeOut = doc.createElement('span') sizeOut.className = 'apexcharts-ink-marker-size' rowMarker.appendChild(sizeOut) rowMarker.appendChild( this._cardBtn(doc, '+', 'Larger marker', () => this._stepMarker(1)), ) rowMarker.appendChild( this._cardBtn( doc, SHAPE_GLYPHS.circle, 'Marker shape', () => this._cycleShape(), 'apexcharts-ink-btn--shape', ), ) card.appendChild(rowMarker) } elWrap.appendChild(card) this._editor = { card, input, type, index } this._positionCard() this._syncCard() card.style.visibility = '' input.focus() if (opts.select) input.select() card.addEventListener('keydown', (/** @type {any} */ e) => { if (e.key === 'Escape') { e.preventDefault() e.stopPropagation() this._closeEditor(false) } else if (e.key === 'Enter' && e.target === input) { e.preventDefault() this._closeEditor(true) } }) doc.addEventListener('mousedown', this._onDocDownEditor, true) doc.addEventListener('touchstart', this._onDocDownEditor, true) } /** * Commit + close on any press outside the card. * @param {any} e */ _onDocDownEditor(e) { const ed = this._editor if (!ed || ed.card.contains(e.target)) return this._closeEditor(true) } /** * Anchor the card to the annotation's label (below it, or above when there * is no room), clamped inside the chart wrap. Re-run after each restyle * since the label rect changes. */ _positionCard() { const ed = this._editor if (!ed) return const w = this.w const baseEl = w.dom.baseEl const elWrap = w.dom.elWrap const anno = this._annoList(ed.type)[ed.index] if (!baseEl || !elWrap || !anno) return const anchor = baseEl.querySelector( '.apexcharts-' + ed.type + '-annotation-label.' + anno.id, ) || baseEl.querySelector('.' + anno.id) if (!anchor) return const wrapRect = elWrap.getBoundingClientRect() const aRect = anchor.getBoundingClientRect() const cw = ed.card.offsetWidth const ch = ed.card.offsetHeight let left = Math.round(aRect.left - wrapRect.left) let top = Math.round(aRect.bottom - wrapRect.top) + 8 if (top + ch > elWrap.clientHeight - 4) { top = Math.round(aRect.top - wrapRect.top) - ch - 8 } if (left + cw > elWrap.clientWidth - 4) left = elWrap.clientWidth - cw - 4 ed.card.style.left = Math.max(4, left) + 'px' ed.card.style.top = Math.max(4, top) + 'px' } /** Reflect the annotation's current style on the card controls. */ _syncCard() { const ed = this._editor if (!ed) return const anno = this._annoList(ed.type)[ed.index] if (!anno) return const style = (anno.label && anno.label.style) || {} const bg = String(style.background || '').toLowerCase() ed.card .querySelectorAll('.apexcharts-ink-swatch:not(.apexcharts-ink-swatch--line)') .forEach((/** @type {any} */ sw) => { sw.classList.toggle( 'apexcharts-ink-swatch--active', (sw.dataset.color || '').toLowerCase() === bg, ) }) const stroke = String(anno.borderColor || '').toLowerCase() ed.card .querySelectorAll('.apexcharts-ink-swatch--line') .forEach((/** @type {any} */ sw) => { sw.classList.toggle( 'apexcharts-ink-swatch--active', (sw.dataset.color || '').toLowerCase() === stroke, ) }) const boldBtn = ed.card.querySelector('.apexcharts-ink-btn--bold') if (boldBtn) { boldBtn.classList.toggle('apexcharts-ink-btn--active', InkLayer._isBold(style)) } const m = anno.marker || {} const sizeOut = ed.card.querySelector('.apexcharts-ink-marker-size') if (sizeOut) { sizeOut.textContent = String(typeof m.size === 'number' ? m.size : 4) } const shapeBtn = ed.card.querySelector('.apexcharts-ink-btn--shape') if (shapeBtn) { shapeBtn.textContent = SHAPE_GLYPHS[m.shape] || SHAPE_GLYPHS.circle } } /** * Commit the input's text into the annotation (the card stays open). Also * runs before any style apply so typed-but-unconfirmed text survives the * redraw. * @param {any} ed */ _commitTextOf(ed) { const anno = this._annoList(ed.type)[ed.index] if (!anno) return const text = ed.input.value if (!anno.label) anno.label = {} if (anno.label.text === text) return anno.label.text = text this._redrawAnno(ed.type, anno, ed.index) this._checkpoint('ink:edit') this._fireEdited(ed.type, anno, ed.index) } /** * Close the editor card. commit=true also commits the pending text. Style * edits apply immediately and are not rolled back by Escape; use undo. * @param {boolean} commit */ _closeEditor(commit) { const ed = this._editor if (!ed) return this._editor = null const doc = this.w.dom.baseEl && this.w.dom.baseEl.ownerDocument if (doc) { doc.removeEventListener('mousedown', this._onDocDownEditor, true) doc.removeEventListener('touchstart', this._onDocDownEditor, true) } if (ed.card.parentNode) ed.card.parentNode.removeChild(ed.card) if (commit) this._commitTextOf(ed) } /** * Apply a config mutation from a card control: commit pending text, mutate, * redraw, checkpoint for undo, then refresh + re-anchor the card. * @param {string} label @param {(anno: any) => void} mutate */ _applyStyle(label, mutate) { const ed = this._editor if (!ed) return const anno = this._annoList(ed.type)[ed.index] if (!anno) return this._commitTextOf(ed) if (!anno.label) anno.label = {} if (!anno.label.style) anno.label.style = {} mutate(anno) this._redrawAnno(ed.type, anno, ed.index) this._checkpoint(label) this._fireStyled(ed.type, anno, ed.index) this._syncCard() this._positionCard() } /** * Apply an accent color: label chip + marker (points) or line/range fill * (axis annotations), with text/border contrast following the luminance. * @param {string} c */ _applyColor(c) { const ed = this._editor if (!ed) return const light = InkLayer._isLight(c) this._applyStyle('ink:style', (anno) => { anno.label.style.background = c anno.label.style.color = light ? '#334155' : '#ffffff' anno.label.borderColor = light ? '#cbd5e1' : c if (ed.type === 'point') { if (!anno.marker) anno.marker = {} anno.marker.strokeColor = light ? '#334155' : c anno.marker.fillColor = light ? '#ffffff' : c } // Axis annotations: the label chip only. The line stroke has its own // swatch row (_applyLineColor); coupling them meant a white chip // background turned the dashed line white and invisible. }) } /** * Line-stroke color for axis annotations, separate from the label chip. * @param {string} c */ _applyLineColor(c) { const ed = this._editor if (!ed || ed.type === 'point') return this._applyStyle('ink:style', (anno) => { anno.borderColor = c if (anno.x2 != null || anno.y2 != null) anno.fillColor = c }) } /** * Step the label font size through the preset scale. * @param {number} dir */ _stepFont(dir) { this._applyStyle('ink:style', (anno) => { const cur = parseFloat(anno.label.style.fontSize) || 11 let i = 0 for (let k = 1; k < FONT_STEPS.length; k++) { if (Math.abs(FONT_STEPS[k] - cur) < Math.abs(FONT_STEPS[i] - cur)) i = k } i = Math.min(FONT_STEPS.length - 1, Math.max(0, i + dir)) anno.label.style.fontSize = FONT_STEPS[i] + 'px' }) } _toggleBold() { this._applyStyle('ink:style', (anno) => { anno.label.style.fontWeight = InkLayer._isBold(anno.label.style) ? 400 : 700 }) } /** * Grow/shrink the point marker. * @param {number} dir */ _stepMarker(dir) { this._applyStyle('ink:style', (anno) => { if (!anno.marker) anno.marker = {} const cur = typeof anno.marker.size === 'number' ? anno.marker.size : 4 anno.marker.size = Math.min(14, Math.max(2, cur + dir)) }) } /** Cycle the point marker shape (circle, square, diamond, triangle). */ _cycleShape() { this._applyStyle('ink:style', (anno) => { if (!anno.marker) anno.marker = {} const i = MARKER_SHAPES.indexOf(anno.marker.shape) anno.marker.shape = MARKER_SHAPES[(i + 1) % MARKER_SHAPES.length] }) } /** Delete the annotation the editor is open on (undoable via Rewind). */ _deleteAnno() { const ed = this._editor if (!ed) return const list = this._annoList(ed.type) const anno = list[ed.index] this._closeEditor(false) if (!anno) return const baseEl = this.w.dom.baseEl if (baseEl && anno.id) { baseEl .querySelectorAll('.' + anno.id) .forEach((/** @type {any} */ el) => el.remove()) } list.splice(ed.index, 1) // Annotations after the removed one shift down a slot; redraw them so the // rebound handlers close over their new indices. for (let i = ed.index; i < list.length; i++) { this._redrawAnno(ed.type, list[i], i) } this._checkpoint('ink:delete') this._fireDeleted(ed.type, anno, ed.index) } /** @param {string} type @param {any} anno @param {number} index */ _fireEdited(type, anno, index) { const args = { type, id: anno.id, index, text: anno.label ? anno.label.text : '' } const events = this.w.config.chart.events if (typeof events.annotationEdited === 'function') { events.annotationEdited(this.ctx, args) } this.ctx.events?.fireEvent('annotationEdited', [this.ctx, args]) } /** @param {string} type @param {any} anno @param {number} index */ _fireStyled(type, anno, index) { const args = { type, id: anno.id, index, label: anno.label, marker: anno.marker } const events = this.w.config.chart.events if (typeof events.annotationStyled === 'function') { events.annotationStyled(this.ctx, args) } this.ctx.events?.fireEvent('annotationStyled', [this.ctx, args]) } /** @param {string} type @param {any} anno @param {number} index */ _fireDeleted(type, anno, index) { const args = { type, id: anno.id, index } const events = this.w.config.chart.events if (typeof events.annotationDeleted === 'function') { events.annotationDeleted(this.ctx, args) } this.ctx.events?.fireEvent('annotationDeleted', [this.ctx, args]) } // ─── lifecycle ──────────────────────────────────────────────────────────── _teardownDocListeners() { const doc = this.w.dom.baseEl && this.w.dom.baseEl.ownerDocument if (!doc) return doc.removeEventListener('mousemove', this._onMove) doc.removeEventListener('touchmove', this._onMove) doc.removeEventListener('mouseup', this._onUp) doc.removeEventListener('touchend', this._onUp) } teardown() { this._teardownDocListeners() this._closeEditor(false) this.stopCreate() this._drag = null if (this._wired) { this.ctx.removeEventListener?.('mounted', this._onRerender) this.ctx.removeEventListener?.('updated', this._onRerender) this._wired = false } } }