キャンバスUIの実装知見(ポインタ・ズーム/パン・直接DOM更新・自動配置)
人間と自動配置が共存するキャンバスでは、自動配置は新規要素の座標だけを調整し、既存要素は一切動かさない
10日前
マインドマップ・ホワイトボード等、人間が自由に要素を配置できる一方でシステム(エージェント等)も自動でノードを追加するキャンバスでは、複数ブランチへの一括追加時にノード同士が重なる問題が起きやすい。単純な「親の周りに扇状配置」だけでは、他のブランチのノードと衝突することがある。
適用条件: 自動配置と人間の手動配置が同じキャンバス上で共存し、既存要素の位置を勝手に変えてはいけない制約がある場合。全体を毎回グラフレイアウトし直す(force-directed等)方式は人間の配置意図を壊すため不採用にすべきケース。
対処方法: 衝突回避のアルゴリズムを「新規追加される要素の座標だけを、既存の全要素(他ブランチ含む)の矩形と衝突しなくなるまでずらす」ことに限定する。既存要素(システム配置・人間配置いずれも)の位置は一切変更しない。矩形はテキスト長から概算できるが、本文だけを占有領域の正本にしてはいけない。アイコン、履歴・経緯footer、状態ラベル、バッジなど、ノード状態によって追加される表示要素も含めて矩形を推定する。サーバーが座標を決め、ブラウザが描画する構成では、この非本文部分が境界間契約になる。実ブラウザで状態ごとのbounding boxを測り、本文推定との差分を安全側に丸めてレイアウト定数へ反映する。人間が要素をドラッグしたら、その要素に「固定済み」フラグを立てておくと、将来もし既存要素も動かす再配置ロジックを足すことになっても、人間の配置を保護する境界がすでに用意されている。
確認方法: 複数ブランチ×複数子要素(例: 3ブランチ×各3子=計12件程度)を一括追加する再現手順を用意し、追加後の全要素ペアについて推定矩形が重ならないことを自動テストで検証する。矩形推定式はテスト側で実装から独立して書き直し、実装のリファクタでなく実際の退行を検知できるようにする。さらに、追加表示を持つ要素を「配置される候補」と「既存の障害物」の両方に置き、全体整形と個別移動の双方を検体にする。通常状態だけでなく、footerや状態行を持つ代表状態を含める。最後に実ブラウザのbounding boxとスクリーンショットで、推定値が実描画を下回らず、意図した間隔が残ることを確認する。
教訓: 「複数要素の自動配置」は「グラフ全体を最適レイアウトし直す」ことだと考えがちだが、人間の配置意図を守る必要がある共同編集キャンバスでは、スコープを「新規追加分だけ」に絞ることで、アルゴリズムが単純になり、人間の操作を壊すリスクも構造的に無くなる。
ドラッグ中にsetStateを避けて直接DOM更新する要素は、他要素との接続線も同じ手法で同期させる必要がある
10日前
Reactでキャンバス型UI(マインドマップ・フローチャート等)を実装する際、パフォーマンスのためドラッグ中はpointermoveごとのsetStateを避け、対象要素のel.style.left/topを直接書き換える手法はよく使われる。この手法自体は正しいが、その要素と接続している他の視覚要素(SVGのエッジ・コネクタ・矢印など、Reactのpropsから座標を再計算して描画されるもの)を見落としやすい。ドラッグ中のノード自体は滑らかに動くのに、接続線だけが更新前の位置のまま取り残される、という部分的な desync が起きる。
適用条件: 1つの要素が「setStateを介さない直接DOM更新」で高頻度に動く一方、その要素の座標に依存する別の描画要素(エッジ・接続線・追従ラベル等)がReactの通常のprops経由レンダリングのままになっている場合。
対処方法: 依存先の描画要素も同じ「直接DOM更新」の経路に乗せる。具体的には、(1) 描画に使うパス計算式・アンカー座標計算をReactレンダリングと直接DOM更新の両方から呼べる純粋関数として共通化する、(2) 対象のSVG要素等にidや data属性を振っておき、ドラッグハンドラからquerySelectorで直接取得して属性(例: パスのd属性)を書き換える、(3) 更新はpointermoveごとに同期実行せず、requestAnimationFrameで1フレーム1回に間引く。間引きの際は「予約時点の値」ではなく「発火時点の最新値」を使う(pendingな値を保持するrefと、rAF予約済みフラグを分離し、複数回のpointermoveが来ても最後の値だけが反映されるようにする)。ドラッグ終了時(pointerup)に、確定値をstateへ反映する一度だけの再描画を行う。
確認方法: ドラッグをゆっくり複数ステップに分けて実行し、各ステップでスクリーンショットを撮って、ドラッグ対象の要素だけでなく依存先の描画要素(線・接続点など)も追従して更新されていることを目視確認する。
教訓: 「パフォーマンスのためにsetStateを避けて直接DOM操作する」という最適化は、その要素単体では正しくても、依存グラフ全体で見ると「一部だけ最適化して他が取り残される」新しい不整合を生みやすい。ある要素をReactの再レンダー経路から外すときは、それに依存する描画要素も同じ経路に揃えるところまでが最適化の範囲。
トラックパッドのピンチズームと二本指スクロールはwheelイベントのctrlKeyで区別する、CDPで実機なし検証もできる
10日前
ブラウザのトラックパッドは、ピンチズームジェスチャー(Ctrl+ホイールも同様)をWheelEventとして合成し、その際ctrlKey: trueを自動付与するという慣例がある。Miro等のキャンバスUIではpinch=ズーム、二本指スクロール=パンという操作体系をこのctrlKeyフラグだけで実装できる。
適用条件: キャンバス型UI(マインドマップ・ホワイトボード・図面エディタ等)で、トラックパッドの自然なパン/ズーム操作感を実装したい場合。
対処方法: onWheelハンドラでe.ctrlKeyを判定し、trueならカーソル位置を中心にしたズーム、falseならパンとして処理を分岐する。ピンチは連続した小さなdeltaYのストリームとして届くため、固定倍率の段階ズーム(例: 常時x1.08)ではなくMath.exp(-deltaY * k)のようなdeltaY比例の連続ズームにすると滑らかになる。二本指スクロールはdeltaX/deltaYをそのままビューポートの平行移動量として使う。
確認方法: 実機トラックパッドがなくても、PlaywrightのChrome DevTools Protocolセッション(page.context().newCDPSession(page))からInput.dispatchMouseEventをtype: 'mouseWheel'で呼び、deltaX/deltaYとmodifiers(Ctrlは2)を指定すると、ctrlKey付き/なしの両パターンをプログラマティックに再現できる。ズーム式がMath.exp(-deltaY*k)なら、送信したdeltaYから期待値を逆算して実際のscaleと比較すれば、式の実装正確性を数値で検証できる(ブラウザUI目視に頼らない)。
教訓: 実トラックパッドを持たない開発環境でも、CDPのInput.dispatchMouseEventはマウスホイール・修飾キー付きイベントを実デバイスイベントとして注入できるため、「実機の確認は他者に任せるが、ロジック自体の正しさは自分で数値検証しておきたい」というケースで有効。
position:fixedを将来の変更に対しても確実にビューポート固定にしたいならReactportalでdocument.body直下へ逃がす
10日前
CSSのposition: fixed要素は、その祖先のいずれかがtransform・filter・backdrop-filter・will-change: transformのいずれかを持つと、その祖先が新たな containing block になり、ビューポートではなくその祖先基準で位置やサイズが決まるようになる(CSS仕様上の振る舞い)。パン/ズームで使う側のキャンバスなどでこの transform が後から付与されると、それまでviewportに完全固定されていたはずのボタン群が、その transform につられて一緒に動くように見えるバグが発生する。
適用条件: position: fixedで常に画面固定していてほしいUI(ズームボタン・FAB・固定ヘッダー等)を、将来変更されうるJSXツリーのどこか(たとえ今は直接のtransform祖先がなくても)に置く場合。現在のツリー構造を目視確認して問題なさそうでも、リファクタで上位に transform が入ると容易に壊れるので、構造目視確認だけでは不十分。
対処方法: ReactならcreatePortal(<FixedUi/>, document.body)でdocument.body直下へレンダリングする。document.body自体にtransformが付くことはまずないため、「JSX上の親子関係が何であれ、DOM上は常にbody直下」という保証が構造的に成立し、将来のリファクタでも崩れなくなる。
確認方法: ブラウザのelement.parentElement.tagNameを確認してBODYになっていることでportal化を実証できる。加えて、対象のパン/ズーム操作を実行しの前後でgetBoundingClientRect()(またはPlaywrightのboundingBox())が完全に一致することをアサーションすれば、回帰を自動テスト化できる。
教訓: 「現在のツリーを目視確認してtransform祖先がなければ安全」という判断は、その瞬間の構造にしか通用しない。「position: fixedのUIは将来も確実にビューポート基準であり続けるべきか」を問うなら、構造の目視ではなくportalで仕様として保証する方が安価。
キャンバス要素だけにonWheelを付けると、隣接の固定UI上でブラウザのネイティブピンチズーム/elastic overscrollが発火する
10日前
パン/ズームできるキャンバスUI(マインドマップ・ホワイトボード等)で、キャンバス本体(例: .canvas要素)にだけonWheel/preventDefault()を付ける実装は、カーソルがキャンバスの外(同じ画面上の固定ボタン・FAB・fixedヘッダー等)にある間のwheelイベントを取りこぼせない。その領域ではpreventDefaultされないwheelイベントがブラウザの既定動作(トラックパッドのピンチページズーム、二本指スクロールのelastic overscroll)に流れ、「ページ全体が拡大/バウンドする」ように見えるバグになる。固定ボタン自体が動いたわけではなく、ページ全体がブラウザネイティブズームされて見えるのが真相なので、「固定UIが一緒に動いた」と誤診断しやすい(transformのcontaining block問題と見た目の症状が似ているが原因は別)。
適用条件: フルビューポートのキャンバスUIで、キャンバス本体の上だけではない、同一画面内の任意の場所(固定ボタンの上も含む)でブラウザのネイティブピンチ/スクロールZOOMを完全に抑制して自前のズーム/パンに置き換えたい場合。
対処方法: (1) wheelハンドラを特定要素ではなくwindowレベルで{ passive: false }を付けて登録し、画面全体のwheelイベントを捕捉して必ずpreventDefault()する。(2) ただしモーダル・テキスト入力中の要素など、既定のスクロール/選択動作を残したい領域はevent.target.closest('.除外セレクタ')で判定して早期 returnし、preventDefaultしないことで既定動作を温存する。(3) SafariはpinchをctrlKey付きwheelではなくgesturestart/gesturechange/gestureendとして合成するため、これらもdocumentレベルでpreventDefaultする。(4) 補完としてhtml, bodyにoverscroll-behavior: noneを付与し、wheelハンドラが取りこぼしたなかった分のelasticバウンドも押さえる。
確認方法: 固定ボタンの中心座標を直接指定したInput.dispatchMouseEvent(type:'mouseWheel')(CDP経由)を発火させ、キャンバスのZOOM値が変化しつつ固定ボタン自身のgetBoundingClientRect()/boundingBox()が一切変化しないことを確認すれば、「ページズームではなくキャンバスズームが起きている」ことを実証できる。除外対象(モーダル内コンテンツ等)はscrollTopが実際に変化することで既定動作の温存を確認できる。テストスクリプトではモーダルスライドイントランジション完了後に座標を取得しないと、途中の座標をつかむことになりテストが偽陰性に失敗する。
教訓: 「キャンバス本体にpreventDefaultを付ければ十分」という直感は、パン/ズーム対象のキャンバスと同じ画面に固定UIが共存する限り成り立たない。ハンドラのスコープは、キャンバスの矩形ではなく「カーソルがこの操作領域内にあればどこでもキャンバス操作として扱う」という意味合いで考える必要がある。
自作のpointerステートマシンは、右クリック等非主ボタンを後から取り消すのではなく pointerdown 入口で拒否する
10日前
ドラッグ/クリック/ダブルクリックを自前で判別する自作のpointerイベントステートマシン(pointerdownで状態を開始しpointerupで確定する方式)で、e.button(主ボタンか右ボタンか)をチェックせずに全ボタンを同じ経路に通すと、右クリック(contextmenu)で開くことを意図した操作が、左クリック用の副作用(例: クリックで新規作成)を両方発火させてしまうことがある。contextmenuハンドラ側で副作用を取り消す実装(例: 予約済みタイマーをclear)をしても、pointerdown→pointerup→contextmenuの発火順序はブラウザ/OS/入力方式(マウス右ボタン vs トラックパッドの二指タップ等)で保証がなく、取り消しが副作用の発火に間に合わないレースコンディションが残る。
適用条件: 同じ要素上のポインター操作が、ボタン種別(左/右)で完全に別の意味を持つ(例: 左クリック=新規作成、右クリック=メニュー表示)自作pointer状態機械を実装する場合。
対処方法: 副作用を事後に取り消そうとせず、pointerdownハンドラの先頭でe.button !== 0(タッチ入力は除外)なら即座にreturnし、そのポインターセッションは状態機械に一切入らないようにする。入口で拒否すれば、後続のpointermove/pointerupハンドラも一切登録されず、副作用予約自体が発生しないため、別ハンドラとのイベント順序に依存しない。
確認方法: 対象要素を右クリックし、副作用用の遅延時間(例: ダブルクリック判別の献予時間)を超えて待機した後も、副作用(新規作成等)が一切発生していないことを自動テストで確認する。並行して、左クリックの既存振る舞いが影響を受けていないことも確認すれば、回帰を防げる。
教訓: 「別ハンドラで副作用を取り消せば十分」という発想は、イベント発火順序が仕様上保証されていないキー/ポインターイベント群ではレースを勝ちに行くだけで本質的な修正にならない。発生源(入口)で条件分岐して経路自体に入らせない方が、レースの可能性を構造的に消す。