admin管理员组

文章数量:1439805

记一次自定义基因分类图实现(一)

一、前言

最近接到一个开发任务,要对基因表达结果进行分类,其中算法分类心酸就不一一提了,最终成功将分类给做出来了,接着想展示形式,询问相关应用同事,也查了相关资料,确定了显示形式。

二、关于基因表达分类图

图片

在征求多个同事意见和建议后,采取类似的一个变种图,将连线连到基因和样本上,整体大概形式为左侧为分类A  顶部分类B。

图片

当然这是最终效果了

三、准备开发

作为一个开发,总会不觉得去想怎么实现,对于实际业务这个图可以认为是无限扩展的,如果没有分类曲线图,还是很容易实现的,但是多了曲线,实现思路有两个,修改TreeView样式,实现逐级递减,第二个自己实现布局控件并结合列表控件。

1. TreeView 方案

优点

  • 内置功能完善:TreeView 提供默认的树状结构、折叠/展开、节点选择等交互功能,减少基础开发工作量。
  • 样式可定制:可通过修改ControlTemplate调整节点样式(如字体、颜色、缩进),满足基本UI需求。
  • 数据绑定支持:天然支持HierarchicalDataTemplate,适合层级数据的展示和操作。

缺点

  • 布局灵活性差
    • 默认横向排列,若需竖向紧凑排列(如一级节点和末级节点同列),需大幅修改模板甚至旋转控件,代码复杂。
    • 树状缩进逻辑固定,难以实现非标准层级样式(如平铺式布局)。
  • 性能问题:节点数量多时(如超1000条),默认渲染可能导致卡顿,需额外优化。
2. 自定义布局控件方案(继承 Panel 重写)

优点

  • 布局完全自由
    • 可灵活定义横向/纵向排版,无需依赖 TreeView 的固有结构。
    • 支持复杂连线绘制(如折线、贝塞尔曲线),适应不同视觉需求。
  • 性能可控:通过自定义测量(MeasureOverride)和排列(ArrangeOverride)逻辑,优化渲染效率。
  • 无冗余功能:仅实现必要特性,避免 TreeView 的额外开销。

缺点

  • 开发成本高:需手动实现布局计算、连线逻辑、交互事件(如点击折叠/展开)。
  • 自适应挑战:需额外处理动态数据变化(如节点增删)、DPI 缩放、滚动支持等细节。
  • 维护复杂:后续需求变更(如动画效果)可能需重构布局逻辑。

相比较两种方案,我其实更喜欢第二种,一方面可以对自己理解布局排列有加深巩固,另一方面也可以对绘制可以更加深刻,最重要一点比较灵活,后边可以逐渐实现虚拟化等方式。

四、绘制自定义布局控件

开始之前有个小插曲,最开始没打算写布局控件,只打算自定义控件将两边树形图画出来即可,折腾出来到实际的使用上发现一个问题,无法能够高效的跟主内容对齐互动,所以这版方案也放弃了,但是也不算没有收获,验证了树状图绘制逻辑和方法。为后来布局控件绘制打下基础。

0、准备

首先我们要进行绘制,实际上是一个树状的结构,我们需要根据层级来进行依次绘制,定义每个节点的DataContext 的实体类

结构如下:

代码语言:javascript代码运行次数:0运行复制
  /// <summary>  /// 基础分类  /// </summary>  public class BaseClusterModel  {      /// <summary>      /// 级别      /// </summary>      public int Level { get; set; }      /// <summary>      /// 每个元素宽度      /// </summary>      internal double Width { get; set; }      /// <summary>      /// 每个元素高度      /// </summary>      internal double Height { get; set; }      /// <summary>      /// 父级唯一标识      /// </summary>      public string ParentUid { get; set; }      /// <summary>      /// 标识      /// </summary>      public string Uid { get; set; }
  }

通过父级标识一级级找自己上级,通过width或者height来确定线段起始或者终点。

定义线段类

代码语言:javascript代码运行次数:0运行复制
  /// <summary>  /// 线段实体  /// </summary>  public class DrawLineModel  {      /// <summary>      /// 起始点      /// </summary>      public Point StartPoint { get; set; }      /// <summary>      /// 终点      /// </summary>      public Point EndPoint { get; set; }      /// <summary>      /// 级别      /// </summary>      public int Level { get; set; }      /// <summary>      /// 标识      /// </summary>      public string Uid { get; set; }      /// <summary>      /// 父级标识      /// </summary>      public string ParentUid { get; set; }  }

拿垂直布局来说,这里垂直指的终端元素垂直

图片

1、了解布局控件

自定义ClusterPanel继承自Panel,有两个非常重要的方法,一个是测量MeasureOverride一个是排列ArrangeOverride,我们自定义布局控件绕不开这两个方法,我们通过ArrangeOverride将标签排到最右侧,空出左侧绿色部分供我们进行画图

代码语言:javascript代码运行次数:0运行复制
protected override Size MeasureOverride(Size availableSize){    Size size = new Size();    if (Orientation == Orientation.Horizontal)    {        foreach (UIElement child in InternalChildren)        {            child.Measure(availableSize);            size.Width = Math.Max(size.Width, child.DesiredSize.Width);            size.Width += child.DesiredSize.Width;        }    }    else    {        foreach (UIElement child in InternalChildren)        {            child.Measure(availableSize);            size.Width = Math.Max(size.Width, child.DesiredSize.Width);            size.Height += child.DesiredSize.Height;        }    }    return size;}protected override Size ArrangeOverride(Size finalSize){    BaseClusters = new List<BaseClusterModel>();    if (Orientation == Orientation.Horizontal)    {        double x = 0;        foreach (FrameworkElement child in InternalChildren)        {            child.Arrange(new Rect(x, ActualHeight - child.DesiredSize.Height, child.DesiredSize.Width, child.DesiredSize.Height));            x += child.DesiredSize.Width;            BaseClusterModel baseClusterModel = child.DataContext as BaseClusterModel;            if (baseClusterModel != null)            {                BaseClusters.Add(new BaseClusterModel() { Width = child.DesiredSize.Width, Height = child.DesiredSize.Height, Uid = baseClusterModel.Uid, ParentUid = baseClusterModel.ParentUid, Level = baseClusterModel.Level });            }        }        if (InternalChildren.Count > 0)            LineMaxSpace = this.ActualHeight - InternalChildren[0].DesiredSize.Height;    }    else    {        double y = 0;        foreach (FrameworkElement child in InternalChildren)        {            child.Arrange(new Rect(ActualWidth - child.DesiredSize.Width, y, child.DesiredSize.Width, child.DesiredSize.Height));            y += child.DesiredSize.Height;            BaseClusterModel baseClusterModel = child.DataContext as BaseClusterModel;            if (baseClusterModel != null)            {                BaseClusters.Add(new BaseClusterModel() { Width = child.DesiredSize.Width, Height = child.DesiredSize.Height, Uid = baseClusterModel.Uid, ParentUid = baseClusterModel.ParentUid, Level = baseClusterModel.Level });            }        }        if (InternalChildren.Count > 0)            LineMaxSpace = this.ActualWidth - InternalChildren[0].DesiredSize.Width;    }    return finalSize;}

通过测量元素大小,以及排列 可以算出LineMaxSpace 空间供我们使用

2、重写OnRender方法

代码语言:javascript代码运行次数:0运行复制
  protected override void OnRender(DrawingContext drawingContext)  {      drawingContext.DrawRectangle(Background, null, new Rect(0, 0, ActualWidth, ActualHeight));      //计算每个格的宽度      PreLineLength = LineMaxSpace / (MaxLevel);      List<DrawLineModel> drawLines = new List<DrawLineModel>();      int yIndex = 0;      double y = 0;      int currentLevel = 0;      var verStartPoint = new Point(0, 0);      var verEndPoint = new Point(0, 0);      //绘制横线      if (Orientation == Orientation.Vertical)          DrawVer(drawLines, ref yIndex, ref y, ref currentLevel, ref verStartPoint, ref verEndPoint);      else          DrawHor(drawLines, ref yIndex, ref y, ref currentLevel, ref verStartPoint, ref verEndPoint);
      DrawLine(drawingContext, drawLines, true);
      base.OnRender(drawingContext);  }

重新OnRender主要目的是将线画出来,并且根据方向来实现分别生成要画的线序列。

画竖线:

代码语言:javascript代码运行次数:0运行复制
 /// <summary> /// 生成横向排列Lines /// </summary> /// <param name="drawLines">最终Line合集</param> /// <param name="yIndex">来控制线的位置</param> /// <param name="x">线的位置</param> /// <param name="currentLevel">当前等级</param> /// <param name="verStartPoint">起始</param> /// <param name="verEndPoint">终止</param> private void DrawHor(List<DrawLineModel> drawLines, ref int yIndex, ref double x, ref int currentLevel, ref Point verStartPoint, ref Point verEndPoint) {     foreach (BaseClusterModel baseClusterModel in BaseClusters)     {
         if (yIndex == 0)         {             x = baseClusterModel.Width / 2;         }         else         {             x += baseClusterModel.Width;         }         yIndex++;
         var startPoint = new Point(x, PreLineLength * (baseClusterModel.Level - 1));         var endPoint = new Point(x, LineMaxSpace);         drawLines.Add(new DrawLineModel() { Level = baseClusterModel.Level, StartPoint = startPoint, Uid = baseClusterModel.Uid, ParentUid = baseClusterModel.ParentUid, EndPoint = endPoint });
         verEndPoint = new Point(x, startPoint.Y);         currentLevel = baseClusterModel.Level;
     } }

画横线:

代码语言:javascript代码运行次数:0运行复制
 /// <summary> /// 生成竖向排列Lines /// </summary> /// <param name="drawLines">最终Line合集</param> /// <param name="yIndex">来控制线的位置</param> /// <param name="y">线的位置</param> /// <param name="currentLevel">当前等级</param> /// <param name="verStartPoint">起始</param> /// <param name="verEndPoint">终止</param> private void DrawVer(List<DrawLineModel> drawLines, ref int yIndex, ref double y, ref int currentLevel, ref Point verStartPoint, ref Point verEndPoint) {     foreach (BaseClusterModel baseClusterModel in BaseClusters)     {
         if (yIndex == 0)         {             y = baseClusterModel.Height / 2;         }         else         {             y += baseClusterModel.Height;         }         yIndex++;
         var startPoint = new Point(PreLineLength * (baseClusterModel.Level - 1), y);         var endPoint = new Point(LineMaxSpace, y);         drawLines.Add(new DrawLineModel() { Level = baseClusterModel.Level, Uid = baseClusterModel.Uid, ParentUid = baseClusterModel.ParentUid, StartPoint = startPoint, EndPoint = endPoint });         if (currentLevel != baseClusterModel.Level)         {             verStartPoint = new Point(startPoint.X, y);         }
         verEndPoint = new Point(startPoint.X, y);         currentLevel = baseClusterModel.Level;     } }

根据元素宽或高来定位线段终点,并根据当前节点等级来确定第一根线的位置。注意:Level从1开始

五、绘制线条

将得到的Lines,进行绘制,大致思路根据等级一级级绘制,第一次绘制出最远端的线,并将同级线段终点连接起来,并取中间作为终点,进入下一次绘制。根据ParentUid来找父级,如果没有父级,则找最近的Level-1的一根线作为父级,代码如下:

代码语言:javascript代码运行次数:0运行复制
 /// <summary> /// 根据等级绘制线段 /// </summary> /// <param name="drawingContext">绘制上下文</param> /// <param name="drawLines">要绘制的线段</param> /// <param name="level">等级</param> void DrawByLevel(DrawingContext drawingContext, List<DrawLineModel> drawLines, int level) {     var allLevelData = drawLines.FindAll(x => x.Level == level);     if (allLevelData != null && allLevelData.Count > 0)     {         var uidDic = allLevelData.GroupBy(x => x.ParentUid).ToDictionary(c => c.Key, m => m.ToList());         foreach (var item in uidDic)         {             var parentLine = drawLines.Find(p => p.Uid == item.Key);             if (parentLine == null)             {                 parentLine = drawLines.Find(p => p.Level == level - 1);             }             if (item.Value.Count == 1)             {                 if (parentLine != null)                 {                     var startPoint = new Point(item.Value[0].StartPoint.X, item.Value[0].StartPoint.Y);                     var endPoint = new Point(item.Value[0].StartPoint.X, parentLine.EndPoint.Y);                     drawingContext.DrawLine(new Pen(Brushes.Black, 1), startPoint, endPoint);                 }             }             else             {                 var minData = item.Value.Min(x => x.StartPoint.Y);                 var maxData = item.Value.Max(x => x.StartPoint.Y);                 var maxX = item.Value.Max(x => x.StartPoint.X);                 drawingContext.DrawLine(new Pen(Brushes.Black, 1), new Point(maxX, minData), new Point(maxX, maxData));                 if (parentLine != null)                 {                     var horEndPoint = GetPoint(maxX, minData, maxData);                     var horStartPoint = new Point(parentLine.StartPoint.X, horEndPoint.Y);                     drawingContext.DrawLine(new Pen(Brushes.Black, 1), horStartPoint, horEndPoint);                     var newDrawLine = new DrawLineModel() { ParentUid = parentLine.ParentUid, Level = parentLine.Level, Uid = Guid.NewGuid().ToString(), StartPoint = horStartPoint, EndPoint = horEndPoint };                     drawLines.Add(newDrawLine);                 }             }         }         level = level - 1;         DrawByLevel(drawingContext, drawLines, level);     } }

横向排序时,绘制方法打通小异,就是X,Y的变换和计算

代码语言:javascript代码运行次数:0运行复制
 /// <summary> /// 横向排版绘制 /// </summary> /// <param name="drawingContext">绘制上下文</param> /// <param name="drawLines">要绘制的线段</param> /// <param name="level">等级</param> private void DrawHorLevel(DrawingContext drawingContext, List<DrawLineModel> drawLines, int level) {     var allLevelData = drawLines.FindAll(x => x.Level == level);
     if (allLevelData != null && allLevelData.Count > 0)     {         var uidDic = allLevelData.GroupBy(x => x.ParentUid).ToDictionary(c => c.Key, m => m.ToList());         foreach (var item in uidDic)         {             var parentLine = drawLines.Find(p => p.Uid == item.Key);             if (parentLine == null)             {                 parentLine = drawLines.Find(p => p.Level == level - 1);             }             if (item.Value.Count == 1)             {                 if (parentLine != null)                 {                     var startPoint = new Point(item.Value[0].StartPoint.X, item.Value[0].StartPoint.Y);                     var endPoint = new Point(item.Value[0].StartPoint.X, parentLine.EndPoint.Y);                     drawingContext.DrawLine(new Pen(Brushes.Black, 1), startPoint, endPoint);                 }             }             else             {                 var minData = item.Value.Min(x => x.StartPoint.X);                 var maxData = item.Value.Max(x => x.StartPoint.X);                 var maxY = item.Value.Max(x => x.StartPoint.Y);                 drawingContext.DrawLine(new Pen(Brushes.Black, 1), new Point(minData, maxY), new Point(maxData, maxY));                 if (parentLine != null)                 {                     var verEndPoint = GetPointY(maxY, minData, maxData);                     var verStartPoint = new Point(verEndPoint.X, parentLine.StartPoint.Y);                     drawingContext.DrawLine(new Pen(Brushes.Black, 1), verStartPoint, verEndPoint);                     var newDrawLine = new DrawLineModel() { ParentUid = parentLine.ParentUid, Level = parentLine.Level, Uid = Guid.NewGuid().ToString(), StartPoint = verStartPoint, EndPoint = verEndPoint };                     drawLines.Add(newDrawLine);                 }             }         }         level = level - 1;         DrawHorLevel(drawingContext, drawLines, level);     } }

现在我们就实现了一个等级的绘制,

图片

下期我们将中间部分绘制以及大小绑定~ 源码奉上!

GitHub: 

本文标签: