医疗服务提供者查找地图是开发者可以构建的最重要的地图之一。使用它的患者往往焦虑、痛苦,或在陌生的系统中摸索。一个加载缓慢、返回不相关结果,或悄悄将患者邮政编码发送到美国第三方服务的地图,不仅是糟糕的用户体验,更可能违反 GDPR。
本教程将构建一个可用于生产环境的医疗服务查找器:专科筛选、使用地理编码 API 的邮政编码搜索、显示「20 分钟内的医生」的等时线叠加层,以及使用 MapAtlas Maps API 的可工作地图,所有这些都从架构层面内置了 GDPR 合规性,而非事后补救。
完整实现采用原生 JavaScript,无需框架。它适用于任何技术栈:React、Vue、纯 HTML、WordPress 或医院的旧版 CMS。本教程还涵盖了欧盟监管背景、英国 CQC 数据、德国 Kassenärztliche Vereinigung 和法国 CNAM,让您了解在哪里合法获取医疗服务提供者数据。
为什么医疗位置应用有额外的 GDPR 义务
标准应用收集个人数据(姓名、电子邮件、使用记录)。医疗应用还有第二个风险:从位置数据推断健康信息。
当用户搜索「SE1 7PB 附近的心脏科医生」时,其邮政编码是个人数据。邮政编码与医疗专科的组合,在 GDPR 第 9(1) 条下可能成为揭示健康状况的特殊类别数据,即使您从未创建用户账户或存储个人档案,这一情况也同样存在。
具体风险如下:
- 包含用户输入邮政编码的客户端地理编码 API 调用,会被发送到第三方服务器。该服务器会看到邮政编码、正在搜索的专科和 IP 地址。没有 DPA 和欧盟数据驻留保证,这是一个有问题的数据传输。
- 浏览器自动填充和历史记录可能会在用户设备上持久保存输入的邮政编码和专科查询,这超出了您的控制范围,但值得在隐私信息中注明。
- 如果您在服务器端记录 API 响应(调试时常见),可能会在不知不觉中记录邮政编码与专科的组合。
解决方法是后端代理:您的服务器接收搜索请求,调用 MapAtlas 地理编码 API,并仅返回坐标,绝不从患者浏览器向第三方 API 发送输入的查询。实现部分将详细介绍。
医疗服务提供者数据结构
医疗服务提供者数据应来自权威官方来源,而非爬取的数据集:
- 英国:NHS Digital 的 NHS Choices API 和 CQC 注册医疗服务提供者名单
- 德国:Kassenärztliche Bundesvereinigung(KBV)开放数据和 Arztsuche API
- 法国:CNAM 的 Ameli API 和 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"
}
}
]
};
第一步:带专科筛选的地图初始化
从地图和专科下拉菜单开始。筛选器在客户端更新可见标记,无需任何网络请求。
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
});
// Provider markers, green = accepting, grey = full
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);
});
// Specialty filter
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]);
}
});
第二步:通过后端代理进行邮政编码搜索
与其从浏览器直接调用地理编码 API(这会将患者的邮政编码暴露给第三方服务器),不如将地理编码请求路由到您自己的后端端点。您的服务器调用 MapAtlas 并仅返回坐标。
前端(患者浏览器):
async function geocodePostcode(postcode) {
// Call YOUR backend, not the geocoding API directly
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, server-side only, postcode never leaves your infrastructure
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); // server-side 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;
// Return ONLY coordinates, no postcode logged, no specialty in response
res.json({ lat, lng });
});
您的 API 密钥保留在服务器端,患者的原始邮政编码查询永远不会发送给第三方。
第三步:等时线叠加层,「20 分钟内的医生」
等时线多边形显示在给定驾车时间内可到达的每个地点。在医疗服务查找器上展示它,回答了最实际的患者问题:「我实际上能联系到多少位医生?」
async function fetchIsochrone(lat, lng) {
// Call through your backend proxy for the same privacy reasons
const res = await fetch(
`/api/isochrone?lat=${lat}&lng=${lng}&minutes=20&profile=driving`
);
const geojson = await res.json();
// Remove existing isochrone layer if present
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'); // Insert below markers so pins render on top
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); // seconds
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 polygon
});
等时线地图详解文章深入介绍了等时线的使用场景,包括公共交通等时线(适用于没有私家车的患者)和显示 10、20、30 分钟区域的多时间段叠加层。
第四步:医疗服务提供者详情弹出框
当患者点击医生标记时,显示基本信息:姓名、专科、接诊状态、语言和下一个可预约时间。
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 = ''; });
第五步:与地图同步的列表面板
地图旁边的列表视图,可以帮助使用屏幕阅读器的用户以及偏好滚动而非点击标记的用户。
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 实施清单
对于欧盟医疗开发者,请将此作为最低合规清单:
- 地理编码的后端代理:患者邮政编码不得从客户端发送到第三方 API。
- 不记录搜索词的会话日志:如果您为调试而记录请求,请在写入日志之前去除邮政编码。
- 隐私声明:您的应用隐私声明必须披露位置数据被处理用于查找附近医疗服务提供者、法律依据(合法利益或明确同意)以及保留期限。
- 位置访问同意:如果您使用浏览器地理定位 API(
navigator.geolocation.getCurrentPosition),在触发浏览器权限提示之前,请显示清晰的用途说明。 - 数据驻留:MapAtlas 在欧盟内部处理所有 API 请求。在填写处理活动记录时,请参考 MapAtlas DPA。
- 保留期限:如果您在服务器端缓存等时线结果或搜索坐标以提升性能,请定义保留期限(例如 24 小时)并自动化删除流程。
欧盟开发者 GDPR 合规地图 API 指南全面介绍了处理协议、数据驻留和同意模式。医疗服务行业页面列出了与数字医疗应用相关的具体 MapAtlas 功能。
下一步构建内容
- 注册免费的 MapAtlas API 密钥,一个密钥即可使用地图、地理编码、路线规划和等时线功能
- 参考门店定位器教程模式,适用于拥有多家诊所的医疗网络
- 探索地理编码 API 功能页面,了解邮政编码查询、逆地理编码和批量医疗服务提供者地址验证
常见问题
即使不存储患者数据,GDPR 为何仍适用于医生查找地图?
患者在医疗查找工具上的搜索位置,揭示了他们的居住地,以及可以推断出的健康状况(例如在某邮政编码附近搜索肿瘤科医生)。根据 GDPR 第 9 条,从位置数据推断出的健康相关信息可能构成特殊类别数据。即使不存储档案,从客户端实时向第三方 API 发送的地理编码请求,也会产生需要法律依据和披露的处理记录。
什么是等时线,它对医疗服务查找器有何用处?
等时线是一个多边形,显示从起始位置在给定出行时间内可到达的每个地点。在医疗服务查找器上,等时线叠加层回答的问题是「哪些医生在 20 分钟车程内?」,这比原始距离更实用,因为它考虑了道路、限速和交通模式。
我可以将 MapAtlas 用于面向患者的 NHS 或公共卫生应用吗?
可以。MapAtlas 托管于欧盟,获得 ISO 27001 认证,并依据第 28 条提供符合 GDPR 的数据处理协议。这满足了典型的公共部门采购要求。英国 NHS Digital 以及德国(DSGVO)和法国(CNIL)的同等机构要求数据驻留在欧盟/英国境内,MapAtlas 满足这一要求。
常见问题
即使不存储患者数据,GDPR 为何仍适用于医生查找地图?
患者在医疗查找工具上的搜索位置,揭示了他们的居住地,以及可以推断出的健康状况(例如在某邮政编码附近搜索肿瘤科医生)。根据 GDPR 第 9(1) 条,从位置数据推断出的健康相关信息可能构成特殊类别数据。即使不创建用户账户或存储档案,从客户端实时向第三方 API 发送的地理编码请求,也会产生需要法律依据和披露的处理记录。
什么是等时线,它对医疗服务查找器有何用处?
等时线是一个多边形,显示从起始位置在给定出行时间内可到达的每个地点。在医疗服务查找器上,等时线叠加层回答的问题是「哪些医生在 20 分钟车程内?」,这比原始距离更实用,因为它考虑了道路、限速和交通模式。
我可以将 MapAtlas 用于面向患者的 NHS 或公共卫生应用吗?
可以。MapAtlas 托管于欧盟,获得 ISO 27001 认证,并依据第 28 条提供符合 GDPR 的数据处理协议。这满足了典型的公共部门采购要求。英国 NHS Digital 以及德国(DSGVO)和法国(CNIL)的同等机构要求数据驻留在欧盟/英国境内,MapAtlas 满足这一要求。

