| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you 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 { |
| Dictionary, TooltipRenderMode, ColorString, |
| TooltipOrderMode, DimensionType |
| } from '../../util/types'; |
| import { |
| TooltipMarkerType, getTooltipMarker, encodeHTML, |
| makeValueReadable, convertToColorString |
| } from '../../util/format'; |
| import { isString, each, hasOwn, isArray, map, assert, extend } from 'zrender/src/core/util'; |
| import { SortOrderComparator } from '../../data/helper/dataValueHelper'; |
| import SeriesModel from '../../model/Series'; |
| import { getRandomIdBase } from '../../util/number'; |
| import Model from '../../model/Model'; |
| import { TooltipOption } from './TooltipModel'; |
| |
| type RichTextStyle = { |
| fontSize: number | string, |
| fill: string, |
| fontWeight?: number | string |
| }; |
| |
| type TextStyle = string | RichTextStyle; |
| |
| const TOOLTIP_LINE_HEIGHT_CSS = 'line-height:1'; |
| |
| // TODO: more textStyle option |
| function getTooltipTextStyle( |
| textStyle: TooltipOption['textStyle'], |
| renderMode: TooltipRenderMode |
| ): { |
| nameStyle: TextStyle |
| valueStyle: TextStyle |
| } { |
| const nameFontColor = textStyle.color || '#6e7079'; |
| const nameFontSize = textStyle.fontSize || 12; |
| const nameFontWeight = textStyle.fontWeight || '400'; |
| const valueFontColor = textStyle.color || '#464646'; |
| const valueFontSize = textStyle.fontSize || 14; |
| const valueFontWeight = textStyle.fontWeight || '900'; |
| |
| if (renderMode === 'html') { |
| // `textStyle` is probably from user input, should be encoded to reduce security risk. |
| return { |
| // eslint-disable-next-line max-len |
| nameStyle: `font-size:${encodeHTML(nameFontSize + '')}px;color:${encodeHTML(nameFontColor)};font-weight:${encodeHTML(nameFontWeight + '')}`, |
| // eslint-disable-next-line max-len |
| valueStyle: `font-size:${encodeHTML(valueFontSize + '')}px;color:${encodeHTML(valueFontColor)};font-weight:${encodeHTML(valueFontWeight + '')}` |
| }; |
| } |
| else { |
| return { |
| nameStyle: { |
| fontSize: nameFontSize, |
| fill: nameFontColor, |
| fontWeight: nameFontWeight |
| }, |
| valueStyle: { |
| fontSize: valueFontSize, |
| fill: valueFontColor, |
| fontWeight: valueFontWeight |
| } |
| }; |
| } |
| } |
| |
| // 0: no gap in this block. |
| // 1: has max gap in level 1 in this block. |
| // ... |
| type GapLevel = number; |
| // See `TooltipMarkupLayoutIntent['innerGapLevel']`. |
| // (value from UI design) |
| const HTML_GAPS: { [key in GapLevel]: number } = [0, 10, 20, 30]; |
| const RICH_TEXT_GAPS: { [key in GapLevel]: string } = ['', '\n', '\n\n', '\n\n\n']; |
| |
| /** |
| * This is an abstract layer to insulate the upper usage of tooltip content |
| * from the different backends according to different `renderMode` ('html' or 'richText'). |
| * With the help of the abstract layer, it does not need to consider how to create and |
| * assemble html or richText snippets when making tooltip content. |
| * |
| * @usage |
| * |
| * ```ts |
| * class XxxSeriesModel { |
| * formatTooltip( |
| * dataIndex: number, |
| * multipleSeries: boolean, |
| * dataType: string |
| * ) { |
| * ... |
| * return createTooltipMarkup('section', { |
| * header: header, |
| * blocks: [ |
| * createTooltipMarkup('nameValue', { |
| * name: name, |
| * value: value, |
| * noValue: value == null |
| * }) |
| * ] |
| * }); |
| * } |
| * } |
| * ``` |
| */ |
| export type TooltipMarkupBlockFragment = |
| TooltipMarkupSection |
| | TooltipMarkupNameValueBlock; |
| |
| interface TooltipMarkupBlock { |
| // Use to make comparison when `sortBlocks: true`. |
| sortParam?: unknown; |
| __gapLevelBetweenSubBlocks?: number; |
| } |
| |
| export interface TooltipMarkupSection extends TooltipMarkupBlock { |
| type: 'section'; |
| header?: unknown; |
| // If `noHeader` is `true`, do not display header. |
| // Otherwise, always display it even if it is |
| // null/undefined/NaN/''... (displayed as '-'). |
| noHeader?: boolean; |
| blocks?: TooltipMarkupBlockFragment[]; |
| // Enable to sort blocks when making final html or richText. |
| sortBlocks?: boolean; |
| } |
| |
| export interface TooltipMarkupNameValueBlock extends TooltipMarkupBlock { |
| type: 'nameValue'; |
| // If `!markerType`, tooltip marker is not used. |
| markerType?: TooltipMarkerType; |
| markerColor?: ColorString; |
| name?: string; |
| // Also support value is `[121, 555, 94.2]`. |
| value?: unknown | unknown[]; |
| // If not specified, treat value as normal string or numeric. |
| // If needs to display formatted time, set as 'time'. |
| // If needs to display original string with numeric guessing, set as 'ordinal'. |
| // If both `value` and `valueType` are array, each valueType[i] cooresponds to value[i]. |
| valueType?: DimensionType | DimensionType[]; |
| // If `noName` or `noValue` is `true`, do not display name or value. |
| // Otherwise, always display them even if they are |
| // null/undefined/NaN/''... (displayed as '-'). |
| noName?: boolean; |
| noValue?: boolean; |
| } |
| |
| /** |
| * Create tooltip markup by this function, we can get TS type check. |
| */ |
| // eslint-disable-next-line max-len |
| export function createTooltipMarkup(type: 'section', option: Omit<TooltipMarkupSection, 'type'>): TooltipMarkupSection; |
| // eslint-disable-next-line max-len |
| export function createTooltipMarkup(type: 'nameValue', option: Omit<TooltipMarkupNameValueBlock, 'type'>): TooltipMarkupNameValueBlock; |
| // eslint-disable-next-line max-len |
| export function createTooltipMarkup(type: TooltipMarkupBlockFragment['type'], option: Omit<TooltipMarkupBlockFragment, 'type'>): TooltipMarkupBlockFragment { |
| (option as TooltipMarkupBlockFragment).type = type; |
| return option as TooltipMarkupBlockFragment; |
| } |
| |
| |
| // Can be null/undefined, which means generate nothing markup text. |
| type MarkupText = string; |
| interface TooltipMarkupFragmentBuilder { |
| planLayout( |
| fragment: TooltipMarkupBlockFragment |
| ): void; |
| build( |
| ctx: TooltipMarkupBuildContext, |
| fragment: TooltipMarkupBlockFragment, |
| topMarginForOuterGap: number, |
| toolTipTextStyle: TooltipOption['textStyle'] |
| ): MarkupText; |
| } |
| |
| function getBuilder(fragment: TooltipMarkupBlockFragment): TooltipMarkupFragmentBuilder { |
| return hasOwn(builderMap, fragment.type) && builderMap[fragment.type]; |
| } |
| |
| const builderMap: { [key in TooltipMarkupBlockFragment['type']]: TooltipMarkupFragmentBuilder } = { |
| |
| /** |
| * A `section` block is like: |
| * ``` |
| * header |
| * subBlock |
| * subBlock |
| * ... |
| * ``` |
| */ |
| section: { |
| planLayout: function (fragment: TooltipMarkupSection) { |
| const subBlockLen = fragment.blocks.length; |
| const thisBlockHasInnerGap = subBlockLen > 1 || (subBlockLen > 0 && !fragment.noHeader); |
| |
| let thisGapLevelBetweenSubBlocks = 0; |
| each(fragment.blocks, function (subBlock) { |
| getBuilder(subBlock).planLayout(subBlock); |
| const subGapLevel = subBlock.__gapLevelBetweenSubBlocks; |
| |
| // If the some of the sub-blocks have some gaps (like 10px) inside, this block |
| // should use a larger gap (like 20px) to distinguish those sub-blocks. |
| if (subGapLevel >= thisGapLevelBetweenSubBlocks) { |
| thisGapLevelBetweenSubBlocks = subGapLevel + ( |
| ( |
| thisBlockHasInnerGap && ( |
| // 0 always can not be readable gap level. |
| !subGapLevel |
| // If no header, always keep the sub gap level. Otherwise |
| // look weird in case `multipleSeries`. |
| || (subBlock.type === 'section' && !subBlock.noHeader) |
| ) |
| ) ? 1 : 0 |
| ); |
| } |
| }); |
| fragment.__gapLevelBetweenSubBlocks = thisGapLevelBetweenSubBlocks; |
| }, |
| |
| build( |
| ctx, |
| fragment: TooltipMarkupSection, |
| topMarginForOuterGap, |
| toolTipTextStyle |
| ): string { |
| const noHeader = fragment.noHeader; |
| const gaps = getGap(fragment); |
| |
| const subMarkupText = buildSubBlocks( |
| ctx, |
| fragment, |
| noHeader ? topMarginForOuterGap : gaps.html, |
| toolTipTextStyle |
| ); |
| |
| if (noHeader) { |
| return subMarkupText; |
| } |
| |
| const displayableHeader = makeValueReadable(fragment.header, 'ordinal', ctx.useUTC); |
| const {nameStyle} = getTooltipTextStyle(toolTipTextStyle, ctx.renderMode); |
| if (ctx.renderMode === 'richText') { |
| return wrapInlineNameRichText(ctx, displayableHeader, nameStyle as RichTextStyle) + gaps.richText |
| + subMarkupText; |
| } |
| else { |
| return wrapBlockHTML( |
| `<div style="${nameStyle};${TOOLTIP_LINE_HEIGHT_CSS};">` |
| + encodeHTML(displayableHeader) |
| + '</div>' |
| + subMarkupText, |
| topMarginForOuterGap |
| ); |
| } |
| } |
| }, |
| |
| /** |
| * A `nameValue` block is like: |
| * ``` |
| * marker name value |
| * ``` |
| */ |
| nameValue: { |
| planLayout: function (fragment: TooltipMarkupNameValueBlock) { |
| fragment.__gapLevelBetweenSubBlocks = 0; |
| }, |
| |
| build(ctx, fragment: TooltipMarkupNameValueBlock, topMarginForOuterGap, toolTipTextStyle) { |
| const renderMode = ctx.renderMode; |
| const noName = fragment.noName; |
| const noValue = fragment.noValue; |
| const noMarker = !fragment.markerType; |
| const name = fragment.name; |
| const value = fragment.value; |
| const useUTC = ctx.useUTC; |
| |
| if (noName && noValue) { |
| return; |
| } |
| |
| const markerStr = noMarker |
| ? '' |
| : ctx.markupStyleCreator.makeTooltipMarker( |
| fragment.markerType, |
| fragment.markerColor || '#333', |
| renderMode |
| ); |
| const readableName = noName |
| ? '' |
| : makeValueReadable(name, 'ordinal', useUTC); |
| const valueTypeOption = fragment.valueType; |
| const readableValueList = noValue |
| ? [] |
| : (isArray(value) |
| ? map(value, (val, idx) => makeValueReadable( |
| val, isArray(valueTypeOption) ? valueTypeOption[idx] : valueTypeOption, useUTC |
| )) |
| : [makeValueReadable( |
| value, isArray(valueTypeOption) ? valueTypeOption[0] : valueTypeOption, useUTC |
| )] |
| ); |
| const valueAlignRight = !noMarker || !noName; |
| // It little weird if only value next to marker but far from marker. |
| const valueCloseToMarker = !noMarker && noName; |
| |
| const {nameStyle, valueStyle} = getTooltipTextStyle(toolTipTextStyle, renderMode); |
| |
| return renderMode === 'richText' |
| ? ( |
| (noMarker ? '' : markerStr) |
| + (noName ? '' : wrapInlineNameRichText(ctx, readableName, nameStyle as RichTextStyle)) |
| // Value has commas inside, so use ' ' as delimiter for multiple values. |
| + (noValue ? '' : wrapInlineValueRichText( |
| ctx, readableValueList, valueAlignRight, valueCloseToMarker, valueStyle as RichTextStyle |
| )) |
| ) |
| : wrapBlockHTML( |
| (noMarker ? '' : markerStr) |
| + (noName ? '' : wrapInlineNameHTML(readableName, !noMarker, nameStyle as string)) |
| + (noValue ? '' : wrapInlineValueHTML( |
| readableValueList, valueAlignRight, valueCloseToMarker, valueStyle as string |
| )), |
| topMarginForOuterGap |
| ); |
| } |
| } |
| }; |
| |
| |
| function buildSubBlocks( |
| ctx: TooltipMarkupBuildContext, |
| fragment: TooltipMarkupSection, |
| topMarginForOuterGap: number, |
| tooltipTextStyle: TooltipOption['textStyle'] |
| ): MarkupText { |
| const subMarkupTextList: string[] = []; |
| let subBlocks = fragment.blocks || []; |
| assert(!subBlocks || isArray(subBlocks)); |
| subBlocks = subBlocks || []; |
| |
| const orderMode = ctx.orderMode; |
| if (fragment.sortBlocks && orderMode) { |
| subBlocks = subBlocks.slice(); |
| const orderMap = { valueAsc: 'asc', valueDesc: 'desc' } as const; |
| if (hasOwn(orderMap, orderMode)) { |
| const comparator = new SortOrderComparator(orderMap[orderMode as 'valueAsc' | 'valueDesc'], null); |
| subBlocks.sort((a, b) => comparator.evaluate(a.sortParam, b.sortParam)); |
| } |
| // FIXME 'seriesDesc' necessary? |
| else if (orderMode === 'seriesDesc') { |
| subBlocks.reverse(); |
| } |
| } |
| |
| const gaps = getGap(fragment); |
| each(subBlocks, function (subBlock, idx) { |
| const subMarkupText = getBuilder(subBlock).build( |
| ctx, |
| subBlock, |
| idx > 0 ? gaps.html : 0, |
| tooltipTextStyle |
| ); |
| subMarkupText != null && subMarkupTextList.push(subMarkupText); |
| }); |
| |
| if (!subMarkupTextList.length) { |
| return; |
| } |
| |
| return ctx.renderMode === 'richText' |
| ? subMarkupTextList.join(gaps.richText) |
| : wrapBlockHTML( |
| subMarkupTextList.join(''), |
| topMarginForOuterGap |
| ); |
| } |
| |
| interface TooltipMarkupBuildContext { |
| useUTC: boolean; |
| renderMode: TooltipRenderMode; |
| orderMode: TooltipOrderMode; |
| markupStyleCreator: TooltipMarkupStyleCreator; |
| } |
| |
| /** |
| * @return markupText. null/undefined means no content. |
| */ |
| export function buildTooltipMarkup( |
| fragment: TooltipMarkupBlockFragment, |
| markupStyleCreator: TooltipMarkupStyleCreator, |
| renderMode: TooltipRenderMode, |
| orderMode: TooltipOrderMode, |
| useUTC: boolean, |
| toolTipTextStyle: TooltipOption['textStyle'] |
| ): MarkupText { |
| if (!fragment) { |
| return; |
| } |
| |
| const builder = getBuilder(fragment); |
| builder.planLayout(fragment); |
| const ctx: TooltipMarkupBuildContext = { |
| useUTC: useUTC, |
| renderMode: renderMode, |
| orderMode: orderMode, |
| markupStyleCreator: markupStyleCreator |
| }; |
| return builder.build(ctx, fragment, 0, toolTipTextStyle); |
| } |
| |
| |
| function getGap(fragment: TooltipMarkupBlock): { |
| html: number; |
| richText: string |
| } { |
| const gapLevelBetweenSubBlocks = fragment.__gapLevelBetweenSubBlocks; |
| return { |
| html: HTML_GAPS[gapLevelBetweenSubBlocks], |
| richText: RICH_TEXT_GAPS[gapLevelBetweenSubBlocks] |
| }; |
| } |
| |
| function wrapBlockHTML( |
| encodedContent: string, |
| topGap: number |
| ): string { |
| const clearfix = '<div style="clear:both"></div>'; |
| const marginCSS = `margin: ${topGap}px 0 0`; |
| return `<div style="${marginCSS};${TOOLTIP_LINE_HEIGHT_CSS};">` |
| + encodedContent + clearfix |
| + '</div>'; |
| } |
| |
| function wrapInlineNameHTML( |
| name: string, |
| leftHasMarker: boolean, |
| style: string |
| ): string { |
| const marginCss = leftHasMarker ? 'margin-left:2px' : ''; |
| return `<span style="${style};${marginCss}">` |
| + encodeHTML(name) |
| + '</span>'; |
| } |
| |
| function wrapInlineValueHTML( |
| valueList: string[], |
| alignRight: boolean, |
| valueCloseToMarker: boolean, |
| style: string |
| ): string { |
| // Do not too close to marker, considering there are multiple values separated by spaces. |
| const paddingStr = valueCloseToMarker ? '10px' : '20px'; |
| const alignCSS = alignRight ? `float:right;margin-left:${paddingStr}` : ''; |
| return ( |
| `<span style="${alignCSS};${style}">` |
| // Value has commas inside, so use ' ' as delimiter for multiple values. |
| + map(valueList, value => encodeHTML(value)).join(' ') |
| + '</span>' |
| ); |
| } |
| |
| function wrapInlineNameRichText(ctx: TooltipMarkupBuildContext, name: string, style: RichTextStyle): string { |
| return ctx.markupStyleCreator.wrapRichTextStyle(name, style as Dictionary<unknown>); |
| } |
| |
| function wrapInlineValueRichText( |
| ctx: TooltipMarkupBuildContext, |
| valueList: string[], |
| alignRight: boolean, |
| valueCloseToMarker: boolean, |
| style: RichTextStyle |
| ): string { |
| const styles: Dictionary<unknown>[] = [style]; |
| const paddingLeft = valueCloseToMarker ? 10 : 20; |
| alignRight && styles.push({ padding: [0, 0, 0, paddingLeft], align: 'right' }); |
| // Value has commas inside, so use ' ' as delimiter for multiple values. |
| return ctx.markupStyleCreator.wrapRichTextStyle(valueList.join(' '), styles); |
| } |
| |
| |
| export function retrieveVisualColorForTooltipMarker( |
| series: SeriesModel, |
| dataIndex: number |
| ): ColorString { |
| const style = series.getData().getItemVisual(dataIndex, 'style'); |
| const color = style[series.visualDrawType]; |
| return convertToColorString(color); |
| } |
| |
| export function getPaddingFromTooltipModel( |
| model: Model<TooltipOption>, |
| renderMode: TooltipRenderMode |
| ): number | number[] { |
| const padding = model.get('padding'); |
| return padding != null |
| ? padding |
| // We give slightly different to look pretty. |
| : renderMode === 'richText' |
| ? [8, 10] |
| : 10; |
| } |
| |
| /** |
| * The major feature is generate styles for `renderMode: 'richText'`. |
| * But it also serves `renderMode: 'html'` to provide |
| * "renderMode-independent" API. |
| */ |
| export class TooltipMarkupStyleCreator { |
| readonly richTextStyles: Dictionary<Dictionary<unknown>> = {}; |
| |
| // Notice that "generate a style name" usuall happens repeatly when mouse moving and |
| // displaying a tooltip. So we put the `_nextStyleNameId` as a member of each creator |
| // rather than static shared by all creators (which will cause it increase to fast). |
| private _nextStyleNameId: number = getRandomIdBase(); |
| |
| private _generateStyleName() { |
| return '__EC_aUTo_' + this._nextStyleNameId++; |
| } |
| |
| makeTooltipMarker( |
| markerType: TooltipMarkerType, |
| colorStr: ColorString, |
| renderMode: TooltipRenderMode |
| ): string { |
| const markerId = renderMode === 'richText' |
| ? this._generateStyleName() |
| : null; |
| const marker = getTooltipMarker({ |
| color: colorStr, |
| type: markerType, |
| renderMode, |
| markerId: markerId |
| }); |
| if (isString(marker)) { |
| return marker; |
| } |
| else { |
| if (__DEV__) { |
| assert(markerId); |
| } |
| this.richTextStyles[markerId] = marker.style; |
| return marker.content; |
| } |
| } |
| |
| /** |
| * @usage |
| * ```ts |
| * const styledText = markupStyleCreator.wrapRichTextStyle([ |
| * // The styles will be auto merged. |
| * { |
| * fontSize: 12, |
| * color: 'blue' |
| * }, |
| * { |
| * padding: 20 |
| * } |
| * ]); |
| * ``` |
| */ |
| wrapRichTextStyle(text: string, styles: Dictionary<unknown> | Dictionary<unknown>[]): string { |
| const finalStl = {}; |
| if (isArray(styles)) { |
| each(styles, stl => extend(finalStl, stl)); |
| } |
| else { |
| extend(finalStl, styles); |
| } |
| const styleName = this._generateStyleName(); |
| this.richTextStyles[styleName] = finalStl; |
| return `{${styleName}|${text}}`; |
| } |
| } |