UMLダイアグラムエディタのプロトタイプを例として使用して、OOPを使用したインタラクティブダイアグラムの実装。 パート1

ソフトウェア開発者は、2次元のインタラクティブなグラフィックコンポーネントを頻繁に作成する必要に対処する必要があります。 以前はデータ処理アルゴリズムのみを使用することに慣れていたプログラマーは、事前に定義された「アクティブ」エリアの静的な画像などの完全に原始的なソリューションを使用できない限り、このようなタスクが発生すると大きな困難に直面します。 タスクの非標準的な性質は多くの人々を驚かせ、グラフを描くための既製のツールとライブラリを探しさせます。 しかし、ライブラリがどれほど多機能であっても、特定の問題を解決するために何かが欠けています。

この記事では、オブジェクト指向開発環境でインタラクティブな「ドラッグ可能な」要素を使用して「ゼロから」コンポーネントを作成する方法を詳しく調べます。 例として、プロトタイプUMLエディターを作成します。


問題の声明


基本的なソリューション要件


インタラクティブなグラフィックスの実装を必要とするタスクのリストは非常に広範囲です。 かもしれない

-などなど。 これらすべての図の外観は異なりますが、すべての場合、いくつかの一般的な要件を実装する必要があります。 ここにあります:

要素を「ドラッグアンドドロップ」する機能の重要性を強調する必要があります。 タスクにグラフを視覚化する必要がある場合は、覚えておく必要があります:平面上にグラフノードを自動的に配置するための多くのアルゴリズムのいずれも、すべての場合に完全に満足できるソリューションを提供することはできません。
この「料理」を準備するには、どの「成分」が必要ですか? この記事では、 4つの主要な条件がすべて満たされた場合に、あらゆる開発環境に適用できる一般的な原則を示します
  1. オブジェクト指向プログラミング言語。
  2. グラフィックプリミティブ(線、円弧、多角形など)を描画する機能を備えた「キャンバス」オブジェクト(キャンバス)のアクセシビリティ。
  3. 制御されたスクロールバーを実装するコンポーネント。
  4. マウスイベント処理のアクセシビリティ。

説明に役立つ例は、UMLユースケース図エディタのプロトタイプです; このガイドの美しい図を使用します 。 この例のソースコードはhttps://github.com/inponomarev/graphexampleで入手でき、Mavenを使用してコンパイルできます。 記事に記載されている原則をよりよく理解したい場合は、これらのソースをダウンロードして、記事で学習することを強くお勧めします。

この例は、標準のSwingライブラリを使用してJava 8で構築されています。 ただし、記載されている原則にはJava固有のものはありません。 最初にここで概説した原則をDelphi(Windowsアプリケーション)で実装し、次にGoogle Web Toolkit(HTML Canvasへのグラフィックス出力を備えたWebアプリケーション)で実装しました。 上記の4つの条件が満たされると、提案された例を別の開発環境に変換できます。

「単純な」アプローチの難しさ


一般に、グラフィックプリミティブを表示する方法を使用して、何らかの種類の図を画面に描画することは困難な作業です。 スティックスティックキュウリ
canvas.drawOval(10, 0, 10, 10); canvas.drawLine(15, 10, 15, 25); canvas.drawLine(5, 15, 25, 15); canvas.drawLine(5, 35, 15, 25); canvas.drawLine(25, 35, 15, 25); 


しかし、これまでのところ、私たちの「小さな男」は「生き返る」わけではありません。割り当てたり、拡大縮小したり、キャンバス上を移動したりすることはできません。 したがって、これらすべての操作を担当するコードを記述する必要があります。 単純な図では、これは複雑ではないように見えますが、取得したいものの複雑さにより、問題が待っています。

手順を複雑にすることで問題を「正面から」解決しようとすると、ソースコードがすぐに複雑になり、ダイアグラムが複雑になるにつれて雪崩のように大きくなるため、失敗に終わります。 ただし、オブジェクト指向開発、「分割統治」の普遍的な原則、および設計パターンを使用すると、これらの問題を適切に処理し、必要な機能を実装する強力なツールが得られます。

だから、私たちは決定を始めています。

タスクの分解。 クラス構造


まず、描画する必要があるものの階層リストを作成して、タスクを小さな部分に分割します。
まず、ボードまたは紙に描きたい絵を描きます。



そして、次の階層を構築します。


私たちの例は、実際には非常に単純なので、階層は浅いことがわかりました。 画像が複雑になるほど、階層が広く深くなります。

一部の項目は斜体であることに注意してください。 これらは、マウスカーソルで選択可能および移動可能にするダイアグラム上のオブジェクトです。

この階層内の各アイテムは描画クラスに関連付けられ、それらの間の階層関係により、 複合パターン-「レイアウト」を適用できます( 「デザインパターン」という本を引用します)「部分全体の階層を表すツリー構造にオブジェクトを構成し、均一に...個々のオブジェクトおよび複合オブジェクトを解釈します。」 つまり、まさに必要なことを行います。

クラス図では、システムの形式は次のとおりです。


クラス図の上部には、描画されるチャートの詳細について「何も知らない」2つのクラス(DiagramPanelとDiagramObject)があり、さまざまな種類の図を作成できるフレームワークを形成します。 DiagramPanel(この場合、これはjavax.swing.JPanelクラスの相続人です)は、ダイアグラムを表示し、ユーザーとの対話を行うビジュアルインターフェイスコンポーネントです。 DiagramPanelオブジェクトには、レンダリング階層の最上位レベルに対応するルート描画オブジェクトであるDiagramObjectへのリンクが含まれています(この場合、これはUseCaseDiagramクラスのインスタンスになります)。

DiagramObjectは、Compositeパターンなどを介して階層を実装するすべての描画オブジェクトの基本クラスです。これについては後で説明します。

下部には、フレームワークの使用例があります。 Exampleクラス(javax.swing.JFrameの後継)はメインアプリケーションウィンドウであり、この例では、DiagramPanelのインスタンスを1つのコンポーネントとして含んでいます。 他のすべてのクラスは、DiagramObjectの子孫です。 これらは、レンダリングの階層リストのタスクに対応しています。 これらのクラスの継承階層とレンダリング階層は異なる階層であることに注意してください!

上記のレンダリング階層は次のようになります。



次に、DiagramObjectクラスとDiagramPanelクラスの構造とそれらの使用方法について詳しく説明します。

DiagramObjectクラスとその子孫


データ構造


DiagramObjectクラスは、各インスタンス内に従属レンダラーの二重リンクリストが存在するように設計されています。 これは、変数previous、next、first、およびlastを使用して実現されます。これにより、リストおよび階層内の隣接する要素を参照できます。 オブジェクトがインスタンス化されると、次のようなものが得られます。



このデータ構造は、単純な二重リンクリストに似ていますが、O(N)時間に必要な階層を収集し、必要に応じてO(1)時間に階層を収集し、特定の要素を削除するか、リストに新しい要素を挿入することで変更できます任意のアイテム。 この構造の要素へのアクセスは、リンクをクリックすることで達成される、深さのツリートラバーサルに対応するシーケンシャルにのみ興味があります。 赤い矢印に沿った移動は、直線での移動に対応し、青い矢印に沿った移動は、反対方向への移動に対応します。

内部のDiagramObjectリストに新しいオブジェクトを追加するには、addToQueue(DiagramObject subObj)メソッドを使用します。
 if (last!=null)) { last.next = subObj; subObj.previous = last; } else { first = subObj; subObj.previous = null; } subObj.next = null; subObj.parent = this; last = subObj; 


目的の画像を収集するには、適切な数の適切な引き出しをインスタンス化し、それらを適切な順序でキューに結合するだけです。 この例では、この作業のほとんどはUseCaseDiagramクラスのコンストラクターで発生します。
 DiagramActor a1 = new DiagramActor(70, 150, "Customer"); addToQueue(a1); DiagramActor a2 = new DiagramActor(50, 350, "NFRC Customer"); addToQueue(a2); DiagramActor a3 = new DiagramActor(600, 50, "Bank Employee"); addToQueue(a3); … DiagramUseCase uc1 = new DiagramUseCase(250, 50, "Open account"); addToQueue(uc1); DiagramUseCase uc2 = new DiagramUseCase(250, 150, "Deposit funds"); addToQueue(uc2); … addToQueue(new DiagramAssociation(a1, uc1)); addToQueue(new DiagramAssociation(a1, uc2)); … addToQueue(new DiagramDependency(uc2, uc5, DependencyStereotype.EXTEND)); addToQueue(new DiagramDependency(uc2, uc6, DependencyStereotype.INCLUDE)); … addToQueue(new DiagramGeneralization(a2, a1)); 


もちろん、実際にはこれを行うべきではありません。描画オブジェクトを作成するプロセスを「コードにステッチ」する代わりに、システムのデータモデルをルートクラスのコンストラクターに渡す必要があります。 既にサイクルでこのモデルのオブジェクトをバイパスして、引き出しを作成します。 たとえば、現在のダイアグラム(「UMLドキュメントモデル」の「ロール」に対応)に関連付けられたActorクラスの各インスタンスに対して、DiagramActorレンダリングクラスのオブジェクトをインスタンス化する必要があります。

引き出しに対応するモデルオブジェクトへのリンクを保存すると便利です。 これらのリンクをレンダラーのデザイナーのパラメーターの形式で直接渡すのが最も便利です。 この例では、オブジェクトの世界座標と、名前やステレオタイプなどのパラメーターが、オブジェクトの代わりに送信されます。

世界座標と画面座標


「世界座標」という用語を使用したら、すぐにそれが何であるかを明確にする必要があります。 私たちの国の「世界座標」は、図全体が収まる「想像上のミリメートル紙」上の図のオブジェクトの座標であり、左上隅に原点があり、スケーリングは行われません。 画像の縮尺が1:1で、スクロールバーが最小位置にある場合、世界座標は画面上の座標と一致します。 画面とは異なり、ワールド座標は整数型ではありませんが、浮動小数点値を取ります。 これは、画像のピクセル化がスケールの増加とともに発生しないようにするために必要です。 たとえば、1:1のスケールでは、世界座標0.3の値はゼロのスクリーンピクセルと区別できませんが、100:1のスケールでは30スクリーンピクセルになります。

チャートモデルはズームやスクロールなどの瞬間的なユーザーアクションに依存しないため、世界座標でチャートモデルを正確に計算して保存すると便利です。

世界座標を画面に変換するために、DiagramObjectクラスには重要なメソッドscaleX(...)、scaleY(...)、および単にscale(...)が含まれています。 最初の2つは、スケール係数をワールド座標に適用し、水平スクロールバーと垂直スクロールバーのシフトをそれぞれ考慮します。 最後の方法であるスケール(...)は、スケール係数を使用しますが、シフトは考慮しません:位置ではなくサイズ(たとえば、長方形の幅や円の半径)を計算するために必要です。

DiagramObjectの観点からチャートを描画します。 独立、半独立、依存オブジェクト



チャートを描画するには、ルートDiagramObjectのdraw(グラフィックスキャンバス、ダブルaDX、ダブルaDY、ダブルスケール)メソッドが呼び出されます。 そのパラメーターは次のとおりです。

このメソッドは、 テンプレートメソッドのデザインパターンを実装し、次のようになります。
 this.canvas = canvas; this.scale = scale; dX = aDX; dY = aDY; saveCanvasSetup(); internalDraw(canvas); restoreCanvasSetup(); DiagramObject curObj = first; while (assigned(curObj)) { curObj.draw(canvas, aDX, aDY, scale); curObj = curObj.next; } 


つまり、描画(...)メソッド:

したがって、アルゴリズムの不変部分はdraw(...)メソッドで実装され、可変部分(実際の描画)は後継クラスで実装されます。これがTemplate Methodパターンの本質です。

saveCanvasSetup()およびrestoreCanvasSetup()メソッドの目的は、描画コンテキストの状態を保存して、各描画オブジェクトが「元の」形式で取得できるようにすることです。 これらの方法が使用されておらず、描画相続人の1つで、インクの色が赤に変更された場合、後で描画されるものはすべて赤で描画されます。 これらのメソッドの実装は、開発環境と描画エンジンが提供する機能に依存します。 たとえば、DelphiとJava Swingでは、多くのコンテキストパラメーターを保存する必要があります。また、HTML Canvas2Dには、コンテキストのすべての状態を特別なスタックにすぐに保存するための既製のsave()およびrestore()メソッドがあります。

DiagramActorクラスでinternalDrawメソッドがどのように見えるかを示します(最初の「単純な例」と比較してください):
 static final double ACTOR_WIDTH = 25.0; static final double ACTOR_HEIGHT = 35.0; @Override protected void internalDraw(Graphics canvas) { double mX = getmX(); double mY = getmY(); canvas.drawOval(scaleX(mX + 10 - ACTOR_WIDTH / 2), scaleY(mY + 0 - ACTOR_HEIGHT / 2), scale(10), scale(10)); canvas.drawLine(scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 10 - ACTOR_HEIGHT / 2), scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 25 - ACTOR_HEIGHT / 2)); canvas.drawLine(scaleX(mX + 5 - ACTOR_WIDTH / 2), scaleY(mY + 15 - ACTOR_HEIGHT / 2), scaleX(mX + 25 - ACTOR_WIDTH / 2), scaleY(mY + 15 - ACTOR_HEIGHT / 2)); canvas.drawLine(scaleX(mX + 5 - ACTOR_WIDTH / 2), scaleY(mY + 35 - ACTOR_HEIGHT / 2), scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 25 - ACTOR_HEIGHT / 2)); canvas.drawLine(scaleX(mX + 25 - ACTOR_WIDTH / 2), scaleY(mY + 35 - ACTOR_HEIGHT / 2), scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 25 - ACTOR_HEIGHT / 2)); } 


ポイント(mX、mY)は、オブジェクトの中央です。 「単純な例」の原点は左上隅にあるため、オブジェクトの幅と高さの半分だけシフトする必要があります。 「単純な例」では、画像のスケーリングとシフトの必要性を考慮していませんでしたが、メソッドscaleX(...)、scaleY(...)、scale(...)を使用して世界座標を画面に変換するとき、これを考慮します。

DiagramActorおよびDiagramUseCaseオブジェクトは完全に「独立」しており、それらの位置はmXおよびmYフィールドに格納されている内部状態によって完全に決定されます。 同時に、すべての種類の接続矢印には独自の状態がありません。画面上の位置は、接続するオブジェクトの位置によって完全に決定され、完全に「独立」ではなく、オブジェクトの中心を結ぶ直線に沿って移動します:



また、個別にオブジェクトの署名に注意する必要があります。 内部状態では、絶対座標ではなく、親の描画オブジェクトに対するオフセットが保存されるため、「半独立」に動作します。



マウスカーソルの下のオブジェクトの定義


図面を扱った後、どのオブジェクトがクリックされたかをチャートがどのように「理解」するかという質問に移ります。 マウスカーソルの下のオブジェクトを決定するタスクは、レンダリングタスクと非常によく似ており、ある意味では対称的です。

まず、特定のチャートオブジェクトごとに、カーソルがこのオブジェクト上にあるかどうかをマウスカーソルの座標によって決定するメソッドを記述することは難しくありません。

たとえば、DiagramActorの場合、長方形の領域に入ることについて話します:
  protected boolean internalTestHit(double x, double y) { double dX = x - getmX(); double dY = y - getmY(); return dY > -ACTOR_HEIGHT / 2 && dY < ACTOR_HEIGHT / 2 && dX > -ACTOR_WIDTH / 2 && dX < ACTOR_WIDTH / 2; } 

DiagramUseCaseについては、楕円のように見える領域に入ることについて話しています:
  protected boolean internalTestHit(double x, double y) { double dX = 2 * getScale() * (x - getmX()) / (width + 2 * MARGIN / getScale()); double dY = 2 * (y - getmY()) / HEIGHT; return dX * dX + dY * dY <= 1; } 

これで、カーソルが置かれているオブジェクトを特定したい場合、シーケンシャル検索によって各チャートオブジェクトのinternalTestHitを呼び出すことができ、trueを返す最初のオブジェクトが目的のオブジェクトになります。 これは、レンダリングの逆の順序でのみ行う必要があります(データ構造を示す図の青い矢印に沿った移動)! マウスカーソルが複数のオブジェクトが交差する領域にある場合、カーソルが他のオブジェクトよりも遅く描画されたオブジェクト、つまり視覚的に「他のオブジェクトの上」にヒットするのは逆順の検索です。



別のDiagramObjectテンプレートメソッドでの実装方法は次のとおりです。
 public final DiagramObject testHit(int x, int y) { DiagramObject result; DiagramObject curObj = last; while (assigned(curObj)) { result = curObj.testHit(x, y); if (assigned(result)) return result; curObj = curObj.previous; } if (internalTestHit(x / scale + dX, y / scale + dY)) result = this; else { result = null; } return result; } 


DiagramPanelオブジェクトは、ルートレンダリングオブジェクトのtestHitメソッドを呼び出します。 実行中に、描画方向とは反対の方向に深さでレンダリングツリーを横断する再帰が行われます。 最初に見つかったオブジェクトが返されます。これは、マウスカーソルの下にある、ユーザーの視点から見た「トップ」オブジェクトになります。

マウスカーソルの下の現在のコンテキストの定義


マウスカーソルの下のオブジェクトは、より大きなオブジェクトの不可欠な部分にしかならず、独立した意味を持つことはできません。 オブジェクトに対して何らかの操作を実行し、その部分をマウスでクリックする場合は、親オブジェクトに対して操作を実行する必要があります。 複合パターンの使用に関連する手法である委任の助けを借りて、コンテキストを正しく表示することができます(このテーマに関する本デザインパターンを参照してください)。 この例では、委任を使用してオブジェクトのツールチップを取得します。たとえば、ユーザーがアクターの署名の上にマウスカーソルを移動すると、カーソルがアクター自体に置かれたときと同じヒントを取得します。

考え方は非常に単純です。DiagramObjectクラスのgetHint()メソッドは次のことを行います。internalGetHint()メソッドの独自の実装がプロンプト文字列を返すことができる場合、戻ります。 それができない場合、(レンダリング階層内の)親にアクセスして、getHint()メソッドを実行できるかどうかを確認します。 それが「行われない」場合、「責任の移転」は、非常に根本的なオブジェクト引き出しまで続きます。 委任メカニズムに加えて、 テンプレートメソッドパターンを再度適用します。
  public String getHint() { StringBuilder hintStr = new StringBuilder(); if (internalGetHint(hintStr)) return hintStr.toString(); else if (assigned(parent)) return parent.getHint(); else { return ""; } } protected boolean internalGetHint(StringBuilder hintStr) { return false; } 

ヘルパーメソッドDiagramObject


DiagramObjectの相続人は、次のメソッドをオーバーライドできます。DiagramPanelクラスでのそれらの使用は、以下から明らかになります。


図面の選択


最後に、DiagramObjectレベルで実装される別の重要な機能は、その子孫で再定義できますが、選択、つまりグラフィックラベルを描画します。これにより、ユーザーはオブジェクトが選択された状態にあることを理解できます。 デフォルトでは、これらはオブジェクトの角にある4つの青い正方形のドットです。

 private static final int L = 4; protected void internalDrawSelection(Graphics canvas, int dX, int dY) { canvas.setColor(Color.BLUE); canvas.setXORMode(Color.WHITE); canvas.fillRect(minX() + dX - L, minY() + dY - L, L, L); canvas.fillRect(maxX() + dX, minY() + dY - L, L, L); canvas.fillRect(minX() + dX - L, maxY() + dY, L, L); canvas.fillRect(maxX() + dX, maxY() + dY, L, L); canvas.setPaintMode(); } 

整数(したがって画面座標)パラメーターdX、dY、およびsetXORMode()の呼び出しに注意してください。これは、レンダリングコンテキストを「XORモード」に切り替えます。このモードでは、以前に描画した画像を消去するには、再描画するだけで十分です。これは、チャートオブジェクトにドラッグアンドドロップを実装するために必要です。簡単にするために、画像自体ではなく、マウスで「ドラッグ」して、画像を新しい場所に転送します。画面内のオブジェクトのオフセットは、dX、dYパラメータ開始位置を基準とした座標:


システムのこの動作が適切でない場合、DiagramObjectクラスの継承者のinternalDrawSelectionメソッドをオーバーライドして、選択としてより複雑なものを描画します(そしてドラッグアンドドロップで移動します)。

* * *


DrawObjectクラスについては以上です。この記事第2部では、DiagramPanelクラスの構築について説明します。このクラスは、マウスイベントの処理、スケーリング、パン、オブジェクトの選択、およびドラッグアンドドロップを担当します。この例の完全なソースコードは、https://github.com/inponomarev/graphexampleから入手でき、Mavenを使用してコンパイルできます。

Source: https://habr.com/ru/post/J303794/


All Articles