diff --git a/.github/workflows/skill-generation-test.yml b/.github/workflows/skill-generation-test.yml
new file mode 100644
index 0000000000..d7ee3a3778
--- /dev/null
+++ b/.github/workflows/skill-generation-test.yml
@@ -0,0 +1,32 @@
+name: Skill generation contracts
+
+on:
+ pull_request:
+ branches: ['develop', 'main']
+ paths:
+ - 'skills/vchart-development-assistant/**'
+ - 'packages/vchart/skill-maintenance/**'
+ - '.github/workflows/skill-generation-test.yml'
+ push:
+ branches: ['develop', 'main']
+ paths:
+ - 'skills/vchart-development-assistant/**'
+ - 'packages/vchart/skill-maintenance/**'
+ - '.github/workflows/skill-generation-test.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ contracts:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - uses: astral-sh/setup-uv@v6
+ with:
+ python-version: '3.11'
+ - run: node --test packages/vchart/skill-maintenance/tests/spec-contract.test.cjs
+ - run: uv run --no-project --with pytest python -m pytest packages/vchart/skill-maintenance/tests -q
diff --git a/.gitignore b/.gitignore
index 885e0d9b01..3a5aefb8ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -148,3 +148,5 @@ packages/vchart/__tests__/runtime/node/**.png
# Local caches for the data science skill and its maintenance tests
/skills/data-science-visualization/**/__pycache__/
/packages/vchart-statplots/skill-maintenance/**/__pycache__/
+/packages/vchart/skill-maintenance/**/__pycache__/
+/skills/vchart-development-assistant/**/__pycache__/
diff --git a/docs/skills/vchart-development-assistant-maintenance.md b/docs/skills/vchart-development-assistant-maintenance.md
index b8620f2534..7b2d3d30cb 100644
--- a/docs/skills/vchart-development-assistant-maintenance.md
+++ b/docs/skills/vchart-development-assistant-maintenance.md
@@ -37,3 +37,25 @@ node --test packages/vchart/scripts/skill-metadata/distribution.test.mjs package
`audit/check` 只读,差异使退出码非零。`audit --report` 的 `provenance` 记录核心/扩展版本、TypeScript 版本、输入内容指纹和相关源码提交,报告保存在指定位置,不复制到技能目录。指纹不包含当前时间或无关文档提交。
生成只更新 type-meta 和补充声明;topkey 与人工详情按源码依据维护,audit 检查字段、作用域及导航。这些检查不要求用户对生成配置执行 schema 或编译验证。
+
+## 生成器与配置契约回归
+
+维护测试位于 `packages/vchart/skill-maintenance/tests`,不随运行 Skill 分发。
+从仓库根执行,无需安装 VChart 仓库的 npm 依赖:
+
+```sh
+node --test packages/vchart/skill-maintenance/tests/spec-contract.test.cjs
+uv run --no-project --with pytest python -m pytest packages/vchart/skill-maintenance/tests -q
+```
+
+对应 CI 为 `skill-generation-test.yml`。覆盖完整源码与辅助函数闭包、初始/编辑执行一致性、
+内联源码安全序列化、任意工作目录输出,以及图/树数据、回调和空状态的有限配置检查。
+这些测试不证明浏览器渲染、交互或模型行为正确,实际产物仍按 HTML/spec 工作流验收。
+
+## 按变更范围选择模型回归
+
+- 层级示例或 tooltip 取值变更:简单 Treemap 验证默认展示;明确要求自定义内容时,用不同字段和层级核对叶子记录、名称、数值及指定配色。
+- 空状态变更:串行验证有数据、清空、恢复、再次清空和容器 resize;无数据时提示可见且不溢出,恢复后无残留提示。
+- 图数据说明变更:按业务口径验证边的权重和聚合粒度。
+
+这些是按实际变更选择的维护场景,不要求每次用户生成任务执行整套矩阵。空状态及其清空/恢复生命周期属于可选边缘回归,不作为本次知识库收敛的必过项;未通过的模型用例仍须如实记录。
diff --git a/packages/vchart/skill-maintenance/tests/spec-contract.test.cjs b/packages/vchart/skill-maintenance/tests/spec-contract.test.cjs
new file mode 100644
index 0000000000..d79f81612d
--- /dev/null
+++ b/packages/vchart/skill-maintenance/tests/spec-contract.test.cjs
@@ -0,0 +1,212 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const {validateSpec} = require('../../../../skills/vchart-development-assistant/scripts/spec-contract.cjs');
+const {validateSource} = require('../../../../skills/vchart-development-assistant/scripts/validate_spec.cjs');
+const edges = [{source:'Channel',target:'Product',value:37}, {source:'Product',target:'Order',value:12}];
+const graph = {nodes:[{name:'Channel'},{name:'Product'},{name:'Order'}],links:edges};
+const codes = spec => validateSpec(spec).errors.map(e => e.code);
+for (const [name, spec] of [
+ ['root array', {type:'sankey',data:[{values:edges}]}],
+ ['series', {type:'sankey',series:[{type:'sankey',data:{values:edges}}]}],
+ ['root object', {type:'sankey',data:{values:edges}}],
+ ['dataset reference', {type:'common',data:[{id:'flow',values:edges}],series:[{type:'sankey',dataId:'flow'}]}]
+]) test(`rejects flat edges: ${name}`,()=>assert.deepEqual(codes(spec),['SANKEY_GRAPH_REQUIRED']));
+for (const [name,data] of [
+ ['graph',{values:[graph]}], ['empty',{values:[]}],['empty graph',{values:[{nodes:[],links:[]}]}],
+ ['hierarchy',{values:[{nodes:[{name:'Root',children:[{name:'Leaf',value:9}]}]}]}],
+ ['named records',{values:[{id:'nodes',values:graph.nodes},{id:'links',values:edges}]}],
+ ['deferred reference',{fromDataId:'upstream'}]
+]) test(`accepts supported sankey input: ${name}`,()=>assert.equal(validateSpec({type:'sankey',data}).ok,true));
+test('does not validate an unrelated combined-chart dataset as sankey',()=>{
+ assert.equal(validateSpec({type:'common',data:[{id:'bars',values:[{x:'a',y:3}]},{id:'flow',values:[graph]}],series:[{type:'bar',dataId:'bars'},{type:'sankey',dataId:'flow'}]}).ok,true);
+});
+for (const series of [undefined, null, []]) {
+ test(`ordinary sankey ignores auxiliary dataset shapes with series ${String(series)}`, () => {
+ assert.equal(validateSpec({type:'sankey',series,data:[
+ {id:'flow',values:[graph]},
+ {id:'notes',values:[{note:'total',amount:49}]},
+ {id:'metadata',values:{description:'Flow context'}}
+ ]}).ok,true);
+ });
+}
+test('ordinary sankey still rejects malformed selected data with valid auxiliary graph', () => {
+ assert.deepEqual(validateSpec({type:'sankey',data:[{values:edges},{values:[graph]}]}).errors.map(({code,path})=>({code,path})), [
+ {code:'SANKEY_GRAPH_REQUIRED',path:'spec.data[0].values'}
+ ]);
+});
+test('source validation accepts ordinary sankey with auxiliary records', () => {
+ assert.equal(validateSource(`const spec = ${JSON.stringify({type:'sankey',data:[{values:[graph]},{values:[{note:'total'}]}]})};`).ok,true);
+});
+for (const [name, config] of [
+ ['default first dataset', {}],
+ ['explicit index zero', {dataIndex:0}],
+ ['id takes precedence over index', {dataId:'flow',dataIndex:1}],
+ ['inline data takes precedence', {data:{values:[graph]},dataId:'extra'}],
+ ['unresolved id', {dataId:'missing'}],
+ ['unresolved index', {dataIndex:9}],
+]) test(`only checks selected sankey data: ${name}`,()=>{
+ assert.equal(validateSpec({type:'common',data:[{id:'flow',values:[graph]},{id:'extra',values:[{x:'a',y:3}]}],series:[{type:'sankey',...config}]}).ok,true);
+});
+test('checks explicit nonzero dataset index',()=>assert.equal(validateSpec({type:'common',data:[{values:edges},{values:[graph]}],series:[{type:'sankey',dataIndex:1}]}).ok,true));
+test('still rejects malformed default first dataset',()=>assert.deepEqual(codes({type:'common',data:[{values:edges},{values:[graph]}],series:[{type:'sankey'}]}),['SANKEY_GRAPH_REQUIRED']));
+test('rejects singular data.value',()=>assert.deepEqual(codes({type:'treemap',data:{value:{children:[]}}}),['DATA_VALUES_REQUIRED']));
+test('rejects object instead of tree roots array',()=>assert.deepEqual(codes({type:'treemap',data:{values:{children:[]}}}),['HIERARCHY_ROOT_ARRAY']));
+test('permits arbitrary hierarchy field names and non-100 totals',()=>assert.equal(validateSpec({type:'treemap',categoryField:'item',valueField:'revenue',data:{values:[{item:'Group',children:[{item:'A',revenue:7},{item:'B',revenue:16}]}]}}).ok,true));
+test('rejects function label formatter but retains tooltip callbacks',()=>{
+ assert.deepEqual(codes({type:'treemap',data:{values:[]},label:{formatter:()=>''},nonLeafLabel:{formatMethod:()=>''},tooltip:{mark:{title:{value:()=>''}}}}),['FUNCTION_FORMATTER']);
+});
+test('evaluates complete source and helper closures',()=>assert.equal(validateSource('const rows = [{x:"P",y:31}]; function make() { return {type:"bar",data:{values:rows}}; } const spec = make();').ok,true));
+test('rejects syntax errors',()=>assert.equal(validateSource('const spec = {').errors[0].code,'SOURCE_SYNTAX'));
+test('reports deferred runtime globals without claiming spec was checked',()=>{
+ const r=validateSource('const spec = window.buildChart();');assert.equal(r.specChecked,false);assert.equal(r.warnings[0].code,'RUNTIME_VALIDATION_DEFERRED');
+});
+test('limits source evaluation',()=>assert.equal(validateSource('while(true) {}').ok,false));
+
+test('treemap example preserves records and optional tooltip reads layout nodes',()=>{
+ const fs=require('node:fs'),vm=require('node:vm'),ctx={};
+ vm.runInNewContext(fs.readFileSync(require.resolve('../../../../skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js'),'utf8'),ctx);
+ const rows=[{region:'East',sku:'Blueberry',units:11},{region:'East',sku:'Cherry',units:32},{region:'West',sku:'Apple',units:7}];
+ const spec=ctx.createTreemapSpec(rows,{hierarchyFields:['region','sku'],valueField:'units'});
+ assert.deepEqual(JSON.parse(JSON.stringify(spec.data.values[0].children.flatMap(p=>p.children))),rows);
+ const tooltip=ctx.createTreemapTooltip('sku','units');
+ for (const node of [rows[0],{datum:rows[0]},{datum:[{sku:'Total'},rows[0]]}]) {
+ assert.equal(tooltip.mark.title.value(node),'Blueberry');
+ assert.equal(tooltip.mark.content[0].value(node),'11');
+ }
+});
+
+test('treemap example preserves empty input without synthetic leaves',()=>{
+ const fs=require('node:fs'),vm=require('node:vm'),ctx={};
+ vm.runInNewContext(fs.readFileSync(require.resolve('../../../../skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js'),'utf8'),ctx);
+ const spec=ctx.createTreemapSpec([],{hierarchyFields:['category','item'],valueField:'quantity'});
+ assert.equal(spec.data.values.length,0);
+});
+
+for (const axis of ['x','y']) test(`rejects CSS percentage custom-mark ${axis} regardless of chart and text`,()=>{
+ const result=validateSpec({type:'line',data:{values:[]},customMark:[{type:'text',style:{[axis]:' 50% ',text:'No matching measurements'}}]});
+ assert.deepEqual(result.errors.map(({code,path})=>({code,path})),[{code:'CUSTOM_MARK_PIXEL_COORDINATE',path:`spec.customMark[0].style.${axis}`}]);
+});
+test('validates graphic coordinates nested inside custom groups',()=>assert.deepEqual(codes({type:'bar',customMark:[{type:'group',children:[{type:'text',style:{x:'25%'}}]}]}),['CUSTOM_MARK_PIXEL_COORDINATE']));
+test('accepts numeric coordinates and layout callbacks without executing them',()=>{
+ const callback=()=>{throw Error('needs browser layout')};
+ assert.equal(validateSpec({type:'treemap',customMark:[{type:'text',style:{x:callback,y:0,text:'Empty'}},{type:'text',style:{x:12,y:callback}}]}).ok,true);
+});
+test('leaves percentage layout properties and percentage text untouched',()=>assert.equal(validateSpec({type:'bar',padding:'5%',customMark:[{type:'text',style:{x:20,y:30,text:'50%'}}]}).ok,true));
+test('accepts no custom marks after restoring data',()=>assert.equal(validateSpec({type:'bar',data:{values:[{category:'A',count:0}]},customMark:[]}).ok,true));
+
+for (const value of ['(text, datum) => datum.count', '__CALLBACK__', 5, true, {}]) {
+ for (const key of ['label', 'nonLeafLabel']) {
+ test(`rejects non-function ${key}.formatMethod: ${JSON.stringify(value)}`, () => {
+ assert.deepEqual(codes({type:'treemap',[key]:{formatMethod:value}}), ['LABEL_FORMAT_METHOD_FUNCTION']);
+ });
+ }
+}
+test('checks series label callbacks', () => {
+ assert.deepEqual(validateSpec({type:'common',series:[{type:'bar',label:{formatMethod:'bad'}}]}).errors.map(e=>e.path), ['spec.series[0].label.formatMethod']);
+});
+test('preserves declarative formatting, tooltip strings and ordinary data fields', () => {
+ assert.equal(validateSpec({type:'bar',data:{values:[{formatMethod:'ordinary data'}]},label:{formatter:'{count}',formatMethod:(text,datum)=>datum.count},tooltip:{mark:{title:{value:'Total'}}}}).ok,true);
+});
+test('source validation rejects serialized callbacks before HTML generation', () => {
+ assert.deepEqual(validateSource('const spec = {type:"bar",label:{formatMethod:"(text) => text"}};').errors.map(e=>e.code), ['LABEL_FORMAT_METHOD_FUNCTION']);
+});
+
+for (const [name, spec] of [
+ ['root graph object', {type:'sankey',data:{values:graph}}],
+ ['inline series graph object', {type:'common',series:[{type:'sankey',data:{values:graph}}]}],
+ ['referenced graph object', {type:'common',data:[{id:'flow',values:graph}],series:[{type:'sankey',dataId:'flow'}]}],
+ ['indexed graph object', {type:'common',data:[{values:[graph]},{values:graph}],series:[{type:'sankey',dataIndex:1}]}],
+ ['null values', {type:'sankey',data:{values:null}}]
+]) test(`rejects malformed inline Sankey values: ${name}`,()=>assert.deepEqual(codes(spec),['SANKEY_GRAPH_REQUIRED']));
+test('reports the field containing the bare graph',()=>assert.equal(validateSpec({type:'sankey',data:{values:graph}}).errors[0].path,'spec.data[0].values'));
+for (const [name, data] of [
+ ['parsed text', {values:'source,target,value\nA,B,3',parser:{type:'csv'}}],
+ ['parsed object', {values:graph,parser:{type:'custom'}}],
+ ['transformed rows', {values:edges,transforms:[{type:'customGraph'}]}],
+ ['transformed object', {values:graph,transforms:[{type:'customGraph'}]}],
+ ['referenced data', {values:graph,fromDataId:'upstream'}]
+]) test(`defers Sankey input requiring runtime data processing: ${name}`,()=>assert.equal(validateSpec({type:'sankey',data}).ok,true));
+test('does not impose the Sankey graph wrapper on another chart',()=>assert.equal(validateSpec({type:'custom',data:{values:graph}}).ok,true));
+test('source validator catches a bare graph before generation',()=>assert.deepEqual(validateSource('const spec = {type:"sankey", data:{values:{nodes:[],links:[]}}};').errors.map(e=>e.code),['SANKEY_GRAPH_REQUIRED']));
+
+test('does not confuse root datasets with named graph records',()=>assert.deepEqual(codes({type:'sankey',data:[{id:'nodes',values:graph.nodes},{id:'links',values:edges}]}),['SANKEY_GRAPH_REQUIRED']));
+test('rejects malformed named graph record values',()=>assert.deepEqual(codes({type:'sankey',data:{values:[{id:'nodes',values:{}},{id:'links',values:edges}]}}),['SANKEY_GRAPH_REQUIRED']));
+
+for (const data of [[], [{label:'A'}], 'items', {}]) {
+ test(`rejects non-function discrete legend data: ${JSON.stringify(data)}`,()=>assert.deepEqual(codes({type:'bar',legends:{data}}),['DISCRETE_LEGEND_DATA_FUNCTION']));
+}
+test('checks explicit discrete legends in an array with a field path',()=>assert.deepEqual(validateSpec({legends:[{type:'color',data:[]},{type:'discrete',data:[]}]}).errors.map(({code,path})=>({code,path})),[{code:'DISCRETE_LEGEND_DATA_FUNCTION',path:'spec.legends[1].data'}]));
+test('accepts legend data callbacks without evaluating runtime dependencies',()=>assert.equal(validateSpec({legends:{data:()=>{throw Error('requires runtime legend items')}}}).ok,true));
+for (const legends of [undefined, {}, {data:null}, {type:'color',data:[]}, {type:'size',data:[]}]) {
+ test(`leaves omitted and continuous legend data alone: ${JSON.stringify(legends)}`,()=>assert.equal(validateSpec({legends}).ok,true));
+}
+test('source validation rejects discrete legend arrays',()=>assert.deepEqual(validateSource('const spec={type:"bar",legends:{data:[]}};').errors.map(e=>e.code),['DISCRETE_LEGEND_DATA_FUNCTION']));
+
+
+test('treemap example leaves optional presentation features to the caller',()=>{
+ const fs=require('node:fs'),vm=require('node:vm'),ctx={};
+ vm.runInNewContext(fs.readFileSync(require.resolve('../../../../skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js'),'utf8'),ctx);
+ const spec=ctx.createTreemapSpec([{department:'Ops',name:'Service',amount:9}],{hierarchyFields:['department','name'],valueField:'amount'});
+ for (const key of ['legends','leaf','splitType']) assert.equal(Object.hasOwn(spec,key),false);
+ assert.deepEqual(JSON.parse(JSON.stringify(spec.tooltip)),{visible:true});
+ assert.deepEqual(JSON.parse(JSON.stringify(spec.label)),{visible:true});
+});
+
+for (const value of [{}, 'bar', 42, false]) {
+ test(`reports non-array series as configuration errors: ${JSON.stringify(value)}`, () => {
+ const result = validateSpec({type: 'common', series: value});
+ assert.equal(result.ok, false);
+ assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
+ {code: 'SERIES_ARRAY_REQUIRED', path: 'spec.series'},
+ ]);
+ });
+}
+for (const value of [null, undefined, 'bar', 42, [], false]) {
+ test(`reports invalid series entries at their original index: ${String(value)}`, () => {
+ const result = validateSpec({type: 'common', series: [{type: 'bar'}, value]});
+ assert.equal(result.ok, false);
+ assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
+ {code: 'SERIES_OBJECT_REQUIRED', path: 'spec.series[1]'},
+ ]);
+ });
+}
+for (const series of [undefined, null, []]) {
+ test(`preserves root chart validation without active series: ${String(series)}`, () => {
+ assert.deepEqual(codes({type: 'sankey', series, data: {values: edges}}), ['SANKEY_GRAPH_REQUIRED']);
+ });
+}
+test('source validation distinguishes malformed series from source evaluation failure', () => {
+ const result = validateSource('const spec = {type:"common", series:{type:"bar"}};');
+ assert.equal(result.specChecked, true);
+ assert.deepEqual(result.errors.map(e => e.code), ['SERIES_ARRAY_REQUIRED']);
+});
+
+for (const data of ['input.csv', 0, false, [null], [1], [['nested']]]) {
+ test(`rejects malformed data sources without throwing: ${JSON.stringify(data)}`, () => {
+ const result = validateSpec({type: 'sankey', data});
+ assert.equal(result.ok, false);
+ assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
+ {code: 'DATA_SOURCE_OBJECT_REQUIRED', path: 'spec.data[0]'},
+ ]);
+ });
+}
+test('reports invalid inline series data at the series path', () => {
+ const result = validateSpec({type: 'common', series: [{type: 'sankey', data: [false]}]});
+ assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
+ {code: 'DATA_SOURCE_OBJECT_REQUIRED', path: 'spec.series[0].data[0]'},
+ ]);
+});
+test('reports invalid referenced root data once at its source path', () => {
+ const result = validateSpec({type: 'common', data: [{values: []}, 1], series: [{type: 'sankey', dataIndex: 1}]});
+ assert.deepEqual(result.errors.map(({code, path}) => ({code, path})), [
+ {code: 'DATA_SOURCE_OBJECT_REQUIRED', path: 'spec.data[1]'},
+ ]);
+});
+test('accepts parsed text inside a data descriptor', () => {
+ assert.equal(validateSpec({type: 'bar', data: {values: 'category,value\nA,1', parser: {type: 'csv'}}}).ok, true);
+});
+test('source validation classifies malformed data as a configuration error', () => {
+ const result = validateSource('const spec = {type:"sankey", data:[1]};');
+ assert.equal(result.specChecked, true);
+ assert.deepEqual(result.errors.map(e => e.code), ['DATA_SOURCE_OBJECT_REQUIRED']);
+});
diff --git a/packages/vchart/skill-maintenance/tests/test_artifact_paths.py b/packages/vchart/skill-maintenance/tests/test_artifact_paths.py
new file mode 100644
index 0000000000..1066ceffb1
--- /dev/null
+++ b/packages/vchart/skill-maintenance/tests/test_artifact_paths.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[4]
+SCRIPT_DIR = REPO_ROOT / "skills" / "vchart-development-assistant" / "scripts"
+
+
+def command(script: str) -> list[str]:
+ args = [sys.executable, str(SCRIPT_DIR / script)]
+ if script == "generate_diagnosis_react_html.py":
+ args += ["--problem-code", "", "--problem-title", "Chart issue", "--cause", "Invalid data"]
+ return args
+
+
+@pytest.mark.parametrize("script", ["generate_demo_html.py", "generate_diagnosis_html.py", "generate_diagnosis_react_html.py"])
+@pytest.mark.parametrize("mode", ["absolute", "relative", "parent", "default", "output"])
+def test_generators_report_the_actual_file_independently_of_host_environment(
+ tmp_path: Path, script: str, mode: str
+) -> None:
+ cwd = tmp_path / "working directory"
+ cwd.mkdir()
+ host_root = tmp_path / "unrelated-host-root"
+ filename = {"generate_demo_html.py": "demo.html", "generate_diagnosis_html.py": "diagnosis.html", "generate_diagnosis_react_html.py": "diagnosis_react.html"}[script]
+ if mode == "absolute":
+ directory = tmp_path / "absolute output"
+ args = ["--output-dir", str(directory)]
+ expected = directory / filename
+ elif mode in {"relative", "parent"}:
+ directory = "relative output" if mode == "relative" else "../sibling output"
+ args = ["--output-dir", directory]
+ expected = (cwd / directory / filename).resolve()
+ elif mode == "output":
+ args = ["--output", "custom/result.html"]
+ expected = cwd / "custom/result.html"
+ else:
+ args = []
+ expected = cwd / "output" / filename
+
+ result = subprocess.run(
+ [*command(script), *args],
+ cwd=cwd,
+ env={**os.environ, "AGENTS_DIR": str(host_root)},
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ assert result.stdout.strip() == str(expected)
+ assert expected.is_file()
+ assert not host_root.exists()
+
+
+@pytest.mark.parametrize("script", ["generate_demo_html.py", "generate_diagnosis_html.py", "generate_diagnosis_react_html.py"])
+@pytest.mark.parametrize("flag", ["--output-dir", "--output"])
+@pytest.mark.parametrize("value", ["", " "])
+def test_explicit_empty_path_fails_without_writing_fallback(
+ tmp_path: Path, script: str, flag: str, value: str
+) -> None:
+ result = subprocess.run(
+ [*command(script), flag, value],
+ cwd=tmp_path,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode != 0
+ assert f"{flag} must not be empty" in result.stderr
+ assert not list(tmp_path.iterdir())
diff --git a/packages/vchart/skill-maintenance/tests/test_demo_contract.py b/packages/vchart/skill-maintenance/tests/test_demo_contract.py
new file mode 100644
index 0000000000..c4b69f22f7
--- /dev/null
+++ b/packages/vchart/skill-maintenance/tests/test_demo_contract.py
@@ -0,0 +1,155 @@
+"""Exercise generated HTML wiring; real VChart rendering is a separate check."""
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parents[4] / "skills/vchart-development-assistant/scripts/generate_demo_html.py"
+
+
+def generate(tmp_path: Path, source: str) -> tuple[subprocess.CompletedProcess[str], Path]:
+ spec = tmp_path / "spec.js"
+ spec.write_text(source)
+ output = tmp_path / "output/demo.html"
+ result = subprocess.run(
+ [sys.executable, str(SCRIPT), "--spec-file", str(spec), "--output", str(output)],
+ capture_output=True, text=True, cwd=tmp_path,
+ )
+ return result, output
+
+
+def generated_script(output: Path) -> Path:
+ """Extract inline script with HTML parsing, so premature closing tags fail."""
+ class Scripts(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.active = False
+ self.scripts = []
+
+ def handle_starttag(self, tag, attrs):
+ if tag == "script":
+ self.active = True
+ self.scripts.append("")
+
+ def handle_data(self, data):
+ if self.active:
+ self.scripts[-1] += data
+
+ def handle_endtag(self, tag):
+ if tag == "script":
+ self.active = False
+
+ parser = Scripts()
+ parser.feed(output.read_text())
+ script = next(text for text in parser.scripts if "DOMContentLoaded" in text)
+ target = output.with_suffix(".js")
+ target.write_text(script)
+ return target
+
+
+@pytest.mark.parametrize("source,code", [
+ ("const spec={type:'bar',legends:{data:[]}}", "DISCRETE_LEGEND_DATA_FUNCTION"),
+ ("const spec={type:'bar',label:{formatMethod:'__CALLBACK__'}}", "LABEL_FORMAT_METHOD_FUNCTION"),
+ ("const spec={type:'treemap',data:{value:[]}}", "DATA_VALUES_REQUIRED"),
+ ("const spec={type:'treemap',data:{values:{children:[]}}}", "HIERARCHY_ROOT_ARRAY"),
+ ("const spec={type:'sankey',data:{values:[{source:'a',target:'b',value:2}]}}", "SANKEY_GRAPH_REQUIRED"),
+ ("const spec={type:'sankey',data:{values:{nodes:[],links:[]}}}", "SANKEY_GRAPH_REQUIRED"),
+ ("const spec={type:'treemap',data:{values:[]},label:{formatter:()=>''}}", "FUNCTION_FORMATTER"),
+ ("const spec={type:'bar',data:{values:[]},customMark:[{type:'text',style:{x:'50%',y:120,text:'No data'}}]}", "CUSTOM_MARK_PIXEL_COORDINATE"),
+])
+def test_invalid_spec_does_not_publish_html(tmp_path: Path, source: str, code: str) -> None:
+ result, output = generate(tmp_path, source)
+ assert result.returncode != 0
+ assert code in result.stderr
+ assert not output.exists()
+
+
+@pytest.mark.parametrize("kind", ["treemap", "sankey", "bar"])
+def test_empty_data_can_be_delivered(tmp_path: Path, kind: str) -> None:
+ result, output = generate(tmp_path, f"const spec={{type:'{kind}',data:{{values:[]}}}}")
+ assert result.returncode == 0, result.stderr
+ assert output.is_file()
+
+
+def test_sankey_with_auxiliary_data_can_be_delivered(tmp_path: Path) -> None:
+ source = """const spec = {type:'sankey',categoryField:'name',valueField:'value',data:[
+ {id:'flow',values:[{nodes:[{name:'A'},{name:'B'}],links:[{source:0,target:1,value:5}]}]},
+ {id:'notes',values:[{note:'total',amount:5}]}
+ ]};"""
+ result, output = generate(tmp_path, source)
+ assert result.returncode == 0, result.stderr
+ assert output.is_file()
+
+
+@pytest.mark.parametrize("name", ["A", "", "", "", "引号\"、反斜杠\\、`和${value}\n下一行"])
+def test_editor_preserves_source_and_helper_closures_and_validates_edits(tmp_path: Path, name: str) -> None:
+ source = """window.sourceRuns = (window.sourceRuns || 0) + 1;
+const rows = [{name:'A',amount:23}];
+function label(d) { return `${d.name}: ${d.amount}`; }
+const spec = {type:'bar',data:{values:rows},label:{formatMethod:(_text,d)=>label(d)}};
+"""
+ source = source.replace("name:'A'", "name:" + json.dumps(name, ensure_ascii=False))
+ result, output = generate(tmp_path, source)
+ assert result.returncode == 0, result.stderr
+ probe = r"""
+const fs=require('node:fs'),vm=require('node:vm');
+const script=fs.readFileSync(process.argv[1],'utf8');
+let rendered,editorValue,editorChange,renderCount=0;
+const container={innerHTML:''};
+const ctx={document:{addEventListener:(_e,f)=>f(),getElementById:()=>container},
+ require:(_deps,f)=>f(),monaco:{editor:{create:(_el,options)=>{
+ editorValue=options.value;return {onDidChangeModelContent:f=>editorChange=f,getValue:()=>editorValue};
+ }}},VChart:{default:class {constructor(spec){rendered=spec;}renderSync(){renderCount++;}release(){}}},
+ setTimeout:f=>f(),clearTimeout:()=>{}};
+ctx.window=ctx;vm.runInNewContext(script,ctx);
+const initial={editorValue,label:rendered.label.formatMethod('',rendered.data.values[0]),renderCount,sourceRuns:ctx.sourceRuns};
+editorChange();
+const afterEdit={sourceRuns:ctx.sourceRuns,renderCount};
+editorValue="const spec={type:'sankey',data:{values:[{source:'a',target:'b',value:2}]}}";
+editorChange();
+const error=container.innerHTML;
+const invalidObjectErrors=[];
+for (const value of ['undefined','null','[]',"'bar'"]) {
+ editorValue='const spec='+value+';';editorChange();
+ invalidObjectErrors.push(container.innerHTML);
+}
+console.log(JSON.stringify({initial,afterEdit,renderCount,error,invalidObjectErrors}));
+"""
+ checked = subprocess.run(["node", "-e", probe, str(generated_script(output))], capture_output=True, text=True, check=True)
+ report = json.loads(checked.stdout)
+ assert report["initial"]["editorValue"] == source.strip()
+ assert report["initial"]["label"] == f"{name}: 23"
+ assert report["initial"]["sourceRuns"] == report["initial"]["renderCount"] == 1
+ assert report["afterEdit"] == {"sourceRuns": 2, "renderCount": 2}
+ assert report["renderCount"] == 2
+ assert "SANKEY_GRAPH_REQUIRED" in report["error"]
+ assert all("SPEC_OBJECT_REQUIRED" in error for error in report["invalidObjectErrors"])
+
+
+def test_browser_initialization_error_can_be_fixed_in_editor(tmp_path: Path) -> None:
+ result, output = generate(tmp_path, "if (!window.ready) throw Error('Waiting for input'); const spec={type:'bar',data:{values:[]}};")
+ assert result.returncode == 0, result.stderr
+ probe = r"""
+const fs=require('node:fs'),vm=require('node:vm');
+const script=fs.readFileSync(process.argv[1],'utf8');
+let editorValue,editorChange,renders=0;
+const container={innerHTML:''};
+const ctx={document:{addEventListener:(_e,f)=>f(),getElementById:()=>container},
+ require:(_deps,f)=>f(),monaco:{editor:{create:(_el,options)=>{
+ editorValue=options.value;return {onDidChangeModelContent:f=>editorChange=f,getValue:()=>editorValue};
+ }}},VChart:{default:class {renderSync(){renders++;}release(){}}},setTimeout:f=>f(),clearTimeout:()=>{}};
+ctx.window=ctx;vm.runInNewContext(script,ctx);
+const initialError=container.innerHTML;
+editorValue="const spec={type:'bar',data:{values:[]}}";editorChange();
+console.log(JSON.stringify({initialError,renders,error:container.innerHTML}));
+"""
+ checked = subprocess.run(["node", "-e", probe, str(generated_script(output))], capture_output=True, text=True, check=True)
+ report = json.loads(checked.stdout)
+ assert "Waiting for input" in report["initialError"]
+ assert report["renders"] == 1
+ assert report["error"] == ""
diff --git a/packages/vchart/skill-maintenance/tests/test_demo_generation.py b/packages/vchart/skill-maintenance/tests/test_demo_generation.py
new file mode 100644
index 0000000000..dcd2c708bc
--- /dev/null
+++ b/packages/vchart/skill-maintenance/tests/test_demo_generation.py
@@ -0,0 +1,25 @@
+"""The actual HTML preserves source text without executing template interpolation."""
+import json
+from pathlib import Path
+import re
+import subprocess
+import sys
+import tempfile
+import unittest
+
+SCRIPT = Path(__file__).resolve().parents[4] / 'skills/vchart-development-assistant/scripts' / 'generate_demo_html.py'
+
+class DemoCodeTextTest(unittest.TestCase):
+ def test_javascript_code_roundtrips_without_template_interpolation(self):
+ source = "const spec = {type:'bar',data:{values:[]},label:{formatMethod: text => `值 ${text}`}};\n// ${globalThis.executed = true}"
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ (root / 'spec.js').write_text(source)
+ subprocess.run([sys.executable, str(SCRIPT), '--spec-file', str(root / 'spec.js'), '--output', str(root / 'demo.html')], check=True, capture_output=True, cwd=root)
+ html = (root / 'demo.html').read_text()
+ encoded = re.search(r'const initialCode = (.*);', html).group(1)
+ self.assertEqual(json.loads(encoded), source)
+ self.assertNotIn('', encoded)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/skills/vchart-development-assistant/README.md b/skills/vchart-development-assistant/README.md
index e6a533351d..bf0155b267 100644
--- a/skills/vchart-development-assistant/README.md
+++ b/skills/vchart-development-assistant/README.md
@@ -63,6 +63,6 @@ spec 临时渲染页是验收材料,不会取代最终配置。JSON 不能承
## 参考
- [共享生成流程](workflows/scenario-2-generation.md)、[HTML 流程](workflows/scenario-2-html-generation.md)、[spec 流程](workflows/scenario-2-spec-generation.md)
-- [JSON/JS 示例](examples/spec-generation/README.md)
+- [JSON/JS 示例](references/examples/spec-generation/README.md)
- [VChart Skill 教程](https://www.visactor.io/vchart/guide/tutorial_docs/VChart_Skill_Usage)
- [配置文档](https://www.visactor.io/vchart/option)、[API 文档](https://www.visactor.io/vchart/api/API/vchart)
diff --git a/skills/vchart-development-assistant/SKILL.md b/skills/vchart-development-assistant/SKILL.md
index 52009ab26c..de1e72a8ce 100644
--- a/skills/vchart-development-assistant/SKILL.md
+++ b/skills/vchart-development-assistant/SKILL.md
@@ -22,7 +22,7 @@ description: 使用 VChart 生成可查看的 HTML 或供宿主消费的完整 s
- 尚未建立交付目标时,独立的配置生成/修改请求选择 spec。“已有 spec”是配置输入,不等于请求 spec 作为最终产物;修改已有 HTML 内的 spec 继续交付 HTML,组件或组合产物也保留原目标,除非用户明确改变输出要求。已有 React 组件问题继续交付组件修复。
- “加标签”“换图型”“修复报错”改变任务内容,通常不改变交付目标;沿用前文的格式和产物。只有明确要求片段或补丁时才仅返回局部配置,增量修改默认交付整合后的完整产物。
-- 同时要 spec 和 HTML 时交付两者,HTML 使用同一份 spec。spec 任务的临时验证页面不代替最终配置。
+- 同时要 spec 和 HTML 时交付两者,HTML 使用同一份 spec。校验、生成和最终链接使用同一份完整配置源码;不要另交一份丢失回调或包含占位符的 JSON。spec 任务的临时验证页面不代替最终配置。
- 信息充分时直接选图和生成,不逐次确认图型。只澄清会影响图义、字段含义、数据口径或交付可用性的缺失信息;不要默认询问 JS/React。
- 独立 HTML 默认原生 JS;明确的 React 上下文使用相应组件/API。交付目标不明且选错会影响使用时,只询问必要的交付形式。
- 既有宿主约束持续有效,但不把未知版本、图型或回调支持当作已知。要求与已知宿主能力冲突时说明限制,不静默换产物。
@@ -31,7 +31,7 @@ description: 使用 VChart 生成可查看的 HTML 或供宿主消费的完整 s
## 共享配置知识
-按需查询,不一次加载所有图型、API 或类型详情。
+按需查询,不一次加载所有图型、API 或类型详情。Markdown 链接相对于所在文档;下表路径相对于本 Skill 根目录。类型元数据的 `complexTypeDefinitions[*].reference` 相对于 `references/`:例如 `type-details/IMediaQuerySpec-Type-Definition.md` 实际位于 `references/type-details/IMediaQuerySpec-Type-Definition.md`。读取失败时先核对目录,不继续猜路径。
| 资源 | 用途 |
| --- | --- |
@@ -40,7 +40,11 @@ description: 使用 VChart 生成可查看的 HTML 或供宿主消费的完整 s
| `references/type-meta/[图型].json` | 最终属性、类型、必需字段 |
| `references/type-details/[类型名]-Type-Definition.md` | 复杂类型的定义与示例;函数需求查询对应签名 |
| [示例库](references/examples/) | 相近配置参考;示例数据不能充当用户数据 |
-| [组件参考](references/components/) | 轴、标签、图例、tooltip 等配置 |
+| [Sankey 专项示例](references/examples/sankey/basic-sankey.md) | 生成或修改桑基图时读取:图数组、命名节点/边记录、层级图及端点映射 |
+| [Treemap 专项](references/chart/treemap.md) | 生成或修改矩形树图时读取:层级数据、回调取值、颜色、标签及默认图例 |
+| [数据结构排查](references/chart/common-issues.md#图数据与层级数据) | 层级/图数据、空数据、回调及 HTML 运行约束 |
+| [轴](references/components/axis.md)、[标签](references/components/label.md)、[图例](references/components/legend.md) | 按需求读取对应组件配置 |
+| [Tooltip](references/components/tooltip.md) | 修改 tooltip 内容或格式化时读取,确认触发类型及回调参数 |
| [API 索引](references/api/API_INDEX.md) | 只有需要实例 API 时才查询相关文档 |
配置查询顺序:用户意图 → 对应 topkey → type-meta → 必要 type-details。`isSimple: true` 的属性可直接使用元数据类型,复杂属性再读取详情。
@@ -57,6 +61,7 @@ description: 使用 VChart 生成可查看的 HTML 或供宿主消费的完整 s
- 使用实际数据检查字段映射。按图型检查 xField/yField、categoryField/valueField、seriesField、数据引用和所需配置,不要求所有图型都有 xField/yField。
- 属性值与文档类型一致,配置与已知目标版本及宿主约束相符。数据可以采用 VChart 支持的对象或数组形式,不为示例样式机械归一化。
- 信息充分时直接修正生成错误,不仅提醒用户自行检查。缺少真实业务数据时澄清;功能教学或样式演示可用明确标注的 mock 数据。
+- 标签 `formatMethod(text, datum)` 的第一个参数是文本,记录在第二个参数;[tooltip 回调](references/components/tooltip.md#回调参数层级)另查对应签名,不能混用。函数保留为 JS 函数,不写成字符串或占位符。详见 [标签格式化](references/components/label.md#格式化)。
- JSON 用标准解析器检查,不能混入注释、函数、NaN/Infinity;格式通过不代表 VChart 配置或图义正确。本期不使用 schema 校验。
- 能渲染时检查实际交付配置的图元、数据和可见效果;HTML 交互需要验证真实状态或 datum。未实际渲染则说明验证范围,不因生成文件或存在 canvas 就报成功。浏览器验收不等于目标宿主验收。
@@ -104,6 +109,6 @@ description: 使用 VChart 生成可查看的 HTML 或供宿主消费的完整 s
| JS 诊断页面 | `scripts/generate_diagnosis_html.py`、`template/diagnosis.html` |
| React 诊断页面 | `scripts/generate_diagnosis_react_html.py`、`template/diagnosis-react.html` |
| 参数、脚本问题、文件命名 | [参数参考](references/SCRIPT_PARAMS_REFERENCE.md)、[排错指南](references/SCRIPTS_TROUBLESHOOTING.md)、[命名约定](references/FILE_NAMING_CONVENTIONS.md) |
-| 完整 JSON 与 JS 回调示例 | [spec 示例](examples/spec-generation/README.md) |
+| 完整 JSON 与 JS 回调示例 | [spec 示例](references/examples/spec-generation/README.md) |
只有用户询问能力范围时才简要介绍:支持 HTML/spec 生成、配置修改、诊断、视觉还原和 React/API 集成;不主动输出自我介绍。
diff --git a/skills/vchart-development-assistant/references/SCRIPTS_TROUBLESHOOTING.md b/skills/vchart-development-assistant/references/SCRIPTS_TROUBLESHOOTING.md
index b95fef6817..5a88ad4ee3 100644
--- a/skills/vchart-development-assistant/references/SCRIPTS_TROUBLESHOOTING.md
+++ b/skills/vchart-development-assistant/references/SCRIPTS_TROUBLESHOOTING.md
@@ -12,33 +12,9 @@
❌ 模板不存在: template/diagnosis-react.html
```
-**原因**:脚本运行位置不正确,未在项目根目录执行
+**原因**:安装目录缺少模板,或脚本与模板版本不一致。脚本按自身位置读取模板,不要求切换工作目录。
-**解决步骤**:
-
-1. 确认当前工作目录:
-
- ```bash
- pwd
- ```
-
-2. 切换到项目根目录:
-
- ```bash
- cd /path/to/vchart-development-assistant
- ```
-
-3. 验证模板文件存在:
-
- ```bash
- ls template/diagnosis.html
- ls template/demo.html
- ls template/diagnosis-react.html
- ```
-
-4. 重新运行脚本
-
-**💡 提示**:脚本必须在 `vchart-development-assistant` 目录下运行,因为模板路径是相对路径。
+核对实际 Skill 安装目录下的 `scripts/` 与 `template/` 来自同一版本,检查报错中的绝对路径。用完整脚本路径调用,输入/输出相对路径仍基于当前工作目录。
---
@@ -86,7 +62,7 @@
```
❌ 模板中未找到 {{CONFIG_BLOCK}} 占位符
-❌ 模板中未找到 {{SPEC_CODE}} 占位符
+❌ 模板中未找到 {{INITIAL_CODE}} 占位符
```
**原因**:模板文件被意外修改或损坏
@@ -100,7 +76,7 @@
cat template/diagnosis.html | grep "{{CONFIG_BLOCK}}"
# 检查 demo.html
- cat template/demo.html | grep "{{SPEC_CODE}}"
+ cat template/demo.html | grep "{{INITIAL_CODE}}"
# 检查 diagnosis-react.html
cat template/diagnosis-react.html | grep "{{CONFIG_BLOCK}}"
@@ -122,7 +98,7 @@
## 回调代码含模板字符串时页面空白
-如果 `generate_demo_html.py` 生成页面报 `datum is not defined` 等错误,而原配置使用了 `${datum...}` 标签回调,检查生成器对编辑器代码文本的转义。代码文本中的 `${` 必须转义为 `\${`,不能被外层 `initialCode` 模板字符串提前执行;实际 spec 中的回调保持原样。此问题应修复生成器,不应通过删除回调或把函数序列化成 JSON 掩盖。
+演示生成器将完整源码序列化为 JSON 字符串,并转义 `<`,避免回调中的模板字符串被提前插值或字符串中的 HTML 标签截断 script。若出现此类错误,检查脚本和模板是否来自同一版本;不要删除回调、只截取 spec 对象或通过 JSON 往返丢掉函数。诊断模板的代码字符串须另外按其输入格式转义。
## 按脚本分类故障排除
@@ -153,7 +129,6 @@
ls -la output/config.js
```
- ### ❌ 模板中未找到 {{SPEC_CODE}} 占位符
3. 创建配置文件示例:
@@ -167,7 +142,6 @@
problem: "图表显示问题",
cause: "配置项错误"
};
- cat template/demo.html | grep "{{SPEC_CODE}}"
const solutions = [
{
@@ -188,7 +162,6 @@
**症状**:HTML 生成成功但无法正确显示
- 检查模板中是否有 {{SPEC_CODE}} 占位符
1. 验证配置文件是否为有效 JavaScript:
@@ -263,8 +236,8 @@
```
2. 确保数据格式符合 VChart 要求:
- - `data.values` 必须是数组
- - 数组中的对象必须包含 `xField` 和 `yField` 指定的字段
+ - 按实际图型核对数据结构,参考 [数据契约](chart/common-issues.md#图数据与层级数据);不能把所有输入都当作平铺二维表
+ - 字段映射必须对应实际数据;缺少宿主绑定时在宿主中验证
3. 验证字段映射:
diff --git a/skills/vchart-development-assistant/references/SCRIPT_PARAMS_REFERENCE.md b/skills/vchart-development-assistant/references/SCRIPT_PARAMS_REFERENCE.md
index cb0cc604b0..4d0f17cea2 100644
--- a/skills/vchart-development-assistant/references/SCRIPT_PARAMS_REFERENCE.md
+++ b/skills/vchart-development-assistant/references/SCRIPT_PARAMS_REFERENCE.md
@@ -1,6 +1,6 @@
# Python 脚本参数参考
-本文档提供所有 Python 脚本的参数详细说明。
+本文档提供所有 Python 脚本的参数详细说明。脚本从自身位置读取模板,可在任意工作目录调用。三个生成器均支持 `--output-dir`(优先于 `--output`,使用默认文件名);相对输出路径基于当前工作目录,成功时输出实际文件的绝对路径。演示生成器默认校验配置,`--validate` 保留为兼容参数。
---
diff --git a/skills/vchart-development-assistant/references/api/API_INDEX.md b/skills/vchart-development-assistant/references/api/API_INDEX.md
index b747650e56..8eb752cd84 100644
--- a/skills/vchart-development-assistant/references/api/API_INDEX.md
+++ b/skills/vchart-development-assistant/references/api/API_INDEX.md
@@ -28,7 +28,7 @@ VChart API 分为以下几大类别:
| 用户需求 | 推荐 API | 文档链接 |
|---------|---------|---------|
| 更新图表数据 | `updateData` / `updateDataSync` | [data-api.md](data-api.md) |
-| 切换图表类型 | `updateSpec` / `updateSpecSync` | [spec-api.md](spec-api.md) |
+| 切换图表类型 | `updateSpec` / `updateSpecSync` | [Spec updates (this page)](#spec-update-api) |
| 高亮某个数据 | `setHovered` / `setSelected` | [state-api.md](state-api.md) |
| 响应点击事件 | `on('click')` | [event-api.md](event-api.md) |
| 切换深色模式 | `setCurrentTheme` | [theme-api.md](theme-api.md) |
@@ -41,26 +41,6 @@ VChart API 分为以下几大类别:
---
-## 快速查找 API
-
-### 按用户需求查找
-
-| 用户需求 | 推荐 API | 文档链接 |
-|---------|---------|---------|
-| 更新图表数据 | `updateData` / `updateDataSync` | [data-api.md](data-api.md) |
-| 切换图表类型 | `updateSpec` / `updateSpecSync` | [spec-api.md](spec-api.md) |
-| 高亮某个数据 | `setHovered` / `setSelected` | [state-api.md](state-api.md) |
-| 响应点击事件 | `on('click')` | [event-api.md](event-api.md) |
-| 切换深色模式 | `setCurrentTheme` | [theme-api.md](theme-api.md) |
-| 手动显示 tooltip | `showTooltip` / `hideTooltip` | [tooltip-api.md](tooltip-api.md) |
-| 导出图片 | `exportImg` / `getDataURL` | [export-api.md](export-api.md) |
-| 窗口 resize | `resize` | [layout-api.md](layout-api.md) |
-| 控制图例选中 | `setLegendSelectedDataById` | [legend-api.md](legend-api.md) |
-| 暂停动画 | `pauseAnimation` / `resumeAnimation` | [animation-api.md](animation-api.md) |
-| 数据坐标转换 | `convertDatumToPosition` | [coordinate-api.md](coordinate-api.md) |
-
----
-
## API 详细文档
### 核心数据操作 API
@@ -75,9 +55,9 @@ VChart API 分为以下几大类别:
| `updateFullData` | 异步 | 更新完整数据对象 |
| `updateFullDataSync` | 同步 | 同步更新完整数据 |
-### Spec 配置更新 API
+
-**文档**: [spec-api.md](spec-api.md)
+### Spec 配置更新 API
| API | 同步/异步 | 用途 |
|-----|----------|------|
@@ -284,4 +264,4 @@ window.addEventListener('resize', () => {
- **官方 API 文档**: https://visactor.com/vchart/api/API/vchart
- **事件文档**: [event-api.md](event-api.md)
-- **示例代码**: GitHub `docs/assets/examples/`
\ No newline at end of file
+- **示例代码**: GitHub `docs/assets/examples/`
diff --git a/skills/vchart-development-assistant/references/chart/common-issues.md b/skills/vchart-development-assistant/references/chart/common-issues.md
index ab21f94958..851eb2b5a8 100644
--- a/skills/vchart-development-assistant/references/chart/common-issues.md
+++ b/skills/vchart-development-assistant/references/chart/common-issues.md
@@ -365,3 +365,28 @@ export default {
3. 动画配置优化
详细迁移指南请参考:[VChart 2.0 升级指南](https://www.visactor.io/vchart/guide/tutorial_docs/migrate)
+
+
+## 图数据与层级数据
+
+桑基图先核对 [Sankey 数据与配置](../examples/sankey/basic-sankey.md):数据源描述、图对象、边记录是不同层级;sourceField/targetField 只映射字段,不负责转换数据。
+
+### 层级图数据与回调
+
+Treemap / sunburst 的 `data` 是数据源描述,节点放在 `values` 数组中:
+
+```js
+const root = { name: 'Total', children: [{ name: 'One', amount: 3 }, { name: 'Two', amount: 7 }] };
+const spec = {
+ type: 'treemap', categoryField: 'name', valueField: 'amount',
+ data: { values: [root] }
+};
+```
+
+不要写 `data.value` 或把节点直接放在 `data` 数组里。保留所有叶子与原始字段,按实际层级组织 children;占比分母从完整输入求和。合法空输入为 `values: []`,文本数据可配 csv/dsv/tsv parser,数据引用不当作树节点检查。
+
+Treemap 的布局回调、分组颜色、标签和默认图例见 [Treemap 专项](treemap.md)。
+
+### 空数据与恢复
+
+保留真实空数据;0 值记录不等于无记录。中央提示使用 [图表级 customMark](../components/mark.md#空数据与居中提示),不要把标题或虚构数据当作空状态。恢复后重新计算提示状态。
diff --git a/skills/vchart-development-assistant/references/chart/treemap.md b/skills/vchart-development-assistant/references/chart/treemap.md
new file mode 100644
index 0000000000..1eae916d9b
--- /dev/null
+++ b/skills/vchart-development-assistant/references/chart/treemap.md
@@ -0,0 +1,32 @@
+# Treemap 数据与回调
+
+生成或修改矩形树图时使用;保留当前交付目标与用户的数据要求。
+
+数据源使用 `data: { values: [root] }`,按实际字段组织 `children`,保留所有叶子与业务字段。
+
+以下布局与标签说明为 Treemap 专用。VChart 2.0.21 的布局节点 datum 是祖先链数组,末项才是当前原始记录;标签、配色和 tooltip 应使用一致的取值方式:
+
+```js
+function originalDatum(node) {
+ if (Array.isArray(node?.datum)) return node.datum[node.datum.length - 1] ?? node;
+ return node?.datum && typeof node.datum === 'object' ? node.datum : node;
+}
+```
+
+- 标签回调为 `(text, node)`;使用原始字段而非内部 key 映射名称和颜色,不用灰色兜底掩盖缺失分类。
+- Treemap 默认不添加 `legends` 配置(省略即不显示图例),指定分组颜色不代表要求显示图例。仅在用户明确要求时启用,并核对图例项、颜色与实际分组一致;可点击图例还需验证筛选行为,不能直接开启只代表包装根节点的默认图例。
+- 父矩形包含子节点。启用 nonLeafLabel 时为父标题分配独立空间,不能仅凭父矩形面积判断标签不重叠;没有空间时不强行显示。图表标题使用 title。
+- 标签内容按用户需求选择,并避免超出矩形或与父标题重叠。优先使用内置标签布局;需要自定义容量判断时,可结合节点边界与文本测量。多行文本使用字符串数组;省略的内容可由 tooltip 补充。
+- VChart 2.0.21 使用 squarify 时显式提供有效 aspectRatio;未设置可能退化成细条。不能因此声称该枚举不支持,也不擅自升级宿主版本。
+
+[最小层级示例](../examples/treemap/hierarchy-treemap.js) 默认使用内置标签和 tooltip;其中 `createTreemapTooltip` 演示按需自定义内容时的取值方式。按实际字段与层级使用,所需 helper 随交付源码保留。配色、占比、标签裁剪按任务需求添加,不是生成矩形树图的必需步骤。
+
+## 按需自定义颜色
+
+需要按业务分组指定叶子颜色时,使用 `leaf.style.fill` 的图元回调;顶层 `color` 不是逐节点样式回调。先从布局节点取原始记录,再查实际分组的颜色映射:
+
+```js
+leaf: { style: { fill: node => palette[originalDatum(node)[groupField]] } }
+```
+
+`palette`、`groupField` 来自当前任务的分组与配色要求;没有此要求时使用默认配色,无需额外建立映射。
diff --git a/skills/vchart-development-assistant/references/components/label.md b/skills/vchart-development-assistant/references/components/label.md
index 902c647a06..899e0874fa 100644
--- a/skills/vchart-development-assistant/references/components/label.md
+++ b/skills/vchart-development-assistant/references/components/label.md
@@ -63,25 +63,17 @@ label: {
## 格式化
+`label.formatter` / `nonLeafLabel.formatter` 是模板字符串或字符串数组;函数使用 `formatMethod(text, datum, ctx)`。第一个参数是标签文本,原始数值从 datum 读取,不把文本当成数据记录。完整签名见 [ILabelSpec](../type-details/ILabelSpec-Type-Definition.md)。
+
```javascript
label: {
visible: true,
- formatMethod: (value, datum, ctx) => {
- // value: 原始值
- // datum: 数据项
- // ctx: 上下文信息
-
- // 数值格式化
- if (typeof value === 'number') {
- return value.toLocaleString();
- }
-
- // 百分比
- return `${(value * 100).toFixed(1)}%`;
- }
+ formatMethod: (text, datum) => `${datum.amount} 次`
}
```
+回调保留为 JS 函数,不转换成函数字符串或占位符 JSON。Tooltip 使用自己的回调和 `keyFormatter` / `valueFormatter`,见 [Tooltip](tooltip.md)。Treemap 的布局 datum 需要解包,见 [层级数据](../chart/treemap.md)。
+
## 偏移配置
```javascript
diff --git a/skills/vchart-development-assistant/references/components/legend.md b/skills/vchart-development-assistant/references/components/legend.md
index 649d59daf1..a77b1f183a 100644
--- a/skills/vchart-development-assistant/references/components/legend.md
+++ b/skills/vchart-development-assistant/references/components/legend.md
@@ -1,5 +1,7 @@
# 图例配置
+图例用于解释颜色或符号编码,不是每张图的必选组件。类别已由标签或分组标题清楚表达时可省略。显示图例时,图例项及颜色、符号应与实际数据编码一致;启用筛选交互时,还需验证筛选行为。
+
## 基础配置
```javascript
@@ -173,34 +175,45 @@ legends: {
}
```
-## 自定义图例数据
+## 自定义图例内容与样式
+
+离散图例通过 `data` 回调调整图例项及顺序,通过 `item` 配置图标、文字和交互状态样式。可以基于自动生成的图例项修改,也可以返回完全自定义的图例项。
+
+以下示例反转图例顺序、使用圆形图标,并自定义文字显示和样式,同时保留图表生成的颜色等属性:
```javascript
legends: {
visible: true,
- data: (data) => {
- // data 是自动生成的图例数据
- // 可以进行过滤、排序、修改
- return data.map(item => ({
- label: item.label + ' (自定义)',
- shape: {
- fill: item.shape.fill,
- symbolType: 'circle'
- }
- }));
+ data: items => items.slice().reverse().map(item => ({
+ ...item,
+ shape: { ...item.shape, symbolType: 'circle' }
+ })),
+ item: {
+ shape: {
+ style: { size: 10 }
+ },
+ label: {
+ formatMethod: label => `${label}(系列)`,
+ style: { fontSize: 14, fill: '#333' }
+ }
}
}
+```
-// 或完全自定义
+如果需要自行定义图例内容,可通过回调返回图例项。下面假设图表已使用“系列A/系列B”作为分类,并采用相同的颜色映射:
+
+```javascript
legends: {
visible: true,
- data: [
+ data: () => [
{ label: '系列A', shape: { fill: '#1890ff', symbolType: 'circle' } },
{ label: '系列B', shape: { fill: '#52c41a', symbolType: 'square' } }
]
}
```
+注意:`label` 同时是默认筛选键,应与数据分类一致;仅修改显示文字时用 `item.label.formatMethod`。纯展示图例可设置 `filter: false` 关闭筛选,但颜色、符号仍应与图表编码对应。
+
## 图例标题
```javascript
diff --git a/skills/vchart-development-assistant/references/components/mark.md b/skills/vchart-development-assistant/references/components/mark.md
index 9a911a24c3..06d2127898 100644
--- a/skills/vchart-development-assistant/references/components/mark.md
+++ b/skills/vchart-development-assistant/references/components/mark.md
@@ -1,4 +1,12 @@
-# 标注配置 (MarkLine / MarkArea / MarkPoint)
+# 标注与自定义图元
+
+按需求定位对应章节:
+
+- [MarkLine:标注线](#markline---标注线)
+- [MarkArea:标注区域](#markarea---标注区域)
+- [MarkPoint:标注点](#markpoint---标注点)
+- [标签位置](#标签位置配置)与[完整标注示例](#完整示例)
+- [customMark:空数据与居中提示](#空数据与居中提示)
## MarkLine - 标注线
@@ -438,3 +446,44 @@ const spec = {
]
};
```
+
+## 空数据与居中提示
+
+适用于任意图表的空数据创建、清空、筛选无结果和恢复数据,不依赖业务字段或固定文案。
+
+### 数据与提示分开
+
+- 保留真实空数据 `values: []`,不补造占位数据,不保留上一版图形。判断是否为空应依据当前有效记录;“记录存在但数值全为 0”不等于“没有记录”,按图表语义另行说明。
+- 用户明确要求图中央提示时,标题或副标题不能代替中央提示。使用图表级 `customMark`,不放进依赖业务数据逐条生成的 series mark。
+- 独立提示无需 `dataId`、`dataIndex`、`dataKey` 或虚构数据集。只有确实需要逐条绑定已声明数据时才使用这些属性。
+
+### 坐标必须来自布局
+
+`customMark.style.x/y` 是图形像素坐标,不是 CSS:不要写 `'50%'`,也不要把固定画布宽高的一半当成响应式居中。VChart 2.0.21 的样式回调上下文提供 `context.vchart`,可获取当前画布尺寸:
+
+```js
+function emptyStateMarks(isEmpty, message) {
+ if (!isEmpty) return [];
+ return [{
+ type: 'text',
+ interactive: false,
+ style: {
+ x: (_datum, context) => context.vchart.getChart().getCanvasRect().width / 2,
+ y: (_datum, context) => context.vchart.getChart().getCanvasRect().height / 2,
+ text: message,
+ textAlign: 'center',
+ textBaseline: 'middle',
+ fontSize: 18,
+ fill: '#64748b'
+ }
+ }];
+}
+// 在基于当前数据重新构造 spec 时使用:
+// customMark: emptyStateMarks(currentRows.length === 0, requestedMessage)
+```
+
+这是整个画布的中央位置。若用户要求绘图区中央,应读取目标 region 的布局起点和尺寸,使用同一坐标系;不能把 customMark 自身文字的包围盒误当作绘图区。长提示需按可用宽度分行或缩短辅助说明,窄屏下不能溢出。
+
+数据恢复时重新计算空状态并更新 `customMark`;只调用 `updateData` 不会重新执行构造 spec 的条件表达式。恢复数据、再次清空都应更新提示状态,不能把首次空状态固化进回调闭包。
+
+静态校验不执行布局回调;提示的位置和可见性需在实际渲染环境中确认。
diff --git a/skills/vchart-development-assistant/references/components/tooltip.md b/skills/vchart-development-assistant/references/components/tooltip.md
index 65df45534c..f2555ec75e 100644
--- a/skills/vchart-development-assistant/references/components/tooltip.md
+++ b/skills/vchart-development-assistant/references/components/tooltip.md
@@ -5,7 +5,8 @@
```javascript
tooltip: {
visible: true,
- trigger: 'axis' // 触发方式
+ trigger: 'hover', // 悬停触发;其他触发方式见下文
+ activeType: 'mark' // 内容类型:'mark' | 'dimension' | 'group'
}
```
@@ -13,25 +14,51 @@ tooltip: {
| 值 | 说明 | 适用场景 |
|---|------|---------|
-| `'mark'` | 图元触发 | 散点图、饼图等 |
-| `'axis'` | 坐标轴触发 | 折线图、柱状图等 |
+| `'hover'` | 悬停触发 tooltip | 默认交互 |
+| `'click'` | 点击触发 tooltip | 移动端/点选场景 |
+| `'none'` | 不自动触发 tooltip | 配合 API 手动控制 |
+| `['hover', 'click']` | 组合内置触发方式 | 同时支持悬停与点击 |
+| `{ eventType, source?, consume? }` | 自定义事件对象,也可与内置触发方式组合为数组 | 高级事件编排 |
+
+自定义事件必须使用对象形式,不能将事件名直接写成字符串或字符串数组。完整类型见 [ITooltipSpec](../type-details/ITooltipSpec-Type-Definition.md#trigger-configuration)。
```javascript
-// mark 触发 - hover到具体图形才显示
+// 默认:hover 触发
+tooltip: {
+ trigger: 'hover'
+}
+
+// click 触发
+tooltip: {
+ trigger: 'click'
+}
+
+// 不自动触发(手动控制)
tooltip: {
- trigger: 'mark'
+ trigger: 'none'
}
-// axis 触发 - hover到X轴位置就显示
+// 自定义事件,也可与 'hover' / 'click' 组合为数组
tooltip: {
- trigger: 'axis'
+ trigger: { eventType: 'pointerdown', source: 'chart' }
}
```
+> `trigger` 负责“何时触发”;`activeType` 负责“展示 mark / dimension / group 哪种 tooltip 内容”。
+
## 内容配置
+### 回调参数层级
+
+- `content: (data, params) => [...]` 是内容工厂,接收 `TooltipData` 集合(如 mark 类型的 `[{ datum: [图元数据], series }]`),不是单条记录。不要直接访问 `data.value`。
+- `content: [{ key: ..., value: (datum, params) => ... }]` 中的行回调接收当前图元数据,不保证就是输入的业务记录;`title.value` 同理。普通柱状图等可直接读取业务字段,经过布局转换的图表需按该图表的数据结构取值。只需格式化字段时优先使用行回调。
+- Treemap 的行回调和标题回调接收布局节点。先按 [Treemap 数据与回调](../chart/treemap.md) 解包,再读取业务字段;标签或配色中的解包不会自动应用到 tooltip。完整示例见 [hierarchy-treemap.js](../examples/treemap/hierarchy-treemap.js)。
+- 工厂返回的是行配置,仍可让行的 `value` 回调读取当前记录。dimension/group 的集合结构不同,不要把 mark 的集合索引写成通用取值逻辑。
+
### mark 类型内容
+以下示例适用于业务字段直接位于图元数据上的普通图表;Treemap 使用上面的专项取值方式。
+
```javascript
tooltip: {
mark: {
@@ -56,11 +83,12 @@ tooltip: {
}
```
-### dimension 类型内容(axis触发)
+### dimension 类型内容(按维度聚合)
```javascript
tooltip: {
- trigger: 'axis',
+ trigger: 'hover',
+ activeType: 'dimension',
dimension: {
title: {
value: (datum) => datum[0]?.category
@@ -75,7 +103,11 @@ tooltip: {
}
```
-## 内容格式化
+## 模板字符串格式化
+
+`key` / `value` 接收普通字符串或回调;模板使用 `keyFormatter` / `valueFormatter`,不能把模板写进 value 后期待求值。例如 `content: [{key: '数量', valueFormatter: '{value:,.0f}'}]`。完整类型见 [ITooltipSpec](../type-details/ITooltipSpec-Type-Definition.md)。
+
+## 内容格式化(函数方式)
### 基础格式化
@@ -188,22 +220,26 @@ tooltip: {
```javascript
tooltip: {
// 偏移
- offsetX: 10,
- offsetY: 10,
+ offset: { x: 10, y: 10 },
- // 固定位置(不跟随鼠标)
- fixedPosition: true,
- position: {
- x: 100,
- y: 100
+ // dimension tooltip 可通过回调自定义位置
+ dimension: {
+ position: (event, params) => {
+ return {
+ x: event.canvasX + 20,
+ y: event.canvasY + 20
+ };
+ }
},
-
- // 或使用函数
- position: (event, params) => {
- return {
- x: event.canvasX + 20,
- y: event.canvasY + 20
- };
+
+ // mark tooltip 也支持单独配置
+ mark: {
+ position: (event, params) => {
+ return {
+ x: event.canvasX + 12,
+ y: event.canvasY + 12
+ };
+ }
},
// 限制在图表区域内
@@ -222,7 +258,11 @@ tooltip: {
showDelay: 50,
// 隐藏延迟
- hideDelay: 100,
+ hideTimer: 100,
+
+ // 显示触发与关闭触发可分离
+ trigger: 'hover',
+ triggerOff: 'hover',
// 过渡动画
transitionDuration: 100
@@ -301,7 +341,8 @@ series: [
```javascript
tooltip: {
visible: true,
- trigger: 'axis',
+ trigger: 'hover',
+ activeType: 'dimension',
dimension: {
title: {
@@ -349,7 +390,9 @@ tooltip: {
},
enterable: false,
+ offset: { x: 8, y: 8 },
confine: true,
+ hideTimer: 120,
transitionDuration: 150
}
```
diff --git a/skills/vchart-development-assistant/references/examples/sankey/basic-sankey.md b/skills/vchart-development-assistant/references/examples/sankey/basic-sankey.md
new file mode 100644
index 0000000000..85a6a363e1
--- /dev/null
+++ b/skills/vchart-development-assistant/references/examples/sankey/basic-sankey.md
@@ -0,0 +1,71 @@
+# Sankey 数据与配置
+
+生成或修改 Sankey 时先核对数据结构、端点标识和聚合口径,再添加样式。推荐的普通图写法中,`values` 是只含一个图对象的数组,不能直接放平铺边,也不能写成 `values: {nodes, links}`。
+
+```js
+const rawRows = [
+ { from: 'Search', to: 'Landing', count: 27 },
+ { from: 'Landing', to: 'Signup', count: 19 },
+ { from: 'Landing', to: 'Exit', count: 8 }
+];
+const nodes = [...new Set(rawRows.flatMap(row => [row.from, row.to]))].map(id => ({ id }));
+const spec = {
+ type: 'sankey',
+ data: { values: [{ nodes, links: rawRows }] },
+ nodeKey: 'id', categoryField: 'id',
+ sourceField: 'from', targetField: 'to', valueField: 'count',
+ label: { visible: true },
+ tooltip: { visible: true }
+};
+```
+
+`nodeKey` 与连线端点使用同一标识;`categoryField` 只控制显示内容。整数索引端点保留原节点顺序。字段名使用实际数据,不要求改成示例中的名字。数据既可挂在根配置,也可挂在系列中;移动挂载位置不会转换平铺数据。
+
+重复边是否合并取决于业务粒度和指标口径;聚合时保留原始输入并核对总量。口径不明且会影响图义时先澄清,不静默改变数据粒度。节点从处理后的端点派生,已有元信息保留。没有额外交互需求时优先使用内置标签和 tooltip。
+
+## 其他合法输入
+
+- 一个数据源的 `values` 中放两条命名记录,id 分别为 `nodes` 和 `links`,见下例。
+- 层级 Sankey 可用 `values: [{nodes: [root]}]`,根节点带 `children`,例如下例。
+- 空输入可用 `values: []` 或空图,不补造占位数据。
+
+```js
+const root = {
+ name: 'Total',
+ children: [{ name: 'One', value: 3 }, { name: 'Two', value: 7 }]
+};
+const spec = {
+ type: 'sankey',
+ data: { values: [{ nodes: [root] }] },
+ categoryField: 'name', valueField: 'value',
+ label: { visible: true }
+};
+```
+
+已有两份节点/边数据时,可在**同一个数据源的 `values`** 中放两条命名记录;各记录的 `values` 是对应数组。不要把它们直接拆成两个根 `data` 数据集,系列默认只选择其中一个:
+
+```js
+const spec = {
+ type: 'sankey',
+ data: { values: [
+ { id: 'nodes', values: [{ key: 'Browse' }, { key: 'Order' }] },
+ { id: 'links', values: [{ from: 'Browse', to: 'Order', amount: 12 }] }
+ ] },
+ nodeKey: 'key', categoryField: 'key',
+ sourceField: 'from', targetField: 'to', valueField: 'amount',
+ label: { visible: true }
+};
+```
+
+生成 HTML 时,使用生成脚本校验实际交付的配置源文件;独立校验可用 `node scripts/validate_spec.cjs `。`SANKEY_GRAPH_REQUIRED` 表示直接内联数据的形状不合法,按上述结构修正后重新校验。parser、transform 或引用数据的最终形状仍需运行时检查;校验通过不代表已经正确绘制。
+
+## 回调与交互
+
+- VChart 2.0.21 的标签回调为 `(text, originalNode)`,不要把文本当对象。只显示 categoryField 时不需要回调。
+- `node.style.fill` 收到布局节点,原始字段在 `layoutNode.datum`;`setNodeLayer` 接收原始节点,只在业务明确规定阶段时使用。
+- 自定义 tooltip 时分别核对节点与边:节点名称从原始数据取,流量可由布局计算;边按实际 source/target/value 取值。不要假定所有回调都是原始行。
+- 完整相关路径用 `emphasis: {enable: true, trigger: 'hover', effect: 'related'}`;`adjacency` 和普通 hover 不等价。离散图例的 `legends.data` 是转换函数,不是静态数组。
+
+**已知版本边界:** VChart 2.0.21 的普通图 `related` 交互可能在 `_handleNodeRelatedClick` / `_handleLinkRelatedClick` 抛库内异常。记录版本与堆栈,不修改私有方法、伪造 parents、擅自升级依赖或用 adjacency 冒充 related。其他版本必须实际验证。
+
+核对实际节点、边及权重,悬停节点和边检查文字;标题、canvas 或无报错不能证明图数据正确。按 [HTML](../../../workflows/scenario-2-html-generation.md) 或 [spec](../../../workflows/scenario-2-spec-generation.md) 流程交付。
diff --git a/skills/vchart-development-assistant/examples/spec-generation/README.md b/skills/vchart-development-assistant/references/examples/spec-generation/README.md
similarity index 90%
rename from skills/vchart-development-assistant/examples/spec-generation/README.md
rename to skills/vchart-development-assistant/references/examples/spec-generation/README.md
index 7e280f62fe..c7f798b100 100644
--- a/skills/vchart-development-assistant/examples/spec-generation/README.md
+++ b/skills/vchart-development-assistant/references/examples/spec-generation/README.md
@@ -1,6 +1,6 @@
# spec 生成示例
-这些是演示数据,不能替代用户真实数据。完整生成和修改后交付遵循 [spec 工作流](../../workflows/scenario-2-spec-generation.md)。
+这些是演示数据,不能替代用户真实数据。完整生成和修改后交付遵循 [spec 工作流](../../../workflows/scenario-2-spec-generation.md)。
| 文件 | 表达目标与数据 | 格式与边界 |
| --- | --- | --- |
diff --git a/skills/vchart-development-assistant/examples/spec-generation/callback-label.js b/skills/vchart-development-assistant/references/examples/spec-generation/callback-label.js
similarity index 100%
rename from skills/vchart-development-assistant/examples/spec-generation/callback-label.js
rename to skills/vchart-development-assistant/references/examples/spec-generation/callback-label.js
diff --git a/skills/vchart-development-assistant/examples/spec-generation/composition-pie.json b/skills/vchart-development-assistant/references/examples/spec-generation/composition-pie.json
similarity index 100%
rename from skills/vchart-development-assistant/examples/spec-generation/composition-pie.json
rename to skills/vchart-development-assistant/references/examples/spec-generation/composition-pie.json
diff --git a/skills/vchart-development-assistant/examples/spec-generation/weekly-bar.json b/skills/vchart-development-assistant/references/examples/spec-generation/weekly-bar.json
similarity index 100%
rename from skills/vchart-development-assistant/examples/spec-generation/weekly-bar.json
rename to skills/vchart-development-assistant/references/examples/spec-generation/weekly-bar.json
diff --git a/skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js b/skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js
new file mode 100644
index 0000000000..f6152c0438
--- /dev/null
+++ b/skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js
@@ -0,0 +1,47 @@
+// Convert tabular records to a hierarchy without changing business field names.
+function createTreemapSpec(rows, { hierarchyFields, valueField }) {
+ if (!hierarchyFields.length) throw new Error('Specify at least one hierarchy field.');
+ for (const row of rows) {
+ if (row[valueField] === null || row[valueField] === '' || !Number.isFinite(Number(row[valueField]))) {
+ throw new Error(`Invalid numeric value for ${valueField}.`);
+ }
+ }
+ const categoryField = hierarchyFields[hierarchyFields.length - 1];
+ function nest(items, level) {
+ if (level === hierarchyFields.length - 1) return items.map(row => ({ ...row, [valueField]: Number(row[valueField]) }));
+ const buckets = new Map();
+ for (const row of items) {
+ const key = row[hierarchyFields[level]];
+ if (!buckets.has(key)) buckets.set(key, []);
+ buckets.get(key).push(row);
+ }
+ return Array.from(buckets, ([key, children]) => ({ [categoryField]: key, children: nest(children, level + 1) }));
+ }
+ return {
+ type: 'treemap', categoryField, valueField,
+ data: { values: rows.length ? [{ [categoryField]: 'Total', children: nest(rows, 0) }] : [] },
+ label: { visible: true },
+ tooltip: { visible: true }
+ };
+}
+
+// Optional: use only when the task needs custom tooltip content or formatting.
+function createTreemapTooltip(categoryField, valueField) {
+ function raw(node) {
+ if (Array.isArray(node?.datum)) return node.datum[node.datum.length - 1] ?? node;
+ return node?.datum && typeof node.datum === 'object' ? node.datum : node;
+ }
+ return { visible: true, mark: {
+ title: { value: node => String(raw(node)[categoryField]) },
+ content: [{ key: valueField, value: node => String(raw(node)[valueField]) }]
+ } };
+}
+
+// Replace the sample records and mappings with the user's complete data.
+const rows = [
+ { segment: 'North', product: 'Alpha', sales: 8 },
+ { segment: 'North', product: 'Beta', sales: 5 },
+ { segment: 'South', product: 'Gamma', sales: 4 }
+];
+const spec = createTreemapSpec(rows, { hierarchyFields: ['segment', 'product'], valueField: 'sales' });
+// When custom content is requested: spec.tooltip = createTreemapTooltip('product', 'sales');
diff --git a/skills/vchart-development-assistant/references/type-details/ITooltipSpec-Type-Definition.md b/skills/vchart-development-assistant/references/type-details/ITooltipSpec-Type-Definition.md
index 6231ded8c6..8bb3dcf221 100644
--- a/skills/vchart-development-assistant/references/type-details/ITooltipSpec-Type-Definition.md
+++ b/skills/vchart-development-assistant/references/type-details/ITooltipSpec-Type-Definition.md
@@ -119,15 +119,26 @@ interface ITooltipHandlerSpec {
}
```
+### Callback inputs
+
+```typescript
+// Pattern factories receive the tooltip data collection.
+type TooltipPatternCallback = (data?: TooltipData, params?: TooltipHandlerParams) => T | undefined;
+type TooltipPatternProperty = T | TooltipPatternCallback;
+// Line properties receive the current datum.
+type TooltipContentCallback = (datum?: Datum, params?: TooltipHandlerParams) => T | undefined;
+type TooltipContentProperty = T | TooltipContentCallback | { field: string };
+```
+
### Tooltip Line Pattern
```typescript
interface ITooltipLinePattern {
- key?: TooltipPatternProperty; // Line key
- value?: TooltipPatternProperty; // Line value
- hasShape?: TooltipPatternProperty; // Show shape
- shapeType?: TooltipPatternProperty; // Shape type
- shapeColor?: TooltipPatternProperty; // Shape color
+ key?: TooltipContentProperty; // Line key
+ value?: TooltipContentProperty; // Line value
+ hasShape?: TooltipContentProperty; // Show shape
+ shapeType?: TooltipContentProperty; // Shape type
+ shapeColor?: TooltipContentProperty; // Shape color
// ... other shape properties
}
```
diff --git a/skills/vchart-development-assistant/scripts/artifact_paths.py b/skills/vchart-development-assistant/scripts/artifact_paths.py
new file mode 100644
index 0000000000..5d86681453
--- /dev/null
+++ b/skills/vchart-development-assistant/scripts/artifact_paths.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+
+def resolve_output_path(
+ output: str | None,
+ output_dir: str | None,
+ filename: str,
+) -> Path:
+ """Resolve ordinary filesystem paths without depending on an agent host."""
+ if output_dir is not None:
+ if not output_dir.strip():
+ raise ValueError("--output-dir must not be empty")
+ target = Path(output_dir).expanduser() / filename
+ else:
+ if output is not None and not output.strip():
+ raise ValueError("--output must not be empty")
+ target = Path(output if output is not None else f"output/{filename}").expanduser()
+ return target.resolve()
diff --git a/skills/vchart-development-assistant/scripts/generate_demo_html.py b/skills/vchart-development-assistant/scripts/generate_demo_html.py
index 08749360b9..7ad9040d08 100644
--- a/skills/vchart-development-assistant/scripts/generate_demo_html.py
+++ b/skills/vchart-development-assistant/scripts/generate_demo_html.py
@@ -9,32 +9,37 @@
--feature "主要功能说明" \\
--tips "编辑提示" \\
--spec-file spec.js \\
- --output output/demo.html
+ --output-dir ./chart-output
更多信息请查看: workflows/scenario-2-generation.md
"""
-from pathlib import Path
+from __future__ import annotations
+
import argparse
+import json
+import subprocess
import sys
+from pathlib import Path
+
+from artifact_paths import resolve_output_path
+
+
+def get_script_dir() -> Path:
+ return Path(__file__).parent.resolve()
+
-def escape_js_string(s: str) -> str:
- """转义 JavaScript 字符串中的特殊字符"""
- s = s.replace("\\", "\\\\")
- s = s.replace("`", "\\`")
- # 编辑器中的回调代码是文本,不能被外层模板字符串提前插值。
- s = s.replace("${", "\\${")
- s = s.replace("\n", "\\n")
- return s
-
-def validate_spec_code(spec_code: str) -> bool:
- """验证 spec 代码基本格式"""
- # 检查是否包含基本的 spec 结构
- if "const spec" not in spec_code and "let spec" not in spec_code and "var spec" not in spec_code:
- return False
- if "type:" not in spec_code:
- return False
- return True
+def validate_spec_code(spec_code: str) -> None:
+ """Validate executable configuration, not incidental source formatting."""
+ result = subprocess.run(
+ ["node", str(get_script_dir() / "validate_spec.cjs"), "--stdin"],
+ input=spec_code, capture_output=True, text=True, timeout=10,
+ )
+ if result.returncode:
+ raise ValueError(f"Spec validation failed: {result.stdout.strip() or result.stderr.strip()}")
+ report = json.loads(result.stdout)
+ for warning in report.get("warnings", []):
+ print(f"{warning['code']}: {warning['message']}", file=sys.stderr)
def main():
try:
@@ -44,14 +49,14 @@ def main():
epilog="""
示例:
# 使用自定义 spec 文件
- python3 scripts/generate_demo_html.py --spec-file my-spec.js --output demo.html
+ python3 scripts/generate_demo_html.py --spec-file my-spec.js --output-dir ./chart-output
# 完整参数
python3 scripts/generate_demo_html.py \\
--title "柱状图示例" \\
--desc "基础柱状图配置" \\
--spec-file spec.js \\
- --output output/demo.html
+ --output-dir ./chart-output
"""
)
parser.add_argument("--title", default="VChart 图表示例", help="页面标题")
@@ -59,15 +64,16 @@ def main():
parser.add_argument("--feature", default="补充主要功能说明", help="主要功能说明")
parser.add_argument("--tips", default="补充编辑提示", help="编辑提示")
parser.add_argument("--spec-file", help="包含完整 spec 代码的文件路径")
+ parser.add_argument("--output-dir", help="输出目录:绝对路径或相对于当前工作目录的路径")
parser.add_argument("--output", default="output/demo.html", help="输出 HTML 文件路径")
- parser.add_argument("--validate", action="store_true", help="验证 spec 代码格式")
+ parser.add_argument("--validate", action="store_true", help="兼容参数;配置校验默认执行")
args = parser.parse_args()
# 检查模板文件
- template_path = Path("template/demo.html")
+ template_path = get_script_dir().parent / "template" / "demo.html"
if not template_path.exists():
print(f"❌ 错误: 模板文件不存在: {template_path}", file=sys.stderr)
- print(f"💡 提示: 请确保在 skills/vchart-development-assistant 目录运行脚本", file=sys.stderr)
+ print("💡 提示: 请检查 skill 目录结构或使用正确的脚本路径", file=sys.stderr)
sys.exit(1)
html = template_path.read_text(encoding="utf-8")
@@ -85,10 +91,6 @@ def main():
spec_code = spec_path.read_text(encoding="utf-8")
- # 验证 spec 代码格式
- if args.validate and not validate_spec_code(spec_code):
- print(f"⚠️ 警告: Spec 代码格式可能不正确", file=sys.stderr)
- print(f" 请确保包含 'const spec = {{...}}' 和 'type: \"...\"'", file=sys.stderr)
else:
# 使用内置示例
spec_code = """const spec = {
@@ -107,26 +109,27 @@ def main():
};"""
print(f"ℹ️ 使用内置示例 spec", file=sys.stderr)
- # 转义 spec 代码供 JavaScript 字符串使用
- initial_code_escaped = escape_js_string(spec_code.strip())
+ validate_spec_code(spec_code)
+ contract = (get_script_dir() / "spec-contract.cjs").read_text(encoding="utf-8")
+ html = html.replace("{{SPEC_CONTRACT}}", contract)
+ # Serialize the complete source; literal HTML tags must not end the script.
+ initial_code_json = json.dumps(spec_code.strip()).replace("<", "\\u003c")
- # 填充 spec 和 initialCode 模板变量
- html = html.replace("{{SPEC_CODE}}", spec_code)
- html = html.replace("{{INITIAL_CODE}}", initial_code_escaped)
+ # 初次渲染与编辑共用 updateChart,源码只作为编辑器初始值注入。
+ html = html.replace("{{INITIAL_CODE}}", initial_code_json)
# 检查模板占位符是否都被替换
- if "{{SPEC_CODE}}" in html or "{{INITIAL_CODE}}" in html:
+ if "{{SPEC_CONTRACT}}" in html or "{{INITIAL_CODE}}" in html:
print(f"❌ 错误: 模板占位符替换失败", file=sys.stderr)
print(f"💡 提示: 请检查模板文件: {template_path}", file=sys.stderr)
sys.exit(1)
# 创建输出目录并写入文件
- output_path = Path(args.output)
+ output_path = resolve_output_path(args.output, args.output_dir, "demo.html")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(html, encoding="utf-8")
- print(f"✅ 示例 HTML 已生成: {output_path.resolve()}")
- print(f"📖 请在浏览器中打开以查看交互式图表")
+ print(str(output_path))
except FileNotFoundError as e:
print(f"❌ 文件错误: {e}", file=sys.stderr)
diff --git a/skills/vchart-development-assistant/scripts/generate_diagnosis_html.py b/skills/vchart-development-assistant/scripts/generate_diagnosis_html.py
index bc72de0412..d42f941595 100644
--- a/skills/vchart-development-assistant/scripts/generate_diagnosis_html.py
+++ b/skills/vchart-development-assistant/scripts/generate_diagnosis_html.py
@@ -7,25 +7,35 @@
--title "诊断报告标题" \\
--desc "诊断报告描述" \\
--config-file config.js \\
- --output output/diagnosis.html
+ --output-dir ./chart-output
更多信息请查看: workflows/scenario-1-diagnosis.md
"""
-from pathlib import Path
+from __future__ import annotations
+
import argparse
+from pathlib import Path
+
+from artifact_paths import resolve_output_path
+
+
+def get_script_dir() -> Path:
+ return Path(__file__).parent.resolve()
+
def main():
parser = argparse.ArgumentParser(description="Generate diagnosis HTML from template/diagnosis.html")
parser.add_argument("--title", default="VChart 问题诊断报告", help="报告标题")
parser.add_argument("--desc", default="基于用户配置的诊断与修复结果", help="报告描述")
parser.add_argument("--config-file", help="包含 problemReview/diagnosis/solutions 的 JS 代码块文件路径")
+ parser.add_argument("--output-dir", help="输出目录:绝对路径或相对于当前工作目录的路径")
parser.add_argument("--output", default="output/diagnosis.html", help="输出 HTML 文件路径")
args = parser.parse_args()
- template_path = Path("template/diagnosis.html")
+ template_path = get_script_dir().parent / "template" / "diagnosis.html"
if not template_path.exists():
- raise FileNotFoundError(f"❌ 模板不存在: {template_path}\n💡 请确保在项目根目录运行脚本")
+ raise FileNotFoundError(f"❌ 模板不存在: {template_path}\n💡 请检查 skill 目录结构或使用正确的脚本路径")
html = template_path.read_text(encoding="utf-8")
@@ -87,12 +97,11 @@ def main():
filled = html.replace("{{CONFIG_BLOCK}}", config_block)
- output_path = Path(args.output)
+ output_path = resolve_output_path(args.output, args.output_dir, "diagnosis.html")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(filled, encoding="utf-8")
- print(f"✅ 诊断 HTML 已生成: {output_path.resolve()}")
- print(f"📖 请在浏览器中打开以查看交互式诊断报告")
+ print(str(output_path))
if __name__ == "__main__":
main()
diff --git a/skills/vchart-development-assistant/scripts/generate_diagnosis_react_html.py b/skills/vchart-development-assistant/scripts/generate_diagnosis_react_html.py
index f0d2f13727..1d8478a8e1 100755
--- a/skills/vchart-development-assistant/scripts/generate_diagnosis_react_html.py
+++ b/skills/vchart-development-assistant/scripts/generate_diagnosis_react_html.py
@@ -11,14 +11,22 @@
--solution-1-title "方案标题" \\
--solution-1-desc "方案描述" \\
--solution-1-code "修复代码" \\
- --output output.html
+ --output-dir ./chart-output
"""
-from pathlib import Path
+from __future__ import annotations
+
import argparse
import re
+from pathlib import Path
from typing import Dict, List
+from artifact_paths import resolve_output_path
+
+
+def get_script_dir() -> Path:
+ return Path(__file__).parent.resolve()
+
def escape_js_string(s: str) -> str:
"""转义 JavaScript 字符串中的特殊字符"""
@@ -104,6 +112,7 @@ def main():
parser.add_argument("--problem-title", required=True, help="问题简述")
parser.add_argument("--cause", required=True, help="问题原因分析")
parser.add_argument("--suggestion", help="修复建议(可选)")
+ parser.add_argument("--output-dir", help="输出目录:绝对路径或相对于当前工作目录的路径")
parser.add_argument("--output", default="output/diagnosis_react.html", help="输出 HTML 文件路径")
# 解析动态参数(支持多个解决方案)
@@ -127,9 +136,9 @@ def main():
solutions[sol_num][sol_type] = unknown[i + 1]
# 检查模板文件
- template_path = Path("template/diagnosis-react.html")
+ template_path = get_script_dir().parent / "template" / "diagnosis-react.html"
if not template_path.exists():
- raise FileNotFoundError(f"❌ 模板不存在: {template_path}\n💡 请确保在项目根目录运行脚本")
+ raise FileNotFoundError(f"❌ 模板不存在: {template_path}\n💡 请检查 skill 目录结构或使用正确的脚本路径")
# 读取模板
html = template_path.read_text(encoding="utf-8")
@@ -161,12 +170,12 @@ def main():
html = html.replace("{{CONFIG_BLOCK}}", new_config)
# 创建输出目录
- output_path = Path(args.output)
+ output_path = resolve_output_path(args.output, args.output_dir, "diagnosis_react.html")
output_path.parent.mkdir(parents=True, exist_ok=True)
# 写入输出文件
output_path.write_text(html, encoding="utf-8")
- print(f"✅ 诊断 HTML 已生成: {output_path}")
+ print(str(output_path))
if __name__ == "__main__":
diff --git a/skills/vchart-development-assistant/scripts/spec-contract.cjs b/skills/vchart-development-assistant/scripts/spec-contract.cjs
new file mode 100644
index 0000000000..aca7577289
--- /dev/null
+++ b/skills/vchart-development-assistant/scripts/spec-contract.cjs
@@ -0,0 +1,114 @@
+/* Narrow VChart configuration checks; rendering and visual acceptance are separate. */
+(function (root) {
+ function validateSpec(spec) {
+ const errors = [];
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
+ return {ok: false, errors: [{code: 'SPEC_OBJECT_REQUIRED', path: 'spec', message: 'Define a spec object.'}]};
+ }
+ // Validate the series boundary before inspecting individual configurations.
+ const series = spec.series ?? [];
+ if (!Array.isArray(series)) {
+ return {ok: false, errors: [{code: 'SERIES_ARRAY_REQUIRED', path: 'spec.series', message: 'series must be an array of series configuration objects.'}]};
+ }
+ for (const [index, config] of series.entries()) {
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
+ errors.push({code: 'SERIES_OBJECT_REQUIRED', path: `spec.series[${index}]`, message: 'Each series must be a configuration object.'});
+ }
+ }
+ if (errors.length) return {ok: false, errors};
+ // Graphic coordinates are not CSS lengths. Do not evaluate callbacks here:
+ // their pixel values depend on the actual browser layout.
+ function checkCustomMarks(marks, path) {
+ if (!Array.isArray(marks)) return;
+ marks.forEach((mark, index) => {
+ if (!mark || typeof mark !== 'object') return;
+ for (const axis of ['x', 'y']) {
+ if (typeof mark.style?.[axis] === 'string' && mark.style[axis].trim().endsWith('%')) {
+ errors.push({code: 'CUSTOM_MARK_PIXEL_COORDINATE', path: `${path}[${index}].style.${axis}`, message: 'customMark x/y are graphic coordinates, not CSS percentages. Use pixel numbers or layout callbacks returning pixels; see references/components/mark.md.'});
+ }
+ }
+ checkCustomMarks(mark.children, `${path}[${index}].children`);
+ });
+ }
+ checkCustomMarks(spec.customMark, 'spec.customMark');
+ const legends = Array.isArray(spec.legends) ? spec.legends : spec.legends ? [spec.legends] : [];
+ legends.forEach((legend, index) => {
+ if (legend && (legend.type == null || legend.type === 'discrete') &&
+ legend.data != null && typeof legend.data !== 'function') {
+ errors.push({code: 'DISCRETE_LEGEND_DATA_FUNCTION', path: `spec.legends[${index}].data`, message: 'Discrete legend data must be a callback returning legend items, not an array. Omit data to use generated items; see references/components/legend.md.'});
+ }
+ });
+ const charts = [{config: spec, path: 'spec'}, ...series.map((config, index) => ({config, path: `spec.series[${index}]`}))];
+ for (const {config, path} of charts) {
+ for (const key of ['label', 'nonLeafLabel']) {
+ if (config[key]?.formatMethod != null && typeof config[key].formatMethod !== 'function') {
+ errors.push({code: 'LABEL_FORMAT_METHOD_FUNCTION', path: `${path}.${key}.formatMethod`, message: 'formatMethod must be an executable function (text, datum), not a string or placeholder. Preserve callbacks in JS; use a supported formatter template for JSON.'});
+ }
+ if (typeof config[key]?.formatter === 'function') {
+ errors.push({code: 'FUNCTION_FORMATTER', path: `${path}.${key}.formatter`, message: 'formatter is a template string; use formatMethod for a function.'});
+ }
+ }
+ const data = Array.isArray(config.data) ? config.data : config.data != null ? [config.data] : [];
+ for (const [index, entry] of data.entries()) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ errors.push({code: 'DATA_SOURCE_OBJECT_REQUIRED', path: `${path}.data[${index}]`, message: 'Each data source must be a descriptor object; put records or parsed text in values.'});
+ continue;
+ }
+ if ('value' in entry && !('values' in entry)) {
+ errors.push({code: 'DATA_VALUES_REQUIRED', path: `${path}.data[${index}]`, message: 'Use data.values for inline data; a hierarchy root belongs inside the values array.'});
+ }
+ const hierarchyNode = ['treemap', 'sunburst'].includes(config.type) &&
+ ('children' in entry || (typeof config.categoryField === 'string' && config.categoryField in entry));
+ const dataReference = typeof entry.fromDataId === 'string' || typeof entry.fromDataIndex === 'number';
+ if (hierarchyNode && !('values' in entry) && !('value' in entry) && !dataReference) {
+ errors.push({code: 'DATA_VALUES_REQUIRED', path: `${path}.data[${index}]`, message: 'data contains data-source descriptors, not hierarchy nodes. Put root nodes inside data.values.'});
+ }
+ const parsedText = typeof entry.values === 'string' && ['csv', 'dsv', 'tsv'].includes(entry.parser?.type);
+ if (['treemap', 'sunburst'].includes(config.type) && 'values' in entry && !Array.isArray(entry.values) && !parsedText) {
+ errors.push({code: 'HIERARCHY_ROOT_ARRAY', path: `${path}.data[${index}].values`, message: 'Hierarchy values must be an array of root nodes, or text with a csv/dsv/tsv parser; children stay on each node.'});
+ }
+ }
+ }
+ // Resolve each series' actual data rather than treating every root dataset
+ // as graph data. Both ordinary and combined charts may keep auxiliary data.
+ const sources = data => Array.isArray(data) ? data : data != null ? [data] : [];
+ const dataSeries = series.length ? series : [spec];
+ for (const [index, config] of dataSeries.entries()) {
+ if ((config.type || spec.type) !== 'sankey') continue;
+ const path = series.length ? `spec.series[${index}].data` : 'spec.data';
+ // An ordinary chart creates a default series using the first root dataset;
+ // the root data collection is not series-inline data.
+ let data = config === spec ? sources(spec.data)[0] : config.data;
+ if (data == null && config !== spec) {
+ const rootData = sources(spec.data);
+ // A series without inline data uses one root dataset, first by default.
+ // Unresolved explicit references are left to runtime validation.
+ if (config.dataId != null) data = rootData.find(d => d?.id === config.dataId);
+ else data = rootData[config.dataIndex ?? 0];
+ }
+ const entries = sources(data);
+ for (const [i, entry] of entries.entries()) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !('values' in entry) || entry.parser || entry.transforms?.length ||
+ entry.fromDataId != null || entry.fromDataIndex != null) continue;
+ if (!Array.isArray(entry.values)) {
+ errors.push({code: 'SANKEY_GRAPH_REQUIRED', path: `${path}[${i}].values`, message: 'Inline Sankey values must be an array containing a graph: [{nodes, links}], not a bare graph object. See references/examples/sankey/basic-sankey.md.'});
+ continue;
+ }
+ if (entry.values.length === 0) continue;
+ // Named graph records belong inside one dataset's values array.
+ if (entry.values.length === 2 &&
+ entry.values.some(d => d?.id === 'nodes' && Array.isArray(d.values)) &&
+ entry.values.some(d => d?.id === 'links' && Array.isArray(d.values))) continue;
+ const graph = entry.values[0];
+ // A nodes-only hierarchy is also valid. Endpoint mapping is controlled
+ // by nodeKey/sourceField/targetField, not by a fixed business schema.
+ if (entry.values.length !== 1 || !graph || (!Array.isArray(graph.nodes) && !Array.isArray(graph.links))) {
+ errors.push({code: 'SANKEY_GRAPH_REQUIRED', path: `${path}[${i}].values`, message: 'Sankey values must contain one graph object: [{nodes, links}], not flat edge rows. Put the requested edge data in links; derive unique nodes and set nodeKey when endpoints use names. A nodes-only hierarchy or named nodes/links records inside values is also supported.'});
+ }
+ }
+ }
+ return {ok: errors.length === 0, errors};
+ }
+ if (typeof module === 'object' && module.exports) module.exports = {validateSpec};
+ else root.VChartSpecContract = {validateSpec};
+})(typeof globalThis === 'object' ? globalThis : this);
diff --git a/skills/vchart-development-assistant/scripts/validate_spec.cjs b/skills/vchart-development-assistant/scripts/validate_spec.cjs
new file mode 100644
index 0000000000..eb393d4c15
--- /dev/null
+++ b/skills/vchart-development-assistant/scripts/validate_spec.cjs
@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+// Check syntax, then check configurations that can run without host globals.
+// This is a configuration check, not a security sandbox or browser render test.
+const fs = require('node:fs');
+const vm = require('node:vm');
+const {validateSpec} = require('./spec-contract.cjs');
+
+function validateSource(source) {
+ let script;
+ try {
+ script = new vm.Script(source + '\n; typeof spec !== "undefined" ? spec : undefined;');
+ } catch (error) {
+ return {ok: false, specChecked: false, errors: [{code: 'SOURCE_SYNTAX', path: 'spec', message: String(error.message)}]};
+ }
+ try {
+ const spec = script.runInNewContext(Object.create(null), {timeout: 1000});
+ return {...validateSpec(spec), specChecked: true};
+ } catch (error) {
+ // A missing global may be supplied by the browser or the host. Do not mock it
+ // or report a successful Spec check; the template checks the real result.
+ if (error.name === 'ReferenceError') {
+ return {
+ ok: true,
+ specChecked: false,
+ errors: [],
+ warnings: [{code: 'RUNTIME_VALIDATION_DEFERRED', path: 'spec', message: `${error.message}; syntax checked, validate the Spec in the browser.`}],
+ };
+ }
+ return {ok: false, specChecked: false, errors: [{code: 'SOURCE_EVALUATION', path: 'spec', message: String(error.message)}]};
+ }
+}
+
+if (require.main === module) {
+ const source = process.argv[2] === '--stdin' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(process.argv[2], 'utf8');
+ const result = validateSource(source);
+ process.stdout.write(JSON.stringify(result) + '\n');
+ process.exitCode = result.ok ? 0 : 1;
+}
+module.exports = {validateSource};
diff --git a/skills/vchart-development-assistant/template/demo.html b/skills/vchart-development-assistant/template/demo.html
index 2c25d504aa..90758dac1b 100644
--- a/skills/vchart-development-assistant/template/demo.html
+++ b/skills/vchart-development-assistant/template/demo.html
@@ -186,28 +186,22 @@ 🛠️ 编辑提示