.NET手擼繪製TypeScript類圖——下篇

  • 2019 年 11 月 15 日
  • 筆記

.NET手擼繪製TypeScript類圖——下篇

在上篇的文章中,我們介紹了如何使用.NET解析TypeScript,這篇將介紹如何使用程式碼將類圖渲染出來。

註:以防有人錯過了,上篇鏈接如下:https://www.cnblogs.com/sdflysha/p/20191113-ts-uml-with-dotnet-1.html

類型定義渲染

不出意外,我們繼續使用FlysEngine。雖然文字排版沒做過,但不試試怎麼知道好不好做呢?

正常實時渲染時,畫一兩行文字可能很容易,但繪製大量文字時,就需要引入一些排版操作了。為了實現排板,首先需要將ClassDef類擴充一下——乾脆再加個RenderingClassDef類,包含一個ClassDef

class RenderingClassDef  {      public ClassDef Def { get; set; }        public Vector2 Position { get; set; }        public Vector2 Size { get; set; }        public Vector2 Center => Position + Size / 2;  }

它包含了一些位置和大小資訊,並提供了一個中間值的變數。之所以這樣定義,因為這裡存在了一些挺麻煩的過程,比如想想以下操作:

  • 如果我想繪製放在中間的類名,我就必須知道所有行的寬度
  • 如果我想繪製邊框,我也必須知道所有行的高度

還好Direct2D/DirectWrite提供了方塊的文字寬度、高度計算屬性,通過.Metrics即可獲取。有了這個,排板過程中,我認為最難處理的是y坐標了,它是一個狀態機,需要實時去更新、計算y坐標的位置,繪製過程如下:

foreach (var classDef in AllClass.Values)  {      ctx.FillRectangle(new RectangleF(classDef.Position.X, classDef.Position.Y, classDef.Size.X, classDef.Size.Y), XResource.GetColor(Color.AliceBlue));        var position = classDef.Position;      List<TextLayout> lines =          classDef.Def.Properties.OrderByDescending(x => x.IsPublic).Select(x => x.ToString())          .Concat(new string[] { "" })          .Concat(classDef.Def.Methods.OrderByDescending(m => m.IsPublic).Select(x => x.ToString()))          .Select(x => XResource.TextLayouts[x, FontSize])          .ToList();        TextLayout titleLayout = XResource.TextLayouts[classDef.Def.Name, FontSize + 3];      float width = Math.Max(titleLayout.Metrics.Width, lines.Max(x => x.Metrics.Width)) + MarginLR * 2;      ctx.DrawTextLayout(new Vector2(position.X + (width - titleLayout.DetermineMinWidth()) / 2 + MarginLR, position.Y), titleLayout, XResource.GetColor(Color.Black));      ctx.DrawLine(new Vector2(position.X, position.Y + titleLayout.Metrics.Height), new Vector2(position.X + width, position.Y + titleLayout.Metrics.Height), XResource.GetColor(TextColor), 2.0f);        float y = lines.Aggregate(position.Y + titleLayout.Metrics.Height, (y, pt) =>      {          if (pt.Metrics.Width == 0)          {              ctx.DrawLine(new Vector2(position.X, y), new Vector2(position.X + width, y), XResource.GetColor(TextColor), 2.0f);              return y;          }          else          {              ctx.DrawTextLayout(new Vector2(position.X + MarginLR, y), pt, XResource.GetColor(TextColor));              return y + pt.Metrics.Height;          }      });      float height = y - position.Y;        ctx.DrawRectangle(new RectangleF(position.X, position.Y, width, height), XResource.GetColor(TextColor), 2.0f);      classDef.Size = new Vector2(width, height);  }

請注意變數y的使用,我使用了一個LINQ中的Aggregate,實時的繪製並統計y變數的最新值,讓程式碼簡化了不少。

這裡我又取巧了,正常文章排板應該是xy都需要更新,但這裡每個定義都固定為一行,因此我不需要關心x的位置。但如果您想搞一些更的操作,如所有類型著個色,這時只需要同時更新xy即可。

此時渲染出來效果如下:

可見類圖可能太小,我們可能需要局部放大一點,然後類圖之間產生了重疊,我們需要拖拽的方式來移動到正確位置。

放大和縮小

由於我們使用了Direct2D,無損的高清放大變得非常容易,首先我們需要定義一個變數,並響應滑鼠滾輪事件:

Vector2 mousePos;  Matrix3x2 worldTransform = Matrix3x2.Identity;    protected override void OnMouseWheel(MouseEventArgs e)  {      float scale = MathF.Pow(1.1f, e.Delta / 120.0f);      worldTransform *= Matrix3x2.Scaling(scale, scale, mousePos);  }

其中魔術值1.1代表,滑鼠每滾動一次,放大1.1倍。

另外mousePos變數由滑鼠移動事件的XY坐標經worldTransform的逆變換計算而來:

protected override void OnMouseMove(MouseEventArgs e)  {      mousePos = XResource.InvertTransformPoint(worldTransform, new Vector2(e.X, e.Y));  }

注意:

矩陣逆變換涉及一些高等數學中的線性代數知識,沒必要立即掌握。只需知道矩陣變換可以變換點位置,矩陣逆變換可以恢復原點的位置。

在本文中滑鼠移動的坐標是窗體提供的,換算成真實坐標,即需要進行「矩陣逆變換」——這在碰撞檢測中很常見。

以防我有需要,我們還再加一個快捷鍵,按空格即可立即恢復縮放:

protected override void OnKeyUp(KeyEventArgs e)  {      if (e.KeyCode == Keys.Space) worldTransform = Matrix3x2.Identity;  }

然後在OnDraw事件中,將worldTransform應用起來即可:

protected override void OnDraw(DeviceContext ctx)  {      ctx.Clear(Color.White);      ctx.Transform = worldTransform; // 重點      // 其它程式碼...  }

運行效果如下(注意放大縮小時,會以滑鼠位置為中心點進行):

碰撞檢測和拖拽

拖拽而已,為什麼會和碰撞檢測有關呢?

這是因為拖拽時,必須知道滑鼠是否處於元素的上方,這就需要碰撞檢測了。

首先給RenderingClassDef方法加一個TestPoint()方法,判斷是滑鼠是否與繪製位置重疊,這裡我使用了SharpDX提供的RectangleF.Contains(Vector2)方法,具體演算法已經不用關心,調用函數即可:

class RenderingClassDef  {      // 其它程式碼...      public bool TestPoint(Vector2 point) => new RectangleF(Position.X, Position.Y, Size.X, Size.Y).Contains(point);  }

然後在OnDraw方法中,做一個判斷,如果類方框與滑鼠出現重疊,則畫一個寬度2.0的紅色的邊框,程式碼如下:

if (classDef.TestPoint(mousePos))  {      ctx.DrawRectangle(new RectangleF(classDef.Position.X, classDef.Position.Y, classDef.Size.X, classDef.Size.Y), XResource.GetColor(Color.Red), 2.0f);  }

測試效果如下(注意滑鼠位置和紅框):

碰撞檢測做好,就能寫程式碼拖拽了。要實現拖拽,首先需要在RenderingClassDef類中定義兩個變數,用於保存其起始位置和滑鼠起始位置,用於計算滑鼠移動距離:

class RenderingClassDef  {      // 其它定義...        public Vector2? CapturedPosition { get; set; }        public Vector2 OriginPosition { get; set; }  }

然後在滑鼠按下、滑鼠移動、滑鼠鬆開時進行判斷,如果滑鼠按下時處於某個類的方框裡面,則記錄這兩個起始值:

protected override void OnMouseDown(MouseEventArgs e)  {      foreach (var item in this.AllClass.Values)      {          item.CapturedPosition = null;      }        foreach (var item in this.AllClass.Values)      {          if (item.TestPoint(mousePos))          {              item.CapturedPosition = mousePos;              item.OriginPosition = item.Position;              return;          }      }  }

如果滑鼠移動時,且有類的方框處於有值的狀態,則計算偏移量,並讓該方框隨著滑鼠移動:

protected override void OnMouseMove(MouseEventArgs e)  {      mousePos = XResource.InvertTransformPoint(worldTransform, new Vector2(e.X, e.Y));      foreach (var item in this.AllClass.Values)      {          if (item.CapturedPosition != null)          {              item.Position = item.OriginPosition + mousePos - item.CapturedPosition.Value;              return;          }      }  }

如果滑鼠鬆開,則清除該記錄值:

protected override void OnMouseUp(MouseEventArgs e)  {      foreach (var item in this.AllClass.Values)      {          item.CapturedPosition = null;      }  }

此時,運行效果如下:

類型間的關係

類型和類型之間是有依賴關係的,這也應該通過圖形的方式體現出來。使用DeviceContext.DrawLine()方法即可畫出線條,注意先畫的會被後畫的覆蓋,因此這個foreach需要放在OnDraw方法的foreach語句之前:

foreach (var classDef in AllClass.Values)  {      List<string> allTypes = classDef.Def.Properties.Select(x => x.Type).ToList();      foreach (var kv in AllClass.Where(x => allTypes.Contains(x.Key)))      {          ctx.DrawLine(classDef.Center, kv.Value.Center, XResource.GetColor(Color.Gray), 2.0f);      }  }

此時,運行效果如下:

注意:在真正的UML圖中,除了依賴關係,繼承關係也是需要體現的。而且線條是有箭頭、且線條類型也是有講究的,Direct2D支援自定義線條,這些都能做,權當留給各位自己去挑戰嘗試了。

方框順序

現在我們不能決定哪個在前,哪個在後,想像中方框可能應該就像窗體一樣,客戶點擊哪個哪個就應該提到最前,這可以通過一個ZIndex變數來表示,首先在RenderingClassDef類中加一個屬性:

public int ZIndex { get; set; } = 0;

然後在滑鼠點擊事件中,判斷如果擊中該類的方框,則將ZIndex賦值為最大值加1:

protected override void OnClick(EventArgs e)  {      foreach (var item in this.AllClass.Values)      {          if (item.TestPoint(mousePos))          {              item.ZIndex = this.AllClass.Values.Max(v => v.ZIndex) + 1;          }      }  }

然後在OnDraw方法的第二個foreach循環,改成按ZIndex從小到大排序渲染即可:

// 其它程式碼...  foreach (var classDef in AllClass.Values.OrderBy(x => x.ZIndex))  // 其它程式碼...

運行效果如下(注意我的滑鼠點擊和前後順序):

總結

其實這是一個真實的需求,我們公司寫程式碼時要求設計文檔,通常我們都使用ProcessOn等工具來繪製,但前端開發者通過需要面對好幾螢幕的類、方法和屬性,然後弄將其名稱、參數和類型一一拷貝到該工具中,這是一個需要極大耐心的工作。

「哪裡有需求,哪裡就有辦法」,這個小工具也許能給我們的客戶少許幫助,我正準備「說干就干」時——有人提醒我,我們的開發流程要先出文檔,再寫程式碼。所以……理論上不應該存在這種工具?

但後來有一天,某同事突然點醒了我,「為什麼不能有呢?這就叫Code First設計!」——是啊,Entity Framework也提供了Code First設計,很合理嘛,所以最後,就有了本篇文章?。

本文所用到的完整程式碼,可以在我的Github倉庫中下載:
https://github.com/sdcb/blog-data/tree/master/2019/20191113-ts-uml-with-dotnet

喜歡的朋友 請關注我的微信公眾號:【DotNet騷操作】

DotNet騷操作