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 @@

🛠️ 编辑提示