UNPKG

highcharts

Version:
721 lines (720 loc) 27 kB
/* * * * (c) 2010-2025 Torstein Honsi * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import F from '../Templating.js'; import H from '../Globals.js'; const { deg2rad } = H; import U from '../Utilities.js'; const { clamp, correctFloat, defined, destroyObjectProperties, extend, fireEvent, getAlignFactor, isNumber, merge, objectEach, pick } = U; /* * * * Class * * */ /* eslint-disable no-invalid-this, valid-jsdoc */ /** * The Tick class. * * @class * @name Highcharts.Tick * * @param {Highcharts.Axis} axis * The axis of the tick. * * @param {number} pos * The position of the tick on the axis in terms of axis values. * * @param {string} [type] * The type of tick, either 'minor' or an empty string * * @param {boolean} [noLabel=false] * Whether to disable the label or not. Defaults to false. * * @param {Object} [parameters] * Optional parameters for the tick. */ class Tick { /* * * * Constructors * * */ constructor(axis, pos, type, noLabel, parameters) { this.isNew = true; this.isNewLabel = true; /** * The related axis of the tick. * @name Highcharts.Tick#axis * @type {Highcharts.Axis} */ this.axis = axis; /** * The logical position of the tick on the axis in terms of axis values. * @name Highcharts.Tick#pos * @type {number} */ this.pos = pos; /** * The tick type, which can be `"minor"`, or an empty string. * @name Highcharts.Tick#type * @type {string} */ this.type = type || ''; this.parameters = parameters || {}; /** * The mark offset of the tick on the axis. Usually `undefined`, numeric * for grid axes. * @name Highcharts.Tick#tickmarkOffset * @type {number|undefined} */ this.tickmarkOffset = this.parameters.tickmarkOffset; this.options = this.parameters.options; fireEvent(this, 'init'); if (!type && !noLabel) { this.addLabel(); } } /* * * * Functions * * */ /** * Write the tick label. * * @private * @function Highcharts.Tick#addLabel */ addLabel() { const tick = this, axis = tick.axis, options = axis.options, chart = axis.chart, categories = axis.categories, log = axis.logarithmic, names = axis.names, pos = tick.pos, labelOptions = pick(tick.options?.labels, options.labels), tickPositions = axis.tickPositions, isFirst = pos === tickPositions[0], isLast = pos === tickPositions[tickPositions.length - 1], animateLabels = (!labelOptions.step || labelOptions.step === 1) && axis.tickInterval === 1, tickPositionInfo = tickPositions.info; let label = tick.label, dateTimeLabelFormat, dateTimeLabelFormats, i; // The context value let value = this.parameters.category || (categories ? pick(categories[pos], names[pos], pos) : pos); if (log && isNumber(value)) { value = correctFloat(log.lin2log(value)); } // Set the datetime label format. If a higher rank is set for this // position, use that. If not, use the general format. if (axis.dateTime) { if (tickPositionInfo) { dateTimeLabelFormats = chart.time.resolveDTLFormat(options.dateTimeLabelFormats[(!options.grid && tickPositionInfo.higherRanks[pos]) || tickPositionInfo.unitName]); dateTimeLabelFormat = dateTimeLabelFormats.main; } else if (isNumber(value)) { // #1441 dateTimeLabelFormat = axis.dateTime.getXDateFormat(value, options.dateTimeLabelFormats || {}); } } // Set properties for access in render method /** * True if the tick is the first one on the axis. * @name Highcharts.Tick#isFirst * @readonly * @type {boolean|undefined} */ tick.isFirst = isFirst; /** * True if the tick is the last one on the axis. * @name Highcharts.Tick#isLast * @readonly * @type {boolean|undefined} */ tick.isLast = isLast; // Get the string const ctx = { axis, chart, dateTimeLabelFormat: dateTimeLabelFormat, isFirst, isLast, pos, tick: tick, tickPositionInfo, value }; // Fire an event that allows modifying the context for use in // `labels.format` and `labels.formatter`. fireEvent(this, 'labelFormat', ctx); // Label formatting. When `labels.format` is given, we first run the // defaultFormatter and append the result to the context as `text`. // Handy for adding prefix or suffix while keeping default number // formatting. const labelFormatter = (ctx) => { if (labelOptions.formatter) { return labelOptions.formatter.call(ctx, ctx); } if (labelOptions.format) { ctx.text = axis.defaultLabelFormatter.call(ctx); return F.format(labelOptions.format, ctx, chart); } return axis.defaultLabelFormatter.call(ctx); }; const str = labelFormatter.call(ctx, ctx); // Set up conditional formatting based on the format list if existing. const list = dateTimeLabelFormats?.list; if (list) { tick.shortenLabel = function () { for (i = 0; i < list.length; i++) { extend(ctx, { dateTimeLabelFormat: list[i] }); label.attr({ text: labelFormatter.call(ctx, ctx) }); if (label.getBBox().width < axis.getSlotWidth(tick) - 2 * (labelOptions.padding || 0)) { return; } } label.attr({ text: '' }); }; } else { // #15692 tick.shortenLabel = void 0; } // Call only after first render if (animateLabels && axis._addedPlotLB) { tick.moveLabel(str, labelOptions); } // First call if (!defined(label) && !tick.movedLabel) { /** * The rendered text label of the tick. * @name Highcharts.Tick#label * @type {Highcharts.SVGElement|undefined} */ tick.label = label = tick.createLabel(str, labelOptions); // Base value to detect change for new calls to getBBox tick.rotation = 0; // Update } else if (label && label.textStr !== str && !animateLabels) { // When resetting text, also reset the width if dynamically set // (#8809) if (label.textWidth && !labelOptions.style.width && !label.styles.width) { label.css({ width: null }); } label.attr({ text: str }); label.textPxLength = label.getBBox().width; } } /** * Render and return the label of the tick. * * @private * @function Highcharts.Tick#createLabel */ createLabel(str, labelOptions, xy) { const axis = this.axis, { renderer, styledMode } = axis.chart, whiteSpace = labelOptions.style.whiteSpace, label = defined(str) && labelOptions.enabled ? renderer .text(str, xy?.x, xy?.y, labelOptions.useHTML) .add(axis.labelGroup) : void 0; // Un-rotated length if (label) { if (!styledMode) { label.css(merge(labelOptions.style)); } label.textPxLength = label.getBBox().width; // Apply the white-space setting after we read the full text width if (!styledMode && whiteSpace) { label.css({ whiteSpace }); } } return label; } /** * Destructor for the tick prototype * * @private * @function Highcharts.Tick#destroy */ destroy() { destroyObjectProperties(this, this.axis); } /** * Gets the x and y positions for ticks in terms of pixels. * * @private * @function Highcharts.Tick#getPosition * * @param {boolean} horiz * Whether the tick is on an horizontal axis or not. * * @param {number} tickPos * Position of the tick. * * @param {number} tickmarkOffset * Tickmark offset for all ticks. * * @param {boolean} [old] * Whether the axis has changed or not. * * @return {Highcharts.PositionObject} * The tick position. * * @emits Highcharts.Tick#event:afterGetPosition */ getPosition(horiz, tickPos, tickmarkOffset, old) { const axis = this.axis, chart = axis.chart, cHeight = (old && chart.oldChartHeight) || chart.chartHeight, pos = { x: horiz ? correctFloat(axis.translate(tickPos + tickmarkOffset, void 0, void 0, old) + axis.transB) : (axis.left + axis.offset + (axis.opposite ? (((old && chart.oldChartWidth) || chart.chartWidth) - axis.right - axis.left) : 0)), y: horiz ? (cHeight - axis.bottom + axis.offset - (axis.opposite ? axis.height : 0)) : correctFloat(cHeight - axis.translate(tickPos + tickmarkOffset, void 0, void 0, old) - axis.transB) }; // Chrome workaround for #10516 pos.y = clamp(pos.y, -1e9, 1e9); fireEvent(this, 'afterGetPosition', { pos: pos }); return pos; } /** * Get the x, y position of the tick label * @private */ getLabelPosition(x, y, label, horiz, labelOptions, tickmarkOffset, index, step) { const axis = this.axis, transA = axis.transA, reversed = ( // #7911 axis.isLinked && axis.linkedParent ? axis.linkedParent.reversed : axis.reversed), staggerLines = axis.staggerLines, rotCorr = axis.tickRotCorr || { x: 0, y: 0 }, // Adjust for label alignment if we use reserveSpace: true (#5286) labelOffsetCorrection = (!horiz && !axis.reserveSpaceDefault ? -axis.labelOffset * (axis.labelAlign === 'center' ? 0.5 : 1) : 0), distance = labelOptions.distance, pos = {}; let yOffset, line; if (axis.side === 0) { yOffset = label.rotation ? -distance : -label.getBBox().height; } else if (axis.side === 2) { yOffset = rotCorr.y + distance; } else { // #3140, #3140 yOffset = Math.cos(label.rotation * deg2rad) * (rotCorr.y - label.getBBox(false, 0).height / 2); } if (defined(labelOptions.y)) { yOffset = axis.side === 0 && axis.horiz ? labelOptions.y + yOffset : labelOptions.y; } x = x + pick(labelOptions.x, [0, 1, 0, -1][axis.side] * distance) + labelOffsetCorrection + rotCorr.x - (tickmarkOffset && horiz ? tickmarkOffset * transA * (reversed ? -1 : 1) : 0); y = y + yOffset - (tickmarkOffset && !horiz ? tickmarkOffset * transA * (reversed ? 1 : -1) : 0); // Correct for staggered labels if (staggerLines) { line = (index / (step || 1) % staggerLines); if (axis.opposite) { line = staggerLines - line - 1; } y += line * (axis.labelOffset / staggerLines); } pos.x = x; pos.y = Math.round(y); fireEvent(this, 'afterGetLabelPosition', { pos: pos, tickmarkOffset: tickmarkOffset, index: index }); return pos; } /** * Get the offset height or width of the label * * @private * @function Highcharts.Tick#getLabelSize */ getLabelSize() { return this.label ? this.label.getBBox()[this.axis.horiz ? 'height' : 'width'] : 0; } /** * Extendible method to return the path of the marker * @private */ getMarkPath(x, y, tickLength, tickWidth, horiz = false, renderer) { return renderer.crispLine([[ 'M', x, y ], [ 'L', x + (horiz ? 0 : -tickLength), y + (horiz ? tickLength : 0) ]], tickWidth); } /** * Handle the label overflow by adjusting the labels to the left and right * edge, or hide them if they collide into the neighbour label. * * @private * @function Highcharts.Tick#handleOverflow */ handleOverflow(xy) { const tick = this, axis = this.axis, labelOptions = axis.options.labels, pxPos = xy.x, chartWidth = axis.chart.chartWidth, spacing = axis.chart.spacing, leftBound = pick(axis.labelLeft, Math.min(axis.pos, spacing[3])), rightBound = pick(axis.labelRight, Math.max(!axis.isRadial ? axis.pos + axis.len : 0, chartWidth - spacing[1])), label = this.label, rotation = this.rotation, factor = getAlignFactor(axis.labelAlign || label.attr('align')), labelWidth = label.getBBox().width, slotWidth = axis.getSlotWidth(tick), xCorrection = factor, css = {}; let modifiedSlotWidth = slotWidth, goRight = 1, leftPos, rightPos, textWidth; // Check if the label overshoots the chart spacing box. If it does, move // it. If it now overshoots the slotWidth, add ellipsis. if (!rotation && labelOptions.overflow === 'justify') { leftPos = pxPos - factor * labelWidth; rightPos = pxPos + (1 - factor) * labelWidth; if (leftPos < leftBound) { modifiedSlotWidth = xy.x + modifiedSlotWidth * (1 - factor) - leftBound; } else if (rightPos > rightBound) { modifiedSlotWidth = rightBound - xy.x + modifiedSlotWidth * factor; goRight = -1; } modifiedSlotWidth = Math.min(slotWidth, modifiedSlotWidth); // #4177 if (modifiedSlotWidth < slotWidth && axis.labelAlign === 'center') { xy.x += (goRight * (slotWidth - modifiedSlotWidth - xCorrection * (slotWidth - Math.min(labelWidth, modifiedSlotWidth)))); } // If the label width exceeds the available space, set a text width // to be picked up below. Also, if a width has been set before, we // need to set a new one because the reported labelWidth will be // limited by the box (#3938). if (labelWidth > modifiedSlotWidth || (axis.autoRotation && label?.styles?.width)) { textWidth = modifiedSlotWidth; } // Add ellipsis to prevent rotated labels to be clipped against the edge // of the chart } else if (rotation < 0 && pxPos - factor * labelWidth < leftBound) { textWidth = Math.round(pxPos / Math.cos(rotation * deg2rad) - leftBound); } else if (rotation > 0 && pxPos + factor * labelWidth > rightBound) { textWidth = Math.round((chartWidth - pxPos) / Math.cos(rotation * deg2rad)); } if (textWidth && label) { if (tick.shortenLabel) { tick.shortenLabel(); } else { label.css(extend(css, { width: Math.floor(textWidth) + 'px', lineClamp: axis.isRadial ? 0 : 1 })); } } } /** * Try to replace the label if the same one already exists. * * @private * @function Highcharts.Tick#moveLabel */ moveLabel(str, labelOptions) { const tick = this, label = tick.label, axis = tick.axis; let moved = false, labelPos; if (label && label.textStr === str) { tick.movedLabel = label; moved = true; delete tick.label; } else { // Find a label with the same string objectEach(axis.ticks, function (currentTick) { if (!moved && !currentTick.isNew && currentTick !== tick && currentTick.label && currentTick.label.textStr === str) { tick.movedLabel = currentTick.label; moved = true; currentTick.labelPos = tick.movedLabel.xy; delete currentTick.label; } }); } // Create new label if the actual one is moved if (!moved && (tick.labelPos || label)) { labelPos = tick.labelPos || label.xy; tick.movedLabel = tick.createLabel(str, labelOptions, labelPos); if (tick.movedLabel) { tick.movedLabel.attr({ opacity: 0 }); } } } /** * Put everything in place * * @private * @param {number} index * * @param {boolean} [old] * Use old coordinates to prepare an animation into new position * * @param {number} [opacity] */ render(index, old, opacity) { const tick = this, axis = tick.axis, horiz = axis.horiz, pos = tick.pos, tickmarkOffset = pick(tick.tickmarkOffset, axis.tickmarkOffset), xy = tick.getPosition(horiz, pos, tickmarkOffset, old), x = xy.x, y = xy.y, axisStart = axis.pos, axisEnd = axisStart + axis.len, pxPos = horiz ? x : y; const labelOpacity = pick(opacity, tick.label?.newOpacity, // #15528 1); // Anything that is not between `axis.pos` and `axis.pos + axis.length` // should not be visible (#20166). The `correctFloat` is for reversed // axes in Safari. if (!axis.chart.polar && (correctFloat(pxPos) < axisStart || pxPos > axisEnd)) { opacity = 0; } opacity ?? (opacity = 1); this.isActive = true; // Create the grid line this.renderGridLine(old, opacity); // Create the tick mark this.renderMark(xy, opacity); // The label is created on init - now move it into place this.renderLabel(xy, old, labelOpacity, index); tick.isNew = false; fireEvent(this, 'afterRender'); } /** * Renders the gridLine. * * @private * @function Highcharts.Tick#renderGridLine * @param {boolean} old Whether or not the tick is old * @param {number} opacity The opacity of the grid line */ renderGridLine(old, opacity) { const tick = this, axis = tick.axis, options = axis.options, attribs = {}, pos = tick.pos, type = tick.type, tickmarkOffset = pick(tick.tickmarkOffset, axis.tickmarkOffset), renderer = axis.chart.renderer; let gridLine = tick.gridLine, gridLinePath, gridLineWidth = options.gridLineWidth, gridLineColor = options.gridLineColor, dashStyle = options.gridLineDashStyle; if (tick.type === 'minor') { gridLineWidth = options.minorGridLineWidth; gridLineColor = options.minorGridLineColor; dashStyle = options.minorGridLineDashStyle; } if (!gridLine) { if (!axis.chart.styledMode) { attribs.stroke = gridLineColor; attribs['stroke-width'] = gridLineWidth || 0; attribs.dashstyle = dashStyle; } if (!type) { attribs.zIndex = 1; } if (old) { opacity = 0; } /** * The rendered grid line of the tick. * @name Highcharts.Tick#gridLine * @type {Highcharts.SVGElement|undefined} */ tick.gridLine = gridLine = renderer.path() .attr(attribs) .addClass('highcharts-' + (type ? type + '-' : '') + 'grid-line') .add(axis.gridGroup); } if (gridLine) { gridLinePath = axis.getPlotLinePath({ value: pos + tickmarkOffset, lineWidth: gridLine.strokeWidth(), force: 'pass', old: old, acrossPanes: false // #18025 }); // If the parameter 'old' is set, the current call will be followed // by another call, therefore do not do any animations this time if (gridLinePath) { gridLine[old || tick.isNew ? 'attr' : 'animate']({ d: gridLinePath, opacity: opacity }); } } } /** * Renders the tick mark. * * @private * @function Highcharts.Tick#renderMark * @param {Highcharts.PositionObject} xy The position vector of the mark * @param {number} opacity The opacity of the mark */ renderMark(xy, opacity) { const tick = this, axis = tick.axis, options = axis.options, renderer = axis.chart.renderer, type = tick.type, tickSize = axis.tickSize(type ? type + 'Tick' : 'tick'), x = xy.x, y = xy.y, tickWidth = pick(options[type !== 'minor' ? 'tickWidth' : 'minorTickWidth'], !type && axis.isXAxis ? 1 : 0), // X axis defaults to 1 tickColor = options[type !== 'minor' ? 'tickColor' : 'minorTickColor']; let mark = tick.mark; const isNewMark = !mark; if (tickSize) { // Negate the length if (axis.opposite) { tickSize[0] = -tickSize[0]; } // First time, create it if (!mark) { /** * The rendered mark of the tick. * @name Highcharts.Tick#mark * @type {Highcharts.SVGElement|undefined} */ tick.mark = mark = renderer.path() .addClass('highcharts-' + (type ? type + '-' : '') + 'tick') .add(axis.axisGroup); if (!axis.chart.styledMode) { mark.attr({ stroke: tickColor, 'stroke-width': tickWidth }); } } mark[isNewMark ? 'attr' : 'animate']({ d: tick.getMarkPath(x, y, tickSize[0], mark.strokeWidth(), axis.horiz, renderer), opacity: opacity }); } } /** * Renders the tick label. * Note: The label should already be created in init(), so it should only * have to be moved into place. * * @private * @function Highcharts.Tick#renderLabel * @param {Highcharts.PositionObject} xy The position vector of the label * @param {boolean} old Whether or not the tick is old * @param {number} opacity The opacity of the label * @param {number} index The index of the tick */ renderLabel(xy, old, opacity, index) { const tick = this, axis = tick.axis, horiz = axis.horiz, options = axis.options, label = tick.label, labelOptions = options.labels, step = labelOptions.step, tickmarkOffset = pick(tick.tickmarkOffset, axis.tickmarkOffset), x = xy.x, y = xy.y; let show = true; if (label && isNumber(x)) { label.xy = xy = tick.getLabelPosition(x, y, label, horiz, labelOptions, tickmarkOffset, index, step); // Apply show first and show last. If the tick is both first and // last, it is a single centered tick, in which case we show the // label anyway (#2100). if ((tick.isFirst && !tick.isLast && !options.showFirstLabel) || (tick.isLast && !tick.isFirst && !options.showLastLabel)) { show = false; // Handle label overflow and show or hide accordingly } else if (horiz && !labelOptions.step && !labelOptions.rotation && !old && opacity !== 0) { tick.handleOverflow(xy); } // Apply step if (step && index % step) { // Show those indices dividable by step show = false; } // Set the new position, and show or hide if (show && isNumber(xy.y)) { xy.opacity = opacity; label[tick.isNewLabel ? 'attr' : 'animate'](xy).show(true); tick.isNewLabel = false; } else { label.hide(); // #1338, #15863 tick.isNewLabel = true; } } } /** * Replace labels with the moved ones to perform animation. Additionally * destroy unused labels. * * @private * @function Highcharts.Tick#replaceMovedLabel */ replaceMovedLabel() { const tick = this, label = tick.label, axis = tick.axis; // Animate and destroy if (label && !tick.isNew) { label.animate({ opacity: 0 }, void 0, label.destroy); delete tick.label; } axis.isDirty = true; tick.label = tick.movedLabel; delete tick.movedLabel; } } /* * * * Default Export * * */ export default Tick; /* * * * API Declarations * * */ /** * Optional parameters for the tick. * @private * @interface Highcharts.TickParametersObject */ /** * Set category for the tick. * @name Highcharts.TickParametersObject#category * @type {string|undefined} */ /** * @name Highcharts.TickParametersObject#options * @type {Highcharts.Dictionary<any>|undefined} */ /** * Set tickmarkOffset for the tick. * @name Highcharts.TickParametersObject#tickmarkOffset * @type {number|undefined} */ /** * Additional time tick information. * * @interface Highcharts.TimeTicksInfoObject * @extends Highcharts.TimeNormalizedObject */ /** * @name Highcharts.TimeTicksInfoObject#higherRanks * @type {Array<string>} */ /** * @name Highcharts.TimeTicksInfoObject#totalRange * @type {number} */ (''); // Keeps doclets above in JS file