作図ツールにおけるコネクター(矢印機能)の体験改善と実装

はじめに

このブログは2024年のアドベントカレンダー15日目の記事です(が、遅れて16日になりました…ゆるしてゆるして…)。

はじめまして。Goodpatchでエンジニアをしているちげです。 私は普段、Strapというサービスの開発・デザインをしています。

StrapのOGP画像

Strap(ストラップ) | 日本製オンラインホワイトボード

Strapはブラウザで動くオンラインホワイトボードツールで、業務に溶け込むようなチームコラボレーション & 情報資産化ツールを目指しています。 この開発チームの特徴として、チーム全員がプロダクトをデザインすることを考えていて、プロダクト自体、非常に手触り感や使い心地がいいんですよね。そこに惚れて4年前にインターン生としてチームにjoinしました。正社員となってからは2年目になります。

コネクター機能について

Strapには、コネクターと呼ばれる、図形と図形をつなぐ矢印があります。マウスのドラッグやショートカットによって、矢印のついた線を引けます。このコネクター機能の体験改善をメインで担当させていただきました。楽しかったです。あざます。

動画を見ていただいたらわかると思うのですが、簡単な操作でコネクターが様々な形状に変化している事がわかります。 「ここに矢印の線が来てほしい」「図形のここに矢印をつなぎたい」というなんとなく暗黙知化されたニーズにできるだけ添えられるようにリデザイン・実装したつもりです。

チームメンバーに沢山壁打ちしたり、相談に乗ってもらって我ながらいいものが出来たなと思っています。

書いても他の方の参考になりそうにないので、ブログ化するのをかなりサボっていました。ただ国内プロダクトでこんなことをやってる例はかなり少ないと思うので、アドベントカレンダー企画でようやく書くか!と決心がつきました。

わりとボリューミーなので「へぇ〜こんな感じなんだ」と気楽に読んでいただければと思います。

途中コード本文が出てきますが、別に読み飛ばしてもらって構いません。読みたい人だけ読んでください笑。

設計の難度の高さ

設計めちゃムズでした。

暗黙知化されたニーズにできるだけ添えられるように体験をデザインするためには、例えば以下のような問いに答えられなければいけません。

  • マウスをドラッグしたときに、どのように線を引くのがよいのか。
  • 間を繋げる経路はどのようにすればよいのか。
  • どの位置で線を折り曲げるべきなのか。

でも、こんなの作図したい図によって変わりますよね。図形と繋げているときと、繋げていないとき。図形の形が円のとき、四角形のとき。作図のパターンには様々なユースケースがあります。

また、作図のパターンだけでなく「その図を作るためにどのような操作ができれば扱いやすいのか」という、心地良い操作性をデザインすることが求められます。けっこう至難の業です。

ちなみに怖いこと言うと、エンジニアの方は当たり前に感じるかもしれませんが、データとして取得できるのは始点と終点の座標だけなんですよ。実装怖いですね。ゾゾゾ〜〜

そんな難しい機能改善のタスクのデザインと実装方法について、経緯を残せればいいなと思っています。

デザイン過程

心地良い操作性をデザインするには?

どのようにすれば、心地良い操作性をデザインできるのでしょうか。

そんなの一つです。「こうしたときはこうなってほしい」というパターンの言語化を出せるだけ出すしかありません。異論は認めます。

要するに、ニーズや現状の課題を調べて、ユースケース分析するということになります。

ニーズとユースケースの分析

ユースケースとは「だれが、なにを、どうする」というUIの機能要件をまとめたものです。今回はニーズや要望からユースケースを作成することになるので、「だれが、なにを、どうしたい」という風に言い換えるとわかりやすいですね。

過去のサポート履歴やプロダクトフィードバック、要望などからコネクターに関係するニーズを探し出し、理想状態とはどういうものなのかを模索していきます。

実際にはSlackのスクリーンショットなどをStrap上に貼り付けてまとめていきました。

サポート履歴や要望をスクリーンショットで保存し、グルーピングした図の一部

私もユーザーの一人なので、ときに類似製品を触って分析しながら、「こうなっていてほしいな」と思うことを自分で付け足したりもしました。

スクリーンショット「コネクターを揃えるのがめんどくさい。最初から揃っていてほしい」
自分で付け足した要望の例

ときには以下のように、現状の仕様と理想の仕様を図で比較してわかりやすくまとめたりもしました。図示すると、PdMや他のエンジニア、デザイナーと完成形のイメージを持ちやすくなりますよね。

現状の仕様と、理想の仕様を図で比較

またときには、条件的に考えうるコネクターの様々な形状パターンを洗い出したりもしました。

以下の図は、図形がコネクターとつながっているとき、繋がっている方向別にコネクターがどのような形状になるとよいかを検証している図になります。

様々な形状パターンを洗い出し

正確に言うとパターンはコレで全部ではなく、「コネクターにつながる2つの図形の位置関係が右上斜めに存在するとき」という条件で絞っているため少なくなっています。二つの図形がどの位置に存在するかは様々あるので、実際にはこの4倍以上のパターンがあります。脳溶けちゃうよ〜!

気が狂いそうになったので、他のパターンは実装時に頭の中でイメージすることにして、ここまででやめました。

このようにして、理想の作図体験とはどういうものか、イメージを高めていきました。

そうして取り出した数十項目の改善案や新仕様がわかりやすいように、以下のような一覧の図にまとめました。

新仕様をまとめた図の一部

実装過程

現状の実装を分析

今回は新規機能開発ではなく、機能改善です。そのため、既存のコードを細かく読み取り、構造を完全に理解した状態で改修の手を加える必要があります。そうしないと意図しない挙動や副作用が生まれてバグだらけになってしまいます。

既存のコードを読み解いて、先ほど出した改善案を具体的にどのように実現していくか、考えていく必要がありました。

改善案を実装PBIにする

デザイン過程で洗い出した様々なユースケースから、優先度順に上位数項目を抽出して実装PBIにしました。以下がそのリストの一部です。

  • コネクター作成のショートカットを追加
  • 複数のコネクターの折れ曲がり位置を自動で揃える
  • 始点の図形との接続方向をドラッグ中に保持できるようにする
  • 狙ったところにコネクターが吸着するようにする
  • 図形どうしの間隔が狭いときのコネクターの形状改善
  • コネクターのスタイルを継承する
  • エレメント内部へコネクターを入り込ませる
  • 各種バグ修正とパフォーマンス改善

詳細を見たい方は以下の画像をクリックしてみてください。

これを順番に叩いていきました。なかなか骨が折れる作業です。

既存のコードを読み解くなかで、コネクターがどうやって実装されているのか、個人的に面白いと感じた処理や、改善に苦労した箇所、こだわりなどをいくつか簡単にピックアップしてご紹介したいと思います。

コネクターの基本の処理構造

生成と描画

コネクターに関連する処理は主に以下の2種類に分けられます。

  • 生成:マウスのドラッグ座標などのユーザー操作から、データ構造を生成し、保存するコード
  • 描画:生成されたデータ構造をもとに図形を描画するコード

リアルタイムに図を描いている体験が求められるので、実際にはこの2つは同時に動いていますが、内部的にはフォルダが分かれており、コネクターのデータ構造をインターフェースとして用いています。reduxを経由した疎結合です。 描画の方はPixi.jsを使って、ゴリッと書いています。

コネクターのデータ構造

コネクターのエレメントは内部に以下のようなデータ構造を持っています。ここでは描画に関連するプロパティの一部を示しています。

...
x: number,
y: number,
width: number,
height: number,
properties: {
  type: 'straight' | 'polyline';  // 直線か折れ線か
  headPoint: Point;  // 矢印の根本のXY座標
  tailPoint: Point;  // 矢印の先端のXY座標
  visibleHeadPoint: Point;  // 矢印の根本の表示上のXY座標
  visibleTailPoint: Point;  // 矢印の先端の表示上のXY座標
  headTarget: {  // 根本側に繋がった図形エレメントに関する情報
    id: Element['id'];
    relativeRatio: Point;
    isIntoInternal: boolean;
  } | null;  
  tailTarget: {  // 先端側に繋がった図形エレメントに関する情報
    id: Element['id'];
    relativeRatio: Point;
    isIntoInternal: boolean;
  } | null; 
  headAxis: 'x' | 'y' | null;  // 根本側の繋がってる方向
  tailAxis: 'x' | 'y' | null;  // 先端側の繋がってる方向
  breakAxes: {  // 線の途中の折れ曲がる軸
    value: number;
    axis: 'x' | 'y';
    isFixed: boolean;
  }[];
  visibleBreakpoints: Point[];  // 線の途中の折れ曲がる位置の座標
  headArrow: boolean;  // 根本側の形状を矢印にするか
  tailArrow: boolean;  // 先端側の形状を矢印にするか
  lineWidth: 'thin' | 'light' | 'normal' | 'thick' | 'extraThick'; // 線幅
  ...
}

こういうデータ構造をユーザーの操作から生成していますし、このデータ構造を参照して図形を描画しています。

データ構造を見せられてもよくわからないと思うので、以下に図を用意しました。このように、ホワイトボード上に作図するための様々な箇所の2次元座標を持つようになっています。

各プロパティの座標が示しているもの

データ構造の生成

冒頭でもチラッとお話しましたが、コネクターを描画する際のマウスドラッグ中に取得できるのは、ドラッグの始点 headPoint と終点 tailPoint の座標だけです。ここからどのようにコネクターのデータ構造を生成するのかをお話します。具体的には以下のステップで処理しています。

  1. 始点の座標が、図形エレメントの内部に含まれるかどうかを検索する(終点も同様)
    なかった場合は、直線になる。
    あった場合は、コネクターの根本側の図形として登録。エレメントのid等を headTarget に、ドラッグした方向を headAxis に保存。
  2. 始点に繋がった図形の端から固定ピクセル分離れたところで、折れ曲がり位置 breakAxes を作成
  3. visibleBreakpoints を作成(線の途中の折れ曲がる位置のすべての座標)
  4. visibleHeadPointvisibleTailPoint を作成

この後に線上のテキストの位置調整処理や、前後にローカル↔グローバルの座標変換処理などが続くのですが、冗長なので省略します。

コネクターの生成手順と改善策

1. 座標が図形エレメントの内部に含まれるかどうかを判定する処理

図形エレメントの内部かどうかを判定する処理は以下のような感じです。とっても愚直ですね。

...

export const findOverlappedElement = (
  point: Point,
  elements: Element[],
  ignoreElements: Element['id'][] = [],
): Element | undefined => {
  return elements.find(
    (e) =>
      !e.isLocked &&  // ロックされていないこと
      !CONNECT_RESTRICTED_TYPES[e.type] &&  // コネクターとつながることができるエレメントであること
      !ignoreElements.includes(e.id) &&  // その他オプショナルで含めたくないものを弾く
      isHitElementByPoint(point, e),  
  );
};

// 各図形ごとに内部に含まれるかどうかを判定
export const isHitElementByPoint = (
  target: Point,
  element: Element,
): boolean => {
  const isHitBoundingBox = isHitPoint(target, element);
  if (!isHitBoundingBox) return false;  // 処理高速化のためのアーリーリターン

  switch (element.type) {
    case TYPES.TRIANGLE:
      return isHitTriangleByPoint(target, element as Triangle); // 三角形の場合の判定
    case TYPES.RHOMBUS: 
      return isHitRhombusByPoint(target, element);  // ひし形の場合の判定
    case TYPES.CIRCLE:
      return isHitEllipseByPoint(target, element);  // 円の場合の判定
    case TYPES.PILL:
      return isHitPillByPoint(target, element);  // カプセル型の場合の判定
    default:
      return true;
  }
};


// 四角形のヒットポイントを判定
export const isHitPoint = (target: Point, source: Area): boolean => {
  if (source.width <= 0 || source.height <= 0) return false;

  return (
    target.x >= source.x &&
    target.x <= source.x + source.width &&
    target.y >= source.y &&
    target.y <= source.y + source.height
  );
};

// 三角形のエレメントの場合
export const isHitTriangleByPoint = (
  target: Point,
  source: Triangle,
): boolean => {
  const points = convertTriangleToPoints(source);

  const pointA = points[0];
  const pointB = points[1];
  const pointC = points[2];

  const areaTotal = calcTriangleArea(pointA, pointB, pointC);
  const areaPAB = calcTriangleArea(target, pointA, pointB);
  const areaPBC = calcTriangleArea(target, pointB, pointC);
  const areaPCA = calcTriangleArea(target, pointC, pointA);

  return areaPAB + areaPBC + areaPCA <= areaTotal;
};

...

この処理は各図形ごとにヒットポイントを計算する、非常に丁寧な実装になっています。ドラッグした始点の座標にヒットする図形を探しているわけですね。

パフォーマンスのためにStrapでは、現在の表示範囲のエレメントだけを描画する処理(オクルージョンカリング)を実施していますが、ボード内のすべてのエレメントのデータが同期通信されているため、この検索処理が可能となっています。

狙ったところにコネクターが吸着するようにする【改善】

図形がヒットした場合、次は図形のどの方向からコネクターが飛び出るかを決定する必要があります。コネクターは垂直水平の線で構成されているので、上下左右どの方向に出るか確定させる必要があります。これはコネクターの始点、終点のどちらにも当てはまります。

しかし、任意のエレメントにコネクターを接続する際、マウスをエレメント内のどの範囲にドラッグすれば、軸がキレイに切り替えられるのか分かりづらいという問題がありました。コードの海を泳いだ結果、分かりづらい原因は、以下のような範囲のときにx方向にコネクターが接続される仕様にあることがわかりました。コネクターが接続される方向の判定基準が直感に反していたのです。

コネクターが接続する方向を判定する関数が直感と反する

前の仕様では、ドラッグしているマウスの位置が、エレメントの4つの枠線のうち、どの枠線に近いか、という基準で判定していました。そのため上記のような判定範囲になっていたのです。

そこで、より直感に近い判定範囲になるよう、図形上の対角線で接続方向が切り替わるようにしました。以下がその判定範囲です。

さて、ここからは中学数学のお時間です。対角線を導出するために傾きを計算した一次関数を用いています。

任意のエレメントE(x_e, y_e, w_e, h_e)に対して、コネクターの先端P(x_p, y_p)がどの領域にあるかを導出する。Eを長方形の対角線で分けた①〜④の領域があり、その2つの対角線の式は、ア:y=-ax+bと、イ:y=axの式で表され、傾きaはh_e/w_e、切片bはh_eである。
図形上の対角線で接続方向が切り替わるようにする。

例として①の領域にコネクターが接続されているかどうかを判定する例を示します。コネクターの端が接続された位置(x,y)を、x_p、y_p とします。すると以下の画像のような判定条件があることが分かります。

①の領域の判定条件

つまり、これをXとYに分けて考えると以下の2つの条件を満たせれば良い、ということになります。

x_pの条件は、x_left &lt; x_p &lt; x_right。y_pの条件はy_p &lt; min(y_left, y_right)
xは不等式で判定、yはminと不等式で判定

この判定処理を実装してDONEです!

始点の図形との接続方向をドラッグ中に保持できるようにする【改善】

コネクターの接続方向に関して、別の改善策も講じました。

元々、ドラッグ中に図形との接続方向が意図せず変化するような仕様となっており、接続方向を保持したいという要望が挙がっていました。

ただし様々なパターンを検証した結果、接続方向を保持しすぎるのも良くないなと感じてきました。それによる副作用が生まれてしまうからです。

例えば、図形Aの右から出た矢印が図形Bの左に接続していたとします。図形Bを図形Aよりも左側に持っていったときも接続方向を保持しようとし、図形Aを回り込むような複雑な形状のコネクターが描かれることになります。この挙動は便利な面もある一方、Strapのユースケースとあわなかったり、美しい図が簡単に描ける体験とは異なると感じました。

そこで、図形との接続方向がドラッグ中に変わるときと変わらないときの仕様を独自に設定しました。それが"優先モードXY切り替え処理"です。 今接続されている方向を優先しつつ、上下なら上下だけ、左右なら左右だけ、切り替えたい意図を感じられたタイミングで切り替えられるような判定基準を目指しました。以下がその判定基準を表した図です。

接続方向の切り替えポイントの仕様

コネクターが左方向に接続されているときは、上下方向には変化しませんが、赤のエリア内にドラッグした際に、接続方向が反対側に切り替わります。(緑のエリアは後述)

一方、コネクターが下方向に接続されているときは、左右方向には変化しませんが、赤のエリア内にドラッグした際に、接続方向が反対側に切り替わります。

上下方向を左右方向に変更したい場合は、コネクターの軸を直接いじれば変更することができます。

接続方向を切り替えるデモ

X方向ならX方向で保持し、左右は移動可能。Y方向ならY方向で維持し、上下は移動可能。ということになります。つまり、今接続されている方向が優先されて、接続方向が以前よりも "変わりにくく" なったのです。

「接続方向を保持してほしい」という要望に対してそのまま鵜呑みにするのではなく、「接続方向を変わりにくくする」という解決策を取ったのは英断だったなと思います。「自分デザインやってるな〜」という特別な気分に浸れて僕は好きです。

結果、ユーザーにコネクターについてFBを求めた際に「そういえば最近あんまりイライラしなくなったかも!」という声をいただけました。改善によってデザインが意識しなくても済むような状態にまで融けていったんだなと感じて、透明な体験を作れて嬉しかったです。

2. 始点に繋がった図形の端から固定ピクセル分離れたところで、折れ曲がり位置 breakAxes を作成【改善】

breakAxesの作成処理は、もともと始点と終点の中心となるだけのシンプルな実装でした。( headX + tailX ) / 2 の簡単な式で実装できます。

しかし、改善案では、始点に繋がった図形の端から固定ピクセル分離れたところを見つける必要があるため、図形の端の座標を算出する必要がありました。幸い、上下左右方向にしかコネクターが伸びない仕様なので、角度を考慮する必要がなく助かりました。生きてるって感じがするぜ。

export const AXIS_KEYS: Record<(typeof AXIS)[keyof typeof AXIS], AxisKeys> = {
  [AXIS.X]: {
    position: 'x',
    size: 'width',
    minPosition: 'minX',
    maxPosition: 'maxX',
  },
  [AXIS.Y]: {
    position: 'y',
    size: 'height',
    minPosition: 'minY',
    maxPosition: 'maxY',
  },
};

// 図形どうしの間隔が狭いとき
const isNarrowMargin = (margin: number) =>
  margin >= NARROW_BREAKPOINT_MARGIN_RANGE_MIN &&
  margin <= NARROW_BREAKPOINT_MARGIN_RANGE_MAX;

// 折れ曲がり位置の座標を計算
export const calcHeadNextAxisValue = (
  props: CreateAxisVectorProps,
  direction: Direction,
) => {
  const { head, tail, tailPoint, lineWidth, headArrow, tailArrow } = props;
  const isHorizontal =
    direction === DIRECTIONS.LEFT || direction === DIRECTIONS.RIGHT;
  const axis = isHorizontal ? AXIS.X : AXIS.Y;
  const { position, size } = AXIS_KEYS[axis];

  const isLeftOrTop =
    direction === DIRECTIONS.LEFT || direction === DIRECTIONS.TOP;

  const tailMin = tail ? tail[position] : tailPoint[position];
  const tailMax = tail ? tail[position] + tail[size] : tailPoint[position];

  const marginMin = isLeftOrTop ? tailMax : head[position] + head[size];
  const marginMax = isLeftOrTop ? head[position] : tailMin;
  const margin = marginMax - marginMin;

  if (isNarrowMargin(margin)) {
    const isCenter = (headArrow && tailArrow) || (!headArrow && !tailArrow);
    const arrowTipWidth = isCenter ? 0 : ARROW_SIZE[lineWidth].width;
    const breakPointDistance = (margin - arrowTipWidth) / 2;

    const isLeftArrow = isLeftOrTop ? tailArrow : headArrow;
    return marginMin + (isLeftArrow ? arrowTipWidth : 0) + breakPointDistance;
  }
  return isLeftOrTop
    ? marginMax - BREAKPOINT_DISTANCE
    : marginMin + BREAKPOINT_DISTANCE;
};

始点に繋がった図形の端から固定ピクセル分離れたところに折れ曲がり位置を設けるということは、すなわち複数のコネクターの折れ曲がり位置を自動で揃えるということであり、これまでの「折れ曲がり位置がキレイに揃わない」という課題を解決しています。

図形どうしの間隔が狭いときのコネクターの形状改善【改善】

また、先ほどのコード内で「図形どうしの間隔が狭いときのコネクターの形状改善」タスクも実施しています。 isNarrowMargin によって条件分岐で処理を追加している部分です。

この処理は、折れ曲がり位置が始点と終点の中心となるような改善前と似た実装ではありますが、矢印の鏃の幅を考慮して、折れ曲がり位置を少しズラしています。

鏃の幅分、折れ曲がり位置をずらす

この修正は、デザイン当時の修正案にはなかったのですが、実装していて思いついちゃったので空き時間に勝手に作っちゃいました。PdMが「いいじゃん」と言ったので、PBI化しました。

3. visibleBreakpoints を作成

visibleBreakpointsは、表示上の折れ曲がり位置です。headPointtailPointbreakAxesのデータから生成します。

  breakAxes: {  // 線の途中の折れ曲がる軸
    value: number;
    axis: 'x' | 'y';
    isFixed: boolean;
  }[];
  visibleBreakpoints: Point[];  // 線の途中の折れ曲がる位置の座標

各プロパティの座標が示しているもの

breakAxesvisibleBreakpointsは、折れ曲がり位置の2種類の表現方法と言えます。その2つを一緒にデータ構造に保存している理由は、使用用途が異なるためです。breakAxesの使用用途はエディタです。エディタでは線をドラッグして自由に動かしたいので、その線上を判定する必要があるため、Axis(X or Y)の軸ベースのデータが欲しくなります。一方visibleBreakpointsの使用用途は描画です。線を描画する際に必要なのは、線と線をつなぐ点のデータの集合列なので、座標の配列が保存されているわけです。

他にも処理の都合で、軸がほしいときと座標がほしいときがそれぞれあるので、その度に軸から座標を生成するのを避けるために、どちらもをデータ構造に保存しています。

4. visibleHeadPointvisibleTailPoint を作成

実は、実際の接続点と表示上の接続点は異なっています。以下の図をご覧ください。

実際は図形の内部に入り込んでいるが、表示上は図形の端から繋がっているように見える

図形とのヒットポイントを分析する関係上、headPointは必ず図形の内部に入り込んでいます。しかし、表示上は図形の端から繋がっているように見えさせたいので、図形の形状に数ピクセルのマージンをつけて、コネクターをマスクしています。つまり、図形と線の接点を算出する処理と言えます。惚れ惚れする丁寧なお仕事ですね。

エレメント内部へコネクターを入り込ませる【改善】

一方で、上記のマスク処理があるがために、図形内部にコネクターの先端が入り込むことができないようになっていました。

そこで、図形内部に入り込むかどうかの判定処理を追加し、入り込めると判定された場合にマスク処理をOFFにするという実装方針を考えました。

以下がその判定処理です。デフォルトサイズの図形より大きく、図形の端ではない場合にマスク処理をOFFにしています。

const isLargeElement = element
  ? element.width > BOARD_BACKGROUND_GRID_GAP * 10 &&
  element.height > BOARD_BACKGROUND_GRID_GAP * 10
  : false;
const isEdge = element ? isPointInEdgeArea(point, element) : false;
const isIntoInternal = isLargeElement && !isEdge;

青もしくは赤の範囲は端なら接続。図形が大きいときはそれより内部に入り込める

この処理は図形に限らず、画像や長い文章を書き込んだエレメントの内部にも入り込めるため、画像内の特定のポイントをコネクターで指し示したいといったニーズに答える事ができます。

おわりに

ある程度書けたので突然終わりますけれども、大体どんな処理をやっているのかはご理解いただけたのではないでしょうか。

なかなかこんな作図ツールを作っているプロダクトないので、大変でしたが、結構面白くて楽しかったです。この記事が何かの参考になれば幸いです。

さいごに:エンジニア採用中です!

2024.12.15現在、Strapチームではバックエンドエンジニアの採用を行なっています。 まずはカジュアル面談からお気軽にお待ちしておりますすす〜。

hrmos.co

その他、Goodpatchではデザイン好きなエンジニアの仲間を募集しています。 少しでもご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!