blob: 2e88c1df309fd32d8740d372f9be73c02732c00e [file] [log] [blame]
/*
* 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('&nbsp;&nbsp;')
+ '</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}}`;
}
}