山手線3DジオラマはPC向けに作り始めたプロジェクトですが、公開後に一番多く来たフィードバックが「スマホで操作したい」「iPhoneでマスコンが効かない」という声でした。当然対応しなければと思い、軽い気持ちでモバイル対応に着手したのですが、ここからが地獄でした。

今回は、ブラウザのタッチ周りで私が踏んだあらゆる罠と、最終的にどうやって落ち着かせたかを記録しておきます。同じところでハマる人が一人でも減ったら嬉しいです。

そもそもiPhoneで何も動かない問題

最初にビビったのは、iPhoneのSafariでマスコンを操作しても電車が1mmも動かないことでした。PCのChrome DevToolsで「デバイスツールバー」からiPhoneを模擬すると動くのですが、実機で開くと静止画のよう。コンソールすらスマホで見られない状態なので、まず eruda を埋め込んでページ内からconsole.logを確認するところから始めました。

結果分かったのは、私が書いていた操作ハンドラが mousedownmousemove だけだったという、今思えば当たり前の原因です。iOS SafariはPCと違って、基本的にはマウスイベントのエミュレーションをタップ(click)の前にしか発火しません。ドラッグに相当するmousemoveはスマホでは来ません。当たり前すぎて拍子抜けしましたが、ここに気づくまで2時間くらい「イベントが発火しない…なぜ?」と唸っていました。

touchstart / touchmove / touchend を追加

モバイル用のリスナーを追加して、タップ座標から指をスライドした距離に応じてマスコン位置を決めるよう書き直しました。

const mascon = document.getElementById('mascon');
let startY = null;

mascon.addEventListener('touchstart', (e) => {
    startY = e.touches[0].clientY;
}, { passive: true });

mascon.addEventListener('touchmove', (e) => {
    if (startY == null) return;
    const dy = e.touches[0].clientY - startY;
    updateNotchByDelta(dy);
    e.preventDefault(); // ← これで詰まった
}, { passive: false });

ここでいきなり、2つ目の罠が来ます。

passive: false を指定しないとpreventDefaultが効かない

最近のブラウザでは、touchmove のリスナーはデフォルトで { passive: true } として登録されます。スクロール性能を担保するための仕様変更なのですが、preventDefault()を呼んでも警告すら出ずに静かに無視されるのが厄介でした。

私は「マスコンを縦にスライドしているときは、裏で地図が一緒に動かないようにしたい」つもりだったのですが、いくらpreventDefaultしてもページごと縦スクロールしてしまう。Safariの開発者向けログにも手がかりらしい手がかりがなく、Chromeのコンソール警告「Unable to preventDefault inside passive event listener」を見てようやく理解しました。

ただしpassive: falseも罠

「じゃあ全部 { passive: false } にすればいいじゃん」と思って広げてしまうと、今度はそのリスナーが貼ってある要素の内部ではスクロールが一切できなくなる現象に出くわします。例えば関連記事一覧のスクロール領域にまで貼り付けてしまっていて、ユーザーがリストを縦スワイプしてもピクリとも動かない、という状況が本番で発生しました。しかも手元のiPhone 14では再現せず、友人のiPhone SE(第2世代)でだけ顕著に出るという、再現性の低い地獄。

結局、「preventDefaultを呼ぶ必要があるのは、ユーザーがマスコン本体を触っているときだけ」という原則に立ち返り、対象を本当にマスコン要素内に絞るように書き直しました。対象を絞るだけで、世界が平和になります。

MapLibreのジェスチャとの競合

マスコン側が動くようになって一息ついたところで、今度は別の問題が出てきました。地図の上で電車を追いかけているカメラ追従モードの最中に、ユーザーがうっかり画面中央をタップするとMapLibreがピンチズームモードに入ってしまい、そこから抜けられなくなるバグです。

MapLibre GL JSは、内部に独自のジェスチャハンドラ(dragPan, touchZoomRotate, dragRotate など)を持っていて、それぞれ有効無効を切り替えられるようになっています。しかしデフォルトでは全部ONなので、カスタムUIを重ねている場合にタッチが両側に届いてしまう。

// MapLibre側のジェスチャを明示的に制御
map.dragPan.disable();
map.touchZoomRotate.disable();
map.doubleClickZoom.disable();

// 地図のピンチズームだけは残したい場合
map.touchZoomRotate.enable();
map.touchZoomRotate.disableRotation();

シミュレーターとしては「2本指でピンチするときだけMapLibreに操作を渡したい、1本指のタップ・ドラッグは全部自前UIで処理したい」という要望だったので、touchstart の時点で e.touches.length を見て、1本指ならstopPropagationして自分で消化、2本指なら素通りさせる、という仕分けを最終的に入れています。

pointer events への段階的な移行

タッチ系のイベントを扱っているうちに「マウス用と指用と両方書くのダルすぎる」と限界を感じ、Pointer Events へ移行しました。Pointer Eventsなら、マウス・タッチ・スタイラスを統一的なAPIで扱えます。

const mascon = document.getElementById('mascon');

mascon.addEventListener('pointerdown', (e) => {
    mascon.setPointerCapture(e.pointerId);
    startY = e.clientY;
});
mascon.addEventListener('pointermove', (e) => {
    if (startY == null) return;
    updateNotchByDelta(e.clientY - startY);
});
mascon.addEventListener('pointerup', () => { startY = null; });

特に効果が大きかったのが setPointerCapture です。指が要素の外に出てしまってもイベントが拾えるので、「スライド中に指が隣のボタンに乗ったら動作が止まる」という、今までずっとユーザーを困らせていた問題が一気に消えました。

ただし、MapLibre側のtouchハンドラと競合しないよう、Pointer Eventsを使う要素には touch-action: none をCSSで指定しておく必要があります。これを忘れると、Android Chromeではpointer events経由でも縦スクロールが発動してしまい、マスコンを下に引くつもりがページ全体が動くという現象に戻ってしまいます。

ダブルタップでズームされる問題

iOSのSafariには、ダブルタップで自動的にズームインする独自挙動があります。マスコンを連打で操作しようとしたユーザーから「連打するとなぜか画面が拡大してしまう」と報告があり、ここも対処が必要でした。

昔は <meta name="viewport" content="user-scalable=no"> で封じられたのですが、iOS 10以降この指定は無視されるようになっています。最終的に、操作系要素だけ touch-action: manipulation を指定する方法に落ち着きました。

.mascon, .btn-stop, .btn-door {
    touch-action: manipulation; /* ダブルタップズームを抑止 */
}

これが効くのは「指定された要素上でのダブルタップだけ」なので、通常の記事本文などではユーザーが拡大できる自由を残したまま、操作系だけズームを封じることができます。ユーザビリティ上も合理的で、この仕様に最初から気づいていたかった……。

「Chromeでは動くがiPhoneでは動かない」のデバッグ

開発中に一番辛かったのが、Android Chromeでは完璧に動くコードがiPhone Safariでだけ動かないパターンです。例として覚えているのが、マスコンのドラッグ中に電車の速度表示がスムーズに更新されない現象。

原因は、私が requestAnimationFrame の中から touches[0].clientY を参照していたことでした。iOS Safariでは、イベント発火時のtouchesオブジェクトはその場で読まないと値がクリアされることがあるようで、AF後に参照すると空配列になっていたのです。これはドキュメントにも「可能な限り同期的に値を取り出すこと」と書かれていますが、見落としていました。

// NG: AFに持ち越した時点でtouchesが空になることがある
mascon.addEventListener('touchmove', (e) => {
    requestAnimationFrame(() => {
        const y = e.touches[0].clientY; // iOSでundefinedになる
    });
});

// OK: イベントハンドラ内で値をコピー
mascon.addEventListener('touchmove', (e) => {
    const y = e.touches[0].clientY;
    requestAnimationFrame(() => updateNotch(y));
});

この修正を入れるまで丸1日、iPhoneとAndroidを交互に触りながら「なぜだ……なぜだ……」と呻いていました。ブラウザのバージョン違い、端末違いを本番で検証するのは本当に大変で、この経験からChrome DevToolsのデバイスエミュレータは半分しか信用しないようになりました。

最終的な設計指針

散々遠回りして、最終的に以下のルールに落ち着いています。

1. 入力は全部 Pointer Events に寄せる

マウスとタッチで別ハンドラを書くと、必ずどこかで挙動がズレます。Pointer Eventsに一本化し、pointerdown / pointermove / pointerup / pointercancel の4つだけを扱うようにしました。

2. touch-action はCSSで明示

JS側でpreventDefaultを頑張るより、CSSの touch-action で宣言する方が圧倒的に安定します。操作系要素は none または manipulation、スクロールしてほしい領域は pan-y のように使い分けています。

3. MapLibreのジェスチャは明示的に絞る

デフォルト全ON任せにすると必ず競合します。dragPantouchZoomRotatedoubleClickZoom をユースケースに応じてdisable/enableして、指の本数で振り分けるのが安全でした。

4. 本番環境は3台以上で確認する

iPhone 1台、Android 1台では網羅できません。私は最低でもiPhone(新しめ)、iPhone SE世代(画面小)、Android Chrome(Pixel系)、タブレットの4つを触って確認するようにしています。友人に頼むか、古い端末を中古で確保しておくのがおすすめです。

まとめ

タッチイベント周りは、仕様を知らないと静かに挙動がズレるAPIの塊です。preventDefaultが無視される、touchesが消える、ダブルタップでズームされる、ジェスチャが競合する。どれも単独なら半日で直せますが、複数の不具合が重なるとどこから手を付けるべきか分からなくなります。

個人開発でここまで細かく詰めるのは正直しんどいのですが、「PCだけ動けばいい」と割り切らずにモバイル対応まで仕上げると、アクセス数の傾向がガラッと変わりました。山手線3Dジオラマでも、今ではアクセスの6割以上がスマホからです。遠回りの価値はあったと感じています。