Skip to main content
GDPRに準拠した医療機関検索マップの構築方法
Tutorials

GDPRに準拠した医療機関検索マップの構築方法

専門科フィルター、郵便番号検索、等時線による移動時間オーバーレイ、設計段階からの患者プライバシーを備えたGDPR準拠の医師検索マップを構築します。完全なJavaScriptチュートリアル。

Brent van der Heiden15 min read
#healthcare map#provider finder#doctor finder#healthcare api#gdpr healthcare#health app

医療機関検索マップは、開発者が構築できる最も重要なマップの1つです。それを使用する患者は、不安を感じていたり、痛みを感じていたり、不慣れなシステムに対応していたりする場合があります。遅く読み込まれるマップ、不関連な結果を返すマップ、または患者の郵便番号を米国ベースのサードパーティサービスに無言で送信するマップは、単に悪いユーザーエクスペリエンスではなく、潜在的なGDPR違反です。

このチュートリアルでは、本番環境対応の医療機関検索マップを作成します。専門科フィルター、Geocoding APIを使用した郵便番号検索、「20分以内の医師」を表示する等時線オーバーレイ、MapAtlas Maps APIを使用した動作マップ、すべてが最初からアーキテクチャに組み込まれたGDPR準拠の実装です。

実装全体はバニラJavaScriptで、フレームワークは不要です。React、Vue、プレーンHTML、WordPress、または病院の従来型CMSなど、どのスタックでも機能します。このチュートリアルでは、EU規制環境、英国CQCデータ、ドイツのKassenärztliche Vereinigung、フランスのCNAMもカバーしているため、医療機関データの合法的なソーシングに関する知識が得られます。

医療位置情報アプリが追加のGDPR義務を持つ理由

標準的なアプリは個人データ(名前、メール、使用状況)を収集します。医療アプリには2番目のリスク、位置情報データから健康情報を推定する、があります。

ユーザーが「カーディオロジストSE1 7PBの近く」を検索すると、その郵便番号は個人データです。郵便番号と医学的専門科の組み合わせは、GDPR Article 9(1)に基づく潜在的な特別カテゴリデータ、健康状態を明かすデータになります。ユーザーアカウントを作成したり、プロフィールを保存したりしなくても、これは当てはまります。

リスクは具体的です。

  • クライアント側のジオコーディングAPI呼び出しに、ユーザーが入力した郵便番号が含まれている場合、その呼び出しはサードパーティサーバーに送信されます。そのサーバーは郵便番号、検索されている専門科、IPアドレスを見ることができます。DPAとEUデータレジデンシー保証がない場合、これは問題のある転送です。
  • ブラウザのオートコンプリートと履歴は、ユーザーのデバイスで入力された郵便番号と専門医のクエリを保持する可能性があります。これはあなたの管理外ですが、プライバシー情報で言及する価値があります。
  • サーバー側でAPIレスポンスをログに記録している場合(デバッグの際に一般的)、気付かずに郵便番号と専門科の組み合わせをログに記録している可能性があります。

修正はバックエンドプロキシです。あなたのサーバーが検索を受け取り、MapAtlas Geocoding APIを呼び出し、座標のみを返します。患者のブラウザからサードパーティAPIへの入力クエリは送信されません。実装セクションで詳細について説明します。

医療機関データ構造

医療機関データは、スクレイプされたデータセットではなく、公式な権威あるソースから取得すべきです。

  • 英国: NHS DigitalのNHS Choices APIおよびCQC登録医療機関リスト
  • ドイツ: Kassenärztliche Bundesvereinigung (KBV)のオープンデータおよびArztsuche API
  • フランス: CNAMのAmeli APIおよびrépertoire RPPS

各医療機関をGeoJSON機能として構造化します。

const providers = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",
      geometry: { type: "Point", coordinates: [4.9051, 52.3680] },
      properties: {
        id: "prov-001",
        name: "Dr. Maria van den Berg",
        specialty: "general-practitioner",
        accepting: true,
        address: "Prinsengracht 263, 1016 GV Amsterdam",
        phone: "+31 20 423 5678",
        languages: ["Dutch", "English"],
        nextAvailable: "2026-02-18"
      }
    },
    {
      type: "Feature",
      geometry: { type: "Point", coordinates: [4.8890, 52.3610] },
      properties: {
        id: "prov-002",
        name: "Dr. Jan Smits",
        specialty: "cardiologist",
        accepting: false,
        address: "Leidseplein 15, 1017 PS Amsterdam",
        phone: "+31 20 612 3456",
        languages: ["Dutch"],
        nextAvailable: null
      }
    },
    {
      type: "Feature",
      geometry: { type: "Point", coordinates: [4.9200, 52.3750] },
      properties: {
        id: "prov-003",
        name: "Anna Fischer, MD",
        specialty: "dermatologist",
        accepting: true,
        address: "Plantage Middenlaan 14, 1018 DD Amsterdam",
        phone: "+31 20 789 0123",
        languages: ["Dutch", "English", "German"],
        nextAvailable: "2026-02-20"
      }
    }
  ]
};

ステップ1:専門科フィルタ付きマップ初期化

マップと専門科ドロップダウンで開始します。フィルタはネットワークリクエストなしでクライアント側で表示マーカーを更新します。

import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';

const map = new mapmetricsgl.Map({
  container: 'provider-map',
  style: 'https://tiles.mapatlas.eu/styles/basic/style.json?key=YOUR_API_KEY',
  center: [4.9041, 52.3676],
  zoom: 13
});

map.on('load', () => {
  map.addSource('providers', {
    type: 'geojson',
    data: providers
  });

  // 医療機関マーカー、緑 = 受け付けしている、灰色 = 満員
  map.addLayer({
    id: 'provider-markers',
    type: 'circle',
    source: 'providers',
    paint: {
      'circle-radius': 10,
      'circle-color': [
        'case',
        ['==', ['get', 'accepting'], true], '#16A34A',
        '#9CA3AF'
      ],
      'circle-stroke-width': 2,
      'circle-stroke-color': '#ffffff'
    }
  });

  buildList(providers.features);
});

// 専門科フィルタ
document.getElementById('specialty-select').addEventListener('change', (e) => {
  const specialty = e.target.value;
  if (specialty === 'all') {
    map.setFilter('provider-markers', null);
  } else {
    map.setFilter('provider-markers', ['==', ['get', 'specialty'], specialty]);
  }
});

ステップ2:バックエンドプロキシ経由の郵便番号検索

ブラウザから直接Geocoding APIを呼び出す代わりに(患者の郵便番号をサードパーティサーバーに公開する)、ジオコーディングリクエストを自身のバックエンドエンドポイント経由でルーティングします。サーバーはMapAtlasを呼び出し、座標のみを返します。

フロントエンド(患者のブラウザ):

async function geocodePostcode(postcode) {
  // ジオコーディングAPIではなく、あなたのバックエンドを呼び出す
  const res = await fetch(`/api/geocode?q=${encodeURIComponent(postcode)}`);
  const { lat, lng } = await res.json();
  return { lat, lng };
}

document.getElementById('postcode-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const postcode = document.getElementById('postcode-input').value.trim();
  const { lat, lng } = await geocodePostcode(postcode);
  map.flyTo({ center: [lng, lat], zoom: 13 });
  fetchIsochrone(lat, lng);
});

バックエンドプロキシ(Node.js/Express):

// /api/geocode、サーバー側のみ、郵便番号はあなたのインフラを離れない
app.get('/api/geocode', async (req, res) => {
  const url = new URL('https://api.mapatlas.eu/geocoding/v1/search');
  url.searchParams.set('text', req.query.q);
  url.searchParams.set('key', process.env.MAPATLAS_API_KEY); // サーバー側キー
  url.searchParams.set('size', '1');

  const upstream = await fetch(url.toString());
  const data = await upstream.json();

  if (!data.features?.length) {
    return res.status(404).json({ error: 'Postcode not found' });
  }

  const [lng, lat] = data.features[0].geometry.coordinates;
  // 座標のみを返す、郵便番号はログに記録しない、専門科はレスポンスに含めない
  res.json({ lat, lng });
});

APIキーはサーバーに保存され、患者の生郵便番号クエリはサードパーティに送信されません。

ステップ3:等時線オーバーレイ、「20分以内の医師」

等時線ポリゴンは、指定された運転時間内に到達可能なすべてのポイントを示します。医療機関検索に等時線を表示すると、最も実用的な患者の質問に答えます。「実際に到達できる医師は何人か?」

async function fetchIsochrone(lat, lng) {
  // プライバシー上の同じ理由でバックエンドプロキシを通じて呼び出す
  const res = await fetch(
    `/api/isochrone?lat=${lat}&lng=${lng}&minutes=20&profile=driving`
  );
  const geojson = await res.json();

  // 既存の等時線レイヤが存在する場合は削除
  if (map.getLayer('isochrone-fill')) map.removeLayer('isochrone-fill');
  if (map.getLayer('isochrone-border')) map.removeLayer('isochrone-border');
  if (map.getSource('isochrone')) map.removeSource('isochrone');

  map.addSource('isochrone', { type: 'geojson', data: geojson });

  map.addLayer({
    id: 'isochrone-fill',
    type: 'fill',
    source: 'isochrone',
    paint: {
      'fill-color': '#16A34A',
      'fill-opacity': 0.12
    }
  }, 'provider-markers'); // マーカーの下に挿入してピンをトップに表示

  map.addLayer({
    id: 'isochrone-border',
    type: 'line',
    source: 'isochrone',
    paint: {
      'line-color': '#16A34A',
      'line-width': 2,
      'line-dasharray': [3, 2]
    }
  }, 'provider-markers');
}

バックエンド等時線プロキシ:

app.get('/api/isochrone', async (req, res) => {
  const { lat, lng, minutes, profile } = req.query;
  const url = new URL('https://api.mapatlas.eu/v1/isochrone');
  url.searchParams.set('lat',     lat);
  url.searchParams.set('lng',     lng);
  url.searchParams.set('time',    minutes * 60); // 秒
  url.searchParams.set('profile', profile || 'driving');
  url.searchParams.set('key',     process.env.MAPATLAS_API_KEY);

  const upstream = await fetch(url.toString());
  const data = await upstream.json();
  res.json(data); // GeoJSONポリゴン
});

等時線マップの解説の記事では、公共交通の等時線(車を持たない患者に有用)や10、20、30分のゾーンを同時に表示する複数時間帯オーバーレイなど、等時線の使用例をさらに詳しく説明しています。

ステップ4:医療機関詳細ポップアップ

患者が医師マーカーをクリックすると、必須詳細情報を表示します。名前、専門科、受け付け状態、言語、次の利用可能な予約です。

map.on('click', 'provider-markers', (e) => {
  const {
    name, specialty, accepting, address, phone, languages, nextAvailable
  } = e.features[0].properties;
  const coords = e.features[0].geometry.coordinates.slice();

  const statusBadge = accepting
    ? '<span style="color:#16A34A;font-weight:600">Accepting patients</span>'
    : '<span style="color:#9CA3AF">Not accepting new patients</span>';

  const apptLine = accepting && nextAvailable
    ? `<p style="margin:4px 0">Next available: <strong>${nextAvailable}</strong></p>`
    : '';

  new mapmetricsgl.Popup({ maxWidth: '280px' })
    .setLngLat(coords)
    .setHTML(`
      <strong style="font-size:15px">${name}</strong>
      <p style="margin:4px 0;text-transform:capitalize">${specialty.replace('-', ' ')}</p>
      ${statusBadge}
      ${apptLine}
      <p style="margin:6px 0;font-size:13px;color:#64748b">${address}</p>
      <p style="margin:4px 0;font-size:13px">
        Languages: ${Array.isArray(languages) ? languages.join(', ') : languages}
      </p>
      <a href="tel:${phone}" style="color:#2563EB">${phone}</a>
    `)
    .addTo(map);
});

map.on('mouseenter', 'provider-markers', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'provider-markers', () => { map.getCanvas().style.cursor = ''; });

ステップ5:マップと同期されたリストパネル

マップの隣にあるリストビューは、スクリーンリーダーを使用するユーザーとピンをクリックする代わりにスクロールを好むユーザーに役立ちます。

function buildList(features) {
  const list = document.getElementById('provider-list');
  list.innerHTML = '';

  features.forEach(({ properties, geometry }) => {
    const { name, specialty, accepting, address, nextAvailable } = properties;

    const card = document.createElement('div');
    card.className = `provider-card ${accepting ? 'accepting' : 'full'}`;
    card.innerHTML = `
      <strong>${name}</strong>
      <p>${specialty.replace('-', ' ')}</p>
      <p style="font-size:12px;color:#64748b">${address}</p>
      <small>${accepting ? `Next: ${nextAvailable || 'call to confirm'}` : 'Not accepting'}</small>
    `;
    card.addEventListener('click', () => {
      map.flyTo({ center: geometry.coordinates, zoom: 15 });
    });

    list.appendChild(card);
  });
}

GDPR実装チェックリスト

EU医療開発者の場合、これを最小コンプライアンスチェックリストとして扱います。

  • ジオコーディングのバックエンドプロキシ: 患者の郵便番号をクライアント側からサードパーティAPIに送信してはいけません。
  • 検索用語のセッションロギングなし: デバッグのためにリクエストをログに記録する場合は、ログに書き込む前に郵便番号を削除します。
  • プライバシー通知: アプリのプライバシー通知には、位置情報データが医療機関を見つけるために処理されていること、法的根拠(正当な利益または明示的同意)、保持期間を開示する必要があります。
  • 位置情報アクセスの同意: ブラウザGeolocation API (navigator.geolocation.getCurrentPosition)を使用する場合は、ブラウザの権限プロンプトをトリガーする前に明確な目的の説明を表示します。
  • データレジデンシー: MapAtlasはすべてのAPI要求をEU内で処理します。処理活動記録を完成させる際、MapAtlas DPAを参照します。
  • 保持: パフォーマンスのためにサーバー側で等時線結果または検索座標をキャッシュする場合、保持期間(例えば24時間)を定義し、削除を自動化します。

EUデベロッパーのためのGDPR準拠マップAPI用ガイドでは、プロセッサー契約、データレジデンシー、同意パターンを完全にカバーしています。医療サービス業界ページでは、デジタルヘルスケアアプリケーションに関連する特定のMapAtlas機能をリストしています。

次に構築すること

よくある質問

患者データを保存していない場合でも、なぜGDPRが医師検索マップに適用されるのですか?

医療検索サイトの患者の検索位置は、患者がどこに住んでいるか、そしてどのような状態を持っている可能性があるか(例えば、郵便番号付近の腫瘍専門医を検索する)を推測することができます。GDPR Article 9では、位置情報データからの健康関連の推論は特別カテゴリデータとして適格である可能性があります。プロフィールを保存しない場合でも、リアルタイムジオコーディングリクエストをクライアント側からサードパーティAPIに送信すると、法的根拠と開示が必要な処理レコードが作成されます。

等時線とは何ですか。医療機関検索で等時線が有用なのはなぜですか?

等時線は、開始位置から指定された移動時間内に到達可能なすべてのポイントを示すポリゴンです。医療検索サイトでは、等時線オーバーレイは「20分以内の医師は何人か?」という最も実用的な患者の質問に答えます。道路、速度制限、トラフィックパターンを考慮しているため、純粋な距離よりもはるかに有用な質問です。

MapAtlasを患者向けNHSまたは公衆衛生アプリケーションに使用できますか?

はい。MapAtlasはEUホストで、ISO 27001認証を取得したデータセンターで稼働しており、Article 28の下でGDPR準拠のデータ処理契約を提供しています。これは一般的な公部門調達要件を満たしています。英国NHS Digital、ドイツ(DSGVO)、フランス(CNIL)の同等の機関は、MapAtlasが満たすEU/UK内のデータレジデンシーが必要です。

役に立ちましたか?シェアしてください。

著者について

Brent van der Heiden

著者

Brent van der Heiden

Co-Founder & CEO at MapAtlas

Brent built MapAtlas out of a conviction that developers deserve location APIs with fair pricing and genuine end-user privacy. He writes about geospatial infrastructure, AI search visibility, and how location data powers the products people rely on every day.

すべての記事を見る
ブログに戻る