highcharts
Version:
JavaScript charting framework
626 lines (625 loc) • 22.7 kB
JavaScript
/* *
*
* GUI generator for Stock tools
*
* (c) 2009-2024 Sebastian Bochan
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import U from '../../Core/Utilities.js';
import AST from '../../Core/Renderer/HTML/AST.js';
import StockToolsUtilities from './StockToolsUtilities.js';
const { addEvent, createElement, css, defined, fireEvent, getStyle, isArray, merge, pick } = U;
const { shallowArraysEqual } = StockToolsUtilities;
/* *
*
* Classes
*
* */
/**
* Toolbar Class
*
* @private
* @class
*
* @param {object} options
* Options of toolbar
*
* @param {Highcharts.Dictionary<string>|undefined} langOptions
* Language options
*
* @param {Highcharts.Chart} chart
* Reference to chart
*/
class Toolbar {
/* *
*
* Constructor
*
* */
constructor(options, langOptions, chart) {
this.width = 0;
this.isDirty = false;
this.chart = chart;
this.options = options;
this.lang = langOptions;
// Set url for icons.
this.iconsURL = this.getIconsURL();
this.guiEnabled = options.enabled;
this.visible = pick(options.visible, true);
this.guiClassName = options.className;
this.toolbarClassName = options.toolbarClassName;
// General events collection which should be removed upon
// destroy/update:
this.eventsToUnbind = [];
if (this.guiEnabled) {
this.createContainer();
this.createButtons();
this.showHideNavigation();
}
fireEvent(this, 'afterInit');
}
/* *
*
* Functions
*
* */
/**
* Create and set up stockTools buttons with their events and submenus.
* @private
*/
createButtons() {
const lang = this.lang, guiOptions = this.options, toolbar = this.toolbar, buttons = guiOptions.buttons, defs = guiOptions.definitions, allButtons = toolbar.childNodes;
this.buttonList = buttons;
// Create buttons
buttons.forEach((btnName) => {
const button = this.addButton(toolbar, defs, btnName, lang);
this.eventsToUnbind.push(addEvent(button.buttonWrapper, 'click', () => this.eraseActiveButtons(allButtons, button.buttonWrapper)));
if (isArray(defs[btnName].items)) {
// Create submenu buttons
this.addSubmenu(button, defs[btnName]);
}
});
}
/**
* Create submenu (list of buttons) for the option. In example main button
* is Line, in submenu will be buttons with types of lines.
*
* @private
*
* @param {Highcharts.Dictionary<Highcharts.HTMLDOMElement>} parentBtn
* Button which has submenu
*
* @param {Highcharts.StockToolsGuiDefinitionsButtonsOptions} button
* List of all buttons
*/
addSubmenu(parentBtn, button) {
const submenuArrow = parentBtn.submenuArrow, buttonWrapper = parentBtn.buttonWrapper, buttonWidth = getStyle(buttonWrapper, 'width'), wrapper = this.wrapper, menuWrapper = this.listWrapper, allButtons = this.toolbar.childNodes,
// Create submenu container
submenuWrapper = this.submenu = createElement('ul', {
className: 'highcharts-submenu-wrapper'
}, void 0, buttonWrapper);
// Create submenu buttons and select the first one
this.addSubmenuItems(buttonWrapper, button);
// Show / hide submenu
this.eventsToUnbind.push(addEvent(submenuArrow, 'click', (e) => {
e.stopPropagation();
// Erase active class on all other buttons
this.eraseActiveButtons(allButtons, buttonWrapper);
// Hide menu
if (buttonWrapper.className
.indexOf('highcharts-current') >= 0) {
menuWrapper.style.width =
menuWrapper.startWidth + 'px';
buttonWrapper.classList.remove('highcharts-current');
submenuWrapper.style.display = 'none';
}
else {
// Show menu
// to calculate height of element
submenuWrapper.style.display = 'block';
let topMargin = submenuWrapper.offsetHeight -
buttonWrapper.offsetHeight - 3;
// Calculate position of submenu in the box
// if submenu is inside, reset top margin
if (
// Cut on the bottom
!(submenuWrapper.offsetHeight +
buttonWrapper.offsetTop >
wrapper.offsetHeight &&
// Cut on the top
buttonWrapper.offsetTop > topMargin)) {
topMargin = 0;
}
// Apply calculated styles
css(submenuWrapper, {
top: -topMargin + 'px',
left: buttonWidth + 3 + 'px'
});
buttonWrapper.className += ' highcharts-current';
menuWrapper.startWidth = wrapper.offsetWidth;
menuWrapper.style.width = menuWrapper.startWidth +
getStyle(menuWrapper, 'padding-left') +
submenuWrapper.offsetWidth + 3 + 'px';
}
}));
}
/**
* Create buttons in submenu
*
* @private
*
* @param {Highcharts.HTMLDOMElement} buttonWrapper
* Button where submenu is placed
*
* @param {Highcharts.StockToolsGuiDefinitionsButtonsOptions} button
* List of all buttons options
*/
addSubmenuItems(buttonWrapper, button) {
const _self = this, submenuWrapper = this.submenu, lang = this.lang, menuWrapper = this.listWrapper, items = button.items;
let submenuBtn;
// Add items to submenu
items.forEach((btnName) => {
// Add buttons to submenu
submenuBtn = this.addButton(submenuWrapper, button, btnName, lang);
this.eventsToUnbind.push(addEvent(submenuBtn.mainButton, 'click', function () {
_self.switchSymbol(this, buttonWrapper, true);
menuWrapper.style.width =
menuWrapper.startWidth + 'px';
submenuWrapper.style.display = 'none';
}));
});
// Select first submenu item
const firstSubmenuItem = submenuWrapper.querySelectorAll('li > .highcharts-menu-item-btn')[0];
// Replace current symbol, in main button, with submenu's button style
this.switchSymbol(firstSubmenuItem, false);
}
/**
* Erase active class on all other buttons.
* @private
*/
eraseActiveButtons(buttons, currentButton, submenuItems) {
[].forEach.call(buttons, (btn) => {
if (btn !== currentButton) {
btn.classList.remove('highcharts-current');
btn.classList.remove('highcharts-active');
submenuItems =
btn.querySelectorAll('.highcharts-submenu-wrapper');
// Hide submenu
if (submenuItems.length > 0) {
submenuItems[0].style.display = 'none';
}
}
});
}
/**
* Create single button. Consist of HTML elements `li`, `button`, and (if
* exists) submenu container.
*
* @private
*
* @param {Highcharts.HTMLDOMElement} target
* HTML reference, where button should be added
*
* @param {object} options
* All options, by btnName refer to particular button
*
* @param {string} btnName
* Button name of functionality mapped for specific class
*
* @param {Highcharts.Dictionary<string>} lang
* All titles, by btnName refer to particular button
*
* @return {object}
* References to all created HTML elements
*/
addButton(target, options, btnName, lang = {}) {
const btnOptions = options[btnName], items = btnOptions.items, classMapping = Toolbar.prototype.classMapping, userClassName = btnOptions.className || '';
// Main button wrapper
const buttonWrapper = createElement('li', {
className: pick(classMapping[btnName], '') + ' ' + userClassName,
title: lang[btnName] || btnName
}, void 0, target);
// Single button
const elementType = (btnOptions.elementType || 'button');
const mainButton = createElement(elementType, {
className: 'highcharts-menu-item-btn'
}, void 0, buttonWrapper);
// Submenu
if (items && items.length) {
// Arrow is a hook to show / hide submenu
const submenuArrow = createElement('button', {
className: 'highcharts-submenu-item-arrow ' +
'highcharts-arrow-right'
}, void 0, buttonWrapper);
submenuArrow.style.backgroundImage = 'url(' +
this.iconsURL + 'arrow-bottom.svg)';
return {
buttonWrapper,
mainButton,
submenuArrow
};
}
mainButton.style.backgroundImage = 'url(' +
this.iconsURL + btnOptions.symbol + ')';
return {
buttonWrapper,
mainButton
};
}
/**
* Create navigation's HTML elements: container and arrows.
* @private
*/
addNavigation() {
const wrapper = this.wrapper;
// Arrow wrapper
this.arrowWrapper = createElement('div', {
className: 'highcharts-arrow-wrapper'
});
this.arrowUp = createElement('div', {
className: 'highcharts-arrow-up'
}, void 0, this.arrowWrapper);
this.arrowUp.style.backgroundImage =
'url(' + this.iconsURL + 'arrow-right.svg)';
this.arrowDown = createElement('div', {
className: 'highcharts-arrow-down'
}, void 0, this.arrowWrapper);
this.arrowDown.style.backgroundImage =
'url(' + this.iconsURL + 'arrow-right.svg)';
wrapper.insertBefore(this.arrowWrapper, wrapper.childNodes[0]);
// Attach scroll events
this.scrollButtons();
}
/**
* Add events to navigation (two arrows) which allows user to scroll
* top/down GUI buttons, if container's height is not enough.
* @private
*/
scrollButtons() {
const wrapper = this.wrapper, toolbar = this.toolbar, step = 0.1 * wrapper.offsetHeight; // 0.1 = 10%
let targetY = 0;
this.eventsToUnbind.push(addEvent(this.arrowUp, 'click', () => {
if (targetY > 0) {
targetY -= step;
toolbar.style.marginTop = -targetY + 'px';
}
}));
this.eventsToUnbind.push(addEvent(this.arrowDown, 'click', () => {
if (wrapper.offsetHeight + targetY <=
toolbar.offsetHeight + step) {
targetY += step;
toolbar.style.marginTop = -targetY + 'px';
}
}));
}
/*
* Create the stockTools container and sets up event bindings.
*
*/
createContainer() {
const chart = this.chart, guiOptions = this.options, container = chart.container, navigation = chart.options.navigation, bindingsClassName = navigation?.bindingsClassName, self = this;
let listWrapper, toolbar;
// Create main container
const wrapper = this.wrapper = createElement('div', {
className: 'highcharts-stocktools-wrapper ' +
guiOptions.className + ' ' + bindingsClassName
});
container.appendChild(wrapper);
this.showHideBtn = createElement('div', {
className: 'highcharts-toggle-toolbar highcharts-arrow-left'
}, void 0, wrapper);
// Toggle menu
this.eventsToUnbind.push(addEvent(this.showHideBtn, 'click', () => {
this.update({
gui: {
visible: !self.visible
}
});
}));
// Mimic event behaviour of being outside chart.container
[
'mousedown',
'mousemove',
'click',
'touchstart'
].forEach((eventType) => {
addEvent(wrapper, eventType, (e) => e.stopPropagation());
});
addEvent(wrapper, 'mouseover', (e) => chart.pointer?.onContainerMouseLeave(e));
// Toolbar
this.toolbar = toolbar = createElement('ul', {
className: 'highcharts-stocktools-toolbar ' +
guiOptions.toolbarClassName
});
// Add container for list of buttons
this.listWrapper = listWrapper = createElement('div', {
className: 'highcharts-menu-wrapper'
});
wrapper.insertBefore(listWrapper, wrapper.childNodes[0]);
listWrapper.insertBefore(toolbar, listWrapper.childNodes[0]);
this.showHideToolbar();
// Add navigation which allows user to scroll down / top GUI buttons
this.addNavigation();
}
/**
* Function called in redraw verifies if the navigation should be visible.
* @private
*/
showHideNavigation() {
// Arrows
// 50px space for arrows
if (this.visible &&
this.toolbar.offsetHeight > (this.wrapper.offsetHeight - 50)) {
this.arrowWrapper.style.display = 'block';
}
else {
// Reset margin if whole toolbar is visible
this.toolbar.style.marginTop = '0px';
// Hide arrows
this.arrowWrapper.style.display = 'none';
}
}
/**
* Create button which shows or hides GUI toolbar.
* @private
*/
showHideToolbar() {
const wrapper = this.wrapper, toolbar = this.listWrapper, submenu = this.submenu,
// Show hide toolbar
showHideBtn = this.showHideBtn;
let visible = this.visible;
showHideBtn.style.backgroundImage =
'url(' + this.iconsURL + 'arrow-right.svg)';
if (!visible) {
// Hide
if (submenu) {
submenu.style.display = 'none';
}
showHideBtn.style.left = '0px';
visible = this.visible = false;
toolbar.classList.add('highcharts-hide');
showHideBtn.classList.add('highcharts-arrow-right');
wrapper.style.height = showHideBtn.offsetHeight + 'px';
}
else {
wrapper.style.height = '100%';
toolbar.classList.remove('highcharts-hide');
showHideBtn.classList.remove('highcharts-arrow-right');
showHideBtn.style.top = getStyle(toolbar, 'padding-top') + 'px';
showHideBtn.style.left = (wrapper.offsetWidth +
getStyle(toolbar, 'padding-left')) + 'px';
}
}
/*
* In main GUI button, replace icon and class with submenu button's
* class / symbol.
*
* @param {HTMLDOMElement} - submenu button
* @param {Boolean} - true or false
*
*/
switchSymbol(button, redraw) {
const buttonWrapper = button.parentNode, buttonWrapperClass = buttonWrapper.className,
// Main button in first level og GUI
mainNavButton = buttonWrapper.parentNode.parentNode;
// If the button is disabled, don't do anything
if (buttonWrapperClass.indexOf('highcharts-disabled-btn') > -1) {
return;
}
// Set class
mainNavButton.className = '';
if (buttonWrapperClass) {
mainNavButton.classList.add(buttonWrapperClass.trim());
}
// Set icon
mainNavButton
.querySelectorAll('.highcharts-menu-item-btn')[0]
.style.backgroundImage =
button.style.backgroundImage;
// Set active class
if (redraw) {
this.toggleButtonActiveClass(mainNavButton);
}
}
/**
* Set select state (active class) on button.
* @private
*/
toggleButtonActiveClass(button) {
const classList = button.classList;
if (classList.contains('highcharts-active')) {
classList.remove('highcharts-active');
}
else {
classList.add('highcharts-active');
}
}
/**
* Remove active class from all buttons except defined.
* @private
*/
unselectAllButtons(button) {
const activeBtns = button.parentNode
.querySelectorAll('.highcharts-active');
[].forEach.call(activeBtns, (activeBtn) => {
if (activeBtn !== button) {
activeBtn.classList.remove('highcharts-active');
}
});
}
/**
* Update GUI with given options.
* @private
*/
update(options, redraw) {
this.isDirty = !!options.gui.definitions;
merge(true, this.chart.options.stockTools, options);
merge(true, this.options, options.gui);
this.visible = pick(this.options.visible && this.options.enabled, true);
// If Stock Tools are updated, then bindings should be updated too:
if (this.chart.navigationBindings) {
this.chart.navigationBindings.update();
}
this.chart.isDirtyBox = true;
if (pick(redraw, true)) {
this.chart.redraw();
}
}
/**
* Destroy all HTML GUI elements.
* @private
*/
destroy() {
const stockToolsDiv = this.wrapper, parent = stockToolsDiv && stockToolsDiv.parentNode;
this.eventsToUnbind.forEach((unbinder) => unbinder());
// Remove the empty element
if (parent) {
parent.removeChild(stockToolsDiv);
}
}
/**
* Redraws the toolbar based on the current state of the options.
* @private
*/
redraw() {
if (this.options.enabled !== this.guiEnabled) {
this.handleGuiEnabledChange();
}
else {
if (!this.guiEnabled) {
return;
}
this.updateClassNames();
this.updateButtons();
this.updateVisibility();
this.showHideNavigation();
this.showHideToolbar();
}
}
/**
* Hadles the change of the `enabled` option.
* @private
*/
handleGuiEnabledChange() {
if (this.options.enabled === false) {
this.destroy();
this.visible = false;
}
if (this.options.enabled === true) {
this.createContainer();
this.createButtons();
}
this.guiEnabled = this.options.enabled;
}
/**
* Updates the class names of the GUI and toolbar elements.
* @private
*/
updateClassNames() {
if (this.options.className !== this.guiClassName) {
if (this.guiClassName) {
this.wrapper.classList.remove(this.guiClassName);
}
if (this.options.className) {
this.wrapper.classList.add(this.options.className);
}
this.guiClassName = this.options.className;
}
if (this.options.toolbarClassName !== this.toolbarClassName) {
if (this.toolbarClassName) {
this.toolbar.classList.remove(this.toolbarClassName);
}
if (this.options.toolbarClassName) {
this.toolbar.classList.add(this.options.toolbarClassName);
}
this.toolbarClassName = this.options.toolbarClassName;
}
}
/**
* Updates the buttons in the toolbar if the button options have changed.
* @private
*/
updateButtons() {
if (!shallowArraysEqual(this.options.buttons, this.buttonList) ||
this.isDirty) {
this.toolbar.innerHTML = AST.emptyHTML;
this.createButtons();
}
}
/**
* Updates visibility based on current options.
* @private
*/
updateVisibility() {
if (defined(this.options.visible)) {
this.visible = this.options.visible;
}
}
/**
* @private
*/
getIconsURL() {
return this.chart.options.navigation.iconsURL ||
this.options.iconsURL ||
'https://code.highcharts.com/@product.version@/gfx/stock-icons/';
}
}
Toolbar.prototype.classMapping = {
circle: 'highcharts-circle-annotation',
ellipse: 'highcharts-ellipse-annotation',
rectangle: 'highcharts-rectangle-annotation',
label: 'highcharts-label-annotation',
segment: 'highcharts-segment',
arrowSegment: 'highcharts-arrow-segment',
ray: 'highcharts-ray',
arrowRay: 'highcharts-arrow-ray',
line: 'highcharts-infinity-line',
arrowInfinityLine: 'highcharts-arrow-infinity-line',
verticalLine: 'highcharts-vertical-line',
horizontalLine: 'highcharts-horizontal-line',
crooked3: 'highcharts-crooked3',
crooked5: 'highcharts-crooked5',
elliott3: 'highcharts-elliott3',
elliott5: 'highcharts-elliott5',
pitchfork: 'highcharts-pitchfork',
fibonacci: 'highcharts-fibonacci',
fibonacciTimeZones: 'highcharts-fibonacci-time-zones',
parallelChannel: 'highcharts-parallel-channel',
measureX: 'highcharts-measure-x',
measureY: 'highcharts-measure-y',
measureXY: 'highcharts-measure-xy',
timeCycles: 'highcharts-time-cycles',
verticalCounter: 'highcharts-vertical-counter',
verticalLabel: 'highcharts-vertical-label',
verticalArrow: 'highcharts-vertical-arrow',
currentPriceIndicator: 'highcharts-current-price-indicator',
indicators: 'highcharts-indicators',
flagCirclepin: 'highcharts-flag-circlepin',
flagDiamondpin: 'highcharts-flag-diamondpin',
flagSquarepin: 'highcharts-flag-squarepin',
flagSimplepin: 'highcharts-flag-simplepin',
zoomX: 'highcharts-zoom-x',
zoomY: 'highcharts-zoom-y',
zoomXY: 'highcharts-zoom-xy',
typeLine: 'highcharts-series-type-line',
typeOHLC: 'highcharts-series-type-ohlc',
typeHLC: 'highcharts-series-type-hlc',
typeCandlestick: 'highcharts-series-type-candlestick',
typeHollowCandlestick: 'highcharts-series-type-hollowcandlestick',
typeHeikinAshi: 'highcharts-series-type-heikinashi',
fullScreen: 'highcharts-full-screen',
toggleAnnotations: 'highcharts-toggle-annotations',
saveChart: 'highcharts-save-chart',
separator: 'highcharts-separator'
};
/* *
*
* Default Export
*
* */
export default Toolbar;