ECharts 是一个使用 JavaScript 实现的开源可视化库,底层依赖矢量图形库 ZRender,提供直观,交互丰富,可高度个性化定制的数据可视化图表。
GeoJSON 是一种对各种地理数据结构进行编码的格式,基于 Javascript对象表示法(JavaScript Object Notation, 简称 JSON)的地理空间信息数据交换格式。
阿里云DataV——阿里巴巴集团旗下数据可视化产品,成熟的企业级数据可视化解决方案以及国产化环境部署,无需编程的一站式智能数据可视化平台。
这里我们使用阿里提供的数据JSON文件,放在主题assets/geo/china.json

2. 数据来源、思路
自定义字段可以添加坐标经纬度信息;标签可以分类标记点类型
利用 ECharts GeoJSON 地图渲染功能,展现多个标签标记点。
实现legend图例显隐控制图层显隐。
设置中心经纬度支持地图中心点、缩放级别自定义。
绘制航迹线连接不同点。
高亮行政区(adcode)划城市,标签区分。
3. 项目分析
获取游记文章里面的自定义字段:
► 显示剧情透露
代码: 全选
//获取游记文章里面的自定义字段的经纬度和第一张图片、文章标题、链接
$tripDataArr = [];
while ($footprint->next()) {
$myTrack = $footprint->fields->footprint;
if (!$myTrack || strlen($myTrack) < 3) continue;
$tripDataArr[] = [
'coords' => explode(',', $myTrack),
'title' => htmlentities($footprint->title),
'excerpt' => mb_substr(strip_tags($footprint->excerpt), 0, 20, 'utf-8'),
'image' => (preg_match('/!\[.*?\]\((.*?)\)/', $footprint->content, $md) ? $md[1] : (preg_match('/<img.*?src=["\'](.*?)["\'].*?>/', $footprint->content, $html) ? $html[1] : null)),
'link' => $footprint->permalink
];
}

开启exif识别第一张图片exif信息的经纬度:
► 显示剧情透露
代码: 全选
//开启exif识别第一张图片exif信息的经纬度,是有摄影的功能;
$pictureDataArr = [];
$this->widget('Widget_Archive@PicturePosts', 'pageSize=100&type=tag', 'slug=picture')->to($picturePosts);
while ($picturePosts->next()) {
$content = $picturePosts->content;
// 正则匹配img标签,提取图片链接,注意,这里我是使用本地图片
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $imgMatch)) {
$imgUrl = $imgMatch[1];
// 替换成服务器本地路径(未优化网络图片,因为难搞,需要下载后识别)
$localPath = str_replace($this->options->siteUrl, __TYPECHO_ROOT_DIR__ . '/', $imgUrl);
if (file_exists($localPath)) {
$exif = @exif_read_data($localPath);
if ($exif && isset($exif['GPSLatitude'], $exif['GPSLongitude'], $exif['GPSLatitudeRef'], $exif['GPSLongitudeRef'])) {
function gps2Num($coordPart) {
$parts = explode('/', $coordPart);
if (count($parts) == 2 && $parts[1] != 0) {
return floatval($parts[0]) / floatval($parts[1]);
}
return floatval($coordPart);
}
function exifToFloat($exifCoord, $ref) {
$degrees = gps2Num($exifCoord[0]);
$minutes = gps2Num($exifCoord[1]);
$seconds = gps2Num($exifCoord[2]);
$float = $degrees + ($minutes / 60) + ($seconds / 3600);
return ($ref === 'S' || $ref === 'W') ? -$float : $float;
}
$lat = exifToFloat($exif['GPSLatitude'], $exif['GPSLatitudeRef']);
$lng = exifToFloat($exif['GPSLongitude'], $exif['GPSLongitudeRef']);
//同样获取文章标题等信息
$pictureDataArr[] = [
'coords' => [$lng, $lat],
'title' => htmlentities($picturePosts->title),
'excerpt' => mb_substr(strip_tags($picturePosts->excerpt), 0, 20, 'utf-8'),
'image' => $imgUrl,
'link' => $picturePosts->permalink
];
}
}
}
}

编写 ECharts 配置:
1. 使用 geo 组件加载地图。
2. 使用 series 绘制多个分类的散点,每类数据对应一个 series,设置颜色和图例。
3. 使用 lines 类型绘制航迹线。
4. 使用 geo 的 regions 配置高亮行政区划城市。
5. 通过 JS 控制对应 series 和 lines 的显示与隐藏。
► 显示剧情透露
代码: 全选
fetch(geoJsonUrl).then(res => res.json()).then(geoJson => {
echarts.registerMap('china', geoJson);
const option = {
tooltip: {
trigger: 'item',
triggerOn: 'click',
enterable: true,
confine: true,
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333',
fontSize: 13
},
formatter: function (params) {
const data = params.data || {};
if (data.title && data.link) {
const imgHtml = data.image ? `<div><img src="${data.image}" style="width:100%;border-radius:6px;margin-bottom:6px;"></div>` : '';
const titleHtml = `<div style="font-weight:bold;margin:4px 0;">
<a href="${data.link}" target="_blank" style="text-decoration:none;color:#1976d2;">${data.title}</a>
</div>`;
const descHtml = `<div style="font-size:13px;color:#555;line-height:1.4;">${data.excerpt || '暂无摘要'}</div>`;
return `<div style="max-width:300px;">${imgHtml}${titleHtml}${descHtml}</div>`;
}
if (data.fromName && data.toName) {
return `${data.fromName} → ${data.toName}`;
}
const region = highlightRegions.find(r => r.name === params.name);
if (region) {
return `<div><strong>${region.name}</strong><br>点击查看地域文章</div>`;
}
return params.name;
}
},
legend: { //图例显隐
data: [
{ name: '游记', icon: 'circle', itemStyle: { color: 'rgba(255, 87, 34, 0.5)' } },
{ name: '当前在', icon: 'circle', itemStyle: { color: 'rgba(0, 200, 0, 0.7)' } },
{
name: '航迹线',
icon: 'path://M1705.06,1318.313v-89.254l-319.9-221.799l0.073-208.063c0.521-84.662-26.629-121.796-63.961-121.491c-37.332-0.305-64.482,36.829-63.961,121.491l0.073,208.063l-319.9,221.799v89.254l330.343-157.288l12.238,241.308l-134.449,92.931l0.531,42.034l175.125-42.917l175.125,42.917l0.531-42.034l-134.449-92.931l12.238-241.308L1705.06,1318.313z'
},
{ name: '点亮城市', icon: 'circle', itemStyle: { color: 'rgba(255, 153, 0, 0.5)' } },
{ name: '有摄影', icon: 'circle', itemStyle: { color: '#2196f3' } }
],
selected: {
'游记': true,
'当前在': true,
'航迹线': false,
'点亮城市': false,
'有摄影': true
},
bottom: 10,
right: 10,
orient: 'vertical',
selectedMode: 'multiple'
},
visualMap: {
min: 0,
max: 10,
calculable: true,
seriesIndex: 3,
inRange: {
color: ['#ffffff', '#ffcc00']
},
orient: 'horizontal',
bottom: 130,
right: 10,
show: false, //视觉组件开关
itemWidth: 10,
itemHeight: 100,
textStyle: {
fontSize: 10
}
},
geo: {
map: 'china',
roam: true,
silent: true, // 禁止地图区域鼠标交互
center: defaultCenter,
zoom: defaultZoom,
scaleLimit: {
min: 1,
max: 100
},
label: { show: false },
itemStyle: {
areaColor: '#eaeaea',
borderColor: '#999'
},
emphasis: {
itemStyle: {
areaColor: '#c8e6c9'
}
}
},
series: [
{
name: '游记',
type: 'scatter',
coordinateSystem: 'geo',
data: tripData.map(item => ({
name: item.title,
value: [...item.coords, item.title],
...item
})),
symbolSize: 15,
itemStyle: { color: '#ff5722' }
},
{
name: '当前在',
type: 'scatter',
coordinateSystem: 'geo',
data: mapHereData.map(item => ({
name: item.title,
value: [...item.coords, item.title],
...item
})),
symbolSize: 15,
itemStyle: { color: 'rgba(0, 200, 0, 0.7)' }
},
{
name: '航迹线',
type: 'lines',
coordinateSystem: 'geo',
zlevel: 2,
data: mapFlyData,
lineStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: '#ffffff' },
{ offset: 1, color: '#00aaff' }
]
},
width: 2,
opacity: 0.6,
curveness: 0.2,
shadowBlur: 10,
shadowColor: '#00aaff'
},
effect: {
show: true,
constantSpeed: 30,
symbol: 'path://M1705.06,1318.313v-89.254l-319.9-221.799l0.073-208.063c0.521-84.662-26.629-121.796-63.961-121.491c-37.332-0.305-64.482,36.829-63.961,121.491l0.073,208.063l-319.9,221.799v89.254l330.343-157.288l12.238,241.308l-134.449,92.931l0.531,42.034l175.125-42.917l175.125,42.917l0.531-42.034l-134.449-92.931l12.238-241.308L1705.06,1318.313z',
symbolSize: 15,
trailLength: 0
}
},
{
name: '点亮城市',
type: 'map',
map: 'china',
geoIndex: 0,
data: highlightRegions,
itemStyle: {
areaColor: '#ffcc00',
borderColor: null,
borderWidth: 0
},
emphasis: {
itemStyle: {
areaColor: '#ff9900'
}
}
},
{
name: '有摄影',
type: 'scatter',
coordinateSystem: 'geo',
data: pictureData.map(item => ({
name: item.title,
value: [...item.coords, item.title],
...item
})),
symbolSize: 15,
itemStyle: { color: '#42a5f5' }
}
//这里是Exif识别
]
};
myChart.setOption(option);
//弃用:点击跳转地区
myChart.on('click', function (params) {
const region = highlightRegions.find(r => r.name === params.name);
if (region) {
window.open(region.link, '_blank');
}
});
document.getElementById('toggleBrightness')?.addEventListener('change', function () {
const show = this.checked;
const currentOption = myChart.getOption();
currentOption.visualMap[0].show = show;
myChart.setOption(currentOption);
});
});footprint.zip
5. 后续修复修复无法循环获取EXIF的GPS信息:
► 显示剧情透露
1. 把辅助函数 `gps2Num` 和 `exifToFloat` 放到模板最顶部或循环外,只定义一次:
2. `$pictureDataArr` 循环,改成如下,避免重复定义函数:
代码: 全选
<?php
if (!defined('__TYPECHO_ROOT_DIR__')) exit;
// 建议放这儿
function gps2Num($coordPart) {
$parts = explode('/', $coordPart);
if (count($parts) == 2 && $parts[1] != 0) {
return floatval($parts[0]) / floatval($parts[1]);
}
return floatval($coordPart);
}
function exifToFloat($exifCoord, $ref) {
$degrees = gps2Num($exifCoord[0]);
$minutes = gps2Num($exifCoord[1]);
$seconds = gps2Num($exifCoord[2]);
$float = $degrees + ($minutes / 60) + ($seconds / 3600);
return ($ref === 'S' || $ref === 'W') ? -$float : $float;
}
?>代码: 全选
$pictureDataArr = [];
$this->widget('Widget_Archive@PicturePosts', 'pageSize=100&type=tag', 'slug=picture')->to($picturePosts);
while ($picturePosts->next()) {
$content = $picturePosts->content;
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $imgMatch)) {
$imgUrl = $imgMatch[1];
$localPath = str_replace($this->options->siteUrl, __TYPECHO_ROOT_DIR__ . '/', $imgUrl);
if (file_exists($localPath)) {
$exif = @exif_read_data($localPath);
if ($exif
&& isset($exif['GPSLatitude'], $exif['GPSLongitude'], $exif['GPSLatitudeRef'], $exif['GPSLongitudeRef'])
&& is_array($exif['GPSLatitude']) && is_array($exif['GPSLongitude'])
) {
$lat = exifToFloat($exif['GPSLatitude'], $exif['GPSLatitudeRef']);
$lng = exifToFloat($exif['GPSLongitude'], $exif['GPSLongitudeRef']);
$pictureDataArr[] = [
'coords' => [$lng, $lat],
'title' => htmlentities($picturePosts->title),
'excerpt' => mb_substr(strip_tags($picturePosts->excerpt), 0, 20, 'utf-8'),
'image' => $imgUrl,
'link' => $picturePosts->permalink
];
}
}
}
}