From eb03c87438c138af7423bcca2d5848d6bd651d6b Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Mon, 6 Jan 2025 19:50:27 +0800 Subject: [PATCH 1/4] feat: add theme & label format --- common/config/rush/pnpm-lock.yaml | 6 + packages/vstory-core/package.json | 3 +- .../src/character/character-base.ts | 4 + .../character/chart/runtime/label-style.ts | 268 ++++++++++++++++-- .../src/character/chart/runtime/mark-style.ts | 13 +- .../src/character/chart/runtime/utils.ts | 32 ++- .../src/character/common/utils/format.ts | 263 +++++++++++++++++ .../character/table/character/pivot-chart.ts | 3 + packages/vstory-core/src/constants/format.ts | 92 ++++++ packages/vstory-core/src/constants/index.ts | 1 + packages/vstory-core/src/core/story.ts | 8 + .../vstory-core/src/interface/character.ts | 2 + .../vstory-core/src/interface/dsl/chart.ts | 27 +- .../vstory-core/src/interface/dsl/common.ts | 93 ++++++ packages/vstory-core/src/interface/dsl/dsl.ts | 1 + packages/vstory-core/src/interface/story.ts | 1 + .../vstory-core/src/theme/builtin/default.ts | 18 ++ packages/vstory-core/src/theme/index.ts | 0 packages/vstory-core/src/theme/interface.ts | 35 +++ .../vstory-core/src/theme/theme-manager.ts | 110 +++++++ packages/vstory-core/src/utils/theme.ts | 17 ++ packages/vstory-core/src/utils/type.ts | 6 +- .../src/processor/chart/visibility.ts | 1 - .../src/demos/chart/runtime/label-style.tsx | 30 +- .../src/demos/chart/runtime/series-mark.tsx | 17 ++ .../demos/table/runtime/pivot-chart-base.tsx | 25 +- 26 files changed, 1013 insertions(+), 63 deletions(-) create mode 100644 packages/vstory-core/src/character/common/utils/format.ts create mode 100644 packages/vstory-core/src/constants/format.ts create mode 100644 packages/vstory-core/src/constants/index.ts create mode 100644 packages/vstory-core/src/interface/dsl/common.ts create mode 100644 packages/vstory-core/src/theme/builtin/default.ts create mode 100644 packages/vstory-core/src/theme/index.ts create mode 100644 packages/vstory-core/src/theme/interface.ts create mode 100644 packages/vstory-core/src/theme/theme-manager.ts create mode 100644 packages/vstory-core/src/utils/theme.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 10c9013f..8d1d6b48 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -224,6 +224,7 @@ importers: '@visactor/vtable': 1.14.4-alpha.0 '@visactor/vutils': ~0.18.17 '@vitejs/plugin-react': 3.1.0 + bignumber.js: 9.0.1 canvas: 2.11.2 eslint: ~8.18.0 jest: ^26.0.0 @@ -247,6 +248,7 @@ importers: '@visactor/vscale': 0.18.18 '@visactor/vtable': 1.14.4-alpha.0 '@visactor/vutils': 0.18.18 + bignumber.js: 9.0.1 devDependencies: '@douyinfe/semi-ui': 2.69.1_nnrd3gsncyragczmpvfhocinkq '@ffmpeg/core': 0.11.0 @@ -4762,6 +4764,10 @@ packages: resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} dev: true + /bignumber.js/9.0.1: + resolution: {integrity: sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==} + dev: false + /binary-extensions/1.13.1: resolution: {integrity: sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==} engines: {node: '>=0.10.0'} diff --git a/packages/vstory-core/package.json b/packages/vstory-core/package.json index 57cde25e..b0ba4b98 100644 --- a/packages/vstory-core/package.json +++ b/packages/vstory-core/package.json @@ -33,7 +33,8 @@ "@visactor/vutils": "~0.18.17", "@visactor/vchart-extension": "0.0.3-vstory.2", "@visactor/vdataset": "~0.18.17", - "@visactor/vscale": "~0.18.17" + "@visactor/vscale": "~0.18.17", + "bignumber.js": "9.0.1" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vstory-core/src/character/character-base.ts b/packages/vstory-core/src/character/character-base.ts index 262d0f3a..7c378400 100644 --- a/packages/vstory-core/src/character/character-base.ts +++ b/packages/vstory-core/src/character/character-base.ts @@ -38,6 +38,10 @@ export abstract class CharacterBase implements ICharacter { return this._canvas; } + get theme() { + return this._config.theme; + } + constructor(config: ICharacterConfig, option: ICharacterInitOption) { this.id = config.id ?? `c_${Generator.GenAutoIncrementId()}`; this.type = config.type; diff --git a/packages/vstory-core/src/character/chart/runtime/label-style.ts b/packages/vstory-core/src/character/chart/runtime/label-style.ts index 770ea18c..41dc4b68 100644 --- a/packages/vstory-core/src/character/chart/runtime/label-style.ts +++ b/packages/vstory-core/src/character/chart/runtime/label-style.ts @@ -1,15 +1,33 @@ -import { array, isValid, merge } from '@visactor/vutils'; +import { ThemeManager } from './../../../theme/theme-manager'; +import { array, isNil, isValid, merge } from '@visactor/vutils'; import type { IChartCharacterRuntime } from '../interface/runtime'; import type { ICharacterChart } from '../interface/character-chart'; -import type { ISeries, IVChart } from '@visactor/vchart'; +import { + STACK_FIELD_END, + STACK_FIELD_END_PERCENT, + STACK_FIELD_START, + STACK_FIELD_START_PERCENT, + STACK_FIELD_TOTAL, + type ISeries, + type IVChart, + type IRegion +} from '@visactor/vchart'; import type { Label as VChartLabelComponent } from '@visactor/vchart/esm/component/label/label'; import type { ILabelInfo } from '@visactor/vchart/esm/component/label'; import { MarkStyleRuntime } from './mark-style'; -import { getSeriesKeyScalesMap, isSeriesMatch, matchDatumWithScaleMap } from './utils'; +import { findSingleConfig, getSeriesKeyScalesMap, isSeriesMatch, matchDatumWithScaleMap } from './utils'; import type { IGraphic } from '@visactor/vrender-core'; +import type { IChartCharacterConfig, ITextAttribute } from '../../../interface/dsl/chart'; import { StroyAllDataGroup } from '../../../interface/dsl/chart'; import type { IMark } from '@visactor/vchart/esm/mark/interface'; import { CommonMarkAttributeMap, fillMarkAttribute, SeriesMarkStyleMap } from './const'; +import { formatConfigKey } from '../../../constants/format'; +import type { FormatContentType, IFormatConfig } from '../../../interface/dsl/common'; +import type { FormatValueFunction } from '../../common/utils/format'; +import { getTextWithFormat } from '../../common/utils/format'; +import { validNumber } from '../../../utils/type'; +import { getRegionStackGroup } from '@visactor/vchart/esm/util'; +import { stack } from '@visactor/vchart/esm/util'; export class LabelStyleRuntime implements IChartCharacterRuntime { type = 'LabelStyle'; @@ -64,16 +82,21 @@ export class LabelStyleRuntime implements IChartCharacterRuntime { if (!labelComponent) { return; } - this._setDataGroupStyle(character, labelComponent); + this._setDataGroupStyle(character, vchart, labelComponent); } - private _setDataGroupStyle(character: ICharacterChart, labelComponent: VChartLabelComponent) { + private _setDataGroupStyle(character: ICharacterChart, vchart: IVChart, labelComponent: VChartLabelComponent) { const config = character.getRuntimeConfig().config; const dataGroupStyle = config.options?.dataGroupStyle; if (!dataGroupStyle) { return; } + const formatValue = ThemeManager.getAttribute( + [character.theme, character.story.theme], + 'character.VChart.runtime.formatValue' + ); + const singleLabelStyleKeys: { [key: string]: boolean } = {}; const hasLabelStyle = !!config.options?.labelStyle; if (hasLabelStyle) { @@ -91,22 +114,22 @@ export class LabelStyleRuntime implements IChartCharacterRuntime { array(infos).forEach(info => { const { series, labelMark } = info as { series: ISeries; labelMark: IMark }; const keyScaleMap = getSeriesKeyScalesMap(series); - // 先看单标签样式 - const findKey = hasLabelStyle - ? Object.keys(config.options.labelStyle).find(k => - isSeriesMatch(config.options.labelStyle[k].seriesMatch, series) + // 先看当前系列是否存在单标签样式 + const hasSingleStyle = hasLabelStyle + ? isValid( + Object.keys(config.options.labelStyle).find(k => + isSeriesMatch(config.options.labelStyle[k].seriesMatch, series) + ) ) - : null; - const singleConfig = findKey ? config.options.labelStyle[findKey] : null; + : false; // 系列分组key const seriesField = series.getSeriesField(); // style Map 是 能设置的样式 const styleKeys = - SeriesMarkStyleMap[series.type]?.label?.style ?? CommonMarkAttributeMap.label ?? fillMarkAttribute; + SeriesMarkStyleMap[series.type]?.label?.style ?? CommonMarkAttributeMap.text ?? fillMarkAttribute; - // TODO: 在这里完成组样式下的标签 format - // 多组数据在同一个系列,使用vchart mark后处理 - styleKeys.forEach((key: string) => { + // 多组数据在同一个系列,使用vchart mark后处理。这里只有常规属性,如果发现某些属性设置不上,考虑styleKeys缺少 + styleKeys.forEach((key: keyof ITextAttribute) => { // fill 和 stroke 使用vrender后处理 if (key === 'fill' || key === 'stroke') { return; @@ -116,17 +139,15 @@ export class LabelStyleRuntime implements IChartCharacterRuntime { // 默认值 还必须这样写 labelMark.setAttribute(key, (): any => undefined); } - // 如果是有单标签样式的 - if (singleLabelStyleKeys[key] && singleConfig && isValid(singleConfig.style[key])) { + // 如果当前系列是有单标签样式的 + if (singleLabelStyleKeys[key] && hasSingleStyle) { labelMark.setPostProcess(key, (result, datum) => { - // 如果匹配到单标签样式 - if (matchDatumWithScaleMap(singleConfig.itemKeys, singleConfig.itemKeyMap, keyScaleMap, datum)) { - // TODO: 单标签format处理 - return singleConfig.style[key]; - } - // 否则匹配组样式 return ( - MarkStyleRuntime.getMarkStyle(labelMark, dataGroupStyle, key, datum, seriesField, 'label') ?? result + // 如果匹配到单标签样式 + findSingleConfig(config.options.labelStyle, series, keyScaleMap, datum)?.style?.[key] ?? + // 否则匹配组样式 + MarkStyleRuntime.getMarkStyle(labelMark, dataGroupStyle, key, datum, seriesField, 'label') ?? + result ); }); } else { @@ -139,6 +160,41 @@ export class LabelStyleRuntime implements IChartCharacterRuntime { }); } }); + + // format + // 如果是有单标签样式的 + if (hasSingleStyle) { + labelMark.setPostProcess('text', (result, datum) => { + const formatConfig = ( + findSingleConfig(config.options.labelStyle, series, keyScaleMap, datum) as unknown as { + [formatConfigKey]: IFormatConfig; + } + )?.[formatConfigKey]; + if (isValid(formatConfig)) { + return ( + getLabelTextWithFormat(datum, seriesField, series, vchart, character, formatConfig, formatValue) ?? + result + ); + } + // 否则匹配组样式 + return ( + getTextWithGroupFormat(datum, seriesField, series, vchart, character, dataGroupStyle, formatValue) ?? + result + ); + }); + } else { + // 没有单标签样式的 + // 直接匹配组样式 + labelMark.setPostProcess('text', (result, datum) => { + return ( + getTextWithGroupFormat(datum, seriesField, series, vchart, character, dataGroupStyle, formatValue) ?? + result + ); + }); + } + + // format结束 + // visible 单独设置 if (!labelMark.stateStyle.normal?.visible) { // TODO VChart bug。如果直接设置属性为 undefined 会报错 @@ -148,6 +204,9 @@ export class LabelStyleRuntime implements IChartCharacterRuntime { const spec = config.options.spec; labelMark.setPostProcess('visible', (result, datum) => { return ( + // 如果匹配到单标签样式 + findSingleConfig(config.options.labelStyle, series, keyScaleMap, datum)?.style?.visible ?? + // 否则匹配组样式 dataGroupStyle[datum[seriesField]]?.label?.visible ?? // 单组 visible dataGroupStyle[StroyAllDataGroup]?.label?.visible ?? // 全部组visible spec?.series?.[series.getSpecIndex()]?.label?.visible ?? // 单系列 visible @@ -155,6 +214,7 @@ export class LabelStyleRuntime implements IChartCharacterRuntime { result ); }); + // visible 结束 }); }); } @@ -242,6 +302,12 @@ export class LabelStyleRuntime implements IChartCharacterRuntime { } } +/** + * 将标签graphic放入数组 + * @param g graphic 父节点 + * @param list 将graphic放入数组 + * @returns + */ function _collectAllLabelGraphic(g: IGraphic, list: IGraphic[]) { if (g.type === 'text' || g.type === 'richtext') { list.push(g); @@ -252,6 +318,13 @@ function _collectAllLabelGraphic(g: IGraphic, list: IGraphic[]) { } } +/** + * 找到对应的全部标签绘图节点 + * @param g + * @param info + * @param list + * @returns + */ function findLabelGraphicWithInfo(g: IGraphic, info: ILabelInfo, list: IGraphic[]) { const matchLabel = g.children[0].children.find( // @ts-ignore @@ -263,4 +336,151 @@ function findLabelGraphicWithInfo(g: IGraphic, info: ILabelInfo, list: IGraphic[ _collectAllLabelGraphic(matchLabel, list); } +// 得到标签经过分组配置中的 format 处理后的值 +function getTextWithGroupFormat( + datum: any, + seriesField: string, + series: ISeries, + vchart: IVChart, + character: ICharacterChart, + dataGroupStyle: IChartCharacterConfig['options']['dataGroupStyle'], + formatValue: FormatValueFunction +) { + if (!dataGroupStyle) { + return null; + } + const formatConfig = + dataGroupStyle[datum[seriesField]]?.label?.formatConfig ?? dataGroupStyle[StroyAllDataGroup]?.label?.formatConfig; + + if (!formatConfig) { + return null; + } + + return getLabelTextWithFormat(datum, seriesField, series, vchart, character, formatConfig, formatValue); +} + +// 得到标签经过 format 处理后的值 +function getLabelTextWithFormat( + datum: any, + seriesField: string, + series: ISeries, + vchart: IVChart, + character: ICharacterChart, + formatConfig: IFormatConfig, + formatValue: FormatValueFunction +) { + // 去掉非百分百情况下的 percentdiff 内容 + const formatContents = array(formatConfig.content).filter(content => + series.getPercent() ? true : content !== 'percentdiff' + ); + const opt = { + datum, + seriesField, + series, + vchart, + character + }; + return getTextWithFormat(formatConfig, formatContents, getSeriesContentValue, formatValue, opt); +} + +function getSeriesContentValue( + { + datum, + seriesField, + series, + vchart + }: { + datum: any; + seriesField: string; + series: ISeries; + vchart: IVChart; + }, + content: FormatContentType +) { + const dimensionField = series.getDimensionField()[0]; + const measureField = series.getMeasureField()[0]; + switch (content) { + case 'dimension': + return datum[dimensionField]; + case 'abs': + return Math.abs(datum[measureField]); + case 'percentage': + // TODO: i18n + return validNumber(computeSeriesPercentage(vchart, datum, series)) ?? '百分比'; + case 'series': + return datum[seriesField]; + case 'value': + default: + return Number.parseFloat(datum[measureField]); + } +} + +// 计算系列百分比 +function computeSeriesPercentage(vchart: IVChart, datum: any, series: ISeries) { + // TODO: calculate stack & percentage before format method + // calculate percentage for specified series + if ( + series.type === 'pie' || + series.type === 'rose' || + series.type === 'scatter' || + series.type === 'map' || + series.type === 'funnel' + ) { + const data: any[] = series.getViewData().latestData; + const measureField = series.getMeasureField()[0]; + const totalValue = data.reduce((sum: number, d: any) => { + return sum + Number.parseFloat(d[measureField]); + }, 0); + const percentage = Number.parseFloat(datum[measureField]) / totalValue; + return percentage * 100; + } + // TODO: unite the percentage calculation for different series + // for now, line & waterfall & group bar series cannot get correct stack data + if ( + series.type === 'line' || + series.type === 'waterfall' || + // group bar chart + (series.type === 'bar' && series.getDimensionField().length > 1) + ) { + const seriesAxisOrient = series.getSpec()._editor_axis_orient; + const allSeries = vchart + .getChart() + .getAllSeries() + .filter((s: ISeries) => s.getSpec()._editor_axis_orient === seriesAxisOrient); + const data = allSeries.reduce((data, series) => { + return data.concat(series.getViewData().latestData); + }, []); + const dimensionField = series.getDimensionField()[0]; + const measureField = series.getMeasureField()[0]; + const totalValue = data.reduce((sum: number, d: any) => { + if (d[dimensionField] === datum[dimensionField]) { + const parsedValue = Number.parseFloat(d[measureField]); + return sum + (Number.isNaN(parsedValue) ? 0 : parsedValue); + } + return sum; + }, 0); + const currentValue = Number.parseFloat(datum[measureField]); + const percentage = Number.isNaN(currentValue) ? 0 : currentValue / totalValue; + return percentage * 100; + } + // calculate stack + const chart = vchart.getChart(); + chart.getAllRegions().forEach((region: IRegion) => { + const stackValueGroup = getRegionStackGroup(region, true); + for (const stackValue in stackValueGroup) { + for (const key in stackValueGroup[stackValue].nodes) { + stack(stackValueGroup[stackValue].nodes[key], region.getStackInverse(), true); + } + } + }); + + if (!isNil(datum[STACK_FIELD_TOTAL])) { + return ((datum[STACK_FIELD_END] - datum[STACK_FIELD_START]) / datum[STACK_FIELD_TOTAL]) * 100; + } + if (!isNil(datum[STACK_FIELD_END_PERCENT]) && !isNil(datum[STACK_FIELD_START_PERCENT])) { + return (datum[STACK_FIELD_END_PERCENT] - datum[STACK_FIELD_START_PERCENT]) * 100; + } + return NaN; +} + export const LabelStyleRuntimeInstance = new LabelStyleRuntime(); diff --git a/packages/vstory-core/src/character/chart/runtime/mark-style.ts b/packages/vstory-core/src/character/chart/runtime/mark-style.ts index 4bffaca2..3b9c388c 100644 --- a/packages/vstory-core/src/character/chart/runtime/mark-style.ts +++ b/packages/vstory-core/src/character/chart/runtime/mark-style.ts @@ -165,20 +165,21 @@ export class MarkStyleRuntime implements IChartCharacterRuntime { return; } const chart = vchart.getChart(); - Object.values(markStyle).forEach(i => { - const series = GetVChartSeriesWithMatch(chart, i.seriesMatch) as ISeries; + Object.keys(markStyle).forEach(key => { + const config = markStyle[key]; + const series = GetVChartSeriesWithMatch(chart, config.seriesMatch) as ISeries; if (!series) { return; } - const mark = series.getMarkInName(i.markName); + const mark = series.getMarkInName(config.markName); if (!mark) { return; } const keyScaleMap = getSeriesKeyScalesMap(series); - const stateKey = i.id; + const stateKey = key; mark.setStyle( { - ...i.style + ...config.style }, stateKey, EDITOR_SERIES_MARK_SINGLE_LEVEL @@ -186,7 +187,7 @@ export class MarkStyleRuntime implements IChartCharacterRuntime { chart.updateState({ [stateKey]: { filter: (datum: any) => { - return matchDatumWithScaleMap(i.itemKeys, i.itemKeyMap, keyScaleMap, datum); + return matchDatumWithScaleMap(config.itemKeys, config.itemKeyMap, keyScaleMap, datum); }, level: 10 } diff --git a/packages/vstory-core/src/character/chart/runtime/utils.ts b/packages/vstory-core/src/character/chart/runtime/utils.ts index 65143632..65dfae62 100644 --- a/packages/vstory-core/src/character/chart/runtime/utils.ts +++ b/packages/vstory-core/src/character/chart/runtime/utils.ts @@ -3,11 +3,11 @@ import type { IChart } from '@visactor/vchart/esm/chart/interface'; import type { ICartesianSeries, ISeries } from '@visactor/vchart'; import { isContinuous } from '@visactor/vscale'; import { VCHART_DATA_INDEX, ValueLink, FieldLink } from './const'; -import type { IComponentMatch } from '../../../interface/dsl/chart'; +import type { IComponentMatch, IMarkStyle } from '../../../interface/dsl/chart'; export function GetVChartSeriesWithMatch(vchart: IChart, seriesMatch: IComponentMatch & { type: string }) { if (!isValid(seriesMatch.specIndex) && seriesMatch.type) { - return vchart.getAllSeries().filter(s => s.type === seriesMatch.type); + return vchart.getAllSeries().filter(s => s.type === seriesMatch.type)[0]; } if (!isValid(seriesMatch.specIndex)) { return null; @@ -80,7 +80,33 @@ export function matchDatumWithScaleMap( if (isContinuous(scale.type)) { return keyValueMap[VCHART_DATA_INDEX] === datum[VCHART_DATA_INDEX]; } - return keyValueMap[key] === scaleMap[key]._index.get(`${datum[key]}`); + return keyValueMap[key] === scale._index.get(`${datum[key]}`); + }); +} + +export function isSingleMarkMatch( + config: IMarkStyle, + series: ISeries, + scaleMap: { [key: string]: any } = {}, + datum: any +) { + return ( + isSeriesMatch(config.seriesMatch, series) && + matchDatumWithScaleMap(config.itemKeys, config.itemKeyMap, scaleMap, datum) + ); +} + +export function findSingleConfig( + config: { [key: string]: IMarkStyle }, + series: ISeries, + scaleMap: { [key: string]: any } = {}, + datum: any +) { + if (!config) { + return null; + } + return Object.values(config).find(v => { + return isSingleMarkMatch(v, series, scaleMap, datum); }); } diff --git a/packages/vstory-core/src/character/common/utils/format.ts b/packages/vstory-core/src/character/common/utils/format.ts new file mode 100644 index 00000000..1504a778 --- /dev/null +++ b/packages/vstory-core/src/character/common/utils/format.ts @@ -0,0 +1,263 @@ +import type { FormatContentType, IFormatConfig } from '../../../interface/dsl/common'; +import type { Unit } from '../../../constants/format'; +import { DataFormatUnit, unionContentTypeMap } from '../../../constants/format'; +import { isArray, isNil, isString } from '@visactor/vutils/es/common'; +import { couldBeValidNumber } from '@visactor/vchart/esm/util'; +import { getTimeFormatter } from '@visactor/vutils'; + +export type FormatValueFunction = ( + content: FormatContentType, + value: number | string, + formatConfig: IFormatConfig, + language: string, + percentage?: boolean +) => string; + +// if (formatConfig.unit === 'CN_K') { +// unit = { ratio: 1000, symbol: '千' }; +// } else if (formatConfig.unit === 'CN_W') { +// unit = { ratio: 10000, symbol: '万' }; +// } else if (formatConfig.unit === 'CN_BW') { +// unit = { ratio: 1000000, symbol: '百万' }; +// } else if (formatConfig.unit === 'CN_QW') { +// unit = { ratio: 10000000, symbol: '千万' }; +// } else if (formatConfig.unit === 'CN_Y') { +// unit = { ratio: 1e8, symbol: '亿' }; +// } else if (formatConfig.unit === 'K') { +// unit = { ratio: 1e3, symbol: 'K' }; +// } else if (formatConfig.unit === 'M') { +// unit = { ratio: 1e6, symbol: 'M' }; +// } else if (formatConfig.unit === 'B') { +// unit = { ratio: 1e9, symbol: 'B' }; +// } +export const UnitMap: { [key in IFormatConfig['unit']]?: Unit } = { + CN_K: { ratio: 1000, symbol: '千' }, + CN_W: { ratio: 10000, symbol: '万' }, + CN_BW: { ratio: 1000000, symbol: '百万' }, + CN_QW: { ratio: 10000000, symbol: '千万' }, + CN_Y: { ratio: 1e8, symbol: '亿' }, + K: { ratio: 1e3, symbol: 'K' }, + M: { ratio: 1e6, symbol: 'M' }, + B: { ratio: 1e9, symbol: 'B' } +}; + +export type getContentValueFunction = (opt: any, content: FormatContentType) => string; + +export function textFormatWithFix(text: string, config: { prefix?: string; postfix?: string }) { + return normalizeFormatResult(`${config.prefix ?? ''}${text}${config.postfix ?? ''}`.split('\n')); +} +export function normalizeFormatResult(str: string | string[]): string | string[] { + if (isArray(str)) { + if (str.length === 0) { + return ''; + } + // extract string from array to make sure that the render result of [str] and str is same + if (str.length === 1) { + return str[0]; + } + return str; + } + return str; +} + +export function formatValue( + content: FormatContentType, + value: number | string, + formatConfig: IFormatConfig, + language: string, + percentage?: boolean +) { + if (content === 'date') { + return formatDate(value, formatConfig, language, percentage); + } + if (content === 'text') { + return value; + } + return formatNumber(value, formatConfig, language, percentage); +} + +export function formatDate( + value: number | string, + formatConfig: IFormatConfig, + language: string, + percentage?: boolean +) { + return getTimeFormatter(formatConfig.dateFormat)(`${value}`); +} + +export function formatNumber( + value: number | string, + formatConfig: IFormatConfig, + language: string, + percentage?: boolean +): string | number { + // 字符串类型的处理 + if (value && isString(value) && !couldBeValidNumber(value)) { + return value; + } + + if (Number.isNaN(value) || isNil(value) || value === '') { + return ''; + } + + // 先计算 unit + let unit: Unit; + // 优先百分比和千分比 + if (formatConfig.dataType === 'percent') { + unit = { ratio: 1e-2, symbol: '%' }; + } else if (formatConfig.dataType === 'permil') { + unit = { ratio: 1e-3, symbol: '‰' }; + } else if (formatConfig.unit === 'auto') { + unit = getNumFormatAuto( + value, + language.includes('zh') ? DataFormatUnit.ZH_CN : DataFormatUnit.EN_US, + language as 'zh_CN' | 'en_US' + ); + } else { + // 使用固定匹配 + unit = UnitMap[formatConfig.unit] ?? { ratio: 0, symbol: '' }; + } + + // 处理缩放 考虑超大数值的字符串场景 + if (typeof value === 'string') { + const [integerPart, decimalPart = ''] = value.split('.'); + const combinedNumber = integerPart + decimalPart; + const decimalShift = decimalPart.length - unit.ratio; + + // 计算缩放后的整数和小数部分 + if (decimalShift > 0) { + value = combinedNumber.slice(0, decimalShift) + '.' + combinedNumber.slice(decimalShift); + } else { + value = combinedNumber + '0'.repeat(-decimalShift); + } + } else { + value *= Math.pow(10, unit.ratio); + value = value.toString(); + } + + // 分割整数部分和小数部分 + const [integerPart, decimalPart = ''] = value.toString().split('.'); + + // 使用正则表达式添加千位分隔符 + const formattedIntegerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + let formattedDecimalPart = ''; + + if (formatConfig.fixed !== undefined && formatConfig.fixed !== 'auto') { + // 如果提供了fixed值,则格式化小数部分 + const roundedNumber = parseFloat(`0.${decimalPart}`).toFixed(formatConfig.fixed); + formattedDecimalPart = roundedNumber.split('.')[1]; + } else if (decimalPart) { + // 如果没有提供fixed值,且原始数字有小数部分 + formattedDecimalPart = decimalPart; + } + + // 拼接整数部分和小数部分 + const numberString = formattedDecimalPart ? `${formattedIntegerPart}.${formattedDecimalPart}` : formattedIntegerPart; + return `${numberString}${unit.symbol}${percentage ? '%' : ''}`; +} + +/** + * unit = auto 时,获取当前合适的 unit + * @param value + * @param dataFormatUnit + * @param lang + * @returns + */ +function getNumFormatAuto( + value: number | string, + dataFormatUnit?: `${DataFormatUnit}`, + lang?: 'zh_CN' | 'en_US' +): { ratio: number; symbol: string } { + // TODO: i18N + const locale = isNil(dataFormatUnit) || dataFormatUnit === 'auto' ? lang ?? 'zh_CN' : dataFormatUnit; + const valueAbs = Math.abs(Number(value)); + switch (locale) { + case 'zh_CN': + if (valueAbs >= 1e8) { + return { ratio: 1e8, symbol: '亿' }; + } + + if (valueAbs >= 1e4) { + return { ratio: 1e4, symbol: '万' }; + } + break; + case 'en_US': + if (valueAbs >= 1e9) { + return { ratio: 1e9, symbol: 'B' }; + } + if (valueAbs >= 1e6) { + return { ratio: 1e6, symbol: 'M' }; + } + if (valueAbs >= 1e3) { + return { ratio: 1e3, symbol: 'K' }; + } + break; + } + return { ratio: 1, symbol: '' }; +} + +// 得到标签经过 format 处理后的值 +export function getTextWithFormat( + formatConfig: IFormatConfig, + formatContents: FormatContentType[], + getContentValue: getContentValueFunction, + formatValue: FormatValueFunction, + opt?: any +) { + // 得到每一个 content 的内容 + const contentLabels = (formatContents.length === 0 ? (['value'] as FormatContentType[]) : formatContents).map( + content => { + return getLabelContentWithUnion(formatConfig, content, getContentValue, formatValue, opt); + } + ); + // 拼接 + const labelText: string = contentLabels.join(!!formatConfig.contentWrap ? '\n' : ' '); + + // 最后添加前后缀 + return textFormatWithFix(labelText, formatConfig); +} + +// 得到标签一个 content 的 format 值,含复合类型 +export function getLabelContentWithUnion( + formatConfig: IFormatConfig, + content: FormatContentType, + getContentValue: getContentValueFunction, + formatValue: FormatValueFunction, + opt: any +) { + if (unionContentTypeMap[content]) { + const matchResult = content.match(/(.*)\((.*)\)/); + const firstContent = matchResult[1] as FormatContentType; + const secondContent = matchResult[2] as FormatContentType; + return `${getLabelContent(formatConfig, firstContent, getContentValue, formatValue, opt)}(${getLabelContent( + formatConfig, + secondContent, + getContentValue, + formatValue, + opt + )})`; + } + return getLabelContent(formatConfig, content, getContentValue, formatValue, opt); +} + +// 得到标签一个 content 的 format 值 +export function getLabelContent( + formatConfig: IFormatConfig, + content: FormatContentType, + getContentValue: getContentValueFunction, + formatValue: FormatValueFunction, + opt: any +): string { + const datumValue = getContentValue(opt, content); + // number / date 类型的数值处理 + const labelContent = formatValue( + content, + datumValue, + formatConfig, + // TODO: i18n + 'chinese', + content === 'percentage' || content === 'CAGR' || content === 'percentdiff' + ); + return labelContent as string; +} diff --git a/packages/vstory-core/src/character/table/character/pivot-chart.ts b/packages/vstory-core/src/character/table/character/pivot-chart.ts index e1c13d39..51bc6ef4 100644 --- a/packages/vstory-core/src/character/table/character/pivot-chart.ts +++ b/packages/vstory-core/src/character/table/character/pivot-chart.ts @@ -86,6 +86,7 @@ export class PivotChartCharacter extends CharacterTable // 复用 chart 元素的 runtime 对 spec 进行处理 this._chartRuntime.forEach(r => { r.applyConfigToAttribute?.({ + story: this.story, getRuntimeConfig: () => { return runTimeConfig; } @@ -106,6 +107,7 @@ export class PivotChartCharacter extends CharacterTable this._chartRuntime.forEach(r => { r.afterInitialize?.( { + story: this.story, getRuntimeConfig: () => { return { config: { @@ -128,6 +130,7 @@ export class PivotChartCharacter extends CharacterTable this._chartRuntime.forEach(r => { r.beforeVRenderDraw?.( { + story: this.story, getRuntimeConfig: () => { return { config: { diff --git a/packages/vstory-core/src/constants/format.ts b/packages/vstory-core/src/constants/format.ts new file mode 100644 index 00000000..f316c9d7 --- /dev/null +++ b/packages/vstory-core/src/constants/format.ts @@ -0,0 +1,92 @@ +import type { FormatContentType, FormatDate } from '../interface/dsl/common'; + +export enum FormatType { + NONE = 'none', + DIGIT = 'digit', + PERCENT = 'percent', + PERMIL = 'permil' +} + +export type FormatTypeValue = `${FormatType}`; + +/** + * 数值单位 + */ +export interface Unit { + ratio: number; + symbol: string; +} +export enum PrecisionType { + DECIMAL_DIGITS = 'decimalDigits', + SIGNIFICANT_DECIMAL = 'significantDecimal', + SIGNIFICANT_FIGURES = 'significantFigures' +} +export enum DataFormatUnit { + AUTO = 'auto', + ZH_CN = 'zh_CN', + EN_US = 'en_US' +} +/** + * 数据格式化 + */ +export type NumFormat = { + /** 自动类型推导 */ + auto?: boolean; + /** 数值类型 */ + type: FormatTypeValue; + /** 数值单位 */ + unit?: Unit | 'auto' | null; + /** 千位分隔符 */ + kSep?: boolean; + /** 数字前缀 */ + prefix?: string | string[]; + /** 数字后缀 */ + suffix?: string | string[]; + /** 数位精度 */ + precision?: number | null; + /** 数位精度类型 */ + precisionType?: `${PrecisionType}` | null; + /** 数据格式单位 */ + dataFormatUnit?: `${DataFormatUnit}`; + /** @deprecated 有效数字,为空时表示原始值 */ + significantDigits?: number | null; + /** @deprecated 小数位数,为空时表示原始值 */ + decimalDigits?: number | null; +}; + +export const formatConfigKey = 'formatConfig'; + +export const unionContentTypeMap: { [key in FormatContentType]?: boolean } = { + 'value(percentage)': true, + 'percentage(value)': true, + 'series(CAGR)': true, + 'CAGR(series)': true, + 'series(percentage)': true, + 'percentage(series)': true +}; + +export const unionContentTypes: (keyof typeof unionContentTypeMap)[] = Object.keys( + unionContentTypeMap +) as (keyof typeof unionContentTypeMap)[]; + +export enum SpecialValueType { + BRACKET_TXT = 'bracketTxt', + NULL = 'null', + DASH = 'dash', + ZERO = 'zero', + TrueNull = 'true-null' +} + +// 用以标记原始值 +export const OriginalValueFlag = 'aeolus-null'; + +/** + * 特殊值 + */ +export const INVALID_VALUE_MAP = { + [SpecialValueType.BRACKET_TXT]: ' ', //放入一个空格显示真实空白 + [SpecialValueType.NULL]: 'NULL', + [SpecialValueType.ZERO]: '0', + [SpecialValueType.DASH]: '--', + [SpecialValueType.TrueNull]: OriginalValueFlag +}; diff --git a/packages/vstory-core/src/constants/index.ts b/packages/vstory-core/src/constants/index.ts new file mode 100644 index 00000000..c0cb000b --- /dev/null +++ b/packages/vstory-core/src/constants/index.ts @@ -0,0 +1 @@ +export const Constants = {}; diff --git a/packages/vstory-core/src/core/story.ts b/packages/vstory-core/src/core/story.ts index 75c76fc2..77fdca82 100644 --- a/packages/vstory-core/src/core/story.ts +++ b/packages/vstory-core/src/core/story.ts @@ -22,6 +22,7 @@ export interface IStoryInitOption { // 对画面的缩放 scaleX?: number | 'auto'; scaleY?: number | 'auto'; + theme?: string; } export class Story implements IStory { @@ -30,6 +31,7 @@ export class Story implements IStory { protected _dsl: IStoryDSL | null; protected _player: IPlayer; protected _characterTree: ICharacterTree; + protected _theme: string; get canvas(): IStoryCanvas { return this._canvas; @@ -39,6 +41,10 @@ export class Story implements IStory { return this._player; } + get theme(): string { + return this._theme; + } + constructor(dsl: IStoryDSL | null, option: IStoryInitOption) { this.id = `test-mvp_${Generator.GenAutoIncrementId()}`; const { @@ -46,6 +52,7 @@ export class Story implements IStory { canvas, width, height, + theme, background = 'transparent', layerBackground = 'transparent', dpr = vglobal.devicePixelRatio, @@ -68,6 +75,7 @@ export class Story implements IStory { }); this._characterTree = new CharacterTree(this); this._dsl = dsl; + this._theme = theme; } init(player: IPlayer) { diff --git a/packages/vstory-core/src/interface/character.ts b/packages/vstory-core/src/interface/character.ts index 3fa66eab..af989df9 100644 --- a/packages/vstory-core/src/interface/character.ts +++ b/packages/vstory-core/src/interface/character.ts @@ -22,6 +22,8 @@ export interface ICharacter extends IReleaseable { configProcess: IConfigProcess; // attributeProcess: IAttributeProcess; + theme: string; + init: () => void; reset: () => void; // 仅用于在action之前隐藏,在action之后显示 diff --git a/packages/vstory-core/src/interface/dsl/chart.ts b/packages/vstory-core/src/interface/dsl/chart.ts index b0899018..60c985f3 100644 --- a/packages/vstory-core/src/interface/dsl/chart.ts +++ b/packages/vstory-core/src/interface/dsl/chart.ts @@ -1,5 +1,7 @@ +import type { ITextGraphicAttribute } from '@visactor/vrender-core'; import type { IInitOption, ISpec } from '@visactor/vchart'; import type { ICharacterConfigBase } from './dsl'; +import type { IFormatConfig } from './common'; export const StroyAllDataGroup = '_STORY_ALL_DATA_GROUP'; @@ -9,19 +11,26 @@ export interface IComponentMatch { [key: string]: any; } -export interface IMarkStyle { +export type ITextAttribute = ITextGraphicAttribute; + +export interface IMarkStyle { seriesMatch: { type: string } & IComponentMatch; markName: string; - id: string; // 唯一id,避免单个元素有多个匹配样式 itemKeys: string[]; // 数据匹配维度 - itemKeyMap: { [key: string]: any }; // 匹配维度值 - style: any; // 样式 + itemKeyMap: { [key: string]: number }; // 匹配维度值 + style: T; // 样式 } export interface IDataGroupStyle { // markName , label 也在这里,需要 label runtime 处理 + label?: { + style?: IMarkStyle['style']; + formatConfig?: IFormatConfig; + visible?: boolean; // 是否可见 + [key: string]: any; // 其他可能存在的逻辑配置 + }; [key: string]: { - style?: IMarkStyle['style']; // markStyle + style?: IMarkStyle['style']; // markStyle visible?: boolean; // 是否可见 [key: string]: any; // 其他可能存在的逻辑配置 }; @@ -66,17 +75,19 @@ export interface IChartCharacterConfig extends ICharacterConfigBase { }; // series series?: { - [key in ModelSelector]: Partial>; + [key in ModelSelector]?: Partial>; }; // 色板 color?: any; // mark 单元素样式 markStyle?: { - [key: string]: IMarkStyle; + [key: string]: IMarkStyle; }; // label 单元素样式 与 mark 区分开,runtime逻辑完全不同 labelStyle?: { - [key: string]: IMarkStyle; + [key: string]: IMarkStyle & { + formatConfig?: IFormatConfig; + }; }; // 组样式配置 dataGroupStyle?: { diff --git a/packages/vstory-core/src/interface/dsl/common.ts b/packages/vstory-core/src/interface/dsl/common.ts new file mode 100644 index 00000000..ca76e535 --- /dev/null +++ b/packages/vstory-core/src/interface/dsl/common.ts @@ -0,0 +1,93 @@ +import type { FormatType, FormatTypeValue } from '../../constants/format'; + +export type Include = { + [K in keyof T]: T[K]; +} & { + [K in Exclude]: unknown; +}; + +export type FormatContentType = + | 'dimension' + | 'value' + | 'abs' + | 'percentage' + | 'value(percentage)' + | 'percentage(value)' + | 'CAGR' + | 'pp' + // | 'abs-pp' + | 'percentdiff' + | 'series' + | 'series(CAGR)' + | 'CAGR(series)' + | 'series(percentage)' + | 'percentage(series)' + | 'date' + | 'text' // scatter 使用,表示文本字段 + | 'x' // scatter 使用,表示 x 轴字段 + | 'y' // scatter 使用,表示 y 轴字段 + | 'size'; // scatter 使用,表示大小值; + +export type FormatDate = + | 'Auto' + | 'YMD' + | 'YMD_Slash' + | 'MDY' + | 'YMD_CN' + | 'YMD_CN_E' + | 'DMY_AbbrM' + | 'YMD_AbbrM' + | 'MDY_EN' + | 'MD_E_CN' + | 'MD' + | 'MD_CN' + | 'MD_AbbrM' + | 'MD_EN' + | 'Hm' + | 'HmA' + | 'Hms' + | 'HmsA' + | string; + +export interface IFormatConfig { + /** + * 前缀 + */ + prefix?: string; + /** + * 后缀 + */ + postfix?: string; + /** + * 单位 + */ + unit?: 'none' | 'auto' | 'CN_K' | 'CN_W' | 'CN_BW' | 'CN_QW' | 'CN_Y' | 'K' | 'M' | 'B'; + /** + * 小数点位置 + */ + fixed?: number | 'auto'; + /** + * 当前包含的内容 + */ + content?: FormatContentType | FormatContentType[]; + /** + * 标签内容是否换行展示 + */ + contentWrap?: boolean; + /** + * 是否显示千分位分隔符 + */ + separator?: boolean; + /** + * 格式化为哪种类型: + * 1. 'digit' 数值类型 + * 2. 'percent' 百分比 + * 3. 'permil' 千分比 + * 默认为 digit + */ + dataType?: FormatTypeValue; + /** + * 日期格式化形式 + */ + dateFormat?: FormatDate; +} diff --git a/packages/vstory-core/src/interface/dsl/dsl.ts b/packages/vstory-core/src/interface/dsl/dsl.ts index a09b1ebf..99e9aa4d 100644 --- a/packages/vstory-core/src/interface/dsl/dsl.ts +++ b/packages/vstory-core/src/interface/dsl/dsl.ts @@ -71,6 +71,7 @@ export interface ICharacterConfigBase { type: string; // 类型 position: IWidgetData; // 定位描述 zIndex: number; + theme?: string; extra?: any; // 带着的额外信息 } diff --git a/packages/vstory-core/src/interface/story.ts b/packages/vstory-core/src/interface/story.ts index fe13f900..f69508c4 100644 --- a/packages/vstory-core/src/interface/story.ts +++ b/packages/vstory-core/src/interface/story.ts @@ -13,6 +13,7 @@ export interface IStory extends IReleaseable { readonly id: string; readonly canvas: IStoryCanvas; readonly player: IPlayer; + readonly theme: string; load: (dsl: IStoryDSL) => void; reset: () => void; diff --git a/packages/vstory-core/src/theme/builtin/default.ts b/packages/vstory-core/src/theme/builtin/default.ts new file mode 100644 index 00000000..f3bc3832 --- /dev/null +++ b/packages/vstory-core/src/theme/builtin/default.ts @@ -0,0 +1,18 @@ +import { formatValue } from '../../character/common/utils/format'; +import type { ITheme } from '../interface'; + +export const DefaultTheme: { + name: string; + theme: ITheme; +} = { + name: 'default', + theme: { + character: { + VChart: { + runtime: { + formatValue: formatValue + } + } + } + } +}; diff --git a/packages/vstory-core/src/theme/index.ts b/packages/vstory-core/src/theme/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/vstory-core/src/theme/interface.ts b/packages/vstory-core/src/theme/interface.ts new file mode 100644 index 00000000..08eb7a1f --- /dev/null +++ b/packages/vstory-core/src/theme/interface.ts @@ -0,0 +1,35 @@ +import type { IChartCharacterConfig } from '../interface/dsl/chart'; +import type { IComponentCharacterConfig } from '../interface/dsl/component'; +import type { ICharacterConfigBase } from '../interface/dsl/dsl'; +import type { IPivotChartCharacterConfig, ITableCharacterConfig } from '../interface/dsl/table'; + +export interface IThemeCharacterBase { + runtime?: { + // 运行时配置 + // runtime type : runtime ability + [key: string]: any; + }; + position?: ICharacterConfigBase['position']; +} + +export interface IThemeCharacterChart extends IThemeCharacterBase { + options?: Omit; +} + +export interface IThemeCharacterComponent extends IThemeCharacterBase { + options?: IComponentCharacterConfig['options']; +} +export interface IThemeCharacterTable extends IThemeCharacterBase { + options?: ITableCharacterConfig['options']; +} + +export interface IThemeCharacterPivotChart extends IThemeCharacterBase { + options?: IPivotChartCharacterConfig['options']; +} + +export interface ITheme { + character?: { + // character type + [key: string]: IThemeCharacterChart | IThemeCharacterComponent | IThemeCharacterTable | IPivotChartCharacterConfig; + }; +} diff --git a/packages/vstory-core/src/theme/theme-manager.ts b/packages/vstory-core/src/theme/theme-manager.ts new file mode 100644 index 00000000..62736302 --- /dev/null +++ b/packages/vstory-core/src/theme/theme-manager.ts @@ -0,0 +1,110 @@ +import { array, isArray, isString } from '@visactor/vutils'; +import { DefaultTheme } from './builtin/default'; +import type { ITheme } from './interface'; +import { getThemeAttribute } from '../utils/theme'; + +export class ThemeManager { + /** 主题字典 */ + static readonly themes: Map = new Map(); + + private static _currentThemeName: string = DefaultTheme.name; // 设置缺省为默认主题 + + /** + * 注册主题 + * @param name 主题名称 + * @param theme 主题配置 + * @returns + */ + static registerTheme(name: string, theme: Partial) { + if (!name) { + return; + } + if (name === DefaultTheme.name) { + ThemeManager.themes.set(name, theme); + return; + } + ThemeManager.themes.set(name, { ...DefaultTheme.theme, ...theme }); + } + + /** + * 获取主题 + * @param name 主题名称 + * @returns + */ + static getTheme(name: string) { + const theme = ThemeManager.themes.get(name); + if (!theme) { + return DefaultTheme.theme; + } + return theme; + } + + /** + * 移除主题 + * @param name 主题名称 + * @returns 是否移除成功 + */ + static removeTheme(name: string): boolean { + if (!name) { + return false; + } + if (name === DefaultTheme.name) { + return false; + } + return ThemeManager.themes.delete(name); + } + + /** + * 判断主题是否存在 + * @param name 主题名称 + * @returns 是否存在 + */ + static themeExist(name: string): boolean { + return ThemeManager.themes.has(name); + } + + /** 获取图表默认主题(非用户配置) */ + static getDefaultTheme(): ITheme { + return ThemeManager.themes.get(DefaultTheme.name); + } + + /** 设置当前主题(所有实例生效) */ + static setCurrentTheme(name: string) { + if (!ThemeManager.themeExist(name)) { + return; + } + ThemeManager._currentThemeName = name; + } + + /** 获取当前主题(只能获取用户通过`setCurrentTheme`方法设置过的主题,默认值为默认主题) */ + static getCurrentTheme(): ITheme { + return ThemeManager.getTheme(ThemeManager._currentThemeName); + } + + /** 获取当前主题名称(只能获取用户通过`setCurrentTheme`方法设置过的主题,默认值为默认主题) */ + static getCurrentThemeName(): string { + return ThemeManager._currentThemeName; + } + + static getAttribute(keys: string | string[], path: string | string[]): any; + static getAttribute(keys: string | string[], attribute: (theme: ITheme) => any): any; + static getAttribute(keys: string | string[], params: string | string[] | ((theme: ITheme) => any)): any { + keys = array(keys); + let attribute; + if (isString(params) || isArray(params)) { + attribute = (theme: ITheme) => getThemeAttribute(theme, params); + } else { + attribute = params; + } + let att = null; + for (let i = 0; i < keys.length; i++) { + att = attribute(ThemeManager.themes.get(keys[i])); + if (att) { + return att; + } + } + return attribute(ThemeManager.getCurrentTheme()) ?? attribute(ThemeManager.getTheme(DefaultTheme.name)); + } +} + +ThemeManager.registerTheme(DefaultTheme.name, DefaultTheme.theme); diff --git a/packages/vstory-core/src/utils/theme.ts b/packages/vstory-core/src/utils/theme.ts new file mode 100644 index 00000000..2ceeb159 --- /dev/null +++ b/packages/vstory-core/src/utils/theme.ts @@ -0,0 +1,17 @@ +import type { Dict } from '@visactor/vutils'; +import { isString } from '@visactor/vutils'; + +export function getThemeAttribute(obj: Dict, path: string | string[]): any { + if (!obj) { + return undefined; + } + const paths = isString(path) ? (path as string).split('.') : path; + + for (let p = 0; p < paths.length; p++) { + obj = obj ? obj[paths[p]] : undefined; + if (!obj) { + return undefined; + } + } + return obj; +} diff --git a/packages/vstory-core/src/utils/type.ts b/packages/vstory-core/src/utils/type.ts index 96ce4491..0cdb811e 100644 --- a/packages/vstory-core/src/utils/type.ts +++ b/packages/vstory-core/src/utils/type.ts @@ -1,4 +1,4 @@ -import { isString, isValidNumber } from '@visactor/vutils'; +import { isNumber, isString, isValidNumber } from '@visactor/vutils'; import type { ModelSelector } from '../interface/dsl/chart'; export function isIDSelector(value: ModelSelector): value is `#${string}` { @@ -8,3 +8,7 @@ export function isIDSelector(value: ModelSelector): value is `#${string}` { export function isSpecIndexSelector(value: ModelSelector): value is number | `${number}` { return isValidNumber(+value); } + +export function validNumber(value: any) { + return isValidNumber(value) ? value : null; +} diff --git a/packages/vstory-player/src/processor/chart/visibility.ts b/packages/vstory-player/src/processor/chart/visibility.ts index f5f7913d..09e2cf0f 100644 --- a/packages/vstory-player/src/processor/chart/visibility.ts +++ b/packages/vstory-player/src/processor/chart/visibility.ts @@ -166,7 +166,6 @@ export class VChartVisibilityActionProcessor extends VChartBaseActionProcessor { if (!axis) { return; } - // debugger; vrenderComponents.forEach(c => { if (isRun) { (c.attribute as any).visibleAll = true; diff --git a/packages/vstory/demo/src/demos/chart/runtime/label-style.tsx b/packages/vstory/demo/src/demos/chart/runtime/label-style.tsx index 273e95ab..de507407 100644 --- a/packages/vstory/demo/src/demos/chart/runtime/label-style.tsx +++ b/packages/vstory/demo/src/demos/chart/runtime/label-style.tsx @@ -420,6 +420,9 @@ function loadDSL() { [StroyAllDataGroup]: { label: { // visible: true, + formatConfig: { + prefix: 'asd' + }, style: { fill: 'green', stroke: 'yellow', @@ -429,21 +432,42 @@ function loadDSL() { }, a: { label: { - // visible: true, + visible: true, + formatConfig: { + dataType: 'digit', + content: ['value', 'dimension', 'abs', 'percentage'], + fixed: 2, + postfix: ['bb'], + prefix: ['aa'] + }, style: { - fill: 'red' + fill: 'yellow' } } }, b: { label: { - // visible: true, + visible: true, style: { stroke: 'blue', lineWidth: 5 } } } + }, + labelStyle: { + '_editor_dimension_field_1_&_editor_type_field_1': { + markName: 'label', + seriesMatch: { type: 'bar' }, + itemKeys: ['_editor_dimension_field', '_editor_type_field'], + itemKeyMap: { + _editor_dimension_field: 1, + _editor_type_field: 2 + }, + style: { + fill: 'red' + } + } } } } diff --git a/packages/vstory/demo/src/demos/chart/runtime/series-mark.tsx b/packages/vstory/demo/src/demos/chart/runtime/series-mark.tsx index cea20b3c..4020dc1f 100644 --- a/packages/vstory/demo/src/demos/chart/runtime/series-mark.tsx +++ b/packages/vstory/demo/src/demos/chart/runtime/series-mark.tsx @@ -485,6 +485,23 @@ function loadDSL() { } } } + }, + // 'type', 'range', 'type2' + markStyle: { + '_type_1_&_range_1_&_type2_1_&_color_1': { + markName: 'bar', + seriesMatch: { type: 'bar' }, + itemKeys: ['type', 'range', 'type2', 'color'], + itemKeyMap: { + type: 1, + range: 1, + type2: 1, + color: 1 + }, + style: { + fill: 'black' + } + } } } } diff --git a/packages/vstory/demo/src/demos/table/runtime/pivot-chart-base.tsx b/packages/vstory/demo/src/demos/table/runtime/pivot-chart-base.tsx index 0809577b..1c66e3b6 100644 --- a/packages/vstory/demo/src/demos/table/runtime/pivot-chart-base.tsx +++ b/packages/vstory/demo/src/demos/table/runtime/pivot-chart-base.tsx @@ -50,14 +50,11 @@ function loadDSL() { row: 2, options: { series: { - a: { - specIndex: 0, - spec: { - // @ts-ignore - bar: { - style: { - cornerRadius: 20 - } + 0: { + bar: { + style: { + stroke: 'black', + lineWidth: 4 } } } @@ -99,14 +96,10 @@ function loadDSL() { row: 2, options: { series: { - a: { - specIndex: 0, - spec: { - // @ts-ignore - bar: { - style: { - cornerRadius: 20 - } + 0: { + bar: { + style: { + cornerRadius: 5 } } } From 95d82c714dac0b6c6252bdebf6e38ad322c34ddc Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 7 Jan 2025 10:44:50 +0800 Subject: [PATCH 2/4] fix: change validNumber string check --- packages/vstory-core/src/character/common/utils/format.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/vstory-core/src/character/common/utils/format.ts b/packages/vstory-core/src/character/common/utils/format.ts index 1504a778..90ef7e6c 100644 --- a/packages/vstory-core/src/character/common/utils/format.ts +++ b/packages/vstory-core/src/character/common/utils/format.ts @@ -2,8 +2,7 @@ import type { FormatContentType, IFormatConfig } from '../../../interface/dsl/co import type { Unit } from '../../../constants/format'; import { DataFormatUnit, unionContentTypeMap } from '../../../constants/format'; import { isArray, isNil, isString } from '@visactor/vutils/es/common'; -import { couldBeValidNumber } from '@visactor/vchart/esm/util'; -import { getTimeFormatter } from '@visactor/vutils'; +import { getTimeFormatter, isValidNumber } from '@visactor/vutils'; export type FormatValueFunction = ( content: FormatContentType, @@ -92,7 +91,7 @@ export function formatNumber( percentage?: boolean ): string | number { // 字符串类型的处理 - if (value && isString(value) && !couldBeValidNumber(value)) { + if (value && isString(value) && !isValidNumber(+value)) { return value; } From 95b5b3606e41763f9676bf5772d8d7d3d34ac549 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 7 Jan 2025 20:03:17 +0800 Subject: [PATCH 3/4] feat: support totalLabel --- .../src/character/chart/character-chart.ts | 4 +- .../character/chart/runtime/total-label.ts | 237 +++++++++++ .../vstory-core/src/constants/character.ts | 4 + packages/vstory-core/src/constants/config.ts | 2 + .../vstory-core/src/interface/dsl/chart.ts | 80 +++- packages/vstory/demo/src/App.tsx | 9 +- .../src/demos/chart/runtime/total-label.tsx | 374 ++++++++++++++++++ 7 files changed, 693 insertions(+), 17 deletions(-) create mode 100644 packages/vstory-core/src/character/chart/runtime/total-label.ts create mode 100644 packages/vstory/demo/src/demos/chart/runtime/total-label.tsx diff --git a/packages/vstory-core/src/character/chart/character-chart.ts b/packages/vstory-core/src/character/chart/character-chart.ts index 21ea3eed..0f8ff3ac 100644 --- a/packages/vstory-core/src/character/chart/character-chart.ts +++ b/packages/vstory-core/src/character/chart/character-chart.ts @@ -18,6 +18,7 @@ import type { IComponent, ISeries, IVChart } from '@visactor/vchart'; import { MarkStyleRuntimeInstance } from './runtime/mark-style'; import { LabelStyleRuntimeInstance } from './runtime/label-style'; import { isArray } from '@visactor/vutils'; +import { TotalLabelRuntimeInstance } from './runtime/total-label'; export class CharacterChart extends CharacterBase @@ -188,7 +189,8 @@ export class CharacterChart CommonSpecRuntimeInstance, CommonLayoutRuntimeInstance, MarkStyleRuntimeInstance, - LabelStyleRuntimeInstance + LabelStyleRuntimeInstance, + TotalLabelRuntimeInstance ); } protected _clearRuntime(): void { diff --git a/packages/vstory-core/src/character/chart/runtime/total-label.ts b/packages/vstory-core/src/character/chart/runtime/total-label.ts new file mode 100644 index 00000000..eb004425 --- /dev/null +++ b/packages/vstory-core/src/character/chart/runtime/total-label.ts @@ -0,0 +1,237 @@ +import { ThemeManager } from './../../../theme/theme-manager'; +import { array, isValid, merge, isValidNumber } from '@visactor/vutils'; +import type { IChartCharacterRuntime } from '../interface/runtime'; +import type { ICharacterChart } from '../interface/character-chart'; +import type { IVChart } from '@visactor/vchart'; +import { type ISeries, PREFIX } from '@visactor/vchart'; +import type { ITotalLabelConfig } from '../../../interface/dsl/chart'; +import type { FormatContentType, IFormatConfig } from '../../../interface/dsl/common'; +import type { FormatValueFunction } from '../../common/utils/format'; +import { getTextWithFormat } from '../../common/utils/format'; +import { VSTORY_PREFIX } from '../../../constants/config'; +import { getSeriesKeyScalesMap, matchDatumWithScaleMap } from './utils'; +import type { IText } from '@visactor/vrender-core'; +import { SeriesAxisOrientKey } from '../../../constants/character'; + +const totalLabelTempValueKey = `${VSTORY_PREFIX}_totalLabel`; + +// 这里的到的 stackValue 与 vchart 中并不一致 +// 直角坐标系下 vchart series 的默认 stackValue 使用了对应的轴 Id 这个轴Id在spec生成阶段无法获取搭配 +// vchart 直角坐标系下默认 stackValue = `${PREFIX}_series_${this.type}_${axisId}` +export function getStackValueFromSeriesSpec(seriesSpec: any) { + if (seriesSpec.stackValue) { + return seriesSpec.stackValue; + } + return `${PREFIX}_series_${seriesSpec.type}_${seriesSpec[SeriesAxisOrientKey]}`; +} + +export class TotalLabelRuntime implements IChartCharacterRuntime { + type = 'TotalLabel'; + + applyConfigToAttribute(character: ICharacterChart) { + // 将总计标签的 visible 配置和组样式设置到 spec 上 + // 设置 visible 为 true 关闭标签能力放到分组上 + // 当前 dataGroupStyle 中有 label.visible 配置,在这里添加上 visible = true + const config = character.getRuntimeConfig().config; + const totalLabel = config.options?.totalLabel; + if (!totalLabel) { + return; + } + const rawAttribute = character.getRuntimeConfig().getAttribute(); + const { spec } = rawAttribute; + + const formatValue = ThemeManager.getAttribute( + [character.theme, character.story.theme], + 'character.VChart.runtime.formatValue' + ); + + if (spec.series) { + // 符合配置的系列都要设置 totalLabel ,因为一组 totalLabel 可能是多个不同系列的 totalLabel 组成的 + spec.series?.forEach((s: any) => { + const stackValue = getStackValueFromSeriesSpec(s); + if (!totalLabel[stackValue]) { + return; + } + this._mergeTotalLabelConfigToSpec(s, totalLabel[stackValue], formatValue); + }); + } else { + // 如果 spec 中没有 series + // 如果 spec 上有stackValue + if (spec.stackValue) { + if (!totalLabel[spec.stackValue]) { + return; + } + this._mergeTotalLabelConfigToSpec(spec, totalLabel[spec.stackValue], formatValue); + } else { + // 如果 spec 上没有, 那么将 totalLabel 中的第一个key作为stackValue 设置到 spec 上 + const stackValue = Object.keys(totalLabel)[0]; + if (!isValid(stackValue)) { + return; + } + spec.stackValue = stackValue; + this._mergeTotalLabelConfigToSpec(spec, totalLabel[stackValue], formatValue); + } + } + } + + private _mergeTotalLabelConfigToSpec( + spec: any, + totalLabelConfig: ITotalLabelConfig, + formatValue: FormatValueFunction + ) { + spec.totalLabel = spec.totalLabel ?? {}; + if (totalLabelConfig.visible) { + spec.totalLabel.visible = totalLabelConfig.visible; + } + if (totalLabelConfig.style) { + spec.totalLabel.style = spec.totalLabel.style ?? {}; + merge(spec.totalLabel.style, totalLabelConfig.style); + } + this._doFormat(totalLabelConfig, spec, formatValue); + } + + private _doFormat(totalLabelConfig: ITotalLabelConfig, spec: any, formatValue: FormatValueFunction) { + if (!totalLabelConfig.formatConfig && !totalLabelConfig.single) { + return; + } + spec.totalLabel.formatMethod = (value: number, datum: any, ctx: { series: ISeries }) => { + const keyScaleMap = getSeriesKeyScalesMap(ctx.series); + let result: number | string | string[] = value; + // 先进行单个匹配 + // 在这里处理可以避免放重叠开启后无法正常躲避 + if ( + Object.keys(totalLabelConfig.single).some(k => { + const config = totalLabelConfig.single[k]; + config.itemKeys; + if (matchDatumWithScaleMap(config.itemKeys, config.itemKeyMap, keyScaleMap, datum)) { + // 匹配成功 设置结果 + result = getLabelTextWithFormat(value, datum, ctx.series, config.formatConfig, formatValue); + return true; + } + return false; + }) + ) { + // 返回单个匹配结果 + return result; + } + // 整组匹配 + return getLabelTextWithFormat(value, datum, ctx.series, totalLabelConfig.formatConfig, formatValue); + }; + } + + /** + * 处理单个总计标签样式 + * @param character + * @param vchart + * @returns + */ + beforeVRenderDraw(character: ICharacterChart, vchart: IVChart) { + const config = character.getRuntimeConfig().config; + const totalLabel = config.options?.totalLabel; + if (!totalLabel) { + return; + } + if (Object.values(totalLabel).every(v => !v.single)) { + return; + } + + const components = vchart.getChart().getComponentsByKey('totalLabel'); + components.forEach(component => { + // @ts-ignore + const series = component._getSeries(); + const totalLabelConfig = totalLabel[getStackValueFromSeriesSpec(series.getSpec())]; + if (!totalLabelConfig?.single) { + return; + } + const keyScaleMap = getSeriesKeyScalesMap(series); + component.getVRenderComponents().forEach(dataLabel => { + dataLabel.getElementsByName('label').forEach(label => { + (label.getElementsByType('text') as IText[]).forEach(text => { + Object.values(totalLabelConfig.single).forEach(singleConfig => { + if ( + matchDatumWithScaleMap( + singleConfig.itemKeys, + singleConfig.itemKeyMap, + keyScaleMap, + (text.attribute as any).data + ) + ) { + text.setAttributes(singleConfig.style); + } + }); + }); + }); + }); + }); + return; + } +} + +// 得到标签经过 format 处理后的值 +function getLabelTextWithFormat( + value: number, + datum: any, + series: ISeries, + formatConfig: IFormatConfig, + formatValue: FormatValueFunction +) { + const opt = { + value, + datum, + series + }; + return getTextWithFormat(formatConfig, array(formatConfig.content), getTotalContentValue, formatValue, opt); +} + +function getTotalContentValue( + { + value, + datum, + series + }: { + value: number; + datum: any; + series: ISeries; + }, + content: FormatContentType +) { + const dimensionField = series.getDimensionField()[0]; + switch (content) { + case 'dimension': + return datum[dimensionField]; + case 'percentage': + return computeTotalPercentage(value, series); + case 'value': + default: + return value; + } +} + +// 计算系列百分比 +function computeTotalPercentage(value: any, series: ISeries) { + const chart = series.getChart(); + // 先获取全部值总合 + let totalValue = 1; + if (isValidNumber(chart.getSpec()[totalLabelTempValueKey])) { + totalValue = chart.getSpec()[totalLabelTempValueKey]; + } else { + totalValue = series + .getChart() + .getAllSeries() + .reduce((totalValue, series) => { + const data: any[] = series.getViewData().latestData; + const measureField = series.getMeasureField()[0]; + const seriesTotalValue = data.reduce((sum: number, d: any) => { + return sum + Number.parseFloat(d[measureField]); + }, 0); + return totalValue + seriesTotalValue; + }, 0); + chart.getSpec()[totalLabelTempValueKey] = totalValue; + } + if (totalValue === 0) { + return 0; + } + return (value / totalValue) * 100; +} + +export const TotalLabelRuntimeInstance = new TotalLabelRuntime(); diff --git a/packages/vstory-core/src/constants/character.ts b/packages/vstory-core/src/constants/character.ts index 59f55b99..2a6186c4 100644 --- a/packages/vstory-core/src/constants/character.ts +++ b/packages/vstory-core/src/constants/character.ts @@ -1,3 +1,5 @@ +import { VSTORY_PREFIX } from './config'; + export const enum CharacterType { VCHART = 'VChart', VTABLE = 'VTable', @@ -20,3 +22,5 @@ export const enum CharacterType { // 通用的类型,一般在查找effect的时候所有类型都可以匹配 COMMON = 'Common' } + +export const SeriesAxisOrientKey = `${VSTORY_PREFIX}_seriesAxisOrient`; diff --git a/packages/vstory-core/src/constants/config.ts b/packages/vstory-core/src/constants/config.ts index 8becc842..8353c2d4 100644 --- a/packages/vstory-core/src/constants/config.ts +++ b/packages/vstory-core/src/constants/config.ts @@ -1,2 +1,4 @@ // 设置属性的时候,如果是DeletedAttr,则表示删除该属性 export const DeletedAttr = Symbol('DeletedAttr'); + +export const VSTORY_PREFIX = '__VSTORY'; diff --git a/packages/vstory-core/src/interface/dsl/chart.ts b/packages/vstory-core/src/interface/dsl/chart.ts index 60c985f3..d53576f7 100644 --- a/packages/vstory-core/src/interface/dsl/chart.ts +++ b/packages/vstory-core/src/interface/dsl/chart.ts @@ -49,52 +49,104 @@ export type ModelSelector = number | `${number}` | '*' | `#${string}`; // 定义一个类型辅助工具来提取非数组类型 type ElementType = T extends (infer U)[] ? U : T; +export interface ITotalLabelConfig { + visible?: boolean; + style?: ITextAttribute; + formatConfig?: IFormatConfig; + single?: { + // 使用 维度key_维度值_&_维度key_维度值 这样的格式构建key,保证唯一性 + [key: string]: { + itemKeys: string[]; // 数据匹配维度 + itemKeyMap: { [key: string]: number }; // 匹配维度值 + formatConfig?: IFormatConfig; + style?: ITextAttribute; + }; + }; +} + export interface IChartCharacterConfig extends ICharacterConfigBase { options: { - // 图表spec + /** + * 图表spec + */ spec?: any; - // 初始化参数 + /** + * 初始化参数 + */ initOption?: IInitOption & IChartCharacterInitOption; - // 边距 + /** + * 边距 + */ padding?: { left: number; top: number; right: number; bottom: number }; - // 图表容器 + /** + * 图表容器 + */ panel?: any; - // 数据源 + /** + * 数据源 + */ data?: any; - // 标题 + /** + * 标题 + */ title?: { [key in ModelSelector]: Partial>; }; - // 图例 + /** + * 图例 + */ legends?: { [key in ModelSelector]: Partial>; }; - // axes + /** + * axes + */ axes?: { [key in ModelSelector]: Partial>; }; - // series + /** + * series + */ series?: { [key in ModelSelector]?: Partial>; }; - // 色板 + /** + * 色板 + */ color?: any; - // mark 单元素样式 + /** + * mark 单元素样式 + */ markStyle?: { [key: string]: IMarkStyle; }; - // label 单元素样式 与 mark 区分开,runtime逻辑完全不同 + /** + * label 单元素样式 与 mark 区分开,runtime逻辑完全不同 + */ labelStyle?: { [key: string]: IMarkStyle & { formatConfig?: IFormatConfig; }; }; - // 组样式配置 + /** + * 总计标签 + */ + totalLabel: { + // 以 `组` 为单位配置。组的 key 对应 vchart.series.stackValue + // 默认情况下 vchart 中 stackValue = `${PREFIX}_series_${series.type}` + // 直角坐标系下的系列 stackValue = `${PREFIX}_series_${this.type}_${axisId}` + [key: string]: ITotalLabelConfig; + }; + /** + * 组样式配置 + */ dataGroupStyle?: { [StroyAllDataGroup]: IDataGroupStyle; // 全部分组的样式 [key: string]: IDataGroupStyle; // 某一组 }; - // 直接合并的配置 + /** + * 直接合并的配置 + */ rootConfig?: Record; }; } diff --git a/packages/vstory/demo/src/App.tsx b/packages/vstory/demo/src/App.tsx index 44bb91f3..8ce7eab8 100644 --- a/packages/vstory/demo/src/App.tsx +++ b/packages/vstory/demo/src/App.tsx @@ -59,6 +59,7 @@ import { RowHeight } from './demos/table/runtime/row-height'; import { PivotChartBase } from './demos/table/runtime/pivot-chart-base'; import { TextComponent } from './demos/component/text'; import { SpecAxes } from './demos/chart/runtime/spec-axes'; +import { RuntimeTotalLabel } from './demos/chart/runtime/total-label'; type MenusType = ( | { @@ -309,6 +310,10 @@ const App = () => { { name: 'ChartRuntime', subMenus: [ + { + name: 'Common Spec Axes', + component: SpecAxes + }, { name: 'Series Mark', component: RuntimeSeriesMark @@ -318,8 +323,8 @@ const App = () => { component: RuntimeLabelStyle }, { - name: 'Common Spec Axes', - component: SpecAxes + name: 'Total Label', + component: RuntimeTotalLabel } ] }, diff --git a/packages/vstory/demo/src/demos/chart/runtime/total-label.tsx b/packages/vstory/demo/src/demos/chart/runtime/total-label.tsx new file mode 100644 index 00000000..01a95533 --- /dev/null +++ b/packages/vstory/demo/src/demos/chart/runtime/total-label.tsx @@ -0,0 +1,374 @@ +import React, { useEffect } from 'react'; +import { + Player, + Story, + initVR, + registerGraphics, + registerCharacters, + IStoryDSL +} from '../../../../../../vstory-core/src'; +import { registerVComponentAction, registerVChartAction } from '../../../../../../vstory-player/src'; +import { StroyAllDataGroup } from '../../../../../../vstory-core/src/interface/dsl/chart'; + +registerGraphics(); +registerCharacters(); +registerVChartAction(); +registerVComponentAction(); +initVR(); + +function loadDSL() { + const barSpec0 = { + direction: 'vertical', + type: 'common', + color: ['#00295C', '#2568BD', '#9F9F9F', '#C5C5C5', '#00B0F0', '#4BCFFF', '#C2C2C2', '#D7D7D7'], + series: [ + { + type: 'bar', + stack: true, + direction: 'vertical', + bar: { + style: { + stroke: '', + lineWidth: 1 + }, + state: { + hover: { + stroke: '#000', + lineWidth: 1 + } + } + }, + barBackground: { + style: { + stroke: '', + lineWidth: 1 + } + }, + xField: '_editor_dimension_field', + yField: '_editor_value_field', + dataId: '0', + id: 'series-0', + EDITOR_SERIES_DATA_KEY: 'a', + seriesField: '_editor_type_field' + }, + { + type: 'bar', + stack: true, + direction: 'vertical', + bar: { + style: { + stroke: '', + lineWidth: 1 + }, + state: { + hover: { + stroke: '#000', + lineWidth: 1 + } + } + }, + barBackground: { + style: { + stroke: '', + lineWidth: 1 + } + }, + xField: '_editor_dimension_field', + yField: '_editor_value_field', + dataId: '1', + id: 'series-1', + EDITOR_SERIES_DATA_KEY: 'b', + seriesField: '_editor_type_field' + } + ], + legends: { + id: 'legend-discrete', + visible: false, + autoPage: false, + position: 'start', + interactive: false, + item: { + label: { + style: { + fill: '#1F2329', + fontSize: 16 + } + } + }, + _originalVisible: false + }, + region: [ + { + id: 'region-0' + } + ], + tooltip: { + visible: true, + mark: { + content: [{}], + title: {} + }, + dimension: { + content: [{}], + title: {} + } + }, + axes: [ + { + orient: 'left', + id: 'axis-left', + type: 'linear', + label: { + autoLimit: false, + style: { + fill: '#1F2329', + fontSize: 16 + }, + formatConfig: {} + }, + domainLine: { + visible: true, + style: { + stroke: '#000000' + } + }, + tick: { + visible: true, + style: { + stroke: '#000000' + } + }, + grid: { + visible: false, + style: { + stroke: '#bbbfc4' + } + }, + autoIndent: false, + maxWidth: null, + maxHeight: null + }, + { + orient: 'bottom', + id: 'axis-bottom', + type: 'band', + label: { + autoLimit: false, + style: { + fill: '#1F2329', + fontSize: 16 + }, + formatConfig: {} + }, + domainLine: { + visible: true, + style: { + stroke: '#000000' + }, + onZero: true + }, + tick: { + visible: true, + style: { + stroke: '#000000' + } + }, + grid: { + visible: false, + style: { + stroke: '#bbbfc4' + } + }, + autoIndent: false, + maxWidth: null, + maxHeight: null, + trimPadding: false, + paddingInner: [0.2, 0], + paddingOuter: [0.2, 0] + } + ], + data: [ + { + id: '0', + sourceKey: 'a', + values: [ + { + _editor_dimension_field: 'x1', + _editor_value_field: 20, + _editor_type_field: 'a' + }, + { + _editor_dimension_field: 'x2', + _editor_value_field: 23, + _editor_type_field: 'a' + }, + { + _editor_dimension_field: 'x3', + _editor_value_field: 26, + _editor_type_field: 'a' + } + ], + specField: { + _editor_dimension_field: { + type: 'dimension', + order: 0 + }, + _editor_type_field: { + type: 'series', + order: 0 + }, + _editor_value_field: { + type: 'value', + order: 0 + } + } + }, + { + id: '1', + sourceKey: 'b', + values: [ + { + _editor_dimension_field: 'x1', + _editor_value_field: 20, + _editor_type_field: 'b' + }, + { + _editor_dimension_field: 'x2', + _editor_value_field: 24, + _editor_type_field: 'b' + }, + { + _editor_dimension_field: 'x3', + _editor_value_field: 29, + _editor_type_field: 'b' + } + ], + specField: { + _editor_dimension_field: { + type: 'dimension', + order: 0 + }, + _editor_type_field: { + type: 'series', + order: 0 + }, + _editor_value_field: { + type: 'value', + order: 0 + } + } + } + ], + labelLayout: 'region' + }; + const dsl: IStoryDSL = { + characters: [ + { + type: 'VChart', + id: 'chart0', + zIndex: 10, + position: { + top: 50, + left: 50, + width: 500, + height: 300 + }, + options: { + spec: barSpec0, + initOption: { + interactive: true, + animation: false, + disableTriggerEvent: true, + disableDirtyBounds: true + }, + totalLabel: { + __VCHART_series_bar_undefined: { + visible: true, + style: { + fill: 'red', + stroke: 'black', + lineWidth: 2 + }, + formatConfig: { + content: ['dimension', 'value', 'percentage'], + prefix: 'a', + postfix: 'b', + fixed: 0 + }, + single: { + a: { + itemKeys: ['_editor_dimension_field'], + itemKeyMap: { + _editor_dimension_field: 2 + }, + formatConfig: { + content: ['dimension', 'value'], + prefix: 's', + postfix: 'l', + fixed: 1 + }, + style: { + fill: 'blue' + } + } + } + } + } + } + } + ], + acts: [] + }; + dsl.acts = [ + { + id: 'defaultAct', + scenes: [ + { + id: 'defaultScene', + actions: [ + { + characterId: dsl.characters.map(i => i.id), + characterActions: [ + { + action: 'appear' + } + ] + } + ] + } + ] + } + ]; + return dsl; +} + +export const RuntimeTotalLabel = () => { + const id = 'RuntimeTotalLabel'; + + useEffect(() => { + const container = document.getElementById(id); + const canvas = document.createElement('canvas'); + container?.appendChild(canvas); + + const story = new Story(null, { canvas, width: 800, height: 800, background: 'pink' }); + const player = new Player(story); + story.init(player); + // @ts-ignore + window.story = story; + // @ts-ignore + window.player = player; + const dsl = loadDSL(); + story.load(dsl); + player.play(-1); + + const chart0 = story.getCharacterById('chart0'); + // @ts-ignore + window.chart0 = chart0; + + return () => { + story.release(); + }; + }, []); + + return
; +}; From 87bb8db4bc478318067801b0a4b4a4ecbe9461ad Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Wed, 8 Jan 2025 11:30:56 +0800 Subject: [PATCH 4/4] feat: fix type error for build --- .../src/character/config-transform/config-process.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vstory-core/src/character/config-transform/config-process.ts b/packages/vstory-core/src/character/config-transform/config-process.ts index 148612de..8de19d29 100644 --- a/packages/vstory-core/src/character/config-transform/config-process.ts +++ b/packages/vstory-core/src/character/config-transform/config-process.ts @@ -44,7 +44,10 @@ export class ConfigProcessBase implements IConfigProcess { targetConfig.zIndex = zIndex; } if (options) { - targetConfig.options = deepMergeWithDeletedAttr(targetConfig.options ?? {}, options); + targetConfig.options = deepMergeWithDeletedAttr( + targetConfig.options ?? ({} as typeof targetConfig.options), + options + ); } return true; }