図面を管理するために図面版 Figma を開発している話

こんにちは。キャディでソフトウェアエンジニアをしている @sottar です。

この記事は キャディ Advent Calendar 2020 の10日目です。前日は @catupper による async/awaitで躓いて学んだ、「オレは雰囲気でRustをしている!」からの脱し方。 でした。

今日は私がチームで開発してる、図面を管理するためのツールについて紹介します。

図面とは

そもそも図面とはなんでしょうか?

作りたい製品の素材やサイズといった仕様が書かれたものだということはみなさんご存知かと思います。 その図面を管理するためのアプリケーションというと一見簡単そうに思えるかもしれませんが、実際に弊社で行っているオペレーションに即して管理を行おうとすると、いくつか考慮しないといけない点があります。

  1. 取引先からpdfファイルとしてメールで送られてくることが多い
  2. 図面には id のような一意に決まるものがない(図番や品番などはあるが業界として統一されているわけではない)
  3. 手書きで文字が書かれていたり、スキャンされたPDFであることがある(テキストを機械的に読み取るのが困難)
  4. 途中で仕様(素材やサイズ)が変わることがある
  5. 電話でその仕様変更が伝えられることもある

社内では見積もりを行う際に図面に書かれている内容を社内の見積もりシステムに入力する必要があったり、サイズや素材などの変更があった場合にその変更を管理し社内メンバーに周知する必要などがあります。 よりスケーラブルな組織をつくるためにはこのオペレーションをできる限り自動化したいところですが、そのためにはこれらの課題を解決する必要があります。

弊社ではこれらを解決するアプリケーションの開発も行っており、今回はその一部として Figma のようなUIを持つアプリケーション(以下、図面版 Figma)の紹介をします。

図面版 Figma とは

図面版 Figma を開発するに当たっての解決したい課題は主にこの二つです。 1. 図面の拡大/縮小をスムーズに行える 2. 仕様変更があった際にそのアプリ上でやりとり/管理することで窓口を統一することができる

ただ単に図面をwebで表示するだけではなく、文字が小さかったり手書きで書かれた文字もあるため拡大をスムーズに行う機能や取引先とのやりとりの窓口を一元化するためにチャットの機能を盛り込みます。

検証としてフロントエンドのみ実装して netlify にあげています。

今回はその検証で使用した GitHub のリポジトリでの実装を参考に進めていきます。

技術 stack

今回は主に以下の技術を使って実装しました。

状態管理, view のライブラリとして React と styled-components そして図面を表示して拡大縮小を滑らかに行うために WebGL のラッパーである PixiJS とそれを React から使いやすくした react-pixi を使用しました。

PixiJS

PixiJS とは 2D のグラフィックス処理を実現するための JavaScrip ライブラリです。 似たような 2D グラフィクス向けのライブラリとしては konvajsEaselJS などがありますが、それらのライブラリとは異なり WebGL を使ってレンダリングをおこなう点が特徴です。 また、同じく WebGL を利用しているライブラリとして three.js がありますが、three.js は 3D のグラフィック表現を得意としておりそれぞれ得意な領域が異なります。

今回はよりWebGLを使って滑らかに拡大縮小を実現するためにこの PixiJS を使用しました。

セットアップ

必要な npm のインストールを行なっていきます。 (lint や prettier, webpack などは割愛します。詳しくはリポジトリpackage.json を参考にしてください)

$ npm i --save @inlet/react-pixi pixi.js react react-dom styled-components
$ npm i --save-dev @types/react @types/react-dom @types/styled-components typescript

App.tsx

(簡略化のためスタイルなどのコードは省略しています)

アプリケーションのルートとなる App.tsx では PixiJS をマウントするためコンポーネントと、メッセージのやりとりをするコンポーネントをマウントします。

// App.tsx
const App = () => {
  ...
  return (
    <Wrapper>
      <Pixi />
      <Chat />
    </Wrapper>
  )
  ...
}

図面表示エリアの実装

App コンポーネントからマウントされている Pixi コンポーネントで PixiJS のコードを書いていきます。 PixiJS の基本的な使い方はほかのグラフィックライブラリと似ていて、 Stage をつくりその中に表示するオブジェクト(Container, Spriteなど)を記述します。

Container Container は子供の要素を保持する汎用的なオブジェクトで、Graphic や Sprite など、他のオブジェクトのコンテナとして機能するすべてのディスプレイオブジェクトの基底クラス Sprite 画面にレンダリングされるすべてのテクスチャオブジェクトのベースのオブジェクト

Stage オブジェクトではグラフィックを表示させるための root の表示域を設定します。

  <Stage width={canvasWidth} height={canvasHeight} id="canvas">
    ...
  </Stage>

canvasWidth, canvasHeight はそれぞれ windowSize から取得して設定します。

そして Container を定義し、その中に図面を表示させるため Sprite オブジェクトを展開します。

Sprite オブジェクトでは表示する image を指定する他に、画像のサイズや拡大・縮小の割合scale、表示する位置x, yなどを設定することができます。 そのため、表示している画像の拡大縮小や位置を変更するにはこの scale や x, y を React の state で管理し、ユーザーの入力に応じて値を変更すれば良さそうです。

その図面の表示に関する state をそれぞれPixi.tsx 内に定義します。

// Pixi.tsx
  const [scale, setScale] = useState(0);
  const [position, setPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });

そしてその値を Sprite に設定します

// Pixi.tsx
  <Container>
    <Sprite
      image="./images/sample.jpg"
      anchor={0.5}
      x={position.x + canvasWidth * anchor}
      y={position.y + canvasHeight * anchor}
      scale={scale}
      interactive={true}
    />
      ...
    </Sprite>
  </Container>

anchor は設定している Sprite の中心の値を設定します。ここでは 0.5 に設定しており WebGL の表示エリアのちょうど中心に設定しています。 x, y もこの anchor と state で管理している x, y の値で設定をします。

拡大縮小

ここまでで図面を表示するための実装ができました。次にスクロールやピンチイン/アウトに応じて図面を拡大縮小させるための関数を書いていきます。

スクロールやピンチイン/アウトにを行うためのイベントとして onWheel という JavaScript のイベントがあります。 このイベントを Pixi.tsx 内で useEffect を使ってイベントの登録を行います。

// Pixi.tsx
  const wheelHandler = (e: WheelEvent) => {
    e.preventDefault();
    if (!Number.isInteger(e.deltaY)) {
      setScale(currentState => Math.min(Math.max(0.05, currentState + e.deltaY * -0.001), 1));
      return;
    }
    setPosition(currentState => ({ x: currentState.x - e.deltaX, y: currentState.y - e.deltaY }));
  };

  useEffect(() => {
    const el = document.getElementsByTagName('canvas')[0];
    el.onwheel = e => {
      wheelHandler(e);
    };
    return () => {
      el.onwheel = null;
    };
  }, []);

!Number.isInteger(e.deltaY) ここでは現在行われているスクロールが小数点を含んでいるか(整数か)どうかを見ています。 スクロールイベントでは単純な x and/or y 軸方向のスクロールでは deltaY の値は整数になり、ピンチイン/アウトでは小数付きの数字になります。 単純なスクロールの場合は表示している図面の位置を変更し、ピンチイン/アウトでは図面の倍率を変更したいためこの deltaY の値で条件分岐を行い、それぞれのケースで state の値を更新しています。

ここまでで図面の表示とスクロールによる図面の拡大/縮小機能の実装を行なってきました。次にチャット機能を実装していきます。

チャット機能

チャット機能は Figma と同じように図面の上にピンを立てるのと、右カラムに入力されたチャットを表示します。そのため、 App.tsx に必要な state を定義します。

// App.tsx
  const [chatList, setChatList] = useState<
    {
      messages: { id: string; author: string; createdAt: string; message: string }[];
      inputValue: string;
      pin: { xRatio: number; yRatio: number }; // 0 ~ 1
    }[]
  >([]);
  const [activeChatIndex, setActiveChatIndex] = useState(0);

入力されたチャットと入力された場所を保持するための state と active になっているチャットを保持するための state を定義し、それぞれ子コンポーネントに渡します。 右カラムに表示するチャット欄はいわゆる普通の React アプリなため説明は省略します。 Pixi.tsx では App.tsx から受け取った chatList state の pin の位置を展開します。

// Pixi.tsx
  <Container>
    ...
    {props.pins.map((p, i) => {
      const positionX =
        originalImageSize.x * scale * p.xRatio +
        (canvasWidth - originalImageSize.x * scale) / 2 +
        position.x;
      const positionY =
        originalImageSize.y * scale * p.yRatio +
        (canvasHeight - originalImageSize.y * scale) / 2 +
        position.y;
      return (
        <React.Fragment key={`${p.xRatio} ${p.yRatio}`}>
          <Sprite
            image="./images/pin.svg"
            anchor={anchor}
            x={positionX}
            y={positionY - 10}
            scale={0.23}
            click={() => props.clickPinHandler(i)}
            interactive={true}
            cursor="pointer"
          />
          <Text
            text={String(i + 1)}
            x={i < 9 ? positionX - 2 : positionX - 5}
            y={positionY - 18}
            scale={0.3}
            style={numberStyle}
          />
        </React.Fragment>
      );
    })}
  </Container>

pinの位置は、拡大/縮小に対応するために絶対値ではなく比率で保存し、それを展開しています。 Pinの画像は図面と同様に Sprite コンポーネントで表示し、ピンの上に表示する番号は Text コンポーネントを使って表示します。

また図面をクリックされた際に新しくピンを表示するために、図面を表示している Spritepointerdown 属性を追加し、ピンを追加する関数を実装します。 ここで画像のサイズからピンを表示する位置(比率)を取得し、 App.tsx の state の値を更新しています。

// Pixi.tsx
  <Sprite
    image="./images/sample.jpg"
    anchor={anchor}
    x={position.x + canvasWidth * anchor}
    y={position.y + canvasHeight * anchor}
    scale={scale}
    interactive={true}
    pointerdown={(e: PIXI.InteractionEvent) => {
      const imageSize = { x: originalImageSize.x * scale, y: originalImageSize.y * scale };
      const xRatio =
        (e.data.global.x - ((canvasWidth - imageSize.x) / 2 + position.x)) / imageSize.x;
      const yRatio =
        (e.data.global.y - ((canvasHeight - imageSize.y) / 2 + position.y)) / imageSize.y;
      props.addPin({
        xRatio,
        yRatio,
      });
    }}
  />

まとめ

ここまでwebアプリケーションに WebGL を用いて画像を表示し、クリックされた箇所でピンを表示してチャットを行えるアプリケーションの開発を行ってきました。 ピンをクリックした時にフォーカスを合わせる実装など今回の記事では一部省略した部分もありますが、全体のコードはこちらのリポジトリにあるので是非参考にしてみてください。

弊社ではこの図面管理のアプリケーション以外にも上記に挙げた課題を解決するために、送られてきた図面の内容を解析するためのアプリなど製造業の課題を解決するためのアプリケーションの開発を行っています。 少しでも興味ある方は是非一度お話ししましょう! カジュアル面談のお申込み