diff --git a/.agents/skills/development/SKILL.md b/.agents/skills/development/SKILL.md index 2e7056a3cf..18152c04cd 100644 --- a/.agents/skills/development/SKILL.md +++ b/.agents/skills/development/SKILL.md @@ -6,107 +6,88 @@ description: > updates, full package tests, fresh coverage, and website acceptance testing. --- -# VBI Development +# Development -Read [Software Entropy Control](references/software-entropy.md) for ownership, -source-of-truth decisions, maintainability, refactoring, and deletion rules. -It is the only supporting reference for this skill. +Read [Software Entropy Control](references/software-entropy.md) for shared +ownership, source-of-truth, refactoring, and deletion rules. Module conventions +below apply to their owning packages and integrations. -## Required Task Completion - -Before finishing every task: - -1. Identify all changed packages and affected consumers. Read their - `package.json` scripts and test configuration to determine the actual - generation, full-test, and coverage commands. -2. Update relevant documentation, examples, and test cases to match the final - behavior. Cover changed behavior and regressions; remove obsolete cases and - references. Change the owning source or generator for generated artifacts. -3. Run the `g` script for each affected package to refresh generated docs, - examples, tests, and other outputs. Use `pnpm run g` from the repository root - when repository-wide generation is required. Inspect the generated diff, - including any updated snapshots, for correctness. -4. After generation and all edits, run the complete test suite for every - affected package and generate fresh coverage for the final state. Focused - tests, cached results, and existing coverage reports do not satisfy this - gate. A full-suite coverage run may satisfy both requirements if it includes - every test suite; run any omitted suites separately. -5. Review test failures, coverage gaps in changed behavior, and generated - outputs. Fix task-related issues, update relevant tests and docs, then rerun - generation and validation for the affected scope. Do not lower coverage - thresholds or exclude changed code to make validation pass. - Apply the package coverage requirements below. -6. Start the website with `pnpm dev` from the repository root and perform - browser acceptance testing against the running website. Follow the workflow - below; a successful build or passing unit tests alone do not satisfy this gate. -7. Report the packages checked, generation and full-test results, fresh coverage - summaries and report locations, and website acceptance results (URL, pages, - interactions, and outcome). Explicitly report missing scripts, unavailable - coverage support, or blocked commands and their reasons; do not silently skip - them or claim incomplete validation passed. +## Repository-wide Coverage -Typical package commands, adjusted to the scripts actually defined: +- Unit-test coverage must **never decrease** in statements, branches, functions, + or lines for any affected package. Before changing code or tooling, record a + fresh unit-only baseline; after generation and edits, rerun with the same + source scope and compare each metric per package. Full-suite coverage does + not replace unit-only coverage. +- Keep providers and inclusion/exclusion rules comparable; explicitly review + instrumentation changes. Do not remove files, ignore branches, or lower + thresholds to pass. Raise committed thresholds when coverage improves. +- `@visactor/vbi` and `@visactor/vquery` retain **100%** coverage and thresholds + in all four metrics. +- Coverage runs must not update snapshots or open a browser. Review behavior + changes before explicitly updating snapshots. Report before/after percentages, + report locations, and unresolved gaps; do not claim incomplete checks passed. -```bash -pnpm --filter run g -pnpm --filter run test -pnpm --filter run test:coverage -``` +## VBI Module (`packages/vbi`) -If a package has no `g` script, report generation as not applicable. If it has no -coverage script, use the configured test runner's coverage command when -supported; otherwise report the missing coverage setup. +- VBIChartDSL, VQueryDSL, and VSeedDSL remain the sources of truth for their + respective domains. State that must be saved, restored, or reused belongs in + the owning DSL; Builders own domain operations and DSL consistency. +- Adapters translate at integration boundaries; UI owns rendering and transient + state. Consumers use public APIs, and capabilities must remain usable without UI. +- Keep domain terminology consistent. Practices stay independent; move shared + capabilities into their owning packages or local utilities. -## Website Development and Acceptance Testing +## VSeed Module (`packages/vseed`) -Run these commands from the repository root. Use the Node.js and pnpm versions -declared in the root `package.json`; on a fresh checkout or after dependency -changes, install dependencies first: +- Keep each pipe atomic and focused on one responsibility. Do not branch on + chart type inside a pipe; select and compose pipes or inject strategies when + assembling the pipeline. +- Preserve the pipeline design philosophy: concise code, high cohesion, low + coupling, and composition over special cases. Extend the smallest owning pipe + or shared mechanism; avoid duplicated logic and unnecessary abstractions. +- Assess the impact on shared pipes, chart families, and downstream consumers + before changing behavior. Preserve existing DSL/API semantics, defaults, + rendering, and interactions; **no breaking changes**. Cover affected existing + configurations with regression tests and visual acceptance, not only new cases. -```bash -pnpm install --frozen-lockfile -pnpm dev -``` - -The root `dev` script delegates to `pnpm --filter=website run dev`, which starts -the Rspress development server in `apps/website`. Keep it running while testing. -Open the local URL printed by the server, including the configured `/VBI/` base -path; use the actual reported port rather than assuming a fixed one. +## Required Task Completion -- Wait for compilation to finish, then open the website in a browser. Verify - the home page and the documentation, examples, or playground affected by the - change. Exercise the changed behavior and relevant interactions; check for - rendering failures, browser console errors, and failed requests. For changes - without visible UI impact, smoke-test the home page and a relevant example or - playground. An HTTP response alone does not prove browser acceptance. -- Read `apps/website/AGENTS.md` for preview-service and stale-process guidance. - If output is stale or startup reports a port conflict, inspect the website - process and preview port `7890`; stop the confirmed stale website process and - restart `pnpm dev`. After restarting, hard-refresh the browser and remind the - user to do the same (`Ctrl+Shift+R`). -- Keep browser acceptance separate from coverage runs, which must not open a - browser. Fix task-related failures and repeat the affected checks before - reporting acceptance as passed. If startup or browser access is blocked, - report the blocker and the checks that remain unverified. -- Stop the development server you started after validation unless the user - needs it left running; report any server left running and its URL. +1. Identify changed packages and affected consumers. Read their `package.json` + scripts and test configuration for generation, full-test, and coverage commands. +2. Update relevant docs, examples, and regression tests; remove obsolete cases. + Edit the owning source or generator for generated artifacts. +3. Run each affected package's `g` script (root `pnpm run g` for repository-wide + generation). Inspect generated diffs, including snapshots. Report absent `g` + scripts as not applicable. +4. After all edits and generation, run every affected package's complete tests + and fresh coverage. A full-suite coverage run can satisfy both; run omitted + suites separately. Focused tests, caches, and old reports do not satisfy this + gate. If no coverage script exists, use the runner's coverage command or report + unavailable support. +5. Fix task-related failures and coverage gaps, then repeat affected generation + and checks. Run root `lint:check` and `typecheck` when available. +6. Complete website browser acceptance below; a successful build or unit test + alone is insufficient. +7. Report checked packages, generation, tests, coverage comparisons and report + paths, plus browser URLs, interactions, and outcomes. State blockers and + unverified checks explicitly. -## Package Coverage Requirements +## Website Acceptance -- `@visactor/vbi` and `@visactor/vquery`: unit tests must achieve **100%** - statements, branches, functions, and lines. Run each package's complete - `test:coverage` suite and keep all four thresholds at 100% in its test - configuration. -- `@visactor/vseed`: unit-test coverage must **never decrease** in any of the - four metrics. Before changing code or tooling, run fresh unit-only coverage - on the starting revision and record the summary. After generation and edits, - rerun with the same source scope and compare every metric with that baseline. - Preserve full-suite coverage too; do not substitute it for unit-only coverage. - Raise committed thresholds when coverage improves; never lower them. -- Keep coverage providers and inclusion/exclusion rules comparable. A tooling - upgrade that changes instrumentation requires an explicit before/after review; - it is not permission to remove files, ignore branches, or relax thresholds. -- Coverage checks must run without updating snapshots or opening a browser. - Use explicit snapshot-update commands only after reviewing behavior changes. -- Report the before/after percentages and report locations. Existing gaps must - be fixed before claiming these requirements pass. +- Use the Node.js and pnpm versions in root `package.json`. On a fresh checkout + or after dependency changes, run `pnpm install --frozen-lockfile`. +- Read `apps/website/AGENTS.md`, then run `pnpm dev` from the root. It starts the + Rspress website. Wait for compilation and use the printed URL and port with + `/VBI/`; keep the server running during acceptance. +- Verify the home page and affected docs, examples, or playground in a browser. + Exercise changed behavior and interactions; check rendering, console errors, + and failed requests. Without visible UI changes, smoke-test the home page and + a relevant example. An HTTP response alone is not browser acceptance. +- For stale output or port conflicts, inspect website processes and preview port + `7890`; stop only the confirmed stale process and restart `pnpm dev`. Hard-refresh + and remind the user to do the same (`Ctrl+Shift+R`). +- Keep browser acceptance separate from coverage. Fix task-related failures and + repeat affected checks; report startup/access blockers and unverified behavior. +- Stop servers you started after validation unless the user needs them; report + any server left running and its URL. diff --git a/.agents/skills/development/references/software-entropy.md b/.agents/skills/development/references/software-entropy.md index 468a586def..8d0cc63b43 100644 --- a/.agents/skills/development/references/software-entropy.md +++ b/.agents/skills/development/references/software-entropy.md @@ -1,7 +1,7 @@ # Software Entropy Control Use for maintainability, refactoring, cleanup, generated-surface control, and -changes that could expand VBI's long-term maintenance cost. +changes that could expand the repository's long-term maintenance cost. ## Entropy Signals @@ -35,15 +35,7 @@ Identify the owner, source of truth, generated surface, deletion impact, and validation before changing code. Deletion impact includes imports, calls, exports, types, tests, docs, comments, generated references, and old names. -## VBI Boundaries - -- VBIChartDSL, VQueryDSL, and VSeedDSL are core sources of truth. -- Builder owns DSL mutation; consumers should use Builder or public package APIs. -- Practices stay independent; move reuse into packages or local utilities. - ## Validation -Prove both removal and behavior: use `rg` for deleted symbols and old names, run -generators before checks when source changes affect generated files, then run -focused owner checks plus `pnpm run lint:check` and `pnpm run typecheck` when -available. +Prove both removal and behavior: use `rg` to check deleted symbols and old names, +then follow the generation and validation gates in [SKILL.md](../SKILL.md). diff --git a/.changeset/compact-donut-presentation.md b/.changeset/compact-donut-presentation.md new file mode 100644 index 0000000000..6eba6f4069 --- /dev/null +++ b/.changeset/compact-donut-presentation.md @@ -0,0 +1,5 @@ +--- +'@visactor/vseed': patch +--- + +Add static legends and fixed donut center text through composable pipelines. Legend interaction can be disabled without changing existing sizing, spacing or layout. Center titleText/subTitleText fit the inner radius and inherit theme colors. Center text typography, pie geometry and hover, and legend interaction defaults are owned by themes; chart DSLs only need content and explicit overrides. PieStyle follows the existing mark-style naming with pieBorderColor, pieBorderWidth, pieCornerRadius and pieHoverEffect; existing hover enlargement, adaptive borders and chart defaults remain unchanged. Migrate the lightweight dashboard consumer ring to the simplified DSL. diff --git a/README.md b/README.md index 1075ec892c..8e6fc9e000 100644 --- a/README.md +++ b/README.md @@ -9,5 +9,5 @@ VBI provides reusable packages and agent skills for AI-oriented business intelli | Name | Description | Test Coverage | Npm Version | | :------------------------------------------------------------- | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ | | [vbi](https://visactor.github.io/VBI) | HeadlessBI Interface | [![Coverage](https://img.shields.io/badge/Coverage-100%25-brightgreen.svg)](https://github.com/VisActor/VBI/actions/workflows/coverage.yml) | [![npm Version](https://img.shields.io/npm/v/@visactor/vbi.svg)](https://www.npmjs.com/package/@visactor/vbi) | -| [vseed](https://visactor.github.io/VBI/vseed/guide/quickStart) | Visualization Engine | [![Coverage](https://img.shields.io/badge/Coverage-90.56%25-brightgreen.svg)](https://github.com/VisActor/VSeed/actions/workflows/coverage.yml) | [![npm Version](https://img.shields.io/npm/v/@visactor/vseed.svg)](https://www.npmjs.com/package/@visactor/vseed) | +| [vseed](https://visactor.github.io/VBI/vseed/guide/quickStart) | Visualization Engine | [![Coverage](https://img.shields.io/badge/Coverage-90.98%25-brightgreen.svg)](https://github.com/VisActor/VSeed/actions/workflows/coverage.yml) | [![npm Version](https://img.shields.io/npm/v/@visactor/vseed.svg)](https://www.npmjs.com/package/@visactor/vseed) | | [vquery](https://visactor.github.io/VBI/VQuery) | Lightweight Query Engine | [![Coverage](https://img.shields.io/badge/Coverage-100.00%25-brightgreen.svg)](https://github.com/VisActor/VSeed/actions/workflows/coverage.yml) | [![npm Version](https://img.shields.io/npm/v/@visactor/vquery.svg)](https://www.npmjs.com/package/@visactor/vquery) | diff --git a/apps/website/docs/zh-CN/vseed/examples/features/_meta.json b/apps/website/docs/zh-CN/vseed/examples/features/_meta.json index 1fd1cbc399..9a1c339a8b 100644 --- a/apps/website/docs/zh-CN/vseed/examples/features/_meta.json +++ b/apps/website/docs/zh-CN/vseed/examples/features/_meta.json @@ -44,6 +44,11 @@ "name": "brush", "label": "Brush" }, + { + "type": "file", + "name": "centerText", + "label": "CenterText" + }, { "type": "file", "name": "color", @@ -84,6 +89,11 @@ "name": "numFormat", "label": "NumFormat" }, + { + "type": "file", + "name": "pieStyle", + "label": "PieStyle" + }, { "type": "file", "name": "pointStyle", diff --git a/apps/website/docs/zh-CN/vseed/examples/features/centerText.mdx b/apps/website/docs/zh-CN/vseed/examples/features/centerText.mdx new file mode 100644 index 0000000000..1bdb4af0b4 --- /dev/null +++ b/apps/website/docs/zh-CN/vseed/examples/features/centerText.mdx @@ -0,0 +1,190 @@ +# CenterText + +## themeDefaults + +只配置中心文本内容。内外半径、悬停、字号、字重和间距使用主题默认值;省略 theme 时使用 light。 + +```tsx preview +import { VSeedRender } from '@components' + +export default () => { + const vseedConfig = { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "encoding": "angle" + } + ], + "label": { + "enable": false + }, + "centerText": { + "titleText": "62%", + "subTitleText": "消费者" + } +} + + return +} +``` + +## emptyDonut + +无订单时两类占比均为零,展示固定占位文本,不伪造其他客户 100% 的扇区。 + +```tsx preview +import { VSeedRender } from '@components' + +export default () => { + const vseedConfig = { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 0 + }, + { + "category": "其他客户", + "share": 0 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": [ + "#56dabb", + "#c1f3e6" + ] + }, + "legend": { + "enable": false + }, + "centerText": { + "titleText": "—", + "subTitleText": "消费者" + } +} + + return +} +``` + +## pivotDonutCenter + +透视环图的单元格组合相同几何和中心文本 pipe;中心文本固定,不对数据隐式求和。 + +```tsx preview +import { VSeedRender } from '@components' + +export default () => { + const vseedConfig = { + "chartType": "donut", + "dataset": [ + { + "region": "华东", + "category": "消费者", + "share": 62 + }, + { + "region": "华东", + "category": "其他客户", + "share": 38 + }, + { + "region": "华南", + "category": "消费者", + "share": 40 + }, + { + "region": "华南", + "category": "其他客户", + "share": 60 + } + ], + "dimensions": [ + { + "id": "region", + "encoding": "column" + }, + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": [ + "#56dabb", + "#c1f3e6" + ] + }, + "legend": { + "interactive": false, + "position": "bottom", + "border": false, + "shapeType": "circle", + "labelFontSize": 10 + }, + "outerRadius": 0.9, + "innerRadius": 0.6, + "centerText": { + "titleText": "客户分布" + }, + "pieStyle": { + "pieBorderWidth": 0 + } +} + + return +} +``` diff --git a/apps/website/docs/zh-CN/vseed/examples/features/legend.mdx b/apps/website/docs/zh-CN/vseed/examples/features/legend.mdx index e4267269f6..6dc995e809 100644 --- a/apps/website/docs/zh-CN/vseed/examples/features/legend.mdx +++ b/apps/website/docs/zh-CN/vseed/examples/features/legend.mdx @@ -4,7 +4,7 @@ border -```tsx preview +```tsx preview import { VSeedRender } from '@components' export default () => { @@ -108,7 +108,7 @@ export default () => { enable -```tsx preview +```tsx preview import { VSeedRender } from '@components' export default () => { @@ -154,7 +154,7 @@ export default () => { labelColor -```tsx preview +```tsx preview import { VSeedRender } from '@components' export default () => { @@ -204,7 +204,7 @@ export default () => { labelFontSize -```tsx preview +```tsx preview import { VSeedRender } from '@components' export default () => { @@ -254,7 +254,7 @@ export default () => { labelFontWeight -```tsx preview +```tsx preview import { VSeedRender } from '@components' export default () => { @@ -304,7 +304,7 @@ export default () => { maxSize -```tsx preview +```tsx preview import { VSeedRender } from '@components' export default () => { @@ -407,7 +407,7 @@ export default () => { position -```tsx preview +```tsx preview import { VSeedRender } from '@components' export default () => { @@ -450,3 +450,62 @@ export default () => { } ``` +## staticLegend + +设置 interactive: false,仅关闭图例悬停、选择、聚焦和筛选;沿用既有布局和标记样式。 + +```tsx preview +import { VSeedRender } from '@components' + +export default () => { + const vseedConfig = { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": [ + "#56dabb", + "#c1f3e6" + ] + }, + "legend": { + "interactive": false, + "position": "bottom", + "border": false, + "shapeType": "circle", + "labelFontSize": 10 + } +} + + return +} +``` diff --git a/apps/website/docs/zh-CN/vseed/examples/features/pieStyle.mdx b/apps/website/docs/zh-CN/vseed/examples/features/pieStyle.mdx new file mode 100644 index 0000000000..8b0f79ce8a --- /dev/null +++ b/apps/website/docs/zh-CN/vseed/examples/features/pieStyle.mdx @@ -0,0 +1,134 @@ +# PieStyle + +## compactDonut + +静态图例仅说明分类,配置悬停降低扇区透明度,中心文本使用主题字号,超长文本按环内宽度省略。 + +```tsx preview +import { VSeedRender } from '@components' + +export default () => { + const vseedConfig = { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": [ + "#56dabb", + "#c1f3e6" + ] + }, + "legend": { + "interactive": false, + "position": "bottom", + "border": false, + "shapeType": "circle", + "labelFontSize": 10 + }, + "outerRadius": 0.98, + "innerRadius": 0.74, + "startAngle": -115, + "pieStyle": { + "pieBorderWidth": 0, + "pieHoverEffect": "opacity" + }, + "centerText": { + "titleText": "62%", + "subTitleText": "消费者" + } +} + + return +} +``` + +## halfPie + +半圆饼图保留默认颜色,配置圆角、边框和关闭悬停样式。 + +```tsx preview +import { VSeedRender } from '@components' + +export default () => { + const vseedConfig = { + "chartType": "pie", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": [ + "#56dabb", + "#c1f3e6" + ] + }, + "startAngle": -180, + "endAngle": 0, + "outerRadius": 0.9, + "pieStyle": { + "pieCornerRadius": 4, + "pieBorderColor": "white", + "pieBorderWidth": 2, + "pieHoverEffect": "none" + } +} + + return +} +``` diff --git a/apps/website/docs/zh-CN/vseed/option/area.md b/apps/website/docs/zh-CN/vseed/option/area.md index 34e2d8471d..546096eb83 100644 --- a/apps/website/docs/zh-CN/vseed/option/area.md +++ b/apps/website/docs/zh-CN/vseed/option/area.md @@ -1255,6 +1255,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/areaPercent.md b/apps/website/docs/zh-CN/vseed/option/areaPercent.md index bc4296ec30..b719fab807 100644 --- a/apps/website/docs/zh-CN/vseed/option/areaPercent.md +++ b/apps/website/docs/zh-CN/vseed/option/areaPercent.md @@ -1248,6 +1248,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/bar.md b/apps/website/docs/zh-CN/vseed/option/bar.md index 71cad44a75..f122cdd361 100644 --- a/apps/website/docs/zh-CN/vseed/option/bar.md +++ b/apps/website/docs/zh-CN/vseed/option/bar.md @@ -1226,6 +1226,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/barParallel.md b/apps/website/docs/zh-CN/vseed/option/barParallel.md index 4e2bfd1255..d4c80345a2 100644 --- a/apps/website/docs/zh-CN/vseed/option/barParallel.md +++ b/apps/website/docs/zh-CN/vseed/option/barParallel.md @@ -1218,6 +1218,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/barPercent.md b/apps/website/docs/zh-CN/vseed/option/barPercent.md index e009d63325..14543e40ef 100644 --- a/apps/website/docs/zh-CN/vseed/option/barPercent.md +++ b/apps/website/docs/zh-CN/vseed/option/barPercent.md @@ -1218,6 +1218,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/boxPlot.md b/apps/website/docs/zh-CN/vseed/option/boxPlot.md index a917f2de31..a0e1861141 100644 --- a/apps/website/docs/zh-CN/vseed/option/boxPlot.md +++ b/apps/website/docs/zh-CN/vseed/option/boxPlot.md @@ -1222,6 +1222,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/column.md b/apps/website/docs/zh-CN/vseed/option/column.md index 53d5744244..4816fadf7c 100644 --- a/apps/website/docs/zh-CN/vseed/option/column.md +++ b/apps/website/docs/zh-CN/vseed/option/column.md @@ -1214,6 +1214,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/columnParallel.md b/apps/website/docs/zh-CN/vseed/option/columnParallel.md index dbda84c46c..1f7cc1e947 100644 --- a/apps/website/docs/zh-CN/vseed/option/columnParallel.md +++ b/apps/website/docs/zh-CN/vseed/option/columnParallel.md @@ -1214,6 +1214,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/columnPercent.md b/apps/website/docs/zh-CN/vseed/option/columnPercent.md index 7b54e3cd79..f593777541 100644 --- a/apps/website/docs/zh-CN/vseed/option/columnPercent.md +++ b/apps/website/docs/zh-CN/vseed/option/columnPercent.md @@ -1230,6 +1230,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/donut.md b/apps/website/docs/zh-CN/vseed/option/donut.md index 512cdc17e2..bac8ba785a 100644 --- a/apps/website/docs/zh-CN/vseed/option/donut.md +++ b/apps/website/docs/zh-CN/vseed/option/donut.md @@ -46,7 +46,7 @@ 默认开启的功能: -\- 默认开启图例、数据标签、提示信息、占比计算、中心文本 +\- 默认开启图例、数据标签、提示信息、占比计算;中心文本需显式配置 centerText ::: @@ -70,6 +70,126 @@ +## outerRadius + +**Type:** `number | undefined` + +:::note{title=描述} +外半径占可用半径的比例,范围 (0, 1],默认 0.8。 + +::: + + +## innerRadius + +**Type:** `number | undefined` + +:::note{title=描述} +内半径占可用半径的比例,范围 [0, outerRadius)。默认外半径的 80%。 + +::: + + +## startAngle + +**Type:** `number | undefined` + +:::note{title=描述} +起始角度,单位为度,默认 \-90。 + +::: + + +## endAngle + +**Type:** `number | undefined` + +:::note{title=描述} +结束角度,默认 startAngle + 360;跨度必须在 (0, 360]。 + +::: + + +## pieStyle + +**Type:** `PieStyle | undefined` + +:::note{title=描述} +扇区边框、圆角和悬停效果。 + +::: + + +### pieBorderColor + +**Type:** `string | undefined` + +:::note{title=描述} +扇区边框颜色,默认跟随图表背景。 + +::: + +### pieBorderWidth + +**Type:** `number | undefined` + +:::note{title=描述} +扇区边框宽度,单位 px,默认按数据量自适应:不超过 30 条时为 1,否则为 0。0 表示无边框。 + +::: + +### pieCornerRadius + +**Type:** `number | undefined` + +:::note{title=描述} +扇区圆角,单位 px。 + +::: + +### pieHoverEffect + +**Type:** `"none" | "opacity" | "enlarge" | undefined` + +:::note{title=描述} +悬停效果:enlarge 放大(默认)、opacity 降低透明度(固定为 0.75)、none 关闭。 + +::: + + +## centerText + +**Type:** `CenterText | undefined` + +:::note{title=描述} +固定中心主副文本;配置后默认开启,不随扇区交互变化。 + + + +固定中心文本;主副文本默认 18/12px,超长文本按环内宽度省略,颜色跟随主题。省略 centerText 即不显示。 + +::: + + +### titleText + +**Type:** `string | number | undefined` + +:::note{title=描述} +主文本,不随扇区交互变化。业务计算及无数据占位文本由调用方提供。 + +::: + +### subTitleText + +**Type:** `string | number | undefined` + +:::note{title=描述} +副文本。 + +::: + + ## dataset **Type:** `Record[]` @@ -1255,6 +1375,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/dualAxis.md b/apps/website/docs/zh-CN/vseed/option/dualAxis.md index f6550ebe57..7fe7deb5c4 100644 --- a/apps/website/docs/zh-CN/vseed/option/dualAxis.md +++ b/apps/website/docs/zh-CN/vseed/option/dualAxis.md @@ -2610,6 +2610,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/hierarchySankey.md b/apps/website/docs/zh-CN/vseed/option/hierarchySankey.md index 14871d301d..0c9e8b8fc6 100644 --- a/apps/website/docs/zh-CN/vseed/option/hierarchySankey.md +++ b/apps/website/docs/zh-CN/vseed/option/hierarchySankey.md @@ -1226,6 +1226,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/histogram.md b/apps/website/docs/zh-CN/vseed/option/histogram.md index 5753f0000d..4572169a42 100644 --- a/apps/website/docs/zh-CN/vseed/option/histogram.md +++ b/apps/website/docs/zh-CN/vseed/option/histogram.md @@ -1177,6 +1177,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/line.md b/apps/website/docs/zh-CN/vseed/option/line.md index a1904360cd..20eaa697a2 100644 --- a/apps/website/docs/zh-CN/vseed/option/line.md +++ b/apps/website/docs/zh-CN/vseed/option/line.md @@ -1228,6 +1228,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/pie.md b/apps/website/docs/zh-CN/vseed/option/pie.md index b803ed32b5..7475c9f8bb 100644 --- a/apps/website/docs/zh-CN/vseed/option/pie.md +++ b/apps/website/docs/zh-CN/vseed/option/pie.md @@ -70,6 +70,93 @@ +## outerRadius + +**Type:** `number | undefined` + +:::note{title=描述} +外半径占可用半径的比例,范围 (0, 1],默认 0.8。 + +::: + + +## innerRadius + +**Type:** `number | undefined` + +:::note{title=描述} +内半径占可用半径的比例,范围 [0, outerRadius)。默认 0。 + +::: + + +## startAngle + +**Type:** `number | undefined` + +:::note{title=描述} +起始角度,单位为度,默认 \-90。 + +::: + + +## endAngle + +**Type:** `number | undefined` + +:::note{title=描述} +结束角度,默认 startAngle + 360;跨度必须在 (0, 360]。 + +::: + + +## pieStyle + +**Type:** `PieStyle | undefined` + +:::note{title=描述} +扇区边框、圆角和悬停效果。 + +::: + + +### pieBorderColor + +**Type:** `string | undefined` + +:::note{title=描述} +扇区边框颜色,默认跟随图表背景。 + +::: + +### pieBorderWidth + +**Type:** `number | undefined` + +:::note{title=描述} +扇区边框宽度,单位 px,默认按数据量自适应:不超过 30 条时为 1,否则为 0。0 表示无边框。 + +::: + +### pieCornerRadius + +**Type:** `number | undefined` + +:::note{title=描述} +扇区圆角,单位 px。 + +::: + +### pieHoverEffect + +**Type:** `"none" | "opacity" | "enlarge" | undefined` + +:::note{title=描述} +悬停效果:enlarge 放大(默认)、opacity 降低透明度(固定为 0.75)、none 关闭。 + +::: + + ## dataset **Type:** `Record[]` @@ -1255,6 +1342,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/raceBar.md b/apps/website/docs/zh-CN/vseed/option/raceBar.md index e7714efafe..b89b0175f8 100644 --- a/apps/website/docs/zh-CN/vseed/option/raceBar.md +++ b/apps/website/docs/zh-CN/vseed/option/raceBar.md @@ -1367,6 +1367,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/raceColumn.md b/apps/website/docs/zh-CN/vseed/option/raceColumn.md index 0028187538..1a17f26029 100644 --- a/apps/website/docs/zh-CN/vseed/option/raceColumn.md +++ b/apps/website/docs/zh-CN/vseed/option/raceColumn.md @@ -1410,6 +1410,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/raceDonut.md b/apps/website/docs/zh-CN/vseed/option/raceDonut.md index d0e8c291fd..c5f235beaf 100644 --- a/apps/website/docs/zh-CN/vseed/option/raceDonut.md +++ b/apps/website/docs/zh-CN/vseed/option/raceDonut.md @@ -1336,6 +1336,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/raceLine.md b/apps/website/docs/zh-CN/vseed/option/raceLine.md index a853bc8fa5..60bca3dd4f 100644 --- a/apps/website/docs/zh-CN/vseed/option/raceLine.md +++ b/apps/website/docs/zh-CN/vseed/option/raceLine.md @@ -1323,6 +1323,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/racePie.md b/apps/website/docs/zh-CN/vseed/option/racePie.md index a0848a2c3c..27667fb86f 100644 --- a/apps/website/docs/zh-CN/vseed/option/racePie.md +++ b/apps/website/docs/zh-CN/vseed/option/racePie.md @@ -1336,6 +1336,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/raceScatter.md b/apps/website/docs/zh-CN/vseed/option/raceScatter.md index d920ce75a3..34391acfd2 100644 --- a/apps/website/docs/zh-CN/vseed/option/raceScatter.md +++ b/apps/website/docs/zh-CN/vseed/option/raceScatter.md @@ -1420,6 +1420,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/radar.md b/apps/website/docs/zh-CN/vseed/option/radar.md index e6be798a1a..2fd5ea845e 100644 --- a/apps/website/docs/zh-CN/vseed/option/radar.md +++ b/apps/website/docs/zh-CN/vseed/option/radar.md @@ -1242,6 +1242,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/rose.md b/apps/website/docs/zh-CN/vseed/option/rose.md index aabdbee183..d07e2e851e 100644 --- a/apps/website/docs/zh-CN/vseed/option/rose.md +++ b/apps/website/docs/zh-CN/vseed/option/rose.md @@ -1259,6 +1259,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/roseParallel.md b/apps/website/docs/zh-CN/vseed/option/roseParallel.md index 3832df8527..bb2427dd3e 100644 --- a/apps/website/docs/zh-CN/vseed/option/roseParallel.md +++ b/apps/website/docs/zh-CN/vseed/option/roseParallel.md @@ -1259,6 +1259,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/sankey.md b/apps/website/docs/zh-CN/vseed/option/sankey.md index 507e105347..a4f46933e4 100644 --- a/apps/website/docs/zh-CN/vseed/option/sankey.md +++ b/apps/website/docs/zh-CN/vseed/option/sankey.md @@ -1235,6 +1235,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/apps/website/docs/zh-CN/vseed/option/scatter.md b/apps/website/docs/zh-CN/vseed/option/scatter.md index adcc07e27a..af503f8183 100644 --- a/apps/website/docs/zh-CN/vseed/option/scatter.md +++ b/apps/website/docs/zh-CN/vseed/option/scatter.md @@ -1275,6 +1275,15 @@ enable: true +### interactive + +**Type:** `boolean | undefined` + +:::note{title=描述} +离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + +::: + ### border **Type:** `boolean | undefined` diff --git a/packages/vseed/src/pipeline/advanced/chart/pipeline/donut.ts b/packages/vseed/src/pipeline/advanced/chart/pipeline/donut.ts index 51aabc3c28..fcecf266d4 100644 --- a/packages/vseed/src/pipeline/advanced/chart/pipeline/donut.ts +++ b/packages/vseed/src/pipeline/advanced/chart/pipeline/donut.ts @@ -2,6 +2,9 @@ import type { AdvancedPipeline } from 'src/types' import { initAdvancedVSeed, theme, + donutGeometryConfig, + pieStyleConfig, + centerTextConfig, pivotAdapter, annotation, donutConfig, @@ -32,6 +35,9 @@ export const donutAdvancedPipeline: AdvancedPipeline = [ pivotAdapter([reshapeWithEncoding], [pivotReshapeWithEncoding]), donutConfig, + donutGeometryConfig, + pieStyleConfig, + centerTextConfig, theme, annotation, ] diff --git a/packages/vseed/src/pipeline/advanced/chart/pipeline/pie.ts b/packages/vseed/src/pipeline/advanced/chart/pipeline/pie.ts index 33524fad16..a30c6b0ca3 100644 --- a/packages/vseed/src/pipeline/advanced/chart/pipeline/pie.ts +++ b/packages/vseed/src/pipeline/advanced/chart/pipeline/pie.ts @@ -2,6 +2,8 @@ import type { AdvancedPipeline } from 'src/types' import { initAdvancedVSeed, theme, + pieGeometryConfig, + pieStyleConfig, pivotAdapter, pieConfig, annotation, @@ -32,6 +34,8 @@ export const pieAdvancedPipeline: AdvancedPipeline = [ pivotAdapter([reshapeWithEncoding], [pivotReshapeWithEncoding]), pieConfig, + pieGeometryConfig, + pieStyleConfig, theme, annotation, ] diff --git a/packages/vseed/src/pipeline/advanced/chart/pipeline/raceDonut.ts b/packages/vseed/src/pipeline/advanced/chart/pipeline/raceDonut.ts index 936e4544fe..55cb94441c 100644 --- a/packages/vseed/src/pipeline/advanced/chart/pipeline/raceDonut.ts +++ b/packages/vseed/src/pipeline/advanced/chart/pipeline/raceDonut.ts @@ -2,6 +2,7 @@ import type { AdvancedPipeline } from 'src/types' import { initAdvancedVSeed, theme, + donutGeometryConfig, buildMeasures, pivotAdapter, donutConfig, @@ -32,6 +33,7 @@ export const raceDonutAdvancedPipeline: AdvancedPipeline = [ pivotAdapter([reshapeWithEncoding], [pivotReshapeWithEncoding]), donutConfig, + donutGeometryConfig, theme, annotation, ] diff --git a/packages/vseed/src/pipeline/advanced/chart/pipeline/racePie.ts b/packages/vseed/src/pipeline/advanced/chart/pipeline/racePie.ts index 15ba4f9849..eac5c2d171 100644 --- a/packages/vseed/src/pipeline/advanced/chart/pipeline/racePie.ts +++ b/packages/vseed/src/pipeline/advanced/chart/pipeline/racePie.ts @@ -2,6 +2,7 @@ import type { AdvancedPipeline } from 'src/types' import { initAdvancedVSeed, theme, + pieGeometryConfig, buildMeasures, pivotAdapter, pieConfig, @@ -32,6 +33,7 @@ export const racePieAdvancedPipeline: AdvancedPipeline = [ pivotAdapter([reshapeWithEncoding], [pivotReshapeWithEncoding]), pieConfig, + pieGeometryConfig, theme, annotation, ] diff --git a/packages/vseed/src/pipeline/advanced/chart/pipes/config/index.ts b/packages/vseed/src/pipeline/advanced/chart/pipes/config/index.ts index 251662aeac..a10e08dbd7 100644 --- a/packages/vseed/src/pipeline/advanced/chart/pipes/config/index.ts +++ b/packages/vseed/src/pipeline/advanced/chart/pipes/config/index.ts @@ -23,6 +23,8 @@ export { sunburstConfig } from './sunburst' export { circlePackingConfig } from './circlePacking' export { hierarchySankeyConfig } from './hierarchySankey' export { sankeyConfig } from './sankey' +export { pieStyleConfig, centerTextConfig } from './piePresentation' +export { pieGeometryConfig, donutGeometryConfig } from './pieGeometry' // area export const areaConfig = lineConfig diff --git a/packages/vseed/src/pipeline/advanced/chart/pipes/config/pieGeometry.ts b/packages/vseed/src/pipeline/advanced/chart/pipes/config/pieGeometry.ts new file mode 100644 index 0000000000..cab057fa79 --- /dev/null +++ b/packages/vseed/src/pipeline/advanced/chart/pipes/config/pieGeometry.ts @@ -0,0 +1,50 @@ +import type { AdvancedPipe, Donut, PieGeometry } from 'src/types' +import { getDefaultDonutGeometry, getDefaultPieGeometry, getDefaultPieAngles } from 'src/theme/common/pie' + +const geometryConfig = + (getDefaults: typeof getDefaultPieGeometry): AdvancedPipe => + (advancedVSeed, { vseed, customTheme }) => { + const { chartType, theme = 'light' } = vseed + const base = { ...getDefaults(), ...getDefaultPieAngles() } + const themeGeometry = customTheme?.[theme]?.config?.[chartType] as PieGeometry | undefined + const defaults = { + outerRadius: themeGeometry?.outerRadius ?? base.outerRadius, + innerRadius: themeGeometry?.innerRadius ?? base.innerRadius, + startAngle: themeGeometry?.startAngle ?? base.startAngle, + endAngle: themeGeometry?.endAngle ?? base.endAngle, + } + const options = vseed as Donut + const outerRadius = options.outerRadius ?? defaults.outerRadius + const innerRadius = options.innerRadius ?? (outerRadius / defaults.outerRadius) * defaults.innerRadius + const startAngle = options.startAngle ?? defaults.startAngle + const endAngle = options.endAngle ?? startAngle + (defaults.endAngle - defaults.startAngle) + + if (!(outerRadius > 0 && outerRadius <= 1 && innerRadius >= 0 && innerRadius < outerRadius)) { + throw new Error('Pie radii must satisfy 0 <= innerRadius < outerRadius <= 1') + } + if (!(endAngle > startAngle && endAngle - startAngle <= 360)) { + throw new Error('Pie angle span must be greater than 0 and at most 360 degrees') + } + + return { + ...advancedVSeed, + config: { + ...advancedVSeed.config, + [chartType]: { + ...advancedVSeed.config?.[chartType], + outerRadius, + innerRadius, + // Leave unconfigured angles to VChart, including any existing VChart theme. + ...(options.startAngle != null || + options.endAngle != null || + themeGeometry?.startAngle != null || + themeGeometry?.endAngle != null + ? { startAngle, endAngle } + : {}), + }, + }, + } + } + +export const pieGeometryConfig = geometryConfig(getDefaultPieGeometry) +export const donutGeometryConfig = geometryConfig(getDefaultDonutGeometry) diff --git a/packages/vseed/src/pipeline/advanced/chart/pipes/config/piePresentation.ts b/packages/vseed/src/pipeline/advanced/chart/pipes/config/piePresentation.ts new file mode 100644 index 0000000000..193b651669 --- /dev/null +++ b/packages/vseed/src/pipeline/advanced/chart/pipes/config/piePresentation.ts @@ -0,0 +1,19 @@ +import { pick } from 'remeda' +import { replaceNullToUndefined } from 'src/pipeline/utils' +import type { AdvancedPipe, Donut } from 'src/types' + +const presentationConfig = + (keys: (keyof Donut)[]): AdvancedPipe => + (advancedVSeed, { vseed }) => ({ + ...advancedVSeed, + config: { + ...advancedVSeed.config, + [vseed.chartType]: { + ...advancedVSeed.config?.[vseed.chartType], + ...replaceNullToUndefined(pick(vseed as Donut, keys)), + }, + }, + }) + +export const pieStyleConfig = presentationConfig(['pieStyle']) +export const centerTextConfig = presentationConfig(['centerText']) diff --git a/packages/vseed/src/pipeline/spec/chart/pipeline/donut.ts b/packages/vseed/src/pipeline/spec/chart/pipeline/donut.ts index f9ff1748c8..af3652861c 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipeline/donut.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipeline/donut.ts @@ -6,6 +6,8 @@ import { datasetPivot, initDonut, pieAnimation, + pieGeometry, + centerText, initPivot, discreteLegend, pivotAdapter, @@ -26,6 +28,7 @@ import { colorLegend, pivotColorLegend, pieStyle, + pieHover, colorPieStyleFill, labelPie, pivotHideIndicatorName, @@ -40,6 +43,8 @@ const donut: VChartSpecPipeline = [ fontFamilyTheme, initDonut, pieAnimation, + pieGeometry, + centerText, colorAdapter(color, linearColor), backgroundColor, datasetXY, @@ -47,6 +52,7 @@ const donut: VChartSpecPipeline = [ brush, labelPie, colorPieStyleFill(pieStyle), + pieHover, colorAdapter(discreteLegend, colorLegend), tooltip({ titleEncoding: 'color' }), annotationPoint, @@ -65,6 +71,8 @@ const pivotDonut: PivotChartSpecPipeline = [ fontFamilyTheme, initDonut, pieAnimation, + pieGeometry, + centerText, addRegionPadding, colorAdapter(color, linearColor), backgroundColor, @@ -73,6 +81,7 @@ const pivotDonut: PivotChartSpecPipeline = [ brush, labelPie, colorPieStyleFill(pieStyle), + pieHover, tooltip({ titleEncoding: 'color' }), annotationPoint, annotationVerticalLine, diff --git a/packages/vseed/src/pipeline/spec/chart/pipeline/pie.ts b/packages/vseed/src/pipeline/spec/chart/pipeline/pie.ts index 1fd0c8b054..8b658b0cd8 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipeline/pie.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipeline/pie.ts @@ -6,6 +6,7 @@ import { datasetPivot, initPie, pieAnimation, + pieGeometry, initPivot, discreteLegend, pivotAdapter, @@ -22,6 +23,7 @@ import { annotationArea, progressive, pieStyle, + pieHover, colorPieStyleFill, linearColor, colorAdapter, @@ -40,12 +42,14 @@ const pie: VChartSpecPipeline = [ fontFamilyTheme, initPie, pieAnimation, + pieGeometry, colorAdapter(color, linearColor), backgroundColor, datasetXY, progressive, brush, colorPieStyleFill(pieStyle), + pieHover, colorAdapter(discreteLegend, colorLegend), labelPie, tooltip({ titleEncoding: 'color' }), @@ -65,12 +69,14 @@ const pivotPie: PivotChartSpecPipeline = [ fontFamilyTheme, initPie, pieAnimation, + pieGeometry, colorAdapter(color, linearColor), backgroundColor, datasetXY, progressive, brush, colorPieStyleFill(pieStyle), + pieHover, labelPie, tooltip({ titleEncoding: 'color' }), annotationPoint, diff --git a/packages/vseed/src/pipeline/spec/chart/pipeline/raceDonut.ts b/packages/vseed/src/pipeline/spec/chart/pipeline/raceDonut.ts index 5cc5538945..8874fd8d2b 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipeline/raceDonut.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipeline/raceDonut.ts @@ -1,12 +1,14 @@ import type { VChartSpecPipeline } from 'src/types' import { initDonut, + pieGeometry, backgroundColor, labelPie, tooltip, discreteLegend, color, pieStyle, + pieHover, annotationPoint, annotationVerticalLine, annotationHorizontalLine, @@ -26,6 +28,7 @@ import { const raceDonut: VChartSpecPipeline = [ fontFamilyTheme, initDonut, + pieGeometry, colorAdapter(color, linearColor), backgroundColor, isPlayer(playerPie, datasetXY), @@ -33,6 +36,7 @@ const raceDonut: VChartSpecPipeline = [ brush, labelPie, colorPieStyleFill(pieStyle), + pieHover, colorAdapter(discreteLegend, colorLegend), tooltip({ titleEncoding: 'color' }), annotationPoint, diff --git a/packages/vseed/src/pipeline/spec/chart/pipeline/racePie.ts b/packages/vseed/src/pipeline/spec/chart/pipeline/racePie.ts index 460ed774f8..9b82d37654 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipeline/racePie.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipeline/racePie.ts @@ -1,11 +1,13 @@ import type { VChartSpecPipeline } from 'src/types' import { initPie, + pieGeometry, backgroundColor, tooltip, discreteLegend, color, pieStyle, + pieHover, annotationPoint, annotationVerticalLine, annotationHorizontalLine, @@ -26,12 +28,14 @@ import { const racePie: VChartSpecPipeline = [ fontFamilyTheme, initPie, + pieGeometry, colorAdapter(color, linearColor), backgroundColor, isPlayer(playerPie, datasetXY), progressive, brush, colorPieStyleFill(pieStyle), + pieHover, colorAdapter(discreteLegend, colorLegend), labelPie, tooltip({ titleEncoding: 'color' }), diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/geometry/pieGeometry.ts b/packages/vseed/src/pipeline/spec/chart/pipes/geometry/pieGeometry.ts new file mode 100644 index 0000000000..12979a0c49 --- /dev/null +++ b/packages/vseed/src/pipeline/spec/chart/pipes/geometry/pieGeometry.ts @@ -0,0 +1,12 @@ +import { pick } from 'remeda' +import type { PieGeometry, VChartSpecPipe } from 'src/types' + +export const pieGeometry: VChartSpecPipe = (spec, { advancedVSeed }) => ({ + ...spec, + ...pick(advancedVSeed.config[advancedVSeed.chartType] as PieGeometry, [ + 'outerRadius', + 'innerRadius', + 'startAngle', + 'endAngle', + ]), +}) diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/index.ts b/packages/vseed/src/pipeline/spec/chart/pipes/index.ts index 05c96bfb3a..27984f0333 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipes/index.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipes/index.ts @@ -25,3 +25,5 @@ export * from './player' export * from './hierarchy' export * from './theme' export * from './animation' +export { pieGeometry } from './geometry/pieGeometry' +export { centerText } from './indicator/centerText' diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/indicator/centerText.ts b/packages/vseed/src/pipeline/spec/chart/pipes/indicator/centerText.ts new file mode 100644 index 0000000000..86fa329e17 --- /dev/null +++ b/packages/vseed/src/pipeline/spec/chart/pipes/indicator/centerText.ts @@ -0,0 +1,38 @@ +import type { IIndicatorSpec, IPieChartSpec } from '@visactor/vchart' +import type { DonutConfig, VChartSpecPipe } from 'src/types' + +const textItem = ( + text: string | number | null | undefined, + style: NonNullable['style'], +): IIndicatorSpec['title'] => ({ + visible: text != null, + autoLimit: true, + style: { ...style, text: text ?? '' }, +}) + +export const centerText: VChartSpecPipe = (spec, { advancedVSeed }) => { + const config = advancedVSeed.config[advancedVSeed.chartType] as DonutConfig + const text = config?.centerText + if (text?.titleText == null && text?.subTitleText == null) return spec + + return { + ...spec, + indicator: { + visible: true, + fixed: true, + trigger: 'none', + limitRatio: (spec as IPieChartSpec).innerRadius as number, + title: textItem(text.titleText, { + fill: config.label?.labelColor ?? config.legend?.labelColor, + fontSize: text.titleFontSize, + fontWeight: text.titleFontWeight, + }), + content: textItem(text.subTitleText, { + fill: config.legend?.labelColor, + fillOpacity: text.subTitleOpacity, + fontSize: text.subTitleFontSize, + }), + gap: text.gap ?? undefined, + }, + } +} diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/init/donut.ts b/packages/vseed/src/pipeline/spec/chart/pipes/init/donut.ts index d67c7843fb..af8ecaca50 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipes/init/donut.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipes/init/donut.ts @@ -8,8 +8,6 @@ export const initDonut: VChartSpecPipe = (spec, context) => { const { foldInfo, unfoldInfo } = datasetReshapeInfo[0] result.type = 'pie' - result.outerRadius = 0.8 - result.innerRadius = result.outerRadius * 0.8 result.valueField = foldInfo.measureValue result.categoryField = unfoldInfo.encodingColorId diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/init/pie.ts b/packages/vseed/src/pipeline/spec/chart/pipes/init/pie.ts index 381388c73d..221799815e 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipes/init/pie.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipes/init/pie.ts @@ -8,8 +8,6 @@ export const initPie: VChartSpecPipe = (spec, context) => { const { unfoldInfo, foldInfo } = datasetReshapeInfo[0] result.type = 'pie' - result.outerRadius = 0.8 - result.innerRadius = 0 result.valueField = foldInfo.measureValue result.categoryField = unfoldInfo.encodingColorId result.padding = 0 diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/legend/discreteLegend.ts b/packages/vseed/src/pipeline/spec/chart/pipes/legend/discreteLegend.ts index 7b6bdc1b41..3935856d9e 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipes/legend/discreteLegend.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipes/legend/discreteLegend.ts @@ -18,6 +18,7 @@ export const discreteLegend: VChartSpecPipe = (spec, context) => { const { legend } = baseConfig const { enable, + interactive = true, position = 'bottom', labelFontColor, labelColor, @@ -47,6 +48,7 @@ export const discreteLegend: VChartSpecPipe = (spec, context) => { result.legends = { type: 'discrete', + ...(interactive === false ? { interactive: false, select: false, hover: false } : {}), visible: enable, maxCol: Math.max(1, maxSize), maxRow: Math.max(1, maxSize), @@ -54,7 +56,7 @@ export const discreteLegend: VChartSpecPipe = (spec, context) => { orient, position: legendPosition, item: { - focus: true, + focus: interactive, maxWidth: '30%', focusIconStyle: { size: labelFontSize + 2, diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/legend/pivotDiscreteLegend.ts b/packages/vseed/src/pipeline/spec/chart/pipes/legend/pivotDiscreteLegend.ts index 45d2d7ecf7..bee9cfc5d3 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipes/legend/pivotDiscreteLegend.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipes/legend/pivotDiscreteLegend.ts @@ -37,6 +37,7 @@ export const pivotDiscreteLegend: PivotChartSpecPipe = (spec, context): Partial< const { enable, + interactive = true, position = 'bottom', labelFontColor, labelColor, @@ -65,6 +66,7 @@ export const pivotDiscreteLegend: PivotChartSpecPipe = (spec, context): Partial< padding: 0, visible: enable, type: 'discrete', + ...(interactive === false ? { interactive: false, select: false, hover: false } : {}), orient, position: legendPosition, maxCol: Math.max(1, maxSize), @@ -87,7 +89,7 @@ export const pivotDiscreteLegend: PivotChartSpecPipe = (spec, context): Partial< }), item: { - focus: true, + focus: interactive, maxWidth: '30%', focusIconStyle: { size: labelFontSize + 2, diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/index.ts b/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/index.ts index bd7b0c2ae6..a3785d3079 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/index.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/index.ts @@ -6,6 +6,7 @@ export { areaStyle, radarAreaStyle } from './areaStyle' export { cellStyle } from './cellStyle' export { funnelStyle } from './funnelStyle' export { pieStyle } from './pieStyle' +export { pieHover } from './pieHover' export { roseStyle } from './roseStyle' export { funnelTransformStyle } from './funnelTransformStyle' export { boxPlotStyle } from './boxPlotStyle' diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/pieHover.ts b/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/pieHover.ts new file mode 100644 index 0000000000..5879b7a25e --- /dev/null +++ b/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/pieHover.ts @@ -0,0 +1,21 @@ +import type { IPieChartSpec } from '@visactor/vchart' +import type { PieStyle, VChartSpecPipe } from 'src/types' +import { getDefaultPieStyle } from 'src/theme/common/pie' + +export const pieHover: VChartSpecPipe = (spec, { advancedVSeed }) => { + const config = advancedVSeed.config[advancedVSeed.chartType] as { pieStyle?: PieStyle } + const effect = config?.pieStyle?.pieHoverEffect ?? getDefaultPieStyle().pieHoverEffect + const result = spec as IPieChartSpec + if (effect === 'enlarge' && !result.outerRadius) return spec + const style = + effect === 'none' + ? {} + : effect === 'opacity' + ? { fillOpacity: 0.75 } + : { outerRadius: (result.outerRadius as number) * 1.1 } + + return { + ...result, + pie: { ...result.pie, state: { ...result.pie?.state, hover: style } }, + } +} diff --git a/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/pieStyle.ts b/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/pieStyle.ts index b945021716..f6fbe7e166 100644 --- a/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/pieStyle.ts +++ b/packages/vseed/src/pipeline/spec/chart/pipes/markStyle/pieStyle.ts @@ -4,30 +4,21 @@ import type { VChartSpecPipe } from 'src/types' export const pieStyle: VChartSpecPipe = (spec, context) => { const { advancedVSeed, vseed } = context const { chartType } = vseed - const { dataset } = advancedVSeed - const showStroke = dataset.length <= 30 const config = advancedVSeed.config?.[chartType as 'pie'] const result = { ...spec, pie: { style: { - stroke: config?.backgroundColor ?? '#ffffff', - lineWidth: showStroke ? 1 : 0, + stroke: config?.pieStyle?.pieBorderColor ?? config?.backgroundColor ?? '#ffffff', + lineWidth: config?.pieStyle?.pieBorderWidth ?? (advancedVSeed.dataset.length <= 30 ? 1 : 0), }, }, } as Required - if ((spec as IPieChartSpec).outerRadius) { - result.pie.state = { - hover: { - outerRadius: ((spec as IPieChartSpec).outerRadius as number) * 1.1, - }, - } - } - - if (config?.cornerRadius) { - result.pie.style!.cornerRadius = config.cornerRadius + const cornerRadius = config?.pieStyle?.pieCornerRadius ?? (config?.cornerRadius || undefined) + if (cornerRadius !== undefined && cornerRadius !== null) { + result.pie.style!.cornerRadius = cornerRadius } return result diff --git a/packages/vseed/src/theme/common/legend.ts b/packages/vseed/src/theme/common/legend.ts index b0cc47f14a..cfe44d9813 100644 --- a/packages/vseed/src/theme/common/legend.ts +++ b/packages/vseed/src/theme/common/legend.ts @@ -2,6 +2,7 @@ import type { ColorLegend, Legend } from 'src/types' export const getDefaultLegend = (): Legend => ({ enable: true, + interactive: true, border: true, maxSize: 1, shapeType: 'rectRound', diff --git a/packages/vseed/src/theme/common/pie.ts b/packages/vseed/src/theme/common/pie.ts new file mode 100644 index 0000000000..3f67f2b245 --- /dev/null +++ b/packages/vseed/src/theme/common/pie.ts @@ -0,0 +1,25 @@ +import type { CenterTextConfig, PieGeometry, PieStyle } from 'src/types' + +export const getDefaultPieGeometry = (): Required> => ({ + outerRadius: 0.8, + innerRadius: 0, +}) + +export const getDefaultDonutGeometry = (): Required> => { + const geometry = getDefaultPieGeometry() + return { ...geometry, innerRadius: geometry.outerRadius * 0.8 } +} + +export const getDefaultPieAngles = () => ({ startAngle: -90, endAngle: 270 }) + +export const getDefaultPieStyle = (): PieStyle => ({ + pieHoverEffect: 'enlarge', +}) + +export const getDefaultCenterText = (): CenterTextConfig => ({ + titleFontSize: 18, + titleFontWeight: 600, + subTitleFontSize: 12, + subTitleOpacity: 0.75, + gap: 2, +}) diff --git a/packages/vseed/src/theme/dark/pie.ts b/packages/vseed/src/theme/dark/pie.ts index 9b1cf04196..8fc562e712 100644 --- a/packages/vseed/src/theme/dark/pie.ts +++ b/packages/vseed/src/theme/dark/pie.ts @@ -1,6 +1,8 @@ import { getDarkPivotChartGridConfig, getDarkColor, getDarkBrushConfig, getDarkLabel, getDarkLegend } from '../common' import { getDarkTooltip } from '../common/tooltip' +import { getDefaultPieGeometry, getDefaultDonutGeometry, getDefaultPieStyle, getDefaultCenterText } from '../common/pie' + export const getPieTheme = () => { const baseConfig = { backgroundColor: 'transparent', @@ -13,6 +15,8 @@ export const getPieTheme = () => { return { ...baseConfig, + ...getDefaultPieGeometry(), + pieStyle: getDefaultPieStyle(), label: { ...baseConfig.label, showValuePercent: true, @@ -24,5 +28,9 @@ export const getPieTheme = () => { } export const getDonutTheme = () => { - return getPieTheme() + return { + ...getPieTheme(), + ...getDefaultDonutGeometry(), + centerText: getDefaultCenterText(), + } } diff --git a/packages/vseed/src/theme/dark/racePie.ts b/packages/vseed/src/theme/dark/racePie.ts index 535c47e510..2fa7dde0c7 100644 --- a/packages/vseed/src/theme/dark/racePie.ts +++ b/packages/vseed/src/theme/dark/racePie.ts @@ -1,5 +1,6 @@ import type { Player } from 'src/types' import { getPieTheme } from './pie' +import { getDefaultDonutGeometry } from '../common/pie' import { getDarkPlayer } from '../common' export const getRacePieTheme = () => { @@ -17,5 +18,5 @@ export const getRacePieTheme = () => { } export const getRaceDonutTheme = () => { - return getRacePieTheme() + return { ...getRacePieTheme(), ...getDefaultDonutGeometry() } } diff --git a/packages/vseed/src/theme/light/pie.ts b/packages/vseed/src/theme/light/pie.ts index 30a7a68131..2986b95aba 100644 --- a/packages/vseed/src/theme/light/pie.ts +++ b/packages/vseed/src/theme/light/pie.ts @@ -7,6 +7,8 @@ import { } from '../common' import { getLightTooltip } from '../common/tooltip' +import { getDefaultPieGeometry, getDefaultDonutGeometry, getDefaultPieStyle, getDefaultCenterText } from '../common/pie' + export const getPieTheme = () => { const baseConfig = { backgroundColor: 'transparent', @@ -19,6 +21,8 @@ export const getPieTheme = () => { return { ...baseConfig, + ...getDefaultPieGeometry(), + pieStyle: getDefaultPieStyle(), label: { ...baseConfig.label, showValuePercent: true, @@ -31,5 +35,9 @@ export const getPieTheme = () => { } export const getDonutTheme = () => { - return getPieTheme() + return { + ...getPieTheme(), + ...getDefaultDonutGeometry(), + centerText: getDefaultCenterText(), + } } diff --git a/packages/vseed/src/theme/light/racePie.ts b/packages/vseed/src/theme/light/racePie.ts index 5e2ff990ea..1432f0a21f 100644 --- a/packages/vseed/src/theme/light/racePie.ts +++ b/packages/vseed/src/theme/light/racePie.ts @@ -1,5 +1,6 @@ import type { Player } from 'src/types' import { getPieTheme } from './pie' +import { getDefaultDonutGeometry } from '../common/pie' import { getLightPlayer } from '../common' export const getRacePieTheme = () => { @@ -17,5 +18,5 @@ export const getRacePieTheme = () => { } export const getRaceDonutTheme = () => { - return getRacePieTheme() + return { ...getRacePieTheme(), ...getDefaultDonutGeometry() } } diff --git a/packages/vseed/src/types/chartType/donut/donut.ts b/packages/vseed/src/types/chartType/donut/donut.ts index 89fe85c0fd..88a6162199 100644 --- a/packages/vseed/src/types/chartType/donut/donut.ts +++ b/packages/vseed/src/types/chartType/donut/donut.ts @@ -11,6 +11,8 @@ import type { Tooltip, Page, PieLikeAnimation, + PieStyle, + CenterText, } from '../../properties' import type { Brush } from '../../properties' @@ -33,7 +35,7 @@ import type { Brush } from '../../properties' * - 所有维度会与指标名称(存在多个指标时)合并, 作为图例项展示. * - 所有指标会自动合并为一个指标 * 默认开启的功能: - * - 默认开启图例、数据标签、提示信息、占比计算、中心文本 + * - 默认开启图例、数据标签、提示信息、占比计算;中心文本需显式配置 centerText * @recommend * - 推荐字段配置: `1`个指标, `2`个维度 * - 支持数据重塑: 至少`1`个指标, `0`个维度 @@ -47,6 +49,20 @@ export interface Donut { */ chartType: 'donut' + /** @description 外半径占可用半径的比例,范围 (0, 1],默认 0.8。 */ + outerRadius?: number + /** @description 内半径占可用半径的比例,范围 [0, outerRadius)。默认外半径的 80%。 */ + innerRadius?: number + /** @description 起始角度,单位为度,默认 -90。 */ + startAngle?: number + /** @description 结束角度,默认 startAngle + 360;跨度必须在 (0, 360]。 */ + endAngle?: number + /** @description 扇区边框、圆角和悬停效果。 */ + pieStyle?: PieStyle + + /** @description 固定中心主副文本;配置后默认开启,不随扇区交互变化。 */ + centerText?: CenterText + /** * 数据集 * @description 符合TidyData规范的且已经聚合的数据集,用于定义图表的数据来源和结构, 用户输入的数据集并不需要进行任何处理, VSeed带有强大的数据重塑功能, 会自行进行数据重塑, 环形图的数据最终会被转换为1个维度, 1个指标. diff --git a/packages/vseed/src/types/chartType/donut/zDonut.ts b/packages/vseed/src/types/chartType/donut/zDonut.ts index 92e6e50215..72e11f174a 100644 --- a/packages/vseed/src/types/chartType/donut/zDonut.ts +++ b/packages/vseed/src/types/chartType/donut/zDonut.ts @@ -15,10 +15,16 @@ import { zBrush, zPage, zPieLikeAnimation, + zPieGeometry, + zPieStyle, + zCenterText, } from '../../properties' export const zDonut = z.object({ chartType: z.literal('donut'), + ...zPieGeometry.shape, + pieStyle: zPieStyle.nullish(), + centerText: zCenterText.nullish(), dataset: zDataset.nullish(), encoding: zEncoding.nullish(), dimensions: zDimensions.nullish(), diff --git a/packages/vseed/src/types/chartType/pie/pie.ts b/packages/vseed/src/types/chartType/pie/pie.ts index f8f22e16fc..416b414569 100644 --- a/packages/vseed/src/types/chartType/pie/pie.ts +++ b/packages/vseed/src/types/chartType/pie/pie.ts @@ -12,6 +12,7 @@ import type { Tooltip, Page, PieLikeAnimation, + PieStyle, } from '../../properties' import type { Brush } from '../../properties' @@ -47,6 +48,18 @@ export interface Pie { * @example 'pie' */ chartType: 'pie' + + /** @description 外半径占可用半径的比例,范围 (0, 1],默认 0.8。 */ + outerRadius?: number + /** @description 内半径占可用半径的比例,范围 [0, outerRadius)。默认 0。 */ + innerRadius?: number + /** @description 起始角度,单位为度,默认 -90。 */ + startAngle?: number + /** @description 结束角度,默认 startAngle + 360;跨度必须在 (0, 360]。 */ + endAngle?: number + /** @description 扇区边框、圆角和悬停效果。 */ + pieStyle?: PieStyle + /** * 数据集 * @description 符合TidyData规范的且已经聚合的数据集,用于定义图表的数据来源和结构, 用户输入的数据集并不需要进行任何处理, VSeed带有强大的数据重塑功能, 会自行进行数据重塑, 饼图的数据最终会被转换为1个维度, 1个指标. diff --git a/packages/vseed/src/types/chartType/pie/zPie.ts b/packages/vseed/src/types/chartType/pie/zPie.ts index 960ae3e42f..8ff5191b34 100644 --- a/packages/vseed/src/types/chartType/pie/zPie.ts +++ b/packages/vseed/src/types/chartType/pie/zPie.ts @@ -14,10 +14,14 @@ import { zBrush, zPage, zPieLikeAnimation, + zPieGeometry, + zPieStyle, } from '../../properties' export const zPie = z.object({ chartType: z.literal('pie'), + ...zPieGeometry.shape, + pieStyle: zPieStyle.nullish(), dataset: zDataset.nullish(), encoding: zEncoding.nullish(), dimensions: zDimensions.nullish(), diff --git a/packages/vseed/src/types/properties/config/centerText.ts b/packages/vseed/src/types/properties/config/centerText.ts new file mode 100644 index 0000000000..bbaaa7a83f --- /dev/null +++ b/packages/vseed/src/types/properties/config/centerText.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +/** 固定中心文本;主副文本默认 18/12px,超长文本按环内宽度省略,颜色跟随主题。省略 centerText 即不显示。 */ +export type CenterText = { + /** @description 主文本,不随扇区交互变化。业务计算及无数据占位文本由调用方提供。 */ + titleText?: string | number + /** @description 副文本。 */ + subTitleText?: string | number +} + +export const zCenterText = z.object({ + titleText: z.union([z.string(), z.number()]).nullish(), + subTitleText: z.union([z.string(), z.number()]).nullish(), +}) + +/** 中心文本的主题配置;图表 DSL 只需提供文本,排版由主题统一管理。 */ +export const zCenterTextConfig = zCenterText.extend({ + titleFontSize: z.number().positive().nullish(), + titleFontWeight: z.union([z.number(), z.string()]).nullish(), + subTitleFontSize: z.number().positive().nullish(), + subTitleOpacity: z.number().min(0).max(1).nullish(), + gap: z.number().nonnegative().nullish(), +}) + +export type CenterTextConfig = z.infer diff --git a/packages/vseed/src/types/properties/config/index.ts b/packages/vseed/src/types/properties/config/index.ts index 4a3c71c154..349624dabe 100644 --- a/packages/vseed/src/types/properties/config/index.ts +++ b/packages/vseed/src/types/properties/config/index.ts @@ -21,6 +21,8 @@ export * from './funnel' export * from './heatmap' export * from './line' export * from './pie' +export * from './pieGeometry' +export * from './centerText' export * from './rose' export * from './scatter' export * from './table' diff --git a/packages/vseed/src/types/properties/config/legend/legend.ts b/packages/vseed/src/types/properties/config/legend/legend.ts index 7e951fed95..bfbfb2a510 100644 --- a/packages/vseed/src/types/properties/config/legend/legend.ts +++ b/packages/vseed/src/types/properties/config/legend/legend.ts @@ -7,6 +7,11 @@ export type Legend = { * @example enable: true */ enable?: boolean + /** + * @description 离散图例是否允许悬停、选择、聚焦和数据筛选。false 时仅说明分类。 + * @default true + */ + interactive?: boolean /** * @description 图例边框是否开启 * @warning 仅离散图例生效 @@ -113,6 +118,7 @@ export type Legend = { export const zLegend = z.object({ enable: z.boolean().default(true).nullish(), + interactive: z.boolean().nullish(), border: z.boolean().default(true).nullish(), maxSize: z.number().default(1).nullish(), shapeType: z diff --git a/packages/vseed/src/types/properties/config/pie.ts b/packages/vseed/src/types/properties/config/pie.ts index df10d21c7c..2d02f90d66 100644 --- a/packages/vseed/src/types/properties/config/pie.ts +++ b/packages/vseed/src/types/properties/config/pie.ts @@ -7,8 +7,11 @@ import { zTooltip } from './tooltip/tooltip' import { zPivotChartGridConfig } from './pivotGrid' import { zBrushConfig } from '../brush/zBrush' import { zPieLikeAnimation, zRadarAnimation } from './animation' +import { zPieGeometry } from './pieGeometry' +import { zPieStyle } from '../markStyle/zPieStyle' +import { zCenterTextConfig } from './centerText' -export const zPieConfig = z.object({ +const zPolarConfig = z.object({ backgroundColor: zBackgroundColor.nullish(), label: zPieLabel.nullish(), color: zColor.nullish(), @@ -20,8 +23,12 @@ export const zPieConfig = z.object({ brush: zBrushConfig.nullish(), animation: zPieLikeAnimation.nullish(), }) -export const zDonutConfig = zPieConfig -export const zRadarConfig = zPieConfig.extend({ +export const zPieConfig = zPolarConfig.extend({ + ...zPieGeometry.shape, + pieStyle: zPieStyle.nullish(), +}) +export const zDonutConfig = zPieConfig.extend({ centerText: zCenterTextConfig.nullish() }) +export const zRadarConfig = zPolarConfig.extend({ animation: zRadarAnimation.nullish(), }) diff --git a/packages/vseed/src/types/properties/config/pieGeometry.ts b/packages/vseed/src/types/properties/config/pieGeometry.ts new file mode 100644 index 0000000000..9f2f74beeb --- /dev/null +++ b/packages/vseed/src/types/properties/config/pieGeometry.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +export const zPieGeometry = z.object({ + outerRadius: z.number().positive().max(1).nullish(), + innerRadius: z.number().nonnegative().max(1).nullish(), + startAngle: z.number().nullish(), + endAngle: z.number().nullish(), +}) + +export type PieGeometry = { + /** @description 外半径占可用半径的比例,范围 (0, 1],默认 0.8。 */ + outerRadius?: number + /** @description 内半径占可用半径的比例,必须小于外半径。未配置时保持主题的内外半径比例,内置饼图为 0,环图为 80%。 */ + innerRadius?: number + /** @description 起始角度,单位为度,默认 -90。 */ + startAngle?: number + /** @description 结束角度,单位为度。未配置时保持主题的角度跨度,内置主题为 360。角度跨度必须在 (0, 360]。 */ + endAngle?: number +} diff --git a/packages/vseed/src/types/properties/config/race.ts b/packages/vseed/src/types/properties/config/race.ts index 782f48c4d9..e60eb8ce24 100644 --- a/packages/vseed/src/types/properties/config/race.ts +++ b/packages/vseed/src/types/properties/config/race.ts @@ -2,6 +2,8 @@ import type { z } from 'zod' import { zBarConfig } from './bar' import { zColumnConfig } from './column' import { zScatterConfig } from './scatter' +import { zPieGeometry } from './pieGeometry' +import { zPieStyle } from '../markStyle/zPieStyle' import { zPlayer } from './player' @@ -18,9 +20,13 @@ export const zRaceLineConfig = zScatterConfig.extend({ player: zPlayer.nullish(), }) export const zRacePieConfig = zScatterConfig.extend({ + ...zPieGeometry.shape, + pieStyle: zPieStyle.nullish(), player: zPlayer.nullish(), }) export const zRaceDonutConfig = zScatterConfig.extend({ + ...zPieGeometry.shape, + pieStyle: zPieStyle.nullish(), player: zPlayer.nullish(), }) diff --git a/packages/vseed/src/types/properties/markStyle/index.ts b/packages/vseed/src/types/properties/markStyle/index.ts index 5ca36573f5..4e1dd612ba 100644 --- a/packages/vseed/src/types/properties/markStyle/index.ts +++ b/packages/vseed/src/types/properties/markStyle/index.ts @@ -2,6 +2,7 @@ export type * from './barStyle' export type * from './pointStyle' export type * from './lineStyle' export type * from './areaStyle' +export type * from './pieStyle' export type * from './boxPlotStyle' export type * from './outlierStyle' export type * from './markStyle' @@ -10,6 +11,7 @@ export * from './zBarStyle' export * from './zPointStyle' export * from './zLineStyle' export * from './zAreaStyle' +export * from './zPieStyle' export * from './zBoxPlotStyle' export * from './zOutlierStyle' export * from './zMarkStyle' diff --git a/packages/vseed/src/types/properties/markStyle/pieStyle.ts b/packages/vseed/src/types/properties/markStyle/pieStyle.ts new file mode 100644 index 0000000000..d5d9936698 --- /dev/null +++ b/packages/vseed/src/types/properties/markStyle/pieStyle.ts @@ -0,0 +1,10 @@ +export type PieStyle = { + /** @description 扇区边框颜色,默认跟随图表背景。 */ + pieBorderColor?: string + /** @description 扇区边框宽度,单位 px,默认按数据量自适应:不超过 30 条时为 1,否则为 0。0 表示无边框。 */ + pieBorderWidth?: number + /** @description 扇区圆角,单位 px。 */ + pieCornerRadius?: number + /** @description 悬停效果:enlarge 放大(默认)、opacity 降低透明度(固定为 0.75)、none 关闭。 */ + pieHoverEffect?: 'opacity' | 'enlarge' | 'none' +} diff --git a/packages/vseed/src/types/properties/markStyle/zPieStyle.ts b/packages/vseed/src/types/properties/markStyle/zPieStyle.ts new file mode 100644 index 0000000000..90d1c1e07b --- /dev/null +++ b/packages/vseed/src/types/properties/markStyle/zPieStyle.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const zPieStyle = z.object({ + pieBorderColor: z.string().nullish(), + pieBorderWidth: z.number().nonnegative().nullish(), + pieCornerRadius: z.number().nonnegative().nullish(), + pieHoverEffect: z.enum(['opacity', 'enlarge', 'none']).nullish(), +}) diff --git a/packages/vseed/tests/examples/features/centerText/defaults.json b/packages/vseed/tests/examples/features/centerText/defaults.json new file mode 100644 index 0000000000..3c02fde2bb --- /dev/null +++ b/packages/vseed/tests/examples/features/centerText/defaults.json @@ -0,0 +1,36 @@ +{ + "name": "themeDefaults", + "description": "只配置中心文本内容。内外半径、悬停、字号、字重和间距使用主题默认值;省略 theme 时使用 light。", + "vseed": { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "encoding": "angle" + } + ], + "label": { + "enable": false + }, + "centerText": { + "titleText": "62%", + "subTitleText": "消费者" + } + } +} diff --git a/packages/vseed/tests/examples/features/centerText/empty.json b/packages/vseed/tests/examples/features/centerText/empty.json new file mode 100644 index 0000000000..c064eb938e --- /dev/null +++ b/packages/vseed/tests/examples/features/centerText/empty.json @@ -0,0 +1,48 @@ +{ + "name": "emptyDonut", + "description": "无订单时两类占比均为零,展示固定占位文本,不伪造其他客户 100% 的扇区。", + "vseed": { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 0 + }, + { + "category": "其他客户", + "share": 0 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": ["#56dabb", "#c1f3e6"] + }, + "legend": { + "enable": false + }, + "centerText": { + "titleText": "—", + "subTitleText": "消费者" + } + } +} diff --git a/packages/vseed/tests/examples/features/centerText/pivot.json b/packages/vseed/tests/examples/features/centerText/pivot.json new file mode 100644 index 0000000000..25116dfdc2 --- /dev/null +++ b/packages/vseed/tests/examples/features/centerText/pivot.json @@ -0,0 +1,72 @@ +{ + "name": "pivotDonutCenter", + "description": "透视环图的单元格组合相同几何和中心文本 pipe;中心文本固定,不对数据隐式求和。", + "vseed": { + "chartType": "donut", + "dataset": [ + { + "region": "华东", + "category": "消费者", + "share": 62 + }, + { + "region": "华东", + "category": "其他客户", + "share": 38 + }, + { + "region": "华南", + "category": "消费者", + "share": 40 + }, + { + "region": "华南", + "category": "其他客户", + "share": 60 + } + ], + "dimensions": [ + { + "id": "region", + "encoding": "column" + }, + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": ["#56dabb", "#c1f3e6"] + }, + "legend": { + "interactive": false, + "position": "bottom", + "border": false, + "shapeType": "circle", + "labelFontSize": 10 + }, + "outerRadius": 0.9, + "innerRadius": 0.6, + "centerText": { + "titleText": "客户分布" + }, + "pieStyle": { + "pieBorderWidth": 0 + } + } +} diff --git a/packages/vseed/tests/examples/features/features.test.ts b/packages/vseed/tests/examples/features/features.test.ts index 18cf20742e..87e94c8356 100644 --- a/packages/vseed/tests/examples/features/features.test.ts +++ b/packages/vseed/tests/examples/features/features.test.ts @@ -37,76 +37,82 @@ import config_33 from './bodyCellStyle/透视表条件样式---根据度量值 import config_34 from './bodyCellStyle/透视表条件样式---符合条件.json' import config_35 from './brush/enable.json' import config_36 from './brush/removeonclick.json' -import config_37 from './color/colormapping.json' -import config_38 from './color/colorscheme.json' -import config_39 from './dataset/单指标-单维度.json' -import config_40 from './dataset/单指标-多维度.json' -import config_41 from './dataset/单指标-零维度.json' -import config_42 from './dataset/多指标-单维度.json' -import config_43 from './dataset/多指标-多维度.json' -import config_44 from './dataset/多指标-零维度.json' -import config_45 from './dataset/数据维度与指标配置.json' -import config_46 from './dataset/自动选择.json' -import config_47 from './dimensions/列维度.json' -import config_48 from './dimensions/普通维度.json' -import config_49 from './dimensions/行维度.json' -import config_50 from './dimensions/透视维度.json' -import config_51 from './dynamicFilter/annotation-horizontal-line.json' -import config_52 from './dynamicFilter/annotation-point.json' -import config_53 from './dynamicFilter/annotation-vertical-line.json' -import config_54 from './dynamicFilter/area-line-point-style.json' -import config_55 from './dynamicFilter/column-bar-style.json' -import config_56 from './dynamicFilter/pivot-table-cell-style.json' -import config_57 from './dynamicFilter/table-dynamic-cell-style.json' -import config_58 from './formatter/base-dim-formatter-bar.json' -import config_59 from './formatter/base-dim-formatter-dualAxis.json' -import config_60 from './formatter/base-dim-formatter-heatmap.json' -import config_61 from './formatter/base-dim-formatter-pie.json' -import config_62 from './formatter/base-dim-formatter-pivotChart.json' -import config_63 from './formatter/base-dim-formatter-pivotTable.json' -import config_64 from './formatter/base-dim-formatter-radar.json' -import config_65 from './formatter/base-dim-formatter-scatter.json' -import config_66 from './formatter/base-dim-formatter-table.json' -import config_67 from './formatter/base-dim-formatter-treemap.json' -import config_68 from './i18n/en-us.json' -import config_69 from './i18n/zh-cn.json' -import config_70 from './label/enable.json' -import config_71 from './legend/border.json' -import config_72 from './legend/enable.json' -import config_73 from './legend/labelcolor.json' -import config_74 from './legend/labelfontsize.json' -import config_75 from './legend/labelfontweight.json' -import config_76 from './legend/maxsize.json' -import config_77 from './legend/position.json' -import config_78 from './measures/指标组.json' -import config_79 from './numFormat/autoformat.json' -import config_80 from './numFormat/fractiondigits.json' -import config_81 from './numFormat/ratio-&-symbol.json' -import config_82 from './numFormat/roundingmode.json' -import config_83 from './numFormat/roundingpriority.json' -import config_84 from './numFormat/significantdigits.json' -import config_85 from './numFormat/suffix-&-prefix.json' -import config_86 from './numFormat/thousandseparator.json' -import config_87 from './numFormat/type.json' -import config_88 from './pointStyle/active-point.json' -import config_89 from './pointStyle/dimension-condition.json' -import config_90 from './pointStyle/measure-condition.json' -import config_91 from './pointStyle/partial-datum.json' -import config_92 from './pointStyle/point-array.json' -import config_93 from './pointStyle/value.json' -import config_94 from './polynomial/column-示例.json' -import config_95 from './polynomial/scatter-示例.json' -import config_96 from './sort/图例自身排序.json' -import config_97 from './sort/指标排序-1.json' -import config_98 from './sort/指标排序.json' -import config_99 from './sort/维度排序-1.json' -import config_100 from './sort/维度排序.json' -import config_101 from './sort/自定义排序(图例id).json' -import config_102 from './sort/自定义排序(图例名称).json' -import config_103 from './sort/自定义排序.json' -import config_104 from './totals/columnTotal.json' -import config_105 from './totals/rowTotal.json' -import config_106 from './totals/singleIndicator.json' +import config_37 from './centerText/defaults.json' +import config_38 from './centerText/empty.json' +import config_39 from './centerText/pivot.json' +import config_40 from './color/colormapping.json' +import config_41 from './color/colorscheme.json' +import config_42 from './dataset/单指标-单维度.json' +import config_43 from './dataset/单指标-多维度.json' +import config_44 from './dataset/单指标-零维度.json' +import config_45 from './dataset/多指标-单维度.json' +import config_46 from './dataset/多指标-多维度.json' +import config_47 from './dataset/多指标-零维度.json' +import config_48 from './dataset/数据维度与指标配置.json' +import config_49 from './dataset/自动选择.json' +import config_50 from './dimensions/列维度.json' +import config_51 from './dimensions/普通维度.json' +import config_52 from './dimensions/行维度.json' +import config_53 from './dimensions/透视维度.json' +import config_54 from './dynamicFilter/annotation-horizontal-line.json' +import config_55 from './dynamicFilter/annotation-point.json' +import config_56 from './dynamicFilter/annotation-vertical-line.json' +import config_57 from './dynamicFilter/area-line-point-style.json' +import config_58 from './dynamicFilter/column-bar-style.json' +import config_59 from './dynamicFilter/pivot-table-cell-style.json' +import config_60 from './dynamicFilter/table-dynamic-cell-style.json' +import config_61 from './formatter/base-dim-formatter-bar.json' +import config_62 from './formatter/base-dim-formatter-dualAxis.json' +import config_63 from './formatter/base-dim-formatter-heatmap.json' +import config_64 from './formatter/base-dim-formatter-pie.json' +import config_65 from './formatter/base-dim-formatter-pivotChart.json' +import config_66 from './formatter/base-dim-formatter-pivotTable.json' +import config_67 from './formatter/base-dim-formatter-radar.json' +import config_68 from './formatter/base-dim-formatter-scatter.json' +import config_69 from './formatter/base-dim-formatter-table.json' +import config_70 from './formatter/base-dim-formatter-treemap.json' +import config_71 from './i18n/en-us.json' +import config_72 from './i18n/zh-cn.json' +import config_73 from './label/enable.json' +import config_74 from './legend/border.json' +import config_75 from './legend/enable.json' +import config_76 from './legend/labelcolor.json' +import config_77 from './legend/labelfontsize.json' +import config_78 from './legend/labelfontweight.json' +import config_79 from './legend/maxsize.json' +import config_80 from './legend/position.json' +import config_81 from './legend/static.json' +import config_82 from './measures/指标组.json' +import config_83 from './numFormat/autoformat.json' +import config_84 from './numFormat/fractiondigits.json' +import config_85 from './numFormat/ratio-&-symbol.json' +import config_86 from './numFormat/roundingmode.json' +import config_87 from './numFormat/roundingpriority.json' +import config_88 from './numFormat/significantdigits.json' +import config_89 from './numFormat/suffix-&-prefix.json' +import config_90 from './numFormat/thousandseparator.json' +import config_91 from './numFormat/type.json' +import config_92 from './pieStyle/compact-donut.json' +import config_93 from './pieStyle/half-pie.json' +import config_94 from './pointStyle/active-point.json' +import config_95 from './pointStyle/dimension-condition.json' +import config_96 from './pointStyle/measure-condition.json' +import config_97 from './pointStyle/partial-datum.json' +import config_98 from './pointStyle/point-array.json' +import config_99 from './pointStyle/value.json' +import config_100 from './polynomial/column-示例.json' +import config_101 from './polynomial/scatter-示例.json' +import config_102 from './sort/图例自身排序.json' +import config_103 from './sort/指标排序-1.json' +import config_104 from './sort/指标排序.json' +import config_105 from './sort/维度排序-1.json' +import config_106 from './sort/维度排序.json' +import config_107 from './sort/自定义排序(图例id).json' +import config_108 from './sort/自定义排序(图例名称).json' +import config_109 from './sort/自定义排序.json' +import config_110 from './totals/columnTotal.json' +import config_111 from './totals/rowTotal.json' +import config_112 from './totals/singleIndicator.json' const cases = [ { name: 'animation/bar-like', vseed: config_0 }, @@ -146,76 +152,82 @@ const cases = [ { name: 'bodyCellStyle/透视表条件样式---符合条件', vseed: config_34 }, { name: 'brush/enable', vseed: config_35 }, { name: 'brush/removeonclick', vseed: config_36 }, - { name: 'color/colormapping', vseed: config_37 }, - { name: 'color/colorscheme', vseed: config_38 }, - { name: 'dataset/单指标-单维度', vseed: config_39 }, - { name: 'dataset/单指标-多维度', vseed: config_40 }, - { name: 'dataset/单指标-零维度', vseed: config_41 }, - { name: 'dataset/多指标-单维度', vseed: config_42 }, - { name: 'dataset/多指标-多维度', vseed: config_43 }, - { name: 'dataset/多指标-零维度', vseed: config_44 }, - { name: 'dataset/数据维度与指标配置', vseed: config_45 }, - { name: 'dataset/自动选择', vseed: config_46 }, - { name: 'dimensions/列维度', vseed: config_47 }, - { name: 'dimensions/普通维度', vseed: config_48 }, - { name: 'dimensions/行维度', vseed: config_49 }, - { name: 'dimensions/透视维度', vseed: config_50 }, - { name: 'dynamicFilter/annotation-horizontal-line', vseed: config_51 }, - { name: 'dynamicFilter/annotation-point', vseed: config_52 }, - { name: 'dynamicFilter/annotation-vertical-line', vseed: config_53 }, - { name: 'dynamicFilter/area-line-point-style', vseed: config_54 }, - { name: 'dynamicFilter/column-bar-style', vseed: config_55 }, - { name: 'dynamicFilter/pivot-table-cell-style', vseed: config_56 }, - { name: 'dynamicFilter/table-dynamic-cell-style', vseed: config_57 }, - { name: 'formatter/base-dim-formatter-bar', vseed: config_58 }, - { name: 'formatter/base-dim-formatter-dualAxis', vseed: config_59 }, - { name: 'formatter/base-dim-formatter-heatmap', vseed: config_60 }, - { name: 'formatter/base-dim-formatter-pie', vseed: config_61 }, - { name: 'formatter/base-dim-formatter-pivotChart', vseed: config_62 }, - { name: 'formatter/base-dim-formatter-pivotTable', vseed: config_63 }, - { name: 'formatter/base-dim-formatter-radar', vseed: config_64 }, - { name: 'formatter/base-dim-formatter-scatter', vseed: config_65 }, - { name: 'formatter/base-dim-formatter-table', vseed: config_66 }, - { name: 'formatter/base-dim-formatter-treemap', vseed: config_67 }, - { name: 'i18n/en-us', vseed: config_68 }, - { name: 'i18n/zh-cn', vseed: config_69 }, - { name: 'label/enable', vseed: config_70 }, - { name: 'legend/border', vseed: config_71 }, - { name: 'legend/enable', vseed: config_72 }, - { name: 'legend/labelcolor', vseed: config_73 }, - { name: 'legend/labelfontsize', vseed: config_74 }, - { name: 'legend/labelfontweight', vseed: config_75 }, - { name: 'legend/maxsize', vseed: config_76 }, - { name: 'legend/position', vseed: config_77 }, - { name: 'measures/指标组', vseed: config_78 }, - { name: 'numFormat/autoformat', vseed: config_79 }, - { name: 'numFormat/fractiondigits', vseed: config_80 }, - { name: 'numFormat/ratio-&-symbol', vseed: config_81 }, - { name: 'numFormat/roundingmode', vseed: config_82 }, - { name: 'numFormat/roundingpriority', vseed: config_83 }, - { name: 'numFormat/significantdigits', vseed: config_84 }, - { name: 'numFormat/suffix-&-prefix', vseed: config_85 }, - { name: 'numFormat/thousandseparator', vseed: config_86 }, - { name: 'numFormat/type', vseed: config_87 }, - { name: 'pointStyle/active-point', vseed: config_88 }, - { name: 'pointStyle/dimension-condition', vseed: config_89 }, - { name: 'pointStyle/measure-condition', vseed: config_90 }, - { name: 'pointStyle/partial-datum', vseed: config_91 }, - { name: 'pointStyle/point-array', vseed: config_92 }, - { name: 'pointStyle/value', vseed: config_93 }, - { name: 'polynomial/column-示例', vseed: config_94 }, - { name: 'polynomial/scatter-示例', vseed: config_95 }, - { name: 'sort/图例自身排序', vseed: config_96 }, - { name: 'sort/指标排序-1', vseed: config_97 }, - { name: 'sort/指标排序', vseed: config_98 }, - { name: 'sort/维度排序-1', vseed: config_99 }, - { name: 'sort/维度排序', vseed: config_100 }, - { name: 'sort/自定义排序(图例id)', vseed: config_101 }, - { name: 'sort/自定义排序(图例名称)', vseed: config_102 }, - { name: 'sort/自定义排序', vseed: config_103 }, - { name: 'totals/columnTotal', vseed: config_104 }, - { name: 'totals/rowTotal', vseed: config_105 }, - { name: 'totals/singleIndicator', vseed: config_106 } + { name: 'centerText/defaults', vseed: config_37 }, + { name: 'centerText/empty', vseed: config_38 }, + { name: 'centerText/pivot', vseed: config_39 }, + { name: 'color/colormapping', vseed: config_40 }, + { name: 'color/colorscheme', vseed: config_41 }, + { name: 'dataset/单指标-单维度', vseed: config_42 }, + { name: 'dataset/单指标-多维度', vseed: config_43 }, + { name: 'dataset/单指标-零维度', vseed: config_44 }, + { name: 'dataset/多指标-单维度', vseed: config_45 }, + { name: 'dataset/多指标-多维度', vseed: config_46 }, + { name: 'dataset/多指标-零维度', vseed: config_47 }, + { name: 'dataset/数据维度与指标配置', vseed: config_48 }, + { name: 'dataset/自动选择', vseed: config_49 }, + { name: 'dimensions/列维度', vseed: config_50 }, + { name: 'dimensions/普通维度', vseed: config_51 }, + { name: 'dimensions/行维度', vseed: config_52 }, + { name: 'dimensions/透视维度', vseed: config_53 }, + { name: 'dynamicFilter/annotation-horizontal-line', vseed: config_54 }, + { name: 'dynamicFilter/annotation-point', vseed: config_55 }, + { name: 'dynamicFilter/annotation-vertical-line', vseed: config_56 }, + { name: 'dynamicFilter/area-line-point-style', vseed: config_57 }, + { name: 'dynamicFilter/column-bar-style', vseed: config_58 }, + { name: 'dynamicFilter/pivot-table-cell-style', vseed: config_59 }, + { name: 'dynamicFilter/table-dynamic-cell-style', vseed: config_60 }, + { name: 'formatter/base-dim-formatter-bar', vseed: config_61 }, + { name: 'formatter/base-dim-formatter-dualAxis', vseed: config_62 }, + { name: 'formatter/base-dim-formatter-heatmap', vseed: config_63 }, + { name: 'formatter/base-dim-formatter-pie', vseed: config_64 }, + { name: 'formatter/base-dim-formatter-pivotChart', vseed: config_65 }, + { name: 'formatter/base-dim-formatter-pivotTable', vseed: config_66 }, + { name: 'formatter/base-dim-formatter-radar', vseed: config_67 }, + { name: 'formatter/base-dim-formatter-scatter', vseed: config_68 }, + { name: 'formatter/base-dim-formatter-table', vseed: config_69 }, + { name: 'formatter/base-dim-formatter-treemap', vseed: config_70 }, + { name: 'i18n/en-us', vseed: config_71 }, + { name: 'i18n/zh-cn', vseed: config_72 }, + { name: 'label/enable', vseed: config_73 }, + { name: 'legend/border', vseed: config_74 }, + { name: 'legend/enable', vseed: config_75 }, + { name: 'legend/labelcolor', vseed: config_76 }, + { name: 'legend/labelfontsize', vseed: config_77 }, + { name: 'legend/labelfontweight', vseed: config_78 }, + { name: 'legend/maxsize', vseed: config_79 }, + { name: 'legend/position', vseed: config_80 }, + { name: 'legend/static', vseed: config_81 }, + { name: 'measures/指标组', vseed: config_82 }, + { name: 'numFormat/autoformat', vseed: config_83 }, + { name: 'numFormat/fractiondigits', vseed: config_84 }, + { name: 'numFormat/ratio-&-symbol', vseed: config_85 }, + { name: 'numFormat/roundingmode', vseed: config_86 }, + { name: 'numFormat/roundingpriority', vseed: config_87 }, + { name: 'numFormat/significantdigits', vseed: config_88 }, + { name: 'numFormat/suffix-&-prefix', vseed: config_89 }, + { name: 'numFormat/thousandseparator', vseed: config_90 }, + { name: 'numFormat/type', vseed: config_91 }, + { name: 'pieStyle/compact-donut', vseed: config_92 }, + { name: 'pieStyle/half-pie', vseed: config_93 }, + { name: 'pointStyle/active-point', vseed: config_94 }, + { name: 'pointStyle/dimension-condition', vseed: config_95 }, + { name: 'pointStyle/measure-condition', vseed: config_96 }, + { name: 'pointStyle/partial-datum', vseed: config_97 }, + { name: 'pointStyle/point-array', vseed: config_98 }, + { name: 'pointStyle/value', vseed: config_99 }, + { name: 'polynomial/column-示例', vseed: config_100 }, + { name: 'polynomial/scatter-示例', vseed: config_101 }, + { name: 'sort/图例自身排序', vseed: config_102 }, + { name: 'sort/指标排序-1', vseed: config_103 }, + { name: 'sort/指标排序', vseed: config_104 }, + { name: 'sort/维度排序-1', vseed: config_105 }, + { name: 'sort/维度排序', vseed: config_106 }, + { name: 'sort/自定义排序(图例id)', vseed: config_107 }, + { name: 'sort/自定义排序(图例名称)', vseed: config_108 }, + { name: 'sort/自定义排序', vseed: config_109 }, + { name: 'totals/columnTotal', vseed: config_110 }, + { name: 'totals/rowTotal', vseed: config_111 }, + { name: 'totals/singleIndicator', vseed: config_112 } ] describe('features', () => { diff --git a/packages/vseed/tests/examples/features/legend/static.json b/packages/vseed/tests/examples/features/legend/static.json new file mode 100644 index 0000000000..d8e4145296 --- /dev/null +++ b/packages/vseed/tests/examples/features/legend/static.json @@ -0,0 +1,48 @@ +{ + "name": "staticLegend", + "description": "设置 interactive: false,仅关闭图例悬停、选择、聚焦和筛选;沿用既有布局和标记样式。", + "vseed": { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": ["#56dabb", "#c1f3e6"] + }, + "legend": { + "interactive": false, + "position": "bottom", + "border": false, + "shapeType": "circle", + "labelFontSize": 10 + } + } +} diff --git a/packages/vseed/tests/examples/features/pieStyle/compact-donut.json b/packages/vseed/tests/examples/features/pieStyle/compact-donut.json new file mode 100644 index 0000000000..8d09dd0c41 --- /dev/null +++ b/packages/vseed/tests/examples/features/pieStyle/compact-donut.json @@ -0,0 +1,59 @@ +{ + "name": "compactDonut", + "description": "静态图例仅说明分类,配置悬停降低扇区透明度,中心文本使用主题字号,超长文本按环内宽度省略。", + "vseed": { + "chartType": "donut", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": ["#56dabb", "#c1f3e6"] + }, + "legend": { + "interactive": false, + "position": "bottom", + "border": false, + "shapeType": "circle", + "labelFontSize": 10 + }, + "outerRadius": 0.98, + "innerRadius": 0.74, + "startAngle": -115, + "pieStyle": { + "pieBorderWidth": 0, + "pieHoverEffect": "opacity" + }, + "centerText": { + "titleText": "62%", + "subTitleText": "消费者" + } + } +} diff --git a/packages/vseed/tests/examples/features/pieStyle/half-pie.json b/packages/vseed/tests/examples/features/pieStyle/half-pie.json new file mode 100644 index 0000000000..e5ca28d537 --- /dev/null +++ b/packages/vseed/tests/examples/features/pieStyle/half-pie.json @@ -0,0 +1,50 @@ +{ + "name": "halfPie", + "description": "半圆饼图保留默认颜色,配置圆角、边框和关闭悬停样式。", + "vseed": { + "chartType": "pie", + "dataset": [ + { + "category": "消费者", + "share": 62 + }, + { + "category": "其他客户", + "share": 38 + } + ], + "dimensions": [ + { + "id": "category", + "encoding": "color" + } + ], + "measures": [ + { + "id": "share", + "alias": "订单占比", + "encoding": "angle", + "format": { + "type": "number", + "suffix": "%", + "fractionDigits": 1 + } + } + ], + "label": { + "enable": false + }, + "color": { + "colorScheme": ["#56dabb", "#c1f3e6"] + }, + "startAngle": -180, + "endAngle": 0, + "outerRadius": 0.9, + "pieStyle": { + "pieCornerRadius": 4, + "pieBorderColor": "white", + "pieBorderWidth": 2, + "pieHoverEffect": "none" + } + } +} diff --git a/packages/vseed/tests/unit/builder/legendOptions.test.ts b/packages/vseed/tests/unit/builder/legendOptions.test.ts new file mode 100644 index 0000000000..2e7a8cdb8c --- /dev/null +++ b/packages/vseed/tests/unit/builder/legendOptions.test.ts @@ -0,0 +1,141 @@ +import VChart from '@visactor/vchart' +import { Builder, registerAll } from 'src/builder' +import { zLegend, type Legend } from 'src/types' +import { discreteLegend } from 'src/pipeline/spec/chart/pipes/legend/discreteLegend' +import { pivotDiscreteLegend } from 'src/pipeline/spec/chart/pipes/legend/pivotDiscreteLegend' + +beforeAll(registerAll) + +const build = (legend: Legend = {}, pivot = false) => + Builder.from({ + chartType: 'donut', + dataset: [ + { category: 'A', value: 40 }, + { category: 'B', value: 60 }, + ], + dimensions: [{ id: 'category', encoding: pivot ? 'row' : 'color' }], + measures: [{ id: 'value', encoding: 'angle' }], + label: { enable: false }, + animation: { enable: false }, + legend, + }).build() + +describe('discrete legend interaction', () => { + test('composing a legend pipe without its configuration preserves the incoming spec', () => { + const vseed = { chartType: 'donut' as const, dataset: [{ category: 'A', value: 1 }] } + const advancedVSeed = Builder.from(vseed).buildAdvanced() + const spec = { width: 320, height: 240 } + for (const config of [undefined, {}, { color: {} }]) { + const context = { vseed, advancedVSeed: { ...advancedVSeed, config: { donut: config } } } + expect(discreteLegend(spec, context)).toEqual(spec) + expect(pivotDiscreteLegend(spec, context)).toEqual(spec) + } + expect( + pivotDiscreteLegend(spec, { vseed, advancedVSeed: { ...advancedVSeed, config: { donut: { legend: {} } } } }), + ).toEqual(spec) + }) + + const normalize = (legend: any) => ({ + ...legend, + item: { + ...legend.item, + shape: { + ...legend.item.shape, + style: + typeof legend.item.shape.style === 'function' + ? legend.item.shape.style({ shape: { fill: 'red' } }) + : legend.item.shape.style, + }, + }, + }) + + test.each([false, true])('disabling interaction changes no presentation in pivot=%s', (pivot) => { + const options: Legend = { shapeType: 'circle', border: false, labelFontSize: 10, position: 'bottom' } + const normal = normalize(build(options, pivot).legends) + const staticLegend = normalize(build({ ...options, interactive: false }, pivot).legends) + expect(staticLegend).toEqual({ + ...normal, + interactive: false, + select: false, + hover: false, + item: { ...normal.item, focus: false }, + }) + expect(normal).toMatchObject({ item: { focus: true } }) + for (const key of ['interactive', 'select', 'hover']) expect(normal).not.toHaveProperty(key) + expect(normalize(build({ ...options, interactive: true }, pivot).legends)).toEqual(normal) + expect(build({ enable: false, interactive: false }, pivot).legends).toEqual(pivot ? [] : { visible: false }) + }) + + test.each( + [false, true].flatMap((pivot) => + [false, true].flatMap((border) => [10, 12, 18].map((labelFontSize) => ({ pivot, border, labelFontSize }))), + ), + )('preserves existing legend sizing and spacing: %j', ({ pivot, border, labelFontSize }) => { + const legend = normalize(build({ border, labelFontSize }, pivot).legends) + expect(legend).toMatchObject({ + padding: 0, + item: { + focus: true, + maxWidth: '30%', + shape: { space: border ? 6 : 4, style: { size: border ? 8 : 10, symbolType: 'rectRound' } }, + label: { style: { fontSize: labelFontSize } }, + }, + }) + expect(legend.space).toBeUndefined() + expect(legend.item.spaceCol).toBeUndefined() + expect(legend.item.spaceRow).toBeUndefined() + if (!pivot) { + expect(legend.item.shape.style).toMatchObject({ fillOpacity: 1, opacity: 1, stroke: false }) + expect(legend.item.shape.style.outerBorder).toEqual(border ? { stroke: 'red', distance: 3, lineWidth: 1 } : null) + } + }) + + test.each(['top', 'bottom', 'left', 'right'] as const)('pivot keeps its existing zero padding at %s', (position) => { + expect(build({ position }, true).legends.padding).toBe(0) + }) + + test('schema exposes the interaction choice without layout tuning fields', () => { + const config = { interactive: false, labelFontSize: 10 } + expect(zLegend.parse(config)).toMatchObject(config) + expect(zLegend.safeParse({ interactive: 'false' }).success).toBe(false) + for (const key of ['shapeSize', 'shapeGap', 'itemGap', 'itemMaxWidth', 'chartGap']) { + expect(zLegend.shape).not.toHaveProperty(key) + } + }) + + test('static legend events do not filter chart data', async () => { + const element = document.createElement('div') + document.body.append(element) + const spec = { ...build({ interactive: false, border: false }), width: 320, height: 240 } + const chart = new VChart(spec, { dom: element }) + try { + chart.renderSync() + const legend = chart + .getChart()! + .getAllComponents() + .find((component) => component.type === 'discreteLegend') as any + expect(legend).toBeDefined() + const click = (component: any) => { + const items = component._legendComponent._itemsContainer + const target = items.find((node: any) => !!node.delegate && node.name?.startsWith('legend'), true) + expect(target).toBeDefined() + expect(items.listenerCount('pointerdown')).toBe(component.getSpec().select !== false ? 1 : 0) + if (component.getSpec().select !== false) component._legendComponent._onClick({ target }) + else items.emit('pointerdown', { target }) + } + click(legend) + expect(chart.getChart()!.getAllSeries()[0].getViewData()!.latestData).toHaveLength(2) + await chart.updateSpec({ ...build({ border: false }), width: 320, height: 240 }) + const interactive = chart + .getChart()! + .getAllComponents() + .find((component) => component.type === 'discreteLegend') as any + expect(interactive.getSpec().select).not.toBe(false) + click(interactive) + expect(chart.getChart()!.getAllSeries()[0].getViewData()!.latestData).toHaveLength(1) + } finally { + chart.release() + element.remove() + } + }) +}) diff --git a/packages/vseed/tests/unit/builder/piePresentation.test.ts b/packages/vseed/tests/unit/builder/piePresentation.test.ts new file mode 100644 index 0000000000..888268c61a --- /dev/null +++ b/packages/vseed/tests/unit/builder/piePresentation.test.ts @@ -0,0 +1,395 @@ +import VChart from '@visactor/vchart' +import { Builder, registerAll, registerCustomTheme } from 'src/builder' +import { zCenterText, zCustomThemeConfig, zDonut, zPie, zScatterConfig, type Donut, type VSeed } from 'src/types' +import { zRacePieConfig, zRaceDonutConfig } from 'src/types/properties/config/race' +import { pieStyle } from 'src/pipeline/spec/chart/pipes/markStyle/pieStyle' +import { pieHover } from 'src/pipeline/spec/chart/pipes/markStyle/pieHover' + +beforeAll(registerAll) + +const seed = (options: Partial = {}): Donut => ({ + chartType: 'donut', + dataset: [ + { category: '消费者', share: 60 }, + { category: '其他客户', share: 40 }, + ], + dimensions: [{ id: 'category', encoding: 'color' }], + measures: [{ id: 'share', encoding: 'angle' }], + label: { enable: false }, + animation: { enable: false }, + ...options, +}) +const build = (options: Partial = {}) => Builder.from(seed(options)).build() + +describe('pie presentation', () => { + test.each(['pie', 'donut', 'racePie', 'raceDonut'] as const)( + '%s preserves its existing radii, borders and hover enlargement', + (chartType) => { + const spec = Builder.from({ ...seed(), chartType } as VSeed).build() + expect(spec.outerRadius).toBe(0.8) + expect(spec.innerRadius).toBe(chartType.toLowerCase().includes('donut') ? 0.8 * 0.8 : 0) + expect(spec.pie.state.hover).toEqual({ outerRadius: 0.8 * 1.1 }) + expect(spec.startAngle).toBeUndefined() + expect(spec.endAngle).toBeUndefined() + expect(spec.pie.style.cornerRadius).toBeUndefined() + expect(spec.pie.style.lineWidth).toBe(1) + expect(spec.indicator).toBeUndefined() + }, + ) + + test('geometry, appearance and center text survive serialization and schema validation', () => { + const dsl = seed({ + outerRadius: 0.98, + innerRadius: 0.74, + startAngle: -115, + endAngle: 245, + pieStyle: { pieBorderWidth: 0, pieBorderColor: 'red', pieCornerRadius: 0, pieHoverEffect: 'opacity' }, + centerText: { + titleText: 0, + subTitleText: '消费者', + }, + }) + const parsed = zDonut.parse(JSON.parse(JSON.stringify(dsl))) + const spec = Builder.from(parsed as Donut).build() + expect(spec).toMatchObject({ outerRadius: 0.98, innerRadius: 0.74, startAngle: -115, endAngle: 245 }) + expect(spec.pie).toMatchObject({ + style: { lineWidth: 0, stroke: 'red', cornerRadius: 0 }, + state: { hover: { fillOpacity: 0.75 } }, + }) + expect(spec.pie.state.hover.outerRadius).toBeUndefined() + expect(spec.indicator).toMatchObject({ + visible: true, + fixed: true, + trigger: 'none', + limitRatio: 0.74, + title: { autoLimit: true, style: { text: 0, fontSize: 18, fontWeight: 600 } }, + content: { autoLimit: true, style: { text: '消费者', fontSize: 12 } }, + }) + expect(zPie.parse({ ...dsl, chartType: 'pie' })).toMatchObject({ outerRadius: 0.98, pieStyle: dsl.pieStyle }) + }) + + test('radius and angle defaults resolve independently of the chart initializer', () => { + expect(build({ outerRadius: 0.5 }).innerRadius).toBeCloseTo(0.4) + expect(build({ startAngle: 0 })).toMatchObject({ startAngle: 0, endAngle: 360 }) + expect(build({ endAngle: 90 })).toMatchObject({ startAngle: -90, endAngle: 90 }) + expect(build({ innerRadius: 0 })).toMatchObject({ innerRadius: 0 }) + }) + + test.each([ + { outerRadius: 0 }, + { outerRadius: 1.1 }, + { innerRadius: -1 }, + { innerRadius: 0.9, outerRadius: 0.8 }, + { innerRadius: 0.8 }, + { startAngle: 0, endAngle: 0 }, + { startAngle: 0, endAngle: 361 }, + ])('rejects invalid geometry %j at the build boundary', (options) => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => build(options)).toThrow(/Pie (radii|angle)/) + } finally { + log.mockRestore() + } + }) + + test('schema rejects invalid visual values', () => { + for (const options of [ + { outerRadius: 2 }, + { pieStyle: { pieBorderWidth: -1 } }, + { pieStyle: { pieHoverEffect: 'invalid' } }, + { centerText: { titleText: { text: '42' } } }, + ]) + expect(zDonut.safeParse(seed(options as Partial)).success).toBe(false) + }) + + test('hover choices and optional center text need no presentation tuning', () => { + expect(build({ pieStyle: { pieHoverEffect: 'none' } }).pie.state.hover).toEqual({}) + expect(build({ pieStyle: { pieHoverEffect: 'opacity' } }).pie.state.hover).toEqual({ fillOpacity: 0.75 }) + expect(build({ pieStyle: { pieHoverEffect: 'enlarge' } }).pie.state.hover.outerRadius).toBe(0.8 * 1.1) + expect(build().indicator).toBeUndefined() + expect(build({ centerText: { subTitleText: '—' } }).indicator.title.visible).toBe(false) + expect(build({ centerText: { titleText: '42' } }).indicator.content.visible).toBe(false) + expect(build({ centerText: { titleText: 0 } }).indicator.title).toMatchObject({ visible: true, style: { text: 0 } }) + }) + + test.each(['light', 'dark'])('center text inherits %s theme colors', (theme) => { + const builder = Builder.from(seed({ theme, centerText: { titleText: '42', subTitleText: '消费者' } })) + const config = builder.buildAdvanced().config.donut! + const spec = builder.build() + expect(spec.indicator.title.style.fill).toBe(config.legend!.labelColor) + expect(spec.indicator.content.style.fill).toBe(config.legend!.labelColor) + }) + + test.each(['light', 'dark'])('%s provides presentation defaults before building a spec', (theme) => { + const registered = zCustomThemeConfig.parse(Builder.getTheme(theme)).config! + for (const chartType of ['pie', 'donut', 'racePie', 'raceDonut'] as const) { + const builder = Builder.from({ ...seed({ theme }), chartType } as VSeed) + const config = builder.buildAdvanced().config[chartType]! + expect(config).toMatchObject({ + outerRadius: registered[chartType]!.outerRadius, + innerRadius: registered[chartType]!.innerRadius, + pieStyle: { pieHoverEffect: 'enlarge' }, + legend: { interactive: true }, + }) + expect(config.pieStyle?.pieBorderWidth).toBeUndefined() + expect(config.startAngle).toBeUndefined() + expect(config.endAngle).toBeUndefined() + expect(builder.build().indicator).toBeUndefined() + } + expect(registered.donut!.centerText).toMatchObject({ + titleFontSize: 18, + titleFontWeight: 600, + subTitleFontSize: 12, + subTitleOpacity: 0.75, + gap: 2, + }) + expect(Object.keys(zCenterText.shape)).toEqual(['titleText', 'subTitleText']) + expect(build({ theme, centerText: {} }).indicator).toBeUndefined() + }) + + test.each([false, true])('theme geometry and typography apply with only text in the DSL, pivot=%s', (pivot) => { + registerCustomTheme('donut-defaults-test', ({ darkTheme }) => ({ + ...darkTheme, + config: { + ...darkTheme.config, + donut: { + ...darkTheme.config!.donut, + outerRadius: 0.8, + innerRadius: 0.4, + startAngle: 10, + endAngle: 190, + legend: { ...darkTheme.config!.donut!.legend, interactive: false }, + pieStyle: { pieHoverEffect: 'none', pieBorderWidth: 0 }, + centerText: { + ...darkTheme.config!.donut!.centerText, + titleFontSize: 24, + titleFontWeight: 500, + subTitleFontSize: 10, + subTitleOpacity: 0, + gap: 0, + }, + }, + }, + })) + const spec = build({ + theme: 'donut-defaults-test', + dimensions: [{ id: 'category', encoding: pivot ? 'row' : 'color' }], + centerText: { titleText: 0, subTitleText: '消费者' }, + outerRadius: 0.4, + startAngle: 30, + }) + const chart = pivot ? spec.indicators[0].chartSpec : spec + expect(chart).toMatchObject({ outerRadius: 0.4, innerRadius: 0.2, startAngle: 30, endAngle: 210 }) + expect(chart.pie).toMatchObject({ style: { lineWidth: 0 }, state: { hover: {} } }) + expect(chart.indicator).toMatchObject({ + limitRatio: 0.2, + title: { style: { text: 0, fontSize: 24, fontWeight: 500 } }, + content: { style: { text: '消费者', fontSize: 10, fillOpacity: 0 } }, + gap: 0, + }) + expect(spec.legends).toMatchObject({ interactive: false, select: false, hover: false }) + expect(Builder.getTheme('donut-defaults-test').config!.donut).toMatchObject({ + outerRadius: 0.8, + innerRadius: 0.4, + startAngle: 10, + endAngle: 190, + }) + }) + + test.each(['pie', 'donut', 'racePie', 'raceDonut'] as const)( + '%s preserves adaptive borders with legacy themes', + (chartType) => { + registerCustomTheme('legacy-pie-test', { + config: { [chartType]: { backgroundColor: '#123456', cornerRadius: 5 } }, + }) + for (const theme of ['light', 'dark', 'legacy-pie-test', 'unregistered-pie-test']) { + for (const count of [30, 31]) { + const spec = Builder.from({ + ...seed({ theme }), + chartType, + dataset: Array.from({ length: count }, (_, index) => ({ category: `${index}`, share: 1 })), + } as VSeed).build() + expect(spec.pie.style.lineWidth).toBe(count <= 30 ? 1 : 0) + expect(spec.pie.state.hover).toEqual({ outerRadius: 0.8 * 1.1 }) + expect(spec.pie.style.stroke).toBe( + theme === 'legacy-pie-test' ? '#123456' : theme === 'unregistered-pie-test' ? '#ffffff' : 'transparent', + ) + expect(spec.pie.style.cornerRadius).toBe(theme === 'legacy-pie-test' ? 5 : undefined) + expect(spec.indicator).toBeUndefined() + } + } + }, + ) + + test('explicit border width overrides the adaptive default in the theme or DSL', () => { + registerCustomTheme('dense-pie-test', { config: { donut: { pieStyle: { pieBorderWidth: 2 } } } }) + const dataset = Array.from({ length: 31 }, (_, index) => ({ category: `${index}`, share: 1 })) + expect(build({ dataset }).pie.style.lineWidth).toBe(0) + expect(build({ dataset, theme: 'dense-pie-test' }).pie.style.lineWidth).toBe(2) + expect(build({ dataset, theme: 'dense-pie-test', pieStyle: { pieBorderWidth: 0 } }).pie.style.lineWidth).toBe(0) + expect(build({ dataset, pieStyle: { pieBorderWidth: 1 } }).pie.style.lineWidth).toBe(1) + }) + + test('race theme schemas retain every existing field and validator', () => { + for (const schema of [zRacePieConfig, zRaceDonutConfig]) { + for (const [key, validator] of Object.entries(zScatterConfig.shape)) { + expect(schema.shape[key as keyof typeof zScatterConfig.shape]).toBe(validator) + } + const legacy = { + xAxis: { enable: false }, + yAxis: { enable: false }, + size: [4, 20], + sizeRange: 8, + annotation: {}, + regressionLine: {}, + dimensionLinkage: {}, + animation: { enter: { type: 'grow' } }, + } + expect(schema.parse(legacy)).toEqual(zScatterConfig.parse(legacy)) + } + }) + + test('appearance and hover pipes preserve fallbacks when composed without a theme or radius', () => { + const vseed = seed() + const advancedVSeed = { ...Builder.from(vseed).buildAdvanced(), config: {} } + const context = { vseed, advancedVSeed } + const spec = pieStyle({ width: 200 }, context) + expect(spec).toEqual({ width: 200, pie: { style: { stroke: '#ffffff', lineWidth: 1 } } }) + expect(pieHover(spec, context)).toEqual(spec) + }) + + test('explicit presentation overrides theme defaults, including zero values', () => { + registerCustomTheme('pie-presentation-test', ({ lightTheme }) => ({ + ...lightTheme, + config: { + ...lightTheme.config, + donut: { + ...lightTheme.config!.donut, + outerRadius: 0.9, + startAngle: 0, + endAngle: 360, + pieStyle: { pieBorderWidth: 3, pieCornerRadius: 8, pieHoverEffect: 'enlarge' }, + label: { ...lightTheme.config!.donut!.label, labelColor: 'red' }, + centerText: { titleText: 'Theme' }, + }, + }, + })) + const spec = build({ + theme: 'pie-presentation-test', + pieStyle: { pieBorderWidth: 0 }, + centerText: { titleText: 'DSL' }, + }) + expect(spec).toMatchObject({ outerRadius: 0.9, startAngle: 0, endAngle: 360 }) + expect(spec.pie.style).toMatchObject({ lineWidth: 0, cornerRadius: 8 }) + expect(spec.pie.state.hover).toEqual({ outerRadius: 0.9 * 1.1 }) + expect(spec.indicator.title.style).toMatchObject({ text: 'DSL', fill: 'red' }) + }) + + test('pivot donut cells compose the same presentation pipes', () => { + const spec = build({ + dimensions: [{ id: 'category', encoding: 'row' }], + outerRadius: 0.9, + innerRadius: 0.6, + pieStyle: { pieBorderWidth: 0, pieHoverEffect: 'none' }, + centerText: { titleText: '固定标题' }, + }) + expect(spec.indicators[0].chartSpec).toMatchObject({ + outerRadius: 0.9, + innerRadius: 0.6, + pie: { style: { lineWidth: 0 }, state: { hover: {} } }, + indicator: { fixed: true, title: { style: { text: '固定标题' } } }, + }) + }) + + test.each([false, true])('presentation preserves radius animation callbacks, customized=%s', (customized) => { + const spec = build({ + ...(customized + ? { outerRadius: 0.95, pieStyle: { pieHoverEffect: 'opacity' as const }, centerText: { titleText: '60%' } } + : {}), + animation: { + enable: true, + params: { + appear: { enable: true, effects: ['scale'] }, + loop: { enable: true, loop: { effects: ['enlarge'] } }, + }, + }, + }) + expect(spec.animationAppear.pie.type).toBe('growRadiusIn') + const phases = spec.animationNormal.pie + // The loop must use each rendered sector's radius and return to it, independently of hover styling. + const element = { attribute: { outerRadius: 140 } } + expect( + phases.map((phase: any) => [ + phase.channel.outerRadius.from({}, element), + phase.channel.outerRadius.to({}, element), + ]), + ).toEqual([ + [140, 150], + [150, 140], + ]) + expect(spec.pie.state.hover).toEqual(customized ? { fillOpacity: 0.75 } : { outerRadius: 0.8 * 1.1 }) + }) + + test('renders, resizes, removes and restores center text without losing the series', async () => { + const renderSpec = (share: number | null, enabled = true) => ({ + ...build({ + dataset: [ + { category: '消费者', share: share ?? 0 }, + { category: '其他客户', share: share === null ? 0 : 100 - share }, + ], + outerRadius: 0.98, + innerRadius: 0.74, + legend: { interactive: false, border: false, shapeType: 'circle', labelFontSize: 10 }, + pieStyle: { pieBorderWidth: 0 }, + centerText: enabled ? { titleText: share === null ? '—' : `${share}%`, subTitleText: '消费者' } : undefined, + }), + width: 320, + height: 240, + }) + const element = document.createElement('div') + document.body.append(element) + const chart = new VChart(renderSpec(60), { dom: element }) + const indicator = () => [chart.getSpec().indicator].flat()[0] as any + try { + chart.renderSync() + const series = chart.getChart()!.getAllSeries()[0] + const titleWidth = () => { + const component = chart + .getChart()! + .getAllComponents() + .find((item) => item.type === 'indicator') as any + return component._indicatorComponent._title.attribute.maxLineWidth as number + } + const initialWidth = titleWidth() + chart.resize(160, 100) + expect(titleWidth()).toBeGreaterThan(0) + expect(titleWidth()).toBeLessThan(initialWidth) + chart.resize(320, 240) + expect(titleWidth()).toBe(initialWidth) + await chart.updateSpec(renderSpec(25)) + expect(chart.getChart()!.getAllSeries()[0] === series).toBe(true) + expect(indicator().title.style.text).toBe('25%') + await chart.updateSpec(renderSpec(25, false)) + expect( + chart + .getChart()! + .getAllComponents() + .some((component) => component.type === 'indicator'), + ).toBe(false) + await chart.updateSpec(renderSpec(null)) + expect( + chart + .getChart()! + .getAllSeries()[0] + .getViewData()! + .latestData.map((row: any) => row.share), + ).toEqual([0, 0]) + expect(indicator().title.style.text).toBe('—') + await chart.updateSpec(renderSpec(80)) + expect(indicator().title.style.text).toBe('80%') + } finally { + chart.release() + element.remove() + } + }) +}) diff --git a/packages/vseed/vite.config.ts b/packages/vseed/vite.config.ts index ffc7af7ce4..293f5dcba5 100644 --- a/packages/vseed/vite.config.ts +++ b/packages/vseed/vite.config.ts @@ -20,10 +20,10 @@ export default defineConfig(({ mode }) => ({ // V8 branch counts vary slightly between runs; use verified floors. thresholds: mode === 'unit' - ? { statements: 82, lines: 82, branches: 76, functions: 80.6 } + ? { statements: 82.57, lines: 82.57, branches: 77.0, functions: 81.7 } : mode === 'integration' ? undefined - : { statements: 90.5, lines: 90.5, branches: 81.5, functions: 83.4 }, + : { statements: 90.98, lines: 90.98, branches: 82.1, functions: 84.5 }, }, globals: true, environment: 'jsdom', diff --git a/skills/vbi/examples/lightweight-dashboard.html b/skills/vbi/examples/lightweight-dashboard.html index 4d2529030d..46d0af2765 100644 --- a/skills/vbi/examples/lightweight-dashboard.html +++ b/skills/vbi/examples/lightweight-dashboard.html @@ -1153,12 +1153,12 @@

利润

function buildConsumerRingSpec(share, hasOrders) { // 占比来自 VBI 的去重订单查询;固定分类和字段,供 VChart 匹配更新前后的扇区。 - const spec = Builder.from({ + return Builder.from({ chartType: 'donut', animation: updateAnimation(), dataset: [ - { category: '消费者', share }, - { category: '其他客户', share: 100 - share }, + { category: '消费者', share: hasOrders ? share : 0 }, + { category: '其他客户', share: hasOrders ? 100 - share : 0 }, ], dimensions: [{ id: 'category', alias: '客户类型', encoding: 'color' }], measures: [ @@ -1172,28 +1172,23 @@

利润

backgroundColor: 'transparent', color: { colorScheme: ['#56dabb', '#c1f3e6'] }, label: { enable: false }, - legend: { enable: true, position: 'bottom', border: false, labelFontSize: 10, labelColor: '#909aa4' }, + // 图例只说明分类,保持中心占比与完整订单口径一致。 + legend: { + interactive: false, + position: 'bottom', + border: false, + labelFontSize: 10, + shapeType: 'circle', + }, + outerRadius: 0.98, + innerRadius: 0.74, + pieStyle: { pieBorderWidth: 0, pieHoverEffect: 'opacity' }, + centerText: { + titleText: hasOrders ? `${Math.round(share)}%` : '—', + subTitleText: '消费者', + }, tooltip: { enable: hasOrders }, }).build() - spec.outerRadius = 0.98 - spec.innerRadius = 0.74 - spec.startAngle = -115 - spec.endAngle = 245 - // 图例只说明分类;不筛掉扇区,保证中心占比与完整订单口径一致。 - Object.assign(spec.legends, { filter: false, select: false, space: 4 }) - Object.assign(spec.legends.item, { focus: false, maxWidth: '100%', spaceCol: 10 }) - spec.legends.item.shape = { space: 4, style: { symbolType: 'circle', size: 6 } } - spec.indicator = { - visible: true, - fixed: true, - trigger: 'none', - title: { - style: { text: hasOrders ? `${Math.round(share)}%` : '—', fontSize: 14, fontWeight: 550, fill: '#28333e' }, - }, - content: { space: 2, style: { text: '消费者', fontSize: 8, fill: '#909aa4' } }, - } - spec.pie = { style: { lineWidth: 0 }, state: { hover: { fillOpacity: 0.75 } } } - return spec } async function draw(element, spec, chart) {