Appearance
图层
在空样式或地图方案上自己组织图层:栅格切片、WMTS、TMS、OGC API 瓦片、WMS、矢量瓦片,以及它们之间的切换与叠加。
切片地址的行列顺序
平台 MapServer 的切片地址是 …/tile/{z}/{y}/{x},行(y)在前、列(x)在后,与许多 XYZ 服务的 {z}/{x}/{y} 相反。使用 SDK 的 client.services.tileUrl() 与 client.maplibre.addRasterService() 时已经处理好;自己拼地址时请注意顺序。
栅格切片
用 client.maplibre.addRasterService() 把 MapServer 的切片作为栅格图层加到地图上。地图的样式加载完成(load 事件)之后才能添加数据源与图层。左上角的面板实时显示地图中心落在哪张切片上:级别、行号、列号、这张切片的地址与图片,可以直观看到"行在前"的地址顺序。
需要: 一个提供 MapServer 切片的地图服务。
js
在沙箱中打开/**
* @title 栅格切片
* @description 把香港底图 MapServer 的切片作为栅格图层加到地图上;面板实时显示地图中心所在切片的 z/行/列、地址与图片。注意切片地址是 {z}/{y}/{x},行在前。
* @category 图层
* @service demo/basemap
*/
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)
try {
// 拷到自己页面时:换成你的地图服务路径(服务须开启 MapServer 切片)
const path = services['demo/basemap']
const map = new maplibregl.Map({
container,
style: { version: 8, sources: {}, layers: [] },
transformRequest: client.transformRequest,
})
map.addControl(new maplibregl.NavigationControl())
// 样式加载完成后才能加 source 与图层
await new Promise((resolve) => map.once('load', resolve))
// addRasterService 用 tileUrl() 的模板建 raster source,已处理好行在前的顺序
client.maplibre.addRasterService(map, path)
// 缩放到服务的数据范围:extent() 读服务自身的公开元数据(经纬度),匿名也可用;取不到时保持默认视野
const bounds = await client.services.extent(path)
if (bounds) map.fitBounds(bounds, { padding: 20, animate: false })
// 面板:地图中心落在哪张切片上(Web 墨卡托切片编号公式),以及这张切片的地址与图片
const panel = document.createElement('div')
panel.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;background:#fff;padding:8px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.3);font:12px/1.6 sans-serif;max-width:300px;word-break:break-all'
const text = document.createElement('div')
const img = document.createElement('img')
img.style.cssText = 'width:128px;height:128px;border:1px solid #ccc;display:block;margin-top:4px'
panel.append(text, img)
container.appendChild(panel)
const template = client.services.tileUrl(path)
function showCenterTile() {
const z = Math.round(map.getZoom())
const { lng, lat } = map.getCenter()
const n = 2 ** z
const x = Math.floor(((lng + 180) / 360) * n)
const rad = (lat * Math.PI) / 180
const y = Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n)
const url = template.replace('{z}', z).replace('{y}', y).replace('{x}', x)
text.textContent = `中心切片 z=${z} 行 y=${y} 列 x=${x}:${url}`
// <img> 不经过 transformRequest,受保护服务要用 withKey 把 token 补进地址
img.src = client.withKey(url)
return url
}
map.on('moveend', showCenterTile)
console.log('切片地址模板(行在前):', template)
console.log('当前中心切片:', showCenterTile())
} catch (e) {
console.error(friendly(e))
}WMTS 作为栅格源
用 client.maplibre.addWmtsService() 读取 WMTS 能力文档,选出 Web 墨卡托矩阵集(默认 WebMercatorQuad,其次 GoogleMapsCompatible),把 ResourceURL 模板里的 TileMatrix、TileRow、TileCol 换成 MapLibre 的 {z}、{y}、{x} 后作为栅格数据源。瓦片地址不带 Key,由 transformRequest 附加。左上角列出能力文档里的矩阵集:坐标系与级数;经纬度矩阵集(WorldCRS84Quad、CGCS2000Quad)MapLibre 无法使用,见下面的 OpenLayers 与 Cesium 示例。
需要: 一个开启了 WMTS 的切片地图服务。
js
在沙箱中打开/**
* @title WMTS 作为栅格源
* @description 用 maplibre.addWmtsService 读取香港底图的 WMTS 能力文档,按 Web 墨卡托矩阵集把瓦片模板交给 MapLibre;面板列出能力文档里的矩阵集(坐标系与级数),日志给出所用的瓦片模板。
* @category 图层
* @service demo/basemap
*/
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在或未开启 WMTS,请检查服务路径' : e && e.message)
try {
// 拷到自己页面时:换成你开启了 WMTS 的地图服务路径
const path = services['demo/basemap']
const map = new maplibregl.Map({
container,
style: { version: 8, sources: {}, layers: [] },
// 瓦片地址里不放 Key,由 transformRequest 按 Key 的传递方式附加
transformRequest: client.transformRequest,
})
map.addControl(new maplibregl.NavigationControl())
await new Promise((resolve) => map.once('load', resolve))
// MapLibre 只认 Web 墨卡托:默认选 WebMercatorQuad,其次 GoogleMapsCompatible;经纬度矩阵集请改用 OpenLayers 或三维示例里的 WMTS 影像
const handle = await client.maplibre.addWmtsService(map, path)
console.log('WMTS 瓦片模板:', map.getStyle().sources[handle.sourceId].tiles[0])
const bounds = await client.services.extent(path)
if (bounds) map.fitBounds(bounds, { padding: 20, animate: false })
// 面板:能力文档解析后的矩阵集(wmtsCapabilities 返回 { layers, tileMatrixSets })
const caps = await client.services.wmtsCapabilities(path)
const layer = caps.layers[0]
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'
const title = document.createElement('b')
title.textContent = `图层 ${layer.identifier}(${layer.formats.join('、')})`
panel.appendChild(title)
for (const id of layer.tileMatrixSets) {
const tms = caps.tileMatrixSets.find((t) => t.identifier === id)
const row = document.createElement('div')
row.textContent = `${id}:${tms ? tms.supportedCrs : '?'},${tms ? tms.tileMatrices.length : 0} 级`
panel.appendChild(row)
}
container.appendChild(panel)
} catch (e) {
console.error(friendly(e))
}TMS 瓦片
TMS 1.0.0 的 TileMapService 列出服务提供的瓦片集(矩阵集 × 图片格式);client.services.tmsUrl(path, 矩阵集, 格式) 给出瓦片模板 …/tms/1.0.0/{服务名}@{矩阵集}@{格式}/{z}/{x}/{y}.{格式}。TMS 的行号自下而上,MapLibre 的数据源要写 scheme: 'tms'。左上角在 PNG 与 JPEG 之间切换。
需要: 一个开启了 TMS 的切片地图服务。
js
在沙箱中打开/**
* @title TMS 瓦片
* @description 读取 TMS 1.0.0 的 TileMapService 列出可用瓦片集(矩阵集 × 格式),用 services.tmsUrl 生成瓦片模板交给 MapLibre;TMS 的行号自下而上,source 要写 scheme: 'tms'。面板切换 PNG 与 JPEG。
* @category 图层
* @service demo/basemap
*/
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在或未开启 TMS,请检查服务路径' : e && e.message)
try {
// 拷到自己页面时:换成你开启了 TMS 的地图服务路径
const path = services['demo/basemap']
// TileMapService 是 XML:列出每个 TileMap 的地址末段与坐标系(global-mercator、global-geodetic 是 GDAL 等客户端用的别名)
const xml = await client.request(client.services.tmsUrl(path), { as: 'text' })
const doc = new DOMParser().parseFromString(xml, 'application/xml')
const tileMaps = Array.from(doc.getElementsByTagName('TileMap')).map((n) => `${n.getAttribute('href').split('/').pop()}:${n.getAttribute('srs')}`)
console.log('TileMapService 中的瓦片集:\n' + tileMaps.join('\n'))
const map = new maplibregl.Map({
container,
style: { version: 8, sources: {}, layers: [] },
transformRequest: client.transformRequest,
})
map.addControl(new maplibregl.NavigationControl())
await new Promise((resolve) => map.once('load', resolve))
// 瓦片模板 …/tms/1.0.0/{服务名}@WebMercatorQuad@png/{z}/{x}/{y}.png
const show = (format) => {
if (map.getLayer('tms')) map.removeLayer('tms')
if (map.getSource('tms')) map.removeSource('tms')
const tiles = client.services.tmsUrl(path, 'WebMercatorQuad', format)
map.addSource('tms', { type: 'raster', tiles: [tiles], tileSize: 256, scheme: 'tms' })
map.addLayer({ id: 'tms', type: 'raster', source: 'tms' })
console.log('TMS 瓦片模板:', tiles)
}
show('png')
const bounds = await client.services.extent(path)
if (bounds) map.fitBounds(bounds, { padding: 20, animate: false })
// 面板:切换格式(JPEG 无透明,海面等空白处为白色)
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.9 sans-serif'
for (const format of ['png', 'jpg']) {
const label = document.createElement('label')
label.style.marginRight = '10px'
const radio = document.createElement('input')
radio.type = 'radio'
radio.name = 'fmt'
radio.checked = format === 'png'
radio.onchange = () => show(format)
label.append(radio, ' ' + format.toUpperCase())
panel.appendChild(label)
}
container.appendChild(panel)
} catch (e) {
console.error(friendly(e))
}OGC API - Tiles
client.maplibre.addOgcApiTiles() 读取 OGC API 的瓦片集元数据(WebMercatorQuad),取出瓦片模板与级别范围交给 MapLibre:底图服务的地图瓦片(kind: 'map',PNG)作底,道路集合的矢量瓦片(kind: 'vector',MVT)叠在上面。矢量瓦片的图层名(source-layer)就是集合 id;不传 layers 时 SDK 按几何类型生成默认样式,示例自己写了按道路等级配色的线图层,点击道路弹出名称与等级。
需要: 一个开启了 OGC API 的切片地图服务,和一个开启了 OGC API 的矢量服务。
js
在沙箱中打开/**
* @title OGC API - Tiles
* @description 用 maplibre.addOgcApiTiles 加载 OGC API 瓦片:香港底图的地图瓦片(PNG)作底,道路集合的矢量瓦片(MVT)叠在上面并按道路等级配色。瓦片模板与级别范围都取自瓦片集元数据。
* @category 图层
* @service demo/basemap
* @service demo/roads
*/
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在或未开启 OGC API,请检查服务路径' : e && e.message)
try {
// 拷到自己页面时:换成你开启了 OGC API 的服务路径;集合 id 见 ogcApi.collections()
const basemap = services['demo/basemap']
const roads = services['demo/roads']
const map = new maplibregl.Map({
container,
style: { version: 8, sources: {}, layers: [] },
transformRequest: client.transformRequest,
center: [114.172, 22.305],
zoom: 13,
})
map.addControl(new maplibregl.NavigationControl())
await new Promise((resolve) => map.once('load', resolve))
// 地图瓦片:整个服务(不给 collection)的 WebMercatorQuad 瓦片集
await client.maplibre.addOgcApiTiles(map, basemap, { kind: 'map' })
// 矢量瓦片:道路集合;source-layer 即集合 id,自己写图层时按字段配色
const { collections } = await client.ogcApi.collections(roads)
const id = collections[0].id
const color = ['match', ['get', 'fclass'], 'motorway', '#e8590c', 'trunk', '#f08c00', 'primary', '#1971c2', '#868e96']
const handle = await client.maplibre.addOgcApiTiles(map, roads, {
kind: 'vector',
collection: id,
layers: [{ id: 'roads', type: 'line', 'source-layer': id, paint: { 'line-color': color, 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 0.6, 16, 3] } }],
})
console.log(`矢量瓦片集合 ${id}:图层 ${handle.layerIds.join('、')},级别 ${map.getSource(handle.sourceId).minzoom}–${map.getSource(handle.sourceId).maxzoom}`)
// 点击道路查看属性(矢量瓦片带全部字段)
map.on('click', handle.layerIds[0], (e) => {
const p = e.features[0].properties
new maplibregl.Popup().setLngLat(e.lngLat).setText(`${p.name || '(无名)'} · ${p.fclass}`).addTo(map)
})
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.8 sans-serif'
legend.innerHTML = [['#e8590c', '高速'], ['#f08c00', '快速路'], ['#1971c2', '主干道'], ['#868e96', '其他']]
.map(([c, t]) => `<div><span style="display:inline-block;width:18px;height:3px;background:${c};vertical-align:middle"></span> ${t}</div>`)
.join('')
container.appendChild(legend)
} catch (e) {
console.error(friendly(e))
}切换底图
页面上的两个按钮在"栅格切片"(服务端出图)与"矢量瓦片"(三个矢量瓦片服务叠加,浏览器端渲染)之间切换底图。addRasterService、addVectorTileService 返回的对象带 remove(),用它移除当前底图后再加另一种,地图的中心与缩放保持不变。示例先显示栅格底图,几秒后自动切到矢量底图,之后可用按钮来回切换。
需要: 一个提供 MapServer 切片的地图服务,以及一个或多个矢量瓦片服务。
js
在沙箱中打开/**
* @title 切换底图
* @description 两个按钮在「栅格切片」(服务端出图的香港底图)与「矢量瓦片」(土地利用、水体、道路三个矢量瓦片服务,浏览器端渲染)之间切换;handle.remove() 移除当前底图,中心与缩放不变。
* @category 图层
* @service demo/basemap
* @service demo/landuse
* @service demo/water
* @service demo/roads
*/
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)
try {
// 拷到自己页面时:换成你的地图服务(栅格)与矢量瓦片服务路径
const raster = services['demo/basemap']
const vectors = [services['demo/landuse'], services['demo/water'], services['demo/roads']]
const map = new maplibregl.Map({
container,
style: { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#f1f3f5' } }] },
transformRequest: client.transformRequest,
center: [114.172, 22.298],
zoom: 15.5,
})
map.addControl(new maplibregl.NavigationControl())
// 切片、图标等资源加载失败时 MapLibre 发 error 事件:转成提示,不影响其余内容显示
map.on('error', (e) => console.warn('地图资源加载失败:', e.error && e.error.message))
await new Promise((resolve) => map.once('load', resolve))
// 当前底图的图层句柄(矢量底图由三个服务组成,所以是数组)
let handles = []
let busy = false
const buttons = {}
async function use(kind) {
if (busy) return
busy = true
try {
// remove() 只移除这次加入的图层与 source,不会动地图的中心与缩放
handles.forEach((h) => h.remove())
handles = []
if (kind === 'raster') handles.push(client.maplibre.addRasterService(map, raster))
// 按顺序叠加:先加的在下面(土地利用 → 水体 → 道路)
else for (const path of vectors) handles.push(await client.maplibre.addVectorTileService(map, path))
for (const [k, b] of Object.entries(buttons)) b.disabled = k === kind
const layers = handles.flatMap((h) => h.layerIds)
console.log('当前底图:', kind === 'raster' ? '栅格切片' : '矢量瓦片', ',图层:', layers.join('、'))
} catch (e) {
console.error('切换失败:', friendly(e))
} finally {
busy = false
}
}
const bar = document.createElement('div')
bar.style.cssText = 'position:absolute;top:8px;left:8px;z-index:2;display:flex;gap:6px'
for (const [kind, label] of [['raster', '栅格切片(MapServer)'], ['vector', '矢量瓦片(VectorTileServer)']]) {
const b = document.createElement('button')
b.textContent = label
b.style.cssText = 'padding:4px 10px;cursor:pointer'
b.onclick = () => use(kind)
buttons[kind] = b
bar.appendChild(b)
}
container.appendChild(bar)
// 先显示栅格底图,再自动切到矢量,便于对比两种底图(之后可用按钮来回切换)
await use('raster')
setTimeout(() => use('vector'), 3000)
} catch (e) {
console.error(friendly(e))
}底图上叠加矢量瓦片
用空样式建图,地图加载完成后先加栅格切片底图,再用 addVectorTileService() 把兴趣点矢量瓦片服务叠加在上面。叠加层的 source 与图层 id 都带 gb:服务路径: 前缀,不会与已有图层冲突;返回的 layerIds 可以用来开关显示。左上角的复选框开关叠加层,并统计视野内的兴趣点数。
需要: 一个提供 MapServer 切片的地图服务和一个矢量瓦片服务。
js
在沙箱中打开/**
* @title 底图上叠加矢量瓦片
* @description 地图 load 之后先加香港底图的栅格切片,再把兴趣点矢量瓦片服务叠加上去;复选框开关叠加层,面板显示视野内的兴趣点数。
* @category 图层
* @service demo/basemap
* @service demo/pois
*/
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在,请检查服务路径' : e && e.message)
try {
// 拷到自己页面时:换成你的地图服务(底图)与矢量瓦片服务路径
const basemap = services['demo/basemap']
const pois = services['demo/pois']
const map = new maplibregl.Map({
container,
style: { version: 8, sources: {}, layers: [] },
transformRequest: client.transformRequest,
// 尖沙咀一带,兴趣点密集
center: [114.172, 22.298],
zoom: 16,
})
map.addControl(new maplibregl.NavigationControl())
// 切片、图标等资源加载失败时 MapLibre 发 error 事件:转成提示,不影响其余内容显示
map.on('error', (e) => console.warn('地图资源加载失败:', e.error && e.error.message))
// 样式加载完成后才能加 source 与图层
await new Promise((resolve) => map.once('load', resolve))
client.maplibre.addRasterService(map, basemap)
// 矢量瓦片服务的 source 与图层 id 都以 `gb:服务路径:` 为前缀,不会和已有图层冲突
// 空样式没有 glyphs 与 sprite,addVectorTileService 会设为服务样式的,注记与图标才能显示
const overlay = await client.maplibre.addVectorTileService(map, pois)
console.log('叠加的图层:', overlay.layerIds.join('、'))
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.8 sans-serif'
const label = document.createElement('label')
const box = document.createElement('input')
box.type = 'checkbox'
box.checked = true
label.append(box, ' 显示兴趣点(矢量瓦片)')
const stat = document.createElement('div')
panel.append(label, stat)
container.appendChild(panel)
// 开关叠加层:对句柄里的每个图层改 visibility
box.onchange = () => {
for (const id of overlay.layerIds) map.setLayoutProperty(id, 'visibility', box.checked ? 'visible' : 'none')
}
// 每次地图静止后统计视野内渲染出的兴趣点(同一要素可能同时有圆点与注记,按 osm_id 去重)
let logged = false
map.on('idle', () => {
const shown = box.checked ? map.queryRenderedFeatures({ layers: overlay.layerIds }) : []
const count = new Set(shown.map((f) => f.properties.osm_id)).size
stat.textContent = `视野内兴趣点:${count} 个`
if (!logged && count > 0) {
logged = true
console.log(stat.textContent)
}
})
} catch (e) {
console.error(friendly(e))
}WMS 叠加
在地图方案底图上叠加 WMS 图层。WMS 图层名由 client.services.layers() 取得图层列表、再用 GeoBridge.wmsLayerNames() 算出(与平台的命名规则一致);GetMap 地址里写 {bbox-epsg-3857},MapLibre 会按每张切片的范围替换,WMS 就能按切片请求。添加图层时传 beforeId,把 WMS 插在道路之下。左上角的滑块调透明度,图例图片来自 client.services.legendUrl()。
需要: 一个开启了 WMS 的地图服务,以及作为底图的地图方案(也可以换成其他底图)。
js
在沙箱中打开/**
* @title WMS 叠加
* @description 在地图方案底图上叠加土地利用服务的 WMS 图层(插在道路下面),滑块调透明度,面板显示 WMS 图例。
* @category 图层
* @service demo/landuse
* @map demo/map
*/
// 出错时给出可理解的提示(服务不存在多半是 demo 服务映射或服务路径有误)
const friendly = (e) => (e && e.status === 404 ? '服务不存在或未开启 WMS,请检查服务路径' : e && e.message)
try {
// 拷到自己页面时:换成你开启了 WMS 的地图服务路径与你的底图(地图方案 id)
const path = services['demo/landuse']
const map = new maplibregl.Map({
container,
style: client.maplibre.mapStyleUrl(maps['demo/map']),
transformRequest: client.transformRequest,
center: [114.172, 22.302],
zoom: 15,
})
map.addControl(new maplibregl.NavigationControl())
await new Promise((resolve) => map.once('load', resolve))
// WMS 图层名:按 MapServer 图层列表算出(与平台的命名规则一致),取第一个图层
const { layers } = await client.services.layers(path)
const layerName = GeoBridge.wmsLayerNames(layers)[0]
// MapLibre 会把 {bbox-epsg-3857} 替换成每张切片的范围,于是 WMS GetMap 也能按切片请求
const getMap = `${client.services.wmsUrl(path)}?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&LAYERS=${encodeURIComponent(layerName)}` +
'&STYLES=&CRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256&FORMAT=image/png&TRANSPARENT=true'
map.addSource('wms', { type: 'raster', tiles: [getMap], tileSize: 256 })
// beforeId:插在底图第一个线图层(道路)之下,道路仍压在 WMS 图片上面
const firstLine = map.getStyle().layers.find((l) => l.type === 'line')
map.addLayer({ id: 'wms', type: 'raster', source: 'wms', paint: { 'raster-opacity': 0.7 } }, firstLine && firstLine.id)
// 面板:透明度滑块 + 图例
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/1.8 sans-serif'
const title = document.createElement('div')
title.textContent = `WMS 图层 ${layerName}`
const slider = document.createElement('input')
slider.type = 'range'
slider.min = '0'
slider.max = '100'
slider.value = '70'
const value = document.createElement('span')
value.textContent = ' 70%'
slider.oninput = () => {
map.setPaintProperty('wms', 'raster-opacity', slider.value / 100)
value.textContent = ` ${slider.value}%`
}
// 图例是一张图片:<img> 不经过 transformRequest,受保护服务用 withKey 补 token
const legend = document.createElement('img')
legend.src = client.withKey(client.services.legendUrl(path, layerName))
legend.style.display = 'block'
panel.append(title, '透明度 ', slider, value, legend)
container.appendChild(panel)
console.log('WMS 图层:', layerName, ',插在图层', firstLine ? firstLine.id : '(最上层)', '之下')
} catch (e) {
console.error(friendly(e))
}