apexcharts
Version:
A JavaScript Chart Library
296 lines (269 loc) • 9.94 kB
JavaScript
// @ts-check
/**
* Breadcrumb chrome for the charts that navigate into a hierarchy.
*
* Three separate things can put the reader inside a branch: the drilldown
* feature (replaces the view one level at a time), the sunburst's click-to-zoom
* and the treemap's. They are different interactions but the same affordance,
* so they render the same markup, honour the same config and carry the same
* accessible semantics from here rather than each growing its own copy.
*
* Config comes from `drilldown.breadcrumb` - already the documented home for
* this chrome - and a chart may override it locally (the treemap reads
* `plotOptions.treemap.zoom.breadcrumb`), so a chart that never imports the
* drilldown feature still styles its breadcrumb the same way.
*
* NOTE: `src/modules/drilldown/Breadcrumb.js` still has its own copy of this
* markup. It is the drilldown feature's own bundle and is being worked on
* elsewhere; folding it into this module is a follow-up, and the two are kept
* behaviourally identical in the meantime.
*
* @module charts/common/Breadcrumb
*/
import { BrowserAPIs } from '../../ssr/BrowserAPIs.js'
import { Environment } from '../../utils/Environment.js'
const XHTML = 'http://www.w3.org/1999/xhtml'
/** Height reserved for the compact strip when a chart asks the layout for room. */
export const BREADCRUMB_HEIGHT = 18
/**
* Height of the full-size strip. From the CSS: 12px text at line-height 1.2,
* plus 2px item padding and 2px nav padding on each side, rounded up. The
* compact variant above drops to 11px text and no nav padding.
*
* Only an estimate for sizing the band; `placeInReservedBand` measures the real
* height once the strip is in the DOM.
*/
export const BREADCRUMB_HEIGHT_FULL = 23
/**
* Resolve the breadcrumb config: a chart-local override on top of the shared
* `drilldown.breadcrumb` block, on top of the built-in defaults.
*
* @param {any} w
* @param {any} [localCfg] chart-specific override
* @returns {any}
*/
export function breadcrumbConfig(w, localCfg) {
const shared = (w.config.drilldown && w.config.drilldown.breadcrumb) || {}
return {
show: true,
position: 'top-left',
separator: ' / ',
rootLabel: 'All',
offsetX: 0,
offsetY: 0,
formatter: undefined,
...shared,
...(localCfg || {}),
}
}
/**
* Push `nav` below the title/subtitle if it would sit on top of them.
*
* Runs up to two passes, because clearing the title can land the nav on the
* subtitle. A no-op when the chart has neither.
*
* @param {any} w
* @param {any} nav the breadcrumb element, already positioned and in the DOM
*/
export function avoidChromeOverlap(w, nav) {
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
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`
}
}
/**
* The lowest the breadcrumb's bottom edge may sit, in elWrap coords.
*
* The plot's top edge is the obvious answer and the wrong one: the topmost
* y-axis tick label is CENTRED on that edge, so half of it hangs above the grid.
* Clearing the grid alone still left the strip resting on the first label (the
* reported "320" under "All years"). Measured rather than derived from the font
* size, so a rotated label or a host stylesheet is accounted for too.
*
* Only labels that overlap the strip horizontally count: a right-hand y-axis
* must not push a top-left breadcrumb around.
*
* @param {any} w
* @param {any} nav
* @returns {number}
*/
function breadcrumbCeiling(w, nav) {
const gridTop = w.layout.translateY || 0
const elWrap = w.dom.elWrap
if (!elWrap) return gridTop
const labels = w.dom.baseEl.querySelectorAll('.apexcharts-yaxis-label')
if (!labels.length) return gridTop
const wrapTop = elWrap.getBoundingClientRect().top
const navRect = nav.getBoundingClientRect()
let ceiling = gridTop
for (let i = 0; i < labels.length; i++) {
const r = labels[i].getBoundingClientRect()
if (!r.height) continue
if (r.left >= navRect.right || r.right <= navRect.left) continue
ceiling = Math.min(ceiling, r.top - wrapTop)
}
return ceiling
}
/**
* Sit `nav` in the band the layout reserved for it (see
* `Dimensions.gridPadForBreadcrumb`), just above the plot.
*
* The fallback should not normally fire: the reserve is unconditional once the
* chart declares it needs a breadcrumb. It stays for what the reserve cannot
* cover, a responsive override that turns navigation on after layout or a host
* stylesheet that grows the font, where a readable chip over the plot beats a
* strip clipped by it.
*
* @param {any} w
* @param {any} ctx the chart context (for the title/subtitle measurements)
* @param {any} nav the breadcrumb element, already in the DOM
* @param {any} [cfg] resolved breadcrumb config, for `offsetY`
*/
export function placeInReservedBand(w, ctx, nav, cfg) {
const dimHelpers = ctx?.dimensions?.dimHelpers
const titleArea = dimHelpers
? dimHelpers.getTitleSubtitleCoords('title').height +
dimHelpers.getTitleSubtitleCoords('subtitle').height
: 0
const navH = nav.getBoundingClientRect().height || BREADCRUMB_HEIGHT
const offsetY = (cfg && cfg.offsetY) || 0
const ceiling = breadcrumbCeiling(w, nav)
if (ceiling - titleArea >= navH + 1) {
nav.style.top = `${ceiling - navH - 1 + offsetY}px`
return true
}
nav.style.top = `${titleArea + offsetY}px`
const dark = w.config.theme.mode === 'dark'
nav.style.background = dark ? 'rgba(20,24,30,0.82)' : 'rgba(255,255,255,0.86)'
nav.style.borderRadius = '4px'
return false
}
/**
* Remove any breadcrumb currently in the wrap.
* @param {any} w
*/
export function clearBreadcrumb(w) {
const elWrap = w.dom.elWrap
if (!elWrap) return
const existing = elWrap.querySelector('.apexcharts-breadcrumb')
if (existing && existing.parentNode) existing.parentNode.removeChild(existing)
}
/**
* Render a breadcrumb into the chart wrap.
*
* The last crumb is the current position and is not actionable; every earlier
* one is a button that calls `onNavigate` with its index. The leftmost carries
* a back arrow so the strip reads as "go back" at a glance.
*
* @param {any} w
* @param {{
* crumbs: Array<{ label: string, data?: any }>,
* onNavigate: (index: number, crumb: any) => void,
* ariaLabel?: string,
* config?: any,
* compact?: boolean,
* }} opts
* @returns {any} the nav element, or null if nothing was rendered
*/
export function renderBreadcrumb(w, opts) {
if (!Environment.isBrowser()) return null
const elWrap = w.dom.elWrap
if (!elWrap) return null
clearBreadcrumb(w)
const cfg = opts.config || breadcrumbConfig(w)
if (cfg.show === false) return null
const crumbs = opts.crumbs || []
// Nothing to go back to: the strip would say only where you already are.
if (crumbs.length < 2) return null
const nav = BrowserAPIs.createElementNS(XHTML, 'nav')
nav.setAttribute('class', 'apexcharts-breadcrumb')
nav.setAttribute('aria-label', opts.ariaLabel || 'Breadcrumb')
positionBreadcrumb(nav, cfg)
if (opts.compact) {
// A treemap fills its plot edge to edge, so its strip has to fit the narrow
// band above the grid rather than float over a tile.
nav.style.fontSize = '11px'
nav.style.padding = '0 2px'
}
const separator = cfg.separator != null ? cfg.separator : ' / '
crumbs.forEach((crumb, 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)
}
let label = i === 0 ? (cfg.rootLabel ?? 'All') : crumb.label
if (typeof cfg.formatter === 'function') {
label = cfg.formatter(label, {
index: i,
depth: crumbs.length - 1,
data: crumb.data,
})
}
if (i === crumbs.length - 1) {
const cur = BrowserAPIs.createElementNS(XHTML, 'span')
cur.setAttribute(
'class',
'apexcharts-breadcrumb-item apexcharts-breadcrumb-current',
)
cur.setAttribute('aria-current', 'page')
cur.textContent = String(label)
nav.appendChild(cur)
return
}
const btn = /** @type {HTMLButtonElement} */ (
BrowserAPIs.createElementNS(XHTML, 'button')
)
btn.setAttribute('type', 'button')
btn.setAttribute('class', 'apexcharts-breadcrumb-item')
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 = String(label)
btn.appendChild(text)
btn.addEventListener('click', () => opts.onNavigate(i, crumb))
nav.appendChild(btn)
})
elWrap.appendChild(nav)
return nav
}
/**
* @param {any} nav
* @param {any} cfg
*/
export function positionBreadcrumb(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'
}
}