ABEJA Tech Blog

中の人の興味のある情報を発信していきます

【リファクタリング】フロントエンドのロジックを凝集して、UIコンポーネントから切り離してみた

はじめに

プラットフォームアプリケーショングループで開発を担当している平原です。 Next.jsのフロントエンドの開発を行っているときに、 複雑化してきたことでバグが出やすくなっているページが出てきました。 制約の多いデータをユーザーに操作してもらうため、 UIコンポーネントにドメインロジックが含まれてしまっていることが大きな要因になっていると感じました。 そこで、状況を改善する手段のひとつとして、 思考実験的にドメインロジックをUIコンポーネントから切り離して、 より凝集度の高いコードにリファクタリングしてみました。 (動作を変えないように作っているわけではないため、リプレイスの方が近いかもしれません。)

方針

データとドメインロジックの凝集を高めるためには、 データ型とそのドメインロジックを同じスコープに置き、 それ以外はそのスコープに入れないことが重要と考えています。 実現するためには、型と関数の関係が明確であれば十分なので、 最もシンプルな実装を考えると、

  • クラス構文で関連関数をメソッドとして定義する(状態にはクラスインスタンスを利用)
  • 型とその関連関数のみを同じモジュールに定義する(状態にはプレーンオブジェクトを利用)

の2つの方法が考えられます。今回は、通常TypeScriptにおいてクラスはあまり使われませんが、思考実験的に両方試してみます。 (Go、Pythonでバックエンドを書くことが多くメソッドを使うことに慣れているため、TypeScriptでクラスが使われない背景に疑問を持っていました。)

フロントエンドで記述されるロジックはドメインロジックだけではなく、 操作中やローディング中のフラグなどのUIロジックも含まれます。 こちらもユーザーの操作が増えてくると、コンポーネントの記述が煩雑化してしまう要因です。 そこで、UIロジックを含めた状態の操作はuseReducerにまとめることにします。 UIロジックのためReactに依存することを受け入れます。 とはいえ、本体はreducerという純粋関数で実装されるため、 UIロジックについてもテスト可能性が失われません。

以上のようにすると、ドメイン層はそのドメインに関する知識に専念、 reducerはUIロジックに専念、 コンポーネントはUIの描画とユーザー操作のハンドリングに専念できるようになる予定です。

さて、状態に対してクラス構文を使う場合には、いくつか注意することがあります。 TypeScript(特にReact)では、クラス自体があまり利用されていませんが、これは

  • ミュータビリティの問題
    • クラスインスタンスはミュータブルなものとして扱われることが多い
  • シリアライズ可能性による制約
    • サーバーコンポーネントから、クライアントコンポーネントへの受け渡しではシリアライズ可能性が必要
    • Reduxなど一部の状態管理ライブラリはシリアライズ可能性が要求されるものがある
    • クラスのメソッドはシリアライズできない
  • JavaScriptのクラス特有の問題
    • thisの扱いの複雑性(状況によりthisが指すものが変わる)

などの理由があります。そのため、

  • 全てのプロパティをreadonlyにする(プレーンオブジェクトでも同様ですが、型レベルのみで実行時には効かないので、不変として運用する必要があります。開発中はObject.freezeを併用しても良いかもしれません。)
    • 更新メソッドは純粋メソッド(新しいインスタンスを返す)にする
  • Reduxなどのシリアライズ可能性を要求する状態管理ライブラリには使用しない
  • シリアライズ境界をまたがない

のルールを守る必要があります。

具体例

作るもの

なるべく実務に近いロジックを含む機能を作ってみようと思い、 実際の要件よりは簡略化していますが「カメラの人物検出エリア設定」の画面を作ってみます。 (CSRな画面を想定しています。) この画面で満たすべき条件は、次のように設定しました。 これらのロジックをReactの外側で実装するように作ってみます。

  • 矩形で囲んだエリア全てが検出対象エリアとなる
  • 矩形の境界線は対象エリアに含む
  • 矩形は画像の中に収まっている
  • 矩形同士は重なりを持たない
  • 矩形の座標は整数である
  • 矩形は最大5つまで作成できる

実際に作成した画面は次のようになりました。

デモ画面

ドメインロジック(クラススタイル)

まず、クラスを使った実装から作っていきます。 登場するクラスはRect(矩形を表すクラス)とTargetArea(人物検出エリアを表すクラス)です。

まずは、クラスのプロパティを定義します。 人物検出エリアは矩形の集合となるように定義します。 また、矩形の最大数・画像サイズなど、人物検出エリアの条件判定に使うものを定義します。 この時にすべてのプロパティがreadonlyとなるようにしています。 そうすることでクラスがイミュータブルとなり変更されることがなくなるため、 2つのインスタンスが「同一である」ならば「いつでも同値である」ことが保証されます。

/** 矩形を表すクラス */
export class Rect {
  public readonly left: number;
  public readonly top: number;
  public readonly right: number;
  public readonly bottom: number;
}

/** 人物検出エリアを表すクラス */
export class TargetArea {
  static readonly MAX_NUMBER_OF_RECTS = 5; // 最大矩形数
  public readonly id: string;
  public readonly name: string;
  public readonly rects: readonly Rect[]; // 配列の要素も変更不可
  public readonly canvasWidth: number; // 画像の幅
  public readonly canvasHeight: number; // 画像の高さ
}

次に、クラスが満たすべき条件をコンストラクタでチェックします。 このようにすることで、想定しない状態を作成できないようにします。 開発中もエラーが発生するため、問題に早く気づくことができます。 一方で、矩形同士の重なりなど、ユーザーの入力中に一時的に発生してしまうエラーはコンストラクタでは許容しています。 その代わり、メソッドとして条件を記載して、 ドキュメント的な意味も込めて条件を満たす必要があることを明記しておきます。

/** 矩形を表すクラス */
export class Rect {
  ...
  /** 座標の整数化と、left <= right, top <= bottom の順序を保証します。 */
  constructor(left: number, top: number, right: number, bottom: number) {
    if (left <= right) {
      this.left = Math.round(left);
      this.right = Math.round(right);
    } else {
      this.left = Math.round(right);
      this.right = Math.round(left);
    }
    if (top <= bottom) {
      this.top = Math.round(top);
      this.bottom = Math.round(bottom);
    } else {
      this.top = Math.round(bottom);
      this.bottom = Math.round(top);
    }
  }

  /** 矩形が他の矩形と交差しているかどうかを判定します。 */
  isIntersect(other: Rect): boolean {
    const { left, top, right, bottom } = this;
    const { left: oLeft, top: oTop, right: oRight, bottom: oBottom } = other;
    return (
      left <= oRight &&
      right >= oLeft &&
      top <= oBottom &&
      bottom >= oTop
    );
  }
}

/** 人物検出エリアを表すクラス */
export class TargetArea {
  ...
  /** 画像サイズが0より大きいこと、矩形の座標がキャンバスの範囲内に収まること、矩形数が最大数以下であることを保証します。 */
  constructor(id: string, name: string, rects: readonly Rect[], canvasWidth: number, canvasHeight: number) {
    this.id = id;
    this.name = name;
    this.rects = rects;
    this.canvasWidth = canvasWidth;
    this.canvasHeight = canvasHeight;
    if (canvasWidth <= 0 || canvasHeight <= 0) {
      throw new Error("Width and height must be greater than 0");
    }
    if (TargetArea.MAX_NUMBER_OF_RECTS < rects.length) {
      throw new Error(`Number of rectangles cannot exceed ${TargetArea.MAX_NUMBER_OF_RECTS}`);
    }
    for (const rect of rects) {
      if (rect.left < 0 || rect.top < 0 || canvasWidth <= rect.right || canvasHeight <= rect.bottom) {
        throw new Error("Rectangle coordinates must be within the canvas bounds" + JSON.stringify(rect));
      }
    }
  }

  /** 人物検出エリアが有効であることを確認します */
  isValid(): boolean {
    return !this.hasIntersectRectPair()
  }

  /** 矩形が交差しているかどうかを判定します。 */
  hasIntersectRectPair(): boolean {
    for (let i = 0; i < this.rects.length; i++) {
      for (let j = i + 1; j < this.rects.length; j++) {
        if (this.rects[i].isIntersect(this.rects[j])) {
          return true;
        }
      }
    }
    return false;
  }

  /** 矩形が最大数存在するかどうかを判定します */
  isFullRects(): boolean {
    return TargetArea.MAX_NUMBER_OF_RECTS <= this.rects.length;
  }
}

最後に、更新用のメソッドを定義します。 矩形の変形を例に見てみます。 クラスをイミュータブルにしたので、プロパティに代入することができません。 そのため、新しいインスタンスを作成することで更新します。 (関数型プログラミングとして親しまれているArray.map(): ArrayArray.filter(): Arrayのような、自クラスに閉じた関数を作成します。これにより、メソッドチェーンが使えたり、useReducerへの橋渡しがしやすくなったりします。) このとき、変更のないプロパティは再生成せずに参照をコピーするだけにして、なるべく同一性を維持します。 プレーンオブジェクトの展開式が使えないので、クラスの場合は意識しておく必要がありそうです。

/** 矩形を表すクラス */
export class Rect {
  ...
  /** 矩形を変形します
   * @param delta - 変形量。left, top, right, bottom をそれぞれ指定した座標分だけ変更します。
   */
  transform(delta: { left?: number; top?: number; right?: number; bottom?: number }): Rect {
    return new Rect(
      this.left + (delta.left ?? 0),
      this.top + (delta.top ?? 0),
      this.right + (delta.right ?? 0),
      this.bottom + (delta.bottom ?? 0),
    );
  }

  /** 他の矩形の範囲に収まるように座標を調整します。 */
  clampTo(other: Rect): Rect {
    const { left, top, right, bottom } = this;
    const { left: oLeft, top: oTop, right: oRight, bottom: oBottom } = other;
    return new Rect(
      Math.min(Math.max(left, oLeft), oRight),
      Math.min(Math.max(top, oTop), oBottom),
      Math.min(Math.max(right, oLeft), oRight),
      Math.min(Math.max(bottom, oTop), oBottom),
    );
  }
}

/** 人物検出エリアを表すクラス */
export class TargetArea {
  ...
  /** 更新されたTargetAreaを返します。 */
  private update(data: { name?: string, rects?: readonly Rect[]; width?: number; height?: number }): TargetArea {
    return new TargetArea(
      this.id,
      data.name ?? this.name,
      data.rects ?? this.rects,
      data.width ?? this.canvasWidth,
      data.height ?? this.canvasHeight,
    );
  }

  /** 指定したインデックスの矩形を変形した新しいTargetAreaを返します。 */
  transform(index: number, delta: { left?: number; top?: number; right?: number; bottom?: number }): TargetArea {
    const rect = this.rects[index];
    if (!rect) {
      throw new Error(`No rect found at index ${index}`);
    }
    const newRects = [...this.rects];
    newRects[index] = rect.transform(delta).clampTo(new Rect(0, 0, this.canvasWidth - 1, this.canvasHeight - 1));
    return this.update({ rects: newRects });
  }
}

reducer(クラススタイル)

今回のでデモではWeb APIをサボっているため、ローディング中の状態はありませんが、 どの矩形を選択しているか、矩形を変形中かどうかなどの状態があります。 こちらのUIロジックを実現するためのreducerを作成していきます。

後ほど関数スタイルでも同じことをするので、共通部分を書き出しておきます。 ほとんど同じI/Fになるため、型部分は共通です。 Stateの型はクラスインスタンスかプレーンオブジェクトかの違いがあるため、 こちらには記載がないです。 (enumを使用していますが、コメントの記載や執筆中にシンボル名変更で名前を変えやすくするなどの利便性のためです。ユニオン型で大丈夫です。)

// ./reducer/type.ts
export enum ActionType {
  /** 矩形追加 */
  ADD_RECT = 'ADD_RECT',
  /** 矩形削除 */
  REMOVE_RECT = 'REMOVE_RECT',
  /** 矩形選択 */
  SELECT_RECT = 'SELECT_RECT',
  /** 矩形選択解除 */
  UNSELECT_RECT = 'UNSELECT_RECT',
  /** 矩形移動開始 */
  START_MOVE_RECT = 'START_MOVE_RECT',
  /** 矩形移動終了 */
  END_MOVE_RECT = 'END_MOVE_RECT',
  /** 矩形移動。START_MOVE_RECTとEND_MOVE_RECTの間のみ効果があります。 */
  MOVE_RECT = 'MOVE_RECT',
}

export type Action =
  | { type: ActionType.ADD_RECT; payload: { left: number; top: number; right: number; bottom: number } }
  | { type: ActionType.REMOVE_RECT; payload: { index: number } }
  | { type: ActionType.SELECT_RECT; payload: { index: number } }
  | { type: ActionType.UNSELECT_RECT }
  | { type: ActionType.START_MOVE_RECT; payload: { point: { x: number; y: number }; displaySize: { width: number, height: number }; moveSensibility: MoveSensibility } }
  | { type: ActionType.END_MOVE_RECT }
  | { type: ActionType.MOVE_RECT; payload: { point: { x: number; y: number }; displaySize: { width: number, height: number }; } };

/**
 * 矩形の変形感度を定義する型
 * left, top, right, bottom のいずれかを true にすると、その方向の辺を移動できることを示します。
 * すべて false の場合は、変形できないことを示します。
 */
export type MoveSensibility = {
  readonly left?: true;
  readonly top?: true;
  readonly right?: true;
  readonly bottom?: true;
};

次に、reducerの本体を作成します。 UIの更新ロジックをこちらになるべく集約します。 また、ドメインロジックは先ほどのmodel.tsに委譲するようにします。 実装中にドメインロジックらしきものが出てきた場合は、model.tsに移動させることを検討します。 コードは、ドメインロジックが関わるところを抜粋しています。

// ./reducer/class.ts
import { TargetArea } from '../model'
import { Action, ActionType, MoveSensibility } from './type'

type State = {
  /** 人物検出エリア */
  targetArea: TargetArea
  /** 選択中の矩形のインデックス。nullは選択されていないことを表す。 */
  selectedRectIndex: number | null
  /** 矩形の変形開始時の状態。nullは変形中ではないことを表す。 */
  anchor: {
    point: {
      x: number
      y: number
    }
    moveSensibility: MoveSensibility
    targetArea: TargetArea
    selectedRectIndex: number
  } | null
}

export function reducer(state: State, action: Action): State {
  switch (action.type) {
    case ActionType.ADD_RECT:
      return {
        ...state,
        targetArea: state.targetArea.addRect(action.payload.left, action.payload.top, action.payload.right, action.payload.bottom),
      };
    case ActionType.REMOVE_RECT:
      if (state.selectedRectIndex === action.payload.index) {
        // 選択中の矩形を削除する場合
        return {
          ...state,
          selectedRectIndex: null,
          targetArea: state.targetArea.removeRect(action.payload.index),
        };
      } else if (
        state.selectedRectIndex !== null && action.payload.index < state.selectedRectIndex
      ) {
        // 選択中の矩形より前を削除する場合
        return {
          ...state,
          selectedRectIndex: state.selectedRectIndex - 1,
          targetArea: state.targetArea.removeRect(action.payload.index),
        };
      } else {
        // 選択中の矩形より後を削除する場合 or 矩形を選択していない場合
        return {
          ...state,
          targetArea: state.targetArea.removeRect(action.payload.index),
        };
      }

    // 中略...

    case ActionType.MOVE_RECT:
      if (!state.anchor) return state;
      const x = action.payload.point.x * state.targetArea.canvasWidth / action.payload.displaySize.width;
      const y = action.payload.point.y * state.targetArea.canvasHeight / action.payload.displaySize.height;
      const dx = x - state.anchor.point.x;
      const dy = y - state.anchor.point.y;
      const delta = {
        left: state.anchor.moveSensibility.left ? dx : 0,
        top: state.anchor.moveSensibility.top ? dy : 0,
        right: state.anchor.moveSensibility.right ? dx : 0,
        bottom: state.anchor.moveSensibility.bottom ? dy : 0,
      };
      return {
        ...state,
        targetArea: state.anchor.targetArea.transform(state.anchor.selectedRectIndex, delta),
      };
    default:
      const _: never = action; // eslint-disable-line @typescript-eslint/no-unused-vars
      return state;
  }
}

コンポーネント上での値の更新(クラススタイル)

reducerをUIコンポーネントに導入してみます。 UIの更新ロジックはreducerに集約されたため、 コンポーネントではユーザーの操作をアクションに変換するだけで状態が更新できるようになります。 コードは長くなるので、ユーザーのマウス操作をアクションに変換している箇所を抜粋しています。

export default function Page() {
  // ユーザー操作用の状態
  const [state, dispatch] = useReducer(reducerWithClass, {
    targetArea: new model.TargetArea(
      "23d3f604-60e6-4b2b-8b78-d753081b8f49",
      "example",
      [],
      1920,
      1080
    ),
    selectedRectIndex: null,
    anchor: null,
  });

  ...

  // マウス操作
  /** マウスの押し込み時に状態を保存 */
  const onMouseDown: ComponentProps<typeof RectActionsGuide>["onMouseDown"] =
    useCallback((moveSensibility, event) => {
      /** 画面上の画像サイズ */
      const displaySize = cacheSvgSize.current;
      if (!displaySize) {
        return;
      }
      dispatch({
        type: ActionType.START_MOVE_RECT,
        payload: {
          point: { x: event.pageX, y: event.pageY },
          displaySize,
          moveSensibility,
        },
      });
    }, []);
  /** マウス移動による矩形の変形を処理 */
  const handleMouseMove = useCallback((event: MouseEvent) => {
    /** 画面上の画像サイズ */
    const displaySize = cacheSvgSize.current;
    if (!displaySize) {
      return;
    }
    dispatch({
      type: ActionType.MOVE_RECT,
      payload: { point: { x: event.pageX, y: event.pageY }, displaySize },
    });
  }, []);
  /** マウス押し込み状態をリリースして、矩形更新を止める */
  const handleMouseUp = useCallback(() => {
    dispatch({ type: ActionType.END_MOVE_RECT });
  }, []);

  // マウスイベントのリスナーを登録
  useEffect(() => {
    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mouseup", handleMouseUp);
    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
    };
  }, [handleMouseMove, handleMouseUp]);

  ...

}

ドメインロジック(関数スタイル)

次に、クラススタイルではなく、関数スタイルで同じことを実現してみます。 クラススタイルと同じように、矩形を表すRectと人物検出エリアを表すTargetAreaを定義します。

クラススタイルでは、これらはクラスとして定義されていましたが、 関数スタイルではプレーンなオブジェクトとして定義します。 クラスで切られていたスコープはモジュールで切り、 コンストラクタはファクトリ関数に置き換えます。 また、メソッドは第一引数に対象のオブジェクトを受け取る関数に置き換えます。 (不変のthisを暗黙的に受け取っていると見ると、メソッドも純粋関数です。 reducerとも近いI/Fになり、reducerでも使いやすくなります。) privateだったメソッドは、exportしない関数として表現し、 staticなプロパティはグローバルな定数として表現しました。

// ./model/rect.ts

/** プレーンオブジェクトな型になります */
export type Rect = {
  readonly left: number;
  readonly top: number;
  readonly right: number;
  readonly bottom: number;
}

/** コンストラクタだったところは、ファクトリ関数に置き換えました */
export function newRect(left: number, top: number, right: number, bottom: number): Rect {
  ...
}

/** メソッドだったものは、thisがない代わりに対象のRectを受け取ります。 */
export function transform(rect: Rect, delta: { left?: number; top?: number; right?: number; bottom?: number }): Rect {
  ...
}

...
// ./model/targetArea.ts
/** 関心をモジュールで分割したので、必要ならimportします */
import * as rectModel from './rect'

/** スタティックプロパティだったものはグローバル定数に置き換えました。 */
export const MAX_NUMBER_OF_RECTS = 5;

/** プレーンオブジェクトな型になります */
export type TargetArea = {
  readonly id: string;
  readonly name: string;
  readonly rects: readonly rectModel.Rect[];
  readonly canvasWidth: number;
  readonly canvasHeight: number;
}

/** privateメソッドだったものは、exportしない関数として表現しました。 */
function update(targetArea: TargetArea, data: { name?: string, rects?: readonly rectModel.Rect[], canvasWidth?: number, canvasHeight?: number }) {
  ...
}

...

reducer(関数スタイル)

クラスからの変更はほとんどありません。 状態のTargetArea型のクラスからプレーンオブジェクトへの変更と、 メソッドを使用していたところの関数への変更を行いました。

// ./reducer/function.ts
import * as targetAreaModel from '../model/targetArea'
import { Action, ActionType, MoveSensibility } from './type'

/** TargetAreaの型がクラスではなく、プレーンオブジェクトになります */
type State = {
  /** 人物検出エリア */
  targetArea: targetAreaModel.TargetArea
  /** 選択中の矩形のインデックス。nullは選択されていないことを表す。 */
  selectedRectIndex: number | null
  /** 矩形の変形開始時の状態。nullは変形中ではないことを表す。 */
  anchor: {
    point: {
      x: number
      y: number
    }
    moveSensibility: MoveSensibility
    targetArea: targetAreaModel.TargetArea
    selectedRectIndex: number
  } | null
}

export function reducer(state: State, action: Action): State {
  ...
  // メソッドを関数に変更
  // クラスの場合: state.targetArea.removeRect(action.payload.index)
  targetAreaModel.removeRect(state.targetArea, action.payload.index)
  ...
}

コンポーネント上での値の更新(関数スタイル)

クラスの時とほとんど変わりがありません。 reducerのI/Fはほとんど一緒のため、reducerを入れ替えるだけでほとんど完了しました。 あとは、メソッドを使っていたところを関数に直すと完了します。

export default function Page() {
  // ユーザー操作用の状態
  // const [state, dispatch] = useReducer(reducerWithClass, {
  //   targetArea: new model.TargetArea(
  //     "23d3f604-60e6-4b2b-8b78-d753081b8f49",
  //     "example",
  //     [],
  //     1920,
  //     1080
  //   ),
  //   selectedRectIndex: null,
  //   anchor: null,
  // });
  const [state, dispatch] = useReducer(reducerWithFunction, {
    targetArea: targetAreaModel.newTargetArea(
      "23d3f604-60e6-4b2b-8b78-d753081b8f49",
      "example",
      [],
      1920,
      1080,
    ),
    selectedRectIndex: null,
    anchor: null,
  });
  ...
}

思考実験を通して感じたこと

クラススタイルと関数スタイルの違い

ドメインロジックの記述以外では違いは大きく感じられませんでした。 どちらでもほとんど同じように記述できました。 シリアライズ境界による制約を受けるクラスがあまり使われないのは、 イミュータブルなクラスにすると関数との違いが少なくなることが大きいのかなと感じました。

些細ですが感じた違いは、関数ではメソッドチェーンが使えないことです。 連続で更新をかけようとすると、f3(f2(f1(obj)))のようになるので、 まとめずに更新ごとに変数に取り出すか、 pipe(obj, f1, f2, f3)のように関数適用できるようなユーティリティを用意すると良さそうです。 もう一つの違いは、関数ではthisに対象オブジェクトがバインドされないため、 第一引数で対象オブジェクトを受け取る必要があることです。 強制力があるわけではないため、意識して記述する必要がありそうです。

ドメインロジックの凝集

ドメインロジックが複雑になる場面では、 RectTargetAreaのように関心領域で分割することで、 それぞれのタスクに専念できるようになりました。 ただし、想像ですが、ドメインロジックがシンプルな場合にはやりすぎになってしまったり、 ドメインロジックが複雑すぎる場合には分割自体が難しくなったりすることもありそうです。

UIロジックの凝集

reducerにUIロジックを凝集させてみましたが、 しばらく眺めているとUIの状態遷移テストが行いやすくなっていることに気づきました。 useReducerはReactの機能ですが、reducer自体は純粋関数です。 (UIフレームワークにも依存しません。) また、今回はUIが扱う状態もreducerに集約されています。 そのため、0スイッチカバレッジを網羅するには全てのアクションを実行、 1スイッチカバレッジを網羅するには全てのアクションの組み合わせを実行していけば良さそうです。 ただし、純粋関数のためテストの実行は簡単にできますが、 副作用を含めたテストやreducerに含まれない状態の遷移を伴うテストは別の手法を取る必要がありそうです。

コンポーネントの役割

UIの状態更新ロジックがreducerに集約されたため、 コンポーネントからは細かなロジックは無くなりました。 ここで行なっていることは、ユーザーの操作をアクションに変換することと、 状態を元にUIを描画することです。

最後に

やってみて、フロントエンドの状態にまつわる事情を少し理解できた気がしました。 フロントエンドはユーザー操作用の状態がたくさんあり、それぞれが相互作用するため、 すぐにロジックが複雑化してしまいます。 そのため、保守性を維持していくためには、UIの記述と混ざってより複雑化しないように、 ロジック部分の凝集度を上げていくのもひとつの手段だと感じました。 UIのフレームワーク移行や、E2Eテストなどまだまだ悩みはありますが、 徐々に整えていけたらと思います。

参考:コード全文

コードが長くなってしまったので、全文はこちらに記載しています。 新規のNext.jsプロジェクト(App router)にこれらのファイルを追加しました。

■ page.tsx(クリックして開く)

"use client";

import styles from "./page.module.css";
import * as model from "./model";
import * as rectModel from "./model/rect";
import * as targetAreaModel from "./model/targetArea";
import { Action, ActionType, MoveSensibility } from "./reducer/type";
import { reducer as reducerWithClass } from "./reducer/class";
import { reducer as reducerWithFunction } from "./reducer/function";

import {
  ComponentProps,
  useCallback,
  useEffect,
  useMemo,
  useReducer,
  useRef,
  useSyncExternalStore,
} from "react";

export default function Page() {
  // クラスの場合
  const [state, dispatch] = useReducer(reducerWithClass, {
    targetArea: new model.TargetArea(
      "23d3f604-60e6-4b2b-8b78-d753081b8f49",
      "example",
      [],
      1920,
      1080
    ),
    selectedRectIndex: null,
    anchor: null,
  });
  const isFullRects = useMemo(
    () => state.targetArea.isFullRects(),
    [state.targetArea]
  );
  const hasIntersectRectPair = useMemo(
    () => state.targetArea.hasIntersectRectPair(),
    [state.targetArea]
  );

  // 関数の場合
  // const [state, dispatch] = useReducer(reducerWithFunction, {
  //   targetArea: targetAreaModel.newTargetArea(
  //     "23d3f604-60e6-4b2b-8b78-d753081b8f49",
  //     "example",
  //     [],
  //     1920,
  //     1080,
  //   ),
  //   selectedRectIndex: null,
  //   anchor: null,
  // });
  // const isFullRects = useMemo(
  //   () => targetAreaModel.isFullRects(state.targetArea),
  //   [state.targetArea]
  // );
  // const hasIntersectRectPair = useMemo(
  //   () => targetAreaModel.hasIntersectRectPair(state.targetArea),
  //   [state.targetArea]
  // );

  // SVGのサイズを取得
  const svgRef = useRef<SVGSVGElement | null>(null);
  const cacheSvgSize = useRef<{ width: number; height: number } | null>(null);
  const svgSize = useElementSize(svgRef, cacheSvgSize);
  const sizeRate = useMemo(
    () => ({
      width: (svgSize?.width ?? 0) / state.targetArea.canvasWidth,
      height: (svgSize?.height ?? 0) / state.targetArea.canvasHeight,
    }),
    [svgSize, state.targetArea.canvasWidth, state.targetArea.canvasHeight]
  );

  // マウス操作
  /** マウスの押し込み時に状態を保存 */
  const onMouseDown: ComponentProps<typeof RectActionsGuide>["onMouseDown"] =
    useCallback((moveSensibility, event) => {
      const displaySize = cacheSvgSize.current;
      if (!displaySize) {
        return;
      }
      dispatch({
        type: ActionType.START_MOVE_RECT,
        payload: {
          point: { x: event.pageX, y: event.pageY },
          displaySize,
          moveSensibility,
        },
      });
    }, []);
  /** マウス移動による矩形の変形を処理 */
  const handleMouseMove = useCallback((event: MouseEvent) => {
    const displaySize = cacheSvgSize.current;
    if (!displaySize) {
      return;
    }
    dispatch({
      type: ActionType.MOVE_RECT,
      payload: { point: { x: event.pageX, y: event.pageY }, displaySize },
    });
  }, []);
  /** マウス押し込み状態をリリースして、矩形更新を止める */
  const handleMouseUp = useCallback(() => {
    dispatch({ type: ActionType.END_MOVE_RECT });
  }, []);

  // マウスイベントのリスナーを登録
  useEffect(() => {
    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mouseup", handleMouseUp);
    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
    };
  }, [handleMouseMove, handleMouseUp]);

  return (
    <div className={styles.layout}>
      {/* 左ペイン */}
      <div>
        <h2>矩形一覧</h2>
        <div className={styles["rect-item-list"]}>
          {/* 矩形リスト */}
          {state.targetArea.rects.map((rect, index) => (
            <div
              key={index}
              className={`${styles["rect-item"]} ${
                state.selectedRectIndex === index ? styles.selected : ""
              }`}
              onClick={() => {
                dispatch({ type: ActionType.SELECT_RECT, payload: { index } });
              }}
            >
              {/* 座標表示 */}
              <div>{`${index + 1}: (${rect.left}, ${rect.top}, ${rect.right}, ${
                rect.bottom
              })`}</div>
              {/* 削除ボタン */}
              <div>
                <button
                  className={styles["delete-button"]}
                  onClick={(event) => {
                    event.stopPropagation();
                    dispatch({
                      type: ActionType.REMOVE_RECT,
                      payload: { index },
                    });
                  }}
                >
                  削除
                </button>
              </div>
            </div>
          ))}
          {/* 矩形追加ボタン */}
          {isFullRects || (
            <button
              className={styles["add-button"]}
              onClick={() => {
                dispatch({
                  type: ActionType.ADD_RECT,
                  payload: { left: 0, top: 0, right: 100, bottom: 100 },
                });
              }}
            ></button>
          )}
        </div>
        {hasIntersectRectPair && <div>矩形が交差しています。</div>}
      </div>
      {/* 右ペイン */}
      <div>
        <div className={styles["canvas-container"]}>
          <img
            className={styles.image}
            src="/1920x1080.png"
            alt="Rect Canvas"
          />
          <div className={styles.canvas}>
            <svg
              width="100%"
              height="100%"
              ref={svgRef}
              onMouseDown={() => {
                dispatch({ type: ActionType.UNSELECT_RECT });
              }}
            >
              {/* 矩形の図形表示 */}
              {state.targetArea.rects.map((rect, index) => (
                <RectDisplay
                  key={index}
                  index={index}
                  rect={rect}
                  sizeRate={sizeRate}
                  dispatch={dispatch}
                />
              ))}
              {/* 選択中の矩形の変形ガイド */}
              {state.selectedRectIndex !== null &&
                state.targetArea.rects[state.selectedRectIndex] && (
                  <RectActionsGuide
                    rect={state.targetArea.rects[state.selectedRectIndex]}
                    sizeRate={sizeRate}
                    onMouseDown={onMouseDown}
                  />
                )}
            </svg>
          </div>
        </div>
      </div>
    </div>
  );
}

/**
 * 矩形の図形表示用のコンポーネント
 * 選択できるようにクリックイベントを処理する関数を受け取れます。
 */
function RectDisplay({
  index,
  rect,
  sizeRate,
  dispatch,
}: {
  index: number;
  rect: model.Rect | rectModel.Rect;
  sizeRate: { width: number; height: number };
  dispatch: React.ActionDispatch<[action: Action]>;
}) {
  // width, heightについて関数スタイル・クラススタイルのどちらでも使えるようにしています。
  let width: number, height: number;
  if (rect instanceof model.Rect) {
    ({ width, height } = rect.getXYWH());
  } else {
    ({ width, height } = rectModel.getXYWH(rect));
  }
  const visualLeft = rect.left * sizeRate.width;
  const visualTop = rect.top * sizeRate.height;
  const visualWidth = width * sizeRate.width;
  const visualHeight = height * sizeRate.height;

  const strokeWidth = Math.min(4, visualWidth, visualHeight);
  const strokeWidthHalf = strokeWidth / 2;

  const onClick = useCallback(
    (event: React.MouseEvent<SVGRectElement, MouseEvent>) => {
      event.stopPropagation();
      dispatch({ type: ActionType.SELECT_RECT, payload: { index } });
    },
    [dispatch, index]
  );

  return (
    <>
      <rect
        x={visualLeft + strokeWidthHalf}
        y={visualTop + strokeWidthHalf}
        width={visualWidth - strokeWidth}
        height={visualHeight - strokeWidth}
        fill="none"
        stroke="#00FF00"
        strokeWidth={strokeWidth}
      />
      <rect
        className={styles["cursor-pointer"]}
        x={visualLeft}
        y={visualTop}
        width={visualWidth}
        height={visualHeight}
        fill="#000000"
        fillOpacity="0"
        onClick={onClick}
      />
    </>
  );
}

/**
 * 選択中の矩形が変形できる状態であることを、図で示すコンポーネント
 * 矩形の四隅と辺に、変形用のハンドルを表示します。
 * マウスダウンイベントを受け取り、どのハンドルが押されたかを判定します。
 */
function RectActionsGuide({
  rect,
  sizeRate,
  onMouseDown,
}: {
  rect: model.Rect | rectModel.Rect;
  sizeRate: { width: number; height: number };
  onMouseDown: (
    moveSensibility: MoveSensibility,
    event: React.MouseEvent<SVGRectElement | SVGCircleElement, MouseEvent>
  ) => void;
}) {
  // width, heightについて関数スタイル・クラススタイルのどちらでも使えるようにしています。
  let width: number, height: number;
  if (rect instanceof model.Rect) {
    ({ width, height } = rect.getXYWH());
  } else {
    ({ width, height } = rectModel.getXYWH(rect));
  }
  const visualLeft = rect.left * sizeRate.width;
  const visualTop = rect.top * sizeRate.height;
  const visualWidth = width * sizeRate.width;
  const visualHeight = height * sizeRate.height;
  const visualRight = visualLeft + visualWidth;
  const visualBottom = visualTop + visualHeight;
  return (
    <>
      {/* visual */}
      <rect
        x={visualLeft}
        y={visualTop}
        width={visualWidth}
        height={visualHeight}
        fill="none"
        stroke="#0000FF"
        strokeWidth="5"
        strokeOpacity="0.3"
      />
      <rect
        x={visualLeft}
        y={visualTop}
        width={visualWidth}
        height={visualHeight}
        fill="none"
        stroke="#0000FF"
        strokeWidth="1"
      />
      <circle
        cx={visualLeft}
        cy={visualTop}
        r="3"
        fill="#FFFFFF"
        stroke="#000000"
        strokeWidth="0.5"
      />
      <circle
        cx={visualRight}
        cy={visualTop}
        r="3"
        fill="#FFFFFF"
        stroke="#000000"
        strokeWidth="0.5"
      />
      <circle
        cx={visualRight}
        cy={visualBottom}
        r="3"
        fill="#FFFFFF"
        stroke="#000000"
        strokeWidth="0.5"
      />
      <circle
        cx={visualLeft}
        cy={visualBottom}
        r="3"
        fill="#FFFFFF"
        stroke="#000000"
        strokeWidth="0.5"
      />
      {/* action */}
      <rect
        className={styles["cursor-all-scroll"]}
        x={visualLeft}
        y={visualTop}
        width={visualWidth}
        height={visualHeight}
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown(
            { left: true, top: true, right: true, bottom: true },
            event
          );
        }}
      />
      <rect
        className={styles["cursor-ew-resize"]}
        x={visualLeft - 3}
        y={visualTop}
        width="6"
        height={visualHeight}
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ left: true }, event);
        }}
      />
      <rect
        className={styles["cursor-ns-resize"]}
        x={visualLeft}
        y={visualTop - 3}
        width={visualWidth}
        height="6"
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ top: true }, event);
        }}
      />
      <rect
        className={styles["cursor-ew-resize"]}
        x={visualRight - 3}
        y={visualTop}
        width="6"
        height={visualHeight}
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ right: true }, event);
        }}
      />
      <rect
        className={styles["cursor-ns-resize"]}
        x={visualLeft}
        y={visualBottom - 3}
        width={visualWidth}
        height="6"
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ bottom: true }, event);
        }}
      />
      <circle
        className={styles["cursor-nwse-resize"]}
        cx={visualLeft}
        cy={visualTop}
        r="6"
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ left: true, top: true }, event);
        }}
      />
      <circle
        className={styles["cursor-nesw-resize"]}
        cx={visualRight}
        cy={visualTop}
        r="6"
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ top: true, right: true }, event);
        }}
      />
      <circle
        className={styles["cursor-nwse-resize"]}
        cx={visualRight}
        cy={visualBottom}
        r="6"
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ right: true, bottom: true }, event);
        }}
      />
      <circle
        className={styles["cursor-nesw-resize"]}
        cx={visualLeft}
        cy={visualBottom}
        r="6"
        fillOpacity="0"
        onMouseDown={(event) => {
          event.stopPropagation();
          onMouseDown({ left: true, bottom: true }, event);
        }}
      />
    </>
  );
}

/**
 * 要素のサイズを監視するカスタムフックです。
 * @param refElement - 監視対象の要素
 * @param refSize - 要素のサイズを格納する参照。
 *   描画に関係のない場合はこちらを参照します。
 * @returns 要素のサイズ
 */
function useElementSize(
  refElement: React.RefObject<HTMLElement | SVGElement | null>,
  refSize: React.RefObject<{ width: number; height: number } | null>
): { width: number; height: number } | null {
  const subscribe = useCallback(
    (onStoreChange: () => void) => {
      const resizeObserver = new ResizeObserver((entries) => {
        for (const entry of entries) {
          if (entry.target === refElement.current) {
            onStoreChange();
          }
        }
      });
      if (refElement.current) resizeObserver.observe(refElement.current);

      return () => {
        resizeObserver.disconnect();
      };
    },
    [refElement]
  );
  const getSnapshot = useCallback(() => {
    const rect = refElement.current?.getBoundingClientRect();
    if (
      rect !== undefined &&
      (rect.width !== refSize.current?.width ||
        rect.height !== refSize.current?.height)
    ) {
      refSize.current = { width: rect.width, height: rect.height };
    }
    return refSize.current;
  }, [refElement, refSize]);
  return useSyncExternalStore(subscribe, getSnapshot);
}

■ page.module.css(クリックして開く)

.layout {
  display: flex;
  flex-direction: row;
  width: 100vw;
  height: 100vh;
}

.canvas-container {
  flex: 1;
  position: relative;
  overflow: hidden;
}

.image {
  max-width: 100%;
  height: auto;
  display: block;
}

.canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.cursor-all-scroll {
  cursor: all-scroll;
}
.cursor-col-resize {
  cursor: col-resize;
}
.cursor-row-resize {
  cursor: row-resize;
}
.cursor-ew-resize {
  cursor: ew-resize;
}
.cursor-ns-resize {
  cursor: ns-resize;
}
.cursor-nwse-resize {
  cursor: nwse-resize;
}
.cursor-nesw-resize {
  cursor: nesw-resize;
}
.cursor-pointer {
  cursor: pointer;
}

.rect-item-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 320px;
  padding: 8px;
}

.rect-item {
  display: flex;
  justify-content: space-between;
  background-color: #333;
  border-radius: 4px;
  padding: 4px 8px;

  &:hover {
    background-color: #555;
  }
  &.selected {
    background-color: #000080;
  }
}

.delete-button {
  background-color: #ff0000;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 4px 8px;
  cursor: pointer;

  &:hover {
    background-color: #cc0000;
  }
}

.add-button {
  background-color: #333;
  border-radius: 4px;
  border: none;
  text-align: center;
  cursor: pointer;

  &:hover {
    background-color: #555;
  }
}

■ model.ts(クリックして開く)

/**
 * 矩形を表すクラス。
 * 矩形の座標は整数に四捨五入されます。
 * 必ず left <= right, top <= bottom となるように座標を設定します。
 */
export class Rect {
  public readonly left: number;
  public readonly top: number;
  public readonly right: number;
  public readonly bottom: number;

  /**
   * 座標の整数化と、left <= right, top <= bottom の順序を保証します。
   */
  constructor(left: number, top: number, right: number, bottom: number) {
    if (left <= right) {
      this.left = Math.round(left);
      this.right = Math.round(right);
    } else {
      this.left = Math.round(right);
      this.right = Math.round(left);
    }
    if (top <= bottom) {
      this.top = Math.round(top);
      this.bottom = Math.round(bottom);
    } else {
      this.top = Math.round(bottom);
      this.bottom = Math.round(top);
    }
  }

  /** 矩形を変形します
   * @param delta - 変形量。left, top, right, bottom をそれぞれ指定した座標分だけ変更します。
   */
  transform(delta: { left?: number; top?: number; right?: number; bottom?: number }): Rect {
    return new Rect(
      this.left + (delta.left ?? 0),
      this.top + (delta.top ?? 0),
      this.right + (delta.right ?? 0),
      this.bottom + (delta.bottom ?? 0),
    );
  }

  /**
   * 他の矩形の範囲に収まるように座標を調整します。
   * @param other - 参照矩形
   */
  clampTo(other: Rect): Rect {
    const { left, top, right, bottom } = this;
    const { left: oLeft, top: oTop, right: oRight, bottom: oBottom } = other;
    return new Rect(
      Math.min(Math.max(left, oLeft), oRight),
      Math.min(Math.max(top, oTop), oBottom),
      Math.min(Math.max(right, oLeft), oRight),
      Math.min(Math.max(bottom, oTop), oBottom),
    );
  }

  /** 矩形の中心座標と幅・高さを返します。 */
  getXYWH() {
    const { left, top, right, bottom } = this;
    return {
      x: (left + right) / 2,
      y: (top + bottom) / 2,
      width: right - left,
      height: bottom - top,
    }
  }

  /**
   * 矩形が他の矩形と交差しているかどうかを判定します。
   * @param other - 参照矩形
   */
  isIntersect(other: Rect): boolean {
    const { left, top, right, bottom } = this;
    const { left: oLeft, top: oTop, right: oRight, bottom: oBottom } = other;
    return (
      left <= oRight &&
      right >= oLeft &&
      top <= oBottom &&
      bottom >= oTop
    );
  }

  toJSON() {
    return {
      left: this.left,
      top: this.top,
      right: this.right,
      bottom: this.bottom,
    };
  }
}

/**
 * 人物検出エリアを表すクラス。
 * キャンバスの幅と高さは、矩形の座標が収まる範囲を定義します。
 * 矩形の数には最大数があります。
 * 矩形の座標はキャンバスの範囲内に収まる必要があります。
 */
export class TargetArea {
  /** 最大矩形数 */
  static readonly MAX_NUMBER_OF_RECTS = 5;
  public readonly id: string;
  public readonly name: string;
  public readonly rects: readonly Rect[];
  public readonly canvasWidth: number;
  public readonly canvasHeight: number;

  /** 画像サイズが0より大きいこと、矩形の座標がキャンバスの範囲内に収まること、矩形数が最大数以下であることを保証します。 */
  constructor(id: string, name: string, rects: readonly Rect[], canvasWidth: number, canvasHeight: number) {
    this.id = id;
    this.name = name;
    this.rects = rects;
    this.canvasWidth = canvasWidth;
    this.canvasHeight = canvasHeight;
    if (canvasWidth <= 0 || canvasHeight <= 0) {
      throw new Error("Width and height must be greater than 0");
    }
    if (TargetArea.MAX_NUMBER_OF_RECTS < rects.length) {
      throw new Error(`Number of rectangles cannot exceed ${TargetArea.MAX_NUMBER_OF_RECTS}`);
    }
    for (const rect of rects) {
      if (rect.left < 0 || rect.top < 0 || canvasWidth <= rect.right || canvasHeight <= rect.bottom) {
        throw new Error("Rectangle coordinates must be within the canvas bounds" + JSON.stringify(rect));
      }
    }
  }

  /** 人物検出エリア設定が有効であることを確認します */
  isValid(): boolean {
    return !this.hasIntersectRectPair()
  }

  /** 矩形が交差しているかどうかを判定します。 */
  hasIntersectRectPair(): boolean {
    for (let i = 0; i < this.rects.length; i++) {
      for (let j = i + 1; j < this.rects.length; j++) {
        if (this.rects[i].isIntersect(this.rects[j])) {
          return true;
        }
      }
    }
    return false;
  }

  /** 矩形が最大数存在するかどうかを判定します */
  isFullRects(): boolean {
    return TargetArea.MAX_NUMBER_OF_RECTS <= this.rects.length;
  }

  /**
   * 更新されたTargetAreaを返します。
   * @param data - 更新データ
   */
  private update(data: { name?: string, rects?: readonly Rect[]; width?: number; height?: number }): TargetArea {
    return new TargetArea(
      this.id,
      data.name ?? this.name,
      data.rects ?? this.rects,
      data.width ?? this.canvasWidth,
      data.height ?? this.canvasHeight,
    );
  }

  /**
   * 新しい矩形を追加した新しいTargetAreaを返します。
   * 矩形の数が最大数に達している場合はエラーを投げます。
   * 矩形の座標はキャンバスの範囲内に収まるように調整されます。
   * @param left - 矩形の左座標
   * @param top - 矩形の上座標
   * @param right - 矩形の右座標
   * @param bottom - 矩形の下座標
   */
  addRect(left: number, top: number, right: number, bottom: number): TargetArea {
    if (this.isFullRects()) {
      throw new Error(`Cannot add more than ${TargetArea.MAX_NUMBER_OF_RECTS} rectangles`);
    }
    const newRect = new Rect(left, top, right, bottom)
      .clampTo(new Rect(0, 0, this.canvasWidth - 1, this.canvasHeight - 1));
    return this.update({
      rects: [...this.rects, newRect],
    });
  }

  /** 指定したインデックスの矩形を変形します。
   * @param index - 変形する矩形のインデックス
   * @param delta - 変形量
   */
  transform(index: number, delta: { left?: number; top?: number; right?: number; bottom?: number }): TargetArea {
    const rect = this.rects[index];
    if (!rect) {
      throw new Error(`No rect found at index ${index}`);
    }
    const newRects = [...this.rects];
    newRects[index] = rect.transform(delta).clampTo(new Rect(0, 0, this.canvasWidth - 1, this.canvasHeight - 1));
    return this.update({ rects: newRects });
  }

  /** 指定したインデックスの矩形を削除した新しいTargetAreaを返します。
   * @param index - 削除する矩形のインデックス
   */
  removeRect(index: number): TargetArea {
    if (index < 0 || this.rects.length <= index) {
      throw new Error(`Index ${index} is out of bounds for rects length ${this.rects.length}`);
    }
    const newRects = [...this.rects];
    newRects.splice(index, 1);
    return this.update({ rects: newRects });
  }

  toJSON() {
    return {
      id: this.id,
      name: this.name,
      rects: this.rects.map((rect) => rect.toJSON()),
      width: this.canvasWidth,
      height: this.canvasHeight,
    };
  }
}

■ util.ts(クリックして開く)

export function pipe<T>(value: T, ...fns: Array<(arg: T) => T>): T {
  return fns.reduce((acc, fn) => fn(acc), value);
}

■ model/rect.ts(クリックして開く)

/**
 * 矩形を表す型。
 * 矩形の座標は整数に四捨五入されます。
 * 必ず left <= right, top <= bottom となるように座標を設定します。
 */
export type Rect = {
  readonly left: number;
  readonly top: number;
  readonly right: number;
  readonly bottom: number;
}

/**
 * 新しい矩形を作成します。
 * 座標の整数化と、left <= right, top <= bottom の順序を保証します。
 */
export function newRect(left: number, top: number, right: number, bottom: number): Rect {
  if (right < left) {
    [left, right] = [right, left];
  }
  if (bottom < top) {
    [top, bottom] = [bottom, top];
  }
  left = Math.round(left);
  top = Math.round(top);
  right = Math.round(right);
  bottom = Math.round(bottom);
  return { left, top, right, bottom };
}

/** 矩形を変形します
 * @param rect - 変形対象の矩形
 * @param delta - 変形量。left, top, right, bottom をそれぞれ指定した座標分だけ変更します。
 */
export function transform(rect: Rect, delta: { left?: number; top?: number; right?: number; bottom?: number }): Rect {
  const { left, top, right, bottom } = rect;
  return newRect(
    left + (delta.left ?? 0),
    top + (delta.top ?? 0),
    right + (delta.right ?? 0),
    bottom + (delta.bottom ?? 0),
  );
}

/**
 * 矩形を他の矩形の範囲に収まるように調整します。
 * @param rect - 変形対象の矩形
 * @param other - 参照矩形
 */
export function clampTo(rect: Rect, other: Rect): Rect {
  const { left, top, right, bottom } = rect;
  const { left: oLeft, top: oTop, right: oRight, bottom: oBottom } = other;
  return newRect(
    Math.min(Math.max(left, oLeft), oRight),
    Math.min(Math.max(top, oTop), oBottom),
    Math.min(Math.max(right, oLeft), oRight),
    Math.min(Math.max(bottom, oTop), oBottom),
  );
}

/**
 * 矩形の中心座標と幅・高さを返します。
 * @param rect - 対象の矩形
 */
export function getXYWH(rect: Rect) {
  const { left, top, right, bottom } = rect;
  return {
    x: (left + right) / 2,
    y: (top + bottom) / 2,
    width: right - left,
    height: bottom - top,
  };
}

/**
 * 矩形が他の矩形と交差しているかどうかを判定します。
 * @param rect - 対象の矩形
 * @param other - 参照矩形
 */
export function isIntersect(rect: Rect, other: Rect): boolean {
  const { left, top, right, bottom } = rect;
  const { left: oLeft, top: oTop, right: oRight, bottom: oBottom } = other;
  return (
    left <= oRight &&
    right >= oLeft &&
    top <= oBottom &&
    bottom >= oTop
  );
}

■ model/targetArea.ts(クリックして開く)

import * as rectModel from './rect'
import { pipe } from '../util';

/** 最大矩形数 */
export const MAX_NUMBER_OF_RECTS = 5;

/**
 * 人物検出エリアを表すクラス。
 * キャンバスの幅と高さは、矩形の座標が収まる範囲を定義します。
 * 矩形の数には最大数があります。
 * 矩形の座標はキャンバスの範囲内に収まる必要があります。
 */
export type TargetArea = {
  readonly id: string;
  readonly name: string;
  readonly rects: readonly rectModel.Rect[];
  readonly canvasWidth: number;
  readonly canvasHeight: number;
}

/** 画像サイズが0より大きいこと、矩形の座標がキャンバスの範囲内に収まること、矩形数が最大数以下であることを保証します。 */
export function newTargetArea(
  id: string,
  name: string,
  rects: readonly rectModel.Rect[],
  canvasWidth: number,
  canvasHeight: number
): TargetArea {
  if (canvasWidth <= 0 || canvasHeight <= 0) {
    throw new Error('Canvas dimensions must be greater than zero');
  }
  if (rects.length > MAX_NUMBER_OF_RECTS) {
    throw new Error(`Maximum number of rectangles exceeded: ${MAX_NUMBER_OF_RECTS}`);
  }
  for (const rect of rects) {
    if (rect.left < 0 || rect.top < 0 || canvasWidth <= rect.right || canvasHeight <= rect.bottom) {
      throw new Error("Rectangle coordinates must be within the canvas bounds" + JSON.stringify(rect));
    }
  }
  return {
    id,
    name,
    rects,
    canvasWidth,
    canvasHeight
  };
}

/**
 * 人物検出エリア設定が有効であることを確認します
 * @param targetArea - 対象の人物検出エリア
 */
export function isValid(targetArea: TargetArea): boolean {
  return !hasIntersectRectPair(targetArea);
}

/**
 * 矩形が交差しているかどうかを判定します。
 * @param targetArea - 対象の人物検出エリア
 */
export function hasIntersectRectPair(targetArea: TargetArea): boolean {
  const { rects } = targetArea;
  for (let i = 0; i < rects.length; i++) {
    for (let j = i + 1; j < rects.length; j++) {
      if (rectModel.isIntersect(rects[i], rects[j])) {
        return true;
      }
    }
  }
  return false;
}

/**
 * 矩形が最大数存在するかどうかを判定します
 * @param targetArea - 対象の人物検出エリア
 */
export function isFullRects(targetArea: TargetArea): boolean {
  return MAX_NUMBER_OF_RECTS <= targetArea.rects.length;
}

/**
 * 人物検出エリアを更新します。
 * @param targetArea - 対象の人物検出エリア
 * @param data - 更新データ
 */
function update(targetArea: TargetArea, data: { name?: string, rects?: readonly rectModel.Rect[], canvasWidth?: number, canvasHeight?: number }) {
  return newTargetArea(
    targetArea.id,
    data.name ?? targetArea.name,
    data.rects ?? targetArea.rects,
    data.canvasWidth ?? targetArea.canvasWidth,
    data.canvasHeight ?? targetArea.canvasHeight
  )
}

/**
 * 新しい矩形を追加した新しいTargetAreaを返します。
 * 矩形の数が最大数に達している場合はエラーを投げます。
 * 矩形の座標はキャンバスの範囲内に収まるように調整されます。
 * @param targetArea - 対象の人物検出エリア
 * @param left - 矩形の左座標
 * @param top - 矩形の上座標
 * @param right - 矩形の右座標
 * @param bottom - 矩形の下座標
 */
export function addRect(targetArea: TargetArea, left: number, top: number, right: number, bottom: number): TargetArea {
  if (isFullRects(targetArea)) {
    throw new Error(`Cannot add more than ${MAX_NUMBER_OF_RECTS} rectangles`);
  }
  const newRect = pipe(
    rectModel.newRect(left, top, right, bottom),
    (rect) => rectModel.clampTo(rect, rectModel.newRect(0, 0, targetArea.canvasWidth - 1, targetArea.canvasHeight - 1))
  );
  return update(targetArea, {
    rects: [...targetArea.rects, newRect]
  });
}

/**
 * 指定したインデックスの矩形を変形します。
 * @param targetArea - 対象の人物検出エリア
 * @param index - 変形する矩形のインデックス
 * @param delta - 変形量
 */
export function transform(targetArea: TargetArea, index: number, delta: { left?: number; top?: number; right?: number; bottom?: number }): TargetArea {
  const rect = targetArea.rects[index];
  if (!rect) {
    throw new Error(`No rect found at index ${index}`);
  }
  const newRects = [...targetArea.rects];
  newRects[index] = pipe(
    rect,
    (rect) => rectModel.transform(rect, delta),
    (rect) => rectModel.clampTo(rect, rectModel.newRect(0, 0, targetArea.canvasWidth - 1, targetArea.canvasHeight - 1))
  )

  return update(targetArea, {
    rects: newRects
  });
}

/**
 * 指定したインデックスの矩形を削除します。
 * @param targetArea - 対象の人物検出エリア
 * @param index - 削除する矩形のインデックス
 */
export function removeRect(targetArea: TargetArea, index: number): TargetArea {
  const rect = targetArea.rects[index];
  if (!rect) {
    throw new Error(`No rect found at index ${index}`);
  }
  const newRects = [...targetArea.rects];
  newRects.splice(index, 1);
  return update(targetArea, {
    rects: newRects
  });
}

■ reducer/type.ts(クリックして開く)

export enum ActionType {
  /** 矩形追加 */
  ADD_RECT = 'ADD_RECT',
  /** 矩形削除 */
  REMOVE_RECT = 'REMOVE_RECT',
  /** 矩形選択 */
  SELECT_RECT = 'SELECT_RECT',
  /** 矩形選択解除 */
  UNSELECT_RECT = 'UNSELECT_RECT',
  /** 矩形移動開始 */
  START_MOVE_RECT = 'START_MOVE_RECT',
  /** 矩形移動終了 */
  END_MOVE_RECT = 'END_MOVE_RECT',
  /** 矩形移動。START_MOVE_RECTとEND_MOVE_RECTの間のみ効果があります。 */
  MOVE_RECT = 'MOVE_RECT',
}

export type Action =
  | { type: ActionType.ADD_RECT; payload: { left: number; top: number; right: number; bottom: number } }
  | { type: ActionType.REMOVE_RECT; payload: { index: number } }
  | { type: ActionType.SELECT_RECT; payload: { index: number } }
  | { type: ActionType.UNSELECT_RECT }
  | { type: ActionType.START_MOVE_RECT; payload: { point: { x: number; y: number }; displaySize: { width: number, height: number }; moveSensibility: MoveSensibility } }
  | { type: ActionType.END_MOVE_RECT }
  | { type: ActionType.MOVE_RECT; payload: { point: { x: number; y: number }; displaySize: { width: number, height: number }; } };

/**
 * 矩形の変形感度を定義する型
 * left, top, right, bottom のいずれかを true にすると、その方向の辺を移動できることを示します。
 * すべて false の場合は、変形できないことを示します。
 */
export type MoveSensibility = {
  readonly left?: true;
  readonly top?: true;
  readonly right?: true;
  readonly bottom?: true;
};

■ reducer/class.ts(クリックして開く)

import { TargetArea } from '../model'
import { Action, ActionType, MoveSensibility } from './type'

type State = {
  targetArea: TargetArea
  selectedRectIndex: number | null
  /** 矩形の変形開始時の状態。nullは変形中ではないことを表す。 */
  anchor: {
    point: {
      x: number
      y: number
    }
    moveSensibility: MoveSensibility
    targetArea: TargetArea
    selectedRectIndex: number
  } | null
}

export function reducer(state: State, action: Action): State {
  switch (action.type) {
    case ActionType.ADD_RECT:
      return {
        ...state,
        targetArea: state.targetArea.addRect(action.payload.left, action.payload.top, action.payload.right, action.payload.bottom),
      };
    case ActionType.REMOVE_RECT:
      if (state.selectedRectIndex === action.payload.index) {
        return {
          ...state,
          selectedRectIndex: null,
          targetArea: state.targetArea.removeRect(action.payload.index),
        };
      } else if (
        state.selectedRectIndex !== null && action.payload.index < state.selectedRectIndex
      ) {
        return {
          ...state,
          selectedRectIndex: state.selectedRectIndex - 1,
          targetArea: state.targetArea.removeRect(action.payload.index),
        };
      } else {
        return {
          ...state,
          targetArea: state.targetArea.removeRect(action.payload.index),
        };
      }
    case ActionType.SELECT_RECT:
      return {
        ...state,
        selectedRectIndex: action.payload.index,
      };
    case ActionType.UNSELECT_RECT:
      return {
        ...state,
        selectedRectIndex: null,
      };
    case ActionType.START_MOVE_RECT:
      if (state.selectedRectIndex === null) {
        return state;
      }
      const point = {
        x: action.payload.point.x * state.targetArea.canvasWidth / action.payload.displaySize.width,
        y: action.payload.point.y * state.targetArea.canvasHeight / action.payload.displaySize.height,
      }
      return {
        ...state,
        anchor: {
          point,
          moveSensibility: action.payload.moveSensibility,
          targetArea: state.targetArea,
          selectedRectIndex: state.selectedRectIndex,
        },
      };
    case ActionType.END_MOVE_RECT:
      return {
        ...state,
        anchor: null,
      };
    case ActionType.MOVE_RECT:
      if (!state.anchor) return state;
      const x = action.payload.point.x * state.targetArea.canvasWidth / action.payload.displaySize.width;
      const y = action.payload.point.y * state.targetArea.canvasHeight / action.payload.displaySize.height;
      const dx = x - state.anchor.point.x;
      const dy = y - state.anchor.point.y;
      const delta = {
        left: state.anchor.moveSensibility.left ? dx : 0,
        top: state.anchor.moveSensibility.top ? dy : 0,
        right: state.anchor.moveSensibility.right ? dx : 0,
        bottom: state.anchor.moveSensibility.bottom ? dy : 0,
      };
      return {
        ...state,
        targetArea: state.anchor.targetArea.transform(state.anchor.selectedRectIndex, delta),
      };
    default:
      const _: never = action; // eslint-disable-line @typescript-eslint/no-unused-vars
      return state;
  }
}

■ reducer/function.ts(クリックして開く)

import * as targetAreaModel from '../model/targetArea'
import { Action, ActionType, MoveSensibility } from './type'

type State = {
  targetArea: targetAreaModel.TargetArea
  selectedRectIndex: number | null
  /** 矩形の変形開始時の状態。nullは変形中ではないことを表す。 */
  anchor: {
    point: {
      x: number
      y: number
    }
    moveSensibility: MoveSensibility
    targetArea: targetAreaModel.TargetArea
    selectedRectIndex: number
  } | null
}

export function reducer(state: State, action: Action): State {
  switch (action.type) {
    case ActionType.ADD_RECT:
      return {
        ...state,
        targetArea: targetAreaModel.addRect(state.targetArea, action.payload.left, action.payload.top, action.payload.right, action.payload.bottom),
      };
    case ActionType.REMOVE_RECT:
      if (state.selectedRectIndex === action.payload.index) {
        return {
          ...state,
          selectedRectIndex: null,
          targetArea: targetAreaModel.removeRect(state.targetArea, action.payload.index),
        };
      } else if (
        state.selectedRectIndex !== null && action.payload.index < state.selectedRectIndex
      ) {
        return {
          ...state,
          selectedRectIndex: state.selectedRectIndex - 1,
          targetArea: targetAreaModel.removeRect(state.targetArea, action.payload.index),
        };
      } else {
        return {
          ...state,
          targetArea: targetAreaModel.removeRect(state.targetArea, action.payload.index),
        };
      }
    case ActionType.SELECT_RECT:
      return {
        ...state,
        selectedRectIndex: action.payload.index,
      };
    case ActionType.UNSELECT_RECT:
      return {
        ...state,
        selectedRectIndex: null,
      };
    case ActionType.START_MOVE_RECT:
      if (state.selectedRectIndex === null) {
        return state;
      }
      const point = {
        x: action.payload.point.x * state.targetArea.canvasWidth / action.payload.displaySize.width,
        y: action.payload.point.y * state.targetArea.canvasHeight / action.payload.displaySize.height,
      }
      return {
        ...state,
        anchor: {
          point,
          moveSensibility: action.payload.moveSensibility,
          targetArea: state.targetArea,
          selectedRectIndex: state.selectedRectIndex,
        },
      };
    case ActionType.END_MOVE_RECT:
      return {
        ...state,
        anchor: null,
      };
    case ActionType.MOVE_RECT:
      if (!state.anchor) return state;
      const x = action.payload.point.x * state.targetArea.canvasWidth / action.payload.displaySize.width;
      const y = action.payload.point.y * state.targetArea.canvasHeight / action.payload.displaySize.height;
      const dx = x - state.anchor.point.x;
      const dy = y - state.anchor.point.y;
      const delta = {
        left: state.anchor.moveSensibility.left ? dx : 0,
        top: state.anchor.moveSensibility.top ? dy : 0,
        right: state.anchor.moveSensibility.right ? dx : 0,
        bottom: state.anchor.moveSensibility.bottom ? dy : 0,
      };
      return {
        ...state,
        targetArea: targetAreaModel.transform(state.anchor.targetArea, state.anchor.selectedRectIndex, delta),
      };
    default:
      const _: never = action; // eslint-disable-line @typescript-eslint/no-unused-vars
      return state;
  }
}