JavaScript

JavaScriptで現在地から近い順に並べ替える|距離計算で上位5件を表示する方法

みなと
UI実装担当
UI実装担当

現在地から近い順にスポットを並べたい…けど、どう作るの?

Web制作の現場では、ユーザーの現在地をもとに“近い順”にスポットを並べ替えるケースがよくあります。例えば、店舗検索、イベント会場一覧、観光スポット、配送拠点など……距離の比較やソートは、実務で比較的出番の多い機能です。

前回の記事ではGeolocation APIを使い、「現在地の緯度・経度を取得する方法」を解説しました。

あわせて読みたい
JavaScriptで現在地を取得してGoogleマップを移動させる|Geolocation API実装ガイド
JavaScriptで現在地を取得してGoogleマップを移動させる|Geolocation API実装ガイド

今回はその発展版として、取得した現在地と複数地点の距離を比べ、最も近いスポット順に並べ替える仕組みを作っていきます。

今回の記事でできること
  • 現在地の緯度・経度を取得する
  • 10個の地点(全国の主要駅)との距離を計算
  • 距離が近い順に並べ替える
  • 上位5件だけを抽出してリスト化
  • <template>を使ってDOMを効率よく生成

この記事を読めば、「店舗検索」や「周辺スポット検索」などにも応用できる、実務的な距離ソート処理が作れるようになります。

まずは、完成デモから見てみましょう!

完成デモの紹介

まずは、今回作成する「現在地から近い順に並べ替えるデモ」を動かしてみましょう。以下のボタンを押すと、全国10駅の中からあなたの現在地に最も近い上位5駅が表示されます。

「近い順で上位5駅を表示」を押してください。

距離を比較するサンプルとして、全国の主要駅10駅のデータを用意しました。緯度・経度に加えて住所や開業日などの情報も含めているので、駅以外のデータ(店舗・施設・会場など)に置き換えても同じ仕組みで使えます。

対象としている駅は以下のとおりです。

対象駅一覧札幌駅、仙台駅、金沢駅、東京駅、名古屋駅、京都駅、大阪駅、広島駅、高松駅、博多駅

コード全体(HTML / CSS / JavaScript)

ここからは、完成デモで使用しているHTML・CSS・JavaScriptのコード全文を紹介します。それぞれのコードには、理解しやすいようにポイントとなる箇所へコメントも入れてあります。

まずは全体の動きをざっくりつかみながら、後の「ポイント解説」で細かい仕組みを確認していきましょう。

HTMLコード

HTMLでは、ボタン・ステータス表示・結果表示エリア・テンプレートの4つを用意しています。特に<template>要素を使うことで、JavaScript側でリストを簡単に生成できるようになっています。

<!-- ボタン:クリックすると現在地を取得し、近い駅順に並べる -->
<button id="geo-sortBtn" class="geo-button" type="button">近い順で上位5駅を表示</button>

<!-- 現在の処理状況を表示するエリア -->
<div class="geo-status-wrapper">
  <p id="geo-status" class="geo-status">「近い順で上位5駅を表示」を押してください。</p>
</div>

<!-- 並び替え結果(駅リスト)を挿入する領域 -->
<div id="geo-result" class="geo-list"></div>

<!-- 駅1件分のテンプレート(JavaScriptで複製して表示) -->
<template id="geo-item-tpl">
  <li class="geo-card">
    <div class="geo-card-inner">

      <!-- 上段:駅名・順位・距離 -->
      <div class="geo-card-head">
        <div class="geo-card-title">
          <!-- 駅の順位(近い順) -->
          <p class="geo-rank"><span></span></p>
          <!-- 駅名 -->
          <p class="geo-name"></p>
        </div>
        <!-- 現在地からの距離 -->
        <p class="geo-distance"><span></span>km</p>
      </div>

      <!-- 下段:駅の詳細情報(住所・開業年) -->
      <ul class="geo-card-info">
        <li>
          <p class="geo-label">住所:</p>
          <p class="geo-value is-address"></p>
        </li>
        <li>
          <p class="geo-label">開業:</p>
          <p class="geo-value is-opened"></p>
        </li>
      </ul>
    </div>
  </li>
</template>

CSSコード

CSSでは、駅カードのレイアウトとレスポンシブ対応を中心にスタイルを整えています。PC・スマホどちらでも読みやすいように、flexを使ったシンプルな構成にしています。

/* ================================
  ボタン(位置情報取得+並び替え)
================================ */
.geo-button {
  display: block;
  margin: 0 auto;
  border: none;
  border-radius: 5px;
  background: #c11a51;
  color: #fff;
  font: inherit;
  font-weight: 700;
  line-height: 1.4;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  appearance: none;
  transition: background-color 300ms;
}

@media (min-width: 768px) {
  .geo-button {
    padding: 15px 30px;
  }
}

@media (max-width: 767px) {
  .geo-button {
    padding: 15px 25px;
  }
}

@media (min-width: 768px) {
  .geo-button:hover {
    background: #890631;
  }
}

/* 位置情報取得中など、ボタンが無効のとき */
.geo-button:disabled {
  opacity: .5;
  cursor: not-allowed;
}

/* ================================
  ステータス表示(成功/失敗メッセージなど)
================================ */
.geo-status-wrapper {
  display: flex;
  justify-content: center;
  margin-top: 20px;
}

.geo-status {
  line-height: 1.7;
  text-align: left;
}

@media (min-width: 768px) {
  .geo-status {
    font-size: 16px;
  }
}

@media (max-width: 767px) {
  .geo-status {
    font-size: 15px;
  }
}

/* 強調表示(成功/エラー) */
.geo-status strong {
  font-weight: 700;
}

.geo-status strong.is-success {
  color: #15803d;
}

.geo-status strong.is-error {
  color: #b91c1c;
}

/* ================================
  駅リスト(ul と li のデザイン)
================================ */
.geo-list > ul {
  list-style: none;
  margin-top: 30px;
  border-top: 1px solid rgba(49, 52, 94, 0.15);
  font-size: 16px;
}

.geo-list > ul > li {
  border-bottom: 1px solid rgba(49, 52, 94, 0.15);
}

@media (min-width: 768px) {
  .geo-list > ul > li {
    padding: 20px;
  }
}

@media (max-width: 767px) {
  .geo-list > ul > li {
    padding: 15px 10px;
  }
}

/* ================================
  駅カードのレイアウト(横並び or 縦並び)
================================ */
.geo-card-inner {
  display: flex;
}

/* PC:横並び */
@media (min-width: 768px) {
  .geo-card-inner {
    align-items: center;
    gap: 40px;
  }
}

/* スマホ:縦並びに切り替え */
@media (max-width: 767px) {
  .geo-card-inner {
    flex-direction: column;
    gap: 10px;
  }
}

/* ================================
  カード上段(順位・駅名・距離)
================================ */
.geo-card-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

@media (min-width: 768px) {
  .geo-card-head {
    flex: 0 0 230px;
  }
}

/* 駅名+順位 */
.geo-card-title {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* 順位の数字 */
.geo-rank > span {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
  border-radius: 5px;
  background: #4466ce;
  color: #fff;
  font-weight: 700;
  line-height: 1;
  text-align: center;
}

/* 駅名 */
.geo-name {
  font-weight: 700;
  line-height: 1.5;
}

/* 距離(右寄せ) */
.geo-distance {
  font-weight: 700;
  text-align: right;
}

.geo-distance > span {
  margin-right: 5px; /* 「km」との間に余白を作る */
}

/* ================================
  カード下段(住所・開業年)
================================ */
.geo-card-info {
  list-style: none;
  line-height: 1.7;
}

@media (min-width: 768px) {
  .geo-card-info {
    flex: 1;
  }
}

.geo-card-info > li {
  display: flex;
}

.geo-label {
  white-space: nowrap;
}

.geo-value {
  flex: 1;
}

JavaScriptコード

JavaScriptでは、現在地の取得・距離計算・並べ替え・リスト表示といった主要処理をまとめています。詳しい仕組みは後の「ポイント解説」で説明しますので、ここでは流れだけ確認しておけば大丈夫です。

// 即時実行関数でスコープを閉じ、他のスクリプトとの衝突を防ぐ
(async () => {
  'use strict';

  // 必要なDOM要素を取得
  const statusEl = document.getElementById('geo-status'); // 処理状況の表示
  const btnEl = document.getElementById('geo-sortBtn'); // 実行ボタン
  const listEl = document.getElementById('geo-result'); // 結果表示エリア
  const itemTpl = document.getElementById('geo-item-tpl'); // 駅カードのテンプレート

  // 対象となる10駅のデータ(緯度・経度は確定値)
  const stations = [
    { name: '札幌駅', lat: 43.0687, lng: 141.3507, address: '北海道札幌市北区北6条西4丁目1-1', opened: '1880年11月28日' },
    { name: '仙台駅', lat: 38.2606, lng: 140.8825, address: '宮城県仙台市青葉区中央1丁目', opened: '1887年12月15日' },
    { name: '金沢駅', lat: 36.5781, lng: 136.6479, address: '石川県金沢市木ノ新保町1-1', opened: '1898年4月1日' },
    { name: '東京駅', lat: 35.6812, lng: 139.7671, address: '東京都千代田区丸の内1丁目', opened: '1914年12月20日' },
    { name: '名古屋駅', lat: 35.1709, lng: 136.8815, address: '愛知県名古屋市中村区名駅1丁目1-4', opened: '1886年5月1日' },
    { name: '京都駅', lat: 34.9858, lng: 135.7588, address: '京都府京都市下京区烏丸通塩小路下ル東塩小路町', opened: '1877年2月5日' },
    { name: '大阪駅', lat: 34.7025, lng: 135.4959, address: '大阪府大阪市北区梅田3丁目1-1', opened: '1874年5月11日' },
    { name: '広島駅', lat: 34.3974, lng: 132.4755, address: '広島県広島市南区松原町2-37', opened: '1894年6月10日' },
    { name: '高松駅', lat: 34.3506, lng: 134.0466, address: '香川県高松市浜ノ町1-20', opened: '1897年2月21日' },
    { name: '博多駅', lat: 33.5901, lng: 130.4207, address: '福岡県福岡市博多区博多駅中央街1-1', opened: '1889年12月11日' }
  ];

  /**
   * 2点間の距離を km 単位で求める(ハバーサイン公式)
   * 緯度・経度から距離を計算するときの定番の方法
   */
  function getDistanceKm(lat1, lng1, lat2, lng2) {
    const R = 6371; // 地球の半径(km)
    const toRad = d => d * Math.PI / 180;
    const dLat = toRad(lat2 - lat1);
    const dLng = toRad(lng2 - lng1);
    const a =
      Math.sin(dLat / 2) ** 2 +
      Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
      Math.sin(dLng / 2) ** 2;
    return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }

  /**
   * 駅リストをHTMLとして描画する
   * items … 距離情報を含んだ駅オブジェクトの配列
   */
  function renderList(items) {
    listEl.innerHTML = ''; // 一度クリア

    const ul = document.createElement('ul');
    const frag = document.createDocumentFragment();

    items.forEach((item, i) => {
      // テンプレートを複製し、駅情報を代入
      const node = itemTpl.content.cloneNode(true);

      node.querySelector('.geo-rank span').textContent = String(i + 1);
      node.querySelector('.geo-name').textContent = item.name;
      node.querySelector('.geo-distance span').textContent = item.distanceKm.toFixed(2);
      node.querySelector('.geo-value.is-address').textContent = item.address;
      node.querySelector('.geo-value.is-opened').textContent = item.opened;

      frag.appendChild(node);
    });

    ul.appendChild(frag); // li をまとめて ul に追加
    listEl.appendChild(ul); // 完成したリストを表示
  }

  // ボタンを押したら現在地の取得 → 距離順ソート → 上位5件を表示
  btnEl.addEventListener('click', () => {
    // ブラウザが geolocation API をサポートしているか確認
    if (!('geolocation' in navigator)) {
      statusEl.textContent = 'このブラウザは位置情報に対応していません。';
      return;
    }

    btnEl.disabled = true; // 多重クリック防止
    statusEl.textContent = '現在地を取得しています…';

    // 現在地の取得を開始
    navigator.geolocation.getCurrentPosition(
      // 取得成功時の処理
      (pos) => {
        const { latitude, longitude } = pos.coords;
        statusEl.innerHTML = `<strong class="is-success">取得成功:</strong>緯度 ${latitude.toFixed(6)}、経度 ${longitude.toFixed(6)}`;

        // 各駅に「現在地からの距離」を付与し、近い順に並び替え
        const withDistance = stations.map(station => ({
          ...station,
          distanceKm: getDistanceKm(latitude, longitude, station.lat, station.lng)
        })).sort((a, b) => a.distanceKm - b.distanceKm);

        // 上位5駅だけ描画
        renderList(withDistance.slice(0, 5));

        btnEl.disabled = false; // ボタンを再び有効化
      },

      // 取得失敗時の処理
      (err) => {
        let message = '';
        if (err.code === err.PERMISSION_DENIED) {
          message = '位置情報の利用が拒否されました。ブラウザの設定で許可してください。';
        } else if (err.code === err.POSITION_UNAVAILABLE) {
          message = '位置情報を取得できませんでした(信号なし・機内モードなど)。';
        } else if (err.code === err.TIMEOUT) {
          message = 'タイムアウトしました。電波状況を確認して再試行してください。';
        } else {
          message = '不明なエラーが発生しました。';
        }
        statusEl.innerHTML = `<strong class="is-error">取得失敗:</strong>${message}`;
        btnEl.disabled = false;
      },

      // Geolocation API のオプション
      { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
    );
  });
})();

今回のデモでも、現在地の取得に「Geolocation API」を使用しています。成功時・失敗時の挙動や、二重クリック防止の実装などは、前回の記事で詳しく解説しています。

あわせて読みたい
JavaScriptで現在地を取得してGoogleマップを移動させる|Geolocation API実装ガイド
JavaScriptで現在地を取得してGoogleマップを移動させる|Geolocation API実装ガイド

Geolocation APIの仕組み自体をもう少し深く理解したい方は、ぜひ合わせてご覧ください。

コードのポイント解説

ここからは、今回のデモで重要になる箇所をピックアップして解説します。距離の計算、データへの距離追加、並べ替え、テンプレート生成の4つがポイントです。それぞれの処理がどんな役割を持っているのか、順番に見ていきましょう。

ポイント1:距離を計算する関数(Haversine公式)

まずは、現在地と各駅との距離を計算する部分です。今回のデモではHaversine(ハバーサイン)公式を使って距離を求めています。

function getDistanceKm(lat1, lng1, lat2, lng2) {
  const R = 6371; // 地球の半径(km)
  const toRad = d => d * Math.PI / 180;
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
    Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

Haversine公式とは?

ハバーサイン公式は、緯度・経度(地球上の2点)から、地表に沿った距離を求めるための計算方法です。ざっくり言うと、地球を球体として扱い、表面の最短距離(弧の長さ)を出すための“定番の式”だと思ってください。

なぜHaversineを使うのか?

緯度・経度は「地球上の角度情報」なので、単純な直線計算(平面として扱う)では厳密には距離にズレが生まれます。Haversineを使えば地球の曲率を考慮した距離を求められるため、距離計算の定番として実務でも広く採用されています。

日本国内のような短〜中距離の比較であればそのズレは小さく、順位付けなどの用途では問題にならないケースも多いでしょう。ただ、扱う距離のスケールが広がった場合でも安心して使えるように、最初からHaversine公式で距離を計算しておくのがおすすめです。

数式を完璧に理解しなくても大丈夫

ここで数式を暗記したり、理論を深掘りする必要はありません。今回のように「緯度・経度がある複数地点を、距離順に並べたい」という場面で、この関数をそのまま使えばOK。“距離を出すための道具として使える”ことを押さえておけば十分です。

ポイント2:駅データに距離を追加するmapの使い方

次に重要なのが、駅データそれぞれに「現在地からの距離」を追加する処理です。今回のデモでは、mapを使って、元の駅データに距離情報を足した新しい配列を作っています。

const withDistance = stations.map(station => ({
  ...station,
  distanceKm: getDistanceKm(latitude, longitude, station.lat, station.lng)
}));

何をしている処理?

ここでやっていることはシンプルです。

  1. stations(10駅の配列)を1件ずつ取り出す
  2. 各駅に対して「距離(distanceKm)」を計算する
  3. 元の駅情報にその距離を追加した新しい配列を作る

結果として、配列の中身はこんな形になります。

{
  name: "東京駅",
  lat: 35.6812,
  lng: 139.7671,
  address: "...",
  opened: "...",
  distanceKm: 1.87  // ← ここが追加される
}

...station(スプレッド構文)の意味

...station

これは駅データstationの中身をそのままコピーして、そこに新しいプロパティdistanceKmを追加するための書き方です。もし...stationがないと、nameaddressなど既存の情報が消えてしまうので、「元の駅情報+距離情報」をまとめて持たせたいときの定番パターンになります。

ポイント3:sortで距離の近い順に並べ替える処理

距離つきの駅データが作れたら、次はそれを「近い順(距離が小さい順)」に並べ替えるステップです。ここで使うのがsortです。

const withDistance = stations.map(station => ({
  ...station,
  distanceKm: getDistanceKm(latitude, longitude, station.lat, station.lng)
})).sort((a, b) => a.distanceKm - b.distanceKm);

sortの基本の考え方

sortは配列の要素を2つずつ比べ、内部的にペア比較を繰り返しながら順番を入れ替えて並べ替えます。その判断は、比較関数の返り値で決まるというルールがあります。

array.sort((a, b) => ルール)
  • 返り値がマイナス → aを前へ
  • プラス → bを前へ
  • 0 → 順番はそのまま

距離が近い順になる理由

今回のルールはこれです。

(a, b) => a.distanceKm - b.distanceKm

距離が小さいほうが前に来てほしいので、2つの距離を引き算して、

  • aのほうが近い(小さい)ならマイナス → aが前
  • bのほうが近いならプラス → bが前

という形で、自然に「近い順」に並びます。

ポイント4:<template>を使ってカードを生成する処理

並べ替えた駅データを画面に表示する部分では、HTMLの<template>要素を使っています。

<template>は、画面には表示されない“HTMLのひな型置き場”のようなものです。あらかじめリスト1件分のHTML構造をテンプレートとして用意しておき、JavaScriptで必要な回数だけ複製して使えます。

今回のテンプレートは以下です。

<template id="geo-item-tpl">
  <li class="geo-card">
    <div class="geo-card-inner">

      <!-- 上段:駅名・順位・距離 -->
      <div class="geo-card-head">
        <div class="geo-card-title">
          <!-- 駅の順位(近い順) -->
          <p class="geo-rank"><span></span></p>
          <!-- 駅名 -->
          <p class="geo-name"></p>
        </div>
        <!-- 現在地からの距離 -->
        <p class="geo-distance"><span></span>km</p>
      </div>

      <!-- 下段:駅の詳細情報(住所・開業年) -->
      <ul class="geo-card-info">
        <li>
          <p class="geo-label">住所:</p>
          <p class="geo-value is-address"></p>
        </li>
        <li>
          <p class="geo-label">開業:</p>
          <p class="geo-value is-opened"></p>
        </li>
      </ul>
    </div>
  </li>
</template>

テンプレートを複製して使う処理

JavaScript側では、テンプレートを複製して中身を書き換え、リストとして追加しています。

function renderList(items) {
  listEl.innerHTML = ''; // 一度クリア

  const ul = document.createElement('ul');
  const frag = document.createDocumentFragment();

  items.forEach((item, i) => {
    // テンプレートを複製し、駅情報を代入
    const node = itemTpl.content.cloneNode(true);

    node.querySelector('.geo-rank span').textContent = String(i + 1);
    node.querySelector('.geo-name').textContent = item.name;
    node.querySelector('.geo-distance span').textContent = item.distanceKm.toFixed(2);
    node.querySelector('.geo-value.is-address').textContent = item.address;
    node.querySelector('.geo-value.is-opened').textContent = item.opened;

    frag.appendChild(node);
  });

  ul.appendChild(frag); // li をまとめて ul に追加
  listEl.appendChild(ul); // 完成したリストを表示
}

何をしているのか?

流れとしては、次の3ステップです。

テンプレートを複製する

const node = itemTpl.content.cloneNode(true);

cloneNode(true)により、テンプレートの中身(li構造)を丸ごとコピーします。

コピーしたカードにデータを差し込む

node.querySelector('.geo-name').textContent = item.name;

テンプレート内の空欄に、駅名・距離・住所などを埋めていきます。

まとめて一気にDOMへ追加する

frag.appendChild(node);

最後に、作成したカード要素はいったんまとめてから、一気にDOMへ追加しています。こうすることで、表示処理が無駄に重くならず、スムーズにリストを描画できます。

DocumentFragmentとは?

DocumentFragmentは、画面に直接表示されない「一時的な入れ物」です。ここに要素を追加している間はブラウザの再描画(レイアウト計算や描画処理)が走りません。

もし毎回listEl.appendChild(node)のようにDOMへ直接追加すると、追加のたびに画面更新が発生し、要素数が多いと動作が重くなりがちです。そこで、いったんfragにまとめておき、最後に一度だけ挿入することで、画面更新が1回で済み、表示が軽くなります。

JavaScript側でHTMLを組み立てる方法もある

<template>を使う方法以外に、JavaScript側でHTML文字列を作って差し込むやり方もあります。

  • 文字列方式は短く書けて仕組みもシンプルなので、試作や簡単なリストには便利
  • 一方で構造が複雑になると、HTMLがJSに長くベタ書きされて見通しが悪くなりやすいという面もあります

今回のデモはカード構造がやや複雑で、あとからHTML側を調整しやすい形にしたかったため、
HTMLにひな型を置ける<template>を採用しました。短いリストなら文字列で組み立てる方法でも十分なので、場面に応じて使い分ければOKです。

まとめ

今回は、JavaScriptで現在地を取得し、地点データを現在地から近い順に並べ替えて上位5件だけ表示する方法を紹介しました。

距離計算とソートの流れが作れるようになると、店舗検索や周辺スポット表示など、実務でよくある「近い順リスト」の機能にそのまま応用できます。ぜひ自分の案件やデータに置き換えて試してみてください!

押していただけると励みになります!

ABOUT ME
みなと
みなと
フロントエンドエンジニア
東京のWeb制作会社で15年以上働いている現役フロントエンドエンジニアです。これまで、いろんなプロジェクトに関わりながら、フロントエンド開発やWebデザインに取り組んできました。このブログでは、今までの経験を活かして、Web制作に役立つ情報やノウハウをシェアしていきたいと思います。初心者の方から、現場で働く方まで、誰でも参考にできる内容をお届けしますので、ぜひ覗いてみてください。
記事URLをコピーしました