高德地图自定义点标记: SVG vs HTML+CSS两种方案

一 . 在开发项目的时候,需要在地图上绘制带颜色,文字,角标的小水滴标记类似地图气泡针

实现方式有两种:

  • SVG: 矢量图生成图标,性能好,缩放不清晰
  • HTML+CSS : 通过DOM+样式生成marker

二. 实现方式对比

两种实现方式各有优劣,可根据项目点位数量,清晰度要求,开发周期选择,具体对比如下:

|----------|---------------------------------------|-------------------------------------------|---------------------------------------------|
| 实现方式 | 优点 | 缺点 | 适用场景 |
| HTML+CSS | 开发快,DOM操作灵活,颜色/文字修改便捷,无需掌握SVG语法,调试成本低 | 矢量性差,缩放易模糊,性能略逊于SVG(大量点位时),适配高分屏需额外处理 | 点位数量少(≤100个)、对缩放清晰度要求不高、开发周期短的场景 |
| SVG | 矢量特性优,缩放无模糊,性能好(支持大量点位),优化后适配所有高分屏 | 需处理高分屏模糊问题,SVG语法略复杂,调试图标细节(如角标,三角)需精准计算坐标 | 点位数量多(>100个)、对缩放清晰度要求高、需长期维护的场景(如车辆监控、点位管理) |

二. 实现方式

1.HTML+CSS

javascript 复制代码
//车辆点标记
// 用 HTML+CSS 生成一个气泡 DOM
function createPinDOM({
  modelLabel = "",
  coulombColor = "#2ac66d",
  textColor,
  minTag = "",
  badgeColor = "#b277c8",
  badgeTextColor = "#fff"
}) {
  const el = document.createElement("div");
  el.className = "pin";

  // 动态颜色(用 CSS 变量)
  el.style.setProperty("--pin-color", coulombColor);
  el.style.setProperty("--text-color", textColor || coulombColor);
  el.style.setProperty("--badge-color", badgeColor);
  el.style.setProperty("--badge-text", badgeTextColor);

  el.innerHTML = `
    <div class="pin__inner">
      <div class="pin__text">${modelLabel ?? ""}</div>
    </div>
    ${minTag ? `<div class="pin__badge">${minTag}</div>` : ""}
  `;
  return el;
}

export class VehicleLabels {
  constructor(map) {
    this.map = map;
    this.items = []; // { marker, data } 列表
    this.index = new Map(); // vehicleId -> marker
  }

  clear() {
    this.items.forEach(({ marker }) => marker.setMap(null));
    this.items = [];
    this.index.clear();
  }

  draw(list = [], { fit = false } = {}) {
    this.clear();
    const bounds = new AMap.Bounds();

    list.forEach(item => {
      // 位置
      const pos = Array.isArray(item.latestAxis)
        ? item.latestAxis
        : JSON.parse(item.latestAxis);
      const content = createPinDOM({
        modelLabel: item.modelLabel,
        coulombColor: item.coulombColor,
        textColor: item.textColor || item.coulombColor,
        minTag: item.minTag,
        badgeColor: item.badgeColor,
        badgeTextColor: item.badgeTextColor
      });

      const marker = new AMap.Marker({
        position: pos,
        anchor: "bottom-center", // 底部尖点对准坐标
        content,
        offset: new AMap.Pixel(0, 0),
        clickable: true,
        zIndex: 200
      });

      marker.setExtData(item);
      marker.setMap(this.map);

      this.items.push({ marker, data: item });
      if (item.vehicleId != null) this.index.set(item.vehicleId, marker);

      bounds.extend(pos);
    });

    if (fit && this.items.length) {
      this.map.setFitView(null, false, [30, 30, 30, 30], 18);
    }
  }

  // 更新:支持按 id 或过滤函数
  update(idOrFilter, patch = {}) {
    const targets =
      typeof idOrFilter === "function"
        ? this.items.filter(({ marker }) => idOrFilter(marker.getExtData()))
        : [this.index.get(idOrFilter)]
            .filter(Boolean)
            .map(m => ({ marker: m }));

    targets.forEach(({ marker }) => {
      const ext = Object.assign({}, marker.getExtData(), patch);
      marker.setExtData(ext);

      // 位置
      if (patch.latestAxis) {
        const pos = Array.isArray(patch.latestAxis)
          ? patch.latestAxis
          : JSON.parse(patch.latestAxis);
        marker.setPosition(pos);
      }

      // DOM 内容(颜色/文字/角标)
      const el = marker.getContent();
      if (!el) return;

      if ("coulombColor" in patch)
        el.style.setProperty("--pin-color", patch.coulombColor);
      if ("textColor" in patch || "coulombColor" in patch)
        el.style.setProperty(
          "--text-color",
          patch.textColor ||
            patch.coulombColor ||
            ext.textColor ||
            ext.coulombColor
        );

      if ("modelLabel" in patch) {
        const t = el.querySelector(".pin__text");
        if (t) t.textContent = patch.modelLabel ?? "";
      }

      if ("minTag" in patch) {
        let b = el.querySelector(".pin__badge");
        if (patch.minTag) {
          if (!b) {
            b = document.createElement("div");
            b.className = "pin__badge";
            el.appendChild(b);
          }
          b.textContent = patch.minTag;
        } else if (b) {
          b.remove();
        }
      }

      if ("badgeColor" in patch)
        el.style.setProperty("--badge-color", patch.badgeColor);
      if ("badgeTextColor" in patch)
        el.style.setProperty("--badge-text", patch.badgeTextColor);
    });
  }
}

样式,我放在了公共scss文件

html 复制代码
/* DOM 标记:水滴主体(绿色圆 + 底部三角) */
.pin {
  position: relative;
  width: 40px; // 直径
  height: 40px;
  border-radius: 50%;
  background: var(--pin-color, #2ac66d); // 外圈颜色:动态
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
  // transform: translate(-50%, -100%); // 锚点移到底部中心
  will-change: transform;

  /* 底部小三角(尖头) */
  &::after {
    content: "";
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    bottom: -10px;
    width: 0;
    height: 0;
    border-left: 10px solid transparent;
    border-right: 10px solid transparent;
    border-top: 12px solid var(--pin-color, #2ac66d); // 同外圈颜色
  }

  /* 内部白色圆盘 */
  &__inner {
    position: absolute;
    inset: 5px;
    border-radius: 50%;
    background: #fff;
    display: grid;
    place-items: center;
  }

  /* 中间型号文字 */
  &__text {
    font:
      700 14px/1 system-ui,
      -apple-system,
      "Segoe UI",
      Arial;
    color: var(--text-color, #16a34a); // 文字颜色:动态
    letter-spacing: 0.2px;
  }

    /* 右下角角标(小圆/可当胶囊) */
    &__badge {
      position: absolute;
      z-index: 999;
      right: -2px;
      bottom: -2px;
      min-width: 18px;
      height: 18px;
      padding: 0 4px;
      border-radius: 999px; // 圆/胶囊
      background: var(--badge-color, #b277c8); // 角标底色:动态
      color: var(--badge-text, #fff); // 角标文字色:动态
      font:
        700 12px/18px system-ui,
        Arial;
      text-align: center;
      box-shadow: 0 0 0 2px #fff; // 白描边提清晰度
      pointer-events: none;
    }
    }
实现效果
使用方法

//绘制车辆点标记 一个参数是数组,另一个参数是配置对象

this.vehicleLayer.draw(res.findAllVO.vehicleAxisList, {

fit: false, // 绘制点位完成后是否调整视野到点标记区域

showLabel: true, // 显示车辆 ID

})

2.svg方式

javascript 复制代码
export class VehicleLabels {
  /**
   * @param {AMap.Map} map  地图实例
   * @param {Object} config  配置项
   * @param {Function|Object} [config.colorMap]  颜色映射:函数(item)=>'#hex' 或 {ok:'#..',warn:'#..'}
   * @param {Number} [config.size=36]            圆标尺寸
   */
  constructor(map, config = {}) {
    console.log("配置项", config);
    this.map = map;
    //合并配置项
    this.cfg = Object.assign({ size: 36, colorMap: null }, config);

    //创建一个LabelsLayer 图层,管理点标记
    this.layer = new AMap.LabelsLayer({
      zooms: [3, 20],
      zIndex: 200,
      collision: false
      // animation: false
    });
    this.map.add(this.layer);
    //存储所有点位
    this.items = []; // AMap.LabelMarker 实例列表
    //创建一个索引表(查找字典), 后面用Id直接找到某个点的Marker实例
    this.index = new Map();
  }

  // -------------------- public APIs --------------------

  //清空方法
  clear() {
    this.layer.clear();
    this.items = [];
    this.index.clear();
  }

  /**
   * 批量绘制
   * item 结构示例:
   * { vehicleId:11223, latestAxis:'[113.32,23.13]', code:'F18', status:'ok', bottomText:'停/沉/...', color:'#2ecc71' }
   */
  draw(list = [], opts = {}) {
    console.log("绘制车辆点方法list", list);
    console.log("opts配置对象", opts);
    //绘制完成后是否自动调整视野
    const { fit = false } = opts;
    //清空旧的点
    this.clear();
    //生成新的marker 把每辆车的数据编程一个LabelMarker对象
    const markers = list.map(item => this._makeMarker(item));
    this.layer.add(markers);
    this.items = markers;
    markers.forEach(m => this.index.set(m.getExtData().vehicleId, m));

    if (fit && markers.length) this.map.setFitView(markers, false, null, 18);
  }

  /**
   * 动态更新单个或多个点位
   * @param {Number|Function} idOrFilter  vehicleId 或 (ext)=>boolean
   * @param {Object} patch  可更新字段:latestAxis, code, status, color, bottomText
   */
  update(idOrFilter, patch = {}) {
    const targets =
      typeof idOrFilter === "function"
        ? this.items.filter(m => idOrFilter(m.getExtData()))
        : [this.index.get(idOrFilter)].filter(Boolean);

    targets.forEach(m => {
      const ext = Object.assign({}, m.getExtData(), patch);

      // 位置
      if (patch.latestAxis) {
        const pos = Array.isArray(patch.latestAxis)
          ? patch.latestAxis
          : JSON.parse(patch.latestAxis);
        m.setPosition(pos);
      }

      // 图标(颜色/中心文字/徽标)
      if (
        "color" in patch ||
        "status" in patch ||
        "code" in patch ||
        "badge" in patch
      ) {
        m.setIcon(this._buildIcon(ext));
      }

      // 底部文字
      if ("bottomText" in patch) {
        if (patch.bottomText) {
          m.setText(this._buildBottomText(patch.bottomText));
        } else {
          m.setText(null);
        }
      }

      m.setExtData(ext);
    });
  }

  // 无论缩放都显示
  setAlwaysVisible() {
    this.layer.setOptions?.({ zooms: [3, 20], collision: false });
    this.items.forEach(m => m.setOptions?.({ zooms: [3, 20] }));
  }

  // -------------------- internals --------------------

  //把一条数据转换成地图上的一个点对象
  _makeMarker(item) {
    console.log("转换item", item);
    const pos = Array.isArray(item.latestAxis)
      ? item.latestAxis
      : JSON.parse(item.latestAxis);
    //创建一个点标记实力
    const marker = new AMap.LabelMarker({
      //点的位置
      position: pos,
      //这个点在哪些缩放级别下显示
      zooms: [3, 20],
      icon: this._buildIcon(item),
      text: item.bottomText
        ? this._buildBottomText(item.bottomText)
        : undefined,
      extData: item
    });

    return marker;
  }

  _buildBottomText(content) {
    return {
      content: String(content),
      direction: "bottom",
      offset: [0, -6],
      style: {
        fontSize: 12,
        fillColor: "#333",
        backgroundColor: "rgba(255,255,255,.95)",
        padding: [2, 6],
        borderRadius: 6,
        strokeColor: "#e5e7eb",
        strokeWidth: 1
      }
    };
  }

  _resolveColor(item) {
    // 优先 item.color;其次根据 status 用 colorMap;否则默认蓝
    if (item.color) return item.color;
    const { colorMap } = this.cfg;
    if (typeof colorMap === "function") return colorMap(item) || "#1791fc";
    if (colorMap && item.status && colorMap[item.status])
      return colorMap[item.status];
    return "#1791fc";
  }

  //生成点的样式
  _buildIcon(item) {
    const size = 50;
    const dpr = Math.max(2, Math.round(window.devicePixelRatio || 1));
    const W = size * dpr;
    const H = size * dpr;

    const cx = W / 2;
    const cy = H / 2 - 4 * dpr; // 上移,给三角留位置
    const R = (size / 2 - 4) * dpr;

    // 动态参数
    const fillMain = item.coulombColor || "#2ac66d"; // 外圈颜色
    const textColor = item.coulombColor || "#16a34a"; // 中间文字颜色
    const text = item.modelLabel ?? ""; // 型号文字
    const badge = item.minTag ?? ""; // 角标文字
    const badgeFill = item.badgeColor || "#b277c8"; // 角标底色
    const badgeTextC = item.badgeTextColor || "#fff"; // 角标文字色

    // 角标位置
    const badgeR = size * 0.18 * dpr;
    const bx = cx + R * 0.5;
    const by = cy + R * 0.5;

    const svg = `
    <svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
      <!-- 水滴外圈 -->
      <circle cx="${cx}" cy="${cy}" r="${R}" fill="${fillMain}" />
      <!-- 下方三角形 -->
      <path d="M ${cx - 8 * dpr} ${cy + R - 2 * dpr}
               L ${cx + 8 * dpr} ${cy + R - 2 * dpr}
               L ${cx} ${cy + R + 10 * dpr} Z"
            fill="${fillMain}" />
      <!-- 内圆白底 -->
      <circle cx="${cx}" cy="${cy}" r="${R - 5 * dpr}" fill="#fff" />
      <!-- 中间型号文字 -->
      <text x="${cx}" y="${cy}" text-anchor="middle"
            font-family="system-ui,Arial"
            font-size="${10 * dpr}" font-weight="700"
            fill="${textColor}" dominant-baseline="middle">${text}</text>
      ${
        badge
          ? `
          <!-- 右下角标 -->
          <circle cx="${bx}" cy="${by}" r="${badgeR}" fill="${badgeFill}" stroke="#fff" stroke-width="${2 *
              dpr}" />
          <text x="${bx}" y="${by}" text-anchor="middle"
                font-size="${10 * dpr}" font-weight="700"
                fill="${badgeTextC}" dominant-baseline="middle">${badge}</text>`
          : ""
      }
    </svg>
  `.trim();

    return {
      type: "image",
      size: [size, size],
      anchor: "bottom-center", // 关键!水滴底部对准坐标点
      image: "data:image/svg+xml;utf8," + encodeURIComponent(svg)
    };
  }
}
实现效果

size=50

size=36

总体来说我感觉有点模糊

优化方案(解决高分屏模糊问题)

核心思路: 按设备像素比(dpr) 放大SVG画布绘制, icon,size仍使用目标显示尺寸,相当于"2x/3x高清图",在高分屏上显示更锐利,同时优化细节提升可读性

优化后 _buildIcon 方法(核心优化代码)

javascript 复制代码
_buildIcon(item) {
  const size = this.cfg.size || 36;
  const tipH = Math.round((size/2 - 2) * 0.55);

  const dpr = Math.max(2, Math.round(window.devicePixelRatio || 1)); // 放大倍率,最低2倍,适配高分屏
  const W = size * dpr;
  const H = (size + tipH) * dpr;

  // 尺寸都按 dpr 放大,确保绘制精度
  const cx = Math.floor((size/2) * dpr);
  const cy = cx;
  const R  = Math.floor((size/2 - 2) * dpr);
  const rInner = Math.max(10*dpr, R - 5*dpr);

  const fillMain   = item.color || '#2ac66d';
  const strokeMain = 'rgba(0,0,0,.15)';
  const textColor  = item.textColor || '#16a34a';
  const text       = item.code ?? '';
  const badge      = item.badge ?? '';
  const badgeFill  = item.badgeColor || '#b277c8';
  const badgeTextC = item.badgeTextColor || '#fff';

  const tipHpx = Math.round((R) * 0.55);
  const tipWpx = Math.round((R) * 0.55);
  const tipPath = [
    `M ${cx - tipWpx/2} ${cy + R*0.55}`,
    `L ${cx} ${cy + R + tipHpx}`,
    `L ${cx + tipWpx/2} ${cy + R*0.55}`,
    'Z'
  ].join(' ');

  // 角标位置精准计算,提升细节
  const badgeR = Math.round(size * 0.17 * dpr);
  const bx = cx + Math.round(R * 0.45);
  const by = cy + Math.round(R * 0.35);

  const svg = `
  <svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
    <!-- 去掉滤镜,避免浑浊;打开几何渲染,提升清晰度 -->
    <g shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
      <circle cx="${cx}" cy="${cy}" r="${R}" fill="${fillMain}" stroke="${strokeMain}" stroke-width="${2*dpr}"/>
      <path d="${tipPath}" fill="${fillMain}" stroke="${strokeMain}" stroke-width="${1.5*dpr}"/>
      <circle cx="${cx}" cy="${cy}" r="${rInner}" fill="#fff"/>
      <text x="${cx}" y="${cy + 1*dpr}" text-anchor="middle"
            font-family="system-ui,Arial"
            font-size="${Math.max(12*dpr, Math.floor(rInner*0.8))}"
            font-weight="700" fill="${textColor}"
            dominant-baseline="middle"
            paint-order="stroke fill" stroke="#fff" stroke-width="${0.5*dpr}">
        ${text}
      </text>
      ${badge ? `
        <circle cx="${bx}" cy="${by}" r="${badgeR}" fill="${badgeFill}" stroke="#ffffff" stroke-width="${2*dpr}"/>
        <text x="${bx}" y="${by + Math.max(0, Math.floor(badgeR*0.15))}" text-anchor="middle"
              font-size="${Math.max(10*dpr, Math.floor(badgeR*0.95))}" font-weight="700"
              fill="${badgeTextC}" dominant-baseline="middle">${badge}</text>
      ` : ''}
    </g>
  </svg>
  `.trim();

  return {
    type: 'image',
    // ⚠️ 展示尺寸用"目标尺寸",不要乘dpr,避免显示异常
    size: [size, size + tipH],
    anchor: 'bottom-center',
    image: 'data:image/svg+xml;utf8,' + encodeURIComponent(svg)
  };
}

优化要点:

  • SVG画布宽高 x dpr; 颞部所有坐标/尺寸/字体也 x dpr: 核心是"高清绘制,正常显示",避免高分屏(如手机,Retina屏)下的模糊问题,相当于绘制2x/3x高清图,再压缩到目标尺寸显示,即保证清晰度,又不改变标记在地图上的实际大小
  • 返回的size: [w,h] 不要乘dpr: size 是标记在地图上的"显示尺寸", 乘dpr会导致标记被放大,出现显示异常(如水滴过大,遮挡地图内容),保持原始尺寸才能贴合预期效果
  • 去掉滤镜(阴影)和过重的半透明描边: 原SVG代码可能存在滤镜导致的图表浑浊问题,去掉冗余滤镜,简化描边,既能提升清晰度,又能减少SVG绘制成本,避免页面卡顿
  • paint-order="stroke fill" + 极细白描边: 让文字边缘更清晰,避免文字背景融合(如浅色文字配浅色背景),及细白描边即能提升文字辨识度,又不会显得突兀,兼顾美观和可读性

效果

size=50

size=36

相关推荐
陕西小伙伴网络科技有限公司2 小时前
kettle单转换实现分页查询
开发语言·前端·javascript
踩着两条虫2 小时前
低代码 + AI,到底是生产力革命,还是下一代“技术债务”?
前端·人工智能·低代码
南知意-2 小时前
cloud-app-admin:一款现代化、开箱即用的 Vue 3 后台管理模板
前端·javascript·vue.js·开源·开源项目
前端小王呀2 小时前
Vue 中高级开发面试题及答案
前端·javascript·vue.js
紫_龙2 小时前
最新版vue3+TypeScript开发入门到实战教程之watch与watchEffect对比区别
前端·vue.js·typescript
啪叽2 小时前
别再手写 if-else 选字体颜色了,CSS contrast-color() 来帮你处理
前端·css
刘宇琪2 小时前
JavaScript单页应用(SPA)首次加载慢优化方案
前端
CoovallyAIHub2 小时前
Agency-Agents(52k+ Stars):140+ 个角色模板,让 AI 编程助手变成一支专业团队
前端·算法·编程语言
德育处主任2 小时前
前端元素转图片,dom-to-image-more入门教程
前端·javascript