GODRICH

地図 ui デザイン 9選 — クリックでピンが落ちる位置選択

地図 ui デザインとは、配達先や待ち合わせ場所を「住所を書かせる」のではなく地図の上で指して決めてもらう画面の作りです。9つとも地図 API を使わず、CSS で描いた盤の上だけで動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つの並びは人気順ではなく、位置が決まるまでの時間軸です。前半の3つは地図の上に点を作る段階で、押した場所に点を打ち(01)、密集した点を数字に畳んでから開き(02)、リストの行と地図の点を同じ色で結びます(03)。中盤の3つは候補を絞る段階で、半径の外を切り落とし(04)、住所を打って中心を移し(05)、そもそもの出発点を現在地に置きます(06)。後半の3つは決まったあとの話で、経路を見せ(07)、保存し(08)、狭い画面で全体の位置関係を確認します(09)。盤面の描き方は9つとも共通で、縦横2枚の repeating-linear-gradient で作った道路の格子を敷き、background の並びでそれより前に書いた公園の円と川の帯が道路の上を横切ります。先に書いた層が上に載るからです。その背景は固定したままピンとリングとカードだけを動かします。

01クリックで落ちるピンドロップ

地図のどこかを押すと、その座標へピンが 62px 上から落ちて一度だけ弾み、影は落ちてくる間は狭く、着地に合わせて広がります。クリック位置は地図ボックスの大きさで割ってパーセントに直すので、画面幅が変わっても同じ建物の上に刺さります。同じアニメーションをもう一度走らせるには、クラスを外して付け直す間にリフローを一度強制する必要があります。

translateYcubic-bezierclick 좌표
function drop(nx, ny) {
  root.classList.remove('is-demo');
  x = Math.max(8, Math.min(92, nx));
  y = Math.max(14, Math.min(90, ny));
  parts.forEach(function (el) {
    el.style.left = x.toFixed(1) + '%';
    el.style.top = y.toFixed(1) + '%';
    el.classList.remove('is-drop');
    void el.offsetWidth; // リフローを強制しないと同じアニメーションが再生されない
    el.classList.add('is-drop');
  });
  val.textContent = names[Math.floor((x + y) / 38) % names.length];
  coord.textContent = (37.52 + y / 400).toFixed(4) + ', ' + (127.03 + x / 400).toFixed(4);
}

02数字バッジクラスターの拡散

重なったピンの位置に数字バッジが浮かび、押すとピン6個が扇状に散り、もう一度押すとバッジ1つへ合流します。ピンごとに違うのは方向の係数2つ(--kx--ky)だけで、半径(--rx 92px・--ry 40px)は共通なので、狭い画面ではこの2つの値を縮めるだけでピンが地図の外へ出なくなります。

scaletransform-originaria-expanded
@keyframes cb-burst {
  0%        { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
  10%       { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
  32%       { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
  68%       { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
  86%, 100% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
}

03結果カードとピンの双方向ハイライト

カードにマウスを乗せるか Tab でフォーカスすると、地図側の同じ店舗のピンが 1.42 倍に拡大してリングが広がり、逆にピンへ乗せるとカードの枠が点灯します。両者をつないでいるのは data-id 1つだけなので、カードとピンの DOM 順が揃っていなくても構いません。

mouseenterdata-idbox-shadow
.cl__pin.is-on, .cl__pin:hover { color: $color; transform: translate(-50%, -100%) scale(1.42); }
.cl__ring {
  position: absolute;
  left: 50%; top: 100%;
  width: 34px; height: 34px;
  border: 2px solid $color;
  border-radius: 50%;
  transform: translate(-50%, -50%) scale(.4);
  opacity: 0;
  pointer-events: none;
}
// リングは点灯している間ずっと広がり続ける — 両端の値がどちらも透明なので transition では見えない
.cl__pin.is-on .cl__ring, .cl__pin:hover .cl__ring {
  animation-name: cl-ring-pulse;
  animation-duration: $dur-enter;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
}
@keyframes cl-ring-pulse {
  0%   { opacity: .6; transform: translate(-50%, -50%) scale(.55); }
  100% { opacity: 0;  transform: translate(-50%, -50%) scale(1.9); }
}

04半径円の距離スライダー

スライダーを動かすと半径円が広がって距離ラベルが 3.2km まで上がり、円の中に入ったピンだけが濃く残ります。円は地図と同じ高さの正方形(aspect-ratio: 1)で、scale(var(--k)) だけで拡大するため、大きくなる間も地図のレイアウトは一度も計算し直されません。

input rangescaleopacity
function rsPaint() {
  var v = Number(rsRange.value);
  var t = (v / 100 * RS_MAX_KM).toFixed(1) + 'km';
  rsRing.style.setProperty('--k', v / 100);
  rsKm.textContent = t;
  rsRange.setAttribute('aria-valuetext', t);
  rsPins.forEach(function (p) {
    p.classList.toggle('is-in', Number(p.getAttribute('data-dist')) <= v);
  });
}

05住所自動補完ドロップダウン

文字を打つと候補4件が入力欄の上へスライドして現れ、上下の矢印キーで移動して Enter を押すと地図のピンがその候補の座標へ実際に飛びます。候補の座標は要素ごとに data-xdata-y として、地図の幅を基準にしたパーセントで埋め込んであります。

role=comboboxaria-activedescendanttyping keyframes
function acPick(i) {
  var o = acOpts[i];
  acOpts.forEach(function (x, k) { x.setAttribute('aria-selected', String(k === i)); });
  acInput.value = o.lastElementChild.textContent;
  acPin.style.left = o.getAttribute('data-x');
  acPin.style.top = o.getAttribute('data-y');
  acOpen(false);
  acInput.focus({ preventScroll: true });
}

06現在地クロスヘッドスキャン

ボタンを押すとリング3個が時間差で外へ広がり、1.4秒後にスキャンが終わるとその場所で現在地の点が脈打ち始めます。リングは同じキーフレームを共有したまま2つ目と3つ目に負の animation-delay を入れて途中から再生させており、状態の文言は aria-live の領域なので画面が見えない人にも切り替わりが読み上げられます。

ring keyframesaria-livereduced-motion
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
  animation-name: lc-scan;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 { animation-delay: -667ms; }
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 { animation-delay: -1334ms; }

07描かれていく経路線

出発のピンから到着の旗まで経路線が筆で引くように伸びていき、引き終わると所要時間と距離のバッジが飛び出します。pathLength="100" で長さを 100 に正規化しておくと stroke-dasharraystroke-dashoffset に入れる数字がそのまま進捗のパーセントになるため、経路の座標を書き換えても値を計算し直す必要がありません。

stroke-dashoffsetpathLengtharia-label
.rd__line--draw {
  stroke: $color;
  stroke-width: 5;
  stroke-dasharray: 100;              // pathLength="100" なので dash の値がそのまま %
  stroke-dashoffset: 100;
}
// 操作モードでは transition で引く
.rd:not(.is-demo) .rd__line--draw { transition: stroke-dashoffset 900ms $ease-out; }
.rd.is-drawn .rd__line--draw { stroke-dashoffset: 0; }

08お気に入りピンリスト

ピンのハートを押すとデュオトーンアイコンの下地が点灯して弾み、地図の下のマイプレイスの行にその店のチップが差し込まれます。チップは消しているのではなく max-width と左右の余白を 0 に畳んでいるだけなので、もう一度ハートを点ければ同じ位置に開き直します。

aria-pressedscale bouncechip stack
function sync() {
  pins.forEach(function (p, i) {
    var on = p.getAttribute('aria-pressed') === 'true';
    chips[i].classList.toggle('is-on', on);
    chips[i].setAttribute('aria-hidden', String(!on));
  });
}
pins.forEach(function (p) {
  p.addEventListener('click', function () {
    root.classList.remove('is-demo');
    p.setAttribute('aria-pressed', p.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
    sync();
  });
});

09押して拡大するミニマップ

隅のミニマップの中で今見ている範囲の四角が往復し、ミニマップを押すと右下の角を軸にして大きなパネルが伸び上がります。transform-origin: 100% 100% で拡大の向きを固定しておくと、開いたパネルが地図ボックスの外へはみ出しません。

transform-originoverlayaria-expanded
function toggle(on) {
  root.classList.remove('is-demo');
  root.classList.toggle('is-open', on);
  btn.setAttribute('aria-expanded', String(on));
}
btn.addEventListener('click', function () { toggle(!root.classList.contains('is-open')); });
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && root.classList.contains('is-open')) {
    toggle(false);
    btn.focus({ preventScroll: true });
  }
});

どこで壊れるか — 落とし穴

地図の座標はほぼすべてパーセントです。盤の幅がいくつであっても「横 82%・縦 38% の地点」は同じ建物でなければ困るからです。ところがそのパーセントをそのまま lefttop に入れて動かすと、ブラウザはそのたびに位置を計算し直します。05 のピンが候補へ飛ぶときがまさにその動き方です。かといって transform: translate() に置き換えればよい話でもありません。ここでのパーセントは盤ではなく動く要素自身の大きさが基準なので、20px のピンに translate(82%) を与えても 16px しか進まないからです。そこで座標はパーセントのまま持ちつつ、頻繁に動くもの(01 の落下、02 の拡散、03 の拡大、04 の円、09 の展開)は transformopacity だけで動かし、位置そのものを書き換えるのは人が1回選んだときだけに限りました。

2つ目の落とし穴は大きさです。これらのデモは記事の中に 480×300 の iframe として埋め込まれますが、スマートフォンでは 320×200 まで縮みます。ステージの余白を引くと縦に使えるのは 174px しか残りません。地図の高さは9つのうち5つが clamp(104px, 34vw, 160px)、残りはもっと狭い clamp か aspect-ratio: 12 / 5 で抑えてあり、幅 320px で実測した9つの部品の高さは 127px から 155px の間に収まりました(この実測は部品全体の高さで、地図の板だけなら 96px から 117px です)。ここでリストの行の間隔を 4px 広げるだけで最後の行が切れます。同じ 174px の制約は 04 の半径円にも効いていて、円は地図の高さに合わせた正方形として作ってあるので、地図が縮めば円も一緒に縮み、幅 320px でのはみ出し検査は9つとも 0 件で通りました。もう1つ、狭さが文字を壊した例が 02 です。ピン6個を同じ半径のまま1行の名札で広げると、幅 480px でも名札同士が重なって読めません。扇の角度を 170 度から 10 度までの範囲に広げ、名札を2行に互い違いに置いて、ようやく6つとも読めるようになりました。圧縮ファイルを開くパスワードは 7ajdcn4a で、いま見ている9つの SCSS と React 版が、直したあとの値のまま入っています。

アクセシビリティ

9つとも、prefers-reduced-motion: reduce の環境では自動再生のループが止まり、動いた結果だけが残ります。ピンは落ちる動きなしに指された場所へ立ち、半径円は拡大の動きなしにその大きさで置かれ、06 のスキャンはリングが広がる動きをやめ、1つ目のリングだけが現在地の点のまわりに残ります。このメディア特性がどの設定に反応するのかは MDN の prefers-reduced-motion の解説に一覧があります。

項目 マウス キーボード 状態の通知
01 ピンドロップ 地図をクリック 矢印キーで移動・Enter でその場に落下 role="status" で場所の名前だけ(座標は画面のみ)
03 カードとピン hover Tab でフォーカス・矢印キーで次の項目 aria-pressedrole="status"
04 半径 スライダーをドラッグ 矢印キー・Home・End aria-valuetext に距離
05 住所 クリック 上下の矢印・Enter・Escape aria-activedescendant
06 現在地 ボタンをクリック Enter・Space aria-live="polite"
09 ミニマップ クリック Enter・Space・Escape aria-expanded

05 が住所を選んだあと、09 が Escape で畳まれたあとにフォーカスを戻すとき preventScroll: true を添えているのは、これらの地図が記事の中に iframe として埋め込まれているからです。オプションなしで呼ぶと、読んでいた画面が地図のほうへスクロールしてしまいます。同じように値を選ばせる部品はコマース・購入カテゴリに、押すと反応する部品はクリックカテゴリに集めてあります。

FAQ

座標を px ではなくパーセントで持つと何が変わりますか

盤の幅が変わっても同じ場所を指し続けます。9つとも地図を position: relative の箱として扱い、ピンはその箱に対する割合で置いてあるので、480px でも 320px でもピンは同じ建物の上に立ちます。px で持つと幅が変わるたびに全部の座標を掛け直すことになり、その計算を JavaScript でやると縮めるたびにピンが一瞬ずれて見えます。

自動再生はいつ止まりますか

人が最初に触った瞬間です。9つとも is-demo というクラスが付いている間だけ自動のループが回り、クリック・キー入力・スライダーの操作が入った時点でそのクラスを外します。一度外れたら読み込み直すまで戻らないので、自分で選んだピンの位置がループに上書きされることはありません。

React 版と vanilla 版で作りが違うところはありますか

アニメーションをもう一度走らせる方法だけが違います。vanilla はクラスを外して void el.offsetWidth でリフローを一度強制し、同じキーフレームを頭から再生させますが、React 版は落とした回数を状態として持ち、その数値を key に渡して要素を作り直すことで同じ結果を出しています。durationeasingcolor は9つとも props で受け取り --duration--easing--color として CSS へ渡すので、値を変えるときに触る場所はどちらも1か所です。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。