前言
在做景区规划图、厂区平面图、管线示意等「没有地理坐标、只有像素位置」的图件在线展示时,我们经常会遇到这样一个需求:
在图上打点 ,点开之后要弹出一个「像样的」信息面板------不是 Leaflet 默认那个白底小气泡,而是一个与项目整体视觉风格统一、字段可任意扩展、还能带状态灯/占比条的科技感面板。
本文基于一张真实的「桃花岭景区自行车道规划图」(1920×1356 像素),使用 L.CRS.Simple 把它当作无空间参考的像素画布,完整演示:
- 为什么必须做自定义标注(默认 marker 有哪些坑);
- 如何用
L.divIcon做出圆形科技感标注点; - 如何把信息面板从「朴素气泡」升级为深蓝科技风 HUD 信息卡(四角装饰、脉冲状态灯、装饰代号、状态徽标、负载条);
- 点击高亮联动等工程细节。

📷 图1 初始全图效果
一、为什么要进行「自定义标注」
1.1 默认 Marker 的三大局限
很多同学第一反应是「Leaflet 不是自带 marker 吗?直接用不就行了」。但在真实项目里,默认 marker 很快会遇到瓶颈:
| 局限 | 具体表现 |
|---|---|
| 样式不可控 | 默认是蓝色水滴图钉(PNG 图标),无法用 CSS 做渐变、发光、脉冲动画,与科技风系统格格不入 |
| 内容只是个图标 | marker 本身只能放一张图,文字标签要靠额外 tooltip/label,样式与定位都要另写 |
| 信息面板是「白底气泡」 | 默认 popup 是白底小框,无法满足「多字段、两列、带状态、带主题色」的业务信息展示 |
1.2 自定义标注的价值
把标注拆成「图标(divIcon)+ 数据(SITES 数组)+ 面板(popup 自定义 HTML)」三层,换来的是:
- 视觉统一:图标、面板、整站主题色一致,直接提升「系统感」;
- 数据驱动:新增一个标注 = 往数组加一条,渲染逻辑零改动;
- 可扩展性 :面板字段支持任意条数,还能挂「状态徽标
tag」「占比条bar」等富信息; - 可交互联动:点击标注 → 高亮 + 打开面板 + 位置点变色,一气呵成。
一句话:自定义标注不是「炫技」,而是把「地图打点」升级为「业务信息节点」的必经之路。
📷 图片占位:图2 默认 marker vs 自定义标注对比
左:蓝色水滴图钉(朴素);右:圆形科技感标注点(渐变+脉冲光环+文字)
(此处预留截图位置,图片可留空;发布时替换为对比示意)
二、技术基底:L.CRS.Simple 无空间参考坐标系
无坐标图片的关键在于「不当它是有投影的地图,而把它当成一块像素画布」:
js
// 图片像素尺寸(1920 x 1356)
const IMG_W = 1920, IMG_H = 1356;
// L.CRS.Simple:无空间参考的平面直角坐标系(单位 = 像素)
// y 轴向上为正,因此 bounds 的 y 取负值,保证图片显示方向正确
const bounds = L.latLngBounds([
[ -IMG_H, 0 ], // 图片左下角(y 翻转)
[ 0, IMG_W ] // 图片右上角
]);
// 图片中心点(CRS.Simple 坐标系),旋转始终绕该点进行
const IMAGE_CENTER = bounds.getCenter();
const map = L.map('map', {
crs: L.CRS.Simple, // 无空间参考坐标系
rotate: true, // 开启旋转能力(leaflet-rotate 插件)
bearing: 0,
minZoom: -3,
maxZoom: 4,
zoomSnap: 0.25, // 平滑缩放
zoomDelta: 0.5,
attributionControl: false
});
// 以图片方式加载规划图(ImageOverlay,不做任何投影变换)
L.imageOverlay('taohualing.jpg', bounds, {
interactive: true,
className: 'plan-img'
}).addTo(map);
map.fitBounds(bounds, { padding: [20, 20] });
关键换算 :在 L.CRS.Simple 下,经纬度坐标被当作「平面 x/y」,且 y 轴向上为正。所以「图片像素 (px, py)」要映射成地图坐标 latLng(-py, px),封装成一个 helper 后面所有标注都复用它:
js
// 像素坐标 → 地图坐标(CRS.Simple 下 latlng = (-py, px))
const P = (px, py) => L.latLng(-py, px);
三、自定义标注:圆形科技感图标
3.1 用 L.divIcon 彻底接管图标渲染
L.divIcon 不加载任何图片,而是渲染一段 HTML + CSS,因此我们可以对标注外观做 100% 控制:
js
// 圆形科技感标注图标(L.divIcon,尺寸 28×28,锚点居中)
function makeIcon(glyph) {
return L.divIcon({
className: '', // 清空默认样式,完全由自己的 CSS 控制
iconSize: [28, 28],
iconAnchor: [14, 14], // 锚点 = 圆心,保证标注精确落在坐标点
popupAnchor: [0, -16],
html: `<div class="site-marker">` +
`<span class="pulse"></span>` + // 外圈脉冲光环
`<span class="core">${glyph}</span>` + // 圆形主体 + 文字
`</div>`
});
}
3.2 标注的视觉样式(CSS)
图标由「脉冲光环 + 圆形主体 」构成。圆形主体用 radial-gradient 做立体感,外圈用 @keyframes 做呼吸扩散动画,hover 放大,active 切换为科技蓝:
css
/* 自定义标注点(圆形科技感图标) */
.site-marker {
position: relative;
width: 28px; height: 28px;
cursor: pointer;
}
/* 外圈脉冲光环 */
.site-marker .pulse {
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid rgba(43, 191, 126, .65);
animation: sitePulse 1.8s ease-out infinite;
}
@keyframes sitePulse {
0% { transform: scale(.7); opacity: 1; }
100% { transform: scale(1.6); opacity: 0; }
}
/* 圆形主体:深色底 + 绿色描边(科技感) */
.site-marker .core {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #2fbf71, #146c43 75%);
border: 2px solid #d9f2e5;
box-shadow: 0 0 8px rgba(47, 191, 113, .8), inset 0 0 6px rgba(255,255,255,.25);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 13px; line-height: 1;
transition: transform .15s;
}
.site-marker:hover .core { transform: scale(1.15); }
/* 选中态(信息面板打开时):变科技蓝 */
.site-marker.active .core {
background: radial-gradient(circle at 35% 30%, #4fc3f7, #0d47a1 75%);
border-color: #e3f2fd;
box-shadow: 0 0 10px rgba(79, 195, 247, .9), inset 0 0 6px rgba(255,255,255,.3);
}
.site-marker.active .pulse { border-color: rgba(79, 195, 247, .75); }
3.3 数据驱动:新增标注只改这一处
标注点的「位置、图标文字、弹窗内容」全部由 SITES 数组驱动。新增一个节点只需往数组加一条,渲染逻辑完全不用动:
js
const SITES = [
{
px: 560, py: 520, icon: '客',
title: '游客服务中心',
fields: [
{ label: '编码', value: 'THL-001' },
{ label: '类型', value: '综合服务' },
{ label: '城市', value: '长沙市' },
{ label: '地址', value: '桃花岭景区东入口' },
{ label: '热线', value: '0731-8888 6666' },
{ label: '开放', value: '08:30 - 17:30', tag: 'ok', tagText: '营业' },
{ label: '简介', value: '提供票务咨询、导览图领取、行李寄存与医疗急救服务,配备无障碍通道。', full: true }
]
},
// ...... 其余 4 个标注点(观景台 / 驿站 / 茶舍 / 出入口)结构相同
];
设计要点 :
fields是一个「任意条数」的字段数组,每个字段支持三个可选增强属性:
full: true------ 该字段独占整行(用于长文本简介);tag+tagText------ 彩色状态徽标(如「营业 / 眺望 / 预订」);bar: 0~1------ 占比/负载条(如车位占用率)。
📷 图2 自定义标注点特写
3.4 把标注落到地图上
每个 SITES 元素,先通过 P(px,py) 换算真实坐标,再创建 marker 并绑定面板 popup:
js
SITES.forEach(site => {
const pos = P(site.px, site.py); // 真实"位置"点 = 标注图标落点(零偏移贴合)
// 位置点(小圆点)作为标注图标的中心锚定标记,与图标严格零偏移贴合
const dot = addPositionDot(pos);
const marker = L.marker(pos, {
icon: makeIcon(site.icon),
riseOnHover: true
}).addTo(map);
// 信息面板 popup(重点见第四章)
const popup = L.popup({
className: 'tech-popup',
closeButton: false, // 用面板自己的 ✕
autoPan: true,
offset: [0, -4]
}).setContent(buildPanelHTML(site));
marker.bindPopup(popup);
// ......点击高亮联动(见 4.2)
});
四、信息面板:从「朴素气泡」到「科技风信息卡」
这是本文的重点 。默认 popup 是白底小框,我们需要一个深蓝半透明、带 HUD 四角、脉冲状态灯、装饰代号、多字段网格、状态徽标与负载条的专业面板。
4.1 为什么不能直接用默认 popup
默认 popup 的 DOM 是 Leaflet 生成的固定结构,样式空间有限。正确做法是:
- 通过
className: 'tech-popup'给 popup 换肤; - 用
setContent(buildPanelHTML(site))把完全自定义的 HTML 塞进去; - 在 CSS 中针对
.tech-popup重写外观。
4.2 面板的整体外观(毛玻璃 + 发光 + 入场动画)
css
/* 信息面板(深蓝科技风 · 增强版) */
.tech-popup .leaflet-popup-content-wrapper {
position: relative; /* 作为四角装饰的定位上下文 */
background: rgba(9, 24, 46, .86); /* 半透明深蓝,毛玻璃质感 */
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid #2f6fb0;
border-radius: 6px;
box-shadow: 0 0 0 1px rgba(79,195,247,.22), /* 内描边发光 */
0 10px 34px rgba(4, 16, 36, .65),
0 0 26px rgba(42, 109, 181, .40); /* 外发光晕 */
color: #cfe6ff;
animation: tpIn .26s cubic-bezier(.2,.8,.2,1);
}
@keyframes tpIn { /* 入场:淡入 + 轻微上移缩放 */
from { opacity: 0; transform: translateY(6px) scale(.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.tech-popup .leaflet-popup-tip {
background: rgba(9, 24, 46, .86);
border: 1px solid #2f6fb0;
}
.tech-popup .leaflet-popup-content {
margin: 0;
width: 360px !important; /* 面板固定宽度 */
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
line-height: 1.5;
}
.tech-popup .leaflet-popup-close-button { display: none; } /* 用自己的 × */
4.3 HUD 四角装饰 + 脉冲状态灯 + 装饰代号
这四角是「指挥系统」风格的视觉符号,纯 CSS 实现:
css
/* 四角科技装饰(HUD 风格切角) */
.tp-corner {
position: absolute; width: 12px; height: 12px;
border: 2px solid #4fc3f7; opacity: .85; z-index: 3;
}
.tp-corner.tl { top: 5px; left: 5px; border-width: 2px 0 0 2px; }
.tp-corner.tr { top: 5px; right: 5px; border-width: 2px 2px 0 0; }
.tp-corner.bl { bottom: 5px; left: 5px; border-width: 0 0 2px 2px; }
.tp-corner.br { bottom: 5px; right: 5px;border-width: 0 2px 2px 0; }
/* 标题栏:渐变 + 发光标题 + 状态灯 */
.tp-head {
position: relative;
display: flex; align-items: center; gap: 8px;
padding: 9px 12px;
background: linear-gradient(90deg, #123a6e, #1f5fa6 60%, #2a73c9);
border-bottom: 1px solid #3a7bc0;
}
.tp-head .tp-led { /* 脉冲状态指示灯 */
flex: 0 0 auto;
width: 8px; height: 8px; border-radius: 50%;
background: #5fe39a;
box-shadow: 0 0 8px #5fe39a, 0 0 2px #fff;
animation: tpLed 1.6s ease-in-out infinite;
}
@keyframes tpLed { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.tp-head .tp-title {
flex: 1; font-size: 14px; font-weight: 700; letter-spacing: 1px;
color: #eaf5ff; text-shadow: 0 0 8px rgba(79,195,247,.7); /* 标题发光 */
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tp-head .tp-close { /* ✕ 关闭按钮 */ }
/* 装饰副标题(代号 / 类型,等宽字体大写,装饰性文字元素) */
.tp-sub {
font-size: 10px; letter-spacing: 2px;
color: #5b9bd5; padding: 4px 12px 0;
font-family: Consolas, "Courier New", monospace;
text-transform: uppercase; opacity: .85;
}
4.4 字段网格 + 状态徽标 + 占比条
信息体用两列网格排布,每条字段支持「序号 + 标签 + 值 + 可选徽标 + 可选占比条」:
css
/* 信息体:两列网格 + 极淡科技网格背景 */
.tp-body {
position: relative;
padding: 12px 14px 14px;
display: grid;
grid-template-columns: 1fr 1fr; /* 每行两列 */
gap: 10px 12px;
background-image:
linear-gradient(rgba(79,195,247,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(79,195,247,.05) 1px, transparent 1px);
background-size: 20px 20px;
}
.tp-item { min-width: 0; }
.tp-item.full { grid-column: 1 / -1; } /* 单条长信息占满整行 */
.tp-item .tp-row {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
}
.tp-item .tp-idx { /* 字段序号 */
font-size: 9px; color: #4fc3f7;
font-family: Consolas, monospace; margin-right: 4px; opacity: .7;
}
.tp-item .tp-label { font-size: 12px; color: #7fa6cc; white-space: nowrap; }
.tp-item .tp-value {
font-size: 13px; color: #e8f4ff; text-align: right; word-break: break-all;
text-shadow: 0 0 6px rgba(79,195,247,.25); /* 数值微发光 */
}
.tp-item.full .tp-value { text-align: left; }
/* 彩色状态徽标 */
.tp-tag {
display: inline-block; margin-left: 6px;
padding: 1px 7px; border-radius: 10px;
font-size: 11px; line-height: 1.5; border: 1px solid currentColor;
}
.tp-tag.ok { color: #5fe39a; background: rgba(95,227,154,.12); }
.tp-tag.warn { color: #ffd166; background: rgba(255,209,102,.12); }
.tp-tag.off { color: #9fb3c8; background: rgba(159,179,200,.12); }
/* 占比/负载条 */
.tp-bar {
margin-top: 4px; height: 4px; border-radius: 3px;
background: rgba(255,255,255,.10); overflow: hidden;
}
.tp-bar > i {
display: block; height: 100%;
background: linear-gradient(90deg, #4fc3f7, #5fe39a);
box-shadow: 0 0 6px rgba(79,195,247,.6);
}
4.5 面板 HTML 的拼装逻辑(buildPanelHTML)
面板内容是完全数据驱动 生成的:遍历 site.fields,按需插入序号、徽标、占比条;并从字段中自动提取「编码」作为装饰代号:
js
// 构造信息面板 HTML:四角装饰 + 标题栏(状态灯) + 装饰代号 + 两列字段(序号/徽标/占比条)
function buildPanelHTML(site) {
const items = site.fields.map((f, i) => {
const idx = String(i + 1).padStart(2, '0');
const tag = f.tag
? `<span class="tp-tag ${f.tag}">${f.tagText || f.tag}</span>` : '';
const bar = (typeof f.bar === 'number')
? `<div class="tp-bar"><i style="width:${Math.max(0, Math.min(100, f.bar * 100))}%"></i></div>` : '';
return `<div class="tp-item${f.full ? ' full' : ''}">` +
`<div class="tp-row">` +
`<span class="tp-label"><i class="tp-idx">${idx}</i>${f.label}</span>` +
`<span class="tp-value">${f.value}${tag}</span>` +
`</div>${bar}</div>`;
}).join('');
const code = (site.fields.find(f => f.label === '编码') || {}).value || '';
return `<div class="tp-corner tl"></div><div class="tp-corner tr"></div>` +
`<div class="tp-corner bl"></div><div class="tp-corner br"></div>` +
`<div class="tp-head">` +
`<span class="tp-led"></span>` +
`<span class="tp-title">${site.title}</span>` +
`<button class="tp-close" title="关闭">✕</button>` +
`</div>` +
(code ? `<div class="tp-sub">FACILITY · ${code}</div>` : '') +
`<div class="tp-body">${items}</div>`;
}
五、交互联动:点击高亮 + 关闭
点击标注 → 图标变蓝(.active)+ 面板打开 + 位置点变蓝;关闭面板或点别处 → 还原绿色。面板自己的 ✕ 按钮通过 popupopen 事件委托绑定:
js
SITES.forEach(site => {
// ...... 创建 marker / dot / popup(见 3.4)
// 点击标注:打开面板 + 高亮标注 + 位置点联动变色
marker.on('click', () => {
if (activeSite) {
activeSite._marker.getElement().classList.remove('active');
activeSite._dot.setStyle({ color: '#2fbf71', fillColor: '#d9f2e5' });
}
activeSite = site;
marker.getElement().classList.add('active');
site._dot.setStyle({ color: '#4fc3f7', fillColor: '#e3f2fd' });
});
// 面板关闭(含点击地图其他位置):取消高亮与联动变色
marker.on('popupclose', () => {
marker.getElement().classList.remove('active');
if (activeSite === site) {
site._dot.setStyle({ color: '#2fbf71', fillColor: '#d9f2e5' });
activeSite = null;
}
});
// 面板内自己的 ✕ 按钮(事件委托,DOM 在 popup 打开后才存在)
marker.on('popupopen', e => {
const btn = e.popup.getElement().querySelector('.tp-close');
if (btn) btn.onclick = () => map.closePopup();
});
});

📷 图3 点击高亮联动
六、完整 HTML 源码
以下是可直接运行的 index_marker.html 全量代码(与底图 taohualing.jpg 同目录即可双击打开,需联网加载 CDN):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>桃花岭景区规划图 - Leaflet 查看器(自定义标注 + 信息面板)</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet-rotate@0.2.1/dist/leaflet-rotate.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
background: #f4f7f6; display: flex; flex-direction: column;
}
header {
position: relative; z-index: 2000; flex: 0 0 auto;
background: linear-gradient(135deg, #2e7d32, #66bb6a);
color: #fff; padding: 10px 20px;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.15);
}
header h1 { font-size: 18px; font-weight: 600; letter-spacing: 1px; }
header .sub { font-size: 12px; opacity: .85; }
#toolbar { display: flex; align-items: center; gap: 8px;
background: rgba(255,255,255,.15); padding: 6px 12px; border-radius: 8px; }
#toolbar button {
background: #fff; color: #2e7d32; border: none; border-radius: 6px;
padding: 5px 12px; font-size: 13px; cursor: pointer; font-weight: 600; transition: all .15s;
}
#toolbar button:hover { background: #e8f5e9; transform: translateY(-1px); }
#bearingVal { color: #fff; font-size: 13px; min-width: 64px; text-align: center; }
#map { flex: 1; position: relative; z-index: 1; background: #eef2f0; }
#info {
position: fixed; z-index: 2000; bottom: 16px; left: 50%;
transform: translateX(-50%);
background: rgba(255,255,255,.92); border: 1px solid #cfe3d2;
border-radius: 8px; padding: 6px 16px; font-size: 12px; color: #555;
box-shadow: 0 2px 6px rgba(0,0,0,.1); pointer-events: none; white-space: nowrap;
}
/* ================= 自定义标注点(圆形科技感图标) ================= */
.site-marker { position: relative; width: 28px; height: 28px; cursor: pointer; }
.site-marker .pulse {
position: absolute; inset: -4px; border-radius: 50%;
border: 2px solid rgba(43, 191, 126, .65); animation: sitePulse 1.8s ease-out infinite;
}
@keyframes sitePulse { 0% { transform: scale(.7); opacity: 1; } 100% { transform: scale(1.6); opacity: 0; } }
.site-marker .core {
position: absolute; inset: 0; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #2fbf71, #146c43 75%);
border: 2px solid #d9f2e5;
box-shadow: 0 0 8px rgba(47, 191, 113, .8), inset 0 0 6px rgba(255,255,255,.25);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 13px; line-height: 1; transition: transform .15s;
}
.site-marker:hover .core { transform: scale(1.15); }
.site-marker.active .core {
background: radial-gradient(circle at 35% 30%, #4fc3f7, #0d47a1 75%);
border-color: #e3f2fd;
box-shadow: 0 0 10px rgba(79, 195, 247, .9), inset 0 0 6px rgba(255,255,255,.3);
}
.site-marker.active .pulse { border-color: rgba(79, 195, 247, .75); }
/* ================= 信息面板(深蓝科技风 · 增强版) ================= */
.tech-popup .leaflet-popup-content-wrapper {
position: relative;
background: rgba(9, 24, 46, .86); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: 1px solid #2f6fb0; border-radius: 6px;
box-shadow: 0 0 0 1px rgba(79,195,247,.22), 0 10px 34px rgba(4, 16, 36, .65), 0 0 26px rgba(42, 109, 181, .40);
color: #cfe6ff; animation: tpIn .26s cubic-bezier(.2,.8,.2,1);
}
@keyframes tpIn { from { opacity: 0; transform: translateY(6px) scale(.96); } to { opacity: 1; transform: translateY(0) scale(1); } }
.tech-popup .leaflet-popup-tip { background: rgba(9, 24, 46, .86); border: 1px solid #2f6fb0; }
.tech-popup .leaflet-popup-content { margin: 0; width: 360px !important;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; line-height: 1.5; }
.tech-popup .leaflet-popup-close-button { display: none; }
.tp-corner { position: absolute; width: 12px; height: 12px; border: 2px solid #4fc3f7; opacity: .85; z-index: 3; }
.tp-corner.tl { top: 5px; left: 5px; border-width: 2px 0 0 2px; }
.tp-corner.tr { top: 5px; right: 5px; border-width: 2px 2px 0 0; }
.tp-corner.bl { bottom: 5px; left: 5px; border-width: 0 0 2px 2px; }
.tp-corner.br { bottom: 5px; right: 5px; border-width: 0 2px 2px 0; }
.tp-head {
position: relative; display: flex; align-items: center; gap: 8px; padding: 9px 12px;
background: linear-gradient(90deg, #123a6e, #1f5fa6 60%, #2a73c9); border-bottom: 1px solid #3a7bc0;
}
.tp-head .tp-led {
flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: #5fe39a;
box-shadow: 0 0 8px #5fe39a, 0 0 2px #fff; animation: tpLed 1.6s ease-in-out infinite;
}
@keyframes tpLed { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.tp-head .tp-title {
flex: 1; font-size: 14px; font-weight: 700; letter-spacing: 1px; color: #eaf5ff;
text-shadow: 0 0 8px rgba(79,195,247,.7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tp-head .tp-close { flex: 0 0 auto; width: 22px; height: 22px; border: none; border-radius: 3px;
background: transparent; color: #cfe6ff; font-size: 15px; line-height: 1; cursor: pointer; }
.tp-head .tp-close:hover { background: rgba(255,255,255,.18); color: #fff; }
.tp-sub { font-size: 10px; letter-spacing: 2px; color: #5b9bd5; padding: 4px 12px 0;
font-family: Consolas, "Courier New", monospace; text-transform: uppercase; opacity: .85; }
.tp-body {
position: relative; padding: 12px 14px 14px; display: grid; grid-template-columns: 1fr 1fr;
gap: 10px 12px;
background-image:
linear-gradient(rgba(79,195,247,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(79,195,247,.05) 1px, transparent 1px);
background-size: 20px 20px;
}
.tp-item { min-width: 0; }
.tp-item.full { grid-column: 1 / -1; }
.tp-item .tp-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.tp-item.full .tp-row { flex-direction: column; align-items: stretch; gap: 2px; }
.tp-item .tp-idx { font-size: 9px; color: #4fc3f7; font-family: Consolas, monospace; margin-right: 4px; opacity: .7; }
.tp-item .tp-label { font-size: 12px; color: #7fa6cc; white-space: nowrap; }
.tp-item .tp-value { font-size: 13px; color: #e8f4ff; text-align: right; word-break: break-all;
text-shadow: 0 0 6px rgba(79,195,247,.25); }
.tp-item.full .tp-value { text-align: left; }
.tp-tag { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px;
font-size: 11px; line-height: 1.5; border: 1px solid currentColor; }
.tp-tag.ok { color: #5fe39a; background: rgba(95,227,154,.12); }
.tp-tag.warn { color: #ffd166; background: rgba(255,209,102,.12); }
.tp-tag.off { color: #9fb3c8; background: rgba(159,179,200,.12); }
.tp-bar { margin-top: 4px; height: 4px; border-radius: 3px; background: rgba(255,255,255,.10); overflow: hidden; }
.tp-bar > i { display: block; height: 100%; background: linear-gradient(90deg, #4fc3f7, #5fe39a);
box-shadow: 0 0 6px rgba(79,195,247,.6); }
</style>
</head>
<body>
<header>
<div>
<h1>岳麓山国家级风景名胜区桃花岭景区详细规划 · 自行车道规划图</h1>
<div class="sub">Leaflet CRS.Simple(无空间参考 / 像素坐标系)· 缩放 · 旋转 · 自定义标注与信息面板</div>
</div>
<div id="toolbar">
<button id="zoomIn" title="放大">+ 放大</button>
<button id="zoomOut" title="缩小">- 缩小</button>
<button id="rotLeft" title="逆时针旋转 15°">↺ 左转15°</button>
<button id="rotRight" title="顺时针旋转 15°">↻ 右转15°</button>
<input id="bearingSlider" type="range" min="-180" max="180" step="1" value="0" style="width:140px;" title="旋转角度滑块">
<span id="bearingVal">0°</span>
<button id="reset" title="重置视图">⟲ 重置</button>
</div>
</header>
<div id="map"></div>
<div id="info">滚轮缩放 · 拖拽平移 · <b>点击标注点查看信息面板</b> · Ctrl + Alt + 拖拽 旋转 · Alt + 滚轮 缩放</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-rotate@0.2.1/dist/leaflet-rotate.js"></script>
<script>
const IMG_W = 1920, IMG_H = 1356;
const bounds = L.latLngBounds([ [ -IMG_H, 0 ], [ 0, IMG_W ] ]);
const IMAGE_CENTER = bounds.getCenter();
const map = L.map('map', {
crs: L.CRS.Simple, rotate: true, bearing: 0,
minZoom: -3, maxZoom: 4, zoomSnap: 0.25, zoomDelta: 0.5, attributionControl: false
});
L.imageOverlay('taohualing.jpg', bounds, { interactive: true, className: 'plan-img' }).addTo(map);
map.fitBounds(bounds, { padding: [20, 20] });
const P = (px, py) => L.latLng(-py, px);
// 位置点小圆点
function addPositionDot(ll, opts) {
const dot = L.circleMarker(ll, Object.assign(
{ radius: 3.5, color: '#2fbf71', weight: 1.5, fillColor: '#d9f2e5', fillOpacity: 1 }, opts || {}));
dot.addTo(map);
return dot;
}
// 标注数据:完全数据驱动(icon=内文字符;fields=任意条数字段,支持 full/tag/bar)
const SITES = [
{ px: 560, py: 520, icon: '客', title: '游客服务中心', fields: [
{ label: '编码', value: 'THL-001' }, { label: '类型', value: '综合服务' },
{ label: '城市', value: '长沙市' }, { label: '地址', value: '桃花岭景区东入口' },
{ label: '热线', value: '0731-8888 6666' },
{ label: '开放', value: '08:30 - 17:30', tag: 'ok', tagText: '营业' },
{ label: '简介', value: '提供票务咨询、导览图领取、行李寄存与医疗急救服务,配备无障碍通道。', full: true } ] },
{ px: 940, py: 380, icon: '观', title: '山顶观景台', fields: [
{ label: '编码', value: 'THL-002' }, { label: '类型', value: '观景设施' },
{ label: '海拔', value: '约 268 米' },
{ label: '朝向', value: '东南 · 可眺望湘江', tag: 'ok', tagText: '眺望' },
{ label: '建议', value: '日落前 1 小时景观最佳', full: true } ] },
{ px: 1240, py: 660, icon: '驿', title: '骑行驿站(南)', fields: [
{ label: '编码', value: 'THL-003' }, { label: '类型', value: '驿站补给' },
{ label: '车位', value: '自行车 40 辆', bar: 0.5 },
{ label: '饮水', value: '直饮水 · 免费头盔', tag: 'ok', tagText: '供给' },
{ label: '维修', value: '补胎 / 打气 / 简易维修工具', full: true } ] },
{ px: 760, py: 880, icon: '茶', title: '湖畔茶舍', fields: [
{ label: '编码', value: 'THL-004' }, { label: '类型', value: '餐饮休憩' },
{ label: '人均', value: '约 45 元' },
{ label: '包间', value: '4 间(需预订)', tag: 'warn', tagText: '预订' },
{ label: '电话', value: '0731-8666 8888' } ] },
{ px: 1520, py: 300, icon: '出', title: '西门出入口', fields: [
{ label: '编码', value: 'THL-005' }, { label: '类型', value: '出入口' },
{ label: '状态', value: '开放中', tag: 'ok', tagText: '在线' },
{ label: '车位', value: '地面停车 120 个', bar: 0.6 },
{ label: '公交', value: 'W116 / 桃花岭站 步行 300 米', full: true } ] }
];
let activeSite = null;
function buildPanelHTML(site) {
const items = site.fields.map((f, i) => {
const idx = String(i + 1).padStart(2, '0');
const tag = f.tag ? `<span class="tp-tag ${f.tag}">${f.tagText || f.tag}</span>` : '';
const bar = (typeof f.bar === 'number')
? `<div class="tp-bar"><i style="width:${Math.max(0, Math.min(100, f.bar * 100))}%"></i></div>` : '';
return `<div class="tp-item${f.full ? ' full' : ''}">` +
`<div class="tp-row">` +
`<span class="tp-label"><i class="tp-idx">${idx}</i>${f.label}</span>` +
`<span class="tp-value">${f.value}${tag}</span>` +
`</div>${bar}</div>`;
}).join('');
const code = (site.fields.find(f => f.label === '编码') || {}).value || '';
return `<div class="tp-corner tl"></div><div class="tp-corner tr"></div>` +
`<div class="tp-corner bl"></div><div class="tp-corner br"></div>` +
`<div class="tp-head"><span class="tp-led"></span>` +
`<span class="tp-title">${site.title}</span>` +
`<button class="tp-close" title="关闭">✕</button></div>` +
(code ? `<div class="tp-sub">FACILITY · ${code}</div>` : '') +
`<div class="tp-body">${items}</div>`;
}
function makeIcon(glyph) {
return L.divIcon({ className: '', iconSize: [28, 28], iconAnchor: [14, 14], popupAnchor: [0, -16],
html: `<div class="site-marker"><span class="pulse"></span><span class="core">${glyph}</span></div>` });
}
SITES.forEach(site => {
const pos = P(site.px, site.py);
const dot = addPositionDot(pos);
const marker = L.marker(pos, { icon: makeIcon(site.icon), riseOnHover: true }).addTo(map);
site._dot = dot; site._marker = marker;
const popup = L.popup({ className: 'tech-popup', closeButton: false, autoPan: true, offset: [0, -4] })
.setContent(buildPanelHTML(site));
marker.bindPopup(popup);
marker.on('click', () => {
if (activeSite) {
activeSite._marker.getElement().classList.remove('active');
activeSite._dot.setStyle({ color: '#2fbf71', fillColor: '#d9f2e5' });
}
activeSite = site;
marker.getElement().classList.add('active');
site._dot.setStyle({ color: '#4fc3f7', fillColor: '#e3f2fd' });
});
marker.on('popupclose', () => {
marker.getElement().classList.remove('active');
if (activeSite === site) { site._dot.setStyle({ color: '#2fbf71', fillColor: '#d9f2e5' }); activeSite = null; }
});
marker.on('popupopen', e => { const btn = e.popup.getElement().querySelector('.tp-close');
if (btn) btn.onclick = () => map.closePopup(); });
});
const bearingSlider = document.getElementById('bearingSlider');
const bearingVal = document.getElementById('bearingVal');
function norm(d) { return ((d + 180) % 360 + 360) % 360 - 180; }
function setBearing(deg) {
map.setView(IMAGE_CENTER, map.getZoom(), { animate: false });
map.setBearing(deg);
bearingSlider.value = deg; bearingVal.textContent = deg + '°';
}
document.getElementById('zoomIn').onclick = () => map.zoomIn(1);
document.getElementById('zoomOut').onclick = () => map.zoomOut(1);
document.getElementById('rotLeft').onclick = () => setBearing(norm(map.getBearing() - 15));
document.getElementById('rotRight').onclick = () => setBearing(norm(map.getBearing() + 15));
bearingSlider.oninput = e => setBearing(norm(+e.target.value));
document.getElementById('reset').onclick = () => { setBearing(0); map.fitBounds(bounds, { padding: [20, 20] }); };
map.on('rotate', () => { const b = Math.round(map.getBearing()); bearingSlider.value = b; bearingVal.textContent = b + '°'; });
</script>
</body>
</html>

📷 图4 完整运行效果
七、总结
本文围绕「无坐标图片上的自定义标注与信息面板」这一主题,讲清了三件事:
- 为什么要自定义:默认 marker 样式不可控、信息面板朴素,无法满足业务系统的视觉与字段需求;自定义 = 把「地图打点」升级为「业务信息节点」。
- 标注怎么做 :用
L.divIcon接管图标渲染,做出圆形科技感 + 脉冲动画,并以「SITES数组 +P(px,py)换算」实现数据驱动,新增节点零成本。 - 面板怎么做 :用「自定义
className+setContent注入 HTML」把默认气泡升级为深蓝毛玻璃 HUD 信息卡,内含四角装饰、脉冲状态灯、装饰代号、两列字段、状态徽标与负载条,且字段完全数据驱动。
可迁移价值 :本方案的坐标系(L.CRS.Simple)与无坐标图片绑定,但面板/标注的封装思路可直接迁移到带真实经纬度的地图 上------只要把 P(px,py) 换成 [lat, lng],整套科技风信息卡即可复用到供水管网节点、泵站、用水户等正式业务系统。
适用场景延伸:景区/厂区平面图标注、应急资源点位、管网设施信息卡、POI 空间分布可视化等,均可基于本文的「divIcon 自定义标注 + 科技风信息面板」范式快速落地。
