/** * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type Nullable from '../common/Nullable'; import type TViewData from '../common/TViewData'; import type Crosshair from '../common/Crosshair'; import { type IndicatorStyle, type TooltipStyle, type TooltipIconStyle, type TooltipTextStyle, type TooltipLegend, TooltipShowRule, type TooltipLegendChild, TooltipIconPosition, } from '../common/Styles'; import { ActionType } from '../common/Action'; import { formatPrecision, formatThousands, formatFoldDecimal, } from '../common/utils/format'; import { isValid, isObject, isString, isNumber, } from '../common/utils/typeChecks'; import { createFont } from '../common/utils/canvas'; import type Coordinate from '../common/Coordinate'; import { type CustomApi } from '../Options'; import type YAxis from '../component/YAxis'; import { type Indicator, type IndicatorFigure, type IndicatorFigureStyle, type IndicatorTooltipData, } from '../component/Indicator'; import type IndicatorImp from '../component/Indicator'; import { eachFigures } from '../component/Indicator'; import { type TooltipIcon } from '../store/TooltipStore'; import View from './View'; export default class IndicatorTooltipView extends View { private readonly _boundIconClickEvent = (currentIcon: TooltipIcon) => () => { const pane = this.getWidget().getPane(); pane .getChart() .getChartStore() .getActionStore() .execute(ActionType.OnTooltipIconClick, { ...currentIcon }); return true; }; private readonly _boundIconMouseMoveEvent = (currentIconInfo: TooltipIcon) => () => { const pane = this.getWidget().getPane(); const tooltipStore = pane.getChart().getChartStore().getTooltipStore(); tooltipStore.setActiveIcon({ ...currentIconInfo }); return true; }; override drawImp(ctx: CanvasRenderingContext2D): void { const widget = this.getWidget(); const pane = widget.getPane(); const chartStore = pane.getChart().getChartStore(); const crosshair = chartStore.getTooltipStore().getCrosshair(); if (isValid(crosshair.TViewData)) { const bounding = widget.getBounding(); const customApi = chartStore.getCustomApi(); const thousandsSeparator = chartStore.getThousandsSeparator(); const decimalFoldThreshold = chartStore.getDecimalFoldThreshold(); const indicators = chartStore .getIndicatorStore() .getInstances(pane.getId()); const activeIcon = chartStore.getTooltipStore().getActiveIcon(); const defaultStyles = chartStore.getStyles().indicator; const { offsetLeft, offsetTop, offsetRight } = defaultStyles.tooltip; this.drawIndicatorTooltip( ctx, pane.getId(), chartStore.getDataList(), crosshair, activeIcon, indicators, customApi, thousandsSeparator, decimalFoldThreshold, offsetLeft, offsetTop, bounding.width - offsetRight, defaultStyles, ); } } protected drawIndicatorTooltip( ctx: CanvasRenderingContext2D, paneId: string, dataList: TViewData[], crosshair: Crosshair, activeTooltipIcon: Nullable, indicators: IndicatorImp[], customApi: CustomApi, thousandsSeparator: string, decimalFoldThreshold: number, left: number, top: number, maxWidth: number, styles: IndicatorStyle, ): number { const tooltipStyles = styles.tooltip; if (this.isDrawTooltip(crosshair, tooltipStyles)) { const tooltipTextStyles = tooltipStyles.text; indicators.forEach((proxy) => { const indicator = proxy.getIndicator(); let prevRowHeight = 0; const coordinate = { x: left, y: top }; const { name, calcParamsText, values: legends, icons, } = this.getIndicatorTooltipData( dataList, crosshair, indicator, customApi, thousandsSeparator, decimalFoldThreshold, styles, ); const nameValid = name.length > 0; const legendValid = legends.length > 0; if (nameValid || legendValid) { const [leftIcons, middleIcons, rightIcons] = this.classifyTooltipIcons(icons); prevRowHeight = this.drawStandardTooltipIcons( ctx, activeTooltipIcon, leftIcons, coordinate, paneId, indicator.name, left, prevRowHeight, maxWidth, ); if (nameValid) { let text = name; if (calcParamsText.length > 0) { text = `${text}${calcParamsText}`; } prevRowHeight = this.drawStandardTooltipLegends( ctx, [ { title: { text: '', color: tooltipTextStyles.color }, value: { text, color: tooltipTextStyles.color }, }, ], coordinate, left, prevRowHeight, maxWidth, tooltipTextStyles, ); } prevRowHeight = this.drawStandardTooltipIcons( ctx, activeTooltipIcon, middleIcons, coordinate, paneId, indicator.name, left, prevRowHeight, maxWidth, ); if (legendValid) { prevRowHeight = this.drawStandardTooltipLegends( ctx, legends, coordinate, left, prevRowHeight, maxWidth, tooltipStyles.text, ); } // draw right icons prevRowHeight = this.drawStandardTooltipIcons( ctx, activeTooltipIcon, rightIcons, coordinate, paneId, indicator.name, left, prevRowHeight, maxWidth, ); top = coordinate.y + prevRowHeight; } }); } return top; } protected drawStandardTooltipIcons( ctx: CanvasRenderingContext2D, activeIcon: Nullable, icons: TooltipIconStyle[], coordinate: Coordinate, paneId: string, indicatorName: string, left: number, prevRowHeight: number, maxWidth: number, ): number { if (icons.length > 0) { let width = 0; let height = 0; icons.forEach((icon) => { const { marginLeft = 0, marginTop = 0, marginRight = 0, marginBottom = 0, paddingLeft = 0, paddingTop = 0, paddingRight = 0, paddingBottom = 0, size, fontFamily, icon: text, } = icon; ctx.font = createFont(size, 'normal', fontFamily); width += marginLeft + paddingLeft + ctx.measureText(text).width + paddingRight + marginRight; height = Math.max( height, marginTop + paddingTop + size + paddingBottom + marginBottom, ); }); if (coordinate.x + width > maxWidth) { coordinate.x = left; coordinate.y += prevRowHeight; prevRowHeight = height; } else { prevRowHeight = Math.max(prevRowHeight, height); } icons.forEach((icon) => { const { marginLeft = 0, marginTop = 0, marginRight = 0, paddingLeft = 0, paddingTop = 0, paddingRight = 0, paddingBottom = 0, color, activeColor, size, fontFamily, icon: text, backgroundColor, activeBackgroundColor, } = icon; const active = activeIcon?.paneId === paneId && activeIcon?.indicatorName === indicatorName && activeIcon?.iconId === icon.id; this.createFigure( { name: 'text', attrs: { text, x: coordinate.x + marginLeft, y: coordinate.y + marginTop, }, styles: { paddingLeft, paddingTop, paddingRight, paddingBottom, color: active ? activeColor : color, size, family: fontFamily, backgroundColor: active ? activeBackgroundColor : backgroundColor, }, }, { mouseClickEvent: this._boundIconClickEvent({ paneId, indicatorName, iconId: icon.id, }), mouseMoveEvent: this._boundIconMouseMoveEvent({ paneId, indicatorName, iconId: icon.id, }), }, )?.draw(ctx); coordinate.x += marginLeft + paddingLeft + ctx.measureText(text).width + paddingRight + marginRight; }); } return prevRowHeight; } protected drawStandardTooltipLegends( ctx: CanvasRenderingContext2D, legends: TooltipLegend[], coordinate: Coordinate, left: number, prevRowHeight: number, maxWidth: number, styles: TooltipTextStyle, ): number { if (legends.length > 0) { const { marginLeft, marginTop, marginRight, marginBottom, size, family, weight, } = styles; ctx.font = createFont(size, weight, family); legends.forEach((data) => { const title = data.title as TooltipLegendChild; const value = data.value as TooltipLegendChild; const titleTextWidth = ctx.measureText(title.text).width; const valueTextWidth = ctx.measureText(value.text).width; const totalTextWidth = titleTextWidth + valueTextWidth; const h = marginTop + size + marginBottom; if ( coordinate.x + marginLeft + totalTextWidth + marginRight > maxWidth ) { coordinate.x = left; coordinate.y += prevRowHeight; prevRowHeight = h; } else { prevRowHeight = Math.max(prevRowHeight, h); } if (title.text.length > 0) { this.createFigure({ name: 'text', attrs: { x: coordinate.x + marginLeft, y: coordinate.y + marginTop, text: title.text, }, styles: { color: title.color, size, family, weight }, })?.draw(ctx); } this.createFigure({ name: 'text', attrs: { x: coordinate.x + marginLeft + titleTextWidth, y: coordinate.y + marginTop, text: value.text, }, styles: { color: value.color, size, family, weight }, })?.draw(ctx); coordinate.x += marginLeft + totalTextWidth + marginRight; }); } return prevRowHeight; } protected isDrawTooltip(crosshair: Crosshair, styles: TooltipStyle): boolean { const showRule = styles.showRule; return ( showRule === TooltipShowRule.Always || (showRule === TooltipShowRule.FollowCross && isString(crosshair.paneId)) ); } protected getIndicatorTooltipData( dataList: TViewData[], crosshair: Crosshair, indicator: Indicator, customApi: CustomApi, thousandsSeparator: string, decimalFoldThreshold: number, styles: IndicatorStyle, ): IndicatorTooltipData { const tooltipStyles = styles.tooltip; const name = tooltipStyles.showName ? indicator.shortName : ''; let calcParamsText = ''; const calcParams = indicator.calcParams; if (calcParams.length > 0 && tooltipStyles.showParams) { calcParamsText = `(${calcParams.join(',')})`; } const tooltipData: IndicatorTooltipData = { name, calcParamsText, values: [], icons: tooltipStyles.icons, }; const dataIndex = crosshair.dataIndex!; const result = indicator.result ?? []; const legends: TooltipLegend[] = []; if (indicator.visible) { const indicatorData = result[dataIndex] ?? {}; eachFigures( dataList, indicator, dataIndex, styles, ( figure: IndicatorFigure, figureStyles: Required, ) => { if (isString(figure.title)) { const color = figureStyles.color; let value = indicatorData[figure.key]; if (isNumber(value)) { value = formatPrecision(value, indicator.precision); if (indicator.shouldFormatBigNumber) { value = customApi.formatBigNumber(value as string); } } legends.push({ title: { text: figure.title, color }, value: { text: formatFoldDecimal( formatThousands( (value ?? tooltipStyles.defaultValue) as string, thousandsSeparator, ), decimalFoldThreshold, ), color, }, }); } }, ); tooltipData.values = legends; } if (indicator.createTooltipDataSource !== null) { const widget = this.getWidget(); const pane = widget.getPane(); const chartStore = pane.getChart().getChartStore(); const { name: customName, calcParamsText: customCalcParamsText, values: customLegends, icons: customIcons, } = indicator.createTooltipDataSource({ TViewDataList: dataList, indicator, visibleRange: chartStore.getTimeScaleStore().getVisibleRange(), bounding: widget.getBounding(), crosshair, defaultStyles: styles, xAxis: pane.getChart().getXAxisPane().getAxisComponent(), yAxis: pane.getAxisComponent(), }); if (isString(customName) && tooltipStyles.showName) { tooltipData.name = customName; } if (isString(customCalcParamsText) && tooltipStyles.showParams) { tooltipData.calcParamsText = customCalcParamsText; } if (isValid(customIcons)) { tooltipData.icons = customIcons; } if (isValid(customLegends) && indicator.visible) { const optimizedLegends: TooltipLegend[] = []; const color = styles.tooltip.text.color; customLegends.forEach((data) => { let title = { text: '', color }; if (isObject(data.title)) { title = data.title; } else { title.text = data.title; } let value = { text: '', color }; if (isObject(data.value)) { value = data.value; } else { value.text = data.value; } value.text = formatFoldDecimal( formatThousands(value.text, thousandsSeparator), decimalFoldThreshold, ); optimizedLegends.push({ title, value }); }); tooltipData.values = optimizedLegends; } } return tooltipData; } protected classifyTooltipIcons( icons: TooltipIconStyle[], ): TooltipIconStyle[][] { const leftIcons: TooltipIconStyle[] = []; const middleIcons: TooltipIconStyle[] = []; const rightIcons: TooltipIconStyle[] = []; icons.forEach((icon) => { switch (icon.position) { case TooltipIconPosition.Left: { leftIcons.push(icon); break; } case TooltipIconPosition.Middle: { middleIcons.push(icon); break; } case TooltipIconPosition.Right: { rightIcons.push(icon); break; } } }); return [leftIcons, middleIcons, rightIcons]; } }