2023年11月3日金曜日

HTML 要素の panzoom

世界地図パズル を作った時、HTML 要素の panzoom が必要になりました。 有名なものとしては以下があり、すべて検証したのですが、それぞれ欠点があることがわかりました。 もうハマりたくないのでメモを残しておきます。 上記のライブラリを遊んでみてわかったのは、 svg-pan-zoom は (1) ホイール時にページも動く、 (2) object タグの中に適用してしまうので予期しない動作が生まれる (特に y軸方向)、 anvaka/panzoom は (3) z-index が変わって表示に影響が出る、 (4) フォーカスが時々外れて違和感がある、 (5) 特定範囲内で動かす処理が指定しにくい、 (6) zoomTo() で縮小できない致命的な問題がある、 timmywil/panzoom は (7) イベントが増えると zoom がズレる、などが気になりました。 1 は親要素に overflow: hidden; を付けることで解決できるとわかったのですが、3, 4 は不明です。 7 が見つかるまでは timmywil/panzoom が一番良さそうだったのですが、 この問題が厳しかったので、結局はセルフ実装をするしかありませんでした。

Canvas の panzoom はつらい

世界地図パズル では panzoom の実装を 200行くらいで実現していますが、作るのはものすごく時間が掛かった気がします。 ちなみに何を苦労したかというと、1つは Canvas ライブラリ fabric.js との擦り合わせです。 まずは Canvas だけで実装してみようと思ったのですが、その場合 setBackgroundImage() で地図を背景画像にし、zoom を実装することになります。 しかし拡大していくと再レンダリングされない問題が発生し、ジャギーが気になりました。 小さな SVG はきちんと再描画されているので、巨大な画像だけ別処理の扱いなのかな。 issue で同じ問題が提起されており、 現状この解決がかなり難しいとわかりました。 panzoom を実装するときは SVG などを使ったほうがずっと簡単です。

とはいえ今回は fabric.js を使うのは絶対条件だったので、Canvas (ピース) と SVG (背景) で別々に panzoom を実装しました。 まずは fabric.js の zoomToPoint() や absolutePan() と timmywil/panzoom の両方を使って実装しました。 しかし座標変換の方法に差異があり擦り合わせに苦労し、また 7 の問題に直面し、セルフ実装を行うことにしました。 Canvas だと特定領域だけレンダリングをし直すようなビューが存在するので、計算が複雑になる問題があります。 これがなかなかつらい。

panzoom の実装のポイント

最後に汎用的に使えるコードをまとめておくと以下になります。 scale→translate する方式と translate→scale する方式がありますが、前者のほうが楽です。 scale を変えるときに複雑な計算がいらないのが大きいです。
let panning = false;
let zoom = 1;
let px = 0;
let py = 0;
let dx = 0;
let dy = 0;
node.addEventListener("mousewheel", (event) => {
  if (pannning) return;
  zoom += event.deltaY * -0.01;
  map.style.transform = `scale(${zoom}) translate(${dx}px,${dy}px)`;
});
node.addEventListener("mouseup", (event) => {
  if (!panning) return;
  panning = false;
  const dx += (event.clientX - px) / zoom;
  const dy += (event.clientY - py) / zoom;
});
node.addEventListener("mousedown", (event) => {
  if (panning) return;
  panning = true;
  px = event.clientX;
  py = event.clientY;
});
node.addEventListener("mousemove", (event) => {
  if (!panning) return;
  const x = event.clientX - px;
  const y = event.clientY - py;
  const tx = x / zoom + dx;
  const ty = y / zoom + dy;
  map.style.transform = `scale(${zoom}) translate(${tx}px,${ty}px)`;
});

マルチタッチがつらい

マルチタッチを考慮すると一気に実装が複雑になります。 タッチパネルを考慮して pinch zoom に対応する必要があったり、移動域を制限するなども手間です。 しかし一番厳しいのは、1点タッチが始まったとき、同時に 2点タッチが始まったとき、1点タッチの後に 2点タッチが始まったとき、 1点タッチが終わったとき、片方のタッチが終わったとき、同時に 2点タッチが終わったとき、 などかなりのパターンを考慮して実装しないといけない点です。 しかもイベントが非同期に発生するので、処理がぶつかることがあり、超大変でした。 3点タッチの実装なんて絶対に考えたくない…。

ライブラリにしようかとも思ったのですが、fabric.js などと組み合わせるとほとんどの計算過程を保持しておく必要があって、綺麗にまとめるのは難しそうでした。 また panzoom なアプリを作ることがあれば考えてみたいですが、使うことないからなー。

0 件のコメント: