apexcharts
Version:
A JavaScript Chart Library
1,227 lines (1,135 loc) • 43 kB
JavaScript
// @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
}
}
}