apexcharts
Version:
A JavaScript Chart Library
297 lines (282 loc) • 11.2 kB
JavaScript
// @ts-check
/**
* Trellis (#22): shared-scale resolution.
*
* Computes the union domains across all panel slices and turns them into the
* explicit `{ min, max, tickAmount }` every panel receives. Identical scale
* input is what makes identical geometry output (spike 22a Q1): every panel
* runs the library's own scale code on the same bounds, so ticks, label
* strings, measured label widths and therefore the plot rectangle all agree.
*
* The nice-bounds algorithm here does not need to match the library's
* `Scales.niceScale`; it only needs to hand every panel the SAME bounds.
* A plain 1-2-5 step keeps the labels readable.
*
* Pure module: no DOM, no `w`.
*
* @module modules/trellis/TrellisScales
*/
import { getThemePalettes } from '../../utils/ThemePalettes'
/**
* Default tick-interval target for the shared y scale. A trellis panel is
* small: 3 intervals means at most 4-5 labels, which is the most a
* 100-200px plot wears without the axis outweighing the data.
*/
export const DEFAULT_TARGET_TICKS = 3
/**
* Round an extent out to nice bounds with an integer tick count.
*
* Candidate steps are 1-2-(2.5)-5-10 times the magnitude; the winner is the
* candidate whose resulting tick count lands CLOSEST to the target (the old
* fixed thresholds could land 6 ticks against a target of 4: 0..55000 gave
* a raw step of 13750, rounded DOWN to 10000). Ties prefer fewer ticks
* (less label ink in a small panel), then the smaller step (less wasted
* headroom above the data). 2.5 only competes where it keeps integer labels.
* @param {number} min
* @param {number} max
* @param {number} [targetTicks]
* @returns {{ min: number, max: number, tickAmount: number }}
*/
export function niceBounds(min, max, targetTicks = DEFAULT_TARGET_TICKS) {
if (!isFinite(min) || !isFinite(max)) {
return { min: 0, max: 1, tickAmount: 1 }
}
if (min === max) {
// A flat domain still needs height to draw in.
const pad = min === 0 ? 1 : Math.abs(min) * 0.1
min -= pad
max += pad
}
const target = Math.max(1, targetTicks)
const rawStep = (max - min) / target
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
const norms = mag >= 10 ? [1, 2, 2.5, 5, 10] : [1, 2, 5, 10]
let best = { dist: Infinity, ticks: 0, min: 0, max: 1 }
norms.forEach((n) => {
const step = n * mag
const lo = Math.floor(min / step) * step
const hi = Math.ceil(max / step) * step
const ticks = Math.max(1, Math.round((hi - lo) / step))
const dist = Math.abs(ticks - target)
if (dist < best.dist || (dist === best.dist && ticks < best.ticks)) {
best = { dist, ticks, min: lo, max: hi }
}
})
return { min: best.min, max: best.max, tickAmount: best.ticks }
}
/**
* Fold every numeric value of one datum into an extent. Handles the datum
* forms the split emits: plain numbers, `[x, y]` pairs, and `{ x, y }` objects
* whose y may be a number, an OHLC/box summary array, or a range pair.
* @param {any} d
* @param {'plain'|'paired'|'object'} form
* @param {{ min: number, max: number }} ext mutated
*/
function extendByDatum(d, form, ext) {
if (d === null || d === undefined) return
/** @type {any} */
let y = d
if (form === 'paired') y = d[1]
else if (form === 'object') y = d.y
if (y === null || y === undefined) return
if (Array.isArray(y)) {
for (let i = 0; i < y.length; i++) {
const v = Number(y[i])
if (isFinite(v)) {
if (v < ext.min) ext.min = v
if (v > ext.max) ext.max = v
}
}
return
}
const v = Number(y)
if (isFinite(v)) {
if (v < ext.min) ext.min = v
if (v > ext.max) ext.max = v
}
}
/**
* Decimal places of one number, capped at 4 (exponent notation counts as 4).
* @param {any} v
* @returns {number}
*/
export function decimalCount(v) {
if (typeof v !== 'number' || !isFinite(v) || v % 1 === 0) return 0
const s = String(v)
if (s.indexOf('e') !== -1 || s.indexOf('E') !== -1) return 4
return Math.min(4, (s.split('.')[1] || '').length)
}
/**
* The most decimal places any y value in the trellis carries (capped at 4).
* The library derives label decimals from each panel's OWN data, so two
* panels with identical pushed bounds can still render "20" vs "20.00" and
* come out with different gutters (found via the all-zero placeholder, but
* equally true for an integer-valued panel among float siblings). The
* trellis therefore pushes one uniform label formatter with the bounds,
* sized by this count (and by the tick step's own decimals).
* @param {import('./TrellisSplit').TrellisSlice[]} panels
* @returns {number}
*/
export function maxYDecimals(panels) {
let max = 0
/** @param {any} v */
const count = (v) => {
const d = decimalCount(v)
if (d > max) max = d
}
panels.forEach((p) =>
p.series.forEach((s) => {
if (!Array.isArray(s.data)) return
s.data.forEach((/** @type {any} */ d) => {
if (d === null || d === undefined) return
let y = d
if (Array.isArray(d)) y = d[1]
else if (typeof d === 'object') y = d.y
if (Array.isArray(y)) y.forEach(count)
else count(y)
})
}),
)
return max
}
/**
* The union y extent over every panel's every series.
* @param {import('./TrellisSplit').TrellisSlice[]} panels
* @param {'plain'|'paired'|'object'} xForm
* @returns {{ min: number, max: number } | null} null when no finite value exists
*/
export function yExtent(panels, xForm) {
const ext = { min: Infinity, max: -Infinity }
panels.forEach((p) =>
p.series.forEach((s) => {
if (!Array.isArray(s.data)) return
s.data.forEach((/** @type {any} */ d) => extendByDatum(d, xForm, ext))
}),
)
if (!isFinite(ext.min) || !isFinite(ext.max)) return null
return ext
}
/**
* The union y extent restricted to an x window (numeric x only). Backs the
* shared-scale autoscale on zoom: the y domain must be the union of what is
* VISIBLE in every panel, or the first zoom silently un-shares the scale.
* @param {import('./TrellisSplit').TrellisSlice[]} panels
* @param {'plain'|'paired'|'object'} xForm
* @param {number} xMin
* @param {number} xMax
* @returns {{ min: number, max: number } | null}
*/
export function yExtentInWindow(panels, xForm, xMin, xMax) {
const ext = { min: Infinity, max: -Infinity }
panels.forEach((p) =>
p.series.forEach((s) => {
if (!Array.isArray(s.data)) return
s.data.forEach((/** @type {any} */ d) => {
if (d === null || d === undefined) return
const rawX = xForm === 'paired' ? d[0] : xForm === 'object' ? d.x : null
const x = rawX instanceof Date ? rawX.getTime() : Number(rawX)
if (!isFinite(x) || x < xMin || x > xMax) return
extendByDatum(d, xForm, ext)
})
}),
)
if (!isFinite(ext.min) || !isFinite(ext.max)) return null
return ext
}
/**
* Resolve the shared domains and the trellis-wide color map.
*
* @param {import('./TrellisSplit').TrellisSplitResult} splitResult
* @param {{ scales?: { x?: string, y?: string, color?: string }, targetTicks?: number }} cfg
* @param {{ chartType?: string, userColors?: any[], yExtentOverride?: { min: number, max: number } | null }} host
* @returns {{
* x: { min: number, max: number } | null,
* y: { min: number, max: number, tickAmount: number } | null,
* rowY: Map<string, { min: number, max: number, tickAmount: number }> | null,
* colY: Map<string, { min: number, max: number, tickAmount: number }> | null,
* colorOf: (name: string) => string,
* palette: string[],
* }}
*/
export function resolve(splitResult, cfg = {}, host = {}) {
const scales = cfg.scales || {}
const xMode = scales.x || 'shared'
const yMode = scales.y || 'shared'
// x: only meaningful for a numeric/datetime union; category alignment is
// done by the union list itself (every panel carries every category).
/** @type {{ min: number, max: number } | null} */
let x = null
if (xMode === 'shared' && splitResult.xIsNumeric && splitResult.unionX.length) {
const xs = /** @type {number[]} */ (splitResult.unionX)
x = { min: xs[0], max: xs[xs.length - 1] }
}
// Bars measure LENGTH from a baseline: an all-positive bar trellis must
// share the zero baseline or panel heights lie. Histogram counts likewise.
// Positional range marks (candlestick, boxPlot, rangeBar, violin) encode
// POSITION, not length: a candlestick trellis at price 800-900 must not
// scale from 0 (P5; P1 wrongly floored these).
const barFamily = ['bar', 'column', 'histogram'].includes(host.chartType || '')
/** @param {{min:number,max:number}|null} ext */
const toBounds = (ext) => {
if (!ext) return null
if (barFamily && ext.min > 0) ext.min = 0
return niceBounds(ext.min, ext.max, cfg.targetTicks || DEFAULT_TARGET_TICKS)
}
/** @type {{ min: number, max: number, tickAmount: number } | null} */
let y = null
if (yMode === 'shared') {
// A type frame (P5) can supply the y extent when the DRAWN domain is not
// the data's own values (histogram: bin counts, not observations).
y = toBounds(host.yExtentOverride || yExtent(splitResult.panels, splitResult.xForm))
}
// 2-D group scales (P4): one shared domain per row (comparable along a
// row, free across rows) or per column. Identical bounds into every panel
// of the group means identical ticks out: the exit-gate invariant.
/** @type {Map<string, { min: number, max: number, tickAmount: number }> | null} */
let rowY = null
if (yMode === 'independent-row') {
rowY = new Map()
const groups = new Map()
splitResult.panels.forEach((p) => {
const k = p.rowKey ?? ''
if (!groups.has(k)) groups.set(k, [])
groups.get(k).push(p)
})
groups.forEach((panels, k) => {
const b = toBounds(yExtent(panels, splitResult.xForm))
if (b) rowY?.set(k, b)
})
}
/** @type {Map<string, { min: number, max: number, tickAmount: number }> | null} */
let colY = null
if (yMode === 'independent-column') {
colY = new Map()
const groups = new Map()
splitResult.panels.forEach((p) => {
const k = p.colKey ?? ''
if (!groups.has(k)) groups.set(k, [])
groups.get(k).push(p)
})
groups.forEach((panels, k) => {
const b = toBounds(yExtent(panels, splitResult.xForm))
if (b) colY?.set(k, b)
})
}
// Color: one series-name -> color mapping for the whole trellis. The user's
// `colors` array wins; the default palette otherwise. Function entries in
// `colors` are per-datapoint resolvers the trellis cannot index by name, so
// they fall back to the palette for the shared map.
const palettes = getThemePalettes()
const fallback = palettes.palette1
const userColors = Array.isArray(host.userColors)
? host.userColors.filter((c) => typeof c === 'string')
: []
const palette = userColors.length ? userColors : fallback
const names = splitResult.seriesNames
/** @param {string} name */
const colorOf = (name) => {
const idx = names.indexOf(name)
return palette[(idx === -1 ? 0 : idx) % palette.length]
}
return { x, y, rowY, colY, colorOf, palette }
}