图表(Chart):运行时、Chart.js 绘制、设计器的"系列和图例"

- System.Windows.Forms.DataVisualization.Charting:ChartAreas、Legends、Titles、Series
  (ChartType、Points.AddXY、图例文字等),DataSource + DataBind(DataTable / DataView / DataSet,
  按 XValueMember、YValueMembers 取列);数据变化时把各系列的点发给浏览器
- 浏览器用 Chart.js 画:SeriesChartType 对应折线、柱形、饼图等,WinForms 默认的 BrightPastel 配色,不要动画
- 设计器工具箱加"图表",和 VS 一样带 ChartArea1、Legend1、Series1;属性窗口"系列和图例"对话框
  编辑系列(名称、图表类型、图例、图例文字、X / Y 值字段)和图例,按 VS 的格式和顺序写回 Designer.cs,
  对话框不管的属性原样保留,教材项目 8 的图表读入再写出逐字节不变;删除图表时一起删掉局部变量;
  设计时用示例数据预览

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-23 15:14:49 +08:00
1 parent d60868d912
commit 2cd0111967
13 files changed
+1227 -4

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"@iot/shared": "workspace:*",
"chart.js": "^4.6.1",
"modern-screenshot": "^4.7.0",
"monaco-editor": "^0.57.0",
"naive-ui": "^2.41.0",
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
// 图表的"系列和图例"(相当于 VS 里图表属性 Series、Legends 的集合编辑器):
// 左边是系列列表,右边编辑选中系列的属性;下面编辑图例。教材项目 8 的折线图在这里设置:
// 两个图例 MaxLegend、AVGLegend,两条折线 MaxSeries、AVGSeries,X 值字段 _Time,Y 值字段 MaxTemperature / AVGTemperature。
import { NButton, NModal } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { CHART_TYPES, type ChartModel, type ChartSeries } from './chart'
const props = defineProps<{ show: boolean; model: ChartModel }>()
const emit = defineEmits<{ 'update:show': [show: boolean]; save: [model: ChartModel] }>()
const draft = ref<ChartModel>({ areas: [], legends: [], series: [] })
const current = ref(0)
const error = ref('')
watch(
() => props.show,
(show) => {
if (!show) return
draft.value = structuredClone(props.model)
current.value = 0
error.value = ''
},
{ immediate: true },
)
const series = computed<ChartSeries | undefined>(() => draft.value.series[current.value])
function nextName(prefix: string, taken: string[]) {
for (let i = 1; ; i++) if (!taken.includes(`${prefix}${i}`)) return `${prefix}${i}`
}
function addSeries() {
const d = draft.value
d.series.push({
name: nextName('Series', d.series.map((s) => s.name)),
chartType: 'Column',
chartArea: d.areas[0] ?? '',
legend: d.legends[0] ?? '',
legendText: '',
isVisibleInLegend: true,
xValueMember: '',
yValueMembers: '',
})
current.value = d.series.length - 1
}
function removeSeries() {
draft.value.series.splice(current.value, 1)
current.value = Math.max(0, current.value - 1)
}
function addLegend() {
draft.value.legends.push(nextName('Legend', draft.value.legends))
}
function renameLegend(index: number, name: string) {
const old = draft.value.legends[index]
draft.value.legends[index] = name
// 用到这个图例的系列跟着改名
for (const s of draft.value.series) if (s.legend === old) s.legend = name
}
function removeLegend(index: number) {
const [old] = draft.value.legends.splice(index, 1)
for (const s of draft.value.series) if (s.legend === old) s.legend = draft.value.legends[0] ?? ''
}
function save() {
const d = draft.value
const names = d.series.map((s) => s.name.trim())
const legends = d.legends.map((l) => l.trim())
if (names.some((n) => !n) || legends.some((n) => !n)) return void (error.value = '系列和图例的名称不能为空')
if (new Set(names).size !== names.length) return void (error.value = '系列的名称不能重复')
if (new Set(legends).size !== legends.length) return void (error.value = '图例的名称不能重复')
emit('save', { areas: d.areas, legends, series: d.series.map((s, i) => ({ ...s, name: names[i] })) })
emit('update:show', false)
}
</script>
<template>
<NModal :show="show" preset="card" title="系列和图例" style="width: 640px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
<div class="layout">
<section class="list">
<div class="caption">系列(Series)</div>
<ul>
<li v-for="(s, i) in draft.series" :key="i" :class="{ on: i === current }" @click="current = i">{{ s.name || '(未命名)' }}</li>
</ul>
<div class="list-buttons">
<NButton size="small" @click="addSeries">添加</NButton>
<NButton size="small" :disabled="!series" @click="removeSeries">移除</NButton>
</div>
</section>
<section v-if="series" class="fields">
<label title="Name:代码里用 chart1.Series[&quot;名称&quot;] 访问这个系列">名称</label>
<input v-model="series.name" />
<label title="ChartType:画成折线、柱形还是饼图">图表类型</label>
<select v-model="series.chartType">
<option v-for="(label, type) in CHART_TYPES" :key="type" :value="type">{{ label }}({{ type }})</option>
<option v-if="!(series.chartType in CHART_TYPES)" :value="series.chartType">{{ series.chartType }}</option>
</select>
<label title="Legend:这个系列显示在哪个图例里">图例</label>
<select v-model="series.legend">
<option value="">(不显示在图例里)</option>
<option v-for="legend in draft.legends" :key="legend" :value="legend">{{ legend }}</option>
</select>
<label title="LegendText:图例里显示的文字,不填时显示系列的名称">图例文字</label>
<input v-model="series.legendText" :placeholder="series.name" />
<label title="IsVisibleInLegend:是否显示在图例里">显示在图例里</label>
<input v-model="series.isVisibleInLegend" type="checkbox" class="check" />
<label title="XValueMember:绑定数据源(DataSource)后,X 值取哪一列">X 值字段</label>
<input v-model="series.xValueMember" placeholder="如 _Time" />
<label title="YValueMembers:绑定数据源(DataSource)后,Y 值取哪一列">Y 值字段</label>
<input v-model="series.yValueMembers" placeholder="如 MaxTemperature" />
</section>
<section v-else class="fields empty">点"添加"加一个系列</section>
</div>
<section class="legends">
<div class="caption">图例(Legends)</div>
<div v-for="(legend, i) in draft.legends" :key="i" class="legend">
<input :value="legend" @change="renameLegend(i, ($event.target as HTMLInputElement).value.trim())" />
<NButton size="tiny" @click="removeLegend(i)">移除</NButton>
</div>
<NButton size="small" @click="addLegend">添加图例</NButton>
</section>
<template #footer>
<div class="buttons">
<span class="error">{{ error }}</span>
<NButton type="primary" @click="save">确定</NButton>
<NButton @click="emit('update:show', false)">取消</NButton>
</div>
</template>
</NModal>
</template>
<style scoped>
.layout {
display: grid;
grid-template-columns: 180px 1fr;
gap: 12px;
}
.caption {
margin-bottom: 4px;
font-weight: 600;
font-size: 13px;
}
.list ul {
margin: 0;
padding: 0;
list-style: none;
height: 200px;
overflow: auto;
border: 1px solid #d1d5db;
border-radius: 3px;
}
.list li {
padding: 4px 8px;
cursor: pointer;
font-size: 13px;
}
.list li.on {
background: #dbeafe;
}
.list-buttons {
display: flex;
gap: 6px;
margin-top: 6px;
}
.fields {
display: grid;
grid-template-columns: 90px 1fr;
gap: 6px 8px;
align-content: start;
font-size: 13px;
}
.fields label {
align-self: center;
color: #374151;
}
.fields input:not(.check),
.fields select,
.legend input {
padding: 3px 6px;
border: 1px solid #d1d5db;
border-radius: 3px;
font: inherit;
}
.fields .check {
justify-self: start;
}
.fields.empty {
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
}
.legends {
margin-top: 14px;
}
.legend {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.error {
margin-right: auto;
color: #b91c1c;
font-size: 13px;
}
</style>
+7
View File
@@ -6,6 +6,7 @@ import { computed, ref, shallowRef, watch } from 'vue'
import ControlView from '../winforms/ControlView.vue'
import { displayText, fontCss, type FontValue } from '../winforms/format'
import { CONTROLS, type ControlDef, FORM_DEF, controlDef, isContainerType } from './catalog'
import { type ChartModel, writeChart } from './chart'
import { ensureHandler } from './code'
import { type DesignerDocument, DesignerParseError, generateDesigner, parseDesigner } from './document'
import {
@@ -350,6 +351,11 @@ function onSetItems(target: string, items: string[]) {
if (d) commit(setItems(d, target, items))
}
function onSetChart(target: string, model: ChartModel) {
const d = parsed.value.doc
if (d) commit(writeChart(d, target, model))
}
function onRename(from: string, to: string) {
const d = parsed.value.doc
if (!d) return
@@ -505,6 +511,7 @@ const noop = () => {}
@open-event="openEvent"
@clear-event="clearEvent"
@add-resource="emit('addResource')"
@set-chart="onSetChart"
/>
</div>
</template>
+21 -1
View File
@@ -2,6 +2,8 @@
// 属性窗口和事件窗口(仿 VS):显示选中控件的常用属性;改动立即写回 Designer.cs。
import { computed, ref, watch } from 'vue'
import { type ControlDef, FORM_DEF, type PropDef, controlDef } from './catalog'
import { type ChartModel, readChart } from './chart'
import ChartDialog from './ChartDialog.vue'
import type { DesignerDocument } from './document'
import { checkName, getEventHandler, getItems, getPropText, typeOf } from './edit'
import { resourceNameOf } from './resources'
@@ -24,8 +26,13 @@ const emit = defineEmits<{
clearEvent: [target: string, event: string]
/** 图片属性里选了"添加资源…":打开项目资源对话框 */
addResource: []
/** 图表的"系列和图例"对话框点了确定 */
setChart: [target: string, model: ChartModel]
}>()
const chartOpen = ref(false)
const chartModel = computed(() => readChart(props.doc, props.selected))
const tab = ref<'props' | 'events'>('props')
const def = computed<ControlDef>(() => (props.selected === '' ? FORM_DEF : controlDef(typeOf(props.doc, props.selected) ?? '')))
const typeName = computed(() => def.value.type.split('.').at(-1)!)
@@ -173,7 +180,7 @@ function renameHandler(event: string, value: string) {
<div v-for="prop in def.props" :key="prop.name" class="row">
<label :title="`${prop.name}:${prop.description}`">{{ prop.label }}</label>
<div class="editor">
<code v-if="prop.type !== 'image' && raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
<code v-if="prop.type !== 'image' && prop.type !== 'chart' && raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
<input v-else-if="prop.type === 'string'" :value="str(prop)" @change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })" />
@@ -231,6 +238,11 @@ function renameHandler(event: string, value: string) {
</template>
</span>
<span v-else-if="prop.type === 'chart'" class="chart-summary">
<span :title="`系列:${chartModel.series.map((s) => s.name).join('、') || '无'};图例:${chartModel.legends.join('、') || '无'}`">{{ chartModel.series.length }} 个系列</span>
<button @click="chartOpen = true">编辑…</button>
</span>
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
@@ -258,6 +270,7 @@ function renameHandler(event: string, value: string) {
</div>
</div>
</div>
<ChartDialog v-model:show="chartOpen" :model="chartModel" @save="emit('setChart', selected, $event)" />
</div>
</template>
@@ -384,6 +397,13 @@ function renameHandler(event: string, value: string) {
white-space: nowrap;
}
.chart-summary {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.raw {
font-size: 11px;
color: #6b7280;
+15 -1
View File
@@ -2,7 +2,7 @@
// 只列出仿真运行时已经实现的控件和属性;默认值与 WinForms 一致(等于默认值的属性不写进 Designer.cs)。
import type { Value } from './values'
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items' | 'image'
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items' | 'image' | 'chart'
export interface PropDef {
/** WinForms 的属性名,写进 Designer.cs,代码里也用它 */
@@ -253,6 +253,20 @@ export const CONTROLS: ControlDef[] = [
],
initial: () => [['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')], ['Size', size(240, 150)]],
},
{
type: 'System.Windows.Forms.DataVisualization.Charting.Chart',
label: '图表',
group: '数据',
namePrefix: 'chart',
defaultEvent: 'Click',
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'),
// 图表区、图例、系列在 Designer.cs 里是局部变量和一串语句(见 chart.ts),用对话框编辑
{ name: 'Series', label: '系列和图例', description: '图表里画哪几条线(系列)、数据来自哪一列、图例怎样显示', type: 'chart', category: '数据' },
],
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
initial: (name) => [['Size', size(300, 300)], ['Text', str(name)]],
},
{
type: `${FORMS}.DateTimePicker`,
label: '日期时间选择器',
+91
View File
@@ -0,0 +1,91 @@
/// <reference types="bun" />
import { describe, expect, test } from 'bun:test'
import { resolve } from 'node:path'
import { CHART_TYPE, readChart, writeChart } from './chart'
import { controlDef } from './catalog'
import { generateDesigner, parseDesigner } from './document'
import { addControl, removeControl } from './edit'
const ROOT = resolve(import.meta.dir, '../../..')
const ENVIRONMENT = resolve(ROOT, 'samples/p8-t5-历史图片/Environment.Designer.cs')
const EMPTY_FORM = `namespace Demo
{
partial class Form1
{
private System.ComponentModel.IContainer components = null;
#region Windows 窗体设计器生成的代码
private void InitializeComponent()
{
this.SuspendLayout();
//
// Form1
//
this.ClientSize = new System.Drawing.Size(400, 300);
this.Name = "Form1";
this.Text = "Form1";
this.ResumeLayout(false);
}
#endregion
}
}
`
describe('图表的系列和图例', () => {
test('读出教材项目 8 的图表:两个图例、两条折线', async () => {
const doc = parseDesigner(await Bun.file(ENVIRONMENT).text())
expect(readChart(doc, 'chart1')).toEqual({
areas: ['ChartArea1'],
legends: ['MaxLegend', 'AVGLegend'],
series: [
{ name: 'MaxSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'MaxLegend', legendText: '最大值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'MaxTemperature' },
{ name: 'AVGSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'AVGLegend', legendText: '平均值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'AVGTemperature' },
],
})
})
test('原样写回时内容逐字节不变(对话框不管的 TitleForeColor 也保留)', async () => {
const text = await Bun.file(ENVIRONMENT).text()
const doc = parseDesigner(text)
expect(generateDesigner(writeChart(doc, 'chart1', readChart(doc, 'chart1')))).toBe(text)
})
test('改系列:只改动相关的语句', async () => {
const doc = parseDesigner(await Bun.file(ENVIRONMENT).text())
const model = readChart(doc, 'chart1')
model.series[1] = { ...model.series[1], chartType: 'Column', legendText: '' }
const out = generateDesigner(writeChart(doc, 'chart1', model))
expect(out).not.toContain('series4.ChartType')
expect(out).not.toContain('series4.LegendText')
expect(out).toContain('series4.YValueMembers = "AVGTemperature";')
expect(readChart(parseDesigner(out), 'chart1').series[1]).toMatchObject({ chartType: 'Column', legendText: '' })
})
test('从工具箱拖入图表:和 VS 一样带 ChartArea1、Legend1、Series1,能删掉', () => {
const def = controlDef(CHART_TYPE)!
const { doc, name } = addControl(parseDesigner(EMPTY_FORM), def, '', { x: 10, y: 20 })
expect(name).toBe('chart1')
const out = generateDesigner(doc)
expect(out).toContain(` System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea1 = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
System.Windows.Forms.DataVisualization.Charting.Legend legend1 = new System.Windows.Forms.DataVisualization.Charting.Legend();
System.Windows.Forms.DataVisualization.Charting.Series series1 = new System.Windows.Forms.DataVisualization.Charting.Series();
this.chart1 = new System.Windows.Forms.DataVisualization.Charting.Chart();`)
expect(out).toContain(` chartArea1.Name = "ChartArea1";
this.chart1.ChartAreas.Add(chartArea1);
legend1.Name = "Legend1";
this.chart1.Legends.Add(legend1);
this.chart1.Location = new System.Drawing.Point(10, 20);
this.chart1.Name = "chart1";
series1.ChartArea = "ChartArea1";
series1.Legend = "Legend1";
series1.Name = "Series1";
this.chart1.Series.Add(series1);
this.chart1.Size = new System.Drawing.Size(300, 300);`)
const removed = generateDesigner(removeControl(parseDesigner(out), 'chart1'))
expect(removed).not.toContain('Charting')
})
})
+247
View File
@@ -0,0 +1,247 @@
// 图表(Chart)的图表区、图例和系列。VS 把它们写成 InitializeComponent 开头的局部变量
// System.Windows.Forms.DataVisualization.Charting.Series series1 = new ...Series();
// 加上图表属性块里的 series1.Name = "Series1"; this.chart1.Series.Add(series1); 等语句。
// 这些语句在文档里是原样保留的 raw 语句;这里把它们读成数据("系列和图例"对话框编辑),改完按 VS 的格式和顺序写回。
// 对话框不管的属性(如教材的 legend3.TitleForeColor)原样保留。
import type { DesignerDocument, Stmt } from './document'
import { parseValue, renderValue } from './values'
export const CHARTING = 'System.Windows.Forms.DataVisualization.Charting'
export const CHART_TYPE = `${CHARTING}.Chart`
type Kind = 'ChartArea' | 'Legend' | 'Series' | 'Title'
const COLLECTION: Record<Kind, string> = { ChartArea: 'ChartAreas', Legend: 'Legends', Series: 'Series', Title: 'Titles' }
const KINDS = Object.keys(COLLECTION) as Kind[]
export interface ChartSeries {
name: string
/** SeriesChartType 的成员名,如 Line、Column */
chartType: string
chartArea: string
legend: string
legendText: string
isVisibleInLegend: boolean
/** 数据绑定:X 值、Y 值来自数据源的哪一列 */
xValueMember: string
yValueMembers: string
}
export interface ChartModel {
areas: string[]
legends: string[]
series: ChartSeries[]
}
/** 常用的图表类型(对话框的选项);SeriesChartType 的默认值是 Column */
export const CHART_TYPES: Record<string, string> = {
Column: '柱形图',
Bar: '条形图',
Line: '折线图',
Spline: '平滑折线图',
Area: '面积图',
Point: '散点图',
Pie: '饼图',
Doughnut: '圆环图',
StackedColumn: '堆积柱形图',
StepLine: '阶梯线图',
Radar: '雷达图',
}
/** 拖入新图表时和 VS 一样带一个图表区、一个图例、一个系列 */
export const DEFAULT_CHART: ChartModel = {
areas: ['ChartArea1'],
legends: ['Legend1'],
series: [{ name: 'Series1', chartType: 'Column', chartArea: 'ChartArea1', legend: 'Legend1', legendText: '', isVisibleInLegend: true, xValueMember: '', yValueMembers: '' }],
}
interface ChartObject {
kind: Kind
variable: string
/** 属性名 → C# 原文,按 VS 的顺序(属性名排序)写出 */
props: Map<string, string>
}
const DECLARATION = new RegExp(`^${CHARTING.replaceAll('.', '\\.')}\\.(\\w+) ([\\p{L}\\p{N}_]+) = new ${CHARTING.replaceAll('.', '\\.')}\\.\\1\\(\\);$`, 'u')
const ASSIGN = /^([\p{L}\p{N}_]+)\.(\w+) = (.*);$/su
const addPattern = (chart: string) => new RegExp(`^this\\.${chart}\\.(\\w+)\\.Add\\(([\\p{L}\\p{N}_]+)\\);$`, 'u')
function blockOf(doc: DesignerDocument, chart: string) {
return doc.blocks.find((b) => b.name === chart)
}
/** 读出图表的各个对象(按加入图表的顺序) */
function readObjects(doc: DesignerDocument, chart: string): ChartObject[] {
const declared = new Map<string, Kind>()
for (const stmt of doc.prologue) {
const m = stmt.kind === 'raw' ? DECLARATION.exec(stmt.text) : null
if (m && (KINDS as string[]).includes(m[1])) declared.set(m[2], m[1] as Kind)
}
const objects = new Map<string, ChartObject>()
const stmts = blockOf(doc, chart)?.stmts ?? []
const add = addPattern(chart)
for (const stmt of stmts) {
const m = stmt.kind === 'raw' ? add.exec(stmt.text) : null
const kind = m ? declared.get(m[2]) : undefined
if (m && kind && COLLECTION[kind] === m[1]) objects.set(m[2], { kind, variable: m[2], props: new Map() })
}
for (const stmt of stmts) {
const m = stmt.kind === 'raw' ? ASSIGN.exec(stmt.text) : null
if (m && objects.has(m[1])) objects.get(m[1])!.props.set(m[2], m[3])
}
return [...objects.values()]
}
const text = (value: string | undefined) => {
if (value === undefined) return ''
const v = parseValue(value)
return v.kind === 'string' ? v.value : ''
}
const member = (value: string | undefined, fallback: string) => {
if (value === undefined) return fallback
const v = parseValue(value)
return v.kind === 'enum' ? v.member : (/\.(\w+)$/.exec(value)?.[1] ?? fallback)
}
export function readChart(doc: DesignerDocument, chart: string): ChartModel {
const objects = readObjects(doc, chart)
const of = (kind: Kind) => objects.filter((o) => o.kind === kind)
return {
areas: of('ChartArea').map((o) => text(o.props.get('Name'))),
legends: of('Legend').map((o) => text(o.props.get('Name'))),
series: of('Series').map((o) => ({
name: text(o.props.get('Name')),
chartType: member(o.props.get('ChartType'), 'Column'),
chartArea: text(o.props.get('ChartArea')),
legend: text(o.props.get('Legend')),
legendText: text(o.props.get('LegendText')),
isVisibleInLegend: o.props.get('IsVisibleInLegend') !== 'false',
xValueMember: text(o.props.get('XValueMember')),
yValueMembers: text(o.props.get('YValueMembers')),
})),
}
}
const quote = (value: string) => renderValue({ kind: 'string', value })
/** 对话框管理的属性:空值或默认值不写(和 VS 一样) */
function managedProps(kind: Kind, name: string, series?: ChartSeries): [string, string | null][] {
const props: [string, string | null][] = [['Name', quote(name)]]
if (kind === 'Series' && series) {
props.push(
['ChartArea', series.chartArea ? quote(series.chartArea) : null],
['ChartType', series.chartType === 'Column' ? null : `${CHARTING}.SeriesChartType.${series.chartType}`],
['IsVisibleInLegend', series.isVisibleInLegend ? null : 'false'],
['Legend', series.legend ? quote(series.legend) : null],
['LegendText', series.legendText ? quote(series.legendText) : null],
['XValueMember', series.xValueMember ? quote(series.xValueMember) : null],
['YValueMembers', series.yValueMembers ? quote(series.yValueMembers) : null],
)
}
return props
}
const PREFIX: Record<Kind, string> = { ChartArea: 'chartArea', Legend: 'legend', Series: 'series', Title: 'title' }
/** 把对话框编辑后的数据写回文档(返回新文档;名字相同的对象沿用原来的变量和其他属性) */
export function writeChart(doc: DesignerDocument, chart: string, model: ChartModel): DesignerDocument {
const next: DesignerDocument = structuredClone(doc)
const old = readObjects(next, chart)
const oldVars = new Set(old.map((o) => o.variable))
// 变量名:VS 按类型在整个 InitializeComponent 里编号(chartArea1、legend1、series1……)
const used = new Set<string>()
for (const stmt of next.prologue) {
const m = stmt.kind === 'raw' ? DECLARATION.exec(stmt.text) : null
if (m && !oldVars.has(m[2])) used.add(m[2])
}
const fresh = (kind: Kind) => {
for (let i = 1; ; i++) {
const name = `${PREFIX[kind]}${i}`
if (!used.has(name)) return name
}
}
const objects: ChartObject[] = []
const place = (kind: Kind, name: string, series?: ChartSeries) => {
const previous = old.find((o) => o.kind === kind && text(o.props.get('Name')) === name && !used.has(o.variable))
const variable = previous?.variable ?? fresh(kind)
used.add(variable)
const props = new Map(previous?.props)
for (const [key, value] of managedProps(kind, name, series)) {
if (value === null) props.delete(key)
else props.set(key, value)
}
objects.push({ kind, variable, props })
}
for (const name of model.areas) place('ChartArea', name)
for (const name of model.legends) place('Legend', name)
// 对话框不编辑标题:原样保留
for (const title of old.filter((o) => o.kind === 'Title')) {
used.add(title.variable)
objects.push(title)
}
for (const series of model.series) place('Series', series.name, series)
// 局部变量声明:去掉旧的,按图表区、图例、标题、系列的顺序放在其他图表的声明后面(没有时放在 components 后面或最前面)
const declarationOf = (o: ChartObject): Stmt => ({ kind: 'raw', text: `${CHARTING}.${o.kind} ${o.variable} = new ${CHARTING}.${o.kind}();` })
const isOld = (s: Stmt) => s.kind === 'raw' && oldVars.has(DECLARATION.exec(s.text)?.[2] ?? '')
next.prologue = next.prologue.filter((s) => !isOld(s))
const lastDecl = next.prologue.findLastIndex((s) => s.kind === 'raw' && DECLARATION.test(s.text))
const components = next.prologue.findIndex((s) => s.kind === 'raw' && s.text.startsWith('this.components = new'))
const order: Kind[] = ['ChartArea', 'Legend', 'Title', 'Series']
const sorted = order.flatMap((kind) => objects.filter((o) => o.kind === kind))
next.prologue.splice(lastDecl >= 0 ? lastDecl + 1 : components + 1, 0, ...sorted.map(declarationOf))
// 图表属性块:去掉旧的对象语句,每类对象作为一组,按集合名插到属性排序的位置(ChartAreas、Legends、Series、Titles)
const block = next.blocks.find((b) => b.name === chart)
if (!block) return next
const add = addPattern(chart)
block.stmts = block.stmts.filter((s) => {
if (s.kind !== 'raw') return true
const assign = ASSIGN.exec(s.text)
return !(add.test(s.text) || (assign && oldVars.has(assign[1])))
})
for (const kind of order) {
// 先写这一类所有对象的属性,再依次 Add(VS 的顺序)
const ofKind = objects.filter((o) => o.kind === kind)
const group: Stmt[] = [
...ofKind.flatMap((o) =>
[...o.props].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, value]): Stmt => ({ kind: 'raw', text: `${o.variable}.${key} = ${value};` })),
),
...ofKind.map((o): Stmt => ({ kind: 'raw', text: `this.${chart}.${COLLECTION[kind]}.Add(${o.variable});` })),
]
if (group.length === 0) continue
const collection = COLLECTION[kind]
let at = block.stmts.findIndex((s) => (s.kind === 'prop' && s.path > collection) || s.kind === 'event')
if (at < 0) at = block.stmts.length
block.stmts.splice(at, 0, ...group)
}
return next
}
/** 删除图表时一起删掉它的局部变量声明 */
export function removeChartDeclarations(doc: DesignerDocument, chart: string): DesignerDocument {
const vars = new Set(readObjects(doc, chart).map((o) => o.variable))
if (vars.size === 0) return doc
return { ...doc, prologue: doc.prologue.filter((s) => !(s.kind === 'raw' && vars.has(DECLARATION.exec(s.text)?.[2] ?? ''))) }
}
/** 设计器预览:和 VS 一样用示例数据画出各个系列 */
export function previewChart(model: ChartModel) {
const sample = [3, 5, 4, 6, 5]
return {
series: model.series.map((s, i) => ({
name: s.name,
type: s.chartType,
legend: s.legendText || s.name,
inLegend: s.isVisibleInLegend && s.legend !== '',
color: null,
width: 1,
labels: false,
points: sample.map((y, j) => ({ x: j + 1, y: y + i * 2 })),
})),
titles: [],
legendDocking: 'Right',
yMin: null,
yMax: null,
}
}
+5 -1
View File
@@ -1,6 +1,7 @@
// 对 Designer.cs 文档的编辑操作。每个操作返回新文档,不修改传入的文档。
// 修改过的语句清掉原文(text),生成时按 VS 格式重写;其余语句原样保留。
import { type ControlDef, controlDef, isContainerType } from './catalog'
import { CHART_TYPE, DEFAULT_CHART, removeChartDeclarations, writeChart } from './chart'
import type { Block, DesignerDocument, Stmt } from './document'
import { renderValue, type Value } from './values'
@@ -180,7 +181,8 @@ export function addControl(
if (!def.component) insertChild(next, parent, name)
next.tail = addField(next.tail, def.type, name, next.eol)
syncLayout(next)
return { doc: next, name }
// 图表和 VS 一样带一个图表区、一个图例、一个系列
return { doc: def.type === CHART_TYPE ? writeChart(next, name, DEFAULT_CHART) : next, name }
}
/** 新加入的子控件在最上层:Controls.Add 放在已有的 Controls.Add 之前 */
@@ -201,6 +203,8 @@ export function removeControl(doc: DesignerDocument, name: string): DesignerDocu
for (const child of childrenOf(next, n)) collect(child)
}
collect(name)
// 图表的图表区、图例、系列是 InitializeComponent 开头的局部变量,一起删掉
for (const n of doomed) if (typeOf(next, n) === CHART_TYPE) next.prologue = removeChartDeclarations(next, n).prologue
const keep = (s: Stmt) =>
!(
(s.kind === 'create' && doomed.has(s.name)) ||
+4
View File
@@ -2,6 +2,7 @@
// 保证设计时看到的样子和运行时一致。
import type { ControlNode } from '@iot/shared'
import { controlDef } from './catalog'
import { CHART_TYPE, previewChart, readChart } from './chart'
import type { DesignerDocument } from './document'
import { childrenOf, findBlock, getItems, listControls } from './edit'
import { resourceNameOf, resourceUrl } from './resources'
@@ -13,6 +14,7 @@ export const FORM_ID = '__form'
/** 运行时控件树里的类型名:System.Windows.Forms.Button → Button,教材控件库保留完整类名 */
function clientType(type: string) {
if (type === 'AxWMPLib.AxWindowsMediaPlayer') return 'AxWindowsMediaPlayer'
if (type === 'System.Windows.Forms.DataVisualization.Charting.Chart') return 'Chart'
return type.startsWith('System.Windows.Forms.') ? type.slice('System.Windows.Forms.'.length) : type
}
@@ -77,6 +79,8 @@ export function previewTree(doc: DesignerDocument): ControlNode {
})
// 设计器里不需要滚动:LED 只显示黑底
if (type === 'WinFormsDeviceControlLibrary.LED') props.LedText = ''
// 图表和 VS 一样用示例数据画出各个系列
if (type === CHART_TYPE) props.Chart = previewChart(readChart(doc, name))
// 和 VS 一样显示今天的日期
if (type === 'System.Windows.Forms.DateTimePicker') props.Text = previewDate(String(props.Format ?? 'Long'))
return { id: name, type: clientType(type), name, props, children: childrenOf(doc, name).filter((c) => types.has(c)).map(build) }
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
// 图表(WinForms 的 Chart,教材项目 8 的 7 天温度折线图):运行器发来各个系列的点,用 Chart.js 画。
// 图表类型按 WinForms 的 SeriesChartType 对应;配色是 WinForms 默认的 BrightPastel;图例默认在右边;不要动画。
import {
ArcElement, BarController, BarElement, CategoryScale, Chart, DoughnutController, Filler, Legend, LinearScale, LineController,
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip, type ChartConfiguration,
} from 'chart.js'
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
import type { ControlNode } from '@iot/shared'
Chart.register(ArcElement, BarController, BarElement, CategoryScale, DoughnutController, Filler, Legend, LinearScale, LineController,
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip)
interface ChartSeries {
name: string
type: string
legend: string
inLegend: boolean
color: string | null
width: number
labels: boolean
points: { x: string | number; y: number | null }[]
}
interface ChartData {
series: ChartSeries[]
titles: string[]
legendDocking: string
yMin: number | null
yMax: number | null
}
const props = defineProps<{ node: ControlNode; style: CSSProperties }>()
/** WinForms 图表默认的 BrightPastel 配色 */
const PALETTE = ['#418CF0', '#FCB441', '#E0400A', '#056492', '#BFBFBF', '#1A3B69', '#FFE382', '#129CDD', '#CA6B4B', '#005CDB', '#F3D288', '#506381', '#F1B9A8', '#E0830A', '#7893BE']
const data = computed(() => props.node.props.Chart as ChartData | undefined)
const canvas = ref<HTMLCanvasElement>()
let chart: Chart | undefined
const kind = (type: string) => {
if (type === 'Pie' || type === 'Funnel' || type === 'Pyramid') return 'pie'
if (type === 'Doughnut') return 'doughnut'
if (type === 'Radar' || type === 'Polar') return 'radar'
if (type.includes('Column') || type.includes('Bar') || type.includes('Range')) return 'bar'
return 'line'
}
function config(d: ChartData): ChartConfiguration {
const first = d.series[0]
const chartKind = first ? kind(first.type) : 'line'
const labels = (first?.points ?? []).map((p) => String(p.x))
const pie = chartKind === 'pie' || chartKind === 'doughnut'
const horizontal = d.series.some((s) => s.type === 'Bar' || s.type.startsWith('StackedBar'))
const stacked = d.series.some((s) => s.type.startsWith('Stacked'))
const datasets = d.series.map((s, i) => {
const color = s.color ?? PALETTE[i % PALETTE.length]
const type = kind(s.type)
return {
type: pie ? chartKind : type,
label: s.legend,
data: s.points.map((p) => p.y),
borderColor: pie ? '#fff' : color,
backgroundColor: pie ? s.points.map((_, j) => PALETTE[j % PALETTE.length]) : type === 'line' && !s.type.includes('Area') ? color : `${color}cc`,
borderWidth: Math.max(1, s.width) * (type === 'line' ? 2 : 1),
tension: s.type.includes('Spline') ? 0.4 : 0,
stepped: s.type === 'StepLine',
showLine: !(s.type === 'Point' || s.type === 'FastPoint' || s.type === 'Bubble'),
fill: s.type.includes('Area'),
pointRadius: type === 'line' ? 3 : undefined,
}
})
const position = ({ Top: 'top', Bottom: 'bottom', Left: 'left' } as Record<string, 'top' | 'bottom' | 'left'>)[d.legendDocking] ?? 'right'
return {
type: chartKind,
data: { labels, datasets },
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
indexAxis: horizontal ? 'y' : 'x',
plugins: {
legend: { display: d.series.some((s) => s.inLegend), position, labels: { filter: (item) => d.series[item.datasetIndex ?? 0]?.inLegend ?? true } },
title: { display: d.titles.length > 0, text: d.titles },
},
scales: pie || chartKind === 'radar'
? {}
: {
x: { stacked, grid: { color: '#d9d9d9' } },
y: { stacked, min: d.yMin ?? undefined, max: d.yMax ?? undefined, beginAtZero: true, grid: { color: '#d9d9d9' } },
},
},
} as ChartConfiguration
}
function draw() {
chart?.destroy()
chart = undefined
if (!canvas.value || !data.value) return
chart = new Chart(canvas.value, config(data.value))
}
onMounted(draw)
watch(data, draw)
onBeforeUnmount(() => chart?.destroy())
</script>
<template>
<div class="wf-control wf-chart" :style="style">
<canvas ref="canvas" />
</div>
</template>
+2
View File
@@ -5,6 +5,7 @@ import { computed, inject, provide, ref, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format'
import { backgroundCss, RUN_IMAGES, type ImageValue } from './images'
import ChartControl from './ChartControl.vue'
import DataGridViewControl from './DataGridViewControl.vue'
import DateTimePickerControl from './DateTimePickerControl.vue'
import LedControl from './LedControl.vue'
@@ -189,6 +190,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<DateTimePickerControl v-else-if="node.type === 'DateTimePicker'" :node="node" :style="style" :enabled="enabled" :act="act" />
<ChartControl v-else-if="node.type === 'Chart'" :node="node" :style="style" />
<TrackBarControl v-else-if="node.type === 'TrackBar'" :node="node" :style="style" :enabled="enabled" :act="act" />