From 2cd0111967601d19f6ab1a2455500ff53f06e222 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 23 Oct 2026 15:14:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E8=A1=A8=EF=BC=88Chart=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E8=BF=90=E8=A1=8C=E6=97=B6=E3=80=81Chart.js=20?= =?UTF-8?q?=E7=BB=98=E5=88=B6=E3=80=81=E8=AE=BE=E8=AE=A1=E5=99=A8=E7=9A=84?= =?UTF-8?q?"=E7=B3=BB=E5=88=97=E5=92=8C=E5=9B=BE=E4=BE=8B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bun.lock | 7 +- runtime/Sim.WinForms/Forms/Chart.cs | 481 ++++++++++++++++++++++++++++ web/package.json | 1 + web/src/designer/ChartDialog.vue | 234 ++++++++++++++ web/src/designer/FormDesigner.vue | 7 + web/src/designer/PropertyGrid.vue | 22 +- web/src/designer/catalog.ts | 16 +- web/src/designer/chart.test.ts | 91 ++++++ web/src/designer/chart.ts | 247 ++++++++++++++ web/src/designer/edit.ts | 6 +- web/src/designer/preview.ts | 4 + web/src/winforms/ChartControl.vue | 113 +++++++ web/src/winforms/ControlView.vue | 2 + 13 files changed, 1227 insertions(+), 4 deletions(-) create mode 100644 runtime/Sim.WinForms/Forms/Chart.cs create mode 100644 web/src/designer/ChartDialog.vue create mode 100644 web/src/designer/chart.test.ts create mode 100644 web/src/designer/chart.ts create mode 100644 web/src/winforms/ChartControl.vue diff --git a/bun.lock b/bun.lock index 1b86688..edbd8cd 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/runtime/Sim.WinForms/Forms/Chart.cs b/runtime/Sim.WinForms/Forms/Chart.cs new file mode 100644 index 0000000..db4953c --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Chart.cs @@ -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 } + +/// 图表里的元素(ChartArea、Legend、Series、Title):有名字,改动时通知所在的图表重画 +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(); +} + +/// ChartAreas、Legends、Series、Titles 共用:按序号或名字取,Add 一个对象或一个名字 +public class ChartNamedElementCollection : IEnumerable where T : ChartNamedElement, new() +{ + private readonly List _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 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 }; + /// X 值是文字(如日期字符串)时显示的标签 + 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 +{ + private readonly List _items = new(); + private readonly Series _series; + + internal DataPointCollection(Series series) => _series = series; + + public int Count => _items.Count; + public DataPoint this[int index] => _items[index]; + + /// 加一个点:X 是数字或日期时按数值,否则(如字符串)当作分类标签,和 WinForms 一样 + 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(); + } + + /// 按两组值绑定:xValues 和 yValues 一一对应 + public void DataBindXY(IEnumerable xValues, params IEnumerable[] yValues) + { + _items.Clear(); + var xs = xValues.Cast().ToList(); + var ys = yValues.Select(y => y.Cast().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().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 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(); + } + } +} + +/// +/// 图表(教材项目 8 显示最近 7 天的最高温和平均温)。和 WinForms 的 Chart 一样,设置 DataSource 后调用 DataBind() +/// 按各 Series 的 XValueMember / YValueMembers 取数据,也可以用 Points.AddXY 自己加点。浏览器用 Chart.js 画。 +/// +public class Chart : Control, ISupportInitialize +{ + private bool _initializing; + private bool _dirty; + + public Chart() + { + ChartAreas = new ChartNamedElementCollection(this, "ChartArea"); + Legends = new ChartNamedElementCollection(this, "Legend"); + Series = new ChartNamedElementCollection(this, "Series"); + Titles = new ChartNamedElementCollection(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(); + } +} diff --git a/web/package.json b/web/package.json index a9e23cc..a81ebc7 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/designer/ChartDialog.vue b/web/src/designer/ChartDialog.vue new file mode 100644 index 0000000..6297156 --- /dev/null +++ b/web/src/designer/ChartDialog.vue @@ -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["名称"] 访问这个系列">名称</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> diff --git a/web/src/designer/FormDesigner.vue b/web/src/designer/FormDesigner.vue index 0c32b7a..3173a08 100644 --- a/web/src/designer/FormDesigner.vue +++ b/web/src/designer/FormDesigner.vue @@ -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> diff --git a/web/src/designer/PropertyGrid.vue b/web/src/designer/PropertyGrid.vue index 526afdf..b60a3fc 100644 --- a/web/src/designer/PropertyGrid.vue +++ b/web/src/designer/PropertyGrid.vue @@ -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; diff --git a/web/src/designer/catalog.ts b/web/src/designer/catalog.ts index e929e74..3a9a3f2 100644 --- a/web/src/designer/catalog.ts +++ b/web/src/designer/catalog.ts @@ -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: '日期时间选择器', diff --git a/web/src/designer/chart.test.ts b/web/src/designer/chart.test.ts new file mode 100644 index 0000000..01be944 --- /dev/null +++ b/web/src/designer/chart.test.ts @@ -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') + }) +}) diff --git a/web/src/designer/chart.ts b/web/src/designer/chart.ts new file mode 100644 index 0000000..f117adc --- /dev/null +++ b/web/src/designer/chart.ts @@ -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, + } +} diff --git a/web/src/designer/edit.ts b/web/src/designer/edit.ts index 9d122c4..308f3f7 100644 --- a/web/src/designer/edit.ts +++ b/web/src/designer/edit.ts @@ -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)) || diff --git a/web/src/designer/preview.ts b/web/src/designer/preview.ts index 83d9b79..d2b0444 100644 --- a/web/src/designer/preview.ts +++ b/web/src/designer/preview.ts @@ -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) } diff --git a/web/src/winforms/ChartControl.vue b/web/src/winforms/ChartControl.vue new file mode 100644 index 0000000..75f16d2 --- /dev/null +++ b/web/src/winforms/ChartControl.vue @@ -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> diff --git a/web/src/winforms/ControlView.vue b/web/src/winforms/ControlView.vue index 480916c..5a9f4e5 100644 --- a/web/src/winforms/ControlView.vue +++ b/web/src/winforms/ControlView.vue @@ -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" />