開発eurekapu-nuxt4メモ

7段階の幅で横スクロールを順に潰した

/lessons/financial-statements/cockpit-00-summary をスマホで開いたら、BS の右列がはみ出して、画面の外に消えた数字を確認するために横スクロールを2回引っ張った。「狭い画面で動くこと」を後回しにしてきたツケを、ここで払うことに決めた。

検証幅は 1920 / 1366 / 1280 / 1100 / 900 / 768 / 375 の7段階。Chrome DevTools MCP の emulate で各幅に切り替えながら、横スクロールバーが画面下に出ない状態を順に作った。

最初は resize_page で幅だけ変える方針で進めたが、window.innerWidth は変わるのに CSS のメディアクエリが効かない瞬間があって、レイアウトが古い幅のまま固まる挙動が出た。emulate でデバイスエミュレーションごと切り替える形に変更したら、メディアクエリも素直に追従した。


1920px — wide-section を1480pxで頭打ちにする

24インチ以上のディスプレイで開くと、wide-section が画面いっぱいに広がって、左右のテーブルが遠く離れて視線が振れた。「広い画面なら何でも広く使えばいい」訳ではなく、読む距離を一定に保つ方針に切り替える。

.wide-section {
  max-width: 1480px;
  margin-inline: auto;
}

1480px で頭打ちにして、それ以上の幅では中央に寄せる。1920px のディスプレイでは左右に220pxずつ余白が出るが、テーブルの数字が視野角に収まる側を優先した。


375px — BSの折り返しがオーバーラップした

モバイル幅で BS を開くと、資産側と負債純資産側が横並びのまま縮んで、テーブル内のテキストが折り返して隣の列に重なった。「狭い画面では2列を諦めて1列にする」が正解。

@media (max-width: 480px) {
  .bs-grid {
    grid-template-columns: 1fr;
  }
}

資産明細 → 負債純資産明細 → 合計行、の順に縦に積み重ねる構造に変更。スマホで開いたとき、上から下にスクロールすれば全数字が見える。横スクロールは消えた。


statements-grid に S/S が入っていなかった

狭幅版(1280px以下)の statements-grid の grid-template-areas を確認したら、bs / pl / cs の3エリアしか定義されていない。S/S を追加した時に狭幅側だけ追従漏れしていた。

広幅版では4表が見えるのに、狭幅版に切り替えた瞬間 S/S が消える挙動が出ていた。grid-template-areas に ss エリアを追加し、各幅でレイアウト先を再定義。


モーダル拡大時、左ペインの下に空白が伸びていた

「拡大ボタン」でモーダルを開くと、左ペイン(テーブル側)の下に画面1〜2画面分の空白が伸びて、右ペイン(解説側)と高さが揃わなかった。

調べると、左ペイン側に min-height: 100dvh が常時掛かっていた。デスクトップでは右ペインに合わせて高さを揃えたいが、モバイルでは内容に応じた自然高で良い。

@media (min-width: 769px) {
  .modal-left {
    min-height: 100dvh;
  }
}

デスクトップだけに条件付けしたら、モバイルの空白が消えた。


ヘッダー横に拡大/縮小ボタンを追加

モーダル拡大中、テーブルの数字を細かく見たい時にブラウザのズーム(Ctrl++)を使うと、モーダル外の背景までズームしてレイアウトが崩れる。モーダル内だけでズームを完結させたい。

ヘッダー右側、× 閉じるボタンの隣に + のボタンを並べた。クリックで modalZoom の値を 0.1 刻みで上下させ、CSS の zoom プロパティに反映する。

const modalZoom = ref(1)
const zoomIn = () => modalZoom.value = Math.min(2, modalZoom.value + 0.1)
const zoomOut = () => modalZoom.value = Math.max(0.5, modalZoom.value - 0.1)

開いた瞬間、ビューポートに自動でフィットさせる

+/− ボタンで手調整できる前提を作った後、「最初に開いた瞬間にビューポート全体が画面に収まる」初期値が欲しくなった。手で何回も を押させるのは負け筋だ。

applyFitZoom 関数を追加して、モーダルを開いた瞬間とウィンドウリサイズ時に呼び出す。コンテンツの実寸とビューポートサイズから、縦横どちらも収まる倍率を計算して modalZoom に代入する。

const applyFitZoom = () => {
  const content = modalContentRef.value
  if (!content) return
  const ratioW = window.innerWidth / content.scrollWidth
  const ratioH = window.innerHeight / content.scrollHeight
  modalZoom.value = Math.min(ratioW, ratioH, 1)
}

onMounted(() => {
  applyFitZoom()
  window.addEventListener('resize', applyFitZoom)
})

最初は縦方向のフィットだけ計算していたが、横長コンテンツで画面右にはみ出す事象が出たので、ratioW も足して Math.min で小さい方を採る形に直した。


CSS zoom 下で SVG オーバーレイがずれた

ここで一番ハマった問題。zoom: 0.7 を当てた状態で BS と CS を結ぶ SVG コネクターを描くと、線の端がテーブルの数字から数十px ずれる。zoom が等倍(1.0)に戻すと、ずれは消える。

原因を追ったら、SVG の path 座標は getBoundingClientRect() で取得したアンカー座標から計算している。getBoundingClientRect()CSS zoom 後の実画面座標を返すのに対し、SVG の viewBox を未指定にしていたため、SVG 側は内部的に等倍前提の座標系で描画していた。両者の座標系がズレて、線の端が数字から離れた。

解決策は SVG に 動的な viewBox を付けて、コンテナの実寸(CSS zoom 前のサイズ)に座標系を揃えること。

// コンテナの「論理サイズ」(zoom無視)でviewBoxを設定
const svgViewBox = computed(() => {
  const w = containerRef.value?.offsetWidth ?? 0
  const h = containerRef.value?.offsetHeight ?? 0
  return `0 0 ${w} ${h}`
})

offsetWidth は zoom の影響を受けない論理サイズを返すので、これを viewBox の幅として渡せば SVG 側の座標系と path 計算側の座標系が一致する。zoom がいくつでも、線の端が数字に正確に当たる。


modalZoom 変更時にオーバーレイ再計算

+/− ボタンで modalZoom を変えた瞬間、SVG オーバーレイが古い座標のまま残って、線が数字からずれて見えた。

オーバーレイの再計算 trigger に modalZoom を含めて、ズーム変更ごとに座標を再評価する形に変更。

watch([entryId, modalZoom], () => {
  recalcOverlayPositions()
})

これで +/− を押すたびに線が追従するようになった。


コミット

担当分のみ4ファイル、162行追加でコミット。

  • 1eea1fb レスポンシブ調整・S/S狭幅版バグ修正・左ペイン min-height 条件付け
  • 4a67b35 モーダル ± ズーム・applyFitZoom・SVG viewBox 動的化

作業ログは memo/2026-05-09/cockpit-responsive-and-modal-zoom-worklog.md に残した。


学びメモ

resize_page だけだとメディアクエリが追従しないことがあった。 Chrome DevTools MCP で幅を変えた時、window.innerWidth の値は更新されるのに @media (max-width: 480px) のスタイルが古い幅のまま固まる瞬間が出た。emulate でデバイスエミュレーションごと切り替えに変えたら、メディアクエリも一緒に追従した。次から狭幅検証では最初から emulate を使う。

CSS zoom と座標計算ライブラリの相性は悪い。 getBoundingClientRect() は zoom 後の座標を返すのに、SVG の path は viewBox なしでは等倍前提で描画される。両者の座標系を揃える方法は SVG 側に動的 viewBox を当てるのが一番素直だった。transform: scale() で代替する案も検討したが、子要素のレイアウト幅が変わらない問題が出るので zoom を維持した。

「最初に開いた瞬間に最適な状態」を作るのは ±ボタン2個分の手間を消す。 applyFitZoom を入れる前は、ユーザーが毎回 を3〜4回押してフィットさせていた。自動フィット1行が、ユーザーの指の動きを毎回4回ずつ削った。