一條曲線的誕生

ES2049發表於2021-10-30
好雨知時節,當春乃發生。隨風潛入夜,潤物細無聲。 --杜甫

引言

在圖視覺化領域有著大量和曲線相關的場景,然而想要得到一條合適的曲線卻並不容易。筆者最近在使用 AntV G6 的時候,就遇到了這樣的問題。形狀扁平,箭頭方向和連線趨勢不一致,連線起點和終點被隱藏等等,好看的曲線總是相似的,而醜陋的曲線卻各有各的問題。如何得到一條好看的曲線呢 ?來一探究竟吧。

問題

AntV G6 自帶了 cubic-vertical 連線,即豎直方向上的三階貝塞爾曲線,但是這種連線不支援傳入控制點。連線自身較不美觀,且無法處理一些極端情況,如下圖所示。圖 2 中曲線過於扁平,連線趨勢和末端箭頭的方向偏離角度太大;圖 3 中,箭頭和連線起始點完全被遮蓋。

圖1

圖 1

圖 2

圖 2

圖 3

圖 3

其他圖編輯產品的連線

他山之石,可以攻玉。為了提升使用者的連線體驗,我們橫向比較了其他圖編輯產品的連線方案。共找到 4 個較為相似的知名產品。其中 Draw.io 和 Processon 是比較偏重於圖編輯場景的,二者都支援貝塞爾曲線,都支援通過拖拽控制點的方式來讓使用者改變曲線形狀。但這種互動方式相當於把優化曲線的任務交給了使用者,和我們的目標不同。另外 DataV 的藍圖編輯器和 AntV X6 的示例都是輕量級的圖編輯場景,二者都是通過優化調整三次貝塞爾曲線來獲取更好的連線效果。下面詳細闡述各種連線方案。

Draw.io

draw.io 十分偏重圖編輯,支援三階貝塞爾曲線,高階貝塞爾曲線,使用者可以通過拖拽控制點生成任意複雜,任意形狀的曲線。如下圖所示,draw.io 支援在拖拽曲線的時候,增加或減少控制點,可以調整錨點的連線位置,調整末端箭頭的方向。

圖 4

圖 4

Draw.io 的優勢在於:

  1. 支援動態增刪控制點,使用者幾乎可以繪製出任意形狀的曲線。
  2. 拖動曲線或控制點時,錨點連線位置和末端的箭頭方向可以自適應調整,以獲得更好的效果。如上圖所示。

其缺點在於:

  1. 互動較為複雜,上手難度高。
  2. 實現複雜,動態增刪控制點邏輯複雜。

ProcessOn

Processon 同樣偏重圖編輯,但僅支援三階貝塞爾曲線,使用者可以通過拖拽控制點調整曲線形狀,拖拽控制點過程中箭頭方向會自動調整。且 Processon 的控制點位於曲線外,符合貝塞爾曲線的原始定義。此外,控制點和曲線的首尾兩點之間有輔助線,便於使用者感知控制點對於曲線形狀的作用方式。

圖 5

圖 5

Processon 的優勢在於:

  1. 拖動控制點的過程中,末端箭頭的方向會自動調整。
  2. 控制點的互動較為簡單,且有輔助線幫助使用者理解。

其缺點在於:

  1. 拖拽曲線後,再拖拽節點,曲線形狀發生較大改變。
  2. 與 Draw.io 一樣,將曲線形狀優化的任務交給了使用者。

DataV 的藍圖編輯器

DataV 的藍圖編輯器僅支援三階貝塞爾曲線,不支援拖拽控制點,但其連線效果很好,連線末尾的箭頭在拖動節點的過程中也會自適應調整形狀。

另外,藍圖編輯器限制了錨點是輸出還是輸入,如指定節點左側的錨點僅支援輸入,節點右側的錨點僅支援輸出。這樣設定,能夠減少連線所需要處理的特殊情況。

圖 6

圖 6

DataV 藍圖編輯器的優點在於:

  1. 連線為簡單的貝塞爾三階曲線,僅通過調整控制點引數來適應節點拖動所形成的形狀。
  2. 節點拖動過程中,連線變化流暢。

其缺點在於:

  1. 末端箭頭的形狀和曲線並不是完全貼合,如上圖中橙色箭頭所示。

AntV X6

AntV X6 與藍圖編輯器的效果相似,但實現方式不同。該示例同樣根據錨點相對於節點的位置限制錨點是輸入還是輸出。X6 示例通過在貝塞爾曲線的兩端新增兩段直線來獲取更好的連線效果,如下圖所示。

圖 7

圖 7

X6 的實現方式的優點在於:

  1. 實現方式簡單,僅在貝塞爾曲線兩端新增直線,且貝塞爾曲線的引數為固定值。
  2. 互動效果較為流暢。

其缺點在於:

  1. 貝塞爾曲線引數固定,節點距離較近時,曲線過於 “彎曲”,如下圖所示。

圖 8

圖 8

連線優化

本次曲線優化主要借鑑 AntV X6 的示例以及 DataV 藍圖編輯器的思路。

新增直線

設計思路

首先是借鑑 X6 的思路在連線兩端新增直線,此方案的重點在於計算首尾兩段直線的起點和終點。如下圖所示,根據錨點相對於節點的方向,直線延伸的方向也有所不同。圖中左側開始節點的連線錨點位於節點的下方,所以開始端的直線要向下延伸,而終止端節點的連線錨點位於節點的上方,此時終止端的直線要向上延伸。而右圖中開始節點的連線錨點位於節點的右側,故開始端的連線要向右延伸。

圖 9

圖 9

實現方案

為了驗證我們思路的正確性,我們首先分析了 AntV X6 的曲線。

採集資料
連線形狀Path
M -335 -185 L -335 -181 C -335 -101 -165 -194 -165 -114 L -165 -110
M -375 -75 L -375 -71 C -375 9 -165 -194 -165 -114 L -165 -110
M -255 -95 L -255 -91 C -255 -11 -175 -324 -175 -244 L -175 -240
反推規律

根據上表中 path 這一列的資料,可以看出 AntV X6 在貝塞爾曲線兩端新增直線的方式為:MoveTo 起點,LineTo 貝塞爾曲線的起點,然後 Curve,三階貝塞爾曲線,最後 lineTo 終點。以第三行為例,M -255 -95 即移動到(-255, -95)這個點,然後 L -255 -91 即從(-255, -95)這個點畫一條直線到(-255, -91)。接著,C -255 -11 -175 -324 -175 -244 即以(-255, -11)為第一個控制點,以(-175 ,-324)為第二個控制點,以(-175, -244)為貝塞爾曲線的終點繪製貝塞爾曲線。最後 L -175 -240 即從 (-175, -244)到(-175, -240)繪製直線。總結一下,第一條命令和最後一條命令中的座標分別為連線的起點和終點。兩段連線都是在 y 方向偏離起(終)點 4個單位,而貝塞爾曲線的兩個控制點的 y 座標分別為起 (終) 點的 y 座標 +(-)80,而兩個控制點的 x 座標分別與起點和終點的 x 座標相同。

用公式表達就是 :

startPoint, endPoint
M startPoint.x  startPoint.y
L startPoint.x   startPoint.y+4
C startPoint.x  startPoint.y+4+80   endPoint.x endPoint.y-4-80  endPoint.x endPoint.y-4
L endPoint.x endPoint.y

實際效果

根據錨點相對於節點的位置來自適應兩端直線的延伸方向能夠讓連線末尾的箭頭保持正確的方向,同時連線起始和末尾的直線也保證了連線的方向能夠被使用者準確感知。如下圖所示,圖中左側為預設的 cubic-vertical 曲線,圖中紅色圈中的連線部分,要麼是起點端無法顯示錨點相對於節點的方向,要麼是終點的箭頭被隱藏,這兩種情況都會嚴重影響使用者對於連線走向的感知。而右圖中的連線完全避免了這兩個問題,且連線走向更為清晰,流暢,更符合使用者的心理感知。

圖 10

圖 10

有待改進

然而,這種方案並非盡善盡美,單純在貝塞爾曲線兩端新增直線仍有兩個問題需要解決。

  1. 上述方案在計算 中段貝塞爾曲線的控制點時,使用了一個 Magic Numbe,常量 80, 導致當兩個節點比較靠近時,曲線的形狀有些奇怪,如下圖中所示。

    圖 11

    圖 11

上述問題的核心在於,我們對於貝塞爾曲線的控制點和曲線形狀之間的關係沒有準確的理解,無法理解 Magic Number 背後的原理,對應的解法就是理解貝塞爾曲線的控制點,將 Magic Number 修改為 Func (startPoint, endPoint)

  1. 上述方案在曲線連線的過程中沒有感知使用者的連線方向,導致箭頭和曲線的形狀在連線的過程和結果發生較大的變化。如下圖所示,左側為連線過程中曲線的形狀和箭頭方向,右側為連線完成後的曲線形狀和箭頭方向。該問題的核心在於要在連線的過程中準確感知使用者想要連線的方向,上述方案的做法是直接將連線方向等同於開始端連線錨點相對於節點的反方向。

    圖 12

    圖 12

    該問題的解決方案即:根據連線過程中連線末尾的座標相對於 startPoint 的方位來確定連線的方向,改造效果如下圖所示。

圖 13

圖 13

優化貝塞爾曲線的引數

除了借鑑 AntV X6 的連線方案,我們還希望借鑑 DataV 藍圖編輯器中的貝塞爾曲線。有了上述對 AntV X6 示例中連線的 “逆向工程” 之後,我們打算對 DataV 藍圖編輯器中的貝塞爾曲線也如法炮製。

實現方案

採集資料
曲線形狀Path平移起點到座標原點
M 586.5 336 C 532.7496710549095 336 545.2503289450905 335.5 491.5 335.5M 0 0 C -54 0 -41 0 -95 0
M 662.5 229 C 603.0729185103246 229 626.9270814896754 159.5 567.5 159.5M 0 0 C -60 0 -36 -70 -96 -70
M 640.5 304 C 571.5182334289478 304 648.4817665710522 160.5 579.5 160.5M 0 0 C -69 0 8 -144 -61 -144
M 457.5 345 C 372.2029329439616 345 664.7970670560384 160.5 579.5 160.5M 0 0 C - 85 0 207 -185 122 -185
M 310.5 302 C 204.51346747613758 302 685.4865325238624 160.5 579.5 160.5M 0 0 C -106 0 375 -142 269 -142
M 269.5 158 C 164.2498961794736 158 675.7501038205264 157.5 570.5 157.5M 0 0 C -105 0 406 0 301 0
M sx sy,C x1 y1 x2 y2 x3 y3,sy === y1,y2 === y3M 0 0,C x1 y1 x2 y2 x3 y3,x1 + x2 === x3,y1 + y2 === y3

如上表所示, 我們採集了不同形狀的貝塞爾曲線所對應的 Path,並試圖直接找出其中的規律。第一列是曲線的形狀,第二列是曲線對應的 Path,第三列是將曲線起點平移到座標原點後的資料。

找出規律 ?

如上表最後一行所示,第二列僅僅找到了 y1 和 y2 座標的規律,也就是控制點 1 和控制點 2 的 y 座標的計算方式。而第三列所描述的等式中關於 x1 x2 的僅有一個 二元一次方程 ,此時是無法求出 x1 x2 的,我們至少還需要一個關於 x1 x2 的方程才能求出控制點的座標。

攤手

但是如何找到另一個關於 x1、x2 的方程呢 ?先數形結合試一試,數無形則不直觀。

圖 14

圖 14

如上圖所示,圖中的黑色曲線對應上表中的四條資料,我們使用第三列的資料來繪製此圖,希望通過將起點都集中到原點的方式來方便對比和發現規律。圖中紅色曲線的每個彎折點都是控制點。

我們可以從此圖中得到以下幾點知識:

  1. 兩個控制點相對於曲線的中點對稱,也就是說,只要求出一個控制點就能得出另一個控制點。
  2. 兩個控制點在 x 方向上的距離要大於兩個起點在 x 方向上的距離。
  3. 不同形狀曲線的控制點在 x 方向上到起點的距離不同,即控制點到起點在 x 方向上的距離不是常數。

得到這些結論之後,結合表中的規律,我們發現其實只要計算出單個控制點的座標,就能得出另一個控制點的座標了。而單個控制點的座標中唯一要求的就是它的 x 座標,也就是說,只要求出控制點在 x 方向上到起點的距離即可。

換個角度考慮,在繪製開始前,我們應該只知道一條曲線的起點和終點。也就是說控制點 x 座標應該從是根據起點和終點計算出的,也就是 controlPoint.x = F (startPoint, endPoint)。但是,也有可能是controlPoint.x = F (startPoint.x, endPoint.x) 即控制點的 x 座標僅僅由起點和終點的 x 座標決定。

再拿點資料驗證一下 !

曲線形狀Path
M 490.5 -146 C 439.9372150475671 -146 494.0627849524329 -213.5 443.5 -213.5
M 490.5 -89 C 427.23097348884403 -89 506.76902651115597 -213.5 443.5 -213.5

如上表所示,兩條曲線形狀的起點和終點的 x 座標分別都相同,但是曲線的形狀不同,控制點也不同 !說明應當是 controlPoint.x = F (startPoint, endPoint)

但是如何求出 F 呢 ?

再次攤手

深挖一把

觀察 controlPoint.x = F (startPoint, endPoint),結合圖 14,我們可以發現:座標平移並不會改變曲線的形狀,對計算控制點有意義的資料應該是起點和終點在 x 方向上和 y 方向上的距離。也就是說 controlPoint.x = F(startPoint, endPoint) 即是 ctrlDistanceX = F (distanceX, distanceY)

找 F 其實就是找規律。那可以用用概率統計裡面的方法來擬合試試 ?也許規律足夠簡單,也可以先畫圖看看。但是第一步需要收集更多的資料。

  1. 更多的資料

為了更方便的採集更多的資料,我們直接在藍圖編輯器頁面的控制檯裡來點程式碼。

const data = [];
const func = () => {
  const targetPath = document.querySelector('.butterflies-link');
  data.push(targetPath.getAttribute('d'));
}
const intervalId = setInterval(func, 1000);

然後去拖動節點,變化曲線的形狀,最後先 clearInterval(intervalId), 然後在控制檯裡面輸入 data,複製貼上走獲取的資料。

去重之後的資料如下所示 :

        "M 524.5 72 C 402.67229108270203 72 527.327708917298 -275.5 405.5 -275.5",
        "M 587.5 -53 C 485.63630523692785 -53 507.36369476307215 -275.5 405.5 -275.5",
        "M 523.5 -85 C 437.4786592002655 -85 491.5213407997345 -275.5 405.5 -275.5",
        "M 407.5 173 C 265.3738851783404 173 547.6261148216596 -275.5 405.5 -275.5",
        "M 600.5 17 C 482.61468766056527 17 523.3853123394347 -275.5 405.5 -275.5",
        "M 600.5 17 C 481.18236106456914 17 939.8176389354309 -264.5 820.5 -264.5",
        "M 514.5 -1 C 383.54572507813253 -1 951.4542749218674 -264.5 820.5 -264.5",
        "M 355.5 -25 C 194.73655661844936 -25 981.2634433815506 -264.5 820.5 -264.5",
        "M 355.5 -25 C 203.2729443821868 -25 952.7270556178132 -227.5 800.5 -227.5",
        "M 355.5 -25 C 251.76642131972704 -25 751.2335786802729 -66.5 647.5 -66.5",
        "M 609.5 92 C 538.7521089502782 92 718.2478910497218 -66.5 647.5 -66.5",
        "M 609.5 92 C 538.5661738289713 92 717.4338261710287 -67.5 646.5 -67.5",
        "M 609.5 92 C 473.7472665837899 92 461.2527334162101 -221.5 325.5 -221.5",
        "M 309.5 124 C 193.03243021224662 124 441.9675697877534 -221.5 325.5 -221.5",
        "M 441.5 -199 C 381.95950872107915 -199 385.04049127892085 -221.5 325.5 -221.5",
        "M 441.5 -255 C 360.8441452051197 -255 428.1558547948803 -75.5 347.5 -75.5",
        "M 441.5 -255 C 301.5519298714161 -255 496.4480701285839 176.5 356.5 176.5",
        "M 242.5 -257 C 100.44023636915881 -257 498.5597636308412 176.5 356.5 176.5",
        "M 242.5 -257 C 93.61264522666846 -257 762.3873547733315 40.5 613.5 40.5",
        "M 649.5 -258 C 544.3342456633342 -258 718.6657543366658 40.5 613.5 40.5",
        "M 649.5 -258 C 560.515759520021 -258 712.484240479979 -23.5 623.5 -23.5",
        "M 649.5 -258 C 597.4897041137563 -258 614.5102958862437 -244.5 562.5 -244.5",
        "M 649.5 -258 C 597.4202440004424 -258 613.5797559995576 -250.5 561.5 -250.5",
        "M 641.5 -242 C 591.3874261965307 -242 611.6125738034693 -250.5 561.5 -250.5",
        "M 317.5 29 C 194.74486200215108 29 684.2551379978489 -250.5 561.5 -250.5",
        "M 600.5 -79 C 526.5303726988732 -79 635.4696273011268 -250.5 561.5 -250.5",
        "M 600.5 -79 C 525.5554160660041 -79 685.4445839339959 -258.5 610.5 -258.5",
        "M 600.5 -79 C 491.4951544207572 -79 1025.5048455792428 -75.5 916.5 -75.5",
        "M 570.5 140 C 438.59432977012517 140 1048.4056702298749 -75.5 916.5 -75.5",
        "M 568.5 176 C 465.1543925991474 176 800.8456074008526 -87.5 697.5 -87.5",
        "M 568.5 176 C 453.3999669506527 176 537.6000330493473 -131.5 422.5 -131.5",
  1. 處理資料

轉換資料格式,計算出第一個控制點在 x 方向上到起點的距離,起點和終點在 x 方向上的距離,起點和終點在 y 方向上的距離。

     // 第一個控制點在 X 方向上到起點的距離
     [
        121.82770891729797, 101.86369476307215, 86.0213407997345,
        142.1261148216596, 117.88531233943473, 119.31763893543086,
        130.95427492186747, 160.76344338155064, 152.2270556178132,
        103.73357868027296, 70.74789104972183, 70.93382617102873,
        135.7527334162101, 116.46756978775338, 59.540491278920854,
        80.65585479488033, 139.94807012858388, 142.0597636308412,
        148.88735477333154, 105.16575433666583, 88.98424047997901,
        52.01029588624374, 52.07975599955762, 50.11257380346933,
        122.75513799784892, 73.96962730112682, 74.94458393399589,
        109.00484557924278, 131.90567022987483, 103.3456074008526,
        115.1000330493473,
      ]

            // 起點和終點在 X 方向上的距離
            [
        119, 182, 118, 2, 195, 220, 306, 465, 445, 292, 38, 37, 284, 16, 116,
        94, 85, 114, 371, 36, 26, 87, 88, 80, 244, 39, 10, 316, 346, 129, 146,
      ]
       
      // 起點和終點在 Y 方向上的距離
      [
        347.5, 222.5, 190.5, 448.5, 292.5, 281.5, 263.5, 239.5, 202.5, 41.5,
        158.5, 159.5, 313.5, 345.5, 22.5, 179.5, 431.5, 433.5, 297.5, 298.5,
        234.5, 13.5, 7.5, 8.5, 279.5, 171.5, 179.5, 3.5, 215.5, 263.5, 307.5,
      ]
  1. 嘗試分析

    直接看肯定看不出什麼的,畫個圖試試。

    圖 15

    <div align="center">圖 15</div>

    圖中橫座標是不同的貝塞爾曲線的 id,縱座標是距離,紅色折線是第一個控制點在 x 方向上到起點的距離,藍色折線是起點和終點在 x 方向上的距離,綠色折線則是起點和終點在 y 方向上的距離。

    好像看不出什麼趨勢,排個序試一試。

    圖 16

    圖 16

    紅色折線總是位於藍色折線和綠色折線之間,類似於平均值的關係,應該是 z = ax + by 的形式。在這裡,我嘗試了 a = b = 0.5 | 0.4 ... 並觀察不同的計算結果得出的折線與紅色折線的貼合程度。如下圖所示,圖中黑色折現即 a = b = 0.5 時的計算結果,但是最優結果肯定不是試出來的 ...

    圖 17

    圖 17

  2. 最小二乘法

擬合一條曲線的一種方法就是最小二乘法,原理這裡就不介紹了。總之,假定紅色折線可以通過 z = ax + by 的形式由藍色折線和綠色折線擬合的話,最小二乘法可以幫我們求出最準確的 a 和 b。

import numpy as np
from scipy import optimize        # 最小二乘法擬合


def func(x, y, p):
    """ 資料擬合所用的函式:z=ax+by
    :param x: 自變數 x
    :param y: 自變數 y
    :param p: 擬合引數 a, b
    """
    a, b = p
    return a * x + b * y

def residuals(p, z, x, y):
    """ 得到資料 z 和擬合函式之間的差
    """
    return z - func(x, y, p)

xSource = [80, 87, 88, 116, 38, 37, 39, 10, 94, 118, 26, 182, 129, 292, 36, 316, 146, 16, 195, 220, 119, 244, 306, 346, 284, 85, 114, 2, 371, 445, 465]
ySource = [8.5, 13.5, 7.5, 22.5, 158.5, 159.5, 171.5, 179.5, 179.5, 190.5, 234.5, 222.5, 263.5, 41.5, 298.5, 3.5, 307.5, 345.5, 292.5, 281.5, 347.5, 279.5, 263.5, 215.5, 313.5, 431.5, 433.5, 448.5, 297.5, 202.5, 239.5]
cSource = [50.11, 52.01, 52.07, 59.54, 70.74, 70.93, 73.96, 74.94, 80.65, 86.02, 88.98, 101.86, 103.34, 103.73, 105.16, 109.0, 115.1, 116.46, 117.88, 119.31, 121.82, 122.75, 130.95, 131.9, 135.75, 139.94, 142.05, 142.12, 148.88, 152.22, 160.76]
    

def main():

  x = np.array(xSource)
  y = np.array(ySource)
  z = np.array(cSource)  # 資料隨便取的
  
  plsq = optimize.leastsq(residuals, np.array([0, 0]), args=(z, x, y))  # 最小二乘法擬合
    # [0, 0] 為引數 a, b 初始值
  
  a, b = plsq[0]  # 獲得擬合結果
  print("what >>>>>")
  print("擬合結果:\na = {}".format(a))
  print("b = {}".format(b))

main()

得出 a = 0.22320872185884902 , b = 0.28534578186377385,對應到圖上是如下效果。紅色折線為目標折線,黑色折線為擬合結果。

圖 18

圖 18

有了 a 和 b,也就有了貝塞爾曲線的控制點的計算方法。讓我們再回到畫布中看一看。

實際效果

對比上一節 “有待改進” 中的遺留問題,如下圖所示。可以發現,通過修改貝塞爾曲線控制點的計算方式我們已經完美解決了上一節中的遺留問題。

圖 19

展望未來

目前仍遺留的問題在於,我們僅知其然,還不知其所以然。我們能提供圖編輯場景下的最好的貝塞爾曲線形狀,但暫時還不明確控制點對於曲線形狀影響的規律該如何描述,也就無法提供任意形狀(風格)的貝塞爾曲線。將來可以考慮製作一個小工具,先讓使用者拖拽控制點,生成若干條某種風格的曲線,然後通過程式從中推斷出控制點的計算方式,輸出給使用者。

總結

我們針對圖編輯場景中的連線優化問題,參考了 AntV X6 示例 和 DataV 藍圖編輯器中的連線方案,通過在貝塞爾曲線兩端新增直線以及優化貝塞爾曲線控制點的計算方法優化了曲線形狀,與原有的連線方案 -- AntV G6 中的 cubic-vertical 曲線相比,大幅提升了的連線的使用者體驗。

作者:ES2049 / 金克絲

文章可隨意轉載,但請保留此原文連結。
非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj@alibaba-inc.com

相關文章