Files
xuyueandClaude Opus 5.5 2cd0111967 图表(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>
2026-10-23 15:14:49 +08:00

482 lines
15 KiB
C#

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();
}
}