Leaflet 无坐标图片上的自定义标注与科技风信息面板(CRS.Simple 实战)

前言

在做景区规划图、厂区平面图、管线示意等「没有地理坐标、只有像素位置」的图件在线展示时,我们经常会遇到这样一个需求:

在图上打点 ,点开之后要弹出一个「像样的」信息面板------不是 Leaflet 默认那个白底小气泡,而是一个与项目整体视觉风格统一、字段可任意扩展、还能带状态灯/占比条的科技感面板。

本文基于一张真实的「桃花岭景区自行车道规划图」(1920×1356 像素),使用 L.CRS.Simple 把它当作无空间参考的像素画布,完整演示:

  1. 为什么必须做自定义标注(默认 marker 有哪些坑);
  2. 如何用 L.divIcon 做出圆形科技感标注点;
  3. 如何把信息面板从「朴素气泡」升级为深蓝科技风 HUD 信息卡(四角装饰、脉冲状态灯、装饰代号、状态徽标、负载条);
  4. 点击高亮联动等工程细节。

📷 图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 四角、脉冲状态灯、装饰代号、多字段网格、状态徽标与负载条的专业面板。

默认 popup 的 DOM 是 Leaflet 生成的固定结构,样式空间有限。正确做法是:

  1. 通过 className: 'tech-popup' 给 popup 换肤;
  2. 用 setContent(buildPanelHTML(site)) 把完全自定义的 HTML 塞进去;
  3. 在 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 完整运行效果


七、总结

本文围绕「无坐标图片上的自定义标注与信息面板」这一主题,讲清了三件事:

  1. 为什么要自定义:默认 marker 样式不可控、信息面板朴素,无法满足业务系统的视觉与字段需求;自定义 = 把「地图打点」升级为「业务信息节点」。
  2. 标注怎么做 :用 L.divIcon 接管图标渲染,做出圆形科技感 + 脉冲动画,并以「SITES 数组 + P(px,py) 换算」实现数据驱动,新增节点零成本。
  3. 面板怎么做 :用「自定义 className + setContent 注入 HTML」把默认气泡升级为深蓝毛玻璃 HUD 信息卡,内含四角装饰、脉冲状态灯、装饰代号、两列字段、状态徽标与负载条,且字段完全数据驱动。

可迁移价值 :本方案的坐标系(L.CRS.Simple)与无坐标图片绑定,但面板/标注的封装思路可直接迁移到带真实经纬度的地图 上------只要把 P(px,py) 换成 [lat, lng],整套科技风信息卡即可复用到供水管网节点、泵站、用水户等正式业务系统。

适用场景延伸:景区/厂区平面图标注、应急资源点位、管网设施信息卡、POI 空间分布可视化等,均可基于本文的「divIcon 自定义标注 + 科技风信息面板」范式快速落地。


相关推荐
夜郎king7 天前
Leaflet + leaflet-draw 在无坐标图上做自定义标绘(CRS.Simple 像素坐标系实战)
leaflet·leaflet-draw·无空间参考、自定义标绘·geojson 导出
奔跑的呱呱牛1 个月前
腾讯地图瓦片接入方案:一个 Bun 代理,让 Mapbox / Leaflet / OpenLayers 通用
gis·openlayers·mapbox·leaflet
梦想的初衷~3 个月前
《双 Agent 工作台 + 全栈 GIS 项目搭建:前端地图/空间数据库/后端/云部署指南》
人工智能·echarts·leaflet·webgis·ai 辅助编程
q平面人5 个月前
go-mapus地图协作增加上传展示视频功能
leaflet·go-mapus·地图协作·地图上插入视频
YouGIS顽石工坊6 个月前
风场可视化实战:从数据到动态地图的完整技术实现
可视化·leaflet·天地图·velocity·noaa·真气网
牛老师讲GIS8 个月前
终于,Leaflet WIKI发布上线了
leaflet·webgis
随便起的名字也被占用1 年前
leaflet中绘制轨迹线的大量轨迹点,解决大量 marker 绑定 tooltip 同时显示导致的性能问题
前端·javascript·vue.js·leaflet
Modify_QmQ1 年前
leaflet【十一】地图瓦片路径可视化
gis·vue3·leaflet·leafletmapblock
kongxx1 年前
在Angular中使用Leaflet构建地图应用
leaflet