图表(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:
1 parent
d60868d912
commit
2cd0111967
13 files changed
+1227
-4
No files matched your search
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '日期时间选择器',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)) ||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
|
||||
Reference in new issue
Block a user