apexcharts
Version:
A JavaScript Chart Library
183 lines (171 loc) • 6.99 kB
JavaScript
// @ts-check
import Graphics from '../../modules/Graphics'
/**
* Outer ("name") labels: a label parked in the margin beside the plot, joined to
* the thing it names by a leader line. Pie/donut draw them around the rim; the
* unit chart draws them beside a silhouette's colour bands. The geometry is the
* caller's business - it knows where its own marks are, and it needs to run a
* de-overlap pass before anything reaches the DOM - so this module only owns the
* two parts that are genuinely common: laying out one label's text block against
* its leader line, and spacing a crowded gutter column apart.
*
* @typedef {object} OuterLabelSpec
* @property {string[]} lines one entry per line, e.g. ['Comedy', '20.3%']
* @property {number} lineHeight
* @property {{ x: number, y: number }} anchor on the mark being named
* @property {{ x: number, y: number }} elbow leader-line bend
* @property {number} labelX where the leader line ends and the text starts
* @property {number} labelY vertical centre of the text block
* @property {'left' | 'right'} side which gutter this label sits in
* @property {{ show: boolean, width: number, color: string }} connector
* @property {{ fontSize?: string, fontFamily?: string, fontWeight?: string | number }} style
* @property {string} foreColor
* @property {string} [groupClass]
* @property {string} [textClass]
* @property {string} [connectorClass]
*/
/**
* Draw one outer label: an optional leader line (anchor -> elbow -> label) plus
* the text, one tspan per line, the block vertically centred on `labelY` so the
* line terminates in the middle of the text rather than at its first baseline.
*
* @param {import('../../types/internal').ChartStateW} w
* @param {OuterLabelSpec} spec
* @returns {any} the group element, for the caller to add and to reveal
*/
export function drawOuterLabel(w, spec) {
const {
lines,
lineHeight,
anchor,
elbow,
labelX,
labelY,
side,
connector,
style,
foreColor,
} = spec
const graphics = new Graphics(w)
const group = graphics.group({
class: spec.groupClass || 'apexcharts-outer-label-group',
})
if (connector.show) {
const d = `M ${anchor.x} ${anchor.y} L ${elbow.x} ${elbow.y} L ${labelX} ${labelY}`
const line = graphics.drawPath({
d,
stroke: connector.color,
strokeWidth: connector.width,
fill: 'none',
strokeLinecap: 'round',
})
line.node.classList.add(spec.connectorClass || 'apexcharts-outer-label-connector')
group.add(line)
}
// small horizontal pad so the text doesn't touch the connector end
const textX = side === 'right' ? labelX + 4 : labelX - 4
const n = lines.length
// single text element with one tspan per line, block centered on labelY
const startY = labelY - ((n - 1) * lineHeight) / 2
const elText = graphics.drawText({
x: textX,
y: startY,
text: n === 1 ? lines[0] : lines,
textAnchor: side === 'right' ? 'start' : 'end',
fontSize: style.fontSize,
fontFamily: style.fontFamily,
fontWeight: style.fontWeight,
foreColor,
dominantBaseline: 'central',
cssClass: spec.textClass || 'apexcharts-outer-label',
})
// normalize multi-line spacing to lineHeight (svg.js newLine leading varies)
if (n > 1) {
const tspans = elText.node.getElementsByTagName('tspan')
for (let li = 0; li < tspans.length; li++) {
tspans[li].setAttribute('x', `${textX}`)
tspans[li].setAttribute('dy', li === 0 ? '0' : `${lineHeight}`)
}
}
group.add(elText)
// Reveal timing is handled by the caller via delayedElements +
// apexcharts-element-hidden (faded back in on animationCompleted), so the
// labels and connectors don't pop in before the marks they name.
return group
}
/**
* Widest of the given strings, for reserving gutter room before anything is
* drawn. Measured through the DOM where one can measure, estimated from the
* character count otherwise (SSR, jsdom), so a headless render still leaves its
* labels a gutter instead of reserving nothing and letting them hang off the
* edge. Pie keeps its own measure-only version: it has shipped snapshots that a
* suddenly non-zero SSR reserve would move.
*
* @param {import('../../types/internal').ChartStateW} w
* @param {string[]} labels
* @param {{ fontSize?: string, fontFamily?: string }} [style]
* @returns {number}
*/
export function measureLabelWidth(w, labels, style = {}) {
const graphics = new Graphics(w)
const fontSize = style.fontSize || '12px'
const px = parseFloat(fontSize) || 12
let max = 0
labels.forEach((text) => {
if (text == null || text === '') return
const str = `${text}`
const measured = graphics.getTextRects(str, fontSize, style.fontFamily, '').width
// 0.58em per character is a reasonable mean for the sans-serif faces charts
// ship with - close enough to reserve space, never used for placement.
max = Math.max(max, measured > 0 ? measured : str.length * px * 0.58)
})
return max
}
/**
* Space one gutter column apart: push each label down until it clears its
* neighbour by `minGap`, then, if the column ran past `maxY`, pull the whole
* block back up. Mutates each item's `labelY` (seeded from `idealY`), which is
* also where its leader line will terminate.
*
* `minY` is optional: pie leaves the top unclamped (a column pulled up as a
* block stays inside the chart because the pie is centred in it), while a unit
* silhouette clamps both ends against its plot rect.
*
* @param {{ idealY: number, labelY: number }[]} items one side's labels
* @param {number} minGap a full label block, so multi-line labels stay clear
* @param {number} maxY
* @param {number} [minY]
*/
export function spaceOutLabels(items, minGap, maxY, minY) {
const col = items.slice().sort((a, b) => a.idealY - b.idealY)
col.forEach((l) => {
l.labelY = l.idealY
})
// forward pass: push each label down to keep the minimum gap
for (let k = 1; k < col.length; k++) {
if (col[k].labelY - col[k - 1].labelY < minGap) {
col[k].labelY = col[k - 1].labelY + minGap
}
}
// if the column ran past the bottom, pull it back up as a block
const last = col[col.length - 1]
const overflow = last ? last.labelY - maxY : 0
if (overflow > 0) {
for (let k = col.length - 1; k >= 0; k--) {
col[k].labelY -= overflow
if (k < col.length - 1 && col[k + 1].labelY - col[k].labelY < minGap) {
col[k].labelY = col[k + 1].labelY - minGap
}
}
}
// Clamping the top is opt-in: a caller with a hard ceiling (a plot rect)
// pushes the column back down, keeping the gaps it just earned. The column
// can end up taller than the space available, in which case it overflows
// the bottom rather than overlapping itself.
if (minY != null && col.length && col[0].labelY < minY) {
const shift = minY - col[0].labelY
for (let k = 0; k < col.length; k++) {
col[k].labelY += shift
}
}
}