/* * 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, CommonTooltipOption, OptionDataValue } 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: Record = [0, 10, 20, 30]; const RICH_TEXT_GAPS: Record = ['', '\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; } 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; valueFormatter?: CommonTooltipOption['valueFormatter'] } 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; valueFormatter?: CommonTooltipOption['valueFormatter'] } /** * 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; // eslint-disable-next-line max-len export function createTooltipMarkup(type: 'nameValue', option: Omit): TooltipMarkupNameValueBlock; // eslint-disable-next-line max-len export function createTooltipMarkup(type: TooltipMarkupBlockFragment['type'], option: Omit): 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 { ( ctx: TooltipMarkupBuildContext, fragment: TooltipMarkupBlockFragment, topMarginForOuterGap: number, toolTipTextStyle: TooltipOption['textStyle'] ): MarkupText; } function isSectionFragment(frag: TooltipMarkupBlockFragment): frag is TooltipMarkupSection { return frag.type === 'section'; } function getBuilder(frag: TooltipMarkupBlockFragment): TooltipMarkupFragmentBuilder { return isSectionFragment(frag) ? buildSection : buildNameValue; } function getBlockGapLevel(frag: TooltipMarkupBlockFragment) { if (isSectionFragment(frag)) { let gapLevel = 0; const subBlockLen = frag.blocks.length; const hasInnerGap = subBlockLen > 1 || (subBlockLen > 0 && !frag.noHeader); each(frag.blocks, function (subBlock) { const subGapLevel = getBlockGapLevel(subBlock); // 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 >= gapLevel) { gapLevel = subGapLevel + ( +( hasInnerGap && ( // 0 always can not be readable gap level. !subGapLevel // If no header, always keep the sub gap level. Otherwise // look weird in case `multipleSeries`. || (isSectionFragment(subBlock) && !subBlock.noHeader) ) ) ); } }); return gapLevel; } return 0; } function buildSection( ctx: TooltipMarkupBuildContext, fragment: TooltipMarkupSection, topMarginForOuterGap: number, toolTipTextStyle: TooltipOption['textStyle'] ) { const noHeader = fragment.noHeader; const gaps = getGap(getBlockGapLevel(fragment)); 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(); } } each(subBlocks, function (subBlock, idx) { const valueFormatter = fragment.valueFormatter; const subMarkupText = getBuilder(subBlock)( // Inherit valueFormatter valueFormatter ? extend(extend({}, ctx), { valueFormatter }) : ctx, subBlock, idx > 0 ? gaps.html : 0, toolTipTextStyle ); subMarkupText != null && subMarkupTextList.push(subMarkupText); }); const subMarkupText = ctx.renderMode === 'richText' ? subMarkupTextList.join(gaps.richText) : wrapBlockHTML( subMarkupTextList.join(''), noHeader ? topMarginForOuterGap : gaps.html ); 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( `
` + encodeHTML(displayableHeader) + '
' + subMarkupText, topMarginForOuterGap ); } } function buildNameValue( ctx: TooltipMarkupBuildContext, fragment: TooltipMarkupNameValueBlock, topMarginForOuterGap: number, toolTipTextStyle: TooltipOption['textStyle'] ) { const renderMode = ctx.renderMode; const noName = fragment.noName; const noValue = fragment.noValue; const noMarker = !fragment.markerType; const name = fragment.name; const useUTC = ctx.useUTC; const valueFormatter = fragment.valueFormatter || ctx.valueFormatter || ((value) => { value = isArray(value) ? value : [value]; return map(value as unknown[], (val, idx) => makeValueReadable( val, isArray(valueTypeOption) ? valueTypeOption[idx] : valueTypeOption, 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 ? [] : valueFormatter(fragment.value as OptionDataValue); 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 ); } interface TooltipMarkupBuildContext { useUTC: boolean; renderMode: TooltipRenderMode; orderMode: TooltipOrderMode; markupStyleCreator: TooltipMarkupStyleCreator; valueFormatter: CommonTooltipOption['valueFormatter'] } /** * @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); const ctx: TooltipMarkupBuildContext = { useUTC: useUTC, renderMode: renderMode, orderMode: orderMode, markupStyleCreator: markupStyleCreator, valueFormatter: fragment.valueFormatter }; return builder(ctx, fragment, 0, toolTipTextStyle); } function getGap(gapLevel: number): { html: number; richText: string } { return { html: HTML_GAPS[gapLevel], richText: RICH_TEXT_GAPS[gapLevel] }; } function wrapBlockHTML( encodedContent: string, topGap: number ): string { const clearfix = '
'; const marginCSS = `margin: ${topGap}px 0 0`; return `
` + encodedContent + clearfix + '
'; } function wrapInlineNameHTML( name: string, leftHasMarker: boolean, style: string ): string { const marginCss = leftHasMarker ? 'margin-left:2px' : ''; return `` + encodeHTML(name) + ''; } function wrapInlineValueHTML( valueList: string | 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}` : ''; valueList = isArray(valueList) ? valueList : [valueList]; return ( `` // Value has commas inside, so use ' ' as delimiter for multiple values. + map(valueList, value => encodeHTML(value)).join('  ') + '' ); } function wrapInlineNameRichText(ctx: TooltipMarkupBuildContext, name: string, style: RichTextStyle): string { return ctx.markupStyleCreator.wrapRichTextStyle(name, style as Dictionary); } function wrapInlineValueRichText( ctx: TooltipMarkupBuildContext, values: string | string[], alignRight: boolean, valueCloseToMarker: boolean, style: RichTextStyle ): string { const styles: Dictionary[] = [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( isArray(values) ? values.join(' ') : values, 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, 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> = {}; // Notice that "generate a style name" usually happens repeatedly when mouse is moving and // a tooltip is displayed. 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 | Dictionary[]): 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}}`; } }