apexcharts
Version:
A JavaScript Chart Library
186 lines (171 loc) • 6.31 kB
JavaScript
// @ts-check
import { BrowserAPIs } from '../../ssr/BrowserAPIs.js'
import { Environment } from '../../utils/Environment.js'
import { placeInReservedBand } from '../../charts/common/Breadcrumb.js'
const XHTML = 'http://www.w3.org/1999/xhtml'
/**
* Drilldown breadcrumb: an absolutely-positioned <nav> rendered inside
* w.dom.elWrap (the same wrapper the toolbar uses), re-rendered after every
* chart (re)render because elWrap's contents are rebuilt each time.
*
* On an axis chart `Dimensions.gridPadForBreadcrumb` reserves a band above the
* plot and the strip sits in it. Being an overlay is not enough on its own:
* with nothing reserved it was pushed below the title and came to rest on the
* top gridline and the first y-axis label. A pie or donut still floats, since
* its corners are empty.
*
* @module Breadcrumb
*/
export default class Breadcrumb {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
* @param {import('./Drilldown').default} drilldown
*/
constructor(w, ctx, drilldown) {
this.w = w
this.ctx = ctx
this.drilldown = drilldown
}
/**
* @param {Array<string|number>} path - ['root', id, id, ...]
*/
render(path) {
if (!Environment.isBrowser()) return
const w = this.w
const elWrap = w.dom.elWrap
if (!elWrap) return
// Remove any stale breadcrumb first (guards against double-render).
const existing = elWrap.querySelector('.apexcharts-breadcrumb')
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing)
}
const cfg = w.config.drilldown && w.config.drilldown.breadcrumb
if (!cfg || cfg.show === false) return
// Only show once the user has drilled in.
if (this.drilldown.depth === 0) return
const nav = BrowserAPIs.createElementNS(XHTML, 'nav')
nav.setAttribute('class', 'apexcharts-breadcrumb')
nav.setAttribute('aria-label', 'Drilldown breadcrumb')
this._position(nav, cfg)
const separator = cfg.separator != null ? cfg.separator : ' / '
path.forEach((id, i) => {
if (i > 0) {
const sep = BrowserAPIs.createElementNS(XHTML, 'span')
sep.setAttribute('class', 'apexcharts-breadcrumb-separator')
sep.setAttribute('aria-hidden', 'true')
sep.textContent = separator
nav.appendChild(sep)
}
const label = this._label(id, i)
const isCurrent = i === path.length - 1
if (isCurrent) {
const cur = BrowserAPIs.createElementNS(XHTML, 'span')
cur.setAttribute(
'class',
'apexcharts-breadcrumb-item apexcharts-breadcrumb-current',
)
cur.setAttribute('aria-current', 'page')
cur.textContent = label
nav.appendChild(cur)
} else {
const btn = /** @type {HTMLButtonElement} */ (
BrowserAPIs.createElementNS(XHTML, 'button')
)
btn.setAttribute('type', 'button')
btn.setAttribute('class', 'apexcharts-breadcrumb-item')
// Prefix the leftmost crumb with a back-arrow so it reads as the
// "go back" affordance, e.g. "← All Years / 2021 by Channel".
if (i === 0) {
const arrow = BrowserAPIs.createElementNS(XHTML, 'span')
arrow.setAttribute('class', 'apexcharts-breadcrumb-arrow')
arrow.setAttribute('aria-hidden', 'true')
arrow.textContent = '←'
btn.appendChild(arrow)
}
const text = BrowserAPIs.createElementNS(XHTML, 'span')
text.setAttribute('class', 'apexcharts-breadcrumb-label')
text.textContent = label
btn.appendChild(text)
btn.addEventListener('click', () => this.drilldown.drillToLevel(i))
nav.appendChild(btn)
}
})
elWrap.appendChild(nav)
if (this.w.globals.axisCharts) {
placeInReservedBand(this.w, this.ctx, nav, cfg)
}
// Still runs after the band placement: on a pie there is no band, and even
// on an axis chart a responsive override can turn drilldown on after layout
// and leave the strip with nowhere reserved.
this._avoidChromeOverlap(nav)
}
/**
* The breadcrumb is an absolute overlay, so at its default top-left it can
* sit on top of a left-aligned title (or subtitle). After mounting, push it
* below any chart chrome it intersects. (Sunburst's self-contained
* breadcrumb applies the same rule.)
* @param {HTMLElement} nav
*/
_avoidChromeOverlap(nav) {
const w = this.w
const chrome = /** @type {Element[]} */ (
['.apexcharts-title-text', '.apexcharts-subtitle-text']
.map((s) => w.dom.baseEl.querySelector(s))
.filter((el) => el !== null)
)
if (!chrome.length) return
const wrapTop = w.dom.elWrap.getBoundingClientRect().top
// Up to two passes: moving below the title can land on the subtitle.
for (let pass = 0; pass < chrome.length + 1; pass++) {
const nr = nav.getBoundingClientRect()
const hit = chrome.find((el) => {
const r = el.getBoundingClientRect()
return (
nr.left < r.right &&
nr.right > r.left &&
nr.top < r.bottom &&
nr.bottom > r.top
)
})
if (!hit) break
nav.style.top = `${hit.getBoundingClientRect().bottom - wrapTop + 4}px`
}
}
/**
* @param {string|number} id
* @param {number} index
* @returns {string}
*/
_label(id, index) {
const cfg = this.w.config.drilldown.breadcrumb
let label
if (index === 0) {
label = cfg.rootLabel != null ? cfg.rootLabel : 'All'
} else {
const list = (this.w.config.drilldown.series || []).find(
(s) => s && s.id === id,
)
label = (list && list.name) || String(id)
}
if (typeof cfg.formatter === 'function') {
return cfg.formatter(label, { index, depth: this.drilldown.depth })
}
return label
}
/**
* @param {HTMLElement} nav
* @param {Record<string, any>} cfg
*/
_position(nav, cfg) {
const ox = cfg.offsetX || 0
const oy = cfg.offsetY || 0
nav.style.position = 'absolute'
nav.style.top = oy + 'px'
if (cfg.position === 'top-right') {
nav.style.right = -ox + 3 + 'px'
} else {
nav.style.left = ox + 'px'
}
}
}