UNPKG

highcharts

Version:
1,215 lines 62.7 kB
/* * * * (c) 2010-2025 Torstein Honsi * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import Axis from '../../Core/Axis/Axis.js'; import D from '../../Core/Defaults.js'; const { defaultOptions } = D; import H from '../../Core/Globals.js'; import RangeSelectorComposition from './RangeSelectorComposition.js'; import SVGElement from '../../Core/Renderer/SVG/SVGElement.js'; import T from '../../Core/Templating.js'; const { format } = T; import U from '../../Core/Utilities.js'; import OrdinalAxis from '../../Core/Axis/OrdinalAxis.js'; const { addEvent, createElement, css, defined, destroyObjectProperties, diffObjects, discardElement, extend, fireEvent, isNumber, isString, merge, objectEach, pick, splat } = U; /* * * * Functions * * */ /** * Get the preferred input type based on a date format string. * * @private * @function preferredInputType */ function preferredInputType(format) { const hasTimeKey = (char) => new RegExp(`%[[a-zA-Z]*${char}`).test(format); const ms = isString(format) ? format.indexOf('%L') !== -1 : // Implemented but not typed as of 2024 format.fractionalSecondDigits; if (ms) { return 'text'; } const date = isString(format) ? ['a', 'A', 'd', 'e', 'w', 'b', 'B', 'm', 'o', 'y', 'Y'] .some(hasTimeKey) : format.dateStyle || format.day || format.month || format.year; const time = isString(format) ? ['H', 'k', 'I', 'l', 'M', 'S'].some(hasTimeKey) : format.timeStyle || format.hour || format.minute || format.second; if (date && time) { return 'datetime-local'; } if (date) { return 'date'; } if (time) { return 'time'; } return 'text'; } /* * * * Class * * */ /** * The range selector. * * @private * @class * @name Highcharts.RangeSelector * @param {Highcharts.Chart} chart */ class RangeSelector { /* * * * Static Functions * * */ /** * @private */ static compose(AxisClass, ChartClass) { RangeSelectorComposition.compose(AxisClass, ChartClass, RangeSelector); } /* * * * Constructor * * */ constructor(chart) { this.isDirty = false; this.buttonOptions = []; this.initialButtonGroupWidth = 0; this.maxButtonWidth = () => { let buttonWidth = 0; this.buttons.forEach((button) => { const bBox = button.getBBox(); if (bBox.width > buttonWidth) { buttonWidth = bBox.width; } }); return buttonWidth; }; this.init(chart); } /* * * * Functions * * */ /** * The method to run when one of the buttons in the range selectors is * clicked * * @private * @function Highcharts.RangeSelector#clickButton * @param {number} i * The index of the button * @param {boolean} [redraw] */ clickButton(i, redraw) { const rangeSelector = this, chart = rangeSelector.chart, rangeOptions = rangeSelector.buttonOptions[i], baseAxis = chart.xAxis[0], unionExtremes = (chart.scroller && chart.scroller.getUnionExtremes()) || baseAxis || {}, type = rangeOptions.type, dataGrouping = rangeOptions.dataGrouping; let dataMin = unionExtremes.dataMin, dataMax = unionExtremes.dataMax, newMin, newMax = isNumber(baseAxis?.max) ? Math.round(Math.min(baseAxis.max, dataMax ?? baseAxis.max)) : void 0, // #1568 baseXAxisOptions, range = rangeOptions._range, rangeMin, ctx, ytdExtremes, addOffsetMin = true; // Chart has no data, base series is removed if (dataMin === null || dataMax === null) { return; } rangeSelector.setSelected(i); // Apply dataGrouping associated to button if (dataGrouping) { this.forcedDataGrouping = true; Axis.prototype.setDataGrouping.call(baseAxis || { chart: this.chart }, dataGrouping, false); this.frozenStates = rangeOptions.preserveDataGrouping; } // Apply range if (type === 'month' || type === 'year') { if (!baseAxis) { // This is set to the user options and picked up later when the // axis is instantiated so that we know the min and max. range = rangeOptions; } else { ctx = { range: rangeOptions, max: newMax, chart: chart, dataMin: dataMin, dataMax: dataMax }; newMin = baseAxis.minFromRange.call(ctx); if (isNumber(ctx.newMax)) { newMax = ctx.newMax; } // #15799: offsetMin is added in minFromRange so that it works // with pre-selected buttons as well addOffsetMin = false; } // Fixed times like minutes, hours, days } else if (range) { if (isNumber(newMax)) { newMin = Math.max(newMax - range, dataMin); newMax = Math.min(newMin + range, dataMax); addOffsetMin = false; } } else if (type === 'ytd') { // On user clicks on the buttons, or a delayed action running from // the beforeRender event (below), the baseAxis is defined. if (baseAxis) { // When "ytd" is the pre-selected button for the initial view, // its calculation is delayed and rerun in the beforeRender // event (below). When the series are initialized, but before // the chart is rendered, we have access to the xData array // (#942). if (baseAxis.hasData() && (!isNumber(dataMax) || !isNumber(dataMin))) { dataMin = Number.MAX_VALUE; dataMax = -Number.MAX_VALUE; chart.series.forEach((series) => { // Reassign it to the last item const xData = series.getColumn('x'); if (xData.length) { dataMin = Math.min(xData[0], dataMin); dataMax = Math.max(xData[xData.length - 1], dataMax); } }); redraw = false; } if (isNumber(dataMax) && isNumber(dataMin)) { ytdExtremes = rangeSelector.getYTDExtremes(dataMax, dataMin); newMin = rangeMin = ytdExtremes.min; newMax = ytdExtremes.max; } // "ytd" is pre-selected. We don't yet have access to processed // point and extremes data (things like pointStart and pointInterval // are missing), so we delay the process (#942) } else { rangeSelector.deferredYTDClick = i; return; } } else if (type === 'all' && baseAxis) { // If the navigator exist and the axis range is declared reset that // range and from now on only use the range set by a user, #14742. if (chart.navigator && chart.navigator.baseSeries[0]) { chart.navigator.baseSeries[0].xAxis.options.range = void 0; } newMin = dataMin; newMax = dataMax; } if (addOffsetMin && rangeOptions._offsetMin && defined(newMin)) { newMin += rangeOptions._offsetMin; } if (rangeOptions._offsetMax && defined(newMax)) { newMax += rangeOptions._offsetMax; } if (this.dropdown) { this.dropdown.selectedIndex = i + 1; } // Update the chart if (!baseAxis) { // Axis not yet instantiated. Temporarily set min and range // options and axes once defined and remove them on // chart load (#4317 & #20529). baseXAxisOptions = splat(chart.options.xAxis || {})[0]; const axisRangeUpdateEvent = addEvent(chart, 'afterCreateAxes', function () { const xAxis = chart.xAxis[0]; xAxis.range = xAxis.options.range = range; xAxis.min = xAxis.options.min = rangeMin; }); addEvent(chart, 'load', function resetMinAndRange() { const xAxis = chart.xAxis[0]; chart.setFixedRange(rangeOptions._range); xAxis.options.range = baseXAxisOptions.range; xAxis.options.min = baseXAxisOptions.min; axisRangeUpdateEvent(); // Remove event }); } else if (isNumber(newMin) && isNumber(newMax)) { // Existing axis object. Set extremes after render time. baseAxis.setExtremes(newMin, newMax, pick(redraw, true), void 0, // Auto animation { trigger: 'rangeSelectorButton', rangeSelectorButton: rangeOptions }); chart.setFixedRange(rangeOptions._range); } fireEvent(this, 'afterBtnClick'); } /** * Set the selected option. This method only sets the internal flag, it * doesn't update the buttons or the actual zoomed range. * * @private * @function Highcharts.RangeSelector#setSelected * @param {number} [selected] */ setSelected(selected) { this.selected = this.options.selected = selected; } /** * Initialize the range selector * * @private * @function Highcharts.RangeSelector#init * @param {Highcharts.Chart} chart */ init(chart) { const rangeSelector = this, options = chart.options.rangeSelector, langOptions = chart.options.lang, buttonOptions = options.buttons, selectedOption = options.selected, blurInputs = function () { const minInput = rangeSelector.minInput, maxInput = rangeSelector.maxInput; // #3274 in some case blur is not defined if (minInput && !!minInput.blur) { fireEvent(minInput, 'blur'); } if (maxInput && !!maxInput.blur) { fireEvent(maxInput, 'blur'); } }; rangeSelector.chart = chart; rangeSelector.options = options; rangeSelector.buttons = []; rangeSelector.buttonOptions = buttonOptions .map((opt) => { if (opt.type && langOptions.rangeSelector) { opt.text ?? (opt.text = langOptions.rangeSelector[`${opt.type}Text`]); opt.title ?? (opt.title = langOptions.rangeSelector[`${opt.type}Title`]); } opt.text = format(opt.text, { count: opt.count || 1 }); opt.title = format(opt.title, { count: opt.count || 1 }); return opt; }); this.eventsToUnbind = []; this.eventsToUnbind.push(addEvent(chart.container, 'mousedown', blurInputs)); this.eventsToUnbind.push(addEvent(chart, 'resize', blurInputs)); // Extend the buttonOptions with actual range buttonOptions.forEach(rangeSelector.computeButtonRange); // Zoomed range based on a pre-selected button index if (typeof selectedOption !== 'undefined' && buttonOptions[selectedOption]) { this.clickButton(selectedOption, false); } this.eventsToUnbind.push(addEvent(chart, 'load', function () { // If a data grouping is applied to the current button, release it // when extremes change if (chart.xAxis && chart.xAxis[0]) { addEvent(chart.xAxis[0], 'setExtremes', function (e) { if (isNumber(this.max) && isNumber(this.min) && this.max - this.min !== chart.fixedRange && e.trigger !== 'rangeSelectorButton' && e.trigger !== 'updatedData' && rangeSelector.forcedDataGrouping && !rangeSelector.frozenStates) { this.setDataGrouping(false, false); } }); } })); this.createElements(); } /** * Dynamically update the range selector buttons after a new range has been * set * * @private * @function Highcharts.RangeSelector#updateButtonStates */ updateButtonStates() { const rangeSelector = this, chart = this.chart, dropdown = this.dropdown, dropdownLabel = this.dropdownLabel, baseAxis = chart.xAxis[0], actualRange = Math.round(baseAxis.max - baseAxis.min), hasNoData = !baseAxis.hasVisibleSeries, day = 24 * 36e5, // A single day in milliseconds unionExtremes = (chart.scroller && chart.scroller.getUnionExtremes()) || baseAxis, dataMin = unionExtremes.dataMin, dataMax = unionExtremes.dataMax, ytdExtremes = rangeSelector.getYTDExtremes(dataMax, dataMin), ytdMin = ytdExtremes.min, ytdMax = ytdExtremes.max, selected = rangeSelector.selected, allButtonsEnabled = rangeSelector.options.allButtonsEnabled, buttonStates = new Array(rangeSelector.buttonOptions.length) .fill(0), selectedExists = isNumber(selected), buttons = rangeSelector.buttons; let isSelectedTooGreat = false, selectedIndex = null; rangeSelector.buttonOptions.forEach((rangeOptions, i) => { const range = rangeOptions._range, type = rangeOptions.type, count = rangeOptions.count || 1, offsetRange = rangeOptions._offsetMax - rangeOptions._offsetMin, isSelected = i === selected, // Disable buttons where the range exceeds what is allowed i; // the current view isTooGreatRange = range > dataMax - dataMin, // Disable buttons where the range is smaller than the minimum // range isTooSmallRange = range < baseAxis.minRange; // Do not select the YTD button if not explicitly told so let isYTDButNotSelected = false, // Disable the All button if we're already showing all isSameRange = range === actualRange; if (isSelected && isTooGreatRange) { isSelectedTooGreat = true; } if (baseAxis.isOrdinal && baseAxis.ordinal?.positions && range && actualRange < range) { // Handle ordinal ranges const positions = baseAxis.ordinal.positions, prevOrdinalPosition = OrdinalAxis.Additions.findIndexOf(positions, baseAxis.min, true), nextOrdinalPosition = Math.min(OrdinalAxis.Additions.findIndexOf(positions, baseAxis.max, true) + 1, positions.length - 1); if (positions[nextOrdinalPosition] - positions[prevOrdinalPosition] > range) { isSameRange = true; } } else if ( // Months and years have variable range so we check the extremes (type === 'month' || type === 'year') && (actualRange + 36e5 >= { month: 28, year: 365 }[type] * day * count - offsetRange) && (actualRange - 36e5 <= { month: 31, year: 366 }[type] * day * count + offsetRange)) { isSameRange = true; } else if (type === 'ytd') { isSameRange = (ytdMax - ytdMin + offsetRange) === actualRange; isYTDButNotSelected = !isSelected; } else if (type === 'all') { isSameRange = (baseAxis.max - baseAxis.min >= dataMax - dataMin); } // The new zoom area happens to match the range for a button - mark // it selected. This happens when scrolling across an ordinal gap. // It can be seen in the intraday demos when selecting 1h and scroll // across the night gap. const disable = (!allButtonsEnabled && !(isSelectedTooGreat && type === 'all') && (isTooGreatRange || isTooSmallRange || hasNoData)); const select = ((isSelectedTooGreat && type === 'all') || (isYTDButNotSelected ? false : isSameRange) || (isSelected && rangeSelector.frozenStates)); if (disable) { buttonStates[i] = 3; } else if (select) { if (!selectedExists || i === selected) { selectedIndex = i; } } }); if (selectedIndex !== null) { buttonStates[selectedIndex] = 2; rangeSelector.setSelected(selectedIndex); if (this.dropdown) { this.dropdown.selectedIndex = selectedIndex + 1; } } else { rangeSelector.setSelected(); if (this.dropdown) { this.dropdown.selectedIndex = -1; } if (dropdownLabel) { dropdownLabel.setState(0); dropdownLabel.attr({ text: (defaultOptions.lang.rangeSelectorZoom || '') + ' ▾' }); } } for (let i = 0; i < buttonStates.length; i++) { const state = buttonStates[i]; const button = buttons[i]; if (button.state !== state) { button.setState(state); if (dropdown) { dropdown.options[i + 1].disabled = (state === 3); if (state === 2) { if (dropdownLabel) { dropdownLabel.setState(2); dropdownLabel.attr({ text: rangeSelector.buttonOptions[i].text + ' ▾' }); } dropdown.selectedIndex = i + 1; } const bbox = dropdownLabel.getBBox(); css(dropdown, { width: `${bbox.width}px`, height: `${bbox.height}px` }); } } } } /** * Compute and cache the range for an individual button * * @private * @function Highcharts.RangeSelector#computeButtonRange * @param {Highcharts.RangeSelectorButtonsOptions} rangeOptions */ computeButtonRange(rangeOptions) { const type = rangeOptions.type, count = rangeOptions.count || 1, // These time intervals have a fixed number of milliseconds, as // opposed to month, ytd and year fixedTimes = { millisecond: 1, second: 1000, minute: 60 * 1000, hour: 3600 * 1000, day: 24 * 3600 * 1000, week: 7 * 24 * 3600 * 1000 }; // Store the range on the button object if (fixedTimes[type]) { rangeOptions._range = fixedTimes[type] * count; } else if (type === 'month' || type === 'year') { rangeOptions._range = { month: 30, year: 365 }[type] * 24 * 36e5 * count; } rangeOptions._offsetMin = pick(rangeOptions.offsetMin, 0); rangeOptions._offsetMax = pick(rangeOptions.offsetMax, 0); rangeOptions._range += rangeOptions._offsetMax - rangeOptions._offsetMin; } /** * Get the unix timestamp of a HTML input for the dates * * @private * @function Highcharts.RangeSelector#getInputValue */ getInputValue(name) { const input = name === 'min' ? this.minInput : this.maxInput; const options = this.chart.options .rangeSelector; const time = this.chart.time; if (input) { return ((input.type === 'text' && options.inputDateParser) || this.defaultInputDateParser)(input.value, time.timezone === 'UTC', time); } return 0; } /** * Set the internal and displayed value of a HTML input for the dates * * @private * @function Highcharts.RangeSelector#setInputValue */ setInputValue(name, inputTime) { const options = this.options, time = this.chart.time, input = name === 'min' ? this.minInput : this.maxInput, dateBox = name === 'min' ? this.minDateBox : this.maxDateBox; if (input) { input.setAttribute('type', preferredInputType(options.inputDateFormat || '%e %b %Y')); const hcTimeAttr = input.getAttribute('data-hc-time'); let updatedTime = defined(hcTimeAttr) ? Number(hcTimeAttr) : void 0; if (defined(inputTime)) { const previousTime = updatedTime; if (defined(previousTime)) { input.setAttribute('data-hc-time-previous', previousTime); } input.setAttribute('data-hc-time', inputTime); updatedTime = inputTime; } input.value = time.dateFormat((this.inputTypeFormats[input.type] || options.inputEditDateFormat), updatedTime); if (dateBox) { dateBox.attr({ text: time.dateFormat(options.inputDateFormat, updatedTime) }); } } } /** * Set the min and max value of a HTML input for the dates * * @private * @function Highcharts.RangeSelector#setInputExtremes */ setInputExtremes(name, min, max) { const input = name === 'min' ? this.minInput : this.maxInput; if (input) { const format = this.inputTypeFormats[input.type]; const time = this.chart.time; if (format) { const newMin = time.dateFormat(format, min); if (input.min !== newMin) { input.min = newMin; } const newMax = time.dateFormat(format, max); if (input.max !== newMax) { input.max = newMax; } } } } /** * @private * @function Highcharts.RangeSelector#showInput * @param {string} name */ showInput(name) { const dateBox = name === 'min' ? this.minDateBox : this.maxDateBox, input = name === 'min' ? this.minInput : this.maxInput; if (input && dateBox && this.inputGroup) { const isTextInput = input.type === 'text', { translateX = 0, translateY = 0 } = this.inputGroup, { x = 0, width = 0, height = 0 } = dateBox, { inputBoxWidth } = this.options; css(input, { width: isTextInput ? ((width + (inputBoxWidth ? -2 : 20)) + 'px') : 'auto', height: (height - 2) + 'px', border: '2px solid silver' }); if (isTextInput && inputBoxWidth) { css(input, { left: (translateX + x) + 'px', top: translateY + 'px' }); // Inputs of types date, time or datetime-local should be centered // on top of the dateBox } else { css(input, { left: Math.min(Math.round(x + translateX - (input.offsetWidth - width) / 2), this.chart.chartWidth - input.offsetWidth) + 'px', top: (translateY - (input.offsetHeight - height) / 2) + 'px' }); } } } /** * @private * @function Highcharts.RangeSelector#hideInput * @param {string} name */ hideInput(name) { const input = name === 'min' ? this.minInput : this.maxInput; if (input) { css(input, { top: '-9999em', border: 0, width: '1px', height: '1px' }); } } /** * @private * @function Highcharts.RangeSelector#defaultInputDateParser */ defaultInputDateParser(inputDate, useUTC, time) { return time?.parse(inputDate) || 0; } /** * Draw either the 'from' or the 'to' HTML input box of the range selector * * @private * @function Highcharts.RangeSelector#drawInput */ drawInput(name) { const { chart, div, inputGroup } = this; const rangeSelector = this, chartStyle = chart.renderer.style || {}, renderer = chart.renderer, options = chart.options.rangeSelector, lang = defaultOptions.lang, isMin = name === 'min'; /** * @private */ function updateExtremes(name) { const { maxInput, minInput } = rangeSelector, chartAxis = chart.xAxis[0], unionExtremes = chart.scroller?.getUnionExtremes() || chartAxis, dataMin = unionExtremes.dataMin, dataMax = unionExtremes.dataMax, currentExtreme = chart.xAxis[0].getExtremes()[name]; let value = rangeSelector.getInputValue(name); if (isNumber(value) && value !== currentExtreme) { // Validate the extremes. If it goes beyond the data min or // max, use the actual data extreme (#2438). if (isMin && maxInput && isNumber(dataMin)) { if (value > Number(maxInput.getAttribute('data-hc-time'))) { value = void 0; } else if (value < dataMin) { value = dataMin; } } else if (minInput && isNumber(dataMax)) { if (value < Number(minInput.getAttribute('data-hc-time'))) { value = void 0; } else if (value > dataMax) { value = dataMax; } } // Set the extremes if (typeof value !== 'undefined') { // @todo typeof undefined chartAxis.setExtremes(isMin ? value : chartAxis.min, isMin ? chartAxis.max : value, void 0, void 0, { trigger: 'rangeSelectorInput' }); } } } // Create the text label const text = lang[isMin ? 'rangeSelectorFrom' : 'rangeSelectorTo'] || ''; const label = renderer .label(text, 0) .addClass('highcharts-range-label') .attr({ padding: text ? 2 : 0, height: text ? options.inputBoxHeight : 0 }) .add(inputGroup); // Create an SVG label that shows updated date ranges and records click // events that bring in the HTML input. const dateBox = renderer .label('', 0) .addClass('highcharts-range-input') .attr({ padding: 2, width: options.inputBoxWidth, height: options.inputBoxHeight, 'text-align': 'center' }) .on('click', function () { // If it is already focused, the onfocus event doesn't fire // (#3713) rangeSelector.showInput(name); rangeSelector[name + 'Input'].focus(); }); if (!chart.styledMode) { dateBox.attr({ stroke: options.inputBoxBorderColor, 'stroke-width': 1 }); } dateBox.add(inputGroup); // Create the HTML input element. This is rendered as 1x1 pixel then set // to the right size when focused. const input = createElement('input', { name: name, className: 'highcharts-range-selector' }, void 0, div); // #14788: Setting input.type to an unsupported type throws in IE, so // we need to use setAttribute instead input.setAttribute('type', preferredInputType(options.inputDateFormat || '%e %b %Y')); if (!chart.styledMode) { // Styles label.css(merge(chartStyle, options.labelStyle)); dateBox.css(merge({ color: "#333333" /* Palette.neutralColor80 */ }, chartStyle, options.inputStyle)); css(input, extend({ position: 'absolute', border: 0, boxShadow: '0 0 15px rgba(0,0,0,0.3)', width: '1px', // Chrome needs a pixel to see it height: '1px', padding: 0, textAlign: 'center', fontSize: chartStyle.fontSize, fontFamily: chartStyle.fontFamily, top: '-9999em' // #4798 }, options.inputStyle)); } // Blow up the input box input.onfocus = () => { rangeSelector.showInput(name); }; // Hide away the input box input.onblur = () => { // Update extremes only when inputs are active if (input === H.doc.activeElement) { // Only when focused // Update also when no `change` event is triggered, like when // clicking inside the SVG (#4710) updateExtremes(name); } // #10404 - move hide and blur outside focus rangeSelector.hideInput(name); rangeSelector.setInputValue(name); input.blur(); // #4606 }; let keyDown = false; // Handle changes in the input boxes input.onchange = () => { // Update extremes and blur input when clicking date input calendar if (!keyDown) { updateExtremes(name); rangeSelector.hideInput(name); input.blur(); } }; input.onkeypress = (event) => { // IE does not fire onchange on enter if (event.keyCode === 13) { updateExtremes(name); } }; input.onkeydown = (event) => { keyDown = true; // Arrow keys if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Tab') { updateExtremes(name); } }; input.onkeyup = () => { keyDown = false; }; return { dateBox, input, label }; } /** * Get the position of the range selector buttons and inputs. This can be * overridden from outside for custom positioning. * * @private * @function Highcharts.RangeSelector#getPosition */ getPosition() { const chart = this.chart, options = chart.options.rangeSelector, top = options.verticalAlign === 'top' ? chart.plotTop - chart.axisOffset[0] : 0; // Set offset only for verticalAlign top return { buttonTop: top + options.buttonPosition.y, inputTop: top + options.inputPosition.y - 10 }; } /** * Get the extremes of YTD. Will choose dataMax if its value is lower than * the current timestamp. Will choose dataMin if its value is higher than * the timestamp for the start of current year. * * @private * @function Highcharts.RangeSelector#getYTDExtremes * @return {*} * Returns min and max for the YTD */ getYTDExtremes(dataMax, dataMin) { const time = this.chart.time, year = time.toParts(dataMax)[0], startOfYear = time.makeTime(year, 0); return { max: dataMax, min: Math.max(dataMin, startOfYear) }; } createElements() { const chart = this.chart, renderer = chart.renderer, container = chart.container, chartOptions = chart.options, options = chartOptions.rangeSelector, inputEnabled = options.inputEnabled, inputsZIndex = pick(chartOptions.chart.style?.zIndex, 0) + 1; if (options.enabled === false) { return; } this.group = renderer.g('range-selector-group') .attr({ zIndex: 7 }) .add(); this.div = createElement('div', void 0, { position: 'relative', height: 0, zIndex: inputsZIndex }); if (this.buttonOptions.length) { this.renderButtons(); } // First create a wrapper outside the container in order to make // the inputs work and make export correct if (container.parentNode) { container.parentNode.insertBefore(this.div, container); } if (inputEnabled) { this.createInputs(); } } /** * Create the input elements and its group. * */ createInputs() { this.inputGroup = this.chart.renderer.g('input-group').add(this.group); const minElems = this.drawInput('min'); this.minDateBox = minElems.dateBox; this.minLabel = minElems.label; this.minInput = minElems.input; const maxElems = this.drawInput('max'); this.maxDateBox = maxElems.dateBox; this.maxLabel = maxElems.label; this.maxInput = maxElems.input; } /** * Render the range selector including the buttons and the inputs. The first * time render is called, the elements are created and positioned. On * subsequent calls, they are moved and updated. * * @private * @function Highcharts.RangeSelector#render * @param {number} [min] * X axis minimum * @param {number} [max] * X axis maximum */ render(min, max) { if (this.options.enabled === false) { return; } const chart = this.chart, chartOptions = chart.options, options = chartOptions.rangeSelector, // Place inputs above the container inputEnabled = options.inputEnabled; if (inputEnabled) { if (!this.inputGroup) { this.createInputs(); } // Set or reset the input values this.setInputValue('min', min); this.setInputValue('max', max); if (!this.chart.styledMode) { this.maxLabel?.css(options.labelStyle); this.minLabel?.css(options.labelStyle); } const unionExtremes = (chart.scroller && chart.scroller.getUnionExtremes()) || chart.xAxis[0] || {}; if (defined(unionExtremes.dataMin) && defined(unionExtremes.dataMax)) { const minRange = chart.xAxis[0].minRange || 0; this.setInputExtremes('min', unionExtremes.dataMin, Math.min(unionExtremes.dataMax, this.getInputValue('max')) - minRange); this.setInputExtremes('max', Math.max(unionExtremes.dataMin, this.getInputValue('min')) + minRange, unionExtremes.dataMax); } // Reflow if (this.inputGroup) { let x = 0; [ this.minLabel, this.minDateBox, this.maxLabel, this.maxDateBox ].forEach((label) => { if (label) { const { width } = label.getBBox(); if (width) { label.attr({ x }); x += width + options.inputSpacing; } } }); } } else { if (this.inputGroup) { this.inputGroup.destroy(); delete this.inputGroup; } } if (!this.chart.styledMode) { if (this.zoomText) { this.zoomText.css(options.labelStyle); } } this.alignElements(); this.updateButtonStates(); } /** * Render the range buttons. This only runs the first time, later the * positioning is laid out in alignElements. * * @private * @function Highcharts.RangeSelector#renderButtons */ renderButtons() { var _a; const { chart, options } = this; const lang = defaultOptions.lang; const renderer = chart.renderer; const buttonTheme = merge(options.buttonTheme); const states = buttonTheme && buttonTheme.states; // Prevent the button from resetting the width when the button state // changes since we need more control over the width when collapsing // the buttons delete buttonTheme.width; delete buttonTheme.states; this.buttonGroup = renderer.g('range-selector-buttons').add(this.group); const dropdown = this.dropdown = createElement('select', void 0, { position: 'absolute', padding: 0, border: 0, cursor: 'pointer', opacity: 0.0001 }, this.div); // Create a label for dropdown select element const userButtonTheme = chart.userOptions.rangeSelector?.buttonTheme; this.dropdownLabel = renderer.button('', 0, 0, () => { }, merge(buttonTheme, { 'stroke-width': pick(buttonTheme['stroke-width'], 0), width: 'auto', paddingLeft: pick(options.buttonTheme.paddingLeft, userButtonTheme?.padding, 8), paddingRight: pick(options.buttonTheme.paddingRight, userButtonTheme?.padding, 8) }), states && states.hover, states && states.select, states && states.disabled) .hide() .add(this.group); // Prevent page zoom on iPhone addEvent(dropdown, 'touchstart', () => { dropdown.style.fontSize = '16px'; }); // Forward events from select to button const mouseOver = H.isMS ? 'mouseover' : 'mouseenter', mouseOut = H.isMS ? 'mouseout' : 'mouseleave'; addEvent(dropdown, mouseOver, () => { fireEvent(this.dropdownLabel.element, mouseOver); }); addEvent(dropdown, mouseOut, () => { fireEvent(this.dropdownLabel.element, mouseOut); }); addEvent(dropdown, 'change', () => { const button = this.buttons[dropdown.selectedIndex - 1]; fireEvent(button.element, 'click'); }); this.zoomText = renderer .label(lang.rangeSelectorZoom || '', 0) .attr({ padding: options.buttonTheme.padding, height: options.buttonTheme.height, paddingLeft: 0, paddingRight: 0 }) .add(this.buttonGroup); if (!this.chart.styledMode) { this.zoomText.css(options.labelStyle); (_a = options.buttonTheme)['stroke-width'] ?? (_a['stroke-width'] = 0); } createElement('option', { textContent: this.zoomText.textStr, disabled: true }, void 0, dropdown); this.createButtons(); } createButtons() { const { options } = this; const buttonTheme = merge(options.buttonTheme); const states = buttonTheme && buttonTheme.states; // Prevent the button from resetting the width when the button state // changes since we need more control over the width when collapsing // the buttons const width = buttonTheme.width || 28; delete buttonTheme.width; delete buttonTheme.states; this.buttonOptions.forEach((rangeOptions, i) => { this.createButton(rangeOptions, i, width, states); }); } createButton(rangeOptions, i, width, states) { const { dropdown, buttons, chart, options } = this; const renderer = chart.renderer; const buttonTheme = merge(options.buttonTheme); dropdown?.add(createElement('option', { textContent: rangeOptions.title || rangeOptions.text }), i + 2); buttons[i] = renderer .button(rangeOptions.text ?? '', 0, 0, (e) => { // Extract events from button object and call const buttonEvents = (rangeOptions.events && rangeOptions.events.click); let callDefaultEvent; if (buttonEvents) { callDefaultEvent = buttonEvents.call(rangeOptions, e); } if (callDefaultEvent !== false) { this.clickButton(i); } this.isActive = true; }, buttonTheme, states && states.hover, states && states.select, states && states.disabled) .attr({ 'text-align': 'center', width }) .add(this.buttonGroup); if (rangeOptions.title) { buttons[i].attr('title', rangeOptions.title); } } /** * Align the elements horizontally and vertically. * * @private * @function Highcharts.RangeSelector#alignElements */ alignElements() { const { buttonGroup, buttons, chart, group, inputGroup, options, zoomText } = this; const chartOptions = chart.options; const navButtonOptions = (chartOptions.exporting && chartOptions.exporting.enabled !== false && chartOptions.navigation && chartOptions.navigation.buttonOptions); const { buttonPosition, inputPosition, verticalAlign } = options; // Get the X offset required to avoid overlapping with the exporting // button. This is used both by the buttonGroup and the inputGroup. const getXOffsetForExportButton = (group, position, rightAligned) => { if (navButtonOptions && this.titleCollision(chart) && verticalAlign === 'top' && rightAligned && ((position.y - group.getBBox().height - 12) < ((navButtonOptions.y || 0) + (navButtonOptions.height || 0) + chart.spacing[0]))) { return -40; } return 0; }; let plotLeft = chart.plotLeft; if (group && buttonPosition && inputPosition) { let translateX = buttonPosition.x - chart.spacing[3]; if (buttonGroup) { this.positionButtons(); if (!this.initialButtonGroupWidth) { let width = 0; if (zoomText) { width += zoomText.getBBox().width + 5; } buttons.forEach((button, i) => { width += button.width || 0; if (i !== buttons.length - 1) { width += options.buttonSpacing; } }); this.initialButtonGroupWidth = width; } plotLeft -= chart.spacing[3]; // Detect collision between button group and exporting const xOffsetForExportButton = getXOffsetForExportButton(buttonGroup, buttonPosition, buttonPosition.align === 'right' || inputPosition.align === 'right'); this.alignButtonGroup(xOffsetForExportButton); if (this.buttonGroup?.translateY) { this.dropdownLabel .attr({ y: this.buttonGroup.translateY }); } // Skip animation group.placed = buttonGroup.placed = chart.hasLoaded; } let xOffsetForExportButton = 0; if (options.inputEnabled && inputGroup) { // Detect collision between the input group and exporting button xOffsetForExportButton = getXOffsetForExportButton(inputGroup, inputPosition, buttonPosition.align === 'right' || inputPosition.align === 'right'); if (inputPosition.align === 'left') { translateX = plotLeft; } else if (inputPosition.align === 'right') { translateX = -Math.max(chart.axisOffset[1], -xOffsetForExportButton); } // Update the alignment to the updated spacing box inputGroup.align({ y: inputPosition.y, width: inputGroup.getBBox().width, align: inputPosition.align, // Fix wrong getBBox() value on right align x: inputPosition.x + translateX - 2 }, true, chart.spacingBox); // Skip animation inputGroup.placed = chart.hasLoaded; } this.handleCollision(xOffsetForExportButton); // Vertical align group.align({ verticalAlign }, true, chart.spacingBox); const alignTranslateY = group.alignAttr.translateY; // Set position let groupHeight = group.getBBox().height + 20; // # 20 padding let translateY = 0; // Calculate bottom position if (verticalAlign === 'bottom') { const legendOptions = chart.legend && chart.legend.options; const legendHeight = (legendOptions && legendOptions.verticalAlign === 'bottom' && legendOptions.enabled && !legendOptions.floating ? (chart.legend.legendHeight + pick(legendOptions.margin, 10)) : 0); groupHeight = groupHeight + legendHeight - 20; translateY = (alignTranslateY - groupHeight - (options.floating ? 0 : options.y) - (chart.titleOffset ? chart.titleOffset[2] : 0) - 10 // 10 spacing ); } if (verticalAlign === 'top') { if (options.floating) { translateY = 0; } if (chart.titleOffset && chart.titleOffset[0]) { translateY = chart.titleOffset[0]; } translateY += ((chart.margin[0] - chart.spacing[0]) || 0); } else if (verticalAlign === 'middle') { if (inputPosition.y === buttonPosition.y) { translateY = alignTranslateY; } else if (inputPosition.y || buttonPosition.y) { if (inputPosition.y < 0 || buttonPosition.y < 0) { translateY -= Math.min(inputPosition.y, buttonPosition.y); } else { translateY = alignTranslateY - groupHeight; } } } group.translate(options.x, options.y + Math.floor(translateY)); // Translate HTML inputs const { minInput, maxInput, dropdown } = this; if (options.inputEnabled && minInput && maxInput) { minInput.style.marginTop = group.translateY + 'px'; maxInput.style.marginTop = group.translateY + 'px'; } if (dropdown) { dropdown.style.marginTop = group.translateY + 'px'; } } } /** * @private */ redrawElements() { const chart = this.chart, { inputBoxHeight, inputBoxBorderColor } = this.options; this.maxDateBox?.attr({ height: inputBoxHeight }); this.minDateBox?.attr({ height: inputBoxHeight }); if (!chart.styledMode) { this.maxDateBox?.attr({ stroke: inputBoxBorderColor }); this.minDateBox?.attr({ stroke: inputBoxBorderColor }); } if (this.isDirty) { this.isDirty = false; // Reset this prop to force redrawing collapse of buttons this.isCollapsed = void 0; const newButtonsOptions = this.options.buttons ?? []; const btnLength = Math.min(newButtonsOptions.length, this.buttonOptions.length); const { dropdown, options } = this; const buttonTheme = merge(options.buttonTheme); const states = buttonTheme && buttonTheme.states; // Prevent the button from resetting the width when the button state // changes since we need more control over the width when collapsing // the buttons const width = buttonTheme.width || 28; // Destroy additional buttons if (newButtonsOptions.length < this.buttonOptions.length) { for (let i = this.buttonOptions.length - 1; i >= newButtonsOptions.length; i--) { const btn = this.buttons.pop(); btn?.destroy(); this.dropdown?.options.remove(i + 1); } } // Update current buttons for (let i = btnLength - 1; i >= 0; i--) { const diff = diffObjects(newButtonsOptions[i], this.buttonOptions[i]); if (Object.keys(diff).length !== 0) { const rangeOptions = newButtonsOptions[i]; this.buttons[i].destroy(); dropdown?.options.remove(i + 1); this.createButton(rangeOptions, i, width, states); this.computeButtonRange(rangeOptions); } } // Create