图表(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

+6 -1
View File
@@ -23,6 +23,7 @@
"name": "@iot/web",
"dependencies": {
"@iot/shared": "workspace:*",
"chart.js": "^4.6.1",
"modern-screenshot": "^4.7.0",
"monaco-editor": "^0.57.0",
"naive-ui": "^2.41.0",
@@ -115,6 +116,8 @@
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "https://registry.npmjs.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "https://registry.npmjs.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -217,6 +220,8 @@
"bun-types": ["bun-types@1.4.3", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.3.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"chart.js": ["chart.js@4.6.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g=="],
"css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmjs.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -251,7 +256,7 @@
"marked": ["marked@14.0.0", "https://registry.npmjs.com/marked/-/marked-14.0.0.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"modern-screenshot": ["modern-screenshot@4.7.0", "", {}, "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg=="],
"modern-screenshot": ["modern-screenshot@4.7.0", "https://registry.npmjs.com/modern-screenshot/-/modern-screenshot-4.7.0.tgz", {}, "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg=="],
"monaco-editor": ["monaco-editor@0.57.0", "https://registry.npmjs.com/monaco-editor/-/monaco-editor-0.57.0.tgz", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
+481
View File
@@ -0,0 +1,481 @@
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
using Sim.Host;
namespace System.Windows.Forms.DataVisualization.Charting;
public enum SeriesChartType
{
Point, FastPoint, Bubble, Line, Spline, StepLine, FastLine, Bar, StackedBar, StackedBar100, Column, StackedColumn, StackedColumn100,
Area, SplineArea, StackedArea, StackedArea100, Pie, Doughnut, Stock, Candlestick, Range, SplineRange, RangeBar, RangeColumn,
Radar, Polar, ErrorBar, BoxPlot, Renko, ThreeLineBreak, Kagi, PointAndFigure, Funnel, Pyramid,
}
public enum ChartValueType { Auto, Double, Single, Int32, Int64, UInt32, UInt64, String, DateTime, Date, Time, DateTimeOffset }
public enum Docking { Top, Right, Bottom, Left }
public enum MarkerStyle { None, Square, Circle, Diamond, Triangle, Cross, Star4, Star5, Star6, Star10 }
public enum ChartColorPalette { None, Bright, Grayscale, Excel, Light, Pastel, EarthTones, SemiTransparent, Berry, Chocolate, Fire, SeaGreen, BrightPastel }
public enum StringAlignment { Near, Center, Far }
/// <summary>图表里的元素(ChartArea、Legend、Series、Title):有名字,改动时通知所在的图表重画</summary>
public abstract class ChartNamedElement
{
private string _name = "";
internal Chart? Owner { get; set; }
public string Name
{
get => _name;
set
{
_name = value ?? "";
Changed();
}
}
public object? Tag { get; set; }
internal void Changed() => Owner?.Invalidated();
}
/// <summary>ChartAreas、Legends、Series、Titles 共用:按序号或名字取,Add 一个对象或一个名字</summary>
public class ChartNamedElementCollection<T> : IEnumerable<T> where T : ChartNamedElement, new()
{
private readonly List<T> _items = new();
private readonly Chart _owner;
private readonly string _prefix;
internal ChartNamedElementCollection(Chart owner, string prefix)
{
_owner = owner;
_prefix = prefix;
}
public int Count => _items.Count;
public T this[int index] => _items[index];
public T this[string name] =>
FindByName(name) ?? throw new ArgumentException($"在集合中找不到名为“{name}”的图表元素。");
public T? FindByName(string name) => _items.FirstOrDefault(i => i.Name == name);
public void Add(T item)
{
if (string.IsNullOrEmpty(item.Name)) item.Name = NextName();
item.Owner = _owner;
_items.Add(item);
_owner.Invalidated();
}
public T Add(string name)
{
var item = new T { Name = name };
Add(item);
return item;
}
public int IndexOf(string name) => _items.FindIndex(i => i.Name == name);
public bool Remove(T item)
{
var removed = _items.Remove(item);
_owner.Invalidated();
return removed;
}
public void RemoveAt(int index)
{
_items.RemoveAt(index);
_owner.Invalidated();
}
public void Clear()
{
_items.Clear();
_owner.Invalidated();
}
private string NextName()
{
for (var i = 1; ; i++)
if (FindByName($"{_prefix}{i}") == null) return $"{_prefix}{i}";
}
public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class Axis
{
public string Title { get; set; } = "";
public double Minimum { get; set; } = double.NaN;
public double Maximum { get; set; } = double.NaN;
public double Interval { get; set; }
public Grid MajorGrid { get; } = new();
public LabelStyle LabelStyle { get; } = new();
public bool IsStartedFromZero { get; set; } = true;
}
public class Grid
{
public bool Enabled { get; set; } = true;
public Color LineColor { get; set; } = Color.Black;
}
public class LabelStyle
{
public string Format { get; set; } = "";
public int Angle { get; set; }
}
public class ChartArea : ChartNamedElement
{
public ChartArea() { }
public ChartArea(string name) => Name = name;
public Axis AxisX { get; } = new();
public Axis AxisY { get; } = new();
public Axis AxisX2 { get; } = new();
public Axis AxisY2 { get; } = new();
public Color BackColor { get; set; }
}
public class Legend : ChartNamedElement
{
public Legend() { }
public Legend(string name) => Name = name;
public bool Enabled { get; set; } = true;
public Docking Docking { get; set; } = Docking.Right;
public string Title { get; set; } = "";
public Color TitleForeColor { get; set; } = Color.Black;
public Color BackColor { get; set; }
public Color ForeColor { get; set; } = Color.Black;
public StringAlignment Alignment { get; set; }
}
public class Title : ChartNamedElement
{
private string _text = "";
public Title() { }
public Title(string text) => _text = text;
public string Text
{
get => _text;
set
{
_text = value ?? "";
Changed();
}
}
public Font? Font { get; set; }
public Color ForeColor { get; set; } = Color.Black;
public Docking Docking { get; set; } = Docking.Top;
}
public class DataPoint
{
public DataPoint() { }
public DataPoint(double xValue, double yValue)
{
XValue = xValue;
YValues = new[] { yValue };
}
public double XValue { get; set; }
public double[] YValues { get; set; } = { 0 };
/// <summary>X 值是文字(如日期字符串)时显示的标签</summary>
public string AxisLabel { get; set; } = "";
public string Label { get; set; } = "";
public Color Color { get; set; }
public bool IsEmpty { get; set; }
public string ToolTip { get; set; } = "";
}
public class DataPointCollection : IEnumerable<DataPoint>
{
private readonly List<DataPoint> _items = new();
private readonly Series _series;
internal DataPointCollection(Series series) => _series = series;
public int Count => _items.Count;
public DataPoint this[int index] => _items[index];
/// <summary>加一个点:X 是数字或日期时按数值,否则(如字符串)当作分类标签,和 WinForms 一样</summary>
public int AddXY(object? xValue, params object?[] yValue)
{
var point = new DataPoint { YValues = yValue.Select(ToDouble).ToArray() };
switch (xValue)
{
case null or DBNull:
break;
case DateTime d:
point.XValue = d.ToOADate();
point.AxisLabel = d.ToString(CultureInfo.CurrentCulture);
break;
case IConvertible c when xValue is not string:
point.XValue = c.ToDouble(CultureInfo.InvariantCulture);
break;
default:
point.AxisLabel = Convert.ToString(xValue, CultureInfo.CurrentCulture) ?? "";
point.XValue = _items.Count + 1;
break;
}
_items.Add(point);
_series.Changed();
return _items.Count - 1;
}
public int AddY(params object?[] yValue) => AddXY(_items.Count + 1, yValue);
public void Add(DataPoint point)
{
_items.Add(point);
_series.Changed();
}
public void Clear()
{
_items.Clear();
_series.Changed();
}
public void RemoveAt(int index)
{
_items.RemoveAt(index);
_series.Changed();
}
/// <summary>按两组值绑定:xValues 和 yValues 一一对应</summary>
public void DataBindXY(IEnumerable xValues, params IEnumerable[] yValues)
{
_items.Clear();
var xs = xValues.Cast<object?>().ToList();
var ys = yValues.Select(y => y.Cast<object?>().ToList()).ToList();
for (var i = 0; i < xs.Count; i++) AddXY(xs[i], ys.Select(y => i < y.Count ? y[i] : null).ToArray());
_series.Changed();
}
public void DataBindY(params IEnumerable[] yValues) => DataBindXY(Enumerable.Range(1, yValues[0].Cast<object>().Count()).ToList(), yValues);
internal static double ToDouble(object? value) => value switch
{
null or DBNull => double.NaN,
DateTime d => d.ToOADate(),
IConvertible c => double.TryParse(Convert.ToString(c, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : double.NaN,
_ => double.NaN,
};
public IEnumerator<DataPoint> GetEnumerator() => _items.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class Series : ChartNamedElement
{
private SeriesChartType _chartType = SeriesChartType.Column;
private string _legendText = "";
private Color _color;
public Series()
{
Points = new DataPointCollection(this);
}
public Series(string name) : this() => Name = name;
public DataPointCollection Points { get; }
public SeriesChartType ChartType
{
get => _chartType;
set
{
_chartType = value;
Changed();
}
}
public string ChartArea { get; set; } = "";
public string Legend { get; set; } = "";
public string LegendText
{
get => _legendText;
set
{
_legendText = value ?? "";
Changed();
}
}
public string XValueMember { get; set; } = "";
public string YValueMembers { get; set; } = "";
public ChartValueType XValueType { get; set; }
public ChartValueType YValueType { get; set; }
public int YValuesPerPoint { get; set; } = 1;
public bool IsVisibleInLegend { get; set; } = true;
public bool IsValueShownAsLabel { get; set; }
public bool Enabled { get; set; } = true;
public int BorderWidth { get; set; } = 1;
public MarkerStyle MarkerStyle { get; set; }
public int MarkerSize { get; set; } = 5;
public Font? Font { get; set; }
public string Label { get; set; } = "";
public string ToolTip { get; set; } = "";
public Color Color
{
get => _color;
set
{
_color = value;
Changed();
}
}
}
/// <summary>
/// 图表(教材项目 8 显示最近 7 天的最高温和平均温)。和 WinForms 的 Chart 一样,设置 DataSource 后调用 DataBind()
/// 按各 Series 的 XValueMember / YValueMembers 取数据,也可以用 Points.AddXY 自己加点。浏览器用 Chart.js 画。
/// </summary>
public class Chart : Control, ISupportInitialize
{
private bool _initializing;
private bool _dirty;
public Chart()
{
ChartAreas = new ChartNamedElementCollection<ChartArea>(this, "ChartArea");
Legends = new ChartNamedElementCollection<Legend>(this, "Legend");
Series = new ChartNamedElementCollection<Series>(this, "Series");
Titles = new ChartNamedElementCollection<Title>(this, "Title");
BackColor = Color.White;
}
protected override Size DefaultSize => new(300, 300);
public ChartNamedElementCollection<ChartArea> ChartAreas { get; }
public ChartNamedElementCollection<Legend> Legends { get; }
public ChartNamedElementCollection<Series> Series { get; }
public ChartNamedElementCollection<Title> Titles { get; }
public object? DataSource { get; set; }
public ChartColorPalette Palette { get; set; } = ChartColorPalette.BrightPastel;
public Color BorderlineColor { get; set; }
public int BorderlineWidth { get; set; } = 1;
public void BeginInit() => _initializing = true;
public void EndInit()
{
_initializing = false;
Invalidated();
}
/// <summary>按 DataSource 重新取数据:每个设置了 YValueMembers 的 Series 清空后,按行加点</summary>
public void DataBind()
{
var rows = Rows(DataSource);
foreach (var series in Series)
{
if (string.IsNullOrEmpty(series.YValueMembers)) continue;
series.Points.Clear();
var yMembers = series.YValueMembers.Split(',').Select(m => m.Trim()).ToArray();
foreach (var row in rows)
{
var x = string.IsNullOrEmpty(series.XValueMember) ? null : Field(row, series.XValueMember);
series.Points.AddXY(x ?? series.Points.Count + 1, yMembers.Select(m => Field(row, m)).ToArray());
}
}
Invalidated();
}
private static List<object> Rows(object? source) => source switch
{
null => new List<object>(),
DataSet set => set.Tables.Count > 0 ? set.Tables[0].Rows.Cast<object>().ToList() : new List<object>(),
DataTable table => table.Rows.Cast<object>().ToList(),
DataView view => view.Cast<object>().ToList(),
IEnumerable list => list.Cast<object>().ToList(),
_ => throw new ArgumentException("图表的 DataSource 要是 DataTable、DataView、DataSet 或列表。"),
};
private static object? Field(object row, string member)
{
try
{
return row switch
{
DataRow r => r[member],
DataRowView v => v[member],
_ => row.GetType().GetProperty(member)?.GetValue(row),
};
}
catch (ArgumentException)
{
throw new ArgumentException($"数据源里没有名为“{member}”的列(检查 XValueMember / YValueMembers)。");
}
}
/// <summary>图表内容变了:攒到这一轮消息处理结束时再发给浏览器</summary>
internal void Invalidated()
{
if (_initializing || !IsAttached || _dirty) return;
_dirty = true;
SimHost.Ui.Post(() =>
{
_dirty = false;
NotifyChanged("Chart", ChartJson());
});
}
private JsonObject ChartJson()
{
var series = new JsonArray();
foreach (var s in Series)
{
if (!s.Enabled) continue;
var points = new JsonArray();
foreach (var p in s.Points)
points.Add(new JsonObject { ["x"] = p.AxisLabel.Length > 0 ? p.AxisLabel : p.XValue, ["y"] = p.YValues.Length > 0 && !double.IsNaN(p.YValues[0]) ? p.YValues[0] : null });
series.Add(new JsonObject
{
["name"] = s.Name,
["type"] = s.ChartType.ToString(),
["legend"] = s.LegendText.Length > 0 ? s.LegendText : s.Name,
["inLegend"] = s.IsVisibleInLegend && Legends.Count > 0 && Legends.Any(l => l.Enabled),
["color"] = PropValue.ColorCss(s.Color),
["width"] = s.BorderWidth,
["labels"] = s.IsValueShownAsLabel,
["points"] = points,
});
}
var titles = new JsonArray();
foreach (var t in Titles) titles.Add(t.Text);
var area = ChartAreas.Count > 0 ? ChartAreas[0] : null;
return new JsonObject
{
["series"] = series,
["titles"] = titles,
["legendDocking"] = Legends.Count > 0 ? Legends[0].Docking.ToString() : "Right",
["yMin"] = area != null && !double.IsNaN(area.AxisY.Minimum) ? area.AxisY.Minimum : null,
["yMax"] = area != null && !double.IsNaN(area.AxisY.Maximum) ? area.AxisY.Maximum : null,
};
}
protected override void WriteProps(JsonObject props)
{
base.WriteProps(props);
props["Chart"] = ChartJson();
}
}
+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" />