HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

AngularとPaper.jsで簡易なデジタイザを作る3

概要

最終的にデジタイザとして機能させるためには、自分で設定した任意の座標系で点をプロット(座標を取得)可能にする必要がある。今のままでは単にキャンバスのView基準の座標しか取得できないので、任意の座標系を基準とした値に変換しなければならない。ここでは、プロット機能を実装するための準備として、座標系の設定機能と座標変換処理について述べる。

完成イメージ

  • 座標系の基準範囲を設定するパス(紫色の線)をドラッグで調整する
  • 基準範囲の左端、右端、上端、下端の座標値を指定する

実装

レイヤー分割

この辺りから色々と処理が複雑になってくるので、キャンバスを複数のレイヤーに分割して各レイヤーに役割分担させることにする。 ここでは、画像を表示するbackgroundLayer、プロットを行うplottingLayer、座標軸を設定するsettingLayerに分ける。

まずは、コンポーネントのクラスファイルの記述から。座標軸を設定中かどうか判別するフラグisEditAxisと、各レイヤーオブジェクトを定義しておく。この後、レイヤーを作成するメソッドsetLayers()を実装していくが、先にngOnInit()内でsetLayers()を呼び出しておく。

import { view, Tool, Point, Layer } from 'paper/dist/paper-full'; // Layerをimportする

    // 省略

export class DigitizerComponent implements OnInit {
  @ViewChild('canvas', { static: true }) canvas: ElementRef<HTMLCanvasElement>;
  @ViewChild('fileInput', { static: false }) fileInput: ElementRef;

    // 省略

  // 各種フラグ
  isScaleEndRange = false;
  isEditAxis = false; // 追加
  // レイヤー・グループ
  backgroundLayer: Layer; // 追加
  plottingLayer: Layer; // 追加
  settingLayer: Layer; // 追加

    // 省略

  ngOnInit(): void {
    paper.setup(this.canvas.nativeElement);
    this.setEventsToView();
    this.setLayers(); // 追加
  }

    // 省略
}
setLayers()

実際にレイヤーを作成しているメソッド。 レイヤーを複数作成した場合、一番最後に作成したレイヤーが有効状態になっている。今回は使っていないが、Layer.activate()で有効なレイヤーを切り替えることができる。

様々なPaper.jsオブジェクトを生成すると、その時点で有効になっているレイヤーの子要素として自動的に登録されるらしい。もしくは、addChild()addChildren()(引数はオブジェクトの配列を渡す)を使えば現在有効でないレイヤーにも子要素を追加できる。追加すべきレイヤーを明示できるので後者の方が可読性が高いと思う。

また、初期表示時はsettingLayerは非表示にしておきたい。Layerオブジェクトのvisibleプロパティをfalse(デフォルトはtrue)に設定するとそのレイヤーが非表示になるので、settingLayerのみvisibleをfalseに設定する。

  private setLayers(): void {
    this.backgroundLayer = new Layer();
    this.plottingLayer = new Layer();
    this.settingLayer = new Layer({
      visible: false,
    });
  }


次にhtmlファイルを編集する。アクティブレイヤーの切り替えを行う「座標軸の設定」ボタンを追加する。とりあえず「画像を読み込む」ボタンの右隣にでも置いておこう。 この後クラスファイルの方でレイヤーの表示・ロック状態を切り替えるsetAxisRange()を定義するので、まずボタンのクリックイベントでsetAxisRange()を呼び出せるようにしておく。ボタンのラベルは、座標軸の設定中は「座標軸の設定終了」、それ以外の時は「座標軸を設定する」と切り替わるようにしている。

<div class="d-flex image-configure mt-2">
  <div class="col-8 p-0">
    <button mat-raised-button (click)="onClickFileInputButton()">画像を読み込む</button>
    <button mat-raised-button (click)="setAxisRange()" class="ml-2" [class.editingAxis]="isEditAxis" [disabled]="!file">
            {{ isEditAxis ? '座標軸の設定終了' : '座標軸を設定する' }}
    </button>
    <button mat-raised-button class="ml-2" [disabled]="!file" (click)="resetViewConfig()">Viewをリセット</button>
  </div>
</div>
setAxisRange()

最初に、setAxisRange()が呼び出されるたびにisEditAxisの真偽を切り替えるようにする。

settingLayerplottingLayerは、一方のレイヤーを操作中はもう一方をロックして操作できないようにしておいたほうが良い。Layerオブジェクトのlockedプロパティをtrue(デフォルトはfalse)に設定するとそのレイヤーはマウス操作が無効になるので、isEditAxisを利用してロック状態を切り替える。

また、settingLayervisibleisEditAxisで更新してやると、座標軸を設定する時だけ表示されるようになる。

  setAxisRange(): void {
    this.isEditAxis = !this.isEditAxis;
    this.settingLayer.locked = !this.isEditAxis;
    this.settingLayer.visible = this.isEditAxis;
    this.plottingLayer.locked = this.isEditAxis;
  }

座標軸設定パスの描画

X軸と直交する(X軸の範囲を設定する)パスが2本、Y軸と直交する(Y軸の範囲を設定する)パスが2本の計4本を描画する。それぞれのパスを区別するため、X軸と直交する2本のパスを左からleftPathrightPath、Y軸と直交する2本のパスを上からtopPathbottomPathと名付ける。

この4本のパスの座標を指定することで、4本に囲まれてできる長方形(図の黄色部分)の四隅の座標が与えられる。これによって、この長方形を基準とした座標系を定義することができる。この座標系を今後は「プロット座標系」と呼ぶことにし、それに対してView本来の座標系を「View座標系」と呼ぶことにする。

まずは、4本の座標軸設定パスを描画する処理を追加していく。

setRangePath()

Path.Line()は引数で渡したプロパティ(オブジェクト)に応じた線形のパスを生成する。fromは始点、toは終点でありどちらもPointオブジェクトである。

全パス共通の設定(主にパスの描画スタイルに関するもの)について何回も同じ記述をするのは面倒なので、最初にcommonSettingで定義しておき、各パスを生成する時にスプレッド演算子...を使ってプロパティのオブジェクトに追加しておけばよい。

各パスの始点と終点については、View領域の境界の情報(view.boundsで取得)を基に決めていく。bounds.widthbounds.heightでViewの幅と高さ、bounds.leftbounds.rightでViewの左端と右端のx座標値、bounds.topbounds.bottomでViewの上端と下端のy座標値が取得できるので、これらを利用して各パスの初期表示位置を決めていく。パスを最初に描画する位置はどこでもいいのだが、とりあえずleftPathrightPathはViewの左端からそれぞれ1/4と3/4の位置、topPathbottomPathはViewの上端からそれぞれ1/4と3/4の位置に描画されるようにした。

最後に、生成したパスをaddChildren()settingLayerに追加し、パスの操作イベントを設定するsetMouseEventToRangePath()を呼び出す。

  private setRangePath(): void {
    this.settingLayer.removeChildren();

    const bounds = view.bounds;
    // 全パス共通
    const commonSetting = {
      strokeWidth: this.defaultStrokeWidth,
      strokeColor: '#9aa1ff',
      strokeScaling: false,
      opacity: 0.7,
    };
    const leftPath = new Path.Line({
      from: new Point(bounds.width / 4, bounds.top),
      to: new Point(bounds.width / 4, bounds.bottom),
      ...commonSetting,
    });
    const rightPath = new Path.Line({
      from: new Point(bounds.width * 3 / 4, bounds.top),
      to: new Point(bounds.width * 3 / 4, bounds.bottom),
      ...commonSetting,
    });
    const topPath = new Path.Line({
      from: new Point(bounds.left, bounds.height / 4),
      to: new Point(bounds.right, bounds.height / 4),
      ...commonSetting,
    });
    const bottomPath = new Path.Line({
      from: new Point(bounds.left, bounds.height * 3 / 4),
      to: new Point(bounds.right, bounds.height * 3 / 4),
      ...commonSetting,
    });

    this.settingLayer.addChildren([
      leftPath,
      rightPath,
      topPath,
      bottomPath,
    ]);

    this.setMouseEventToRangePath(this.settingLayer.children);
  }

このsetRangePath()を呼び出す場所だが、背景画像を読み込み直す度に座標軸設定パスの描画がリセットされたほうがいいので、setImageToCanvas()の中で呼び出しておくとよい。

  private setImageToCanvas(): void {
    // 背景画像をクリア
    this.backgroundLayer.removeChildren();
    // viewの表示設定をクリア
    this.resetViewConfig();
    // 座標軸設定パスをリセット
    this.setRangePath(); // 追加
    
      // 省略

  }
setMouseEventToRangePath()

X軸とY軸の範囲を設定するパスを定義したら、各パスをドラッグ移動できるようにイベントを設定していく。 引数のpaths配列には、前述のsetRangePath()で生成した4本のパスが格納されているのでforEach()でそれぞれイベントを設定する。

基本的にはAngularとPaper.jsでパス描画ツールを作る2と同じ考え方であるが、ドラッグ移動できる方向に制約をつけることが唯一異なる。処理に必要なactiveLocationisMouseOnStrokeisItemDraggingといったメンバ変数はあらかじめ定義しておく。

鉛直方向に伸びたパスは水平移動だけ、水平方向に伸びたパスは鉛直移動だけ可能にする。イベント設定対象のパスが鉛直方向か水平方向かを判定するにはパスのboundsを見てやればよく、bounds.widthが0であれば鉛直方向のパス、bounds.heightが0であれば水平方向のパスと判定できる。鉛直方向のパスであれば自身のleftrightをマウスポインタのx座標に合わせて(topbottomは変えない)やり、水平方向のパスであれば自身のtopbottomをマウスポインタのy座標に合わせて(leftrightは変えない)やればよい。

  private setMouseEventToRangePath(paths: any[]): void {
    paths.forEach(path => {
      path.onMouseMove = (event) => {
        // セグメントとストロークの当たり判定のみを有効にする
        const hitOptions = {
          stroke: true,
          tolerance: 1,
        };
        const hitResult = paper.project.hitTest(event.point, hitOptions);
        this.activeLocation = hitResult && hitResult.location;
        this.isMouseOnStroke = !!this.activeLocation;
      };

      path.onMouseDrag = (event) => {
        this.isItemDragging = true;
        // 鉛直方向のpath
        if (path.bounds.width === 0) {
          path.bounds.left = path.bounds.right = this.currentX;
        }
        // 水平方向のpath
        if (path.bounds.height === 0) {
          path.bounds.top = path.bounds.bottom = this.currentY;
        }
      };

      path.onMouseUp = () => {
        this.isItemDragging = false;
      };

      path.onMouseLeave = () => {
        if (this.activeLocation) {
          // パスをドラッグしている途中の場合は処理を行わない
          if (this.isItemDragging) { return; }
          // パスからマウスが離れた場合はactiveItemとオンマウスのフラグをクリアする
          this.activeLocation = null;
          this.isMouseOnStroke = false;
        }
        this.isMouseOnStroke = false;
      };
    });
  }

座標軸設定用パスの再描画

setRangePath()はあくまでも座標軸設定パスの最初の位置を描画するだけなので、Viewの平行移動やズームにパスが追随してくれない。Viewを平行移動やズームした後、Viewの情報を利用してパスを再描画してやる必要がある。

updateRangePath()

settingLayerに含まれているパスの縦横の長さをview.boundsの高さと幅で更新する。 鉛直方向のパスであればheightbottomを更新し、水平方向のパスであればwidthrightを更新する。 heightまたはwidthを先に更新してやらないと何故かtopleftがうまく更新されず、Viewにパスが追随しないことがある。

  private updateRangePath(): void {
    this.settingLayer.children.forEach((path, i) => {
      // 鉛直方向のpath
      if (path.bounds.width === 0) {
        // 何故かtopが更新されないので、heightを更新してからbottomを合わせる
        path.bounds.height = view.bounds.height;
        path.bounds.bottom = view.bounds.bottom;
      }
      // 水平方向のpath
      if (path.bounds.height === 0) {
        // 何故かleftが更新されないので、widthを更新してからrightを合わせる
        path.bounds.width = view.bounds.width;
        path.bounds.right = view.bounds.right;
      }
    });
  }

このupdateRangePath()を、Viewが更新される各時点で呼び出して座標軸設定パスを再描画する。scalingView()の拡大縮小後、setEventsToView()の平行移動後、resetViewConfig()のViewリセット後の3箇所で呼び出す。

  scalingView(event): void {
    event.preventDefault();
    if (!this.file) { return; }
    const cursorPoint = new Point(this.currentX, this.currentY);

    if (event.wheelDeltaY > 0) {
      this.zoomUp(cursorPoint); // 拡大
    } else {
      this.zoomOut(cursorPoint); // 縮小
    }
    this.updateRangePath(); // 追加
  }
  private setEventsToView(): void {
    const tool = new Tool();
    tool.onMouseDrag = (event) => {
      // 画像読み込み前、または座標
      if (!this.file || !!this.activeLocation) { return; }
      // delta = 最後にクリックされた位置の座標 - 現在地の座標
      const delta = event.downPoint.subtract(event.point);
      view.scrollBy(delta);
      this.updateRangePath(); // 追加
    };
  }
  resetViewConfig(): void {
    view.matrix.reset();
    this.currentScale = view.zoom;
    this.currentFactor = ScaleConfig.factor;
    this.currentX = this.currentY = 0;
    this.isScaleEndRange = this.currentScale === ScaleConfig.minScale || this.currentScale === ScaleConfig.maxScale;
    this.updateRangePath(); // 追加
  }

基準軸の最大値、最小値の設定

4本の座標軸設定パスに任意の座標を設定するための準備をしよう。xminxmaxyminymaxという変数を定義して、それぞれ「左側にあるパス」に設定するx座標、「右側にあるパス」に設定するx座標、「下側にあるパス」に設定するy座標、「上側にあるパス」に設定するy座標に対応させる。操作しているうちにleftPathrightPathが左右入れ替わったり、topPathbottomPathが上下入れ替わったりしてもいい。(後で座標変換計算をする時につじつま合わせをする)

export class DigitizerComponent implements OnInit {
  @ViewChild('canvas', { static: true }) canvas: ElementRef<HTMLCanvasElement>;
  @ViewChild('fileInput', { static: false }) fileInput: ElementRef;

  file: File = null;
  imgSrc: string | ArrayBuffer = '';
  currentX = 0;
  currentY = 0;
  scale = 1;
  currentFactor = ScaleConfig.factor;
  // 座標軸関係
  xmin = 0; // 追加
  ymin = 0; // 追加
  xmax = 0; // 追加
  ymax = 0; // 追加
    
    // 省略

}


htmlファイルの方では、canvas要素の右側にプロットに関する情報を設定・表示するためのエリアを追加する。 xminxmaxyminymaxを入力するinput要素を追加する。「座標軸を設定する」ボタンをクリックした時(settingLayerが表示されている、つまりisEditAxisがtrue)だけX軸とY軸の始点・終点フォームに入力できるようにしたいので[disabled]="!isEditAxis"で活性・非活性を切り替えられるようにする。

    <div class="col-6 p-0 information-wrapper">
      <div class="information">
        <div class="d-flex flex-column axis-configure">
          <div>
            <span class="axis-name">X軸</span>
            <div>
              <label for="x-start" class="mr-1">左端</label>
              <input matInput type="number" appearance="outline" id="x-start" [disabled]="!isEditAxis" [(ngModel)]="xmin">
              <label for="x-end" class="ml-1 mr-1">右端</label>
              <input matInput type="number" matInput="number" appearance="outline" id="x-end" [disabled]="!isEditAxis" [(ngModel)]="xmax">
            </div>
          </div>
          <div class="mt-1">
            <span class="axis-name">Y軸</span>
            <div>
              <label for="y-start" class="mr-1">下端</label>
              <input matInput type="number" appearance="outline" id="y-start" [disabled]="!isEditAxis" [(ngModel)]="ymin">
              <label for="y-end" class="ml-1 mr-1">上端</label>
              <input matInput type="number" appearance="outline" id="y-end" [disabled]="!isEditAxis" [(ngModel)]="ymax">
            </div>
            </div>
        </div>
        <div class="coordinate-info mt-4">
          <table border=1>
            <thead>
              <tr>
                <td class="number">No.</td>
                <td class="cordinate">X</td>
                <td class="cordinate">Y</td>
              </tr>
            </thead>
            <tbody>
            <!-- 今後プロットした点の座標を表示していくよ -->
            </tbody>
          </table>
        </div>
      </div>
    </div>

デザインも適当に調整する。ちなみに、-webkit-appearance: none;指定している部分は、inputタグでtype=numberの時に右側に表示される矢印ボタン(スピンボタン)を非表示にしている。

.information-wrapper {
  .information {
    width: 250px;
    height: 500px;
    border: solid 1px #979797;
  }
  .axis-configure {
    width: 250px;
    height: 100px;
    margin-bottom: 10px;;
    padding: 10px;
    .axis-name {
      font-weight: bold;
    }
    input[type="number"]::-webkit-outer-spin-button,
    input[type="number"]::-webkit-inner-spin-button {
      -webkit-appearance: none;
      margin: 0;
    }
  }
  .coordinate-info {
    margin: 0;
    padding: 0 10px 10px 10px;
    width: 250px;
    height: 350px;
  }
  .number {
    width: 40px;
  }
  .cordinate {
    width: 90px;
  }
}
.mat-raised-button {
  color: #ffffff;
  background-color: #299c33;
  outline: none;
  &.editingAxis {
    background-color: #f53c3c;
  }
}

.mat-input-element {
  width: 80px;
}

table {
  width: 220px;
  margin: 0 auto;
  thead {
    text-align: center;
    display: block;
    tr {
      height: 25px;
      background-color: #cacaca;
    }
  }
  tbody {
    text-align: right;
    display: block;
    overflow-x: hidden;
    overflow-y: scroll;
    border: none;
    height: 325px;
    font-size: 13px;
    tr {
      height: 25px;
      &:hover {
        background-color:#81d896;
      }
      td {
        padding-right: 10px;
      }
    }
  }
}

座標値の変換

本来Viewの座標系は左上が原点なのだが、数学的には座標の原点が左下になっているのが普通だろう。プロットする時も左下を原点としたほうが直感的に分かりやすい。したがって、プロット座標系は左下が原点となるように実装していく。

これまでは、マウスポインタの座標をView座標系のcurrentXcurrentYで管理してきた。しかし、本当に知りたいのは、自分で任意に設定した軸を基準とした座標値である。したがって、View座標系でのマウスポインタの座標値をプロット座標系に変換することになる。プロット座標系でのマウスポインタの座標値を新たにplotXplotYとして管理する。

export class DigitizerComponent implements OnInit {
  @ViewChild('canvas', { static: true }) canvas: ElementRef<HTMLCanvasElement>;
  @ViewChild('fileInput', { static: false }) fileInput: ElementRef;
  @ViewChild(MatMenuTrigger) contextMenu: MatMenuTrigger;

  file: File = null;
  imgSrc: string | ArrayBuffer = '';
  currentX = 0;
  currentY = 0;
  plotX = 0; // 追加
  plotY = 0; // 追加
  scale = 1;
  currentFactor = ScaleConfig.factor;

 // 省略

}


座標値変換の考え方
下にある図中で、$X_l$、$X_r$はそれぞれ「左側にあるパス」「右側にあるパス」に設定するプロット座標系のx座標、$Y_t$、$Y_b$はそれぞれ「上側にあるパス」「下側にあるパス」に設定するプロット座標系のy座標とする。 $x_l$、$x_r$はそれぞれ「左側にあるパス」「右側にあるパス」のView座標系でのx座標、$y_t$、$y_b$はそれぞれ「上側にあるパス」「下側にあるパス」のView座標系でのy座標とする。

これらの座標値を利用して、View座標系のカーソルの座標値$x_c$、$y_c$をプロット座標系の$X_c$、$Y_c$に変換することを考える。 なお、下の図では理屈を考えやすくするため、座標軸設定パスはViewの上下左右の端に合わせた状態を示している。

(1) 倍率を求める

「プロット座標系での長さ:View座標系での長さ」の比から倍率$S_x$、$S_y$ を計算する。 $$ S_x = \frac{\left| X_r - X_l \right|}{\left| x_r - x_l \right|} $$ $$ S_y = \frac{\left| Y_b - Y_t \right|}{\left| y_b - y_t \right|} $$


(2) View座標系におけるマウスカーソルとパスの座標との差を求める

マウスカーソルのx座標と「左側にあるパス」のx座標との差$D_x$と、マウスカーソルのy座標と「下側にあるパス」のy座標との差$D_y$を計算する。

$$ D_x = x_c - x_l $$ $$ D_y = y_c - y_b $$


(3) プロット座標のマウスカーソル座標を求める

x軸方向は「左側にあるパス」のx座標に$D_x$の$S_x$倍を足す。y軸方向は、View座標系とプロット座標系で軸の向きを逆にすることに注意して、「下側にあるパス」のy座標に$D_y$の$S_y$倍を足す。

$$ X_c = X_l + D_x S_x $$ $$ Y_c = Y_b - D_y S_y $$

なお、leftPathrightPathが左右入れ替わっていたり($x_l > x_r$)、topPathbottomPathが上下入れ替わっている($y_t > y_b$)場合は

$$ X_c = X_r + D_x S_x $$ $$ Y_c = Y_t - D_y S_y $$

とすれば同じである。

この計算手順について、記事の一番最後で具体的な座標値を入れて確認している。

convertViewToPlot()

View座標系のマウスカーソルの座標値(preXpreY)を受け取って、その座標値に対して前述の変換処理を行う。 変数をそれぞれ下記のように対応させて、淡々と数式をコードに書き下していく。

// 倍率の計算
Sx → scaleX, Sy → scaleY
Xl → xmin, Xr → xmax, Yb → ymin, Yt → ymax
xl → leftPath.bounds.left, xr → rightPath.bounds.right
yb → bottomPath.bounds.bottom, yt → topPath.bounds.top

// 差の計算
Dx → diffX, Dy → diffY
xc → preX, yc → preY

// カーソルの座標値
Xc → plotX, Yc → plotY

基準軸の最大値と最小値が適切に設定されていない場合、すなわち、xmaxxminが同値またはymaxyminが同値になっている場合は処理をスキップしていることに注意。

  private convertViewToPlot(preX: number, preY: number): Point {
    // viewの座標系をプロット座標系に変換する
    const leftPath = this.settingLayer.children[0];
    const rightPath = this.settingLayer.children[1];
    const topPath = this.settingLayer.children[2];
    const bottomPath = this.settingLayer.children[3];
    let plotX = 0;
    let plotY = 0;
    if (this.xmax !== this.xmin) {
      const scaleX = Math.abs((this.xmax - this.xmin) / (leftPath.bounds.left - rightPath.bounds.right));
      const diffX = preX - leftPath.bounds.left;
      plotX = leftPath.bounds.left < rightPath.bounds.right ? this.xmin + diffX * scaleX : this.xmax + diffX * scaleX;
    }
    if (this.ymin !== this.ymax) {
      const scaleY = Math.abs((this.ymin - this.ymax) / (bottomPath.bounds.bottom - topPath.bounds.top));
      const diffY = preY - bottomPath.bounds.bottom;
      plotY = bottomPath.bounds.bottom > topPath.bounds.top ? this.ymin - diffY * scaleY : this.ymax - diffY * scaleY;
    }

    return new Point(plotX, plotY);
  }
setCurrentPosision()

これまではcurrentXcurrentYを更新するだけだったが、さらにcurrentXcurrentYを変換してplotXplotYに格納する。 convertViewToPlot()にView座標系のプロット座標系のcurrentXcurrentYを渡し、返ってきたPointオブジェクトをそplotXplotYにそれぞれ格納すればよい。

  setCurrentPosision(event): void {
    if (!this.file) { return; }
    const rect = event.target.getBoundingClientRect();
    this.currentX = view.viewToProject(event.clientX - rect.left).x;
    this.currentY = view.viewToProject(event.clientY - rect.top).y;
    const plotXY = this.convertViewToPlot(this.currentX, this.currentY);
    this.plotX = plotXY.x;
    this.plotY = plotXY.y;
  }



以上で、任意の座標系を設定し、その座標系の値にマウスカーソルの座標を変換する処理は完了となる。 キャンバスの下部に元々表示していたcurrentXcurrentYplotXplotXに書き換え、実際に座標軸を設定して適当にキャンバス上でマウスカーソルを動かして確認してみよう。

<canvas class="m-0" #canvas (mousewheel)="scalingView($event)" (mousemove)="setCurrentPosision($event)"></canvas>
カーソル座標:{{ plotX | number: '1.2-2' }}, {{ plotY | number: '1.2-2' }}
倍率:<span [class.outrange]="isScaleEndRange">x{{ scale | number: '1.2-2' }}</span>

おわりに

これでようやくプロット機能を作るための準備ができた。試行錯誤しながら実装している時は楽しいが、その手順や考え方を文字にして説明するのはなかなか大変でつらい…。 次の記事で紹介するプロット機能の実装で一応完成となる。


おまけ

座標変換計算の検算として、具体的な値を入れて確認してみる。 例えば、横x縦が700×500のView場合で、マウスカーソルがちょうどViewの中心(350, 250)にある時のことを考える。座標軸設定パスの左端と右端の差が70、上端と下端の差が50となるように設定したとすると、プロット座標系でのマウスカーソルの座標は(35, 25)になるはずである。

倍率は、 $$ S_x = \frac{\left| X_r - X_l \right|}{\left| x_r - x_l \right|} = \frac{\left| 70 - 0 \right|}{\left| 700 - 0 \right|} = \frac{1}{10} $$ $$ S_y = \frac{\left| Y_b - Y_t \right|}{\left| y_b - y_t \right|} = \frac{\left| 0 - 50 \right|}{\left| 500 - 0 \right|} = \frac{1}{10} $$

View座標系におけるマウスカーソルとパスの座標の差は、 $$ D_x = x_c - x_l = 350 - 0 = 350 $$ $$ D_y = y_c - y_b = 250 - 500 = -250 $$

プロット座標のマウスカーソル座標は、

$$ X_c = X_l + D_x S_x = 0 + \frac{1}{10} 350 = 35 $$ $$ Y_c = Y_b - D_y S_y = 0 - \frac{1}{10} (-250) = 25 $$

なので、確かに(35, 25)になっている。