Webでユーザビリティの高い手書きアプリケーションを実装するためのTips

はじめに

こんにちは。医療プラットフォーム本部 CLINICS 開発グループの吉岡(@yuya333_)と村上(@yuporonM)です。 吉岡は 2022 年に、村上は 2024 年に新卒でエンジニアとしてメドレーに入社しました。

私たちは、医科診療所向けの電子カルテであるCLINICS カルテを開発しています。CLINICS カルテは 2025 年 3 月に iPad 手書き機能をリリースしました。iPad 手書き機能とは、iPad を使って主訴・所見やシェーマ(身体の部位の絵)を手書きできる機能です。

主訴・所見の手書き

シェーマの手書き

本記事では iPad 手書き機能を開発するにあたって工夫した点を紹介します。

ライブラリ選定

手書き機能を開発するにあたって、手書き処理すべてを実装するのは工数の観点から現実的ではありませんでした。 そのため、要件を満たせるかつメンテナンス面から以下のライブラリが候補となりました。

メドレーでは、歯科診療所向けに電子カルテ Dentis を開発しており、Dentis では既に手書き機能を提供しています。そこで、手書き機能の実装に関して知見を持っている Dentis のエンジニアにライブラリ選定について相談したところ、以下の理由で fabric を採用することに決めました。

  • konva では線が荒くなってしまうことがある
  • konva では線を描くたびに再レンダリングが発生してしまう
    • mousemove や touchevent のデータをキャッシュすることで解決できるが、遅延が気になる

また、fabric を使用した手書き機能の実装方法に関しては、2024 年 12 月にメドレーのアドベントカレンダーで吉岡が書いた以下の記事を参考にしてみてください。

手書き機能で工夫したポイント 4 選

工夫 1. 手を画面につけながらペンで手書きできるようにした

画面に手をつけながらペンで書いたときに、以下のように線が乱れてしまうことがありました。

画面に接触した手はペンや指で書いたときよりも接触面積が大きくなるので、接触部分が一定よりも大きくなった場合には線を書けないように実装しました。具体的には、radiusXプロパティを使用して、radiusX がある値より大きいときには、isDrawingModeが false になるようにしました。

canvas.on("mouse:down:before", (e) => {
  if ("TouchEvent" in window && e.e instanceof TouchEvent) {
    const touch = e.e.touches.item(0);
    if (!touch) {
      return;
    }

    // 接触面積がペンや指よりも大きいときに線を書けなくする
    if (touch.radiusX > 35) {
      canvas.isDrawingMode = false;
    }
  }
});

// 画面から手を離す度にisDrawingModeをリセット
canvas.on("mouse:up:before", () => {
  canvas.isDrawingMode = true;
});

工夫 2. 消しゴムで線を消すときのラグを解消した

消しゴム機能は erase2d ライブラリを使って実装しました。 fabric v5 までは消しゴム機能が内包されていましたが、fabric v6 から erase2d に切り出されました。 erase2d を使って消しゴムを実装すると、次の動画のようにユーザビリティを大きく損なうほどのラグが発生していました。

このラグは PC では発生せず、iPad でのみ発生していました。

この現象について原因を調査したところ、以下の 2 点が原因であることが分かりました。

原因 1. Retina ディスプレイによるピクセル密度の違い

iPad は Retina ディスプレイを搭載しており、ピクセル密度が通常のディスプレイよりも高くなっていました。 検証に利用していた 11 インチ iPad Air (M2) では、PC と比較してピクセル密度が 4 倍にもなっていました。

原因 2. ピクセル密度に依存した消しゴム機能特有の処理

消しゴム機能は、ペン機能と比較して複雑な処理が行われていました。 ペンは 1 つのキャンバスに線を描くだけであるのに対して、消しゴムは複数のキャンバスを扱い、それらを合成する処理が必要でした。 具体的には、消しゴムの軌跡を一時的なキャンバスに描画し、それをメインキャンバスに合成するといった処理等が行われていました。 これは、ピクセル密度に依存しており、ピクセル密度が高い環境では負荷が高くなっていました。

// https://github.com/ShaMan123/erase2d/blob/7ee2b789b4c9161d662ea8c8aaf87af4c37bab13/packages/core/src/erase.ts#L15-L40
export const erase = (
  destination: CanvasRenderingContext2D, // メインキャンバス
  source: CanvasRenderingContext2D, // 消しゴムの軌跡が描かれたキャンバス
  erasingEffect?: CanvasRenderingContext2D // 消したくないものを保護するためのキャンバス
) => {
  // メインキャンバスから、消しゴムの軌跡部分を消去
  drawImage(destination, source, "destination-out");

  if (erasingEffect) {
    // 消しゴムの軌跡に、消したくないものがあれば、消さずに保護
    drawImage(source, erasingEffect, "source-in");
  } else {
    // 消しゴムの軌跡を描いたキャンバスを初期化
    source.save();
    source.resetTransform();
    source.clearRect(0, 0, source.canvas.width, source.canvas.height);
    source.restore();
  }
};

これらの原因によって、Retina ディスプレイの高解像度環境では消しゴムで線を消すときにラグが発生していました。 解決策として Canvas の初期化時に enableRetinaScaling オプションをfalseに設定し、Retina ディスプレイの高解像度環境においても同じピクセル密度で処理を行うようにしました。

const canvas = new fabric.Canvas(canvasElement, {
  enableRetinaScaling: false,
});

この設定により、一定の解像度を担保しつつ、iPad でも滑らかな消しゴム操作を実現できました。

工夫 3. iPad で手書きして保存した画像を PC に即時反映されるようにした

iPad で手書きしながら PC でカルテを使うということを想定しているため、iPad で保存した手書き画像は PC に即時反映される必要があります。こちらを実現するために、以下が候補に挙げられました。

Firebase Realtime Database を既に使用しており、今回保存するのは更新時間のみで、保存容量が大幅に増えることもないため、今回は Firebase Realtime Database を使用することにしました。

以下が、Realtime Database で変更の監視及び通知を行う実装です。

import { initializeApp } from "firebase/app";
import { getDatabase, off, onValue, ref, set } from "firebase/database";

const app = initializeApp(
  {
    /* 省略’*/
  },
  "firebaseapp"
);

const db = () => {
  return getDatabase(app);
};

const PATH = "medicalRecordChiefComplaintImages";

// refを生成
const medicalRecordChiefComplaintImagesRef = (medicalRecordId: string) => {
  return ref(db(), `/${PATH}/${medicalRecordId}`);
};

// 主訴所見の手書き画像が更新されたことを通知する
export const updateMedicalRecordChiefComplaintImageIds = async ({
  medicalRecordId,
}: {
  medicalRecordId: string;
}) => {
  await set(medicalRecordChiefComplaintImagesRef(medicalRecordId), Date.now());
};

// 主訴所見の手書き画像の変更を監視する
export const watchMedicalRecordChiefComplaintImages = ({
  medicalRecordId,
  refreshMedicalRecordChiefComplaintImages,
}: {
  medicalRecordId: string;
  refreshMedicalRecordChiefComplaintImages: () => void;
}) => {
  onValue(medicalRecordChiefComplaintImagesRef(medicalRecordId), () => {
    // Realtime Databaseが更新されたときに実行する処理
    refreshMedicalRecordChiefComplaintImages();
  });
};

// 主訴所見の手書き画像の変更の監視を解除する
export const unwatchMedicalRecordChiefComplaintImages = ({
  medicalRecordId,
}: {
  medicalRecordId: string;
}) => {
  off(medicalRecordChiefComplaintImagesRef(medicalRecordId));
};

フロントエンドでは、データフェッチに TanStack Query を使用しています。以下のように Realtime Database によって手書き画像の更新を監視して refetch しています。

export const useMedicalChiefComplaintImagesWatchQuery = ({
  medicalRecordId,
  initialChiefComplaintImages,
}: UseMedicalChiefComplaintImagesWatchQueryProps) => {
  const medicalChiefComplaintImagesQuery = useQuery({
    queryKey: medicalChiefComplaintImageKey.list(medicalRecordId),
    queryFn: () => query.get(request.get(medicalRecordId)),
  });

  useEffect(() => {
    watchMedicalRecordChiefComplaintImages({
      medicalRecordId: medicalRecordId,
      // Realtime Databaseの更新時にrefetchする
      refreshMedicalRecordChiefComplaintImages:
        medicalChiefComplaintImagesQuery.refetch,
    });

    return () => unwatchMedicalRecordChiefComplaintImages({ medicalRecordId });
  }, [medicalRecordId, medicalChiefComplaintImagesQuery.refetch]);

  return medicalChiefComplaintImagesQuery;
};

最後に、iPad で手書き画像を更新したタイミングでupdateMedicalRecordChiefComplaintImageIdsを呼び出せば、以下のように iPad での変更が PC に即時に反映されるようになります。

工夫 4. 手書きツールバーを自由に配置できるようにした

手書き機能では太さや色の変更、ペンと消しゴムの切り替え、履歴管理ができるようにツールバーを実装しています。ツールバーは頻繁に使われるため、ユーザーが使用しやすい位置に移動できるようにしました。こちらは dnd-kit を使用して実装しました。

動画にあるようにツールバーが親要素からはみ出ないようになっています。こちらは restrictToParentElement を使用することで実現できます。 また、CSS ライブラリに emotion を使用しており、今回の Draggable 対応では動的にスタイルを変更する必要があるため、style prop を使うように注意する必要があります( https://emotion.sh/docs/best-practices#use-the-style-prop-for-dynamic-styles )。

ライブラリの不具合

上記のようにユーザーが使いやすいように手書き機能を開発しましたが、消しゴムで線を消すと消えて欲しくない背景画像まで消えてしまうという不具合がライブラリに存在していました。こちらは同じ fabric を使用している Dentis のエンジニア大岡に相談したところ、ライブラリに PR を出して解消していただけました!

まとめ

本記事では、Web でユーザビリティの高い手書きアプリケーションを実装するための工夫点について紹介しました。手書き機能の実装においては、実装速度とユーザビリティを両立するために適切なライブラリ選定が重要です。私達は fabric を採用することで滑らかな手書きアプリケーションを実現できました。 実装にあたり、以下の 4 つを工夫しました。

  • 手を画面に置きながら書ける機能: 接触面積の大きさを検出してペン入力と区別することで、自然な書き心地を実現しました
  • 消しゴム操作のラグ解消: Retina ディスプレイ対応を最適化し、高負荷な消しゴム処理でもスムーズな操作感を実現しました
  • リアルタイム同期: Firebase Realtime Database を活用し、複数デバイス間での即時反映を実現しました
  • 柔軟な UI: dnd-kit によるドラッグ可能なツールバーで、ユーザー体験を向上しました

また、ライブラリの不具合に対してはプロダクトを横断したコミュニケーションやコミュニティと協力することで解決しました。これらの工夫により、医科診療所で快適に使用できる手書き機能を開発することができました。 今後もユーザビリティを重視しながら、より使いやすい機能の開発を続けていきます。

We’re hiring

メドレーでは一緒に働く仲間を大募集しています! カジュアル面談も実施しておりますので、「お話だけでも聞いてみたい!」「ちょっと雑談してみたい!」でも構いませんので、お気軽にお問い合わせください!

募集の一覧

医療エンジニアリング領域盛り上がっています!メドレーについてお話します!

メドレーの開発チームについて知りたい方!ぜひお話ししましょう!