Skip to content

样式与交互 ​

矢量瓦片在浏览器里渲染,样式与交互完全由前端决定:按属性配色、热力图、悬停高亮、筛选、弹窗,都不需要服务端重新出图。本页示例自己用矢量瓦片写图层,常用的几步是:

  • 瓦片地址用 client.services.vectorTileUrl(),作为 vector 数据源的 tiles。
  • 图层的 source-layer 取自 client.services.tileJson() 返回的 vector_layers[0].id(平台上与服务名相同)。
  • 创建地图时传 transformRequest: client.transformRequest,瓦片请求才会带上 Key。

按道路等级配色 ​

不用服务自带的样式,自己写道路图层:match 表达式按 fclass 字段给每个等级配色、设线宽,interpolate 让线宽随缩放变化。左上角的图例可以点击,把某一等级从 filter 中去掉或加回。

需要: 一个线要素的矢量瓦片服务,且有表示等级的字段(示例用香港道路的 fclass);示例另用一个水体服务作陪衬。

js
/**
 * @title 按道路等级配色
 * @description 不用服务自带样式,自己用矢量瓦片写图层:按 fclass 字段给道路配色、按等级与缩放级别设线宽;图例可点击开关某一等级。
 * @category 样式与交互
 * @service demo/roads
 * @service demo/water
 */
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)

try {
  // 拷到自己页面时:换成你的道路与水体矢量瓦片服务;CLASSES 换成你数据里等级字段的取值
  const roads = services['demo/roads']
  const water = services['demo/water']
  const CLASSES = [
    ['motorway', '高速公路', '#e8590c', 4],
    ['trunk', '快速路', '#f08c00', 3.5],
    ['primary', '主干道', '#f59f00', 3],
    ['secondary', '次干道', '#74b816', 2.2],
    ['tertiary', '支路', '#1c7ed6', 1.6],
    ['residential', '街巷', '#868e96', 1],
  ]

  const map = new maplibregl.Map({
    container,
    style: { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#f8f9fa' } }] },
    transformRequest: client.transformRequest,
    // 沙田一带:既有吐露港公路等快速路,也有主干道与街巷
    center: [114.19, 22.383],
    zoom: 15,
  })
  map.addControl(new maplibregl.NavigationControl())
  await new Promise((resolve) => map.once('load', resolve))

  // 矢量瓦片的 source-layer 名取自 tiles.json 的 vector_layers(平台上与服务名相同)
  const layerOf = async (path) => (await client.services.tileJson(path)).vector_layers[0].id
  map.addSource('water', { type: 'vector', tiles: [client.services.vectorTileUrl(water)] })
  map.addLayer({ id: 'water', type: 'fill', source: 'water', 'source-layer': await layerOf(water), paint: { 'fill-color': '#a5d8ff' } })

  // 数据驱动样式:match 按 fclass 取颜色与线宽,interpolate 让线宽随缩放变化
  const match = (i, fallback) => ['match', ['get', 'fclass'], ...CLASSES.flatMap((c) => [c[0], c[i]]), fallback]
  map.addSource('roads', { type: 'vector', tiles: [client.services.vectorTileUrl(roads)] })
  map.addLayer({
    id: 'roads',
    type: 'line',
    source: 'roads',
    'source-layer': await layerOf(roads),
    filter: ['in', ['get', 'fclass'], ['literal', CLASSES.map((c) => c[0])]],
    layout: { 'line-cap': 'round', 'line-join': 'round' },
    paint: { 'line-color': match(2, '#adb5bd'), 'line-width': ['interpolate', ['linear'], ['zoom'], 10, ['*', 0.4, match(3, 1)], 16, ['*', 2.5, match(3, 1)]] },
  })

  // 图例:点击某一等级把它从 filter 里去掉或加回
  const shown = new Set(CLASSES.map((c) => c[0]))
  const legend = document.createElement('div')
  legend.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;background:#fff;padding:6px 10px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px/1.9 sans-serif;cursor:pointer;user-select:none'
  for (const [fclass, name, color] of CLASSES) {
    const row = document.createElement('div')
    row.innerHTML = `<span style="display:inline-block;width:22px;height:4px;vertical-align:middle;margin-right:6px;background:${color}"></span>${name}`
    row.onclick = () => {
      if (shown.has(fclass)) shown.delete(fclass)
      else shown.add(fclass)
      row.style.opacity = shown.has(fclass) ? 1 : 0.35
      map.setFilter('roads', ['in', ['get', 'fclass'], ['literal', [...shown]]])
    }
    legend.appendChild(row)
  }
  container.appendChild(legend)

  // 地图首次静止后,按等级统计视野内渲染出的道路段数
  map.once('idle', () => {
    const counts = {}
    for (const f of map.queryRenderedFeatures({ layers: ['roads'] })) counts[f.properties.fclass] = (counts[f.properties.fclass] || 0) + 1
    console.log('视野内各等级道路段数:', CLASSES.map((c) => `${c[1]} ${counts[c[0]] || 0}`).join(','))
  })
} catch (e) {
  console.error(friendly(e))
}
在沙箱中打开

兴趣点热力图 ​

用兴趣点矢量瓦片画 heatmap 图层,放大到 15 级以上逐渐淡出、过渡为圆点。左上角的下拉框只看某一类兴趣点(两个图层用同一个 filter),滑块调热力半径。

需要: 一个点要素的矢量瓦片服务;底图用地图方案。

js
/**
 * @title 兴趣点热力图
 * @description 用兴趣点矢量瓦片画热力图,放大到 15 级以上逐渐过渡为圆点;下拉框只看某一类兴趣点,滑块调热力半径。
 * @category 样式与交互
 * @service demo/pois
 * @map demo/map
 */
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)

try {
  // 拷到自己页面时:换成你的点要素矢量瓦片服务与底图(地图方案 id)
  const path = services['demo/pois']

  const map = new maplibregl.Map({
    container,
    style: client.maplibre.mapStyleUrl(maps['demo/map']),
    transformRequest: client.transformRequest,
    center: [114.175, 22.305],
    zoom: 13,
  })
  map.addControl(new maplibregl.NavigationControl())
  await new Promise((resolve) => map.once('load', resolve))

  // source-layer 名取自 tiles.json 的 vector_layers
  const sourceLayer = (await client.services.tileJson(path)).vector_layers[0].id
  map.addSource('poi-tiles', { type: 'vector', tiles: [client.services.vectorTileUrl(path)] })

  // 热力图:15 级以后淡出;圆点:14 级以后淡入
  map.addLayer({
    id: 'heat',
    type: 'heatmap',
    source: 'poi-tiles',
    'source-layer': sourceLayer,
    paint: {
      'heatmap-radius': 12,
      'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 10, 0.1, 13, 0.25, 15, 0.6],
      'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(33,102,172,0)', 0.2, '#67a9cf', 0.4, '#d1e5f0', 0.6, '#fddbc7', 0.8, '#ef8a62', 1, '#b2182b'],
      'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0.9, 16, 0],
    },
  })
  map.addLayer({
    id: 'points',
    type: 'circle',
    source: 'poi-tiles',
    'source-layer': sourceLayer,
    minzoom: 14,
    paint: { 'circle-radius': 4, 'circle-color': '#b2182b', 'circle-stroke-color': '#fff', 'circle-stroke-width': 1, 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1] },
  })

  const panel = document.createElement('div')
  panel.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;background:#fff;padding:8px 10px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px/2 sans-serif'
  panel.innerHTML = `<div>类别 <select>${[['', '全部'], ['restaurant', '餐厅'], ['cafe', '咖啡馆'], ['convenience', '便利店'], ['bank', '银行'], ['toilet', '公厕']]
    .map(([v, t]) => `<option value="${v}">${t}</option>`).join('')}</select></div><div>半径 <input type="range" min="4" max="40" value="12"></div><div data-zoom></div>`
  container.appendChild(panel)

  // 过滤:两个图层用同一个 filter;半径:只改热力图的 paint 属性
  panel.querySelector('select').onchange = (e) => {
    const filter = e.target.value ? ['==', ['get', 'fclass'], e.target.value] : null
    map.setFilter('heat', filter)
    map.setFilter('points', filter)
    console.log('只看:', e.target.selectedOptions[0].textContent)
  }
  panel.querySelector('input').oninput = (e) => map.setPaintProperty('heat', 'heatmap-radius', Number(e.target.value))

  const zoomInfo = panel.querySelector('[data-zoom]')
  const showZoom = () => (zoomInfo.textContent = `缩放 ${map.getZoom().toFixed(1)}:${map.getZoom() < 15 ? '热力图' : '圆点'}(放大到 15 级查看单个兴趣点)`)
  map.on('zoomend', showZoom)
  showZoom()
  // 热力图图层不能用 queryRenderedFeatures 查询,改数已加载瓦片里的要素(同一要素可能跨瓦片重复,按 osm_id 去重)
  map.once('idle', () => {
    const loaded = map.querySourceFeatures('poi-tiles', { sourceLayer })
    console.log('热力图已渲染,已加载兴趣点:', new Set(loaded.map((f) => f.properties.osm_id)).size, '个')
  })
} catch (e) {
  console.error(friendly(e))
}
在沙箱中打开

建筑悬停高亮 ​

鼠标移到建筑上时,用 feature-state 把这栋建筑涂成橙色,面板显示名称与类型。feature-state 需要要素 id,数据源的 promoteId 把唯一字段提升为 id。示例加载后先高亮视野内一栋有名称的建筑。

需要: 一个面要素的矢量瓦片服务,且有唯一字段(示例用香港建筑的 osm_id)。

js
/**
 * @title 建筑悬停高亮
 * @description 鼠标移到建筑上时用 feature-state 把它涂成橙色,面板显示建筑名称与类型;加载后先高亮地图中心的建筑作演示。
 * @category 样式与交互
 * @service demo/buildings
 * @map demo/map
 */
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)

try {
  // 拷到自己页面时:换成你的面要素矢量瓦片服务;promoteId 换成你数据里唯一的字段
  const path = services['demo/buildings']

  const map = new maplibregl.Map({
    container,
    style: client.maplibre.mapStyleUrl(maps['demo/map']),
    transformRequest: client.transformRequest,
    // 中环一带
    center: [114.1585, 22.2825],
    zoom: 16.5,
  })
  map.addControl(new maplibregl.NavigationControl())
  await new Promise((resolve) => map.once('load', resolve))

  // feature-state 需要要素 id:promoteId 把属性 osm_id 提升为 id
  const sourceLayer = (await client.services.tileJson(path)).vector_layers[0].id
  map.addSource('building-tiles', { type: 'vector', tiles: [client.services.vectorTileUrl(path)], promoteId: 'osm_id' })
  map.addLayer({
    id: 'building-fill',
    type: 'fill',
    source: 'building-tiles',
    'source-layer': sourceLayer,
    paint: {
      // 按 feature-state 的 hover 取颜色:悬停的建筑为橙色,其余半透明蓝灰
      'fill-color': ['case', ['boolean', ['feature-state', 'hover'], false], '#fd7e14', '#748ffc'],
      'fill-opacity': ['case', ['boolean', ['feature-state', 'hover'], false], 0.9, 0.35],
      'fill-outline-color': '#364fc7',
    },
  })

  const panel = document.createElement('div')
  panel.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;background:#fff;padding:6px 10px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px/1.7 sans-serif;min-width:200px'
  panel.textContent = '把鼠标移到建筑上'
  container.appendChild(panel)

  // 取消上一个的高亮,再高亮这一个(f 为空表示鼠标不在建筑上)
  let hovered = null
  function highlight(f) {
    if (hovered !== null) map.setFeatureState({ source: 'building-tiles', sourceLayer, id: hovered }, { hover: false })
    hovered = f ? f.id : null
    map.getCanvas().style.cursor = f ? 'pointer' : ''
    if (!f) return (panel.textContent = '把鼠标移到建筑上')
    map.setFeatureState({ source: 'building-tiles', sourceLayer, id: hovered }, { hover: true })
    return (panel.textContent = `${f.properties.name || '(无名称)'} 类型:${f.properties.type || '未标注'}`)
  }
  map.on('mousemove', (e) => highlight(map.queryRenderedFeatures(e.point, { layers: ['building-fill'] })[0]))
  map.getCanvas().addEventListener('mouseleave', () => highlight(null))

  // 演示:瓦片加载完成后,先高亮视野内第一栋有名称的建筑
  map.once('idle', () => {
    const named = map.queryRenderedFeatures({ layers: ['building-fill'] }).find((f) => f.properties.name)
    console.log('演示高亮:', named ? highlight(named) : '视野内没有带名称的建筑')
  })
} catch (e) {
  console.error(friendly(e))
}
在沙箱中打开

兴趣点类别筛选 ​

一排按钮按类别筛选兴趣点:setFilter 只改显示,不重新请求瓦片。同时用 client.query.count() 向要素服务统计全港该类兴趣点的总数,显示在左下角。

需要: 一个同时开启了矢量瓦片与要素服务的点要素服务。

js
/**
 * @title 兴趣点类别筛选
 * @description 一排按钮按 fclass 筛选兴趣点矢量瓦片(setFilter 即时生效,不重新请求瓦片),同时用要素查询的 count 显示全港该类兴趣点总数。
 * @category 样式与交互
 * @service demo/pois
 * @map demo/map
 */
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)

try {
  // 拷到自己页面时:换成你的点要素服务(需同时开启矢量瓦片与要素服务);CATEGORIES 换成你数据里的类别取值
  const path = services['demo/pois']
  const CATEGORIES = [
    ['restaurant', '餐厅', '#e03131'],
    ['cafe', '咖啡馆', '#9c36b5'],
    ['fast_food', '快餐', '#f08c00'],
    ['convenience', '便利店', '#2f9e44'],
    ['supermarket', '超市', '#1971c2'],
    ['bank', '银行', '#495057'],
  ]

  const map = new maplibregl.Map({
    container,
    style: client.maplibre.mapStyleUrl(maps['demo/map']),
    transformRequest: client.transformRequest,
    // 旺角一带
    center: [114.17, 22.318],
    zoom: 15,
  })
  map.addControl(new maplibregl.NavigationControl())
  await new Promise((resolve) => map.once('load', resolve))

  const sourceLayer = (await client.services.tileJson(path)).vector_layers[0].id
  map.addSource('poi-tiles', { type: 'vector', tiles: [client.services.vectorTileUrl(path)] })
  map.addLayer({
    id: 'poi-points',
    type: 'circle',
    source: 'poi-tiles',
    'source-layer': sourceLayer,
    // 按类别取颜色;其余类别画成灰色
    paint: { 'circle-radius': 5, 'circle-color': ['match', ['get', 'fclass'], ...CATEGORIES.flatMap(([v, , c]) => [v, c]), '#adb5bd'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 1 },
  })

  const bar = document.createElement('div')
  bar.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;display:flex;flex-wrap:wrap;gap:6px;max-width:70%'
  const info = document.createElement('div')
  info.style.cssText = 'position:absolute;bottom:28px;left:8px;z-index:2;background:#fff;padding:6px 10px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px sans-serif'
  container.append(bar, info)

  async function select(value, label, button) {
    for (const b of bar.children) b.style.fontWeight = b === button ? 'bold' : 'normal'
    // 矢量瓦片已在浏览器里,setFilter 只改显示
    map.setFilter('poi-points', value ? ['==', ['get', 'fclass'], value] : ['in', ['get', 'fclass'], ['literal', CATEGORIES.map((c) => c[0])]])
    try {
      // 总数来自要素服务:count 只返回数量,不传要素
      const total = await client.query.count(path, 0, { where: value ? `fclass = '${value}'` : `fclass IN (${CATEGORIES.map((c) => `'${c[0]}'`).join(',')})` })
      info.textContent = `${label}:全港共 ${total} 个`
      console.log(info.textContent)
    } catch (e) {
      info.textContent = ''
      console.error('统计失败:', friendly(e))
    }
  }

  for (const [value, label, color] of [['', '全部', '#212529'], ...CATEGORIES]) {
    const b = document.createElement('button')
    b.innerHTML = `<span style="color:${color}">●</span> ${label}`
    b.style.cssText = 'padding:4px 10px;cursor:pointer;background:#fff;border:1px solid #ced4da;border-radius:14px'
    b.onclick = () => select(value, label, b)
    bar.appendChild(b)
  }

  // 演示:默认只看餐厅
  await select('restaurant', '餐厅', bar.children[1])
} catch (e) {
  console.error(friendly(e))
}
在沙箱中打开

点击要素弹窗 ​

点击兴趣点或道路,直接读矢量瓦片里的要素属性,用弹窗显示,不再请求服务端。道路画成透明的粗线,便于点中;属性值写进弹窗前要做 HTML 转义。示例加载后先为视野内一个兴趣点打开弹窗。

需要: 一个或多个矢量瓦片服务。

js
/**
 * @title 点击要素弹窗
 * @description 点击兴趣点或道路,直接读矢量瓦片里的要素属性弹窗显示,不再请求服务端;加载后先为视野内一个兴趣点打开弹窗作演示。
 * @category 样式与交互
 * @service demo/pois
 * @service demo/roads
 * @map demo/map
 */
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)

try {
  // 拷到自己页面时:换成你的矢量瓦片服务与底图(地图方案 id)
  const pois = services['demo/pois']
  const roads = services['demo/roads']

  const map = new maplibregl.Map({
    container,
    style: client.maplibre.mapStyleUrl(maps['demo/map']),
    transformRequest: client.transformRequest,
    // 铜锣湾一带
    center: [114.1835, 22.2805],
    zoom: 16,
  })
  map.addControl(new maplibregl.NavigationControl())
  await new Promise((resolve) => map.once('load', resolve))

  // 两个可点击的图层:道路画成透明的粗线方便点中,兴趣点画成圆点
  const layerOf = async (path) => (await client.services.tileJson(path)).vector_layers[0].id
  map.addSource('road-tiles', { type: 'vector', tiles: [client.services.vectorTileUrl(roads)] })
  map.addLayer({ id: 'roads-hit', type: 'line', source: 'road-tiles', 'source-layer': await layerOf(roads), paint: { 'line-width': 10, 'line-opacity': 0 } })
  map.addSource('poi-tiles', { type: 'vector', tiles: [client.services.vectorTileUrl(pois)] })
  map.addLayer({
    id: 'poi-points',
    type: 'circle',
    source: 'poi-tiles',
    'source-layer': await layerOf(pois),
    paint: { 'circle-radius': 6, 'circle-color': '#1c7ed6', 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 },
  })

  // 属性值来自数据,写进 HTML 前要转义
  const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
  const popup = new maplibregl.Popup({ maxWidth: '280px' })
  function show(f, lngLat) {
    const kind = f.layer.id === 'poi-points' ? '兴趣点' : '道路'
    const rows = Object.entries(f.properties).filter(([, v]) => v !== '' && v !== null).map(([k, v]) => `<tr><td style="color:#868e96;padding-right:8px">${esc(k)}</td><td>${esc(v)}</td></tr>`)
    popup.setLngLat(lngLat).setHTML(`<b>${kind}</b><table style="font-size:12px">${rows.join('')}</table>`).addTo(map)
    return `${kind} ${f.properties.name || f.properties.fclass}`
  }

  // 点击时取最上面的要素:兴趣点优先于道路
  map.on('click', (e) => {
    const f = map.queryRenderedFeatures(e.point, { layers: ['poi-points', 'roads-hit'] })[0]
    if (f) console.log('点击了', show(f, e.lngLat))
  })
  for (const id of ['poi-points', 'roads-hit']) {
    map.on('mouseenter', id, () => (map.getCanvas().style.cursor = 'pointer'))
    map.on('mouseleave', id, () => (map.getCanvas().style.cursor = ''))
  }

  // 演示:地图静止后,给视野内第一个有名称的兴趣点打开弹窗
  map.once('idle', () => {
    const f = map.queryRenderedFeatures({ layers: ['poi-points'] }).find((x) => x.properties.name)
    if (f) console.log('演示弹窗:', show(f, f.geometry.coordinates))
  })
} catch (e) {
  console.error(friendly(e))
}
在沙箱中打开

土地利用专题图 ​

按 fclass 给土地利用面分类设色,图例里每一类附上用 client.query.count() 统计的地块数(各类并行请求,只返回数量)。点击图例项只看这一类,再点恢复全部。

需要: 一个同时开启了矢量瓦片与要素服务的面要素服务,且有分类字段。

js
/**
 * @title 土地利用专题图
 * @description 按 fclass 给土地利用面分类设色,图例里每一类附上要素服务统计的地块数;点击图例项只看这一类,再点恢复全部。
 * @category 样式与交互
 * @service demo/landuse
 * @service demo/water
 */
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)

try {
  // 拷到自己页面时:换成你的面要素服务(需开启矢量瓦片与要素服务);CLASSES 换成你数据里的分类取值
  const path = services['demo/landuse']
  const water = services['demo/water']
  const CLASSES = [
    ['residential', '住宅', '#ffa8a8'],
    ['commercial', '商业', '#da77f2'],
    ['industrial', '工业', '#adb5bd'],
    ['park', '公园', '#8ce99a'],
    ['forest', '林地', '#2b8a3e'],
    ['grass', '草地', '#c0eb75'],
    ['farmland', '农田', '#ffe066'],
    ['cemetery', '墓地', '#868e96'],
  ]

  const map = new maplibregl.Map({
    container,
    style: { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#f1f3f5' } }] },
    transformRequest: client.transformRequest,
    // 沙田一带:住宅、工业、公园与林地交错。地块密集,单张矢量瓦片的要素数有上限,缩得太小会缺地块
    center: [114.195, 22.385],
    zoom: 13.5,
  })
  map.addControl(new maplibregl.NavigationControl())
  await new Promise((resolve) => map.once('load', resolve))

  const layerOf = async (p) => (await client.services.tileJson(p)).vector_layers[0].id
  map.addSource('landuse', { type: 'vector', tiles: [client.services.vectorTileUrl(path)] })
  map.addLayer({
    id: 'landuse',
    type: 'fill',
    source: 'landuse',
    'source-layer': await layerOf(path),
    // 不在 CLASSES 里的类别画成浅灰
    paint: { 'fill-color': ['match', ['get', 'fclass'], ...CLASSES.flatMap(([v, , c]) => [v, c]), '#dee2e6'] },
  })
  map.addSource('water', { type: 'vector', tiles: [client.services.vectorTileUrl(water)] })
  map.addLayer({ id: 'water', type: 'fill', source: 'water', 'source-layer': await layerOf(water), paint: { 'fill-color': '#74c0fc' } })

  // 图例:每一类的地块数用 count 并行统计(只传数量,不传要素)
  const counts = await Promise.all(CLASSES.map(([v]) => client.query.count(path, 0, { where: `fclass = '${v}'` })))
  const legend = document.createElement('div')
  legend.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;background:#fff;padding:6px 10px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:13px/1.9 sans-serif;cursor:pointer;user-select:none'
  let only = null
  CLASSES.forEach(([value, name, color], i) => {
    const row = document.createElement('div')
    row.innerHTML = `<span style="display:inline-block;width:14px;height:14px;vertical-align:-2px;margin-right:6px;background:${color}"></span>${name} <span style="color:#868e96">${counts[i]}</span>`
    row.onclick = () => {
      only = only === value ? null : value
      map.setFilter('landuse', only ? ['==', ['get', 'fclass'], only] : null)
      for (const r of legend.children) r.style.opacity = !only || r === row ? 1 : 0.35
      console.log(only ? `只看${name}` : '显示全部类别')
    }
    legend.appendChild(row)
  })
  container.appendChild(legend)
  console.log('各类地块数:', CLASSES.map(([, n], i) => `${n} ${counts[i]}`).join(','))
} catch (e) {
  console.error(friendly(e))
}
在沙箱中打开

栅格与矢量对比 ​

左右两张地图联动:左边是 MapServer 栅格切片(服务端出图),右边是地图方案(浏览器渲染)。一边移动时把中心、缩放、旋转与倾斜同步到另一边,并用一个标志位防止两边互相触发。

需要: 一个提供 MapServer 切片的地图服务和一个地图方案。

js
/**
 * @title 栅格与矢量对比
 * @description 左右两张地图联动:左边是服务端出图的栅格切片,右边是浏览器渲染的矢量地图方案;拖动或缩放任意一边,另一边跟着走。
 * @category 样式与交互
 * @service demo/basemap
 * @map demo/map
 */
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)

try {
  // 拷到自己页面时:换成你的地图服务路径与地图方案 id;页面里放两个并排的容器
  const raster = services['demo/basemap']
  const mapId = maps['demo/map']

  // 在 container 里并排放两个容器,各带一个标签
  container.style.position = 'relative'
  function pane(left, text) {
    const el = document.createElement('div')
    el.style.cssText = `position:absolute;top:0;bottom:0;left:${left};width:50%;${left === '0' ? 'border-right:2px solid #fff;box-sizing:border-box' : ''}`
    const tag = document.createElement('div')
    tag.textContent = text
    tag.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;background:rgba(0,0,0,.65);color:#fff;padding:3px 8px;border-radius:4px;font:12px sans-serif'
    el.appendChild(tag)
    container.appendChild(el)
    return el
  }
  const view = { center: [114.172, 22.298], zoom: 15 }

  const left = new maplibregl.Map({
    container: pane('0', '栅格切片(MapServer,服务端出图)'),
    style: { version: 8, sources: {}, layers: [] },
    transformRequest: client.transformRequest,
    ...view,
  })
  const right = new maplibregl.Map({
    container: pane('50%', '矢量地图方案(浏览器渲染)'),
    style: client.maplibre.mapStyleUrl(mapId),
    transformRequest: client.transformRequest,
    ...view,
  })
  right.addControl(new maplibregl.NavigationControl())

  await new Promise((resolve) => left.once('load', resolve))
  client.maplibre.addRasterService(left, raster)

  // 联动:一边移动时把中心、缩放、旋转、倾斜同步到另一边;syncing 防止互相触发形成循环
  let syncing = false
  function follow(from, to) {
    from.on('move', () => {
      if (syncing) return
      syncing = true
      to.jumpTo({ center: from.getCenter(), zoom: from.getZoom(), bearing: from.getBearing(), pitch: from.getPitch() })
      syncing = false
    })
  }
  follow(left, right)
  follow(right, left)

  // 演示:右图平移到旺角并放大一级,左图跟随
  right.once('idle', () => {
    right.jumpTo({ center: [114.17, 22.318], zoom: 16 })
    console.log('联动后左图中心:', left.getCenter().lng.toFixed(4), left.getCenter().lat.toFixed(4), '缩放', left.getZoom().toFixed(1))
  })
} catch (e) {
  console.error(friendly(e))
}
在沙箱中打开