はじめに

こんにちは、Acsim 開発チームの寺嶋です。

AI 要件定義サービス Acsim には、業務フローを作成・編集するエディタがあります。画面は担当者(アクター)ごとに横方向のレーンで区切られていて、いわゆるスイムレーン図の形で業務の流れを表現します。

この記事では「レーン」「アクターノード」「ノード」「累積高さ」といった用語が繰り返し出てくるので、実際のエディタ画面で対応関係を先に示しておきます。

業務フローエディタのスイムレーン図に用語の対応を示した図。顧客・店舗・倉庫システムの 3 つのレーンがあり、レーン(アクターレーン)は React Flow 上のアクターノード 1 個、レーン内の注文・確認などがノード、上にあるレーンの高さの合計が累積高さで下のレーンの Y 座標になる

ポイントは、レーン自体も React Flow の Node(アクターノード)として実装されていること、そして各レーンの Y 座標が「自分より上にあるレーンの高さの合計(累積高さ)」で決まることです。この 2 点が、以降の話の前提になります。

今回、このレーンの高さをドラッグで変更できる機能を実装しました。この記事では、その実装で悩んだところや、レビューで見つかった問題についてまとめます。

一見すると「レーンの高さを可変にするだけ」の軽い UI 変更に見えるのですが、着手してみるとレーンの高さは業務フロー全体の座標計算の前提になっていました。高さが動いた瞬間、下のレーンの座標もノードの所属判定も Undo/Redo もまとめて影響を受けます。結果としてわりと大掛かりな作業になりました。

固定高さで困っていたこと

変更前、アクターレーンの高さは 368px 固定でした。

export const DEFAULT_ACTOR_NODE_HEIGHT = 368;

これにより、以下のような課題がありました。

  • 条件分岐が多いフローでは高さが足りず、ノードをレーン内にきれいに収められない
  • 逆にノードが少ないレーンでは余白が大きく、一画面に表示できる情報量が減る

これを解決するために、レーンごとの高さを調整する機能を実装することになりました。

影響範囲の洗い出し

着手前に影響範囲を整理したのが次の図です。

影響範囲が広いため、Epic Issue を起点に Sub Issue へ分割し、フィーチャーブランチ上で「基盤 → UI → 位置追従 → 並び替え → 削除 → その他制約」の順に PR を重ねていく形で実装することにしました。後述する「大変だった点」で触れる問題をその場でスコープを膨らませず別 Issue に切り出せたのは、この分割のおかげでした。

以下、実装で悩んだところを順に書いていきます。

高さをどこに持たせるか

当初の計画: data に持たせる

基盤整備の段階では、アクターノードのアプリケーション固有データに高さを持たせるつもりでした。

// 当初の計画
type ActorNodeData = {
  index: number;
  name: string;
  type: "human" | "system";
  height?: number; // ← 追加する予定だった
};

height をオプショナルにしておけば、未設定の既存データは従来値の 368px として扱えます。データ移行なしで後方互換性を保てるので、この時点ではこれで良さそうだと思っていました。

実装してみて気づいたこと

続く UI 実装では、一度自前で作ったリサイズハンドルを、React Flow が標準で用意している NodeResizeControl に置き換えました。ドラッグ操作の UI とサイズ変更イベントはフレームワークに任せて、プロダクト固有の処理(下にあるノードの追従)だけを自分で書くという分担にしました。

ここで気づいたのですが、React Flow の Node はもともとトップレベルに height を持っています。NodeResizeControl が更新するのもこの height です。つまり data.height を追加すると、同じ情報を 2 箇所で持つことになってしまいます。

// React Flow の Node が最初から持っている
node.height        // ← NodeResizeControl が更新するのはこちら
node.data.height   // ← 自前で追加しようとしていた

同期を取る処理を書けば動きはしますが、ずれたときに原因を追いづらくなるのが目に見えているので、data.height は追加せず、React Flow のノード高さをそのまま使うことにしました。

export const ActorNodeResizeControl: FC<Props> = ({ actorId }) => {
  const { onResizeStart, onResize, onResizeEnd, dynamicMinHeight, isResizing } = useActorNodeResize(actorId);

  return (
    <NodeResizeControl
      minHeight={dynamicMinHeight}
      maxHeight={MAX_ACTOR_NODE_HEIGHT}
      minWidth={ACTOR_NODE_WIDTH}
      maxWidth={ACTOR_NODE_WIDTH}
      position="bottom"
      onResizeStart={onResizeStart}
      onResize={onResize}
      onResizeEnd={onResizeEnd}
      // ...
    />
  );
};

高さだけ変えたいので、minWidthmaxWidth には同じ ACTOR_NODE_WIDTH を指定して幅を固定しています。

計画を立てた時点では気づけず、手を動かして初めて分かる類の話でした。実装計画のドキュメントもこのタイミングで書き換えています。

なお、この判断にはトレードオフもありました。リリース後、高さを変更した業務フローを JSON にエクスポートして再インポートすると、高さが失われる不具合が見つかりました。業務フローの zod スキーマに React Flow 側の height が含まれておらず、エクスポートの段階で落ちていたためです。

暫定的にはストア側で measured.heightheight に同期する処理を入れて対応しました。スキーマへの追加は型の都合で単純には進められず、別 Issue として残しています。data.height を持たない選択で情報の重複は避けられましたが、ドメインのスキーマの外側に永続化すべき値が置かれることになり、そのぶんの整合性はエクスポート/インポートの境界で担保する必要が出てきました。

Y 座標を累積高さで計算する

固定高さが前提だったので、アクターレーンの Y 座標は次の一行で求まっていました。

レーンのY座標 = index × 368

高さが可変になるとこの式は使えません。代わりに「自分より前にあるレーンの高さの合計」を使います。

/**
 * 指定インデックスのアクターの高さを取得する
 * アクターが存在しない場合はデフォルト高さを返す
 */
export const getActorHeight = (nodes: TBusinessFlowNode[], actorIndex: number): number => {
  const actorNode = nodes.find((node): node is TActorNode => isActorNode(node) && node.data.index === actorIndex);
  return actorNode?.height ?? DEFAULT_ACTOR_NODE_HEIGHT;
};

/**
 * 指定インデックスより前のアクターの高さの合計を計算する(累積高さ)
 * アクターのY座標計算にも使用される
 */
export const calculateAccumulatedHeight = (nodes: TBusinessFlowNode[], targetIndex: number): number => {
  let accumulatedHeight = 0;
  for (let i = 0; i < targetIndex; i++) {
    accumulatedHeight += getActorHeight(nodes, i);
  }
  return accumulatedHeight;
};

ここで効いているのが getActorHeight?? DEFAULT_ACTOR_NODE_HEIGHT です。高さが未設定のレーンは 368px として計算されるので、この機能より前に作られた業務フローは、データ移行なしでこれまでどおりの見た目になります。可変高さへの移行でいちばん静かに壊れそうなのが既存データなので、累積高さの計算ではこのフォールバックを getActorHeight に集約し、呼び出し側では未設定を意識しなくて済むようにしています。

並び替えでは差分だけ動かす

レーンの並び替えは、アクター設定画面から行えます。高さが一律だった頃はレーンが 1 つ入れ替わればノードの移動量も一律でしたが、可変高さではレーンごとに移動量が変わります。

A(400px), B(300px), C(500px) の並びで C を先頭に移動した場合はこうなります。

アクター変更前の開始位置変更後の開始位置移動量
C700px0px-700px
A0px500px+500px
B400px900px+500px

移動量は、並び替え前後の累積高さの差で求まります。コード中の calculateOldAccumulatedHeight / calculateNewAccumulatedHeight は、並び替え前後それぞれの高さの並びから累積高さを求めるヘルパーです。

.with({ type: "replace" }, ({ newIndex, oldIndex }): NodePositionChange => {
  // 累積高さベースで移動量を計算
  // oldIndex/newIndex より前にあるアクターの高さ合計を比較し、レーン入れ替えによる上下差分を求める
  const oldAccumulatedHeight = calculateOldAccumulatedHeight(actorIndexChanges, oldIndex);
  const newAccumulatedHeight = calculateNewAccumulatedHeight(actorIndexChanges, newIndex);
  const moveDistance = newAccumulatedHeight - oldAccumulatedHeight;

  return {
    type: "position",
    id: node.id,
    position: { x: node.position.x, y: node.position.y + moveDistance },
  };
})

ここで意識したのは、レーン内のノードを再レイアウトしないことです。移動後のレーンに合わせてノードを並べ直せばきれいになるのですが、ユーザーがレーン内で調整した相対的な配置が失われてしまいます。レーンの開始位置の差分だけを全ノードに一律で足す方式なら、レーン内の見た目はそのまま保てます。

なお、並び替え後の座標を計算するには「並び替え後の高さの並び」が必要になります。まだ存在しない配置なので、変更後の順序を表す仮想的なアクターノード配列を組み立てて、それを calculateAccumulatedHeight に渡しています。newActorsDatadata.index には並び替え後の index が入っているのでその順に並べ、高さは getActorHeightFromOldNodes で並び替え前のノードから引き継いでいます。

// 新しいアクター配置を表す仮想ノード配列を作成(Y座標計算用)
// newIndexの順にソートし、累積高さを計算するために使用
const virtualNewActorNodes: TBusinessFlowNode[] = sortedNewActorsData.map((actor) => ({
  id: actor.id,
  type: BUSINESS_FLOW_NODE_TYPE.ACTOR,
  data: actor.data,
  position: { x: 0, y: 0 },
  height: getActorHeightFromOldNodes(actor.id),
}));

ドラッグ中のノードの追従

高さを変えている最中、下のレーンとノードはリアルタイムで追従する必要があります。素直に思いつくのは「onResize が来るたびに現在位置へ今回の増分を足す」方式ですが、これはやめました。

onResize は pointer move のたびに大量に発火するので、イベント回数に依存した誤差が積み上がる構造になります。丸めや、高さを最小値・最大値の範囲に収める処理(クランプ)が 1 回でも入ると、ドラッグを止めた位置と実際の座標がずれてしまいます。

代わりに、ドラッグ開始時点をすべての計算の基準にしました。

/**
 * リサイズ開始時の処理
 * 現在の高さと影響を受けるノードの初期位置を保存する
 */
const onResizeStart = useCallback(() => {
  const currentNodes = getNodes();
  const currentActor = currentNodes.find((node) => node.id === actorId);
  if (!currentActor || !isActorNode(currentActor)) return;

  setIsResizing(true);

  const height = currentActor.height ?? DEFAULT_ACTOR_NODE_HEIGHT;
  const resizedActorIndex = currentActor.data.index;
  const resizedActorBottom = currentActor.position.y + height;

  initialHeightRef.current = height;
  minHeightAtStartRef.current = dynamicMinHeight;

  const nodePositions = new Map<string, XYPosition>();
  for (const node of currentNodes) {
    if (shouldNodeBeAffected(node, actorId, resizedActorIndex, resizedActorBottom)) {
      nodePositions.set(node.id, { ...node.position });
    }
  }
  initialNodePositionsRef.current = nodePositions;
}, [actorId, dynamicMinHeight, getNodes]);

ドラッグ中は、保存した初期位置に高さの差分を足すだけです。

const onResize: OnResize = useCallback(
  (_event, params) => {
    const initialHeight = initialHeightRef.current;
    if (initialHeight === null) return;

    const clampedHeight = clampHeight(params.height, minHeightAtStartRef.current);
    const heightDelta = clampedHeight - initialHeight;
    if (heightDelta === 0) return;

    const positionChanges: NodePositionChange[] = [];
    for (const [nodeId, initialPosition] of initialNodePositionsRef.current) {
      positionChanges.push({
        type: "position",
        id: nodeId,
        position: { x: initialPosition.x, y: initialPosition.y + heightDelta },
        dragging: true,
      });
    }

    if (positionChanges.length > 0) {
      onNodesChange(positionChanges);
    }
  },
  [onNodesChange]
);

onResize が何度呼ばれても、常に「開始時の状態 + 現在の差分」を計算します。これでイベントの粒度や回数に結果が依存しなくなりました。

動かすノードを絞る

すべてのノードを機械的に動かすと余計な副作用が出るので、移動対象の判定は別の関数に切り出しました。

/**
 * ノードがリサイズの影響を受けて移動すべきかどうかを判定する
 *
 * - 親ノードを持つノードは親の移動に追従するためスキップ
 * - リサイズ対象のアクター自身はスキップ
 */
const shouldNodeBeAffected = (
  node: TBusinessFlowNode,
  resizedActorId: string,
  resizedActorIndex: number,
  resizedActorBottom: number
): boolean => {
  if (node.id === resizedActorId || node.parentId) return false;

  // リサイズされたアクターより下にあるアクター
  if (isActorNode(node) && node.data.index > resizedActorIndex) return true;

  // リサイズされたアクターの下端より下にあるノード
  return node.position.y >= resizedActorBottom;
};

node.parentId を持つノードを除外しているところが地味に重要です。React Flow では子ノードの座標は親からの相対位置なので、親を動かせば子も動きます。ここで子も移動対象に含めると移動量が 2 倍になってしまい、実際この判定を入れる前は入れ子構造のノードだけが不自然に飛んでいく挙動になりました。

あとノードの取得には、React の state ではなく useReactFlow().getNodes() を使っています。ドラッグ中は「今この瞬間の座標」が必要なのですが、state 経由だと再レンダリングを挟んだ値になってしまうためです。

Undo/Redo について

当初は「高さ変更を履歴管理に対応させる」Sub Issue を切っていました。素朴に実装すると、ドラッグ中に発生した数十〜数百回の中間状態がすべて履歴に積まれて、元に戻すのに Undo を連打することになります。

ただ位置追従の実装を終えた時点で確認してみると、既存のストアがこのケースをすでに正しく扱えていました。

具体的には、ノードのドラッグ移動のために作られていた「dragging: true で開始状態をスナップショットして、dragging: false で 1 件の履歴に確定する」仕組みが resizing フラグにも対応済みでした。

つまりリサイズ処理が既存の onNodesChange を通って、中間状態に dragging: true、終了時に dragging: false を付けていれば、Undo/Redo は自動的に 1 操作として扱われます。リサイズ専用のローカル state を作らず、既存の変更経路に乗せる形にしています。

この Sub Issue は、実際に Undo/Redo の挙動を確認したうえで専用実装は不要と判断して、コードを追加せずにクローズしました。既存の仕組みが何をトリガーに動いているかを先に読んでいれば、計画の段階で気づけた気もします……🤔

大変だった点

ここからは、レビューや動作確認で見つかった問題について書いていきます。自分では気づけなかったものもあれば、認識はしていたが後回しにしていたものもあります。

tailwind-merge が破線を消していた

表示ロジックを ActorNodePresenter に共通化したところ、レビュアーからステージング環境との比較スクリーンショット付きで「ActorNode が複数ある場合の黒い破線が消えている」と指摘をもらいました。

レーン間の区切り線は、Tailwind CSS で片側だけ破線にできない都合から疑似要素で描いています。

const dashedBorderStyle = [
  // border-b-dashed といった一方向のみのスタイル変更はできないので疑似要素で破線を設定する
  "border-b-0",
  "after:absolute after:inset-0 after:border-b-md after:border-b-toolbar-default after:border-dashed after:content-['']",
];

このスタイル定義は tailwind-variants で組み立てていて、意図的に twMerge: false を指定しています。クラス名の競合解決が働くと、上のような疑似要素と実要素をまたいだ指定が壊れてしまうためです。

const actorNode = tv(
  { slots: { /* ... */ }, variants: { /* ... */ } },
  {
    twMerge: false,
  }
);

ところが、コンポーネント側で結果をさらに cn()(内部で tailwind-merge を使うヘルパー)に通していました。せっかく無効化した競合解決が、呼び出し側で復活していたわけです……。修正は次の 1 行でした。

- <div id={id} className={cn(container())} style={{ width: ACTOR_NODE_WIDTH, height: nodeHeight }}>
+ <div id={id} className={container()} style={{ width: ACTOR_NODE_WIDTH, height: nodeHeight }}>

厄介だったのが、単一レーンでは再現しないところです。破線はレーンが複数並ぶバリアントにのみ適用されるので、Storybook の単体表示や少数レーンの確認では気づけませんでした。ロジックの分割・共通化でも、見た目の確認は必要ですね。

レーンを縮めるとノードの所属アクターが変わる

並び替えの PR で、レビュアーから動画付きで報告をもらいました。

レーンの高さをノードの位置より小さくすると、ノードに紐づくアクターも変わってしまう

原因はノードがどのレーンに属するかの判定がノードの中心点で行われていたことでした。レーンを縮めると相対的にノードの中心が下のレーンの領域に入ってしまい、actorId まで書き換わります。ユーザーから見ると「レーンを縮めたら担当者が勝手に変わった」という挙動ですね。これは困ります。

ノードの位置より小さくできないようにする制限自体は、他のツールを調べていたときに「あったほうがよさそう」と思いつつ、工数の都合で見送っていたものでした。ただ、実際に触った人から報告をもらうと、見送りにできる話ではないと分かります。この PR のスコープは並び替えに限定していたので、その場では直さず別 Issue に切り出して、後続の PR で「動的な最小高さ」として実装しました。

必要な最小高さ = max(基本最小値 200px, レーン内で最も下にあるノードの下端)
export const calculateMinHeightForActor = (nodes: TBusinessFlowNode[], actorId: string): number => {
  const actorNode = nodes.find((node): node is TActorNode => isActorNode(node) && node.id === actorId);
  if (!actorNode) return MIN_ACTOR_NODE_HEIGHT;

  const actorY = actorNode.position.y;
  const actorHeight = actorNode.height ?? DEFAULT_ACTOR_NODE_HEIGHT;

  // アクターに属するFlowContentNodeを取得し、各ノードの「相対Y座標 + 高さ」の最大値を計算
  let maxNodeBottom = 0;
  for (const node of nodes) {
    if (!isFlowContentNode(node)) continue;
    if (node.data.actorId !== actorId) continue;

    const nodeHeight = node.measured?.height ?? 0;
    const relativeY = node.position.y - actorY;
    const nodeBottom = relativeY + nodeHeight;

    // ノードがアクターの下端をはみ出している場合、現在のアクター高さを最小値とする
    // (ノードの中央がアクター内にあるため属しているが、下部がはみ出している状態)
    const effectiveNodeBottom = nodeBottom > actorHeight ? actorHeight : nodeBottom;

    if (effectiveNodeBottom > maxNodeBottom) {
      maxNodeBottom = effectiveNodeBottom;
    }
  }

  return Math.max(MIN_ACTOR_NODE_HEIGHT, maxNodeBottom);
};

計算式そのものは単純なのですが、実装してみて初めて effectiveNodeBottom の分岐が必要だと分かりました。所属判定が中心点ベースである以上、「所属は対象レーンのままだけど、下端はすでにレーンからはみ出している」という状態が成立してしまいます。

このとき素直に最小高さ=ノード下端としてしまうと、すでにはみ出しているノードのぶんだけレーンが強制的に広がってしまいます。かといって制限しないと、はみ出しをさらに悪化させられてしまう。そこで、はみ出しているノードについては「現在のレーン高さ」を下限として扱い、状況を改善はしないけれど悪化もさせない、というところに落としました。既存データはどんな状態でもありうるので、この手の「理想的でない状態から始まるケース」は潰しておきたいところです。

削除処理の修正

レーン削除時には、そのレーンに紐づいていたノードに対して 2 つのことを同時に行う必要があります。

  • actorIdsubSectionIdnull にして所属を解除する
  • 他のレーンと重ならないよう、レーン群の下へ移動する

調べてみると、従来の実装は同じノードに対して NodeReplaceChange(データ更新)と NodePositionChange(位置更新)を別々に返していました。React Flow の applyNodeChanges は変更をノード id ごとにまとめて処理するのですが、replace が含まれていると、同じ id に対する他の変更は順序にかかわらず捨てられ、replace の内容だけが採用されます。そのため位置更新のほうは反映されていませんでした。

そしてこれは、高さ変更機能に着手する前から動作していない状態でした。レーン削除の利用頻度がそこまで高くなく、問題として表面化していなかったのかもしれません。

修正は、位置も含めた単一の NodeReplaceChange に統合することでした。

// actorの削除が行われたら、紐づいていたNodeのactorIdとsubSectionIdの情報をnullにセットする
// 移動が必要な場合は、新しい位置も含めてNodeReplaceChangeで一括更新する
const newPosition =
  moveDistance > 0
    ? {
        x: node.position.x,
        // 他のActorと被らないようにNodeを移動させる
        y: node.position.y + moveDistance,
      }
    : undefined;

return [
  buildFlowContentNodeReplaceChange({
    node,
    actorId: null,
    sectionId: node.data.sectionId,
    subSectionId: null,
    position: newPosition,
  }),
];

可変高さ対応のためにこの処理を読み込んだ結果、以前からの不具合まで一緒に直せました。

上限値の根拠

最終統合の PR で、レビュアーから次の質問をもらいました。

アクターの高さの上限 1,000 を定めている理由を教えてください。上限値の根拠がシステム都合なのかどうかが気になりました

システム的な制約はなく、実際にはもっと大きくもできます。回答としては以下のように整理しました。

  • ノードを上下方向に複数配置できる高さは確保したい
  • 一方で、一度公開した上限をあとから小さくするのは避けたい

たとえば上限を 5,000px にしてから「大きすぎたので 2,000px にしよう」と変更したとします。すでに 2,000px を超える高さで保存されている業務フローは、UI の操作上つじつまの合わない状態になってしまいます。上限は後から広げるのは簡単でも、狭めるのは難しい。なので最初は控えめに置きました。

テストまわり

細かいところでは、テストについても 2 点ありました。

1 つは、自分で見つけたテストの Flaky 化です。最小高さの計算はノードの measured?.height を参照するのですが、テストデータで measured を指定していないケースがあり、条件次第で結果が変わる状態になっていました。テストデータ側で明示的に指定して解消しています。

もう 1 つはレビューで指摘をもらった、テストコードの肥大化です。削除時の位置更新はケースの組み合わせが多く、テストファイルの行数がかなり膨れ上がっていました。レビューで既存のノード用ファクトリ関数の存在を教えてもらい、それを使って書き直したところ約 500 行削減できました。プロダクトコードの共通化には気を配っていても、テストコードの重複には鈍感になりがちですね……。

編集用・閲覧用のコンポーネント

最後に、コンポーネント構成についても少し触れておきます。

リサイズ機能は編集画面にのみ必要で、閲覧画面には出てはいけません。そこで表示部分を ActorNodePresenter に集約して、resizable フラグで切り替える構成にしました。

// 編集画面: リサイズコントロールを表示する
export const ActorNode: FC<Props> = (props) => {
  return <ActorNodePresenter {...props} resizable />;
};

// 閲覧モード: resizable を渡さないのでリサイズできない
export const ReadonlyActorNode: FC<Props> = (props) => {
  return <ActorNodePresenter {...props} />;
};

// 静的表示専用: Showcase 閲覧画面で使用
export const StaticActorNode: FC<Props> = (props) => {
  return <ActorNodePresenter {...props} />;
};

ReadonlyActorNodeStaticActorNode は、見てのとおりこの時点では中身が同じです。内容が同じになる可能性は Issue の段階で分かっていましたが、画面ごとの責務を分けて構造の見通しを立てたかったので、別のコンポーネントとして用意する方針にしていました。その後、閲覧モード側にだけ差分プレビュー用のバッジ表示が足されて、2 つは実際に別物になっています。

StaticActorNode は当初の要求にはありませんでした。開発中に静的な閲覧画面が別途追加されていて、最終レビューでその対応が必要だと分かって、その場で足しています。

まとめ

「スイムレーンの高さを可変にする」という一文の要求に対して、実際に必要だったのは座標計算の置き換えでした。この記事で紹介した内容は以下のとおりです。

  • 高さの置き場所 — 自前の data.height を追加する計画から、React Flow のノードが元から持つ height を使う構成へ、実装中に切り替えた
  • Y 座標の計算 — index × 368 を「自分より前のレーンの高さの合計」に置き換え、未設定値は 368px にフォールバックさせて既存データを移行なしで維持した
  • ドラッグ中の計算基準 — 直前の位置に増分を足すのをやめ、常に「開始時の位置 + 現在の差分」で計算してイベント回数に依存しない構造にした

振り返ってみると、この実装はレビューにかなり助けられました。破線が消える回帰は自分では気づけず、他の人が実際に触ったからこそ出てきた指摘です。レーンを縮めるとノードの所属が変わる問題は、見送っていた仕様を実装すべきだと判断し直すきっかけになりました。上限値の根拠は、質問されて初めて言葉にしました。機能を段階的な PR に分割していたので、指摘をもらっても「今の PR で直す」「別 Issue に切り出す」をその場で選び分けられました。

レーンの高さを可変にしたことで、業務フローがとても扱いやすくなりました。目立たない機能ですが、効果は大きかったです。この記事の内容が参考になりましたら幸いです👍