将硬编码的 PumpStation2DControl 和 PumpStation3DControl 改造为数据驱动,能够根据 ProjectProfile 动态渲染任意数量/类型的设备。
PumpStation2DControl
├── Tank1LevelProperty ~ Tank4LevelProperty ← 固定 4 个
├── Pump1RunningProperty ~ Pump5RunningProperty ← 固定 5 个
├── Fan1RunningProperty ~ Fan2RunningProperty ← 固定 2 个
└── Render(DrawingContext)
├── DrawTank(index) ← 位置硬编码
├── DrawPump(index) ← 位置硬编码
└── DrawFan(index) ← 位置硬编码
问题:
x = 100 + index * 150)PumpStation2DControl
├── Profile : ProjectProfile ← 从项目档案读取设备列表
├── LayoutEngine ← 根据设备数量自动计算布局
├── Renderers ← 复用现有 Renderer 绘制设备
│ ├── TankRenderer
│ ├── PumpRenderer
│ ├── FanRenderer
│ ├── ValveRenderer
│ └── PipeRenderer
└── Render(DrawingContext)
└── 遍历 Profile 设备,调用对应 Renderer
在 PumpStation2DControl 中用 IList 替代固定属性:
// 移除硬编码属性
// public double Tank1Level { get; set; } ← 删除
// 新增动态属性
public static readonly StyledProperty<ProjectProfile> ProfileProperty =
AvaloniaProperty.Register<PumpStation2DControl, ProjectProfile>(nameof(Profile));
public static readonly StyledProperty<IDictionary<string, float>> AnalogValuesProperty =
AvaloniaProperty.Register<PumpStation2DControl, IDictionary<string, float>>(nameof(AnalogValues));
public static readonly StyledProperty<IDictionary<string, bool>> DigitalValuesProperty =
AvaloniaProperty.Register<PumpStation2DControl, IDictionary<string, bool>>(nameof(DigitalValues));
// ViewModel 通过字典传值,不再绑定具体属性
// AnalogValues = { "集水池.Level": 3.5, "1#泵.Current": 12.3, ... }
// DigitalValues = { "1#泵.Run": true, "2#泵.Fault": false, ... }
public class DeviceLayout
{
public string DeviceId { get; set; }
public string DeviceType { get; set; } // "Tank", "Pump", "Fan", "Valve"
public Rect Bounds { get; set; } // 绘制区域
public Dictionary<string, object> Parameters { get; set; } // 额外参数
}
public class LayoutResult
{
public List<DeviceLayout> Devices { get; set; }
public List<(Point From, Point To)> Pipes { get; set; } // 管道连接
}
public interface ILayoutEngine
{
LayoutResult Calculate(ProjectProfile profile, Size canvasSize);
}
输入:3 泵 + 1 池 + 0 风机
输出:
┌────────────────────────────────────┐
│ [集水池] │ ← 顶部居中
│ │ │
│ ┌───────┼───────┐ │
│ │ │ │ │
│ [P1] [P2] [P3] │ ← 中间均分
│ │ │ │ │
│════╪═══════╪═══════╪═══════════════│ ← 管道
└────────────────────────────────────┘
public class DefaultLayoutEngine : ILayoutEngine
{
public LayoutResult Calculate(ProjectProfile profile, Size canvasSize)
{
var devices = new List<DeviceLayout>();
var pipes = new List<(Point, Point)>();
// 1. 计算水箱位置(顶部居中)
var tankY = canvasSize.Height * 0.15;
var tankSpacing = canvasSize.Width / (profile.Tanks.Count + 1);
for (int i = 0; i < profile.Tanks.Count; i++)
{
devices.Add(new DeviceLayout
{
DeviceId = profile.Tanks[i].Name,
DeviceType = "Tank",
Bounds = new Rect(tankSpacing * (i + 1) - 40, tankY, 80, 120)
});
}
// 2. 计算泵位置(中部均分)
var pumpY = canvasSize.Height * 0.5;
var pumpSpacing = canvasSize.Width / (profile.Pumps.Count + 1);
for (int i = 0; i < profile.Pumps.Count; i++)
{
devices.Add(new DeviceLayout
{
DeviceId = profile.Pumps[i].Name,
DeviceType = "Pump",
Bounds = new Rect(pumpSpacing * (i + 1) - 30, pumpY, 60, 60)
});
}
// 3. 计算管道连接(水箱→泵→出水总管)
// ...
return new LayoutResult { Devices = devices, Pipes = pipes };
}
}
// 只在 Profile 变化时重新计算布局
partial void OnProfileChanged(ProjectProfile oldProfile, ProjectProfile newProfile)
{
_cachedLayout = _layoutEngine.Calculate(newProfile, new Size(960, 540));
InvalidateVisual();
}
现有 Controls/Renderers/ 目录已有:
TankRenderer.csPumpRenderer.csFanRenderer.csValveRenderer.csPipeRenderer.cs只需修改调用方式:
protected override void Render(DrawingContext context)
{
if (_cachedLayout == null) return;
// 遍历布局结果,调用对应 Renderer
foreach (var device in _cachedLayout.Devices)
{
switch (device.DeviceType)
{
case "Tank":
var level = GetAnalogValue($"{device.DeviceId}.Level");
TankRenderer.Draw(context, device.Bounds, level);
break;
case "Pump":
var running = GetDigitalValue($"{device.DeviceId}.Run");
var fault = GetDigitalValue($"{device.DeviceId}.Fault");
PumpRenderer.Draw(context, device.Bounds, running, fault);
break;
// ...
}
}
// 绘制管道
foreach (var pipe in _cachedLayout.Pipes)
{
PipeRenderer.Draw(context, pipe.From, pipe.To);
}
}
private float GetAnalogValue(string key)
{
return AnalogValues != null && AnalogValues.TryGetValue(key, out var v) ? v : 0f;
}
private bool GetDigitalValue(string key)
{
return DigitalValues != null && DigitalValues.TryGetValue(key, out var v) && v;
}
public partial class ProjectViewModel : ObservableObject
{
[ObservableProperty]
private Dictionary<string, float> _analogValues = new();
[ObservableProperty]
private Dictionary<string, bool> _digitalValues = new();
private void OnDataUpdated(PlcDataModel data)
{
// 更新字典
foreach (var pump in _profile.Pumps)
{
AnalogValues[$"{pump.Name}.Current"] = data.GetAnalogRegister($"{pump.Name}.Current");
DigitalValues[$"{pump.Name}.Run"] = data.GetDigitalRegister($"{pump.Name}.Run");
DigitalValues[$"{pump.Name}.Fault"] = data.GetDigitalRegister($"{pump.Name}.Fault");
}
foreach (var tank in _profile.Tanks)
{
AnalogValues[$"{tank.Name}.Level"] = data.GetAnalogRegister($"{tank.Name}.Level");
}
// 触发 PropertyChanged
OnPropertyChanged(nameof(AnalogValues));
OnPropertyChanged(nameof(DigitalValues));
}
}
<!-- 当有项目档案时,显示动态 2D 视图 -->
<controls:PumpStation2DControl
Profile="{Binding ProjectView.Profile}"
AnalogValues="{Binding ProjectView.AnalogValues}"
DigitalValues="{Binding ProjectView.DigitalValues}"
IsVisible="{Binding HasProject}"/>
// 设备点击 → 弹出操作面板
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
var pos = e.GetPosition(this);
var device = _cachedLayout?.Devices.FirstOrDefault(d => d.Bounds.Contains(pos));
if (device != null)
{
// 触发命令
DeviceClickedCommand?.Execute(device.DeviceId);
}
}
public static readonly StyledProperty<ICommand> DeviceClickedCommandProperty = ...;
PumpControl 的动画逻辑)PipeLineControl)// 检测是否传入固定属性(旧用法)
if (Tank1Level > 0 || Pump1Running)
{
// 回退到硬编码布局(兼容旧代码)
DrawLegacyLayout(context);
}
else if (Profile != null)
{
// 使用动态布局
DrawDynamicLayout(context);
}
| # | 任务 | 预估 | 依赖 |
|---|---|---|---|
| 1 | 定义动态属性 + 布局数据结构 | 0.5 天 | 无 |
| 2 | 实现 ILayoutEngine + DefaultLayoutEngine | 1.5 天 | #1 |
| 3 | 改造 Render() 使用 Renderer | 1 天 | #1, #2 |
| 4 | ProjectViewModel 暴露字典属性 | 0.5 天 | 无 |
| 5 | ViewA 绑定调整 | 0.5 天 | #3, #4 |
| 6 | 点击事件 + 操作面板 | 0.5 天 | #5 |
| 7 | 动画效果(泵旋转/管道流动) | 0.5 天 | #3 |
| 8 | 向后兼容处理 | 0.5 天 | #3 |
| 9 | 单元测试 | 0.5 天 | #2 |
| 总计 | ~6 天 |
当前方案(ProjectView 卡片布局)已能满足扬杰二厂需求。
只有以下场景才需要动态 2D 工艺流程图:
如果以上都不需要,维持现状即可,不必投入 6 天开发。
如果只需简单的动态流程图,可以用 SVG/Canvas 方案: