import defaults from './core.defaults'; import Element from './core.element'; import {_alignPixel, _measureText, renderText, clipArea, unclipArea} from '../helpers/helpers.canvas'; import {callback as call, each, finiteOrDefault, isArray, isFinite, isNullOrUndef, isObject, valueOrDefault} from '../helpers/helpers.core'; import {_factorize, toDegrees, toRadians, _int16Range, HALF_PI, _limitValue} from '../helpers/helpers.math'; import {toFont, toPadding} from '../helpers/helpers.options'; import Ticks from './core.ticks'; /** * @typedef { import("./core.controller").default } Chart * @typedef {{value:number | string, label?:string, major?:boolean, $context?:any}} Tick */ defaults.set('scale', { display: true, offset: false, reverse: false, beginAtZero: false, /** * Scale boundary strategy (bypassed by min/max time options) * - `data`: make sure data are fully visible, ticks outside are removed * - `ticks`: make sure ticks are fully visible, data outside are truncated * @see https://github.com/chartjs/Chart.js/pull/4556 * @since 3.0.0 */ bounds: 'ticks', /** * Addition grace added to max and reduced from min data value. * @since 3.0.0 */ grace: 0, // grid line settings gridLines: { display: true, lineWidth: 1, drawBorder: true, drawOnChartArea: true, drawTicks: true, tickLength: 10, tickWidth: (_ctx, options) => options.lineWidth, tickColor: (_ctx, options) => options.color, offsetGridLines: false, borderDash: [], borderDashOffset: 0.0, borderColor: (_ctx, options) => options.color, borderWidth: (_ctx, options) => options.lineWidth }, // scale title title: { // display property display: false, // actual label text: '', // top/bottom padding padding: { top: 4, bottom: 4 } }, // label settings ticks: { minRotation: 0, maxRotation: 50, mirror: false, textStrokeWidth: 0, textStrokeColor: '', padding: 0, display: true, autoSkip: true, autoSkipPadding: 0, labelOffset: 0, // We pass through arrays to be rendered as multiline labels, we convert Others to strings here. callback: Ticks.formatters.values, minor: {}, major: {}, align: 'center', crossAlign: 'near', } }); defaults.route('scale.ticks', 'color', '', 'color'); defaults.route('scale.gridLines', 'color', '', 'borderColor'); defaults.route('scale.title', 'color', '', 'color'); defaults.describe('scale', { _fallback: false, _scriptable: (name) => !name.startsWith('before') && !name.startsWith('after') && name !== 'callback' && name !== 'parser', _indexable: (name) => name !== 'borderDash' && name !== 'tickBorderDash', }); defaults.describe('scales', { _fallback: 'scale', }); /** * Returns a new array containing numItems from arr * @param {any[]} arr * @param {number} numItems */ function sample(arr, numItems) { const result = []; const increment = arr.length / numItems; const len = arr.length; let i = 0; for (; i < len; i += increment) { result.push(arr[Math.floor(i)]); } return result; } /** * @param {Scale} scale * @param {number} index * @param {boolean} offsetGridLines */ function getPixelForGridLine(scale, index, offsetGridLines) { const length = scale.ticks.length; const validIndex = Math.min(index, length - 1); const start = scale._startPixel; const end = scale._endPixel; const epsilon = 1e-6; // 1e-6 is margin in pixels for accumulated error. let lineValue = scale.getPixelForTick(validIndex); let offset; if (offsetGridLines) { if (length === 1) { offset = Math.max(lineValue - start, end - lineValue); } else if (index === 0) { offset = (scale.getPixelForTick(1) - lineValue) / 2; } else { offset = (lineValue - scale.getPixelForTick(validIndex - 1)) / 2; } lineValue += validIndex < index ? offset : -offset; // Return undefined if the pixel is out of the range if (lineValue < start - epsilon || lineValue > end + epsilon) { return; } } return lineValue; } /** * @param {object} caches * @param {number} length */ function garbageCollect(caches, length) { each(caches, (cache) => { const gc = cache.gc; const gcLen = gc.length / 2; let i; if (gcLen > length) { for (i = 0; i < gcLen; ++i) { delete cache.data[gc[i]]; } gc.splice(0, gcLen); } }); } /** * @param {object} options */ function getTickMarkLength(options) { return options.drawTicks ? options.tickLength : 0; } /** * @param {object} options */ function getTitleHeight(options, fallback) { if (!options.display) { return 0; } const font = toFont(options.font, fallback); const padding = toPadding(options.padding); const lines = isArray(options.text) ? options.text.length : 1; return (lines * font.lineHeight) + padding.height; } /** * @param {number[]} arr */ function getEvenSpacing(arr) { const len = arr.length; let i, diff; if (len < 2) { return false; } for (diff = arr[0], i = 1; i < len; ++i) { if (arr[i] - arr[i - 1] !== diff) { return false; } } return diff; } /** * @param {number[]} majorIndices * @param {Tick[]} ticks * @param {number} ticksLimit */ function calculateSpacing(majorIndices, ticks, ticksLimit) { const evenMajorSpacing = getEvenSpacing(majorIndices); const spacing = ticks.length / ticksLimit; // If the major ticks are evenly spaced apart, place the minor ticks // so that they divide the major ticks into even chunks if (!evenMajorSpacing) { return Math.max(spacing, 1); } const factors = _factorize(evenMajorSpacing); for (let i = 0, ilen = factors.length - 1; i < ilen; i++) { const factor = factors[i]; if (factor > spacing) { return factor; } } return Math.max(spacing, 1); } /** * @param {Tick[]} ticks */ function getMajorIndices(ticks) { const result = []; let i, ilen; for (i = 0, ilen = ticks.length; i < ilen; i++) { if (ticks[i].major) { result.push(i); } } return result; } /** * @param {Tick[]} ticks * @param {Tick[]} newTicks * @param {number[]} majorIndices * @param {number} spacing */ function skipMajors(ticks, newTicks, majorIndices, spacing) { let count = 0; let next = majorIndices[0]; let i; spacing = Math.ceil(spacing); for (i = 0; i < ticks.length; i++) { if (i === next) { newTicks.push(ticks[i]); count++; next = majorIndices[count * spacing]; } } } /** * @param {Tick[]} ticks * @param {Tick[]} newTicks * @param {number} spacing * @param {number} [majorStart] * @param {number} [majorEnd] */ function skip(ticks, newTicks, spacing, majorStart, majorEnd) { const start = valueOrDefault(majorStart, 0); const end = Math.min(valueOrDefault(majorEnd, ticks.length), ticks.length); let count = 0; let length, i, next; spacing = Math.ceil(spacing); if (majorEnd) { length = majorEnd - majorStart; spacing = length / Math.floor(length / spacing); } next = start; while (next < 0) { count++; next = Math.round(start + count * spacing); } for (i = Math.max(start, 0); i < end; i++) { if (i === next) { newTicks.push(ticks[i]); count++; next = Math.round(start + count * spacing); } } } function createScaleContext(parent, scale) { return Object.assign(Object.create(parent), { scale, type: 'scale' }); } function createTickContext(parent, index, tick) { return Object.assign(Object.create(parent), { tick, index, type: 'tick' }); } export default class Scale extends Element { // eslint-disable-next-line max-statements constructor(cfg) { super(); /** @type {string} */ this.id = cfg.id; /** @type {string} */ this.type = cfg.type; /** @type {object} */ this.options = undefined; /** @type {CanvasRenderingContext2D} */ this.ctx = cfg.ctx; /** @type {Chart} */ this.chart = cfg.chart; // implements box /** @type {number} */ this.top = undefined; /** @type {number} */ this.bottom = undefined; /** @type {number} */ this.left = undefined; /** @type {number} */ this.right = undefined; /** @type {number} */ this.width = undefined; /** @type {number} */ this.height = undefined; this._margins = { left: 0, right: 0, top: 0, bottom: 0 }; /** @type {number} */ this.maxWidth = undefined; /** @type {number} */ this.maxHeight = undefined; /** @type {number} */ this.paddingTop = undefined; /** @type {number} */ this.paddingBottom = undefined; /** @type {number} */ this.paddingLeft = undefined; /** @type {number} */ this.paddingRight = undefined; // scale-specific properties /** @type {string=} */ this.axis = undefined; /** @type {number=} */ this.labelRotation = undefined; this.min = undefined; this.max = undefined; /** @type {Tick[]} */ this.ticks = []; /** @type {object[]|null} */ this._gridLineItems = null; /** @type {object[]|null} */ this._labelItems = null; /** @type {object|null} */ this._labelSizes = null; this._length = 0; this._longestTextCache = {}; /** @type {number} */ this._startPixel = undefined; /** @type {number} */ this._endPixel = undefined; this._reversePixels = false; this._userMax = undefined; this._userMin = undefined; this._suggestedMax = undefined; this._suggestedMin = undefined; this._ticksLength = 0; this._borderValue = 0; this._cache = {}; this._dataLimitsCached = false; this.$context = undefined; } /** * @param {object} options * @since 3.0 */ init(options) { const me = this; me.options = options; me.axis = options.axis; // parse min/max value, so we can properly determine min/max for other scales me._userMin = me.parse(options.min); me._userMax = me.parse(options.max); me._suggestedMin = me.parse(options.suggestedMin); me._suggestedMax = me.parse(options.suggestedMax); } /** * Parse a supported input value to internal representation. * @param {*} raw * @param {number} [index] * @since 3.0 */ parse(raw, index) { // eslint-disable-line no-unused-vars return raw; } /** * @return {{min: number, max: number, minDefined: boolean, maxDefined: boolean}} * @protected * @since 3.0 */ getUserBounds() { let {_userMin, _userMax, _suggestedMin, _suggestedMax} = this; _userMin = finiteOrDefault(_userMin, Number.POSITIVE_INFINITY); _userMax = finiteOrDefault(_userMax, Number.NEGATIVE_INFINITY); _suggestedMin = finiteOrDefault(_suggestedMin, Number.POSITIVE_INFINITY); _suggestedMax = finiteOrDefault(_suggestedMax, Number.NEGATIVE_INFINITY); return { min: finiteOrDefault(_userMin, _suggestedMin), max: finiteOrDefault(_userMax, _suggestedMax), minDefined: isFinite(_userMin), maxDefined: isFinite(_userMax) }; } /** * @param {boolean} canStack * @return {{min: number, max: number}} * @protected * @since 3.0 */ getMinMax(canStack) { const me = this; // eslint-disable-next-line prefer-const let {min, max, minDefined, maxDefined} = me.getUserBounds(); let range; if (minDefined && maxDefined) { return {min, max}; } const metas = me.getMatchingVisibleMetas(); for (let i = 0, ilen = metas.length; i < ilen; ++i) { range = metas[i].controller.getMinMax(me, canStack); if (!minDefined) { min = Math.min(min, range.min); } if (!maxDefined) { max = Math.max(max, range.max); } } return { min: finiteOrDefault(min, finiteOrDefault(max, min)), max: finiteOrDefault(max, finiteOrDefault(min, max)) }; } /** * Get the padding needed for the scale * @return {{top: number, left: number, bottom: number, right: number}} the necessary padding * @private */ getPadding() { const me = this; return { left: me.paddingLeft || 0, top: me.paddingTop || 0, right: me.paddingRight || 0, bottom: me.paddingBottom || 0 }; } /** * Returns the scale tick objects * @return {Tick[]} * @since 2.7 */ getTicks() { return this.ticks; } /** * @return {string[]} */ getLabels() { const data = this.chart.data; return this.options.labels || (this.isHorizontal() ? data.xLabels : data.yLabels) || data.labels || []; } // When a new layout is created, reset the data limits cache beforeLayout() { this._cache = {}; this._dataLimitsCached = false; } // These methods are ordered by lifecycle. Utilities then follow. // Any function defined here is inherited by all scale types. // Any function can be extended by the scale type beforeUpdate() { call(this.options.beforeUpdate, [this]); } /** * @param {number} maxWidth - the max width in pixels * @param {number} maxHeight - the max height in pixels * @param {{top: number, left: number, bottom: number, right: number}} margins - the space between the edge of the other scales and edge of the chart * This space comes from two sources: * - padding - space that's required to show the labels at the edges of the scale * - thickness of scales or legends in another orientation */ update(maxWidth, maxHeight, margins) { const me = this; const tickOpts = me.options.ticks; const sampleSize = tickOpts.sampleSize; // Update Lifecycle - Probably don't want to ever extend or overwrite this function ;) me.beforeUpdate(); // Absorb the master measurements me.maxWidth = maxWidth; me.maxHeight = maxHeight; me._margins = Object.assign({ left: 0, right: 0, top: 0, bottom: 0 }, margins); me.ticks = null; me._labelSizes = null; me._gridLineItems = null; me._labelItems = null; // Dimensions me.beforeSetDimensions(); me.setDimensions(); me.afterSetDimensions(); // Data min/max if (!me._dataLimitsCached) { me.beforeDataLimits(); me.determineDataLimits(); me.afterDataLimits(); me._dataLimitsCached = true; } me.beforeBuildTicks(); me.ticks = me.buildTicks() || []; // Allow modification of ticks in callback. me.afterBuildTicks(); // Compute tick rotation and fit using a sampled subset of labels // We generally don't need to compute the size of every single label for determining scale size const samplingEnabled = sampleSize < me.ticks.length; me._convertTicksToLabels(samplingEnabled ? sample(me.ticks, sampleSize) : me.ticks); // configure is called twice, once here, once from core.controller.updateLayout. // Here we haven't been positioned yet, but dimensions are correct. // Variables set in configure are needed for calculateLabelRotation, and // it's ok that coordinates are not correct there, only dimensions matter. me.configure(); // Tick Rotation me.beforeCalculateLabelRotation(); me.calculateLabelRotation(); // Preconditions: number of ticks and sizes of largest labels must be calculated beforehand me.afterCalculateLabelRotation(); me.beforeFit(); me.fit(); // Preconditions: label rotation and label sizes must be calculated beforehand me.afterFit(); // Auto-skip me.ticks = tickOpts.display && (tickOpts.autoSkip || tickOpts.source === 'auto') ? me._autoSkip(me.ticks) : me.ticks; if (samplingEnabled) { // Generate labels using all non-skipped ticks me._convertTicksToLabels(me.ticks); } // IMPORTANT: after this point, we consider that `this.ticks` will NEVER change! me.afterUpdate(); } /** * @protected */ configure() { const me = this; let reversePixels = me.options.reverse; let startPixel, endPixel; if (me.isHorizontal()) { startPixel = me.left; endPixel = me.right; } else { startPixel = me.top; endPixel = me.bottom; // by default vertical scales are from bottom to top, so pixels are reversed reversePixels = !reversePixels; } me._startPixel = startPixel; me._endPixel = endPixel; me._reversePixels = reversePixels; me._length = endPixel - startPixel; } afterUpdate() { call(this.options.afterUpdate, [this]); } // beforeSetDimensions() { call(this.options.beforeSetDimensions, [this]); } setDimensions() { const me = this; // Set the unconstrained dimension before label rotation if (me.isHorizontal()) { // Reset position before calculating rotation me.width = me.maxWidth; me.left = 0; me.right = me.width; } else { me.height = me.maxHeight; // Reset position before calculating rotation me.top = 0; me.bottom = me.height; } // Reset padding me.paddingLeft = 0; me.paddingTop = 0; me.paddingRight = 0; me.paddingBottom = 0; } afterSetDimensions() { call(this.options.afterSetDimensions, [this]); } _callHooks(name) { const me = this; me.chart.notifyPlugins(name, me.getContext()); call(me.options[name], [me]); } // Data limits beforeDataLimits() { this._callHooks('beforeDataLimits'); } determineDataLimits() {} afterDataLimits() { this._callHooks('afterDataLimits'); } // beforeBuildTicks() { this._callHooks('beforeBuildTicks'); } /** * @return {object[]} the ticks */ buildTicks() { return []; } afterBuildTicks() { this._callHooks('afterBuildTicks'); } beforeTickToLabelConversion() { call(this.options.beforeTickToLabelConversion, [this]); } /** * Convert ticks to label strings * @param {Tick[]} ticks */ generateTickLabels(ticks) { const me = this; const tickOpts = me.options.ticks; let i, ilen, tick; for (i = 0, ilen = ticks.length; i < ilen; i++) { tick = ticks[i]; tick.label = call(tickOpts.callback, [tick.value, i, ticks], me); } } afterTickToLabelConversion() { call(this.options.afterTickToLabelConversion, [this]); } // beforeCalculateLabelRotation() { call(this.options.beforeCalculateLabelRotation, [this]); } calculateLabelRotation() { const me = this; const options = me.options; const tickOpts = options.ticks; const numTicks = me.ticks.length; const minRotation = tickOpts.minRotation || 0; const maxRotation = tickOpts.maxRotation; let labelRotation = minRotation; let tickWidth, maxHeight, maxLabelDiagonal; if (!me._isVisible() || !tickOpts.display || minRotation >= maxRotation || numTicks <= 1 || !me.isHorizontal()) { me.labelRotation = minRotation; return; } const labelSizes = me._getLabelSizes(); const maxLabelWidth = labelSizes.widest.width; const maxLabelHeight = labelSizes.highest.height - labelSizes.highest.offset; // Estimate the width of each grid based on the canvas width, the maximum // label width and the number of tick intervals const maxWidth = _limitValue(me.chart.width - maxLabelWidth, 0, me.maxWidth); tickWidth = options.offset ? me.maxWidth / numTicks : maxWidth / (numTicks - 1); // Allow 3 pixels x2 padding either side for label readability if (maxLabelWidth + 6 > tickWidth) { tickWidth = maxWidth / (numTicks - (options.offset ? 0.5 : 1)); maxHeight = me.maxHeight - getTickMarkLength(options.gridLines) - tickOpts.padding - getTitleHeight(options.title, me.chart.options.font); maxLabelDiagonal = Math.sqrt(maxLabelWidth * maxLabelWidth + maxLabelHeight * maxLabelHeight); labelRotation = toDegrees(Math.min( Math.asin(Math.min((labelSizes.highest.height + 6) / tickWidth, 1)), Math.asin(Math.min(maxHeight / maxLabelDiagonal, 1)) - Math.asin(maxLabelHeight / maxLabelDiagonal) )); labelRotation = Math.max(minRotation, Math.min(maxRotation, labelRotation)); } me.labelRotation = labelRotation; } afterCalculateLabelRotation() { call(this.options.afterCalculateLabelRotation, [this]); } // beforeFit() { call(this.options.beforeFit, [this]); } fit() { const me = this; // Reset const minSize = { width: 0, height: 0 }; const chart = me.chart; const opts = me.options; const tickOpts = opts.ticks; const titleOpts = opts.title; const gridLineOpts = opts.gridLines; const display = me._isVisible(); const labelsBelowTicks = opts.position !== 'top' && me.axis === 'x'; const isHorizontal = me.isHorizontal(); const titleHeight = display && getTitleHeight(titleOpts, chart.options.font); // Width if (isHorizontal) { minSize.width = me.maxWidth; } else if (display) { minSize.width = getTickMarkLength(gridLineOpts) + titleHeight; } // height if (!isHorizontal) { minSize.height = me.maxHeight; // fill all the height } else if (display) { minSize.height = getTickMarkLength(gridLineOpts) + titleHeight; } // Don't bother fitting the ticks if we are not showing the labels if (tickOpts.display && display && me.ticks.length) { const labelSizes = me._getLabelSizes(); const firstLabelSize = labelSizes.first; const lastLabelSize = labelSizes.last; const widestLabelSize = labelSizes.widest; const highestLabelSize = labelSizes.highest; const lineSpace = highestLabelSize.offset * 0.8; const tickPadding = tickOpts.padding; if (isHorizontal) { // A horizontal axis is more constrained by the height. const isRotated = me.labelRotation !== 0; const angleRadians = toRadians(me.labelRotation); const cosRotation = Math.cos(angleRadians); const sinRotation = Math.sin(angleRadians); const labelHeight = sinRotation * widestLabelSize.width + cosRotation * (highestLabelSize.height - (isRotated ? highestLabelSize.offset : 0)) + (isRotated ? 0 : lineSpace); // padding minSize.height = Math.min(me.maxHeight, minSize.height + labelHeight + tickPadding); const offsetLeft = me.getPixelForTick(0) - me.left; const offsetRight = me.right - me.getPixelForTick(me.ticks.length - 1); let paddingLeft, paddingRight; // Ensure that our ticks are always inside the canvas. When rotated, ticks are right aligned // which means that the right padding is dominated by the font height if (isRotated) { paddingLeft = labelsBelowTicks ? cosRotation * firstLabelSize.width + sinRotation * firstLabelSize.offset : sinRotation * (firstLabelSize.height - firstLabelSize.offset); paddingRight = labelsBelowTicks ? sinRotation * (lastLabelSize.height - lastLabelSize.offset) : cosRotation * lastLabelSize.width + sinRotation * lastLabelSize.offset; } else if (tickOpts.align === 'start') { paddingLeft = 0; paddingRight = lastLabelSize.width; } else if (tickOpts.align === 'end') { paddingLeft = firstLabelSize.width; paddingRight = 0; } else { paddingLeft = firstLabelSize.width / 2; paddingRight = lastLabelSize.width / 2; } // Adjust padding taking into account changes in offsets // and add 3 px to move away from canvas edges me.paddingLeft = Math.max((paddingLeft - offsetLeft) * me.width / (me.width - offsetLeft), 0) + 3; me.paddingRight = Math.max((paddingRight - offsetRight) * me.width / (me.width - offsetRight), 0) + 3; } else { // A vertical axis is more constrained by the width. Labels are the // dominant factor here, so get that length first and account for padding const labelWidth = tickOpts.mirror ? 0 : // use lineSpace for consistency with horizontal axis // tickPadding is not implemented for horizontal widestLabelSize.width + tickPadding + lineSpace; minSize.width = Math.min(me.maxWidth, minSize.width + labelWidth); let paddingTop = lastLabelSize.height / 2; let paddingBottom = firstLabelSize.height / 2; if (tickOpts.align === 'start') { paddingTop = 0; paddingBottom = firstLabelSize.height; } else if (tickOpts.align === 'end') { paddingTop = lastLabelSize.height; paddingBottom = 0; } me.paddingTop = paddingTop; me.paddingBottom = paddingBottom; } } me._handleMargins(); if (isHorizontal) { me.width = me._length = chart.width - me._margins.left - me._margins.right; me.height = minSize.height; } else { me.width = minSize.width; me.height = me._length = chart.height - me._margins.top - me._margins.bottom; } } /** * Handle margins and padding interactions * @private */ _handleMargins() { const me = this; if (me._margins) { me._margins.left = Math.max(me.paddingLeft, me._margins.left); me._margins.top = Math.max(me.paddingTop, me._margins.top); me._margins.right = Math.max(me.paddingRight, me._margins.right); me._margins.bottom = Math.max(me.paddingBottom, me._margins.bottom); } } afterFit() { call(this.options.afterFit, [this]); } // Shared Methods /** * @return {boolean} */ isHorizontal() { const {axis, position} = this.options; return position === 'top' || position === 'bottom' || axis === 'x'; } /** * @return {boolean} */ isFullSize() { return this.options.fullSize; } /** * @param {Tick[]} ticks * @private */ _convertTicksToLabels(ticks) { const me = this; me.beforeTickToLabelConversion(); me.generateTickLabels(ticks); me.afterTickToLabelConversion(); } /** * @return {{ first: object, last: object, widest: object, highest: object }} * @private */ _getLabelSizes() { const me = this; let labelSizes = me._labelSizes; if (!labelSizes) { me._labelSizes = labelSizes = me._computeLabelSizes(); } return labelSizes; } /** * Returns {width, height, offset} objects for the first, last, widest, highest tick * labels where offset indicates the anchor point offset from the top in pixels. * @return {{ first: object, last: object, widest: object, highest: object }} * @private */ _computeLabelSizes() { const me = this; const ctx = me.ctx; const caches = me._longestTextCache; const sampleSize = me.options.ticks.sampleSize; const widths = []; const heights = []; const offsets = []; let widestLabelSize = 0; let highestLabelSize = 0; let ticks = me.ticks; if (sampleSize < ticks.length) { ticks = sample(ticks, sampleSize); } const length = ticks.length; let i, j, jlen, label, tickFont, fontString, cache, lineHeight, width, height, nestedLabel; for (i = 0; i < length; ++i) { label = ticks[i].label; tickFont = me._resolveTickFontOptions(i); ctx.font = fontString = tickFont.string; cache = caches[fontString] = caches[fontString] || {data: {}, gc: []}; lineHeight = tickFont.lineHeight; width = height = 0; // Undefined labels and arrays should not be measured if (!isNullOrUndef(label) && !isArray(label)) { width = _measureText(ctx, cache.data, cache.gc, width, label); height = lineHeight; } else if (isArray(label)) { // if it is an array let's measure each element for (j = 0, jlen = label.length; j < jlen; ++j) { nestedLabel = label[j]; // Undefined labels and arrays should not be measured if (!isNullOrUndef(nestedLabel) && !isArray(nestedLabel)) { width = _measureText(ctx, cache.data, cache.gc, width, nestedLabel); height += lineHeight; } } } widths.push(width); heights.push(height); offsets.push(lineHeight / 2); widestLabelSize = Math.max(width, widestLabelSize); highestLabelSize = Math.max(height, highestLabelSize); } garbageCollect(caches, length); const widest = widths.indexOf(widestLabelSize); const highest = heights.indexOf(highestLabelSize); function valueAt(idx) { return { width: widths[idx] || 0, height: heights[idx] || 0, offset: offsets[idx] || 0 }; } return { first: valueAt(0), last: valueAt(length - 1), widest: valueAt(widest), highest: valueAt(highest) }; } /** * Used to get the label to display in the tooltip for the given value * @param {*} value * @return {string} */ getLabelForValue(value) { return value; } /** * Returns the location of the given data point. Value can either be an index or a numerical value * The coordinate (0, 0) is at the upper-left corner of the canvas * @param {*} value * @param {number} [index] * @return {number} */ getPixelForValue(value, index) { // eslint-disable-line no-unused-vars return NaN; } /** * Used to get the data value from a given pixel. This is the inverse of getPixelForValue * The coordinate (0, 0) is at the upper-left corner of the canvas * @param {number} pixel * @return {*} */ getValueForPixel(pixel) {} // eslint-disable-line no-unused-vars /** * Returns the location of the tick at the given index * The coordinate (0, 0) is at the upper-left corner of the canvas * @param {number} index * @return {number} */ getPixelForTick(index) { const ticks = this.ticks; if (index < 0 || index > ticks.length - 1) { return null; } return this.getPixelForValue(ticks[index].value); } /** * Utility for getting the pixel location of a percentage of scale * The coordinate (0, 0) is at the upper-left corner of the canvas * @param {number} decimal * @return {number} */ getPixelForDecimal(decimal) { const me = this; if (me._reversePixels) { decimal = 1 - decimal; } return _int16Range(me._startPixel + decimal * me._length); } /** * @param {number} pixel * @return {number} */ getDecimalForPixel(pixel) { const decimal = (pixel - this._startPixel) / this._length; return this._reversePixels ? 1 - decimal : decimal; } /** * Returns the pixel for the minimum chart value * The coordinate (0, 0) is at the upper-left corner of the canvas * @return {number} */ getBasePixel() { return this.getPixelForValue(this.getBaseValue()); } /** * @return {number} */ getBaseValue() { const {min, max} = this; return min < 0 && max < 0 ? max : min > 0 && max > 0 ? min : 0; } /** * @protected */ getContext(index) { const me = this; const ticks = me.ticks || []; if (index >= 0 && index < ticks.length) { const tick = ticks[index]; return tick.$context || (tick.$context = createTickContext(me.getContext(), index, tick)); } return me.$context || (me.$context = createScaleContext(me.chart.getContext(), me)); } /** * Returns a subset of ticks to be plotted to avoid overlapping labels. * @param {Tick[]} ticks * @return {Tick[]} * @private */ _autoSkip(ticks) { const me = this; const tickOpts = me.options.ticks; const ticksLimit = tickOpts.maxTicksLimit || me._length / me._tickSize(); const majorIndices = tickOpts.major.enabled ? getMajorIndices(ticks) : []; const numMajorIndices = majorIndices.length; const first = majorIndices[0]; const last = majorIndices[numMajorIndices - 1]; const newTicks = []; // If there are too many major ticks to display them all if (numMajorIndices > ticksLimit) { skipMajors(ticks, newTicks, majorIndices, numMajorIndices / ticksLimit); return newTicks; } const spacing = calculateSpacing(majorIndices, ticks, ticksLimit); if (numMajorIndices > 0) { let i, ilen; const avgMajorSpacing = numMajorIndices > 1 ? Math.round((last - first) / (numMajorIndices - 1)) : null; skip(ticks, newTicks, spacing, isNullOrUndef(avgMajorSpacing) ? 0 : first - avgMajorSpacing, first); for (i = 0, ilen = numMajorIndices - 1; i < ilen; i++) { skip(ticks, newTicks, spacing, majorIndices[i], majorIndices[i + 1]); } skip(ticks, newTicks, spacing, last, isNullOrUndef(avgMajorSpacing) ? ticks.length : last + avgMajorSpacing); return newTicks; } skip(ticks, newTicks, spacing); return newTicks; } /** * @return {number} * @private */ _tickSize() { const me = this; const optionTicks = me.options.ticks; // Calculate space needed by label in axis direction. const rot = toRadians(me.labelRotation); const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot)); const labelSizes = me._getLabelSizes(); const padding = optionTicks.autoSkipPadding || 0; const w = labelSizes ? labelSizes.widest.width + padding : 0; const h = labelSizes ? labelSizes.highest.height + padding : 0; // Calculate space needed for 1 tick in axis direction. return me.isHorizontal() ? h * cos > w * sin ? w / cos : h / sin : h * sin < w * cos ? h / cos : w / sin; } /** * @return {boolean} * @private */ _isVisible() { const display = this.options.display; if (display !== 'auto') { return !!display; } return this.getMatchingVisibleMetas().length > 0; } /** * @private */ _computeGridLineItems(chartArea) { const me = this; const axis = me.axis; const chart = me.chart; const options = me.options; const {gridLines, position} = options; const offsetGridLines = gridLines.offsetGridLines; const isHorizontal = me.isHorizontal(); const ticks = me.ticks; const ticksLength = ticks.length + (offsetGridLines ? 1 : 0); const tl = getTickMarkLength(gridLines); const items = []; const borderOpts = gridLines.setContext(me.getContext(0)); const axisWidth = borderOpts.drawBorder ? borderOpts.borderWidth : 0; const axisHalfWidth = axisWidth / 2; const alignBorderValue = function(pixel) { return _alignPixel(chart, pixel, axisWidth); }; let borderValue, i, lineValue, alignedLineValue; let tx1, ty1, tx2, ty2, x1, y1, x2, y2; if (position === 'top') { borderValue = alignBorderValue(me.bottom); ty1 = me.bottom - tl; ty2 = borderValue - axisHalfWidth; y1 = alignBorderValue(chartArea.top) + axisHalfWidth; y2 = chartArea.bottom; } else if (position === 'bottom') { borderValue = alignBorderValue(me.top); y1 = chartArea.top; y2 = alignBorderValue(chartArea.bottom) - axisHalfWidth; ty1 = borderValue + axisHalfWidth; ty2 = me.top + tl; } else if (position === 'left') { borderValue = alignBorderValue(me.right); tx1 = me.right - tl; tx2 = borderValue - axisHalfWidth; x1 = alignBorderValue(chartArea.left) + axisHalfWidth; x2 = chartArea.right; } else if (position === 'right') { borderValue = alignBorderValue(me.left); x1 = chartArea.left; x2 = alignBorderValue(chartArea.right) - axisHalfWidth; tx1 = borderValue + axisHalfWidth; tx2 = me.left + tl; } else if (axis === 'x') { if (position === 'center') { borderValue = alignBorderValue((chartArea.top + chartArea.bottom) / 2); } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; borderValue = alignBorderValue(me.chart.scales[positionAxisID].getPixelForValue(value)); } y1 = chartArea.top; y2 = chartArea.bottom; ty1 = borderValue + axisHalfWidth; ty2 = ty1 + tl; } else if (axis === 'y') { if (position === 'center') { borderValue = alignBorderValue((chartArea.left + chartArea.right) / 2); } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; borderValue = alignBorderValue(me.chart.scales[positionAxisID].getPixelForValue(value)); } tx1 = borderValue - axisHalfWidth; tx2 = tx1 - tl; x1 = chartArea.left; x2 = chartArea.right; } for (i = 0; i < ticksLength; ++i) { const optsAtIndex = gridLines.setContext(me.getContext(i)); const lineWidth = optsAtIndex.lineWidth; const lineColor = optsAtIndex.color; const borderDash = gridLines.borderDash || []; const borderDashOffset = optsAtIndex.borderDashOffset; const tickWidth = optsAtIndex.tickWidth; const tickColor = optsAtIndex.tickColor; const tickBorderDash = optsAtIndex.tickBorderDash || []; const tickBorderDashOffset = optsAtIndex.tickBorderDashOffset; lineValue = getPixelForGridLine(me, i, offsetGridLines); // Skip if the pixel is out of the range if (lineValue === undefined) { continue; } alignedLineValue = _alignPixel(chart, lineValue, lineWidth); if (isHorizontal) { tx1 = tx2 = x1 = x2 = alignedLineValue; } else { ty1 = ty2 = y1 = y2 = alignedLineValue; } items.push({ tx1, ty1, tx2, ty2, x1, y1, x2, y2, width: lineWidth, color: lineColor, borderDash, borderDashOffset, tickWidth, tickColor, tickBorderDash, tickBorderDashOffset, }); } me._ticksLength = ticksLength; me._borderValue = borderValue; return items; } /** * @private */ _computeLabelItems(chartArea) { const me = this; const axis = me.axis; const options = me.options; const {position, ticks: optionTicks} = options; const isHorizontal = me.isHorizontal(); const ticks = me.ticks; const {align, crossAlign, padding} = optionTicks; const tl = getTickMarkLength(options.gridLines); const tickAndPadding = tl + padding; const rotation = -toRadians(me.labelRotation); const items = []; let i, ilen, tick, label, x, y, textAlign, pixel, font, lineHeight, lineCount, textOffset; let textBaseline = 'middle'; if (position === 'top') { y = me.bottom - tickAndPadding; textAlign = me._getXAxisLabelAlignment(); } else if (position === 'bottom') { y = me.top + tickAndPadding; textAlign = me._getXAxisLabelAlignment(); } else if (position === 'left') { const ret = this._getYAxisLabelAlignment(tl); textAlign = ret.textAlign; x = ret.x; } else if (position === 'right') { const ret = this._getYAxisLabelAlignment(tl); textAlign = ret.textAlign; x = ret.x; } else if (axis === 'x') { if (position === 'center') { y = ((chartArea.top + chartArea.bottom) / 2) + tickAndPadding; } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; y = me.chart.scales[positionAxisID].getPixelForValue(value) + tickAndPadding; } textAlign = me._getXAxisLabelAlignment(); } else if (axis === 'y') { if (position === 'center') { x = ((chartArea.left + chartArea.right) / 2) - tickAndPadding; } else if (isObject(position)) { const positionAxisID = Object.keys(position)[0]; const value = position[positionAxisID]; x = me.chart.scales[positionAxisID].getPixelForValue(value); } textAlign = this._getYAxisLabelAlignment(tl).textAlign; } if (axis === 'y') { if (align === 'start') { textBaseline = 'top'; } else if (align === 'end') { textBaseline = 'bottom'; } } const labelSizes = me._getLabelSizes(); for (i = 0, ilen = ticks.length; i < ilen; ++i) { tick = ticks[i]; label = tick.label; const optsAtIndex = optionTicks.setContext(me.getContext(i)); pixel = me.getPixelForTick(i) + optionTicks.labelOffset; font = me._resolveTickFontOptions(i); lineHeight = font.lineHeight; lineCount = isArray(label) ? label.length : 1; const halfCount = lineCount / 2; const color = optsAtIndex.color; const strokeColor = optsAtIndex.textStrokeColor; const strokeWidth = optsAtIndex.textStrokeWidth; if (isHorizontal) { x = pixel; if (position === 'top') { if (crossAlign === 'near' || rotation !== 0) { textOffset = (Math.sin(rotation) * halfCount + 0.5) * lineHeight; textOffset -= (rotation === 0 ? (lineCount - 0.5) : Math.cos(rotation) * halfCount) * lineHeight; } else if (crossAlign === 'center') { textOffset = -1 * (labelSizes.highest.height / 2); textOffset -= halfCount * lineHeight; } else { textOffset = (-1 * labelSizes.highest.height) + (0.5 * lineHeight); } } else { // eslint-disable-next-line no-lonely-if if (crossAlign === 'near' || rotation !== 0) { textOffset = Math.sin(rotation) * halfCount * lineHeight; textOffset += (rotation === 0 ? 0.5 : Math.cos(rotation) * halfCount) * lineHeight; } else if (crossAlign === 'center') { textOffset = labelSizes.highest.height / 2; textOffset -= halfCount * lineHeight; } else { textOffset = labelSizes.highest.height - ((lineCount - 0.5) * lineHeight); } } } else { y = pixel; textOffset = (1 - lineCount) * lineHeight / 2; } items.push({ rotation, label, font, color, strokeColor, strokeWidth, textOffset, textAlign, textBaseline, translation: [x, y] }); } return items; } _getXAxisLabelAlignment() { const me = this; const {position, ticks} = me.options; const rotation = -toRadians(me.labelRotation); if (rotation) { return position === 'top' ? 'left' : 'right'; } let align = 'center'; if (ticks.align === 'start') { align = 'left'; } else if (ticks.align === 'end') { align = 'right'; } return align; } _getYAxisLabelAlignment(tl) { const me = this; const {position, ticks} = me.options; const {crossAlign, mirror, padding} = ticks; const labelSizes = me._getLabelSizes(); const tickAndPadding = tl + padding; const widest = labelSizes.widest.width; const lineSpace = labelSizes.highest.offset * 0.8; let textAlign; let x; if (position === 'left') { if (mirror) { textAlign = 'left'; x = me.right - padding; } else { x = me.right - tickAndPadding; if (crossAlign === 'near') { textAlign = 'right'; } else if (crossAlign === 'center') { textAlign = 'center'; x -= (widest / 2); } else { textAlign = 'left'; x = me.left + lineSpace; } } } else if (position === 'right') { if (mirror) { textAlign = 'right'; x = me.left + padding; } else { x = me.left + tickAndPadding; if (crossAlign === 'near') { textAlign = 'left'; } else if (crossAlign === 'center') { textAlign = 'center'; x += widest / 2; } else { textAlign = 'right'; x = me.right - lineSpace; } } } else { textAlign = 'right'; } return {textAlign, x}; } /** * @private */ _computeLabelArea() { const me = this; if (me.options.ticks.mirror) { return; } const chart = me.chart; const position = me.options.position; if (position === 'left' || position === 'right') { return {top: 0, left: me.left, bottom: chart.height, right: me.right}; } if (position === 'top' || position === 'bottom') { return {top: me.top, left: 0, bottom: me.bottom, right: chart.width}; } } /** * @protected */ drawGrid(chartArea) { const me = this; const gridLines = me.options.gridLines; const ctx = me.ctx; const chart = me.chart; const borderOpts = gridLines.setContext(me.getContext(0)); const axisWidth = gridLines.drawBorder ? borderOpts.borderWidth : 0; const items = me._gridLineItems || (me._gridLineItems = me._computeGridLineItems(chartArea)); let i, ilen; if (gridLines.display) { for (i = 0, ilen = items.length; i < ilen; ++i) { const item = items[i]; const {color, tickColor, tickWidth, width} = item; if (width && color && gridLines.drawOnChartArea) { ctx.save(); ctx.lineWidth = width; ctx.strokeStyle = color; if (ctx.setLineDash) { ctx.setLineDash(item.borderDash); ctx.lineDashOffset = item.borderDashOffset; } ctx.beginPath(); ctx.moveTo(item.x1, item.y1); ctx.lineTo(item.x2, item.y2); ctx.stroke(); ctx.restore(); } if (tickWidth && tickColor && gridLines.drawTicks) { ctx.save(); ctx.lineWidth = tickWidth; ctx.strokeStyle = tickColor; if (ctx.setLineDash) { ctx.setLineDash(item.tickBorderDash); ctx.lineDashOffset = item.tickBorderDashOffset; } ctx.beginPath(); ctx.moveTo(item.tx1, item.ty1); ctx.lineTo(item.tx2, item.ty2); ctx.stroke(); ctx.restore(); } } } if (axisWidth) { // Draw the line at the edge of the axis const edgeOpts = gridLines.setContext(me.getContext(me._ticksLength - 1)); const lastLineWidth = edgeOpts.lineWidth; const borderValue = me._borderValue; let x1, x2, y1, y2; if (me.isHorizontal()) { x1 = _alignPixel(chart, me.left, axisWidth) - axisWidth / 2; x2 = _alignPixel(chart, me.right, lastLineWidth) + lastLineWidth / 2; y1 = y2 = borderValue; } else { y1 = _alignPixel(chart, me.top, axisWidth) - axisWidth / 2; y2 = _alignPixel(chart, me.bottom, lastLineWidth) + lastLineWidth / 2; x1 = x2 = borderValue; } ctx.lineWidth = axisWidth; ctx.strokeStyle = edgeOpts.borderColor; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } } /** * @protected */ drawLabels(chartArea) { const me = this; const optionTicks = me.options.ticks; if (!optionTicks.display) { return; } const ctx = me.ctx; const area = me._computeLabelArea(); if (area) { clipArea(ctx, area); } const items = me._labelItems || (me._labelItems = me._computeLabelItems(chartArea)); let i, ilen; for (i = 0, ilen = items.length; i < ilen; ++i) { const item = items[i]; const tickFont = item.font; const label = item.label; let y = item.textOffset; renderText(ctx, label, 0, y, tickFont, item); } if (area) { unclipArea(ctx); } } /** * @protected */ drawTitle(chartArea) { // eslint-disable-line no-unused-vars const me = this; const ctx = me.ctx; const options = me.options; const title = options.title; if (!title.display) { return; } const titleFont = toFont(title.font); const titlePadding = toPadding(title.padding); const halfLineHeight = titleFont.lineHeight / 2; const titleAlign = title.align; const position = options.position; const isReverse = me.options.reverse; let rotation = 0; /** @type CanvasTextAlign */ let textAlign; let titleX, titleY; if (me.isHorizontal()) { switch (titleAlign) { case 'start': titleX = me.left + (isReverse ? me.width : 0); textAlign = isReverse ? 'right' : 'left'; break; case 'end': titleX = me.left + (isReverse ? 0 : me.width); textAlign = isReverse ? 'left' : 'right'; break; default: titleX = me.left + me.width / 2; textAlign = 'center'; } titleY = position === 'top' ? me.top + halfLineHeight + titlePadding.top : me.bottom - halfLineHeight - titlePadding.bottom; } else { const isLeft = position === 'left'; titleX = isLeft ? me.left + halfLineHeight + titlePadding.top : me.right - halfLineHeight - titlePadding.top; switch (titleAlign) { case 'start': titleY = me.top + (isReverse ? 0 : me.height); textAlign = isReverse === isLeft ? 'right' : 'left'; break; case 'end': titleY = me.top + (isReverse ? me.height : 0); textAlign = isReverse === isLeft ? 'left' : 'right'; break; default: titleY = me.top + me.height / 2; textAlign = 'center'; } rotation = isLeft ? -HALF_PI : HALF_PI; } renderText(ctx, title.text, 0, 0, titleFont, { color: title.color, rotation, textAlign, textBaseline: 'middle', translation: [titleX, titleY], }); } draw(chartArea) { const me = this; if (!me._isVisible()) { return; } me.drawGrid(chartArea); me.drawTitle(); me.drawLabels(chartArea); } /** * @return {object[]} * @private */ _layers() { const me = this; const opts = me.options; const tz = opts.ticks && opts.ticks.z || 0; const gz = opts.gridLines && opts.gridLines.z || 0; if (!me._isVisible() || tz === gz || me.draw !== me._draw) { // backward compatibility: draw has been overridden by custom scale return [{ z: tz, draw(chartArea) { me.draw(chartArea); } }]; } return [{ z: gz, draw(chartArea) { me.drawGrid(chartArea); me.drawTitle(); } }, { z: tz, draw(chartArea) { me.drawLabels(chartArea); } }]; } /** * Returns visible dataset metas that are attached to this scale * @param {string} [type] - if specified, also filter by dataset type * @return {object[]} */ getMatchingVisibleMetas(type) { const me = this; const metas = me.chart.getSortedVisibleDatasetMetas(); const axisID = me.axis + 'AxisID'; const result = []; let i, ilen; for (i = 0, ilen = metas.length; i < ilen; ++i) { const meta = metas[i]; if (meta[axisID] === me.id && (!type || meta.type === type)) { result.push(meta); } } return result; } /** * @param {number} index * @return {object} * @protected */ _resolveTickFontOptions(index) { const opts = this.options.ticks.setContext(this.getContext(index)); return toFont(opts.font); } } Scale.prototype._draw = Scale.prototype.draw;