From 4381ac2c8b0661ef4844648feeca72cfa09a448f Mon Sep 17 00:00:00 2001 From: Chenzexian Date: Mon, 19 Oct 2026 19:17:58 +0800 Subject: [PATCH 1/6] fix: preserve executable chart sources and clarify skill data contracts Preserve helper closures and literal source through generation and editor updates. Validate graph, hierarchy and callback contracts before delivery. Clarify reference paths and layout-datum tooltip access. Keep public dependencies unchanged and maintenance tests outside the runtime skill. Add focused CI for generation contracts. --- .github/workflows/skill-generation-test.yml | 32 ++++ .gitignore | 2 + ...chart-development-assistant-maintenance.md | 14 ++ .../tests/spec-contract.test.cjs | 137 +++++++++++++++++ .../tests/test_artifact_paths.py | 74 +++++++++ .../tests/test_demo_contract.py | 145 ++++++++++++++++++ .../tests/test_demo_generation.py | 25 +++ skills/vchart-development-assistant/README.md | 2 +- skills/vchart-development-assistant/SKILL.md | 13 +- .../references/SCRIPTS_TROUBLESHOOTING.md | 41 +---- .../references/SCRIPT_PARAMS_REFERENCE.md | 2 +- .../references/api/API_INDEX.md | 10 +- .../references/chart/common-issues.md | 25 +++ .../references/chart/treemap.md | 22 +++ .../references/components/label.md | 18 +-- .../references/components/legend.md | 6 +- .../references/components/mark.md | 43 ++++++ .../references/components/tooltip.md | 87 +++++++---- .../examples/sankey/basic-sankey.md | 71 +++++++++ .../examples/spec-generation/README.md | 2 +- .../spec-generation/callback-label.js | 0 .../spec-generation/composition-pie.json | 0 .../examples/spec-generation/weekly-bar.json | 0 .../examples/treemap/hierarchy-treemap.js | 75 +++++++++ .../ITooltipSpec-Type-Definition.md | 21 ++- .../scripts/artifact_paths.py | 20 +++ .../scripts/generate_demo_html.py | 77 +++++----- .../scripts/generate_diagnosis_html.py | 23 ++- .../scripts/generate_diagnosis_react_html.py | 21 ++- .../scripts/spec-contract.cjs | 98 ++++++++++++ .../scripts/validate_spec.cjs | 39 +++++ .../template/demo.html | 26 ++-- .../tests/test_demo_generation.py | 32 ---- .../workflows/scenario-2-generation.md | 6 + .../workflows/scenario-2-html-generation.md | 9 +- .../workflows/scenario-2-spec-generation.md | 8 +- 36 files changed, 1031 insertions(+), 195 deletions(-) create mode 100644 .github/workflows/skill-generation-test.yml create mode 100644 packages/vchart/skill-maintenance/tests/spec-contract.test.cjs create mode 100644 packages/vchart/skill-maintenance/tests/test_artifact_paths.py create mode 100644 packages/vchart/skill-maintenance/tests/test_demo_contract.py create mode 100644 packages/vchart/skill-maintenance/tests/test_demo_generation.py create mode 100644 skills/vchart-development-assistant/references/chart/treemap.md create mode 100644 skills/vchart-development-assistant/references/examples/sankey/basic-sankey.md rename skills/vchart-development-assistant/{ => references}/examples/spec-generation/README.md (90%) rename skills/vchart-development-assistant/{ => references}/examples/spec-generation/callback-label.js (100%) rename skills/vchart-development-assistant/{ => references}/examples/spec-generation/composition-pie.json (100%) rename skills/vchart-development-assistant/{ => references}/examples/spec-generation/weekly-bar.json (100%) create mode 100644 skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js create mode 100644 skills/vchart-development-assistant/scripts/artifact_paths.py create mode 100644 skills/vchart-development-assistant/scripts/spec-contract.cjs create mode 100644 skills/vchart-development-assistant/scripts/validate_spec.cjs delete mode 100644 skills/vchart-development-assistant/tests/test_demo_generation.py 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..b250791e3d 100644 --- a/docs/skills/vchart-development-assistant-maintenance.md +++ b/docs/skills/vchart-development-assistant-maintenance.md @@ -37,3 +37,17 @@ 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 工作流验收。 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..e66ca7d445 --- /dev/null +++ b/packages/vchart/skill-maintenance/tests/spec-contract.test.cjs @@ -0,0 +1,137 @@ +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 [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 computes shares from changed data and preserves original records',()=>{ + const fs=require('node:fs'),vm=require('node:vm'); + const source=fs.readFileSync(require.resolve('../../../../skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js'),'utf8'); + const ctx={document:{createElement:()=>({getContext:()=>({measureText:text=>({width:text.length*7})})})}}; + vm.runInNewContext(source,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',groupField:'region',palette:{East:'#123456',West:'#654321'}}); + const raw=spec.data.values[0].children[0].children[0]; + const node={datum:[{sku:'Total'},{sku:'East'},raw],x0:0,x1:220,y0:0,y1:100}; + assert.equal(spec.label.formatMethod('',node),'Blueberry 22%'); + assert.equal(spec.leaf.style.fill(node),'#123456'); + assert.equal(spec.tooltip.mark.content[1].value(node),'11'); + assert.equal(spec.label.formatMethod('',{...node,x1:20}), ''); + assert.deepEqual(JSON.parse(JSON.stringify(spec.data.values[0].children.flatMap(p=>p.children))),rows); +}); + +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',groupField:'category',palette:{}}); + 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','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 helper omits legends even when explicit group colors are supplied',()=>{ + 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',groupField:'department',palette:{Ops:'#123456'}}); + assert.equal(Object.hasOwn(spec, 'legends'),false); +}); 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..ab3d1839ae --- /dev/null +++ b/packages/vchart/skill-maintenance/tests/test_demo_contract.py @@ -0,0 +1,145 @@ +"""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() + + +@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..1ac2d5e6f5 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 配置更新 API](#spec-配置更新-api) | | 高亮某个数据 | `setHovered` / `setSelected` | [state-api.md](state-api.md) | | 响应点击事件 | `on('click')` | [event-api.md](event-api.md) | | 切换深色模式 | `setCurrentTheme` | [theme-api.md](theme-api.md) | @@ -48,11 +48,11 @@ VChart API 分为以下几大类别: | 用户需求 | 推荐 API | 文档链接 | |---------|---------|---------| | 更新图表数据 | `updateData` / `updateDataSync` | [data-api.md](data-api.md) | -| 切换图表类型 | `updateSpec` / `updateSpecSync` | [spec-api.md](spec-api.md) | +| 切换图表类型 | `updateSpec` / `updateSpecSync` | [Spec 配置更新 API](#spec-配置更新-api) | | 高亮某个数据 | `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) | +| 手动显示 tooltip | `showTooltip` / `hideTooltip` | [interaction-api.md](interaction-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) | @@ -77,7 +77,7 @@ VChart API 分为以下几大类别: ### Spec 配置更新 API -**文档**: [spec-api.md](spec-api.md) +**文档**: [Spec 配置更新 API](#spec-配置更新-api) | API | 同步/异步 | 用途 | |-----|----------|------| @@ -284,4 +284,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..76b950e33a --- /dev/null +++ b/skills/vchart-development-assistant/references/chart/treemap.md @@ -0,0 +1,22 @@ +# 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。 +- 标签显示名称、数值或占比由用户需求决定。用实际 `x1-x0` / `y1-y0` 和匹配字体的 Canvas measureText 检查容量,不用金额估算文字面积。多行使用字符串数组;隐藏、换行或省略按需求选择,tooltip 保留完整内容。 +- VChart 2.0.21 使用 squarify 时显式提供有效 aspectRatio;未设置可能退化成细条。不能因此声称该枚举不支持,也不擅自升级宿主版本。 + +可执行 helper 见 [hierarchy-treemap.js](../examples/treemap/hierarchy-treemap.js),按实际字段和层级传参并复制所需 helper 到交付源码,不依赖安装目录的浏览器相对路径。修改时保留原要求,核对叶子数量、面积、分组颜色、父子文本重叠及真实 tooltip,不能只看 ready 或 canvas。 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..e1037d0331 100644 --- a/skills/vchart-development-assistant/references/components/legend.md +++ b/skills/vchart-development-assistant/references/components/legend.md @@ -1,5 +1,7 @@ # 图例配置 +图例用于解释颜色或符号编码,不是每张图的必选组件。类别已由标签或分组标题清楚表达时可省略;Treemap 默认省略 `legends` 配置,不额外添加图例;仅在用户明确要求时配置,并验证项目、颜色及启用的筛选交互与实际分组一致;不要显示仅代表包装根节点的图例。 + ## 基础配置 ```javascript @@ -175,6 +177,8 @@ legends: { ## 自定义图例数据 +离散图例的 `data` 是返回图例项的回调,不能直接传数组(数组会被忽略)。优先修改自动生成的项目;完全自定义时,项目与数据的对应关系和点击筛选需要另行验证。 + ```javascript legends: { visible: true, @@ -194,7 +198,7 @@ legends: { // 或完全自定义 legends: { visible: true, - data: [ + data: () => [ { label: '系列A', shape: { fill: '#1890ff', symbolType: 'circle' } }, { label: '系列B', shape: { fill: '#52c41a', symbolType: 'square' } } ] diff --git a/skills/vchart-development-assistant/references/components/mark.md b/skills/vchart-development-assistant/references/components/mark.md index 9a911a24c3..69057556a3 100644 --- a/skills/vchart-development-assistant/references/components/mark.md +++ b/skills/vchart-development-assistant/references/components/mark.md @@ -438,3 +438,46 @@ 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 的条件表达式。恢复数据、再次清空都应更新提示状态,不能把首次空状态固化进回调闭包。 + +### 验证 + +先执行标准配置校验。具备浏览器能力时,检查空数据、恢复数据、再次清空,以及宽屏到窄屏的 resize:空时无旧图形,提示真实可见且边界在容器内;有数据时无空提示。静态校验不执行布局回调,不能证明居中、换行或可见性。 diff --git a/skills/vchart-development-assistant/references/components/tooltip.md b/skills/vchart-development-assistant/references/components/tooltip.md index 65df45534c..f3436a9327 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', // 触发事件:'hover' | 'click' | 'none' | string[] + activeType: 'mark' // 内容类型:'mark' | 'dimension' | 'group' } ``` @@ -13,25 +14,43 @@ tooltip: { | 值 | 说明 | 适用场景 | |---|------|---------| -| `'mark'` | 图元触发 | 散点图、饼图等 | -| `'axis'` | 坐标轴触发 | 折线图、柱状图等 | +| `'hover'` | 悬停触发 tooltip | 默认交互 | +| `'click'` | 点击触发 tooltip | 移动端/点选场景 | +| `'none'` | 不自动触发 tooltip | 配合 API 手动控制 | +| `string[]` | 自定义事件名数组 | 高级事件编排 | ```javascript -// mark 触发 - hover到具体图形才显示 +// 默认:hover 触发 tooltip: { - trigger: 'mark' + trigger: 'hover' } -// axis 触发 - hover到X轴位置就显示 +// click 触发 tooltip: { - trigger: 'axis' + trigger: 'click' +} + +// 不自动触发(手动控制) +tooltip: { + trigger: 'none' } ``` +> `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 +75,12 @@ tooltip: { } ``` -### dimension 类型内容(axis触发) +### dimension 类型内容(按维度聚合) ```javascript tooltip: { - trigger: 'axis', + trigger: 'hover', + activeType: 'dimension', dimension: { title: { value: (datum) => datum[0]?.category @@ -75,7 +95,11 @@ tooltip: { } ``` -## 内容格式化 +## 模板字符串格式化 + +`key` / `value` 接收普通字符串或回调;模板使用 `keyFormatter` / `valueFormatter`,不能把模板写进 value 后期待求值。例如 `content: [{key: '数量', valueFormatter: '{value:,.0f}'}]`。完整类型见 [ITooltipSpec](../type-details/ITooltipSpec-Type-Definition.md)。 + +## 内容格式化(函数方式) ### 基础格式化 @@ -188,22 +212,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 +250,11 @@ tooltip: { showDelay: 50, // 隐藏延迟 - hideDelay: 100, + hideTimer: 100, + + // 显示触发与关闭触发可分离 + trigger: 'hover', + triggerOff: 'hover', // 过渡动画 transitionDuration: 100 @@ -301,7 +333,8 @@ series: [ ```javascript tooltip: { visible: true, - trigger: 'axis', + trigger: 'hover', + activeType: 'dimension', dimension: { title: { @@ -349,7 +382,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..9572869f03 --- /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..1cb6a59c9a --- /dev/null +++ b/skills/vchart-development-assistant/references/examples/treemap/hierarchy-treemap.js @@ -0,0 +1,75 @@ +let treemapLabelMeasureContext; + +function fitTreemapLabel(text, node, options = {}) { + const { fontSize = 14, fontFamily = 'sans-serif', fontWeight = 'normal', lineHeight = 18, padding = 6 } = options; + const width = Math.abs(node.x1 - node.x0) - padding * 2; + const height = Math.abs(node.y1 - node.y0) - padding * 2; + const lines = Array.isArray(text) ? text.map(String) : String(text).split('\n'); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height < lines.length * lineHeight) return ''; + treemapLabelMeasureContext ??= document.createElement('canvas').getContext('2d'); + treemapLabelMeasureContext.font = `${fontWeight} ${fontSize}px ${fontFamily}`; + if (lines.some(line => treemapLabelMeasureContext.measureText(line).width > width)) return ''; + // VRender uses an array of strings for multiple lines, not a newline in one string. + return lines.length > 1 || Array.isArray(text) ? lines : String(text); +} + +function createTreemapSpec(rows, options) { + const { hierarchyFields, valueField, groupField, palette } = options; + 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}.`); + } + if (!Object.hasOwn(palette, row[groupField])) throw new Error(`Missing palette for ${groupField}: ${row[groupField]}`); + } + const categoryField = hierarchyFields[hierarchyFields.length - 1]; + const total = rows.reduce((sum, row) => sum + Number(row[valueField]), 0); + 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, items]) => ({ + [categoryField]: key, + [groupField]: items[0][groupField], + children: nest(items, level + 1) + })); + } + 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; + } + const share = row => `${(total > 0 ? Number(row[valueField]) / total * 100 : 0).toFixed(0)}%`; + function text(node) { + const row = raw(node); + return `${row[categoryField]} ${share(row)}`; + } + return { + type: 'treemap', animation: false, categoryField, valueField, splitType: 'binary', + data: { id: 'hierarchy', values: rows.length ? [{ [categoryField]: 'Total', children: nest(rows, 0) }] : [] }, + nodePadding: 0, gapWidth: 0, + leaf: { style: { fill: node => palette[raw(node)[groupField]], stroke: '#ffffff', lineWidth: 1 }, state: { hover: { stroke: '#111827', lineWidth: 3, fillOpacity: 0.82 } } }, + nonLeafLabel: { visible: false }, + label: { visible: true, formatMethod: (_text, node) => fitTreemapLabel(text(node), node), style: { fill: '#ffffff', fontSize: 14, fontFamily: 'sans-serif', fontWeight: 'normal', lineHeight: 18 } }, + tooltip: { visible: true, mark: { title: { value: node => String(raw(node)[categoryField]) }, content: [ + { key: groupField, value: node => String(raw(node)[groupField]) }, + { key: valueField, value: node => String(raw(node)[valueField]) }, + { key: 'Share', value: node => share(raw(node)) } + ] } } + }; +} + +// Replace this example input and options with the user's complete data/mappings. +const rows = [ + { segment: 'North', product: 'Alpha', sales: 8 }, + { segment: 'North', product: 'Beta', sales: 5 }, + { segment: 'South', product: 'Gamma', sales: 4 }, + { segment: 'South', product: 'Delta', sales: 3 }, +]; +const spec = createTreemapSpec(rows, { + hierarchyFields: ['segment', 'product'], valueField: 'sales', groupField: 'segment', + palette: { North: '#2563eb', South: '#ea580c' }, +}); 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..df5b5c95a5 --- /dev/null +++ b/skills/vchart-development-assistant/scripts/spec-contract.cjs @@ -0,0 +1,98 @@ +/* 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.'}]}; + } + // 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'}, ...(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 ? [config.data] : []; + for (const [index, entry] of data.entries()) { + if (!entry || typeof entry !== 'object') 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. Combined charts may keep unrelated datasets at the root. + const sources = data => Array.isArray(data) ? data : data ? [data] : []; + const series = spec.series?.length ? spec.series : [spec]; + for (const [index, config] of series.entries()) { + if ((config.type || spec.type) !== 'sankey') continue; + const path = spec.series?.length ? `spec.series[${index}].data` : 'spec.data'; + let data = 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 || !('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 @@

🛠️ 编辑提示

", "", "", "引号\"、反斜杠\\、`和${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; diff --git a/skills/vchart-development-assistant/scripts/spec-contract.cjs b/skills/vchart-development-assistant/scripts/spec-contract.cjs index 5611be4cdd..aca7577289 100644 --- a/skills/vchart-development-assistant/scripts/spec-contract.cjs +++ b/skills/vchart-development-assistant/scripts/spec-contract.cjs @@ -70,13 +70,15 @@ } } // Resolve each series' actual data rather than treating every root dataset - // as graph data. Combined charts may keep unrelated datasets at the root. + // 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'; - let data = config.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.