地形断面プロフィールビューア ― 無線伝搬経路の見通し確認

放送・中継現場でマイクロ波などの無線伝送経路を検討するとき、A地点とB地点の間に山や建物の基礎地形があるか、海上区間がどの程度あるかを早い段階で把握したい場面があります。本アプリは、地図上の2地点を結ぶ地形断面を表示し、B地点を移動させながら見通しの変化を確認するための技術検証用Webアプリです。

地形断面プロフィールビューアを開く

本アプリは技術検証用として公開しています。
地図表示、地名検索、標高取得には外部サービスを利用しているため、各サービスの利用制限、仕様変更、障害等により動作しない場合があります。
地形断面、見通し線、水面候補は概略確認用であり、無線回線の成立や通信品質を保証するものではありません。重要な回線設計では、現地調査、正式な地形資料、回線設計計算等を併用してください。

本アプリは地形と見通しを概略確認する補助ツールです。無線回線の成立を保証するものではありません。実際の回線設計では、周波数、送信電力、アンテナ利得、給電損失、自由空間損失、フレネルゾーン、回折、反射、干渉、気象条件などを別途確認する必要があります。


できること

  • 地名検索または地図クリックによるA地点・B地点の設定
  • A/B間の直線距離表示
  • A/B間を分割し、外部標高APIから取得した地形断面の描画
  • A/Bの高度オフセットを加えた見通し直線の表示
  • 標高0m付近が連続する区間の「水面候補」表示
  • B地点移動コースのJSON読込とアニメーション再生
  • 地点情報のJSON保存と共有URL生成
  • OpenStreetMapとMapboxの背景地図切替

無線伝搬で水面候補を表示する理由

海、湖、河川などの水面を含む伝搬経路では、直接波に加えて水面反射波が受信点へ到達し、両者の位相関係によって受信レベルが大きく変動することがあります。このため、断面図上で水面らしい区間を把握できることは、マルチパスやフェージングの可能性を検討するうえで有用です。

ただし、本アプリの水面候補は標高値だけから推定しています。海抜0m付近の陸地も含まれる可能性があるため、確定的な水域判定ではありません。地図、航空写真、現地情報などと併用してください。

使用しているライブラリと外部サービス

  • Leaflet:地図、マーカー、線、距離表示
  • Chart.js:地形断面、見通し直線、水面候補の描画
  • OpenStreetMap標準タイル:背景地図
  • Mapbox:任意選択の背景地図
  • Nominatim:地名・住所検索
  • Open-Elevation:A/B間の標高取得

自作コードはMIT Licenseで公開していますが、外部ライブラリ、地図データ、タイルサーバー、検索API、標高APIにはそれぞれ別の利用条件があります。帰属表示を削除せず、アクセス制限と各サービスの最新条件を確認して利用してください。

地名検索時には検索文字列をNominatimへ、地形断面計算時にはA地点とB地点間の座標列をOpen-Elevationへ送信します。現在地機能を使用した場合、その座標も地形断面計算の対象となることがあります。機密性のある地点情報は入力しないでください。

処理の全体像

  • HTML上の画面部品を取得し、Leaflet地図を初期化する
  • A地点・B地点のマーカーと距離線を生成する
  • 地名検索、地図クリック、JSONから地点を設定する
  • A/B間を複数の座標へ分割する
  • Open-Elevationから各座標の標高を取得する
  • A/Bの高度オフセットを加えて見通し直線を計算する
  • Chart.jsで地形断面、水面候補、見通し直線を描画する
  • B地点コースを移動させ、断面を逐次更新する

フローチャート

フローチャート

※標高取得にはアクセス間隔制御とキャッシュを追加しています


コード解説

以下は、2026年7月時点のコードを機能単位で分けた解説です。行番号はこの掲載版を基準としており、今後の修正で変わる可能性があります。

MITライセンス(1〜23行)

役割:自作部分の利用条件を明示します。著作権表示と許諾文を残すことを条件に、利用・改変・再配布を認めています。

注意点:Leaflet、Chart.js、OpenStreetMap、Mapbox、Nominatim、Open-Elevationには、それぞれ別のライセンスや利用条件が適用されます。

/*!
MIT License

Copyright (c) 2026 Sketlab LLC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
画面部品の取得・定数・入力検証(25〜98行)

役割:HTML上の入力欄、ボタン、地図、グラフなどをJavaScriptから操作できるように参照を取得します。同時に、外部APIのアクセス間隔、JSON上限、座標検証などの共通設定を定義します。

入力:index.html内の各要素ID、緯度・経度、JSON地点データ。

出力:DOM要素の参照、共通定数、座標を正規化した地点オブジェクト。

注意点:緯度は−90〜90、経度は−180〜180に制限します。Leafletが読み込めない場合は処理を停止します。

const statusEl = document.getElementById("status");
const aSelect = document.getElementById("aSelect");
const aJsonFile = document.getElementById("aJsonFile");
const createUrlButton = document.getElementById("createUrl");
const aLatInput = document.getElementById("aLat");
const aLngInput = document.getElementById("aLng");
const bLatInput = document.getElementById("bLat");
const bLngInput = document.getElementById("bLng");
const aOffsetInput = document.getElementById("aOffset");
const bOffsetInput = document.getElementById("bOffset");
const pickAButton = document.getElementById("pickA");
const pickBButton = document.getElementById("pickB");
const aNameInput = document.getElementById("aName");
const bNameInput = document.getElementById("bName");
const exportAButton = document.getElementById("exportA");
const exportBButton = document.getElementById("exportB");
const mapProviderSelect = document.getElementById("mapProvider");
const mapboxTokenInput = document.getElementById("mapboxToken");
const saveMapboxButton = document.getElementById("saveMapbox");
const mapElement = document.getElementById("map");
const mapError = document.getElementById("mapError");
const mapSearchInput = document.getElementById("mapSearchInput");
const mapSearchButton = document.getElementById("mapSearchButton");
const locateMeButton = document.getElementById("locateMe");
const mapSearchStatus = document.getElementById("mapSearchStatus");
const bJsonFile = document.getElementById("bJsonFile");
const stepBackButton = document.getElementById("stepBack");
const stepForwardButton = document.getElementById("stepForward");
const playButton = document.getElementById("play");
const reverseButton = document.getElementById("reverse");
const speedButton = document.getElementById("speed");
const resetButton = document.getElementById("reset");
const stopButton = document.getElementById("stop");
const autoFitCheckbox = document.getElementById("autoFit");
const rememberMapboxCheckbox = document.getElementById("rememberMapbox");

const NOMINATIM_MIN_INTERVAL_MS = 1100;
const ELEVATION_MIN_INTERVAL_MS = 1200;
const MAX_JSON_FILE_BYTES = 2 * 1024 * 1024;
const MAX_TRACK_POINTS = 5000;
const elevationCache = new Map();
const searchCache = new Map();
let lastNominatimRequestAt = 0;
let lastElevationRequestAt = 0;
let elevationAbortController = null;
let profileRequestSerial = 0;
let profileDebounceTimer = null;

const setStatus = (message, type = "info") => {
  statusEl.textContent = message;
  statusEl.dataset.type = type;
};

const sleep = (milliseconds) => new Promise((resolve) => window.setTimeout(resolve, milliseconds));

const isValidLatitude = (value) => Number.isFinite(value) && value >= -90 && value <= 90;
const isValidLongitude = (value) => Number.isFinite(value) && value >= -180 && value <= 180;

const validatePoint = (point) => {
  const lat = Number.parseFloat(point?.lat);
  const lng = Number.parseFloat(point?.lng ?? point?.lon);
  if (!isValidLatitude(lat) || !isValidLongitude(lng)) return null;
  return {
    name: String(point?.name || point?.label || point?.id || "").slice(0, 100),
    lat,
    lng,
    offset: Number.isFinite(Number.parseFloat(point?.offset)) ? Number.parseFloat(point.offset) : 0,
  };
};

if (!window.L) {
  setStatus("地図ライブラリの読み込みに失敗しました。ローカルサーバーで起動してください。", "warn");
  throw new Error("Leaflet failed to load");
}
地図生成・背景タイル・Mapbox設定(100〜142行)

役割:Leaflet地図を生成し、OpenStreetMapまたはMapboxの背景地図を読み込みます。前回選択したプロバイダーと、保存を許可した場合のMapboxトークンも復元します。

入力:地図プロバイダー、Mapbox公開トークン、localStorage。

出力:Leafletの地図と背景タイル。読込成功・失敗状態も画面へ反映します。

注意点:Mapboxでは公開用かつ利用URLを制限したトークンを使用します。帰属表示は地図上から削除しません。

const map = L.map("map").setView([35.681236, 139.767125], 12);
let currentTileLayer = null;

const loadTileLayer = (provider, token) => {
  if (currentTileLayer) {
    currentTileLayer.remove();
  }
  if (provider === "mapbox" && token) {
    currentTileLayer = L.tileLayer(
      "https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/{z}/{x}/{y}?access_token={accessToken}",
      {
        maxZoom: 20,
        tileSize: 512,
        zoomOffset: -1,
        accessToken: token,
        attribution:
          '&copy; <a href="https://www.mapbox.com/about/maps/" target="_blank" rel="noopener noreferrer">Mapbox</a> ' +
          '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors',
      }
    );
  } else {
    currentTileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
      maxZoom: 19,
      attribution:
        '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors',
    });
  }
  currentTileLayer.on("load", () => {
    mapElement.classList.add("map-ready");
  });
  currentTileLayer.on("tileerror", () => {
    mapElement.classList.remove("map-ready");
    mapError.textContent = "地図タイルの読み込みに失敗しました。ネットワークやトークン設定を確認してください。";
  });
  currentTileLayer.addTo(map);
};

const storedProvider = localStorage.getItem("mapProvider") || "osm";
const storedToken = localStorage.getItem("mapboxToken") || "";
mapProviderSelect.value = storedProvider;
mapboxTokenInput.value = storedToken;
rememberMapboxCheckbox.checked = Boolean(storedToken);
loadTileLayer(storedProvider, storedToken);
A/Bマーカー・線・状態変数の準備(144〜240行)

役割:A地点とB地点のマーカー、B地点の移動経路、A/B間の直線、距離ラベルを生成します。地点名や現在位置、再生状態、グラフ状態も保持します。

入力:初期座標、地点名入力、オートフィット設定。

出力:地図上のマーカー、線、ツールチップ、内部状態。

注意点:地点名を変更するとマーカー表示も更新されます。オートフィット有効時はA/B両地点が画面内に収まるよう縮尺を調整します。

const aIcon = L.divIcon({
  className: "marker-a",
  html: "<div class=\"marker-icon marker-a\"></div>",
  iconSize: [18, 18],
  iconAnchor: [9, 9],
});
const bIcon = L.divIcon({
  className: "marker-b",
  html: "<div class=\"marker-icon marker-b\"></div>",
  iconSize: [18, 18],
  iconAnchor: [9, 9],
});

const aMarker = L.marker([35.681236, 139.767125], { draggable: false, icon: aIcon }).addTo(map);
const bMarker = L.marker([35.689487, 139.691706], { draggable: false, icon: bIcon }).addTo(map);
const bPath = L.polyline([], { color: "#b91c1c", weight: 3 }).addTo(map);
const abLine = L.polyline([], { color: "#1d4ed8", weight: 3, dashArray: "6 4" }).addTo(map);
const bTrackMarkers = L.layerGroup().addTo(map);
const abDistanceTooltip = L.tooltip({
  permanent: true,
  direction: "center",
  className: "distance-label",
  offset: [0, 0],
}).setContent("");
let searchMarker = null;
let tempMarker = null;
let userMarker = null;
const lastSearchQuery = {
  A: "",
  B: "",
};
let aLabel = "A地点";
let bLabel = "B地点";
let aName = "";
let bName = "";
let lastSearchResult = null;
let lastTempLocation = null;

aMarker.bindTooltip("A地点", { direction: "right", offset: [8, 0], className: "marker-label" });
bMarker.bindTooltip("B地点", { direction: "right", offset: [8, 0], className: "marker-label" });

aMarker.on("click", () => aMarker.openTooltip());
bMarker.on("click", () => bMarker.openTooltip());

aNameInput.addEventListener("input", () => {
  aName = aNameInput.value.trim();
  aLabel = aName || "A地点";
  aMarker.setTooltipContent(aLabel);
  scheduleProfileRender();
});

bNameInput.addEventListener("input", () => {
  bName = bNameInput.value.trim();
  bLabel = bName || "B地点";
  bMarker.setTooltipContent(bLabel);
  scheduleProfileRender();
});

const defaultAData = "data/a_points.json";
let aPoints = [];
let bTrack = [];
let currentBIndex = 0;
let playTimer = null;
let playDirection = 1;
let playSpeed = 1600;
let playMode = "normal";

const chartCtx = document.getElementById("profileChart");
let profileChart = null;

const updateInputsFromMarker = () => {
  const aLatLng = aMarker.getLatLng();
  aLatInput.value = aLatLng.lat.toFixed(6);
  aLngInput.value = aLatLng.lng.toFixed(6);
  const bLatLng = bMarker.getLatLng();
  bLatInput.value = bLatLng.lat.toFixed(6);
  bLngInput.value = bLatLng.lng.toFixed(6);
  aNameInput.value = aName;
  bNameInput.value = bName;
};

const syncNamesFromInputs = () => {
  aName = aNameInput.value.trim();
  bName = bNameInput.value.trim();
  aLabel = aName || "A地点";
  bLabel = bName || "B地点";
  aMarker.setTooltipContent(aLabel);
  bMarker.setTooltipContent(bLabel);
};

const maybeFitMap = () => {
  if (!autoFitCheckbox?.checked) return;
  const aLatLng = aMarker.getLatLng();
  const bLatLng = bMarker.getLatLng();
  const bounds = L.latLngBounds([aLatLng, bLatLng]);
  map.fitBounds(bounds.pad(0.25));
};
距離線・A地点リスト・B移動コース(242〜350行)

役割:A/B間の距離を計算して表示し、A地点リストの選択、B地点コースJSONの解析、経路線と各地点の描画を行います。

入力:A地点配列、B地点配列、現在のマーカー位置。

出力:距離表示、A地点選択肢、B経路線、B地点マーカー、地形断面再計算。

注意点:JSONの座標は検証後に使用します。地点数が多い場合は描画・API取得に時間がかかるため上限を設けています。

const updateLine = () => {
  const aLatLng = aMarker.getLatLng();
  const bLatLng = bMarker.getLatLng();
  abLine.setLatLngs([aLatLng, bLatLng]);
  const midLat = (aLatLng.lat + bLatLng.lat) / 2;
  const midLng = (aLatLng.lng + bLatLng.lng) / 2;
  const distanceKm = map.distance(aLatLng, bLatLng) / 1000;
  const roundedDistance = Math.round(distanceKm * 10) / 10;
  abDistanceTooltip.setLatLng([midLat, midLng]);
  abDistanceTooltip.setContent(`${roundedDistance.toFixed(1)} km`);
  if (!map.hasLayer(abDistanceTooltip)) {
    abDistanceTooltip.addTo(map);
  }
  maybeFitMap();
};

const loadAData = (points) => {
  const validatedPoints = Array.isArray(points)
    ? points.map(validatePoint).filter(Boolean).slice(0, MAX_TRACK_POINTS)
    : [];
  aPoints = validatedPoints;
  aSelect.innerHTML = "";
  validatedPoints.forEach((point, index) => {
    const option = document.createElement("option");
    option.value = index;
    option.textContent = point.name || point.label || "A地点";
    aSelect.appendChild(option);
  });
  if (validatedPoints.length > 0) {
    selectAPoint(0);
  }
};

const selectAPoint = (index) => {
  const point = aPoints[index];
  if (!point) return;
  aMarker.setLatLng([point.lat, point.lng]);
  aLabel = point.name || "A地点";
  aName = point.name || "";
  aMarker.setTooltipContent(aLabel);
  if (point.offset !== undefined && point.offset !== null && !Number.isNaN(parseFloat(point.offset))) {
    aOffsetInput.value = parseFloat(point.offset).toFixed(1);
  }
  map.setView([point.lat, point.lng], 12);
  updateInputsFromMarker();
  updateLine();
  scheduleProfileRender();
};

const parseTrackJson = (text) => {
  const data = JSON.parse(text);
  const rows = Array.isArray(data) ? data : [data];
  const track = rows.map(validatePoint).filter(Boolean);
  if (track.length === 0) {
    throw new Error("有効な座標を含むJSONではありません");
  }
  if (track.length > MAX_TRACK_POINTS) {
    throw new Error(`地点数が多すぎます(上限 ${MAX_TRACK_POINTS} 点)`);
  }
  return track.map((item) => ({
    name: item.name || "B地点",
    lat: item.lat,
    lng: item.lng,
    offset: item.offset,
  }));
};

const setBTrack = (track) => {
  bTrack = track;
  currentBIndex = 0;
  bTrackMarkers.clearLayers();
  if (track.length === 0) {
    bPath.setLatLngs([]);
    return;
  }
  bPath.setLatLngs(track.map((point) => [point.lat, point.lng]));
  track.forEach((point) => {
    const marker = L.circleMarker([point.lat, point.lng], {
      radius: 4,
      color: "#b91c1c",
      weight: 1,
      fillColor: "#ef4444",
      fillOpacity: 0.9,
    });
    marker.bindTooltip(point.name || "B地点", {
      direction: "right",
      offset: [8, 0],
      className: "marker-label",
    });
    bTrackMarkers.addLayer(marker);
  });
  updateBMarkerFromTrack();
};

const updateBMarkerFromTrack = () => {
  if (bTrack.length === 0) return;
  const point = bTrack[currentBIndex];
  bMarker.setLatLng([point.lat, point.lng]);
  bLatInput.value = point.lat.toFixed(6);
  bLngInput.value = point.lng.toFixed(6);
  if (point.offset !== undefined && point.offset !== null && !Number.isNaN(parseFloat(point.offset))) {
    bOffsetInput.value = parseFloat(point.offset).toFixed(1);
  }
  bLabel = point.name || "B地点";
  bName = point.name || "";
  bMarker.setTooltipContent(bLabel);
  updateLine();
  scheduleProfileRender();
};
B地点コースのステップ・通常再生・逆再生(352〜403行)

役割:B地点をコース上で1点ずつ移動し、通常再生、逆再生、高速再生を制御します。

入力:移動方向、再生モード、現在のB地点インデックス。

出力:B地点位置、距離線、地形断面グラフの更新。

注意点:高速再生でも標高APIへ無制限に要求しないよう逐次処理にしています。停止時はタイマーを解除します。

const stepTrack = (direction) => {
  if (bTrack.length === 0) return;
  currentBIndex += direction;
  if (currentBIndex < 0) currentBIndex = 0;
  if (currentBIndex > bTrack.length - 1) currentBIndex = bTrack.length - 1;
  updateBMarkerFromTrack();
};

const playTrack = (direction, mode = "normal") => {
  stopPlay();
  playDirection = direction;
  playMode = mode;
  playSpeed = mode === "fast" ? 250 : 1600;

  const advance = async () => {
    if (bTrack.length === 0) {
      stopPlay();
      return;
    }

    currentBIndex += playDirection;
    if (currentBIndex <= 0) {
      currentBIndex = 0;
    }
    if (currentBIndex >= bTrack.length - 1) {
      currentBIndex = bTrack.length - 1;
    }

    updateBMarkerFromTrack();
    await renderProfile();

    const reachedStart = currentBIndex === 0 && playDirection < 0;
    const reachedEnd = currentBIndex === bTrack.length - 1 && playDirection > 0;
    if (reachedStart || reachedEnd || !playTimer) {
      stopPlay();
      return;
    }

    playTimer = window.setTimeout(advance, playSpeed);
  };

  playTimer = window.setTimeout(advance, 0);
};

const stopPlay = () => {
  if (playTimer) {
    window.clearTimeout(playTimer);
    playTimer = null;
  }
  speedButton.classList.remove("active");
};
地名検索と検索結果の地点反映(404〜541行)

役割:Nominatimへ地名・住所を問い合わせ、検索結果を地図上の仮マーカーとして表示します。その位置をA地点またはB地点へ反映できます。

入力:検索文字列、対象地点A/B、Nominatimの検索結果。

出力:検索結果マーカー、地図移動、A/B地点座標と地点名。

注意点:同一検索語はキャッシュし、公開Nominatimへの要求間隔を最低1.1秒に制限します。検索語と通信元情報は外部サービスへ送信されます。

const getOffsetValue = (input) => {
  const value = parseFloat(input.value);
  return Number.isNaN(value) ? 0 : value;
};

const setSearchStatus = (message) => {
  mapSearchStatus.textContent = message;
};

const applySearchResult = (target) => {
  if (!lastSearchResult) {
    setStatus("検索結果がありません", "warn");
    return;
  }
  const { lat, lng, query } = lastSearchResult;
  const targetMarker = target === "B" ? bMarker : aMarker;
  targetMarker.setLatLng([lat, lng]);
  if (target === "A") {
    aLabel = query || "A地点";
    aName = query || "";
  } else {
    bLabel = query || "B地点";
    bName = query || "";
  }
  updateInputsFromMarker();
  updateLine();
  renderProfile();
  setStatus(`検索結果を${target}地点に設定しました`, "success");
};

const applyTempMarker = (target) => {
  if (!lastTempLocation) {
    setStatus("一時マーカーがありません", "warn");
    return;
  }
  const { lat, lng } = lastTempLocation;
  const targetMarker = target === "B" ? bMarker : aMarker;
  targetMarker.setLatLng([lat, lng]);
  if (target === "A") {
    aLabel = "A地点";
    aName = aName || "";
  } else {
    bLabel = "B地点";
    bName = bName || "";
  }
  updateInputsFromMarker();
  updateLine();
  renderProfile();
  setStatus(`マーカー位置を${target}地点に設定しました`, "success");
};

const searchLocation = async () => {
  const query = mapSearchInput.value.trim();
  if (!query) {
    setSearchStatus("検索キーワードを入力してください。");
    return;
  }

  const cached = searchCache.get(query);
  if (cached) {
    showSearchResult(cached, query);
    return;
  }

  setSearchStatus("検索中...");
  setStatus("地図検索中...", "info");
  mapSearchButton.disabled = true;

  try {
    const waitMs = Math.max(0, NOMINATIM_MIN_INTERVAL_MS - (Date.now() - lastNominatimRequestAt));
    if (waitMs > 0) await sleep(waitMs);
    lastNominatimRequestAt = Date.now();

    const params = new URLSearchParams({
      format: "jsonv2",
      limit: "1",
      "accept-language": "ja",
      q: query,
      email: "t-matsuoka@sketlab.jp",
    });
    const response = await fetch(`https://nominatim.openstreetmap.org/search?${params.toString()}`, {
      headers: { Accept: "application/json" },
    });
    if (!response.ok) {
      throw new Error(`検索に失敗しました(HTTP ${response.status})`);
    }
    const results = await response.json();
    if (!Array.isArray(results) || results.length === 0) {
      setSearchStatus("該当する地点が見つかりませんでした。");
      setStatus("検索結果がありません", "warn");
      return;
    }

    const result = results[0];
    const lat = Number.parseFloat(result.lat);
    const lng = Number.parseFloat(result.lon);
    if (!isValidLatitude(lat) || !isValidLongitude(lng)) {
      throw new Error("検索結果の座標が不正です");
    }

    const normalized = {
      lat,
      lng,
      displayName: String(result.display_name || query).slice(0, 300),
    };
    searchCache.set(query, normalized);
    showSearchResult(normalized, query);
  } catch (error) {
    setSearchStatus("検索に失敗しました。時間を空けて再試行してください。");
    setStatus(error instanceof Error ? error.message : "地図検索に失敗しました", "warn");
  } finally {
    mapSearchButton.disabled = false;
  }
};

const showSearchResult = (result, query) => {
  const { lat, lng, displayName } = result;
  lastSearchResult = { lat, lng, query };
  lastTempLocation = { lat, lng };

  if (!searchMarker) {
    searchMarker = L.marker([lat, lng], { opacity: 0.85 }).addTo(map);
  } else {
    searchMarker.setLatLng([lat, lng]);
  }

  if (tempMarker) {
    tempMarker.remove();
    tempMarker = null;
  }

  map.setView([lat, lng], 14);
  lastSearchQuery.A = query;
  lastSearchQuery.B = query;
  setSearchStatus(displayName || "検索結果を表示しました。");
  setStatus("検索結果に移動しました", "success");
};
地点JSONの作成・保存・再読込(542〜633行)

役割:現在のA地点またはB地点をJSON形式にし、対応ブラウザでは既存ファイルへ保存、非対応ブラウザではダウンロードとして保存します。

入力:地点名、座標、検索語、高度オフセット。

出力:地点JSONファイル、更新済み地点リスト。

注意点:保存形式は配列に統一しています。ファイル選択APIはブラウザによって利用できないため、ダウンロード方式へフォールバックします。

const buildExportData = (label, marker, query, offset, name) => {
  const latLng = marker.getLatLng();
  return {
    label,
    name: name || "",
    lat: Number(latLng.lat.toFixed(6)),
    lng: Number(latLng.lng.toFixed(6)),
    offset: Number(offset.toFixed(1)),
    query: query || "",
  };
};

const reloadSavedPoints = async (label, handle) => {
  try {
    const file = await handle.getFile();
    const text = await file.text();
    const data = JSON.parse(text);
    if (!Array.isArray(data)) {
      throw new Error("形式が不正です");
    }
    if (label === "A") {
      loadAData(data);
      setStatus("A地点リストを再読み込みしました", "success");
      return;
    }
    if (label === "B") {
      const track = parseTrackJson(JSON.stringify(data));
      setBTrack(track);
      currentBIndex = 0;
      updateBMarkerFromTrack();
      setStatus("B地点コースを再読み込みしました", "success");
    }
  } catch (error) {
    setStatus("保存したファイルの再読み込みに失敗しました", "warn");
  }
};

const exportLocation = async (label, marker, query, offset, name) => {
  const payload = buildExportData(label, marker, query, offset, name);
  const content = `${JSON.stringify([payload], null, 2)}\n`;

  if (window.showSaveFilePicker) {
    try {
      if (!window.showOpenFilePicker) {
        setStatus("このブラウザでは追記保存に対応していません", "warn");
      }
      const [openHandle] = window.showOpenFilePicker
        ? await window.showOpenFilePicker({
            types: [{ description: "JSON", accept: { "application/json": [".json"] } }],
          })
        : [
            await window.showSaveFilePicker({
              suggestedName: `${label}_location.json`,
              types: [{ description: "JSON", accept: { "application/json": [".json"] } }],
            }),
          ];
      const file = await openHandle.getFile();
      const existing = await file.text();
      const trimmed = existing.trim();
      let entries = [];
      if (trimmed) {
        try {
          const parsed = JSON.parse(trimmed);
          entries = Array.isArray(parsed) ? parsed : [parsed];
        } catch (error) {
          entries = [{ raw: trimmed }];
        }
      }
      entries.push(payload);
      const writable = await openHandle.createWritable({ keepExistingData: false });
      await writable.write(`${JSON.stringify(entries, null, 2)}\n`);
      await writable.close();
      setStatus("座標を追記しました", "success");
      await reloadSavedPoints(label, openHandle);
      return;
    } catch (error) {
      setStatus("ファイルへの書き出しに失敗しました", "warn");
    }
  }

  const blob = new Blob([content], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const anchor = document.createElement("a");
  anchor.href = url;
  anchor.download = `${label}_location.json`;
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
  URL.revokeObjectURL(url);
  setStatus("座標を書き出しました", "success");
};
断面サンプル点・標高API・再描画制御(634〜721行)

役割:A/B間を一定数に分割して標高取得用の座標列を作り、Open-Elevationへ問い合わせます。連続操作では古い要求を中止し、最新結果だけをグラフへ反映します。

入力:A/B座標、2地点間距離、サンプル数、Open-Elevation応答。

出力:距離付き標高データ、グラフ再描画要求。

注意点:同一座標列はメモリーキャッシュを使用します。要求間隔は最低1.2秒です。外部APIの応答値は測量値ではなく、伝搬検討の参考値として扱います。

const computeProfilePoints = (aLatLng, bLatLng, samples = 60, totalDistanceKm = 0) => {
  const points = [];
  for (let i = 0; i <= samples; i += 1) {
    const t = i / samples;
    points.push({
      lat: aLatLng.lat + (bLatLng.lat - aLatLng.lat) * t,
      lng: aLatLng.lng + (bLatLng.lng - aLatLng.lng) * t,
      distance: t,
      distanceKm: totalDistanceKm * t,
    });
  }
  return points;
};

const fetchElevations = async (points, signal) => {
  const cacheKey = points
    .map((point) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`)
    .join("|");
  const cached = elevationCache.get(cacheKey);
  if (cached) return cached;

  const waitMs = Math.max(0, ELEVATION_MIN_INTERVAL_MS - (Date.now() - lastElevationRequestAt));
  if (waitMs > 0) await sleep(waitMs);
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
  lastElevationRequestAt = Date.now();

  const locations = points.map((point) => `${point.lat},${point.lng}`).join("|");
  const url = `https://api.open-elevation.com/api/v1/lookup?locations=${encodeURIComponent(locations)}`;
  const response = await fetch(url, {
    signal,
    headers: { Accept: "application/json" },
  });
  if (!response.ok) {
    throw new Error(`標高APIへのアクセスに失敗しました(HTTP ${response.status})`);
  }

  const data = await response.json();
  if (!Array.isArray(data.results) || data.results.length !== points.length) {
    throw new Error("標高データの件数が一致しません");
  }

  const elevations = data.results.map((result, index) => {
    const elevation = Number.parseFloat(result.elevation);
    if (!Number.isFinite(elevation)) {
      throw new Error("標高データに不正な値があります");
    }
    return {
      ...points[index],
      elevation,
    };
  });
  elevationCache.set(cacheKey, elevations);
  return elevations;
};

const scheduleProfileRender = (delay = 350) => {
  window.clearTimeout(profileDebounceTimer);
  profileDebounceTimer = window.setTimeout(() => {
    renderProfile();
  }, delay);
};

const renderProfile = async () => {
  const requestSerial = ++profileRequestSerial;
  elevationAbortController?.abort();
  elevationAbortController = new AbortController();

  const aLatLng = aMarker.getLatLng();
  const bLatLng = bMarker.getLatLng();
  updateLine();
  const totalDistanceKm = map.distance(aLatLng, bLatLng) / 1000;
  const points = computeProfilePoints(aLatLng, bLatLng, 48, totalDistanceKm);
  setStatus("標高データ取得中...", "info");

  try {
    const elevations = await fetchElevations(points, elevationAbortController.signal);
    if (requestSerial !== profileRequestSerial) return;
    drawChart(elevations);
    setStatus("標高データ更新完了", "success");
  } catch (error) {
    if (error?.name === "AbortError") return;
    if (requestSerial !== profileRequestSerial) return;
    setStatus(error instanceof Error ? error.message : "標高データの取得に失敗しました", "warn");
  }
};

const WATER_CANDIDATE_MAX_ELEVATION_M = 0.5;
水面候補・見通し線・Chart.js描画(722〜876行)

役割:標高0m付近が連続する区間を水面候補として表示し、A/Bアンテナ高を含む見通し直線と地形断面をChart.jsで描画します。

入力:標高配列、距離、A/B高度オフセット、地点名。

出力:地形断面、見通し直線、水面候補帯、ツールチップ。

注意点:水面候補は標高データからの推定であり、海・湖・河川を確定するものではありません。無線伝搬では水面反射によるマルチパスやフェージングの検討材料になりますが、本アプリはフレネルゾーンや回折損失、リンクバジェットを自動計算するものではありません。

const isWaterCandidateSegment = (values, startIndex, endIndex) => {
  if (!Array.isArray(values) || values.length === 0) return false;
  const start = values[Math.min(Math.max(startIndex, 0), values.length - 1)];
  const end = values[Math.min(Math.max(endIndex, 0), values.length - 1)];
  return (
    Number.isFinite(start) &&
    Number.isFinite(end) &&
    start <= WATER_CANDIDATE_MAX_ELEVATION_M &&
    end <= WATER_CANDIDATE_MAX_ELEVATION_M
  );
};

const waterCandidateBackgroundPlugin = {
  id: "waterCandidateBackground",
  beforeDatasetsDraw(chart, _args, options) {
    const { ctx, chartArea } = chart;
    const values = options.values || [];
    const meta = chart.getDatasetMeta(0);
    if (!meta?.data || meta.data.length < 2 || values.length < 2) return;

    ctx.save();
    ctx.fillStyle = options.color || "rgba(37, 99, 235, 0.12)";
    for (let i = 0; i < meta.data.length - 1; i += 1) {
      if (!isWaterCandidateSegment(values, i, i + 1)) continue;
      const x0 = meta.data[i].x;
      const x1 = meta.data[i + 1].x;
      const left = Math.min(x0, x1);
      const width = Math.max(1, Math.abs(x1 - x0));
      ctx.fillRect(left, chartArea.top, width, chartArea.bottom - chartArea.top);
    }
    ctx.restore();
  },
};

const profileLabelsPlugin = {
  id: "profileLabels",
  afterDraw(chart, _args, options) {
    const { ctx, chartArea, scales } = chart;
    const x = scales.x;
    if (!x) return;
    ctx.save();
    ctx.fillStyle = "#374151";
    ctx.font = "12px sans-serif";
    ctx.textBaseline = "top";
    const yPos = chartArea.bottom + 68;
    ctx.textAlign = "left";
    ctx.fillText(options.aLabel || "A地点", x.getPixelForTick(0), yPos);
    ctx.textAlign = "right";
    ctx.fillText(options.bLabel || "B地点", x.getPixelForTick(x.ticks.length - 1), yPos);
    ctx.restore();
  },
};



const drawChart = (points) => {
  const labels = points.map((point) => `${point.distanceKm.toFixed(1)} km`);
  const data = points.map((point) => point.elevation);
  const aOffset = getOffsetValue(aOffsetInput);
  const bOffset = getOffsetValue(bOffsetInput);
  const startElevation = data[0] + aOffset;
  const endElevation = data[data.length - 1] + bOffset;
  const sightLineData = data.map((_, index) => {
    const t = index / (data.length - 1);
    return startElevation + (endElevation - startElevation) * t;
  });
  const hasObstruction = data.some((value, index) => value > sightLineData[index]);
  if (profileChart) {
    profileChart.destroy();
  }
  profileChart = new Chart(chartCtx, {
    type: "line",
    data: {
      labels,
      datasets: [
        {
          label: "標高 (m)",
          data,
          borderColor: "#16a34a",
          backgroundColor: "rgba(22, 163, 74, 0.12)",
          fill: true,
          tension: 0.25,
          pointRadius: 0,
          segment: {
            borderColor: (ctx) => {
              const startIndex = ctx.p0DataIndex ?? ctx.p1DataIndex ?? 0;
              const endIndex = ctx.p1DataIndex ?? startIndex;
              const index = endIndex;
              if (isWaterCandidateSegment(data, startIndex, endIndex)) return "#2563eb";
              return data[index] > sightLineData[index] ? "#ef4444" : "#16a34a";
            },
            backgroundColor: (ctx) => {
              const startIndex = ctx.p0DataIndex ?? ctx.p1DataIndex ?? 0;
              const endIndex = ctx.p1DataIndex ?? startIndex;
              const index = endIndex;
              if (isWaterCandidateSegment(data, startIndex, endIndex)) {
                return "rgba(37, 99, 235, 0.15)";
              }
              return data[index] > sightLineData[index]
                ? "rgba(239, 68, 68, 0.2)"
                : "rgba(22, 163, 74, 0.12)";
            },
          },
        },
        {
          label: "見通し直線",
          data: sightLineData,
          borderColor: "#f97316",
          borderDash: [6, 6],
          pointRadius: 0,
          fill: false,
        },
      ],
    },
    options: {
      responsive: true,
      layout: {
        padding: {
          bottom: 48,
        },
      },
      plugins: {
        profileLabels: {
          aLabel,
          bLabel,
        },
        waterCandidateBackground: {
          values: data,
          color: "rgba(37, 99, 235, 0.12)",
        },
      },
      scales: {
        x: {
          title: {
            display: true,
            text: "A → B の距離 (km)",
          },
        },
        y: {
          title: {
            display: true,
            text: "標高 (m)",
          },
        },
      },
    },
    plugins: [waterCandidateBackgroundPlugin, profileLabelsPlugin],
  });

  if (playTimer && playMode === "fast" && hasObstruction) {
    stopPlay();
    setStatus("見通し線を超えたため停止しました", "warn");
  }
};
地図クリック・背景地図設定・A/B地点決定(877〜925行)

役割:地図クリックで仮地点を置き、Mapbox設定の保存、地図プロバイダー切替、検索結果をA/Bへ反映する操作を処理します。

入力:地図クリック位置、Mapboxトークン、保存チェック、A/B決定ボタン。

出力:仮マーカー、背景地図、localStorage、A/B地点。

注意点:トークンは保存チェックを選んだ場合だけlocalStorageへ保存します。同一オリジン上のJavaScriptから参照可能なため、秘密権限を含むトークンは使用しません。

map.on("click", (event) => {
  const { lat, lng } = event.latlng;
  lastTempLocation = { lat, lng };
  if (!tempMarker) {
    tempMarker = L.marker([lat, lng], { opacity: 0.75 }).addTo(map);
  } else {
    tempMarker.setLatLng([lat, lng]);
  }
  setStatus("一時マーカーを更新しました", "success");
});

saveMapboxButton.addEventListener("click", () => {
  const provider = mapProviderSelect.value;
  const token = mapboxTokenInput.value.trim();

  localStorage.setItem("mapProvider", provider);
  if (rememberMapboxCheckbox.checked && token) {
    localStorage.setItem("mapboxToken", token);
  } else {
    localStorage.removeItem("mapboxToken");
  }

  loadTileLayer(provider, token);
  setStatus(
    rememberMapboxCheckbox.checked && token
      ? "地図設定を反映し、トークンをこのブラウザに保存しました"
      : "地図設定を反映しました(トークンは保存していません)",
    "success"
  );
});

mapProviderSelect.addEventListener("change", () => {
  const provider = mapProviderSelect.value;
  const token = mapboxTokenInput.value.trim();
  localStorage.setItem("mapProvider", provider);
  loadTileLayer(provider, token);
});

pickAButton.addEventListener("click", () => {
  applyTempMarker("A");
});

pickBButton.addEventListener("click", () => {
  applyTempMarker("B");
});

aOffsetInput.addEventListener("input", () => scheduleProfileRender());
bOffsetInput.addEventListener("input", () => scheduleProfileRender());
コース読込・操作ボタン・現在地・地点書出し(926〜1053行)

役割:B地点コースJSONの読込、再生操作、検索、現在地取得、A/B地点の書出し、A地点リスト切替をまとめて処理します。

入力:JSONファイル、各操作ボタン、Geolocation API、A地点リスト。

出力:コース、現在地マーカー、再生状態、保存ファイル、選択地点。

注意点:現在地はブラウザの許可が必要です。JSONは2MiB以下、最大5,000地点に制限しています。

const loadJsonTrack = async () => {
  const file = bJsonFile.files[0];
  if (!file) {
    setStatus("JSONファイルを選択してください", "warn");
    return;
  }
  try {
    if (file.size > MAX_JSON_FILE_BYTES) {
      throw new Error("JSONファイルが大きすぎます");
    }
    const text = await file.text();
    const track = parseTrackJson(text);
    setBTrack(track);
    stopPlay();
    currentBIndex = 0;
    updateBMarkerFromTrack();
    setStatus(`JSONを読み込みました (${track.length}点)`, "success");
  } catch (error) {
    setStatus("JSONの読み込みに失敗しました", "warn");
  }
};

bJsonFile.addEventListener("change", () => {
  if (bJsonFile.files.length > 0) {
    loadJsonTrack();
  }
});

stepBackButton.addEventListener("click", () => stepTrack(-1));
stepForwardButton.addEventListener("click", () => stepTrack(1));
playButton.addEventListener("click", () => {
  playSpeed = 1600;
  playTrack(1, "normal");
});
reverseButton.addEventListener("click", () => {
  playSpeed = 1600;
  playTrack(-1, "normal");
});
stopButton.addEventListener("click", () => stopPlay());

speedButton.addEventListener("click", () => {
  speedButton.classList.add("active");
  playTrack(1, "fast");
});

resetButton.addEventListener("click", () => {
  stopPlay();
  currentBIndex = 0;
  updateBMarkerFromTrack();
});

mapSearchButton.addEventListener("click", () => {
  searchLocation();
});

mapSearchInput.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    event.preventDefault();
    searchLocation();
  }
});

locateMeButton.addEventListener("click", () => {
  if (!navigator.geolocation) {
    setStatus("このブラウザでは現在地取得ができません", "warn");
    return;
  }
  navigator.geolocation.getCurrentPosition(
    (position) => {
      const { latitude, longitude } = position.coords;
      const latLng = [latitude, longitude];
      if (!userMarker) {
        userMarker = L.marker(latLng, { opacity: 0.9 }).addTo(map);
        userMarker.bindTooltip("現在地", {
          direction: "right",
          offset: [8, 0],
          className: "marker-label",
        });
      } else {
        userMarker.setLatLng(latLng);
      }
      lastTempLocation = { lat: latitude, lng: longitude };
      if (tempMarker) {
        tempMarker.setLatLng(latLng);
      } else {
        tempMarker = L.marker(latLng, { opacity: 0.75 }).addTo(map);
      }
      map.setView(latLng, 15);
      setStatus("現在地を表示しました", "success");
    },
    () => {
      setStatus("現在地の取得に失敗しました", "warn");
    },
    { enableHighAccuracy: true, timeout: 8000 }
  );
});

exportAButton.addEventListener("click", () => {
  syncNamesFromInputs();
  exportLocation("A", aMarker, lastSearchQuery.A, getOffsetValue(aOffsetInput), aName);
});

exportBButton.addEventListener("click", () => {
  syncNamesFromInputs();
  exportLocation("B", bMarker, lastSearchQuery.B, getOffsetValue(bOffsetInput), bName);
});

aSelect.addEventListener("change", (event) => {
  selectAPoint(parseInt(event.target.value, 10));
});

aJsonFile.addEventListener("change", async () => {
  const file = aJsonFile.files[0];
  if (!file) return;
  try {
    if (file.size > MAX_JSON_FILE_BYTES) {
      throw new Error("JSONファイルが大きすぎます");
    }
    const text = await file.text();
    const data = JSON.parse(text);
    if (!Array.isArray(data)) throw new Error("形式が不正です");
    loadAData(data);
    setStatus("A地点JSONを読み込みました", "success");
  } catch (error) {
    setStatus("JSONの読み込みに失敗しました", "warn");
  }
});
URL共有・座標入力・初期起動(1054〜1162行)

役割:URLパラメータからA地点リストを復元し、緯度・経度入力をマーカーへ反映します。初期データの読込と、共有URL作成もここで行います。

入力:URLパラメータ、座標入力欄、標準A地点JSON。

出力:初期地点、共有URL、クリップボードコピー、初回グラフ。

注意点:共有URLには地点座標等が含まれます。URL長と地点数に上限を設け、過大なデータを読み込まないようにしています。

const parseADataFromUrl = () => {
  const params = new URLSearchParams(window.location.search);
  const raw = params.get("data");
  if (!raw || raw.length > 8000) return null;

  const entries = raw.split(/[|;\n]+/).map((entry) => entry.trim());
  const points = entries
    .slice(0, 100)
    .map((entry) => {
      const parts = entry.split(",").map((part) => part.trim());
      if (parts.length < 3) return null;
      return validatePoint({
        name: parts[0],
        lat: parts[1],
        lng: parts[2],
      });
    })
    .filter(Boolean);
  return points.length > 0 ? points : null;
};


const applyCoordinateInputs = () => {
  const aLat = Number.parseFloat(aLatInput.value);
  const aLng = Number.parseFloat(aLngInput.value);
  const bLat = Number.parseFloat(bLatInput.value);
  const bLng = Number.parseFloat(bLngInput.value);

  if (!isValidLatitude(aLat) || !isValidLongitude(aLng) ||
      !isValidLatitude(bLat) || !isValidLongitude(bLng)) {
    setStatus("緯度・経度の入力値を確認してください", "warn");
    return;
  }

  aMarker.setLatLng([aLat, aLng]);
  bMarker.setLatLng([bLat, bLng]);
  updateLine();
  scheduleProfileRender(150);
};

[aLatInput, aLngInput, bLatInput, bLngInput].forEach((input) => {
  input.addEventListener("change", applyCoordinateInputs);
});

const applyInitialAData = () => {
  const urlPoints = parseADataFromUrl();
  if (urlPoints) {
    loadAData(urlPoints);
    setStatus("URLの地点リストを読み込みました", "success");
    updateInputsFromMarker();
    updateLine();
    renderProfile();
    return;
  }
  fetch(defaultAData)
    .then((response) => response.json())
    .then((data) => loadAData(data))
    .catch(() => {
      loadAData([{ name: "東京駅", lat: 35.681236, lng: 139.767125 }]);
    })
    .finally(() => {
      updateInputsFromMarker();
      updateLine();
      renderProfile();
    });
};

applyInitialAData();

createUrlButton.addEventListener("click", async () => {
  if (aPoints.length === 0) {
    setStatus("A地点リストが空です", "warn");
    return;
  }
  const baseUrl = `${window.location.origin}${window.location.pathname}?data=`;
  const entries = aPoints
    .map((point) => {
      const name = point.name || point.label || "";
      const lat = parseFloat(point.lat);
      const lng = parseFloat(point.lng);
      if (!name || Number.isNaN(lat) || Number.isNaN(lng)) return null;
      return `${encodeURIComponent(name)},${lat},${lng}`;
    })
    .filter(Boolean);
  if (entries.length === 0) {
    setStatus("URLに追加できる地点がありません", "warn");
    return;
  }
  const fullUrl = `${baseUrl}${entries.join("|")}`;
  if (fullUrl.length > 8000) {
    setStatus("URLが長すぎます。地点数を減らしてください", "warn");
    return;
  }
  try {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(fullUrl);
    } else {
      const tempInput = document.createElement("input");
      tempInput.value = fullUrl;
      document.body.appendChild(tempInput);
      tempInput.select();
      document.execCommand("copy");
      document.body.removeChild(tempInput);
    }
    setStatus("URLをクリップボードにコピーしました", "success");
  } catch (error) {
    setStatus("URLのコピーに失敗しました", "warn");
  }
});

index.htmlとstyles.cssの役割

index.html:画面構成と外部ライブラリの読込

地点設定、地図、地形断面グラフ、再生操作の画面を定義しています。LeafletとChart.jsを読み込み、最後にapp.jsを実行します。Mapboxトークン欄はパスワード表示とし、保存は利用者が明示的に選択します。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="referrer" content="strict-origin-when-cross-origin" />
    <title>地形断面プロフィールビューア</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <header class="app-header">
      <div>
        <h1>地形断面プロフィールビューア</h1>
        <p>
          A地点とB地点を結ぶ直線の地形断面を表示し、B地点の移動コースをアニメーションできます。
        </p>
      </div>
      <div class="header-right">
        <div class="header-watermark">©2026 Sketlab LLC.</div>
        <div class="license-note">MIT License</div>
        <div class="status" id="status" role="status" aria-live="polite">準備完了</div>
      </div>
    </header>

    <main class="app-layout">
      <section class="panel">
        <h2>地点設定</h2>
        <label class="row inline-check">
          <span>
            <input type="checkbox" id="autoFit" checked />
            地図をA/Bが収まるよう自動で縮尺
          </span>
        </label>

        <div class="group">
          <h3>地図プロバイダ</h3>
          <div class="row">
            <label>
              種類
              <select id="mapProvider">
                <option value="osm">OpenStreetMap</option>
                <option value="mapbox">Mapbox</option>
              </select>
            </label>
            <label>
              Mapboxアクセストークン
              <input type="password" id="mapboxToken" placeholder="pk.XXXX..." autocomplete="off" spellcheck="false" />
            </label>
            <button type="button" id="saveMapbox">設定を反映</button>
          </div>
          <label class="row inline-check">
            <span><input type="checkbox" id="rememberMapbox" /> このブラウザにトークンを保存する</span>
          </label>
          <p class="hint">Mapboxを使う場合は、公開用かつ利用URLを制限したアクセストークンを使用してください。</p>
        </div>

        <div class="group">
          <h3>A地点設定</h3>
          <div class="row">
            <label>A地点名 <input type="text" id="aName" /></label>
          </div>
          <div class="row">
            <label>緯度 <input type="number" id="aLat" step="0.000001" /></label>
            <label>経度 <input type="number" id="aLng" step="0.000001" /></label>
            <label>高度オフセット (m) <input type="number" id="aOffset" step="0.1" value="0" /></label>
          </div>
          <div class="row action-row">
            <button type="button" id="pickA">マーカーをAに</button>
            <button type="button" id="exportA">A座標を保存</button>
          </div>
          <label for="aSelect">A地点リスト(JSON)</label>
          <div class="row">
            <select id="aSelect"></select>
            <button type="button" id="createUrl">URL作成</button>
            <input type="file" id="aJsonFile" accept="application/json" />
          </div>
        </div>

        <div class="group">
          <h3>B地点設定</h3>
          <div class="row">
            <label>B地点名 <input type="text" id="bName" /></label>
          </div>
          <div class="row">
            <label>緯度 <input type="number" id="bLat" step="0.000001" /></label>
            <label>経度 <input type="number" id="bLng" step="0.000001" /></label>
            <label>高度オフセット (m) <input type="number" id="bOffset" step="0.1" value="0" /></label>
          </div>
          <div class="row action-row">
            <button type="button" id="pickB">マーカーをBに</button>
            <button type="button" id="exportB">B座標を保存</button>
          </div>
          <label>B地点移動コース(JSON)</label>
          <div class="row">
            <input type="file" id="bJsonFile" accept="application/json" />
          </div>
        </div>

        <div class="group">
          <h3>自動再生</h3>
          <div class="row">
            <button type="button" id="reset" class="icon-button" aria-label="開始点へ" title="開始点へ">
              <img src="img/restart.png" alt="開始点へ" />
            </button>
            <button type="button" id="reverse" class="icon-button" aria-label="逆再生" title="逆再生">
              <img src="img/back.png" alt="逆再生" />
            </button>
            <button type="button" id="stepBack" class="icon-button" aria-label="ステップ逆再生" title="ステップ逆再生">
              <img src="img/stepback.png" alt="ステップ逆再生" />
            </button>
            <button type="button" id="stop" class="icon-button" aria-label="停止" title="停止">
              <img src="img/stop.png" alt="停止" />
            </button>
            <button type="button" id="stepForward" class="icon-button" aria-label="ステップ再生" title="ステップ再生">
              <img src="img/stepplay.png" alt="ステップ再生" />
            </button>
            <button type="button" id="play" class="icon-button" aria-label="再生" title="再生">
              <img src="img/play.png" alt="再生" />
            </button>
            <button type="button" id="speed" class="icon-button" aria-label="高速再生" title="高速再生">
              <img src="img/2xplay.png" alt="高速再生" />
            </button>
          </div>
        </div>

      </section>

      <div class="main-column">
        <section class="panel map-panel">
          <div id="map">
            <div class="map-search">
              <input type="text" id="mapSearchInput" placeholder="地名・住所を検索" />
              <button type="button" id="mapSearchButton">検索</button>
              <button type="button" id="locateMe">現在地</button>
              <div class="map-search-status" id="mapSearchStatus" aria-live="polite"></div>
            </div>
            <div class="map-error" id="mapError">
              地図が読み込めません。<br />
              ローカルサーバーで起動しているか、ネットワーク接続があるかを確認してください。
            </div>
          </div>
          <div class="map-legend">
            <span class="legend-item">A地点: 青</span>
            <span class="legend-item">B地点: 赤</span>
            <span class="legend-item">Bコース: 赤線</span>
            <span class="legend-note">背景地図はOpenStreetMapまたはMapboxを使用します。</span>
          </div>
          <p class="hint">ローカルで開く場合は <code>python -m http.server</code> などで起動してください。</p>
          <p class="hint">地図タイルの取得にはインターネット接続が必要です。</p>
          <p class="hint">地名検索はNominatimへ検索語を送信します。現在地は端末の許可後に取得し、地形断面の計算時にはA/B間の座標列をOpen-Elevationへ送信します。</p>
        </section>

        <section class="panel">
          <h2>地形断面プロフィール</h2>
          <canvas id="profileChart" height="160"></canvas>
          <p class="hint">
            標高データは Open-Elevation API を利用します。API制限や通信状況により取得できない場合があります。
            <a href="https://open-elevation.com/" target="_blank" rel="noopener noreferrer">利用規約/ポリシーを確認</a>
          </p>
        </section>
      </div>
    </main>

    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
    <script src="app.js?v=2"></script>
  </body>
</html>
styles.css:PC・スマートフォン向けレイアウト

PCでは地点設定と地図を左右に配置し、画面幅が狭い場合は1列に切り替えます。マーカー、距離ラベル、地図の読込状態、グラフ領域などの外観も定義しています。

:root {
  color-scheme: light;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  background: #f6f7fb;
  color: #1f2937;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
}

.app-header {
  background: #111827;
  color: #f9fafb;
  padding: 20px 24px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 20px;
}

.header-right {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
}

.header-watermark {
  font-size: 11px;
  color: rgba(248, 250, 252, 0.7);
}

.license-note {
  font-size: 11px;
  color: rgba(248, 250, 252, 0.7);
}

.app-header h1 {
  margin: 0 0 6px;
  font-size: 22px;
}

.app-header p {
  margin: 0;
  font-size: 14px;
  opacity: 0.8;
}

.status {
  background: #1f2937;
  border-radius: 999px;
  padding: 6px 14px;
  font-size: 12px;
}

.app-layout {
  display: grid;
  grid-template-columns: 320px 1fr;
  gap: 16px;
  padding: 16px;
}

.main-column {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.panel {
  background: #fff;
  border-radius: 12px;
  padding: 16px;
  box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.panel h2,
.panel h3 {
  margin: 0 0 8px;
}

.group {
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.action-row button {
  flex: 1 1 calc(50% - 8px);
}

.inline-check {
  display: inline-flex;
  flex-wrap: nowrap;
}

.inline-check span {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
label {
  font-size: 13px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

input[type="number"],
input[type="text"],
select,
input[type="file"] {
  padding: 6px 8px;
  border-radius: 6px;
  border: 1px solid #d1d5db;
  font-size: 13px;
}

button {
  padding: 6px 10px;
  border-radius: 6px;
  border: none;
  background: #2563eb;
  color: #fff;
  cursor: pointer;
  font-size: 13px;
}

.icon-button {
  padding: 4px 6px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: #2563eb;
}

.icon-button img {
  width: 18px;
  height: 18px;
  display: block;
}

.icon-button.active {
  background: #1d4ed8;
}

button.secondary {
  background: #6b7280;
}

button:disabled {
  background: #9ca3af;
  cursor: not-allowed;
}

.distance-label {
  font-size: 12px;
  font-weight: 600;
  color: #1d4ed8;
  background: rgba(255, 255, 255, 0.9);
  border: 1px solid rgba(29, 78, 216, 0.4);
  border-radius: 8px;
  padding: 2px 6px;
}

.map-panel {
  min-height: 820px;
}

#map {
  flex: 1;
  min-height: 740px;
  border-radius: 12px;
  position: relative;
  overflow: hidden;
}

#profileChart {
  max-height: 240px;
}

.map-search {
  position: absolute;
  left: 60px;
  top: 12px;
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: 6px;
  align-items: center;
  background: rgba(255, 255, 255, 0.9);
  padding: 6px;
  border-radius: 10px;
  box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
  z-index: 500;
}

.marker-icon {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid #ffffff;
  box-shadow: 0 2px 6px rgba(15, 23, 42, 0.35);
}

.marker-a {
  background: #2563eb;
}

.marker-b {
  background: #dc2626;
}

.marker-label {
  font-size: 12px;
  font-weight: 600;
  color: #111827;
}

.map-search input[type="text"] {
  min-width: 220px;
  font-size: 12px;
}

.map-search button {
  font-size: 12px;
  padding: 6px 10px;
}

.map-search-status {
  grid-column: 1 / -1;
  font-size: 11px;
  color: #4b5563;
}

.map-legend {
  display: flex;
  gap: 10px;
  font-size: 12px;
  color: #4b5563;
  flex-wrap: wrap;
}

.legend-note {
  font-size: 11px;
  color: #6b7280;
}

.legend-item::before {
  content: "";
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 999px;
  margin-right: 6px;
  background: currentColor;
}

.legend-item:nth-child(1)::before {
  color: #2563eb;
}

.legend-item:nth-child(2)::before {
  color: #dc2626;
}

.legend-item:nth-child(3)::before {
  color: #b91c1c;
}

.map-error {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 16px;
  background: #f9fafb;
  color: #6b7280;
  font-size: 13px;
  z-index: 0;
}

.map-ready .map-error {
  display: none;
}

.hint {
  font-size: 12px;
  color: #6b7280;
  margin: 0;
}

@media (max-width: 1200px) {
  .app-layout {
    grid-template-columns: 1fr;
  }

  .map-panel {
    min-height: 400px;
  }
}

@media (max-width: 900px) {
  .app-header {
    flex-direction: column;
    align-items: flex-start;
  }

  .header-right {
    flex-direction: row;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
  }

  .row {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
  }

  .map-search {
    left: 12px;
    right: 12px;
    grid-template-columns: 1fr auto auto;
  }

  button,
  input,
  select {
    min-height: 40px;
  }
}

@media (max-width: 640px) {
  .app-layout {
    padding: 12px;
  }

  .panel {
    padding: 12px;
  }

  .field-row {
    grid-template-columns: 96px 1fr auto;
  }

  .map-panel {
    min-height: 320px;
  }

  #map {
    min-height: 300px;
  }

  #profileChart {
    max-height: 220px;
  }

  .map-search {
    grid-template-columns: 1fr auto;
    grid-template-rows: auto auto;
    left: 8px;
    right: 8px;
    padding: 8px;
  }

  #mapSearchInput {
    grid-column: 1 / -1;
    min-width: 0;
    height: 36px;
    font-size: 14px;
  }

  #mapSearchButton,
  #locateMe {
    width: 100%;
    height: 34px;
    font-size: 11px;
    padding: 0 6px;
    white-space: nowrap;
  }
}

公開・運用上の注意

  • 標高、地名検索、地図タイルは外部サービスに依存し、仕様変更や停止の影響を受けます。
  • NominatimとOpen-Elevationへの連続要求を避けるため、間隔制御とキャッシュを実装しています。
  • Mapboxには公開用かつURL制限済みのトークンだけを使用してください。
  • OSM・Mapboxの帰属表示は削除しないでください。
  • 現在地取得にはブラウザの許可が必要です。
  • 水面候補、標高、見通し線は概略判断用です。重要回線は現地調査、測量資料、無線回線設計ソフト等で再確認してください。

ソースコード

本記事のコードはMIT Licenseで公開しています。改変・再利用する場合は、著作権表示とライセンス文を残してください。

動作ページ

← トップページへ戻る