|
@@ -0,0 +1,374 @@
|
|
|
|
|
+# PumpStation2D/3D 动态化实施计划
|
|
|
|
|
+
|
|
|
|
|
+## 目标
|
|
|
|
|
+
|
|
|
|
|
+将硬编码的 `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
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 实施步骤
|
|
|
|
|
+
|
|
|
|
|
+### 阶段一:数据模型扩展(1 天)
|
|
|
|
|
+
|
|
|
|
|
+#### 1.1 定义动态属性
|
|
|
|
|
+
|
|
|
|
|
+在 `PumpStation2DControl` 中用 `IList` 替代固定属性:
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 移除硬编码属性
|
|
|
|
|
+// 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, ... }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 1.2 布局数据结构
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+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; } // 管道连接
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 阶段二:布局引擎(2 天)
|
|
|
|
|
+
|
|
|
|
|
+#### 2.1 布局策略接口
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+public interface ILayoutEngine
|
|
|
|
|
+{
|
|
|
|
|
+ LayoutResult Calculate(ProjectProfile profile, Size canvasSize);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 2.2 默认布局算法
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+输入:3 泵 + 1 池 + 0 风机
|
|
|
|
|
+输出:
|
|
|
|
|
+┌────────────────────────────────────┐
|
|
|
|
|
+│ [集水池] │ ← 顶部居中
|
|
|
|
|
+│ │ │
|
|
|
|
|
+│ ┌───────┼───────┐ │
|
|
|
|
|
+│ │ │ │ │
|
|
|
|
|
+│ [P1] [P2] [P3] │ ← 中间均分
|
|
|
|
|
+│ │ │ │ │
|
|
|
|
|
+│════╪═══════╪═══════╪═══════════════│ ← 管道
|
|
|
|
|
+└────────────────────────────────────┘
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+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 };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 2.3 布局缓存
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 只在 Profile 变化时重新计算布局
|
|
|
|
|
+partial void OnProfileChanged(ProjectProfile oldProfile, ProjectProfile newProfile)
|
|
|
|
|
+{
|
|
|
|
|
+ _cachedLayout = _layoutEngine.Calculate(newProfile, new Size(960, 540));
|
|
|
|
|
+ InvalidateVisual();
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 阶段三:渲染层改造(2 天)
|
|
|
|
|
+
|
|
|
|
|
+#### 3.1 复用现有 Renderer
|
|
|
|
|
+
|
|
|
|
|
+现有 `Controls/Renderers/` 目录已有:
|
|
|
|
|
+- `TankRenderer.cs`
|
|
|
|
|
+- `PumpRenderer.cs`
|
|
|
|
|
+- `FanRenderer.cs`
|
|
|
|
|
+- `ValveRenderer.cs`
|
|
|
|
|
+- `PipeRenderer.cs`
|
|
|
|
|
+
|
|
|
|
|
+只需修改调用方式:
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 3.2 值读取辅助方法
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+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;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 阶段四:ViewModel 适配(1 天)
|
|
|
|
|
+
|
|
|
|
|
+#### 4.1 ProjectViewModel 暴露字典属性
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+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));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 4.2 ViewA 绑定调整
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<!-- 当有项目档案时,显示动态 2D 视图 -->
|
|
|
|
|
+<controls:PumpStation2DControl
|
|
|
|
|
+ Profile="{Binding ProjectView.Profile}"
|
|
|
|
|
+ AnalogValues="{Binding ProjectView.AnalogValues}"
|
|
|
|
|
+ DigitalValues="{Binding ProjectView.DigitalValues}"
|
|
|
|
|
+ IsVisible="{Binding HasProject}"/>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 阶段五:交互与动画(1 天)
|
|
|
|
|
+
|
|
|
|
|
+#### 5.1 点击事件
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 设备点击 → 弹出操作面板
|
|
|
|
|
+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 = ...;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5.2 动画效果
|
|
|
|
|
+
|
|
|
|
|
+- 泵运行时叶轮旋转(复用 `PumpControl` 的动画逻辑)
|
|
|
|
|
+- 管道流动虚线动画(复用 `PipeLineControl`)
|
|
|
|
|
+- 故障设备红色闪烁
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### 阶段六:向后兼容(0.5 天)
|
|
|
|
|
+
|
|
|
|
|
+```csharp
|
|
|
|
|
+// 检测是否传入固定属性(旧用法)
|
|
|
|
|
+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 天** | |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 风险与注意事项
|
|
|
|
|
+
|
|
|
|
|
+1. **布局算法复杂度**:不同泵站拓扑差异大(单池/多池、串联/并联),需要多种布局策略
|
|
|
|
|
+2. **性能**:设备数量很多时(>50),需要优化渲染(裁剪、缓存)
|
|
|
|
|
+3. **动画同步**:多个泵的旋转动画需要独立计时器
|
|
|
|
|
+4. **触控适配**:工业触摸屏需要大点击区域(≥ 40×40 px)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 是否必要?
|
|
|
|
|
+
|
|
|
|
|
+**当前方案(ProjectView 卡片布局)已能满足扬杰二厂需求。**
|
|
|
|
|
+
|
|
|
|
|
+只有以下场景才需要动态 2D 工艺流程图:
|
|
|
|
|
+- 客户坚持要"仿真图"风格的界面
|
|
|
|
|
+- 需要在一个画面展示整个泵站拓扑关系
|
|
|
|
|
+- 有大屏展示需求(需要直观的工艺流程)
|
|
|
|
|
+
|
|
|
|
|
+如果以上都不需要,**维持现状即可**,不必投入 6 天开发。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 替代方案
|
|
|
|
|
+
|
|
|
|
|
+如果只需简单的动态流程图,可以用 SVG/Canvas 方案:
|
|
|
|
|
+- 在 JSON 中定义流程图 SVG 模板
|
|
|
|
|
+- 运行时替换 SVG 中的变量(液位、状态)
|
|
|
|
|
+- 无需自定义控件,开发量更小(~2 天)
|